1use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
27use facett_core::clip::{ClipKind, ClipPayload, CopySource};
28use facett_core::{FacetCaps, Semantics, a11y_node, theme};
29use serde::{Deserialize, Serialize};
30
31#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct SysNode {
36 pub id: String,
38 pub label: String,
40 pub color: Color32,
42 pub badge: u64,
44 pub pos: (f32, f32),
46 pub detail: String,
50}
51
52impl SysNode {
53 pub fn new(id: impl Into<String>, label: impl Into<String>, color: Color32, pos: (f32, f32)) -> Self {
54 Self { id: id.into(), label: label.into(), color, badge: 0, pos, detail: String::new() }
55 }
56 pub fn badge(mut self, n: u64) -> Self {
57 self.badge = n;
58 self
59 }
60 pub fn detail(mut self, d: impl Into<String>) -> Self {
61 self.detail = d.into();
62 self
63 }
64}
65
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
68pub struct SysEdge {
69 pub a: String,
70 pub b: String,
71}
72
73impl SysEdge {
74 pub fn new(a: impl Into<String>, b: impl Into<String>) -> Self {
75 Self { a: a.into(), b: b.into() }
76 }
77}
78
79#[derive(Clone, Debug, PartialEq)]
84pub enum Msg {
85 Select(String),
88 ClearSelection,
90 SetBadge(String, u64),
92 SetDetail(String, String),
94}
95
96#[derive(Clone, Debug, PartialEq)]
101pub enum Effect {}
102
103#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
110pub struct SysChartModel {
111 pub nodes: Vec<SysNode>,
113 pub edges: Vec<SysEdge>,
115 pub selected: Option<String>,
118 pub canvas_h: f32,
120}
121
122pub struct SystemChart {
126 pub title: String,
128 pub state: SysChartModel,
130}
131
132const NODE_R: f32 = 16.0;
133
134impl SystemChart {
135 pub fn new(title: impl Into<String>, nodes: Vec<SysNode>, edges: Vec<SysEdge>) -> Self {
136 Self {
137 title: title.into(),
138 state: SysChartModel { nodes, edges, selected: None, canvas_h: 280.0 },
139 }
140 }
141 pub fn with_canvas_height(mut self, h: f32) -> Self {
142 self.state.canvas_h = h;
143 self
144 }
145
146 pub fn from_layers(title: impl Into<String>, layers: Vec<Vec<SysNode>>, edges: Vec<SysEdge>) -> Self {
154 let n_layers = layers.len().max(1) as f32;
155 let mut nodes = Vec::new();
156 for (li, layer) in layers.into_iter().enumerate() {
157 let y = if n_layers <= 1.0 { 0.5 } else { 0.12 + (li as f32) * (0.76 / (n_layers - 1.0)) };
159 let count = layer.len().max(1) as f32;
160 for (ni, mut node) in layer.into_iter().enumerate() {
161 let x = if count <= 1.0 { 0.5 } else { 0.1 + (ni as f32) * (0.8 / (count - 1.0)) };
162 node.pos = (x, y);
163 nodes.push(node);
164 }
165 }
166 Self::new(title, nodes, edges)
167 }
168
169 #[must_use]
174 pub fn demo() -> Self {
175 let c = Color32::from_rgb;
176 let layers = vec![
177 vec![SysNode::new("gateway", "Gateway", c(120, 200, 255), (0.0, 0.0)).badge(2).detail("ingress · TLS terminate")],
178 vec![
179 SysNode::new("korp", "korp", c(200, 160, 255), (0.0, 0.0)).badge(5).detail("case + analysis warehouse"),
180 SysNode::new("nornir", "nornir", c(160, 255, 180), (0.0, 0.0)).badge(3).detail("orchestrator + test matrix"),
181 SysNode::new("holger", "holger", c(255, 200, 140), (0.0, 0.0)).badge(1).detail("registry browser"),
182 ],
183 vec![
184 SysNode::new("skade", "skade", c(140, 190, 255), (0.0, 0.0)).badge(8).detail("lakehouse warehouse"),
185 SysNode::new("iceberg", "Iceberg", c(180, 220, 160), (0.0, 0.0)).badge(0).detail("table format"),
186 ],
187 ];
188 let edges = vec![
189 SysEdge::new("gateway", "korp"),
190 SysEdge::new("gateway", "nornir"),
191 SysEdge::new("korp", "holger"),
192 SysEdge::new("korp", "skade"),
193 SysEdge::new("nornir", "skade"),
194 SysEdge::new("skade", "iceberg"),
195 ];
196 Self::from_layers("System Map", layers, edges)
197 }
198
199 pub fn nodes(&self) -> &[SysNode] {
201 &self.state.nodes
202 }
203 pub fn edges(&self) -> &[SysEdge] {
205 &self.state.edges
206 }
207
208 fn index_of(&self, id: &str) -> Option<usize> {
209 self.state.nodes.iter().position(|n| n.id == id)
210 }
211
212 fn selected_idx(&self) -> Option<usize> {
214 self.state.selected.as_deref().and_then(|id| self.index_of(id))
215 }
216
217 pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
221 match msg {
222 Msg::Select(id) => {
223 if self.index_of(&id).is_some() {
224 self.state.selected = if self.state.selected.as_deref() == Some(id.as_str()) {
225 None
226 } else {
227 Some(id)
228 };
229 }
230 }
231 Msg::ClearSelection => self.state.selected = None,
232 Msg::SetBadge(id, badge) => {
233 if let Some(i) = self.index_of(&id) {
234 self.state.nodes[i].badge = badge;
235 }
236 }
237 Msg::SetDetail(id, detail) => {
238 if let Some(i) = self.index_of(&id) {
239 self.state.nodes[i].detail = detail;
240 }
241 }
242 }
243 Vec::new()
244 }
245
246 pub fn select(&mut self, id: &str) {
250 let _ = self.update(Msg::Select(id.to_string()));
251 }
252 pub fn clear_selection(&mut self) {
254 let _ = self.update(Msg::ClearSelection);
255 }
256 pub fn selected(&self) -> Option<&str> {
258 self.state.selected.as_deref()
259 }
260
261 pub fn set_badge(&mut self, id: &str, badge: u64) {
264 let _ = self.update(Msg::SetBadge(id.to_string(), badge));
265 }
266 pub fn set_detail(&mut self, id: &str, detail: impl Into<String>) {
269 let _ = self.update(Msg::SetDetail(id.to_string(), detail.into()));
270 }
271
272 pub fn node_center(&self, i: usize, rect: Rect) -> Pos2 {
280 self.center(i, rect)
281 }
282 pub const NODE_R: f32 = NODE_R;
285
286 fn center(&self, i: usize, rect: Rect) -> Pos2 {
288 let (nx, ny) = self.state.nodes[i].pos;
289 let pad = NODE_R + 6.0;
290 let inner = Rect::from_min_max(
291 rect.min + vec2(pad, pad),
292 rect.max - vec2(pad, pad),
293 );
294 Pos2::new(
295 inner.min.x + nx.clamp(0.0, 1.0) * inner.width().max(1.0),
296 inner.min.y + ny.clamp(0.0, 1.0) * inner.height().max(1.0),
297 )
298 }
299}
300
301impl SystemChart {
302 pub fn copy_text(&self) -> Option<String> {
306 if self.state.nodes.is_empty() {
307 return None;
308 }
309 if let Some(n) = self.selected().and_then(|id| self.state.nodes.iter().find(|n| n.id == id)) {
310 let mut s = format!("{} (badge {})", n.label, n.badge);
311 if !n.detail.is_empty() {
312 s.push('\n');
313 s.push_str(&n.detail);
314 }
315 return Some(s);
316 }
317 let mut out = String::from("id\tlabel\tbadge");
318 for n in &self.state.nodes {
319 out.push('\n');
320 out.push_str(&format!("{}\t{}\t{}", n.id, n.label, n.badge));
321 }
322 Some(out)
323 }
324
325 pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
331 let mut msgs: Vec<Msg> = Vec::new();
332 let th = theme(ui);
333 let canvas = vec2(
335 ui.available_width(),
336 self.state.canvas_h.min(ui.available_height().max(self.state.canvas_h)),
337 );
338 let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
339 let base = ui.id().with("syschart-node");
340 let painter = ui.painter_at(rect);
341
342 let centers: Vec<Pos2> = (0..self.state.nodes.len()).map(|i| self.center(i, rect)).collect();
343
344 for e in &self.state.edges {
346 if let (Some(ai), Some(bi)) = (self.index_of(&e.a), self.index_of(&e.b)) {
347 painter.line_segment([centers[ai], centers[bi]], Stroke::new(1.5, th.edge));
348 }
349 }
350
351 let sel_id = self.state.selected.clone();
356 for (i, node) in self.state.nodes.iter().enumerate() {
357 let c = centers[i];
358 let selected = sel_id.as_deref() == Some(node.id.as_str());
359 let r = if selected { NODE_R + 3.0 } else { NODE_R };
360 facett_core::look::elevation_shadow(ui, Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r)), r);
364 painter.circle_filled(c, r, node.color);
365 let ring = if selected { th.accent } else { th.node_stroke };
366 painter.circle_stroke(c, r, Stroke::new(if selected { 2.5 } else { 1.0 }, ring));
367 painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
369 painter.text(
371 c + vec2(0.0, r + 2.0),
372 Align2::CENTER_TOP,
373 &node.label,
374 FontId::proportional(11.0),
375 th.text,
376 );
377
378 let node_rect = Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r));
381 facett_core::look::reveal_on_hover(ui, node_rect, r);
386 facett_core::look::apply_focus_ring(ui, node_rect, selected, r);
387 let label = format!("{} · {} events", node.label, node.badge);
388 let hit = a11y_node(
389 ui,
390 base,
391 &node.id,
392 Sense::click(),
393 node_rect,
394 Semantics::button(label).value(node.badge as f64).selected(selected),
395 );
396 if hit.clicked() {
397 msgs.push(Msg::Select(node.id.clone()));
398 }
399 }
400
401 ui.separator();
403 match self.selected_idx() {
404 None => {
405 ui.weak("Click a node to expand its detail.");
406 }
407 Some(i) => {
408 let node = &self.state.nodes[i];
409 ui.horizontal(|ui| {
410 ui.strong(&node.label);
411 ui.weak(format!("· {} events", node.badge));
412 });
413 if node.detail.is_empty() {
414 ui.weak("(no detail)");
415 } else {
416 for line in node.detail.lines() {
417 ui.monospace(line);
418 }
419 }
420 }
421 }
422
423 #[cfg(feature = "testmatrix")]
425 facett_core::testmatrix::emit(
426 "facett-syschart::SystemChart::view",
427 "ui_render",
428 !self.state.nodes.is_empty(),
429 &format!("nodes={} edges={}", self.state.nodes.len(), self.state.edges.len()),
430 );
431
432 msgs
433 }
434}
435
436impl CopySource for SystemChart {
438 fn copy_kinds(&self) -> &[ClipKind] {
439 &[ClipKind::Text]
440 }
441
442 fn copy_payload(&self) -> Option<ClipPayload> {
443 self.copy_text().map(ClipPayload::Text)
444 }
445}
446
447impl facett_core::Elm for SystemChart {
449 type Model = SysChartModel;
450 type Msg = Msg;
451 type Effect = Effect;
452
453 fn title(&self) -> &str {
454 &self.title
455 }
456 fn state(&self) -> &SysChartModel {
457 &self.state
458 }
459 fn update(&mut self, msg: Msg) -> Vec<Effect> {
460 SystemChart::update(self, msg)
461 }
462 fn view(&self, ui: &mut Ui) -> Vec<Msg> {
463 SystemChart::view(self, ui)
464 }
465}
466
467facett_core::impl_facet_via_elm!(SystemChart, custom_state_json, {
474 fn copy(&mut self) -> Option<String> {
475 self.copy_payload().map(|p| p.as_text())
476 }
477
478 fn state_json(&self) -> serde_json::Value {
479 let s = &self.state;
480 serde_json::json!({
481 "nodes": s.nodes.iter().map(|n| serde_json::json!({
482 "id": n.id,
483 "label": n.label,
484 "badge": n.badge,
485 "pos": [n.pos.0, n.pos.1],
486 "has_detail": !n.detail.is_empty(),
487 })).collect::<Vec<_>>(),
488 "edges": s.edges.iter().map(|e| serde_json::json!([e.a, e.b])).collect::<Vec<_>>(),
489 "selected": self.selected(),
490 })
491 }
492
493 fn selection_json(&self) -> serde_json::Value {
494 match self.selected() {
495 Some(id) => serde_json::json!(id),
496 None => serde_json::Value::Null,
497 }
498 }
499
500 fn caps(&self) -> FacetCaps {
503 FacetCaps::NONE.themeable().resizable().selectable().copyable()
504 }
505
506 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
510 Some(self)
511 }
512});
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use facett_core::{Elm, Facet, harness};
519
520 #[test]
521 fn typed_copy_is_selected_node_or_node_list_rows() {
522 use facett_core::clip::{ClipKind, CopySource};
523 let mut c = SystemChart::new(
524 "sys",
525 vec![
526 SysNode::new("pki", "PKI", Color32::WHITE, (0.1, 0.1)).badge(3),
527 SysNode::new("oidc", "OIDC", Color32::WHITE, (0.9, 0.1)).badge(7),
528 ],
529 vec![SysEdge::new("pki", "oidc")],
530 );
531 let p = c.copy_payload().unwrap();
533 assert_eq!(p.kind(), ClipKind::Text);
534 assert!(p.as_text().starts_with("id\tlabel\tbadge"), "{}", p.as_text());
535 assert!(p.as_text().contains("\npki\tPKI\t3"));
536 c.select("oidc");
538 assert_eq!(c.copy_payload().unwrap().as_text(), "OIDC (badge 7)");
539 assert!(SystemChart::new("empty", vec![], vec![]).copy_payload().is_none());
540 }
541
542 fn sample() -> SystemChart {
543 let nodes = vec![
544 SysNode::new("pki", "PKI", Color32::from_rgb(120, 200, 255), (0.1, 0.1)).badge(3).detail("issued: a\nissued: b"),
545 SysNode::new("oidc", "OIDC", Color32::from_rgb(200, 160, 255), (0.9, 0.1)).badge(7),
546 SysNode::new("nexus", "Nexus", Color32::from_rgb(160, 255, 180), (0.5, 0.9)).badge(0),
547 ];
548 let edges = vec![
549 SysEdge::new("pki", "oidc"),
550 SysEdge::new("pki", "nexus"),
551 SysEdge::new("oidc", "nexus"),
552 ];
553 SystemChart::new("System Map", nodes, edges)
554 }
555
556 #[test]
557 fn select_toggles_and_reports() {
558 let mut c = sample();
559 assert_eq!(c.selected(), None);
560 c.select("oidc");
561 assert_eq!(c.selected(), Some("oidc"));
562 c.select("oidc"); assert_eq!(c.selected(), None);
564 c.select("nope"); assert_eq!(c.selected(), None);
566 }
567
568 #[test]
569 fn set_badge_and_detail_mutate_named_node() {
570 let mut c = sample();
571 c.set_badge("nexus", 42);
572 c.set_detail("nexus", "repo: maven-releases");
573 let nexus = c.nodes().iter().find(|n| n.id == "nexus").unwrap();
574 assert_eq!(nexus.badge, 42);
575 assert!(nexus.detail.contains("maven-releases"));
576 }
577
578 #[test]
579 fn state_json_carries_every_node_edge_and_selection() {
580 let mut c = sample();
581 c.select("pki");
582 let j = c.state_json();
583 assert_eq!(j["nodes"].as_array().unwrap().len(), 3);
584 assert_eq!(j["edges"].as_array().unwrap().len(), 3);
585 assert_eq!(j["selected"], "pki");
586 let pki = j["nodes"].as_array().unwrap().iter().find(|n| n["id"] == "pki").unwrap();
588 assert_eq!(pki["badge"], 3);
589 assert_eq!(pki["has_detail"], true);
590 }
591
592 #[test]
593 fn headless_render_draws_and_selection_shows_detail() {
594 let mut c = sample();
598 c.select("pki");
599 let r = harness::headless_render(&mut c);
600 assert_eq!(r.title, "System Map");
601 assert!(r.drew(), "a 3-node chart should tessellate to vertices");
602 assert_eq!(r.state["selected"], "pki");
603 assert_eq!(r.state["nodes"].as_array().unwrap().len(), 3);
604 }
605
606 #[test]
607 fn caps_advertise_selectable_themeable_resizable() {
608 let caps = sample().caps();
609 assert!(caps.selectable);
610 assert!(caps.themeable);
611 assert!(caps.resizable);
612 assert!(!caps.scalable, "syschart has no zoom yet");
613 }
614
615 #[test]
618 fn drive_select_toggles_via_msg_no_effects() {
619 let mut c = sample();
620 let fx = harness::drive(&mut c, [Msg::Select("oidc".into())]);
622 assert!(fx.is_empty(), "syschart issues no Effects (uninhabited)");
623 assert_eq!(c.state().selected.as_deref(), Some("oidc"));
624 harness::drive(&mut c, [Msg::Select("oidc".into())]);
625 assert_eq!(c.state().selected, None, "re-select toggles off");
626 harness::drive(&mut c, [Msg::Select("ghost".into())]);
627 assert_eq!(c.state().selected, None, "unknown id is a no-op");
628 }
629
630 #[test]
631 fn drive_clear_selection_via_msg() {
632 let mut c = sample();
633 let snap = harness::snapshot(&mut c, [Msg::Select("pki".into()), Msg::ClearSelection]);
634 assert_eq!(snap.selected, None);
635 }
636
637 #[test]
638 fn drive_set_badge_and_detail_via_msg() {
639 let mut c = sample();
640 let snap = harness::snapshot(
641 &mut c,
642 [
643 Msg::SetBadge("nexus".into(), 42),
644 Msg::SetDetail("nexus".into(), "repo: maven-releases".into()),
645 Msg::SetBadge("ghost".into(), 99),
647 ],
648 );
649 let nexus = snap.nodes.iter().find(|n| n.id == "nexus").unwrap();
650 assert_eq!(nexus.badge, 42);
651 assert!(nexus.detail.contains("maven-releases"));
652 assert!(snap.nodes.iter().all(|n| n.id != "ghost"));
653 }
654
655 #[test]
656 fn drive_produces_no_effects_for_any_msg() {
657 let mut c = sample();
658 let fx = harness::drive(
659 &mut c,
660 [
661 Msg::Select("pki".into()),
662 Msg::ClearSelection,
663 Msg::SetBadge("oidc".into(), 5),
664 Msg::SetDetail("oidc".into(), "x".into()),
665 ],
666 );
667 assert!(fx.is_empty(), "the syschart never asks the host to do side work");
668 }
669
670 #[test]
671 fn from_layers_groups_nodes_into_vertical_bands() {
672 let c = SystemChart::from_layers(
673 "layered",
674 vec![
675 vec![SysNode::new("a", "A", Color32::WHITE, (0.0, 0.0))],
676 vec![
677 SysNode::new("b", "B", Color32::WHITE, (0.0, 0.0)),
678 SysNode::new("c", "C", Color32::WHITE, (0.0, 0.0)),
679 ],
680 ],
681 vec![SysEdge::new("a", "b")],
682 );
683 assert_eq!(c.nodes().len(), 3);
685 let y = |id: &str| c.nodes().iter().find(|n| n.id == id).unwrap().pos.1;
686 assert!(y("a") < y("b"), "the first layer is above the second");
688 assert!((y("b") - y("c")).abs() < f32::EPSILON, "same-layer nodes share a band");
689 let x = |id: &str| c.nodes().iter().find(|n| n.id == id).unwrap().pos.0;
691 assert!((x("a") - 0.5).abs() < f32::EPSILON, "a lone node centres");
692 assert!(x("b") < x("c"), "peers spread across the band");
693 }
694
695 #[test]
696 fn demo_is_a_layered_constellation_that_renders() {
697 let mut c = SystemChart::demo();
698 assert_eq!(c.nodes().len(), 6, "gateway + 3 services + 2 storage");
699 assert_eq!(c.edges().len(), 6);
700 use std::collections::BTreeSet;
702 let bands: BTreeSet<i32> = c.nodes().iter().map(|n| (n.pos.1 * 1000.0) as i32).collect();
703 assert_eq!(bands.len(), 3, "three layers ⇒ three vertical bands");
704 let r = harness::headless_render(&mut c);
706 assert_eq!(r.title, "System Map");
707 assert!(r.drew(), "a 6-node constellation tessellates to vertices");
708 assert_eq!(r.state["nodes"].as_array().unwrap().len(), 6);
709 }
710
711 #[test]
712 fn model_serde_round_trips() {
713 let mut c = sample();
714 c.select("oidc");
715 let json = serde_json::to_value(c.state()).unwrap();
716 let back: SysChartModel = serde_json::from_value(json).unwrap();
717 assert_eq!(&back, c.state(), "the Model round-trips through serde (FC-3)");
718 }
719}