Files
el/ui/vessels/el-graph/src/layout.el

353 lines
14 KiB
EmacsLisp

// 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_<id>" x position
// "node_y_<id>" y position
// "node_vx_<id>" x velocity
// "node_vy_<id>" 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_<id>" 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
}