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/__init__.py +42 -0
- tscode_kg/__main__.py +6 -0
- tscode_kg/analysis.py +1829 -0
- tscode_kg/app.py +1355 -0
- tscode_kg/bridge.py +114 -0
- tscode_kg/centrality.py +434 -0
- tscode_kg/cli/__init__.py +1 -0
- tscode_kg/cli/cmd_analyze.py +69 -0
- tscode_kg/cli/cmd_bridges.py +38 -0
- tscode_kg/cli/cmd_build.py +86 -0
- tscode_kg/cli/cmd_centrality.py +124 -0
- tscode_kg/cli/cmd_explain.py +58 -0
- tscode_kg/cli/cmd_framework_nodes.py +43 -0
- tscode_kg/cli/cmd_hooks.py +125 -0
- tscode_kg/cli/cmd_init.py +234 -0
- tscode_kg/cli/cmd_mcp.py +35 -0
- tscode_kg/cli/cmd_model.py +52 -0
- tscode_kg/cli/cmd_query.py +75 -0
- tscode_kg/cli/cmd_snapshot.py +431 -0
- tscode_kg/cli/cmd_viz.py +175 -0
- tscode_kg/cli/main.py +56 -0
- tscode_kg/coderank.py +564 -0
- tscode_kg/config.py +36 -0
- tscode_kg/explain.py +270 -0
- tscode_kg/extractor.py +827 -0
- tscode_kg/framework_detector.py +106 -0
- tscode_kg/kg.py +193 -0
- tscode_kg/layout3d.py +492 -0
- tscode_kg/mcp_server.py +1412 -0
- tscode_kg/snapshots.py +64 -0
- tscode_kg/viz3d.py +1457 -0
- tscode_kg/viz3d_timeline.py +369 -0
- tscode_kg-0.2.0.dist-info/METADATA +196 -0
- tscode_kg-0.2.0.dist-info/RECORD +37 -0
- tscode_kg-0.2.0.dist-info/WHEEL +4 -0
- tscode_kg-0.2.0.dist-info/entry_points.txt +15 -0
- tscode_kg-0.2.0.dist-info/licenses/LICENSE +24 -0
tscode_kg/layout3d.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
layout3d.py — Pluggable 3-D layout engine for the TypeScriptKG knowledge graph.
|
|
4
|
+
|
|
5
|
+
Provides an abstract :class:`Layout3D` base class and two concrete
|
|
6
|
+
implementations:
|
|
7
|
+
|
|
8
|
+
- :class:`AlliumLayout`: Each module is rendered as a Giant Allium plant
|
|
9
|
+
(a vertical stem with a Fibonacci-sphere "head" of classes and functions).
|
|
10
|
+
Modules are arranged in a Fibonacci annulus in the XY plane.
|
|
11
|
+
|
|
12
|
+
- :class:`FunnelLayout`: Node kind determines the Z level (modules at
|
|
13
|
+
the bottom, classes above, functions/methods at the top). XY positions
|
|
14
|
+
are spread via a golden-angle spiral within each layer.
|
|
15
|
+
|
|
16
|
+
The Fibonacci utilities (``fibonacci_sphere``, ``fibonacci_annulus``) are
|
|
17
|
+
adapted from *repo_vis* ``pkg_visualizer/utility.py``
|
|
18
|
+
(Eric G. Suchanek, PhD — https://github.com/Suchanek/repo_vis).
|
|
19
|
+
|
|
20
|
+
Author: Eric G. Suchanek, PhD
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Fibonacci spatial utilities (adapted from repo_vis/pkg_visualizer/utility.py)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fibonacci_sphere(
|
|
36
|
+
samples: int,
|
|
37
|
+
radius: float = 1.0,
|
|
38
|
+
center: np.ndarray | None = None,
|
|
39
|
+
) -> list[np.ndarray]:
|
|
40
|
+
"""
|
|
41
|
+
Distribute *samples* points uniformly on a sphere using the Fibonacci spiral.
|
|
42
|
+
|
|
43
|
+
Adapted from ``utility.fibonacci_sphere`` in *repo_vis*.
|
|
44
|
+
|
|
45
|
+
:param samples: Number of points to generate.
|
|
46
|
+
:param radius: Sphere radius.
|
|
47
|
+
:param center: Centre of the sphere (default: origin).
|
|
48
|
+
:return: List of 3-D coordinate arrays.
|
|
49
|
+
"""
|
|
50
|
+
if center is None:
|
|
51
|
+
center = np.zeros(3)
|
|
52
|
+
if samples <= 0:
|
|
53
|
+
return []
|
|
54
|
+
if samples == 1:
|
|
55
|
+
return [center + radius * np.array([0.0, 0.0, 1.0])]
|
|
56
|
+
|
|
57
|
+
phi = np.pi * (3.0 - np.sqrt(5.0)) # golden angle in radians
|
|
58
|
+
points: list[np.ndarray] = []
|
|
59
|
+
for i in range(samples):
|
|
60
|
+
y = 1.0 - (i / float(samples - 1)) * 2.0
|
|
61
|
+
r_at_y = np.sqrt(max(0.0, 1.0 - y * y))
|
|
62
|
+
theta = phi * i
|
|
63
|
+
x = np.cos(theta) * r_at_y
|
|
64
|
+
z = np.sin(theta) * r_at_y
|
|
65
|
+
points.append(center + radius * np.array([x, y, z]))
|
|
66
|
+
return points
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fibonacci_annulus(
|
|
70
|
+
samples: int,
|
|
71
|
+
inner_radius: float = 1.0,
|
|
72
|
+
outer_radius: float = 2.0,
|
|
73
|
+
center: np.ndarray | None = None,
|
|
74
|
+
z_thickness: float = 0.2,
|
|
75
|
+
) -> list[np.ndarray]:
|
|
76
|
+
"""
|
|
77
|
+
Distribute *samples* points in a flat annular ring in the XY plane.
|
|
78
|
+
|
|
79
|
+
A small Z jitter (``z_thickness``) adds visual depth when non-zero.
|
|
80
|
+
Adapted from ``utility.fibonacci_annulus`` in *repo_vis*.
|
|
81
|
+
|
|
82
|
+
:param samples: Number of points to generate.
|
|
83
|
+
:param inner_radius: Inner radius of the annulus.
|
|
84
|
+
:param outer_radius: Outer radius of the annulus.
|
|
85
|
+
:param center: Centre of the annulus (default: origin).
|
|
86
|
+
:param z_thickness: Half-range of Z jitter applied to each point.
|
|
87
|
+
:return: List of 3-D coordinate arrays.
|
|
88
|
+
"""
|
|
89
|
+
if center is None:
|
|
90
|
+
center = np.zeros(3)
|
|
91
|
+
if samples <= 0:
|
|
92
|
+
return []
|
|
93
|
+
if samples == 1:
|
|
94
|
+
mid = (inner_radius + outer_radius) / 2.0
|
|
95
|
+
return [center + np.array([mid, 0.0, 0.0])]
|
|
96
|
+
|
|
97
|
+
phi = np.pi * (3.0 - np.sqrt(5.0))
|
|
98
|
+
r_range = outer_radius - inner_radius
|
|
99
|
+
r_step = r_range / max(samples - 1, 1)
|
|
100
|
+
rng = np.random.default_rng(42) # deterministic jitter seed
|
|
101
|
+
|
|
102
|
+
points: list[np.ndarray] = []
|
|
103
|
+
for i in range(samples):
|
|
104
|
+
r = inner_radius + i * r_step
|
|
105
|
+
theta = phi * i
|
|
106
|
+
x = np.cos(theta) * r
|
|
107
|
+
y = np.sin(theta) * r
|
|
108
|
+
z = (rng.random() * 2.0 - 1.0) * z_thickness
|
|
109
|
+
points.append(center + np.array([x, y, z]))
|
|
110
|
+
return points
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _golden_spiral_2d(
|
|
114
|
+
samples: int,
|
|
115
|
+
radius: float = 1.0,
|
|
116
|
+
center: np.ndarray | None = None,
|
|
117
|
+
z: float = 0.0,
|
|
118
|
+
) -> list[np.ndarray]:
|
|
119
|
+
"""
|
|
120
|
+
Place *samples* points in the XY plane using a golden-angle disc spiral.
|
|
121
|
+
|
|
122
|
+
:param samples: Number of points.
|
|
123
|
+
:param radius: Outer radius of the disc.
|
|
124
|
+
:param center: XY centre (Z component ignored; overridden by *z*).
|
|
125
|
+
:param z: Fixed Z coordinate for all output points.
|
|
126
|
+
:return: List of 3-D coordinate arrays.
|
|
127
|
+
"""
|
|
128
|
+
if center is None:
|
|
129
|
+
center = np.zeros(3)
|
|
130
|
+
if samples <= 0:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
phi = np.pi * (3.0 - np.sqrt(5.0))
|
|
134
|
+
points: list[np.ndarray] = []
|
|
135
|
+
for i in range(samples):
|
|
136
|
+
r = radius * np.sqrt(i / max(samples - 1, 1))
|
|
137
|
+
theta = phi * i
|
|
138
|
+
x = r * np.cos(theta)
|
|
139
|
+
y = r * np.sin(theta)
|
|
140
|
+
points.append(center + np.array([x, y, z]))
|
|
141
|
+
return points
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Data transfer objects
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class LayoutNode:
|
|
151
|
+
"""
|
|
152
|
+
Thin wrapper around a node dict from :class:`~kg_utils.store.GraphStore`.
|
|
153
|
+
|
|
154
|
+
:param id: Stable node identifier (e.g. ``mod:src/foo.ts``).
|
|
155
|
+
:param kind: Node kind — ``module``, ``class``, ``interface``,
|
|
156
|
+
``type_alias``, ``enum``, ``namespace``, ``function``, ``method``,
|
|
157
|
+
or ``symbol``.
|
|
158
|
+
:param name: Short name of the node.
|
|
159
|
+
:param module_path: Source module path (may be ``None`` for symbol stubs).
|
|
160
|
+
:param docstring: Raw JSDoc text (may be ``None``).
|
|
161
|
+
:param lineno: First source line number (may be ``None``).
|
|
162
|
+
:param end_lineno: Last source line number (may be ``None``).
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
id: str
|
|
166
|
+
kind: str
|
|
167
|
+
name: str
|
|
168
|
+
module_path: str | None = None
|
|
169
|
+
docstring: str | None = None
|
|
170
|
+
lineno: int | None = None
|
|
171
|
+
end_lineno: int | None = None
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def from_dict(cls, d: dict) -> LayoutNode:
|
|
175
|
+
"""Construct from a GraphStore node dict.
|
|
176
|
+
|
|
177
|
+
:param d: Dict with keys ``id``, ``kind``, ``name``, etc.
|
|
178
|
+
:return: New :class:`LayoutNode`.
|
|
179
|
+
"""
|
|
180
|
+
return cls(
|
|
181
|
+
id=d["id"],
|
|
182
|
+
kind=d["kind"],
|
|
183
|
+
name=d["name"],
|
|
184
|
+
module_path=d.get("module_path"),
|
|
185
|
+
docstring=d.get("docstring"),
|
|
186
|
+
lineno=d.get("lineno"),
|
|
187
|
+
end_lineno=d.get("end_lineno"),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def line_count(self) -> int:
|
|
192
|
+
"""Approximate source size in lines (0 if unknown).
|
|
193
|
+
|
|
194
|
+
:return: ``end_lineno - lineno`` or 0.
|
|
195
|
+
"""
|
|
196
|
+
if self.lineno and self.end_lineno:
|
|
197
|
+
return max(0, self.end_lineno - self.lineno)
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class LayoutEdge:
|
|
203
|
+
"""
|
|
204
|
+
Thin wrapper around an edge dict from :class:`~kg_utils.store.GraphStore`.
|
|
205
|
+
|
|
206
|
+
:param src: Source node ID.
|
|
207
|
+
:param rel: Relation type — ``CONTAINS``, ``CALLS``, ``IMPORTS``,
|
|
208
|
+
``INHERITS``, ``IMPLEMENTS``, ``EXTENDS``.
|
|
209
|
+
:param dst: Destination node ID.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
src: str
|
|
213
|
+
rel: str
|
|
214
|
+
dst: str
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def from_dict(cls, d: dict) -> LayoutEdge:
|
|
218
|
+
"""Construct from a GraphStore edge dict.
|
|
219
|
+
|
|
220
|
+
:param d: Dict with keys ``src``, ``rel``, ``dst``.
|
|
221
|
+
:return: New :class:`LayoutEdge`.
|
|
222
|
+
"""
|
|
223
|
+
return cls(src=d["src"], rel=d["rel"], dst=d["dst"])
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Abstract base
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Layout3D(ABC):
|
|
232
|
+
"""
|
|
233
|
+
Abstract base class for 3-D graph layout strategies.
|
|
234
|
+
|
|
235
|
+
Subclasses implement :meth:`compute` to assign a 3-D position to every
|
|
236
|
+
node, returning a ``{node_id: np.ndarray([x, y, z])}`` mapping that the
|
|
237
|
+
:class:`~tscode_kg.viz3d.KGViz3D` renderer consumes.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
@abstractmethod
|
|
241
|
+
def compute(
|
|
242
|
+
self,
|
|
243
|
+
nodes: list[LayoutNode],
|
|
244
|
+
edges: list[LayoutEdge],
|
|
245
|
+
) -> dict[str, np.ndarray]:
|
|
246
|
+
"""
|
|
247
|
+
Compute 3-D positions for all *nodes*.
|
|
248
|
+
|
|
249
|
+
:param nodes: All nodes in the graph.
|
|
250
|
+
:param edges: All edges in the graph (used to derive hierarchy).
|
|
251
|
+
:return: Mapping from node ID to ``[x, y, z]`` position.
|
|
252
|
+
"""
|
|
253
|
+
...
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# AlliumLayout
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class AlliumLayout(Layout3D):
|
|
262
|
+
"""
|
|
263
|
+
Allium-plant layout: each module is visualised as a Giant Allium flower.
|
|
264
|
+
|
|
265
|
+
Spatial structure:
|
|
266
|
+
|
|
267
|
+
- **Stem base** — module node sits at XY position in a Fibonacci annulus
|
|
268
|
+
at ``Z = 0``.
|
|
269
|
+
- **Head** — classes, interfaces, and top-level functions are distributed
|
|
270
|
+
on a Fibonacci sphere centred at the stem apex (``Z = stem_height``).
|
|
271
|
+
Head radius scales with ``sqrt(n_children)``.
|
|
272
|
+
- **Florets** — methods orbit their parent class on a smaller Fibonacci
|
|
273
|
+
sphere, slightly above the head.
|
|
274
|
+
- **Orphans** — nodes with no CONTAINS parent cluster on a small sphere
|
|
275
|
+
at the origin.
|
|
276
|
+
|
|
277
|
+
Multiple module-alliums are arranged in a Fibonacci annulus in the XY
|
|
278
|
+
plane so they are evenly spaced regardless of count.
|
|
279
|
+
|
|
280
|
+
Inspired by :class:`GiantAllium` in *repo_vis/pkg_visualizer/plants3d.py*.
|
|
281
|
+
|
|
282
|
+
:param stem_height: Height of each allium stem (Z offset of the head).
|
|
283
|
+
:param base_head_radius: Minimum radius for the Fibonacci sphere head.
|
|
284
|
+
:param method_orbit_radius: Base radius for method sub-spheres.
|
|
285
|
+
:param annulus_inner_radius: Inner radius of the module placement ring.
|
|
286
|
+
:param annulus_outer_radius: Minimum outer radius (auto-scaled for large graphs).
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
stem_height: float = 8.0,
|
|
292
|
+
base_head_radius: float = 2.0,
|
|
293
|
+
method_orbit_radius: float = 0.8,
|
|
294
|
+
annulus_inner_radius: float = 8.0,
|
|
295
|
+
annulus_outer_radius: float = 20.0,
|
|
296
|
+
) -> None:
|
|
297
|
+
"""Initialise layout parameters.
|
|
298
|
+
|
|
299
|
+
:param stem_height: Vertical height of each allium stem.
|
|
300
|
+
:param base_head_radius: Minimum allium head sphere radius.
|
|
301
|
+
:param method_orbit_radius: Base orbit radius for methods.
|
|
302
|
+
:param annulus_inner_radius: Inner radius for module ring placement.
|
|
303
|
+
:param annulus_outer_radius: Minimum outer radius for module ring.
|
|
304
|
+
"""
|
|
305
|
+
self.stem_height = stem_height
|
|
306
|
+
self.base_head_radius = base_head_radius
|
|
307
|
+
self.method_orbit_radius = method_orbit_radius
|
|
308
|
+
self.annulus_inner_radius = annulus_inner_radius
|
|
309
|
+
self.annulus_outer_radius = annulus_outer_radius
|
|
310
|
+
|
|
311
|
+
def compute(
|
|
312
|
+
self,
|
|
313
|
+
nodes: list[LayoutNode],
|
|
314
|
+
edges: list[LayoutEdge],
|
|
315
|
+
) -> dict[str, np.ndarray]:
|
|
316
|
+
"""
|
|
317
|
+
Compute allium-plant 3-D positions for all nodes.
|
|
318
|
+
|
|
319
|
+
:param nodes: All nodes in the graph.
|
|
320
|
+
:param edges: All edges (``CONTAINS`` used to derive hierarchy).
|
|
321
|
+
:return: Mapping from node ID to ``[x, y, z]`` position.
|
|
322
|
+
"""
|
|
323
|
+
# Build CONTAINS hierarchy: child_id -> parent_id, parent_id -> [child_ids]
|
|
324
|
+
parent: dict[str, str] = {}
|
|
325
|
+
children: dict[str, list[str]] = {}
|
|
326
|
+
for e in edges:
|
|
327
|
+
if e.rel == "CONTAINS":
|
|
328
|
+
parent[e.dst] = e.src
|
|
329
|
+
children.setdefault(e.src, []).append(e.dst)
|
|
330
|
+
|
|
331
|
+
node_by_id: dict[str, LayoutNode] = {n.id: n for n in nodes}
|
|
332
|
+
positions: dict[str, np.ndarray] = {}
|
|
333
|
+
|
|
334
|
+
# Module nodes form the allium stems
|
|
335
|
+
modules = [n for n in nodes if n.kind == "module"]
|
|
336
|
+
if not modules:
|
|
337
|
+
# Fallback: treat nodes without a CONTAINS parent as pseudo-modules
|
|
338
|
+
modules = [n for n in nodes if n.id not in parent]
|
|
339
|
+
|
|
340
|
+
n_mods = len(modules)
|
|
341
|
+
inner = self.annulus_inner_radius
|
|
342
|
+
# Scale outer radius so stems don't crowd each other
|
|
343
|
+
outer = max(self.annulus_outer_radius, inner + np.sqrt(n_mods) * 4.0)
|
|
344
|
+
|
|
345
|
+
mod_positions = fibonacci_annulus(
|
|
346
|
+
n_mods,
|
|
347
|
+
inner_radius=inner,
|
|
348
|
+
outer_radius=outer,
|
|
349
|
+
center=np.zeros(3),
|
|
350
|
+
z_thickness=0.0, # flat ring — alliums stand vertically
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
for mod_node, mod_pos in zip(modules, mod_positions):
|
|
354
|
+
positions[mod_node.id] = np.array(mod_pos)
|
|
355
|
+
stem_apex = np.array([mod_pos[0], mod_pos[1], self.stem_height])
|
|
356
|
+
|
|
357
|
+
# Direct children (classes, interfaces, top-level functions)
|
|
358
|
+
direct_ids = children.get(mod_node.id, [])
|
|
359
|
+
direct = [node_by_id[cid] for cid in direct_ids if cid in node_by_id]
|
|
360
|
+
n_direct = len(direct)
|
|
361
|
+
if not direct:
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
# Head radius scales with child count
|
|
365
|
+
head_r = self.base_head_radius + np.sqrt(n_direct) * 0.4
|
|
366
|
+
head_positions = fibonacci_sphere(n_direct, radius=head_r, center=stem_apex)
|
|
367
|
+
|
|
368
|
+
for child, child_pos in zip(direct, head_positions):
|
|
369
|
+
positions[child.id] = np.array(child_pos)
|
|
370
|
+
|
|
371
|
+
# Grandchildren (methods) orbit their parent class
|
|
372
|
+
grand_ids = children.get(child.id, [])
|
|
373
|
+
grand = [node_by_id[gid] for gid in grand_ids if gid in node_by_id]
|
|
374
|
+
n_grand = len(grand)
|
|
375
|
+
if not grand:
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
method_r = self.method_orbit_radius + np.sqrt(n_grand) * 0.15
|
|
379
|
+
method_positions = fibonacci_sphere(
|
|
380
|
+
n_grand, radius=method_r, center=np.array(child_pos)
|
|
381
|
+
)
|
|
382
|
+
for gc, gc_pos in zip(grand, method_positions):
|
|
383
|
+
positions[gc.id] = np.array(gc_pos)
|
|
384
|
+
|
|
385
|
+
# Orphan nodes: anything not yet placed (symbols, unrooted nodes)
|
|
386
|
+
orphans = [n for n in nodes if n.id not in positions]
|
|
387
|
+
if orphans:
|
|
388
|
+
orphan_r = 3.0
|
|
389
|
+
orphan_positions = fibonacci_sphere(
|
|
390
|
+
len(orphans), radius=orphan_r, center=np.array([0.0, 0.0, orphan_r])
|
|
391
|
+
)
|
|
392
|
+
for n, pos in zip(orphans, orphan_positions):
|
|
393
|
+
positions[n.id] = np.array(pos)
|
|
394
|
+
|
|
395
|
+
return positions
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
# FunnelLayout
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
# Z level per node kind
|
|
403
|
+
_KIND_ZLEVEL: dict[str, int] = {
|
|
404
|
+
"module": 0,
|
|
405
|
+
"namespace": 1,
|
|
406
|
+
"class": 1,
|
|
407
|
+
"interface": 1,
|
|
408
|
+
"enum": 1,
|
|
409
|
+
"type_alias": 1,
|
|
410
|
+
"function": 2,
|
|
411
|
+
"method": 2,
|
|
412
|
+
"symbol": 3,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
# Representative node radius per Z level (mirrors KIND_SIZE in viz3d)
|
|
416
|
+
_LEVEL_NODE_SIZE: dict[int, float] = {
|
|
417
|
+
0: 1.2, # module
|
|
418
|
+
1: 0.9, # class / interface / enum / namespace / type_alias
|
|
419
|
+
2: 0.7, # function / method
|
|
420
|
+
3: 0.4, # symbol
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class FunnelLayout(Layout3D):
|
|
425
|
+
"""
|
|
426
|
+
Stratified layout: node *kind* determines the Z layer; XY positions use a
|
|
427
|
+
golden-angle disc spiral within each layer.
|
|
428
|
+
|
|
429
|
+
Layers (bottom to top):
|
|
430
|
+
|
|
431
|
+
- **Z = 0** — modules
|
|
432
|
+
- **Z = layer_gap** — classes, interfaces, enums, namespaces, type aliases
|
|
433
|
+
- **Z = 2 × layer_gap** — functions and methods
|
|
434
|
+
- **Z = 3 × layer_gap** — symbol stubs
|
|
435
|
+
|
|
436
|
+
Cross-cutting edges (``CALLS``, ``IMPORTS``, ``INHERITS``,
|
|
437
|
+
``IMPLEMENTS``, ``EXTENDS``) arc between layers, making structural
|
|
438
|
+
coupling immediately visible from any angle.
|
|
439
|
+
|
|
440
|
+
Disc radius is derived algorithmically:
|
|
441
|
+
``r = node_spacing * node_size * sqrt(n)``
|
|
442
|
+
so the layout scales correctly for repos of any size without hand-tuning.
|
|
443
|
+
|
|
444
|
+
:param layer_gap: Vertical distance between adjacent layers.
|
|
445
|
+
:param node_spacing: Spacing multiplier — larger spreads layers out more.
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
def __init__(
|
|
449
|
+
self,
|
|
450
|
+
layer_gap: float = 12.0,
|
|
451
|
+
node_spacing: float = 2.0,
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Initialise layout parameters.
|
|
454
|
+
|
|
455
|
+
:param layer_gap: Vertical separation between layers.
|
|
456
|
+
:param node_spacing: Controls minimum gap between node surfaces.
|
|
457
|
+
"""
|
|
458
|
+
self.layer_gap = layer_gap
|
|
459
|
+
self.node_spacing = node_spacing
|
|
460
|
+
|
|
461
|
+
def compute(
|
|
462
|
+
self,
|
|
463
|
+
nodes: list[LayoutNode],
|
|
464
|
+
edges: list[LayoutEdge],
|
|
465
|
+
) -> dict[str, np.ndarray]:
|
|
466
|
+
"""
|
|
467
|
+
Compute funnel 3-D positions for all nodes.
|
|
468
|
+
|
|
469
|
+
:param nodes: All nodes in the graph.
|
|
470
|
+
:param edges: Unused by this layout (present for API compatibility).
|
|
471
|
+
:return: Mapping from node ID to ``[x, y, z]`` position.
|
|
472
|
+
"""
|
|
473
|
+
# Group nodes by Z layer
|
|
474
|
+
layers: dict[int, list[LayoutNode]] = {}
|
|
475
|
+
for n in nodes:
|
|
476
|
+
level = _KIND_ZLEVEL.get(n.kind, 3)
|
|
477
|
+
layers.setdefault(level, []).append(n)
|
|
478
|
+
|
|
479
|
+
positions: dict[str, np.ndarray] = {}
|
|
480
|
+
|
|
481
|
+
for level, layer_nodes in layers.items():
|
|
482
|
+
z = level * self.layer_gap
|
|
483
|
+
node_size = _LEVEL_NODE_SIZE.get(level, 0.7)
|
|
484
|
+
# Derived radius: scales with sqrt(n) and node size so no manual
|
|
485
|
+
# tuning is needed as the repo grows
|
|
486
|
+
r = self.node_spacing * node_size * np.sqrt(len(layer_nodes))
|
|
487
|
+
r = max(r, 4.0)
|
|
488
|
+
pts = _golden_spiral_2d(len(layer_nodes), radius=r, z=z)
|
|
489
|
+
for n, pt in zip(layer_nodes, pts):
|
|
490
|
+
positions[n.id] = pt
|
|
491
|
+
|
|
492
|
+
return positions
|