From 8e2e712cb0901e164ce447bf51d79bed13f9b791 Mon Sep 17 00:00:00 2001 From: Tim Wundenberg Date: Thu, 2 Apr 2026 20:58:48 +0200 Subject: [PATCH] nodes --- CONTEXT.md | 17 +++- src/main.zig | 280 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 279 insertions(+), 18 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 0fb49a3..7c9a64d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,13 +24,22 @@ Sourced from books by Aajonus Vonderplanitz. Curated by the owner only — no co Game-engine style — custom rendering, animations, smooth transitions, physics-based graph layouts. Not a traditional web UI. -## Tech Stack — Under Consideration +## Tech Stack -- **Raylib + C++ + Emscripten** — leading candidate. Mature WASM story, true native binary, lean output. -- **Raylib + Zig** — elegant `@embedFile` for data embedding, but friction between Zig's WASM target and raylib's Emscripten dependency. -- **Mach (Zig game engine)** — native + WASM via WebGPU, but pre-1.0 and still maturing. +- **Zig + raylib-zig + Emscripten** — chosen. Native binary and WASM from the same codebase. Package managed via `build.zig.zon`. +- ~~Raylib + C++ + Emscripten~~ — considered, but Zig's `@embedFile` and compile-time data embedding are a better fit. +- ~~Mach (Zig game engine)~~ — discarded, pre-1.0 and still maturing. - ~~Three.js + Electron~~ — discarded. Electron too heavy, sidesteps WASM. +## Current Implementation State + +Graph navigation is working (`src/main.zig`): + +- **Data model**: nodes of five kinds — `body_part`, `ailment`, `recipe`, `ingredient`, `property` — with typed link lists, all hardcoded in the binary. +- **Navigation**: root view shows body systems in a radial ring; clicking any node drills into it, placing it at the centre with its linked nodes in orbit. Right-click or `B` navigates back. Nav history kept in a fixed-size stack. +- **Rendering**: colour-coded circles per node kind, glow on hover, selection ring on the active centre node, edge lines to linked nodes, breadcrumb trail, colour legend, navigation hints. +- **WASM path**: `emscripten_set_main_loop` hook in place; same `drawFrame` callback used for both targets. + ## Constraints - No widget toolkits — fully custom rendered diff --git a/src/main.zig b/src/main.zig index 2028761..6eff304 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,18 +1,279 @@ const std = @import("std"); const rl = @import("raylib"); const builtin = @import("builtin"); +const math = std.math; -const screen_width = 800; -const screen_height = 450; +const SCREEN_W: i32 = 1024; +const SCREEN_H: i32 = 768; +const CX: f32 = @as(f32, @floatFromInt(SCREEN_W)) / 2.0; +const CY: f32 = @as(f32, @floatFromInt(SCREEN_H)) / 2.0; +const NODE_R: f32 = 42.0; +const ORBIT_R: f32 = 220.0; + +// ── Data model ─────────────────────────────────────────────────────────────── + +const NodeKind = enum { body_part, ailment, recipe, ingredient, property }; + +const NodeDef = struct { + id: u8, + name: [:0]const u8, + kind: NodeKind, + links: []const u8, +}; + +/// All content is hardcoded and will be embedded in the binary at compile time. +const all_nodes = [_]NodeDef{ + // Body systems (0–4) + .{ .id = 0, .name = "Digestive System", .kind = .body_part, .links = &.{ 10, 11, 12 } }, + .{ .id = 1, .name = "Immune System", .kind = .body_part, .links = &.{ 13, 14 } }, + .{ .id = 2, .name = "Nervous System", .kind = .body_part, .links = &.{15} }, + .{ .id = 3, .name = "Circulatory", .kind = .body_part, .links = &.{ 13, 14 } }, + .{ .id = 4, .name = "Endocrine", .kind = .body_part, .links = &.{ 14, 15 } }, + // Ailments (10–15) + .{ .id = 10, .name = "Poor Digestion", .kind = .ailment, .links = &.{ 20, 21 } }, + .{ .id = 11, .name = "Constipation", .kind = .ailment, .links = &.{ 20, 22 } }, + .{ .id = 12, .name = "Bloating", .kind = .ailment, .links = &.{21} }, + .{ .id = 13, .name = "Inflammation", .kind = .ailment, .links = &.{ 22, 23 } }, + .{ .id = 14, .name = "Fatigue", .kind = .ailment, .links = &.{ 21, 23 } }, + .{ .id = 15, .name = "Brain Fog", .kind = .ailment, .links = &.{23} }, + // Recipes (20–23) + .{ .id = 20, .name = "Meat Smoothie", .kind = .recipe, .links = &.{ 30, 31, 32 } }, + .{ .id = 21, .name = "Raw Milk Kefir", .kind = .recipe, .links = &.{ 33, 34 } }, + .{ .id = 22, .name = "Raw Egg Blend", .kind = .recipe, .links = &.{ 31, 32, 34 } }, + .{ .id = 23, .name = "Honey-Butter Mix", .kind = .recipe, .links = &.{ 35, 36 } }, + // Ingredients (30–36) + .{ .id = 30, .name = "Raw Meat", .kind = .ingredient, .links = &.{ 40, 41 } }, + .{ .id = 31, .name = "Raw Eggs", .kind = .ingredient, .links = &.{ 40, 42 } }, + .{ .id = 32, .name = "Lemon Juice", .kind = .ingredient, .links = &.{43} }, + .{ .id = 33, .name = "Raw Milk", .kind = .ingredient, .links = &.{ 42, 44 } }, + .{ .id = 34, .name = "Kefir Grains", .kind = .ingredient, .links = &.{44} }, + .{ .id = 35, .name = "Raw Honey", .kind = .ingredient, .links = &.{ 41, 43 } }, + .{ .id = 36, .name = "Raw Butter", .kind = .ingredient, .links = &.{ 41, 44 } }, + // Properties (40–44) + .{ .id = 40, .name = "High Protein", .kind = .property, .links = &.{} }, + .{ .id = 41, .name = "Energy Dense", .kind = .property, .links = &.{} }, + .{ .id = 42, .name = "Probiotic", .kind = .property, .links = &.{} }, + .{ .id = 43, .name = "Alkalizing", .kind = .property, .links = &.{} }, + .{ .id = 44, .name = "Enzyme Rich", .kind = .property, .links = &.{} }, +}; + +const root_ids = [_]u8{ 0, 1, 2, 3, 4 }; + +fn findNode(id: u8) ?*const NodeDef { + for (&all_nodes) |*n| { + if (n.id == id) return n; + } + return null; +} + +fn kindColor(kind: NodeKind) rl.Color { + return switch (kind) { + .body_part => rl.Color.init(70, 150, 215, 255), + .ailment => rl.Color.init(210, 75, 65, 255), + .recipe => rl.Color.init(75, 190, 110, 255), + .ingredient => rl.Color.init(215, 170, 50, 255), + .property => rl.Color.init(170, 90, 215, 255), + }; +} + +// ── Navigation state ────────────────────────────────────────────────────────── + +const VisNode = struct { id: u8, x: f32, y: f32, is_center: bool }; + +const MAX_STACK = 16; +const MAX_VIS = 24; + +var nav_stack: [MAX_STACK]u8 = undefined; +var nav_depth: usize = 0; +var vis: [MAX_VIS]VisNode = undefined; +var vis_count: usize = 0; +var hovered_id: ?u8 = null; +var ready: bool = false; + +fn buildVis() void { + vis_count = 0; + if (nav_depth == 0) { + // Root view: body systems arranged in a circle + const n = root_ids.len; + const r: f32 = 200.0; + for (root_ids, 0..) |id, i| { + const angle = (2.0 * math.pi * @as(f32, @floatFromInt(i))) / + @as(f32, @floatFromInt(n)) - math.pi / 2.0; + vis[vis_count] = .{ + .id = id, + .x = CX + r * @cos(angle), + .y = CY + r * @sin(angle), + .is_center = false, + }; + vis_count += 1; + } + } else { + // Drill-down view: selected node at centre, its links in orbit + const cur_id = nav_stack[nav_depth - 1]; + const cur = findNode(cur_id) orelse return; + vis[vis_count] = .{ .id = cur_id, .x = CX, .y = CY, .is_center = true }; + vis_count += 1; + const n = cur.links.len; + if (n > 0) { + for (cur.links, 0..) |link_id, i| { + const angle = (2.0 * math.pi * @as(f32, @floatFromInt(i))) / + @as(f32, @floatFromInt(n)) - math.pi / 2.0; + vis[vis_count] = .{ + .id = link_id, + .x = CX + ORBIT_R * @cos(angle), + .y = CY + ORBIT_R * @sin(angle), + .is_center = false, + }; + vis_count += 1; + } + } + } +} + +fn visNodeAt(mx: f32, my: f32) ?u8 { + for (vis[0..vis_count]) |vn| { + const dx = mx - vn.x; + const dy = my - vn.y; + if (dx * dx + dy * dy <= NODE_R * NODE_R) return vn.id; + } + return null; +} + +// ── Per-frame logic ─────────────────────────────────────────────────────────── + +fn update() void { + const mx: f32 = @floatFromInt(rl.getMouseX()); + const my: f32 = @floatFromInt(rl.getMouseY()); + hovered_id = visNodeAt(mx, my); + + if (rl.isMouseButtonPressed(.left)) { + if (hovered_id) |id| { + const is_cur = nav_depth > 0 and nav_stack[nav_depth - 1] == id; + if (!is_cur and nav_depth < MAX_STACK) { + nav_stack[nav_depth] = id; + nav_depth += 1; + buildVis(); + } + } + } + + if (rl.isMouseButtonPressed(.right) or rl.isKeyPressed(.b)) { + if (nav_depth > 0) { + nav_depth -= 1; + buildVis(); + } + } +} + +fn drawScene() void { + // Edges from centre to orbit + if (nav_depth > 0 and vis_count > 1) { + const c = vis[0]; + for (vis[1..vis_count]) |vn| { + rl.drawLineEx( + .{ .x = c.x, .y = c.y }, + .{ .x = vn.x, .y = vn.y }, + 1.5, + rl.Color.init(55, 55, 65, 255), + ); + } + } + + // Nodes + for (vis[0..vis_count]) |vn| { + const node = findNode(vn.id) orelse continue; + const col = kindColor(node.kind); + const hov = hovered_id != null and hovered_id.? == vn.id; + const r = if (hov) NODE_R + 5.0 else NODE_R; + const ix: i32 = @intFromFloat(vn.x); + const iy: i32 = @intFromFloat(vn.y); + + // Soft glow on hover + if (hov) { + rl.drawCircle(ix, iy, r + 12.0, rl.Color.init(col.r, col.g, col.b, 45)); + } + rl.drawCircle(ix, iy, r, col); + + // White ring marks the current centre node + if (vn.is_center) { + rl.drawCircleLines(ix, iy, r + 5.0, rl.Color.white); + } + + // Label below the circle + const fsz: i32 = if (vn.is_center) 14 else 12; + const tw = rl.measureText(node.name, fsz); + const lx = ix - @divTrunc(tw, 2); + const ly = iy + @as(i32, @intFromFloat(r)) + 5; + rl.drawText(node.name, lx, ly, fsz, rl.Color.ray_white); + } +} + +fn drawHUD() void { + // Breadcrumb trail + var bx: i32 = 10; + const by: i32 = 10; + rl.drawText("Body Systems", bx, by, 15, rl.Color.gray); + bx += rl.measureText("Body Systems", 15); + for (nav_stack[0..nav_depth]) |id| { + rl.drawText(" > ", bx, by, 15, rl.Color.dark_gray); + bx += rl.measureText(" > ", 15); + const node = findNode(id) orelse continue; + rl.drawText(node.name, bx, by, 15, rl.Color.ray_white); + bx += rl.measureText(node.name, 15); + } + + // Navigation hint at bottom + const hint: [:0]const u8 = if (nav_depth > 0) + "[B] / right-click to go back" + else + "Click a body system to explore"; + rl.drawText(hint, 10, SCREEN_H - 28, 13, rl.Color.dark_gray); + + // Colour legend (top-right) + const legend = [_]struct { kind: NodeKind, label: [:0]const u8 }{ + .{ .kind = .body_part, .label = "Body System" }, + .{ .kind = .ailment, .label = "Ailment" }, + .{ .kind = .recipe, .label = "Recipe" }, + .{ .kind = .ingredient, .label = "Ingredient" }, + .{ .kind = .property, .label = "Property" }, + }; + const lx: i32 = SCREEN_W - 140; + var ly: i32 = 10; + for (legend) |e| { + rl.drawCircle(lx + 8, ly + 8, 8.0, kindColor(e.kind)); + rl.drawText(e.label, lx + 22, ly + 1, 13, rl.Color.light_gray); + ly += 22; + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +fn drawFrame() callconv(.c) void { + if (!ready) { + buildVis(); + ready = true; + } + update(); + + rl.beginDrawing(); + defer rl.endDrawing(); + + rl.clearBackground(rl.Color.init(12, 12, 18, 255)); + drawScene(); + drawHUD(); +} pub fn main() anyerror!void { - rl.initWindow(screen_width, screen_height, "primalwiki"); + rl.initWindow(SCREEN_W, SCREEN_H, "primalwiki"); defer rl.closeWindow(); if (builtin.os.tag == .emscripten) { - // Browser: emscripten's main loop drives the frame callback const em = struct { - extern fn emscripten_set_main_loop(func: *const fn () callconv(.c) void, fps: c_int, simulate_infinite_loop: c_int) void; + extern fn emscripten_set_main_loop( + func: *const fn () callconv(.c) void, + fps: c_int, + simulate_infinite_loop: c_int, + ) void; }; em.emscripten_set_main_loop(&drawFrame, 0, 1); } else { @@ -22,12 +283,3 @@ pub fn main() anyerror!void { } } } - -fn drawFrame() callconv(.c) void { - rl.beginDrawing(); - defer rl.endDrawing(); - - rl.clearBackground(rl.Color.black); - rl.drawText("primalwiki poc", 190, 180, 30, rl.Color.ray_white); - rl.drawText("zig + raylib", 240, 230, 20, rl.Color.dark_gray); -}