|
1
|
# Copyright CONFLICT LLC 2026 (weareconflict.com) |
|
2
|
""" |
|
3
|
HTML template for the Navegador Graph Explorer. |
|
4
|
|
|
5
|
A fully self-contained, single-file HTML page with: |
|
6
|
- Force-directed graph visualisation via inline canvas + JS physics |
|
7
|
- Search box that queries /api/search |
|
8
|
- Node click → detail panel via /api/node/<name> |
|
9
|
- Stats bar via /api/stats |
|
10
|
- Zero external dependencies (no CDN, no frameworks) |
|
11
|
""" |
|
12
|
|
|
13
|
# Colour palette per node label ───────────────────────────────────────────── |
|
14
|
NODE_COLORS = { |
|
15
|
"Function": "#4e9af1", |
|
16
|
"Method": "#6cb4f5", |
|
17
|
"Class": "#f4a93b", |
|
18
|
"File": "#a8d9a7", |
|
19
|
"Module": "#82c9a0", |
|
20
|
"Repository": "#e67e22", |
|
21
|
"Variable": "#c39bd3", |
|
22
|
"Import": "#a9cce3", |
|
23
|
"Decorator": "#f1948a", |
|
24
|
"Domain": "#f7dc6f", |
|
25
|
"Concept": "#f9e79f", |
|
26
|
"Rule": "#f0b27a", |
|
27
|
"Decision": "#f8c471", |
|
28
|
"WikiPage": "#d2b4de", |
|
29
|
"Person": "#fadbd8", |
|
30
|
"default": "#aaaaaa", |
|
31
|
} |
|
32
|
|
|
33
|
_COLORS_JS = "\n".join(f" '{label}': '{color}'," for label, color in NODE_COLORS.items()) |
|
34
|
|
|
35
|
HTML_TEMPLATE = """<!DOCTYPE html> |
|
36
|
<html lang="en"> |
|
37
|
<head> |
|
38
|
<meta charset="UTF-8"> |
|
39
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
40
|
<title>Navegador Graph Explorer</title> |
|
41
|
<style> |
|
42
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }} |
|
43
|
body {{ |
|
44
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
45
|
background: #1a1a2e; |
|
46
|
color: #e0e0e0; |
|
47
|
height: 100vh; |
|
48
|
display: flex; |
|
49
|
flex-direction: column; |
|
50
|
overflow: hidden; |
|
51
|
}} |
|
52
|
#header {{ |
|
53
|
background: #16213e; |
|
54
|
border-bottom: 1px solid #0f3460; |
|
55
|
padding: 10px 16px; |
|
56
|
display: flex; |
|
57
|
align-items: center; |
|
58
|
gap: 12px; |
|
59
|
flex-shrink: 0; |
|
60
|
}} |
|
61
|
#header h1 {{ |
|
62
|
font-size: 1.1rem; |
|
63
|
color: #4e9af1; |
|
64
|
letter-spacing: 0.05em; |
|
65
|
white-space: nowrap; |
|
66
|
}} |
|
67
|
#search-box {{ |
|
68
|
flex: 1; |
|
69
|
max-width: 400px; |
|
70
|
background: #0f3460; |
|
71
|
border: 1px solid #4e9af1; |
|
72
|
border-radius: 6px; |
|
73
|
padding: 6px 12px; |
|
74
|
color: #e0e0e0; |
|
75
|
font-size: 0.9rem; |
|
76
|
outline: none; |
|
77
|
}} |
|
78
|
#search-box::placeholder {{ color: #5a6a8a; }} |
|
79
|
#search-box:focus {{ border-color: #6cb4f5; box-shadow: 0 0 0 2px rgba(78,154,241,0.2); }} |
|
80
|
#stats-bar {{ |
|
81
|
font-size: 0.75rem; |
|
82
|
color: #7a8aaa; |
|
83
|
white-space: nowrap; |
|
84
|
}} |
|
85
|
#main {{ |
|
86
|
display: flex; |
|
87
|
flex: 1; |
|
88
|
overflow: hidden; |
|
89
|
}} |
|
90
|
#canvas-wrap {{ |
|
91
|
flex: 1; |
|
92
|
position: relative; |
|
93
|
overflow: hidden; |
|
94
|
}} |
|
95
|
#graph-canvas {{ |
|
96
|
display: block; |
|
97
|
width: 100%; |
|
98
|
height: 100%; |
|
99
|
cursor: grab; |
|
100
|
}} |
|
101
|
#graph-canvas:active {{ cursor: grabbing; }} |
|
102
|
#sidebar {{ |
|
103
|
width: 300px; |
|
104
|
background: #16213e; |
|
105
|
border-left: 1px solid #0f3460; |
|
106
|
display: flex; |
|
107
|
flex-direction: column; |
|
108
|
flex-shrink: 0; |
|
109
|
overflow: hidden; |
|
110
|
}} |
|
111
|
#sidebar-title {{ |
|
112
|
padding: 10px 14px; |
|
113
|
font-size: 0.8rem; |
|
114
|
color: #7a8aaa; |
|
115
|
border-bottom: 1px solid #0f3460; |
|
116
|
text-transform: uppercase; |
|
117
|
letter-spacing: 0.08em; |
|
118
|
}} |
|
119
|
#search-results {{ |
|
120
|
border-bottom: 1px solid #0f3460; |
|
121
|
max-height: 220px; |
|
122
|
overflow-y: auto; |
|
123
|
}} |
|
124
|
.search-result {{ |
|
125
|
padding: 8px 14px; |
|
126
|
cursor: pointer; |
|
127
|
border-bottom: 1px solid #0d1b35; |
|
128
|
transition: background 0.15s; |
|
129
|
}} |
|
130
|
.search-result:hover {{ background: #0f3460; }} |
|
131
|
.search-result .sr-name {{ font-size: 0.85rem; font-weight: 600; color: #c8d8f0; }} |
|
132
|
.search-result .sr-meta {{ font-size: 0.72rem; color: #5a6a8a; margin-top: 2px; }} |
|
133
|
#detail-panel {{ |
|
134
|
flex: 1; |
|
135
|
padding: 14px; |
|
136
|
overflow-y: auto; |
|
137
|
font-size: 0.82rem; |
|
138
|
}} |
|
139
|
#detail-panel h2 {{ |
|
140
|
font-size: 1rem; |
|
141
|
color: #4e9af1; |
|
142
|
margin-bottom: 8px; |
|
143
|
word-break: break-all; |
|
144
|
}} |
|
145
|
.detail-label {{ |
|
146
|
color: #5a6a8a; |
|
147
|
font-size: 0.72rem; |
|
148
|
text-transform: uppercase; |
|
149
|
letter-spacing: 0.06em; |
|
150
|
margin-top: 10px; |
|
151
|
margin-bottom: 3px; |
|
152
|
}} |
|
153
|
.detail-value {{ |
|
154
|
color: #c8d8f0; |
|
155
|
word-break: break-word; |
|
156
|
}} |
|
157
|
.badge {{ |
|
158
|
display: inline-block; |
|
159
|
padding: 2px 7px; |
|
160
|
border-radius: 10px; |
|
161
|
font-size: 0.7rem; |
|
162
|
font-weight: 600; |
|
163
|
margin-right: 4px; |
|
164
|
margin-bottom: 4px; |
|
165
|
}} |
|
166
|
.neighbor-item {{ |
|
167
|
background: #0f3460; |
|
168
|
border-radius: 4px; |
|
169
|
padding: 4px 8px; |
|
170
|
margin: 3px 0; |
|
171
|
cursor: pointer; |
|
172
|
transition: background 0.15s; |
|
173
|
display: flex; |
|
174
|
justify-content: space-between; |
|
175
|
align-items: center; |
|
176
|
}} |
|
177
|
.neighbor-item:hover {{ background: #1a4a80; }} |
|
178
|
.neighbor-name {{ color: #c8d8f0; font-size: 0.8rem; }} |
|
179
|
.neighbor-type {{ color: #5a6a8a; font-size: 0.7rem; }} |
|
180
|
#empty-hint {{ |
|
181
|
color: #3a4a6a; |
|
182
|
text-align: center; |
|
183
|
margin-top: 40px; |
|
184
|
font-size: 0.85rem; |
|
185
|
line-height: 1.6; |
|
186
|
}} |
|
187
|
#loading {{ |
|
188
|
position: absolute; |
|
189
|
top: 50%; |
|
190
|
left: 50%; |
|
191
|
transform: translate(-50%, -50%); |
|
192
|
color: #4e9af1; |
|
193
|
font-size: 0.9rem; |
|
194
|
pointer-events: none; |
|
195
|
}} |
|
196
|
::-webkit-scrollbar {{ width: 5px; }} |
|
197
|
::-webkit-scrollbar-track {{ background: #0d1b35; }} |
|
198
|
::-webkit-scrollbar-thumb {{ background: #0f3460; border-radius: 3px; }} |
|
199
|
</style> |
|
200
|
</head> |
|
201
|
<body> |
|
202
|
<div id="header"> |
|
203
|
<h1>navegador</h1> |
|
204
|
<input id="search-box" type="text" placeholder="Search nodes..." autocomplete="off"> |
|
205
|
<div id="stats-bar">Loading…</div> |
|
206
|
</div> |
|
207
|
<div id="main"> |
|
208
|
<div id="canvas-wrap"> |
|
209
|
<canvas id="graph-canvas"></canvas> |
|
210
|
<div id="loading">Loading graph…</div> |
|
211
|
</div> |
|
212
|
<div id="sidebar"> |
|
213
|
<div id="sidebar-title">Explorer</div> |
|
214
|
<div id="search-results"></div> |
|
215
|
<div id="detail-panel"> |
|
216
|
<div id="empty-hint">Click a node<br>or search above<br>to see details.</div> |
|
217
|
</div> |
|
218
|
</div> |
|
219
|
</div> |
|
220
|
<script> |
|
221
|
(function() { |
|
222
|
'use strict'; |
|
223
|
|
|
224
|
// ── Colour palette ───────────────────────────────────────────────────────── |
|
225
|
const NODE_COLORS = {{ |
|
226
|
{colors} |
|
227
|
}}; |
|
228
|
function nodeColor(label) {{ |
|
229
|
return NODE_COLORS[label] || NODE_COLORS['default']; |
|
230
|
}} |
|
231
|
|
|
232
|
// ── State ────────────────────────────────────────────────────────────────── |
|
233
|
let nodes = []; // {{id, label, name, x, y, vx, vy, ...props}} |
|
234
|
let edges = []; // {{source_id, target_id, type}} |
|
235
|
let nodeById = {{}}; // id → node |
|
236
|
|
|
237
|
let selectedNode = null; |
|
238
|
let hoveredNode = null; |
|
239
|
|
|
240
|
// Camera |
|
241
|
let camX = 0, camY = 0, camScale = 1; |
|
242
|
let isDragging = false, dragStartX = 0, dragStartY = 0, camStartX = 0, camStartY = 0; |
|
243
|
let isDraggingNode = false, dragNode = null, dragNodeOffX = 0, dragNodeOffY = 0; |
|
244
|
|
|
245
|
// Physics |
|
246
|
let physicsRunning = true; |
|
247
|
const REPEL = 8000; |
|
248
|
const ATTRACT = 0.04; |
|
249
|
const EDGE_LEN = 120; |
|
250
|
const DAMPING = 0.85; |
|
251
|
const MAX_VEL = 12; |
|
252
|
|
|
253
|
// ── Canvas setup ──────────────────────────────────────────────────────────── |
|
254
|
const canvas = document.getElementById('graph-canvas'); |
|
255
|
const ctx = canvas.getContext('2d'); |
|
256
|
const wrap = document.getElementById('canvas-wrap'); |
|
257
|
const loading = document.getElementById('loading'); |
|
258
|
|
|
259
|
function resize() {{ |
|
260
|
canvas.width = wrap.clientWidth; |
|
261
|
canvas.height = wrap.clientHeight; |
|
262
|
}} |
|
263
|
window.addEventListener('resize', resize); |
|
264
|
resize(); |
|
265
|
|
|
266
|
// ── Fetch graph data ──────────────────────────────────────────────────────── |
|
267
|
async function loadGraph() {{ |
|
268
|
try {{ |
|
269
|
const data = await fetch('/api/graph').then(r => r.json()); |
|
270
|
initGraph(data.nodes || [], data.edges || []); |
|
271
|
loading.style.display = 'none'; |
|
272
|
loadStats(); |
|
273
|
}} catch(e) {{ |
|
274
|
loading.textContent = 'Error loading graph.'; |
|
275
|
}} |
|
276
|
}} |
|
277
|
|
|
278
|
function initGraph(rawNodes, rawEdges) {{ |
|
279
|
const cx = canvas.width / 2, cy = canvas.height / 2; |
|
280
|
nodeById = {{}}; |
|
281
|
nodes = rawNodes.map((n, i) => {{ |
|
282
|
const angle = (i / Math.max(rawNodes.length, 1)) * 2 * Math.PI; |
|
283
|
const r = Math.min(cx, cy) * 0.6; |
|
284
|
const node = {{ |
|
285
|
id: n.id, |
|
286
|
label: n.label || 'default', |
|
287
|
name: n.name || n.id, |
|
288
|
x: cx + r * Math.cos(angle), |
|
289
|
y: cy + r * Math.sin(angle), |
|
290
|
vx: 0, vy: 0, |
|
291
|
props: n.props || {{}}, |
|
292
|
}}; |
|
293
|
nodeById[n.id] = node; |
|
294
|
return node; |
|
295
|
}}); |
|
296
|
edges = rawEdges.map(e => ({{ |
|
297
|
source_id: e.source, |
|
298
|
target_id: e.target, |
|
299
|
type: e.type || '', |
|
300
|
}})); |
|
301
|
}} |
|
302
|
|
|
303
|
async function loadStats() {{ |
|
304
|
try {{ |
|
305
|
const s = await fetch('/api/stats').then(r => r.json()); |
|
306
|
const bar = document.getElementById('stats-bar'); |
|
307
|
bar.textContent = `${{s.nodes}} nodes · ${{s.edges}} edges`; |
|
308
|
}} catch(_) {{}} |
|
309
|
}} |
|
310
|
|
|
311
|
// ── Physics simulation ────────────────────────────────────────────────────── |
|
312
|
function tick() {{ |
|
313
|
if (!physicsRunning || nodes.length === 0) return; |
|
314
|
|
|
315
|
const cx = canvas.width / 2, cy = canvas.height / 2; |
|
316
|
|
|
317
|
// Repulsion between all pairs (Barnes-Hut approximation skipped for simplicity) |
|
318
|
for (let i = 0; i < nodes.length; i++) {{ |
|
319
|
const a = nodes[i]; |
|
320
|
for (let j = i + 1; j < nodes.length; j++) {{ |
|
321
|
const b = nodes[j]; |
|
322
|
let dx = b.x - a.x, dy = b.y - a.y; |
|
323
|
const dist2 = dx*dx + dy*dy + 0.1; |
|
324
|
const dist = Math.sqrt(dist2); |
|
325
|
const force = REPEL / dist2; |
|
326
|
const fx = (dx / dist) * force; |
|
327
|
const fy = (dy / dist) * force; |
|
328
|
a.vx -= fx; a.vy -= fy; |
|
329
|
b.vx += fx; b.vy += fy; |
|
330
|
}} |
|
331
|
}} |
|
332
|
|
|
333
|
// Edge spring attraction |
|
334
|
for (const e of edges) {{ |
|
335
|
const a = nodeById[e.source_id], b = nodeById[e.target_id]; |
|
336
|
if (!a || !b) continue; |
|
337
|
const dx = b.x - a.x, dy = b.y - a.y; |
|
338
|
const dist = Math.sqrt(dx*dx + dy*dy) || 1; |
|
339
|
const force = (dist - EDGE_LEN) * ATTRACT; |
|
340
|
const fx = (dx / dist) * force; |
|
341
|
const fy = (dy / dist) * force; |
|
342
|
a.vx += fx; a.vy += fy; |
|
343
|
b.vx -= fx; b.vy -= fy; |
|
344
|
}} |
|
345
|
|
|
346
|
// Weak centering pull |
|
347
|
for (const n of nodes) {{ |
|
348
|
n.vx += (cx - n.x) * 0.0005; |
|
349
|
n.vy += (cy - n.y) * 0.0005; |
|
350
|
}} |
|
351
|
|
|
352
|
// Integrate |
|
353
|
for (const n of nodes) {{ |
|
354
|
if (n === dragNode) continue; |
|
355
|
n.vx = Math.max(-MAX_VEL, Math.min(MAX_VEL, n.vx * DAMPING)); |
|
356
|
n.vy = Math.max(-MAX_VEL, Math.min(MAX_VEL, n.vy * DAMPING)); |
|
357
|
n.x += n.vx; |
|
358
|
n.y += n.vy; |
|
359
|
}} |
|
360
|
}} |
|
361
|
|
|
362
|
// ── Render ────────────────────────────────────────────────────────────────── |
|
363
|
function draw() {{ |
|
364
|
const w = canvas.width, h = canvas.height; |
|
365
|
ctx.clearRect(0, 0, w, h); |
|
366
|
ctx.save(); |
|
367
|
ctx.translate(camX, camY); |
|
368
|
ctx.scale(camScale, camScale); |
|
369
|
|
|
370
|
// Edges |
|
371
|
ctx.lineWidth = 1 / camScale; |
|
372
|
for (const e of edges) {{ |
|
373
|
const a = nodeById[e.source_id], b = nodeById[e.target_id]; |
|
374
|
if (!a || !b) continue; |
|
375
|
const isHighlighted = selectedNode && (a === selectedNode || b === selectedNode); |
|
376
|
ctx.globalAlpha = isHighlighted ? 0.9 : 0.25; |
|
377
|
ctx.strokeStyle = isHighlighted ? '#4e9af1' : '#3a5070'; |
|
378
|
ctx.beginPath(); |
|
379
|
ctx.moveTo(a.x, a.y); |
|
380
|
ctx.lineTo(b.x, b.y); |
|
381
|
ctx.stroke(); |
|
382
|
|
|
383
|
// Arrowhead |
|
384
|
if (isHighlighted) {{ |
|
385
|
const dx = b.x - a.x, dy = b.y - a.y; |
|
386
|
const dist = Math.sqrt(dx*dx+dy*dy) || 1; |
|
387
|
const r = 8; |
|
388
|
const tx = b.x - (dx/dist)*r, ty = b.y - (dy/dist)*r; |
|
389
|
const angle = Math.atan2(dy, dx); |
|
390
|
ctx.globalAlpha = 0.7; |
|
391
|
ctx.fillStyle = '#4e9af1'; |
|
392
|
ctx.beginPath(); |
|
393
|
ctx.moveTo(tx, ty); |
|
394
|
ctx.lineTo(tx - 8*Math.cos(angle-0.4), ty - 8*Math.sin(angle-0.4)); |
|
395
|
ctx.lineTo(tx - 8*Math.cos(angle+0.4), ty - 8*Math.sin(angle+0.4)); |
|
396
|
ctx.closePath(); |
|
397
|
ctx.fill(); |
|
398
|
}} |
|
399
|
}} |
|
400
|
ctx.globalAlpha = 1; |
|
401
|
|
|
402
|
// Edge labels on highlighted edges |
|
403
|
if (selectedNode) {{ |
|
404
|
ctx.font = `${{Math.max(9, 10/camScale)}}px sans-serif`; |
|
405
|
ctx.fillStyle = '#5a8ab8'; |
|
406
|
for (const e of edges) {{ |
|
407
|
const a = nodeById[e.source_id], b = nodeById[e.target_id]; |
|
408
|
if (!a || !b) continue; |
|
409
|
if (a !== selectedNode && b !== selectedNode) continue; |
|
410
|
if (!e.type) continue; |
|
411
|
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; |
|
412
|
ctx.fillText(e.type, mx, my); |
|
413
|
}} |
|
414
|
}} |
|
415
|
|
|
416
|
// Nodes |
|
417
|
const nodeR = 8; |
|
418
|
for (const n of nodes) {{ |
|
419
|
const isSelected = n === selectedNode; |
|
420
|
const isHovered = n === hoveredNode; |
|
421
|
const color = nodeColor(n.label); |
|
422
|
|
|
423
|
ctx.beginPath(); |
|
424
|
ctx.arc(n.x, n.y, nodeR + (isSelected ? 3 : isHovered ? 1 : 0), 0, 2*Math.PI); |
|
425
|
ctx.fillStyle = color; |
|
426
|
ctx.globalAlpha = 0.9; |
|
427
|
ctx.fill(); |
|
428
|
if (isSelected || isHovered) {{ |
|
429
|
ctx.strokeStyle = '#ffffff'; |
|
430
|
ctx.lineWidth = 2 / camScale; |
|
431
|
ctx.stroke(); |
|
432
|
}} |
|
433
|
ctx.globalAlpha = 1; |
|
434
|
|
|
435
|
// Label |
|
436
|
const labelThreshold = 0.4; |
|
437
|
if (camScale > labelThreshold || isSelected || isHovered) {{ |
|
438
|
const fontSize = Math.max(8, 11 / camScale); |
|
439
|
ctx.font = `${{isSelected ? 'bold ' : ''}}${{fontSize}}px sans-serif`; |
|
440
|
ctx.fillStyle = '#e0e8ff'; |
|
441
|
ctx.globalAlpha = Math.min(1, (camScale - labelThreshold + 0.1) * 3); |
|
442
|
if (isSelected || isHovered) ctx.globalAlpha = 1; |
|
443
|
ctx.fillText(n.name, n.x + nodeR + 2, n.y + 4); |
|
444
|
ctx.globalAlpha = 1; |
|
445
|
}} |
|
446
|
}} |
|
447
|
|
|
448
|
ctx.restore(); |
|
449
|
}} |
|
450
|
|
|
451
|
function loop() {{ |
|
452
|
tick(); |
|
453
|
draw(); |
|
454
|
requestAnimationFrame(loop); |
|
455
|
}} |
|
456
|
|
|
457
|
// ── Hit testing ───────────────────────────────────────────────────────────── |
|
458
|
function screenToWorld(sx, sy) {{ |
|
459
|
return {{ x: (sx - camX) / camScale, y: (sy - camY) / camScale }}; |
|
460
|
}} |
|
461
|
|
|
462
|
function nodeAtScreen(sx, sy) {{ |
|
463
|
const w = screenToWorld(sx, sy); |
|
464
|
const nodeR = 11; |
|
465
|
for (let i = nodes.length - 1; i >= 0; i--) {{ |
|
466
|
const n = nodes[i]; |
|
467
|
const dx = n.x - w.x, dy = n.y - w.y; |
|
468
|
if (dx*dx + dy*dy <= nodeR*nodeR) return n; |
|
469
|
}} |
|
470
|
return null; |
|
471
|
}} |
|
472
|
|
|
473
|
// ── Mouse / touch events ──────────────────────────────────────────────────── |
|
474
|
canvas.addEventListener('mousedown', e => {{ |
|
475
|
const hit = nodeAtScreen(e.offsetX, e.offsetY); |
|
476
|
if (hit) {{ |
|
477
|
isDraggingNode = true; |
|
478
|
dragNode = hit; |
|
479
|
physicsRunning = true; |
|
480
|
const w = screenToWorld(e.offsetX, e.offsetY); |
|
481
|
dragNodeOffX = hit.x - w.x; |
|
482
|
dragNodeOffY = hit.y - w.y; |
|
483
|
}} else {{ |
|
484
|
isDragging = true; |
|
485
|
dragStartX = e.offsetX; dragStartY = e.offsetY; |
|
486
|
camStartX = camX; camStartY = camY; |
|
487
|
}} |
|
488
|
}}); |
|
489
|
|
|
490
|
canvas.addEventListener('mousemove', e => {{ |
|
491
|
if (isDraggingNode && dragNode) {{ |
|
492
|
const w = screenToWorld(e.offsetX, e.offsetY); |
|
493
|
dragNode.x = w.x + dragNodeOffX; |
|
494
|
dragNode.y = w.y + dragNodeOffY; |
|
495
|
dragNode.vx = 0; dragNode.vy = 0; |
|
496
|
}} else if (isDragging) {{ |
|
497
|
camX = camStartX + (e.offsetX - dragStartX); |
|
498
|
camY = camStartY + (e.offsetY - dragStartY); |
|
499
|
}} else {{ |
|
500
|
hoveredNode = nodeAtScreen(e.offsetX, e.offsetY); |
|
501
|
canvas.style.cursor = hoveredNode ? 'pointer' : 'grab'; |
|
502
|
}} |
|
503
|
}}); |
|
504
|
|
|
505
|
canvas.addEventListener('mouseup', e => {{ |
|
506
|
if (isDraggingNode && dragNode) {{ |
|
507
|
const wasDragged = Math.abs(dragNode.vx) < 0.5 && Math.abs(dragNode.vy) < 0.5; |
|
508
|
if (wasDragged) selectNode(dragNode); |
|
509
|
}} else if (!isDragging || (Math.abs(e.offsetX - dragStartX) < 4 && Math.abs(e.offsetY - dragStartY) < 4)) {{ |
|
510
|
const hit = nodeAtScreen(e.offsetX, e.offsetY); |
|
511
|
if (hit) selectNode(hit); |
|
512
|
}} |
|
513
|
isDragging = false; |
|
514
|
isDraggingNode = false; |
|
515
|
dragNode = null; |
|
516
|
}}); |
|
517
|
|
|
518
|
canvas.addEventListener('wheel', e => {{ |
|
519
|
e.preventDefault(); |
|
520
|
const factor = e.deltaY < 0 ? 1.1 : 0.9; |
|
521
|
const mx = e.offsetX, my = e.offsetY; |
|
522
|
camX = mx - (mx - camX) * factor; |
|
523
|
camY = my - (my - camY) * factor; |
|
524
|
camScale = Math.max(0.05, Math.min(10, camScale * factor)); |
|
525
|
}}, {{ passive: false }}); |
|
526
|
|
|
527
|
// ── Node selection ────────────────────────────────────────────────────────── |
|
528
|
async function selectNode(node) {{ |
|
529
|
selectedNode = node; |
|
530
|
try {{ |
|
531
|
const data = await fetch('/api/node/' + encodeURIComponent(node.name)).then(r => r.json()); |
|
532
|
renderDetail(data); |
|
533
|
}} catch(e) {{ |
|
534
|
renderDetail({{ name: node.name, label: node.label, props: node.props, neighbors: [] }}); |
|
535
|
}} |
|
536
|
}} |
|
537
|
|
|
538
|
function renderDetail(data) {{ |
|
539
|
const panel = document.getElementById('detail-panel'); |
|
540
|
const label = data.label || ''; |
|
541
|
const color = nodeColor(label); |
|
542
|
let html = `<h2>${{data.name}}</h2>`; |
|
543
|
html += `<div class="detail-label">Type</div>`; |
|
544
|
html += `<span class="badge" style="background:${{color}}22;color:${{color}};border:1px solid ${{color}}44">${{label}}</span>`; |
|
545
|
|
|
546
|
const props = data.props || {{}}; |
|
547
|
const skip = new Set(['name']); |
|
548
|
const order = ['file_path', 'line_start', 'line_end', 'signature', 'docstring', |
|
549
|
'description', 'status', 'domain', 'rationale', 'url']; |
|
550
|
const shown = new Set(); |
|
551
|
|
|
552
|
for (const key of order) {{ |
|
553
|
if (props[key] !== undefined && props[key] !== null && props[key] !== '') {{ |
|
554
|
html += `<div class="detail-label">${{key.replace(/_/g,' ')}}</div>`; |
|
555
|
html += `<div class="detail-value">${{escHtml(String(props[key]))}}</div>`; |
|
556
|
shown.add(key); |
|
557
|
}} |
|
558
|
}} |
|
559
|
for (const [key, val] of Object.entries(props)) {{ |
|
560
|
if (skip.has(key) || shown.has(key) || val === null || val === undefined || val === '') continue; |
|
561
|
html += `<div class="detail-label">${{key.replace(/_/g,' ')}}</div>`; |
|
562
|
html += `<div class="detail-value">${{escHtml(String(val))}}</div>`; |
|
563
|
}} |
|
564
|
|
|
565
|
const neighbors = data.neighbors || []; |
|
566
|
if (neighbors.length > 0) {{ |
|
567
|
html += `<div class="detail-label">Neighbors (${{neighbors.length}})</div>`; |
|
568
|
for (const nb of neighbors.slice(0, 50)) {{ |
|
569
|
html += `<div class="neighbor-item" onclick="jumpToNode(${{JSON.stringify(nb.name)}})"> |
|
570
|
<span class="neighbor-name">${{escHtml(nb.name)}}</span> |
|
571
|
<span class="neighbor-type">${{escHtml(nb.label || '')}} · ${{escHtml(nb.rel || '')}}</span> |
|
572
|
</div>`; |
|
573
|
}} |
|
574
|
if (neighbors.length > 50) {{ |
|
575
|
html += `<div style="color:#5a6a8a;font-size:0.75rem;margin-top:4px">and ${{neighbors.length - 50}} more…</div>`; |
|
576
|
}} |
|
577
|
}} |
|
578
|
|
|
579
|
panel.innerHTML = html; |
|
580
|
}} |
|
581
|
|
|
582
|
function escHtml(s) {{ |
|
583
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); |
|
584
|
}} |
|
585
|
|
|
586
|
// ── Jump to node by name ──────────────────────────────────────────────────── |
|
587
|
function jumpToNode(name) {{ |
|
588
|
const n = nodes.find(x => x.name === name); |
|
589
|
if (!n) return; |
|
590
|
selectedNode = n; |
|
591
|
// Pan to node |
|
592
|
camX = canvas.width/2 - n.x * camScale; |
|
593
|
camY = canvas.height/2 - n.y * camScale; |
|
594
|
selectNode(n); |
|
595
|
}} |
|
596
|
window.jumpToNode = jumpToNode; |
|
597
|
|
|
598
|
// ── Search ────────────────────────────────────────────────────────────────── |
|
599
|
let searchTimer = null; |
|
600
|
const searchBox = document.getElementById('search-box'); |
|
601
|
const searchResults = document.getElementById('search-results'); |
|
602
|
|
|
603
|
searchBox.addEventListener('input', () => {{ |
|
604
|
clearTimeout(searchTimer); |
|
605
|
searchTimer = setTimeout(doSearch, 200); |
|
606
|
}}); |
|
607
|
|
|
608
|
async function doSearch() {{ |
|
609
|
const q = searchBox.value.trim(); |
|
610
|
if (!q) {{ searchResults.innerHTML = ''; return; }} |
|
611
|
try {{ |
|
612
|
const results = await fetch('/api/search?q=' + encodeURIComponent(q)).then(r => r.json()); |
|
613
|
renderSearchResults(results.nodes || []); |
|
614
|
}} catch(_) {{}} |
|
615
|
}} |
|
616
|
|
|
617
|
function renderSearchResults(results) {{ |
|
618
|
if (!results.length) {{ |
|
619
|
searchResults.innerHTML = '<div style="padding:8px 14px;color:#5a6a8a;font-size:0.8rem">No results</div>'; |
|
620
|
return; |
|
621
|
}} |
|
622
|
searchResults.innerHTML = results.slice(0, 20).map(r => ` |
|
623
|
<div class="search-result" onclick="jumpToNode(${{JSON.stringify(r.name)}})"> |
|
624
|
<div class="sr-name">${{escHtml(r.name)}}</div> |
|
625
|
<div class="sr-meta">${{escHtml(r.label || '')}} · ${{escHtml(r.file_path || r.domain || '')}}</div> |
|
626
|
</div> |
|
627
|
`).join(''); |
|
628
|
}} |
|
629
|
|
|
630
|
// ── Boot ──────────────────────────────────────────────────────────────────── |
|
631
|
loadGraph().then(() => loop()); |
|
632
|
}()); |
|
633
|
</script> |
|
634
|
</body> |
|
635
|
</html>""" |
|
636
|
|
|
637
|
# Inject the colour map into the template |
|
638
|
HTML_TEMPLATE = HTML_TEMPLATE.replace("{colors}", _COLORS_JS) |
|
639
|
|