Better visualization

This commit is contained in:
2026-04-23 17:39:29 +02:00
parent d82bf2d899
commit 5ced96b918
+432 -279
View File
@@ -1759,128 +1759,127 @@ GRAPH_HTML = """<!DOCTYPE html>
<meta charset="utf-8">
<title>Festinger — Knowledge Graph</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: monospace; display: flex; height: 100vh; overflow: hidden; background: #f0f0f0; color: #222; }
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: monospace; display: flex; height: 100vh; overflow: hidden; background: #f0f0f0; color: #222; }}
/* ── Sidebar ── */
#sidebar {
width: 260px; flex-shrink: 0; background: #1a1a2e; color: #ccc;
display: flex; flex-direction: column; gap: 0; overflow-y: auto;
}
.sb-section { padding: 14px 16px; border-bottom: 1px solid #2a2a4e; }
.sb-section:last-child { border-bottom: none; flex: 1; }
#sb-title { font-size: 1.05em; color: #fff; font-weight: bold; }
#sb-subtitle { font-size: 0.72em; color: #666; margin-top: 2px; }
.back-link { font-size: 0.72em; color: #5555aa; text-decoration: none; }
.back-link:hover { color: #8888ff; }
.sec-label {
#sidebar {{
width: 270px; flex-shrink: 0; background: #1a1a2e; color: #ccc;
display: flex; flex-direction: column; overflow-y: auto;
}}
.sb-section {{ padding: 14px 16px; border-bottom: 1px solid #2a2a4e; }}
.sb-section:last-child {{ border-bottom: none; flex: 1; }}
#sb-title {{ font-size: 1.05em; color: #fff; font-weight: bold; }}
#sb-subtitle {{ font-size: 0.72em; color: #555; margin-top: 2px; }}
.back-link {{ font-size: 0.72em; color: #5555aa; text-decoration: none; }}
.back-link:hover {{ color: #8888ff; }}
.sec-label {{
font-size: 0.68em; text-transform: uppercase; letter-spacing: 0.1em;
color: #555; margin-bottom: 8px; font-weight: bold;
}
/* Stats */
.stats-row { display: flex; gap: 8px; }
.stat-box { flex: 1; background: #0d0d20; border-radius: 4px; padding: 7px 10px; }
.stat-val { font-size: 1.25em; font-weight: bold; color: #7070ff; }
.stat-lbl { font-size: 0.68em; color: #555; text-transform: uppercase; margin-top: 1px; }
/* Inputs */
input[type="text"] {
}}
.stats-row {{ display: flex; gap: 8px; }}
.stat-box {{ flex: 1; background: #0d0d20; border-radius: 4px; padding: 7px 10px; }}
.stat-val {{ font-size: 1.25em; font-weight: bold; color: #7070ff; }}
.stat-lbl {{ font-size: 0.68em; color: #555; text-transform: uppercase; margin-top: 1px; }}
input[type="text"] {{
width: 100%; padding: 6px 8px; background: #0d0d20; border: 1px solid #2a2a4e;
color: #ddd; border-radius: 3px; font-family: monospace; font-size: 0.83em;
}
input[type="text"]:focus { outline: 1px solid #7070ff; border-color: #7070ff; }
input[type="range"] { width: 100%; accent-color: #7070ff; cursor: pointer; margin-top: 4px; }
.range-vals { display: flex; justify-content: space-between; font-size: 0.7em; color: #555; }
label.field-label { font-size: 0.8em; color: #999; display: flex; justify-content: space-between; }
label.field-label span { color: #aaa; }
}}
input[type="text"]:focus {{ outline: 1px solid #7070ff; border-color: #7070ff; }}
input[type="range"] {{ width: 100%; accent-color: #7070ff; cursor: pointer; margin-top: 4px; }}
.range-vals {{ display: flex; justify-content: space-between; font-size: 0.7em; color: #555; }}
label.field-label {{ font-size: 0.8em; color: #999; display: flex; justify-content: space-between; }}
label.field-label span {{ color: #aaa; }}
.btn-row {{ display: flex; gap: 6px; margin-top: 8px; }}
.btn {{ flex: 1; padding: 7px 10px; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.83em; }}
.btn-primary {{ background: #4040cc; color: #fff; }}
.btn-primary:hover {{ background: #5555dd; }}
.btn-secondary {{ background: #1e1e40; color: #aaa; }}
.btn-secondary:hover {{ background: #2a2a55; }}
.btn-load {{ width: 100%; padding: 9px; background: #4040cc; color: #fff; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.87em; }}
.btn-load:hover {{ background: #5555dd; }}
.dim-item {{ display: flex; align-items: center; gap: 7px; padding: 3px 0; cursor: pointer; font-size: 0.82em; }}
.dim-dot {{ width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }}
.dim-item input {{ cursor: pointer; accent-color: #7070ff; }}
/* Buttons */
.btn-row { display: flex; gap: 6px; margin-top: 8px; }
.btn {
flex: 1; padding: 7px 10px; border: none; border-radius: 3px;
cursor: pointer; font-family: monospace; font-size: 0.83em;
}
.btn-primary { background: #4040cc; color: #fff; }
.btn-primary:hover { background: #5555dd; }
.btn-secondary { background: #1e1e40; color: #aaa; }
.btn-secondary:hover { background: #2a2a55; }
.btn-load {
width: 100%; padding: 9px; background: #4040cc; color: #fff;
border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.87em;
}
.btn-load:hover { background: #5555dd; }
/* Dimension list */
.dim-item { display: flex; align-items: center; gap: 7px; padding: 3px 0; cursor: pointer; font-size: 0.82em; }
.dim-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.dim-item input { cursor: pointer; accent-color: #7070ff; }
/* Node info */
#node-info { display: none; }
.ni-name { font-size: 1.05em; color: #fff; word-break: break-all; margin-bottom: 5px; }
.ni-meta { font-size: 0.75em; color: #666; margin-bottom: 6px; }
.dim-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
.dbadge { padding: 2px 8px; border-radius: 10px; font-size: 0.72em; color: #fff; }
.nbr-label { font-size: 0.72em; color: #555; text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 4px; }
#nbr-list { list-style: none; max-height: 160px; overflow-y: auto; }
#nbr-list li { padding: 2px 0; font-size: 0.8em; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#nbr-list li:hover { color: #8888ff; }
#no-sel { font-size: 0.8em; color: #444; }
/* Selected node */
#node-info {{ display: none; }}
.ni-name {{ font-size: 1.05em; color: #fff; word-break: break-all; margin-bottom: 4px; }}
.ni-meta {{ font-size: 0.75em; color: #666; margin-bottom: 6px; }}
.nbr-label {{ font-size: 0.68em; color: #555; text-transform: uppercase; letter-spacing: 0.07em; margin: 8px 0 4px; }}
#nbr-list {{ list-style: none; max-height: 200px; overflow-y: auto; }}
#nbr-list li {{
padding: 3px 0; font-size: 0.78em; cursor: pointer;
border-bottom: 1px solid #1a1a3e; line-height: 1.4;
}}
#nbr-list li:hover {{ color: #aaaaff; }}
#nbr-list li .edge-dim {{ font-size: 0.85em; opacity: 0.7; }}
#no-sel {{ font-size: 0.8em; color: #444; }}
/* Graph area */
#graph-area { flex: 1; position: relative; overflow: hidden; background: #f9f9f9; }
#graph-svg { width: 100%; height: 100%; }
#graph-area {{ flex: 1; position: relative; overflow: hidden; background: #fafafa; }}
#graph-svg {{ width: 100%; height: 100%; }}
/* Loading */
#loading {
#loading {{
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(249,249,249,0.85); font-size: 1em; color: #888; z-index: 10;
}
#loading.hidden { display: none; }
.spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid #ccc; border-top-color: #7070ff; border-radius: 50%; animation: spin 0.7s linear infinite; margin-right: 8px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Empty state */
#empty-state {
background: rgba(250,250,250,0.88); font-size: 1em; color: #888; z-index: 10;
}}
#loading.hidden {{ display: none; }}
.spinner {{ display: inline-block; width: 18px; height: 18px; border: 2px solid #ccc;
border-top-color: #7070ff; border-radius: 50%; animation: spin 0.7s linear infinite; margin-right: 8px; }}
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
#empty-state {{
position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
flex-direction: column; gap: 10px; color: #999;
}
#empty-state.visible { display: flex; }
#empty-state p { font-size: 0.85em; }
}}
#empty-state.visible {{ display: flex; }}
#empty-state p {{ font-size: 0.85em; }}
/* Zoom controls */
#zoom-ctrls { position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 4px; }
#zoom-ctrls button {
/* Zoom */
#zoom-ctrls {{ position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 4px; }}
#zoom-ctrls button {{
width: 34px; height: 34px; background: #fff; border: 1px solid #ddd; border-radius: 4px;
cursor: pointer; font-size: 1.1em; line-height: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
#zoom-ctrls button:hover { background: #f0f0f0; }
}}
#zoom-ctrls button:hover {{ background: #f0f0f0; }}
/* Tooltip */
#tip {
position: absolute; pointer-events: none; background: rgba(20,20,40,0.92);
color: #eee; padding: 7px 11px; border-radius: 5px; font-size: 0.78em;
display: none; z-index: 20; max-width: 200px; line-height: 1.5;
}
#tip strong { color: #fff; }
#tip {{
position: absolute; pointer-events: none; background: rgba(15,15,35,0.95);
color: #eee; padding: 8px 12px; border-radius: 5px; font-size: 0.78em;
display: none; z-index: 20; max-width: 240px; line-height: 1.6;
}}
#tip strong {{ color: #fff; font-size: 1.1em; }}
#tip .tip-edge {{ color: #aaa; font-size: 0.9em; }}
#tip .tip-dim {{ font-weight: bold; }}
/* Legend strip at top of graph */
#legend {
position: absolute; top: 12px; left: 12px; display: flex; gap: 10px;
flex-wrap: wrap; z-index: 5;
}
.leg-item { display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,0.85);
padding: 3px 8px; border-radius: 12px; font-size: 0.72em; border: 1px solid #e0e0e0; cursor: pointer; }
.leg-item:hover { background: #fff; }
.leg-dot { width: 8px; height: 8px; border-radius: 50%; }
/* Legend */
#legend {{
position: absolute; top: 12px; left: 12px; display: flex; gap: 8px;
flex-wrap: wrap; z-index: 5; pointer-events: none;
}}
.leg-item {{
display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,0.88);
padding: 3px 10px; border-radius: 12px; font-size: 0.72em; border: 1px solid #ddd;
cursor: pointer; pointer-events: all;
}}
.leg-item:hover {{ background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.12); }}
.leg-dot {{ width: 10px; height: 10px; border-radius: 50%; }}
.leg-item.dimmed {{ opacity: 0.35; }}
/* View mode toggle */
#view-toggle {{ display: flex; gap: 4px; margin-bottom: 8px; }}
#view-toggle button {{
flex: 1; padding: 5px 8px; border: 1px solid #2a2a4e; border-radius: 3px;
background: #0d0d20; color: #666; cursor: pointer; font-family: monospace; font-size: 0.78em;
}}
#view-toggle button.active {{ background: #4040cc; color: #fff; border-color: #4040cc; }}
</style>
</head>
<body>
<!-- ── Sidebar ── -->
<div id="sidebar">
<div class="sb-section">
<div id="sb-title">Knowledge Graph</div>
<div id="sb-subtitle">Festinger URD explorer</div>
@@ -1891,6 +1890,7 @@ GRAPH_HTML = """<!DOCTYPE html>
<div class="stats-row">
<div class="stat-box"><div class="stat-val" id="s-nodes">—</div><div class="stat-lbl">Nodes</div></div>
<div class="stat-box"><div class="stat-val" id="s-edges">—</div><div class="stat-lbl">Edges</div></div>
<div class="stat-box"><div class="stat-val" id="s-dims">—</div><div class="stat-lbl">Dims</div></div>
</div>
</div>
@@ -1905,8 +1905,8 @@ GRAPH_HTML = """<!DOCTYPE html>
<div class="sb-section">
<div class="sec-label">Filters</div>
<label class="field-label">Min saliency <span id="sal-val">1.0</span></label>
<input type="range" id="sal-slider" min="0" max="6" step="0.5" value="1.0"
<label class="field-label">Min saliency <span id="sal-val">0.0</span></label>
<input type="range" id="sal-slider" min="0" max="6" step="0.5" value="0"
oninput="document.getElementById('sal-val').textContent=parseFloat(this.value).toFixed(1)">
<div class="range-vals"><span>0</span><span>6</span></div>
<div style="margin-top:10px"></div>
@@ -1914,6 +1914,17 @@ GRAPH_HTML = """<!DOCTYPE html>
<input type="range" id="limit-slider" min="50" max="600" step="50" value="300"
oninput="document.getElementById('limit-val').textContent=this.value">
<div class="range-vals"><span>50</span><span>600</span></div>
<div style="margin-top:10px"></div>
<label class="field-label" style="align-items:center">
<span>Show bubbles</span>
<input type="checkbox" id="show-hulls" checked style="cursor:pointer;accent-color:#7070ff"
onchange="renderWithFilters()">
</label>
<label class="field-label" style="align-items:center;margin-top:6px">
<span>Edge labels</span>
<input type="checkbox" id="show-edgelabels" checked style="cursor:pointer;accent-color:#7070ff"
onchange="renderWithFilters()">
</label>
</div>
<div class="sb-section">
@@ -1927,26 +1938,23 @@ GRAPH_HTML = """<!DOCTYPE html>
<div class="sb-section" style="flex:1">
<div class="sec-label">Selected node</div>
<div id="no-sel">Click any node to inspect it.</div>
<div id="no-sel" style="font-size:0.8em;color:#444">Click any node to inspect it.</div>
<div id="node-info">
<div class="ni-name" id="ni-name"></div>
<div class="ni-meta">saliency: <span id="ni-sal"></span></div>
<div class="dim-badges" id="ni-dims"></div>
<div class="nbr-label">Connections</div>
<div class="nbr-label">Facts (outgoing)</div>
<ul id="nbr-list"></ul>
</div>
</div>
</div>
<!-- ── Graph area ── -->
<div id="graph-area">
<svg id="graph-svg"></svg>
<div id="legend"></div>
<div id="loading"><span class="spinner"></span>Loading graph…</div>
<div id="empty-state">
<p>No nodes match the current filters.</p>
<p>Try lowering the saliency threshold or reloading after the agent has run.</p>
<p>Try lowering the saliency threshold or adding concepts via <a href="/admin" style="color:#7070ff">admin</a>.</p>
</div>
<div id="tip"></div>
<div id="zoom-ctrls">
@@ -1958,140 +1966,200 @@ GRAPH_HTML = """<!DOCTYPE html>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
// ─────────────────────────────────────────────────────────────────────────────
// Colour palette
// ─────────────────────────────────────────────────────────────────────────────
// ── Palette ──────────────────────────────────────────────────────────────────
const PALETTE = [
'#4e79a7','#f28e2b','#59a14f','#e15759',
'#b07aa1','#76b7b2','#edc948','#ff9da7',
'#9c755f','#d4a0c7',
'#9c755f','#d4a0c7','#8cd17d','#499894',
];
const dimColors = {};
const dimColors = {{}};
['type','membership','runs-on','tech','owned-by','geography']
.forEach((d, i) => { dimColors[d] = PALETTE[i]; });
.forEach((d, i) => {{ dimColors[d] = PALETTE[i]; }});
function dimColor(dim) {
if (!dimColors[dim]) {
dimColors[dim] = PALETTE[Object.keys(dimColors).length % PALETTE.length];
}
function dimColor(dim) {{
if (!dimColors[dim]) {{
const idx = Object.keys(dimColors).length % PALETTE.length;
dimColors[dim] = PALETTE[idx];
}}
return dimColors[dim];
}
}}
// ─────────────────────────────────────────────────────────────────────────────
// SVG + zoom
// ─────────────────────────────────────────────────────────────────────────────
function hexWithAlpha(hex, a) {{
// Convert #rrggbb to rgba(r,g,b,a)
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return `rgba(${{r}},${{g}},${{b}},${{a}})`;
}}
// ── SVG setup ─────────────────────────────────────────────────────────────────
const svgEl = document.getElementById('graph-svg');
const graphArea = document.getElementById('graph-area');
const svg = d3.select(svgEl);
const root = svg.append('g');
const defs = svg.append('defs');
// Layer order matters: hulls behind links behind nodes
const hullLayer = root.append('g').attr('class', 'hulls');
const linkLayer = root.append('g').attr('class', 'links');
const edgeLblLayer = root.append('g').attr('class', 'edge-labels');
const nodeLayer = root.append('g').attr('class', 'nodes');
const zoom = d3.zoom().scaleExtent([0.05, 12]).on('zoom', e => root.attr('transform', e.transform));
const zoom = d3.zoom().scaleExtent([0.04, 14]).on('zoom', e => root.attr('transform', e.transform));
svg.call(zoom).on('dblclick.zoom', null);
let W = graphArea.clientWidth, H = graphArea.clientHeight;
window.addEventListener('resize', () => {
window.addEventListener('resize', () => {{
W = graphArea.clientWidth; H = graphArea.clientHeight;
if (sim) sim.force('center', d3.forceCenter(W / 2, H / 2)).alpha(0.2).restart();
});
if (sim) sim.force('center', d3.forceCenter(W/2, H/2)).alpha(0.15).restart();
}});
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
// ── State ─────────────────────────────────────────────────────────────────────
let graphData = null;
let sim = null;
function nodeR(d) { return 5 + Math.min(Math.log(d.saliency + 1) * 4, 14); }
function nodeR(d) {{ return 5 + Math.min(Math.log1p(d.saliency) * 5, 16); }}
// ─────────────────────────────────────────────────────────────────────────────
// Arrow markers (one per dimension, re-built when dim list changes)
// ─────────────────────────────────────────────────────────────────────────────
function buildMarkers(dims) {
// ── Arrow markers ─────────────────────────────────────────────────────────────
function buildMarkers(dims) {{
defs.selectAll('marker').remove();
dims.forEach(dim => {
dims.forEach(dim => {{
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
defs.append('marker')
.attr('id', 'arr_' + safe)
.attr('viewBox', '0 -5 10 10').attr('refX', 18).attr('refY', 0)
.attr('viewBox', '0 -5 10 10').attr('refX', 20).attr('refY', 0)
.attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
.append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', dimColor(dim));
});
}
}});
}}
// ─────────────────────────────────────────────────────────────────────────────
// Load data from server
// ─────────────────────────────────────────────────────────────────────────────
async function loadGraph() {
// ── Hull computation ──────────────────────────────────────────────────────────
// Group by dimension: each dim bubble encloses all concepts + parents in that dim.
function computeHulls(nd, ed) {{
const byId = {{}};
nd.forEach(n => byId[n.id] = n);
const groups = {{}}; // dim → Set of node refs
for (const e of ed) {{
const src = typeof e.source === 'object' ? e.source : byId[e.source];
const tgt = typeof e.target === 'object' ? e.target : byId[e.target];
if (!src || !tgt) continue;
if (!groups[e.dim]) groups[e.dim] = new Set();
groups[e.dim].add(src);
groups[e.dim].add(tgt);
}}
return Object.entries(groups).map(([dim, nodeSet]) => ({{
dim, nodes: [...nodeSet]
}}));
}}
// Build a padded convex hull SVG path from a list of node objects.
// Falls back to circle (1 node) or capsule (2 nodes).
const HULL_PAD = 32;
function hullPath(nodes) {{
const pts = nodes.map(n => [n.x, n.y]);
if (pts.length === 0) return '';
if (pts.length === 1) {{
const [x, y] = pts[0]; const r = HULL_PAD;
return `M${{x-r}},${{y}} A${{r}},${{r}} 0 1,0 ${{x+r}},${{y}} A${{r}},${{r}} 0 1,0 ${{x-r}},${{y}}`;
}}
// Centroid
const cx = d3.mean(pts, p => p[0]);
const cy = d3.mean(pts, p => p[1]);
// Expand each point outward from centroid by HULL_PAD
const expanded = pts.map(p => {{
const dx = p[0]-cx, dy = p[1]-cy;
const l = Math.sqrt(dx*dx+dy*dy) || 1;
return [p[0]+dx/l*HULL_PAD, p[1]+dy/l*HULL_PAD];
}});
if (pts.length === 2) {{
// Fallback: hull of the 4 corners of a rectangle around the segment
const [a, b] = expanded;
const nx = -(b[1]-a[1]), ny = b[0]-a[0];
const nl = Math.sqrt(nx*nx+ny*ny) || 1;
const ox = nx/nl*HULL_PAD*0.5, oy = ny/nl*HULL_PAD*0.5;
expanded.push([a[0]+ox,a[1]+oy],[b[0]+ox,b[1]+oy],[b[0]-ox,b[1]-oy],[a[0]-ox,a[1]-oy]);
}}
const hull = d3.polygonHull(expanded);
if (!hull) return '';
return 'M' + hull.map(p => p.join(',')).join('L') + 'Z';
}}
// Centroid of a node set
function centroid(nodes) {{
return [d3.mean(nodes, n => n.x), d3.mean(nodes, n => n.y)];
}}
// ── Data load ─────────────────────────────────────────────────────────────────
async function loadGraph() {{
setLoading(true);
clearSelection();
const minSal = document.getElementById('sal-slider').value;
const limit = document.getElementById('limit-slider').value;
const center = document.getElementById('search-input').value.trim();
const params = new URLSearchParams({ min_saliency: minSal, limit });
const params = new URLSearchParams({{ min_saliency: minSal, limit }});
if (center) params.set('center', center);
try {
try {{
const resp = await fetch('/graph/data?' + params);
graphData = await resp.json();
buildDimCheckboxes(graphData.dim_list);
buildLegend(graphData.dim_list);
renderWithFilters();
} catch (err) {
}} catch(err) {{
console.error(err);
} finally {
}} finally {{
setLoading(false);
}
}
}}
}}
// ─────────────────────────────────────────────────────────────────────────────
// Dimension checkboxes
// ─────────────────────────────────────────────────────────────────────────────
function buildDimCheckboxes(dims) {
// ── Dimension checkboxes ──────────────────────────────────────────────────────
function buildDimCheckboxes(dims) {{
const el = document.getElementById('dim-list');
const existing = new Set([...el.querySelectorAll('.dchk')].map(c => c.value));
dims.forEach(dim => {
dims.forEach(dim => {{
if (existing.has(dim)) return;
const label = document.createElement('label');
label.className = 'dim-item';
label.innerHTML =
`<input type="checkbox" class="dchk" value="${dim}" checked>` +
`<span class="dim-dot" style="background:${dimColor(dim)}"></span>` +
`<span>${dim}</span>`;
`<input type="checkbox" class="dchk" value="${{dim}}" checked>` +
`<span class="dim-dot" style="background:${{dimColor(dim)}}"></span>` +
`<span>${{dim}}</span>`;
label.querySelector('input').addEventListener('change', renderWithFilters);
el.appendChild(label);
});
}
}});
}}
function enabledDims() {
function enabledDims() {{
return new Set([...document.querySelectorAll('.dchk:checked')].map(c => c.value));
}
}}
// ─────────────────────────────────────────────────────────────────────────────
// Legend
// ─────────────────────────────────────────────────────────────────────────────
function buildLegend(dims) {
// ── Legend ────────────────────────────────────────────────────────────────────
function buildLegend(dims) {{
const el = document.getElementById('legend');
el.innerHTML = dims.map(dim => {
el.innerHTML = dims.map(dim => {{
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
return `<span class="leg-item" onclick="toggleDim('${dim}')" id="leg_${safe}">
<span class="leg-dot" style="background:${dimColor(dim)}"></span>${dim}
return `<span class="leg-item" id="leg_${{safe}}" onclick="toggleDim('${{dim}}')">
<span class="leg-dot" style="background:${{dimColor(dim)}}"></span>${{dim}}
</span>`;
}).join('');
}
}}).join('');
}}
function toggleDim(dim) {
function toggleDim(dim) {{
const cb = document.querySelector(`.dchk[value="${{dim}}"]`);
if (cb) {{ cb.checked = !cb.checked; renderWithFilters(); }}
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
const cb = document.querySelector(`.dchk[value="${dim}"]`);
if (cb) { cb.checked = !cb.checked; renderWithFilters(); }
}
const leg = document.getElementById('leg_' + safe);
if (leg) leg.classList.toggle('dimmed', !cb.checked);
}}
// ─────────────────────────────────────────────────────────────────────────────
// Filter and render
// ─────────────────────────────────────────────────────────────────────────────
function renderWithFilters() {
// ── Filter + render ───────────────────────────────────────────────────────────
function renderWithFilters() {{
if (!graphData) return;
const allowed = enabledDims();
const edges = graphData.edges.filter(e => allowed.has(e.dim));
@@ -2100,188 +2168,273 @@ function renderWithFilters() {
document.getElementById('s-nodes').textContent = nodes.length.toLocaleString();
document.getElementById('s-edges').textContent = edges.length.toLocaleString();
document.getElementById('s-dims').textContent = allowed.size;
const empty = document.getElementById('empty-state');
if (nodes.length === 0) { empty.classList.add('visible'); renderGraph([], []); return; }
if (nodes.length === 0) {{ empty.classList.add('visible'); renderGraph([], []); return; }}
empty.classList.remove('visible');
renderGraph(nodes, edges);
}
}}
// ─────────────────────────────────────────────────────────────────────────────
// D3 render
// ─────────────────────────────────────────────────────────────────────────────
function renderGraph(nodes, edges) {
// ── D3 render ─────────────────────────────────────────────────────────────────
function renderGraph(nodes, edges) {{
W = graphArea.clientWidth; H = graphArea.clientHeight;
buildMarkers(graphData ? graphData.dim_list : []);
if (sim) sim.stop();
// Fresh copies so D3 mutation doesn't corrupt graphData
const nd = nodes.map(n => ({ ...n }));
const ed = edges.map(e => ({ ...e }));
const nd = nodes.map(n => ({{ ...n }}));
const ed = edges.map(e => ({{ ...e }}));
const showHulls = document.getElementById('show-hulls').checked;
const showEdgeLabels = document.getElementById('show-edgelabels').checked;
// ── Hulls ──
// Updated on every tick; bootstrap empty so D3 has a selection to update.
function updateHulls() {{
if (!showHulls) {{ hullLayer.selectAll('*').remove(); return; }}
const groups = computeHulls(nd, ed);
// Paths
hullLayer.selectAll('path.hull')
.data(groups, g => g.dim)
.join('path').attr('class', 'hull')
.attr('d', g => hullPath(g.nodes))
.attr('fill', g => hexWithAlpha(dimColor(g.dim), 0.08))
.attr('stroke', g => dimColor(g.dim))
.attr('stroke-width', 1.8)
.attr('stroke-opacity', 0.45)
.attr('stroke-dasharray', '6 4');
// Bubble labels (dimension name at hull centroid top)
hullLayer.selectAll('text.hull-label')
.data(groups, g => g.dim)
.join('text').attr('class', 'hull-label')
.attr('x', g => centroid(g.nodes)[0])
.attr('y', g => Math.min(...g.nodes.map(n => n.y)) - HULL_PAD - 4)
.attr('text-anchor', 'middle')
.attr('fill', g => dimColor(g.dim))
.attr('font-size', '11px')
.attr('font-family', 'monospace')
.attr('font-weight', 'bold')
.attr('opacity', 0.75)
.style('pointer-events', 'none')
.text(g => `[${g.dim}]`);
}}
// ── Links ──
const link = linkLayer.selectAll('line').data(ed, e => `${e.source}:${e.target}:${e.dim}`);
const linkKey = e => `${{typeof e.source==='object'?e.source.id:e.source}}:${{typeof e.target==='object'?e.target.id:e.target}}:${{e.dim}}`;
const link = linkLayer.selectAll('line.lnk').data(ed, linkKey);
link.exit().remove();
const linkMerge = link.enter().append('line').merge(link)
const linkMerge = link.enter().append('line').attr('class','lnk').merge(link)
.attr('stroke', e => dimColor(e.dim))
.attr('stroke-opacity', 0.55)
.attr('stroke-width', e => e.is_isa ? 2 : 1.4)
.attr('marker-end', e => `url(#arr_${e.dim.replace(/[^a-zA-Z0-9]/g, '_')})`);
.attr('stroke-width', e => e.is_isa ? 2.2 : 1.5)
.attr('marker-end', e => `url(#arr_${{e.dim.replace(/[^a-zA-Z0-9]/g,'_')}})`);
// ── Edge labels ──
function updateEdgeLabels() {{
if (!showEdgeLabels) {{ edgeLblLayer.selectAll('*').remove(); return; }}
edgeLblLayer.selectAll('text.elbl')
.data(ed, linkKey)
.join('text').attr('class', 'elbl')
.attr('text-anchor', 'middle')
.attr('font-size', '9px')
.attr('font-family', 'monospace')
.attr('fill', e => dimColor(e.dim))
.attr('opacity', 0.7)
.style('pointer-events', 'none')
.text(e => e.dim);
}}
updateEdgeLabels();
// ── Nodes ──
const node = nodeLayer.selectAll('g.nd').data(nd, d => d.id);
node.exit().remove();
const nodeEnter = node.enter().append('g').attr('class', 'nd')
.call(d3.drag()
.on('start', (ev, d) => { if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (ev, d) => { d.fx = ev.x; d.fy = ev.y; })
.on('end', (ev, d) => { if (!ev.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
.on('start', (ev, d) => {{ if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }})
.on('drag', (ev, d) => {{ d.fx = ev.x; d.fy = ev.y; }})
.on('end', (ev, d) => {{ if (!ev.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }})
)
.on('click', (ev, d) => { ev.stopPropagation(); selectNode(d, nd, ed); })
.on('mouseover', (ev, d) => showTip(ev, d))
.on('click', (ev, d) => {{ ev.stopPropagation(); selectNode(d, nd, ed); }})
.on('mouseover', (ev, d) => showTip(ev, d, nd, ed))
.on('mousemove', moveTip)
.on('mouseout', hideTip);
nodeEnter.append('circle');
nodeEnter.append('text')
.style('pointer-events', 'none')
.style('user-select', 'none')
.attr('text-anchor', 'middle')
.style('font-size', '10px')
.style('fill', '#333');
.style('pointer-events', 'none').style('user-select', 'none')
.attr('text-anchor', 'middle').style('font-size', '10px').style('fill', '#222');
const nodeMerge = nodeEnter.merge(node);
nodeMerge.select('circle')
.attr('r', nodeR)
.attr('fill', d => dimColor(d.primary_dim))
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
.attr('stroke', '#fff').attr('stroke-width', 1.5)
.style('cursor', 'pointer');
nodeMerge.select('text')
.text(d => d.token)
.attr('y', d => -(nodeR(d) + 4));
// Click on background clears selection
svg.on('click', () => clearSelection());
// ── Simulation ──
const charge = Math.max(-600, -150 - nd.length * 0.8);
const charge = Math.max(-700, -200 - nd.length * 1.2);
sim = d3.forceSimulation(nd)
.force('link', d3.forceLink(ed).id(d => d.id).distance(80).strength(0.6))
.force('link', d3.forceLink(ed).id(d => d.id).distance(90).strength(0.55))
.force('charge', d3.forceManyBody().strength(charge))
.force('center', d3.forceCenter(W / 2, H / 2))
.force('collide', d3.forceCollide().radius(d => nodeR(d) + 5))
.on('tick', () => {
.force('center', d3.forceCenter(W/2, H/2))
.force('collide', d3.forceCollide().radius(d => nodeR(d) + 8))
.on('tick', () => {{
// Move link endpoints to node surface
linkMerge
.attr('x1', e => e.source.x).attr('y1', e => e.source.y)
.attr('x2', e => {
.attr('x2', e => {{
const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
const l = Math.sqrt(dx*dx + dy*dy) || 1;
return e.target.x - dx/l * (nodeR(e.target) + 2);
})
.attr('y2', e => {
const l = Math.sqrt(dx*dx+dy*dy) || 1;
return e.target.x - dx/l*(nodeR(e.target)+2);
}})
.attr('y2', e => {{
const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
const l = Math.sqrt(dx*dx + dy*dy) || 1;
return e.target.y - dy/l * (nodeR(e.target) + 2);
});
nodeMerge.attr('transform', d => `translate(${d.x},${d.y})`);
});
}
const l = Math.sqrt(dx*dx+dy*dy) || 1;
return e.target.y - dy/l*(nodeR(e.target)+2);
}});
// ─────────────────────────────────────────────────────────────────────────────
// Node selection
// ─────────────────────────────────────────────────────────────────────────────
function selectNode(d, nodes, edges) {
// Edge label at midpoint
if (showEdgeLabels) {{
edgeLblLayer.selectAll('text.elbl')
.attr('x', e => (e.source.x + e.target.x) / 2)
.attr('y', e => (e.source.y + e.target.y) / 2 - 4);
}}
nodeMerge.attr('transform', d => `translate(${{d.x}},${{d.y}})`);
updateHulls();
}});
}}
// ── Selection ─────────────────────────────────────────────────────────────────
function selectNode(d, nodes, edges) {{
document.getElementById('no-sel').style.display = 'none';
document.getElementById('node-info').style.display = 'block';
document.getElementById('ni-name').textContent = d.token;
document.getElementById('ni-sal').textContent = d.saliency.toFixed(3);
document.getElementById('ni-dims').innerHTML =
d.dims.map(dim => `<span class="dbadge" style="background:${dimColor(dim)}">${dim}</span>`).join('');
// Build neighbour list
const nbrs = edges.map(e => {
// Build neighbour list showing full triadic context
const byId = {{}};
nodes.forEach(n => byId[n.id] = n);
const outEdges = edges.filter(e => {{
const sid = typeof e.source === 'object' ? e.source.id : e.source;
return sid === d.id;
}});
const inEdges = edges.filter(e => {{
const tid = typeof e.target === 'object' ? e.target.id : e.target;
if (sid !== d.id && tid !== d.id) return null;
const nid = sid === d.id ? tid : sid;
const arrow = sid === d.id ? '\u2192' : '\u2190';
const nn = nodes.find(n => n.id === nid);
return { id: nid, token: nn ? nn.token : nid, dim: e.dim, arrow };
}).filter(Boolean);
return tid === d.id;
}});
document.getElementById('nbr-list').innerHTML = nbrs.map(n =>
`<li onclick="focusNode(${n.id})" style="color:${dimColor(n.dim)}">${n.arrow} ${n.token} <span style="color:#444;font-size:0.85em">[${n.dim}]</span></li>`
).join('');
let html = '';
if (outEdges.length) {{
html += outEdges.map(e => {{
const tid = typeof e.target === 'object' ? e.target.id : e.target;
const tn = byId[tid]; const tt = tn ? tn.token : '?';
const rel = e.is_isa ? 'is-a' : 'is-part-of';
return `<li onclick="focusNode(${{tid}})" style="color:${{dimColor(e.dim)}}">
→ ${{tt}} <span class="edge-dim">[${{e.dim}}]</span>
<br><span style="color:#555;font-size:0.82em;padding-left:8px">(${{rel}})</span>
</li>`;
}}).join('');
}}
if (inEdges.length) {{
html += `<li style="color:#444;cursor:default;padding-top:4px"><em>referenced by:</em></li>`;
html += inEdges.map(e => {{
const sid = typeof e.source === 'object' ? e.source.id : e.source;
const sn = byId[sid]; const st = sn ? sn.token : '?';
return `<li onclick="focusNode(${{sid}})" style="color:${{dimColor(e.dim)}}">
← ${{st}} <span class="edge-dim">[${{e.dim}}]</span>
</li>`;
}}).join('');
}}
if (!html) html = '<li style="color:#555;cursor:default">no connections visible</li>';
document.getElementById('nbr-list').innerHTML = html;
const allNeighborIds = new Set([
...outEdges.map(e => typeof e.target==='object' ? e.target.id : e.target),
...inEdges.map(e => typeof e.source==='object' ? e.source.id : e.source),
]);
// Dim/fade other nodes and edges
nodeLayer.selectAll('g.nd circle')
.attr('opacity', n => (n.id === d.id || nbrs.some(nb => nb.id === n.id)) ? 1 : 0.15);
.attr('opacity', n => (n.id === d.id || allNeighborIds.has(n.id)) ? 1 : 0.12);
nodeLayer.selectAll('g.nd text')
.attr('opacity', n => (n.id === d.id || nbrs.some(nb => nb.id === n.id)) ? 1 : 0.1);
linkLayer.selectAll('line').attr('opacity', e => {
const sid = typeof e.source === 'object' ? e.source.id : e.source;
const tid = typeof e.target === 'object' ? e.target.id : e.target;
return (sid === d.id || tid === d.id) ? 0.9 : 0.05;
});
}
.attr('opacity', n => (n.id === d.id || allNeighborIds.has(n.id)) ? 1 : 0.08);
linkLayer.selectAll('line.lnk').attr('opacity', e => {{
const sid = typeof e.source==='object' ? e.source.id : e.source;
const tid = typeof e.target==='object' ? e.target.id : e.target;
return (sid===d.id || tid===d.id) ? 0.9 : 0.04;
}});
edgeLblLayer.selectAll('text.elbl').attr('opacity', e => {{
const sid = typeof e.source==='object' ? e.source.id : e.source;
const tid = typeof e.target==='object' ? e.target.id : e.target;
return (sid===d.id || tid===d.id) ? 0.9 : 0.04;
}});
}}
function clearSelection() {
function clearSelection() {{
document.getElementById('no-sel').style.display = 'block';
document.getElementById('node-info').style.display = 'none';
nodeLayer.selectAll('g.nd circle').attr('opacity', 1);
nodeLayer.selectAll('g.nd text').attr('opacity', 1);
linkLayer.selectAll('line').attr('opacity', 0.55);
}
linkLayer.selectAll('line.lnk').attr('opacity', 0.55);
edgeLblLayer.selectAll('text.elbl').attr('opacity', 0.7);
}}
function focusNode(id) {
function focusNode(id) {{
const n = nodeLayer.selectAll('g.nd').filter(d => d.id === id);
if (n.empty()) return;
const d = n.datum();
svg.transition().duration(500).call(
zoom.transform,
d3.zoomIdentity.translate(W/2 - d.x, H/2 - d.y).scale(1.5)
zoom.transform, d3.zoomIdentity.translate(W/2 - d.x*1.8, H/2 - d.y*1.8).scale(1.8)
);
}
}}
// ─────────────────────────────────────────────────────────────────────────────
// Zoom helpers
// ─────────────────────────────────────────────────────────────────────────────
function zoomBy(f) { svg.transition().duration(300).call(zoom.scaleBy, f); }
function resetZoom() {
// ── Zoom ──────────────────────────────────────────────────────────────────────
function zoomBy(f) {{ svg.transition().duration(300).call(zoom.scaleBy, f); }}
function resetZoom() {{
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(W/2, H/2).scale(1));
}
}}
// ─────────────────────────────────────────────────────────────────────────────
// Tooltip
// ─────────────────────────────────────────────────────────────────────────────
// ── Tooltip ───────────────────────────────────────────────────────────────────
const tip = document.getElementById('tip');
function showTip(ev, d) {
function showTip(ev, d, nodes, edges) {{
tip.style.display = 'block';
tip.innerHTML = `<strong>${d.token}</strong><br>saliency: ${d.saliency}<br>${d.dims.join(', ')}`;
}
function moveTip(ev) { tip.style.left = (ev.pageX+14)+'px'; tip.style.top = (ev.pageY-10)+'px'; }
function hideTip() { tip.style.display = 'none'; }
const byId = {{}};
nodes.forEach(n => byId[n.id] = n);
// Outgoing edges for this node
const out = edges.filter(e => {{
const sid = typeof e.source==='object' ? e.source.id : e.source;
return sid === d.id;
}});
let edgeHtml = out.map(e => {{
const tid = typeof e.target==='object' ? e.target.id : e.target;
const tn = byId[tid]; const tt = tn ? tn.token : '?';
return `<div class="tip-edge">→ ${{tt}} <span class="tip-dim" style="color:${{dimColor(e.dim)}}">${{e.dim}}</span></div>`;
}}).join('');
if (!edgeHtml) edgeHtml = `<div class="tip-edge" style="color:#555">no outgoing edges</div>`;
tip.innerHTML = `<strong>${{d.token}}</strong><br>saliency: ${{d.saliency}}<br>${{edgeHtml}}`;
}}
function moveTip(ev) {{ tip.style.left=(ev.pageX+14)+'px'; tip.style.top=(ev.pageY-10)+'px'; }}
function hideTip() {{ tip.style.display='none'; }}
// ─────────────────────────────────────────────────────────────────────────────
// Loading state
// ─────────────────────────────────────────────────────────────────────────────
function setLoading(on) {
// ── Loading ───────────────────────────────────────────────────────────────────
function setLoading(on) {{
document.getElementById('loading').classList.toggle('hidden', !on);
}
}}
// ─────────────────────────────────────────────────────────────────────────────
// Search
// ─────────────────────────────────────────────────────────────────────────────
function searchFocus() { loadGraph(); }
function clearSearch() { document.getElementById('search-input').value = ''; loadGraph(); }
document.getElementById('search-input').addEventListener('keydown', e => { if (e.key === 'Enter') loadGraph(); });
// ── Search ────────────────────────────────────────────────────────────────────
function searchFocus() {{ loadGraph(); }}
function clearSearch() {{ document.getElementById('search-input').value=''; loadGraph(); }}
document.getElementById('search-input').addEventListener('keydown', e => {{
if (e.key === 'Enter') loadGraph();
}});
// ─────────────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────────────
loadGraph();
</script>
</body>