1use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
21use facett_core::{Facet, FacetCaps, theme};
22
23#[derive(Clone, Debug)]
27pub struct SysNode {
28 pub id: String,
30 pub label: String,
32 pub color: Color32,
34 pub badge: u64,
36 pub pos: (f32, f32),
38 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#[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
71pub struct SystemChart {
73 pub title: String,
74 pub nodes: Vec<SysNode>,
75 pub edges: Vec<SysEdge>,
76 selected: Option<usize>,
78 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 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 pub fn clear_selection(&mut self) {
106 self.selected = None;
107 }
108 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 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 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 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 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 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 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 painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
172 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 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 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 fn caps(&self) -> FacetCaps {
239 FacetCaps::NONE.themeable().resizable().selectable()
240 }
241
242 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"); assert_eq!(c.selected(), None);
277 c.select("nope"); 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 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 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}