tscode-kg 0.2.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.
tscode_kg/viz3d.py ADDED
@@ -0,0 +1,1457 @@
1
+ """
2
+ viz3d.py — PyVista/PyQt5 3-D knowledge-graph visualiser for TypeScriptKG.
3
+
4
+ Adapted from *repo_vis/pkg_visualizer/pkg_visualizer.py*
5
+ (Eric G. Suchanek, PhD — https://github.com/suchanek/repo_vis).
6
+
7
+ Window layout mirrors repo_vis exactly:
8
+ Left — control panel (DB path, layout selector, module filter, render options)
9
+ Right — PyVista QtInteractor + button row (Reset View, Reset Settings, status)
10
+
11
+ Pick any node to open a modeless JSDoc popup; the picked node is
12
+ highlighted in pink and the camera zooms in, exactly as in repo_vis.
13
+
14
+ Author: Eric G. Suchanek, PhD
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import atexit
20
+ import gc
21
+ import logging
22
+ import re
23
+ import sys
24
+ import warnings
25
+ from collections import Counter
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+ import param
30
+ import pyvista as pv
31
+ from markdown import markdown # type: ignore[import-untyped]
32
+ from PyQt5.QtCore import Qt, pyqtSignal
33
+ from PyQt5.QtGui import QFont
34
+ from PyQt5.QtWidgets import (
35
+ QApplication,
36
+ QCheckBox,
37
+ QComboBox,
38
+ QDialog,
39
+ QHBoxLayout,
40
+ QLabel,
41
+ QLineEdit,
42
+ QListWidget,
43
+ QMainWindow,
44
+ QPushButton,
45
+ QSizePolicy,
46
+ QSlider,
47
+ QTextBrowser,
48
+ QVBoxLayout,
49
+ QWidget,
50
+ )
51
+ from pyvistaqt import QtInteractor
52
+ from rich.logging import RichHandler
53
+
54
+ from tscode_kg import __version__
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Logging
58
+ # ---------------------------------------------------------------------------
59
+
60
+ logging.basicConfig(level=logging.WARNING, handlers=[RichHandler()])
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Constants
65
+ # ---------------------------------------------------------------------------
66
+
67
+ __author__ = "Eric G. Suchanek, PhD"
68
+
69
+ DEFAULT_DB = ".tscodekg/graph.sqlite"
70
+ DEFAULT_SAVE = "tscodekg_3d"
71
+
72
+ CONTROL_PANEL_WIDTH: int = 240
73
+ BUTTON_WIDTH: int = 120
74
+ ZOOM_FACTOR: float = 10.0
75
+
76
+ # Node colours (flat-UI palette, aligned with the Streamlit visualiser)
77
+ KIND_COLOR: dict[str, str] = {
78
+ "module": "#4A90D9",
79
+ "namespace": "#34495E",
80
+ "class": "#27AE60",
81
+ "interface": "#1ABC9C",
82
+ "type_alias": "#9B59B6",
83
+ "enum": "#E67E22",
84
+ "function": "#E74C3C",
85
+ "private_function": "#F1C40F",
86
+ "method": "#3498DB",
87
+ "symbol": "#95A5A6",
88
+ }
89
+
90
+ # Node sizes (radius)
91
+ KIND_SIZE: dict[str, float] = {
92
+ "module": 1.2,
93
+ "namespace": 1.0,
94
+ "class": 0.9,
95
+ "interface": 0.9,
96
+ "type_alias": 0.6,
97
+ "enum": 0.7,
98
+ "function": 0.7,
99
+ "private_function": 0.7,
100
+ "method": 0.5,
101
+ "symbol": 0.4,
102
+ }
103
+
104
+ # Edge colours
105
+ REL_COLOR: dict[str, str] = {
106
+ "CONTAINS": "#555555",
107
+ "CALLS": "#E74C3C",
108
+ "IMPORTS": "#3498DB",
109
+ "INHERITS": "#F39C12",
110
+ "IMPLEMENTS": "#1ABC9C",
111
+ "EXTENDS": "#9B59B6",
112
+ }
113
+
114
+ # LOD thresholds (total visible nodes)
115
+ LOD_HIGH: int = 800 # icospheres / cylinders
116
+ LOD_LOW: int = 1500 # simplified geometry; above → small spheres
117
+
118
+ # Edge rendering limits
119
+ MAX_EDGES_ARC: int = 2000 # above this, draw straight lines instead of arcs
120
+ MAX_EDGES_TOTAL: int = 8000 # hard cap — skip lowest-priority edges beyond this
121
+
122
+ # Priority order when capping edges: CONTAINS (structural skeleton) always first
123
+ _EDGE_PRIORITY: dict[str, int] = {
124
+ "CONTAINS": 0,
125
+ "INHERITS": 1,
126
+ "IMPLEMENTS": 1,
127
+ "EXTENDS": 1,
128
+ "IMPORTS": 2,
129
+ "CALLS": 3,
130
+ }
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Internal geometry helpers
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def _make_node_mesh(kind: str, center: np.ndarray, size: float, lod: str):
138
+ """
139
+ Return a PyVista mesh for a single node, adapting geometry to the LOD tier.
140
+
141
+ :param kind: Node kind string (``module``, ``class``, etc.).
142
+ :param center: 3-D centre position.
143
+ :param size: Node radius.
144
+ :param lod: LOD tier — ``"high"``, ``"low"``, or ``"points"``.
145
+ :return: PyVista PolyData mesh.
146
+ """
147
+ if lod == "high":
148
+ if kind == "module":
149
+ h = size * 0.9
150
+ return pv.Box(
151
+ bounds=(
152
+ center[0] - h,
153
+ center[0] + h,
154
+ center[1] - h,
155
+ center[1] + h,
156
+ center[2] - h,
157
+ center[2] + h,
158
+ )
159
+ )
160
+ elif kind in ("function", "private_function"):
161
+ return pv.Cylinder(
162
+ center=center,
163
+ direction=(0, 0, 1),
164
+ radius=size * 0.6,
165
+ height=size * 1.4,
166
+ resolution=12,
167
+ )
168
+ else:
169
+ return pv.Icosahedron(radius=size, center=center)
170
+ elif lod == "low":
171
+ if kind == "module":
172
+ h = size * 0.9
173
+ return pv.Box(
174
+ bounds=(
175
+ center[0] - h,
176
+ center[0] + h,
177
+ center[1] - h,
178
+ center[1] + h,
179
+ center[2] - h,
180
+ center[2] + h,
181
+ )
182
+ )
183
+ elif kind in ("function", "private_function"):
184
+ return pv.Cylinder(
185
+ center=center,
186
+ direction=(0, 0, 1),
187
+ radius=size * 0.6,
188
+ height=size * 1.4,
189
+ resolution=6,
190
+ )
191
+ elif kind in ("class", "interface"):
192
+ return pv.Octahedron(radius=size, center=center)
193
+ else:
194
+ return pv.Sphere(radius=size * 0.5, center=center, theta_resolution=4, phi_resolution=4)
195
+ else:
196
+ return pv.Sphere(radius=size * 0.5, center=center, theta_resolution=4, phi_resolution=4)
197
+
198
+
199
+ def _arc_points(p1: np.ndarray, p2: np.ndarray, n_pts: int = 24, lift: float = 0.35) -> np.ndarray:
200
+ """
201
+ Quadratic Bézier arc from *p1* to *p2*, apex lifted ``lift × chord`` in Z.
202
+
203
+ :param p1: Start point.
204
+ :param p2: End point.
205
+ :param n_pts: Number of sample points.
206
+ :param lift: Fraction of chord length used as Z lift.
207
+ :return: ``(n_pts, 3)`` array.
208
+ """
209
+ p1, p2 = np.asarray(p1, float), np.asarray(p2, float)
210
+ mid = (p1 + p2) / 2.0
211
+ mid[2] += lift * np.linalg.norm(p2 - p1)
212
+ t = np.linspace(0.0, 1.0, n_pts)[:, None]
213
+ return (1 - t) ** 2 * p1 + 2 * t * (1 - t) * mid + t**2 * p2
214
+
215
+
216
+ def _docstring_to_markdown(docstring: str | None) -> str:
217
+ """
218
+ Convert a ``:param:``-style or JSDoc ``@param``-style doc comment to Markdown.
219
+ Adapted from ``utility.format_docstring_to_markdown`` in *repo_vis*.
220
+
221
+ :param docstring: Raw JSDoc / docstring text, or ``None``.
222
+ :return: Markdown-formatted string.
223
+ """
224
+ if not docstring:
225
+ return "No JSDoc available."
226
+ lines = docstring.strip().split("\n")
227
+ md: list[str] = [f"# {lines[0]}"]
228
+ for line in lines[1:]:
229
+ line = line.strip()
230
+ if line.startswith(":type") or line.startswith(":rtype"):
231
+ continue
232
+ m = re.match(r":param (\w+): (.+)", line)
233
+ if m is None:
234
+ m = re.match(r"@param\s+(?:\{[^}]*\}\s+)?(\w+)\s*-?\s*(.+)", line)
235
+ if m:
236
+ md.append(f"- **{m.group(1)}**: {m.group(2)}")
237
+ elif line.startswith(":return:"):
238
+ md.append(line.replace(":return:", "**Returns:**"))
239
+ elif line.startswith(("@returns", "@return")):
240
+ md.append(re.sub(r"@returns?\s*", "**Returns:** ", line, count=1))
241
+ else:
242
+ md.append(line)
243
+ return "\n".join(md)
244
+
245
+
246
+ def _remove_highlight_actors(plotter: pv.Plotter) -> None:
247
+ """Remove any leftover pink highlight or outline actors from the plotter."""
248
+ for name in list(plotter.actors.keys()):
249
+ if "highlight" in name.lower() or "bounds" in name.lower() or "outline" in name.lower():
250
+ plotter.remove_actor(name, reset_camera=False) # ty: ignore[invalid-argument-type]
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # DocstringPopup — copied from repo_vis (with attribution)
255
+ # ---------------------------------------------------------------------------
256
+
257
+
258
+ class DocstringPopup(QDialog):
259
+ """
260
+ Modeless popup dialog that renders a JSDoc comment as HTML Markdown.
261
+ Adapted from *repo_vis/pkg_visualizer/pkg_visualizer.py*
262
+ (Eric G. Suchanek, PhD).
263
+ """
264
+
265
+ def __init__(self, title: str, docstring: str, parent=None, on_close_callback=None):
266
+ """
267
+ Initialise the popup window.
268
+
269
+ :param title: Window title.
270
+ :param docstring: Raw JSDoc text (will be rendered as Markdown/HTML).
271
+ :param parent: Parent widget.
272
+ :param on_close_callback: Called when the window is closed.
273
+ """
274
+ super().__init__(parent)
275
+ self.setWindowTitle(title)
276
+ self.setMinimumSize(600, 400)
277
+ self.on_close_callback = on_close_callback
278
+ self.setWindowModality(Qt.NonModal) # ty: ignore[unresolved-attribute]
279
+
280
+ if parent:
281
+ geo = parent.screen().geometry()
282
+ self.move(geo.x() + 50, geo.y() + 50)
283
+
284
+ layout = QVBoxLayout(self)
285
+ html = markdown(docstring or "No JSDoc available.")
286
+ browser = QTextBrowser(self)
287
+ browser.setHtml(html)
288
+ layout.addWidget(browser)
289
+
290
+ close_btn = QPushButton("Close", self)
291
+ close_btn.clicked.connect(self.close) # ty: ignore[invalid-argument-type]
292
+ layout.addWidget(close_btn)
293
+
294
+ def closeEvent(self, event): # ty: ignore[invalid-method-override]
295
+ """Trigger the close callback if set."""
296
+ if self.on_close_callback:
297
+ self.on_close_callback()
298
+ super().closeEvent(event)
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # create_kg_visualization — adapted from create_allium_visualization()
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ def create_kg_visualization(
307
+ viz: KGVisualizer,
308
+ nodes,
309
+ edges,
310
+ plotter: pv.Plotter,
311
+ ) -> tuple[pv.Plotter, str, dict[str, dict]]:
312
+ """
313
+ Render the knowledge graph into *plotter* using the current layout.
314
+
315
+ Mirrors the structure of ``create_allium_visualization`` in *repo_vis*:
316
+ clears the plotter, sets up lights, computes the layout, builds
317
+ per-kind ``pv.MultiBlock`` node meshes, builds per-relation edge
318
+ meshes, and returns the ``actor_to_node`` lookup used by picking.
319
+
320
+ :param viz: :class:`KGVisualizer` instance (source of settings and state).
321
+ :param nodes: List of :class:`~tscode_kg.layout3d.LayoutNode` objects to render.
322
+ :param edges: Full edge list (used for layout computation and edge rendering).
323
+ :param plotter: The ``QtInteractor`` to render into.
324
+ :return: ``(plotter, title_text, actor_to_node)``
325
+ """
326
+ from tscode_kg.layout3d import AlliumLayout, FunnelLayout
327
+
328
+ viz.status = "Setting up visualization..."
329
+ QApplication.processEvents()
330
+
331
+ plotter.clear_actors()
332
+ plotter.enable_anti_aliasing("msaa")
333
+ plotter.enable_terrain_style() # ty: ignore[missing-argument]
334
+ plotter.set_background("white", top="lightblue") # ty: ignore[invalid-argument-type]
335
+ plotter.add_axes( # ty: ignore[missing-argument]
336
+ interactive=False,
337
+ viewport=(0.75, 0.75, 1.0, 1.0),
338
+ )
339
+
340
+ # -- Layout
341
+ layout = (
342
+ AlliumLayout()
343
+ if viz.layout_name == "allium"
344
+ else FunnelLayout(layer_gap=20.0, node_spacing=viz.node_spacing)
345
+ )
346
+ # Always compute on ALL nodes for stable positions; filtering happens in display
347
+
348
+ all_nodes_for_layout = viz.nodes # full set for stable layout
349
+ all_edges_for_layout = viz.edges
350
+ positions = layout.compute(all_nodes_for_layout, all_edges_for_layout)
351
+
352
+ # Clamp all nodes to Z >= 0 so nothing clips below the ground plane
353
+ for nid, pos in positions.items():
354
+ if pos[2] < 0:
355
+ positions[nid] = np.array([pos[0], pos[1], 0.0])
356
+
357
+ # -- LOD tier
358
+ n_visible = len(nodes)
359
+ lod = "high" if n_visible <= LOD_HIGH else "low" if n_visible <= LOD_LOW else "points"
360
+
361
+ # -- Build per-kind MultiBlocks
362
+ kind_blocks: dict[str, pv.MultiBlock] = {k: pv.MultiBlock() for k in KIND_SIZE}
363
+ actor_to_node: dict[str, dict] = {}
364
+ kind_counters: dict[str, int] = {k: 0 for k in KIND_SIZE}
365
+
366
+ node_id_set = {n.id for n in nodes}
367
+
368
+ for node in nodes:
369
+ pos = positions.get(node.id) # type: ignore[assignment]
370
+ if pos is None:
371
+ continue
372
+ kind = node.kind
373
+ if kind == "function" and node.name.startswith("_"):
374
+ kind = "private_function"
375
+ elif kind not in KIND_SIZE:
376
+ kind = "symbol"
377
+
378
+ mesh = _make_node_mesh(kind, pos, KIND_SIZE[kind], lod)
379
+ kind_blocks[kind].append(mesh)
380
+
381
+ mesh_id = f"{kind}_{kind_counters[kind]}"
382
+ kind_counters[kind] += 1
383
+ actor_to_node[mesh_id] = {
384
+ "kind": kind,
385
+ "id": node.id,
386
+ "name": node.name,
387
+ "module_path": node.module_path,
388
+ "lineno": node.lineno,
389
+ "end_lineno": node.end_lineno,
390
+ "docstring": node.docstring,
391
+ "position": pos,
392
+ "mesh": mesh,
393
+ }
394
+
395
+ # Progress update
396
+ total_nodes = len(nodes)
397
+ update_every = max(1, total_nodes // 10)
398
+ rendered = sum(kind_counters.values())
399
+ if rendered % update_every == 0 or rendered == total_nodes:
400
+ pct = int(rendered / total_nodes * 100)
401
+ bar = "█" * (pct // 10) + "░" * ((100 - pct) // 10)
402
+ viz.status = f"Rendering nodes | {bar} {pct}% ({rendered}/{total_nodes})"
403
+ QApplication.processEvents()
404
+
405
+ # -- Add node MultiBlocks
406
+ for kind, block in kind_blocks.items():
407
+ if block.n_blocks > 0:
408
+ # Only apply smooth shading to cylinders (function nodes)
409
+ plotter.add_mesh(
410
+ block,
411
+ color=KIND_COLOR[kind],
412
+ show_edges=False,
413
+ smooth_shading=(kind == "function"),
414
+ name=f"{kind}_nodes",
415
+ )
416
+
417
+ # -- Edge rendering
418
+ rel_to_show = set()
419
+ if viz.show_calls:
420
+ rel_to_show.add("CALLS")
421
+ if viz.show_imports:
422
+ rel_to_show.add("IMPORTS")
423
+ if viz.show_inherits:
424
+ rel_to_show.add("INHERITS")
425
+ if viz.show_implements:
426
+ rel_to_show.add("IMPLEMENTS")
427
+ if viz.show_extends:
428
+ rel_to_show.add("EXTENDS")
429
+ if viz.show_contains:
430
+ rel_to_show.add("CONTAINS")
431
+
432
+ # Count renderable edges to decide rendering strategy
433
+ renderable = [
434
+ e for e in edges if e.rel in rel_to_show and e.src in node_id_set and e.dst in node_id_set
435
+ ]
436
+ use_arcs = len(renderable) <= MAX_EDGES_ARC
437
+ if len(renderable) > MAX_EDGES_TOTAL:
438
+ renderable.sort(key=lambda e: _EDGE_PRIORITY.get(e.rel, 9))
439
+ renderable = renderable[:MAX_EDGES_TOTAL]
440
+
441
+ viz.status = f"Rendering {len(renderable)} edges {'(arcs)' if use_arcs else '(lines)'}..."
442
+ QApplication.processEvents()
443
+
444
+ # Accumulate raw point/connectivity arrays per relation — one PolyData per rel
445
+ rel_pts: dict[str, list[np.ndarray]] = {r: [] for r in rel_to_show}
446
+ rel_cells: dict[str, list[int]] = {r: [] for r in rel_to_show}
447
+ rel_idx: dict[str, int] = {r: 0 for r in rel_to_show}
448
+
449
+ for edge in renderable:
450
+ p1, p2 = positions.get(edge.src), positions.get(edge.dst)
451
+ if p1 is None or p2 is None:
452
+ continue
453
+ rel = edge.rel
454
+ if use_arcs and rel != "CONTAINS":
455
+ seg = _arc_points(p1, p2)
456
+ else:
457
+ seg = np.array([p1, p2])
458
+ n = len(seg)
459
+ rel_pts[rel].extend(seg)
460
+ rel_cells[rel].extend([n] + list(range(rel_idx[rel], rel_idx[rel] + n)))
461
+ rel_idx[rel] += n
462
+
463
+ QApplication.processEvents()
464
+
465
+ for rel in rel_to_show:
466
+ pts = rel_pts[rel]
467
+ cells = rel_cells[rel]
468
+ if not pts:
469
+ continue
470
+ pd = pv.PolyData()
471
+ pd.points = np.array(pts)
472
+ pd.lines = np.array(cells)
473
+ is_contains = rel == "CONTAINS"
474
+ plotter.add_mesh(
475
+ pd,
476
+ color=REL_COLOR[rel],
477
+ line_width=4.0 if is_contains else 2.5,
478
+ opacity=0.5 if is_contains else 1.0,
479
+ name=f"{rel.lower()}_edges",
480
+ )
481
+
482
+ # -- Ground plane: auto-sized to scene bounds, added after all meshes
483
+ plotter.add_floor( # ty: ignore[missing-argument]
484
+ face="-z",
485
+ color="lightgray",
486
+ opacity=0.85,
487
+ show_edges=True,
488
+ i_resolution=20,
489
+ j_resolution=20,
490
+ pad=0.1,
491
+ )
492
+
493
+ # -- Stats
494
+ total_faces = 0
495
+ for block in kind_blocks.values():
496
+ for i in range(block.n_blocks):
497
+ mesh = block[i]
498
+ if hasattr(mesh, "n_faces_strict"):
499
+ total_faces += mesh.n_faces_strict # type: ignore[union-attr]
500
+ viz.num_faces = total_faces
501
+
502
+ _db = Path(viz.db_path)
503
+ _repo_name = _db.parent.parent.name if _db.parent.name == ".tscodekg" else _db.stem
504
+ title = (
505
+ f"TypeScriptKG 3D v{__version__} | {_repo_name} | "
506
+ f"Modules: {viz.num_modules} Classes: {viz.num_classes} "
507
+ f"Interfaces: {viz.num_interfaces} "
508
+ f"Methods: {viz.num_methods} Functions: {viz.num_functions} "
509
+ f"Faces: {total_faces}"
510
+ )
511
+
512
+ plotter.reset_camera() # ty: ignore[missing-argument]
513
+ # Front-elevated perspective: mostly looking along +Y, tilted ~25° down,
514
+ # with a slight rightward rotation so the scene reads with depth.
515
+ plotter.view_vector((0.0, 1.0, 0.35), viewup=(0, 0, 1)) # ty: ignore[invalid-argument-type]
516
+ plotter.camera.zoom(1.6)
517
+ plotter.render()
518
+
519
+ viz.status = "Scene generation complete."
520
+ QApplication.processEvents()
521
+
522
+ return plotter, title, actor_to_node
523
+
524
+
525
+ # ---------------------------------------------------------------------------
526
+ # KGVisualizer — adapted from PackageVisualizer
527
+ # ---------------------------------------------------------------------------
528
+
529
+
530
+ class KGVisualizer(param.Parameterized):
531
+ """
532
+ Data and state model for the TypeScriptKG 3-D visualiser.
533
+ Adapted from ``PackageVisualizer`` in *repo_vis*.
534
+
535
+ Reactive attributes (via ``param``) drive the Qt control panel;
536
+ watched parameters trigger graph reload or UI updates automatically.
537
+ """
538
+
539
+ db_path: str = param.String(default=DEFAULT_DB, doc="SQLite database path") # ty: ignore[invalid-assignment]
540
+ layout_name: str = param.Selector( # ty: ignore[invalid-assignment]
541
+ objects=["allium", "funnel"], default="allium", doc="3-D layout strategy"
542
+ )
543
+ save_path: str = param.String(default=DEFAULT_SAVE, doc="Save path stem") # ty: ignore[invalid-assignment]
544
+ save_format: str = param.Selector( # ty: ignore[invalid-assignment]
545
+ objects=["html", "png", "jpg"], default="html", doc="Export format"
546
+ )
547
+
548
+ # Node kind visibility
549
+ show_methods: bool = param.Boolean(default=True, doc="Render method nodes") # ty: ignore[invalid-assignment]
550
+ show_symbols: bool = param.Boolean(default=False, doc="Render symbol stub nodes") # ty: ignore[invalid-assignment]
551
+ # Edge visibility
552
+ show_calls: bool = param.Boolean(default=True, doc="Render CALLS edges") # ty: ignore[invalid-assignment]
553
+ show_imports: bool = param.Boolean(default=True, doc="Render IMPORTS edges") # ty: ignore[invalid-assignment]
554
+ show_inherits: bool = param.Boolean(default=True, doc="Render INHERITS edges") # ty: ignore[invalid-assignment]
555
+ show_implements: bool = param.Boolean(default=True, doc="Render IMPLEMENTS edges") # ty: ignore[invalid-assignment]
556
+ show_extends: bool = param.Boolean(default=True, doc="Render EXTENDS edges") # ty: ignore[invalid-assignment]
557
+ show_contains: bool = param.Boolean(default=True, doc="Render CONTAINS edges") # ty: ignore[invalid-assignment]
558
+ # Layout spacing (funnel only)
559
+ node_spacing: float = param.Number(default=2.0, bounds=(0.5, 10.0), doc="Funnel node spacing") # ty: ignore[invalid-assignment]
560
+
561
+ # Status / title
562
+ status: str = param.String(default="Ready", doc="Status bar text") # ty: ignore[invalid-assignment]
563
+ window_title: str = param.String(default=f"TypeScriptKG 3D v{__version__}", doc="Window title") # ty: ignore[invalid-assignment]
564
+
565
+ # Stats
566
+ num_modules: int = param.Integer(default=0) # ty: ignore[invalid-assignment]
567
+ num_classes: int = param.Integer(default=0) # ty: ignore[invalid-assignment]
568
+ num_interfaces: int = param.Integer(default=0) # ty: ignore[invalid-assignment]
569
+ num_functions: int = param.Integer(default=0) # ty: ignore[invalid-assignment]
570
+ num_methods: int = param.Integer(default=0) # ty: ignore[invalid-assignment]
571
+ num_faces: int = param.Integer(default=0) # ty: ignore[invalid-assignment]
572
+
573
+ # Module selector data
574
+ available_modules: list[str] = param.List(default=[], doc="Available module names") # ty: ignore[invalid-assignment]
575
+ selected_modules: list[str] = param.ListSelector( # ty: ignore[invalid-assignment]
576
+ default=[], objects=[], doc="Selected module names"
577
+ )
578
+
579
+ def __init__(self, plotter: pv.Plotter | None = None, **params) -> None:
580
+ """
581
+ Initialise the visualiser data model.
582
+
583
+ :param plotter: The ``QtInteractor`` to render into.
584
+ :param params: Additional ``param`` keyword arguments.
585
+ """
586
+ super().__init__(**params)
587
+ self.plotter: pv.Plotter | None = plotter
588
+ self.nodes: list = []
589
+ self.edges: list = []
590
+ self.actor_to_node: dict[str, dict] = {}
591
+ self._load_graph()
592
+
593
+ @param.depends("db_path", watch=True) # ty: ignore[invalid-argument-type]
594
+ def _load_graph(self) -> None:
595
+ """Reload nodes and edges from the SQLite database."""
596
+ from kg_utils.store import GraphStore
597
+
598
+ from tscode_kg.layout3d import LayoutEdge, LayoutNode
599
+
600
+ db = Path(self.db_path)
601
+ if not db.exists():
602
+ self.status = f"Error: database not found: {db}"
603
+ return
604
+
605
+ self.status = "Loading graph..."
606
+ QApplication.processEvents()
607
+
608
+ with GraphStore(db) as store:
609
+ raw_nodes = store.query_nodes()
610
+ node_ids = {n["id"] for n in raw_nodes}
611
+ raw_edges = store.edges_within(node_ids)
612
+
613
+ self.nodes = [LayoutNode.from_dict(n) for n in raw_nodes]
614
+ self.edges = [LayoutEdge.from_dict(e) for e in raw_edges]
615
+
616
+ counts = Counter(n.kind for n in self.nodes)
617
+ self.num_modules = counts.get("module", 0)
618
+ self.num_classes = counts.get("class", 0)
619
+ self.num_interfaces = counts.get("interface", 0)
620
+ self.num_functions = counts.get("function", 0)
621
+ self.num_methods = counts.get("method", 0)
622
+
623
+ mod_names = sorted(n.name for n in self.nodes if n.kind == "module")
624
+ self.available_modules = mod_names
625
+ self.param.selected_modules.objects = mod_names
626
+ self.selected_modules = []
627
+
628
+ repo_name = db.parent.parent.name if db.parent.name == ".tscodekg" else db.stem
629
+ self.window_title = (
630
+ f"TypeScriptKG 3D v{__version__} | {repo_name} | "
631
+ f"Modules: {self.num_modules} Classes: {self.num_classes} "
632
+ f"Interfaces: {self.num_interfaces} "
633
+ f"Methods: {self.num_methods} Functions: {self.num_functions}"
634
+ )
635
+ self.status = f"Loaded: {len(self.nodes)} nodes, {len(self.edges)} edges"
636
+
637
+ if self.plotter and hasattr(self.plotter, "clear_actors"):
638
+ self.plotter.clear_actors()
639
+
640
+ def visualize(self) -> None:
641
+ """
642
+ Build and render the 3-D scene using the current settings.
643
+
644
+ Applies the selected module filter, then delegates to
645
+ :func:`create_kg_visualization`.
646
+ """
647
+ if not self.plotter:
648
+ return
649
+ if not self.nodes:
650
+ self.status = "No data — check DB path."
651
+ return
652
+
653
+ # Apply module filter
654
+ if self.selected_modules:
655
+ contains_children: dict[str, list[str]] = {}
656
+ for e in self.edges:
657
+ if e.rel == "CONTAINS":
658
+ contains_children.setdefault(e.src, []).append(e.dst)
659
+
660
+ def subtree(root_id: str) -> set:
661
+ """Collect all node IDs in the CONTAINS subtree rooted at *root_id*.
662
+
663
+ :param root_id: ID of the root node.
664
+ :return: Set of node IDs reachable via CONTAINS edges.
665
+ """
666
+ s = {root_id}
667
+ for c in contains_children.get(root_id, []):
668
+ s |= subtree(c)
669
+ return s
670
+
671
+ in_scope: set = set()
672
+ for mod_name in self.selected_modules:
673
+ mod_node = next(
674
+ (n for n in self.nodes if n.kind == "module" and n.name == mod_name),
675
+ None,
676
+ )
677
+ if mod_node:
678
+ in_scope |= subtree(mod_node.id)
679
+
680
+ # Respect show_methods / show_symbols
681
+ visible_nodes = [
682
+ n for n in self.nodes if n.id in in_scope and self._kind_visible(n.kind)
683
+ ]
684
+ else:
685
+ visible_nodes = [n for n in self.nodes if self._kind_visible(n.kind)]
686
+
687
+ try:
688
+ _, title, actor_to_node = create_kg_visualization(
689
+ self, visible_nodes, self.edges, self.plotter
690
+ )
691
+ self.actor_to_node = actor_to_node
692
+ self.window_title = title
693
+ except (ValueError, RuntimeError) as exc:
694
+ self.status = f"Error: {exc}"
695
+
696
+ def _kind_visible(self, kind: str) -> bool:
697
+ """Return ``True`` if nodes of this kind should be rendered."""
698
+ if kind == "method" and not self.show_methods:
699
+ return False
700
+ if kind == "symbol" and not self.show_symbols:
701
+ return False
702
+ return True
703
+
704
+
705
+ # ---------------------------------------------------------------------------
706
+ # MainWindow — adapted from repo_vis MainWindow
707
+ # ---------------------------------------------------------------------------
708
+
709
+
710
+ class MainWindow(QMainWindow):
711
+ """
712
+ Full Qt main window for the TypeScriptKG 3-D visualiser.
713
+
714
+ Layout mirrors *repo_vis/pkg_visualizer/pkg_visualizer.py*:
715
+ - **Left** — control panel (DB path, layout, module filter, render options)
716
+ - **Right** — PyVista ``QtInteractor`` + button row
717
+
718
+ Adapted from ``MainWindow`` in *repo_vis* (Eric G. Suchanek, PhD).
719
+ """
720
+
721
+ status_changed: pyqtSignal = pyqtSignal(str)
722
+
723
+ def __init__(
724
+ self,
725
+ db_path: str = DEFAULT_DB,
726
+ save_path: str = DEFAULT_SAVE,
727
+ width: int = 1400,
728
+ height: int = 900,
729
+ ) -> None:
730
+ """
731
+ Initialise the main window.
732
+
733
+ :param db_path: Path to the ``.tscodekg/graph.sqlite`` file.
734
+ :param save_path: Default output file stem for exports.
735
+ :param width: Initial window width in pixels.
736
+ :param height: Initial window height in pixels.
737
+ """
738
+ super().__init__()
739
+
740
+ self.timer = None
741
+ self.current_frame = 0
742
+ self._current_picked_actor = None
743
+ self._current_popup: DocstringPopup | None = None
744
+ self._original_camera_state = None
745
+
746
+ self.setGeometry(100, 100, width, height)
747
+
748
+ self.vtk_plotter: QtInteractor = QtInteractor(self)
749
+ self.visualizer: KGVisualizer = KGVisualizer(
750
+ plotter=self.vtk_plotter, # ty: ignore[invalid-argument-type]
751
+ db_path=db_path,
752
+ save_path=save_path,
753
+ )
754
+ self.plotter = self.vtk_plotter # convenience alias
755
+
756
+ self.setWindowTitle(self.visualizer.window_title)
757
+
758
+ # ── Central widget ──────────────────────────────────────────────────
759
+ central = QWidget()
760
+ self.setCentralWidget(central)
761
+ main_layout = QHBoxLayout(central)
762
+
763
+ self.setStyleSheet(
764
+ """
765
+ QPushButton { background-color: #4CAF50; color: white; border: none;
766
+ border-radius: 3px; padding: 6px; margin: 2px; }
767
+ QPushButton#reset-view { background-color: #FFEB3B; color: black; }
768
+ QPushButton#reset-all { background-color: #E53935; color: white; }
769
+ QPushButton { font-size: 12px; }
770
+ """
771
+ )
772
+
773
+ ctrl_widget = self._build_control_panel()
774
+ vis_widget = self._build_viewport_panel()
775
+
776
+ main_layout.addWidget(ctrl_widget)
777
+ main_layout.addWidget(vis_widget, stretch=1)
778
+ main_layout.setContentsMargins(0, 0, 0, 0)
779
+ main_layout.setSpacing(5)
780
+
781
+ central.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
782
+ ctrl_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding)
783
+
784
+ self._setup_mesh_picking()
785
+ self._connect_signals()
786
+
787
+ font = QFont("Arial", 12)
788
+ self.setFont(font)
789
+ self.resize(width, height)
790
+
791
+ # Perform initial render on launch
792
+ QApplication.processEvents()
793
+ self.on_visualize_clicked()
794
+
795
+ # ── UI builder helpers ───────────────────────────────────────────────────
796
+
797
+ @staticmethod
798
+ def _h2(text: str) -> QLabel:
799
+ """Create a bold section-header QLabel.
800
+
801
+ :param text: Header text (rendered at 13 px bold).
802
+ :return: Styled :class:`QLabel` instance.
803
+ """
804
+ lbl = QLabel(f"<b style='font-size:13px;'>{text}</b>")
805
+ lbl.setStyleSheet("background:transparent; border:none;")
806
+ return lbl
807
+
808
+ @staticmethod
809
+ def _lbl(text: str) -> QLabel:
810
+ """Create a plain-text QLabel with transparent background.
811
+
812
+ :param text: Label text (may contain basic HTML).
813
+ :return: Styled :class:`QLabel` instance.
814
+ """
815
+ lbl = QLabel(text)
816
+ lbl.setStyleSheet("background:transparent; border:none;")
817
+ return lbl
818
+
819
+ def _build_input_params(self, ctrl: QVBoxLayout) -> None:
820
+ """Populate *ctrl* with the Input Parameters section.
821
+
822
+ Adds widgets for DB path, layout selector, save path, and save format.
823
+
824
+ :param ctrl: The control-panel layout to populate.
825
+ """
826
+ ctrl.addWidget(self._h2("Input Parameters"))
827
+
828
+ ctrl.addWidget(self._lbl("<b>Database Path</b>"))
829
+ self.db_path_input = QLineEdit(self.visualizer.db_path)
830
+ self.db_path_input.setPlaceholderText(".tscodekg/graph.sqlite")
831
+ ctrl.addWidget(self.db_path_input)
832
+
833
+ ctrl.addWidget(self._lbl("<b>Layout</b>"))
834
+ self.layout_select = QComboBox()
835
+ self.layout_select.addItems(["allium", "funnel"])
836
+ self.layout_select.setCurrentText(self.visualizer.layout_name)
837
+ ctrl.addWidget(self.layout_select)
838
+
839
+ ctrl.addWidget(self._lbl("<b>Save Path</b>"))
840
+ self.save_path_input = QLineEdit(self.visualizer.save_path)
841
+ ctrl.addWidget(self.save_path_input)
842
+
843
+ ctrl.addWidget(self._lbl("<b>Save Format</b>"))
844
+ self.save_format_select = QComboBox()
845
+ self.save_format_select.addItems(["html", "png", "jpg"])
846
+ self.save_format_select.setCurrentText(self.visualizer.save_format)
847
+ ctrl.addWidget(self.save_format_select)
848
+
849
+ def _build_module_filter(self, ctrl: QVBoxLayout) -> None:
850
+ """Populate *ctrl* with the Module Filter section.
851
+
852
+ Adds a single-selection list pre-filled with available modules.
853
+
854
+ :param ctrl: The control-panel layout to populate.
855
+ """
856
+ ctrl.addWidget(self._h2("Module Filter"))
857
+ ctrl.addWidget(self._lbl("Select module (empty = all):"))
858
+ self.module_selector = QListWidget()
859
+ self.module_selector.setSelectionMode(QListWidget.SingleSelection)
860
+ self.module_selector.setMaximumHeight(90)
861
+ for name in self.visualizer.available_modules:
862
+ self.module_selector.addItem(name)
863
+ ctrl.addWidget(self.module_selector)
864
+
865
+ def _build_render_options(self, ctrl: QVBoxLayout) -> None:
866
+ """Populate *ctrl* with the Render Options and Graph Statistics sections.
867
+
868
+ Adds node-kind checkboxes, edge-type checkboxes, and a live stats label.
869
+
870
+ :param ctrl: The control-panel layout to populate.
871
+ """
872
+ ctrl.addWidget(self._h2("Render Options"))
873
+
874
+ cb_row1 = QHBoxLayout()
875
+ self.cb_methods = QCheckBox("Methods")
876
+ self.cb_methods.setChecked(self.visualizer.show_methods)
877
+ self.cb_symbols = QCheckBox("Symbols")
878
+ self.cb_symbols.setChecked(self.visualizer.show_symbols)
879
+ self.cb_contains = QCheckBox("CONTAINS")
880
+ self.cb_contains.setChecked(self.visualizer.show_contains)
881
+ for w in (self.cb_methods, self.cb_symbols, self.cb_contains):
882
+ cb_row1.addWidget(w)
883
+ ctrl.addLayout(cb_row1)
884
+
885
+ ctrl.addWidget(self._lbl("<b>Edge Types</b>"))
886
+ cb_row2 = QHBoxLayout()
887
+ self.cb_calls = QCheckBox("CALLS")
888
+ self.cb_calls.setChecked(self.visualizer.show_calls)
889
+ self.cb_imports = QCheckBox("IMPORTS")
890
+ self.cb_imports.setChecked(self.visualizer.show_imports)
891
+ self.cb_inherits = QCheckBox("INHERITS")
892
+ self.cb_inherits.setChecked(self.visualizer.show_inherits)
893
+ for w in (self.cb_calls, self.cb_imports, self.cb_inherits):
894
+ cb_row2.addWidget(w)
895
+ ctrl.addLayout(cb_row2)
896
+
897
+ cb_row3 = QHBoxLayout()
898
+ self.cb_implements = QCheckBox("IMPLEMENTS")
899
+ self.cb_implements.setChecked(self.visualizer.show_implements)
900
+ self.cb_extends = QCheckBox("EXTENDS")
901
+ self.cb_extends.setChecked(self.visualizer.show_extends)
902
+ for w in (self.cb_implements, self.cb_extends):
903
+ cb_row3.addWidget(w)
904
+ ctrl.addLayout(cb_row3)
905
+
906
+ ctrl.addWidget(self._lbl("<b>Funnel Spacing</b>"))
907
+ spacing_row = QHBoxLayout()
908
+ self.spacing_slider = QSlider(Qt.Orientation.Horizontal)
909
+ self.spacing_slider.setMinimum(5)
910
+ self.spacing_slider.setMaximum(100)
911
+ self.spacing_slider.setValue(int(self.visualizer.node_spacing * 10))
912
+ self.spacing_slider.setTickInterval(5)
913
+ self.spacing_val_label = QLabel(f"{self.visualizer.node_spacing:.1f}")
914
+ self.spacing_val_label.setFixedWidth(30)
915
+ self.spacing_slider.valueChanged.connect(self._on_spacing_changed)
916
+ spacing_row.addWidget(self.spacing_slider)
917
+ spacing_row.addWidget(self.spacing_val_label)
918
+ ctrl.addLayout(spacing_row)
919
+
920
+ ctrl.addWidget(self._lbl("<b>Graph Statistics</b>"))
921
+ self.stats_label = QLabel(self._stats_text())
922
+ self.stats_label.setWordWrap(True)
923
+ self.stats_label.setStyleSheet(
924
+ "background-color:white; color:black; padding:5px; border-radius:3px;"
925
+ )
926
+ ctrl.addWidget(self.stats_label)
927
+
928
+ def _build_action_buttons(self, ctrl: QVBoxLayout) -> None:
929
+ """Populate *ctrl* with the action-button row at the bottom of the panel.
930
+
931
+ Adds a prominent Render Graph button and a secondary row with
932
+ Show JSDoc and Save View.
933
+
934
+ :param ctrl: The control-panel layout to populate.
935
+ """
936
+ ctrl.addStretch()
937
+
938
+ self.visualize_button = QPushButton("Render Graph")
939
+ self.visualize_button.setMinimumHeight(40)
940
+ self.visualize_button.setStyleSheet("QPushButton { font-size: 14px; font-weight: bold; }")
941
+ ctrl.addWidget(self.visualize_button)
942
+
943
+ btn_row = QHBoxLayout()
944
+ self.show_docstring_button = QPushButton("Show JSDoc")
945
+ self.save_button = QPushButton("Save View")
946
+ btn_row.addWidget(self.show_docstring_button)
947
+ btn_row.addWidget(self.save_button)
948
+ ctrl.addLayout(btn_row)
949
+
950
+ def _build_control_panel(self) -> QWidget:
951
+ """Build and return the left-side control-panel widget.
952
+
953
+ Assembles Input Parameters, Module Filter, Render Options, and
954
+ action buttons into a fixed-width :class:`QWidget`.
955
+
956
+ :return: A :class:`QWidget` containing the complete control panel.
957
+ """
958
+ ctrl = QVBoxLayout()
959
+ ctrl.setSpacing(12)
960
+ ctrl.setContentsMargins(6, 6, 6, 6)
961
+
962
+ self._build_input_params(ctrl)
963
+ self._build_module_filter(ctrl)
964
+ self._build_render_options(ctrl)
965
+ self._build_action_buttons(ctrl)
966
+
967
+ widget = QWidget()
968
+ widget.setLayout(ctrl)
969
+ widget.setFixedWidth(CONTROL_PANEL_WIDTH)
970
+ return widget
971
+
972
+ def _build_viewport_panel(self) -> QWidget:
973
+ """Build and return the right-side viewport widget.
974
+
975
+ Wraps the PyVista :class:`QtInteractor` and a bottom button row
976
+ (Reset View, Reset Settings, status display) in a :class:`QWidget`.
977
+
978
+ :return: A :class:`QWidget` containing the 3-D viewport and controls.
979
+ """
980
+ vis = QVBoxLayout()
981
+ vis.setSpacing(10)
982
+ vis.setContentsMargins(10, 10, 10, 10)
983
+ vis.addWidget(self.vtk_plotter, stretch=1)
984
+ vis.addStretch()
985
+
986
+ btn_row = QHBoxLayout()
987
+
988
+ self.reset_view_button = QPushButton("Reset View")
989
+ self.reset_view_button.setObjectName("reset-view")
990
+ self.reset_view_button.setFixedWidth(BUTTON_WIDTH)
991
+ btn_row.addWidget(self.reset_view_button)
992
+
993
+ self.reset_settings_button = QPushButton("Reset Settings")
994
+ self.reset_settings_button.setObjectName("reset-all")
995
+ self.reset_settings_button.setFixedWidth(BUTTON_WIDTH)
996
+ btn_row.addWidget(self.reset_settings_button)
997
+
998
+ self.status_display = QLabel("Ready")
999
+ self.status_display.setTextInteractionFlags(Qt.TextBrowserInteraction) # ty: ignore[unresolved-attribute]
1000
+ self.status_display.setStyleSheet(
1001
+ "font-weight:bold; font-size:13px; background-color:white; color:black;"
1002
+ )
1003
+ btn_row.addWidget(self.status_display, stretch=1)
1004
+
1005
+ vis.addLayout(btn_row)
1006
+
1007
+ widget = QWidget()
1008
+ widget.setLayout(vis)
1009
+ return widget
1010
+
1011
+ def _setup_mesh_picking(self) -> None:
1012
+ """Configure PyVista mesh picking on the VTK plotter.
1013
+
1014
+ Enables actor-based mesh picking with a tight tolerance, mirroring
1015
+ the *repo_vis* picking setup.
1016
+ """
1017
+ self.vtk_plotter.enable_mesh_picking(
1018
+ callback=self.on_pick,
1019
+ show=False,
1020
+ show_actors=False,
1021
+ show_message=False,
1022
+ font_size=14,
1023
+ left_clicking=False,
1024
+ use_actor=True,
1025
+ through=True,
1026
+ )
1027
+ if hasattr(self.vtk_plotter, "picker"):
1028
+ self.vtk_plotter.picker.SetTolerance(0.005)
1029
+ self.vtk_plotter.picker.SetPickFromList(0)
1030
+
1031
+ def _connect_signals(self) -> None:
1032
+ """Wire all Qt signal/slot connections for the main window.
1033
+
1034
+ Connects control-panel inputs to visualizer properties, action
1035
+ buttons to their handlers, and param watchers for live updates.
1036
+ """
1037
+ self.db_path_input.editingFinished.connect(self.update_db_path)
1038
+ self.layout_select.currentTextChanged.connect(self.update_layout)
1039
+ self.save_path_input.textChanged.connect(lambda t: setattr(self.visualizer, "save_path", t))
1040
+ self.save_format_select.currentTextChanged.connect(
1041
+ lambda t: setattr(self.visualizer, "save_format", t)
1042
+ )
1043
+ self.module_selector.itemSelectionChanged.connect(self.update_selected_modules)
1044
+
1045
+ self.cb_methods.stateChanged.connect(
1046
+ lambda s: setattr(self.visualizer, "show_methods", s == Qt.Checked) # ty: ignore[unresolved-attribute]
1047
+ )
1048
+ self.cb_symbols.stateChanged.connect(
1049
+ lambda s: setattr(self.visualizer, "show_symbols", s == Qt.Checked) # ty: ignore[unresolved-attribute]
1050
+ )
1051
+ self.cb_contains.stateChanged.connect(
1052
+ lambda s: setattr(self.visualizer, "show_contains", s == Qt.Checked) # ty: ignore[unresolved-attribute]
1053
+ )
1054
+ self.cb_calls.stateChanged.connect(
1055
+ lambda s: setattr(self.visualizer, "show_calls", s == Qt.Checked) # ty: ignore[unresolved-attribute]
1056
+ )
1057
+ self.cb_imports.stateChanged.connect(
1058
+ lambda s: setattr(self.visualizer, "show_imports", s == Qt.Checked) # ty: ignore[unresolved-attribute]
1059
+ )
1060
+ self.cb_inherits.stateChanged.connect(
1061
+ lambda s: setattr(self.visualizer, "show_inherits", s == Qt.Checked) # ty: ignore[unresolved-attribute]
1062
+ )
1063
+ self.cb_implements.stateChanged.connect(
1064
+ lambda s: setattr(self.visualizer, "show_implements", s == Qt.Checked) # ty: ignore[unresolved-attribute]
1065
+ )
1066
+ self.cb_extends.stateChanged.connect(
1067
+ lambda s: setattr(self.visualizer, "show_extends", s == Qt.Checked) # ty: ignore[unresolved-attribute]
1068
+ )
1069
+
1070
+ self.visualize_button.clicked.connect(self.on_visualize_clicked)
1071
+ self.show_docstring_button.clicked.connect(self.show_selected_docstring)
1072
+ self.save_button.clicked.connect(self.save_current_view)
1073
+ self.reset_view_button.clicked.connect(self.reset_camera)
1074
+ self.reset_settings_button.clicked.connect(self.reset_settings)
1075
+
1076
+ self.status_changed.connect(self.update_status_display)
1077
+ self.visualizer.param.watch(self.on_status_change, "status")
1078
+ self.visualizer.param.watch(self.update_module_selector, "available_modules")
1079
+ self.visualizer.param.watch(self.update_window_title, "window_title")
1080
+ self.visualizer.param.watch(
1081
+ lambda _: self.stats_label.setText(self._stats_text()), "num_faces"
1082
+ )
1083
+
1084
+ # ── Spacing slider ───────────────────────────────────────────────────────
1085
+
1086
+ def _on_spacing_changed(self, value: int) -> None:
1087
+ spacing = value / 10.0
1088
+ self.visualizer.node_spacing = spacing
1089
+ self.spacing_val_label.setText(f"{spacing:.1f}")
1090
+
1091
+ # ── Stats helper ────────────────────────────────────────────────────────
1092
+
1093
+ def _stats_text(self) -> str:
1094
+ """Build a formatted stats string from the current visualizer state.
1095
+
1096
+ :return: Multi-line string summarising module, class, interface,
1097
+ method, function, and face counts.
1098
+ """
1099
+ v = self.visualizer
1100
+ return (
1101
+ f"Modules: {v.num_modules} Classes: {v.num_classes}\n"
1102
+ f"Interfaces: {v.num_interfaces}\n"
1103
+ f"Methods: {v.num_methods} Functions: {v.num_functions}\n"
1104
+ f"Faces: {v.num_faces}"
1105
+ )
1106
+
1107
+ # ── Control panel updates ───────────────────────────────────────────────
1108
+
1109
+ def update_db_path(self) -> None:
1110
+ """Handle DB path text field edit-finished."""
1111
+ text = self.db_path_input.text().strip()
1112
+ self.visualizer.db_path = text
1113
+ self.module_selector.clear()
1114
+ for name in self.visualizer.available_modules:
1115
+ self.module_selector.addItem(name)
1116
+
1117
+ def update_layout(self, name: str) -> None:
1118
+ """Handle layout combo-box change."""
1119
+ self.visualizer.layout_name = name
1120
+
1121
+ def update_selected_modules(self) -> None:
1122
+ """Sync QListWidget selection to visualizer.selected_modules."""
1123
+ self.visualizer.selected_modules = [
1124
+ item.text() for item in self.module_selector.selectedItems()
1125
+ ]
1126
+
1127
+ def update_module_selector(self, event: param.Event) -> None:
1128
+ """Refresh module list when available_modules changes."""
1129
+ self.module_selector.clear()
1130
+ for name in event.new:
1131
+ self.module_selector.addItem(name)
1132
+
1133
+ def update_window_title(self, event: param.Event) -> None:
1134
+ """Update the window title bar."""
1135
+ self.setWindowTitle(event.new)
1136
+
1137
+ # ── Visualize ───────────────────────────────────────────────────────────
1138
+
1139
+ def on_visualize_clicked(self) -> None:
1140
+ """Trigger a full scene rebuild."""
1141
+ self.visualizer.visualize()
1142
+
1143
+ # ── Picking (mirrors repo_vis on_pick exactly) ──────────────────────────
1144
+
1145
+ def on_pick(self, actor) -> None:
1146
+ """
1147
+ Callback fired when the user right-clicks a node mesh.
1148
+ Finds the nearest node, highlights it, and shows the JSDoc popup.
1149
+ Adapted from ``MainWindow.on_pick`` in *repo_vis*.
1150
+ """
1151
+ if not self.visualizer.plotter:
1152
+ return
1153
+ self.reset_actor_appearances()
1154
+
1155
+ if self._current_popup and self._current_popup.isVisible():
1156
+ self._current_popup.close()
1157
+ self._current_popup = None
1158
+
1159
+ if actor is None:
1160
+ self.update_status_display("No object picked.")
1161
+ return
1162
+
1163
+ if not hasattr(self.vtk_plotter, "picked_point") or self.vtk_plotter.picked_point is None:
1164
+ self.update_status_display("No object picked.")
1165
+ return
1166
+
1167
+ picked_point = np.asarray(self.vtk_plotter.picked_point, float)
1168
+
1169
+ # Identify actor by name
1170
+ picked_name = None
1171
+ for name, act in self.plotter.actors.items():
1172
+ if act == actor:
1173
+ picked_name = name
1174
+ break
1175
+
1176
+ if picked_name is None:
1177
+ self.update_status_display("Could not identify picked object.")
1178
+ return
1179
+
1180
+ # Extract kind from actor name: "{kind}_nodes"
1181
+ picked_kind = None
1182
+ for kind in KIND_SIZE:
1183
+ if picked_name == f"{kind}_nodes":
1184
+ picked_kind = kind
1185
+ break
1186
+
1187
+ if picked_kind is None:
1188
+ self.update_status_display(f"Unknown actor: {picked_name}")
1189
+ return
1190
+
1191
+ # Find closest node of that kind
1192
+ best_id, best_dist = None, float("inf")
1193
+ for mesh_id, elem in self.visualizer.actor_to_node.items():
1194
+ if elem["kind"] != picked_kind:
1195
+ continue
1196
+ pos = np.asarray(elem["position"], float)
1197
+ d = float(np.linalg.norm(pos - picked_point))
1198
+ if d < best_dist:
1199
+ best_dist = d
1200
+ best_id = mesh_id
1201
+
1202
+ if best_id is None:
1203
+ self.update_status_display(f"No {picked_kind} near pick point.")
1204
+ return
1205
+
1206
+ elem = self.visualizer.actor_to_node[best_id]
1207
+ self.highlight_actor(elem["mesh"])
1208
+ title = f"{elem['kind'].capitalize()}: {elem['name']}"
1209
+ self._current_popup = DocstringPopup(
1210
+ title,
1211
+ _docstring_to_markdown(elem.get("docstring")),
1212
+ self,
1213
+ on_close_callback=self.reset_picking_state,
1214
+ )
1215
+ self._current_popup.show()
1216
+
1217
+ def highlight_actor(self, mesh) -> None:
1218
+ """
1219
+ Highlight *mesh* in pink, focus camera, and zoom.
1220
+ Adapted from ``MainWindow.highlight_actor`` in *repo_vis*.
1221
+ """
1222
+ if not self.plotter:
1223
+ return
1224
+ self.reset_actor_appearances()
1225
+ self.reset_camera()
1226
+
1227
+ self.plotter.add_mesh(
1228
+ mesh,
1229
+ color="pink",
1230
+ show_edges=True,
1231
+ edge_color="white",
1232
+ line_width=3,
1233
+ pickable=False,
1234
+ show_scalar_bar=False,
1235
+ reset_camera=False,
1236
+ name="_kg_highlight",
1237
+ )
1238
+ self._current_picked_actor = self.plotter.actors.get("_kg_highlight")
1239
+
1240
+ self._original_camera_state = { # type: ignore[assignment]
1241
+ "position": self.plotter.camera.position,
1242
+ "focal_point": self.plotter.camera.focal_point,
1243
+ "view_up": self.plotter.camera.up,
1244
+ }
1245
+
1246
+ self.plotter.camera.focal_point = np.array(mesh.center)
1247
+ self.plotter.camera.Zoom(ZOOM_FACTOR)
1248
+ self.plotter.render()
1249
+
1250
+ def reset_actor_appearances(self) -> None:
1251
+ """Reset pick highlights and restore actor colours."""
1252
+ if self._current_picked_actor:
1253
+ self.plotter.remove_actor(self._current_picked_actor, reset_camera=False)
1254
+ self._current_picked_actor = None
1255
+
1256
+ for kind in KIND_SIZE:
1257
+ actor = self.plotter.actors.get(f"{kind}_nodes")
1258
+ if actor:
1259
+ actor.prop.color = KIND_COLOR[kind]
1260
+ actor.prop.show_edges = False
1261
+ actor.prop.line_width = 1
1262
+
1263
+ _remove_highlight_actors(self.plotter)
1264
+
1265
+ def reset_picking_state(self) -> None:
1266
+ """Clear pick state after popup is closed."""
1267
+ self.reset_actor_appearances()
1268
+ self.module_selector.clearSelection()
1269
+ self.update_status_display("Ready")
1270
+ self.plotter.render()
1271
+
1272
+ # ── JSDoc button ────────────────────────────────────────────────────────
1273
+
1274
+ def show_selected_docstring(self) -> None:
1275
+ """Show JSDoc for the selected module in the list (if any)."""
1276
+ items = self.module_selector.selectedItems()
1277
+ if not items:
1278
+ self.update_status_display("Select a module first.")
1279
+ return
1280
+ mod_name = items[0].text()
1281
+ mod_node = next(
1282
+ (n for n in self.visualizer.nodes if n.kind == "module" and n.name == mod_name),
1283
+ None,
1284
+ )
1285
+ if mod_node is None:
1286
+ return
1287
+ self._current_popup = DocstringPopup(
1288
+ f"Module: {mod_name}",
1289
+ _docstring_to_markdown(mod_node.docstring),
1290
+ self,
1291
+ on_close_callback=self.reset_picking_state,
1292
+ )
1293
+ self._current_popup.show()
1294
+
1295
+ # ── Camera controls (spin_camera copied verbatim from repo_vis) ─────────
1296
+
1297
+ def reset_camera(self) -> None:
1298
+ """Reset the camera to the default front-elevated view."""
1299
+ if not self.plotter:
1300
+ return
1301
+ self.plotter.reset_camera()
1302
+ self.plotter.view_vector((0.0, 1.0, 0.35), viewup=(0, 0, 1))
1303
+ self.plotter.render()
1304
+ self.visualizer.status = "View reset."
1305
+
1306
+ # ── Save ────────────────────────────────────────────────────────────────
1307
+
1308
+ def save_current_view(self) -> None:
1309
+ """
1310
+ Save the current visualization to a file.
1311
+ Adapted from ``MainWindow.save_current_view`` in *repo_vis*.
1312
+ """
1313
+ save_path = Path(self.visualizer.save_path)
1314
+ fmt = self.visualizer.save_format
1315
+ if save_path.suffix.lstrip(".") != fmt:
1316
+ save_path = save_path.with_suffix(f".{fmt}")
1317
+
1318
+ self.visualizer.status = f"Saving to {save_path}…"
1319
+ QApplication.processEvents()
1320
+
1321
+ try:
1322
+ save_path.parent.mkdir(parents=True, exist_ok=True)
1323
+ if fmt == "html":
1324
+ self.plotter.export_html(str(save_path))
1325
+ else:
1326
+ self.plotter.screenshot(str(save_path))
1327
+ self.visualizer.status = f"Saved → {save_path}"
1328
+ except ImportError as exc:
1329
+ self.visualizer.status = f"HTML export unavailable: {exc}"
1330
+ except (OSError, RuntimeError, ValueError) as exc:
1331
+ self.visualizer.status = f"Error saving: {exc}"
1332
+
1333
+ # ── Status display (mirrors repo_vis update_status_display) ────────────
1334
+
1335
+ def on_status_change(self, event: param.Event) -> None:
1336
+ """Forward param status change to the Qt signal."""
1337
+ self.status_changed.emit(event.new)
1338
+ QApplication.processEvents()
1339
+
1340
+ def update_status_display(self, status: str) -> None:
1341
+ """Render status text with colour coding (mirrors repo_vis)."""
1342
+ if status.startswith("Error"):
1343
+ html = f"<span style='color:#E53935;font-size:13px;'><b>Error:</b> {status[6:]}</span>"
1344
+ elif "Rendering" in status or "Loading" in status or "Saving" in status:
1345
+ html = f"<span style='color:#42A5F5;font-size:13px;'><b>⏳ {status}</b></span>"
1346
+ elif "Loaded" in status or "Saved" in status or "complete" in status.lower():
1347
+ html = f"<span style='color:#66BB6A;font-size:13px;'><b>✓ {status}</b></span>"
1348
+ elif "Ready" in status or "reset" in status.lower():
1349
+ html = "<span style='color:#66BB6A;font-size:13px;'><b>⚡ Ready</b></span>"
1350
+ else:
1351
+ html = f"<span style='font-size:13px;'>{status}</span>"
1352
+ self.status_display.setText(html)
1353
+
1354
+ # ── Reset settings ──────────────────────────────────────────────────────
1355
+
1356
+ def reset_settings(self) -> None:
1357
+ """Reset all controls to defaults and clear the scene."""
1358
+ self.module_selector.clearSelection()
1359
+ self.cb_methods.setChecked(True)
1360
+ self.cb_symbols.setChecked(False)
1361
+ self.cb_contains.setChecked(False)
1362
+ self.cb_calls.setChecked(True)
1363
+ self.cb_imports.setChecked(True)
1364
+ self.cb_inherits.setChecked(True)
1365
+ self.cb_implements.setChecked(True)
1366
+ self.cb_extends.setChecked(True)
1367
+ self.reset_camera()
1368
+ self.visualizer.status = "Ready"
1369
+
1370
+ # ── Cleanup / close (copied from repo_vis) ──────────────────────────────
1371
+
1372
+ def cleanup_pyvista_objects(self) -> None:
1373
+ """Thorough PyVista cleanup to prevent errors on exit."""
1374
+ if self._current_popup and hasattr(self._current_popup, "isVisible"):
1375
+ try:
1376
+ self._current_popup.close()
1377
+ except (AttributeError, RuntimeError):
1378
+ pass
1379
+ self._current_popup = None
1380
+
1381
+ if hasattr(self, "plotter") and self.plotter:
1382
+ try:
1383
+ if hasattr(self.plotter, "clear_actors"):
1384
+ self.plotter.clear_actors()
1385
+ if hasattr(self.plotter, "clear"):
1386
+ self.plotter.clear()
1387
+ if hasattr(self.plotter, "close"):
1388
+ self.plotter.close()
1389
+ self.visualizer.plotter = None
1390
+ self.plotter = None
1391
+ self.vtk_plotter = None
1392
+ except (AttributeError, RuntimeError):
1393
+ pass
1394
+ gc.collect()
1395
+
1396
+ def closeEvent(self, event) -> None: # ty: ignore[invalid-method-override]
1397
+ """Handle window close with cleanup."""
1398
+ with warnings.catch_warnings():
1399
+ warnings.simplefilter("ignore")
1400
+ try:
1401
+ self.cleanup_pyvista_objects()
1402
+ except (AttributeError, RuntimeError):
1403
+ pass
1404
+ event.accept()
1405
+
1406
+ def run(self) -> None:
1407
+ """Start the Qt event loop and show the window."""
1408
+ app = QApplication.instance() or QApplication(sys.argv)
1409
+ self.show()
1410
+ try:
1411
+ app.exec()
1412
+ except (RuntimeError, AttributeError) as exc:
1413
+ logger.error("Application error: %s", exc)
1414
+ finally:
1415
+ sys.exit()
1416
+
1417
+
1418
+ # ---------------------------------------------------------------------------
1419
+ # Atexit cleanup
1420
+ # ---------------------------------------------------------------------------
1421
+
1422
+ atexit.register(gc.collect)
1423
+
1424
+
1425
+ # ---------------------------------------------------------------------------
1426
+ # launch() — convenience entry point used by the tscodekg viz3d CLI
1427
+ # ---------------------------------------------------------------------------
1428
+
1429
+
1430
+ def launch(
1431
+ db_path: str = DEFAULT_DB,
1432
+ layout_name: str = "allium",
1433
+ width: int = 1400,
1434
+ height: int = 900,
1435
+ **_kwargs,
1436
+ ) -> None:
1437
+ """
1438
+ Create a :class:`QApplication`, open :class:`MainWindow`, and run the event loop.
1439
+
1440
+ :param db_path: Path to the SQLite database.
1441
+ :param layout_name: ``"allium"`` or ``"funnel"``.
1442
+ :param width: Initial window width.
1443
+ :param height: Initial window height.
1444
+ """
1445
+ app = QApplication.instance() or QApplication(sys.argv)
1446
+ app.setApplicationName("tscodekg-viz3d")
1447
+
1448
+ win = MainWindow(
1449
+ db_path=db_path,
1450
+ save_path=Path(db_path).stem,
1451
+ width=width,
1452
+ height=height,
1453
+ )
1454
+ win.visualizer.layout_name = layout_name
1455
+ win.layout_select.setCurrentText(layout_name)
1456
+ win.show()
1457
+ sys.exit(app.exec())