facett-syschart 0.1.1

facett — system-map chart: free-positioned clickable nodes + edges with a per-node badge and an inline expandable detail panel
Documentation
//! **facett-syschart** — a **system-map chart**: a handful of *free-positioned*
//! peer nodes (not a layered DAG) joined by edges, each carrying a **badge**
//! (a count / status number) and, when selected, an **inline expandable detail
//! panel**. Click a node to select it; its detail renders in a panel under the
//! canvas. The host owns *what* a node's detail is (a string here; richer hosts
//! draw their own widgets after `ui()` keyed off [`SystemChart::selected`]).
//!
//! This is the piece [`facett_core::draw`]/[`facett_graph::GraphView`] don't
//! cover — those paint a *non-interactive* `Scene`; `DepGraphView` is a
//! *layered* DAG with a fixed deps/dependents drill-down. A "monitoring map" of
//! a few peer systems (a PKI/OIDC/Nexus triangle, a service mesh) wants
//! deterministic positions, a per-node badge, click-select, and a free-form
//! detail panel — that's [`SystemChart`].
//!
//! Like every facett component it implements [`Facet`]: `title` / `ui` /
//! `state_json` (every node, badge, edge + the selection), so it drops into a
//! `FacetDeck` and is robot-testable through `facett_core::harness` — drive
//! [`SystemChart::select`] headlessly and assert `state_json`.

use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
use facett_core::{Facet, FacetCaps, theme};

/// One system in the map. Positions are **normalized** (`0.0..=1.0` of the
/// canvas), so the layout is resolution-independent and deterministic — ideal
/// for headless rendering and screenshots.
#[derive(Clone, Debug)]
pub struct SysNode {
    /// Stable id (used for selection + edges). Unique within a chart.
    pub id: String,
    /// Human label drawn next to the node.
    pub label: String,
    /// Node colour (the host picks the policy — by status, by hash, …).
    pub color: Color32,
    /// A small count/status badge drawn on the node (e.g. an event count).
    pub badge: u64,
    /// Normalized position in the canvas (`0,0` = top-left, `1,1` = bottom-right).
    pub pos: (f32, f32),
    /// Free-form detail shown in the inline panel when this node is selected.
    /// Hosts that draw richer detail can leave this empty and render their own
    /// widgets after `ui()` keyed off [`SystemChart::selected`].
    pub detail: String,
}

impl SysNode {
    pub fn new(id: impl Into<String>, label: impl Into<String>, color: Color32, pos: (f32, f32)) -> Self {
        Self { id: id.into(), label: label.into(), color, badge: 0, pos, detail: String::new() }
    }
    pub fn badge(mut self, n: u64) -> Self {
        self.badge = n;
        self
    }
    pub fn detail(mut self, d: impl Into<String>) -> Self {
        self.detail = d.into();
        self
    }
}

/// An undirected link between two node ids.
#[derive(Clone, Debug)]
pub struct SysEdge {
    pub a: String,
    pub b: String,
}

impl SysEdge {
    pub fn new(a: impl Into<String>, b: impl Into<String>) -> Self {
        Self { a: a.into(), b: b.into() }
    }
}

/// The system-map chart: nodes + edges + the current selection.
pub struct SystemChart {
    pub title: String,
    pub nodes: Vec<SysNode>,
    pub edges: Vec<SysEdge>,
    /// Index into `nodes` of the selected node (drives the detail panel).
    selected: Option<usize>,
    /// Height (px) the node canvas takes before the detail panel.
    canvas_h: f32,
}

const NODE_R: f32 = 16.0;

impl SystemChart {
    pub fn new(title: impl Into<String>, nodes: Vec<SysNode>, edges: Vec<SysEdge>) -> Self {
        Self { title: title.into(), nodes, edges, selected: None, canvas_h: 280.0 }
    }
    pub fn with_canvas_height(mut self, h: f32) -> Self {
        self.canvas_h = h;
        self
    }

    fn index_of(&self, id: &str) -> Option<usize> {
        self.nodes.iter().position(|n| n.id == id)
    }

    /// Select a node by id (headless-test + host entry point). Selecting the
    /// already-selected node deselects it. Unknown id is a no-op.
    pub fn select(&mut self, id: &str) {
        if let Some(i) = self.index_of(id) {
            self.selected = if self.selected == Some(i) { None } else { Some(i) };
        }
    }
    /// Clear the selection.
    pub fn clear_selection(&mut self) {
        self.selected = None;
    }
    /// The selected node's id, if any.
    pub fn selected(&self) -> Option<&str> {
        self.selected.and_then(|i| self.nodes.get(i)).map(|n| n.id.as_str())
    }

    /// Update a node's badge by id (e.g. a live event count). No-op if unknown.
    pub fn set_badge(&mut self, id: &str, badge: u64) {
        if let Some(i) = self.index_of(id) {
            self.nodes[i].badge = badge;
        }
    }
    /// Update a node's detail text by id. No-op if unknown.
    pub fn set_detail(&mut self, id: &str, detail: impl Into<String>) {
        if let Some(i) = self.index_of(id) {
            self.nodes[i].detail = detail.into();
        }
    }

    /// Absolute pixel centre of node `i` inside `rect`.
    fn center(&self, i: usize, rect: Rect) -> Pos2 {
        let (nx, ny) = self.nodes[i].pos;
        let pad = NODE_R + 6.0;
        let inner = Rect::from_min_max(
            rect.min + vec2(pad, pad),
            rect.max - vec2(pad, pad),
        );
        Pos2::new(
            inner.min.x + nx.clamp(0.0, 1.0) * inner.width().max(1.0),
            inner.min.y + ny.clamp(0.0, 1.0) * inner.height().max(1.0),
        )
    }
}

impl Facet for SystemChart {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut Ui) {
        let th = theme(ui);
        // ── node canvas ──────────────────────────────────────────────────────
        let canvas = vec2(ui.available_width(), self.canvas_h.min(ui.available_height().max(self.canvas_h)));
        let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click());
        let painter = ui.painter_at(rect);

        let centers: Vec<Pos2> = (0..self.nodes.len()).map(|i| self.center(i, rect)).collect();

        // edges first (under the nodes)
        for e in &self.edges {
            if let (Some(ai), Some(bi)) = (self.index_of(&e.a), self.index_of(&e.b)) {
                painter.line_segment([centers[ai], centers[bi]], Stroke::new(1.5, th.edge));
            }
        }

        // nodes
        for (i, node) in self.nodes.iter().enumerate() {
            let c = centers[i];
            let selected = self.selected == Some(i);
            let r = if selected { NODE_R + 3.0 } else { NODE_R };
            painter.circle_filled(c, r, node.color);
            let ring = if selected { th.accent } else { th.node_stroke };
            painter.circle_stroke(c, r, Stroke::new(if selected { 2.5 } else { 1.0 }, ring));
            // badge inside the node
            painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
            // label under the node
            painter.text(
                c + vec2(0.0, r + 2.0),
                Align2::CENTER_TOP,
                &node.label,
                FontId::proportional(11.0),
                th.text,
            );
        }

        // click-select: nearest node within its radius
        if resp.clicked() {
            if let Some(p) = resp.interact_pointer_pos() {
                let hit = centers.iter().enumerate().find(|(_, c)| c.distance(p) <= NODE_R + 4.0).map(|(i, _)| i);
                if let Some(i) = hit {
                    self.selected = if self.selected == Some(i) { None } else { Some(i) };
                }
            }
        }

        // ── inline detail panel ──────────────────────────────────────────────
        ui.separator();
        match self.selected {
            None => {
                ui.weak("Click a node to expand its detail.");
            }
            Some(i) => {
                let node = &self.nodes[i];
                ui.horizontal(|ui| {
                    ui.strong(&node.label);
                    ui.weak(format!("· {} events", node.badge));
                });
                if node.detail.is_empty() {
                    ui.weak("(no detail)");
                } else {
                    for line in node.detail.lines() {
                        ui.monospace(line);
                    }
                }
            }
        }
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "nodes": self.nodes.iter().map(|n| serde_json::json!({
                "id": n.id,
                "label": n.label,
                "badge": n.badge,
                "pos": [n.pos.0, n.pos.1],
                "has_detail": !n.detail.is_empty(),
            })).collect::<Vec<_>>(),
            "edges": self.edges.iter().map(|e| serde_json::json!([e.a, e.b])).collect::<Vec<_>>(),
            "selected": self.selected(),
        })
    }

    fn selection_json(&self) -> serde_json::Value {
        match self.selected() {
            Some(id) => serde_json::json!(id),
            None => serde_json::Value::Null,
        }
    }

    /// Painted with the active `Theme` (nodes/edges/text/accent ring), and its
    /// canvas takes the host's available width — themeable + resizable.
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().selectable()
    }

    /// Opt into typed downcast so a host (e.g. the demo's robot-UI node-select
    /// toolbar) can forward a selection to [`SystemChart::select`] when this chart
    /// lives boxed inside a `FacetDeck`.
    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use facett_core::harness;

    fn sample() -> SystemChart {
        let nodes = vec![
            SysNode::new("pki", "PKI", Color32::from_rgb(120, 200, 255), (0.1, 0.1)).badge(3).detail("issued: a\nissued: b"),
            SysNode::new("oidc", "OIDC", Color32::from_rgb(200, 160, 255), (0.9, 0.1)).badge(7),
            SysNode::new("nexus", "Nexus", Color32::from_rgb(160, 255, 180), (0.5, 0.9)).badge(0),
        ];
        let edges = vec![
            SysEdge::new("pki", "oidc"),
            SysEdge::new("pki", "nexus"),
            SysEdge::new("oidc", "nexus"),
        ];
        SystemChart::new("System Map", nodes, edges)
    }

    #[test]
    fn select_toggles_and_reports() {
        let mut c = sample();
        assert_eq!(c.selected(), None);
        c.select("oidc");
        assert_eq!(c.selected(), Some("oidc"));
        c.select("oidc"); // toggle off
        assert_eq!(c.selected(), None);
        c.select("nope"); // unknown → no-op
        assert_eq!(c.selected(), None);
    }

    #[test]
    fn set_badge_and_detail_mutate_named_node() {
        let mut c = sample();
        c.set_badge("nexus", 42);
        c.set_detail("nexus", "repo: maven-releases");
        let nexus = c.nodes.iter().find(|n| n.id == "nexus").unwrap();
        assert_eq!(nexus.badge, 42);
        assert!(nexus.detail.contains("maven-releases"));
    }

    #[test]
    fn state_json_carries_every_node_edge_and_selection() {
        let mut c = sample();
        c.select("pki");
        let j = c.state_json();
        assert_eq!(j["nodes"].as_array().unwrap().len(), 3);
        assert_eq!(j["edges"].as_array().unwrap().len(), 3);
        assert_eq!(j["selected"], "pki");
        // badge + detail flags surfaced for robot assertions
        let pki = j["nodes"].as_array().unwrap().iter().find(|n| n["id"] == "pki").unwrap();
        assert_eq!(pki["badge"], 3);
        assert_eq!(pki["has_detail"], true);
    }

    #[test]
    fn headless_render_draws_and_selection_shows_detail() {
        // Inject a real chart + a real selection, render offscreen, assert it
        // both DREW pixels and reported the selected node in its state — the
        // inject-input/assert-output law, no display.
        let mut c = sample();
        c.select("pki");
        let r = harness::headless_render(&mut c);
        assert_eq!(r.title, "System Map");
        assert!(r.drew(), "a 3-node chart should tessellate to vertices");
        assert_eq!(r.state["selected"], "pki");
        assert_eq!(r.state["nodes"].as_array().unwrap().len(), 3);
    }

    #[test]
    fn caps_advertise_selectable_themeable_resizable() {
        let caps = sample().caps();
        assert!(caps.selectable);
        assert!(caps.themeable);
        assert!(caps.resizable);
        assert!(!caps.scalable, "syschart has no zoom yet");
    }
}