src/viz/graph.rs
//! Rectangular dep-graph renderer (🔗 Dep Graph tab).
//!
//! Nodes (repos) are **rectangular** cards laid out by topological rank — a
//! repo's dependencies sit to its right, consumers to its left — from the
//! pure-data [`DepGraphLayout`] (`super::depgraph_layout`), which also computes
//! the deep transitive closure (C5) and classifies every edge **direct** vs
//! **transitive** (C7).
//!
//! Navigable canvas (I3):
//! * **pan** — drag the background, or scroll;
//! * **zoom** — Ctrl/Cmd + scroll (or the +/− buttons);
//! * **click a node** — toggle collapse/expand of its transitive subtree;
//! * **deep toggle** — explode to the full transitive closure.
//!
//! The whole graph (nodes, positions, classified edges, collapse set) is mirrored
//! into `state_json` via [`DepGraphView::state_json`] (LAW #6), so the headless
//! snapshot/robot tests assert on the exact structure painted.
use std::collections::BTreeSet;
use eframe::egui::{
self, Align2, Color32, CornerRadius, FontId, Pos2, Rect, Sense, Stroke, Ui, Vec2,
};
use uuid::Uuid;
use super::depgraph_layout::{DepGraphLayout, EdgeClass};
use super::facett_theme::ThemeExt; // nornir ramps over the shared facett palette (L5)
use super::model::Timeline;
const NODE_W: f32 = 160.0;
const NODE_H: f32 = 56.0;
const COL_GAP: f32 = 220.0;
const ROW_GAP: f32 = 90.0;
const LEFT_PAD: f32 = 30.0;
const TOP_PAD: f32 = 40.0;
// Edge palette (C7/C8) — direct deps take the palette accent, transitive deps the
// dimmer palette edge, so the eye separates "A depends on B" from "A reaches C
// through B" and both shift with the active facett palette.
/// Persistent view state for the 🔗 Dep Graph tab (owned by the app). Carries the
/// navigation transform (pan/zoom), the C5 **deep** toggle, the I3 collapse set,
/// and the last laid-out graph so its structure can be dumped to `state_json`.
#[derive(Default)]
pub struct DepGraphView {
/// C5: explode to the full transitive closure when true.
pub deep: bool,
/// Pan offset (screen px) applied to the whole graph.
pub pan: Vec2,
/// Zoom factor (1.0 = 100%).
pub zoom: f32,
/// I3: repos the user has collapsed (their exclusive subtree hidden).
pub collapsed: BTreeSet<String>,
/// C8: active facett palette — every painted colour derives from this so a
/// palette switch re-skins the pane.
pub theme: super::facett_theme::Theme,
/// Memoized transitive-closure for the selected repo's drill-down panel.
/// `draw_drilldown` runs every frame; without this it recomputed the BFS
/// closure each repaint. Keyed on `(repo, edges-fingerprint)` so it
/// recomputes only when the selection or the underlying edges change.
closure_cache: std::cell::RefCell<Option<ClosureMemo>>,
}
/// One memoized `transitive_closure` result + the key that produced it.
#[derive(Default)]
struct ClosureMemo {
repo: String,
/// Cheap fingerprint of `snap.edges` (count + folded hash) — if it matches,
/// the edges are unchanged and the cached closure is reused.
fingerprint: u64,
closure: BTreeSet<String>,
/// Diagnostics for the inject-assert test: bumped only on a real recompute.
recomputes: u64,
}
impl DepGraphView {
pub fn new() -> Self {
Self {
deep: false,
pan: Vec2::ZERO,
zoom: 1.0,
collapsed: BTreeSet::new(),
theme: super::facett_theme::Theme::default(),
closure_cache: std::cell::RefCell::new(None),
}
}
/// Transitive closure of `repo` over `edges`, **memoized** across frames.
/// `draw_drilldown` calls this every repaint; the BFS only re-runs when the
/// selected repo or the edge set changes (cheap fingerprint compare). The
/// returned set is cloned out so the `RefCell` borrow doesn't escape.
///
/// Returns `(closure, recomputed)` — `recomputed` is `true` only when a real
/// BFS happened, so a test can assert it's `false` on an unchanged repaint.
pub fn closure_memo(
&self,
repo: &str,
edges: &[crate::warehouse::dep_graph::CrossRepoEdge],
) -> (BTreeSet<String>, bool) {
let fp = edges_fingerprint(edges);
let mut slot = self.closure_cache.borrow_mut();
if let Some(memo) = slot.as_ref() {
if memo.repo == repo && memo.fingerprint == fp {
return (memo.closure.clone(), false); // cache hit — no recompute
}
}
let closure = DepGraphLayout::transitive_closure(repo, edges);
let recomputes = slot.as_ref().map(|m| m.recomputes).unwrap_or(0) + 1;
*slot = Some(ClosureMemo {
repo: repo.to_string(),
fingerprint: fp,
closure: closure.clone(),
recomputes,
});
(closure, true)
}
/// Total number of real closure recomputes so far — for the inject-assert
/// test that asserts an unchanged repaint does not recompute.
pub fn closure_recomputes(&self) -> u64 {
self.closure_cache
.borrow()
.as_ref()
.map(|m| m.recomputes)
.unwrap_or(0)
}
/// C8: swap the active palette; the next draw re-skins from `t`.
pub fn set_palette(&mut self, t: super::facett_theme::Theme) {
self.theme = t;
}
/// The laid-out graph as JSON for the introspection dump, computed fresh
/// from `tl` + the current view settings (deep + collapse). Used by
/// `state_json` so the headless matrix sees the structure without a draw.
/// Returns `Null` when there is no snapshot to lay out.
pub fn state_json_for(
&self,
tl: &Timeline,
selected_release: Option<Uuid>,
) -> serde_json::Value {
// An empty timeline (no release/snapshot to lay out) still EMITS a
// readable "empty" slice (LAW #6 / coverage-guard) — never a bare null the
// headless matrix can't tell apart from "this pane has no emitter".
let mut v = self
.layout_for(tl, selected_release)
.map(|l| l.state_json())
.unwrap_or_else(|| {
serde_json::json!({
"empty": true,
"node_count": 0,
"edge_count": 0,
})
});
if let serde_json::Value::Object(obj) = &mut v {
obj.insert("palette".into(), serde_json::json!(self.theme.name));
}
v
}
fn layout_for(&self, tl: &Timeline, selected_release: Option<Uuid>) -> Option<DepGraphLayout> {
// DEFAULT-LOAD (never blank while ANY snapshot exists): prefer the
// explicitly-selected release's snapshot; else fall back to the MOST RECENT
// release that HAS a dep-graph snapshot (walk `release_order` newest-first);
// else the latest loaded snapshot. Only truly-empty workspaces (no snapshot
// anywhere) return `None`. Previously this returned `None` whenever the
// last release / the selection lacked a pinned snapshot, blanking the tab
// even though the workspace held other snapshots.
let snapshot = selected_release
.and_then(|r| tl.snapshot_for(&r))
.or_else(|| {
tl.release_order
.iter()
.rev()
.find_map(|r| tl.snapshot_for(r))
})
.or(tl.latest_snapshot.as_ref())?;
let repos: Vec<String> = tl.lanes.iter().map(|l| l.repo.clone()).collect();
Some(DepGraphLayout::build(
&repos,
&snapshot.edges,
self.deep,
&self.collapsed,
))
}
}
/// Draw the dep-graph tab. `selected_repo` is the app's drill-down selection
/// (kept so the existing right-panel keeps working). Returns nothing; mutates
/// `view` (pan/zoom/collapse/deep) and `selected_repo` from interaction.
pub fn draw_dep_graph(
ui: &mut Ui,
tl: &Timeline,
selected_release: Option<Uuid>,
selected_repo: &mut Option<String>,
view: &mut DepGraphView,
) {
if view.zoom <= 0.0 {
view.zoom = 1.0;
}
let theme = view.theme;
let direct_color = theme.accent;
let transitive_color = theme.edge;
// ── toolbar: deep toggle, zoom, reset, legend ──────────────────────────
ui.horizontal(|ui| {
let deep_label = if view.deep {
"🌳 deep: ON"
} else {
"🌳 deep: off"
};
if ui
.selectable_label(view.deep, deep_label)
.on_hover_text("explode to the FULL transitive closure (C5)")
.clicked()
{
view.deep = !view.deep;
}
ui.separator();
if ui.button("➖").on_hover_text("zoom out").clicked() {
view.zoom = (view.zoom * 0.9).max(0.25);
}
ui.label(format!("{:.0}%", view.zoom * 100.0));
if ui.button("➕").on_hover_text("zoom in").clicked() {
view.zoom = (view.zoom * 1.1).min(4.0);
}
if ui
.button("⟲ reset view")
.on_hover_text("reset pan + zoom")
.clicked()
{
view.pan = Vec2::ZERO;
view.zoom = 1.0;
}
if !view.collapsed.is_empty()
&& ui
.button(format!("expand all ({})", view.collapsed.len()))
.on_hover_text("re-expand every collapsed node")
.clicked()
{
view.collapsed.clear();
}
ui.separator();
// Legend (C7).
legend_swatch(ui, direct_color, "direct dep");
legend_swatch(ui, transitive_color, "transitive dep");
});
let Some(layout) = view.layout_for(tl, selected_release) else {
// No release in the WHOLE workspace has a dep-graph snapshot yet (the
// fallback above already default-loads the most recent one that does). A
// snapshot is captured whenever a workspace is (re)populated / released, so
// point the user at that path rather than leaving a bare "empty".
ui.label("no dependency-graph snapshot recorded in this workspace yet");
ui.weak("a snapshot is captured when the workspace is populated / released — run a populate (🧬 nornir → Populate) or `nornir workspace republish` to record one.");
return;
};
// ── canvas ─────────────────────────────────────────────────────────────
let n_cols = layout.nodes.iter().map(|n| n.col).max().unwrap_or(0) + 1;
let n_rows = layout.nodes.iter().map(|n| n.row).max().unwrap_or(0) + 1;
let available = ui.available_size();
let content_w = LEFT_PAD * 2.0 + n_cols as f32 * COL_GAP + NODE_W;
let content_h = TOP_PAD * 2.0 + n_rows as f32 * ROW_GAP + NODE_H;
let canvas = Vec2::new(available.x.max(400.0), available.y.max(360.0));
let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click_and_drag());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, theme.bg);
// ── pan + zoom interaction (I3) ──────────────────────────────────────────
if resp.dragged() {
view.pan += resp.drag_delta();
}
let hovered = resp.hovered();
if hovered {
ui.input(|i| {
// scroll → pan; Ctrl/Cmd+scroll → zoom about the cursor.
let scroll = i.smooth_scroll_delta;
if i.modifiers.command || i.modifiers.ctrl {
let factor = (1.0 + scroll.y * 0.0015).clamp(0.5, 2.0);
view.zoom = (view.zoom * factor).clamp(0.25, 4.0);
} else if scroll != Vec2::ZERO {
view.pan += scroll;
}
});
}
// world → screen: center the content, then apply pan + zoom.
let base = rect.left_top().to_vec2() + Vec2::new(LEFT_PAD, TOP_PAD);
let z = view.zoom;
let offset = base
+ view.pan
+ Vec2::new(
((rect.width() - content_w * z).max(0.0)) / 2.0,
((rect.height() - content_h * z).max(0.0)) / 2.0,
);
let node_w = NODE_W * z;
let node_h = NODE_H * z;
let pos_of = |col: usize, row: usize| -> Pos2 {
(offset + Vec2::new(col as f32 * COL_GAP * z, row as f32 * ROW_GAP * z)).to_pos2()
};
let node_pos =
|repo: &str| -> Option<Pos2> { layout.position(repo).map(|(c, r)| pos_of(c, r)) };
let node_rect = |repo: &str| -> Option<Rect> {
node_pos(repo).map(|p| Rect::from_min_size(p, Vec2::new(node_w, node_h)))
};
// ── edges first (under nodes), coloured by class (C7) ───────────────────
// Painted-primitive counters (testmatrix only): the emit-fallback signal that
// the egui draw produced a NON-BLANK scene. Stripped from release.
#[cfg(feature = "testmatrix")]
let (mut painted_edges, mut painted_nodes) = (0usize, 0usize);
for e in &layout.edges {
let (Some(fr), Some(tr)) = (node_rect(&e.from), node_rect(&e.to)) else {
continue;
};
#[cfg(feature = "testmatrix")]
{
painted_edges += 1;
}
let from = fr.center();
let to = tr.center();
let color = match e.class {
EdgeClass::Direct => direct_color,
EdgeClass::Transitive => transitive_color,
};
let dashed = e.class == EdgeClass::Transitive;
draw_arrow(&painter, from, to, color, node_w, dashed, z);
// Label: via crates for a direct edge, hop count for a transitive one.
let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0 * z);
let label = match e.class {
EdgeClass::Direct => {
let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
if e.via.len() > 2 {
format!("{via} +{}", e.via.len() - 2)
} else {
via
}
}
EdgeClass::Transitive => format!("⤳ {} hops", e.hops),
};
if z > 0.45 && !label.is_empty() {
painter.text(
mid,
Align2::CENTER_BOTTOM,
&label,
FontId::monospace(10.0 * z.min(1.4)),
if dashed { theme.text_dim } else { theme.text },
);
}
}
// ── nodes (rectangular cards — KEEP the look) ───────────────────────────
for node in &layout.nodes {
let Some(nrect) = node_rect(&node.repo) else {
continue;
};
#[cfg(feature = "testmatrix")]
{
painted_nodes += 1;
}
let lane = tl.lanes.iter().find(|l| l.repo == node.repo);
let release = selected_release.or_else(|| tl.release_order.last().copied());
let status = lane
.and_then(|l| release.and_then(|rid| l.nodes.iter().find(|n| n.release_id == rid)))
.map(|n| status_color(&theme, &n.gate_status))
.unwrap_or(theme.node_stroke);
let is_sel = selected_repo.as_deref() == Some(node.repo.as_str());
let fill = if is_sel {
theme.accent.linear_multiply(0.18)
} else {
theme.node_fill
};
painter.rect_filled(nrect, CornerRadius::same(6), fill);
let (bw, bc) = if is_sel {
(3.0_f32, theme.accent)
} else if node.collapsed {
(2.5_f32, super::facett_theme::AMBER)
} else {
(2.0_f32, status)
};
painter.rect_stroke(
nrect,
CornerRadius::same(6),
Stroke::new(bw, bc),
egui::StrokeKind::Inside,
);
// collapse marker (▸ collapsed / ▾ expanded) at the top-left.
if z > 0.5 {
painter.text(
nrect.left_top() + Vec2::new(6.0, 4.0),
Align2::LEFT_TOP,
if node.collapsed { "▸" } else { "▾" },
FontId::monospace(12.0),
theme.text,
);
}
painter.text(
nrect.center_top() + Vec2::new(0.0, 12.0 * z),
Align2::CENTER_TOP,
&node.repo,
FontId::proportional(15.0 * z.clamp(0.6, 1.4)),
theme.text,
);
if z > 0.55 {
if let Some(n) = lane
.and_then(|l| release.and_then(|rid| l.nodes.iter().find(|n| n.release_id == rid)))
{
painter.text(
nrect.center_top() + Vec2::new(0.0, 32.0 * z),
Align2::CENTER_TOP,
&n.sha[..n.sha.len().min(12)],
FontId::monospace(11.0 * z.clamp(0.6, 1.4)),
theme.text,
);
}
}
}
// ── click: a node toggles collapse/expand (I3); empty space deselects ───
if resp.clicked() {
if let Some(pos) = resp.interact_pointer_pos() {
let hit = layout
.nodes
.iter()
.map(|n| n.repo.clone())
.find(|r| node_rect(r).is_some_and(|rc| rc.contains(pos)));
if let Some(repo) = hit {
// Select for the drill-down panel AND toggle collapse.
*selected_repo = Some(repo.clone());
if view.collapsed.contains(&repo) {
view.collapsed.remove(&repo);
} else {
view.collapsed.insert(repo);
}
} else {
*selected_repo = None;
}
}
}
// ── drill-down panel for the selected repo (unchanged behaviour) ────────
if let (Some(repo), Some(release)) = (
selected_repo.clone(),
selected_release.or_else(|| tl.release_order.last().copied()),
) {
if let Some(snap) = tl.snapshot_for(&release) {
draw_drilldown(view, &painter, rect, &repo, snap, &layout, &theme);
}
}
// ── emit-fallback (testmatrix only): the egui paint is a side-effect that
// can't be read back from outside, so record the painted-primitive count as
// a functional row. ok ⇔ at least one node card was painted ⇒ scene NOT blank.
//
// Gate on a non-empty layout: only assert "non-blank" when the timeline
// actually carried repos to lay out. A dep-graph pane drawn over an empty
// warehouse (no dep_graph snapshot) legitimately has nothing to paint — that
// is an empty dataset, not a render failure, so it must not stamp a RED row.
#[cfg(feature = "testmatrix")]
if !layout.nodes.is_empty() {
crate::selftest::emit(
"viz/graph (DepGraphView egui draw)",
"dep_graph_rendered_non_blank",
painted_nodes > 0,
&format!(
"painted {painted_nodes} node card(s) + {painted_edges} edge(s) on a \
{:.0}x{:.0}px canvas",
rect.width(),
rect.height()
),
);
}
}
/// Cheap order-insensitive-enough fingerprint of the recorded edge set, used to
/// invalidate the drill-down closure memo. Edges are produced deterministically
/// from a snapshot, so a fold over `(from, to)` in stored order is a stable key:
/// identical edges → identical fingerprint → cache hit (no recompute).
fn edges_fingerprint(edges: &[crate::warehouse::dep_graph::CrossRepoEdge]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
edges.len().hash(&mut h);
for e in edges {
e.from.hash(&mut h);
e.to.hash(&mut h);
}
h.finish()
}
fn legend_swatch(ui: &mut Ui, color: Color32, label: &str) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(22.0, 12.0), Sense::hover());
ui.painter().rect_filled(rect, CornerRadius::same(2), color);
ui.label(label);
}
fn draw_drilldown(
view: &DepGraphView,
painter: &egui::Painter,
rect: Rect,
repo: &str,
snap: &crate::warehouse::dep_graph::DepGraphSnapshot,
layout: &DepGraphLayout,
theme: &super::facett_theme::Theme,
) {
let deps: Vec<String> = snap
.edges
.iter()
.filter(|e| e.from == repo)
.map(|e| {
format!(
" → {} [{}]",
e.to,
e.via.iter().cloned().collect::<Vec<_>>().join(", ")
)
})
.collect();
let users: Vec<String> = snap
.edges
.iter()
.filter(|e| e.to == repo)
.map(|e| {
format!(
" ← {} [{}]",
e.from,
e.via.iter().cloned().collect::<Vec<_>>().join(", ")
)
})
.collect();
// Memoized across frames: the per-repaint BFS recompute is the draw-path
// cost EPIC #44 calls out. `closure_memo` re-runs only when repo/edges change.
let (trans, _recomputed) = view.closure_memo(repo, &snap.edges);
let rows = 5 + deps.len() + users.len();
let panel = Rect::from_min_size(
rect.left_top() + Vec2::new(8.0, 8.0),
Vec2::new(390.0, rows as f32 * 16.0 + 16.0),
);
painter.rect_filled(panel, CornerRadius::same(6), theme.panel_bg);
painter.rect_stroke(
panel,
CornerRadius::same(6),
Stroke::new(1.0_f32, theme.panel_stroke),
egui::StrokeKind::Inside,
);
let mut y = panel.top() + 8.0;
let mut put = |s: &str, c: Color32| {
painter.text(
Pos2::new(panel.left() + 10.0, y),
Align2::LEFT_TOP,
s,
FontId::proportional(12.0),
c,
);
y += 16.0;
};
let collapsed = layout.nodes.iter().any(|n| n.repo == repo && n.collapsed);
put(
&format!(
"◆ {repo} (click node to {})",
if collapsed { "expand" } else { "collapse" }
),
theme.accent,
);
put(&format!("direct deps ({}):", deps.len()), theme.accent);
for d in &deps {
put(d, theme.text);
}
put(
&format!(
"transitive closure ({}): {}",
trans.len(),
trans.iter().cloned().collect::<Vec<_>>().join(", ")
),
theme.edge,
);
put(&format!("used by ({}):", users.len()), theme.text_dim);
for u in &users {
put(u, theme.text);
}
}
fn status_color(theme: &super::facett_theme::Theme, status: &str) -> Color32 {
theme.status_color(status)
}
/// Arrow from card-center to card-center, stopping at the target card's edge.
/// `dashed` draws a transitive edge as a dashed line.
fn draw_arrow(
painter: &egui::Painter,
from: Pos2,
to: Pos2,
color: Color32,
node_w: f32,
dashed: bool,
z: f32,
) {
let dir = (to - from).normalized();
let tip = to - dir * (node_w / 2.0 + 4.0);
let stroke = Stroke::new(2.0 * z.clamp(0.6, 1.5), color);
if dashed {
// simple manual dashes
let total = (tip - from).length();
let step = 10.0 * z;
let mut t = 0.0;
while t < total {
let a = from + dir * t;
let b = from + dir * (t + step * 0.6).min(total);
painter.line_segment([a, b], stroke);
t += step;
}
} else {
painter.line_segment([from, tip], stroke);
}
// arrowhead
let perp = Vec2::new(-dir.y, dir.x);
let base = tip - dir * 10.0 * z;
let a = base + perp * 5.0 * z;
let b = base - perp * 5.0 * z;
painter.add(egui::Shape::convex_polygon(
vec![tip, a, b],
color,
Stroke::NONE,
));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::warehouse::dep_graph::CrossRepoEdge;
fn edge(from: &str, to: &str) -> CrossRepoEdge {
CrossRepoEdge::normal(from, to, BTreeSet::new())
}
#[test]
fn closure_memo_is_correct_and_not_recomputed_per_frame() {
// INJECT a known chain A→B→C, A→C: A's transitive closure is {B, C}.
let edges = vec![edge("A", "B"), edge("B", "C"), edge("A", "C")];
let view = DepGraphView::new();
// First call: a real recompute, correct closure.
let (c1, recomputed1) = view.closure_memo("A", &edges);
assert!(recomputed1, "first call must recompute");
assert_eq!(
c1.into_iter().collect::<Vec<_>>(),
vec!["B".to_string(), "C".to_string()],
"closure of A is {{B, C}}"
);
assert_eq!(view.closure_recomputes(), 1);
// Repaint with UNCHANGED repo + edges (what every idle frame does): the
// cache must serve it WITHOUT recomputing — the draw-path cost #44 named.
for _ in 0..10 {
let (_, recomputed) = view.closure_memo("A", &edges);
assert!(
!recomputed,
"unchanged repaint must NOT recompute the closure"
);
}
assert_eq!(
view.closure_recomputes(),
1,
"still only one BFS for 11 frames"
);
// Selecting a different repo invalidates → one more recompute, new answer.
let (cb, recomputed_b) = view.closure_memo("B", &edges);
assert!(recomputed_b, "selection change recomputes");
assert_eq!(cb.into_iter().collect::<Vec<_>>(), vec!["C".to_string()]);
assert_eq!(view.closure_recomputes(), 2);
// Changing the edge set (new fingerprint) also invalidates.
let mut edges2 = edges.clone();
edges2.push(edge("C", "D"));
let (cd, recomputed_d) = view.closure_memo("A", &edges2);
assert!(recomputed_d, "edge-set change recomputes");
assert!(
cd.contains("D"),
"new transitive node D reached after edge added"
);
assert_eq!(view.closure_recomputes(), 3);
}
}