353 lines
14 KiB
EmacsLisp
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
|
|
}
|