graph, search, api_catalog pages: back/forward history stack (PanelNav/dpNav). File artifact paths open in external tabs via card.repo (Gitea source URL) or card.docs (cargo docs for .rs) — openFile/openFileInPanel removed from all pages. Tera | safe required for URL values inside <script> blocks (auto-escape of slashes). card.ncl: repo field added. insert_brand_ctx: injects card_repo/card_docs into Tera context. #[onto_api] proc-macro: source_file = file!() emitted; ApiRouteEntry.source_file populated in primary catalog handler. migration 0007-card-repo-field: check card.ncl for repo field; skip if absent.
1360 lines
57 KiB
HTML
1360 lines
57 KiB
HTML
{% 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 main_class %}container mx-auto px-4 py-6{% endblock main_class %}
|
||
|
||
{% 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-fullscreen-wrapper:fullscreen,
|
||
#graph-fullscreen-wrapper:-webkit-full-screen {
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: oklch(var(--b1));
|
||
padding: 12px 16px;
|
||
box-sizing: border-box;
|
||
}
|
||
#graph-fullscreen-wrapper:fullscreen #graph-root,
|
||
#graph-fullscreen-wrapper:-webkit-full-screen #graph-root {
|
||
flex: 1 1 auto;
|
||
height: 0; /* flex child — grows to fill remaining space */
|
||
}
|
||
|
||
#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;
|
||
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>
|
||
|
||
<!-- Extended levels -->
|
||
<section>
|
||
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Extended levels — code & system layers</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:#6366f1;border-color:#6366f1;color:#fff;min-width:80px">⬢ Crates</span>
|
||
<div>
|
||
<p class="font-medium leading-snug">Workspace crate</p>
|
||
<p class="text-base-content/60 leading-relaxed mt-0.5">A Rust crate declared in the workspace <code>Cargo.toml</code>. Edges show intra-workspace <code>DependsOn</code> relationships — external crates are omitted.</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:#6366f1;border-color:#6366f1;color:#fff;min-width:80px;opacity:0.8">⬡ API Crate</span>
|
||
<div>
|
||
<p class="font-medium leading-snug">API surface crate</p>
|
||
<p class="text-base-content/60 leading-relaxed mt-0.5">A crate that has exported <code>#[onto_api]</code> routes. Generated from <code>artifacts/api-catalog-*.ncl</code>.</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:#10b981;border-color:#10b981;color:#fff;min-width:80px">⊢ Routes</span>
|
||
<div>
|
||
<p class="font-medium leading-snug">HTTP route</p>
|
||
<p class="text-base-content/60 leading-relaxed mt-0.5">An individual HTTP endpoint annotated with <code>#[onto_api]</code>. Each route is owned by exactly one API Crate node via a <code>Contains</code> edge.</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:#ec4899;border-color:#ec4899;color:#fff;min-width:80px">⬡ State</span>
|
||
<div>
|
||
<p class="font-medium leading-snug">FSM dimension</p>
|
||
<p class="text-base-content/60 leading-relaxed mt-0.5">A lifecycle dimension from <code>.ontology/state.ncl</code>. Shows <code>current_state</code> → <code>desired_state</code>. <code>CoupledWith</code> edges link co-dependent dimensions. Invariant flag is set when current equals desired.</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:#14b8a6;border-color:#14b8a6;color:#fff;min-width:80px">⊓ Config</span>
|
||
<div>
|
||
<p class="font-medium leading-snug">Config section</p>
|
||
<p class="text-base-content/60 leading-relaxed mt-0.5">A named section in <code>.ontoref/config.ncl</code> as declared in <code>manifest.config_surface.sections</code>. Includes contract reference when a Nickel type contract is attached.</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:#f97316;border-color:#f97316;color:#fff;min-width:80px">◈ Deps</span>
|
||
<div>
|
||
<p class="font-medium leading-snug">External requirement</p>
|
||
<p class="text-base-content/60 leading-relaxed mt-0.5">A tool, service, or infrastructure dependency from <code>manifest.requirements</code>. Invariant nodes are <em>required</em> (missing them breaks the project). Optional nodes degrade gracefully.</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 class="flex items-start gap-3">
|
||
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#ec4899;color:#fff;border-color:#ec4899;min-width:64px">State</span>
|
||
<div>
|
||
<p class="font-medium leading-snug">Lifecycle · FSM · temporal</p>
|
||
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that represent the project's operational state model — FSM dimensions tracking where the project currently is and where it intends to go.</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:#14b8a6;color:#fff;border-color:#14b8a6;min-width:64px">Config</span>
|
||
<div>
|
||
<p class="font-medium leading-snug">Configuration · settings · tuneable</p>
|
||
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that describe the project's runtime configuration surface — sections that can be overridden without recompiling, validated by Nickel contracts at load time.</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:#f97316;color:#fff;border-color:#f97316;min-width:64px">Env</span>
|
||
<div>
|
||
<p class="font-medium leading-snug">Environment · external · required tooling</p>
|
||
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that represent external dependencies the project cannot provide for itself — tools, services, and infrastructure that must be present in the environment.</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>
|
||
|
||
|
||
<div id="graph-fullscreen-wrapper">
|
||
<!-- Toolbar row 1: title + inline node search + layout controls -->
|
||
<div class="flex items-center justify-between mb-1 gap-2">
|
||
<h1 class="text-lg font-bold shrink-0">Ontology Graph</h1>
|
||
<div class="relative flex-1 max-w-xs min-w-0" id="search-wrap">
|
||
<input id="graph-search" type="search" placeholder="Search nodes…"
|
||
class="input input-bordered input-xs w-full pr-14 font-mono text-xs"
|
||
autocomplete="off" autocorrect="off" spellcheck="false">
|
||
<span class="absolute right-2 top-1.5 text-xs text-base-content/30 pointer-events-none select-none"
|
||
id="search-count"></span>
|
||
<ul id="search-dropdown"
|
||
class="hidden absolute left-0 top-full mt-1 w-full bg-base-100 border border-base-content/15 rounded-lg shadow-xl z-50 max-h-56 overflow-y-auto text-xs">
|
||
</ul>
|
||
</div>
|
||
<div class="flex items-center gap-1 shrink-0">
|
||
<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>
|
||
|
||
<!-- Toolbar row 2: filters, grouped by category -->
|
||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 mb-2 text-xs">
|
||
|
||
<!-- Ontology core -->
|
||
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Ontology</span>
|
||
<div class="flex gap-1">
|
||
<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>
|
||
|
||
<div class="w-px h-4 bg-base-content/15 self-center"></div>
|
||
|
||
<!-- Code surface -->
|
||
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Code</span>
|
||
<div class="flex gap-1">
|
||
<button class="filter-btn btn btn-xs" data-level="WorkspaceCrate" style="background:#6366f1;border-color:#6366f1;color:#fff">⬢ Crates</button>
|
||
<button class="filter-btn btn btn-xs" data-level="Crate" style="background:#6366f1;border-color:#6366f1;color:#fff;opacity:0.8">⬡ API Crate</button>
|
||
<button class="filter-btn btn btn-xs" data-level="Route" style="background:#10b981;border-color:#10b981;color:#fff">⊢ Routes</button>
|
||
</div>
|
||
|
||
<div class="w-px h-4 bg-base-content/15 self-center"></div>
|
||
|
||
<!-- System -->
|
||
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">System</span>
|
||
<div class="flex gap-1">
|
||
<button class="filter-btn btn btn-xs" data-level="Dimension" style="background:#ec4899;border-color:#ec4899;color:#fff">⬡ State</button>
|
||
<button class="filter-btn btn btn-xs" data-level="ConfigSection" style="background:#14b8a6;border-color:#14b8a6;color:#fff">⊓ Config</button>
|
||
<button class="filter-btn btn btn-xs" data-level="Requirement" style="background:#f97316;border-color:#f97316;color:#fff">◈ Deps</button>
|
||
</div>
|
||
|
||
<div class="w-px h-4 bg-base-content/15 self-center"></div>
|
||
|
||
<!-- Poles -->
|
||
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Pole</span>
|
||
<div class="flex gap-1">
|
||
<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>
|
||
<button class="filter-btn btn btn-xs" style="background:#ec4899;color:#fff;border-color:#ec4899" data-pole="State">State</button>
|
||
<button class="filter-btn btn btn-xs" style="background:#14b8a6;color:#fff;border-color:#14b8a6" data-pole="Config">Config</button>
|
||
<button class="filter-btn btn btn-xs" style="background:#f97316;color:#fff;border-color:#f97316" data-pole="Env">Env</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">
|
||
<!-- Panel nav header -->
|
||
<div class="flex items-center gap-0.5 mb-2">
|
||
<button id="nav-back" class="btn btn-xs btn-ghost px-1.5" title="Back" disabled>‹</button>
|
||
<button id="nav-forward" class="btn btn-xs btn-ghost px-1.5" title="Forward" disabled>›</button>
|
||
<h3 class="font-bold text-base leading-tight flex-1 truncate mx-1" id="d-name"></h3>
|
||
<button id="btn-close-panel" class="btn btn-xs btn-ghost flex-shrink-0">✕</button>
|
||
</div>
|
||
|
||
<!-- Node detail view -->
|
||
<div id="d-node-view">
|
||
<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 mb-3">
|
||
<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 id="d-extra" class="hidden space-y-3"></div>
|
||
</div>
|
||
|
||
<!-- Content view: file or ADR rendered in-panel -->
|
||
<div id="d-content-view" class="hidden">
|
||
<p class="text-xs text-base-content/40 mb-3 font-mono break-all leading-relaxed" id="d-content-subtitle"></p>
|
||
<div id="d-content-body" class="text-sm space-y-3"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock content %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
// ── Source link helper ────────────────────────────────────────────────────────
|
||
// Opens file paths in the configured repo (Gitea/GitHub /src/branch/main/{path}).
|
||
// Falls back to cargo-doc root for .rs files when docs URL is set.
|
||
// option 1 (load file) is intentionally not used.
|
||
const CARD_REPO = "{{ card_repo | default(value='') | safe }}";
|
||
const CARD_DOCS = "{{ card_docs | default(value='') | safe }}";
|
||
|
||
function srcOpen(path) {
|
||
if (!path) return;
|
||
const ext = path.split('.').pop();
|
||
if (ext === 'rs' && CARD_DOCS) {
|
||
window.open(CARD_DOCS.replace(/\/$/, ''), '_blank', 'noopener');
|
||
return;
|
||
}
|
||
if (CARD_REPO) {
|
||
window.open(`${CARD_REPO.replace(/\/$/, '')}/src/branch/main/${path}`, '_blank', 'noopener');
|
||
return;
|
||
}
|
||
// no URL configured — nothing to open
|
||
}
|
||
|
||
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", Api: "#10b981", Code: "#6366f1", State: "#ec4899", Config: "#14b8a6", Env: "#f97316" };
|
||
const LEVEL_SHAPE = {
|
||
Axiom: "diamond",
|
||
Tension: "ellipse",
|
||
Practice: "round-rectangle",
|
||
Project: "hexagon",
|
||
Moment: "triangle",
|
||
Crate: "round-hexagon",
|
||
Route: "tag",
|
||
Dimension: "hexagon",
|
||
WorkspaceCrate: "barrel",
|
||
ConfigSection: "cut-rectangle",
|
||
Requirement: "round-tag",
|
||
};
|
||
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" },
|
||
CoupledWith: { color: "#ec4899", dashed: true },
|
||
};
|
||
|
||
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",
|
||
// Route
|
||
method: n.method || "",
|
||
auth: n.auth || "",
|
||
actors: n.actors || [],
|
||
tags: n.tags || [],
|
||
params: n.params || [],
|
||
feature: n.feature || "",
|
||
// Dimension (FSM)
|
||
current_state: n.current_state || "",
|
||
desired_state: n.desired_state || "",
|
||
horizon: n.horizon || "",
|
||
// WorkspaceCrate
|
||
features: n.features || [],
|
||
ws_deps: n.ws_deps || [],
|
||
// ConfigSection
|
||
contract: n.contract || "",
|
||
// Requirement
|
||
env_kind: n.kind || "",
|
||
env_target: n.env || "",
|
||
required: !!n.required,
|
||
}
|
||
}));
|
||
|
||
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",
|
||
}
|
||
},
|
||
{
|
||
selector: "node.search-dim",
|
||
style: { "opacity": 0.12 }
|
||
},
|
||
{
|
||
selector: "node.search-match",
|
||
style: { "border-color": "#ffffff", "border-width": 3, "opacity": 1 }
|
||
},
|
||
],
|
||
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 graphFsWrapper = document.getElementById('graph-fullscreen-wrapper');
|
||
const btnFullscreen = document.getElementById('cy-fullscreen');
|
||
|
||
function isFullscreen() {
|
||
return !!(document.fullscreenElement || document.webkitFullscreenElement);
|
||
}
|
||
|
||
btnFullscreen.addEventListener('click', () => {
|
||
if (!isFullscreen()) {
|
||
const req = graphFsWrapper.requestFullscreen || graphFsWrapper.webkitRequestFullscreen;
|
||
if (req) req.call(graphFsWrapper);
|
||
} 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(["Crate", "Route", "Dimension", "WorkspaceCrate", "ConfigSection", "Requirement"]);
|
||
const hiddenPoles = new Set();
|
||
// Reflect initial hidden state on buttons.
|
||
document.querySelectorAll(".filter-btn[data-level]").forEach(btn => {
|
||
if (hiddenLevels.has(btn.dataset.level)) {
|
||
btn.style.opacity = "0.4";
|
||
btn.style.textDecoration = "line-through";
|
||
}
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
// ── Panel refs ────────────────────────────────────────────────
|
||
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");
|
||
const dExtra = document.getElementById("d-extra");
|
||
const dNodeView = document.getElementById("d-node-view");
|
||
const dContentView = document.getElementById("d-content-view");
|
||
const dContentSub = document.getElementById("d-content-subtitle");
|
||
const dContentBody = document.getElementById("d-content-body");
|
||
const navBack = document.getElementById("nav-back");
|
||
const navForward = document.getElementById("nav-forward");
|
||
const GRAPH_SLUG = document.getElementById("graph-slug").value || null;
|
||
|
||
// ── Panel history (browser-style back/forward) ────────────────
|
||
const panelNav = {
|
||
stack: [],
|
||
cursor: -1,
|
||
push(entry) {
|
||
this.stack = this.stack.slice(0, this.cursor + 1);
|
||
this.stack.push(entry);
|
||
this.cursor = this.stack.length - 1;
|
||
this._sync();
|
||
},
|
||
back() {
|
||
if (this.cursor <= 0) return;
|
||
this.cursor--;
|
||
this._replay();
|
||
this._sync();
|
||
},
|
||
forward() {
|
||
if (this.cursor >= this.stack.length - 1) return;
|
||
this.cursor++;
|
||
this._replay();
|
||
this._sync();
|
||
},
|
||
reset() {
|
||
this.stack = [];
|
||
this.cursor = -1;
|
||
this._sync();
|
||
},
|
||
_replay() {
|
||
const e = this.stack[this.cursor];
|
||
if (e.type === "node") openNode(e.id, { push: false, animate: true });
|
||
else if (e.type === "adr") openAdr(e.id, { push: false });
|
||
else if (e.type === "file") srcOpen(e.id);
|
||
},
|
||
_sync() {
|
||
navBack.disabled = this.cursor <= 0;
|
||
navForward.disabled = this.cursor >= this.stack.length - 1;
|
||
}
|
||
};
|
||
|
||
navBack.addEventListener("click", () => panelNav.back());
|
||
navForward.addEventListener("click", () => panelNav.forward());
|
||
|
||
// ── Panel view switching ──────────────────────────────────────
|
||
function showNodeView() {
|
||
dNodeView.classList.remove("hidden");
|
||
dContentView.classList.add("hidden");
|
||
}
|
||
|
||
function showContentView(subtitle) {
|
||
dNodeView.classList.add("hidden");
|
||
dContentView.classList.remove("hidden");
|
||
dContentSub.textContent = subtitle || "";
|
||
}
|
||
|
||
function closePanel() {
|
||
panel.classList.add("hidden");
|
||
cy.elements().removeClass("faded highlighted");
|
||
panelNav.reset();
|
||
}
|
||
|
||
// ── Open node in panel ────────────────────────────────────────
|
||
function openNode(id, { push = true, animate = true } = {}) {
|
||
const node = cy.getElementById(id);
|
||
if (!node.length) return;
|
||
const d = node.data();
|
||
|
||
panel.classList.remove("hidden");
|
||
showNodeView();
|
||
|
||
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 => {
|
||
const ext = p.split(".").pop();
|
||
const canView = ["ncl","toml","rs","nu","md","json","html"].includes(ext);
|
||
return `<li class="flex items-center gap-1.5 break-all">` +
|
||
(canView
|
||
? `<button class="artifact-link font-mono text-xs text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 text-left cursor-pointer" data-path="${p}">${p}</button>`
|
||
: `<code class="text-xs text-base-content/60">${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-xs 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 otherId = isSrc ? e.data("target") : e.data("source");
|
||
const other = cy.getElementById(otherId);
|
||
const otherLbl = other.data("label") || otherId;
|
||
const arrow = isSrc ? "→" : "←";
|
||
return `<li class="flex gap-1 items-baseline">` +
|
||
`<span class="opacity-40 flex-shrink-0">${arrow}</span>` +
|
||
`<span class="text-base-content/80 flex-shrink-0 text-xs font-medium">${e.data("kind")}</span>` +
|
||
`<button class="node-jump-link text-xs opacity-60 break-all bg-transparent border-none p-0 cursor-pointer hover:text-primary hover:underline underline-offset-2 text-left" data-node-id="${otherId}">${otherLbl}</button>` +
|
||
`</li>`;
|
||
}).join("");
|
||
} else {
|
||
dEdges.classList.add("hidden");
|
||
}
|
||
|
||
renderNodeExtra(d);
|
||
|
||
cy.elements().addClass("faded").removeClass("highlighted");
|
||
node.closedNeighborhood().removeClass("faded").addClass("highlighted");
|
||
|
||
if (animate) {
|
||
cy.animate(
|
||
{ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) },
|
||
{ duration: 350, easing: "ease-in-out-cubic" }
|
||
);
|
||
}
|
||
|
||
if (push) panelNav.push({ type: "node", id });
|
||
}
|
||
|
||
cy.on("tap", "node", evt => { openNode(evt.target.data("id")); });
|
||
|
||
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();
|
||
});
|
||
|
||
const graphRoot = document.getElementById("graph-root");
|
||
|
||
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 = "";
|
||
});
|
||
|
||
// ── Shared helpers ────────────────────────────────────────────
|
||
function esc(s) { return String(s ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
||
|
||
function renderKvSection(data, skipKeys = []) {
|
||
return Object.entries(data)
|
||
.filter(([k, v]) => !skipKeys.includes(k) && v !== "" && v !== null && v !== undefined)
|
||
.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 text-base-content/70">${v.map(item =>
|
||
typeof item === "object"
|
||
? `<li><pre class="text-xs whitespace-pre-wrap bg-base-300 p-1.5 rounded mt-0.5">${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 if (typeof v === "boolean") {
|
||
val = `<span class="badge badge-xs ${v ? "badge-success" : "badge-ghost"}">${v}</span>`;
|
||
} else {
|
||
val = `<span class="text-base-content/80">${v}</span>`;
|
||
}
|
||
return `<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-0.5">${label}</p>${val}</div>`;
|
||
})
|
||
.filter(Boolean)
|
||
.join("");
|
||
}
|
||
|
||
// ── Open ADR in panel ─────────────────────────────────────────
|
||
async function openAdr(id, { push = true } = {}) {
|
||
panel.classList.remove("hidden");
|
||
showContentView("Architecture Decision Record");
|
||
dName.textContent = id;
|
||
dContentBody.innerHTML = `<span class="loading loading-spinner loading-sm"></span>`;
|
||
|
||
const params = new URLSearchParams({ ...(GRAPH_SLUG ? { slug: GRAPH_SLUG } : {}) });
|
||
try {
|
||
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${params}`);
|
||
const data = await res.json();
|
||
dContentBody.innerHTML = data.error
|
||
? `<p class="text-error text-sm">${esc(data.error)}</p>`
|
||
: renderKvSection(data, ["id"]) || `<p class="text-base-content/50 text-sm">No details.</p>`;
|
||
} catch (err) {
|
||
dContentBody.innerHTML = `<p class="text-error text-sm">Failed: ${esc(String(err))}</p>`;
|
||
}
|
||
|
||
if (push) panelNav.push({ type: "adr", id });
|
||
}
|
||
|
||
// openFile is replaced by srcOpen — opens in repo/docs tab, no panel loading.
|
||
|
||
// ── Node-type extra sections in the side panel ────────────────
|
||
function renderNodeExtra(d) {
|
||
const el = dExtra;
|
||
el.innerHTML = "";
|
||
el.classList.add("hidden");
|
||
|
||
const sec = (label, content) =>
|
||
`<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-1">${label}</p>${content}</div>`;
|
||
|
||
const badge = (text, color) =>
|
||
`<span class="badge badge-xs" style="background:${color};color:#fff;border:none">${text}</span>`;
|
||
|
||
let html = "";
|
||
|
||
if (d.level === "Route") {
|
||
const authColor = { none: "#6b7280", viewer: "#f59e0b", bearer: "#3b82f6", admin: "#ef4444" };
|
||
const authBadge = badge(d.auth || "none", authColor[d.auth] || "#6b7280");
|
||
const methodBadge = badge(d.method, "#10b981");
|
||
html += sec("Endpoint",
|
||
`<div class="flex flex-wrap gap-1">${methodBadge}${authBadge}` +
|
||
(d.feature ? `<span class="badge badge-xs badge-outline">feature:${d.feature}</span>` : "") +
|
||
`</div>`);
|
||
if (d.actors?.length)
|
||
html += sec("Actors", d.actors.map(a => `<span class="badge badge-xs badge-ghost">${a}</span>`).join(" "));
|
||
if (d.tags?.length)
|
||
html += sec("Tags", d.tags.map(t => `<code class="text-xs bg-base-300 px-1 rounded">${t}</code>`).join(" "));
|
||
if (d.params?.length) {
|
||
const rows = d.params.map(p =>
|
||
`<li class="flex flex-wrap gap-1 items-baseline">` +
|
||
`<code class="text-xs font-bold">${p.name}</code>` +
|
||
`<span class="text-xs text-base-content/40">${p.kind}</span>` +
|
||
`<span class="badge badge-xs badge-ghost">${p.constraint}</span>` +
|
||
(p.description ? `<span class="text-xs text-base-content/50">— ${p.description}</span>` : "") +
|
||
`</li>`
|
||
).join("");
|
||
html += sec("Parameters", `<ul class="space-y-1">${rows}</ul>`);
|
||
}
|
||
|
||
} else if (d.level === "Dimension") {
|
||
const reached = d.current_state && d.current_state === d.desired_state;
|
||
const statusBadge = reached
|
||
? `<span class="badge badge-xs badge-success">reached</span>`
|
||
: `<span class="badge badge-xs badge-warning">in progress</span>`;
|
||
html += sec("FSM State",
|
||
`<div class="space-y-1 text-xs">` +
|
||
`<div class="flex gap-2 items-center">${statusBadge}<span class="badge badge-xs badge-ghost">${d.horizon}</span></div>` +
|
||
`<div><span class="text-base-content/40">current: </span><code>${d.current_state}</code></div>` +
|
||
`<div><span class="text-base-content/40">desired: </span><code>${d.desired_state}</code></div>` +
|
||
`</div>`);
|
||
|
||
} else if (d.level === "WorkspaceCrate") {
|
||
if (d.features?.length)
|
||
html += sec("Features", d.features.map(f =>
|
||
`<span class="badge badge-xs badge-outline font-mono">${f}</span>`).join(" "));
|
||
if (d.ws_deps?.length) {
|
||
html += sec("Depends on",
|
||
d.ws_deps.map(dep => {
|
||
const depId = dep;
|
||
const depNode = cy.nodes(`[id = "${depId}"]`);
|
||
const label = depNode.length ? depNode.data("label") : depId.replace("ws:", "");
|
||
return `<button class="node-jump-link badge badge-xs badge-ghost cursor-pointer" data-node-id="${depId}">${label}</button>`;
|
||
}).join(" "));
|
||
}
|
||
|
||
} else if (d.level === "ConfigSection") {
|
||
if (d.contract)
|
||
html += sec("Contract",
|
||
`<code class="text-xs bg-base-300 px-1.5 py-0.5 rounded">${d.contract}</code>`);
|
||
|
||
} else if (d.level === "Requirement") {
|
||
html += sec("Details",
|
||
`<div class="flex flex-wrap gap-1">` +
|
||
(d.env_kind ? badge(d.env_kind, "#6366f1") : "") +
|
||
(d.env_target ? badge(d.env_target, "#8b5cf6") : "") +
|
||
(d.required ? `<span class="badge badge-xs badge-error">required</span>`
|
||
: `<span class="badge badge-xs badge-ghost">optional</span>`) +
|
||
`</div>`);
|
||
}
|
||
|
||
if (html) {
|
||
el.innerHTML = html;
|
||
el.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
// ── Click delegation ──────────────────────────────────────────
|
||
document.addEventListener("click", e => {
|
||
// Close search dropdown on outside click
|
||
if (!e.target.closest("#search-wrap")) {
|
||
searchDropdown.classList.add("hidden");
|
||
}
|
||
|
||
const adrBtn = e.target.closest(".adr-link");
|
||
if (adrBtn) { openAdr(adrBtn.dataset.adr); return; }
|
||
|
||
const artBtn = e.target.closest(".artifact-link");
|
||
if (artBtn) { srcOpen(artBtn.dataset.path); return; }
|
||
|
||
const jumpBtn = e.target.closest(".node-jump-link");
|
||
if (jumpBtn) { openNode(jumpBtn.dataset.nodeId, { push: true, animate: true }); }
|
||
});
|
||
|
||
// ── Inline node search ────────────────────────────────────────
|
||
const graphSearch = document.getElementById("graph-search");
|
||
const searchCount = document.getElementById("search-count");
|
||
const searchDropdown = document.getElementById("search-dropdown");
|
||
|
||
let searchMatches = [];
|
||
|
||
function clearGraphSearch() {
|
||
cy.nodes().removeClass("search-match search-dim");
|
||
searchMatches = [];
|
||
searchCount.textContent = "";
|
||
searchDropdown.classList.add("hidden");
|
||
searchDropdown.innerHTML = "";
|
||
}
|
||
|
||
function doGraphSearch(q) {
|
||
clearGraphSearch();
|
||
if (!q) return;
|
||
const ql = q.toLowerCase();
|
||
searchMatches = cy.nodes(":visible").filter(n =>
|
||
(n.data("label") || "").toLowerCase().includes(ql) ||
|
||
(n.data("id") || "").toLowerCase().includes(ql) ||
|
||
(n.data("description") || "").toLowerCase().includes(ql)
|
||
).toArray();
|
||
|
||
searchCount.textContent = `${searchMatches.length}`;
|
||
|
||
if (searchMatches.length === 0) return;
|
||
|
||
// Highlight in graph
|
||
cy.nodes().addClass("search-dim");
|
||
searchMatches.forEach(n => n.removeClass("search-dim").addClass("search-match"));
|
||
|
||
// Dropdown list (cap 12)
|
||
const shown = searchMatches.slice(0, 12);
|
||
searchDropdown.innerHTML = shown.map((n, i) =>
|
||
`<li class="search-hit cursor-pointer px-3 py-2 hover:bg-base-300 transition-colors flex items-center gap-2" data-idx="${i}">` +
|
||
`<span class="badge badge-xs flex-shrink-0" style="background:${n.data("color")};border:none;color:#fff">${esc(n.data("level"))}</span>` +
|
||
`<span class="truncate">${esc(n.data("label"))}</span>` +
|
||
`</li>`
|
||
).join("");
|
||
if (searchMatches.length > 12) {
|
||
searchDropdown.innerHTML +=
|
||
`<li class="px-3 py-1.5 text-xs text-base-content/40">+${searchMatches.length - 12} more…</li>`;
|
||
}
|
||
searchDropdown.classList.remove("hidden");
|
||
}
|
||
|
||
graphSearch.addEventListener("input", () => {
|
||
const q = graphSearch.value.trim();
|
||
if (!q) { clearGraphSearch(); return; }
|
||
doGraphSearch(q);
|
||
});
|
||
|
||
graphSearch.addEventListener("keydown", e => {
|
||
if (e.key === "Escape") { graphSearch.value = ""; clearGraphSearch(); e.preventDefault(); return; }
|
||
if (e.key === "Enter" && searchMatches.length > 0) {
|
||
openNode(searchMatches[0].data("id"));
|
||
searchDropdown.classList.add("hidden");
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
|
||
searchDropdown.addEventListener("click", e => {
|
||
const li = e.target.closest(".search-hit");
|
||
if (!li) return;
|
||
openNode(searchMatches[parseInt(li.dataset.idx, 10)].data("id"));
|
||
searchDropdown.classList.add("hidden");
|
||
});
|
||
|
||
// ── URL param auto-open ───────────────────────────────────────
|
||
const urlNode = new URLSearchParams(location.search).get("node");
|
||
if (urlNode) {
|
||
cy.ready(() => {
|
||
// Wait for layout to finish before centering
|
||
setTimeout(() => openNode(urlNode, { push: true, animate: true }), 600);
|
||
});
|
||
}
|
||
|
||
// ── Keyboard shortcuts ────────────────────────────────────────
|
||
document.addEventListener('keydown', e => {
|
||
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 %}
|