Skip to main content

facett_syschart/
lib.rs

1//! **facett-syschart** — a **system-map chart**: a handful of *free-positioned*
2//! peer nodes (not a layered DAG) joined by edges, each carrying a **badge**
3//! (a count / status number) and, when selected, an **inline expandable detail
4//! panel**. Click a node to select it; its detail renders in a panel under the
5//! canvas. The host owns *what* a node's detail is (a string here; richer hosts
6//! draw their own widgets after `ui()` keyed off [`SystemChart::selected`]).
7//!
8//! This is the piece [`facett_core::draw`]/[`facett_graph::GraphView`] don't
9//! cover — those paint a *non-interactive* `Scene`; `DepGraphView` is a
10//! *layered* DAG with a fixed deps/dependents drill-down. A "monitoring map" of
11//! a few peer systems (a PKI/OIDC/Nexus triangle, a service mesh) wants
12//! deterministic positions, a per-node badge, click-select, and a free-form
13//! detail panel — that's [`SystemChart`].
14//!
15//! Like every facett component it implements [`Facet`]: `title` / `ui` /
16//! `state_json` (every node, badge, edge + the selection), so it drops into a
17//! `FacetDeck` and is robot-testable through `facett_core::harness` — drive
18//! [`SystemChart::select`] headlessly and assert `state_json`.
19
20use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
21use facett_core::{Facet, FacetCaps, theme};
22
23/// One system in the map. Positions are **normalized** (`0.0..=1.0` of the
24/// canvas), so the layout is resolution-independent and deterministic — ideal
25/// for headless rendering and screenshots.
26#[derive(Clone, Debug)]
27pub struct SysNode {
28    /// Stable id (used for selection + edges). Unique within a chart.
29    pub id: String,
30    /// Human label drawn next to the node.
31    pub label: String,
32    /// Node colour (the host picks the policy — by status, by hash, …).
33    pub color: Color32,
34    /// A small count/status badge drawn on the node (e.g. an event count).
35    pub badge: u64,
36    /// Normalized position in the canvas (`0,0` = top-left, `1,1` = bottom-right).
37    pub pos: (f32, f32),
38    /// Free-form detail shown in the inline panel when this node is selected.
39    /// Hosts that draw richer detail can leave this empty and render their own
40    /// widgets after `ui()` keyed off [`SystemChart::selected`].
41    pub detail: String,
42}
43
44impl SysNode {
45    pub fn new(id: impl Into<String>, label: impl Into<String>, color: Color32, pos: (f32, f32)) -> Self {
46        Self { id: id.into(), label: label.into(), color, badge: 0, pos, detail: String::new() }
47    }
48    pub fn badge(mut self, n: u64) -> Self {
49        self.badge = n;
50        self
51    }
52    pub fn detail(mut self, d: impl Into<String>) -> Self {
53        self.detail = d.into();
54        self
55    }
56}
57
58/// An undirected link between two node ids.
59#[derive(Clone, Debug)]
60pub struct SysEdge {
61    pub a: String,
62    pub b: String,
63}
64
65impl SysEdge {
66    pub fn new(a: impl Into<String>, b: impl Into<String>) -> Self {
67        Self { a: a.into(), b: b.into() }
68    }
69}
70
71/// The system-map chart: nodes + edges + the current selection.
72pub struct SystemChart {
73    pub title: String,
74    pub nodes: Vec<SysNode>,
75    pub edges: Vec<SysEdge>,
76    /// Index into `nodes` of the selected node (drives the detail panel).
77    selected: Option<usize>,
78    /// Height (px) the node canvas takes before the detail panel.
79    canvas_h: f32,
80}
81
82const NODE_R: f32 = 16.0;
83
84impl SystemChart {
85    pub fn new(title: impl Into<String>, nodes: Vec<SysNode>, edges: Vec<SysEdge>) -> Self {
86        Self { title: title.into(), nodes, edges, selected: None, canvas_h: 280.0 }
87    }
88    pub fn with_canvas_height(mut self, h: f32) -> Self {
89        self.canvas_h = h;
90        self
91    }
92
93    fn index_of(&self, id: &str) -> Option<usize> {
94        self.nodes.iter().position(|n| n.id == id)
95    }
96
97    /// Select a node by id (headless-test + host entry point). Selecting the
98    /// already-selected node deselects it. Unknown id is a no-op.
99    pub fn select(&mut self, id: &str) {
100        if let Some(i) = self.index_of(id) {
101            self.selected = if self.selected == Some(i) { None } else { Some(i) };
102        }
103    }
104    /// Clear the selection.
105    pub fn clear_selection(&mut self) {
106        self.selected = None;
107    }
108    /// The selected node's id, if any.
109    pub fn selected(&self) -> Option<&str> {
110        self.selected.and_then(|i| self.nodes.get(i)).map(|n| n.id.as_str())
111    }
112
113    /// Update a node's badge by id (e.g. a live event count). No-op if unknown.
114    pub fn set_badge(&mut self, id: &str, badge: u64) {
115        if let Some(i) = self.index_of(id) {
116            self.nodes[i].badge = badge;
117        }
118    }
119    /// Update a node's detail text by id. No-op if unknown.
120    pub fn set_detail(&mut self, id: &str, detail: impl Into<String>) {
121        if let Some(i) = self.index_of(id) {
122            self.nodes[i].detail = detail.into();
123        }
124    }
125
126    /// Absolute pixel centre of node `i` inside `rect`.
127    fn center(&self, i: usize, rect: Rect) -> Pos2 {
128        let (nx, ny) = self.nodes[i].pos;
129        let pad = NODE_R + 6.0;
130        let inner = Rect::from_min_max(
131            rect.min + vec2(pad, pad),
132            rect.max - vec2(pad, pad),
133        );
134        Pos2::new(
135            inner.min.x + nx.clamp(0.0, 1.0) * inner.width().max(1.0),
136            inner.min.y + ny.clamp(0.0, 1.0) * inner.height().max(1.0),
137        )
138    }
139}
140
141impl Facet for SystemChart {
142    fn title(&self) -> &str {
143        &self.title
144    }
145
146    fn ui(&mut self, ui: &mut Ui) {
147        let th = theme(ui);
148        // ── node canvas ──────────────────────────────────────────────────────
149        let canvas = vec2(ui.available_width(), self.canvas_h.min(ui.available_height().max(self.canvas_h)));
150        let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click());
151        let painter = ui.painter_at(rect);
152
153        let centers: Vec<Pos2> = (0..self.nodes.len()).map(|i| self.center(i, rect)).collect();
154
155        // edges first (under the nodes)
156        for e in &self.edges {
157            if let (Some(ai), Some(bi)) = (self.index_of(&e.a), self.index_of(&e.b)) {
158                painter.line_segment([centers[ai], centers[bi]], Stroke::new(1.5, th.edge));
159            }
160        }
161
162        // nodes
163        for (i, node) in self.nodes.iter().enumerate() {
164            let c = centers[i];
165            let selected = self.selected == Some(i);
166            let r = if selected { NODE_R + 3.0 } else { NODE_R };
167            painter.circle_filled(c, r, node.color);
168            let ring = if selected { th.accent } else { th.node_stroke };
169            painter.circle_stroke(c, r, Stroke::new(if selected { 2.5 } else { 1.0 }, ring));
170            // badge inside the node
171            painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
172            // label under the node
173            painter.text(
174                c + vec2(0.0, r + 2.0),
175                Align2::CENTER_TOP,
176                &node.label,
177                FontId::proportional(11.0),
178                th.text,
179            );
180        }
181
182        // click-select: nearest node within its radius
183        if resp.clicked() {
184            if let Some(p) = resp.interact_pointer_pos() {
185                let hit = centers.iter().enumerate().find(|(_, c)| c.distance(p) <= NODE_R + 4.0).map(|(i, _)| i);
186                if let Some(i) = hit {
187                    self.selected = if self.selected == Some(i) { None } else { Some(i) };
188                }
189            }
190        }
191
192        // ── inline detail panel ──────────────────────────────────────────────
193        ui.separator();
194        match self.selected {
195            None => {
196                ui.weak("Click a node to expand its detail.");
197            }
198            Some(i) => {
199                let node = &self.nodes[i];
200                ui.horizontal(|ui| {
201                    ui.strong(&node.label);
202                    ui.weak(format!("· {} events", node.badge));
203                });
204                if node.detail.is_empty() {
205                    ui.weak("(no detail)");
206                } else {
207                    for line in node.detail.lines() {
208                        ui.monospace(line);
209                    }
210                }
211            }
212        }
213    }
214
215    fn state_json(&self) -> serde_json::Value {
216        serde_json::json!({
217            "nodes": self.nodes.iter().map(|n| serde_json::json!({
218                "id": n.id,
219                "label": n.label,
220                "badge": n.badge,
221                "pos": [n.pos.0, n.pos.1],
222                "has_detail": !n.detail.is_empty(),
223            })).collect::<Vec<_>>(),
224            "edges": self.edges.iter().map(|e| serde_json::json!([e.a, e.b])).collect::<Vec<_>>(),
225            "selected": self.selected(),
226        })
227    }
228
229    fn selection_json(&self) -> serde_json::Value {
230        match self.selected() {
231            Some(id) => serde_json::json!(id),
232            None => serde_json::Value::Null,
233        }
234    }
235
236    /// Painted with the active `Theme` (nodes/edges/text/accent ring), and its
237    /// canvas takes the host's available width — themeable + resizable.
238    fn caps(&self) -> FacetCaps {
239        FacetCaps::NONE.themeable().resizable().selectable()
240    }
241
242    /// Opt into typed downcast so a host (e.g. the demo's robot-UI node-select
243    /// toolbar) can forward a selection to [`SystemChart::select`] when this chart
244    /// lives boxed inside a `FacetDeck`.
245    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
246        Some(self)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use facett_core::harness;
254
255    fn sample() -> SystemChart {
256        let nodes = vec![
257            SysNode::new("pki", "PKI", Color32::from_rgb(120, 200, 255), (0.1, 0.1)).badge(3).detail("issued: a\nissued: b"),
258            SysNode::new("oidc", "OIDC", Color32::from_rgb(200, 160, 255), (0.9, 0.1)).badge(7),
259            SysNode::new("nexus", "Nexus", Color32::from_rgb(160, 255, 180), (0.5, 0.9)).badge(0),
260        ];
261        let edges = vec![
262            SysEdge::new("pki", "oidc"),
263            SysEdge::new("pki", "nexus"),
264            SysEdge::new("oidc", "nexus"),
265        ];
266        SystemChart::new("System Map", nodes, edges)
267    }
268
269    #[test]
270    fn select_toggles_and_reports() {
271        let mut c = sample();
272        assert_eq!(c.selected(), None);
273        c.select("oidc");
274        assert_eq!(c.selected(), Some("oidc"));
275        c.select("oidc"); // toggle off
276        assert_eq!(c.selected(), None);
277        c.select("nope"); // unknown → no-op
278        assert_eq!(c.selected(), None);
279    }
280
281    #[test]
282    fn set_badge_and_detail_mutate_named_node() {
283        let mut c = sample();
284        c.set_badge("nexus", 42);
285        c.set_detail("nexus", "repo: maven-releases");
286        let nexus = c.nodes.iter().find(|n| n.id == "nexus").unwrap();
287        assert_eq!(nexus.badge, 42);
288        assert!(nexus.detail.contains("maven-releases"));
289    }
290
291    #[test]
292    fn state_json_carries_every_node_edge_and_selection() {
293        let mut c = sample();
294        c.select("pki");
295        let j = c.state_json();
296        assert_eq!(j["nodes"].as_array().unwrap().len(), 3);
297        assert_eq!(j["edges"].as_array().unwrap().len(), 3);
298        assert_eq!(j["selected"], "pki");
299        // badge + detail flags surfaced for robot assertions
300        let pki = j["nodes"].as_array().unwrap().iter().find(|n| n["id"] == "pki").unwrap();
301        assert_eq!(pki["badge"], 3);
302        assert_eq!(pki["has_detail"], true);
303    }
304
305    #[test]
306    fn headless_render_draws_and_selection_shows_detail() {
307        // Inject a real chart + a real selection, render offscreen, assert it
308        // both DREW pixels and reported the selected node in its state — the
309        // inject-input/assert-output law, no display.
310        let mut c = sample();
311        c.select("pki");
312        let r = harness::headless_render(&mut c);
313        assert_eq!(r.title, "System Map");
314        assert!(r.drew(), "a 3-node chart should tessellate to vertices");
315        assert_eq!(r.state["selected"], "pki");
316        assert_eq!(r.state["nodes"].as_array().unwrap().len(), 3);
317    }
318
319    #[test]
320    fn caps_advertise_selectable_themeable_resizable() {
321        let caps = sample().caps();
322        assert!(caps.selectable);
323        assert!(caps.themeable);
324        assert!(caps.resizable);
325        assert!(!caps.scalable, "syschart has no zoom yet");
326    }
327}