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