feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user