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__.

lmata 2026-03-07 22:10 trunk
Commit 1e76ab0a2b6c1c6bc624c00fd247701dffb866db962300cd2407ce03a37b8229
1 file changed +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">&times;</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">&times;</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>

Keyboard Shortcuts

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