180 lines
8.4 KiB
HTML
180 lines
8.4 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Spore Agent Commons — Knowledge Graph</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { background: #0d1117; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; }
|
|
#controls { position: fixed; top: 12px; left: 12px; z-index: 10; display: flex; gap: 8px; flex-wrap: wrap; }
|
|
.btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
|
.btn:hover { background: #30363d; }
|
|
.btn.active { background: #1f6feb; border-color: #388bfd; }
|
|
#info { position: fixed; bottom: 12px; left: 12px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; max-width: 360px; font-size: 13px; z-index: 10; }
|
|
#info h3 { color: #58a6ff; margin-bottom: 6px; }
|
|
#info .field { margin: 3px 0; }
|
|
#info .label { color: #8b949e; }
|
|
#legend { position: fixed; top: 12px; right: 12px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px 14px; font-size: 12px; z-index: 10; }
|
|
.legend-item { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
|
|
.legend-dot { width: 12px; height: 12px; border-radius: 50%; }
|
|
svg { width: 100vw; height: 100vh; }
|
|
.link { stroke-opacity: 0.4; }
|
|
.node-label { font-size: 11px; fill: #8b949e; pointer-events: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="controls">
|
|
<button class="btn active" data-filter="all">All</button>
|
|
<button class="btn" data-filter="holon">Holons</button>
|
|
<button class="btn" data-filter="claim">Claims</button>
|
|
<button class="btn" data-filter="intent">Intents</button>
|
|
<button class="btn" data-filter="commitment">Commitments</button>
|
|
<button class="btn" data-filter="governance">Governance</button>
|
|
</div>
|
|
<div id="legend">
|
|
<div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div> Holon</div>
|
|
<div class="legend-item"><div class="legend-dot" style="background:#f0883e"></div> Claim</div>
|
|
<div class="legend-item"><div class="legend-dot" style="background:#3fb950"></div> Evidence</div>
|
|
<div class="legend-item"><div class="legend-dot" style="background:#d2a8ff"></div> Intent</div>
|
|
<div class="legend-item"><div class="legend-dot" style="background:#f778ba"></div> Commitment</div>
|
|
<div class="legend-item"><div class="legend-dot" style="background:#79c0ff"></div> Governance</div>
|
|
</div>
|
|
<div id="info"><h3>Spore Agent Commons</h3><p>Click a node to inspect</p></div>
|
|
<svg></svg>
|
|
|
|
<script>
|
|
const COLORS = {
|
|
holon: '#58a6ff', claim: '#f0883e', evidence: '#3fb950',
|
|
intent: '#d2a8ff', commitment: '#f778ba', governance: '#79c0ff',
|
|
attestation: '#ffa657', peer: '#56d364'
|
|
};
|
|
const SHAPES = {
|
|
holon: d3.symbolCircle, claim: d3.symbolSquare, evidence: d3.symbolTriangle,
|
|
intent: d3.symbolDiamond, commitment: d3.symbolStar, governance: d3.symbolCross,
|
|
};
|
|
const RADIUS = { holon: 10, claim: 8, evidence: 6, intent: 9, commitment: 10, governance: 8 };
|
|
|
|
let allNodes = [], allLinks = [], activeFilter = 'all';
|
|
|
|
async function fetchData() {
|
|
const [holons, claims, intents, commitments, dag] = await Promise.all([
|
|
fetch('/holons').then(r => r.json()),
|
|
fetch('/claims').then(r => r.json()),
|
|
fetch('/intents').then(r => r.json()),
|
|
fetch('/commitments').then(r => r.json()),
|
|
fetch('/governance/dag').then(r => r.json()),
|
|
]);
|
|
|
|
const nodes = [], links = [];
|
|
|
|
holons.forEach(h => nodes.push({ id: h.rid, label: h.name, kind: 'holon', data: h }));
|
|
claims.forEach(c => {
|
|
nodes.push({ id: c.rid, label: c.content.slice(0, 40), kind: 'claim', data: c });
|
|
if (c.proposer_rid) links.push({ source: c.proposer_rid, target: c.rid, type: 'proposed' });
|
|
});
|
|
intents.forEach(i => {
|
|
nodes.push({ id: i.rid, label: i.title, kind: 'intent', data: i });
|
|
if (i.publisher_rid) links.push({ source: i.publisher_rid, target: i.rid, type: 'published' });
|
|
});
|
|
commitments.forEach(c => {
|
|
nodes.push({ id: c.rid, label: c.title, kind: 'commitment', data: c });
|
|
if (c.proposer_rid) links.push({ source: c.proposer_rid, target: c.rid, type: 'proposed' });
|
|
if (c.acceptor_rid) links.push({ source: c.acceptor_rid, target: c.rid, type: 'accepted' });
|
|
});
|
|
dag.nodes.forEach(d => {
|
|
nodes.push({ id: `gov:${d.doc_id}`, label: d.title || d.doc_id, kind: 'governance', data: d });
|
|
d.parents.forEach(p => links.push({ source: `gov:${p}`, target: `gov:${d.doc_id}`, type: 'depends_on' }));
|
|
});
|
|
|
|
// Filter out links with missing nodes
|
|
const nodeIds = new Set(nodes.map(n => n.id));
|
|
const validLinks = links.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target));
|
|
|
|
return { nodes, links: validLinks };
|
|
}
|
|
|
|
function render(nodes, links) {
|
|
const svg = d3.select('svg');
|
|
svg.selectAll('*').remove();
|
|
const w = window.innerWidth, h = window.innerHeight;
|
|
const g = svg.append('g');
|
|
|
|
svg.call(d3.zoom().scaleExtent([0.2, 5]).on('zoom', e => g.attr('transform', e.transform)));
|
|
|
|
const sim = d3.forceSimulation(nodes)
|
|
.force('link', d3.forceLink(links).id(d => d.id).distance(80))
|
|
.force('charge', d3.forceManyBody().strength(-200))
|
|
.force('center', d3.forceCenter(w / 2, h / 2))
|
|
.force('collision', d3.forceCollide().radius(20));
|
|
|
|
const link = g.selectAll('.link').data(links).enter().append('line')
|
|
.attr('class', 'link')
|
|
.attr('stroke', '#30363d')
|
|
.attr('stroke-width', 1.5);
|
|
|
|
const node = g.selectAll('.node').data(nodes).enter().append('g')
|
|
.attr('class', 'node')
|
|
.call(d3.drag().on('start', dragStart).on('drag', dragging).on('end', dragEnd));
|
|
|
|
node.append('path')
|
|
.attr('d', d => d3.symbol().type(SHAPES[d.kind] || d3.symbolCircle).size((RADIUS[d.kind] || 8) ** 2 * 3)())
|
|
.attr('fill', d => COLORS[d.kind] || '#8b949e')
|
|
.attr('stroke', '#0d1117')
|
|
.attr('stroke-width', 1.5)
|
|
.style('cursor', 'pointer');
|
|
|
|
node.append('text')
|
|
.attr('class', 'node-label')
|
|
.attr('dx', 14).attr('dy', 4)
|
|
.text(d => d.label.length > 30 ? d.label.slice(0, 30) + '…' : d.label);
|
|
|
|
node.on('click', (e, d) => {
|
|
const info = document.getElementById('info');
|
|
info.innerHTML = `<h3>${d.label}</h3>
|
|
<div class="field"><span class="label">Kind:</span> ${d.kind}</div>
|
|
<div class="field"><span class="label">RID:</span> ${d.id}</div>
|
|
${d.data.status ? `<div class="field"><span class="label">Status:</span> ${d.data.status}</div>` : ''}
|
|
${d.data.state ? `<div class="field"><span class="label">State:</span> ${d.data.state}</div>` : ''}
|
|
${d.data.holon_type ? `<div class="field"><span class="label">Type:</span> ${d.data.holon_type}</div>` : ''}
|
|
${d.data.intent_type ? `<div class="field"><span class="label">Intent:</span> ${d.data.intent_type}</div>` : ''}
|
|
${d.data.confidence !== undefined ? `<div class="field"><span class="label">Confidence:</span> ${d.data.confidence}</div>` : ''}`;
|
|
});
|
|
|
|
sim.on('tick', () => {
|
|
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
|
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
});
|
|
|
|
function dragStart(e, d) { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
|
|
function dragging(e, d) { d.fx = e.x; d.fy = e.y; }
|
|
function dragEnd(e, d) { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }
|
|
}
|
|
|
|
function applyFilter(filter) {
|
|
activeFilter = filter;
|
|
document.querySelectorAll('.btn').forEach(b => b.classList.toggle('active', b.dataset.filter === filter));
|
|
const nodes = filter === 'all' ? allNodes : allNodes.filter(n => n.kind === filter);
|
|
const nodeIds = new Set(nodes.map(n => n.id));
|
|
const links = allLinks.filter(l => {
|
|
const sId = typeof l.source === 'object' ? l.source.id : l.source;
|
|
const tId = typeof l.target === 'object' ? l.target.id : l.target;
|
|
return nodeIds.has(sId) && nodeIds.has(tId);
|
|
});
|
|
// Deep clone to avoid D3 mutation issues
|
|
render(nodes.map(n => ({...n})), links.map(l => ({source: typeof l.source === 'object' ? l.source.id : l.source, target: typeof l.target === 'object' ? l.target.id : l.target, type: l.type})));
|
|
}
|
|
|
|
document.querySelectorAll('.btn').forEach(b => b.addEventListener('click', () => applyFilter(b.dataset.filter)));
|
|
|
|
fetchData().then(({ nodes, links }) => {
|
|
allNodes = nodes;
|
|
allLinks = links;
|
|
render(nodes, links);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|