OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet / scripts / graph_canvas.gd

649 lines · gd ref:
extends Control
## Static 2D graph renderer using _draw().
## Force-directed layout computed once on data load, then rendered.
## Nodes = rooms, edges = MI relationships, colors = clusters.

signal room_clicked(room_id: String)

## Graph data
var _nodes: Array = []       # [{id, title}]
var _edges: Array = []       # [{from, to, mi}]
var _positions: Dictionary = {}  # id -> Vector2
var _cluster_map: Dictionary = {}  # id -> cluster_id
var _title_map: Dictionary = {}    # id -> String
var _cluster_labels: Dictionary = {}  # cluster_index -> {label, topics}
var _hub_positions: Dictionary = {}   # cluster_index -> Vector2 (mind-map hub nodes)

## Layout mode: "auto" (force/grid), "mindmap" (radial tree)
var graph_layout: String = "auto"

## Interaction state
var _hovered_node: String = ""
var _zoom: float = 1.0
var _pan: Vector2 = Vector2.ZERO
var _dragging: bool = false
var _drag_start: Vector2 = Vector2.ZERO
var _pan_start: Vector2 = Vector2.ZERO

## Layout params — balanced so MI-based attraction creates visible clusters
const NODE_RADIUS: float = 10.0
const HOVER_RADIUS: float = 14.0
const LABEL_SIZE: int = 10
const MAX_LABEL: int = 18
const REPULSION: float = 500.0
const ATTRACTION: float = 0.3
const CENTER_GRAVITY: float = 0.005
const DAMPING: float = 0.85
const LAYOUT_ITERATIONS: int = 80
const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 4.0

const CLUSTER_COLORS: Array[Color] = [
	Color("#e67e22"), Color("#5865f2"), Color("#0dbd8b"), Color("#e74c3c"),
	Color("#f1c40f"), Color("#9b59b6"), Color("#1abc9c"), Color("#3498db")
]
const COLOR_UNCLUSTERED := Color(0.75, 0.8, 0.85)
const COLOR_EDGE := Color(1.0, 1.0, 1.0)
const COLOR_BG := Color(0.08, 0.08, 0.1)

var _font: Font = null


func _ready() -> void:
	mouse_filter = Control.MOUSE_FILTER_STOP
	_font = ThemeDB.fallback_font
	set_process(false)


func set_graph_data(nodes: Array, edges: Array, clusters: Array, cluster_labels: Dictionary = {}) -> void:
	_nodes = nodes
	_edges = edges
	_cluster_map.clear()
	_title_map.clear()
	_cluster_labels = cluster_labels

	# Reset zoom/pan so new layout is fully visible
	_zoom = 1.0
	_pan = Vector2.ZERO
	_hovered_node = ""

	# Build cluster membership: room_id -> cluster_id
	for ci: int in clusters.size():
		var cluster: Variant = clusters[ci]
		if cluster is Array:
			for room_id: Variant in cluster:
				_cluster_map[str(room_id)] = ci

	for node: Variant in _nodes:
		if node is Dictionary:
			var nid: String = str(node.get("id", ""))
			_title_map[nid] = str(node.get("title", nid.left(8)))

	# Deterministic seed from room IDs so same data = same layout
	var ids: Array = []
	for node: Variant in _nodes:
		if node is Dictionary:
			ids.append(str(node.get("id", "")))
	ids.sort()
	seed("".join(ids).hash())

	_hub_positions.clear()

	# Choose layout strategy
	if graph_layout == "mindmap":
		_mindmap_layout(clusters)
	else:
		# Auto: sparse → grid, dense → force-directed
		var edge_ratio: float = float(_edges.size()) / maxf(float(_nodes.size()), 1.0)
		if _edges.is_empty() or edge_ratio < 0.5:
			_grid_layout()
		else:
			_init_cluster_positions(clusters)
			for i: int in LAYOUT_ITERATIONS:
				_layout_step()
	_spread_overlapping()
	_rescale_to_fit()

	# Debug: log position bounds so we can diagnose invisible graphs
	if not _positions.is_empty():
		var min_p := Vector2(INF, INF)
		var max_p := Vector2(-INF, -INF)
		var nan_count: int = 0
		for p: Vector2 in _positions.values():
			if is_nan(p.x) or is_nan(p.y) or is_inf(p.x) or is_inf(p.y):
				nan_count += 1
				continue
			min_p.x = minf(min_p.x, p.x)
			min_p.y = minf(min_p.y, p.y)
			max_p.x = maxf(max_p.x, p.x)
			max_p.y = maxf(max_p.y, p.y)
		print("[graph_canvas] layout done: %d positions, canvas=%s, bounds=(%s → %s), nan=%d" % [_positions.size(), str(size), str(min_p), str(max_p), nan_count])
		# Safety: if positions are out of canvas bounds or contain NaN, redo with current size
		if nan_count > 0 or max_p.x < 0 or max_p.y < 0 or min_p.x > size.x or min_p.y > size.y:
			print("[graph_canvas] positions out of bounds — forcing re-layout with canvas size %s" % str(size))
			_positions.clear()
			_grid_layout()
			_spread_overlapping()
			_rescale_to_fit()
	queue_redraw()


func _grid_layout() -> void:
	## Cluster-aware grid layout — groups nodes by cluster in separate regions.
	if _nodes.is_empty():
		return
	_positions.clear()
	var rect: Vector2 = size if size.x > 0 else Vector2(600, 400)

	# Group nodes by cluster
	var clustered: Dictionary = {}  # cluster_id -> [node_id]
	var unclustered: Array = []
	for node: Variant in _nodes:
		if not node is Dictionary:
			continue
		var nid: String = str(node.get("id", ""))
		if _cluster_map.has(nid):
			var ci: int = _cluster_map[nid]
			if not clustered.has(ci):
				clustered[ci] = []
			clustered[ci].append(nid)
		else:
			unclustered.append(nid)

	# All groups: each cluster + unclustered
	var groups: Array = []  # [{ids: [], color_idx: int}]
	var sorted_keys: Array = clustered.keys()
	sorted_keys.sort()
	for ci: Variant in sorted_keys:
		groups.append({"ids": clustered[ci], "ci": ci})
	if not unclustered.is_empty():
		groups.append({"ids": unclustered, "ci": -1})

	if groups.is_empty():
		return

	# Layout groups with fixed minimum cell size so nodes never bunch up.
	# Each group gets enough space for its nodes at min_cell_w apart.
	var padding: float = 40.0
	var group_gap: float = 40.0
	var min_cell_w: float = 90.0  # minimum horizontal spacing between nodes
	var min_cell_h: float = 70.0  # minimum vertical spacing between nodes
	var avail_h: float = rect.y - padding * 2

	var x_offset: float = padding
	for g: Dictionary in groups:
		var ids: Array = g["ids"]
		var n: int = ids.size()
		var cols: int = ceili(sqrt(float(n)))
		var rows: int = ceili(float(n) / float(cols))
		var cell_w: float = maxf(min_cell_w, avail_h / float(rows + 1))
		var cell_h: float = maxf(min_cell_h, avail_h / float(rows + 1))
		var group_w: float = cell_w * float(cols + 1)

		for i: int in n:
			var nid: String = ids[i]
			var col: int = i % cols
			var row: int = i / cols
			_positions[nid] = Vector2(
				x_offset + (col + 1) * cell_w,
				padding + (row + 1) * cell_h
			)
		x_offset += group_w + group_gap


func _mindmap_layout(clusters: Array) -> void:
	## Radial mind-map: center hub with cluster branches radiating outward.
	## Each cluster gets a hub node (labeled) with room nodes fanning around it.
	if _nodes.is_empty():
		return
	_positions.clear()

	var rect: Vector2 = size if size.x > 0 else Vector2(600, 400)
	var center: Vector2 = rect / 2.0

	# Group nodes by cluster
	var clustered: Dictionary = {}  # cluster_id -> [node_id]
	var unclustered: Array = []
	for node: Variant in _nodes:
		if not node is Dictionary:
			continue
		var nid: String = str(node.get("id", ""))
		if _cluster_map.has(nid):
			var ci: int = _cluster_map[nid]
			if not clustered.has(ci):
				clustered[ci] = []
			clustered[ci].append(nid)
		else:
			unclustered.append(nid)

	var sorted_keys: Array = clustered.keys()
	sorted_keys.sort()
	var num_branches: int = sorted_keys.size() + (1 if not unclustered.is_empty() else 0)
	if num_branches == 0:
		num_branches = 1

	# Place cluster hubs in a circle around center
	var hub_radius: float = minf(rect.x, rect.y) * 0.28
	var branch_idx: int = 0

	for ci: Variant in sorted_keys:
		var angle: float = (float(branch_idx) / float(num_branches)) * TAU - PI / 2.0
		var hub_pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * hub_radius
		_hub_positions[ci] = hub_pos

		# Fan room nodes around the hub
		var ids: Array = clustered[ci]
		var fan_radius: float = maxf(30.0, float(ids.size()) * 12.0)
		fan_radius = minf(fan_radius, hub_radius * 0.7)
		for ni: int in ids.size():
			var node_angle: float = (float(ni) / float(ids.size())) * TAU
			var offset: Vector2 = Vector2(cos(node_angle), sin(node_angle)) * fan_radius
			_positions[ids[ni]] = hub_pos + offset

		branch_idx += 1

	# Unclustered nodes in a small cluster near center
	if not unclustered.is_empty():
		var angle: float = (float(branch_idx) / float(num_branches)) * TAU - PI / 2.0
		var hub_pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * hub_radius * 0.5
		var fan_radius: float = maxf(30.0, float(unclustered.size()) * 12.0)
		for ni: int in unclustered.size():
			var node_angle: float = (float(ni) / float(unclustered.size())) * TAU
			_positions[unclustered[ni]] = hub_pos + Vector2(cos(node_angle), sin(node_angle)) * fan_radius


func _init_cluster_positions(clusters: Array) -> void:
	## Place nodes from the same cluster near each other.
	## Unclustered nodes start near center with random offset.
	if _nodes.is_empty():
		return

	var rect: Vector2 = size if size.x > 0 else Vector2(600, 400)
	var center: Vector2 = rect / 2.0
	_positions.clear()

	# Assign cluster centers around a circle
	var cluster_centers: Array[Vector2] = []
	var cluster_radius: float = minf(rect.x, rect.y) * 0.25
	var num_clusters: int = clusters.size()
	if num_clusters == 0:
		num_clusters = 1
	for ci: int in num_clusters:
		var angle: float = (float(ci) / float(num_clusters)) * TAU
		cluster_centers.append(center + Vector2(cos(angle), sin(angle)) * cluster_radius)

	# Place nodes
	var placed: Dictionary = {}
	for node: Variant in _nodes:
		if not node is Dictionary:
			continue
		var nid: String = str(node.get("id", ""))
		if _cluster_map.has(nid):
			var ci: int = _cluster_map[nid]
			var cc: Vector2 = cluster_centers[ci] if ci < cluster_centers.size() else center
			# Small random offset within cluster
			var offset := Vector2(randf_range(-30, 30), randf_range(-30, 30))
			_positions[nid] = cc + offset
		else:
			# Unclustered — start near center with wider spread
			var offset := Vector2(randf_range(-60, 60), randf_range(-60, 60))
			_positions[nid] = center + offset
		placed[nid] = true


func _layout_step() -> void:
	var node_ids: Array = _positions.keys()
	var velocities: Dictionary = {}
	for nid: String in node_ids:
		velocities[nid] = Vector2.ZERO

	var rect: Vector2 = size if size.x > 0 else Vector2(600, 400)
	var center: Vector2 = rect / 2.0

	# Repulsion between all pairs (Coulomb)
	for i: int in node_ids.size():
		for j: int in range(i + 1, node_ids.size()):
			var a: String = node_ids[i]
			var b: String = node_ids[j]
			var delta: Vector2 = _positions[a] - _positions[b]
			var dist: float = maxf(delta.length(), 1.0)
			var force: Vector2 = delta.normalized() * (REPULSION / (dist * dist))
			velocities[a] += force
			velocities[b] -= force

	# Attraction along edges (Hooke, scaled by MI)
	# Daemon returns {from, to, mi, shared}
	for edge: Variant in _edges:
		if not edge is Dictionary:
			continue
		var src: String = str(edge.get("from", ""))
		var tgt: String = str(edge.get("to", ""))
		var mi: float = float(edge.get("mi", 1.0))
		if not _positions.has(src) or not _positions.has(tgt):
			continue
		var delta: Vector2 = _positions[tgt] - _positions[src]
		var force: Vector2 = delta * ATTRACTION * mi
		velocities[src] += force
		velocities[tgt] -= force

	# Gentle center gravity
	for nid: String in node_ids:
		var to_center: Vector2 = center - _positions[nid]
		velocities[nid] += to_center * CENTER_GRAVITY

	# Apply velocities with damping (NO boundary clamping — rescale later)
	for nid: String in node_ids:
		velocities[nid] *= DAMPING
		_positions[nid] += velocities[nid]


func _spread_overlapping() -> void:
	## Push apart nodes whose labels would overlap.
	## Uses actual text width per node to compute bounding rects.
	var node_ids: Array = _positions.keys()
	if node_ids.size() < 2:
		return

	# Pre-compute label half-widths and vertical extent for each node
	var half_w: Dictionary = {}  # nid -> float (half of label width + node radius)
	var half_h: float = NODE_RADIUS + 14.0 + float(LABEL_SIZE)  # node + gap + text height
	for nid: String in node_ids:
		var title: String = _title_map.get(nid, nid.left(8))
		if title.length() > MAX_LABEL:
			title = title.left(MAX_LABEL - 1) + "..."
		var text_w: float = 0.0
		if _font != null:
			text_w = _font.get_string_size(title, HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_SIZE).x
		half_w[nid] = maxf(text_w * 0.5, NODE_RADIUS) + 8.0  # 8px padding

	# Run passes to resolve overlaps using axis-aligned bounding box overlap
	for _pass: int in 10:
		var moved: bool = false
		for i: int in node_ids.size():
			for j: int in range(i + 1, node_ids.size()):
				var a: String = node_ids[i]
				var b: String = node_ids[j]
				var pa: Vector2 = _positions[a]
				var pb: Vector2 = _positions[b]
				# Required separation on each axis to avoid overlap
				var sep_x: float = half_w[a] + half_w[b]
				var sep_y: float = half_h
				var dx: float = absf(pa.x - pb.x)
				var dy: float = absf(pa.y - pb.y)
				# Check if bounding boxes overlap
				if dx < sep_x and dy < sep_y:
					var delta: Vector2 = pa - pb
					if delta.length() < 0.01:
						# Exactly overlapping — nudge horizontally
						_positions[a] += Vector2(sep_x * 0.5, 0)
						_positions[b] -= Vector2(sep_x * 0.5, 0)
					else:
						# Push apart along the axis with less overlap
						var overlap_x: float = sep_x - dx
						var overlap_y: float = sep_y - dy
						if overlap_x < overlap_y:
							var sign_x: float = 1.0 if pa.x >= pb.x else -1.0
							_positions[a].x += overlap_x * 0.5 * sign_x
							_positions[b].x -= overlap_x * 0.5 * sign_x
						else:
							var sign_y: float = 1.0 if pa.y >= pb.y else -1.0
							_positions[a].y += overlap_y * 0.5 * sign_y
							_positions[b].y -= overlap_y * 0.5 * sign_y
					moved = true
		if not moved:
			break


func _rescale_to_fit() -> void:
	## After layout, rescale all positions to fit within the viewport with padding.
	if _positions.is_empty():
		return

	var rect: Vector2 = size if size.x > 0 else Vector2(600, 400)
	var padding: float = 60.0

	# Find bounding box of all positions
	var min_pos := Vector2(INF, INF)
	var max_pos := Vector2(-INF, -INF)
	for pos: Vector2 in _positions.values():
		min_pos.x = minf(min_pos.x, pos.x)
		min_pos.y = minf(min_pos.y, pos.y)
		max_pos.x = maxf(max_pos.x, pos.x)
		max_pos.y = maxf(max_pos.y, pos.y)

	var extent: Vector2 = max_pos - min_pos
	if extent.x < 1.0:
		extent.x = 1.0
	if extent.y < 1.0:
		extent.y = 1.0

	var available: Vector2 = rect - Vector2(padding * 2, padding * 2)
	var scale_factor: float = minf(available.x / extent.x, available.y / extent.y)
	# Don't scale up excessively
	scale_factor = minf(scale_factor, 3.0)

	var center_of_nodes: Vector2 = (min_pos + max_pos) / 2.0
	var viewport_center: Vector2 = rect / 2.0

	for nid: String in _positions.keys():
		var offset: Vector2 = _positions[nid] - center_of_nodes
		_positions[nid] = viewport_center + offset * scale_factor

	# Also rescale hub positions (mind-map mode)
	for ci: int in _hub_positions.keys():
		var offset: Vector2 = _hub_positions[ci] - center_of_nodes
		_hub_positions[ci] = viewport_center + offset * scale_factor


func _draw() -> void:
	# Background
	draw_rect(Rect2(Vector2.ZERO, size), COLOR_BG)

	if _nodes.is_empty():
		var center: Vector2 = size / 2.0
		draw_string(_font, center - Vector2(60, 0), "No graph data", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.5, 0.5))
		return

	if _positions.is_empty():
		var center: Vector2 = size / 2.0
		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))
		return

	# Mind-map mode: draw hub → node spokes (no fake center)
	if graph_layout == "mindmap" and not _hub_positions.is_empty():
		for ci: int in _hub_positions.keys():
			var hub_screen: Vector2 = _world_to_screen(_hub_positions[ci])
			var color: Color = CLUSTER_COLORS[ci % CLUSTER_COLORS.size()]
			# Hub → Node spokes (real connections: this node belongs to this cluster)
			for nid: String in _cluster_map.keys():
				if _cluster_map[nid] == ci and _positions.has(nid):
					var node_screen: Vector2 = _world_to_screen(_positions[nid])
					draw_line(hub_screen, node_screen, Color(color, 0.25), 1.5 * _zoom)
		# Draw hub circles with labels
		for ci2: int in _hub_positions.keys():
			var hub_screen2: Vector2 = _world_to_screen(_hub_positions[ci2])
			var hub_color: Color = CLUSTER_COLORS[ci2 % CLUSTER_COLORS.size()]
			draw_circle(hub_screen2, 6.0 * _zoom, hub_color.lightened(0.3))
			var label_text: String = ""
			if _cluster_labels.has(ci2):
				label_text = str(_cluster_labels[ci2].get("label", ""))
			if label_text != "":
				var font_size: int = int(13.0 * _zoom)
				if font_size >= 8:
					var text_w: float = _font.get_string_size(label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x
					var lbl_pos: Vector2 = hub_screen2 + Vector2(-text_w * 0.5, -12.0 * _zoom)
					draw_string(_font, lbl_pos, label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, hub_color.lightened(0.5))
	else:
		# Standard mode: draw MI edges with adaptive alpha
		var edge_count: int = _edges.size()
		var mi_floor: float = 0.0
		var alpha_scale: float = 1.0
		if edge_count > 100:
			mi_floor = 0.3
			alpha_scale = 0.5
		elif edge_count > 50:
			mi_floor = 0.1
			alpha_scale = 0.7

		for edge: Variant in _edges:
			if not edge is Dictionary:
				continue
			var src: String = str(edge.get("from", ""))
			var tgt: String = str(edge.get("to", ""))
			var mi: float = float(edge.get("mi", 1.0))
			if mi < mi_floor:
				continue
			if not _positions.has(src) or not _positions.has(tgt):
				continue
			var p1: Vector2 = _world_to_screen(_positions[src])
			var p2: Vector2 = _world_to_screen(_positions[tgt])
			var alpha: float = clampf(mi / 3.0 * alpha_scale, 0.05, 0.6)
			var width: float = clampf(mi, 0.5, 3.0) * _zoom
			draw_line(p1, p2, Color(COLOR_EDGE, alpha), width)

	# Draw nodes
	for node: Variant in _nodes:
		if not node is Dictionary:
			continue
		var nid: String = str(node.get("id", ""))
		if not _positions.has(nid):
			continue
		var pos: Vector2 = _world_to_screen(_positions[nid])
		var is_hovered: bool = nid == _hovered_node
		var radius: float = (HOVER_RADIUS if is_hovered else NODE_RADIUS) * _zoom

		# Color by cluster
		var color: Color = COLOR_UNCLUSTERED
		if _cluster_map.has(nid):
			var ci: int = _cluster_map[nid]
			color = CLUSTER_COLORS[ci % CLUSTER_COLORS.size()]
		if is_hovered:
			color = color.lightened(0.3)

		draw_circle(pos, radius, color)

		# Label below node — centered using font metrics
		var title: String = _title_map.get(nid, nid.left(8))
		if title.length() > MAX_LABEL:
			title = title.left(MAX_LABEL - 1) + "..."
		var font_size: int = int(LABEL_SIZE * _zoom)
		if font_size >= 6:
			var text_w: float = _font.get_string_size(title, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x
			var label_pos: Vector2 = pos + Vector2(-text_w * 0.5, radius + 14.0 * _zoom)
			draw_string(_font, label_pos, title, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color(0.85, 0.85, 0.85))

	# Draw cluster labels above cluster bounding box (not centroid — avoids node collision)
	if not _cluster_labels.is_empty():
		# Collect node positions per cluster and find top-center of bounding box
		var cluster_bounds: Dictionary = {}  # cluster_index -> {min_x, max_x, min_y}
		for nid: String in _cluster_map.keys():
			var ci: int = _cluster_map[nid]
			if not _positions.has(nid):
				continue
			var p: Vector2 = _positions[nid]
			if not cluster_bounds.has(ci):
				cluster_bounds[ci] = {"min_x": p.x, "max_x": p.x, "min_y": p.y}
			else:
				var b: Dictionary = cluster_bounds[ci]
				b["min_x"] = minf(b["min_x"], p.x)
				b["max_x"] = maxf(b["max_x"], p.x)
				b["min_y"] = minf(b["min_y"], p.y)

		for ci: int in cluster_bounds.keys():
			if not _cluster_labels.has(ci):
				continue
			var meta: Dictionary = _cluster_labels[ci]
			var label_text: String = str(meta.get("label", ""))
			if label_text == "":
				continue
			var b: Dictionary = cluster_bounds[ci]
			# Position label at top-center of cluster, above all nodes
			var top_center := Vector2((b["min_x"] + b["max_x"]) / 2.0, b["min_y"])
			var screen_pos: Vector2 = _world_to_screen(top_center)
			var color: Color = CLUSTER_COLORS[ci % CLUSTER_COLORS.size()]
			var font_size: int = int(14.0 * _zoom)
			if font_size >= 8:
				var text_w: float = _font.get_string_size(label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x
				var lbl_pos: Vector2 = screen_pos - Vector2(text_w * 0.5, 24.0 * _zoom)
				draw_string(_font, lbl_pos, label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color.lightened(0.4))

	# Tooltip for hovered node
	if _hovered_node != "" and _positions.has(_hovered_node):
		var pos: Vector2 = _world_to_screen(_positions[_hovered_node])
		var full_title: String = _title_map.get(_hovered_node, _hovered_node)
		var tip_pos: Vector2 = pos + Vector2(16 * _zoom, -8 * _zoom)
		draw_string(_font, tip_pos, full_title, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color.WHITE)


func _gui_input(event: InputEvent) -> void:
	if event is InputEventMouseMotion:
		_update_hover(event.position)
		if _dragging:
			_pan = _pan_start + (event.position - _drag_start)
			queue_redraw()

	elif event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
			var hit: String = _hit_test(event.position)
			if hit != "":
				room_clicked.emit(hit)

		elif event.button_index == MOUSE_BUTTON_MIDDLE:
			if event.pressed:
				_dragging = true
				_drag_start = event.position
				_pan_start = _pan
			else:
				_dragging = false

		elif event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
			_zoom = clampf(_zoom * 1.15, MIN_ZOOM, MAX_ZOOM)
			queue_redraw()

		elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
			_zoom = clampf(_zoom / 1.15, MIN_ZOOM, MAX_ZOOM)
			queue_redraw()


func _update_hover(mouse_pos: Vector2) -> void:
	var old_hover: String = _hovered_node
	_hovered_node = ""
	var best_dist: float = (HOVER_RADIUS + 4.0) * _zoom

	for node: Variant in _nodes:
		if not node is Dictionary:
			continue
		var nid: String = str(node.get("id", ""))
		if not _positions.has(nid):
			continue
		var screen_pos: Vector2 = _world_to_screen(_positions[nid])
		var dist: float = mouse_pos.distance_to(screen_pos)
		if dist < best_dist:
			best_dist = dist
			_hovered_node = nid

	if _hovered_node != old_hover:
		mouse_default_cursor_shape = CURSOR_POINTING_HAND if _hovered_node != "" else CURSOR_ARROW
		queue_redraw()


func _hit_test(mouse_pos: Vector2) -> String:
	var best_dist: float = (NODE_RADIUS + 6.0) * _zoom
	var hit: String = ""
	for node: Variant in _nodes:
		if not node is Dictionary:
			continue
		var nid: String = str(node.get("id", ""))
		if not _positions.has(nid):
			continue
		var screen_pos: Vector2 = _world_to_screen(_positions[nid])
		var dist: float = mouse_pos.distance_to(screen_pos)
		if dist < best_dist:
			best_dist = dist
			hit = nid
	return hit


func _world_to_screen(world_pos: Vector2) -> Vector2:
	return (world_pos * _zoom) + _pan