mcp-vector-search 0.12.6__py3-none-any.whl → 1.1.22__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.
- mcp_vector_search/__init__.py +3 -3
- mcp_vector_search/analysis/__init__.py +111 -0
- mcp_vector_search/analysis/baseline/__init__.py +68 -0
- mcp_vector_search/analysis/baseline/comparator.py +462 -0
- mcp_vector_search/analysis/baseline/manager.py +621 -0
- mcp_vector_search/analysis/collectors/__init__.py +74 -0
- mcp_vector_search/analysis/collectors/base.py +164 -0
- mcp_vector_search/analysis/collectors/cohesion.py +463 -0
- mcp_vector_search/analysis/collectors/complexity.py +743 -0
- mcp_vector_search/analysis/collectors/coupling.py +1162 -0
- mcp_vector_search/analysis/collectors/halstead.py +514 -0
- mcp_vector_search/analysis/collectors/smells.py +325 -0
- mcp_vector_search/analysis/debt.py +516 -0
- mcp_vector_search/analysis/interpretation.py +685 -0
- mcp_vector_search/analysis/metrics.py +414 -0
- mcp_vector_search/analysis/reporters/__init__.py +7 -0
- mcp_vector_search/analysis/reporters/console.py +646 -0
- mcp_vector_search/analysis/reporters/markdown.py +480 -0
- mcp_vector_search/analysis/reporters/sarif.py +377 -0
- mcp_vector_search/analysis/storage/__init__.py +93 -0
- mcp_vector_search/analysis/storage/metrics_store.py +762 -0
- mcp_vector_search/analysis/storage/schema.py +245 -0
- mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
- mcp_vector_search/analysis/trends.py +308 -0
- mcp_vector_search/analysis/visualizer/__init__.py +90 -0
- mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
- mcp_vector_search/analysis/visualizer/exporter.py +484 -0
- mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
- mcp_vector_search/analysis/visualizer/schemas.py +525 -0
- mcp_vector_search/cli/commands/analyze.py +1062 -0
- mcp_vector_search/cli/commands/chat.py +1455 -0
- mcp_vector_search/cli/commands/index.py +621 -5
- mcp_vector_search/cli/commands/index_background.py +467 -0
- mcp_vector_search/cli/commands/init.py +13 -0
- mcp_vector_search/cli/commands/install.py +597 -335
- mcp_vector_search/cli/commands/install_old.py +8 -4
- mcp_vector_search/cli/commands/mcp.py +78 -6
- mcp_vector_search/cli/commands/reset.py +68 -26
- mcp_vector_search/cli/commands/search.py +224 -8
- mcp_vector_search/cli/commands/setup.py +1184 -0
- mcp_vector_search/cli/commands/status.py +339 -5
- mcp_vector_search/cli/commands/uninstall.py +276 -357
- mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
- mcp_vector_search/cli/commands/visualize/cli.py +292 -0
- mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
- mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
- mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +33 -0
- mcp_vector_search/cli/commands/visualize/graph_builder.py +647 -0
- mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
- mcp_vector_search/cli/commands/visualize/server.py +600 -0
- mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
- mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
- mcp_vector_search/cli/commands/visualize/templates/base.py +234 -0
- mcp_vector_search/cli/commands/visualize/templates/scripts.py +4542 -0
- mcp_vector_search/cli/commands/visualize/templates/styles.py +2522 -0
- mcp_vector_search/cli/didyoumean.py +27 -2
- mcp_vector_search/cli/main.py +127 -160
- mcp_vector_search/cli/output.py +158 -13
- mcp_vector_search/config/__init__.py +4 -0
- mcp_vector_search/config/default_thresholds.yaml +52 -0
- mcp_vector_search/config/settings.py +12 -0
- mcp_vector_search/config/thresholds.py +273 -0
- mcp_vector_search/core/__init__.py +16 -0
- mcp_vector_search/core/auto_indexer.py +3 -3
- mcp_vector_search/core/boilerplate.py +186 -0
- mcp_vector_search/core/config_utils.py +394 -0
- mcp_vector_search/core/database.py +406 -94
- mcp_vector_search/core/embeddings.py +24 -0
- mcp_vector_search/core/exceptions.py +11 -0
- mcp_vector_search/core/git.py +380 -0
- mcp_vector_search/core/git_hooks.py +4 -4
- mcp_vector_search/core/indexer.py +632 -54
- mcp_vector_search/core/llm_client.py +756 -0
- mcp_vector_search/core/models.py +91 -1
- mcp_vector_search/core/project.py +17 -0
- mcp_vector_search/core/relationships.py +473 -0
- mcp_vector_search/core/scheduler.py +11 -11
- mcp_vector_search/core/search.py +179 -29
- mcp_vector_search/mcp/server.py +819 -9
- mcp_vector_search/parsers/python.py +285 -5
- mcp_vector_search/utils/__init__.py +2 -0
- mcp_vector_search/utils/gitignore.py +0 -3
- mcp_vector_search/utils/gitignore_updater.py +212 -0
- mcp_vector_search/utils/monorepo.py +66 -4
- mcp_vector_search/utils/timing.py +10 -6
- {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +184 -53
- mcp_vector_search-1.1.22.dist-info/RECORD +120 -0
- {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +1 -1
- {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +1 -0
- mcp_vector_search/cli/commands/visualize.py +0 -1467
- mcp_vector_search-0.12.6.dist-info/RECORD +0 -68
- {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Layout calculation algorithms for visualization V2.0.
|
|
2
|
+
|
|
3
|
+
This module implements layout algorithms for hierarchical list-based navigation:
|
|
4
|
+
- List Layout: Vertical alphabetical positioning of root nodes
|
|
5
|
+
- Fan Layout: Horizontal arc (180°) for expanded directory/file children
|
|
6
|
+
|
|
7
|
+
Design Principles:
|
|
8
|
+
- Deterministic: Same input → same output (no randomness)
|
|
9
|
+
- Adaptive: Radius and spacing adjust to child count
|
|
10
|
+
- Sorted: Alphabetical order (directories first) for predictability
|
|
11
|
+
- Performance: O(n) time complexity, <50ms for 100 nodes
|
|
12
|
+
|
|
13
|
+
Reference: docs/development/VISUALIZATION_ARCHITECTURE_V2.md
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from loguru import logger
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def calculate_list_layout(
|
|
25
|
+
nodes: list[dict[str, Any]], canvas_width: int, canvas_height: int
|
|
26
|
+
) -> dict[str, tuple[float, float]]:
|
|
27
|
+
"""Calculate vertical list positions for nodes.
|
|
28
|
+
|
|
29
|
+
Positions nodes in a vertical list with fixed spacing, sorted
|
|
30
|
+
alphabetically with directories appearing before files.
|
|
31
|
+
|
|
32
|
+
Design Decision: Vertical list for alphabetical browsing
|
|
33
|
+
|
|
34
|
+
Rationale: Users expect alphabetical file/folder listings (like Finder, Explorer).
|
|
35
|
+
Vertical layout mirrors familiar file manager interfaces.
|
|
36
|
+
|
|
37
|
+
Trade-offs:
|
|
38
|
+
- Familiarity: Matches OS file managers vs. novel layouts
|
|
39
|
+
- Vertical space: Requires scrolling for many files vs. compact layouts
|
|
40
|
+
- Readability: One item per line is clearest vs. dense grids
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
nodes: List of node dictionaries with 'id', 'name', and 'type' keys
|
|
44
|
+
canvas_width: SVG viewport width in pixels
|
|
45
|
+
canvas_height: SVG viewport height in pixels
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Dictionary mapping node_id -> (x, y) position
|
|
49
|
+
|
|
50
|
+
Time Complexity: O(n log n) where n = number of nodes (due to sorting)
|
|
51
|
+
Space Complexity: O(n) for position dictionary
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> nodes = [
|
|
55
|
+
... {'id': 'dir1', 'name': 'src', 'type': 'directory'},
|
|
56
|
+
... {'id': 'file1', 'name': 'main.py', 'type': 'file'}
|
|
57
|
+
... ]
|
|
58
|
+
>>> positions = calculate_list_layout(nodes, 1920, 1080)
|
|
59
|
+
>>> positions['dir1'] # (x, y) coordinates
|
|
60
|
+
(100, 490.0)
|
|
61
|
+
>>> positions['file1']
|
|
62
|
+
(100, 540.0)
|
|
63
|
+
"""
|
|
64
|
+
if not nodes:
|
|
65
|
+
logger.debug("No nodes to layout")
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
# Sort alphabetically (directories first, then files)
|
|
69
|
+
sorted_nodes = sorted(
|
|
70
|
+
nodes,
|
|
71
|
+
key=lambda n: (
|
|
72
|
+
0 if n.get("type") == "directory" else 1, # Directories first
|
|
73
|
+
(n.get("name") or "").lower(), # Then alphabetical
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Layout parameters
|
|
78
|
+
node_height = 50 # Vertical space per node (icon + label)
|
|
79
|
+
x_position = 100 # Left margin
|
|
80
|
+
total_height = len(sorted_nodes) * node_height
|
|
81
|
+
|
|
82
|
+
# Center vertically in viewport
|
|
83
|
+
start_y = (canvas_height - total_height) / 2
|
|
84
|
+
|
|
85
|
+
# Calculate positions
|
|
86
|
+
positions = {}
|
|
87
|
+
for i, node in enumerate(sorted_nodes):
|
|
88
|
+
node_id = node.get("id")
|
|
89
|
+
if not node_id:
|
|
90
|
+
logger.warning(f"Node missing 'id': {node}")
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
y_position = start_y + (i * node_height)
|
|
94
|
+
positions[node_id] = (x_position, y_position)
|
|
95
|
+
|
|
96
|
+
logger.debug(
|
|
97
|
+
f"List layout: {len(positions)} nodes, "
|
|
98
|
+
f"height={total_height}px, "
|
|
99
|
+
f"start_y={start_y:.1f}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return positions
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def calculate_fan_layout(
|
|
106
|
+
parent_pos: tuple[float, float],
|
|
107
|
+
children: list[dict[str, Any]],
|
|
108
|
+
canvas_width: int,
|
|
109
|
+
canvas_height: int,
|
|
110
|
+
fan_type: str = "horizontal",
|
|
111
|
+
) -> dict[str, tuple[float, float]]:
|
|
112
|
+
"""Calculate horizontal fan positions for child nodes.
|
|
113
|
+
|
|
114
|
+
Arranges children in a 180° arc (horizontal fan) emanating from parent node.
|
|
115
|
+
Radius adapts to child count (200-400px range). Children sorted alphabetically.
|
|
116
|
+
|
|
117
|
+
Design Decision: Horizontal fan for directory expansion
|
|
118
|
+
|
|
119
|
+
Rationale: Horizontal layout provides intuitive left-to-right navigation,
|
|
120
|
+
matching Western reading patterns. 180° arc provides clear parent-child
|
|
121
|
+
visual connection while maintaining adequate spacing between nodes.
|
|
122
|
+
|
|
123
|
+
Trade-offs:
|
|
124
|
+
- Horizontal space: Limited by viewport width vs. vertical fans
|
|
125
|
+
- Readability: Left-to-right natural vs. radial (360°) all directions
|
|
126
|
+
- Density: 180° arc provides more space per node vs. full circle
|
|
127
|
+
- Visual connection: Clear parent→child line vs. grid layout
|
|
128
|
+
|
|
129
|
+
Alternatives Considered:
|
|
130
|
+
1. Full circle (360°): Rejected - confusing orientation, no clear "top"
|
|
131
|
+
2. Vertical fan: Rejected - conflicts with list view scrolling
|
|
132
|
+
3. Grid layout: Rejected - loses parent-child visual connection
|
|
133
|
+
4. Tree layout: Rejected - doesn't support sibling switching gracefully
|
|
134
|
+
|
|
135
|
+
Extension Points: Arc angle parameterizable (currently 180°) for different
|
|
136
|
+
density preferences. Radius calculation can use different formulas.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
parent_pos: (x, y) coordinates of parent node
|
|
140
|
+
children: List of child node dictionaries with 'id', 'name', 'type'
|
|
141
|
+
canvas_width: SVG viewport width in pixels
|
|
142
|
+
canvas_height: SVG viewport height in pixels
|
|
143
|
+
fan_type: Type of fan ("horizontal" for 180° arc)
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Dictionary mapping child_id -> (x, y) position
|
|
147
|
+
|
|
148
|
+
Time Complexity: O(n log n) where n = number of children (sorting)
|
|
149
|
+
Space Complexity: O(n) for position dictionary
|
|
150
|
+
|
|
151
|
+
Performance:
|
|
152
|
+
- Expected: <10ms for 50 children
|
|
153
|
+
- Tested: <50ms for 500 children
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
>>> parent = (500, 400)
|
|
157
|
+
>>> children = [
|
|
158
|
+
... {'id': 'c1', 'name': 'utils', 'type': 'directory'},
|
|
159
|
+
... {'id': 'c2', 'name': 'tests', 'type': 'directory'},
|
|
160
|
+
... {'id': 'c3', 'name': 'main.py', 'type': 'file'}
|
|
161
|
+
... ]
|
|
162
|
+
>>> positions = calculate_fan_layout(parent, children, 1920, 1080)
|
|
163
|
+
>>> # Children positioned in 180° arc from left to right
|
|
164
|
+
>>> len(positions)
|
|
165
|
+
3
|
|
166
|
+
"""
|
|
167
|
+
if not children:
|
|
168
|
+
logger.debug("No children to layout in fan")
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
parent_x, parent_y = parent_pos
|
|
172
|
+
|
|
173
|
+
# Calculate adaptive radius based on child count
|
|
174
|
+
# More children → larger radius to prevent overlap
|
|
175
|
+
base_radius = 200 # Minimum radius
|
|
176
|
+
max_radius = 400 # Maximum radius
|
|
177
|
+
spacing_per_child = 60 # Horizontal space needed per child
|
|
178
|
+
|
|
179
|
+
# Arc length = radius * angle (in radians)
|
|
180
|
+
# For 180° arc: arc_length = radius * π
|
|
181
|
+
# We want: arc_length >= num_children * spacing_per_child
|
|
182
|
+
# Therefore: radius >= (num_children * spacing_per_child) / π
|
|
183
|
+
calculated_radius = (len(children) * spacing_per_child) / math.pi
|
|
184
|
+
radius = max(base_radius, min(calculated_radius, max_radius))
|
|
185
|
+
|
|
186
|
+
# Horizontal fan: 180° arc from left to right
|
|
187
|
+
# Start at π radians (180°, left side)
|
|
188
|
+
# End at 0 radians (0°, right side)
|
|
189
|
+
start_angle = math.pi # Left (180°)
|
|
190
|
+
end_angle = 0 # Right (0°)
|
|
191
|
+
angle_range = start_angle - end_angle # π radians
|
|
192
|
+
|
|
193
|
+
# Sort children (directories first, then alphabetical)
|
|
194
|
+
sorted_children = sorted(
|
|
195
|
+
children,
|
|
196
|
+
key=lambda n: (
|
|
197
|
+
0 if n.get("type") == "directory" else 1, # Directories first
|
|
198
|
+
(n.get("name") or "").lower(), # Then alphabetical
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Calculate positions
|
|
203
|
+
positions = {}
|
|
204
|
+
num_children = len(sorted_children)
|
|
205
|
+
|
|
206
|
+
for i, child in enumerate(sorted_children):
|
|
207
|
+
child_id = child.get("id")
|
|
208
|
+
if not child_id:
|
|
209
|
+
logger.warning(f"Child node missing 'id': {child}")
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
# Calculate angle for this child
|
|
213
|
+
if num_children == 1:
|
|
214
|
+
# Single child: center of arc (90°)
|
|
215
|
+
angle = math.pi / 2
|
|
216
|
+
else:
|
|
217
|
+
# Distribute evenly across arc
|
|
218
|
+
# angle = start_angle - (progress * angle_range)
|
|
219
|
+
# progress = i / (num_children - 1) goes from 0 to 1
|
|
220
|
+
progress = i / (num_children - 1)
|
|
221
|
+
angle = start_angle - (progress * angle_range)
|
|
222
|
+
|
|
223
|
+
# Convert polar coordinates (radius, angle) to cartesian (x, y)
|
|
224
|
+
x = parent_x + radius * math.cos(angle)
|
|
225
|
+
y = parent_y + radius * math.sin(angle)
|
|
226
|
+
|
|
227
|
+
positions[child_id] = (x, y)
|
|
228
|
+
|
|
229
|
+
logger.debug(
|
|
230
|
+
f"Fan layout: {len(positions)} children, "
|
|
231
|
+
f"radius={radius:.1f}px, "
|
|
232
|
+
f"arc={math.degrees(angle_range):.0f}°"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return positions
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def calculate_compact_folder_layout(
|
|
239
|
+
parent_pos: tuple[float, float],
|
|
240
|
+
children: list[dict[str, Any]],
|
|
241
|
+
canvas_width: int,
|
|
242
|
+
canvas_height: int,
|
|
243
|
+
) -> dict[str, tuple[float, float]]:
|
|
244
|
+
"""Calculate compact horizontal folder layout for directory children.
|
|
245
|
+
|
|
246
|
+
Arranges children horizontally in a straight line to the right of parent,
|
|
247
|
+
with fixed 800px spacing. Designed for directory-only views where vertical
|
|
248
|
+
space is limited.
|
|
249
|
+
|
|
250
|
+
This layout is optimized for horizontal directory navigation without
|
|
251
|
+
the arc geometry of fan layout.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
parent_pos: (x, y) coordinates of parent node
|
|
255
|
+
children: List of child node dictionaries with 'id', 'name', 'type'
|
|
256
|
+
canvas_width: SVG viewport width in pixels
|
|
257
|
+
canvas_height: SVG viewport height in pixels
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Dictionary mapping child_id -> (x, y) position
|
|
261
|
+
|
|
262
|
+
Time Complexity: O(n log n) where n = number of children (sorting)
|
|
263
|
+
Space Complexity: O(n) for position dictionary
|
|
264
|
+
"""
|
|
265
|
+
if not children:
|
|
266
|
+
logger.debug("No children to layout in compact folder mode")
|
|
267
|
+
return {}
|
|
268
|
+
|
|
269
|
+
parent_x, parent_y = parent_pos
|
|
270
|
+
|
|
271
|
+
# Compact folder layout parameters
|
|
272
|
+
horizontal_offset = 800 # Fixed horizontal spacing from parent
|
|
273
|
+
vertical_spacing = 50 # Vertical spacing between children
|
|
274
|
+
|
|
275
|
+
# Sort children (directories first, then alphabetical)
|
|
276
|
+
sorted_children = sorted(
|
|
277
|
+
children,
|
|
278
|
+
key=lambda n: (
|
|
279
|
+
0 if n.get("type") == "directory" else 1,
|
|
280
|
+
(n.get("name") or "").lower(),
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Calculate vertical centering
|
|
285
|
+
total_height = len(sorted_children) * vertical_spacing
|
|
286
|
+
start_y = parent_y - (total_height / 2)
|
|
287
|
+
|
|
288
|
+
# Calculate positions
|
|
289
|
+
positions = {}
|
|
290
|
+
for i, child in enumerate(sorted_children):
|
|
291
|
+
child_id = child.get("id")
|
|
292
|
+
if not child_id:
|
|
293
|
+
logger.warning(f"Child node missing 'id': {child}")
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
x = parent_x + horizontal_offset
|
|
297
|
+
y = start_y + (i * vertical_spacing)
|
|
298
|
+
|
|
299
|
+
positions[child_id] = (x, y)
|
|
300
|
+
|
|
301
|
+
logger.debug(
|
|
302
|
+
f"Compact folder layout: {len(positions)} children, "
|
|
303
|
+
f"offset={horizontal_offset}px, "
|
|
304
|
+
f"spacing={vertical_spacing}px"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return positions
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def calculate_tree_layout(
|
|
311
|
+
nodes: list[dict[str, Any]],
|
|
312
|
+
expansion_path: list[str],
|
|
313
|
+
canvas_width: int,
|
|
314
|
+
canvas_height: int,
|
|
315
|
+
level_spacing: int = 300,
|
|
316
|
+
node_spacing: int = 50,
|
|
317
|
+
) -> dict[str, tuple[float, float]]:
|
|
318
|
+
"""Calculate positions for rightward tree layout.
|
|
319
|
+
|
|
320
|
+
Design Decision: Rightward Tree Layout
|
|
321
|
+
|
|
322
|
+
Rationale: Traditional file explorer tree layout matches user expectations
|
|
323
|
+
for hierarchical file system navigation. Nodes expand horizontally to the
|
|
324
|
+
right (like Finder/Explorer) rather than radially, providing clear depth
|
|
325
|
+
visualization and natural left-to-right reading flow.
|
|
326
|
+
|
|
327
|
+
Trade-offs:
|
|
328
|
+
- Familiarity: Matches OS file explorers vs. novel graph layouts
|
|
329
|
+
- Depth visibility: Clear level separation vs. radial compactness
|
|
330
|
+
- Horizontal space: Requires wider viewport vs. vertical layouts
|
|
331
|
+
- Simplicity: No angle calculations vs. fan layouts
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
nodes: All nodes in the graph
|
|
335
|
+
expansion_path: List of expanded node IDs (root → current)
|
|
336
|
+
canvas_width: Canvas width
|
|
337
|
+
canvas_height: Canvas height
|
|
338
|
+
level_spacing: Horizontal distance between tree levels (default 300px)
|
|
339
|
+
node_spacing: Vertical distance between sibling nodes (default 50px)
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Dict mapping node_id → (x, y) position
|
|
343
|
+
|
|
344
|
+
Layout Logic:
|
|
345
|
+
- Root nodes: x=100, y=vertical list (50px spacing)
|
|
346
|
+
- Level 1 (children of expanded root): x=400, y=vertical under parent
|
|
347
|
+
- Level 2: x=700, etc.
|
|
348
|
+
- Center view by shifting all x positions left as tree grows
|
|
349
|
+
|
|
350
|
+
Time Complexity: O(n log n) where n = number of nodes (sorting)
|
|
351
|
+
Space Complexity: O(n) for position dictionary and level grouping
|
|
352
|
+
|
|
353
|
+
Example:
|
|
354
|
+
>>> nodes = [
|
|
355
|
+
... {'id': 'root1', 'name': 'src', 'type': 'directory', 'parent': None},
|
|
356
|
+
... {'id': 'child1', 'name': 'main.py', 'type': 'file', 'parent': 'root1'}
|
|
357
|
+
... ]
|
|
358
|
+
>>> expansion_path = ['root1']
|
|
359
|
+
>>> positions = calculate_tree_layout(nodes, expansion_path, 1920, 1080)
|
|
360
|
+
>>> # root1 at x=100, child1 at x=400
|
|
361
|
+
"""
|
|
362
|
+
if not nodes:
|
|
363
|
+
logger.debug("No nodes to layout in tree")
|
|
364
|
+
return {}
|
|
365
|
+
|
|
366
|
+
positions = {}
|
|
367
|
+
|
|
368
|
+
# Group nodes by depth level based on expansion path
|
|
369
|
+
levels = _build_tree_levels(nodes, expansion_path)
|
|
370
|
+
|
|
371
|
+
if not levels:
|
|
372
|
+
logger.warning("No levels built from expansion path")
|
|
373
|
+
return {}
|
|
374
|
+
|
|
375
|
+
# Calculate positions level by level
|
|
376
|
+
for level_index, level_nodes in enumerate(levels):
|
|
377
|
+
x = 100 + (level_index * level_spacing)
|
|
378
|
+
|
|
379
|
+
# Sort alphabetically (directories first)
|
|
380
|
+
sorted_nodes = sorted(
|
|
381
|
+
level_nodes,
|
|
382
|
+
key=lambda n: (
|
|
383
|
+
0 if n.get("type") == "directory" else 1,
|
|
384
|
+
(n.get("name") or "").lower(),
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Position vertically
|
|
389
|
+
total_height = len(sorted_nodes) * node_spacing
|
|
390
|
+
start_y = (canvas_height - total_height) / 2
|
|
391
|
+
|
|
392
|
+
for i, node in enumerate(sorted_nodes):
|
|
393
|
+
node_id = node.get("id")
|
|
394
|
+
if not node_id:
|
|
395
|
+
logger.warning(f"Node missing 'id': {node}")
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
y = start_y + (i * node_spacing)
|
|
399
|
+
positions[node_id] = (x, y)
|
|
400
|
+
|
|
401
|
+
# Center the view (shift left based on deepest level)
|
|
402
|
+
max_level = len(levels) - 1
|
|
403
|
+
shift_x = (max_level * level_spacing) // 2
|
|
404
|
+
|
|
405
|
+
for node_id in positions:
|
|
406
|
+
x, y = positions[node_id]
|
|
407
|
+
positions[node_id] = (x - shift_x, y)
|
|
408
|
+
|
|
409
|
+
logger.debug(
|
|
410
|
+
f"Tree layout: {len(positions)} nodes across {len(levels)} levels, "
|
|
411
|
+
f"shift_x={shift_x}px"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return positions
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _build_tree_levels(
|
|
418
|
+
nodes: list[dict[str, Any]], expansion_path: list[str]
|
|
419
|
+
) -> list[list[dict[str, Any]]]:
|
|
420
|
+
"""Build tree levels based on expansion path.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
List of levels, where each level is a list of nodes
|
|
424
|
+
Level 0: Root nodes (no parent)
|
|
425
|
+
Level 1: Children of expanded root node
|
|
426
|
+
Level 2: Children of expanded level-1 node
|
|
427
|
+
etc.
|
|
428
|
+
|
|
429
|
+
Example:
|
|
430
|
+
>>> nodes = [
|
|
431
|
+
... {'id': 'a', 'parent': None},
|
|
432
|
+
... {'id': 'b', 'parent': 'a'},
|
|
433
|
+
... {'id': 'c', 'parent': 'a'}
|
|
434
|
+
... ]
|
|
435
|
+
>>> expansion_path = ['a']
|
|
436
|
+
>>> levels = _build_tree_levels(nodes, expansion_path)
|
|
437
|
+
>>> len(levels)
|
|
438
|
+
2
|
|
439
|
+
>>> len(levels[0]) # Root level
|
|
440
|
+
1
|
|
441
|
+
>>> len(levels[1]) # Children of 'a'
|
|
442
|
+
2
|
|
443
|
+
"""
|
|
444
|
+
levels: list[list[dict[str, Any]]] = []
|
|
445
|
+
|
|
446
|
+
# Build node ID to node map for quick lookup
|
|
447
|
+
node_map = {node.get("id"): node for node in nodes if node.get("id")}
|
|
448
|
+
|
|
449
|
+
# Level 0: Root nodes (no parent or parent not in graph)
|
|
450
|
+
root_nodes = [
|
|
451
|
+
node
|
|
452
|
+
for node in nodes
|
|
453
|
+
if not node.get("parent") or node.get("parent") not in node_map
|
|
454
|
+
]
|
|
455
|
+
levels.append(root_nodes)
|
|
456
|
+
|
|
457
|
+
# Subsequent levels: Children of expanded nodes in expansion path
|
|
458
|
+
for expanded_node_id in expansion_path:
|
|
459
|
+
# Find children of this expanded node
|
|
460
|
+
children = [node for node in nodes if node.get("parent") == expanded_node_id]
|
|
461
|
+
|
|
462
|
+
if children:
|
|
463
|
+
levels.append(children)
|
|
464
|
+
|
|
465
|
+
logger.debug(
|
|
466
|
+
f"Built {len(levels)} tree levels from expansion path: {expansion_path}"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
return levels
|