extends Node2D const GRAPH_FILE_PATH = "res://network_graph3.json" var graph_node_scene: PackedScene = preload("res://GraphNode.tscn") #@onready var status_label: Label = $ColorRect/Label @onready var status_label: RichTextLabel = $ColorRect/Label var _stable_frames: int = 0 # ========================================== # PHYSICS TUNING & MOBILE OPTIMIZATIONS # ========================================== const REPULSION_STRENGTH: float = 45000.0 const SPRING_STRENGTH: float = 2.5 const REST_LENGTH: float = 330.0 const DAMPING: float = 0.95 const MAX_SPEED: float = 500.0 # Mobile safety cap #const PHYSICS_SLEEP_THRESHOLD: float = 1.10 # Stops sim when stable (saves mobile CPU/battery) const PHYSICS_SLEEP_THRESHOLD: float = 250.0 # Avg force per node allowed before sleeping const STABLE_FRAME_REQUIREMENT: int = 60 # Must stay stable for ~1.5s (at 60fps) to sleep # State & Caching var spawned_nodes: Dictionary = {} var node_ids: Array[String] = [] var relationships: Array = [] var lines: Array[Line2D] = [] var node_colors: Dictionary = {} var viewport_size: Vector2i = Vector2i.ZERO var physics_active: bool = true # FPS & Metrics Tracking var _last_metrics_update: float = 0.0 const METRICS_UPDATE_INTERVAL: float = 0.2 # Update UI 5x/sec to prevent GC spikes var _total_node_count: int = 0 var _total_edge_count: int = 0 # ✅ NEW: Store benchmark results for the dashboard var _benchmark_read_ms: float = 0.0 var _benchmark_parse_ms: float = 0.0 # ========================================== # INITIALIZATION # ========================================== func _ready() -> void: viewport_size = get_viewport_rect().size get_window().size_changed.connect(_on_window_resized) # FIX: Enable BBCode parsing so [color=...] tags render as actual colors status_label.bbcode_enabled = true var json_data: Dictionary = load_json_ld(GRAPH_FILE_PATH) if json_data.is_empty(): push_warning("JSON-LD failed to load or is empty.") return var graph_array = json_data.get("@graph", []) var nodes_map: Dictionary = {} for item in graph_array: if item is Dictionary and item.has("@id"): nodes_map[item["@id"]] = item build_graph_network(nodes_map) _total_node_count = spawned_nodes.size() _total_edge_count = relationships.size() benchmark_json_ld() print_rich("[color=cyan]Graph Loaded: ", str(_total_node_count), " nodes, ", str(_total_edge_count), " edges.[/color]") # ========================================== # CORE FUNCTIONS # ========================================== func _process(delta: float) -> void: if physics_active: _run_physics_step(min(delta, 0.03)) _update_lines() _update_metrics(delta, Time.get_ticks_msec()) # ========================================== # INPUT HANDLING (Mouse + Touch) # ========================================== func _unhandled_input(event: InputEvent) -> void: var screen_pos = Vector2.ZERO var is_click_like = false if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: screen_pos = event.position is_click_like = true elif event is InputEventScreenTouch: screen_pos = event.position is_click_like = true if not is_click_like: return if event.is_pressed(): _attempt_grab_node(screen_pos, true) else: _attempt_release_drag() func _on_window_resized() -> void: viewport_size = get_viewport_rect().size # ========================================== # PHYSICS & UPDATE LOOPS # ========================================== func _run_physics_step(dt: float) -> void: var total_force_magnitude: float = 0.0 var node_count = node_ids.size() for i in range(node_count): var id_a = node_ids[i] var node_a = spawned_nodes[id_a] if node_a.is_dragging: continue var pos_a = node_a.position var vel_a = node_a.velocity for j in range(i + 1, node_count): var id_b = node_ids[j] var node_b = spawned_nodes[id_b] if node_b.is_dragging: continue var dir = pos_a - node_b.position var dist_sq = dir.dot(dir) if dist_sq < 1.0 or dist_sq > REPULSION_STRENGTH * 2.0: continue var dist = sqrt(dist_sq) var force = (REPULSION_STRENGTH / dist_sq) * dt var f_vec = dir.normalized() * force vel_a += f_vec node_b.velocity -= f_vec if not node_b.is_dragging else Vector2.ZERO total_force_magnitude += absf(force) for rel in relationships: var node_a = rel[0] var node_b = rel[1] var dir = node_b.position - node_a.position var dist = dir.length() if dist < 0.1: continue var displacement = dist - REST_LENGTH var force_vec = dir.normalized() * (SPRING_STRENGTH * displacement) * dt if not node_a.is_dragging: node_a.velocity += force_vec if not node_b.is_dragging: node_b.velocity -= force_vec total_force_magnitude += absf(SPRING_STRENGTH * displacement) for id in node_ids: var node = spawned_nodes[id] if node.is_dragging: continue node.position += node.velocity * dt node.velocity *= DAMPING if node.velocity.length() > MAX_SPEED: node.velocity = node.velocity.normalized() * MAX_SPEED var gp = node.global_position gp.x = clampf(gp.x, 50.0, float(viewport_size.x - 200)) gp.y = clampf(gp.y, 50.0, float(viewport_size.y - 100)) node.global_position = gp # ========================================== # STABILIZATION & AUTO-SLEEP LOGIC # ========================================== var avg_force_per_node = total_force_magnitude / max(node_count, 1.0) if avg_force_per_node < PHYSICS_SLEEP_THRESHOLD: _stable_frames += 1 else: _stable_frames = 0 if _stable_frames >= STABLE_FRAME_REQUIREMENT: physics_active = false func _update_lines() -> void: for i in range(relationships.size()): var line = lines[i] var rel = relationships[i] var offset = Vector2(75.0, 25.0) line.points = [ Vector2(rel[0].position + offset), Vector2(rel[1].position + offset) ] # ✅ UPDATED: Injects benchmark data into the persistent dashboard func _update_metrics(_delta: float, current_time_ms: int) -> void: if current_time_ms - _last_metrics_update < METRICS_UPDATE_INTERVAL * 1000.0: return var fps = Engine.get_frames_per_second() var frame_time = 1000.0 / fps if fps > 0 else 0.0 var phys_color = "#7ee787" if physics_active else "#e74c3c" var phys_status = "ACTIVE" if physics_active else "SLEEPING" status_label.text = ("[color=#7ee787]▮ MOBILE DASHBOARD[/color]\n" + "FPS: [color=cyan]%d[/color] | Frame: [color=orange]%.2f ms[/color]\n" + "Nodes: %d | Edges: %d\n" + "Physics: [color=%s]%s[/color]\n" + "[color=#ffdd57]📊 BENCHMARK:[/color] Read: %.1f ms | Parse: %.1f ms") \ % [fps, frame_time, _total_node_count, _total_edge_count, phys_color, phys_status, _benchmark_read_ms, _benchmark_parse_ms] _last_metrics_update = current_time_ms # ========================================== # DRAG SYSTEM # ========================================== func _attempt_grab_node(screen_pos: Vector2, press: bool) -> void: if not press: return var best_dist: float = 50.0 var grabbed_id: String = "" for id in node_ids: var node = spawned_nodes[id] var dist = (node.position - screen_pos).length() if dist < best_dist: best_dist = dist grabbed_id = id if not grabbed_id.is_empty(): spawned_nodes[grabbed_id].is_dragging = true #func _attempt_release_drag() -> void: #for node in spawned_nodes.values(): #if node.is_dragging: #node.is_dragging = false #physics_active = true func _attempt_release_drag() -> void: for node in spawned_nodes.values(): if node.is_dragging: node.is_dragging = false physics_active = true _stable_frames = 0 # ✅ Reset stabilization counter on interaction # ========================================== # BUILDERS & UTILITIES # ========================================== func build_graph_network(nodes_map: Dictionary) -> void: var keys = nodes_map.keys() var total_nodes = keys.size() var center = get_viewport_rect().size * 0.5 for i in range(total_nodes): var node_id = keys[i] var node_data = nodes_map[node_id] var new_visual_node = graph_node_scene.instantiate() if not new_visual_node.has_method("setup"): new_visual_node.set_script(preload("res://GraphNode.gd")) add_child(new_visual_node) var angle = (TAU / total_nodes) * i var radius = 400.0 new_visual_node.position = center + Vector2.from_angle(angle) * radius new_visual_node.position += Vector2(randf_range(-50, 50), randf_range(-50, 50)) new_visual_node.setup(node_id, node_data.get("name", "Unknown")) spawned_nodes[node_id] = new_visual_node node_ids.append(node_id) node_colors[node_id] = _get_unique_color(node_id) for node_id in keys: var node_data = nodes_map[node_id] if not node_data.has("knows"): continue var raw_connections = node_data["knows"] var connection_list: Array = raw_connections if raw_connections is Array else [raw_connections] for conn in connection_list: if conn is Dictionary and conn.has("@id") and spawned_nodes.has(conn["@id"]): relationships.append([spawned_nodes[node_id], spawned_nodes[conn["@id"]]]) var line = Line2D.new() line.width = 5.0 line.z_index = -1 var grad = Gradient.new() grad.set_color(0, node_colors[node_id]) grad.set_color(1, node_colors[conn["@id"]]) line.gradient = grad add_child(line) lines.append(line) func _get_unique_color(id_string: String) -> Color: var h = hash(id_string) return Color8( max(abs(h) % 256, 128), max((abs(h) >> 8) % 256, 128), max((abs(h) >> 16) % 256, 128) ) func load_json_ld(path: String) -> Dictionary: if not FileAccess.file_exists(path): push_error("JSON-LD file not found at: " + path) return {} var file = FileAccess.open(path, FileAccess.READ) var json_string = file.get_as_text() file.close() var parsed_data = JSON.parse_string(json_string) return parsed_data if parsed_data is Dictionary else {} # ✅ UPDATED: Saves timing data to class variables instead of just printing func benchmark_json_ld() -> void: if not FileAccess.file_exists(GRAPH_FILE_PATH): return var start_time = Time.get_ticks_msec() var file = FileAccess.open(GRAPH_FILE_PATH, FileAccess.READ) var json_string = file.get_as_text() file.close() _benchmark_read_ms = float(Time.get_ticks_msec() - start_time) start_time = Time.get_ticks_msec() var _parsed = JSON.parse_string(json_string) _benchmark_parse_ms = float(Time.get_ticks_msec() - start_time) #print_rich("[color=yellow]📊 BENCHMARK: Read: %.1f ms | Parse: %.1f ms[/color]" % [_benchmark_read_ms, _benchmark_parse_ms]) print_rich("[color=yellow]📊 BENCHMARK: Read: {read} ms | Parse: {parse} [/color]".format({ "read": "%0.1f" % _benchmark_read_ms, "parse": "%0.1f" % _benchmark_parse_ms }))