ex-brain 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1070 @@
1
+ import { Command } from "commander";
2
+ import { loadSettings } from "../settings";
3
+ import { BrainRepository } from "../repositories/brain-repo";
4
+ import { BrainDb } from "../db/client";
5
+
6
+ interface GraphNode {
7
+ id: string;
8
+ label: string;
9
+ type: string;
10
+ title: string;
11
+ group: string;
12
+ }
13
+
14
+ interface GraphEdge {
15
+ from: string;
16
+ to: string;
17
+ label: string;
18
+ context: string;
19
+ }
20
+
21
+ interface GraphData {
22
+ nodes: GraphNode[];
23
+ edges: GraphEdge[];
24
+ stats: {
25
+ nodes: number;
26
+ edges: number;
27
+ types: Record<string, number>;
28
+ };
29
+ }
30
+
31
+ async function getGraphData(repo: BrainRepository): Promise<GraphData> {
32
+ // Get all pages as nodes
33
+ const pages = await repo.listPages({ limit: 10000 });
34
+
35
+ // Get all links as edges
36
+ const linksRows = await repo.db.client.execute(
37
+ `SELECT from_slug, to_slug, context FROM links ORDER BY from_slug ASC`
38
+ );
39
+
40
+ const nodes: GraphNode[] = [];
41
+ const edges: GraphEdge[] = [];
42
+ const typeCounts: Record<string, number> = {};
43
+
44
+ // Create nodes from pages
45
+ for (const page of pages) {
46
+ const type = page.type || "other";
47
+ typeCounts[type] = (typeCounts[type] || 0) + 1;
48
+
49
+ nodes.push({
50
+ id: page.slug,
51
+ label: page.title || page.slug.split("/").pop() || page.slug,
52
+ type,
53
+ title: page.title,
54
+ group: type,
55
+ });
56
+ }
57
+
58
+ // Create edges from links
59
+ for (const row of linksRows || []) {
60
+ const r = row as { from_slug: string; to_slug: string; context: string };
61
+
62
+ // Extract relation type from context
63
+ const context = r.context || "";
64
+ const labelMatch = context.match(/^\[([^\]]+)\]/);
65
+ const label = labelMatch ? labelMatch[1] : "links";
66
+
67
+ edges.push({
68
+ from: r.from_slug,
69
+ to: r.to_slug,
70
+ label,
71
+ context,
72
+ });
73
+ }
74
+
75
+ return {
76
+ nodes,
77
+ edges,
78
+ stats: {
79
+ nodes: nodes.length,
80
+ edges: edges.length,
81
+ types: typeCounts,
82
+ },
83
+ };
84
+ }
85
+
86
+ async function getNodeDetails(repo: BrainRepository, slug: string) {
87
+ const page = await repo.getPage(slug);
88
+ if (!page) return null;
89
+
90
+ const backlinks = await repo.backlinks(slug);
91
+ const outgoingLinks = await repo.db.client.execute(
92
+ `SELECT to_slug, context FROM links WHERE from_slug = ?`,
93
+ [slug]
94
+ );
95
+ const timeline = await repo.timeline(slug, 10);
96
+
97
+ return {
98
+ page,
99
+ backlinks,
100
+ outgoingLinks: (outgoingLinks || []).map((r) => r as { to_slug: string; context: string }),
101
+ timeline,
102
+ };
103
+ }
104
+
105
+ export function registerGraphCommand(program: Command): void {
106
+ program
107
+ .command("graph")
108
+ .option("-p, --port <port>", "web server port", "3000")
109
+ .option("-h, --host <host>", "web server host", "localhost")
110
+ .option("--no-open", "don't open browser automatically")
111
+ .description("Start interactive knowledge graph visualization web server")
112
+ .addHelpText(
113
+ "after",
114
+ `
115
+ Examples:
116
+ ebrain graph # Start and open browser on http://localhost:3000
117
+ ebrain graph --port 8080 # Start on http://localhost:8080
118
+ ebrain graph --no-open # Start without opening browser
119
+ `
120
+ )
121
+ .action(async (opts: { port: string; host: string; open?: boolean }) => {
122
+ const settings = await loadSettings();
123
+ const db = await BrainDb.connect(settings.dbPath, settings);
124
+ const repo = new BrainRepository(db);
125
+
126
+ const port = parseInt(opts.port, 10);
127
+ const host = opts.host;
128
+
129
+ console.log(`\n🌐 Starting Ex-Brain Server...`);
130
+ console.log(` Database: ${settings.dbPath}`);
131
+ console.log(` URL: http://${host}:${port}`);
132
+ console.log(`\n Press Ctrl+C to stop\n`);
133
+
134
+ // Create the HTML page with embedded vis.js
135
+ const htmlPage = getGraphHtml();
136
+
137
+ // Start Bun server
138
+ const server = Bun.serve({
139
+ port,
140
+ hostname: host,
141
+ async fetch(req) {
142
+ const url = new URL(req.url);
143
+
144
+ // API endpoint: Get graph data
145
+ if (url.pathname === "/api/graph") {
146
+ try {
147
+ const data = await getGraphData(repo);
148
+ return Response.json(data);
149
+ } catch (error) {
150
+ return Response.json({ error: String(error) }, { status: 500 });
151
+ }
152
+ }
153
+
154
+ // API endpoint: Get node details
155
+ if (url.pathname.startsWith("/api/node/")) {
156
+ const slug = decodeURIComponent(url.pathname.slice("/api/node/".length));
157
+ try {
158
+ const details = await getNodeDetails(repo, slug);
159
+ if (!details) {
160
+ return Response.json({ error: "Not found" }, { status: 404 });
161
+ }
162
+ return Response.json(details);
163
+ } catch (error) {
164
+ return Response.json({ error: String(error) }, { status: 500 });
165
+ }
166
+ }
167
+
168
+ // Serve the HTML page
169
+ if (url.pathname === "/" || url.pathname === "/index.html") {
170
+ return new Response(htmlPage, {
171
+ headers: { "Content-Type": "text/html; charset=utf-8" },
172
+ });
173
+ }
174
+
175
+ // 404 for other paths
176
+ return new Response("Not Found", { status: 404 });
177
+ },
178
+ });
179
+
180
+ // Open browser automatically (default: true, use --no-open to disable)
181
+ const shouldOpenBrowser = opts.open !== false;
182
+ if (shouldOpenBrowser) {
183
+ const openCommand = process.platform === "darwin"
184
+ ? "open"
185
+ : process.platform === "win32"
186
+ ? "start"
187
+ : "xdg-open";
188
+
189
+ // Delay 500ms to ensure server is ready
190
+ setTimeout(() => {
191
+ try {
192
+ Bun.spawn([openCommand, `http://${host}:${port}`], {
193
+ detached: true,
194
+ });
195
+ console.log(` Opening browser...\n`);
196
+ } catch (e) {
197
+ console.log(` (Could not open browser: ${e})\n`);
198
+ }
199
+ }, 500);
200
+ }
201
+
202
+ // Keep the server running
203
+ await new Promise(() => {}); // Never resolves
204
+ });
205
+ }
206
+
207
+ function getGraphHtml(): string {
208
+ return `<!DOCTYPE html>
209
+ <html lang="en">
210
+ <head>
211
+ <meta charset="UTF-8">
212
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
213
+ <title>Ex-Brain Knowledge Graph</title>
214
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
215
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
216
+ <style>
217
+ * { margin: 0; padding: 0; box-sizing: border-box; }
218
+
219
+ body {
220
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
221
+ background: #0f0f0f;
222
+ color: #e0e0e0;
223
+ overflow: hidden;
224
+ }
225
+
226
+ #app {
227
+ display: flex;
228
+ height: 100vh;
229
+ }
230
+
231
+ #sidebar {
232
+ width: 320px;
233
+ background: #1a1a1a;
234
+ border-right: 1px solid #333;
235
+ display: flex;
236
+ flex-direction: column;
237
+ overflow: hidden;
238
+ }
239
+
240
+ #sidebar-header {
241
+ padding: 16px;
242
+ border-bottom: 1px solid #333;
243
+ background: #222;
244
+ }
245
+
246
+ #sidebar-header h1 {
247
+ font-size: 18px;
248
+ font-weight: 600;
249
+ margin-bottom: 8px;
250
+ }
251
+
252
+ #stats {
253
+ font-size: 12px;
254
+ color: #888;
255
+ }
256
+
257
+ #search-box {
258
+ padding: 12px 16px;
259
+ border-bottom: 1px solid #333;
260
+ }
261
+
262
+ #search-box input {
263
+ width: 100%;
264
+ padding: 8px 12px;
265
+ border: 1px solid #333;
266
+ border-radius: 6px;
267
+ background: #252525;
268
+ color: #e0e0e0;
269
+ font-size: 13px;
270
+ }
271
+
272
+ #search-box input:focus {
273
+ outline: none;
274
+ border-color: #4a9eff;
275
+ }
276
+
277
+ #filters {
278
+ padding: 12px 16px;
279
+ border-bottom: 1px solid #333;
280
+ font-size: 12px;
281
+ }
282
+
283
+ #filters label {
284
+ display: inline-flex;
285
+ align-items: center;
286
+ margin-right: 12px;
287
+ margin-bottom: 4px;
288
+ cursor: pointer;
289
+ }
290
+
291
+ #filters input[type="checkbox"] {
292
+ margin-right: 4px;
293
+ }
294
+
295
+ #node-list {
296
+ flex: 1;
297
+ overflow-y: auto;
298
+ padding: 8px;
299
+ }
300
+
301
+ .node-item {
302
+ padding: 8px 12px;
303
+ border-radius: 6px;
304
+ cursor: pointer;
305
+ margin-bottom: 4px;
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 8px;
309
+ font-size: 13px;
310
+ }
311
+
312
+ .node-item:hover {
313
+ background: #2a2a2a;
314
+ }
315
+
316
+ .node-item.selected {
317
+ background: #2a4a6a;
318
+ }
319
+
320
+ .node-type-dot {
321
+ width: 8px;
322
+ height: 8px;
323
+ border-radius: 50%;
324
+ flex-shrink: 0;
325
+ }
326
+
327
+ #graph-container {
328
+ flex: 1;
329
+ position: relative;
330
+ }
331
+
332
+ #network {
333
+ width: 100%;
334
+ height: 100%;
335
+ }
336
+
337
+ #node-detail {
338
+ position: absolute;
339
+ width: 360px;
340
+ min-width: 280px;
341
+ max-width: calc(100vw - 400px);
342
+ height: 480px;
343
+ min-height: 200px;
344
+ max-height: calc(100vh - 32px);
345
+ background: #1a1a1a;
346
+ border: 1px solid #333;
347
+ border-radius: 8px;
348
+ overflow: hidden;
349
+ display: none;
350
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
351
+ }
352
+
353
+ #node-detail.visible {
354
+ display: flex;
355
+ flex-direction: column;
356
+ }
357
+
358
+ #detail-header {
359
+ padding: 16px;
360
+ border-bottom: 1px solid #333;
361
+ background: #222;
362
+ display: flex;
363
+ justify-content: space-between;
364
+ align-items: start;
365
+ cursor: move;
366
+ user-select: none;
367
+ flex-shrink: 0;
368
+ }
369
+
370
+ #detail-header:hover {
371
+ background: #282828;
372
+ }
373
+
374
+ #detail-header h2 {
375
+ font-size: 16px;
376
+ font-weight: 600;
377
+ margin-bottom: 4px;
378
+ }
379
+
380
+ #detail-header .type-badge {
381
+ font-size: 11px;
382
+ padding: 2px 8px;
383
+ border-radius: 4px;
384
+ background: #333;
385
+ }
386
+
387
+ #close-detail {
388
+ background: none;
389
+ border: none;
390
+ color: #888;
391
+ font-size: 20px;
392
+ cursor: pointer;
393
+ padding: 0;
394
+ line-height: 1;
395
+ }
396
+
397
+ #close-detail:hover {
398
+ color: #fff;
399
+ }
400
+
401
+ #detail-content {
402
+ padding: 16px;
403
+ overflow-y: auto;
404
+ flex: 1;
405
+ min-height: 0;
406
+ }
407
+
408
+ /* Custom resize handle */
409
+ #resize-handle {
410
+ position: absolute;
411
+ right: 0;
412
+ bottom: 0;
413
+ width: 16px;
414
+ height: 16px;
415
+ cursor: nwse-resize;
416
+ background: linear-gradient(135deg, transparent 50%, #555 50%);
417
+ border-radius: 0 0 8px 0;
418
+ opacity: 0.5;
419
+ transition: opacity 0.2s;
420
+ }
421
+
422
+ #resize-handle:hover {
423
+ opacity: 1;
424
+ background: linear-gradient(135deg, transparent 50%, #4a9eff 50%);
425
+ }
426
+
427
+ .detail-section {
428
+ margin-bottom: 16px;
429
+ }
430
+
431
+ .detail-section h3 {
432
+ font-size: 12px;
433
+ color: #888;
434
+ text-transform: uppercase;
435
+ margin-bottom: 8px;
436
+ }
437
+
438
+ .detail-section p {
439
+ font-size: 13px;
440
+ line-height: 1.6;
441
+ white-space: pre-wrap;
442
+ }
443
+
444
+ .link-item {
445
+ font-size: 13px;
446
+ padding: 6px 0;
447
+ border-bottom: 1px solid #252525;
448
+ }
449
+
450
+ .link-item:last-child {
451
+ border-bottom: none;
452
+ }
453
+
454
+ .link-item a {
455
+ color: #4a9eff;
456
+ text-decoration: none;
457
+ }
458
+
459
+ .link-item a:hover {
460
+ text-decoration: underline;
461
+ }
462
+
463
+ .timeline-item {
464
+ padding: 8px 0;
465
+ border-bottom: 1px solid #252525;
466
+ }
467
+
468
+ .timeline-date {
469
+ font-size: 11px;
470
+ color: #888;
471
+ margin-bottom: 2px;
472
+ }
473
+
474
+ .timeline-summary {
475
+ font-size: 13px;
476
+ }
477
+ .timeline-detail {
478
+ font-size: 12px;
479
+ color: #888;
480
+ margin-top: 4px;
481
+ padding-left: 8px;
482
+ border-left: 2px solid #333;
483
+ }
484
+
485
+ #loading {
486
+ position: absolute;
487
+ top: 50%;
488
+ left: 50%;
489
+ transform: translate(-50%, -50%);
490
+ text-align: center;
491
+ }
492
+
493
+ .spinner {
494
+ width: 40px;
495
+ height: 40px;
496
+ border: 3px solid #333;
497
+ border-top-color: #4a9eff;
498
+ border-radius: 50%;
499
+ animation: spin 1s linear infinite;
500
+ margin: 0 auto 16px;
501
+ }
502
+
503
+ @keyframes spin {
504
+ to { transform: rotate(360deg); }
505
+ }
506
+
507
+ #toolbar {
508
+ position: absolute;
509
+ bottom: 16px;
510
+ left: 50%;
511
+ transform: translateX(-50%);
512
+ display: flex;
513
+ gap: 8px;
514
+ background: #1a1a1a;
515
+ padding: 8px;
516
+ border-radius: 8px;
517
+ border: 1px solid #333;
518
+ }
519
+
520
+ .toolbar-btn {
521
+ padding: 8px 16px;
522
+ background: #2a2a2a;
523
+ border: 1px solid #333;
524
+ border-radius: 6px;
525
+ color: #e0e0e0;
526
+ font-size: 13px;
527
+ cursor: pointer;
528
+ }
529
+
530
+ .toolbar-btn:hover {
531
+ background: #333;
532
+ }
533
+
534
+ /* Type colors */
535
+ .type-person { background: #4caf50; }
536
+ .type-company { background: #2196f3; }
537
+ .type-project { background: #ff9800; }
538
+ .type-note { background: #9c27b0; }
539
+ .type-deal { background: #f44336; }
540
+ .type-yc { background: #ff5722; }
541
+ .type-civic { background: #00bcd4; }
542
+ .type-other { background: #607d8b; }
543
+
544
+ /* Markdown content styles */
545
+ .markdown-content {
546
+ font-size: 13px;
547
+ line-height: 1.6;
548
+ }
549
+ .markdown-content h1, .markdown-content h2, .markdown-content h3 {
550
+ margin-top: 16px;
551
+ margin-bottom: 8px;
552
+ font-weight: 600;
553
+ }
554
+ .markdown-content h1 { font-size: 18px; }
555
+ .markdown-content h2 { font-size: 16px; color: #aaa; }
556
+ .markdown-content h3 { font-size: 14px; color: #888; }
557
+ .markdown-content p { margin: 8px 0; }
558
+ .markdown-content ul, .markdown-content ol {
559
+ margin: 8px 0;
560
+ padding-left: 20px;
561
+ }
562
+ .markdown-content li { margin: 4px 0; }
563
+ .markdown-content code {
564
+ background: #2a2a2a;
565
+ padding: 2px 6px;
566
+ border-radius: 4px;
567
+ font-family: 'SF Mono', Monaco, monospace;
568
+ font-size: 12px;
569
+ }
570
+ .markdown-content pre {
571
+ background: #2a2a2a;
572
+ padding: 12px;
573
+ border-radius: 6px;
574
+ overflow-x: auto;
575
+ margin: 8px 0;
576
+ }
577
+ .markdown-content pre code {
578
+ background: none;
579
+ padding: 0;
580
+ }
581
+ .markdown-content blockquote {
582
+ border-left: 3px solid #444;
583
+ margin: 8px 0;
584
+ padding-left: 12px;
585
+ color: #888;
586
+ }
587
+ .markdown-content a {
588
+ color: #4a9eff;
589
+ text-decoration: none;
590
+ }
591
+ .markdown-content a:hover {
592
+ text-decoration: underline;
593
+ }
594
+ .markdown-content strong { color: #fff; }
595
+ .markdown-content em { color: #ccc; }
596
+ .markdown-content hr {
597
+ border: none;
598
+ border-top: 1px solid #333;
599
+ margin: 16px 0;
600
+ }
601
+ </style>
602
+ </head>
603
+ <body>
604
+ <div id="app">
605
+ <div id="sidebar">
606
+ <div id="sidebar-header">
607
+ <h1>Ex-Brain</h1>
608
+ <div id="stats">Loading...</div>
609
+ </div>
610
+ <div id="search-box">
611
+ <input type="text" id="search-input" placeholder="Search nodes...">
612
+ </div>
613
+ <div id="filters"></div>
614
+ <div id="node-list"></div>
615
+ </div>
616
+ <div id="graph-container">
617
+ <div id="loading">
618
+ <div class="spinner"></div>
619
+ <div>Loading graph...</div>
620
+ </div>
621
+ <div id="network"></div>
622
+ <div id="node-detail">
623
+ <div id="detail-header">
624
+ <div>
625
+ <h2 id="detail-title">-</h2>
626
+ <span class="type-badge" id="detail-type">-</span>
627
+ </div>
628
+ <button id="close-detail">&times;</button>
629
+ </div>
630
+ <div id="detail-content"></div>
631
+ <div id="resize-handle"></div>
632
+ </div>
633
+ </div>
634
+ <div id="toolbar">
635
+ <button class="toolbar-btn" id="btn-fit">Fit View</button>
636
+ <button class="toolbar-btn" id="btn-reset">Reset Filters</button>
637
+ <button class="toolbar-btn" id="btn-physics">Toggle Physics</button>
638
+ </div>
639
+ </div>
640
+ </div>
641
+
642
+ <script>
643
+ // Type colors mapping
644
+ const typeColors = {
645
+ person: '#4caf50',
646
+ company: '#2196f3',
647
+ project: '#ff9800',
648
+ note: '#9c27b0',
649
+ deal: '#f44336',
650
+ yc: '#ff5722',
651
+ civic: '#00bcd4',
652
+ other: '#607d8b',
653
+ };
654
+
655
+ // Safe markdown parser with fallback
656
+ function parseMarkdown(text) {
657
+ if (!text) return '';
658
+ try {
659
+ // marked.js v4+ uses marked.parse(), older versions use marked() directly
660
+ if (typeof marked !== 'undefined') {
661
+ if (typeof marked.parse === 'function') {
662
+ return marked.parse(text);
663
+ } else if (typeof marked === 'function') {
664
+ return marked(text);
665
+ }
666
+ }
667
+ } catch (e) {
668
+ console.warn('Markdown parse error:', e);
669
+ }
670
+ // Fallback: simple text formatting
671
+ const div = document.createElement('div');
672
+ div.textContent = text;
673
+ return div.innerHTML
674
+ .replace(/\\n/g, '<br>')
675
+ .replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')
676
+ .replace(/\\*([^*]+)\\*/g, '<em>$1</em>')
677
+ .replace(/^## (.+)$/gm, '<h2>$1</h2>')
678
+ .replace(/^### (.+)$/gm, '<h3>$1</h3>')
679
+ .replace(/^- (.+)$/gm, '<li>$1</li>');
680
+ }
681
+
682
+ let network = null;
683
+ let graphData = null;
684
+ let nodes = null;
685
+ let edges = null;
686
+ let selectedNode = null;
687
+ let physicsEnabled = true;
688
+ let activeTypes = new Set();
689
+
690
+ // Initialize
691
+ async function init() {
692
+ try {
693
+ const response = await fetch('/api/graph');
694
+ graphData = await response.json();
695
+
696
+ updateStats();
697
+ renderFilters();
698
+ renderNodeList();
699
+ createNetwork();
700
+
701
+ document.getElementById('loading').style.display = 'none';
702
+ } catch (error) {
703
+ document.getElementById('loading').innerHTML =
704
+ '<div style="color: #f44336;">Error loading graph: ' + error + '</div>';
705
+ }
706
+ }
707
+
708
+ function updateStats() {
709
+ const stats = graphData.stats;
710
+ const typeList = Object.entries(stats.types)
711
+ .map(([type, count]) => type + ': ' + count)
712
+ .join(', ');
713
+ document.getElementById('stats').textContent =
714
+ stats.nodes + ' nodes, ' + stats.edges + ' edges | ' + typeList;
715
+ }
716
+
717
+ function renderFilters() {
718
+ const container = document.getElementById('filters');
719
+ const types = Object.keys(graphData.stats.types);
720
+
721
+ activeTypes = new Set(types);
722
+
723
+ container.innerHTML = types.map(type =>
724
+ '<label><input type="checkbox" checked data-type="' + type + '">' +
725
+ '<span class="node-type-dot type-' + type + '"></span> ' + type + '</label>'
726
+ ).join('');
727
+
728
+ container.querySelectorAll('input').forEach(input => {
729
+ input.addEventListener('change', () => {
730
+ if (input.checked) {
731
+ activeTypes.add(input.dataset.type);
732
+ } else {
733
+ activeTypes.delete(input.dataset.type);
734
+ }
735
+ updateNetworkVisibility();
736
+ renderNodeList();
737
+ });
738
+ });
739
+ }
740
+
741
+ function renderNodeList(filter = '') {
742
+ const container = document.getElementById('node-list');
743
+ const filtered = graphData.nodes
744
+ .filter(n => activeTypes.has(n.type))
745
+ .filter(n => !filter ||
746
+ n.label.toLowerCase().includes(filter.toLowerCase()) ||
747
+ n.id.toLowerCase().includes(filter.toLowerCase()))
748
+ .slice(0, 200);
749
+
750
+ container.innerHTML = filtered.map(node =>
751
+ '<div class="node-item' + (selectedNode === node.id ? ' selected' : '') + '" data-slug="' + node.id + '">' +
752
+ '<span class="node-type-dot type-' + node.type + '"></span>' +
753
+ '<span>' + escapeHtml(node.label) + '</span>' +
754
+ '</div>'
755
+ ).join('');
756
+
757
+ container.querySelectorAll('.node-item').forEach(item => {
758
+ item.addEventListener('click', () => {
759
+ selectNode(item.dataset.slug);
760
+ });
761
+ });
762
+ }
763
+
764
+ function createNetwork() {
765
+ nodes = new vis.DataSet(graphData.nodes.map(n => ({
766
+ id: n.id,
767
+ label: n.label,
768
+ group: n.type,
769
+ title: n.title + '\\n(' + n.id + ')',
770
+ color: typeColors[n.type] || typeColors.other,
771
+ font: { color: '#e0e0e0', size: 12 },
772
+ borderWidth: 1,
773
+ borderWidthSelected: 3,
774
+ })));
775
+
776
+ edges = new vis.DataSet(graphData.edges.map(e => ({
777
+ from: e.from,
778
+ to: e.to,
779
+ label: e.label,
780
+ title: e.context,
781
+ arrows: 'to',
782
+ color: { color: '#444', highlight: '#4a9eff' },
783
+ font: { color: '#666', size: 10, strokeWidth: 0 },
784
+ smooth: { type: 'continuous' },
785
+ })));
786
+
787
+ const container = document.getElementById('network');
788
+ const data = { nodes, edges };
789
+ const options = {
790
+ nodes: {
791
+ shape: 'dot',
792
+ size: 16,
793
+ font: { strokeWidth: 0 },
794
+ },
795
+ edges: {
796
+ width: 0.5,
797
+ smooth: { type: 'continuous' },
798
+ },
799
+ physics: {
800
+ enabled: true,
801
+ solver: 'forceAtlas2Based',
802
+ forceAtlas2Based: {
803
+ gravitationalConstant: -50,
804
+ springLength: 100,
805
+ springConstant: 0.08,
806
+ },
807
+ stabilization: { iterations: 100 },
808
+ },
809
+ interaction: {
810
+ hover: true,
811
+ tooltipDelay: 200,
812
+ navigationButtons: true,
813
+ keyboard: true,
814
+ },
815
+ groups: Object.fromEntries(
816
+ Object.entries(typeColors).map(([type, color]) => [type, { color }])
817
+ ),
818
+ };
819
+
820
+ network = new vis.Network(container, data, options);
821
+
822
+ network.on('click', params => {
823
+ if (params.nodes.length > 0) {
824
+ selectNode(params.nodes[0]);
825
+ }
826
+ });
827
+
828
+ network.on('doubleClick', params => {
829
+ if (params.nodes.length > 0) {
830
+ focusNode(params.nodes[0]);
831
+ }
832
+ });
833
+
834
+ // Fit view after stabilization
835
+ network.once('stabilizationIterationsDone', () => {
836
+ network.fit({ animation: true });
837
+ });
838
+ }
839
+
840
+ function updateNetworkVisibility() {
841
+ if (!nodes) return;
842
+
843
+ graphData.nodes.forEach(node => {
844
+ const visible = activeTypes.has(node.type);
845
+ nodes.update({ id: node.id, hidden: !visible });
846
+ });
847
+
848
+ // Also hide edges connected to hidden nodes
849
+ graphData.edges.forEach(edge => {
850
+ const fromNode = graphData.nodes.find(n => n.id === edge.from);
851
+ const toNode = graphData.nodes.find(n => n.id === edge.to);
852
+ const visible = fromNode && toNode &&
853
+ activeTypes.has(fromNode.type) && activeTypes.has(toNode.type);
854
+ edges.update({ id: edge.from + '->' + edge.to, hidden: !visible });
855
+ });
856
+ }
857
+
858
+ async function selectNode(slug) {
859
+ selectedNode = slug;
860
+ renderNodeList(document.getElementById('search-input').value);
861
+
862
+ // Highlight in network
863
+ if (network) {
864
+ network.selectNodes([slug]);
865
+ network.focus(slug, { animation: true, scale: 1 });
866
+ }
867
+
868
+ // Fetch details
869
+ try {
870
+ const response = await fetch('/api/node/' + encodeURIComponent(slug));
871
+ const data = await response.json();
872
+ showNodeDetail(data);
873
+ } catch (error) {
874
+ console.error('Error fetching node details:', error);
875
+ }
876
+ }
877
+
878
+ function focusNode(slug) {
879
+ if (!network) return;
880
+
881
+ // Get connected nodes
882
+ const connectedNodes = new Set([slug]);
883
+ graphData.edges.forEach(e => {
884
+ if (e.from === slug) connectedNodes.add(e.to);
885
+ if (e.to === slug) connectedNodes.add(e.from);
886
+ });
887
+
888
+ // Focus on subgraph
889
+ network.fit({
890
+ nodes: Array.from(connectedNodes),
891
+ animation: true,
892
+ });
893
+ }
894
+
895
+ function showNodeDetail(data) {
896
+ const page = data.page;
897
+ const detail = document.getElementById('node-detail');
898
+ const content = document.getElementById('detail-content');
899
+
900
+ document.getElementById('detail-title').textContent = page.title;
901
+ document.getElementById('detail-type').textContent = page.type;
902
+
903
+ let html = '';
904
+
905
+ // Compiled truth - render as markdown
906
+ if (page.compiledTruth) {
907
+ const renderedMd = parseMarkdown(page.compiledTruth);
908
+ html += '<div class="detail-section">' +
909
+ '<h3>Compiled Truth</h3>' +
910
+ '<div class="markdown-content">' + renderedMd + '</div>' +
911
+ '</div>';
912
+ }
913
+
914
+ // Outgoing links
915
+ if (data.outgoingLinks && data.outgoingLinks.length > 0) {
916
+ html += '<div class="detail-section">' +
917
+ '<h3>Links To (' + data.outgoingLinks.length + ')</h3>' +
918
+ data.outgoingLinks.map(l =>
919
+ '<div class="link-item"><a href="#" data-slug="' + l.to_slug + '">' +
920
+ escapeHtml(l.to_slug) + '</a> <span style="color:#888">(' +
921
+ escapeHtml(l.context.slice(0, 50)) + ')</span></div>'
922
+ ).join('') +
923
+ '</div>';
924
+ }
925
+
926
+ // Backlinks
927
+ if (data.backlinks && data.backlinks.length > 0) {
928
+ html += '<div class="detail-section">' +
929
+ '<h3>Referenced By (' + data.backlinks.length + ')</h3>' +
930
+ data.backlinks.map(slug =>
931
+ '<div class="link-item"><a href="#" data-slug="' + slug + '">' +
932
+ escapeHtml(slug) + '</a></div>'
933
+ ).join('') +
934
+ '</div>';
935
+ }
936
+
937
+ // Timeline
938
+ if (data.timeline && data.timeline.length > 0) {
939
+ html += '<div class="detail-section">' +
940
+ '<h3>Timeline</h3>' +
941
+ data.timeline.map(t =>
942
+ '<div class="timeline-item">' +
943
+ '<div class="timeline-date">' + t.date + ' | ' + t.source + '</div>' +
944
+ '<div class="timeline-summary">' + escapeHtml(t.summary) + '</div>' +
945
+ (t.detail ? '<div class="timeline-detail markdown-content">' + parseMarkdown(t.detail) + '</div>' : '') +
946
+ '</div>'
947
+ ).join('') +
948
+ '</div>';
949
+ }
950
+
951
+ content.innerHTML = html;
952
+
953
+ // Add click handlers for links
954
+ content.querySelectorAll('a[data-slug]').forEach(a => {
955
+ a.addEventListener('click', e => {
956
+ e.preventDefault();
957
+ selectNode(a.dataset.slug);
958
+ });
959
+ });
960
+
961
+ // Initialize position if not already set
962
+ if (!detail.style.left) {
963
+ const container = document.getElementById('graph-container');
964
+ const containerRect = container.getBoundingClientRect();
965
+ detail.style.left = (containerRect.width - 376) + 'px';
966
+ detail.style.top = '16px';
967
+ }
968
+
969
+ detail.classList.add('visible');
970
+ }
971
+
972
+ function escapeHtml(text) {
973
+ const div = document.createElement('div');
974
+ div.textContent = text || '';
975
+ return div.innerHTML;
976
+ }
977
+
978
+ // Event listeners
979
+ document.getElementById('search-input').addEventListener('input', e => {
980
+ renderNodeList(e.target.value);
981
+ });
982
+
983
+ document.getElementById('close-detail').addEventListener('click', () => {
984
+ document.getElementById('node-detail').classList.remove('visible');
985
+ selectedNode = null;
986
+ if (network) network.unselectAll();
987
+ renderNodeList(document.getElementById('search-input').value);
988
+ });
989
+
990
+ document.getElementById('btn-fit').addEventListener('click', () => {
991
+ if (network) network.fit({ animation: true });
992
+ });
993
+
994
+ document.getElementById('btn-reset').addEventListener('click', () => {
995
+ document.querySelectorAll('#filters input').forEach(input => {
996
+ input.checked = true;
997
+ activeTypes.add(input.dataset.type);
998
+ });
999
+ updateNetworkVisibility();
1000
+ renderNodeList();
1001
+ });
1002
+
1003
+ document.getElementById('btn-physics').addEventListener('click', () => {
1004
+ physicsEnabled = !physicsEnabled;
1005
+ if (network) {
1006
+ network.setOptions({ physics: { enabled: physicsEnabled } });
1007
+ }
1008
+ });
1009
+
1010
+ // Drag to move node-detail panel
1011
+ const nodeDetail = document.getElementById('node-detail');
1012
+ const detailHeader = document.getElementById('detail-header');
1013
+ const resizeHandle = document.getElementById('resize-handle');
1014
+ let isDragging = false;
1015
+ let isResizing = false;
1016
+ let dragStartX, dragStartY, elemStartX, elemStartY;
1017
+ let resizeStartX, resizeStartY, startWidth, startHeight;
1018
+
1019
+ detailHeader.addEventListener('mousedown', (e) => {
1020
+ // Don't drag when clicking close button
1021
+ if (e.target.id === 'close-detail') return;
1022
+
1023
+ isDragging = true;
1024
+ dragStartX = e.clientX;
1025
+ dragStartY = e.clientY;
1026
+ elemStartX = parseInt(nodeDetail.style.left) || nodeDetail.getBoundingClientRect().left;
1027
+ elemStartY = parseInt(nodeDetail.style.top) || nodeDetail.getBoundingClientRect().top;
1028
+ e.preventDefault();
1029
+ });
1030
+
1031
+ resizeHandle.addEventListener('mousedown', (e) => {
1032
+ isResizing = true;
1033
+ resizeStartX = e.clientX;
1034
+ resizeStartY = e.clientY;
1035
+ startWidth = nodeDetail.offsetWidth;
1036
+ startHeight = nodeDetail.offsetHeight;
1037
+ e.preventDefault();
1038
+ e.stopPropagation();
1039
+ });
1040
+
1041
+ document.addEventListener('mousemove', (e) => {
1042
+ if (isDragging) {
1043
+ const dx = e.clientX - dragStartX;
1044
+ const dy = e.clientY - dragStartY;
1045
+ const newX = Math.max(0, Math.min(window.innerWidth - nodeDetail.offsetWidth, elemStartX + dx));
1046
+ const newY = Math.max(0, Math.min(window.innerHeight - nodeDetail.offsetHeight, elemStartY + dy));
1047
+ nodeDetail.style.left = newX + 'px';
1048
+ nodeDetail.style.top = newY + 'px';
1049
+ }
1050
+ if (isResizing) {
1051
+ const dx = e.clientX - resizeStartX;
1052
+ const dy = e.clientY - resizeStartY;
1053
+ const newWidth = Math.max(280, Math.min(window.innerWidth - 400, startWidth + dx));
1054
+ const newHeight = Math.max(200, Math.min(window.innerHeight - 32, startHeight + dy));
1055
+ nodeDetail.style.width = newWidth + 'px';
1056
+ nodeDetail.style.height = newHeight + 'px';
1057
+ }
1058
+ });
1059
+
1060
+ document.addEventListener('mouseup', () => {
1061
+ isDragging = false;
1062
+ isResizing = false;
1063
+ });
1064
+
1065
+ // Start
1066
+ init();
1067
+ </script>
1068
+ </body>
1069
+ </html>`;
1070
+ }