1use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
21use facett_core::{Facet, FacetCaps, Semantics, a11y_node, 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<String>,
80 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 fn selected_idx(&self) -> Option<usize> {
101 self.selected.as_deref().and_then(|id| self.index_of(id))
102 }
103
104 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 pub fn clear_selection(&mut self) {
117 self.selected = None;
118 }
119 pub fn selected(&self) -> Option<&str> {
121 self.selected.as_deref()
122 }
123
124 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 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 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 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 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 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 painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
188 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 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 if let Some(id) = toggle {
216 self.select(&id);
217 }
218
219 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 fn caps(&self) -> FacetCaps {
266 FacetCaps::NONE.themeable().resizable().selectable()
267 }
268
269 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"); assert_eq!(c.selected(), None);
304 c.select("nope"); 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 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 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}