src/viz/graph_render.rs
//! Shared pan/zoom **dependency-graph renderer** — the single egui draw routine
//! behind BOTH the 🏛 Architecture wiring board (`arch_tab`) and the 🧬 nornir
//! RELEASE DASHBOARD (`nornir_tab`).
//!
//! # This module is a THIN RE-EXPORT of `facett-graphpan` (L5)
//! The canvas itself no longer lives here. It was extracted from
//! `arch_tab::draw_board` into this module, and facett then **adopted it
//! verbatim** as the `facett-graphpan` crate ("Adopted from nornir's viz
//! `graph_render`" — its own crate doc). Keeping a second copy here was exactly
//! the twin L5 forbids: the same `draw_graph` / `draw_graph_scrolled` /
//! `content_extent` / `downstream_of` bodies in two repos, drifting apart the
//! moment either side is touched. facett is the egui component framework and
//! OWNS the canvas; nornir CONSUMES it.
//!
//! The module path stays (`viz::graph_render::draw_graph`, …) so every call site
//! — `arch_tab`, `nornir_dashboard` — is unchanged; only the implementation moved.
//! Consuming the leaf also picks up work nornir never had: `GraphView::project` /
//! `GraphView::state_json` (headless assertions of the exact pan/zoom mapping),
//! the `GraphPanView` Facet, and facett's opt-in legibility/GPU-cloud lane (all
//! default OFF, so `draw_graph` paints byte-for-byte what it painted here).
//!
//! # Domain-agnostic by design
//! `facett-graphpan` knows NOTHING about architecture node-kinds or release
//! gates. The caller hands it:
//! * a [`GraphModel`] — nodes (id + label + fill/stroke colour) + edges
//! (from/to + colour + dashed flag + optional mid-label), already laid out in
//! world space (caller owns the layout so each domain keeps its own columns);
//! * a [`Decorations`] overlay — **caller-supplied**, domain-agnostic: a per-node
//! status ring colour + an optional corner badge (glyph/text + colour), and a
//! set of edges to draw dashed-and-labelled (e.g. a cycle's suggested cut).
//!
//! The 🏛 Architecture tab calls [`draw_graph`] with [`Decorations::default()`]
//! (no overlay — its own coverage rings ride in the model's node ring colour);
//! the 🧬 dashboard calls the SAME function passing gate markers. No copy-pasted
//! canvas/pan-zoom code, and facett needn't learn what a "release gate" is.
//!
//! **egui version note (the old vendoring reason, now void):** this module used
//! to carry its own copy because nornir's viz was on `eframe::egui` 0.33 while
//! facett was on egui 0.34. Both are on **egui 0.34.3** now — `eframe::egui` IS
//! the `egui` crate facett paints with — so the types unify and the copy is gone.
pub use facett_graphpan::{
BOX_H, BOX_W, Decorations, GraphEdge, GraphModel, GraphNode, GraphResponse, GraphView,
NodeDecoration, content_extent, downstream_of, draw_graph, draw_graph_scrolled,
};
#[cfg(test)]
mod tests {
//! The canvas's OWN behaviour (BFS closure, extent, fit/clear) is tested in
//! `facett-graphpan` — the leaf that owns it. What nornir must prove is the
//! **seam**: that `viz::graph_render` really resolves to facett's canvas and
//! that the surface nornir's two boards call is present and behaves. A
//! RED-when-broken assertion here fires the moment the re-export is dropped,
//! renamed, or silently re-forked into a local copy.
use super::*;
use eframe::egui::{Color32, Pos2, Vec2};
fn node(id: &str, x: f32) -> GraphNode {
GraphNode {
id: id.into(),
label: id.into(),
fill: Color32::GRAY,
stroke: Color32::WHITE,
pos: Pos2::new(x, 0.0),
}
}
fn edge(from: &str, to: &str) -> GraphEdge {
GraphEdge {
from: from.into(),
to: to.into(),
color: Color32::WHITE,
dashed: false,
label: None,
}
}
/// The seam itself: nornir's `graph_render::GraphModel` and facett's are the
/// SAME type (not two structurally-identical twins), and the re-exported
/// entry points are facett's. If someone re-forks a local copy this stops
/// compiling / stops being the same type.
#[test]
fn graph_render_is_facett_graphpan_not_a_local_copy() {
let m: GraphModel = GraphModel {
nodes: vec![node("A", 0.0), node("B", 100.0)],
edges: vec![edge("A", "B")],
};
// Same-type proof: hand nornir's alias straight to facett's function.
let facett_side: &facett_graphpan::GraphModel = &m;
assert_eq!(
facett_graphpan::content_extent(facett_side),
content_extent(&m),
"viz::graph_render::content_extent must BE facett_graphpan::content_extent"
);
// And the egui types unify: `eframe::egui::Vec2` is what facett returns.
let _: Vec2 = content_extent(&m);
}
/// The two surfaces nornir's boards depend on, asserted through the nornir
/// path so a facett API change that breaks `arch_tab`/`nornir_dashboard` is
/// caught by nornir's own suite, not only by facett's.
#[test]
fn consumed_surface_covers_what_the_arch_board_and_dashboard_call() {
let m = GraphModel {
nodes: vec![node("A", 0.0), node("B", 100.0), node("C", 200.0)],
edges: vec![edge("A", "B"), edge("B", "C"), edge("A", "C")],
};
// `nornir_dashboard` lights the downstream subtree of the selection.
let lit = downstream_of(&m, "B");
assert!(lit.contains("B") && lit.contains("C"));
assert!(
!lit.contains("A"),
"BFS is forward-only — A is not downstream of B"
);
// `arch_tab` sizes the wiring board's ScrollArea from the TRUE extent.
// (task #23 root cause: the canvas was allocated at `available_size`, so
// the scrollbar reported the viewport instead of the overflow — the
// extent MUST grow with the laid-out graph, never with the viewport.)
let narrow = GraphModel {
nodes: vec![node("A", 0.0), node("B", 300.0)],
edges: vec![],
};
let wide = GraphModel {
nodes: vec![node("A", 0.0), node("B", 900.0)],
edges: vec![],
};
assert!(
content_extent(&wide).x > content_extent(&narrow).x,
"a wider graph must report a wider extent (else the scrollbar lies)"
);
assert!(content_extent(&m).x > BOX_W, "extent exceeds one chip");
assert!(content_extent(&m).y > BOX_H, "extent exceeds one chip row");
// The transform both boards own across frames.
let mut v = GraphView {
pan: Vec2::new(5.0, 5.0),
zoom: 2.0,
selected: Some("A".into()),
dot_px: None,
};
v.fit();
assert_eq!(v.pan, Vec2::ZERO);
assert_eq!(v.zoom, 1.0);
assert_eq!(
v.selected.as_deref(),
Some("A"),
"fit resets the view, not the selection"
);
v.clear_selection();
assert!(v.selected.is_none());
// A default overlay is what the 🏛 board passes; the 🧬 dashboard fills it.
let empty = Decorations::default();
assert!(empty.nodes.is_empty() && empty.edges.is_empty());
let mut deco = Decorations::default();
deco.nodes.insert(
"A".into(),
NodeDecoration {
ring: Some(Color32::RED),
badge: Some("!".into()),
badge_color: None,
},
);
assert_eq!(deco.nodes.len(), 1, "gate rings/badges ride in Decorations");
// The frame result the boards read to run their domain side effects.
let r = GraphResponse::default();
assert!(r.clicked_node.is_none() && !r.clicked_empty);
}
}