interlinked-mapper 0.1.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.
@@ -0,0 +1,50 @@
1
+ """Interactive REPL for controlling the visualization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import code
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from interlinked.analyzer.graph import CodeGraph
10
+ from interlinked.commander.query import QueryEngine
11
+
12
+
13
+ class InterlinkedREPL:
14
+ """Drops into an interactive Python REPL with the QueryEngine as `view`."""
15
+
16
+ def __init__(self, graph: CodeGraph) -> None:
17
+ self.graph = graph
18
+ self.view = QueryEngine(graph)
19
+
20
+ def start(self) -> None:
21
+ """Launch the interactive REPL."""
22
+ banner = (
23
+ "\n"
24
+ "╔══════════════════════════════════════════════════╗\n"
25
+ "║ INTERLINKED — Topology Explorer ║\n"
26
+ "╚══════════════════════════════════════════════════╝\n"
27
+ "\n"
28
+ " Available objects:\n"
29
+ " view — QueryEngine (main control interface)\n"
30
+ " graph — CodeGraph (raw graph access)\n"
31
+ "\n"
32
+ " Quick start:\n"
33
+ " view.stats() # summary\n"
34
+ " view.zoom('module') # zoom level\n"
35
+ " view.focus('my_module') # focus on node\n"
36
+ " view.query('dead functions') # find dead code\n"
37
+ " view.trace_variable('config') # trace a var\n"
38
+ " view.nl('show me uncalled functions') # natural language\n"
39
+ "\n"
40
+ " Type help(view) for full API documentation.\n"
41
+ )
42
+
43
+ local_ns = {
44
+ "view": self.view,
45
+ "graph": self.graph,
46
+ "QueryEngine": QueryEngine,
47
+ "CodeGraph": CodeGraph,
48
+ }
49
+
50
+ code.interact(banner=banner, local=local_ns, exitmsg="Goodbye.")
@@ -0,0 +1,324 @@
1
+ """MCP Server for Interlinked — exposes the full view API as MCP tools.
2
+
3
+ This allows Windsurf, Claude Desktop, or any MCP-compatible client to
4
+ drive the Interlinked visualization directly.
5
+
6
+ Usage:
7
+ interlinked mcp ./my_project # stdio transport
8
+ interlinked mcp ./my_project --port 8421 # SSE transport
9
+
10
+ MCP config example (for .windsurf/mcp_config.json or claude_desktop_config.json):
11
+ {
12
+ "mcpServers": {
13
+ "interlinked": {
14
+ "command": "/path/to/.venv/bin/interlinked",
15
+ "args": ["mcp", "/path/to/project"],
16
+ "env": {
17
+ "ANTHROPIC_API_KEY": "sk-ant-..."
18
+ }
19
+ }
20
+ }
21
+ }
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from mcp.server import Server
32
+ from mcp.server.stdio import stdio_server
33
+ from mcp.types import Tool, TextContent
34
+
35
+ from interlinked.analyzer.parser import parse_project
36
+ from interlinked.analyzer.graph import CodeGraph
37
+ from interlinked.analyzer.dead_code import detect_dead_code
38
+ from interlinked.commander.query import QueryEngine
39
+
40
+
41
+ def build_graph(project_path: str) -> tuple[CodeGraph, QueryEngine]:
42
+ """Parse a project and build the graph + query engine."""
43
+ nodes, edges = parse_project(project_path)
44
+ graph = CodeGraph()
45
+ graph.build_from(nodes, edges)
46
+ detect_dead_code(graph)
47
+
48
+ # Run similarity analysis if available
49
+ try:
50
+ from interlinked.analyzer.similarity import analyze_similarity
51
+ analyze_similarity(graph)
52
+ except Exception:
53
+ pass
54
+
55
+ engine = QueryEngine(graph)
56
+ return graph, engine
57
+
58
+
59
+ def create_mcp_server(project_path: str) -> Server:
60
+ """Create an MCP server with all Interlinked tools."""
61
+ graph, engine = build_graph(project_path)
62
+
63
+ # Pick up API key from env if available
64
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
65
+
66
+ server = Server("interlinked")
67
+
68
+ @server.list_tools()
69
+ async def list_tools() -> list[Tool]:
70
+ return [
71
+ Tool(
72
+ name="interlinked_stats",
73
+ description="Get summary statistics about the analyzed Python project: node counts, edge counts, dead code count.",
74
+ inputSchema={"type": "object", "properties": {}, "required": []},
75
+ ),
76
+ Tool(
77
+ name="interlinked_isolate",
78
+ description="Isolate a module, class, or function and show it plus everything that connects to it. This is the primary command for exploring a codebase. The target can be a partial name.",
79
+ inputSchema={
80
+ "type": "object",
81
+ "properties": {
82
+ "target": {"type": "string", "description": "Name or partial name of the module/class/function to isolate"},
83
+ "level": {"type": "string", "enum": ["module", "class", "function", "variable", "all"], "description": "Zoom level", "default": "function"},
84
+ "depth": {"type": "integer", "description": "How many hops of connections to show", "default": 3},
85
+ "edge_types": {"type": "array", "items": {"type": "string"}, "description": "Optional: only follow these edge types (calls, imports, inherits, reads, writes)"},
86
+ },
87
+ "required": ["target"],
88
+ },
89
+ ),
90
+ Tool(
91
+ name="interlinked_zoom",
92
+ description="Set the zoom level of the visualization: 'module' (high-level), 'class' (mid-level), or 'function' (detailed).",
93
+ inputSchema={
94
+ "type": "object",
95
+ "properties": {
96
+ "level": {"type": "string", "enum": ["module", "class", "function", "variable", "all"]},
97
+ },
98
+ "required": ["level"],
99
+ },
100
+ ),
101
+ Tool(
102
+ name="interlinked_focus",
103
+ description="Focus the visualization on a specific node and its neighborhood within N hops.",
104
+ inputSchema={
105
+ "type": "object",
106
+ "properties": {
107
+ "node_id": {"type": "string", "description": "Qualified name of the node to focus on"},
108
+ "depth": {"type": "integer", "description": "Number of hops to show", "default": 2},
109
+ },
110
+ "required": ["node_id"],
111
+ },
112
+ ),
113
+ Tool(
114
+ name="interlinked_query",
115
+ description="Run a structured query against the codebase graph. Supports: 'dead functions', 'callers of X', 'callees of X', 'modules', 'classes', 'functions', or any search term for fuzzy name matching.",
116
+ inputSchema={
117
+ "type": "object",
118
+ "properties": {
119
+ "expression": {"type": "string", "description": "Query expression"},
120
+ },
121
+ "required": ["expression"],
122
+ },
123
+ ),
124
+ Tool(
125
+ name="interlinked_trace_variable",
126
+ description="Trace a variable's path through reads and writes across the codebase.",
127
+ inputSchema={
128
+ "type": "object",
129
+ "properties": {
130
+ "variable": {"type": "string", "description": "Variable name to trace"},
131
+ "origin": {"type": "string", "description": "Optional: qualified name of the origin scope"},
132
+ },
133
+ "required": ["variable"],
134
+ },
135
+ ),
136
+ Tool(
137
+ name="interlinked_propose_function",
138
+ description="Add a hypothetical function to the graph to visualize where it would connect. Shown in green to distinguish from real code.",
139
+ inputSchema={
140
+ "type": "object",
141
+ "properties": {
142
+ "name": {"type": "string", "description": "Function name"},
143
+ "module": {"type": "string", "description": "Module to place it in"},
144
+ "calls": {"type": "array", "items": {"type": "string"}, "description": "Functions this would call"},
145
+ "called_by": {"type": "array", "items": {"type": "string"}, "description": "Functions that would call this"},
146
+ },
147
+ "required": ["name", "module"],
148
+ },
149
+ ),
150
+ Tool(
151
+ name="interlinked_find_duplicates",
152
+ description="Find functions/methods with similar structure, signatures, or logic paths — potential duplicated functionality. Returns groups of similar symbols with similarity scores.",
153
+ inputSchema={
154
+ "type": "object",
155
+ "properties": {
156
+ "threshold": {"type": "number", "description": "Similarity threshold 0.0-1.0 (default 0.6)", "default": 0.6},
157
+ "scope": {"type": "string", "description": "Optional: limit search to symbols under this prefix"},
158
+ },
159
+ "required": [],
160
+ },
161
+ ),
162
+ Tool(
163
+ name="interlinked_similar_to",
164
+ description="Find functions/classes structurally similar to a given symbol. Useful for finding duplicated or redundant functionality.",
165
+ inputSchema={
166
+ "type": "object",
167
+ "properties": {
168
+ "target": {"type": "string", "description": "Name or partial name of the symbol to compare against"},
169
+ "threshold": {"type": "number", "description": "Similarity threshold 0.0-1.0", "default": 0.5},
170
+ },
171
+ "required": ["target"],
172
+ },
173
+ ),
174
+ Tool(
175
+ name="interlinked_get_context",
176
+ description="Get rich context about a symbol: source code, docstring, signature, comments, connections, and structural fingerprint. Useful for understanding what a function does before comparing.",
177
+ inputSchema={
178
+ "type": "object",
179
+ "properties": {
180
+ "target": {"type": "string", "description": "Name or partial name of the symbol"},
181
+ },
182
+ "required": ["target"],
183
+ },
184
+ ),
185
+ Tool(
186
+ name="interlinked_command",
187
+ description="Execute a raw Python command against the view API. For advanced usage. The `view` object is the QueryEngine and `graph` is the CodeGraph.",
188
+ inputSchema={
189
+ "type": "object",
190
+ "properties": {
191
+ "command": {"type": "string", "description": "Python expression to evaluate, e.g. view.isolate('analyzer')"},
192
+ },
193
+ "required": ["command"],
194
+ },
195
+ ),
196
+ Tool(
197
+ name="interlinked_switch_project",
198
+ description="Switch to analyzing a different Python project. Re-parses the new project and rebuilds the entire graph.",
199
+ inputSchema={
200
+ "type": "object",
201
+ "properties": {
202
+ "path": {"type": "string", "description": "Absolute or relative path to the Python project root"},
203
+ },
204
+ "required": ["path"],
205
+ },
206
+ ),
207
+ Tool(
208
+ name="interlinked_reset",
209
+ description="Reset all filters, focus, and highlights back to the default full-graph view.",
210
+ inputSchema={"type": "object", "properties": {}, "required": []},
211
+ ),
212
+ Tool(
213
+ name="interlinked_set_api_key",
214
+ description="Set the Anthropic API key for the built-in chat pilot. This is stored in memory only, not written to disk.",
215
+ inputSchema={
216
+ "type": "object",
217
+ "properties": {
218
+ "api_key": {"type": "string", "description": "Anthropic API key (sk-ant-...)"},
219
+ },
220
+ "required": ["api_key"],
221
+ },
222
+ ),
223
+ ]
224
+
225
+ @server.call_tool()
226
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
227
+ nonlocal api_key
228
+
229
+ try:
230
+ result = _dispatch_tool(name, arguments, engine, graph, api_key)
231
+ if name == "interlinked_set_api_key":
232
+ api_key = arguments.get("api_key", "")
233
+ os.environ["ANTHROPIC_API_KEY"] = api_key
234
+ result = f"API key {'set' if api_key else 'cleared'}."
235
+ return [TextContent(type="text", text=str(result))]
236
+ except Exception as e:
237
+ return [TextContent(type="text", text=f"Error: {e}")]
238
+
239
+ return server
240
+
241
+
242
+ def _dispatch_tool(
243
+ name: str, args: dict[str, Any],
244
+ engine: QueryEngine, graph: CodeGraph, api_key: str,
245
+ ) -> str:
246
+ """Route a tool call to the appropriate engine method."""
247
+
248
+ if name == "interlinked_stats":
249
+ stats = engine.stats()
250
+ return json.dumps(stats, indent=2)
251
+
252
+ elif name == "interlinked_isolate":
253
+ return engine.isolate(
254
+ target=args["target"],
255
+ level=args.get("level", "function"),
256
+ depth=args.get("depth", 3),
257
+ edge_types=args.get("edge_types"),
258
+ )
259
+
260
+ elif name == "interlinked_zoom":
261
+ return engine.zoom(args["level"])
262
+
263
+ elif name == "interlinked_focus":
264
+ return engine.focus(args["node_id"], depth=args.get("depth", 2))
265
+
266
+ elif name == "interlinked_query":
267
+ results = engine.query(args["expression"])
268
+ if len(results) > 20:
269
+ summary = f"Found {len(results)} results. Showing first 20:\n"
270
+ return summary + json.dumps(results[:20], indent=2)
271
+ return json.dumps(results, indent=2)
272
+
273
+ elif name == "interlinked_trace_variable":
274
+ return engine.trace_variable(args["variable"], args.get("origin"))
275
+
276
+ elif name == "interlinked_propose_function":
277
+ return engine.propose_function(
278
+ name=args["name"], module=args["module"],
279
+ calls=args.get("calls"), called_by=args.get("called_by"),
280
+ )
281
+
282
+ elif name == "interlinked_find_duplicates":
283
+ threshold = args.get("threshold", 0.6)
284
+ scope = args.get("scope")
285
+ return engine.find_duplicates(threshold=threshold, scope=scope)
286
+
287
+ elif name == "interlinked_similar_to":
288
+ return engine.similar_to(args["target"], threshold=args.get("threshold", 0.5))
289
+
290
+ elif name == "interlinked_get_context":
291
+ return engine.get_context(args["target"])
292
+
293
+ elif name == "interlinked_command":
294
+ cmd = args["command"]
295
+ local_ns: dict[str, Any] = {"view": engine, "graph": graph}
296
+ try:
297
+ result = eval(cmd, {"__builtins__": {}}, local_ns)
298
+ except SyntaxError:
299
+ exec(cmd, {"__builtins__": {}}, local_ns)
300
+ result = "OK"
301
+ if hasattr(result, "model_dump"):
302
+ return json.dumps(result.model_dump(), indent=2)
303
+ return str(result)
304
+
305
+ elif name == "interlinked_switch_project":
306
+ from interlinked.visualizer.server import _rebuild_graph
307
+ result = _rebuild_graph(args["path"], graph)
308
+ engine.reset_filter()
309
+ return json.dumps(result, indent=2)
310
+
311
+ elif name == "interlinked_reset":
312
+ return engine.reset_filter()
313
+
314
+ elif name == "interlinked_set_api_key":
315
+ return "" # handled in call_tool
316
+
317
+ return f"Unknown tool: {name}"
318
+
319
+
320
+ async def run_mcp_stdio(project_path: str) -> None:
321
+ """Run the MCP server over stdio."""
322
+ server = create_mcp_server(project_path)
323
+ async with stdio_server() as (read_stream, write_stream):
324
+ await server.run(read_stream, write_stream, server.create_initialization_options())
interlinked/models.py ADDED
@@ -0,0 +1,107 @@
1
+ """Shared data models for Interlinked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class SymbolType(str, enum.Enum):
12
+ MODULE = "module"
13
+ CLASS = "class"
14
+ FUNCTION = "function"
15
+ METHOD = "method"
16
+ VARIABLE = "variable"
17
+ PARAMETER = "parameter"
18
+
19
+
20
+ class EdgeType(str, enum.Enum):
21
+ CALLS = "calls"
22
+ IMPORTS = "imports"
23
+ INHERITS = "inherits"
24
+ READS = "reads"
25
+ WRITES = "writes"
26
+ CONTAINS = "contains" # module contains class, class contains method, etc.
27
+ PROPOSED = "proposed"
28
+ RETURNS = "returns" # function returns value to caller
29
+
30
+
31
+ class NodeData(BaseModel):
32
+ id: str
33
+ name: str
34
+ qualified_name: str
35
+ symbol_type: SymbolType
36
+ file_path: str | None = None
37
+ line_start: int | None = None
38
+ line_end: int | None = None
39
+ docstring: str | None = None
40
+ signature: str | None = None
41
+ is_dead: bool = False
42
+ is_proposed: bool = False
43
+ metadata: dict[str, Any] = Field(default_factory=dict)
44
+
45
+
46
+ class EdgeData(BaseModel):
47
+ source: str
48
+ target: str
49
+ edge_type: EdgeType
50
+ is_dead: bool = False
51
+ is_proposed: bool = False
52
+ line: int | None = None # line where the reference occurs
53
+ metadata: dict[str, Any] = Field(default_factory=dict)
54
+
55
+
56
+ class ColorScheme(BaseModel):
57
+ healthy: str = "#4a90d9"
58
+ dead_link: str = "#e74c3c"
59
+ proposed: str = "#2ecc71"
60
+ highlighted: str = "#f1c40f"
61
+ contains: str = "#95a5a6"
62
+ inherits: str = "#9b59b6"
63
+ imports: str = "#3498db"
64
+ calls: str = "#e67e22"
65
+ reads: str = "#1abc9c"
66
+ writes: str = "#e91e63"
67
+ module_bg: str = "#2c3e50"
68
+ class_bg: str = "#34495e"
69
+ function_bg: str = "#4a6fa5"
70
+ variable_bg: str = "#7f8c8d"
71
+
72
+
73
+ class ViewContext(BaseModel):
74
+ """Natural language context for what the user is currently looking at."""
75
+ what: str = "" # What is being shown
76
+ why: str = "" # Why this view was chosen
77
+ where: str = "" # Which part of the codebase (scope/modules/symbols)
78
+ source: str = "" # Who set this context: "llm", "command", "trace", ""
79
+
80
+
81
+ class ViewState(BaseModel):
82
+ """Current state of the visualization — what the user sees."""
83
+ zoom_level: str = "function" # module, class, function
84
+ focus_node: str | None = None
85
+ focus_depth: int = 2
86
+ visible_node_ids: list[str] = Field(default_factory=list)
87
+ visible_edge_types: list[EdgeType] = Field(
88
+ default_factory=lambda: list(EdgeType)
89
+ )
90
+ highlighted_node_ids: list[str] = Field(default_factory=list)
91
+ highlighted_edge_ids: list[tuple[str, str]] = Field(default_factory=list)
92
+ # Trace roles: node_id -> "origin" | "mutator" | "passthrough" | "destination"
93
+ trace_node_roles: dict[str, str] = Field(default_factory=dict)
94
+ # Trace edge roles: "src|tgt" -> "write" | "read"
95
+ trace_edge_roles: dict[str, str] = Field(default_factory=dict)
96
+ colors: ColorScheme = Field(default_factory=ColorScheme)
97
+ show_dead: bool = True
98
+ show_proposed: bool = True
99
+ filter_expression: str | None = None
100
+ context: ViewContext = Field(default_factory=ViewContext)
101
+
102
+
103
+ class GraphSnapshot(BaseModel):
104
+ """Serializable snapshot of the graph for the frontend."""
105
+ nodes: list[NodeData]
106
+ edges: list[EdgeData]
107
+ view: ViewState
@@ -0,0 +1 @@
1
+ """Visualizer — FastAPI server and web frontend."""
@@ -0,0 +1,181 @@
1
+ """Graph layout algorithms — compute node positions for the frontend.
2
+
3
+ Uses a pure-Python force-directed layout so numpy is not required.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import random
10
+ from typing import Any
11
+
12
+ from interlinked.models import NodeData, EdgeData, SymbolType
13
+
14
+
15
+ def _circular_layout(nodes: list[NodeData]) -> dict[str, tuple[float, float]]:
16
+ """Arrange nodes in a circle."""
17
+ n = len(nodes)
18
+ if n == 0:
19
+ return {}
20
+ pos: dict[str, tuple[float, float]] = {}
21
+ for i, node in enumerate(nodes):
22
+ angle = 2 * math.pi * i / n
23
+ pos[node.id] = (math.cos(angle), math.sin(angle))
24
+ return pos
25
+
26
+
27
+ def compute_layout(
28
+ nodes: list[NodeData],
29
+ edges: list[EdgeData],
30
+ algorithm: str = "force",
31
+ width: float = 1200,
32
+ height: float = 800,
33
+ ) -> dict[str, dict[str, float]]:
34
+ """Compute x,y positions for each node.
35
+
36
+ Returns: {node_id: {"x": float, "y": float}}
37
+ """
38
+ if not nodes:
39
+ return {}
40
+
41
+ if algorithm == "hierarchical":
42
+ pos = _hierarchical_layout(nodes, edges)
43
+ elif algorithm == "circular":
44
+ pos = _circular_layout(nodes)
45
+ else:
46
+ pos = _force_layout(nodes, edges, iterations=80)
47
+
48
+ # Scale to canvas dimensions
49
+ result: dict[str, dict[str, float]] = {}
50
+ if pos:
51
+ xs = [p[0] for p in pos.values()]
52
+ ys = [p[1] for p in pos.values()]
53
+ min_x, max_x = min(xs), max(xs)
54
+ min_y, max_y = min(ys), max(ys)
55
+ range_x = max_x - min_x or 1
56
+ range_y = max_y - min_y or 1
57
+ margin = 80
58
+
59
+ for nid, (x, y) in pos.items():
60
+ result[nid] = {
61
+ "x": margin + ((x - min_x) / range_x) * (width - 2 * margin),
62
+ "y": margin + ((y - min_y) / range_y) * (height - 2 * margin),
63
+ }
64
+
65
+ return result
66
+
67
+
68
+ def _force_layout(
69
+ nodes: list[NodeData],
70
+ edges: list[EdgeData],
71
+ iterations: int = 80,
72
+ ) -> dict[str, tuple[float, float]]:
73
+ """Pure-Python Fruchterman-Reingold force-directed layout."""
74
+ rng = random.Random(42)
75
+ node_ids = [n.id for n in nodes]
76
+ n = len(node_ids)
77
+ if n == 0:
78
+ return {}
79
+ if n == 1:
80
+ return {node_ids[0]: (0.0, 0.0)}
81
+
82
+ idx = {nid: i for i, nid in enumerate(node_ids)}
83
+ id_set = set(node_ids)
84
+
85
+ # Initial random positions
86
+ px = [rng.uniform(-1.0, 1.0) for _ in range(n)]
87
+ py = [rng.uniform(-1.0, 1.0) for _ in range(n)]
88
+
89
+ # Build adjacency
90
+ adj: list[list[int]] = [[] for _ in range(n)]
91
+ for e in edges:
92
+ if e.source in id_set and e.target in id_set:
93
+ si, ti = idx[e.source], idx[e.target]
94
+ adj[si].append(ti)
95
+ adj[ti].append(si)
96
+
97
+ area = 4.0 * n
98
+ k = math.sqrt(area / max(n, 1))
99
+ temp = 1.0
100
+
101
+ for iteration in range(iterations):
102
+ # Repulsive forces between all pairs
103
+ dx = [0.0] * n
104
+ dy = [0.0] * n
105
+
106
+ for i in range(n):
107
+ for j in range(i + 1, n):
108
+ ddx = px[i] - px[j]
109
+ ddy = py[i] - py[j]
110
+ dist = math.sqrt(ddx * ddx + ddy * ddy) or 0.001
111
+ force = (k * k) / dist
112
+ fx = (ddx / dist) * force
113
+ fy = (ddy / dist) * force
114
+ dx[i] += fx
115
+ dy[i] += fy
116
+ dx[j] -= fx
117
+ dy[j] -= fy
118
+
119
+ # Attractive forces along edges
120
+ for i in range(n):
121
+ for j in adj[i]:
122
+ if j <= i:
123
+ continue
124
+ ddx = px[i] - px[j]
125
+ ddy = py[i] - py[j]
126
+ dist = math.sqrt(ddx * ddx + ddy * ddy) or 0.001
127
+ force = (dist * dist) / k
128
+ fx = (ddx / dist) * force
129
+ fy = (ddy / dist) * force
130
+ dx[i] -= fx
131
+ dy[i] -= fy
132
+ dx[j] += fx
133
+ dy[j] += fy
134
+
135
+ # Apply with temperature
136
+ for i in range(n):
137
+ disp = math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]) or 0.001
138
+ scale = min(disp, temp) / disp
139
+ px[i] += dx[i] * scale
140
+ py[i] += dy[i] * scale
141
+
142
+ temp *= 0.95 # cooling
143
+
144
+ return {node_ids[i]: (px[i], py[i]) for i in range(n)}
145
+
146
+
147
+ def _hierarchical_layout(
148
+ nodes: list[NodeData], edges: list[EdgeData]
149
+ ) -> dict[str, tuple[float, float]]:
150
+ """Simple hierarchical layout: modules at top, classes below, functions at bottom."""
151
+ layers: dict[SymbolType, list[str]] = {
152
+ SymbolType.MODULE: [],
153
+ SymbolType.CLASS: [],
154
+ SymbolType.FUNCTION: [],
155
+ SymbolType.METHOD: [],
156
+ SymbolType.VARIABLE: [],
157
+ }
158
+
159
+ for n in nodes:
160
+ layers[n.symbol_type].append(n.id)
161
+
162
+ pos: dict[str, tuple[float, float]] = {}
163
+ layer_order = [
164
+ SymbolType.MODULE,
165
+ SymbolType.CLASS,
166
+ SymbolType.FUNCTION,
167
+ SymbolType.METHOD,
168
+ SymbolType.VARIABLE,
169
+ ]
170
+
171
+ y = 0.0
172
+ for sym_type in layer_order:
173
+ ids = layers[sym_type]
174
+ if not ids:
175
+ continue
176
+ for i, nid in enumerate(ids):
177
+ x = (i + 1) / (len(ids) + 1)
178
+ pos[nid] = (x, y)
179
+ y += 1.0
180
+
181
+ return pos