909 lines
35 KiB
HTML
Raw Normal View History

2026-03-13 00:18:14 +00:00
{% extends "base.html" %}
{% block title %}Ontology Graph — Ontoref{% endblock title %}
{% block nav_graph %}active{% endblock nav_graph %}
feat: config surface, NCL contracts, override-layer mutation, on+re update Config surface — per-project config introspection, coherence verification, and audited mutation without destroying NCL structure (ADR-008): - crates/ontoref-daemon/src/config.rs — typed DaemonNclConfig (parse-at-boundary pattern); all section structs derive ConfigFields + config_section(id, ncl_file) emitting inventory::submit!(ConfigFieldsEntry{...}) at link time - crates/ontoref-derive/src/lib.rs — #[derive(ConfigFields)] proc-macro; serde rename support; serde_rename_of() helper extracted to fix excessive_nesting - crates/ontoref-daemon/src/main.rs — 3-tuple bootstrap block (nickel_import_path, loaded_ncl_config: Option<DaemonNclConfig>, stdin_raw); apply_ui_config takes &UiConfig; NATS call site typed; resolve_asset_dir cfg(feature = "ui") - crates/ontoref-daemon/src/api.rs — config GET/PUT endpoints, quickref, coherence, cross-project comparison; index_section_fields() extracted (excessive_nesting) - crates/ontoref-daemon/src/config_coherence.rs — multi-consumer coherence; merge_meta_into_section() extracted; and() replaces unnecessary and_then NCL contracts for ontoref's own config: - .ontoref/contracts.ncl — LogConfig (LogLevel, LogRotation, PositiveInt) and DaemonConfig (Port, optional overrides); std.contract.from_validator throughout - .ontoref/config.ncl — log | C.LogConfig applied - .ontology/manifest.ncl — contracts_path, log/daemon contract refs, daemon section with DaemonRuntimeConfig consumer and 7 declared fields Protocol: - adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl — NCL contracts as single validation gate; Rust structs are contract-trusted; override-layer mutation writes {section}.overrides.ncl + _overrides_meta, never touches source on+re update: - .ontology/core.ncl — config-surface node (28 practices); adr-lifecycle extended to adr-007 + adr-008; 6 new edges (ManifestsIn daemon, DependsOn ontology-crate, Complements api-catalog-surface/dag-formalized/self-describing/adopt-ontoref) - .ontology/state.ncl — protocol-maturity blocker and self-description-coverage catalyst updated for session 2026-03-26 - README.md / CHANGELOG.md updated
2026-03-26 20:20:22 +00:00
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
2026-03-13 00:18:14 +00:00
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
2026-03-29 00:19:56 +00:00
<script src="/assets/vendor/cytoscape-navigator.js"></script>
2026-03-13 00:18:14 +00:00
<style>
#graph-root {
display: flex;
height: calc(100vh - 148px);
min-height: 400px;
gap: 0;
user-select: none;
}
2026-03-29 00:19:56 +00:00
#graph-root:fullscreen,
#graph-root:-webkit-full-screen {
height: 100dvh;
background: oklch(var(--b1));
}
2026-03-13 00:18:14 +00:00
#cy-wrapper {
flex: 1 1 auto;
min-width: 220px;
overflow: hidden;
border-radius: 0.5rem;
2026-03-29 00:19:56 +00:00
position: relative;
2026-03-13 00:18:14 +00:00
}
#cy { width: 100%; height: 100%; }
2026-03-29 00:19:56 +00:00
/* 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;
}
2026-03-13 00:18:14 +00:00
#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 %}">
2026-03-29 00:19:56 +00:00
<!-- 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>
2026-03-13 00:18:14 +00:00
<!-- 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>
2026-03-29 00:19:56 +00:00
<button id="btn-legend" class="btn btn-xs btn-ghost" title="Graph legend">?</button>
2026-03-13 00:18:14 +00:00
</div>
</div>
<!-- Split: graph | drag handle | detail -->
<div id="graph-root">
<div id="cy-wrapper" class="bg-base-200">
<div id="cy"></div>
2026-03-29 00:19:56 +00:00
<!-- 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>
2026-03-13 00:18:14 +00:00
</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>
2026-03-13 00:18:14 +00:00
<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 }};
2026-03-29 00:19:56 +00:00
// ── 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 ────────────────────────────────────────────────
2026-03-13 00:18:14 +00:00
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 || [],
2026-03-13 00:18:14 +00:00
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,
}
};
});
2026-03-29 00:19:56 +00:00
// ── Cytoscape ─────────────────────────────────────────────────
2026-03-13 00:18:14 +00:00
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: {
2026-03-29 00:19:56 +00:00
"opacity": 1,
"width": 2.5,
"label": "data(kind)",
2026-03-13 00:18:14 +00:00
"font-size": "9px",
2026-03-29 00:19:56 +00:00
"color": "#e5e7eb",
2026-03-13 00:18:14 +00:00
"text-background-color": "#0f172a",
"text-background-opacity": 0.85,
"text-background-padding": "2px",
"text-background-shape": "round-rectangle",
}
},
],
2026-03-29 00:19:56 +00:00
layout: buildBfsLayout(false),
2026-03-13 00:18:14 +00:00
wheelSensitivity: 0.3,
boxSelectionEnabled: false,
minZoom: 0.1,
maxZoom: 4,
});
2026-03-29 00:19:56 +00:00
// ── 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) {
2026-03-13 00:18:14 +00:00
return {
name: "breadthfirst",
directed: true,
2026-03-29 00:19:56 +00:00
animate: !!animate,
animationDuration: 500,
animationEasing: 'ease-in-out-cubic',
2026-03-13 00:18:14 +00:00
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,
};
}
2026-03-29 00:19:56 +00:00
// ── 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));
2026-03-13 00:18:14 +00:00
2026-03-29 00:19:56 +00:00
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 ───────────────────────────────────────────────────
2026-03-13 00:18:14 +00:00
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();
});
});
2026-03-29 00:19:56 +00:00
// ── Layout buttons ────────────────────────────────────────────
2026-03-13 00:18:14 +00:00
const btnBfs = document.getElementById("btn-bfs");
const btnCose = document.getElementById("btn-cose");
btnBfs.addEventListener("click", () => {
2026-03-29 00:19:56 +00:00
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");
2026-03-13 00:18:14 +00:00
});
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");
});
2026-03-29 00:19:56 +00:00
document.getElementById("btn-legend").addEventListener("click", () => {
document.getElementById("legend-modal").showModal();
});
2026-03-13 00:18:14 +00:00
document.getElementById("btn-reset").addEventListener("click", () => {
cy.elements().removeClass("faded highlighted");
2026-03-29 00:19:56 +00:00
cy.animate(
{ fit: { eles: cy.elements(':visible'), padding: 48 } },
{ duration: 400, easing: 'ease-in-out-cubic' }
);
2026-03-13 00:18:14 +00:00
closePanel();
});
2026-03-29 00:19:56 +00:00
// ── 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");
2026-03-13 00:18:14 +00:00
const dArtifacts = document.getElementById("d-artifacts");
2026-03-29 00:19:56 +00:00
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");
2026-03-13 00:18:14 +00:00
function closePanel() {
panel.classList.add("hidden");
cy.elements().removeClass("faded highlighted");
}
cy.on("tap", "node", evt => {
2026-03-29 00:19:56 +00:00
const node = evt.target;
const d = node.data();
2026-03-13 00:18:14 +00:00
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");
}
2026-03-29 00:19:56 +00:00
const conn = node.connectedEdges();
2026-03-13 00:18:14 +00:00
if (conn.length) {
dEdges.classList.remove("hidden");
dEdgeList.innerHTML = conn.map(e => {
2026-03-29 00:19:56 +00:00
const isSrc = e.data("source") === d.id;
const other = isSrc
2026-03-13 00:18:14 +00:00
? cy.getElementById(e.data("target")).data("label")
: cy.getElementById(e.data("source")).data("label");
2026-03-29 00:19:56 +00:00
const arrow = isSrc ? "→" : "←";
2026-03-13 00:18:14 +00:00
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");
2026-03-29 00:19:56 +00:00
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' }
);
2026-03-13 00:18:14 +00:00
});
cy.on("tap", evt => {
if (evt.target === cy) closePanel();
});
document.getElementById("btn-close-panel").addEventListener("click", closePanel);
// ── Resizable split ───────────────────────────────────────────
2026-03-29 00:19:56 +00:00
const handle = document.getElementById("resize-handle");
const cyWrapper = document.getElementById("cy-wrapper");
2026-03-13 00:18:14 +00:00
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;
2026-03-29 00:19:56 +00:00
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);
2026-03-13 00:18:14 +00:00
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)
2026-03-29 00:19:56 +00:00
.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);
});
2026-03-29 00:19:56 +00:00
// ── 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;
}
});
2026-03-13 00:18:14 +00:00
</script>
{% endblock scripts %}