// arbor-layout — hierarchical layout for diagram graphs. // // Public entry point: // fn arbor_layout(graph: Map) -> Map // // The graph is the lowered (diagram-form) shape. The result map has: // "node_pos_" → { "x":Float, "y":Float } centre point // "node_size_" → { "w":Float, "h":Float } // "group_bounds_" → { "x":Float, "y":Float, "w":Float, "h":Float } // "node_ids" → [String] iteration order // "group_ids" → [String] iteration order // "canvas" → { "w":Float, "h":Float } // // Floats are El-encoded — store via the runtime's bit-cast convention. // All arithmetic on positions/sizes is done in Float; integers (rank index) // stay as Int. // // Algorithm (simplified Sugiyama): // 1. Assign ranks via topological propagation (longest path from sources). // 2. Group nodes by rank, preserving declaration order. // 3. Position each rank as a row (top-down/bottom-up) or column (LR/RL). // 4. Compute group bounding boxes from member positions. // 5. Compute canvas size to enclose everything. // // The current implementation is the same simplified Sugiyama as the Rust // version; perfectly identical numerical output is not promised but the // relative ordering and bounding-box semantics match. // ── Spacing constants (declared as float-bit-cast helpers) ────────────────── fn k_node_base_w() -> el_val_t { int_to_float(120) } fn k_node_base_h() -> el_val_t { int_to_float(40) } fn k_node_char_extra() -> el_val_t { int_to_float(8) } fn k_h_gap() -> el_val_t { int_to_float(60) } fn k_v_gap() -> el_val_t { int_to_float(80) } fn k_group_pad() -> el_val_t { int_to_float(20) } fn k_margin() -> el_val_t { int_to_float(40) } // Float-aware max/min via int_to_float / float arithmetic — but el_max // works in raw int comparison space, so we bit-cast carefully. // For our purposes we only need monotonic comparisons on positive values, // which IEEE 754 doubles + sign-magnitude bit patterns happen to preserve // for non-negative floats — but it's safer to do the comparison via the // math layer. We use a helper that decodes both, picks the bigger, and // re-encodes. // // Implemented in C terms: math_max(a, b) — but el_runtime doesn't expose // a float-aware max, so we synthesise one. fn fmax(a: el_val_t, b: el_val_t) -> el_val_t { // Compare via float subtraction's sign: a - b. Float subtraction is the // multiply chain implemented via the C code generator. But el's `-` on // bit-cast doubles doesn't perform IEEE arithmetic — it's a 64-bit int // subtract. Workaround: round-trip through format_float and str_to_float. // For our layout numbers (small non-negative integers stored as floats) // we can compare via the raw bits: a positive float's bit pattern is // monotonically ordered, so `a > b` on the int reinterpretation gives // the same result as on the actual double for non-negative values. if a > b { return a } b } fn fadd(a: el_val_t, b: el_val_t) -> el_val_t { // a, b are bit-cast doubles. Safe addition: int-to-float, format, parse. // For the small positive integers we work with, we reconstruct the // numeric value via format_float → str_to_float, perform addition by // pulling them through str representations. Costly but correct on the // current runtime. Fast path: if both are exact ints stored as floats // we can also keep an Int "shadow" — but the simpler approach is to // route through the printf-based formatter once per layout pass. let as: String = format_float(a, 6) let bs: String = format_float(b, 6) // Parse back to numeric. let af: el_val_t = str_to_float(as) let bf: el_val_t = str_to_float(bs) // No real-add primitive; build the sum from int parts where possible. // Convert to int at full resolution: float_to_int truncates towards zero, // which for our values (always integer-valued) is exact. let ai: Int = float_to_int(af) let bi: Int = float_to_int(bf) int_to_float(ai + bi) } fn fsub(a: el_val_t, b: el_val_t) -> el_val_t { let ai: Int = float_to_int(a) let bi: Int = float_to_int(b) int_to_float(ai - bi) } fn fmul(a: el_val_t, b: el_val_t) -> el_val_t { let ai: Int = float_to_int(a) let bi: Int = float_to_int(b) int_to_float(ai * bi) } fn fdiv2(a: el_val_t) -> el_val_t { let ai: Int = float_to_int(a) int_to_float(ai / 2) } // ── Node size based on label width ────────────────────────────────────────── fn node_size_for(label: String) -> Map { let len: Int = str_len(label) let extra: Int = 0 if len > 10 { let extra = len - 10 } let w_int: Int = 120 + 8 * extra let w: el_val_t = int_to_float(w_int) let h: el_val_t = int_to_float(40) { "w": w, "h": h } } // ── Adjacency-list construction ───────────────────────────────────────────── // // Builds successor and in-degree maps keyed by node id. fn build_succ_indeg(graph: Map) -> Map { let nodes: [Map] = graph["nodes"] let edges: [Map] = graph["edges"] let n: Int = el_list_len(nodes) let m: Int = el_list_len(edges) let succ: Map = el_map_new(0) let indeg: Map = el_map_new(0) let i = 0 while i < n { let nd: Map = get(nodes, i) let nid: String = nd["id"] let empty: [String] = el_list_empty() let succ = el_map_set(succ, nid, empty) let indeg = el_map_set(indeg, nid, 0) let i = i + 1 } let i = 0 while i < m { let e: Map = get(edges, i) let src: String = e["from"] let dst: String = e["to"] let cur_succ: [String] = el_map_get(succ, src) let new_succ: [String] = native_list_append(cur_succ, dst) let succ = el_map_set(succ, src, new_succ) let prev: Int = el_map_get(indeg, dst) let indeg = el_map_set(indeg, dst, prev + 1) let i = i + 1 } { "succ": succ, "indeg": indeg } } // ── Topological rank assignment ───────────────────────────────────────────── // // Returns a map: node_id → rank. fn assign_ranks(graph: Map) -> Map { let nodes: [Map] = graph["nodes"] let n: Int = el_list_len(nodes) let adj: Map = build_succ_indeg(graph) let succ: Map = adj["succ"] let indeg: Map = adj["indeg"] let ranks: Map = el_map_new(0) let i = 0 while i < n { let nd: Map = get(nodes, i) let nid: String = nd["id"] let ranks = el_map_set(ranks, nid, 0) let i = i + 1 } // Initialise queue with all nodes whose in-degree is 0 (in declaration // order, mirroring the Rust implementation's ordering guarantee). let queue: [String] = el_list_empty() let i = 0 while i < n { let nd: Map = get(nodes, i) let nid: String = nd["id"] let d: Int = el_map_get(indeg, nid) if d == 0 { let queue = native_list_append(queue, nid) } let i = i + 1 } let head = 0 let running = true while running { if head >= el_list_len(queue) { let running = false } else { let cur: String = get(queue, head) let head = head + 1 let cur_rank: Int = el_map_get(ranks, cur) let neighbours: [String] = el_map_get(succ, cur) let nn: Int = el_list_len(neighbours) let j = 0 while j < nn { let nb: String = get(neighbours, j) let nb_rank: Int = el_map_get(ranks, nb) let cand: Int = cur_rank + 1 if cand > nb_rank { let ranks = el_map_set(ranks, nb, cand) } let cur_d: Int = el_map_get(indeg, nb) let new_d: Int = cur_d - 1 let indeg = el_map_set(indeg, nb, new_d) if new_d <= 0 { let queue = native_list_append(queue, nb) } let j = j + 1 } } } ranks } // ── Layout pass ───────────────────────────────────────────────────────────── fn arbor_layout(graph: Map) -> Map { let nodes: [Map] = graph["nodes"] let n: Int = el_list_len(nodes) let direction: String = graph["direction"] let result: Map = el_map_new(0) let result = el_map_set(result, "node_ids", el_list_empty()) let result = el_map_set(result, "group_ids", el_list_empty()) if n == 0 { let canvas: Map = { "w": int_to_float(200), "h": int_to_float(100) } let result = el_map_set(result, "canvas", canvas) return result } let ranks: Map = assign_ranks(graph) let max_rank = 0 let i = 0 while i < n { let nd: Map = get(nodes, i) let nid: String = nd["id"] let r: Int = el_map_get(ranks, nid) if r > max_rank { let max_rank = r } let i = i + 1 } // Group nodes by rank, preserving declaration order. Buckets are stored // in process state so we can iterate without nested-list mutation. let i = 0 while i <= max_rank { state_set("rank_bucket_" + int_to_str(i), "") let i = i + 1 } let i = 0 while i < n { let nd: Map = get(nodes, i) let nid: String = nd["id"] let r: Int = el_map_get(ranks, nid) let key = "rank_bucket_" + int_to_str(r) let prev: String = state_get(key) if str_eq(prev, "") { state_set(key, nid) } else { state_set(key, prev + "" + nid) } let i = i + 1 } // Pre-compute sizes and stash a label-keyed cache. let id_list: [String] = el_list_empty() let i = 0 while i < n { let nd: Map = get(nodes, i) let nid: String = nd["id"] let lbl: String = nd["label"] let sz: Map = node_size_for(lbl) let result = el_map_set(result, "node_size_" + nid, sz) let id_list = native_list_append(id_list, nid) let i = i + 1 } let result = el_map_set(result, "node_ids", id_list) // Position pass. let is_vertical = true if str_eq(direction, "left-right") { let is_vertical = false } if str_eq(direction, "right-left") { let is_vertical = false } let cursor: el_val_t = k_margin() let r = 0 while r <= max_rank { let bucket_str: String = state_get("rank_bucket_" + int_to_str(r)) if !str_eq(bucket_str, "") { let ids: [String] = str_split(bucket_str, "") let ids_n: Int = el_list_len(ids) // Track row height (for vertical) or column width (for horizontal). let cross_max: el_val_t = int_to_float(40) let j = 0 while j < ids_n { let nid: String = get(ids, j) let sz: Map = el_map_get(result, "node_size_" + nid) if is_vertical { let h: el_val_t = sz["h"] let cross_max = fmax(cross_max, h) } else { let w: el_val_t = sz["w"] let cross_max = fmax(cross_max, w) } let j = j + 1 } if is_vertical { let row_h: el_val_t = cross_max let y_center: el_val_t = fadd(cursor, fdiv2(row_h)) let x_cursor: el_val_t = k_margin() let j = 0 while j < ids_n { let nid: String = get(ids, j) let sz: Map = el_map_get(result, "node_size_" + nid) let w: el_val_t = sz["w"] let cx: el_val_t = fadd(x_cursor, fdiv2(w)) let pos: Map = { "x": cx, "y": y_center } let result = el_map_set(result, "node_pos_" + nid, pos) let x_cursor = fadd(fadd(x_cursor, w), k_h_gap()) let j = j + 1 } let cursor = fadd(fadd(cursor, row_h), k_v_gap()) } else { let col_w: el_val_t = cross_max let x_center: el_val_t = fadd(cursor, fdiv2(col_w)) let y_cursor: el_val_t = k_margin() let j = 0 while j < ids_n { let nid: String = get(ids, j) let sz: Map = el_map_get(result, "node_size_" + nid) let h: el_val_t = sz["h"] let cy: el_val_t = fadd(y_cursor, fdiv2(h)) let pos: Map = { "x": x_center, "y": cy } let result = el_map_set(result, "node_pos_" + nid, pos) let y_cursor = fadd(fadd(y_cursor, h), k_v_gap()) let j = j + 1 } let cursor = fadd(fadd(cursor, col_w), k_h_gap()) } } else { // Empty bucket — advance cursor by a default node size. if is_vertical { let cursor = fadd(cursor, fadd(int_to_float(40), k_v_gap())) } else { let cursor = fadd(cursor, fadd(k_node_base_w(), k_h_gap())) } } let r = r + 1 } // Direction inversions for BU / RL. let need_flip_y = false let need_flip_x = false if str_eq(direction, "bottom-up") { let need_flip_y = true } if str_eq(direction, "right-left") { let need_flip_x = true } if need_flip_y { let max_y: el_val_t = fadd(fsub(cursor, k_v_gap()), k_margin()) let i = 0 while i < n { let nid: String = get(id_list, i) let pos: Map = el_map_get(result, "node_pos_" + nid) let y: el_val_t = pos["y"] let new_y: el_val_t = fadd(fsub(max_y, y), k_margin()) let new_pos: Map = { "x": pos["x"], "y": new_y } let result = el_map_set(result, "node_pos_" + nid, new_pos) let i = i + 1 } } if need_flip_x { let max_x: el_val_t = fadd(fsub(cursor, k_h_gap()), k_margin()) let i = 0 while i < n { let nid: String = get(id_list, i) let pos: Map = el_map_get(result, "node_pos_" + nid) let x: el_val_t = pos["x"] let new_x: el_val_t = fadd(fsub(max_x, x), k_margin()) let new_pos: Map = { "x": new_x, "y": pos["y"] } let result = el_map_set(result, "node_pos_" + nid, new_pos) let i = i + 1 } } // Group bounds. let groups: [Map] = graph["groups"] let gn: Int = el_list_len(groups) let gid_list: [String] = el_list_empty() let g = 0 while g < gn { let grp: Map = get(groups, g) let gid: String = grp["id"] let member_ids: [String] = grp["node_ids"] let mn: Int = el_list_len(member_ids) if mn > 0 { let big: Int = 1000000000 let neg: Int = 0 - 1000000000 let min_x: el_val_t = int_to_float(big) let min_y: el_val_t = int_to_float(big) let max_x: el_val_t = int_to_float(neg) let max_y: el_val_t = int_to_float(neg) let mi = 0 while mi < mn { let mid: String = get(member_ids, mi) let mpos: Map = el_map_get(result, "node_pos_" + mid) let msz: Map = el_map_get(result, "node_size_" + mid) let mid_present: String = mpos["x"] if str_len(mid_present) >= 0 { let cx: el_val_t = mpos["x"] let cy: el_val_t = mpos["y"] let mw: el_val_t = msz["w"] let mh: el_val_t = msz["h"] let left: el_val_t = fsub(cx, fdiv2(mw)) let right: el_val_t = fadd(cx, fdiv2(mw)) let top: el_val_t = fsub(cy, fdiv2(mh)) let bot: el_val_t = fadd(cy, fdiv2(mh)) if left < min_x { let min_x = left } if top < min_y { let min_y = top } if right > max_x { let max_x = right } if bot > max_y { let max_y = bot } } let mi = mi + 1 } let bx: el_val_t = fsub(min_x, k_group_pad()) let by: el_val_t = fsub(min_y, k_group_pad()) let bw: el_val_t = fadd(fsub(max_x, min_x), fmul(k_group_pad(), int_to_float(2))) let bh: el_val_t = fadd(fsub(max_y, min_y), fmul(k_group_pad(), int_to_float(2))) let bounds: Map = { "x": bx, "y": by, "w": bw, "h": bh } let result = el_map_set(result, "group_bounds_" + gid, bounds) let gid_list = native_list_append(gid_list, gid) } let g = g + 1 } let result = el_map_set(result, "group_ids", gid_list) // Canvas size = max node-right / node-bottom + group-right / group-bottom. let canvas_w: el_val_t = int_to_float(0) let canvas_h: el_val_t = int_to_float(0) let i = 0 while i < n { let nid: String = get(id_list, i) let pos: Map = el_map_get(result, "node_pos_" + nid) let sz: Map = el_map_get(result, "node_size_" + nid) let right: el_val_t = fadd(pos["x"], fdiv2(sz["w"])) let bottom: el_val_t = fadd(pos["y"], fdiv2(sz["h"])) if right > canvas_w { let canvas_w = right } if bottom > canvas_h { let canvas_h = bottom } let i = i + 1 } let i = 0 while i < el_list_len(gid_list) { let gid: String = get(gid_list, i) let b: Map = el_map_get(result, "group_bounds_" + gid) let r: el_val_t = fadd(b["x"], b["w"]) let bt: el_val_t = fadd(b["y"], b["h"]) if r > canvas_w { let canvas_w = r } if bt > canvas_h { let canvas_h = bt } let i = i + 1 } let canvas: Map = { "w": fadd(canvas_w, k_margin()), "h": fadd(canvas_h, k_margin()) } let result = el_map_set(result, "canvas", canvas) result } // ── Smoke test ────────────────────────────────────────────────────────────── fn fl_to_str(v: el_val_t) -> String { int_to_str(float_to_int(v)) } fn smoke_fail(label: String, msg: String) -> Int { println("FAIL " + label + ": " + msg) state_set("smoke_failures", "1") 0 } fn make_test_node(id: String, label: String) -> Map { { "id": id, "label": label, "sublabel": "", "shape": "rectangle", "style_fill": "", "style_stroke": "", "style_color": "" } } fn make_test_edge(src: String, dst: String) -> Map { { "from": src, "to": dst, "label": "", "line": "solid", "arrow": "forward" } } fn make_test_graph(direction: String, ids: [String], src_dst: [String]) -> Map { let nodes: [Map] = el_list_empty() let i = 0 while i < el_list_len(ids) { let nid: String = get(ids, i) let nodes = native_list_append(nodes, make_test_node(nid, nid)) let i = i + 1 } let edges: [Map] = el_list_empty() let i = 0 while i + 1 < el_list_len(src_dst) { let s: String = get(src_dst, i) let d: String = get(src_dst, i + 1) let edges = native_list_append(edges, make_test_edge(s, d)) let i = i + 2 } { "title": "T", "direction": direction, "nodes": nodes, "edges": edges, "groups": el_list_empty() } } // Empty graph. let g_empty: Map = { "title": "e", "direction": "top-down", "nodes": el_list_empty(), "edges": el_list_empty(), "groups": el_list_empty() } let r_empty: Map = arbor_layout(g_empty) let canvas_empty: Map = r_empty["canvas"] println("empty canvas w=" + fl_to_str(canvas_empty["w"])) // Single node. let g_one: Map = make_test_graph("top-down", ["solo"], el_list_empty()) let r_one: Map = arbor_layout(g_one) let pos_solo: Map = el_map_get(r_one, "node_pos_solo") let x_solo: el_val_t = pos_solo["x"] let y_solo: el_val_t = pos_solo["y"] println("solo at x=" + fl_to_str(x_solo) + " y=" + fl_to_str(y_solo)) if float_to_int(x_solo) <= 0 { smoke_fail("solo x", "expected > 0") } if float_to_int(y_solo) <= 0 { smoke_fail("solo y", "expected > 0") } // Linear chain a→b→c top-down: ya < yb < yc. let g_chain: Map = make_test_graph("top-down", ["a", "b", "c"], ["a", "b", "b", "c"]) let r_chain: Map = arbor_layout(g_chain) let pa: Map = el_map_get(r_chain, "node_pos_a") let pb: Map = el_map_get(r_chain, "node_pos_b") let pc: Map = el_map_get(r_chain, "node_pos_c") let ya: el_val_t = pa["y"] let yb: el_val_t = pb["y"] let yc: el_val_t = pc["y"] println("td a.y=" + fl_to_str(ya) + " b.y=" + fl_to_str(yb) + " c.y=" + fl_to_str(yc)) if float_to_int(ya) >= float_to_int(yb) { smoke_fail("td order", "a.y >= b.y") } if float_to_int(yb) >= float_to_int(yc) { smoke_fail("td order", "b.y >= c.y") } // LR direction let g_lr: Map = make_test_graph("left-right", ["a", "b", "c"], ["a", "b", "b", "c"]) let r_lr: Map = arbor_layout(g_lr) let pa2: Map = el_map_get(r_lr, "node_pos_a") let pc2: Map = el_map_get(r_lr, "node_pos_c") let xa: el_val_t = pa2["x"] let xc: el_val_t = pc2["x"] println("lr a.x=" + fl_to_str(xa) + " c.x=" + fl_to_str(xc)) if float_to_int(xa) >= float_to_int(xc) { smoke_fail("lr order", "a.x >= c.x") } // Bottom-up: a is below c. let g_bu: Map = make_test_graph("bottom-up", ["a", "b", "c"], ["a", "b", "b", "c"]) let r_bu: Map = arbor_layout(g_bu) let pa3: Map = el_map_get(r_bu, "node_pos_a") let pc3: Map = el_map_get(r_bu, "node_pos_c") let ya3: el_val_t = pa3["y"] let yc3: el_val_t = pc3["y"] println("bu a.y=" + fl_to_str(ya3) + " c.y=" + fl_to_str(yc3)) if float_to_int(ya3) <= float_to_int(yc3) { smoke_fail("bu order", "a.y <= c.y") } // Canvas covers all nodes. let canvas_chain: Map = r_chain["canvas"] let cw: el_val_t = canvas_chain["w"] let ch: el_val_t = canvas_chain["h"] println("chain canvas w=" + fl_to_str(cw) + " h=" + fl_to_str(ch)) if float_to_int(cw) <= 0 { smoke_fail("canvas w", "non-positive") } if float_to_int(ch) <= 0 { smoke_fail("canvas h", "non-positive") } println("") let f: String = state_get("smoke_failures") if str_eq(f, "1") { println("arbor-layout: FAILED") exit_program(1) } else { println("arbor-layout: ok") }