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, Semantics, a11y_node, 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    /// Stable id of the selected node (drives the detail panel). Keyed on the
77    /// domain id ([`SysNode::id`]), not an enumeration index, so reorder/insert/
78    /// delete preserves the selection (FC-5).
79    selected: Option<String>,
80    /// Height (px) the node canvas takes before the detail panel.
81    canvas_h: f32,
82}
83
84const NODE_R: f32 = 16.0;
85
86impl SystemChart {
87    pub fn new(title: impl Into<String>, nodes: Vec<SysNode>, edges: Vec<SysEdge>) -> Self {
88        Self { title: title.into(), nodes, edges, selected: None, canvas_h: 280.0 }
89    }
90    pub fn with_canvas_height(mut self, h: f32) -> Self {
91        self.canvas_h = h;
92        self
93    }
94
95    fn index_of(&self, id: &str) -> Option<usize> {
96        self.nodes.iter().position(|n| n.id == id)
97    }
98
99    /// Index of the currently-selected node, resolved from its stable id.
100    fn selected_idx(&self) -> Option<usize> {
101        self.selected.as_deref().and_then(|id| self.index_of(id))
102    }
103
104    /// Select a node by id (headless-test + host entry point). Selecting the
105    /// already-selected node deselects it. Unknown id is a no-op.
106    pub fn select(&mut self, id: &str) {
107        if self.index_of(id).is_some() {
108            self.selected = if self.selected.as_deref() == Some(id) {
109                None
110            } else {
111                Some(id.to_string())
112            };
113        }
114    }
115    /// Clear the selection.
116    pub fn clear_selection(&mut self) {
117        self.selected = None;
118    }
119    /// The selected node's id, if any.
120    pub fn selected(&self) -> Option<&str> {
121        self.selected.as_deref()
122    }
123
124    /// Update a node's badge by id (e.g. a live event count). No-op if unknown.
125    pub fn set_badge(&mut self, id: &str, badge: u64) {
126        if let Some(i) = self.index_of(id) {
127            self.nodes[i].badge = badge;
128        }
129    }
130    /// Update a node's detail text by id. No-op if unknown.
131    pub fn set_detail(&mut self, id: &str, detail: impl Into<String>) {
132        if let Some(i) = self.index_of(id) {
133            self.nodes[i].detail = detail.into();
134        }
135    }
136
137    /// Absolute pixel centre of node `i` inside `rect`.
138    fn center(&self, i: usize, rect: Rect) -> Pos2 {
139        let (nx, ny) = self.nodes[i].pos;
140        let pad = NODE_R + 6.0;
141        let inner = Rect::from_min_max(
142            rect.min + vec2(pad, pad),
143            rect.max - vec2(pad, pad),
144        );
145        Pos2::new(
146            inner.min.x + nx.clamp(0.0, 1.0) * inner.width().max(1.0),
147            inner.min.y + ny.clamp(0.0, 1.0) * inner.height().max(1.0),
148        )
149    }
150}
151
152impl Facet for SystemChart {
153    fn title(&self) -> &str {
154        &self.title
155    }
156
157    fn ui(&mut self, ui: &mut Ui) {
158        let th = theme(ui);
159        // ── node canvas ──────────────────────────────────────────────────────
160        let canvas = vec2(ui.available_width(), self.canvas_h.min(ui.available_height().max(self.canvas_h)));
161        let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
162        let base = ui.id().with("syschart-node");
163        let painter = ui.painter_at(rect);
164
165        let centers: Vec<Pos2> = (0..self.nodes.len()).map(|i| self.center(i, rect)).collect();
166
167        // edges first (under the nodes)
168        for e in &self.edges {
169            if let (Some(ai), Some(bi)) = (self.index_of(&e.a), self.index_of(&e.b)) {
170                painter.line_segment([centers[ai], centers[bi]], Stroke::new(1.5, th.edge));
171            }
172        }
173
174        // nodes — paint as before, plus an AccessKit node per element (FC-4) keyed
175        // on the stable domain id (FC-5). Collect the click into a local and apply
176        // selection AFTER the loop to avoid borrowing `self` mutably mid-iteration.
177        let sel_id = self.selected.clone();
178        let mut toggle: Option<String> = None;
179        for (i, node) in self.nodes.iter().enumerate() {
180            let c = centers[i];
181            let selected = sel_id.as_deref() == Some(node.id.as_str());
182            let r = if selected { NODE_R + 3.0 } else { NODE_R };
183            painter.circle_filled(c, r, node.color);
184            let ring = if selected { th.accent } else { th.node_stroke };
185            painter.circle_stroke(c, r, Stroke::new(if selected { 2.5 } else { 1.0 }, ring));
186            // badge inside the node
187            painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
188            // label under the node
189            painter.text(
190                c + vec2(0.0, r + 2.0),
191                Align2::CENTER_TOP,
192                &node.label,
193                FontId::proportional(11.0),
194                th.text,
195            );
196
197            // AccessKit hit-test node over the node's circle (FC-4): role=Button,
198            // label = "{label} · {badge} events", numeric_value = badge, selected.
199            let node_rect = Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r));
200            let label = format!("{} · {} events", node.label, node.badge);
201            let hit = a11y_node(
202                ui,
203                base,
204                &node.id,
205                Sense::click(),
206                node_rect,
207                Semantics::button(label).value(node.badge as f64).selected(selected),
208            );
209            if hit.clicked() {
210                toggle = Some(node.id.clone());
211            }
212        }
213
214        // click-select: toggle the clicked node by its stable id.
215        if let Some(id) = toggle {
216            self.select(&id);
217        }
218
219        // ── inline detail panel ──────────────────────────────────────────────
220        ui.separator();
221        match self.selected_idx() {
222            None => {
223                ui.weak("Click a node to expand its detail.");
224            }
225            Some(i) => {
226                let node = &self.nodes[i];
227                ui.horizontal(|ui| {
228                    ui.strong(&node.label);
229                    ui.weak(format!("· {} events", node.badge));
230                });
231                if node.detail.is_empty() {
232                    ui.weak("(no detail)");
233                } else {
234                    for line in node.detail.lines() {
235                        ui.monospace(line);
236                    }
237                }
238            }
239        }
240    }
241
242    fn state_json(&self) -> serde_json::Value {
243        serde_json::json!({
244            "nodes": self.nodes.iter().map(|n| serde_json::json!({
245                "id": n.id,
246                "label": n.label,
247                "badge": n.badge,
248                "pos": [n.pos.0, n.pos.1],
249                "has_detail": !n.detail.is_empty(),
250            })).collect::<Vec<_>>(),
251            "edges": self.edges.iter().map(|e| serde_json::json!([e.a, e.b])).collect::<Vec<_>>(),
252            "selected": self.selected(),
253        })
254    }
255
256    fn selection_json(&self) -> serde_json::Value {
257        match self.selected() {
258            Some(id) => serde_json::json!(id),
259            None => serde_json::Value::Null,
260        }
261    }
262
263    /// Painted with the active `Theme` (nodes/edges/text/accent ring), and its
264    /// canvas takes the host's available width — themeable + resizable.
265    fn caps(&self) -> FacetCaps {
266        FacetCaps::NONE.themeable().resizable().selectable()
267    }
268
269    /// Opt into typed downcast so a host (e.g. the demo's robot-UI node-select
270    /// toolbar) can forward a selection to [`SystemChart::select`] when this chart
271    /// lives boxed inside a `FacetDeck`.
272    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
273        Some(self)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use facett_core::harness;
281
282    fn sample() -> SystemChart {
283        let nodes = vec![
284            SysNode::new("pki", "PKI", Color32::from_rgb(120, 200, 255), (0.1, 0.1)).badge(3).detail("issued: a\nissued: b"),
285            SysNode::new("oidc", "OIDC", Color32::from_rgb(200, 160, 255), (0.9, 0.1)).badge(7),
286            SysNode::new("nexus", "Nexus", Color32::from_rgb(160, 255, 180), (0.5, 0.9)).badge(0),
287        ];
288        let edges = vec![
289            SysEdge::new("pki", "oidc"),
290            SysEdge::new("pki", "nexus"),
291            SysEdge::new("oidc", "nexus"),
292        ];
293        SystemChart::new("System Map", nodes, edges)
294    }
295
296    #[test]
297    fn select_toggles_and_reports() {
298        let mut c = sample();
299        assert_eq!(c.selected(), None);
300        c.select("oidc");
301        assert_eq!(c.selected(), Some("oidc"));
302        c.select("oidc"); // toggle off
303        assert_eq!(c.selected(), None);
304        c.select("nope"); // unknown → no-op
305        assert_eq!(c.selected(), None);
306    }
307
308    #[test]
309    fn set_badge_and_detail_mutate_named_node() {
310        let mut c = sample();
311        c.set_badge("nexus", 42);
312        c.set_detail("nexus", "repo: maven-releases");
313        let nexus = c.nodes.iter().find(|n| n.id == "nexus").unwrap();
314        assert_eq!(nexus.badge, 42);
315        assert!(nexus.detail.contains("maven-releases"));
316    }
317
318    #[test]
319    fn state_json_carries_every_node_edge_and_selection() {
320        let mut c = sample();
321        c.select("pki");
322        let j = c.state_json();
323        assert_eq!(j["nodes"].as_array().unwrap().len(), 3);
324        assert_eq!(j["edges"].as_array().unwrap().len(), 3);
325        assert_eq!(j["selected"], "pki");
326        // badge + detail flags surfaced for robot assertions
327        let pki = j["nodes"].as_array().unwrap().iter().find(|n| n["id"] == "pki").unwrap();
328        assert_eq!(pki["badge"], 3);
329        assert_eq!(pki["has_detail"], true);
330    }
331
332    #[test]
333    fn headless_render_draws_and_selection_shows_detail() {
334        // Inject a real chart + a real selection, render offscreen, assert it
335        // both DREW pixels and reported the selected node in its state — the
336        // inject-input/assert-output law, no display.
337        let mut c = sample();
338        c.select("pki");
339        let r = harness::headless_render(&mut c);
340        assert_eq!(r.title, "System Map");
341        assert!(r.drew(), "a 3-node chart should tessellate to vertices");
342        assert_eq!(r.state["selected"], "pki");
343        assert_eq!(r.state["nodes"].as_array().unwrap().len(), 3);
344    }
345
346    #[test]
347    fn caps_advertise_selectable_themeable_resizable() {
348        let caps = sample().caps();
349        assert!(caps.selectable);
350        assert!(caps.themeable);
351        assert!(caps.resizable);
352        assert!(!caps.scalable, "syschart has no zoom yet");
353    }
354}