feat: port el-ui vessels — rename crates→vessels, add El source + manifests

This commit is contained in:
Will Anderson
2026-05-05 04:19:22 -05:00
parent b580a63540
commit faee6fdb25
145 changed files with 4050 additions and 12 deletions
+46
View File
@@ -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)
}
+44
View File
@@ -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" }
+138
View File
@@ -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)
}
+352
View File
@@ -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
}
+34
View File
@@ -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) + "...")
+77
View File
@@ -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.01.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")
}
+80
View File
@@ -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)
}
+155
View File
@@ -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, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
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
}