OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet commits / af3018e

Add mind-map radial layout + hierarchical clustering combos
Mind map mode: radial tree with center hub, cluster branches radiating outward, room nodes fanning around each hub. Spokes drawn from center → hub → nodes with cluster-colored lines and labeled hub circles. Three mindmap presets: category, topics, activity. Selectable from the algo dropdown alongside existing graph modes. Hierarchical combos (creator+category, etc.) now produce real sub-splits via two-pass clustering in the daemon. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
af3018ead39c286ce68c1d02e5235a25a40f9d59
by plankatron <fleeky@gmail.com> · 2026-02-23T13:24:48.000Z

Changed files (2) +150 -38

scripts/graph_canvas.gd +140 -36
……@@ -12,6 +12,10 @@ var _positions: Dictionary = {} # id -> Vector2
1212var _cluster_map: Dictionary = {} # id -> cluster_id
1313var _title_map: Dictionary = {} # id -> String
1414var _cluster_labels: Dictionary = {} # cluster_index -> {label, topics}
15var _hub_positions: Dictionary = {} # cluster_index -> Vector2 (mind-map hub nodes)
16
17## Layout mode: "auto" (force/grid), "mindmap" (radial tree)
18var layout_mode: String = "auto"
1519
1620## Interaction state
1721var _hovered_node: String = ""
……@@ -83,16 +87,20 @@ func set_graph_data(nodes: Array, edges: Array, clusters: Array, cluster_labels:
8387 ids.sort()
8488 seed("".join(ids).hash())
8589
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)
9295 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()
96104 _spread_overlapping()
97105 _rescale_to_fit()
98106
……@@ -183,6 +191,67 @@ func _grid_layout() -> void:
183191 x_offset += group_w + group_gap
184192
185193
194func _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
186255func _init_cluster_positions(clusters: Array) -> void:
187256 ## Place nodes from the same cluster near each other.
188257 ## Unclustered nodes start near center with random offset.
……@@ -360,6 +429,11 @@ func _rescale_to_fit() -> void:
360429 var offset: Vector2 = _positions[nid] - center_of_nodes
361430 _positions[nid] = viewport_center + offset * scale_factor
362431
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
363437
364438func _draw() -> void:
365439 # Background
……@@ -375,33 +449,63 @@ func _draw() -> void:
375449 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))
376450 return
377451
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)
405509
406510 # Draw nodes
407511 for node: Variant in _nodes:
scripts/graph_panel.gd +10 -2
……@@ -23,10 +23,14 @@ const ALGO_PRESETS: Array[Dictionary] = [
2323 {"name": "size", "features": [{"type": "size", "weight": 1.0}], "threshold": 0.1, "minSize": 2},
2424 {"name": "topics", "features": [{"type": "topics", "weight": 1.0}], "threshold": 0.3, "minSize": 2},
2525 {"name": "members", "features": [{"type": "members", "weight": 1.0}], "threshold": 0.5, "minSize": 2},
26 # Stacked combos (pie → sub-slice)
26 # Stacked combos (hierarchical: primary → sub-split by secondary)
2727 {"name": "creator+category", "features": [{"type": "creator", "weight": 1.0}, {"type": "category", "weight": 0.8}], "threshold": 0.3, "minSize": 2},
2828 {"name": "activity+topics", "features": [{"type": "activity", "weight": 1.0}, {"type": "topics", "weight": 0.7}], "threshold": 0.3, "minSize": 2},
2929 {"name": "category+activity", "features": [{"type": "category", "weight": 1.0}, {"type": "activity", "weight": 0.6}], "threshold": 0.2, "minSize": 2},
30 # Mind map variants (radial tree layout)
31 {"name": "mindmap:category", "layout": "mindmap", "features": [{"type": "category", "weight": 1.0}], "threshold": 0.1, "minSize": 2},
32 {"name": "mindmap:topics", "layout": "mindmap", "features": [{"type": "topics", "weight": 1.0}], "threshold": 0.3, "minSize": 2},
33 {"name": "mindmap:activity", "layout": "mindmap", "features": [{"type": "activity", "weight": 1.0}], "threshold": 0.1, "minSize": 2},
3034]
3135
3236var _clusters: Array = []
……@@ -149,7 +153,11 @@ func _try_render() -> void:
149153 elif cluster is Array:
150154 cluster_arrays.append(cluster)
151155
152 print("[graph] rendering: %d nodes, %d edges, %d clusters, %d labels" % [nodes.size(), _edges.size(), cluster_arrays.size(), cluster_labels.size()])
156 # Set layout mode from algo preset
157 var layout: String = str(_selected_algo.get("layout", "auto"))
158 graph_canvas.layout_mode = layout
159
160 print("[graph] rendering: %d nodes, %d edges, %d clusters, %d labels, layout=%s" % [nodes.size(), _edges.size(), cluster_arrays.size(), cluster_labels.size(), layout])
153161 graph_canvas.set_graph_data(nodes, _edges, cluster_arrays, cluster_labels)
154162
155163