Navegador

navegador / navegador / explorer / templates.py
Blame History Raw 639 lines
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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

Keyboard Shortcuts

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