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.
- synapse/__init__.py +207 -0
- synapse/app.py +942 -0
- synapse/cli.py +88 -0
- synapse/config.py +103 -0
- synapse/connectors/__init__.py +29 -0
- synapse/connectors/base.py +222 -0
- synapse/connectors/factory.py +202 -0
- synapse/connectors/gmail.py +292 -0
- synapse/connectors/mcp_wrapper.py +153 -0
- synapse/connectors/notion.py +161 -0
- synapse/connectors/photos.py +141 -0
- synapse/database/__init__.py +19 -0
- synapse/database/base.py +63 -0
- synapse/database/chroma.py +175 -0
- synapse/database/memory.py +132 -0
- synapse/database/pgvector.py +99 -0
- synapse/database/qdrant.py +138 -0
- synapse/embeddings/__init__.py +21 -0
- synapse/embeddings/base.py +262 -0
- synapse/export/__init__.py +14 -0
- synapse/export/mcp_exporter.py +202 -0
- synapse/graph/__init__.py +21 -0
- synapse/graph/base.py +130 -0
- synapse/graph/memory.py +20 -0
- synapse/graph/neo4j_store.py +176 -0
- synapse/graph/networkx_store.py +221 -0
- synapse/inspector/__init__.py +5 -0
- synapse/inspector/template.py +530 -0
- synapse/models/__init__.py +73 -0
- synapse/models/base.py +95 -0
- synapse/models/gemini.py +328 -0
- synapse/models/ollama.py +322 -0
- synapse/models/openai.py +289 -0
- synapse/oauth/__init__.py +7 -0
- synapse/oauth/google.py +194 -0
- synapse/oauth/notion.py +35 -0
- synapse/rag/__init__.py +8 -0
- synapse/rag/chunker.py +219 -0
- synapse/rag/hybrid_rag.py +122 -0
- synapse/resource/__init__.py +5 -0
- synapse/resource/resource.py +304 -0
- synapse/resources/__init__.py +5 -0
- synapse/skills/__init__.py +5 -0
- synapse/skills/skill.py +321 -0
- synapse/storage/__init__.py +24 -0
- synapse/storage/graph_exporter.py +624 -0
- synapse/storage/graph_store.py +162 -0
- synapse/storage/vector_store.py +103 -0
- synapse_framework-0.2.4.dist-info/METADATA +415 -0
- synapse_framework-0.2.4.dist-info/RECORD +54 -0
- synapse_framework-0.2.4.dist-info/WHEEL +5 -0
- synapse_framework-0.2.4.dist-info/entry_points.txt +2 -0
- synapse_framework-0.2.4.dist-info/licenses/LICENSE +21 -0
- 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
|