From c5e34ed09b1978e420fad7494892bcffea085468 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sun, 3 May 2026 04:04:43 -0500 Subject: [PATCH] snapshot before rust archive --- examples/counter/app.js | 3 - vessels/el-graph/manifest.el | 33 +++ vessels/el-graph/src/canvas.el | 46 ++++ vessels/el-graph/src/edge.el | 44 ++++ vessels/el-graph/src/editor.el | 138 +++++++++++ vessels/el-graph/src/layout.el | 352 +++++++++++++++++++++++++++++ vessels/el-graph/src/main.el | 34 +++ vessels/el-graph/src/node.el | 77 +++++++ vessels/el-graph/src/serializer.el | 80 +++++++ vessels/el-graph/src/view.el | 155 +++++++++++++ 10 files changed, 959 insertions(+), 3 deletions(-) create mode 100644 vessels/el-graph/manifest.el create mode 100644 vessels/el-graph/src/canvas.el create mode 100644 vessels/el-graph/src/edge.el create mode 100644 vessels/el-graph/src/editor.el create mode 100644 vessels/el-graph/src/layout.el create mode 100644 vessels/el-graph/src/main.el create mode 100644 vessels/el-graph/src/node.el create mode 100644 vessels/el-graph/src/serializer.el create mode 100644 vessels/el-graph/src/view.el diff --git a/examples/counter/app.js b/examples/counter/app.js index 096de43..f1d095a 100644 --- a/examples/counter/app.js +++ b/examples/counter/app.js @@ -56,6 +56,3 @@ class App extends Component { } export { Counter, App }; - -// Mount the app -mount(App, '#app'); diff --git a/vessels/el-graph/manifest.el b/vessels/el-graph/manifest.el new file mode 100644 index 0000000..291a6cd --- /dev/null +++ b/vessels/el-graph/manifest.el @@ -0,0 +1,33 @@ +// el-graph — Force-directed graph engine for el-ui. +// +// Server-side SVG renderer for knowledge graphs, Engram viewers, DHARMA +// network maps, and soul relationship graphs. +// +// Architecture: +// layout.el — pure El force simulation (Coulomb repulsion + spring edges) +// node.el — node type definitions and color mapping +// edge.el — edge type definitions +// canvas.el — HTTP endpoint helper (full pipeline in one call) +// view.el — SVG renderer (layout -> SVG string) +// editor.el — round-trip mutation API (add/remove/connect/move nodes) +// serializer.el — export to SVG or JSON +// +// Client-side interaction (drag, zoom, pan) is deferred until el-ui-compiler +// gains a JavaScript backend. For now, consumers call graph_svg_endpoint() +// which runs the full server-side pipeline and returns a static SVG string. + +vessel "el-graph" { + version "0.1.0" + description "Force-directed graph: layout, SVG rendering, editing API" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/vessels/el-graph/src/canvas.el b/vessels/el-graph/src/canvas.el new file mode 100644 index 0000000..0b07c57 --- /dev/null +++ b/vessels/el-graph/src/canvas.el @@ -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 ... 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 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) +} diff --git a/vessels/el-graph/src/edge.el b/vessels/el-graph/src/edge.el new file mode 100644 index 0000000..0df469b --- /dev/null +++ b/vessels/el-graph/src/edge.el @@ -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" } diff --git a/vessels/el-graph/src/editor.el b/vessels/el-graph/src/editor.el new file mode 100644 index 0000000..0d92860 --- /dev/null +++ b/vessels/el-graph/src/editor.el @@ -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) +} diff --git a/vessels/el-graph/src/layout.el b/vessels/el-graph/src/layout.el new file mode 100644 index 0000000..93f7878 --- /dev/null +++ b/vessels/el-graph/src/layout.el @@ -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_" — x position +// "node_y_" — y position +// "node_vx_" — x velocity +// "node_vy_" — 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_" 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 +} diff --git a/vessels/el-graph/src/main.el b/vessels/el-graph/src/main.el new file mode 100644 index 0000000..e26d209 --- /dev/null +++ b/vessels/el-graph/src/main.el @@ -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) + "...") diff --git a/vessels/el-graph/src/node.el b/vessels/el-graph/src/node.el new file mode 100644 index 0000000..f71f6e8 --- /dev/null +++ b/vessels/el-graph/src/node.el @@ -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") +} diff --git a/vessels/el-graph/src/serializer.el b/vessels/el-graph/src/serializer.el new file mode 100644 index 0000000..6cb4b6d --- /dev/null +++ b/vessels/el-graph/src/serializer.el @@ -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) + + "No nodes in graph" + + svg_close() + } + + graph_svg_endpoint(nodes_json, edges_json, width, height) +} diff --git a/vessels/el-graph/src/view.el b/vessels/el-graph/src/view.el new file mode 100644 index 0000000..1524d28 --- /dev/null +++ b/vessels/el-graph/src/view.el @@ -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 ... 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 { + "" +} + +fn svg_close() -> String { "" } + +fn svg_defs() -> String { + "" + + "" + + "" + + "" +} + +// ── 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) + "" +} + +// ── 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) + "" + + "" + label_safe + "" +} + +// ── Position lookup ─────────────────────────────────────────────────────────── +// +// Build a flat map from node_id -> position JSON in process state. +// Key: "pos_" -> "{\"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 +}