synapse-framework 0.2.4__py3-none-any.whl

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.
Files changed (54) hide show
  1. synapse/__init__.py +207 -0
  2. synapse/app.py +942 -0
  3. synapse/cli.py +88 -0
  4. synapse/config.py +103 -0
  5. synapse/connectors/__init__.py +29 -0
  6. synapse/connectors/base.py +222 -0
  7. synapse/connectors/factory.py +202 -0
  8. synapse/connectors/gmail.py +292 -0
  9. synapse/connectors/mcp_wrapper.py +153 -0
  10. synapse/connectors/notion.py +161 -0
  11. synapse/connectors/photos.py +141 -0
  12. synapse/database/__init__.py +19 -0
  13. synapse/database/base.py +63 -0
  14. synapse/database/chroma.py +175 -0
  15. synapse/database/memory.py +132 -0
  16. synapse/database/pgvector.py +99 -0
  17. synapse/database/qdrant.py +138 -0
  18. synapse/embeddings/__init__.py +21 -0
  19. synapse/embeddings/base.py +262 -0
  20. synapse/export/__init__.py +14 -0
  21. synapse/export/mcp_exporter.py +202 -0
  22. synapse/graph/__init__.py +21 -0
  23. synapse/graph/base.py +130 -0
  24. synapse/graph/memory.py +20 -0
  25. synapse/graph/neo4j_store.py +176 -0
  26. synapse/graph/networkx_store.py +221 -0
  27. synapse/inspector/__init__.py +5 -0
  28. synapse/inspector/template.py +530 -0
  29. synapse/models/__init__.py +73 -0
  30. synapse/models/base.py +95 -0
  31. synapse/models/gemini.py +328 -0
  32. synapse/models/ollama.py +322 -0
  33. synapse/models/openai.py +289 -0
  34. synapse/oauth/__init__.py +7 -0
  35. synapse/oauth/google.py +194 -0
  36. synapse/oauth/notion.py +35 -0
  37. synapse/rag/__init__.py +8 -0
  38. synapse/rag/chunker.py +219 -0
  39. synapse/rag/hybrid_rag.py +122 -0
  40. synapse/resource/__init__.py +5 -0
  41. synapse/resource/resource.py +304 -0
  42. synapse/resources/__init__.py +5 -0
  43. synapse/skills/__init__.py +5 -0
  44. synapse/skills/skill.py +321 -0
  45. synapse/storage/__init__.py +24 -0
  46. synapse/storage/graph_exporter.py +624 -0
  47. synapse/storage/graph_store.py +162 -0
  48. synapse/storage/vector_store.py +103 -0
  49. synapse_framework-0.2.4.dist-info/METADATA +415 -0
  50. synapse_framework-0.2.4.dist-info/RECORD +54 -0
  51. synapse_framework-0.2.4.dist-info/WHEEL +5 -0
  52. synapse_framework-0.2.4.dist-info/entry_points.txt +2 -0
  53. synapse_framework-0.2.4.dist-info/licenses/LICENSE +21 -0
  54. synapse_framework-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,624 @@
1
+ """WebGL 3D Knowledge Graph HTML exporter using Solid Matte / Zinc styling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from synapse.config import DESIGN_TOKENS
10
+
11
+
12
+ class GraphExporter:
13
+ """Exports knowledge graph state into a standalone 3D WebGL HTML visualizer."""
14
+
15
+ @classmethod
16
+ def to_webgl_json(cls, graph_source: Any) -> dict[str, Any]:
17
+ """Converts graph into node and link lists for 3D force simulation."""
18
+ default_palette = DESIGN_TOKENS["palettes"]["zinc"]
19
+
20
+ # 1. Any BaseGraphStore or custom store providing get_3d_payload
21
+ if hasattr(graph_source, "get_3d_payload"):
22
+ payload = graph_source.get_3d_payload()
23
+ if isinstance(payload, dict) and "nodes" in payload and "links" in payload:
24
+ return payload
25
+
26
+ # 2. Raw dict containing nodes and links
27
+ if isinstance(graph_source, dict) and "nodes" in graph_source and "links" in graph_source:
28
+ return graph_source
29
+
30
+ # 3. NetworkX Graph or wrapper with .graph attribute
31
+ underlying = getattr(graph_source, "graph", graph_source)
32
+ if hasattr(underlying, "nodes") and hasattr(underlying, "edges"):
33
+ nodes: list[dict[str, Any]] = []
34
+ for node_id, data in underlying.nodes(data=True):
35
+ node_type = data.get("type", "Default")
36
+ color = data.get("color")
37
+ if not color:
38
+ color = default_palette.get(node_type, default_palette.get("Default", "#71717a"))
39
+ degree = underlying.degree(node_id) if hasattr(underlying, "degree") else 1
40
+ nodes.append({
41
+ "id": str(node_id),
42
+ "name": str(node_id),
43
+ "type": node_type,
44
+ "color": color,
45
+ "val": max(3.5, min(12.0, degree * 2.0)),
46
+ })
47
+
48
+ links: list[dict[str, Any]] = []
49
+ for u, v, data in underlying.edges(data=True):
50
+ links.append({
51
+ "source": str(u),
52
+ "target": str(v),
53
+ "relation": data.get("relation", "CONNECTED_TO"),
54
+ })
55
+
56
+ return {"nodes": nodes, "links": links}
57
+
58
+ return {"nodes": [], "links": []}
59
+
60
+ @classmethod
61
+ def render_html(cls, graph_source: Any) -> str:
62
+ """Renders a self-contained 3D WebGL visualizer HTML string."""
63
+ payload = cls.to_webgl_json(graph_source)
64
+ payload_json = json.dumps(payload, indent=2)
65
+ palettes_json = json.dumps(DESIGN_TOKENS["palettes"], indent=2)
66
+
67
+ font_family_js = json.dumps(DESIGN_TOKENS["text"]["font_family"])
68
+ canvas_bg_js = json.dumps(DESIGN_TOKENS["surface"]["canvas"])
69
+ panel_bg_js = json.dumps(DESIGN_TOKENS["surface"]["panel"])
70
+ border_subtle_js = json.dumps(DESIGN_TOKENS["border"]["subtle"])
71
+ text_primary_js = json.dumps(DESIGN_TOKENS["text"]["primary"])
72
+ text_secondary_js = json.dumps(DESIGN_TOKENS["text"]["secondary"])
73
+
74
+ return f"""<!DOCTYPE html>
75
+ <html lang="en">
76
+ <head>
77
+ <meta charset="UTF-8" />
78
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
79
+ <title>Synapse // Knowledge Graph Inspector</title>
80
+ <style>
81
+ * {{ box-sizing: border-box; margin: 0; padding: 0; user-select: none; }}
82
+ html, body {{
83
+ width: 100%; height: 100%; overflow: hidden;
84
+ background: {DESIGN_TOKENS["surface"]["canvas"]};
85
+ font-family: {DESIGN_TOKENS["text"]["font_family"]};
86
+ color: {DESIGN_TOKENS["text"]["primary"]};
87
+ }}
88
+ .matte-panel {{
89
+ background: {DESIGN_TOKENS["surface"]["panel"]};
90
+ border: 1px solid {DESIGN_TOKENS["border"]["subtle"]};
91
+ border-radius: 6px;
92
+ backdrop-filter: blur(8px);
93
+ }}
94
+ #header-hud {{
95
+ position: absolute; top: 16px; left: 16px; z-index: 20;
96
+ height: 36px; padding: 0 12px; display: flex; align-items: center; gap: 10px;
97
+ }}
98
+ .status-dot {{
99
+ width: 7px; height: 7px; border-radius: 50%; background: #10b981;
100
+ box-shadow: 0 0 8px #10b981;
101
+ }}
102
+ .app-title {{ font-size: 0.82rem; font-weight: 500; letter-spacing: -0.01em; }}
103
+ .node-counter {{ font-size: 0.72rem; color: {DESIGN_TOKENS["text"]["secondary"]}; margin-left: 4px; }}
104
+ #control-hud {{
105
+ position: absolute; top: 16px; right: 16px; z-index: 20;
106
+ display: flex; align-items: center; gap: 8px;
107
+ }}
108
+ .control-item {{
109
+ height: 36px; display: flex; align-items: center; padding: 0 10px; gap: 8px;
110
+ }}
111
+ .search-input {{
112
+ background: transparent; border: none; outline: none;
113
+ color: {DESIGN_TOKENS["text"]["primary"]}; font-size: 0.82rem; width: 170px;
114
+ transition: width 0.2s ease;
115
+ }}
116
+ .search-input:focus {{ width: 230px; }}
117
+ .search-badge {{
118
+ display: none; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px;
119
+ background: #27272a; color: #38bdf8; font-weight: 500; white-space: nowrap;
120
+ }}
121
+ .palette-select {{
122
+ background: transparent; border: none; outline: none;
123
+ color: {DESIGN_TOKENS["text"]["primary"]}; font-size: 0.8rem; cursor: pointer;
124
+ }}
125
+ .palette-select option {{
126
+ background: {DESIGN_TOKENS["surface"]["panel"]}; color: {DESIGN_TOKENS["text"]["primary"]};
127
+ }}
128
+ .action-btn {{
129
+ background: transparent; border: none; outline: none; cursor: pointer;
130
+ color: {DESIGN_TOKENS["text"]["secondary"]}; font-size: 0.78rem; display: flex;
131
+ align-items: center; gap: 4px; padding: 4px 8px; border-radius: 4px;
132
+ transition: background 0.15s, color 0.15s;
133
+ }}
134
+ .action-btn:hover {{
135
+ background: {DESIGN_TOKENS["border"]["subtle"]}; color: {DESIGN_TOKENS["text"]["primary"]};
136
+ }}
137
+ #legend-bar {{
138
+ position: absolute; bottom: 16px; left: 16px; z-index: 20;
139
+ display: flex; align-items: center; gap: 10px; padding: 6px 12px;
140
+ font-size: 0.72rem; color: {DESIGN_TOKENS["text"]["secondary"]};
141
+ max-width: calc(100vw - 32px); overflow-x: auto;
142
+ }}
143
+ .legend-chip {{
144
+ display: flex; align-items: center; gap: 6px; cursor: pointer;
145
+ padding: 3px 6px; border-radius: 4px; transition: background 0.15s;
146
+ white-space: nowrap;
147
+ }}
148
+ .legend-chip:hover {{
149
+ background: {DESIGN_TOKENS["border"]["subtle"]}; color: {DESIGN_TOKENS["text"]["primary"]};
150
+ }}
151
+ .legend-chip.active-filter {{
152
+ background: #27272a; color: #38bdf8; font-weight: 500;
153
+ }}
154
+ .legend-indicator {{
155
+ width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex-shrink: 0;
156
+ }}
157
+ #node-card {{
158
+ position: absolute; bottom: 64px; right: 16px; z-index: 25;
159
+ width: 280px; padding: 14px; display: none; flex-direction: column; gap: 8px;
160
+ box-shadow: 0 10px 25px rgba(0,0,0,0.5);
161
+ }}
162
+ .card-header {{
163
+ display: flex; align-items: center; justify-content: space-between;
164
+ }}
165
+ .card-title {{
166
+ font-size: 0.88rem; font-weight: 600; color: {DESIGN_TOKENS["text"]["primary"]};
167
+ word-break: break-word;
168
+ }}
169
+ .card-close {{
170
+ cursor: pointer; color: {DESIGN_TOKENS["text"]["secondary"]}; font-size: 0.9rem;
171
+ padding: 2px 6px; border-radius: 4px;
172
+ }}
173
+ .card-close:hover {{ background: {DESIGN_TOKENS["border"]["subtle"]}; color: #fff; }}
174
+ .card-badge {{
175
+ display: inline-flex; align-items: center; gap: 6px; font-size: 0.72rem;
176
+ padding: 2px 8px; border-radius: 10px; background: #27272a; width: fit-content;
177
+ }}
178
+ .card-relations {{
179
+ font-size: 0.74rem; color: {DESIGN_TOKENS["text"]["secondary"]};
180
+ max-height: 120px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;
181
+ margin-top: 4px; border-top: 1px solid {DESIGN_TOKENS["border"]["subtle"]}; padding-top: 6px;
182
+ }}
183
+ .rel-item {{ word-break: break-word; line-height: 1.3; }}
184
+ .rel-tag {{ color: #a1a1aa; font-weight: 500; }}
185
+ #graph-viewport {{ position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 1; }}
186
+ </style>
187
+ <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
188
+ <script src="https://cdn.jsdelivr.net/npm/3d-force-graph@1.76.2/dist/3d-force-graph.min.js"></script>
189
+ <script src="https://cdn.jsdelivr.net/npm/three-spritetext@1.9.0/dist/three-spritetext.min.js"></script>
190
+ </head>
191
+ <body>
192
+ <div id="header-hud" class="matte-panel">
193
+ <div class="status-dot"></div>
194
+ <span class="app-title">Synapse // Knowledge Graph</span>
195
+ <span id="node-count-label" class="node-counter"></span>
196
+ </div>
197
+
198
+ <div id="control-hud">
199
+ <div class="matte-panel control-item">
200
+ <select id="palette-select" class="palette-select" title="Change color palette">
201
+ <option value="zinc" selected>Zinc (Default)</option>
202
+ <option value="stone">Warm Stone</option>
203
+ <option value="basalt">Cool Basalt</option>
204
+ <option value="neon">Cyberpunk Neon</option>
205
+ <option value="custom">Developer Custom Colors (.color)</option>
206
+ </select>
207
+ </div>
208
+ <div class="matte-panel control-item">
209
+ <input type="text" id="search-input" class="search-input" placeholder="Find node... (⌘K)" autocomplete="off" />
210
+ <span id="search-badge" class="search-badge"></span>
211
+ </div>
212
+ <div class="matte-panel control-item" style="padding: 0 4px;">
213
+ <button id="btn-recenter" class="action-btn" title="Recenter Camera (Fit Graph)">
214
+ <span>⊙</span> Fit
215
+ </button>
216
+ </div>
217
+ </div>
218
+
219
+ <div id="legend-bar" class="matte-panel"></div>
220
+
221
+ <div id="node-card" class="matte-panel">
222
+ <div class="card-header">
223
+ <span id="card-name" class="card-title"></span>
224
+ <span id="card-close-btn" class="card-close">✕</span>
225
+ </div>
226
+ <div class="card-badge">
227
+ <span id="card-dot" class="legend-indicator"></span>
228
+ <span id="card-type"></span>
229
+ </div>
230
+ <div id="card-degree" style="font-size: 0.72rem; color: #a1a1aa;"></div>
231
+ <div id="card-relations" class="card-relations"></div>
232
+ </div>
233
+
234
+ <div id="graph-viewport"></div>
235
+
236
+ <script>
237
+ const gData = {payload_json};
238
+ const PALETTES = {palettes_json};
239
+ const fontName = {font_family_js};
240
+ const canvasBg = {canvas_bg_js};
241
+ const panelBg = {panel_bg_js};
242
+ const borderSubtle = {border_subtle_js};
243
+ const textPri = {text_primary_js};
244
+ const textSec = {text_secondary_js};
245
+
246
+ const viewport = document.getElementById('graph-viewport');
247
+ const searchInput = document.getElementById('search-input');
248
+ const searchBadge = document.getElementById('search-badge');
249
+ const paletteSelect = document.getElementById('palette-select');
250
+ const legendBar = document.getElementById('legend-bar');
251
+ const nodeCard = document.getElementById('node-card');
252
+ const nodeCountLabel = document.getElementById('node-count-label');
253
+ const btnRecenter = document.getElementById('btn-recenter');
254
+
255
+ let currentPaletteKey = 'zinc';
256
+ let matchedNodeIds = new Set();
257
+ let selectedNode = null;
258
+ let activeFilterType = null;
259
+
260
+ if (nodeCountLabel) {{
261
+ nodeCountLabel.textContent = '(' + gData.nodes.length + ' nodes, ' + gData.links.length + ' relations)';
262
+ }}
263
+
264
+ // Deterministic hue generator for unknown dynamic types
265
+ function getDeterministicColor(str) {{
266
+ let hash = 0;
267
+ for (let i = 0; i < str.length; i++) {{
268
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
269
+ }}
270
+ const hue = Math.abs(hash % 360);
271
+ return `hsl(${{hue}}, 72%, 60%)`;
272
+ }}
273
+
274
+ // Robust color resolution supporting explicit node.color and active theme palettes
275
+ function getNodeColor(node, paletteKey) {{
276
+ const pal = PALETTES[paletteKey] || PALETTES['zinc'] || {{}};
277
+
278
+ // 1. If user selected explicit "Developer Custom Colors (.color)"
279
+ if (paletteKey === 'custom') {{
280
+ if (node.color && node.color !== '#71717a' && node.color !== '#94a3b8') {{
281
+ return node.color;
282
+ }}
283
+ return pal[node.type] || getDeterministicColor(node.type || 'Default');
284
+ }}
285
+
286
+ // 2. Default Zinc Theme: prioritize explicit custom .color if provided by user!
287
+ if (paletteKey === 'zinc') {{
288
+ if (node.color && node.color !== '#71717a' && node.color !== '#94a3b8') {{
289
+ return node.color;
290
+ }}
291
+ if (pal[node.type]) return pal[node.type];
292
+ return getDeterministicColor(node.type || 'Default');
293
+ }}
294
+
295
+ // 3. For chosen theme palettes (Stone, Basalt, Neon):
296
+ // Apply theme palette for the entity type!
297
+ if (pal && pal[node.type]) {{
298
+ return pal[node.type];
299
+ }}
300
+ // If node has an explicit custom color, honor it
301
+ if (node.color && node.color !== '#71717a' && node.color !== '#94a3b8') {{
302
+ return node.color;
303
+ }}
304
+ return pal['Default'] || getDeterministicColor(node.type || 'Default');
305
+ }}
306
+
307
+ // Dynamic legend generation based on actual types present in the graph
308
+ function syncLegend() {{
309
+ if (!legendBar) return;
310
+ legendBar.innerHTML = '';
311
+
312
+ const typesInGraph = new Set();
313
+ gData.nodes.forEach(n => {{
314
+ typesInGraph.add(n.type || 'Default');
315
+ }});
316
+
317
+ Array.from(typesInGraph).sort().forEach(type => {{
318
+ const chip = document.createElement('div');
319
+ chip.className = 'legend-chip';
320
+ if (activeFilterType === type) chip.classList.add('active-filter');
321
+ chip.title = `Filter by ${{type}}`;
322
+
323
+ const sampleNode = gData.nodes.find(n => n.type === type) || {{ type: type }};
324
+ const color = getNodeColor(sampleNode, currentPaletteKey);
325
+
326
+ const indicator = document.createElement('span');
327
+ indicator.className = 'legend-indicator';
328
+ indicator.style.backgroundColor = color;
329
+
330
+ const label = document.createElement('span');
331
+ label.textContent = type;
332
+
333
+ chip.appendChild(indicator);
334
+ chip.appendChild(label);
335
+
336
+ chip.addEventListener('click', (e) => {{
337
+ e.stopPropagation();
338
+ if (activeFilterType === type) {{
339
+ activeFilterType = null;
340
+ searchInput.value = '';
341
+ performSearch('');
342
+ }} else {{
343
+ activeFilterType = type;
344
+ searchInput.value = type;
345
+ performSearch(type);
346
+ }}
347
+ syncLegend();
348
+ }});
349
+
350
+ legendBar.appendChild(chip);
351
+ }});
352
+ }}
353
+
354
+ // Apply palette to all nodes in the 3D scene
355
+ function applyPalette(paletteKey) {{
356
+ currentPaletteKey = paletteKey;
357
+ syncLegend();
358
+
359
+ gData.nodes.forEach(node => {{
360
+ if (node.__mesh && node.__mesh.material) {{
361
+ const isMatched = matchedNodeIds.size === 0 || matchedNodeIds.has(node.id);
362
+ if (isMatched) {{
363
+ const newColor = getNodeColor(node, currentPaletteKey);
364
+ node.__mesh.material.color.set(newColor);
365
+ }}
366
+ }}
367
+ }});
368
+ }}
369
+
370
+ // Search and highlight logic
371
+ function performSearch(query) {{
372
+ const q = query.trim().toLowerCase();
373
+ matchedNodeIds.clear();
374
+
375
+ if (!q) {{
376
+ activeFilterType = null;
377
+ if (searchBadge) {{
378
+ searchBadge.style.display = 'none';
379
+ searchBadge.textContent = '';
380
+ }}
381
+
382
+ gData.nodes.forEach(node => {{
383
+ if (node.__mesh) {{
384
+ node.__mesh.material.opacity = 1.0;
385
+ node.__mesh.material.transparent = false;
386
+ node.__mesh.scale.set(1, 1, 1);
387
+ const col = getNodeColor(node, currentPaletteKey);
388
+ node.__mesh.material.color.set(col);
389
+ }}
390
+ if (node.__sprite) {{
391
+ node.__sprite.visible = true;
392
+ node.__sprite.color = textSec;
393
+ }}
394
+ }});
395
+ syncLegend();
396
+ return;
397
+ }}
398
+
399
+ let firstMatch = null;
400
+ gData.nodes.forEach(node => {{
401
+ const nameMatch = (node.name || '').toLowerCase().includes(q);
402
+ const idMatch = (node.id || '').toLowerCase().includes(q);
403
+ const typeMatch = (node.type || '').toLowerCase().includes(q);
404
+
405
+ if (nameMatch || idMatch || typeMatch) {{
406
+ matchedNodeIds.add(node.id);
407
+ if (!firstMatch) firstMatch = node;
408
+ }}
409
+ }});
410
+
411
+ const count = matchedNodeIds.size;
412
+ if (searchBadge) {{
413
+ searchBadge.style.display = 'inline-block';
414
+ searchBadge.textContent = count === 0 ? '0 found' : `${{count}} found (↵)`;
415
+ }}
416
+
417
+ gData.nodes.forEach(node => {{
418
+ const isMatched = matchedNodeIds.has(node.id);
419
+ if (node.__mesh) {{
420
+ if (isMatched) {{
421
+ node.__mesh.material.transparent = false;
422
+ node.__mesh.material.opacity = 1.0;
423
+ node.__mesh.scale.set(1.4, 1.4, 1.4);
424
+ // Highlight color
425
+ node.__mesh.material.color.set('#38bdf8');
426
+ }} else {{
427
+ node.__mesh.material.transparent = true;
428
+ node.__mesh.material.opacity = 0.12;
429
+ node.__mesh.scale.set(0.75, 0.75, 0.75);
430
+ node.__mesh.material.color.set('#3f3f46');
431
+ }}
432
+ }}
433
+ if (node.__sprite) {{
434
+ if (isMatched) {{
435
+ node.__sprite.visible = true;
436
+ node.__sprite.color = '#ffffff';
437
+ }} else {{
438
+ node.__sprite.visible = false;
439
+ }}
440
+ }}
441
+ }});
442
+ }}
443
+
444
+ // Fly camera smoothly to a target node
445
+ function flyToNode(node) {{
446
+ if (!node) return;
447
+ const x = node.x || 0;
448
+ const y = node.y || 0;
449
+ const z = node.z || 0;
450
+ const distance = 90;
451
+ const currentHypot = Math.hypot(x, y, z) || 1;
452
+ const distRatio = 1 + distance / currentHypot;
453
+
454
+ Graph.cameraPosition(
455
+ {{ x: x * distRatio, y: y * distRatio, z: z * distRatio }},
456
+ {{ x: x, y: y, z: z }},
457
+ 1000
458
+ );
459
+ }}
460
+
461
+ // Display interactive node details card
462
+ function showNodeCard(node) {{
463
+ if (!nodeCard || !node) return;
464
+ selectedNode = node;
465
+
466
+ document.getElementById('card-name').textContent = node.name || node.id;
467
+ document.getElementById('card-type').textContent = node.type || 'Default';
468
+ const col = getNodeColor(node, currentPaletteKey);
469
+ document.getElementById('card-dot').style.backgroundColor = col;
470
+
471
+ // Find relations
472
+ const rels = [];
473
+ gData.links.forEach(l => {{
474
+ const srcId = typeof l.source === 'object' ? l.source.id : l.source;
475
+ const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
476
+ if (srcId === node.id) {{
477
+ rels.push(`→ ${{l.relation || 'CONNECTED_TO'}} → ${{tgtId}}`);
478
+ }} else if (tgtId === node.id) {{
479
+ rels.push(`← ${{l.relation || 'CONNECTED_TO'}} ← ${{srcId}}`);
480
+ }}
481
+ }});
482
+
483
+ document.getElementById('card-degree').textContent = `${{rels.length}} connection${{rels.length === 1 ? '' : 's'}}`;
484
+ const relsContainer = document.getElementById('card-relations');
485
+ relsContainer.innerHTML = '';
486
+ if (rels.length === 0) {{
487
+ relsContainer.innerHTML = '<span style="color: #71717a;">No direct connections</span>';
488
+ }} else {{
489
+ rels.forEach(r => {{
490
+ const div = document.createElement('div');
491
+ div.className = 'rel-item';
492
+ div.textContent = r;
493
+ relsContainer.appendChild(div);
494
+ }});
495
+ }}
496
+
497
+ nodeCard.style.display = 'flex';
498
+ }}
499
+
500
+ // Initialize ForceGraph3D
501
+ const Graph = ForceGraph3D()(viewport)
502
+ .graphData(gData)
503
+ .backgroundColor(canvasBg)
504
+ .nodeThreeObject(node => {{
505
+ const group = new THREE.Group();
506
+ const radius = Math.max(2.8, (node.val || 4) * 0.7);
507
+ const sphereGeo = new THREE.SphereGeometry(radius, 18, 18);
508
+
509
+ const initialColor = getNodeColor(node, currentPaletteKey);
510
+ const sphereMat = new THREE.MeshLambertMaterial({{
511
+ color: initialColor,
512
+ transparent: true,
513
+ opacity: 1.0,
514
+ }});
515
+ const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
516
+ group.add(sphereMesh);
517
+
518
+ const sprite = new SpriteText(node.name || node.id);
519
+ sprite.material.depthWrite = false;
520
+ sprite.color = textSec;
521
+ sprite.textHeight = 3.0;
522
+ sprite.fontFace = fontName;
523
+ sprite.position.y = - (radius + 3.0);
524
+ group.add(sprite);
525
+
526
+ node.__mesh = sphereMesh;
527
+ node.__sprite = sprite;
528
+ node.__radius = radius;
529
+ return group;
530
+ }})
531
+ .linkLabel(link => link.relation || '')
532
+ .linkColor(() => borderSubtle)
533
+ .linkWidth(0.8)
534
+ .linkOpacity(0.5)
535
+ .onNodeClick(node => {{
536
+ flyToNode(node);
537
+ showNodeCard(node);
538
+ }})
539
+ .onNodeHover(node => {{
540
+ viewport.style.cursor = node ? 'pointer' : 'default';
541
+ }})
542
+ .onBackgroundClick(() => {{
543
+ if (nodeCard) nodeCard.style.display = 'none';
544
+ }});
545
+
546
+ // Initialize legend
547
+ syncLegend();
548
+
549
+ // Event Listener 1: Palette / Theme Switcher
550
+ if (paletteSelect) {{
551
+ paletteSelect.addEventListener('change', (e) => {{
552
+ applyPalette(e.target.value);
553
+ }});
554
+ }}
555
+
556
+ // Event Listener 2: Real-time Search Input
557
+ if (searchInput) {{
558
+ searchInput.addEventListener('input', (e) => {{
559
+ performSearch(e.target.value);
560
+ }});
561
+
562
+ searchInput.addEventListener('keydown', (e) => {{
563
+ if (e.key === 'Enter') {{
564
+ if (matchedNodeIds.size > 0) {{
565
+ const firstId = Array.from(matchedNodeIds)[0];
566
+ const target = gData.nodes.find(n => n.id === firstId);
567
+ if (target) {{
568
+ flyToNode(target);
569
+ showNodeCard(target);
570
+ }}
571
+ }}
572
+ }} else if (e.key === 'Escape') {{
573
+ searchInput.value = '';
574
+ performSearch('');
575
+ searchInput.blur();
576
+ }}
577
+ }});
578
+ }}
579
+
580
+ // Global shortcut: ⌘K or Ctrl+K to focus search
581
+ window.addEventListener('keydown', (e) => {{
582
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {{
583
+ e.preventDefault();
584
+ if (searchInput) {{
585
+ searchInput.focus();
586
+ searchInput.select();
587
+ }}
588
+ }}
589
+ }});
590
+
591
+ // Event Listener 3: Recenter Button
592
+ if (btnRecenter) {{
593
+ btnRecenter.addEventListener('click', () => {{
594
+ Graph.zoomToFit(800, 35);
595
+ }});
596
+ }}
597
+
598
+ // Event Listener 4: Close card
599
+ const cardCloseBtn = document.getElementById('card-close-btn');
600
+ if (cardCloseBtn) {{
601
+ cardCloseBtn.addEventListener('click', () => {{
602
+ if (nodeCard) nodeCard.style.display = 'none';
603
+ }});
604
+ }}
605
+
606
+ // Initial Zoom to Fit
607
+ setTimeout(() => Graph.zoomToFit(800, 35), 450);
608
+ </script>
609
+ </body>
610
+ </html>"""
611
+
612
+ @classmethod
613
+ def export_to_html(
614
+ cls,
615
+ graph_source: Any,
616
+ output_path: str | Path = "graph_preview.html",
617
+ ) -> Path:
618
+ """Writes a self-contained 3D WebGL visualizer HTML file to disk."""
619
+ out_file = Path(output_path)
620
+ out_file.parent.mkdir(parents=True, exist_ok=True)
621
+ html_template = cls.render_html(graph_source)
622
+ with open(out_file, "w", encoding="utf-8") as f:
623
+ f.write(html_template)
624
+ return out_file