| … | … | @@ -12,6 +12,10 @@ var _positions: Dictionary = {} # id -> Vector2 |
| 12 | 12 | var _cluster_map: Dictionary = {} # id -> cluster_id |
| 13 | 13 | var _title_map: Dictionary = {} # id -> String |
| 14 | 14 | var _cluster_labels: Dictionary = {} # cluster_index -> {label, topics} |
| 15 | var _hub_positions: Dictionary = {} # cluster_index -> Vector2 (mind-map hub nodes) |
| 16 | |
| 17 | ## Layout mode: "auto" (force/grid), "mindmap" (radial tree) |
| 18 | var layout_mode: String = "auto" |
| 15 | 19 | |
| 16 | 20 | ## Interaction state |
| 17 | 21 | var _hovered_node: String = "" |
| … | … | @@ -83,16 +87,20 @@ func set_graph_data(nodes: Array, edges: Array, clusters: Array, cluster_labels: |
| 83 | 87 | ids.sort() |
| 84 | 88 | seed("".join(ids).hash()) |
| 85 | 89 | |
| 86 | | # Choose layout strategy: |
| 87 | | # - No edges or very sparse → cluster-aware grid (force-directed needs edges to work) |
| 88 | | # - Enough edges → force-directed with cluster seeding |
| 89 | | var edge_ratio: float = float(_edges.size()) / maxf(float(_nodes.size()), 1.0) |
| 90 | | if _edges.is_empty() or edge_ratio < 0.5: |
| 91 | | _grid_layout() |
| 90 | _hub_positions.clear() |
| 91 | |
| 92 | # Choose layout strategy |
| 93 | if layout_mode == "mindmap": |
| 94 | _mindmap_layout(clusters) |
| 92 | 95 | else: |
| 93 | | _init_cluster_positions(clusters) |
| 94 | | for i: int in LAYOUT_ITERATIONS: |
| 95 | | _layout_step() |
| 96 | # Auto: sparse → grid, dense → force-directed |
| 97 | var edge_ratio: float = float(_edges.size()) / maxf(float(_nodes.size()), 1.0) |
| 98 | if _edges.is_empty() or edge_ratio < 0.5: |
| 99 | _grid_layout() |
| 100 | else: |
| 101 | _init_cluster_positions(clusters) |
| 102 | for i: int in LAYOUT_ITERATIONS: |
| 103 | _layout_step() |
| 96 | 104 | _spread_overlapping() |
| 97 | 105 | _rescale_to_fit() |
| 98 | 106 | |
| … | … | @@ -183,6 +191,67 @@ func _grid_layout() -> void: |
| 183 | 191 | x_offset += group_w + group_gap |
| 184 | 192 | |
| 185 | 193 | |
| 194 | func _mindmap_layout(clusters: Array) -> void: |
| 195 | ## Radial mind-map: center hub with cluster branches radiating outward. |
| 196 | ## Each cluster gets a hub node (labeled) with room nodes fanning around it. |
| 197 | if _nodes.is_empty(): |
| 198 | return |
| 199 | _positions.clear() |
| 200 | |
| 201 | var rect: Vector2 = size if size.x > 0 else Vector2(600, 400) |
| 202 | var center: Vector2 = rect / 2.0 |
| 203 | |
| 204 | # Group nodes by cluster |
| 205 | var clustered: Dictionary = {} # cluster_id -> [node_id] |
| 206 | var unclustered: Array = [] |
| 207 | for node: Variant in _nodes: |
| 208 | if not node is Dictionary: |
| 209 | continue |
| 210 | var nid: String = str(node.get("id", "")) |
| 211 | if _cluster_map.has(nid): |
| 212 | var ci: int = _cluster_map[nid] |
| 213 | if not clustered.has(ci): |
| 214 | clustered[ci] = [] |
| 215 | clustered[ci].append(nid) |
| 216 | else: |
| 217 | unclustered.append(nid) |
| 218 | |
| 219 | var sorted_keys: Array = clustered.keys() |
| 220 | sorted_keys.sort() |
| 221 | var num_branches: int = sorted_keys.size() + (1 if not unclustered.is_empty() else 0) |
| 222 | if num_branches == 0: |
| 223 | num_branches = 1 |
| 224 | |
| 225 | # Place cluster hubs in a circle around center |
| 226 | var hub_radius: float = minf(rect.x, rect.y) * 0.28 |
| 227 | var branch_idx: int = 0 |
| 228 | |
| 229 | for ci: Variant in sorted_keys: |
| 230 | var angle: float = (float(branch_idx) / float(num_branches)) * TAU - PI / 2.0 |
| 231 | var hub_pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * hub_radius |
| 232 | _hub_positions[ci] = hub_pos |
| 233 | |
| 234 | # Fan room nodes around the hub |
| 235 | var ids: Array = clustered[ci] |
| 236 | var fan_radius: float = maxf(30.0, float(ids.size()) * 12.0) |
| 237 | fan_radius = minf(fan_radius, hub_radius * 0.7) |
| 238 | for ni: int in ids.size(): |
| 239 | var node_angle: float = (float(ni) / float(ids.size())) * TAU |
| 240 | var offset: Vector2 = Vector2(cos(node_angle), sin(node_angle)) * fan_radius |
| 241 | _positions[ids[ni]] = hub_pos + offset |
| 242 | |
| 243 | branch_idx += 1 |
| 244 | |
| 245 | # Unclustered nodes in a small cluster near center |
| 246 | if not unclustered.is_empty(): |
| 247 | var angle: float = (float(branch_idx) / float(num_branches)) * TAU - PI / 2.0 |
| 248 | var hub_pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * hub_radius * 0.5 |
| 249 | var fan_radius: float = maxf(30.0, float(unclustered.size()) * 12.0) |
| 250 | for ni: int in unclustered.size(): |
| 251 | var node_angle: float = (float(ni) / float(unclustered.size())) * TAU |
| 252 | _positions[unclustered[ni]] = hub_pos + Vector2(cos(node_angle), sin(node_angle)) * fan_radius |
| 253 | |
| 254 | |
| 186 | 255 | func _init_cluster_positions(clusters: Array) -> void: |
| 187 | 256 | ## Place nodes from the same cluster near each other. |
| 188 | 257 | ## Unclustered nodes start near center with random offset. |
| … | … | @@ -360,6 +429,11 @@ func _rescale_to_fit() -> void: |
| 360 | 429 | var offset: Vector2 = _positions[nid] - center_of_nodes |
| 361 | 430 | _positions[nid] = viewport_center + offset * scale_factor |
| 362 | 431 | |
| 432 | # Also rescale hub positions (mind-map mode) |
| 433 | for ci: int in _hub_positions.keys(): |
| 434 | var offset: Vector2 = _hub_positions[ci] - center_of_nodes |
| 435 | _hub_positions[ci] = viewport_center + offset * scale_factor |
| 436 | |
| 363 | 437 | |
| 364 | 438 | func _draw() -> void: |
| 365 | 439 | # Background |
| … | … | @@ -375,33 +449,63 @@ func _draw() -> void: |
| 375 | 449 | draw_string(_font, center - Vector2(80, 0), "%d nodes, no positions" % _nodes.size(), HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.8, 0.3, 0.3)) |
| 376 | 450 | return |
| 377 | 451 | |
| 378 | | # Draw edges — daemon uses {from, to, mi} |
| 379 | | # Adaptive rendering: reduce alpha and filter low-MI edges when dense |
| 380 | | var edge_count: int = _edges.size() |
| 381 | | var mi_floor: float = 0.0 # skip edges below this MI |
| 382 | | var alpha_scale: float = 1.0 |
| 383 | | if edge_count > 100: |
| 384 | | mi_floor = 0.3 |
| 385 | | alpha_scale = 0.5 |
| 386 | | elif edge_count > 50: |
| 387 | | mi_floor = 0.1 |
| 388 | | alpha_scale = 0.7 |
| 389 | | |
| 390 | | for edge: Variant in _edges: |
| 391 | | if not edge is Dictionary: |
| 392 | | continue |
| 393 | | var src: String = str(edge.get("from", "")) |
| 394 | | var tgt: String = str(edge.get("to", "")) |
| 395 | | var mi: float = float(edge.get("mi", 1.0)) |
| 396 | | if mi < mi_floor: |
| 397 | | continue |
| 398 | | if not _positions.has(src) or not _positions.has(tgt): |
| 399 | | continue |
| 400 | | var p1: Vector2 = _world_to_screen(_positions[src]) |
| 401 | | var p2: Vector2 = _world_to_screen(_positions[tgt]) |
| 402 | | var alpha: float = clampf(mi / 3.0 * alpha_scale, 0.05, 0.6) |
| 403 | | var width: float = clampf(mi, 0.5, 3.0) * _zoom |
| 404 | | draw_line(p1, p2, Color(COLOR_EDGE, alpha), width) |
| 452 | # Mind-map mode: draw spokes from center → hubs → nodes |
| 453 | if layout_mode == "mindmap" and not _hub_positions.is_empty(): |
| 454 | var screen_center: Vector2 = _world_to_screen(size / 2.0 if size.x > 0 else Vector2(300, 200)) |
| 455 | for ci: int in _hub_positions.keys(): |
| 456 | var hub_screen: Vector2 = _world_to_screen(_hub_positions[ci]) |
| 457 | var color: Color = CLUSTER_COLORS[ci % CLUSTER_COLORS.size()] |
| 458 | # Center → Hub spoke |
| 459 | draw_line(screen_center, hub_screen, Color(color, 0.4), 2.0 * _zoom) |
| 460 | # Hub → Node spokes |
| 461 | for nid: String in _cluster_map.keys(): |
| 462 | if _cluster_map[nid] == ci and _positions.has(nid): |
| 463 | var node_screen: Vector2 = _world_to_screen(_positions[nid]) |
| 464 | draw_line(hub_screen, node_screen, Color(color, 0.25), 1.5 * _zoom) |
| 465 | # Draw hub circles |
| 466 | for ci2: int in _hub_positions.keys(): |
| 467 | var hub_screen2: Vector2 = _world_to_screen(_hub_positions[ci2]) |
| 468 | var hub_color: Color = CLUSTER_COLORS[ci2 % CLUSTER_COLORS.size()] |
| 469 | draw_circle(hub_screen2, 6.0 * _zoom, hub_color.lightened(0.3)) |
| 470 | # Hub label |
| 471 | var label_text: String = "" |
| 472 | if _cluster_labels.has(ci2): |
| 473 | label_text = str(_cluster_labels[ci2].get("label", "")) |
| 474 | if label_text != "": |
| 475 | var font_size: int = int(13.0 * _zoom) |
| 476 | if font_size >= 8: |
| 477 | var text_w: float = _font.get_string_size(label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x |
| 478 | var lbl_pos: Vector2 = hub_screen2 + Vector2(-text_w * 0.5, -12.0 * _zoom) |
| 479 | draw_string(_font, lbl_pos, label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, hub_color.lightened(0.5)) |
| 480 | # Draw center dot |
| 481 | draw_circle(screen_center, 8.0 * _zoom, Color(0.9, 0.9, 0.95)) |
| 482 | else: |
| 483 | # Standard mode: draw MI edges with adaptive alpha |
| 484 | var edge_count: int = _edges.size() |
| 485 | var mi_floor: float = 0.0 |
| 486 | var alpha_scale: float = 1.0 |
| 487 | if edge_count > 100: |
| 488 | mi_floor = 0.3 |
| 489 | alpha_scale = 0.5 |
| 490 | elif edge_count > 50: |
| 491 | mi_floor = 0.1 |
| 492 | alpha_scale = 0.7 |
| 493 | |
| 494 | for edge: Variant in _edges: |
| 495 | if not edge is Dictionary: |
| 496 | continue |
| 497 | var src: String = str(edge.get("from", "")) |
| 498 | var tgt: String = str(edge.get("to", "")) |
| 499 | var mi: float = float(edge.get("mi", 1.0)) |
| 500 | if mi < mi_floor: |
| 501 | continue |
| 502 | if not _positions.has(src) or not _positions.has(tgt): |
| 503 | continue |
| 504 | var p1: Vector2 = _world_to_screen(_positions[src]) |
| 505 | var p2: Vector2 = _world_to_screen(_positions[tgt]) |
| 506 | var alpha: float = clampf(mi / 3.0 * alpha_scale, 0.05, 0.6) |
| 507 | var width: float = clampf(mi, 0.5, 3.0) * _zoom |
| 508 | draw_line(p1, p2, Color(COLOR_EDGE, alpha), width) |
| 405 | 509 | |
| 406 | 510 | # Draw nodes |
| 407 | 511 | for node: Variant in _nodes: |