OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet / scripts / graph_panel.gd

244 lines · gd ref:
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()
	)