codegraph-cli 2.0.0__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 (43) hide show
  1. codegraph_cli/__init__.py +4 -0
  2. codegraph_cli/agents.py +191 -0
  3. codegraph_cli/bug_detector.py +386 -0
  4. codegraph_cli/chat_agent.py +352 -0
  5. codegraph_cli/chat_session.py +220 -0
  6. codegraph_cli/cli.py +330 -0
  7. codegraph_cli/cli_chat.py +367 -0
  8. codegraph_cli/cli_diagnose.py +133 -0
  9. codegraph_cli/cli_refactor.py +230 -0
  10. codegraph_cli/cli_setup.py +470 -0
  11. codegraph_cli/cli_test.py +177 -0
  12. codegraph_cli/cli_v2.py +267 -0
  13. codegraph_cli/codegen_agent.py +265 -0
  14. codegraph_cli/config.py +31 -0
  15. codegraph_cli/config_manager.py +341 -0
  16. codegraph_cli/context_manager.py +500 -0
  17. codegraph_cli/crew_agents.py +123 -0
  18. codegraph_cli/crew_chat.py +159 -0
  19. codegraph_cli/crew_tools.py +497 -0
  20. codegraph_cli/diff_engine.py +265 -0
  21. codegraph_cli/embeddings.py +241 -0
  22. codegraph_cli/graph_export.py +144 -0
  23. codegraph_cli/llm.py +642 -0
  24. codegraph_cli/models.py +47 -0
  25. codegraph_cli/models_v2.py +185 -0
  26. codegraph_cli/orchestrator.py +49 -0
  27. codegraph_cli/parser.py +800 -0
  28. codegraph_cli/performance_analyzer.py +223 -0
  29. codegraph_cli/project_context.py +230 -0
  30. codegraph_cli/rag.py +200 -0
  31. codegraph_cli/refactor_agent.py +452 -0
  32. codegraph_cli/security_scanner.py +366 -0
  33. codegraph_cli/storage.py +390 -0
  34. codegraph_cli/templates/graph_interactive.html +257 -0
  35. codegraph_cli/testgen_agent.py +316 -0
  36. codegraph_cli/validation_engine.py +285 -0
  37. codegraph_cli/vector_store.py +293 -0
  38. codegraph_cli-2.0.0.dist-info/METADATA +318 -0
  39. codegraph_cli-2.0.0.dist-info/RECORD +43 -0
  40. codegraph_cli-2.0.0.dist-info/WHEEL +5 -0
  41. codegraph_cli-2.0.0.dist-info/entry_points.txt +2 -0
  42. codegraph_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
  43. codegraph_cli-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,390 @@
1
+ """Persistence layer for project-specific code graph memory.
2
+
3
+ Architecture:
4
+ - **SQLite** for structured data (nodes, edges) and graph traversal queries.
5
+ - **LanceDB** (via :class:`~codegraph_cli.vector_store.VectorStore`) for
6
+ vector similarity search.
7
+
8
+ This hybrid approach gives the best of both worlds: fast relational queries
9
+ for graph traversal and fast ANN search for semantic retrieval.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import sqlite3
17
+ from pathlib import Path
18
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
19
+
20
+ from .config import MEMORY_DIR, STATE_FILE, ensure_base_dirs
21
+ from .models import Edge, Node
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ try:
26
+ from .vector_store import VectorStore
27
+ VECTOR_STORE_AVAILABLE = True
28
+ except ImportError:
29
+ VECTOR_STORE_AVAILABLE = False
30
+
31
+
32
+ # ===================================================================
33
+ # ProjectManager (unchanged – manages directories / active project)
34
+ # ===================================================================
35
+
36
+ class ProjectManager:
37
+ """Manage project memory directories and active project state."""
38
+
39
+ def __init__(self) -> None:
40
+ ensure_base_dirs()
41
+
42
+ def list_projects(self) -> List[str]:
43
+ if not MEMORY_DIR.exists():
44
+ return []
45
+ return sorted([p.name for p in MEMORY_DIR.iterdir() if p.is_dir()])
46
+
47
+ def project_dir(self, project_name: str) -> Path:
48
+ return MEMORY_DIR / project_name
49
+
50
+ def create_or_get_project(self, project_name: str) -> Path:
51
+ path = self.project_dir(project_name)
52
+ path.mkdir(parents=True, exist_ok=True)
53
+ return path
54
+
55
+ def set_current_project(self, project_name: str) -> None:
56
+ ensure_base_dirs()
57
+ STATE_FILE.write_text(
58
+ json.dumps({"current_project": project_name}, indent=2),
59
+ encoding="utf-8",
60
+ )
61
+
62
+ def get_current_project(self) -> Optional[str]:
63
+ if not STATE_FILE.exists():
64
+ return None
65
+ try:
66
+ payload = json.loads(STATE_FILE.read_text(encoding="utf-8"))
67
+ except json.JSONDecodeError:
68
+ return None
69
+ return payload.get("current_project")
70
+
71
+ def unload_project(self) -> None:
72
+ ensure_base_dirs()
73
+ STATE_FILE.write_text(
74
+ json.dumps({"current_project": None}, indent=2),
75
+ encoding="utf-8",
76
+ )
77
+
78
+ def delete_project(self, project_name: str) -> bool:
79
+ path = self.project_dir(project_name)
80
+ if not path.exists():
81
+ return False
82
+ for child in sorted(path.glob("**/*"), reverse=True):
83
+ if child.is_file():
84
+ child.unlink()
85
+ elif child.is_dir():
86
+ child.rmdir()
87
+ path.rmdir()
88
+ return True
89
+
90
+
91
+ # ===================================================================
92
+ # GraphStore (SQLite + LanceDB hybrid)
93
+ # ===================================================================
94
+
95
+ class GraphStore:
96
+ """Hybrid store: SQLite for structure, LanceDB for vectors.
97
+
98
+ Public API is backward-compatible with the legacy SQLite-only store.
99
+ The ``vector_store`` attribute exposes the underlying
100
+ :class:`~codegraph_cli.vector_store.VectorStore` for direct vector
101
+ search when needed.
102
+ """
103
+
104
+ def __init__(self, project_dir: Path) -> None:
105
+ self.project_dir = project_dir
106
+ self.db_path = project_dir / "graph.db"
107
+ self.meta_path = project_dir / "project.json"
108
+ self.conn = sqlite3.connect(str(self.db_path))
109
+ self.conn.row_factory = sqlite3.Row
110
+ self._init_schema()
111
+
112
+ # Initialise LanceDB vector store
113
+ self.vector_store: Optional[VectorStore] = None
114
+ if VECTOR_STORE_AVAILABLE:
115
+ try:
116
+ self.vector_store = VectorStore(project_dir)
117
+ except Exception as exc:
118
+ logger.warning("LanceDB vector store unavailable: %s", exc)
119
+
120
+ def close(self) -> None:
121
+ self.conn.close()
122
+
123
+ # ------------------------------------------------------------------
124
+ # Schema
125
+ # ------------------------------------------------------------------
126
+
127
+ def _init_schema(self) -> None:
128
+ cur = self.conn.cursor()
129
+ cur.execute("""
130
+ CREATE TABLE IF NOT EXISTS nodes (
131
+ node_id TEXT PRIMARY KEY,
132
+ node_type TEXT NOT NULL,
133
+ name TEXT NOT NULL,
134
+ qualname TEXT NOT NULL,
135
+ file_path TEXT NOT NULL,
136
+ start_line INTEGER NOT NULL,
137
+ end_line INTEGER NOT NULL,
138
+ code TEXT NOT NULL,
139
+ docstring TEXT,
140
+ embedding TEXT,
141
+ metadata TEXT
142
+ )
143
+ """)
144
+ cur.execute("""
145
+ CREATE TABLE IF NOT EXISTS edges (
146
+ src TEXT NOT NULL,
147
+ dst TEXT NOT NULL,
148
+ edge_type TEXT NOT NULL
149
+ )
150
+ """)
151
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src)")
152
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst)")
153
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name)")
154
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_nodes_qualname ON nodes(qualname)")
155
+ self.conn.commit()
156
+
157
+ # ------------------------------------------------------------------
158
+ # Clear / metadata
159
+ # ------------------------------------------------------------------
160
+
161
+ def clear(self) -> None:
162
+ cur = self.conn.cursor()
163
+ cur.execute("DELETE FROM edges")
164
+ cur.execute("DELETE FROM nodes")
165
+ self.conn.commit()
166
+ if self.vector_store is not None:
167
+ try:
168
+ self.vector_store.clear()
169
+ except Exception:
170
+ pass
171
+
172
+ def set_metadata(self, payload: Dict[str, Any]) -> None:
173
+ self.meta_path.write_text(
174
+ json.dumps(payload, indent=2), encoding="utf-8",
175
+ )
176
+
177
+ def get_metadata(self) -> Dict[str, Any]:
178
+ if not self.meta_path.exists():
179
+ return {}
180
+ try:
181
+ return json.loads(self.meta_path.read_text(encoding="utf-8"))
182
+ except json.JSONDecodeError:
183
+ return {}
184
+
185
+ # ------------------------------------------------------------------
186
+ # Insert
187
+ # ------------------------------------------------------------------
188
+
189
+ def insert_nodes(self, rows: Iterable[Tuple[Node, List[float]]]) -> None:
190
+ """Insert nodes with their embedding vectors.
191
+
192
+ Each element of *rows* is a ``(Node, embedding)`` tuple. Data is
193
+ written to both SQLite (for structured queries) and LanceDB (for
194
+ vector search).
195
+ """
196
+ rows_list = list(rows)
197
+ if not rows_list:
198
+ return
199
+
200
+ # ---- SQLite -----------------------------------------------------
201
+ self.conn.executemany(
202
+ """
203
+ INSERT OR REPLACE INTO nodes (
204
+ node_id, node_type, name, qualname, file_path,
205
+ start_line, end_line, code, docstring, embedding, metadata
206
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
207
+ """,
208
+ [
209
+ (
210
+ node.node_id,
211
+ node.node_type,
212
+ node.name,
213
+ node.qualname,
214
+ node.file_path,
215
+ node.start_line,
216
+ node.end_line,
217
+ node.code,
218
+ node.docstring,
219
+ json.dumps(embedding),
220
+ json.dumps(node.metadata) if node.metadata else None,
221
+ )
222
+ for node, embedding in rows_list
223
+ ],
224
+ )
225
+ self.conn.commit()
226
+
227
+ # ---- LanceDB (vector store) ------------------------------------
228
+ if self.vector_store is not None:
229
+ try:
230
+ node_ids = [node.node_id for node, _ in rows_list]
231
+ embeddings = [emb for _, emb in rows_list]
232
+ metadatas = [
233
+ {
234
+ "node_type": node.node_type,
235
+ "file_path": node.file_path,
236
+ "qualname": node.qualname,
237
+ "name": node.name,
238
+ }
239
+ for node, _ in rows_list
240
+ ]
241
+ documents = [node.code for node, _ in rows_list]
242
+ self.vector_store.add_nodes(
243
+ node_ids, embeddings, metadatas, documents,
244
+ )
245
+ except Exception as exc:
246
+ logger.warning("Failed to sync nodes to LanceDB: %s", exc)
247
+
248
+ def insert_edges(self, edges: Iterable[Edge]) -> None:
249
+ cur = self.conn.cursor()
250
+ cur.executemany(
251
+ "INSERT INTO edges (src, dst, edge_type) VALUES (?, ?, ?)",
252
+ [(e.src, e.dst, e.edge_type) for e in edges],
253
+ )
254
+ self.conn.commit()
255
+
256
+ # ------------------------------------------------------------------
257
+ # Read (structured)
258
+ # ------------------------------------------------------------------
259
+
260
+ def get_nodes(self) -> List[sqlite3.Row]:
261
+ return self.conn.execute("SELECT * FROM nodes").fetchall()
262
+
263
+ def get_node(self, node_id_or_name: str) -> Optional[sqlite3.Row]:
264
+ return self.conn.execute(
265
+ "SELECT * FROM nodes WHERE node_id = ? OR qualname = ? OR name = ? LIMIT 1",
266
+ (node_id_or_name, node_id_or_name, node_id_or_name),
267
+ ).fetchone()
268
+
269
+ def get_edges(self) -> List[sqlite3.Row]:
270
+ return self.conn.execute("SELECT * FROM edges").fetchall()
271
+
272
+ def neighbors(self, src_node_id: str) -> List[sqlite3.Row]:
273
+ return self.conn.execute(
274
+ "SELECT * FROM edges WHERE src = ?", (src_node_id,),
275
+ ).fetchall()
276
+
277
+ def reverse_neighbors(self, dst_node_id: str) -> List[sqlite3.Row]:
278
+ return self.conn.execute(
279
+ "SELECT * FROM edges WHERE dst = ?", (dst_node_id,),
280
+ ).fetchall()
281
+
282
+ def all_by_file(self) -> Dict[str, List[Dict[str, Any]]]:
283
+ by_file: Dict[str, List[Dict[str, Any]]] = {}
284
+ for row in self.get_nodes():
285
+ payload = dict(row)
286
+ by_file.setdefault(payload["file_path"], []).append(payload)
287
+ return by_file
288
+
289
+ # ------------------------------------------------------------------
290
+ # Vector search (convenience wrappers)
291
+ # ------------------------------------------------------------------
292
+
293
+ def search_vectors(
294
+ self,
295
+ query_vector: List[float],
296
+ top_k: int = 10,
297
+ where: Optional[Dict[str, str]] = None,
298
+ ) -> List[Dict[str, Any]]:
299
+ """Semantic search via LanceDB.
300
+
301
+ Args:
302
+ query_vector: Embedding of the search query.
303
+ top_k: Max results.
304
+ where: Metadata filter dict, e.g. ``{"node_type": "function"}``.
305
+
306
+ Returns:
307
+ List of result dicts with ``id``, ``_distance``, ``document``, etc.
308
+ """
309
+ if self.vector_store is None:
310
+ return []
311
+ result = self.vector_store.search(query_vector, n_results=top_k, where=where)
312
+ # Flatten the nested Chroma-compat format into a plain list
313
+ out: List[Dict[str, Any]] = []
314
+ if result["ids"] and result["ids"][0]:
315
+ for i, nid in enumerate(result["ids"][0]):
316
+ out.append({
317
+ "id": nid,
318
+ "_distance": result["distances"][0][i] if result["distances"][0] else 0.0,
319
+ "metadata": result["metadatas"][0][i] if result["metadatas"][0] else {},
320
+ "document": result["documents"][0][i] if result["documents"][0] else "",
321
+ })
322
+ return out
323
+
324
+ def hybrid_search(
325
+ self,
326
+ query_vector: List[float],
327
+ top_k: int = 10,
328
+ where_sql: Optional[str] = None,
329
+ ) -> List[Dict[str, Any]]:
330
+ """Hybrid search: vector + SQL filter (e.g. ``file_path LIKE 'src/%'``).
331
+
332
+ Falls back to :meth:`search_vectors` when where_sql is ``None``.
333
+ """
334
+ if self.vector_store is None:
335
+ return []
336
+ return self.vector_store.hybrid_search(
337
+ query_vector, n_results=top_k, where_sql=where_sql,
338
+ )
339
+
340
+ # ------------------------------------------------------------------
341
+ # Merge (cross-project)
342
+ # ------------------------------------------------------------------
343
+
344
+ def merge_from(self, other: "GraphStore", source_project: str) -> None:
345
+ current_nodes = self.conn.execute("SELECT node_id FROM nodes").fetchall()
346
+ existing = {r[0] for r in current_nodes}
347
+
348
+ node_rows: List[Dict[str, Any]] = []
349
+ id_map: Dict[str, str] = {}
350
+ for row in other.get_nodes():
351
+ row_dict = dict(row)
352
+ original_id = row_dict["node_id"]
353
+ new_id = original_id if original_id not in existing else f"{source_project}:{original_id}"
354
+ existing.add(new_id)
355
+ id_map[original_id] = new_id
356
+ metadata = json.loads(row_dict.get("metadata") or "{}")
357
+ metadata["merged_from"] = source_project
358
+ row_dict["node_id"] = new_id
359
+ row_dict["metadata"] = json.dumps(metadata)
360
+ node_rows.append(row_dict)
361
+
362
+ cur = self.conn.cursor()
363
+ cur.executemany(
364
+ """
365
+ INSERT OR REPLACE INTO nodes (
366
+ node_id, node_type, name, qualname, file_path,
367
+ start_line, end_line, code, docstring, embedding, metadata
368
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
369
+ """,
370
+ [
371
+ (
372
+ n["node_id"], n["node_type"], n["name"], n["qualname"],
373
+ n["file_path"], n["start_line"], n["end_line"], n["code"],
374
+ n["docstring"], n["embedding"], n["metadata"],
375
+ )
376
+ for n in node_rows
377
+ ],
378
+ )
379
+
380
+ edge_rows: List[Tuple[str, str, str]] = []
381
+ for edge_row in other.get_edges():
382
+ e = dict(edge_row)
383
+ src = id_map.get(e["src"], e["src"])
384
+ dst = id_map.get(e["dst"], e["dst"])
385
+ edge_rows.append((src, dst, e["edge_type"]))
386
+ cur.executemany(
387
+ "INSERT INTO edges (src, dst, edge_type) VALUES (?, ?, ?)",
388
+ edge_rows,
389
+ )
390
+ self.conn.commit()
@@ -0,0 +1,257 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <title>CodeGraph Interactive Visualization</title>
7
+ <script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
8
+ <style>
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ margin: 0;
12
+ padding: 20px;
13
+ background: #f5f5f5;
14
+ }
15
+
16
+ #header {
17
+ background: white;
18
+ padding: 20px;
19
+ border-radius: 8px;
20
+ margin-bottom: 20px;
21
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
22
+ }
23
+
24
+ h1 {
25
+ margin: 0 0 10px 0;
26
+ color: #333;
27
+ }
28
+
29
+ #stats {
30
+ color: #666;
31
+ font-size: 14px;
32
+ }
33
+
34
+ #graph {
35
+ width: 100%;
36
+ height: 700px;
37
+ background: white;
38
+ border-radius: 8px;
39
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
40
+ }
41
+
42
+ #legend {
43
+ background: white;
44
+ padding: 15px;
45
+ border-radius: 8px;
46
+ margin-top: 20px;
47
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
48
+ }
49
+
50
+ .legend-item {
51
+ display: inline-block;
52
+ margin-right: 20px;
53
+ margin-bottom: 10px;
54
+ }
55
+
56
+ .legend-color {
57
+ display: inline-block;
58
+ width: 20px;
59
+ height: 20px;
60
+ border-radius: 50%;
61
+ margin-right: 8px;
62
+ vertical-align: middle;
63
+ }
64
+
65
+ #info {
66
+ background: white;
67
+ padding: 15px;
68
+ border-radius: 8px;
69
+ margin-top: 20px;
70
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
71
+ display: none;
72
+ }
73
+ </style>
74
+ </head>
75
+
76
+ <body>
77
+ <div id="header">
78
+ <h1>🔍 CodeGraph Interactive Visualization</h1>
79
+ <div id="stats"></div>
80
+ </div>
81
+
82
+ <div id="graph"></div>
83
+
84
+ <div id="legend">
85
+ <strong>Legend:</strong>
86
+ <div class="legend-item">
87
+ <span class="legend-color" style="background: #4A90E2;"></span>
88
+ <span>Module</span>
89
+ </div>
90
+ <div class="legend-item">
91
+ <span class="legend-color" style="background: #7ED321;"></span>
92
+ <span>Class</span>
93
+ </div>
94
+ <div class="legend-item">
95
+ <span class="legend-color" style="background: #F5A623;"></span>
96
+ <span>Function</span>
97
+ </div>
98
+ <div class="legend-item">
99
+ <span style="color: #999; margin-right: 8px;">━━━</span>
100
+ <span>Contains</span>
101
+ </div>
102
+ <div class="legend-item">
103
+ <span style="color: #E74C3C; margin-right: 8px;">━━━▶</span>
104
+ <span>Calls</span>
105
+ </div>
106
+ <div class="legend-item">
107
+ <span style="color: #9B59B6; margin-right: 8px;">- - -▶</span>
108
+ <span>Depends On</span>
109
+ </div>
110
+ </div>
111
+
112
+ <div id="info"></div>
113
+
114
+ <script type="text/javascript">
115
+ // Graph data will be injected here
116
+ const graphData = {{ GRAPH_DATA }};
117
+
118
+ // Transform nodes
119
+ const nodes = graphData.nodes.map(node => {
120
+ let color = '#4A90E2'; // Module
121
+ let shape = 'box';
122
+
123
+ if (node.id.startsWith('class:')) {
124
+ color = '#7ED321'; // Class
125
+ shape = 'ellipse';
126
+ } else if (node.id.startsWith('function:')) {
127
+ color = '#F5A623'; // Function
128
+ shape = 'box';
129
+ }
130
+
131
+ return {
132
+ id: node.id,
133
+ label: node.label.split(':')[1] || node.label,
134
+ title: `${node.label}\n${node.title}`,
135
+ color: {
136
+ background: color,
137
+ border: color,
138
+ highlight: {
139
+ background: color,
140
+ border: '#333'
141
+ }
142
+ },
143
+ shape: shape,
144
+ font: {
145
+ color: 'white',
146
+ size: 14
147
+ }
148
+ };
149
+ });
150
+
151
+ // Transform edges
152
+ const edges = graphData.edges.map(edge => {
153
+ let color = '#999';
154
+ let dashes = false;
155
+ let arrows = '';
156
+
157
+ if (edge.edge_type === 'calls') {
158
+ color = '#E74C3C';
159
+ arrows = 'to';
160
+ } else if (edge.edge_type === 'depends_on') {
161
+ color = '#9B59B6';
162
+ dashes = true;
163
+ arrows = 'to';
164
+ } else if (edge.edge_type === 'contains') {
165
+ color = '#999';
166
+ arrows = '';
167
+ }
168
+
169
+ return {
170
+ from: edge.src,
171
+ to: edge.dst,
172
+ color: color,
173
+ dashes: dashes,
174
+ arrows: arrows,
175
+ title: edge.edge_type
176
+ };
177
+ });
178
+
179
+ // Update stats
180
+ const callEdges = graphData.edges.filter(e => e.edge_type === 'calls').length;
181
+ const containsEdges = graphData.edges.filter(e => e.edge_type === 'contains').length;
182
+ const dependsEdges = graphData.edges.filter(e => e.edge_type === 'depends_on').length;
183
+
184
+ document.getElementById('stats').innerHTML = `
185
+ <strong>${nodes.length}</strong> nodes |
186
+ <strong>${edges.length}</strong> edges
187
+ (<strong>${callEdges}</strong> calls,
188
+ <strong>${containsEdges}</strong> contains,
189
+ <strong>${dependsEdges}</strong> dependencies)
190
+ `;
191
+
192
+ // Create network
193
+ const container = document.getElementById('graph');
194
+ const data = {
195
+ nodes: new vis.DataSet(nodes),
196
+ edges: new vis.DataSet(edges)
197
+ };
198
+
199
+ const options = {
200
+ physics: {
201
+ enabled: true,
202
+ barnesHut: {
203
+ gravitationalConstant: -8000,
204
+ centralGravity: 0.3,
205
+ springLength: 150,
206
+ springConstant: 0.04
207
+ },
208
+ stabilization: {
209
+ iterations: 200
210
+ }
211
+ },
212
+ interaction: {
213
+ hover: true,
214
+ tooltipDelay: 100,
215
+ zoomView: true,
216
+ dragView: true
217
+ },
218
+ layout: {
219
+ hierarchical: {
220
+ enabled: false
221
+ }
222
+ }
223
+ };
224
+
225
+ const network = new vis.Network(container, data, options);
226
+
227
+ // Click handler
228
+ network.on('click', function (params) {
229
+ if (params.nodes.length > 0) {
230
+ const nodeId = params.nodes[0];
231
+ const node = graphData.nodes.find(n => n.id === nodeId);
232
+
233
+ if (node) {
234
+ const infoDiv = document.getElementById('info');
235
+ infoDiv.style.display = 'block';
236
+ infoDiv.innerHTML = `
237
+ <h3>${node.label}</h3>
238
+ <p><strong>Type:</strong> ${node.id.split(':')[0]}</p>
239
+ <p><strong>File:</strong> ${node.title}</p>
240
+ `;
241
+ }
242
+ }
243
+ });
244
+
245
+ // Stabilization progress
246
+ network.on('stabilizationProgress', function (params) {
247
+ const progress = Math.round((params.iterations / params.total) * 100);
248
+ document.getElementById('stats').innerHTML += ` | Rendering: ${progress}%`;
249
+ });
250
+
251
+ network.on('stabilizationIterationsDone', function () {
252
+ document.getElementById('stats').innerHTML = document.getElementById('stats').innerHTML.replace(/\| Rendering: \d+%/, '');
253
+ });
254
+ </script>
255
+ </body>
256
+
257
+ </html>