248 lines
7 KiB
HTML
248 lines
7 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Ontology{% endblock %}
|
||
{% block nav_ontology %}btn-active{% endblock %}
|
||
{% block main_class %}p-0{% endblock %}
|
||
|
||
{% block head %}
|
||
<style>
|
||
#graph-root {
|
||
display: flex;
|
||
height: calc(100vh - 64px - 40px);
|
||
min-height: 500px;
|
||
}
|
||
#cy-wrapper {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
position: relative;
|
||
}
|
||
#cy { width: 100%; height: 100%; }
|
||
#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 rgba(255,255,255,0.1);
|
||
background: rgba(0,0,0,0.6);
|
||
color: #fff;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
}
|
||
#cy-controls button:hover { background: rgba(80,80,80,0.8); }
|
||
#sidebar {
|
||
width: 280px;
|
||
flex-shrink: 0;
|
||
overflow-y: auto;
|
||
background: oklch(var(--b2, 20% 0 0));
|
||
border-left: 1px solid oklch(var(--b3, 15% 0 0));
|
||
padding: 1rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="px-4 pt-4 pb-2 flex items-center gap-4">
|
||
<h1 class="text-lg font-bold font-mono">Domain Ontology</h1>
|
||
<div class="flex gap-2 text-xs">
|
||
<label class="flex items-center gap-1 cursor-pointer">
|
||
<input type="checkbox" id="show-axioms" checked class="checkbox checkbox-xs">
|
||
<span class="badge badge-primary badge-xs">Axioms</span>
|
||
</label>
|
||
<label class="flex items-center gap-1 cursor-pointer">
|
||
<input type="checkbox" id="show-practices" checked class="checkbox checkbox-xs">
|
||
<span class="badge badge-success badge-xs">Practices</span>
|
||
</label>
|
||
<label class="flex items-center gap-1 cursor-pointer">
|
||
<input type="checkbox" id="show-tensions" checked class="checkbox checkbox-xs">
|
||
<span class="badge badge-warning badge-xs">Tensions</span>
|
||
</label>
|
||
</div>
|
||
{% if not has_root %}
|
||
<div class="alert alert-warning alert-xs py-1 px-3 text-xs">
|
||
--project-root not set — graph will be empty
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div id="graph-root">
|
||
<div id="cy-wrapper">
|
||
<div id="cy"></div>
|
||
<div id="cy-controls">
|
||
<button onclick="cy.fit()" title="Fit">⊡</button>
|
||
<button onclick="cy.zoom(cy.zoom()*1.25)" title="Zoom in">+</button>
|
||
<button onclick="cy.zoom(cy.zoom()*0.8)" title="Zoom out">−</button>
|
||
<button onclick="resetLayout()" title="Re-layout">↺</button>
|
||
</div>
|
||
</div>
|
||
<div id="sidebar">
|
||
<p class="text-base-content/40 text-xs mb-3">Click a node to inspect.</p>
|
||
<div id="node-detail"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const GRAPH = {{ graph_json | safe }};
|
||
let cy = null;
|
||
|
||
function levelColor(level) {
|
||
if (level === 'Axiom') return '#6366f1';
|
||
if (level === 'Practice') return '#22c55e';
|
||
if (level === 'Tension') return '#f59e0b';
|
||
return '#6b7280';
|
||
}
|
||
|
||
function buildElements() {
|
||
const nodes = (GRAPH.nodes || []).map(n => ({
|
||
data: {
|
||
id: n.id,
|
||
label: n.name || n.id,
|
||
level: n.level,
|
||
description: n.description || '',
|
||
color: levelColor(n.level),
|
||
invariant: n.invariant || false,
|
||
artifact_paths: n.artifact_paths || [],
|
||
}
|
||
}));
|
||
const edges = (GRAPH.edges || []).map((e, i) => ({
|
||
data: {
|
||
id: 'e' + i,
|
||
source: e.from,
|
||
target: e.to,
|
||
kind: e.kind,
|
||
note: e.note || '',
|
||
weight: e.weight,
|
||
}
|
||
}));
|
||
return [...nodes, ...edges];
|
||
}
|
||
|
||
function initCytoscape() {
|
||
cy = cytoscape({
|
||
container: document.getElementById('cy'),
|
||
elements: buildElements(),
|
||
style: [
|
||
{
|
||
selector: 'node',
|
||
style: {
|
||
'background-color': 'data(color)',
|
||
'label': 'data(label)',
|
||
'color': '#f1f5f9',
|
||
'font-size': '8px',
|
||
'font-family': 'ui-monospace, monospace',
|
||
'text-valign': 'center',
|
||
'text-halign': 'center',
|
||
'text-wrap': 'wrap',
|
||
'text-max-width': '56px',
|
||
'text-overflow-wrap': 'anywhere',
|
||
'width': 72,
|
||
'height': 72,
|
||
'border-width': 2,
|
||
'border-color': 'rgba(255,255,255,0.15)',
|
||
}
|
||
},
|
||
{
|
||
selector: 'node[?invariant]',
|
||
style: {
|
||
'border-width': 3,
|
||
'border-color': '#a5b4fc',
|
||
'border-style': 'double',
|
||
}
|
||
},
|
||
{
|
||
selector: 'edge',
|
||
style: {
|
||
'width': 1.5,
|
||
'line-color': 'rgba(156,163,175,0.4)',
|
||
'target-arrow-color': 'rgba(156,163,175,0.6)',
|
||
'target-arrow-shape': 'triangle',
|
||
'curve-style': 'bezier',
|
||
'arrow-scale': 0.8,
|
||
}
|
||
},
|
||
{
|
||
selector: 'node:selected',
|
||
style: {
|
||
'border-color': '#818cf8',
|
||
'border-width': 3,
|
||
}
|
||
},
|
||
],
|
||
layout: {
|
||
name: 'cose',
|
||
animate: false,
|
||
nodeRepulsion: 12000,
|
||
idealEdgeLength: 140,
|
||
gravity: 0.15,
|
||
numIter: 1000,
|
||
fit: true,
|
||
padding: 30,
|
||
},
|
||
});
|
||
|
||
cy.on('tap', 'node', function(evt) {
|
||
const d = evt.target.data();
|
||
const paths = d.artifact_paths.length
|
||
? d.artifact_paths.map(p => `<code class="font-mono">${p}</code>`).join('<br>')
|
||
: '<span class="text-base-content/30">—</span>';
|
||
document.getElementById('node-detail').innerHTML = `
|
||
<div class="mb-2">
|
||
<span class="badge badge-xs" style="background:${d.color}">${d.level}</span>
|
||
<span class="font-mono font-semibold ml-1">${d.id}</span>
|
||
</div>
|
||
<p class="font-semibold text-sm mb-1">${d.label}</p>
|
||
<p class="text-base-content/60 mb-3">${d.description}</p>
|
||
<div class="text-base-content/40 text-xs">${paths}</div>
|
||
`;
|
||
});
|
||
|
||
cy.on('tap', function(evt) {
|
||
if (evt.target === cy) document.getElementById('node-detail').innerHTML = '';
|
||
});
|
||
} // end initCytoscape
|
||
|
||
function resetLayout() {
|
||
if (cy) cy.layout({ name: 'cose', animate: true, nodeRepulsion: 12000, idealEdgeLength: 140, gravity: 0.15, numIter: 1000 }).run();
|
||
}
|
||
|
||
// Filter checkboxes
|
||
function applyFilters() {
|
||
if (!cy) return;
|
||
const showAxioms = document.getElementById('show-axioms').checked;
|
||
const showPractices = document.getElementById('show-practices').checked;
|
||
const showTensions = document.getElementById('show-tensions').checked;
|
||
cy.nodes().forEach(n => {
|
||
const l = n.data('level');
|
||
const vis = (l === 'Axiom' && showAxioms)
|
||
|| (l === 'Practice' && showPractices)
|
||
|| (l === 'Tension' && showTensions)
|
||
|| (!['Axiom','Practice','Tension'].includes(l));
|
||
n.style('display', vis ? 'element' : 'none');
|
||
});
|
||
cy.edges().forEach(e => {
|
||
const srcVis = e.source().style('display') !== 'none';
|
||
const tgtVis = e.target().style('display') !== 'none';
|
||
e.style('display', srcVis && tgtVis ? 'element' : 'none');
|
||
});
|
||
}
|
||
|
||
['show-axioms','show-practices','show-tensions'].forEach(id => {
|
||
document.getElementById(id).addEventListener('change', applyFilters);
|
||
});
|
||
|
||
if (typeof cytoscape !== 'undefined') {
|
||
initCytoscape();
|
||
} else {
|
||
const s = document.createElement('script');
|
||
s.src = 'https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js';
|
||
s.onload = initCytoscape;
|
||
document.head.appendChild(s);
|
||
}
|
||
</script>
|
||
{% endblock %}
|