1use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
21use facett_core::clip::{ClipKind, ClipPayload, CopySource};
22use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
23
24#[derive(Clone, Debug)]
28pub struct SysNode {
29 pub id: String,
31 pub label: String,
33 pub color: Color32,
35 pub badge: u64,
37 pub pos: (f32, f32),
39 pub detail: String,
43}
44
45impl SysNode {
46 pub fn new(id: impl Into<String>, label: impl Into<String>, color: Color32, pos: (f32, f32)) -> Self {
47 Self { id: id.into(), label: label.into(), color, badge: 0, pos, detail: String::new() }
48 }
49 pub fn badge(mut self, n: u64) -> Self {
50 self.badge = n;
51 self
52 }
53 pub fn detail(mut self, d: impl Into<String>) -> Self {
54 self.detail = d.into();
55 self
56 }
57}
58
59#[derive(Clone, Debug)]
61pub struct SysEdge {
62 pub a: String,
63 pub b: String,
64}
65
66impl SysEdge {
67 pub fn new(a: impl Into<String>, b: impl Into<String>) -> Self {
68 Self { a: a.into(), b: b.into() }
69 }
70}
71
72pub struct SystemChart {
74 pub title: String,
75 pub nodes: Vec<SysNode>,
76 pub edges: Vec<SysEdge>,
77 selected: Option<String>,
81 canvas_h: f32,
83}
84
85const NODE_R: f32 = 16.0;
86
87impl SystemChart {
88 pub fn new(title: impl Into<String>, nodes: Vec<SysNode>, edges: Vec<SysEdge>) -> Self {
89 Self { title: title.into(), nodes, edges, selected: None, canvas_h: 280.0 }
90 }
91 pub fn with_canvas_height(mut self, h: f32) -> Self {
92 self.canvas_h = h;
93 self
94 }
95
96 fn index_of(&self, id: &str) -> Option<usize> {
97 self.nodes.iter().position(|n| n.id == id)
98 }
99
100 fn selected_idx(&self) -> Option<usize> {
102 self.selected.as_deref().and_then(|id| self.index_of(id))
103 }
104
105 pub fn select(&mut self, id: &str) {
108 if self.index_of(id).is_some() {
109 self.selected = if self.selected.as_deref() == Some(id) {
110 None
111 } else {
112 Some(id.to_string())
113 };
114 }
115 }
116 pub fn clear_selection(&mut self) {
118 self.selected = None;
119 }
120 pub fn selected(&self) -> Option<&str> {
122 self.selected.as_deref()
123 }
124
125 pub fn set_badge(&mut self, id: &str, badge: u64) {
127 if let Some(i) = self.index_of(id) {
128 self.nodes[i].badge = badge;
129 }
130 }
131 pub fn set_detail(&mut self, id: &str, detail: impl Into<String>) {
133 if let Some(i) = self.index_of(id) {
134 self.nodes[i].detail = detail.into();
135 }
136 }
137
138 pub fn node_center(&self, i: usize, rect: Rect) -> Pos2 {
146 self.center(i, rect)
147 }
148 pub const NODE_R: f32 = NODE_R;
151
152 fn center(&self, i: usize, rect: Rect) -> Pos2 {
154 let (nx, ny) = self.nodes[i].pos;
155 let pad = NODE_R + 6.0;
156 let inner = Rect::from_min_max(
157 rect.min + vec2(pad, pad),
158 rect.max - vec2(pad, pad),
159 );
160 Pos2::new(
161 inner.min.x + nx.clamp(0.0, 1.0) * inner.width().max(1.0),
162 inner.min.y + ny.clamp(0.0, 1.0) * inner.height().max(1.0),
163 )
164 }
165}
166
167impl SystemChart {
168 pub fn copy_text(&self) -> Option<String> {
172 if self.nodes.is_empty() {
173 return None;
174 }
175 if let Some(n) = self.selected().and_then(|id| self.nodes.iter().find(|n| n.id == id)) {
176 let mut s = format!("{} (badge {})", n.label, n.badge);
177 if !n.detail.is_empty() {
178 s.push('\n');
179 s.push_str(&n.detail);
180 }
181 return Some(s);
182 }
183 let mut out = String::from("id\tlabel\tbadge");
184 for n in &self.nodes {
185 out.push('\n');
186 out.push_str(&format!("{}\t{}\t{}", n.id, n.label, n.badge));
187 }
188 Some(out)
189 }
190}
191
192impl CopySource for SystemChart {
194 fn copy_kinds(&self) -> &[ClipKind] {
195 &[ClipKind::Text]
196 }
197
198 fn copy_payload(&self) -> Option<ClipPayload> {
199 self.copy_text().map(ClipPayload::Text)
200 }
201}
202
203impl Facet for SystemChart {
204 fn title(&self) -> &str {
205 &self.title
206 }
207
208 fn copy(&mut self) -> Option<String> {
209 self.copy_payload().map(|p| p.as_text())
210 }
211
212 fn ui(&mut self, ui: &mut Ui) {
213 let th = theme(ui);
214 let canvas = vec2(ui.available_width(), self.canvas_h.min(ui.available_height().max(self.canvas_h)));
216 let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
217 let base = ui.id().with("syschart-node");
218 let painter = ui.painter_at(rect);
219
220 let centers: Vec<Pos2> = (0..self.nodes.len()).map(|i| self.center(i, rect)).collect();
221
222 for e in &self.edges {
224 if let (Some(ai), Some(bi)) = (self.index_of(&e.a), self.index_of(&e.b)) {
225 painter.line_segment([centers[ai], centers[bi]], Stroke::new(1.5, th.edge));
226 }
227 }
228
229 let sel_id = self.selected.clone();
233 let mut toggle: Option<String> = None;
234 for (i, node) in self.nodes.iter().enumerate() {
235 let c = centers[i];
236 let selected = sel_id.as_deref() == Some(node.id.as_str());
237 let r = if selected { NODE_R + 3.0 } else { NODE_R };
238 facett_core::look::elevation_shadow(ui, Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r)), r);
242 painter.circle_filled(c, r, node.color);
243 let ring = if selected { th.accent } else { th.node_stroke };
244 painter.circle_stroke(c, r, Stroke::new(if selected { 2.5 } else { 1.0 }, ring));
245 painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
247 painter.text(
249 c + vec2(0.0, r + 2.0),
250 Align2::CENTER_TOP,
251 &node.label,
252 FontId::proportional(11.0),
253 th.text,
254 );
255
256 let node_rect = Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r));
259 facett_core::look::reveal_on_hover(ui, node_rect, r);
264 facett_core::look::apply_focus_ring(ui, node_rect, selected, r);
265 let label = format!("{} · {} events", node.label, node.badge);
266 let hit = a11y_node(
267 ui,
268 base,
269 &node.id,
270 Sense::click(),
271 node_rect,
272 Semantics::button(label).value(node.badge as f64).selected(selected),
273 );
274 if hit.clicked() {
275 toggle = Some(node.id.clone());
276 }
277 }
278
279 if let Some(id) = toggle {
281 self.select(&id);
282 }
283
284 ui.separator();
286 match self.selected_idx() {
287 None => {
288 ui.weak("Click a node to expand its detail.");
289 }
290 Some(i) => {
291 let node = &self.nodes[i];
292 ui.horizontal(|ui| {
293 ui.strong(&node.label);
294 ui.weak(format!("· {} events", node.badge));
295 });
296 if node.detail.is_empty() {
297 ui.weak("(no detail)");
298 } else {
299 for line in node.detail.lines() {
300 ui.monospace(line);
301 }
302 }
303 }
304 }
305
306 #[cfg(feature = "testmatrix")]
308 facett_core::testmatrix::emit(
309 "facett-syschart::SystemChart::ui",
310 "ui_render",
311 !self.nodes.is_empty(),
312 &format!("nodes={} edges={}", self.nodes.len(), self.edges.len()),
313 );
314 }
315
316 fn state_json(&self) -> serde_json::Value {
317 serde_json::json!({
318 "nodes": self.nodes.iter().map(|n| serde_json::json!({
319 "id": n.id,
320 "label": n.label,
321 "badge": n.badge,
322 "pos": [n.pos.0, n.pos.1],
323 "has_detail": !n.detail.is_empty(),
324 })).collect::<Vec<_>>(),
325 "edges": self.edges.iter().map(|e| serde_json::json!([e.a, e.b])).collect::<Vec<_>>(),
326 "selected": self.selected(),
327 })
328 }
329
330 fn selection_json(&self) -> serde_json::Value {
331 match self.selected() {
332 Some(id) => serde_json::json!(id),
333 None => serde_json::Value::Null,
334 }
335 }
336
337 fn caps(&self) -> FacetCaps {
340 FacetCaps::NONE.themeable().resizable().selectable().copyable()
341 }
342
343 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
347 Some(self)
348 }
349}
350
351#[cfg(test)]
352mod tests {
353
354 #[test]
355 fn typed_copy_is_selected_node_or_node_list_rows() {
356 use facett_core::clip::{ClipKind, CopySource};
357 let mut c = SystemChart::new(
358 "sys",
359 vec![
360 SysNode::new("pki", "PKI", Color32::WHITE, (0.1, 0.1)).badge(3),
361 SysNode::new("oidc", "OIDC", Color32::WHITE, (0.9, 0.1)).badge(7),
362 ],
363 vec![SysEdge::new("pki", "oidc")],
364 );
365 let p = c.copy_payload().unwrap();
367 assert_eq!(p.kind(), ClipKind::Text);
368 assert!(p.as_text().starts_with("id\tlabel\tbadge"), "{}", p.as_text());
369 assert!(p.as_text().contains("\npki\tPKI\t3"));
370 c.select("oidc");
372 assert_eq!(c.copy_payload().unwrap().as_text(), "OIDC (badge 7)");
373 assert!(SystemChart::new("empty", vec![], vec![]).copy_payload().is_none());
374 }
375
376 use super::*;
377 use facett_core::harness;
378
379 fn sample() -> SystemChart {
380 let nodes = vec![
381 SysNode::new("pki", "PKI", Color32::from_rgb(120, 200, 255), (0.1, 0.1)).badge(3).detail("issued: a\nissued: b"),
382 SysNode::new("oidc", "OIDC", Color32::from_rgb(200, 160, 255), (0.9, 0.1)).badge(7),
383 SysNode::new("nexus", "Nexus", Color32::from_rgb(160, 255, 180), (0.5, 0.9)).badge(0),
384 ];
385 let edges = vec![
386 SysEdge::new("pki", "oidc"),
387 SysEdge::new("pki", "nexus"),
388 SysEdge::new("oidc", "nexus"),
389 ];
390 SystemChart::new("System Map", nodes, edges)
391 }
392
393 #[test]
394 fn select_toggles_and_reports() {
395 let mut c = sample();
396 assert_eq!(c.selected(), None);
397 c.select("oidc");
398 assert_eq!(c.selected(), Some("oidc"));
399 c.select("oidc"); assert_eq!(c.selected(), None);
401 c.select("nope"); assert_eq!(c.selected(), None);
403 }
404
405 #[test]
406 fn set_badge_and_detail_mutate_named_node() {
407 let mut c = sample();
408 c.set_badge("nexus", 42);
409 c.set_detail("nexus", "repo: maven-releases");
410 let nexus = c.nodes.iter().find(|n| n.id == "nexus").unwrap();
411 assert_eq!(nexus.badge, 42);
412 assert!(nexus.detail.contains("maven-releases"));
413 }
414
415 #[test]
416 fn state_json_carries_every_node_edge_and_selection() {
417 let mut c = sample();
418 c.select("pki");
419 let j = c.state_json();
420 assert_eq!(j["nodes"].as_array().unwrap().len(), 3);
421 assert_eq!(j["edges"].as_array().unwrap().len(), 3);
422 assert_eq!(j["selected"], "pki");
423 let pki = j["nodes"].as_array().unwrap().iter().find(|n| n["id"] == "pki").unwrap();
425 assert_eq!(pki["badge"], 3);
426 assert_eq!(pki["has_detail"], true);
427 }
428
429 #[test]
430 fn headless_render_draws_and_selection_shows_detail() {
431 let mut c = sample();
435 c.select("pki");
436 let r = harness::headless_render(&mut c);
437 assert_eq!(r.title, "System Map");
438 assert!(r.drew(), "a 3-node chart should tessellate to vertices");
439 assert_eq!(r.state["selected"], "pki");
440 assert_eq!(r.state["nodes"].as_array().unwrap().len(), 3);
441 }
442
443 #[test]
444 fn caps_advertise_selectable_themeable_resizable() {
445 let caps = sample().caps();
446 assert!(caps.selectable);
447 assert!(caps.themeable);
448 assert!(caps.resizable);
449 assert!(!caps.scalable, "syschart has no zoom yet");
450 }
451}