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.
- package/README.md +87 -37
- package/package.json +6 -5
- package/src/ai/compiler.ts +494 -0
- package/src/ai/embed-factory.ts +116 -0
- package/src/ai/entity-link.ts +195 -0
- package/src/ai/hash-embed.ts +30 -0
- package/src/ai/llm-client.ts +291 -0
- package/src/ai/timeline-extractor.ts +403 -0
- package/src/cli.ts +16 -0
- package/src/commands/compile-cmd.ts +208 -0
- package/src/commands/graph-cmd.ts +1070 -0
- package/src/commands/index.ts +1973 -0
- package/src/config.ts +80 -0
- package/src/db/client.ts +207 -0
- package/src/db/errors.ts +178 -0
- package/src/db/schema.ts +50 -0
- package/src/markdown/io.ts +61 -0
- package/src/markdown/parser.ts +72 -0
- package/src/mcp/server.ts +703 -0
- package/src/repositories/brain-repo.ts +990 -0
- package/src/settings.ts +235 -0
- package/src/types/index.ts +56 -0
- package/src/utils/cli-output.ts +569 -0
- package/src/utils/progress.ts +171 -0
- package/src/utils/query-sanitizer.ts +63 -0
- package/dist/cli.js +0 -93543
|
@@ -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">×</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
|
+
}
|