PlanOpticon

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>
407

Keyboard Shortcuts

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