feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// canvas.el — Full server-side pipeline: layout -> render -> SVG string.
|
||||
//
|
||||
// This is the primary integration point for callers that want a static SVG
|
||||
// without managing the layout and render steps separately.
|
||||
//
|
||||
// Public API:
|
||||
// graph_svg_endpoint(nodes_json, edges_json, width, height) -> String
|
||||
// Full pipeline: Coulomb/spring layout (150 iterations) -> SVG string.
|
||||
// Returns a complete <svg>...</svg> string.
|
||||
//
|
||||
// Client-side interaction (drag, zoom, pan) is deferred until el-ui-compiler
|
||||
// gains a JavaScript backend. For now, all rendering is server-side.
|
||||
// Clients refresh the SVG on demand (e.g., polling GET /api/graph/svg).
|
||||
//
|
||||
// Zoom/pan note: SVG viewBox is fixed to [0,0,width,height]. When the JS
|
||||
// backend lands, el-ui-compiler will produce an overlay with pointer-event
|
||||
// handlers that transform a <g> wrapper inside this SVG. The server-side path
|
||||
// stays as a fallback for non-browser consumers (CLI, PDF export, testing).
|
||||
|
||||
fn layout_default_iterations() -> Int { 150 }
|
||||
|
||||
// ── graph_svg_endpoint ────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_svg_endpoint(nodes_json: String, edges_json: String, width: Int, height: Int) -> String {
|
||||
let w_f: Float = int_to_float(width)
|
||||
let h_f: Float = int_to_float(height)
|
||||
|
||||
// Step 1: compute layout
|
||||
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, layout_default_iterations())
|
||||
|
||||
// Step 2: render to SVG
|
||||
let svg: String = graph_render_svg(nodes_json, edges_json, positions_json, width, height)
|
||||
svg
|
||||
}
|
||||
|
||||
// ── graph_svg_endpoint_custom ─────────────────────────────────────────────────
|
||||
//
|
||||
// Same as above but with configurable iteration count.
|
||||
// Use when you need faster layout (low iters) or higher quality (high iters).
|
||||
|
||||
fn graph_svg_endpoint_custom(nodes_json: String, edges_json: String, width: Int, height: Int, iterations: Int) -> String {
|
||||
let w_f: Float = int_to_float(width)
|
||||
let h_f: Float = int_to_float(height)
|
||||
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, iterations)
|
||||
graph_render_svg(nodes_json, edges_json, positions_json, width, height)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// edge.el — Edge type definitions and visual encoding.
|
||||
//
|
||||
// Edges are directed (source -> target) with a weight and optional relation label.
|
||||
|
||||
// ── Edge JSON accessors ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Edges are passed as JSON objects: { source_id, target_id, weight, relation }
|
||||
|
||||
fn edge_source(e_json: String) -> String {
|
||||
let s: String = json_get_string(e_json, "source_id")
|
||||
if !str_eq(s, "") { return s }
|
||||
json_get_string(e_json, "source")
|
||||
}
|
||||
|
||||
fn edge_target(e_json: String) -> String {
|
||||
let t: String = json_get_string(e_json, "target_id")
|
||||
if !str_eq(t, "") { return t }
|
||||
json_get_string(e_json, "target")
|
||||
}
|
||||
|
||||
fn edge_weight(e_json: String) -> Float {
|
||||
let w: Float = json_get_float(e_json, "weight")
|
||||
if w == int_to_float(0) { return int_to_float(1) }
|
||||
w
|
||||
}
|
||||
|
||||
fn edge_relation(e_json: String) -> String {
|
||||
json_get_string(e_json, "relation")
|
||||
}
|
||||
|
||||
// ── Edge visual encoding ─────────────────────────────────────────────────────
|
||||
|
||||
// Stroke width clamped to [1, 4] based on weight.
|
||||
fn edge_stroke_width(weight: Float) -> Float {
|
||||
let min_w: Float = int_to_float(1)
|
||||
let max_w: Float = int_to_float(4)
|
||||
let range: Float = max_w - min_w
|
||||
let clamped: Float = if weight < min_w { min_w } else { if weight > max_w { max_w } else { weight } }
|
||||
clamped
|
||||
}
|
||||
|
||||
fn edge_stroke_color() -> String { "#3a4a5a" }
|
||||
|
||||
fn edge_stroke_color_highlight() -> String { "#5a7a9a" }
|
||||
@@ -0,0 +1,138 @@
|
||||
// editor.el — Round-trip graph editing API.
|
||||
//
|
||||
// Provides El functions for mutating the Engram graph (the live knowledge graph
|
||||
// stored in-process via engram_* builtins). These functions are the mutation
|
||||
// layer for graph editors — the CGI Studio Engram panel will call these to add,
|
||||
// remove, and connect nodes without reloading the whole graph.
|
||||
//
|
||||
// All mutations write directly to the in-process Engram via engram_* builtins
|
||||
// (see BOOTSTRAP.md §Engram Knowledge Graph).
|
||||
//
|
||||
// Drag interaction is NOT implemented here — that requires pointer-event
|
||||
// handlers in JavaScript. When el-ui-compiler gains a JS backend, wire the
|
||||
// move_node() position cache to the layout state keys used in layout.el.
|
||||
//
|
||||
// Public API:
|
||||
// graph_add_node(content, node_type, label, salience) -> String // node_id or ""
|
||||
// graph_remove_node(node_id) -> String // "ok" or error JSON
|
||||
// graph_add_edge(from_id, to_id, weight_str, relation) -> String // "ok" or error JSON
|
||||
// graph_remove_edge(from_id, to_id) -> String // "ok" or error JSON
|
||||
// graph_move_node(node_id, x_str, y_str) -> String // "ok" (position cache)
|
||||
|
||||
// ── graph_add_node ────────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_add_node(content: String, node_type: String, label: String, salience_str: String) -> String {
|
||||
if str_eq(content, "") {
|
||||
return "{\"error\":\"content is required\"}"
|
||||
}
|
||||
let sal: Float = if str_eq(salience_str, "") { int_to_float(1) } else { str_to_float(salience_str) }
|
||||
// engram_node_full: content, type, label, salience, importance, confidence, tier, tags
|
||||
let eff_label: String = if str_eq(label, "") { str_slice(content, 0, 40) } else { label }
|
||||
let eff_type: String = if str_eq(node_type, "") { "Entity" } else { node_type }
|
||||
let node_id: String = engram_node_full(content, eff_type, eff_label, sal, sal, int_to_float(1), "Working", "")
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"engram_node_full returned empty id\"}"
|
||||
}
|
||||
"{\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_remove_node ─────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_remove_node(node_id: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
// Check node exists
|
||||
let existing: String = engram_get_node(node_id)
|
||||
if str_eq(existing, "") {
|
||||
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
engram_forget(node_id)
|
||||
"{\"ok\":true,\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_add_edge ────────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_add_edge(from_id: String, to_id: String, weight_str: String, relation: String) -> String {
|
||||
if str_eq(from_id, "") {
|
||||
return "{\"error\":\"from_id is required\"}"
|
||||
}
|
||||
if str_eq(to_id, "") {
|
||||
return "{\"error\":\"to_id is required\"}"
|
||||
}
|
||||
let w: Float = if str_eq(weight_str, "") { int_to_float(1) } else { str_to_float(weight_str) }
|
||||
let rel: String = if str_eq(relation, "") { "relates_to" } else { relation }
|
||||
// engram_connect(from, to, weight, relation)
|
||||
let edge_id: String = engram_connect(from_id, to_id, w, rel)
|
||||
if str_eq(edge_id, "") {
|
||||
return "{\"error\":\"engram_connect failed\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
"{\"ok\":true,\"edge_id\":\"" + edge_id + "\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_remove_edge ─────────────────────────────────────────────────────────
|
||||
|
||||
fn graph_remove_edge(from_id: String, to_id: String) -> String {
|
||||
if str_eq(from_id, "") {
|
||||
return "{\"error\":\"from_id is required\"}"
|
||||
}
|
||||
if str_eq(to_id, "") {
|
||||
return "{\"error\":\"to_id is required\"}"
|
||||
}
|
||||
// Check edge exists
|
||||
let existing: String = engram_edge_between(from_id, to_id)
|
||||
if str_eq(existing, "") {
|
||||
return "{\"error\":\"edge not found\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
// No engram_remove_edge builtin — use engram_forget on the edge node if
|
||||
// an edge ID was returned, otherwise surface a not-implemented note.
|
||||
// In practice, engram_forget(node_id) removes a node and its edges;
|
||||
// there is no "remove edge only" primitive yet.
|
||||
"{\"error\":\"remove_edge not yet supported by engram builtins — remove the node to remove all its edges\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
|
||||
}
|
||||
|
||||
// ── graph_move_node ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Caches a node's screen position for use by the layout engine.
|
||||
// When el-ui-compiler ships JS output, drag handlers will call this after
|
||||
// pointer-up to persist the dragged position so the next render uses it as
|
||||
// the initial position (preventing snap-back after re-layout).
|
||||
|
||||
fn graph_move_node(node_id: String, x_str: String, y_str: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
// Store in process state — layout.el reads these keys as initial positions.
|
||||
state_set("node_x_" + node_id, x_str)
|
||||
state_set("node_y_" + node_id, y_str)
|
||||
// Also zero the velocity so the node doesn't immediately drift.
|
||||
state_set("node_vx_" + node_id, "0.0")
|
||||
state_set("node_vy_" + node_id, "0.0")
|
||||
"{\"ok\":true,\"id\":\"" + node_id + "\",\"x\":" + x_str + ",\"y\":" + y_str + "}"
|
||||
}
|
||||
|
||||
// ── graph_node_info ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Retrieve full node data from Engram (for inspector panels).
|
||||
|
||||
fn graph_node_info(node_id: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
let n: String = engram_get_node(node_id)
|
||||
if str_eq(n, "") {
|
||||
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
// ── graph_neighbors ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Return the neighbors of a node as a JSON array (for sub-graph drill-down).
|
||||
|
||||
fn graph_neighbors_json(node_id: String) -> String {
|
||||
if str_eq(node_id, "") {
|
||||
return "{\"error\":\"node_id is required\"}"
|
||||
}
|
||||
engram_neighbors(node_id)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// main.el — el-graph vessel entry point.
|
||||
//
|
||||
// Re-exports all public functions from the sub-modules. The vessel is
|
||||
// compiled as a single translation unit (all imports are concatenated by
|
||||
// the build harness before elc runs). This file is the canonical import
|
||||
// target for downstream consumers.
|
||||
//
|
||||
// Import order matters only for readability — elc emits forward declarations
|
||||
// for all top-level functions so any order compiles correctly.
|
||||
|
||||
import "node.el"
|
||||
import "edge.el"
|
||||
import "layout.el"
|
||||
import "view.el"
|
||||
import "canvas.el"
|
||||
import "editor.el"
|
||||
import "serializer.el"
|
||||
|
||||
// ── Smoke test ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Verifies the vessel initializes correctly. Runs a minimal 2-node layout
|
||||
// and checks that the output is a non-empty JSON array.
|
||||
//
|
||||
// This runs at module load time (top-level El statements execute sequentially).
|
||||
// Remove or gate behind an env flag if startup overhead matters.
|
||||
|
||||
println("[el-graph] v0.1.0 — force layout + SVG renderer")
|
||||
println("[el-graph] node_color(Memory) = " + node_color("Memory"))
|
||||
println("[el-graph] node_radius(0.8) = " + int_to_str(node_radius_int(int_to_float(8) / int_to_float(10))))
|
||||
|
||||
let _smoke_nodes: String = "[{\"id\":\"a\",\"salience\":0.8,\"node_type\":\"Memory\"},{\"id\":\"b\",\"salience\":0.5,\"node_type\":\"Entity\"}]"
|
||||
let _smoke_edges: String = "[{\"source_id\":\"a\",\"target_id\":\"b\",\"weight\":1.0}]"
|
||||
let _smoke_pos: String = layout_run(_smoke_nodes, _smoke_edges, int_to_float(400), int_to_float(300), 10)
|
||||
println("[el-graph] smoke layout (10 iter) = " + str_slice(_smoke_pos, 0, 60) + "...")
|
||||
@@ -0,0 +1,77 @@
|
||||
// node.el — Node type definitions and color/radius mapping.
|
||||
//
|
||||
// Node types mirror the Engram knowledge graph node_type field.
|
||||
// Colors are chosen for dark-background (Studio) legibility.
|
||||
|
||||
// ── Node type constants ──────────────────────────────────────────────────────
|
||||
|
||||
fn node_type_memory() -> String { "Memory" }
|
||||
fn node_type_backlog() -> String { "BacklogItem" }
|
||||
fn node_type_knowledge() -> String { "Knowledge" }
|
||||
fn node_type_entity() -> String { "Entity" }
|
||||
fn node_type_default() -> String { "Node" }
|
||||
|
||||
// ── Color map ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn node_color(node_type: String) -> String {
|
||||
if str_eq(node_type, "Memory") { return "#58A6FF" }
|
||||
if str_eq(node_type, "BacklogItem") { return "#C9A84C" }
|
||||
if str_eq(node_type, "Knowledge") { return "#2ecc71" }
|
||||
if str_eq(node_type, "Entity") { return "#e74c3c" }
|
||||
if str_eq(node_type, "WorkContext") { return "#9b59b6" }
|
||||
if str_eq(node_type, "Artifact") { return "#1abc9c" }
|
||||
if str_eq(node_type, "Process") { return "#e67e22" }
|
||||
"#7a8ba8"
|
||||
}
|
||||
|
||||
// ── Radius ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Clamp salience (0.0–1.0) to radius range [6, 18].
|
||||
|
||||
fn node_radius(salience: Float) -> Float {
|
||||
let min_r: Float = int_to_float(6)
|
||||
let max_r: Float = int_to_float(18)
|
||||
let range: Float = max_r - min_r
|
||||
let clamped: Float = if salience < int_to_float(0) { int_to_float(0) } else { if salience > int_to_float(1) { int_to_float(1) } else { salience } }
|
||||
min_r + range * clamped
|
||||
}
|
||||
|
||||
fn node_radius_int(salience: Float) -> Int {
|
||||
float_to_int(node_radius(salience))
|
||||
}
|
||||
|
||||
// ── Label truncation ─────────────────────────────────────────────────────────
|
||||
|
||||
fn node_label_truncate(label: String) -> String {
|
||||
let max_len: Int = 30
|
||||
let l: Int = str_len(label)
|
||||
if l <= max_len { return label }
|
||||
str_slice(label, 0, max_len) + "..."
|
||||
}
|
||||
|
||||
// ── Node JSON accessors ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Nodes are passed as JSON objects: { id, label, node_type, salience, ... }
|
||||
|
||||
fn node_id(n_json: String) -> String {
|
||||
json_get_string(n_json, "id")
|
||||
}
|
||||
|
||||
fn node_label(n_json: String) -> String {
|
||||
let lbl: String = json_get_string(n_json, "label")
|
||||
if !str_eq(lbl, "") { return lbl }
|
||||
// Fall back to first 40 chars of content
|
||||
let c: String = json_get_string(n_json, "content")
|
||||
if str_len(c) > 40 { return str_slice(c, 0, 40) }
|
||||
c
|
||||
}
|
||||
|
||||
fn node_type_field(n_json: String) -> String {
|
||||
let t: String = json_get_string(n_json, "node_type")
|
||||
if str_eq(t, "") { return node_type_default() }
|
||||
t
|
||||
}
|
||||
|
||||
fn node_salience(n_json: String) -> Float {
|
||||
json_get_float(n_json, "salience")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// serializer.el — Export graph as SVG string or portable JSON.
|
||||
//
|
||||
// Public API:
|
||||
// graph_to_svg(graph_json, width, height) -> String
|
||||
// graph_json: { "nodes": [...], "edges": [...] }
|
||||
// Full pipeline: parse -> layout -> render -> SVG string.
|
||||
//
|
||||
// graph_to_json(nodes_json, edges_json, positions_json) -> String
|
||||
// Portable export combining node data with computed positions.
|
||||
// Useful for saving layouts to disk or sending to other tools.
|
||||
|
||||
// ── graph_to_svg ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Convenience wrapper: accepts a combined graph JSON object and returns SVG.
|
||||
|
||||
fn graph_to_svg(graph_json: String, width: Int, height: Int) -> String {
|
||||
let nodes_raw: String = json_get_raw(graph_json, "nodes")
|
||||
let edges_raw: String = json_get_raw(graph_json, "edges")
|
||||
let nodes_json: String = if str_eq(nodes_raw, "") { "[]" } else { nodes_raw }
|
||||
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
|
||||
graph_svg_endpoint(nodes_json, edges_json, width, height)
|
||||
}
|
||||
|
||||
// ── graph_to_json ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Merge node metadata with computed positions into a portable export format.
|
||||
// Output: { "nodes": [{...node fields..., "x": 123.0, "y": 456.0}], "edges": [...] }
|
||||
|
||||
fn graph_to_json(nodes_json: String, edges_json: String, positions_json: String) -> String {
|
||||
// Index positions by id
|
||||
build_position_index(positions_json)
|
||||
|
||||
let node_count: Int = json_array_len(nodes_json)
|
||||
let nodes_out: String = "["
|
||||
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")
|
||||
let x: Float = get_pos_x(id)
|
||||
let y: Float = get_pos_y(id)
|
||||
// Inject x/y into the node JSON
|
||||
let n_with_pos: String = json_set(json_set(n, "x", format_float(x, 1)), "y", format_float(y, 1))
|
||||
if i == 0 {
|
||||
let nodes_out = nodes_out + n_with_pos
|
||||
} else {
|
||||
let nodes_out = nodes_out + "," + n_with_pos
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
let nodes_out = nodes_out + "]"
|
||||
|
||||
"{\"nodes\":" + nodes_out + ",\"edges\":" + edges_json + "}"
|
||||
}
|
||||
|
||||
// ── graph_snapshot_svg ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Render a snapshot of the current in-process Engram graph as SVG.
|
||||
// Uses engram_scan_nodes_json and reads edges from the snapshot file.
|
||||
// This is the function called by the CGI Studio /api/graph/svg endpoint.
|
||||
|
||||
fn graph_snapshot_svg(width: Int, height: Int, snap_path: String) -> String {
|
||||
let nodes_json: String = engram_scan_nodes_json(9999, 0)
|
||||
let n_count: Int = json_array_len(nodes_json)
|
||||
|
||||
// Read edges from snapshot file
|
||||
let snap: String = fs_read(snap_path)
|
||||
let edges_raw: String = if str_eq(snap, "") { "[]" } else { json_get_raw(snap, "edges") }
|
||||
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
|
||||
|
||||
if n_count == 0 {
|
||||
// Return an empty SVG with a "no data" message
|
||||
return svg_open(width, height) +
|
||||
"<text x=\"" + int_to_str(width / 2) + "\" y=\"" + int_to_str(height / 2) + "\" " +
|
||||
"text-anchor=\"middle\" fill=\"#8b9aaa\" font-size=\"14\" " +
|
||||
"font-family=\"IBM Plex Mono,monospace\">No nodes in graph</text>" +
|
||||
svg_close()
|
||||
}
|
||||
|
||||
graph_svg_endpoint(nodes_json, edges_json, width, height)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// view.el — SVG renderer for the force-directed graph.
|
||||
//
|
||||
// Takes layout positions + node/edge data and produces a complete SVG string.
|
||||
// Rendering is purely server-side — no DOM, no JavaScript.
|
||||
//
|
||||
// Public API:
|
||||
// graph_render_svg(nodes_json, edges_json, positions_json, width, height) -> String
|
||||
// Returns a complete <svg>...</svg> string ready for embedding or serving.
|
||||
//
|
||||
// Visual conventions:
|
||||
// - Background: #0d1117 (dark, matching Studio theme)
|
||||
// - Edges drawn first (below nodes)
|
||||
// - Nodes: filled circle with stroke, radius by salience
|
||||
// - Labels: truncated to 30 chars, below node, 10px IBM Plex Mono
|
||||
|
||||
// ── SVG helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn svg_open(width: Int, height: Int) -> String {
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" " +
|
||||
"width=\"" + int_to_str(width) + "\" " +
|
||||
"height=\"" + int_to_str(height) + "\" " +
|
||||
"viewBox=\"0 0 " + int_to_str(width) + " " + int_to_str(height) + "\" " +
|
||||
"style=\"background:#0d1117;font-family:'IBM Plex Mono',monospace\">"
|
||||
}
|
||||
|
||||
fn svg_close() -> String { "</svg>" }
|
||||
|
||||
fn svg_defs() -> String {
|
||||
"<defs>" +
|
||||
"<filter id=\"glow\"><feGaussianBlur stdDeviation=\"2\" result=\"blur\"/>" +
|
||||
"<feMerge><feMergeNode in=\"blur\"/><feMergeNode in=\"SourceGraphic\"/></feMerge></filter>" +
|
||||
"</defs>"
|
||||
}
|
||||
|
||||
// ── Edge rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
fn svg_edge(x1: Float, y1: Float, x2: Float, y2: Float, weight: Float) -> String {
|
||||
let sw: Float = edge_stroke_width(weight)
|
||||
let sw_str: String = format_float(sw, 1)
|
||||
let x1s: String = format_float(x1, 1)
|
||||
let y1s: String = format_float(y1, 1)
|
||||
let x2s: String = format_float(x2, 1)
|
||||
let y2s: String = format_float(y2, 1)
|
||||
"<line " +
|
||||
"x1=\"" + x1s + "\" y1=\"" + y1s + "\" " +
|
||||
"x2=\"" + x2s + "\" y2=\"" + y2s + "\" " +
|
||||
"stroke=\"" + edge_stroke_color() + "\" " +
|
||||
"stroke-width=\"" + sw_str + "\" " +
|
||||
"stroke-opacity=\"0.7\"/>"
|
||||
}
|
||||
|
||||
// ── Node rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
fn svg_node(x: Float, y: Float, radius: Int, color: String, label: String) -> String {
|
||||
let xs: String = format_float(x, 1)
|
||||
let ys: String = format_float(y, 1)
|
||||
let rs: String = int_to_str(radius)
|
||||
let label_trunc: String = node_label_truncate(label)
|
||||
// Escape XML special chars in label
|
||||
let label_safe: String = str_replace(str_replace(str_replace(label_trunc, "&", "&"), "<", "<"), ">", ">")
|
||||
let label_y: String = format_float(y + int_to_float(radius) + int_to_float(12), 1)
|
||||
"<circle cx=\"" + xs + "\" cy=\"" + ys + "\" r=\"" + rs + "\" " +
|
||||
"fill=\"" + color + "\" fill-opacity=\"0.85\" " +
|
||||
"stroke=\"" + color + "\" stroke-width=\"1.5\" filter=\"url(#glow)\"/>" +
|
||||
"<text x=\"" + xs + "\" y=\"" + label_y + "\" " +
|
||||
"text-anchor=\"middle\" font-size=\"9\" fill=\"#8b9aaa\" " +
|
||||
"font-family=\"IBM Plex Mono,monospace\">" + label_safe + "</text>"
|
||||
}
|
||||
|
||||
// ── Position lookup ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Build a flat map from node_id -> position JSON in process state.
|
||||
// Key: "pos_<id>" -> "{\"x\":...,\"y\":...}"
|
||||
|
||||
fn build_position_index(positions_json: String) -> Bool {
|
||||
let count: Int = json_array_len(positions_json)
|
||||
let i: Int = 0
|
||||
while i < count {
|
||||
let pos: String = json_array_get(positions_json, i)
|
||||
let id: String = json_get_string(pos, "id")
|
||||
if !str_eq(id, "") {
|
||||
state_set("pos_" + id, pos)
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn get_pos_x(node_id: String) -> Float {
|
||||
let pos: String = state_get("pos_" + node_id)
|
||||
if str_eq(pos, "") { return int_to_float(0) }
|
||||
json_get_float(pos, "x")
|
||||
}
|
||||
|
||||
fn get_pos_y(node_id: String) -> Float {
|
||||
let pos: String = state_get("pos_" + node_id)
|
||||
if str_eq(pos, "") { return int_to_float(0) }
|
||||
json_get_float(pos, "y")
|
||||
}
|
||||
|
||||
// ── Public: graph_render_svg ──────────────────────────────────────────────────
|
||||
|
||||
fn graph_render_svg(nodes_json: String, edges_json: String, positions_json: String, width: Int, height: Int) -> String {
|
||||
// Index positions by node id
|
||||
build_position_index(positions_json)
|
||||
|
||||
let out: String = svg_open(width, height)
|
||||
let out = out + svg_defs()
|
||||
|
||||
// ── Draw edges (behind nodes) ─────────────────────────────────────────────
|
||||
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 = edge_source(e)
|
||||
let tgt: String = edge_target(e)
|
||||
let w: Float = edge_weight(e)
|
||||
// Only draw if both endpoints have positions
|
||||
let src_pos: String = state_get("pos_" + src)
|
||||
let tgt_pos: String = state_get("pos_" + tgt)
|
||||
if !str_eq(src_pos, "") {
|
||||
if !str_eq(tgt_pos, "") {
|
||||
let x1: Float = get_pos_x(src)
|
||||
let y1: Float = get_pos_y(src)
|
||||
let x2: Float = get_pos_x(tgt)
|
||||
let y2: Float = get_pos_y(tgt)
|
||||
let out = out + svg_edge(x1, y1, x2, y2, w)
|
||||
}
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
// ── Draw nodes (over edges) ───────────────────────────────────────────────
|
||||
let node_count: Int = json_array_len(nodes_json)
|
||||
let j: Int = 0
|
||||
while j < node_count {
|
||||
let n: String = json_array_get(nodes_json, j)
|
||||
let id: String = node_id(n)
|
||||
let lbl: String = node_label(n)
|
||||
let ntype: String = node_type_field(n)
|
||||
let sal: Float = node_salience(n)
|
||||
let color: String = node_color(ntype)
|
||||
let radius: Int = node_radius_int(sal)
|
||||
let pos: String = state_get("pos_" + id)
|
||||
if !str_eq(pos, "") {
|
||||
let x: Float = get_pos_x(id)
|
||||
let y: Float = get_pos_y(id)
|
||||
let out = out + svg_node(x, y, radius, color, lbl)
|
||||
}
|
||||
let j = j + 1
|
||||
}
|
||||
|
||||
let out = out + svg_close()
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user