PlanOpticon

planopticon / knowledge-base / viewer.html
Source Blame History 406 lines
0981a08… noreply 1 <!DOCTYPE html>
0981a08… noreply 2 <html lang="en">
0981a08… noreply 3 <head>
0981a08… noreply 4 <meta charset="UTF-8">
0981a08… noreply 5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
0981a08… noreply 6 <title>PlanOpticon Knowledge Graph Viewer</title>
0981a08… noreply 7 <script src="https://d3js.org/d3.v7.min.js"></script>
0981a08… noreply 8 <style>
0981a08… noreply 9 * { margin: 0; padding: 0; box-sizing: border-box; }
0981a08… noreply 10 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; overflow: hidden; height: 100vh; }
0981a08… noreply 11 #toolbar { position: fixed; top: 0; left: 0; right: 0; z-index: 10; background: #16213e; padding: 8px 16px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #0f3460; flex-wrap: wrap; }
0981a08… noreply 12 #toolbar h1 { font-size: 14px; font-weight: 600; color: #e94560; white-space: nowrap; }
0981a08… noreply 13 #search { background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0; padding: 5px 10px; border-radius: 4px; font-size: 13px; width: 200px; }
0981a08… noreply 14 #search::placeholder { color: #666; }
0981a08… noreply 15 .filter-btn { background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0; padding: 4px 10px; border-radius: 12px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 4px; }
0981a08… noreply 16 .filter-btn.active { border-color: #e94560; }
0981a08… noreply 17 .filter-btn .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
0981a08… noreply 18 #drop-zone { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 20; background: #16213e; border: 2px dashed #0f3460; border-radius: 12px; padding: 60px; text-align: center; }
0981a08… noreply 19 #drop-zone.hidden { display: none; }
0981a08… noreply 20 #drop-zone h2 { color: #e94560; margin-bottom: 12px; }
0981a08… noreply 21 #drop-zone p { color: #888; margin-bottom: 16px; font-size: 14px; }
0981a08… noreply 22 #file-input { display: none; }
0981a08… noreply 23 #drop-zone label { background: #e94560; color: white; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; }
0981a08… noreply 24 #drop-zone label:hover { background: #c73e54; }
0981a08… noreply 25 #graph-container { width: 100%; height: 100vh; padding-top: 44px; }
0981a08… noreply 26 svg { width: 100%; height: 100%; }
0981a08… noreply 27 .node circle { stroke: #333; stroke-width: 1.5px; cursor: pointer; }
0981a08… noreply 28 .node text { font-size: 10px; fill: #e0e0e0; pointer-events: none; text-anchor: middle; }
0981a08… noreply 29 .link { stroke: #334; stroke-opacity: 0.6; }
0981a08… noreply 30 .link-label { font-size: 8px; fill: #666; pointer-events: none; }
0981a08… noreply 31 .node.highlighted circle { stroke: #e94560; stroke-width: 3px; }
0981a08… noreply 32 .node.dimmed { opacity: 0.15; }
0981a08… noreply 33 .link.dimmed { opacity: 0.05; }
0981a08… noreply 34 #detail-panel { position: fixed; top: 44px; right: 0; width: 300px; height: calc(100vh - 44px); background: #16213e; border-left: 1px solid #0f3460; padding: 16px; overflow-y: auto; transform: translateX(100%); transition: transform 0.2s; z-index: 10; }
0981a08… noreply 35 #detail-panel.open { transform: translateX(0); }
0981a08… noreply 36 #detail-panel h3 { color: #e94560; margin-bottom: 4px; font-size: 16px; }
0981a08… noreply 37 #detail-panel .type-badge { display: inline-block; padding: 2px 8px; border-radius: 8px; font-size: 11px; margin-bottom: 12px; }
0981a08… noreply 38 #detail-panel .section { margin-bottom: 14px; }
0981a08… noreply 39 #detail-panel .section h4 { font-size: 12px; color: #888; text-transform: uppercase; margin-bottom: 6px; }
0981a08… noreply 40 #detail-panel .section p, #detail-panel .section li { font-size: 13px; line-height: 1.5; }
0981a08… noreply 41 #detail-panel ul { list-style: none; padding: 0; }
0981a08… noreply 42 #detail-panel ul li { padding: 3px 0; border-bottom: 1px solid #0f3460; }
0981a08… noreply 43 #detail-panel .close-btn { position: absolute; top: 12px; right: 12px; background: none; border: none; color: #888; cursor: pointer; font-size: 18px; }
0981a08… noreply 44 #stats { font-size: 11px; color: #666; white-space: nowrap; }
0981a08… noreply 45 .drag-over #drop-zone { border-color: #e94560; background: #1a1a3e; }
0981a08… noreply 46 #no-d3-msg { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #16213e; padding: 40px; border-radius: 12px; text-align: center; border: 1px solid #e94560; z-index: 100; }
0981a08… noreply 47 #no-d3-msg h2 { color: #e94560; margin-bottom: 8px; }
0981a08… noreply 48 </style>
0981a08… noreply 49 </head>
0981a08… noreply 50 <body>
0981a08… noreply 51 <div id="no-d3-msg">
0981a08… noreply 52 <h2>D3.js Required</h2>
0981a08… noreply 53 <p>This viewer requires an internet connection on first load to fetch D3.js.</p>
0981a08… noreply 54 <p style="color:#888; font-size:13px;">Load from: https://d3js.org/d3.v7.min.js</p>
0981a08… noreply 55 </div>
0981a08… noreply 56
0981a08… noreply 57 <div id="toolbar">
0981a08… noreply 58 <h1>PlanOpticon</h1>
0981a08… noreply 59 <input type="text" id="search" placeholder="Search entities...">
0981a08… noreply 60 <button class="filter-btn active" data-type="person"><span class="dot" style="background:#f9d5e5"></span> Person</button>
0981a08… noreply 61 <button class="filter-btn active" data-type="concept"><span class="dot" style="background:#eeeeee"></span> Concept</button>
0981a08… noreply 62 <button class="filter-btn active" data-type="technology"><span class="dot" style="background:#d5e5f9"></span> Technology</button>
0981a08… noreply 63 <button class="filter-btn active" data-type="organization"><span class="dot" style="background:#f9e5d5"></span> Organization</button>
0981a08… noreply 64 <span id="stats"></span>
0981a08… noreply 65 </div>
0981a08… noreply 66
0981a08… noreply 67 <div id="drop-zone">
0981a08… noreply 68 <h2>Load Knowledge Graph</h2>
0981a08… noreply 69 <p>Drag and drop a knowledge_graph.json file here, or click to browse.</p>
0981a08… noreply 70 <label for="file-input">Choose File</label>
0981a08… noreply 71 <input type="file" id="file-input" accept=".json">
0981a08… noreply 72 </div>
0981a08… noreply 73
0981a08… noreply 74 <div id="graph-container">
0981a08… noreply 75 <svg id="graph-svg"></svg>
0981a08… noreply 76 </div>
0981a08… noreply 77
0981a08… noreply 78 <div id="detail-panel">
0981a08… noreply 79 <button class="close-btn" id="close-detail">&times;</button>
0981a08… noreply 80 <h3 id="detail-name"></h3>
0981a08… noreply 81 <div class="type-badge" id="detail-type"></div>
0981a08… noreply 82 <div class="section" id="desc-section">
0981a08… noreply 83 <h4>Descriptions</h4>
0981a08… noreply 84 <ul id="detail-descriptions"></ul>
0981a08… noreply 85 </div>
0981a08… noreply 86 <div class="section" id="conn-section">
0981a08… noreply 87 <h4>Connections</h4>
0981a08… noreply 88 <ul id="detail-connections"></ul>
0981a08… noreply 89 </div>
0981a08… noreply 90 </div>
0981a08… noreply 91
0981a08… noreply 92 <script>
0981a08… noreply 93 (function() {
0981a08… noreply 94 // Check D3 loaded
0981a08… noreply 95 if (typeof d3 === 'undefined') {
0981a08… noreply 96 document.getElementById('no-d3-msg').style.display = 'block';
0981a08… noreply 97 return;
0981a08… noreply 98 }
0981a08… noreply 99
0981a08… noreply 100 const TYPE_COLORS = {
0981a08… noreply 101 person: '#f9d5e5',
0981a08… noreply 102 concept: '#eeeeee',
0981a08… noreply 103 technology: '#d5e5f9',
0981a08… noreply 104 organization: '#f9e5d5',
0981a08… noreply 105 time: '#e5d5f9',
0981a08… noreply 106 diagram: '#d5f9e5'
0981a08… noreply 107 };
0981a08… noreply 108 const DEFAULT_COLOR = '#cccccc';
0981a08… noreply 109
0981a08… noreply 110 let graphData = null; // { nodes: [], relationships: [] }
0981a08… noreply 111 let simulation = null;
0981a08… noreply 112 let nodesG, linksG, labelsG;
0981a08… noreply 113 let activeTypes = new Set(['person', 'concept', 'technology', 'organization', 'time', 'diagram']);
0981a08… noreply 114
0981a08… noreply 115 // --- File loading ---
0981a08… noreply 116 const dropZone = document.getElementById('drop-zone');
0981a08… noreply 117 const fileInput = document.getElementById('file-input');
0981a08… noreply 118
0981a08… noreply 119 dropZone.addEventListener('dragover', e => { e.preventDefault(); document.body.classList.add('drag-over'); });
0981a08… noreply 120 dropZone.addEventListener('dragleave', () => document.body.classList.remove('drag-over'));
0981a08… noreply 121 dropZone.addEventListener('drop', e => {
0981a08… noreply 122 e.preventDefault();
0981a08… noreply 123 document.body.classList.remove('drag-over');
0981a08… noreply 124 const file = e.dataTransfer.files[0];
0981a08… noreply 125 if (file) loadFile(file);
0981a08… noreply 126 });
0981a08… noreply 127 fileInput.addEventListener('change', e => { if (e.target.files[0]) loadFile(e.target.files[0]); });
0981a08… noreply 128
0981a08… noreply 129 function loadFile(file) {
0981a08… noreply 130 const reader = new FileReader();
0981a08… noreply 131 reader.onload = e => {
0981a08… noreply 132 try {
0981a08… noreply 133 const data = JSON.parse(e.target.result);
0981a08… noreply 134 initGraph(data);
0981a08… noreply 135 } catch (err) {
0981a08… noreply 136 alert('Invalid JSON file: ' + err.message);
0981a08… noreply 137 }
0981a08… noreply 138 };
0981a08… noreply 139 reader.readAsText(file);
0981a08… noreply 140 }
0981a08… noreply 141
0981a08… noreply 142 // --- Pre-embedded data support ---
0981a08… noreply 143 if (window.__PLANOPTICON_DATA__) {
0981a08… noreply 144 window.addEventListener('DOMContentLoaded', () => initGraph(window.__PLANOPTICON_DATA__));
0981a08… noreply 145 }
0981a08… noreply 146 // Also check after script runs (if DOM already ready)
0981a08… noreply 147 if (document.readyState !== 'loading' && window.__PLANOPTICON_DATA__) {
0981a08… noreply 148 initGraph(window.__PLANOPTICON_DATA__);
0981a08… noreply 149 }
0981a08… noreply 150
0981a08… noreply 151 // --- Graph init ---
0981a08… noreply 152 function initGraph(raw) {
0981a08… noreply 153 dropZone.classList.add('hidden');
0981a08… noreply 154 graphData = normalize(raw);
0981a08… noreply 155 render();
0981a08… noreply 156 }
0981a08… noreply 157
0981a08… noreply 158 function normalize(raw) {
0981a08… noreply 159 // Accept both { nodes: [], relationships: [] } and { entities: [], relationships: [] }
0981a08… noreply 160 const rawNodes = raw.nodes || raw.entities || [];
0981a08… noreply 161 const rawRels = raw.relationships || raw.edges || [];
0981a08… noreply 162
0981a08… noreply 163 const nodes = rawNodes.map(n => ({
0981a08… noreply 164 id: n.name || n.id,
0981a08… noreply 165 name: n.name || n.id,
0981a08… noreply 166 type: (n.type || 'concept').toLowerCase(),
0981a08… noreply 167 descriptions: n.descriptions || (n.description ? [n.description] : []),
0981a08… noreply 168 occurrences: n.occurrences || []
0981a08… noreply 169 }));
0981a08… noreply 170
0981a08… noreply 171 const nodeSet = new Set(nodes.map(n => n.id));
0981a08… noreply 172
0981a08… noreply 173 const links = rawRels
0981a08… noreply 174 .filter(r => nodeSet.has(r.source) && nodeSet.has(r.target))
0981a08… noreply 175 .map(r => ({
0981a08… noreply 176 source: r.source,
0981a08… noreply 177 target: r.target,
0981a08… noreply 178 type: r.type || 'related_to'
0981a08… noreply 179 }));
0981a08… noreply 180
0981a08… noreply 181 // Compute connection counts
0981a08… noreply 182 const connCount = {};
0981a08… noreply 183 links.forEach(l => {
0981a08… noreply 184 const s = typeof l.source === 'object' ? l.source.id : l.source;
0981a08… noreply 185 const t = typeof l.target === 'object' ? l.target.id : l.target;
0981a08… noreply 186 connCount[s] = (connCount[s] || 0) + 1;
0981a08… noreply 187 connCount[t] = (connCount[t] || 0) + 1;
0981a08… noreply 188 });
0981a08… noreply 189 nodes.forEach(n => { n.connections = connCount[n.id] || 0; });
0981a08… noreply 190
0981a08… noreply 191 return { nodes, links };
0981a08… noreply 192 }
0981a08… noreply 193
0981a08… noreply 194 function render() {
0981a08… noreply 195 const svg = d3.select('#graph-svg');
0981a08… noreply 196 svg.selectAll('*').remove();
0981a08… noreply 197
0981a08… noreply 198 const width = window.innerWidth;
0981a08… noreply 199 const height = window.innerHeight - 44;
0981a08… noreply 200
0981a08… noreply 201 const g = svg.append('g');
0981a08… noreply 202
0981a08… noreply 203 // Zoom
0981a08… noreply 204 const zoom = d3.zoom()
0981a08… noreply 205 .scaleExtent([0.1, 8])
0981a08… noreply 206 .on('zoom', e => g.attr('transform', e.transform));
0981a08… noreply 207 svg.call(zoom);
0981a08… noreply 208
0981a08… noreply 209 // Filter nodes/links by active types
0981a08… noreply 210 const visibleNodes = graphData.nodes.filter(n => activeTypes.has(n.type));
0981a08… noreply 211 const visibleIds = new Set(visibleNodes.map(n => n.id));
0981a08… noreply 212 const visibleLinks = graphData.links.filter(l => {
0981a08… noreply 213 const s = typeof l.source === 'object' ? l.source.id : l.source;
0981a08… noreply 214 const t = typeof l.target === 'object' ? l.target.id : l.target;
0981a08… noreply 215 return visibleIds.has(s) && visibleIds.has(t);
0981a08… noreply 216 });
0981a08… noreply 217
0981a08… noreply 218 // Stats
0981a08… noreply 219 document.getElementById('stats').textContent = visibleNodes.length + ' nodes, ' + visibleLinks.length + ' edges';
0981a08… noreply 220
0981a08… noreply 221 // Links
0981a08… noreply 222 linksG = g.append('g').selectAll('line')
0981a08… noreply 223 .data(visibleLinks)
0981a08… noreply 224 .join('line')
0981a08… noreply 225 .attr('class', 'link')
0981a08… noreply 226 .attr('stroke-width', 1);
0981a08… noreply 227
0981a08… noreply 228 // Link labels
0981a08… noreply 229 labelsG = g.append('g').selectAll('text')
0981a08… noreply 230 .data(visibleLinks)
0981a08… noreply 231 .join('text')
0981a08… noreply 232 .attr('class', 'link-label')
0981a08… noreply 233 .text(d => d.type);
0981a08… noreply 234
0981a08… noreply 235 // Nodes
0981a08… noreply 236 const maxConn = Math.max(1, d3.max(visibleNodes, d => d.connections));
0981a08… noreply 237 const radiusScale = d3.scaleSqrt().domain([0, maxConn]).range([5, 24]);
0981a08… noreply 238
0981a08… noreply 239 nodesG = g.append('g').selectAll('g')
0981a08… noreply 240 .data(visibleNodes)
0981a08… noreply 241 .join('g')
0981a08… noreply 242 .attr('class', 'node')
0981a08… noreply 243 .call(d3.drag()
0981a08… noreply 244 .on('start', dragStart)
0981a08… noreply 245 .on('drag', dragged)
0981a08… noreply 246 .on('end', dragEnd))
0981a08… noreply 247 .on('click', (e, d) => showDetail(d));
0981a08… noreply 248
0981a08… noreply 249 nodesG.append('circle')
0981a08… noreply 250 .attr('r', d => radiusScale(d.connections))
0981a08… noreply 251 .attr('fill', d => TYPE_COLORS[d.type] || DEFAULT_COLOR);
0981a08… noreply 252
0981a08… noreply 253 nodesG.append('text')
0981a08… noreply 254 .attr('dy', d => radiusScale(d.connections) + 12)
0981a08… noreply 255 .text(d => d.name.length > 20 ? d.name.slice(0, 18) + '..' : d.name);
0981a08… noreply 256
0981a08… noreply 257 // Simulation
0981a08… noreply 258 simulation = d3.forceSimulation(visibleNodes)
0981a08… noreply 259 .force('link', d3.forceLink(visibleLinks).id(d => d.id).distance(80))
0981a08… noreply 260 .force('charge', d3.forceManyBody().strength(-200))
0981a08… noreply 261 .force('center', d3.forceCenter(width / 2, height / 2))
0981a08… noreply 262 .force('collision', d3.forceCollide().radius(d => radiusScale(d.connections) + 4))
0981a08… noreply 263 .on('tick', () => {
0981a08… noreply 264 linksG
0981a08… noreply 265 .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
0981a08… noreply 266 .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
0981a08… noreply 267 labelsG
0981a08… noreply 268 .attr('x', d => (d.source.x + d.target.x) / 2)
0981a08… noreply 269 .attr('y', d => (d.source.y + d.target.y) / 2);
0981a08… noreply 270 nodesG.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
0981a08… noreply 271 });
0981a08… noreply 272
0981a08… noreply 273 // Drag handlers
0981a08… noreply 274 function dragStart(e, d) {
0981a08… noreply 275 if (!e.active) simulation.alphaTarget(0.3).restart();
0981a08… noreply 276 d.fx = d.x; d.fy = d.y;
0981a08… noreply 277 }
0981a08… noreply 278 function dragged(e, d) { d.fx = e.x; d.fy = e.y; }
0981a08… noreply 279 function dragEnd(e, d) {
0981a08… noreply 280 if (!e.active) simulation.alphaTarget(0);
0981a08… noreply 281 d.fx = null; d.fy = null;
0981a08… noreply 282 }
0981a08… noreply 283
0981a08… noreply 284 // Store zoom for centering
0981a08… noreply 285 svg._zoom = zoom;
0981a08… noreply 286 svg._g = g;
0981a08… noreply 287 }
0981a08… noreply 288
0981a08… noreply 289 // --- Search ---
0981a08… noreply 290 const searchInput = document.getElementById('search');
0981a08… noreply 291 searchInput.addEventListener('input', () => {
0981a08… noreply 292 const q = searchInput.value.toLowerCase().trim();
0981a08… noreply 293 if (!graphData || !nodesG) return;
0981a08… noreply 294
0981a08… noreply 295 if (!q) {
0981a08… noreply 296 nodesG.classed('highlighted', false).classed('dimmed', false);
0981a08… noreply 297 linksG.classed('dimmed', false);
0981a08… noreply 298 return;
0981a08… noreply 299 }
0981a08… noreply 300
0981a08… noreply 301 const matches = new Set();
0981a08… noreply 302 graphData.nodes.forEach(n => {
0981a08… noreply 303 if (n.name.toLowerCase().includes(q)) matches.add(n.id);
0981a08… noreply 304 });
0981a08… noreply 305
0981a08… noreply 306 // Also include direct neighbors of matches
0981a08… noreply 307 const neighbors = new Set(matches);
0981a08… noreply 308 graphData.links.forEach(l => {
0981a08… noreply 309 const s = typeof l.source === 'object' ? l.source.id : l.source;
0981a08… noreply 310 const t = typeof l.target === 'object' ? l.target.id : l.target;
0981a08… noreply 311 if (matches.has(s)) neighbors.add(t);
0981a08… noreply 312 if (matches.has(t)) neighbors.add(s);
0981a08… noreply 313 });
0981a08… noreply 314
0981a08… noreply 315 nodesG.classed('highlighted', d => matches.has(d.id));
0981a08… noreply 316 nodesG.classed('dimmed', d => !neighbors.has(d.id));
0981a08… noreply 317 linksG.classed('dimmed', d => {
0981a08… noreply 318 const s = typeof d.source === 'object' ? d.source.id : d.source;
0981a08… noreply 319 const t = typeof d.target === 'object' ? d.target.id : d.target;
0981a08… noreply 320 return !neighbors.has(s) || !neighbors.has(t);
0981a08… noreply 321 });
0981a08… noreply 322
0981a08… noreply 323 // Center on first match
0981a08… noreply 324 if (matches.size > 0) {
0981a08… noreply 325 const first = graphData.nodes.find(n => matches.has(n.id));
0981a08… noreply 326 if (first && first.x != null) {
0981a08… noreply 327 const svg = d3.select('#graph-svg');
0981a08… noreply 328 const width = window.innerWidth;
0981a08… noreply 329 const height = window.innerHeight - 44;
0981a08… noreply 330 svg.transition().duration(500).call(
0981a08… noreply 331 svg._zoom.transform,
0981a08… noreply 332 d3.zoomIdentity.translate(width / 2 - first.x, height / 2 - first.y)
0981a08… noreply 333 );
0981a08… noreply 334 }
0981a08… noreply 335 }
0981a08… noreply 336 });
0981a08… noreply 337
0981a08… noreply 338 // --- Filter toggles ---
0981a08… noreply 339 document.querySelectorAll('.filter-btn').forEach(btn => {
0981a08… noreply 340 btn.addEventListener('click', () => {
0981a08… noreply 341 const type = btn.dataset.type;
0981a08… noreply 342 btn.classList.toggle('active');
0981a08… noreply 343 if (activeTypes.has(type)) activeTypes.delete(type);
0981a08… noreply 344 else activeTypes.add(type);
0981a08… noreply 345 if (graphData) render();
0981a08… noreply 346 });
0981a08… noreply 347 });
0981a08… noreply 348
0981a08… noreply 349 // --- Detail panel ---
0981a08… noreply 350 function showDetail(node) {
0981a08… noreply 351 const panel = document.getElementById('detail-panel');
0981a08… noreply 352 document.getElementById('detail-name').textContent = node.name;
0981a08… noreply 353 const badge = document.getElementById('detail-type');
0981a08… noreply 354 badge.textContent = node.type;
0981a08… noreply 355 badge.style.background = TYPE_COLORS[node.type] || DEFAULT_COLOR;
0981a08… noreply 356 badge.style.color = '#1a1a2e';
0981a08… noreply 357
0981a08… noreply 358 const descList = document.getElementById('detail-descriptions');
0981a08… noreply 359 descList.innerHTML = '';
0981a08… noreply 360 if (node.descriptions.length === 0) {
0981a08… noreply 361 descList.innerHTML = '<li style="color:#666">No descriptions</li>';
0981a08… noreply 362 } else {
0981a08… noreply 363 node.descriptions.forEach(d => {
0981a08… noreply 364 const li = document.createElement('li');
0981a08… noreply 365 li.textContent = d;
0981a08… noreply 366 descList.appendChild(li);
0981a08… noreply 367 });
0981a08… noreply 368 }
0981a08… noreply 369
0981a08… noreply 370 const connList = document.getElementById('detail-connections');
0981a08… noreply 371 connList.innerHTML = '';
0981a08… noreply 372 const connections = [];
0981a08… noreply 373 graphData.links.forEach(l => {
0981a08… noreply 374 const s = typeof l.source === 'object' ? l.source.id : l.source;
0981a08… noreply 375 const t = typeof l.target === 'object' ? l.target.id : l.target;
0981a08… noreply 376 if (s === node.id) connections.push({ target: t, type: l.type, dir: '->' });
0981a08… noreply 377 if (t === node.id) connections.push({ target: s, type: l.type, dir: '<-' });
0981a08… noreply 378 });
0981a08… noreply 379 if (connections.length === 0) {
0981a08… noreply 380 connList.innerHTML = '<li style="color:#666">No connections</li>';
0981a08… noreply 381 } else {
0981a08… noreply 382 connections.forEach(c => {
0981a08… noreply 383 const li = document.createElement('li');
0981a08… noreply 384 li.textContent = c.dir + ' ' + c.target + ' (' + c.type + ')';
0981a08… noreply 385 li.style.cursor = 'pointer';
0981a08… noreply 386 li.addEventListener('click', () => {
0981a08… noreply 387 searchInput.value = c.target;
0981a08… noreply 388 searchInput.dispatchEvent(new Event('input'));
0981a08… noreply 389 });
0981a08… noreply 390 connList.appendChild(li);
0981a08… noreply 391 });
0981a08… noreply 392 }
0981a08… noreply 393
0981a08… noreply 394 panel.classList.add('open');
0981a08… noreply 395 }
0981a08… noreply 396
0981a08… noreply 397 document.getElementById('close-detail').addEventListener('click', () => {
0981a08… noreply 398 document.getElementById('detail-panel').classList.remove('open');
0981a08… noreply 399 });
0981a08… noreply 400
0981a08… noreply 401 // --- Resize ---
0981a08… noreply 402 window.addEventListener('resize', () => { if (graphData) render(); });
0981a08… noreply 403 })();
0981a08… noreply 404 </script>
0981a08… noreply 405 </body>
0981a08… noreply 406 </html>

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button