extends PanelContainer
## Graph visualization panel — shows rooms as nodes, MI relationships as edges.
## Replaces MemberPanel when Graph toggle is active.
## Only refreshes on manual button press or initial load.
@onready var title_label: Label = %Title
@onready var algo_btn: OptionButton = %AlgoBtn
@onready var sync_btn: Button = %SyncBtn
@onready var refresh_btn: Button = %RefreshBtn
@onready var graph_canvas: Control = %GraphCanvas
@onready var sync_dialog: Window = %SyncDialog
@onready var key_field: LineEdit = %KeyField
@onready var copy_btn: Button = %CopyBtn
@onready var join_field: LineEdit = %JoinField
@onready var join_btn: Button = %JoinBtn
@onready var status_label: Label = %StatusLabel
const ALGO_PRESETS: Array[Dictionary] = [
# All mindmap layout — single views
{"name": "category", "layout": "mindmap", "features": [{"type": "category", "weight": 1.0}], "threshold": 0.1, "minSize": 2},
{"name": "topics", "layout": "mindmap", "features": [{"type": "topics", "weight": 1.0}], "threshold": 0.3, "minSize": 2},
{"name": "activity", "layout": "mindmap", "features": [{"type": "activity", "weight": 1.0}], "threshold": 0.1, "minSize": 2},
{"name": "people", "layout": "mindmap", "features": [{"type": "size", "weight": 1.0}], "threshold": 0.1, "minSize": 2},
{"name": "creator", "layout": "mindmap", "features": [{"type": "creator", "weight": 1.0}], "threshold": 0.1, "minSize": 2},
{"name": "members", "layout": "mindmap", "features": [{"type": "members", "weight": 1.0}], "threshold": 0.5, "minSize": 2},
# Combined views
{"name": "activity+topics", "layout": "mindmap", "features": [{"type": "activity", "weight": 1.0}, {"type": "topics", "weight": 0.7}], "threshold": 0.3, "minSize": 2},
{"name": "category+activity", "layout": "mindmap", "features": [{"type": "category", "weight": 1.0}, {"type": "activity", "weight": 0.6}], "threshold": 0.2, "minSize": 2},
]
var _clusters: Array = []
var _edges: Array = []
var _rooms: Array = [] # [{id, title}]
var _data_ready: int = 0 # bitmask: 1=clusters, 2=edges, 4=rooms
var _compute_timeout: SceneTreeTimer = null
var _selected_algo: Dictionary = {} # current algo preset (empty = daemon default)
func _ready() -> void:
KeetService.graph_mgr.graph_computed.connect(_on_graph_computed)
KeetService.graph_mgr.clusters_loaded.connect(_on_clusters_loaded)
KeetService.graph_mgr.edges_loaded.connect(_on_edges_loaded)
KeetService.graph_mgr.error_occurred.connect(_on_graph_error)
refresh_btn.pressed.connect(_on_refresh)
graph_canvas.room_clicked.connect(_on_room_clicked)
# Sync dialog
sync_btn.pressed.connect(_on_sync_pressed)
copy_btn.pressed.connect(_on_copy_key)
join_btn.pressed.connect(_on_join_meta)
sync_dialog.close_requested.connect(_on_sync_dialog_close)
# Populate algo selector
algo_btn.add_item("Default", 0)
for i: int in ALGO_PRESETS.size():
algo_btn.add_item(ALGO_PRESETS[i]["name"], i + 1)
algo_btn.item_selected.connect(_on_algo_selected)
# Default to category (first preset, all are mindmap layout now)
algo_btn.selected = 1
_selected_algo = ALGO_PRESETS[0]
title_label.text = "Graph"
# Initial fetch
_fetch_all()
func _fetch_all() -> void:
_data_ready = 0
title_label.text = "Graph (loading...)"
print("[graph] fetching graph data... algo=%s" % str(_selected_algo.get("name", "default")))
KeetService.graph_mgr.compute(true, _selected_algo)
# Timeout: if graph.compute hangs, render rooms without edges after 8s
if _compute_timeout != null:
if _compute_timeout.timeout.is_connected(_on_compute_timeout):
_compute_timeout.timeout.disconnect(_on_compute_timeout)
_compute_timeout = get_tree().create_timer(8.0)
_compute_timeout.timeout.connect(_on_compute_timeout)
# Also fetch room list for titles
KeetService.room_mgr.list_rooms(func(rooms):
_rooms.clear()
if rooms is Array:
for r: Variant in rooms:
if r is Dictionary:
_rooms.append({"id": str(r.get("roomId", "")), "title": str(r.get("title", ""))})
print("[graph] rooms loaded: %d" % _rooms.size())
_data_ready |= 4
_try_render()
)
func _on_compute_timeout() -> void:
if _data_ready & 3 == 0:
print("[graph] compute timed out — rendering rooms without edges")
_clusters = []
_edges = []
_data_ready |= 3
_try_render()
func _on_graph_computed(result: Dictionary) -> void:
_clusters = result.get("clusters", []) if result is Dictionary else []
_edges = result.get("edges", []) if result is Dictionary else []
print("[graph] computed: %d clusters, %d edges" % [_clusters.size(), _edges.size()])
_data_ready |= 3 # clusters + edges both come from compute
_try_render()
func _on_clusters_loaded(_clusters_arr: Array) -> void:
# clusters already set by _on_graph_computed — only handle standalone get_clusters() calls
if _data_ready & 1 == 0:
_clusters = _clusters_arr
_data_ready |= 1
_try_render()
func _on_edges_loaded(edges: Array) -> void:
_edges = edges
_data_ready |= 2
_try_render()
func _try_render() -> void:
if _data_ready < 7: # need all 3 bits
return
title_label.text = "Graph (%d rooms, %d edges)" % [_rooms.size(), _edges.size()]
# Build node list from rooms
var nodes: Array = []
for r: Dictionary in _rooms:
nodes.append({"id": r["id"], "title": r["title"]})
# Build cluster arrays and extract labels (daemon returns [{rooms, label, topics, ...}])
var cluster_arrays: Array = []
var cluster_labels: Dictionary = {} # index -> {label, topics}
for cluster: Variant in _clusters:
if cluster is Dictionary:
var room_ids: Variant = cluster.get("rooms", [])
if room_ids is Array:
var ci: int = cluster_arrays.size()
cluster_arrays.append(room_ids)
var label: String = str(cluster.get("label", ""))
var topics: Variant = cluster.get("topics", [])
if label != "":
cluster_labels[ci] = {"label": label, "topics": topics}
elif cluster is Array:
cluster_arrays.append(cluster)
# Set layout mode from algo preset
var layout: String = str(_selected_algo.get("layout", "auto"))
graph_canvas.graph_layout = layout
print("[graph] rendering: %d nodes, %d edges, %d clusters, %d labels, layout=%s" % [nodes.size(), _edges.size(), cluster_arrays.size(), cluster_labels.size(), layout])
graph_canvas.set_graph_data(nodes, _edges, cluster_arrays, cluster_labels)
func _on_graph_error(msg: String) -> void:
push_warning("Graph compute error: " + msg)
print("[graph] compute error: %s" % msg)
# Fall back to rendering rooms without clustering/edges
_clusters = []
_edges = []
_data_ready |= 3 # Mark clusters+edges as done (just empty)
_try_render()
func _on_algo_selected(index: int) -> void:
if index == 0:
_selected_algo = {}
else:
_selected_algo = ALGO_PRESETS[index - 1]
_fetch_all()
func _on_refresh() -> void:
_fetch_all()
func _on_room_clicked(room_id: String) -> void:
if room_id == "":
return
# Find title
var title: String = room_id.left(8)
for r: Dictionary in _rooms:
if r["id"] == room_id:
title = r["title"]
break
KeetService.select_room(room_id)
func _on_sync_dialog_close() -> void:
sync_dialog.hide()
# --- MetadataBase Sync ---
func _on_sync_pressed() -> void:
status_label.text = ""
key_field.text = "Loading..."
join_field.text = ""
sync_dialog.popup_centered()
# Fetch our MetadataBase key
KeetService.graph_mgr.client.call_rpc("meta.key", {}, func(result, err):
if err != null:
key_field.text = "Error: %s" % str(err)
return
if result is Dictionary:
key_field.text = str(result.get("key", ""))
else:
key_field.text = str(result)
)
func _on_copy_key() -> void:
if key_field.text != "" and key_field.text != "Loading...":
DisplayServer.clipboard_set(key_field.text)
status_label.text = "Key copied to clipboard"
func _on_join_meta() -> void:
var key: String = join_field.text.strip_edges()
if key == "":
status_label.text = "Paste a key first"
return
if key.length() < 32:
status_label.text = "Invalid key (too short)"
return
status_label.text = "Joining..."
join_btn.disabled = true
KeetService.graph_mgr.client.call_rpc("meta.join", {"key": key}, func(result, err):
join_btn.disabled = false
if err != null:
status_label.text = "Error: %s" % str(err)
else:
status_label.text = "Joined! Metadata will sync shortly."
# Refresh graph after joining
_fetch_all()
)