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