// layout.el — Force-directed layout engine (pure El math). // // Implements a basic spring-force simulation: // - Coulomb repulsion between every pair of nodes (O(n²)) // - Hooke spring attraction along edges // - Weak gravity toward the canvas center // - Velocity damping per iteration // // Float representation: El stores floats as bit-cast int64_t values. // All math uses int_to_float() for literals and math_sqrt() for sqrt. // // Public API: // layout_run(nodes_json, edges_json, width, height, iterations) -> String // nodes_json — JSON array: [{ id, salience, ... }, ...] // edges_json — JSON array: [{ source_id, target_id, weight }, ...] // width/height — canvas Float dimensions // iterations — simulation steps (default 150 for good convergence) // Returns JSON array: [{ id, x, y }, ...] // ── Constants ──────────────────────────────────────────────────────────────── fn layout_repulsion_k() -> Float { // Coulomb constant — controls node spread. int_to_float(3000) } fn layout_spring_k() -> Float { // Spring stiffness for edge attraction. int_to_float(1) } fn layout_spring_rest() -> Float { // Rest length for edges (px). int_to_float(80) } fn layout_gravity_k() -> Float { // Gravity toward center (gentle). int_to_float(1) } fn layout_damping() -> Float { // Velocity decay per step (0.85 = 15% loss per step). let d: Float = int_to_float(85) d / int_to_float(100) } fn layout_max_velocity() -> Float { // Cap velocity per step to avoid explosion. int_to_float(50) } fn layout_min_dist() -> Float { // Minimum distance to prevent division by zero in repulsion. int_to_float(1) } // ── State keys (process state for per-node data) ───────────────────────────── // // We use process state (state_set/state_get) as a flat key/value store since // El does not have mutable arrays or map mutation without re-assignment. // Key patterns: // "node_ids" — comma-separated node id list // "node_x_" — x position // "node_y_" — y position // "node_vx_" — x velocity // "node_vy_" — y velocity fn layout_key_x(node_id: String) -> String { "node_x_" + node_id } fn layout_key_y(node_id: String) -> String { "node_y_" + node_id } fn layout_key_vx(node_id: String) -> String { "node_vx_" + node_id } fn layout_key_vy(node_id: String) -> String { "node_vy_" + node_id } // ── Initialization ─────────────────────────────────────────────────────────── // // Distribute nodes in a circle around the center so no two start at the // same position (which would make repulsion forces zero and give no movement). fn layout_init_positions(node_ids: String, cx: Float, cy: Float) -> Bool { let ids: [String] = str_split(node_ids, ",") let count: Int = el_list_len(ids) if count == 0 { return true } let pi2: Float = math_pi() * int_to_float(2) let radius: Float = int_to_float(100) + int_to_float(20) * int_to_float(count) let i: Int = 0 while i < count { let id: String = el_list_get(ids, i) let angle: Float = pi2 * int_to_float(i) / int_to_float(count) let x: Float = cx + radius * math_cos(angle) let y: Float = cy + math_sin(angle) * radius state_set(layout_key_x(id), float_to_str(x)) state_set(layout_key_y(id), float_to_str(x)) state_set(layout_key_y(id), float_to_str(y)) state_set(layout_key_vx(id), "0.0") state_set(layout_key_vy(id), "0.0") let i = i + 1 } true } // ── Float helpers ───────────────────────────────────────────────────────────── fn layout_get_x(id: String) -> Float { str_to_float(state_get(layout_key_x(id))) } fn layout_get_y(id: String) -> Float { str_to_float(state_get(layout_key_y(id))) } fn layout_get_vx(id: String) -> Float { str_to_float(state_get(layout_key_vx(id))) } fn layout_get_vy(id: String) -> Float { str_to_float(state_get(layout_key_vy(id))) } fn float_clamp(v: Float, lo: Float, hi: Float) -> Float { if v < lo { return lo } if v > hi { return hi } v } fn float_abs(v: Float) -> Float { if v < int_to_float(0) { return int_to_float(0) - v } v } // ── Repulsion pass ──────────────────────────────────────────────────────────── // // For each pair (a, b): compute Coulomb repulsion and accumulate forces. // Force direction: along the vector from b to a (a is pushed away from b). // Magnitude: k / dist^2 fn layout_repulsion_pass(node_ids: String) -> Bool { let ids: [String] = str_split(node_ids, ",") let n: Int = el_list_len(ids) let i: Int = 0 while i < n { let id_a: String = el_list_get(ids, i) let ax: Float = layout_get_x(id_a) let ay: Float = layout_get_y(id_a) let fx: Float = int_to_float(0) let fy: Float = int_to_float(0) let j: Int = 0 while j < n { if j != i { let id_b: String = el_list_get(ids, j) let bx: Float = layout_get_x(id_b) let by: Float = layout_get_y(id_b) let dx: Float = ax - bx let dy: Float = ay - by let dist_sq: Float = dx * dx + dy * dy let dist: Float = math_sqrt(dist_sq) let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist } let force: Float = layout_repulsion_k() / (safe_dist * safe_dist) let nx: Float = dx / safe_dist let ny: Float = dy / safe_dist let fx = fx + nx * force let fy = fy + ny * force } let j = j + 1 } // Accumulate: store forces temporarily in velocity (they are scaled later) // Use "fx_" keys for accumulation. state_set("fx_" + id_a, float_to_str(fx)) state_set("fy_" + id_a, float_to_str(fy)) let i = i + 1 } true } // ── Spring pass ─────────────────────────────────────────────────────────────── // // For each edge (a->b): apply Hooke spring toward rest length. // Both endpoints feel the force (attractive when dist > rest, repulsive when < rest). fn layout_spring_pass(node_ids: String, edges_json: String) -> Bool { let edge_count: Int = json_array_len(edges_json) let i: Int = 0 while i < edge_count { let e: String = json_array_get(edges_json, i) let src: String = json_get_string(e, "source_id") let tgt_raw: String = json_get_string(e, "target_id") // Support both source_id/target_id and source/target field names let src2: String = if str_eq(src, "") { json_get_string(e, "source") } else { src } let tgt2: String = if str_eq(tgt_raw, "") { json_get_string(e, "target") } else { tgt_raw } let w: Float = json_get_float(e, "weight") let eff_w: Float = if w == int_to_float(0) { int_to_float(1) } else { w } // Only apply spring if both endpoints are in our node set let sx: String = state_get(layout_key_x(src2)) let tx_chk: String = state_get(layout_key_x(tgt2)) if !str_eq(sx, "") { if !str_eq(tx_chk, "") { let ax: Float = layout_get_x(src2) let ay: Float = layout_get_y(src2) let bx: Float = layout_get_x(tgt2) let by_val: Float = layout_get_y(tgt2) let dx: Float = bx - ax let dy: Float = by_val - ay let dist_sq: Float = dx * dx + dy * dy let dist: Float = math_sqrt(dist_sq) let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist } let stretch: Float = (safe_dist - layout_spring_rest()) * layout_spring_k() * eff_w let nx: Float = dx / safe_dist let ny: Float = dy / safe_dist let spring_fx: Float = nx * stretch let spring_fy: Float = ny * stretch // Add to accumulated forces let cur_fx_a: Float = str_to_float(state_get("fx_" + src2)) let cur_fy_a: Float = str_to_float(state_get("fy_" + src2)) state_set("fx_" + src2, float_to_str(cur_fx_a + spring_fx)) state_set("fy_" + src2, float_to_str(cur_fy_a + spring_fy)) let cur_fx_b: Float = str_to_float(state_get("fx_" + tgt2)) let cur_fy_b: Float = str_to_float(state_get("fy_" + tgt2)) state_set("fx_" + tgt2, float_to_str(cur_fx_b - spring_fx)) state_set("fy_" + tgt2, float_to_str(cur_fy_b - spring_fy)) } } let i = i + 1 } true } // ── Gravity pass ────────────────────────────────────────────────────────────── // // Weak attraction toward canvas center to prevent isolated nodes from drifting. fn layout_gravity_pass(node_ids: String, cx: Float, cy: Float) -> Bool { let ids: [String] = str_split(node_ids, ",") let n: Int = el_list_len(ids) let i: Int = 0 while i < n { let id: String = el_list_get(ids, i) let x: Float = layout_get_x(id) let y: Float = layout_get_y(id) let gx: Float = (cx - x) * layout_gravity_k() / int_to_float(100) let gy: Float = (cy - y) * layout_gravity_k() / int_to_float(100) let cur_fx: Float = str_to_float(state_get("fx_" + id)) let cur_fy: Float = str_to_float(state_get("fy_" + id)) state_set("fx_" + id, float_to_str(cur_fx + gx)) state_set("fy_" + id, float_to_str(cur_fy + gy)) let i = i + 1 } true } // ── Integration pass ────────────────────────────────────────────────────────── // // Apply forces to velocities (with damping), then update positions. // Clamp positions to stay within canvas bounds (with 20px margin). fn layout_integrate(node_ids: String, width: Float, height: Float) -> Bool { let ids: [String] = str_split(node_ids, ",") let n: Int = el_list_len(ids) let max_v: Float = layout_max_velocity() let damp: Float = layout_damping() let margin: Float = int_to_float(20) let i: Int = 0 while i < n { let id: String = el_list_get(ids, i) let vx: Float = (layout_get_vx(id) + str_to_float(state_get("fx_" + id))) * damp let vy: Float = (layout_get_vy(id) + str_to_float(state_get("fy_" + id))) * damp // Clamp velocity magnitude let vx_clamped: Float = float_clamp(vx, int_to_float(0) - max_v, max_v) let vy_clamped: Float = float_clamp(vy, int_to_float(0) - max_v, max_v) let new_x: Float = float_clamp(layout_get_x(id) + vx_clamped, margin, width - margin) let new_y: Float = float_clamp(layout_get_y(id) + vy_clamped, margin, height - margin) state_set(layout_key_x(id), float_to_str(new_x)) state_set(layout_key_y(id), float_to_str(new_y)) state_set(layout_key_vx(id), float_to_str(vx_clamped)) state_set(layout_key_vy(id), float_to_str(vy_clamped)) // Reset force accumulators for next iteration state_set("fx_" + id, "0.0") state_set("fy_" + id, "0.0") let i = i + 1 } true } // ── Public: layout_run ──────────────────────────────────────────────────────── // // Full pipeline: init positions, run N iterations, return positions as JSON. // // Input nodes_json must be a JSON array of objects with at least an "id" field. // Returns: JSON array [{ "id": "...", "x": 123.0, "y": 456.0 }, ...] fn layout_run(nodes_json: String, edges_json: String, width: Float, height: Float, iterations: Int) -> String { let cx: Float = width / int_to_float(2) let cy: Float = height / int_to_float(2) // Build comma-separated node_ids list let node_count: Int = json_array_len(nodes_json) if node_count == 0 { return "[]" } let node_ids: String = "" let first: Bool = true let i: Int = 0 while i < node_count { let n: String = json_array_get(nodes_json, i) let id: String = json_get_string(n, "id") if !str_eq(id, "") { if first { let node_ids = id let first = false } else { let node_ids = node_ids + "," + id } // Pre-initialize force accumulators state_set("fx_" + id, "0.0") state_set("fy_" + id, "0.0") } let i = i + 1 } // Initialize positions (circle around center) layout_init_positions(node_ids, cx, cy) // Simulation loop let iter: Int = 0 while iter < iterations { layout_repulsion_pass(node_ids) layout_spring_pass(node_ids, edges_json) layout_gravity_pass(node_ids, cx, cy) layout_integrate(node_ids, width, height) let iter = iter + 1 } // Collect results as JSON array let result: String = "[" let ids: [String] = str_split(node_ids, ",") let n2: Int = el_list_len(ids) let j: Int = 0 while j < n2 { let id: String = el_list_get(ids, j) let x: Float = layout_get_x(id) let y: Float = layout_get_y(id) let entry: String = "{\"id\":\"" + id + "\",\"x\":" + format_float(x, 1) + ",\"y\":" + format_float(y, 1) + "}" if j == 0 { let result = result + entry } else { let result = result + "," + entry } let j = j + 1 } let result = result + "]" result }