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