use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
use facett_core::{Facet, FacetCaps, theme};
#[derive(Clone, Debug)]
pub struct SysNode {
pub id: String,
pub label: String,
pub color: Color32,
pub badge: u64,
pub pos: (f32, f32),
pub detail: String,
}
impl SysNode {
pub fn new(id: impl Into<String>, label: impl Into<String>, color: Color32, pos: (f32, f32)) -> Self {
Self { id: id.into(), label: label.into(), color, badge: 0, pos, detail: String::new() }
}
pub fn badge(mut self, n: u64) -> Self {
self.badge = n;
self
}
pub fn detail(mut self, d: impl Into<String>) -> Self {
self.detail = d.into();
self
}
}
#[derive(Clone, Debug)]
pub struct SysEdge {
pub a: String,
pub b: String,
}
impl SysEdge {
pub fn new(a: impl Into<String>, b: impl Into<String>) -> Self {
Self { a: a.into(), b: b.into() }
}
}
pub struct SystemChart {
pub title: String,
pub nodes: Vec<SysNode>,
pub edges: Vec<SysEdge>,
selected: Option<usize>,
canvas_h: f32,
}
const NODE_R: f32 = 16.0;
impl SystemChart {
pub fn new(title: impl Into<String>, nodes: Vec<SysNode>, edges: Vec<SysEdge>) -> Self {
Self { title: title.into(), nodes, edges, selected: None, canvas_h: 280.0 }
}
pub fn with_canvas_height(mut self, h: f32) -> Self {
self.canvas_h = h;
self
}
fn index_of(&self, id: &str) -> Option<usize> {
self.nodes.iter().position(|n| n.id == id)
}
pub fn select(&mut self, id: &str) {
if let Some(i) = self.index_of(id) {
self.selected = if self.selected == Some(i) { None } else { Some(i) };
}
}
pub fn clear_selection(&mut self) {
self.selected = None;
}
pub fn selected(&self) -> Option<&str> {
self.selected.and_then(|i| self.nodes.get(i)).map(|n| n.id.as_str())
}
pub fn set_badge(&mut self, id: &str, badge: u64) {
if let Some(i) = self.index_of(id) {
self.nodes[i].badge = badge;
}
}
pub fn set_detail(&mut self, id: &str, detail: impl Into<String>) {
if let Some(i) = self.index_of(id) {
self.nodes[i].detail = detail.into();
}
}
fn center(&self, i: usize, rect: Rect) -> Pos2 {
let (nx, ny) = self.nodes[i].pos;
let pad = NODE_R + 6.0;
let inner = Rect::from_min_max(
rect.min + vec2(pad, pad),
rect.max - vec2(pad, pad),
);
Pos2::new(
inner.min.x + nx.clamp(0.0, 1.0) * inner.width().max(1.0),
inner.min.y + ny.clamp(0.0, 1.0) * inner.height().max(1.0),
)
}
}
impl Facet for SystemChart {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut Ui) {
let th = theme(ui);
let canvas = vec2(ui.available_width(), self.canvas_h.min(ui.available_height().max(self.canvas_h)));
let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click());
let painter = ui.painter_at(rect);
let centers: Vec<Pos2> = (0..self.nodes.len()).map(|i| self.center(i, rect)).collect();
for e in &self.edges {
if let (Some(ai), Some(bi)) = (self.index_of(&e.a), self.index_of(&e.b)) {
painter.line_segment([centers[ai], centers[bi]], Stroke::new(1.5, th.edge));
}
}
for (i, node) in self.nodes.iter().enumerate() {
let c = centers[i];
let selected = self.selected == Some(i);
let r = if selected { NODE_R + 3.0 } else { NODE_R };
painter.circle_filled(c, r, node.color);
let ring = if selected { th.accent } else { th.node_stroke };
painter.circle_stroke(c, r, Stroke::new(if selected { 2.5 } else { 1.0 }, ring));
painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
painter.text(
c + vec2(0.0, r + 2.0),
Align2::CENTER_TOP,
&node.label,
FontId::proportional(11.0),
th.text,
);
}
if resp.clicked() {
if let Some(p) = resp.interact_pointer_pos() {
let hit = centers.iter().enumerate().find(|(_, c)| c.distance(p) <= NODE_R + 4.0).map(|(i, _)| i);
if let Some(i) = hit {
self.selected = if self.selected == Some(i) { None } else { Some(i) };
}
}
}
ui.separator();
match self.selected {
None => {
ui.weak("Click a node to expand its detail.");
}
Some(i) => {
let node = &self.nodes[i];
ui.horizontal(|ui| {
ui.strong(&node.label);
ui.weak(format!("· {} events", node.badge));
});
if node.detail.is_empty() {
ui.weak("(no detail)");
} else {
for line in node.detail.lines() {
ui.monospace(line);
}
}
}
}
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"nodes": self.nodes.iter().map(|n| serde_json::json!({
"id": n.id,
"label": n.label,
"badge": n.badge,
"pos": [n.pos.0, n.pos.1],
"has_detail": !n.detail.is_empty(),
})).collect::<Vec<_>>(),
"edges": self.edges.iter().map(|e| serde_json::json!([e.a, e.b])).collect::<Vec<_>>(),
"selected": self.selected(),
})
}
fn selection_json(&self) -> serde_json::Value {
match self.selected() {
Some(id) => serde_json::json!(id),
None => serde_json::Value::Null,
}
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().selectable()
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use facett_core::harness;
fn sample() -> SystemChart {
let nodes = vec![
SysNode::new("pki", "PKI", Color32::from_rgb(120, 200, 255), (0.1, 0.1)).badge(3).detail("issued: a\nissued: b"),
SysNode::new("oidc", "OIDC", Color32::from_rgb(200, 160, 255), (0.9, 0.1)).badge(7),
SysNode::new("nexus", "Nexus", Color32::from_rgb(160, 255, 180), (0.5, 0.9)).badge(0),
];
let edges = vec![
SysEdge::new("pki", "oidc"),
SysEdge::new("pki", "nexus"),
SysEdge::new("oidc", "nexus"),
];
SystemChart::new("System Map", nodes, edges)
}
#[test]
fn select_toggles_and_reports() {
let mut c = sample();
assert_eq!(c.selected(), None);
c.select("oidc");
assert_eq!(c.selected(), Some("oidc"));
c.select("oidc"); assert_eq!(c.selected(), None);
c.select("nope"); assert_eq!(c.selected(), None);
}
#[test]
fn set_badge_and_detail_mutate_named_node() {
let mut c = sample();
c.set_badge("nexus", 42);
c.set_detail("nexus", "repo: maven-releases");
let nexus = c.nodes.iter().find(|n| n.id == "nexus").unwrap();
assert_eq!(nexus.badge, 42);
assert!(nexus.detail.contains("maven-releases"));
}
#[test]
fn state_json_carries_every_node_edge_and_selection() {
let mut c = sample();
c.select("pki");
let j = c.state_json();
assert_eq!(j["nodes"].as_array().unwrap().len(), 3);
assert_eq!(j["edges"].as_array().unwrap().len(), 3);
assert_eq!(j["selected"], "pki");
let pki = j["nodes"].as_array().unwrap().iter().find(|n| n["id"] == "pki").unwrap();
assert_eq!(pki["badge"], 3);
assert_eq!(pki["has_detail"], true);
}
#[test]
fn headless_render_draws_and_selection_shows_detail() {
let mut c = sample();
c.select("pki");
let r = harness::headless_render(&mut c);
assert_eq!(r.title, "System Map");
assert!(r.drew(), "a 3-node chart should tessellate to vertices");
assert_eq!(r.state["selected"], "pki");
assert_eq!(r.state["nodes"].as_array().unwrap().len(), 3);
}
#[test]
fn caps_advertise_selectable_themeable_resizable() {
let caps = sample().caps();
assert!(caps.selectable);
assert!(caps.themeable);
assert!(caps.resizable);
assert!(!caps.scalable, "syschart has no zoom yet");
}
}