use std::collections::{BTreeSet, HashMap, VecDeque};
use std::path::PathBuf;
use eframe::egui::{self, Color32, FontId, Pos2, Sense, Stroke, Vec2};
use serde::{Deserialize, Serialize};
#[cfg(test)]
use crate::arch::ArchEdge;
use crate::arch::{ArchEdgeKind, ArchGraph, ArchNode, NodeKind};
use crate::viz::trace;
use super::facett_theme::Theme;
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct ArchPayload {
pub graph: ArchGraph,
#[serde(default)]
pub members_with_data: Vec<String>,
#[serde(default)]
pub errors: Vec<String>,
}
enum Src {
Local(PathBuf),
Remote { endpoint: String, token: String },
}
struct Laid {
node: ArchNode,
pos: Pos2,
}
#[derive(Serialize)]
struct LoadIn {
source: String,
workspace: String,
}
#[derive(Serialize)]
struct RenderOut {
node_count: usize,
edge_count: usize,
components: usize,
grpc: usize,
core_fns: usize,
tables: usize,
selected: Option<String>,
}
pub struct ArchTabState {
src: Src,
workspace: String,
loaded: bool,
graph: ArchGraph,
members_with_data: Vec<String>,
members: Vec<String>,
errors: Vec<String>,
load_error: Option<String>,
laid: Vec<Laid>,
idx: HashMap<String, usize>,
adj: HashMap<usize, Vec<usize>>,
selected: Option<usize>,
traced: BTreeSet<usize>,
pan: Vec2,
zoom: f32,
render_dirty: bool,
theme: Theme,
}
impl ArchTabState {
pub fn local(root: PathBuf) -> Self {
Self::with(Src::Local(root), String::new())
}
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self::with(Src::Remote { endpoint, token }, workspace)
}
fn with(src: Src, workspace: String) -> Self {
Self {
src,
workspace,
loaded: false,
graph: ArchGraph::default(),
members_with_data: Vec::new(),
members: Vec::new(),
errors: Vec::new(),
load_error: None,
laid: Vec::new(),
idx: HashMap::new(),
adj: HashMap::new(),
selected: None,
traced: BTreeSet::new(),
pan: Vec2::ZERO,
zoom: 1.0,
render_dirty: false,
theme: Theme::default(),
}
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
pub fn set_members(&mut self, members: Vec<String>) {
self.members = members;
}
pub fn set_workspace(&mut self, members: Vec<String>) {
self.members = members;
self.loaded = false;
self.graph = ArchGraph::default();
self.members_with_data.clear();
self.errors.clear();
self.load_error = None;
self.laid.clear();
self.idx.clear();
self.adj.clear();
self.selected = None;
self.traced.clear();
self.pan = Vec2::ZERO;
self.zoom = 1.0;
}
pub fn set_workspace_name(&mut self, workspace: String) {
self.workspace = workspace;
}
pub fn reload(&mut self) {
self.loaded = false;
}
fn ensure_loaded(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
let source = match &self.src {
Src::Local(p) => format!("local {}", p.display()),
Src::Remote { endpoint, .. } => format!("remote {endpoint} (ws={})", self.workspace),
};
trace::emit_in(
"architecture.load",
&LoadIn { source, workspace: self.workspace.clone() },
);
let payload = match &self.src {
Src::Local(root) => {
match crate::warehouse::iceberg::IcebergWarehouse::open(root) {
Ok(wh) => {
let (graph, with_data, errors) =
crate::arch::warehouse::merged_arch_from_warehouse(&wh, &self.members);
Ok(ArchPayload { graph, members_with_data: with_data, errors })
}
Err(e) => Err(format!("open warehouse: {e:#}")),
}
}
Src::Remote { endpoint, token } => {
super::remote::fetch_architecture(endpoint, token, &self.workspace)
.map_err(|e| format!("{e:#}"))
}
};
match payload {
Ok(p) => {
self.load_error = None;
self.members_with_data = p.members_with_data;
self.errors = p.errors;
self.set_graph(p.graph);
}
Err(e) => {
self.load_error = Some(e);
self.set_graph(ArchGraph::default());
}
}
}
fn set_graph(&mut self, graph: ArchGraph) {
self.graph = graph;
self.selected = None;
self.traced.clear();
self.pan = Vec2::ZERO;
self.zoom = 1.0;
self.build_layout();
self.render_dirty = true;
}
fn build_layout(&mut self) {
self.laid.clear();
self.idx.clear();
self.adj.clear();
let layers = layers_of(&self.graph);
const COL_W: f32 = 230.0;
const ROW_H: f32 = 64.0;
let cols = layers.len().max(1) as f32;
let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1) as f32;
let total_w = COL_W * (cols - 1.0).max(0.0);
let total_h = ROW_H * (rows - 1.0).max(0.0);
for (ci, layer) in layers.iter().enumerate() {
let layer_h = ROW_H * (layer.len().saturating_sub(1)) as f32;
let y0 = -total_h / 2.0 + (total_h - layer_h) / 2.0;
for (ri, &node_i) in layer.iter().enumerate() {
let x = -total_w / 2.0 + ci as f32 * COL_W;
let y = y0 + ri as f32 * ROW_H;
let node = self.graph.nodes[node_i].clone();
self.idx.insert(node.id.clone(), self.laid.len());
self.laid.push(Laid { node, pos: Pos2::new(x, y) });
}
}
for e in &self.graph.edges {
if let (Some(&f), Some(&t)) = (self.idx.get(&e.from), self.idx.get(&e.to)) {
self.adj.entry(f).or_default().push(t);
}
}
}
fn trace_from(&mut self, seed: usize) {
self.traced.clear();
let mut q = VecDeque::new();
self.traced.insert(seed);
q.push_back(seed);
while let Some(cur) = q.pop_front() {
if let Some(outs) = self.adj.get(&cur) {
for &nxt in outs {
if self.traced.insert(nxt) {
q.push_back(nxt);
}
}
}
}
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
self.ensure_loaded();
egui::TopBottomPanel::top("arch_controls").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.heading("🏛 Architecture");
ui.separator();
ui.label(format!(
"{} nodes · {} edges",
self.graph.nodes.len(),
self.graph.edges.len()
));
ui.separator();
if ui.button("↻ reload").clicked() {
self.loaded = false;
}
if ui.button("⊙ fit").clicked() {
self.pan = Vec2::ZERO;
self.zoom = 1.0;
}
if self.selected.is_some() && ui.button("✖ clear selection").clicked() {
self.selected = None;
self.traced.clear();
self.render_dirty = true;
}
if !self.members_with_data.is_empty() {
ui.separator();
ui.label(format!("members: {}", self.members_with_data.join(", ")));
}
});
ui.horizontal_wrapped(|ui| {
for (kind, name) in [
(NodeKind::Component, "UI component"),
(NodeKind::Grpc, "gRPC"),
(NodeKind::CoreFn, "core fn"),
(NodeKind::Table, "warehouse table"),
] {
let (fill, _stroke) = kind_color(&theme, kind);
let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), Sense::hover());
ui.painter().rect_filled(rect, 2.0, fill);
ui.label(name);
ui.add_space(8.0);
}
});
});
if self.render_dirty {
trace::emit_end("architecture.render", &self.render_out());
self.render_dirty = false;
}
egui::CentralPanel::default().show_inside(ui, |ui| {
if let Some(err) = &self.load_error {
ui.colored_label(super::facett_theme::RED, format!("architecture load failed: {err}"));
return;
}
for e in &self.errors {
ui.colored_label(super::facett_theme::AMBER, format!("⚠ {e}"));
}
if self.graph.nodes.is_empty() {
self.draw_placeholder(ui);
return;
}
let (resp, painter) = ui.allocate_painter(ui.available_size(), Sense::click_and_drag());
painter.rect_filled(resp.rect, 4.0, theme.bg);
if resp.dragged() {
self.pan += resp.drag_delta();
}
if resp.hovered() {
let scroll = ui.input(|i| i.raw_scroll_delta.y);
if scroll != 0.0 {
self.zoom = (self.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
}
}
let origin = resp.rect.center() + self.pan;
let zoom = self.zoom;
let project = |p: Pos2| origin + p.to_vec2() * zoom;
if resp.clicked() {
if let Some(click) = resp.interact_pointer_pos() {
let hit = self.laid.iter().enumerate().find_map(|(i, l)| {
let c = project(l.pos);
let half = Vec2::new(BOX_W, BOX_H) * 0.5 * zoom;
let rect = egui::Rect::from_center_size(c, half * 2.0);
rect.contains(click).then_some(i)
});
match hit {
Some(i) => {
self.selected = Some(i);
self.trace_from(i);
}
None => {
self.selected = None;
self.traced.clear();
}
}
self.render_dirty = true;
}
}
let highlighting = self.selected.is_some();
for e in &self.graph.edges {
let (Some(&fi), Some(&ti)) = (self.idx.get(&e.from), self.idx.get(&e.to)) else {
continue;
};
let on_trace =
highlighting && self.traced.contains(&fi) && self.traced.contains(&ti);
let (pa, pb) = (project(self.laid[fi].pos), project(self.laid[ti].pos));
let a = pa + Vec2::new(BOX_W * 0.5 * zoom, 0.0);
let b = pb - Vec2::new(BOX_W * 0.5 * zoom, 0.0);
let base = edge_color(&theme, e.kind);
let color = if highlighting && !on_trace {
dim(base)
} else {
base
};
let w = if on_trace { 2.4 } else { 1.4 };
let mid = Pos2::new((a.x + b.x) * 0.5, a.y);
let mid2 = Pos2::new((a.x + b.x) * 0.5, b.y);
painter.add(egui::Shape::CubicBezier(egui::epaint::CubicBezierShape::from_points_stroke(
[a, mid, mid2, b],
false,
Color32::TRANSPARENT,
Stroke::new(w, color),
)));
let dir = (b - mid2).normalized();
let perp = Vec2::new(-dir.y, dir.x);
let head = 6.0 * zoom.clamp(0.6, 1.6);
painter.line_segment([b, b - dir * head + perp * head * 0.5], Stroke::new(w, color));
painter.line_segment([b, b - dir * head - perp * head * 0.5], Stroke::new(w, color));
}
for (i, l) in self.laid.iter().enumerate() {
let c = project(l.pos);
let (fill, stroke) = kind_color(&theme, l.node.kind);
let on_trace = highlighting && self.traced.contains(&i);
let (fill, stroke) = if highlighting && !on_trace {
(dim(fill), dim(stroke))
} else {
(fill, stroke)
};
let size = Vec2::new(BOX_W, BOX_H) * zoom;
let rect = egui::Rect::from_center_size(c, size);
painter.rect_filled(rect, 5.0 * zoom, fill);
let ring = if self.selected == Some(i) {
Stroke::new(3.0, theme.selection())
} else {
Stroke::new(1.4, stroke)
};
painter.rect_stroke(rect, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
if zoom > 0.45 {
painter.text(
c,
egui::Align2::CENTER_CENTER,
&l.node.label,
FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
theme.text,
);
}
}
let hint = match self.selected {
Some(i) => format!(
"{} — {} downstream node(s) lit · click empty to clear · drag to pan, scroll to zoom",
self.laid[i].node.label,
self.traced.len().saturating_sub(1),
),
None => "click a node to highlight its downstream wiring · drag to pan, scroll to zoom".to_string(),
};
painter.text(
resp.rect.left_top() + Vec2::new(8.0, 8.0),
egui::Align2::LEFT_TOP,
hint,
FontId::proportional(12.0),
theme.text_dim,
);
});
}
fn draw_placeholder(&self, ui: &mut egui::Ui) {
ui.add_space(24.0);
ui.vertical_centered(|ui| {
ui.heading("No architecture wiring recorded yet");
ui.add_space(8.0);
ui.label(
"The EPIC ARCH board is generated per repo and historized in the \
warehouse. Generate it, then reload this tab:",
);
ui.add_space(6.0);
let cmd = if self.members.is_empty() {
"nornir arch generate --repo <member>".to_string()
} else {
format!("nornir arch generate --repo {}", self.members[0])
};
ui.code(&cmd);
if !self.members.is_empty() {
ui.add_space(8.0);
ui.label(format!(
"members in this workspace with no board yet: {}",
self.members
.iter()
.filter(|m| !self.members_with_data.contains(m))
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
});
}
fn render_out(&self) -> RenderOut {
let count = |k: NodeKind| self.graph.nodes.iter().filter(|n| n.kind == k).count();
RenderOut {
node_count: self.graph.nodes.len(),
edge_count: self.graph.edges.len(),
components: count(NodeKind::Component),
grpc: count(NodeKind::Grpc),
core_fns: count(NodeKind::CoreFn),
tables: count(NodeKind::Table),
selected: self.selected.map(|i| self.laid[i].node.label.clone()),
}
}
pub fn state_json(&self) -> serde_json::Value {
let count = |k: NodeKind| self.graph.nodes.iter().filter(|n| n.kind == k).count();
let kinds: Vec<serde_json::Value> = self
.graph
.nodes
.iter()
.map(|n| serde_json::json!({ "id": n.id, "label": n.label, "kind": n.kind.as_str() }))
.collect();
let edges: Vec<serde_json::Value> = self
.graph
.edges
.iter()
.map(|e| serde_json::json!({ "from": e.from, "to": e.to, "kind": e.kind.as_str() }))
.collect();
let traced: Vec<String> =
self.traced.iter().map(|&i| self.laid[i].node.label.clone()).collect();
serde_json::json!({
"source": match &self.src { Src::Local(_) => "local", Src::Remote { .. } => "remote" },
"workspace": self.workspace,
"node_count": self.graph.nodes.len(),
"edge_count": self.graph.edges.len(),
"components": count(NodeKind::Component),
"grpc": count(NodeKind::Grpc),
"core_fns": count(NodeKind::CoreFn),
"tables": count(NodeKind::Table),
"nodes": kinds,
"edges": edges,
"members_with_data": self.members_with_data,
"members": self.members,
"errors": self.errors,
"load_error": self.load_error,
"selected": self.selected.map(|i| self.laid[i].node.label.clone()),
"traced_downstream": traced,
"empty": self.graph.nodes.is_empty(),
"palette": self.theme.name,
})
}
#[doc(hidden)]
pub fn inject_for_test(&mut self, graph: ArchGraph, members_with_data: Vec<String>) {
self.loaded = true;
self.load_error = None;
self.members_with_data = members_with_data;
self.errors = Vec::new();
self.set_graph(graph);
}
#[doc(hidden)]
pub fn select_by_label_for_test(&mut self, label: &str) -> bool {
if let Some(i) = self.laid.iter().position(|l| l.node.label == label) {
self.selected = Some(i);
self.trace_from(i);
self.render_dirty = true;
true
} else {
false
}
}
}
const BOX_W: f32 = 184.0;
const BOX_H: f32 = 34.0;
fn kind_color(theme: &Theme, kind: NodeKind) -> (Color32, Color32) {
match kind {
NodeKind::Component => (theme.accent.linear_multiply(0.30), theme.accent),
NodeKind::Grpc => (
super::facett_theme::AMBER.linear_multiply(0.30),
super::facett_theme::AMBER,
),
NodeKind::CoreFn => (theme.node_fill, theme.node_stroke),
NodeKind::Table => (theme.point.linear_multiply(0.30), theme.point),
}
}
fn edge_color(theme: &Theme, kind: ArchEdgeKind) -> Color32 {
match kind {
ArchEdgeKind::Calls => theme.edge,
ArchEdgeKind::Reads | ArchEdgeKind::Writes => super::facett_theme::AMBER,
}
}
fn dim(c: Color32) -> Color32 {
c.linear_multiply(0.22)
}
fn layers_of(graph: &ArchGraph) -> Vec<Vec<usize>> {
let n = graph.nodes.len();
let idx: HashMap<&str, usize> =
graph.nodes.iter().enumerate().map(|(i, nd)| (nd.id.as_str(), i)).collect();
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut indeg: Vec<usize> = vec![0; n];
for e in &graph.edges {
if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
if f != t {
adj[f].push(t);
indeg[t] += 1;
}
}
}
let mut layer_of = vec![0usize; n];
let mut remaining: BTreeSet<usize> = (0..n).collect();
let mut level = 0usize;
while !remaining.is_empty() {
let ready: Vec<usize> = remaining.iter().copied().filter(|&i| indeg[i] == 0).collect();
if ready.is_empty() {
for &i in &remaining {
layer_of[i] = level;
}
break;
}
for &i in &ready {
layer_of[i] = level;
remaining.remove(&i);
}
for &i in &ready {
for &j in &adj[i] {
if indeg[j] > 0 {
indeg[j] -= 1;
}
}
}
level += 1;
}
let mut max_level = *layer_of.iter().max().unwrap_or(&0);
let has_table = graph.nodes.iter().any(|nd| nd.kind == NodeKind::Table);
if has_table {
max_level = max_level.max(1);
for (i, nd) in graph.nodes.iter().enumerate() {
if nd.kind == NodeKind::Table {
layer_of[i] = max_level;
}
}
}
let mut layers: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| graph.nodes[a].label.cmp(&graph.nodes[b].label));
for i in order {
layers[layer_of[i]].push(i);
}
layers
}
#[cfg(test)]
mod tests {
use super::*;
fn board() -> ArchGraph {
ArchGraph {
nodes: vec![
ArchNode { id: "component:TestTab".into(), label: "TestTab".into(), kind: NodeKind::Component },
ArchNode { id: "grpc:Viz.Architecture".into(), label: "Viz.Architecture".into(), kind: NodeKind::Grpc },
ArchNode { id: "corefn:nornir::viz".into(), label: "nornir::viz".into(), kind: NodeKind::CoreFn },
ArchNode { id: "table:test_results".into(), label: "test_results".into(), kind: NodeKind::Table },
ArchNode { id: "table:release_lineage".into(), label: "release_lineage".into(), kind: NodeKind::Table },
],
edges: vec![
ArchEdge { from: "component:TestTab".into(), to: "table:test_results".into(), kind: ArchEdgeKind::Reads },
ArchEdge { from: "component:TestTab".into(), to: "corefn:nornir::viz".into(), kind: ArchEdgeKind::Calls },
ArchEdge { from: "grpc:Viz.Architecture".into(), to: "table:release_lineage".into(), kind: ArchEdgeKind::Writes },
],
}
}
#[test]
fn injected_board_renders_counts_in_state_json() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.set_members(vec!["nornir".into()]);
st.inject_for_test(board(), vec!["nornir".into()]);
let js = st.state_json();
assert_eq!(js["node_count"], 5);
assert_eq!(js["edge_count"], 3);
assert_eq!(js["components"], 1);
assert_eq!(js["grpc"], 1);
assert_eq!(js["core_fns"], 1);
assert_eq!(js["tables"], 2);
assert_eq!(js["empty"], false);
assert_eq!(js["members_with_data"][0], "nornir");
assert_eq!(js["source"], "local");
}
#[test]
fn empty_board_shows_placeholder_not_error() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.set_members(vec!["nornir".into()]);
st.inject_for_test(ArchGraph::default(), vec![]);
let js = st.state_json();
assert_eq!(js["empty"], true);
assert_eq!(js["node_count"], 0);
assert!(js["load_error"].is_null(), "empty board is not an error");
}
#[test]
fn click_lights_downstream_trace() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.inject_for_test(board(), vec!["nornir".into()]);
assert!(st.select_by_label_for_test("TestTab"));
let js = st.state_json();
assert_eq!(js["selected"], "TestTab");
let traced: BTreeSet<String> = js["traced_downstream"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(traced.contains("TestTab"));
assert!(traced.contains("test_results"));
assert!(traced.contains("nornir::viz"));
assert!(!traced.contains("release_lineage"), "unreachable node not lit: {traced:?}");
}
#[test]
fn layers_pin_tables_right_and_sources_left() {
let g = board();
let layers = layers_of(&g);
let l0: BTreeSet<&str> =
layers[0].iter().map(|&i| g.nodes[i].label.as_str()).collect();
assert!(l0.contains("TestTab"));
assert!(l0.contains("Viz.Architecture"));
let last = layers.len() - 1;
for (li, layer) in layers.iter().enumerate() {
for &i in layer {
if g.nodes[i].kind == NodeKind::Table {
assert_eq!(li, last, "table `{}` not on the right rail", g.nodes[i].label);
}
}
}
assert!(
layers[last].iter().any(|&i| g.nodes[i].kind == NodeKind::Table),
"the rightmost column carries the warehouse tables"
);
}
#[test]
fn palette_switch_reaches_pane() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.inject_for_test(board(), vec!["nornir".into()]);
assert_eq!(st.state_json()["palette"], "default");
st.set_palette(Theme::cyberpunk_neon());
assert_eq!(st.state_json()["palette"], "cyberpunk-neon");
}
}