use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{FacetCaps, Semantics, a11y_node, theme};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
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, PartialEq, Serialize, Deserialize)]
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() }
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
Select(String),
ClearSelection,
SetBadge(String, u64),
SetDetail(String, String),
}
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SysChartModel {
pub nodes: Vec<SysNode>,
pub edges: Vec<SysEdge>,
pub selected: Option<String>,
pub canvas_h: f32,
}
pub struct SystemChart {
pub title: String,
pub state: SysChartModel,
}
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(),
state: SysChartModel { nodes, edges, selected: None, canvas_h: 280.0 },
}
}
pub fn with_canvas_height(mut self, h: f32) -> Self {
self.state.canvas_h = h;
self
}
pub fn from_layers(title: impl Into<String>, layers: Vec<Vec<SysNode>>, edges: Vec<SysEdge>) -> Self {
let n_layers = layers.len().max(1) as f32;
let mut nodes = Vec::new();
for (li, layer) in layers.into_iter().enumerate() {
let y = if n_layers <= 1.0 { 0.5 } else { 0.12 + (li as f32) * (0.76 / (n_layers - 1.0)) };
let count = layer.len().max(1) as f32;
for (ni, mut node) in layer.into_iter().enumerate() {
let x = if count <= 1.0 { 0.5 } else { 0.1 + (ni as f32) * (0.8 / (count - 1.0)) };
node.pos = (x, y);
nodes.push(node);
}
}
Self::new(title, nodes, edges)
}
#[must_use]
pub fn demo() -> Self {
let c = Color32::from_rgb;
let layers = vec![
vec![SysNode::new("gateway", "Gateway", c(120, 200, 255), (0.0, 0.0)).badge(2).detail("ingress · TLS terminate")],
vec![
SysNode::new("korp", "korp", c(200, 160, 255), (0.0, 0.0)).badge(5).detail("case + analysis warehouse"),
SysNode::new("nornir", "nornir", c(160, 255, 180), (0.0, 0.0)).badge(3).detail("orchestrator + test matrix"),
SysNode::new("holger", "holger", c(255, 200, 140), (0.0, 0.0)).badge(1).detail("registry browser"),
],
vec![
SysNode::new("skade", "skade", c(140, 190, 255), (0.0, 0.0)).badge(8).detail("lakehouse warehouse"),
SysNode::new("iceberg", "Iceberg", c(180, 220, 160), (0.0, 0.0)).badge(0).detail("table format"),
],
];
let edges = vec![
SysEdge::new("gateway", "korp"),
SysEdge::new("gateway", "nornir"),
SysEdge::new("korp", "holger"),
SysEdge::new("korp", "skade"),
SysEdge::new("nornir", "skade"),
SysEdge::new("skade", "iceberg"),
];
Self::from_layers("System Map", layers, edges)
}
pub fn nodes(&self) -> &[SysNode] {
&self.state.nodes
}
pub fn edges(&self) -> &[SysEdge] {
&self.state.edges
}
fn index_of(&self, id: &str) -> Option<usize> {
self.state.nodes.iter().position(|n| n.id == id)
}
fn selected_idx(&self) -> Option<usize> {
self.state.selected.as_deref().and_then(|id| self.index_of(id))
}
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::Select(id) => {
if self.index_of(&id).is_some() {
self.state.selected = if self.state.selected.as_deref() == Some(id.as_str()) {
None
} else {
Some(id)
};
}
}
Msg::ClearSelection => self.state.selected = None,
Msg::SetBadge(id, badge) => {
if let Some(i) = self.index_of(&id) {
self.state.nodes[i].badge = badge;
}
}
Msg::SetDetail(id, detail) => {
if let Some(i) = self.index_of(&id) {
self.state.nodes[i].detail = detail;
}
}
}
Vec::new()
}
pub fn select(&mut self, id: &str) {
let _ = self.update(Msg::Select(id.to_string()));
}
pub fn clear_selection(&mut self) {
let _ = self.update(Msg::ClearSelection);
}
pub fn selected(&self) -> Option<&str> {
self.state.selected.as_deref()
}
pub fn set_badge(&mut self, id: &str, badge: u64) {
let _ = self.update(Msg::SetBadge(id.to_string(), badge));
}
pub fn set_detail(&mut self, id: &str, detail: impl Into<String>) {
let _ = self.update(Msg::SetDetail(id.to_string(), detail.into()));
}
pub fn node_center(&self, i: usize, rect: Rect) -> Pos2 {
self.center(i, rect)
}
pub const NODE_R: f32 = NODE_R;
fn center(&self, i: usize, rect: Rect) -> Pos2 {
let (nx, ny) = self.state.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 SystemChart {
pub fn copy_text(&self) -> Option<String> {
if self.state.nodes.is_empty() {
return None;
}
if let Some(n) = self.selected().and_then(|id| self.state.nodes.iter().find(|n| n.id == id)) {
let mut s = format!("{} (badge {})", n.label, n.badge);
if !n.detail.is_empty() {
s.push('\n');
s.push_str(&n.detail);
}
return Some(s);
}
let mut out = String::from("id\tlabel\tbadge");
for n in &self.state.nodes {
out.push('\n');
out.push_str(&format!("{}\t{}\t{}", n.id, n.label, n.badge));
}
Some(out)
}
pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let th = theme(ui);
let canvas = vec2(
ui.available_width(),
self.state.canvas_h.min(ui.available_height().max(self.state.canvas_h)),
);
let (rect, resp) = ui.allocate_exact_size(canvas, Sense::hover());
let base = ui.id().with("syschart-node");
let painter = ui.painter_at(rect);
if self.state.nodes.is_empty() {
resp.widget_info(|| {
Semantics::image(format!("{} — no peers", self.title))
.severity(facett_core::Severity::Error)
.error_code("facet-syschart-1")
.widget_info()
});
painter.text(rect.center(), Align2::CENTER_CENTER, "no peers to show", FontId::proportional(13.0), th.text_dim);
facett_core::errcode::paint_code(&painter, rect.center() + vec2(0.0, 16.0), "facet-syschart-1");
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-syschart::SystemChart::view",
"ui_render",
false,
"nodes=0 drew=empty_hint code=facet-syschart-1",
);
return msgs;
}
let centers: Vec<Pos2> = (0..self.state.nodes.len()).map(|i| self.center(i, rect)).collect();
for e in &self.state.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_f32, th.edge));
}
}
let sel_id = self.state.selected.clone();
for (i, node) in self.state.nodes.iter().enumerate() {
let c = centers[i];
let selected = sel_id.as_deref() == Some(node.id.as_str());
let r = if selected { NODE_R + 3.0 } else { NODE_R };
facett_core::look::elevation_shadow(ui, Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r)), 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_f32 } else { 1.0_f32 }, 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,
);
let node_rect = Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r));
facett_core::look::reveal_on_hover(ui, node_rect, r);
facett_core::look::apply_focus_ring(ui, node_rect, selected, r);
let label = format!("{} · {} events", node.label, node.badge);
let hit = a11y_node(
ui,
base,
&node.id,
Sense::click(),
node_rect,
Semantics::button(label).value(node.badge as f64).selected(selected),
);
if hit.clicked() {
msgs.push(Msg::Select(node.id.clone()));
}
}
ui.separator();
match self.selected_idx() {
None => {
ui.weak("Click a node to expand its detail.");
}
Some(i) => {
let node = &self.state.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);
}
}
}
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-syschart::SystemChart::view",
"ui_render",
!self.state.nodes.is_empty(),
&format!("nodes={} edges={}", self.state.nodes.len(), self.state.edges.len()),
);
msgs
}
}
impl CopySource for SystemChart {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
self.copy_text().map(ClipPayload::Text)
}
}
impl facett_core::Elm for SystemChart {
type Model = SysChartModel;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &SysChartModel {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
SystemChart::update(self, msg)
}
fn view(&self, ui: &mut Ui) -> Vec<Msg> {
SystemChart::view(self, ui)
}
}
facett_core::impl_facet_via_elm!(SystemChart, custom_state_json, {
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
fn state_json(&self) -> serde_json::Value {
let s = &self.state;
serde_json::json!({
"nodes": s.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": s.edges.iter().map(|e| serde_json::json!([e.a, e.b])).collect::<Vec<_>>(),
"selected": self.selected(),
"error_code": if s.nodes.is_empty() {
serde_json::json!("facet-syschart-1")
} else {
serde_json::Value::Null
},
})
}
fn severity(&self) -> facett_core::Severity {
if self.state.nodes.is_empty() {
facett_core::Severity::Error
} else {
facett_core::Severity::Info
}
}
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().copyable()
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
});
#[cfg(test)]
mod tests {
use super::*;
use facett_core::{Elm, Facet, harness};
#[test]
fn typed_copy_is_selected_node_or_node_list_rows() {
use facett_core::clip::{ClipKind, CopySource};
let mut c = SystemChart::new(
"sys",
vec![
SysNode::new("pki", "PKI", Color32::WHITE, (0.1, 0.1)).badge(3),
SysNode::new("oidc", "OIDC", Color32::WHITE, (0.9, 0.1)).badge(7),
],
vec![SysEdge::new("pki", "oidc")],
);
let p = c.copy_payload().unwrap();
assert_eq!(p.kind(), ClipKind::Text);
assert!(p.as_text().starts_with("id\tlabel\tbadge"), "{}", p.as_text());
assert!(p.as_text().contains("\npki\tPKI\t3"));
c.select("oidc");
assert_eq!(c.copy_payload().unwrap().as_text(), "OIDC (badge 7)");
assert!(SystemChart::new("empty", vec![], vec![]).copy_payload().is_none());
}
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");
}
#[test]
fn drive_select_toggles_via_msg_no_effects() {
let mut c = sample();
let fx = harness::drive(&mut c, [Msg::Select("oidc".into())]);
assert!(fx.is_empty(), "syschart issues no Effects (uninhabited)");
assert_eq!(c.state().selected.as_deref(), Some("oidc"));
harness::drive(&mut c, [Msg::Select("oidc".into())]);
assert_eq!(c.state().selected, None, "re-select toggles off");
harness::drive(&mut c, [Msg::Select("ghost".into())]);
assert_eq!(c.state().selected, None, "unknown id is a no-op");
}
#[test]
fn drive_clear_selection_via_msg() {
let mut c = sample();
let snap = harness::snapshot(&mut c, [Msg::Select("pki".into()), Msg::ClearSelection]);
assert_eq!(snap.selected, None);
}
#[test]
fn drive_set_badge_and_detail_via_msg() {
let mut c = sample();
let snap = harness::snapshot(
&mut c,
[
Msg::SetBadge("nexus".into(), 42),
Msg::SetDetail("nexus".into(), "repo: maven-releases".into()),
Msg::SetBadge("ghost".into(), 99),
],
);
let nexus = snap.nodes.iter().find(|n| n.id == "nexus").unwrap();
assert_eq!(nexus.badge, 42);
assert!(nexus.detail.contains("maven-releases"));
assert!(snap.nodes.iter().all(|n| n.id != "ghost"));
}
#[test]
fn drive_produces_no_effects_for_any_msg() {
let mut c = sample();
let fx = harness::drive(
&mut c,
[
Msg::Select("pki".into()),
Msg::ClearSelection,
Msg::SetBadge("oidc".into(), 5),
Msg::SetDetail("oidc".into(), "x".into()),
],
);
assert!(fx.is_empty(), "the syschart never asks the host to do side work");
}
#[test]
fn from_layers_groups_nodes_into_vertical_bands() {
let c = SystemChart::from_layers(
"layered",
vec![
vec![SysNode::new("a", "A", Color32::WHITE, (0.0, 0.0))],
vec![
SysNode::new("b", "B", Color32::WHITE, (0.0, 0.0)),
SysNode::new("c", "C", Color32::WHITE, (0.0, 0.0)),
],
],
vec![SysEdge::new("a", "b")],
);
assert_eq!(c.nodes().len(), 3);
let y = |id: &str| c.nodes().iter().find(|n| n.id == id).unwrap().pos.1;
assert!(y("a") < y("b"), "the first layer is above the second");
assert!((y("b") - y("c")).abs() < f32::EPSILON, "same-layer nodes share a band");
let x = |id: &str| c.nodes().iter().find(|n| n.id == id).unwrap().pos.0;
assert!((x("a") - 0.5).abs() < f32::EPSILON, "a lone node centres");
assert!(x("b") < x("c"), "peers spread across the band");
}
#[test]
fn demo_is_a_layered_constellation_that_renders() {
let mut c = SystemChart::demo();
assert_eq!(c.nodes().len(), 6, "gateway + 3 services + 2 storage");
assert_eq!(c.edges().len(), 6);
use std::collections::BTreeSet;
let bands: BTreeSet<i32> = c.nodes().iter().map(|n| (n.pos.1 * 1000.0) as i32).collect();
assert_eq!(bands.len(), 3, "three layers ⇒ three vertical bands");
let r = harness::headless_render(&mut c);
assert_eq!(r.title, "System Map");
assert!(r.drew(), "a 6-node constellation tessellates to vertices");
assert_eq!(r.state["nodes"].as_array().unwrap().len(), 6);
}
#[test]
fn model_serde_round_trips() {
let mut c = sample();
c.select("oidc");
let json = serde_json::to_value(c.state()).unwrap();
let back: SysChartModel = serde_json::from_value(json).unwrap();
assert_eq!(&back, c.state(), "the Model round-trips through serde (FC-3)");
}
}