Better visualization
This commit is contained in:
+432
-279
@@ -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');
|
||||||
|
|
||||||
|
// 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 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 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);
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user