Better visualization

This commit is contained in:
2026-04-23 17:39:29 +02:00
parent d82bf2d899
commit 5ced96b918
+437 -284
View File
@@ -1759,128 +1759,127 @@ GRAPH_HTML = """<!DOCTYPE html>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Festinger — Knowledge Graph</title> <title>Festinger — Knowledge Graph</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * {{ box-sizing: border-box; margin: 0; padding: 0; }}
body { font-family: monospace; display: flex; height: 100vh; overflow: hidden; background: #f0f0f0; color: #222; } body {{ font-family: monospace; display: flex; height: 100vh; overflow: hidden; background: #f0f0f0; color: #222; }}
/* ── Sidebar ── */ /* ── Sidebar ── */
#sidebar { #sidebar {{
width: 260px; flex-shrink: 0; background: #1a1a2e; color: #ccc; width: 270px; flex-shrink: 0; background: #1a1a2e; color: #ccc;
display: flex; flex-direction: column; gap: 0; overflow-y: auto; display: flex; flex-direction: column; overflow-y: auto;
} }}
.sb-section { padding: 14px 16px; border-bottom: 1px solid #2a2a4e; } .sb-section {{ padding: 14px 16px; border-bottom: 1px solid #2a2a4e; }}
.sb-section:last-child { border-bottom: none; flex: 1; } .sb-section:last-child {{ border-bottom: none; flex: 1; }}
#sb-title { font-size: 1.05em; color: #fff; font-weight: bold; } #sb-title {{ font-size: 1.05em; color: #fff; font-weight: bold; }}
#sb-subtitle { font-size: 0.72em; color: #666; margin-top: 2px; } #sb-subtitle {{ font-size: 0.72em; color: #555; margin-top: 2px; }}
.back-link { font-size: 0.72em; color: #5555aa; text-decoration: none; } .back-link {{ font-size: 0.72em; color: #5555aa; text-decoration: none; }}
.back-link:hover { color: #8888ff; } .back-link:hover {{ color: #8888ff; }}
.sec-label { .sec-label {{
font-size: 0.68em; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.68em; text-transform: uppercase; letter-spacing: 0.1em;
color: #555; margin-bottom: 8px; font-weight: bold; color: #555; margin-bottom: 8px; font-weight: bold;
} }}
.stats-row {{ display: flex; gap: 8px; }}
/* Stats */ .stat-box {{ flex: 1; background: #0d0d20; border-radius: 4px; padding: 7px 10px; }}
.stats-row { display: flex; gap: 8px; } .stat-val {{ font-size: 1.25em; font-weight: bold; color: #7070ff; }}
.stat-box { flex: 1; background: #0d0d20; border-radius: 4px; padding: 7px 10px; } .stat-lbl {{ font-size: 0.68em; color: #555; text-transform: uppercase; margin-top: 1px; }}
.stat-val { font-size: 1.25em; font-weight: bold; color: #7070ff; } input[type="text"] {{
.stat-lbl { font-size: 0.68em; color: #555; text-transform: uppercase; margin-top: 1px; }
/* Inputs */
input[type="text"] {
width: 100%; padding: 6px 8px; background: #0d0d20; border: 1px solid #2a2a4e; width: 100%; padding: 6px 8px; background: #0d0d20; border: 1px solid #2a2a4e;
color: #ddd; border-radius: 3px; font-family: monospace; font-size: 0.83em; 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="text"]:focus {{ outline: 1px solid #7070ff; border-color: #7070ff; }}
input[type="range"] { width: 100%; accent-color: #7070ff; cursor: pointer; margin-top: 4px; } 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; } .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 {{ font-size: 0.8em; color: #999; display: flex; justify-content: space-between; }}
label.field-label span { color: #aaa; } 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 */ /* Selected node */
.btn-row { display: flex; gap: 6px; margin-top: 8px; } #node-info {{ display: none; }}
.btn { .ni-name {{ font-size: 1.05em; color: #fff; word-break: break-all; margin-bottom: 4px; }}
flex: 1; padding: 7px 10px; border: none; border-radius: 3px; .ni-meta {{ font-size: 0.75em; color: #666; margin-bottom: 6px; }}
cursor: pointer; font-family: monospace; font-size: 0.83em; .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; }}
.btn-primary { background: #4040cc; color: #fff; } #nbr-list li {{
.btn-primary:hover { background: #5555dd; } padding: 3px 0; font-size: 0.78em; cursor: pointer;
.btn-secondary { background: #1e1e40; color: #aaa; } border-bottom: 1px solid #1a1a3e; line-height: 1.4;
.btn-secondary:hover { background: #2a2a55; } }}
.btn-load { #nbr-list li:hover {{ color: #aaaaff; }}
width: 100%; padding: 9px; background: #4040cc; color: #fff; #nbr-list li .edge-dim {{ font-size: 0.85em; opacity: 0.7; }}
border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.87em; #no-sel {{ font-size: 0.8em; color: #444; }}
}
.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; }
/* Graph area */ /* Graph area */
#graph-area { flex: 1; position: relative; overflow: hidden; background: #f9f9f9; } #graph-area {{ flex: 1; position: relative; overflow: hidden; background: #fafafa; }}
#graph-svg { width: 100%; height: 100%; } #graph-svg {{ width: 100%; height: 100%; }}
/* Loading */ /* Loading */
#loading { #loading {{
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; 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; background: rgba(250,250,250,0.88); font-size: 1em; color: #888; z-index: 10;
} }}
#loading.hidden { display: none; } #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; } .spinner {{ display: inline-block; width: 18px; height: 18px; border: 2px solid #ccc;
@keyframes spin { to { transform: rotate(360deg); } } 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 {{
#empty-state {
position: absolute; inset: 0; display: none; align-items: center; justify-content: center; position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
flex-direction: column; gap: 10px; color: #999; flex-direction: column; gap: 10px; color: #999;
} }}
#empty-state.visible { display: flex; } #empty-state.visible {{ display: flex; }}
#empty-state p { font-size: 0.85em; } #empty-state p {{ font-size: 0.85em; }}
/* Zoom controls */ /* Zoom */
#zoom-ctrls { position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 4px; } #zoom-ctrls {{ position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 4px; }}
#zoom-ctrls button { #zoom-ctrls button {{
width: 34px; height: 34px; background: #fff; border: 1px solid #ddd; border-radius: 4px; 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); 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 */ /* Tooltip */
#tip { #tip {{
position: absolute; pointer-events: none; background: rgba(20,20,40,0.92); position: absolute; pointer-events: none; background: rgba(15,15,35,0.95);
color: #eee; padding: 7px 11px; border-radius: 5px; font-size: 0.78em; color: #eee; padding: 8px 12px; border-radius: 5px; font-size: 0.78em;
display: none; z-index: 20; max-width: 200px; line-height: 1.5; display: none; z-index: 20; max-width: 240px; line-height: 1.6;
} }}
#tip strong { color: #fff; } #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 */
#legend { #legend {{
position: absolute; top: 12px; left: 12px; display: flex; gap: 10px; position: absolute; top: 12px; left: 12px; display: flex; gap: 8px;
flex-wrap: wrap; z-index: 5; flex-wrap: wrap; z-index: 5; pointer-events: none;
} }}
.leg-item { display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,0.85); .leg-item {{
padding: 3px 8px; border-radius: 12px; font-size: 0.72em; border: 1px solid #e0e0e0; cursor: pointer; } display: flex; align-items: center; gap: 5px; background: rgba(255,255,255,0.88);
.leg-item:hover { background: #fff; } padding: 3px 10px; border-radius: 12px; font-size: 0.72em; border: 1px solid #ddd;
.leg-dot { width: 8px; height: 8px; border-radius: 50%; } 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> </style>
</head> </head>
<body> <body>
<!-- ── Sidebar ── -->
<div id="sidebar"> <div id="sidebar">
<div class="sb-section"> <div class="sb-section">
<div id="sb-title">Knowledge Graph</div> <div id="sb-title">Knowledge Graph</div>
<div id="sb-subtitle">Festinger URD explorer</div> <div id="sb-subtitle">Festinger URD explorer</div>
@@ -1891,6 +1890,7 @@ GRAPH_HTML = """<!DOCTYPE html>
<div class="stats-row"> <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-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-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>
</div> </div>
@@ -1905,8 +1905,8 @@ GRAPH_HTML = """<!DOCTYPE html>
<div class="sb-section"> <div class="sb-section">
<div class="sec-label">Filters</div> <div class="sec-label">Filters</div>
<label class="field-label">Min saliency <span id="sal-val">1.0</span></label> <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="1.0" <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)"> oninput="document.getElementById('sal-val').textContent=parseFloat(this.value).toFixed(1)">
<div class="range-vals"><span>0</span><span>6</span></div> <div class="range-vals"><span>0</span><span>6</span></div>
<div style="margin-top:10px"></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" <input type="range" id="limit-slider" min="50" max="600" step="50" value="300"
oninput="document.getElementById('limit-val').textContent=this.value"> oninput="document.getElementById('limit-val').textContent=this.value">
<div class="range-vals"><span>50</span><span>600</span></div> <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>
<div class="sb-section"> <div class="sb-section">
@@ -1927,26 +1938,23 @@ GRAPH_HTML = """<!DOCTYPE html>
<div class="sb-section" style="flex:1"> <div class="sb-section" style="flex:1">
<div class="sec-label">Selected node</div> <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 id="node-info">
<div class="ni-name" id="ni-name"></div> <div class="ni-name" id="ni-name"></div>
<div class="ni-meta">saliency: <span id="ni-sal"></span></div> <div class="ni-meta">saliency: <span id="ni-sal"></span></div>
<div class="dim-badges" id="ni-dims"></div> <div class="nbr-label">Facts (outgoing)</div>
<div class="nbr-label">Connections</div>
<ul id="nbr-list"></ul> <ul id="nbr-list"></ul>
</div> </div>
</div> </div>
</div> </div>
<!-- ── Graph area ── -->
<div id="graph-area"> <div id="graph-area">
<svg id="graph-svg"></svg> <svg id="graph-svg"></svg>
<div id="legend"></div> <div id="legend"></div>
<div id="loading"><span class="spinner"></span>Loading graph…</div> <div id="loading"><span class="spinner"></span>Loading graph…</div>
<div id="empty-state"> <div id="empty-state">
<p>No nodes match the current filters.</p> <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>
<div id="tip"></div> <div id="tip"></div>
<div id="zoom-ctrls"> <div id="zoom-ctrls">
@@ -1958,140 +1966,200 @@ GRAPH_HTML = """<!DOCTYPE html>
<script src="https://d3js.org/d3.v7.min.js"></script> <script src="https://d3js.org/d3.v7.min.js"></script>
<script> <script>
// ───────────────────────────────────────────────────────────────────────────── // ── Palette ──────────────────────────────────────────────────────────────────
// Colour palette
// ─────────────────────────────────────────────────────────────────────────────
const PALETTE = [ const PALETTE = [
'#4e79a7','#f28e2b','#59a14f','#e15759', '#4e79a7','#f28e2b','#59a14f','#e15759',
'#b07aa1','#76b7b2','#edc948','#ff9da7', '#b07aa1','#76b7b2','#edc948','#ff9da7',
'#9c755f','#d4a0c7', '#9c755f','#d4a0c7','#8cd17d','#499894',
]; ];
const dimColors = {}; const dimColors = {{}};
['type','membership','runs-on','tech','owned-by','geography'] ['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) { function dimColor(dim) {{
if (!dimColors[dim]) { if (!dimColors[dim]) {{
dimColors[dim] = PALETTE[Object.keys(dimColors).length % PALETTE.length]; const idx = Object.keys(dimColors).length % PALETTE.length;
} dimColors[dim] = PALETTE[idx];
}}
return dimColors[dim]; return dimColors[dim];
} }}
// ───────────────────────────────────────────────────────────────────────────── function hexWithAlpha(hex, a) {{
// SVG + zoom // 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 svgEl = document.getElementById('graph-svg');
const graphArea = document.getElementById('graph-area'); const graphArea = document.getElementById('graph-area');
const svg = d3.select(svgEl); const svg = d3.select(svgEl);
const root = svg.append('g'); const root = svg.append('g');
const defs = svg.append('defs'); const defs = svg.append('defs');
const linkLayer = root.append('g').attr('class', 'links');
const nodeLayer = root.append('g').attr('class', 'nodes');
const zoom = d3.zoom().scaleExtent([0.05, 12]).on('zoom', e => root.attr('transform', e.transform)); // 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.04, 14]).on('zoom', e => root.attr('transform', e.transform));
svg.call(zoom).on('dblclick.zoom', null); svg.call(zoom).on('dblclick.zoom', null);
let W = graphArea.clientWidth, H = graphArea.clientHeight; let W = graphArea.clientWidth, H = graphArea.clientHeight;
window.addEventListener('resize', () => { window.addEventListener('resize', () => {{
W = graphArea.clientWidth; H = graphArea.clientHeight; 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 graphData = null;
let sim = 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 ─────────────────────────────────────────────────────────────
// Arrow markers (one per dimension, re-built when dim list changes) function buildMarkers(dims) {{
// ─────────────────────────────────────────────────────────────────────────────
function buildMarkers(dims) {
defs.selectAll('marker').remove(); defs.selectAll('marker').remove();
dims.forEach(dim => { dims.forEach(dim => {{
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_'); const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
defs.append('marker') defs.append('marker')
.attr('id', 'arr_' + safe) .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') .attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
.append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', dimColor(dim)); .append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', dimColor(dim));
}); }});
} }}
// ───────────────────────────────────────────────────────────────────────────── // ── Hull computation ──────────────────────────────────────────────────────────
// Load data from server // Group by dimension: each dim bubble encloses all concepts + parents in that dim.
// ───────────────────────────────────────────────────────────────────────────── function computeHulls(nd, ed) {{
async function loadGraph() { 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); setLoading(true);
clearSelection(); clearSelection();
const minSal = document.getElementById('sal-slider').value; const minSal = document.getElementById('sal-slider').value;
const limit = document.getElementById('limit-slider').value; const limit = document.getElementById('limit-slider').value;
const center = document.getElementById('search-input').value.trim(); 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); if (center) params.set('center', center);
try { try {{
const resp = await fetch('/graph/data?' + params); const resp = await fetch('/graph/data?' + params);
graphData = await resp.json(); graphData = await resp.json();
buildDimCheckboxes(graphData.dim_list); buildDimCheckboxes(graphData.dim_list);
buildLegend(graphData.dim_list); buildLegend(graphData.dim_list);
renderWithFilters(); renderWithFilters();
} catch (err) { }} catch(err) {{
console.error(err); console.error(err);
} finally { }} finally {{
setLoading(false); setLoading(false);
} }}
} }}
// ───────────────────────────────────────────────────────────────────────────── // ── Dimension checkboxes ──────────────────────────────────────────────────────
// Dimension checkboxes function buildDimCheckboxes(dims) {{
// ─────────────────────────────────────────────────────────────────────────────
function buildDimCheckboxes(dims) {
const el = document.getElementById('dim-list'); const el = document.getElementById('dim-list');
const existing = new Set([...el.querySelectorAll('.dchk')].map(c => c.value)); const existing = new Set([...el.querySelectorAll('.dchk')].map(c => c.value));
dims.forEach(dim => { dims.forEach(dim => {{
if (existing.has(dim)) return; if (existing.has(dim)) return;
const label = document.createElement('label'); const label = document.createElement('label');
label.className = 'dim-item'; label.className = 'dim-item';
label.innerHTML = label.innerHTML =
`<input type="checkbox" class="dchk" value="${dim}" checked>` + `<input type="checkbox" class="dchk" value="${{dim}}" checked>` +
`<span class="dim-dot" style="background:${dimColor(dim)}"></span>` + `<span class="dim-dot" style="background:${{dimColor(dim)}}"></span>` +
`<span>${dim}</span>`; `<span>${{dim}}</span>`;
label.querySelector('input').addEventListener('change', renderWithFilters); label.querySelector('input').addEventListener('change', renderWithFilters);
el.appendChild(label); el.appendChild(label);
}); }});
} }}
function enabledDims() { function enabledDims() {{
return new Set([...document.querySelectorAll('.dchk:checked')].map(c => c.value)); return new Set([...document.querySelectorAll('.dchk:checked')].map(c => c.value));
} }}
// ───────────────────────────────────────────────────────────────────────────── // ── Legend ────────────────────────────────────────────────────────────────────
// Legend function buildLegend(dims) {{
// ─────────────────────────────────────────────────────────────────────────────
function buildLegend(dims) {
const el = document.getElementById('legend'); const el = document.getElementById('legend');
el.innerHTML = dims.map(dim => { el.innerHTML = dims.map(dim => {{
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_'); const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
return `<span class="leg-item" onclick="toggleDim('${dim}')" id="leg_${safe}"> return `<span class="leg-item" id="leg_${{safe}}" onclick="toggleDim('${{dim}}')">
<span class="leg-dot" style="background:${dimColor(dim)}"></span>${dim} <span class="leg-dot" style="background:${{dimColor(dim)}}"></span>${{dim}}
</span>`; </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 safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
const cb = document.querySelector(`.dchk[value="${dim}"]`); const leg = document.getElementById('leg_' + safe);
if (cb) { cb.checked = !cb.checked; renderWithFilters(); } if (leg) leg.classList.toggle('dimmed', !cb.checked);
} }}
// ───────────────────────────────────────────────────────────────────────────── // ── Filter + render ───────────────────────────────────────────────────────────
// Filter and render function renderWithFilters() {{
// ─────────────────────────────────────────────────────────────────────────────
function renderWithFilters() {
if (!graphData) return; if (!graphData) return;
const allowed = enabledDims(); const allowed = enabledDims();
const edges = graphData.edges.filter(e => allowed.has(e.dim)); 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-nodes').textContent = nodes.length.toLocaleString();
document.getElementById('s-edges').textContent = edges.length.toLocaleString(); document.getElementById('s-edges').textContent = edges.length.toLocaleString();
document.getElementById('s-dims').textContent = allowed.size;
const empty = document.getElementById('empty-state'); 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'); empty.classList.remove('visible');
renderGraph(nodes, edges); renderGraph(nodes, edges);
} }}
// ───────────────────────────────────────────────────────────────────────────── // ── D3 render ─────────────────────────────────────────────────────────────────
// D3 render function renderGraph(nodes, edges) {{
// ─────────────────────────────────────────────────────────────────────────────
function renderGraph(nodes, edges) {
W = graphArea.clientWidth; H = graphArea.clientHeight; W = graphArea.clientWidth; H = graphArea.clientHeight;
buildMarkers(graphData ? graphData.dim_list : []); buildMarkers(graphData ? graphData.dim_list : []);
if (sim) sim.stop(); if (sim) sim.stop();
// Fresh copies so D3 mutation doesn't corrupt graphData const nd = nodes.map(n => ({{ ...n }}));
const nd = nodes.map(n => ({ ...n })); const ed = edges.map(e => ({{ ...e }}));
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 ── // ── 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(); 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', e => dimColor(e.dim))
.attr('stroke-opacity', 0.55) .attr('stroke-opacity', 0.55)
.attr('stroke-width', e => e.is_isa ? 2 : 1.4) .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, '_')})`); .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 ── // ── Nodes ──
const node = nodeLayer.selectAll('g.nd').data(nd, d => d.id); const node = nodeLayer.selectAll('g.nd').data(nd, d => d.id);
node.exit().remove(); node.exit().remove();
const nodeEnter = node.enter().append('g').attr('class', 'nd') const nodeEnter = node.enter().append('g').attr('class', 'nd')
.call(d3.drag() .call(d3.drag()
.on('start', (ev, d) => { if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .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('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('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('click', (ev, d) => {{ ev.stopPropagation(); selectNode(d, nd, ed); }})
.on('mouseover', (ev, d) => showTip(ev, d)) .on('mouseover', (ev, d) => showTip(ev, d, nd, ed))
.on('mousemove', moveTip) .on('mousemove', moveTip)
.on('mouseout', hideTip); .on('mouseout', hideTip);
nodeEnter.append('circle'); nodeEnter.append('circle');
nodeEnter.append('text') nodeEnter.append('text')
.style('pointer-events', 'none') .style('pointer-events', 'none').style('user-select', 'none')
.style('user-select', 'none') .attr('text-anchor', 'middle').style('font-size', '10px').style('fill', '#222');
.attr('text-anchor', 'middle')
.style('font-size', '10px')
.style('fill', '#333');
const nodeMerge = nodeEnter.merge(node); const nodeMerge = nodeEnter.merge(node);
nodeMerge.select('circle') nodeMerge.select('circle')
.attr('r', nodeR) .attr('r', nodeR)
.attr('fill', d => dimColor(d.primary_dim)) .attr('fill', d => dimColor(d.primary_dim))
.attr('stroke', '#fff') .attr('stroke', '#fff').attr('stroke-width', 1.5)
.attr('stroke-width', 1.5)
.style('cursor', 'pointer'); .style('cursor', 'pointer');
nodeMerge.select('text') nodeMerge.select('text')
.text(d => d.token) .text(d => d.token)
.attr('y', d => -(nodeR(d) + 4)); .attr('y', d => -(nodeR(d) + 4));
// Click on background clears selection
svg.on('click', () => clearSelection()); svg.on('click', () => clearSelection());
// ── Simulation ── // ── 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) 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('charge', d3.forceManyBody().strength(charge))
.force('center', d3.forceCenter(W / 2, H / 2)) .force('center', d3.forceCenter(W/2, H/2))
.force('collide', d3.forceCollide().radius(d => nodeR(d) + 5)) .force('collide', d3.forceCollide().radius(d => nodeR(d) + 8))
.on('tick', () => { .on('tick', () => {{
// Move link endpoints to node surface
linkMerge linkMerge
.attr('x1', e => e.source.x).attr('y1', e => e.source.y) .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 dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
const l = Math.sqrt(dx*dx + dy*dy) || 1; const l = Math.sqrt(dx*dx+dy*dy) || 1;
return e.target.x - dx/l * (nodeR(e.target) + 2); return e.target.x - dx/l*(nodeR(e.target)+2);
}) }})
.attr('y2', e => { .attr('y2', e => {{
const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y; const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
const l = Math.sqrt(dx*dx + dy*dy) || 1; const l = Math.sqrt(dx*dx+dy*dy) || 1;
return e.target.y - dy/l * (nodeR(e.target) + 2); return e.target.y - dy/l*(nodeR(e.target)+2);
}); }});
nodeMerge.attr('transform', d => `translate(${d.x},${d.y})`);
});
}
// ───────────────────────────────────────────────────────────────────────────── // Edge label at midpoint
// Node selection if (showEdgeLabels) {{
// ───────────────────────────────────────────────────────────────────────────── edgeLblLayer.selectAll('text.elbl')
function selectNode(d, nodes, edges) { .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('no-sel').style.display = 'none';
document.getElementById('node-info').style.display = 'block'; document.getElementById('node-info').style.display = 'block';
document.getElementById('ni-name').textContent = d.token; document.getElementById('ni-name').textContent = d.token;
document.getElementById('ni-sal').textContent = d.saliency.toFixed(3); 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 // Build neighbour list showing full triadic context
const nbrs = edges.map(e => { 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; 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; const tid = typeof e.target === 'object' ? e.target.id : e.target;
if (sid !== d.id && tid !== d.id) return null; return tid === d.id;
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);
document.getElementById('nbr-list').innerHTML = nbrs.map(n => let html = '';
`<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>` if (outEdges.length) {{
).join(''); 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') 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') nodeLayer.selectAll('g.nd text')
.attr('opacity', n => (n.id === d.id || nbrs.some(nb => nb.id === n.id)) ? 1 : 0.1); .attr('opacity', n => (n.id === d.id || allNeighborIds.has(n.id)) ? 1 : 0.08);
linkLayer.selectAll('line').attr('opacity', e => { linkLayer.selectAll('line.lnk').attr('opacity', e => {{
const sid = typeof e.source === 'object' ? e.source.id : e.source; const sid = typeof e.source==='object' ? e.source.id : e.source;
const tid = typeof e.target === 'object' ? e.target.id : e.target; const tid = typeof e.target==='object' ? e.target.id : e.target;
return (sid === d.id || tid === d.id) ? 0.9 : 0.05; 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('no-sel').style.display = 'block';
document.getElementById('node-info').style.display = 'none'; document.getElementById('node-info').style.display = 'none';
nodeLayer.selectAll('g.nd circle').attr('opacity', 1); nodeLayer.selectAll('g.nd circle').attr('opacity', 1);
nodeLayer.selectAll('g.nd text').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); const n = nodeLayer.selectAll('g.nd').filter(d => d.id === id);
if (n.empty()) return; if (n.empty()) return;
const d = n.datum(); const d = n.datum();
svg.transition().duration(500).call( svg.transition().duration(500).call(
zoom.transform, zoom.transform, d3.zoomIdentity.translate(W/2 - d.x*1.8, H/2 - d.y*1.8).scale(1.8)
d3.zoomIdentity.translate(W/2 - d.x, H/2 - d.y).scale(1.5)
); );
} }}
// ───────────────────────────────────────────────────────────────────────────── // ── Zoom ──────────────────────────────────────────────────────────────────────
// Zoom helpers function zoomBy(f) {{ svg.transition().duration(300).call(zoom.scaleBy, f); }}
// ───────────────────────────────────────────────────────────────────────────── function resetZoom() {{
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)); svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(W/2, H/2).scale(1));
} }}
// ───────────────────────────────────────────────────────────────────────────── // ── Tooltip ───────────────────────────────────────────────────────────────────
// Tooltip
// ─────────────────────────────────────────────────────────────────────────────
const tip = document.getElementById('tip'); const tip = document.getElementById('tip');
function showTip(ev, d) { function showTip(ev, d, nodes, edges) {{
tip.style.display = 'block'; tip.style.display = 'block';
tip.innerHTML = `<strong>${d.token}</strong><br>saliency: ${d.saliency}<br>${d.dims.join(', ')}`; const byId = {{}};
} nodes.forEach(n => byId[n.id] = n);
function moveTip(ev) { tip.style.left = (ev.pageX+14)+'px'; tip.style.top = (ev.pageY-10)+'px'; } // Outgoing edges for this node
function hideTip() { tip.style.display = 'none'; } 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 ───────────────────────────────────────────────────────────────────
// Loading state function setLoading(on) {{
// ─────────────────────────────────────────────────────────────────────────────
function setLoading(on) {
document.getElementById('loading').classList.toggle('hidden', !on); document.getElementById('loading').classList.toggle('hidden', !on);
} }}
// ───────────────────────────────────────────────────────────────────────────── // ── Search ────────────────────────────────────────────────────────────────────
// Search function searchFocus() {{ loadGraph(); }}
// ───────────────────────────────────────────────────────────────────────────── function clearSearch() {{ document.getElementById('search-input').value=''; loadGraph(); }}
function searchFocus() { loadGraph(); } document.getElementById('search-input').addEventListener('keydown', e => {{
function clearSearch() { document.getElementById('search-input').value = ''; loadGraph(); } if (e.key === 'Enter') loadGraph();
document.getElementById('search-input').addEventListener('keydown', e => { if (e.key === 'Enter') loadGraph(); }); }});
// ─────────────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────────────
loadGraph(); loadGraph();
</script> </script>
</body> </body>