| … | … | @@ -12,9 +12,13 @@ var client: KeetUdsClient |
| 12 | 12 | |
| 13 | 13 | ## Compute full graph (cached 60s, use force=true to recompute). |
| 14 | 14 | ## Result: { clusters: Array, edges: Array, suggestions: Array } |
| 15 | func compute(force: bool = false, callback: Callable = Callable()) -> void: | |
| 15 | ## algo: optional Dictionary with {features, threshold, minSize} for MI clustering. | |
| 16 | func compute(force: bool = false, algo: Dictionary = {}, callback: Callable = Callable()) -> void: | |
| 16 | 17 | _require_client() |
| 17 | client.call_rpc("graph.compute", {"force": force}, func(result, err): | |
| 18 | var params: Dictionary = {"force": force} | |
| 19 | if not algo.is_empty(): | |
| 20 | params["algo"] = algo | |
| 21 | client.call_rpc("graph.compute", params, func(result, err): | |
| 18 | 22 | if err != null: |
| 19 | 23 | error_occurred.emit("compute: %s" % str(err)) |
| 20 | 24 | return |
| … | … | @@ -26,9 +30,12 @@ func compute(force: bool = false, callback: Callable = Callable()) -> void: |
| 26 | 30 | |
| 27 | 31 | |
| 28 | 32 | ## Get clusters from last computation (uses cache). |
| 29 | func get_clusters(callback: Callable = Callable()) -> void: | |
| 33 | func get_clusters(algo: Dictionary = {}, callback: Callable = Callable()) -> void: | |
| 30 | 34 | _require_client() |
| 31 | client.call_rpc("graph.clusters", {}, func(result, err): | |
| 35 | var params: Dictionary = {} | |
| 36 | if not algo.is_empty(): | |
| 37 | params["algo"] = algo | |
| 38 | client.call_rpc("graph.clusters", params, func(result, err): | |
| 32 | 39 | if err != null: |
| 33 | 40 | error_occurred.emit("get_clusters: %s" % str(err)) |
| 34 | 41 | return |
| … | … | @@ -40,9 +47,12 @@ func get_clusters(callback: Callable = Callable()) -> void: |
| 40 | 47 | |
| 41 | 48 | |
| 42 | 49 | ## Get MI edges from last computation (uses cache). |
| 43 | func get_edges(callback: Callable = Callable()) -> void: | |
| 50 | func get_edges(algo: Dictionary = {}, callback: Callable = Callable()) -> void: | |
| 44 | 51 | _require_client() |
| 45 | client.call_rpc("graph.edges", {}, func(result, err): | |
| 52 | var params: Dictionary = {} | |
| 53 | if not algo.is_empty(): | |
| 54 | params["algo"] = algo | |
| 55 | client.call_rpc("graph.edges", params, func(result, err): | |
| 46 | 56 | if err != null: |
| 47 | 57 | error_occurred.emit("get_edges: %s" % str(err)) |
| 48 | 58 | return |
| … | … | @@ -20,6 +20,11 @@ size_flags_horizontal = 3 |
| 20 | 20 | text = "Graph" |
| 21 | 21 | horizontal_alignment = 1 |
| 22 | 22 | |
| 23 | [node name="AlgoBtn" type="OptionButton" parent="VBox/Header"] | |
| 24 | unique_name_in_owner = true | |
| 25 | layout_mode = 2 | |
| 26 | tooltip_text = "Clustering algorithm" | |
| 27 | ||
| 23 | 28 | [node name="RefreshBtn" type="Button" parent="VBox/Header"] |
| 24 | 29 | unique_name_in_owner = true |
| 25 | 30 | layout_mode = 2 |
| … | … | @@ -11,6 +11,7 @@ var _edges: Array = [] # [{from, to, mi}] |
| 11 | 11 | var _positions: Dictionary = {} # id -> Vector2 |
| 12 | 12 | var _cluster_map: Dictionary = {} # id -> cluster_id |
| 13 | 13 | var _title_map: Dictionary = {} # id -> String |
| 14 | var _cluster_labels: Dictionary = {} # cluster_index -> {label, topics} | |
| 14 | 15 | |
| 15 | 16 | ## Interaction state |
| 16 | 17 | var _hovered_node: String = "" |
| … | … | @@ -50,11 +51,12 @@ func _ready() -> void: |
| 50 | 51 | set_process(false) |
| 51 | 52 | |
| 52 | 53 | |
| 53 | func set_graph_data(nodes: Array, edges: Array, clusters: Array) -> void: | |
| 54 | func set_graph_data(nodes: Array, edges: Array, clusters: Array, cluster_labels: Dictionary = {}) -> void: | |
| 54 | 55 | _nodes = nodes |
| 55 | 56 | _edges = edges |
| 56 | 57 | _cluster_map.clear() |
| 57 | 58 | _title_map.clear() |
| 59 | _cluster_labels = cluster_labels | |
| 58 | 60 | |
| 59 | 61 | # Build cluster membership: room_id -> cluster_id |
| 60 | 62 | for ci: int in clusters.size(): |
| … | … | @@ -251,6 +253,39 @@ func _draw() -> void: |
| 251 | 253 | if font_size >= 6: |
| 252 | 254 | draw_string(_font, label_pos, title, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color(0.8, 0.8, 0.8)) |
| 253 | 255 | |
| 256 | # Draw cluster labels at centroids | |
| 257 | if not _cluster_labels.is_empty(): | |
| 258 | # Collect node positions per cluster | |
| 259 | var cluster_positions: Dictionary = {} # cluster_index -> Array[Vector2] | |
| 260 | for nid: String in _cluster_map.keys(): | |
| 261 | var ci: int = _cluster_map[nid] | |
| 262 | if not _positions.has(nid): | |
| 263 | continue | |
| 264 | if not cluster_positions.has(ci): | |
| 265 | cluster_positions[ci] = [] | |
| 266 | cluster_positions[ci].append(_positions[nid]) | |
| 267 | ||
| 268 | for ci: int in cluster_positions.keys(): | |
| 269 | if not _cluster_labels.has(ci): | |
| 270 | continue | |
| 271 | var meta: Dictionary = _cluster_labels[ci] | |
| 272 | var label_text: String = str(meta.get("label", "")) | |
| 273 | if label_text == "": | |
| 274 | continue | |
| 275 | # Compute centroid | |
| 276 | var pts: Array = cluster_positions[ci] | |
| 277 | var centroid := Vector2.ZERO | |
| 278 | for p: Vector2 in pts: | |
| 279 | centroid += p | |
| 280 | centroid /= float(pts.size()) | |
| 281 | var screen_pos: Vector2 = _world_to_screen(centroid) | |
| 282 | var color: Color = CLUSTER_COLORS[ci % CLUSTER_COLORS.size()] | |
| 283 | var font_size: int = int(14.0 * _zoom) | |
| 284 | if font_size >= 8: | |
| 285 | # Draw label above the centroid | |
| 286 | var lbl_pos: Vector2 = screen_pos - Vector2(label_text.length() * 3.5 * _zoom, 20.0 * _zoom) | |
| 287 | draw_string(_font, lbl_pos, label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color.lightened(0.4)) | |
| 288 | ||
| 254 | 289 | # Tooltip for hovered node |
| 255 | 290 | if _hovered_node != "" and _positions.has(_hovered_node): |
| 256 | 291 | var pos: Vector2 = _world_to_screen(_positions[_hovered_node]) |
| … | … | @@ -4,14 +4,23 @@ extends PanelContainer |
| 4 | 4 | ## Only refreshes on manual button press or initial load. |
| 5 | 5 | |
| 6 | 6 | @onready var title_label: Label = %Title |
| 7 | @onready var algo_btn: OptionButton = %AlgoBtn | |
| 7 | 8 | @onready var refresh_btn: Button = %RefreshBtn |
| 8 | 9 | @onready var graph_canvas: Control = %GraphCanvas |
| 9 | 10 | |
| 11 | const ALGO_PRESETS: Array[Dictionary] = [ | |
| 12 | {"name": "members-only", "features": [{"type": "members", "weight": 1.0}], "threshold": 0.5, "minSize": 2}, | |
| 13 | {"name": "topics-only", "features": [{"type": "topics", "weight": 1.0}], "threshold": 0.3, "minSize": 2}, | |
| 14 | {"name": "category-only", "features": [{"type": "category", "weight": 1.0}], "threshold": 0.1, "minSize": 2}, | |
| 15 | {"name": "combined", "features": [{"type": "members", "weight": 1.0}, {"type": "topics", "weight": 0.5}], "threshold": 0.4, "minSize": 2}, | |
| 16 | ] | |
| 17 | ||
| 10 | 18 | var _clusters: Array = [] |
| 11 | 19 | var _edges: Array = [] |
| 12 | 20 | var _rooms: Array = [] # [{id, title}] |
| 13 | 21 | var _data_ready: int = 0 # bitmask: 1=clusters, 2=edges, 4=rooms |
| 14 | 22 | var _compute_timeout: SceneTreeTimer = null |
| 23 | var _selected_algo: Dictionary = {} # current algo preset (empty = daemon default) | |
| 15 | 24 | |
| 16 | 25 | |
| 17 | 26 | func _ready() -> void: |
| … | … | @@ -23,6 +32,13 @@ func _ready() -> void: |
| 23 | 32 | refresh_btn.pressed.connect(_on_refresh) |
| 24 | 33 | graph_canvas.room_clicked.connect(_on_room_clicked) |
| 25 | 34 | |
| 35 | # Populate algo selector | |
| 36 | algo_btn.add_item("Default", 0) | |
| 37 | for i: int in ALGO_PRESETS.size(): | |
| 38 | algo_btn.add_item(ALGO_PRESETS[i]["name"], i + 1) | |
| 39 | algo_btn.selected = 0 | |
| 40 | algo_btn.item_selected.connect(_on_algo_selected) | |
| 41 | ||
| 26 | 42 | title_label.text = "Graph" |
| 27 | 43 | |
| 28 | 44 | # Initial fetch |
| … | … | @@ -32,8 +48,8 @@ func _ready() -> void: |
| 32 | 48 | func _fetch_all() -> void: |
| 33 | 49 | _data_ready = 0 |
| 34 | 50 | title_label.text = "Graph (loading...)" |
| 35 | print("[graph] fetching graph data...") | |
| 36 | KeetService.graph_mgr.compute(true) | |
| 51 | print("[graph] fetching graph data... algo=%s" % str(_selected_algo.get("name", "default"))) | |
| 52 | KeetService.graph_mgr.compute(true, _selected_algo) | |
| 37 | 53 | |
| 38 | 54 | # Timeout: if graph.compute hangs, render rooms without edges after 8s |
| 39 | 55 | if _compute_timeout != null: |
| … | … | @@ -95,18 +111,24 @@ func _try_render() -> void: |
| 95 | 111 | for r: Dictionary in _rooms: |
| 96 | 112 | nodes.append({"id": r["id"], "title": r["title"]}) |
| 97 | 113 | |
| 98 | # Build cluster arrays (daemon returns [{rooms: [id, id, ...], ...}]) | |
| 114 | # Build cluster arrays and extract labels (daemon returns [{rooms, label, topics, ...}]) | |
| 99 | 115 | var cluster_arrays: Array = [] |
| 116 | var cluster_labels: Dictionary = {} # index -> {label, topics} | |
| 100 | 117 | for cluster: Variant in _clusters: |
| 101 | 118 | if cluster is Dictionary: |
| 102 | 119 | var room_ids: Variant = cluster.get("rooms", []) |
| 103 | 120 | if room_ids is Array: |
| 121 | var ci: int = cluster_arrays.size() | |
| 104 | 122 | cluster_arrays.append(room_ids) |
| 123 | var label: String = str(cluster.get("label", "")) | |
| 124 | var topics: Variant = cluster.get("topics", []) | |
| 125 | if label != "": | |
| 126 | cluster_labels[ci] = {"label": label, "topics": topics} | |
| 105 | 127 | elif cluster is Array: |
| 106 | 128 | cluster_arrays.append(cluster) |
| 107 | 129 | |
| 108 | print("[graph] rendering: %d nodes, %d edges, %d clusters" % [nodes.size(), _edges.size(), cluster_arrays.size()]) | |
| 109 | graph_canvas.set_graph_data(nodes, _edges, cluster_arrays) | |
| 130 | print("[graph] rendering: %d nodes, %d edges, %d clusters, %d labels" % [nodes.size(), _edges.size(), cluster_arrays.size(), cluster_labels.size()]) | |
| 131 | graph_canvas.set_graph_data(nodes, _edges, cluster_arrays, cluster_labels) | |
| 110 | 132 | |
| 111 | 133 | |
| 112 | 134 | func _on_graph_error(msg: String) -> void: |
| … | … | @@ -119,6 +141,14 @@ func _on_graph_error(msg: String) -> void: |
| 119 | 141 | _try_render() |
| 120 | 142 | |
| 121 | 143 | |
| 144 | func _on_algo_selected(index: int) -> void: | |
| 145 | if index == 0: | |
| 146 | _selected_algo = {} | |
| 147 | else: | |
| 148 | _selected_algo = ALGO_PRESETS[index - 1] | |
| 149 | _fetch_all() | |
| 150 | ||
| 151 | ||
| 122 | 152 | func _on_refresh() -> void: |
| 123 | 153 | _fetch_all() |
| 124 | 154 |
| … | … | @@ -50,6 +50,9 @@ var _joining_rooms: Dictionary = {} # roomId -> true, tracks rooms being joined |
| 50 | 50 | var _topic_results: Array = [] |
| 51 | 51 | var _topic_searching: bool = false |
| 52 | 52 | |
| 53 | ## Cluster metadata from graph (cluster_id_str -> {label, topics}) | |
| 54 | var _cluster_meta_cache: Dictionary = {} | |
| 55 | ||
| 53 | 56 | ## Style constants |
| 54 | 57 | const COLOR_SELECTED := Color(0.25, 0.35, 0.55) |
| 55 | 58 | const COLOR_HOVER := Color(0.2, 0.25, 0.35) |
| … | … | @@ -121,7 +124,7 @@ func _on_group_changed(group_id: String) -> void: |
| 121 | 124 | if _group_rooms_key != group_id: |
| 122 | 125 | _group_rooms.clear() |
| 123 | 126 | _group_rooms_key = group_id |
| 124 | KeetService.graph_mgr.get_clusters(func(clusters): | |
| 127 | KeetService.graph_mgr.get_clusters({}, func(clusters): | |
| 125 | 128 | if _active_group != group_id: |
| 126 | 129 | return # stale |
| 127 | 130 | for c: Variant in clusters: |
| … | … | @@ -131,6 +134,13 @@ func _on_group_changed(group_id: String) -> void: |
| 131 | 134 | _group_rooms.clear() |
| 132 | 135 | for rid: Variant in c.get("rooms", []): |
| 133 | 136 | _group_rooms.append({"roomId": str(rid)}) |
| 137 | # Cache cluster label/topics for display | |
| 138 | var label: String = str(c.get("label", "")) | |
| 139 | var topics: Variant = c.get("topics", []) | |
| 140 | if label != "": | |
| 141 | _cluster_meta_cache[group_id] = {"label": label, "topics": topics} | |
| 142 | # Update title now that we have metadata | |
| 143 | title_label.text = _get_group_display_name(group_id) | |
| 134 | 144 | _build_joined_group_rooms() |
| 135 | 145 | return |
| 136 | 146 | ) |
| … | … | @@ -235,8 +245,14 @@ func _on_title_gui_input(event: InputEvent) -> void: |
| 235 | 245 | |
| 236 | 246 | |
| 237 | 247 | func _get_group_display_name(group_id: String) -> String: |
| 238 | # Cluster groups | |
| 248 | # Cluster groups — prefer MetadataBase label, fall back to room count | |
| 239 | 249 | if group_id.begins_with("cluster:"): |
| 250 | if _cluster_meta_cache.has(group_id): | |
| 251 | var meta: Dictionary = _cluster_meta_cache[group_id] | |
| 252 | var label: String = str(meta.get("label", "")) | |
| 253 | if label != "": | |
| 254 | var n: int = _group_rooms.size() | |
| 255 | return "%s (%d)" % [label, n] if n > 0 else label | |
| 240 | 256 | var n: int = _group_rooms.size() |
| 241 | 257 | return "Cluster (%d rooms)" % n if n > 0 else "Cluster" |
| 242 | 258 | # Registry room — check cached data, then registries array |