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