src/viz/callgraph.rs
//! Connected **dep graph → call graph** tab.
//!
//! Drill `repo → crate → function calls`. The levels are one nested graph: repos
//! and crates come from `symbol_facts` (and line up with `dep_graph_edges.via`),
//! and the leaf call graph comes from `call_edges` (`caller_path → callee_ident`).
//! Data is read over `Warehouse.Scan` (columns + stringified rows), so this works
//! both **embedded** (local warehouse) and **remote** (against a `nornir-server`,
//! scoped by the `nornir-workspace` header), exactly like the warehouse browser.
use std::collections::HashMap;
use std::path::PathBuf;
use eframe::egui::{self, Color32, FontId, Pos2, Sense, Stroke, Vec2};
use super::facett_theme::ThemeExt; // nornir ramps over the shared facett palette (L5)
use crate::warehouse::iceberg::TablePreview;
/// Cap rendered nodes (highest-degree kept) so a huge crate stays legible.
const MAX_NODES: usize = 160;
/// Volumetric-fog haze colour: a cold pale-blue "atmosphere" the far depths
/// dissolve into. Far geometry is blended toward this (matching the icy
/// palette) rather than merely dimmed, so the cloud reads as genuinely
/// volumetric.
const FOG_COLOR: Color32 = Color32::from_rgb(18, 26, 40);
enum Src {
/// The workspace's own code room under this warehouse root.
Local(PathBuf),
Remote { endpoint: String, token: String },
}
struct Node {
id: String,
label: String,
pos: Pos2,
/// Depth coordinate (the 3rd axis) assigned by the chosen [`Layout3D`].
z: f32,
deg: usize,
}
/// 3D layout method for the call graph's depth axis — selectable in the toolbar.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Layout3D {
/// #1 — extend the Fruchterman–Reingold force sim to xyz.
Force,
/// #2 — connected components → z-shells, biggest cluster at z=0 (center),
/// the rest fanned out in ±z; "Iceland dots hang together".
ClusterZ,
/// #3 — topological layering: z = call depth (callers front, leaves back).
Topo,
/// #4 — spectral embedding: low eigenvectors of the graph Laplacian (power
/// iteration) → xyz.
Spectral,
/// #5 — classical MDS on BFS hop-distances → xyz.
Mds,
/// #6 — keep the 2D force x,y; z = degree centrality so hubs pop toward you.
Centrality,
/// #7 — order by degree onto a 3-turn helix (spins beautifully around Y).
Helix,
}
impl Layout3D {
const ALL: [(Layout3D, &'static str); 7] = [
(Layout3D::ClusterZ, "cluster z-shells"),
(Layout3D::Force, "3D force"),
(Layout3D::Topo, "topological"),
(Layout3D::Spectral, "spectral"),
(Layout3D::Mds, "MDS"),
(Layout3D::Centrality, "centrality (hubs front)"),
(Layout3D::Helix, "helix"),
];
fn label(self) -> &'static str {
Self::ALL
.iter()
.find(|(l, _)| *l == self)
.map(|(_, s)| *s)
.unwrap_or("?")
}
}
pub struct CallGraphState {
src: Src,
/// Selected workspace (the `nornir-workspace` gRPC header); empty for local.
workspace: String,
loaded: bool,
/// When the last warehouse fetch ran — the shared `repo_pane` throttle clock
/// that lets an EMPTY lens auto-refetch (a background populate fills it
/// without a click) instead of latching blank forever. `None` ⇒ never fetched.
last_fetch_at: Option<std::time::Instant>,
error: Option<String>,
/// Distinct repos and their crates, from `symbol_facts`.
repos: Vec<String>,
crates_by_repo: HashMap<String, Vec<String>>,
sel_repo: Option<String>,
sel_crate: Option<String>,
/// Cached `call_edges` scan (reused across crate switches).
call_rows: Option<TablePreview>,
/// Cached `symbol_facts` scan — the details panel looks up a clicked
/// node's kind/file/line/signature here.
sym_rows: Option<TablePreview>,
/// Clicked node (index into `nodes`) — drives the details side panel.
selected: Option<usize>,
/// Current laid-out graph.
nodes: Vec<Node>,
edges: Vec<(usize, usize)>,
/// **SCIP-provenance per edge**, parallel to [`Self::edges`]: `true` = the
/// edge was RESOLVED by the SCIP index (a globally-unique resolved moniker
/// spanned the call, incl. cross-crate bin→lib); `false` = a name-based SYN
/// fallback edge (the `call_edges` heuristic). The render styles resolved
/// edges solid + accent, syn-fallback dashed + dim, and `state_json` reports
/// the per-class counts (LAW 6). The warehouse `call_edges` scan carries no
/// per-row provenance yet, so warehouse-built edges default to syn; the
/// synthetic-injection hooks set it so the overlay is provable.
edge_resolved: Vec<bool>,
built_for: Option<(String, String)>,
truncated: bool,
pan: Vec2,
zoom: f32,
/// 3D Y-axis auto-spin: rotation angle (radians) + the last time the user
/// touched the canvas. The 3D node cloud rotates around its vertical axis the
/// moment the lens is active (N3 — the Architecture default is this spinning
/// cloud); a pointer-drag ORBITS it manually and pauses the auto-spin.
spin: f32,
/// N3: is the auto-spin running? Default ON so opening the Architecture tab
/// shows a live, rotating graph. The ⟳ spin toggle flips it; a pointer-drag
/// temporarily pauses it (the drag orbits instead).
spin_enabled: bool,
last_input: std::time::Instant,
/// Selected 3D depth-axis layout.
layout3d: Layout3D,
/// Volumetric-fog density: far nodes/edges fade toward [`FOG_COLOR`] by
/// `1 - exp(-density * depth_norm)`. 0 = crystal clear, higher = thicker
/// haze. Tunable via the toolbar slider; never read on CLI/headless paths.
fog_density: f32,
/// C8: active facett palette — the painted chrome (edges, highlights,
/// selection ring, labels) derives from this so a palette switch re-skins it.
theme: super::facett_theme::Theme,
/// N2 — background "Deep-scan" state for the 🕸 Call Graph lens (the default
/// Architecture view): a thin-mode server republish that recomputes + persists
/// `call_edges`, then invalidates so the graph rebuilds from the fresh rows.
deep_scan: std::sync::Arc<std::sync::Mutex<CgDeepScan>>,
/// The rows behind the graph came from the code room's reader (not a test
/// injection).
fed_by_room: bool,
}
/// N2 — the 🕸 Call Graph lens's manual deep-scan state.
#[derive(Default)]
struct CgDeepScan {
inflight: bool,
error: Option<String>,
note: Option<String>,
/// The server republish finished OK → invalidate so the graph refetches.
refetch: bool,
}
impl CallGraphState {
/// Fat mode: the lens reads the workspace's own code room under `root`
/// (`<root>/mimir_v2/`).
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,
last_fetch_at: None,
error: None,
repos: Vec::new(),
crates_by_repo: HashMap::new(),
sel_repo: None,
sel_crate: None,
call_rows: None,
sym_rows: None,
selected: None,
nodes: Vec::new(),
edges: Vec::new(),
edge_resolved: Vec::new(),
built_for: None,
truncated: false,
pan: Vec2::ZERO,
zoom: 1.0,
spin: 0.0,
spin_enabled: true,
last_input: std::time::Instant::now(),
layout3d: Layout3D::ClusterZ,
fog_density: 2.0,
theme: super::facett_theme::Theme::default(),
deep_scan: std::sync::Arc::new(std::sync::Mutex::new(CgDeepScan::default())),
fed_by_room: false,
}
}
/// N2 — trigger a real `call_edges` recomputation from the 🕸 Call Graph lens.
/// THIN mode kicks a server-side workspace republish (`Workspaces.Fetch`
/// background) that re-scans every member and PERSISTS `call_edges`, then
/// invalidates so the graph rebuilds from the fresh warehouse rows. Idempotent
/// while a scan is in flight. `pub(crate)` so the app/tests can fire it.
pub(crate) fn trigger_deep_scan(&mut self) {
{
let g = self.deep_scan.lock().unwrap();
if g.inflight {
return;
}
}
let (endpoint, token) = match &self.src {
Src::Remote { endpoint, token } => (endpoint.clone(), token.clone()),
// Local/fat: no server to delegate to — re-read the code room (the
// consumer writes it out of band).
Src::Local(_) => {
self.invalidate();
self.deep_scan.lock().unwrap().note = Some("re-read the code room".to_string());
return;
}
};
let workspace = self.workspace.clone();
if workspace.is_empty() {
self.deep_scan.lock().unwrap().error = Some("no workspace selected".to_string());
return;
}
{
let mut g = self.deep_scan.lock().unwrap();
g.inflight = true;
g.error = None;
g.note = None;
}
let shared = self.deep_scan.clone();
std::thread::spawn(move || {
let res = super::remote::populate_workspace(&endpoint, &token, &workspace);
let mut g = shared.lock().unwrap();
g.inflight = false;
match res {
Ok((fetched, _changed, errors, _snap)) => {
g.refetch = true;
g.note = Some(format!(
"{fetched} member(s) fetched{}; the graph follows what the server's consumer has written to the code room",
if errors.is_empty() {
String::new()
} else {
format!(", {} warn", errors.len())
},
));
}
Err(e) => g.error = Some(format!("{e:#}")),
}
});
}
/// N2 — apply a completed thin-mode republish: invalidate so the next draw
/// refetches the freshly-persisted `call_edges`.
fn poll_deep_scan(&mut self) {
let refetch = {
let mut g = self.deep_scan.lock().unwrap();
std::mem::take(&mut g.refetch)
};
if refetch {
self.invalidate();
}
}
/// 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;
}
/// (resolved, syn-fallback) edge counts from the per-edge SCIP-provenance —
/// the legend's numbers + the `state_json` split. An edge with no recorded
/// provenance counts as syn (the name-based default).
fn provenance_counts(&self) -> (usize, usize) {
let resolved = self.edge_resolved.iter().filter(|&&r| r).count();
(resolved, self.edges.len() - resolved)
}
/// The 🕸 Call Graph lens's slice of `state_json` (LAW #6) — the function
/// call graph as readable DATA so the headless matrix proves it is reachable
/// AND reports data: the source mode, the selected repo/crate, the discovered
/// repo/crate pickers, the laid-out node/edge counts, the highest-degree node
/// labels, the truncation flag, and any load error. `built` distinguishes "a
/// graph was actually built" (data present) from a not-yet-selected pane.
pub fn state_json(&self) -> serde_json::Value {
// The top node labels by degree — a cheap, stable signal that the graph
// carries real fns (not just an empty canvas).
let mut by_deg: Vec<&Node> = self.nodes.iter().collect();
by_deg.sort_by(|a, b| b.deg.cmp(&a.deg).then(a.label.cmp(&b.label)));
let top_nodes: Vec<&str> = by_deg.iter().take(8).map(|n| n.label.as_str()).collect();
let (resolved_edges, syn_edges) = self.provenance_counts();
serde_json::json!({
"source": match &self.src { Src::Local(_) => "local", Src::Remote { .. } => "remote" },
"workspace": self.workspace,
"repos": self.repos,
"selected_repo": self.sel_repo,
"selected_crate": self.sel_crate,
"layout3d": self.layout3d.label(),
// N3 — is the 3D node cloud auto-rotating? Default true (the spinning
// Architecture cloud); a headless drive asserts the default + the toggle.
"spin": self.spin_enabled,
// N2 — is a manual deep-scan republish in flight?
"deep_scan_inflight": self.deep_scan.lock().unwrap().inflight,
"node_count": self.nodes.len(),
"edge_count": self.edges.len(),
// ── SCIP-PROVENANCE OVERLAY (LAW 6): how many edges the SCIP index
// RESOLVED vs the name-based SYN fallback — the resolved-index work made
// visible as DATA, so a test reads the split (not just pixels).
"resolved_edges": resolved_edges,
"syn_edges": syn_edges,
"top_nodes": top_nodes,
"truncated": self.truncated,
"built": self.built_for.is_some(),
"built_for": self.built_for.as_ref().map(|(r, c)| format!("{r}::{c}")),
"selected": self.selected.and_then(|i| self.nodes.get(i)).map(|n| n.label.clone()),
"error": self.error,
// Where the drawn graph comes from: the code room's reader, a test
// injection — or why there is none (`unavailable` + `error`: the
// reader's refusal; `not_loaded`; `empty`: the room has no call
// between fns of the selected crate).
"state": if !self.nodes.is_empty() {
if self.fed_by_room { "code_room" } else { "injected" }
} else if self.error.is_some() {
"unavailable"
} else if !self.loaded {
"not_loaded"
} else {
"empty"
},
"empty": self.nodes.is_empty(),
// LAW 2 empty-state: which reason the (warehouse-)empty lens shows —
// "populated" / "in_route" (a populate job is active for this repo) /
// "not_scanned". Asserted headlessly so a blank board can't pass.
"empty_state": self.empty_state().id(),
"palette": self.theme.name,
})
}
/// Re-scope to a different workspace (the picker switched): drop all cached
/// scans/graph so the next draw rebuilds from the new workspace's warehouse.
pub(crate) fn set_workspace(&mut self, workspace: String) {
self.workspace = workspace;
self.loaded = false;
self.last_fetch_at = None;
self.error = None;
self.repos.clear();
self.crates_by_repo.clear();
self.sel_repo = None;
self.sel_crate = None;
self.call_rows = None;
self.sym_rows = None;
self.selected = None;
self.nodes.clear();
self.edges.clear();
self.edge_resolved.clear();
self.built_for = None;
}
/// Force a rebuild on the next draw (e.g. after a ⟳ Sync changed the
/// warehouse) — drop the cached scans so `ensure_loaded` re-runs.
pub(crate) fn invalidate(&mut self) {
self.loaded = false;
self.last_fetch_at = None;
self.built_for = None;
}
/// The lens's empty-state classification for the SELECTED repo (LAW 2): the
/// shared `repo_pane` helper turns a zero-symbol warehouse into a reasoned
/// "⏳ in route for populate…" / "not scanned yet" based on the live jobs.
fn empty_state(&self) -> super::repo_pane::EmptyState {
let repo = self.sel_repo.clone().unwrap_or_default();
super::repo_pane::classify_empty(self.repos.is_empty(), &self.workspace, &repo)
}
/// Scan `symbol_facts` (for the repo/crate pickers) + `call_edges`. The first
/// fetch always runs; while the warehouse is EMPTY (no repos) the shared
/// `repo_pane` throttle re-fetches — eagerly when a populate job for this
/// workspace is active, else on the slow interval — so a background populate's
/// rows appear WITHOUT a manual ↻ (the sticky-`loaded` fix). Once repos are
/// discovered it latches, exactly as before (no per-frame churn).
fn ensure_loaded(&mut self) {
let repo = self.sel_repo.clone().unwrap_or_default();
let populate_active = super::repo_pane::populate_active_for(&self.workspace, &repo);
if !super::repo_pane::should_refetch(
self.loaded,
self.last_fetch_at,
self.repos.is_empty(),
populate_active,
) {
return;
}
self.loaded = true;
self.last_fetch_at = Some(std::time::Instant::now());
// The lens reads the code room: the crate list from `Viz.Knowledge`, the
// symbol + call rows from `Viz.Architecture` (the same rows the 🚇 metro
// rides) — over gRPC in thin mode, from the workspace's own code room in
// fat mode. A reader that cannot answer is the lens's error, by state.
let fetched: Result<(serde_json::Value, serde_json::Value), String> = match &self.src {
Src::Remote { endpoint, token } => super::remote::fetch_knowledge(endpoint, token, &self.workspace)
.and_then(|k| Ok((k, serde_json::to_value(super::remote::fetch_architecture(endpoint, token, &self.workspace)?)?)))
.map_err(|e| format!("{e:#}")),
Src::Local(root) => {
let ask = |surface: &str| super::remote::local_surface(root, &self.workspace, surface, &serde_json::json!({ "workspace": self.workspace }));
ask("Viz.Knowledge").and_then(|k| Ok((k, ask("Viz.Architecture")?))).map_err(|e| format!("{e:#}"))
}
};
match fetched {
Ok((knowledge, arch)) => {
self.error = None;
self.adopt(&knowledge, &arch);
}
Err(e) => self.error = Some(format!("call graph: {e}")),
}
}
/// Take the reader's two answers as the lens's rows: the (repo, crate)
/// pickers from the knowledge map's bubbles, the call / symbol rows from the
/// architecture payload's fuel (tagged `resolved/scip`: rustc resolved every
/// callee, so every edge draws as resolved).
fn adopt(&mut self, knowledge: &serde_json::Value, arch: &serde_json::Value) {
let text = |v: &serde_json::Value, k: &str| v[k].as_str().unwrap_or("").to_string();
let mut repo_of: HashMap<String, String> = HashMap::new();
self.repos.clear();
self.crates_by_repo.clear();
for b in knowledge["crates"].as_array().map(Vec::as_slice).unwrap_or(&[]) {
let (repo, krate) = (text(b, "repo"), text(b, "krate"));
if !self.repos.contains(&repo) {
self.repos.push(repo.clone());
}
self.crates_by_repo.entry(repo.clone()).or_default().push(krate.clone());
repo_of.insert(krate, repo);
}
let source = text(arch, "metro_fuel_source");
let repo = |krate: &str| repo_of.get(krate).cloned().unwrap_or_default();
self.call_rows = Some(TablePreview {
columns: ["repo", "crate_name", "caller_path", "callee_ident", "source"].map(String::from).to_vec(),
rows: arch["calls"]
.as_array()
.map(Vec::as_slice)
.unwrap_or(&[])
.iter()
.map(|c| {
let krate = text(c, "crate_name");
vec![repo(&krate), krate, text(c, "caller_path"), text(c, "callee_ident"), source.clone()]
})
.collect(),
});
self.sym_rows = Some(TablePreview {
columns: ["repo", "crate_name", "item_name", "file", "line", "item_kind", "signature", "visibility"].map(String::from).to_vec(),
rows: arch["symbols"]
.as_array()
.map(Vec::as_slice)
.unwrap_or(&[])
.iter()
.map(|x| {
let krate = text(x, "crate_name");
vec![
repo(&krate),
krate,
text(x, "item_name"),
text(x, "file"),
x["line"].as_u64().unwrap_or(0).to_string(),
text(x, "item_kind"),
text(x, "signature"),
text(x, "visibility"),
]
})
.collect(),
});
// the board is complete but the fuel was left out (too large for one message)
if self.call_rows.as_ref().is_some_and(|r| r.rows.is_empty())
&& let Some(e) = arch["errors"].as_array().and_then(|a| a.first()).and_then(|e| e.as_str())
{
self.error = Some(format!("call graph: {e}"));
}
if self.sel_repo.as_ref().is_none_or(|r| !self.repos.contains(r)) {
self.sel_repo = self.repos.first().cloned();
self.sel_crate = None;
}
if self.sel_crate.is_none() {
self.sel_crate = self.sel_repo.as_ref().and_then(|r| self.crates_by_repo.get(r)).and_then(|c| c.first()).cloned();
}
self.fed_by_room = true;
self.built_for = None;
}
/// Build + lay out the call graph for the selected (repo, crate).
fn build_graph(&mut self) {
let (Some(repo), Some(krate)) = (self.sel_repo.clone(), self.sel_crate.clone()) else {
self.nodes.clear();
self.edges.clear();
return;
};
if self.built_for.as_ref() == Some(&(repo.clone(), krate.clone())) {
return;
}
self.built_for = Some((repo.clone(), krate.clone()));
self.nodes.clear();
self.edges.clear();
self.edge_resolved.clear();
self.selected = None;
self.truncated = false;
let Some(rows) = &self.call_rows else { return };
let (Some(ri), Some(ci), Some(fi), Some(ti)) = (
col(rows, "repo"),
col(rows, "crate_name"),
col(rows, "caller_path"),
col(rows, "callee_ident"),
) else {
self.error = Some("call_edges missing expected columns".into());
return;
};
// SCIP-provenance column, when the warehouse persists it: a `source` (the
// `load_preferred` tag) or a `call_kind` that names the resolved index.
// Absent ⇒ every edge is a name-based syn fallback (the current schema).
let si = col(rows, "source").or_else(|| col(rows, "provenance"));
let ki = col(rows, "call_kind");
let row_resolved = |row: &[String]| -> bool {
si.map(|i| {
let v = cell(row, i).to_lowercase();
v.contains("scip") || v.contains("resolved")
})
.or_else(|| ki.map(|i| cell(row, i).to_lowercase().contains("scip")))
.unwrap_or(false)
};
// Collect raw edges for this crate, counting node degree.
let mut deg: HashMap<String, usize> = HashMap::new();
let mut raw: Vec<(String, String, bool)> = Vec::new();
for row in &rows.rows {
if cell(row, ri) != repo || cell(row, ci) != krate {
continue;
}
let (from, to) = (short(&cell(row, fi)), short(&cell(row, ti)));
if from.is_empty() || to.is_empty() {
continue;
}
*deg.entry(from.clone()).or_default() += 1;
*deg.entry(to.clone()).or_default() += 1;
raw.push((from, to, row_resolved(row)));
}
// Cap to the highest-degree nodes for legibility.
let mut keep: Vec<String> = deg.keys().cloned().collect();
keep.sort_by(|a, b| deg[b].cmp(°[a]).then(a.cmp(b)));
if keep.len() > MAX_NODES {
keep.truncate(MAX_NODES);
self.truncated = true;
}
let mut idx: HashMap<String, usize> = HashMap::new();
let n = keep.len().max(1) as f32;
for (i, id) in keep.iter().enumerate() {
// Seed deterministically on a circle (no RNG → resume-safe).
let ang = std::f32::consts::TAU * (i as f32) / n;
idx.insert(id.clone(), self.nodes.len());
self.nodes.push(Node {
id: id.clone(),
label: id.clone(),
pos: Pos2::new(220.0 * ang.cos(), 220.0 * ang.sin()),
z: 0.0,
deg: deg[id],
});
}
for (from, to, resolved) in raw {
if let (Some(&a), Some(&b)) = (idx.get(&from), idx.get(&to)) {
if a != b {
self.edges.push((a, b));
self.edge_resolved.push(resolved);
}
}
}
self.layout();
self.assign_z3d();
self.pan = Vec2::ZERO;
self.zoom = 1.0;
}
/// Tiny Fruchterman–Reingold force layout (deterministic).
fn layout(&mut self) {
let n = self.nodes.len();
if n < 2 {
return;
}
let area = 640.0 * 480.0;
let k = (area / n as f32).sqrt();
let mut disp = vec![Vec2::ZERO; n];
for _ in 0..120 {
for d in disp.iter_mut() {
*d = Vec2::ZERO;
}
// Repulsion (all pairs).
for i in 0..n {
for j in (i + 1)..n {
let mut delta = self.nodes[i].pos - self.nodes[j].pos;
let mut dist = delta.length();
if dist < 0.01 {
delta = Vec2::new(0.1 * (i as f32 + 1.0), 0.1);
dist = delta.length();
}
let force = (k * k) / dist;
let push = delta / dist * force;
disp[i] += push;
disp[j] -= push;
}
}
// Attraction (edges).
for &(a, b) in &self.edges {
let delta = self.nodes[a].pos - self.nodes[b].pos;
let dist = delta.length().max(0.01);
let force = (dist * dist) / k;
let pull = delta / dist * force;
disp[a] -= pull;
disp[b] += pull;
}
for i in 0..n {
let d = disp[i];
let len = d.length().max(0.01);
self.nodes[i].pos += d / len * len.min(16.0);
}
}
}
/// Assign the depth (z) axis — and, for spectral/MDS, all three coords —
/// per the selected [`Layout3D`]. Runs after the 2D force `layout()`.
fn assign_z3d(&mut self) {
let n = self.nodes.len();
if n == 0 {
return;
}
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
for &(a, b) in &self.edges {
adj[a].push(b);
adj[b].push(a);
}
match self.layout3d {
Layout3D::Force => self.layout_force3d(),
Layout3D::ClusterZ => self.layout_cluster_z(&adj),
Layout3D::Topo => self.layout_topo(),
Layout3D::Spectral => self.layout_spectral(&adj),
Layout3D::Mds => self.layout_mds(&adj),
Layout3D::Centrality => self.layout_centrality(),
Layout3D::Helix => self.layout_helix(),
}
self.normalize_3d();
}
/// #1 — a z-only force pass (x,y from the 2D layout): edges pull nodes
/// together in depth, all pairs push apart → organic 3D relief.
fn layout_force3d(&mut self) {
let n = self.nodes.len();
for i in 0..n {
self.nodes[i].z = (i as f32 * 7.13).sin() * 120.0;
}
let k = 90.0;
for _ in 0..90 {
let mut dz = vec![0.0f32; n];
for i in 0..n {
for j in (i + 1)..n {
let mut d = self.nodes[i].z - self.nodes[j].z;
if d.abs() < 0.01 {
d = 0.1;
}
let f = (k * k) / d.abs() * d.signum() * 0.0016;
dz[i] += f;
dz[j] -= f;
}
}
for &(a, b) in &self.edges {
let d = self.nodes[a].z - self.nodes[b].z;
dz[a] -= d * 0.05;
dz[b] += d * 0.05;
}
for i in 0..n {
self.nodes[i].z += dz[i].clamp(-20.0, 20.0);
}
}
}
/// #2 — **Louvain communities** → z-shells: biggest community at z=0 (center),
/// the rest fanned out in ±z by size; community-mates share a depth (+jitter).
/// Louvain (modularity) finds tight sub-clusters within a connected blob, so
/// "iceland dots hang together" far better than connected-components did.
fn layout_cluster_z(&mut self, adj: &[Vec<usize>]) {
let n = self.nodes.len();
let comp = louvain(adj);
let nc = comp.iter().copied().max().map(|m| m + 1).unwrap_or(1);
let mut sizes = vec![0usize; nc];
for &c in &comp {
sizes[c] += 1;
}
let mut order: Vec<usize> = (0..sizes.len()).collect();
order.sort_by_key(|&c| std::cmp::Reverse(sizes[c]));
let mut zof = vec![0.0f32; sizes.len()];
for (rank, &c) in order.iter().enumerate() {
let shell = ((rank + 1) / 2) as f32;
let sign = if rank % 2 == 1 { 1.0 } else { -1.0 };
zof[c] = sign * shell * 190.0;
}
for i in 0..n {
self.nodes[i].z = zof[comp[i]] + (i as f32 * 12.9898).sin() * 22.0;
}
}
/// #3 — topological layering: z = longest-path call depth (sources front,
/// sinks back), centered.
fn layout_topo(&mut self) {
let n = self.nodes.len();
let mut indeg = vec![0usize; n];
let mut out: Vec<Vec<usize>> = vec![Vec::new(); n];
for &(a, b) in &self.edges {
out[a].push(b);
indeg[b] += 1;
}
let mut layer = vec![0i32; n];
let mut seen = vec![false; n];
let mut work: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
for &i in &work {
seen[i] = true;
}
while let Some(u) = work.pop() {
for &v in &out[u] {
if layer[v] < layer[u] + 1 {
layer[v] = layer[u] + 1;
}
indeg[v] = indeg[v].saturating_sub(1);
if indeg[v] == 0 && !seen[v] {
seen[v] = true;
work.push(v);
}
}
}
let maxl = layer.iter().cloned().max().unwrap_or(0).max(1) as f32;
for i in 0..n {
self.nodes[i].z = (layer[i] as f32 - maxl / 2.0) * 170.0;
}
}
/// #4 — spectral embedding: 3 lowest non-trivial Laplacian eigenvectors via
/// shifted power iteration (M = cI − L, deflating the constant) → x, y, z.
fn layout_spectral(&mut self, adj: &[Vec<usize>]) {
let n = self.nodes.len();
if n < 4 {
return;
}
let deg: Vec<f32> = adj.iter().map(|a| a.len() as f32).collect();
let c = deg.iter().cloned().fold(0.0, f32::max) * 2.0 + 1.0;
let mul = |v: &[f32]| -> Vec<f32> {
let mut r = vec![0.0f32; n];
for i in 0..n {
r[i] = (c - deg[i]) * v[i];
for &j in &adj[i] {
r[i] += v[j];
}
}
r
};
let ortho = |v: &mut Vec<f32>, basis: &[Vec<f32>]| {
for b in basis {
let dot: f32 = v.iter().zip(b).map(|(x, y)| x * y).sum();
for i in 0..n {
v[i] -= dot * b[i];
}
}
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-6);
for x in v.iter_mut() {
*x /= norm;
}
};
let mut basis = vec![vec![1.0 / (n as f32).sqrt(); n]];
let mut coords: Vec<Vec<f32>> = Vec::new();
for k in 0..3 {
let mut v: Vec<f32> = (0..n)
.map(|i| ((i * 7 + k * 131 + 1) as f32).sin())
.collect();
ortho(&mut v, &basis);
for _ in 0..70 {
let mut nv = mul(&v);
ortho(&mut nv, &basis);
v = nv;
}
basis.push(v.clone());
coords.push(v);
}
for i in 0..n {
self.nodes[i].pos = Pos2::new(coords[0][i] * 320.0, coords[1][i] * 320.0);
self.nodes[i].z = coords[2][i] * 320.0;
}
}
/// #5 — classical MDS on BFS hop-distances → x, y, z (top-3 eigenvectors of
/// the double-centered squared-distance matrix).
fn layout_mds(&mut self, adj: &[Vec<usize>]) {
let n = self.nodes.len();
if n < 4 {
return;
}
let far = (n as f32).sqrt() + 2.0;
let mut dist = vec![vec![0.0f32; n]; n];
for s in 0..n {
let mut d = vec![-1i32; n];
let mut q = std::collections::VecDeque::new();
d[s] = 0;
q.push_back(s);
while let Some(u) = q.pop_front() {
for &v in &adj[u] {
if d[v] < 0 {
d[v] = d[u] + 1;
q.push_back(v);
}
}
}
for j in 0..n {
dist[s][j] = if d[j] < 0 { far } else { d[j] as f32 };
}
}
let mut b = vec![vec![0.0f32; n]; n];
let mut rowm = vec![0.0f32; n];
let mut all = 0.0f32;
for i in 0..n {
for j in 0..n {
let d2 = dist[i][j] * dist[i][j];
b[i][j] = d2;
rowm[i] += d2;
all += d2;
}
}
let allm = all / (n * n) as f32;
for r in rowm.iter_mut() {
*r /= n as f32;
}
for i in 0..n {
for j in 0..n {
b[i][j] = -0.5 * (b[i][j] - rowm[i] - rowm[j] + allm);
}
}
let mul = |v: &[f32]| -> Vec<f32> {
(0..n)
.map(|i| (0..n).map(|j| b[i][j] * v[j]).sum())
.collect()
};
let ortho = |v: &mut Vec<f32>, basis: &[Vec<f32>]| {
for bb in basis {
let dot: f32 = v.iter().zip(bb).map(|(x, y)| x * y).sum();
for i in 0..n {
v[i] -= dot * bb[i];
}
}
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-6);
for x in v.iter_mut() {
*x /= norm;
}
};
let mut basis: Vec<Vec<f32>> = Vec::new();
let mut coords: Vec<Vec<f32>> = Vec::new();
for k in 0..3 {
let mut v: Vec<f32> = (0..n)
.map(|i| ((i * 13 + k * 71 + 1) as f32).sin())
.collect();
ortho(&mut v, &basis);
for _ in 0..70 {
let mut nv = mul(&v);
ortho(&mut nv, &basis);
v = nv;
}
basis.push(v.clone());
coords.push(v);
}
for i in 0..n {
self.nodes[i].pos = Pos2::new(coords[0][i] * 26.0, coords[1][i] * 26.0);
self.nodes[i].z = coords[2][i] * 26.0;
}
}
/// #6 — z by degree centrality: x,y from the 2D force, hubs pushed toward
/// the camera (negative z = near).
fn layout_centrality(&mut self) {
let n = self.nodes.len();
let maxd = self.nodes.iter().map(|nd| nd.deg).max().unwrap_or(1).max(1) as f32;
for i in 0..n {
self.nodes[i].z = -((self.nodes[i].deg as f32 / maxd) - 0.5) * 520.0;
}
}
/// #7 — order nodes by degree onto a 3-turn helix; spinning around Y reads as
/// a rotating DNA strand.
fn layout_helix(&mut self) {
let n = self.nodes.len();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| std::cmp::Reverse(self.nodes[i].deg));
let r = 240.0;
let turns = 3.0;
let denom = (n.max(2) - 1) as f32;
for (rank, &i) in order.iter().enumerate() {
let t = rank as f32 / denom;
let ang = t * std::f32::consts::TAU * turns;
self.nodes[i].pos = Pos2::new(r * ang.cos(), (t - 0.5) * 540.0);
self.nodes[i].z = r * ang.sin();
}
}
/// Rescale all three axes to a consistent on-screen extent so every layout
/// fills the canvas comparably.
fn normalize_3d(&mut self) {
if self.nodes.is_empty() {
return;
}
let (mut mnx, mut mxx, mut mny, mut mxy, mut mnz, mut mxz) =
(f32::MAX, f32::MIN, f32::MAX, f32::MIN, f32::MAX, f32::MIN);
for nd in &self.nodes {
mnx = mnx.min(nd.pos.x);
mxx = mxx.max(nd.pos.x);
mny = mny.min(nd.pos.y);
mxy = mxy.max(nd.pos.y);
mnz = mnz.min(nd.z);
mxz = mxz.max(nd.z);
}
let span = (mxx - mnx).max(mxy - mny).max(mxz - mnz).max(1.0);
let scale = 520.0 / span;
let (cx, cy, cz) = ((mnx + mxx) / 2.0, (mny + mxy) / 2.0, (mnz + mxz) / 2.0);
for nd in &mut self.nodes {
nd.pos = Pos2::new((nd.pos.x - cx) * scale, (nd.pos.y - cy) * scale);
nd.z = (nd.z - cz) * scale;
}
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
self.poll_deep_scan();
self.ensure_loaded();
egui::Panel::top("cg_controls").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label("repo:");
let cur_repo = self.sel_repo.clone().unwrap_or_default();
egui::ComboBox::from_id_salt("cg_repo")
.selected_text(if cur_repo.is_empty() {
"—".into()
} else {
cur_repo.clone()
})
.show_ui(ui, |ui| {
for r in self.repos.clone() {
if ui
.selectable_label(self.sel_repo.as_deref() == Some(&r), &r)
.clicked()
{
self.sel_repo = Some(r.clone());
self.sel_crate =
self.crates_by_repo.get(&r).and_then(|c| c.first().cloned());
self.built_for = None;
}
}
});
ui.separator();
ui.label("crate:");
let crates = self
.sel_repo
.as_ref()
.and_then(|r| self.crates_by_repo.get(r))
.cloned()
.unwrap_or_default();
let cur_crate = self.sel_crate.clone().unwrap_or_default();
egui::ComboBox::from_id_salt("cg_crate")
.selected_text(if cur_crate.is_empty() {
"—".into()
} else {
cur_crate.clone()
})
.show_ui(ui, |ui| {
for c in crates {
if ui
.selectable_label(self.sel_crate.as_deref() == Some(&c), &c)
.clicked()
{
self.sel_crate = Some(c);
self.built_for = None;
}
}
});
ui.separator();
if ui.button("↻ reload").clicked() {
self.loaded = false;
self.last_fetch_at = None;
self.call_rows = None;
self.crates_by_repo.clear();
self.repos.clear();
self.built_for = None;
self.error = None;
}
if ui.button("⊙ fit").clicked() {
self.pan = Vec2::ZERO;
self.zoom = 1.0;
}
// N2 — manual deep-scan: recompute call_edges on demand (thin mode
// republishes the workspace server-side, then the graph rebuilds).
let scanning = self.deep_scan.lock().unwrap().inflight;
if scanning {
ui.add(egui::Spinner::new());
ui.label("deep-scanning…");
} else if ui
.button("🔬 Deep-scan")
.on_hover_text(
"recompute call_edges now (thin: server republish; local: reload)",
)
.clicked()
{
self.trigger_deep_scan();
}
{
let (err, note) = {
let g = self.deep_scan.lock().unwrap();
(g.error.clone(), g.note.clone())
};
if let Some(e) = err {
ui.colored_label(super::facett_theme::RED, format!("⚠ {e}"));
} else if !scanning {
if let Some(n) = note {
ui.colored_label(super::facett_theme::GREEN, format!("✓ {n}"));
}
}
}
ui.separator();
ui.label("3D:");
egui::ComboBox::from_id_salt("cg_layout3d")
.selected_text(self.layout3d.label())
.show_ui(ui, |ui| {
for (l, name) in Layout3D::ALL {
if ui.selectable_label(self.layout3d == l, name).clicked() {
self.layout3d = l;
self.built_for = None; // rebuild with the new 3D layout
}
}
});
// N3 — the auto-spin toggle (the 2D-vs-rotating switch): ON = the
// node cloud rotates around its vertical axis (the Architecture
// default); OFF = it holds still, so it reads as a flat 2D graph you
// orbit by hand. A pointer-drag always orbits regardless.
if ui
.selectable_label(self.spin_enabled, "⟳ spin")
.on_hover_text("auto-rotate the 3D node cloud (drag to orbit by hand)")
.clicked()
{
self.spin_enabled = !self.spin_enabled;
}
ui.separator();
// Volumetric-fog density: pure render param, no graph rebuild.
ui.label("fog:")
.on_hover_text("volumetric haze density — far nodes/edges fade into the depth");
ui.add(
egui::Slider::new(&mut self.fog_density, 0.0..=6.0)
.fixed_decimals(1)
.show_value(true),
);
ui.separator();
// ── SCIP-PROVENANCE LEGEND: SOLID accent edge = RESOLVED by the
// SCIP index; DASHED dim edge = the name-based SYN fallback. Counts
// mirror `state_json`'s `resolved_edges`/`syn_edges` (LAW 6).
let (res_n, syn_n) = self.provenance_counts();
ui.label("edges:");
let (rect, _) = ui.allocate_exact_size(Vec2::new(20.0, 10.0), Sense::hover());
ui.painter().line_segment(
[rect.left_center(), rect.right_center()],
Stroke::new(2.5_f32, theme.accent),
);
ui.label(format!("resolved {res_n}"));
ui.add_space(8.0);
let (rect, _) = ui.allocate_exact_size(Vec2::new(20.0, 10.0), Sense::hover());
let y = rect.center().y;
for k in 0..3 {
let x0 = rect.left() + k as f32 * 7.0;
ui.painter().line_segment(
[Pos2::new(x0, y), Pos2::new(x0 + 4.0, y)],
Stroke::new(2.5_f32, theme.edge),
);
}
ui.label(format!("syn {syn_n}"));
});
});
self.build_graph();
// Details panel for the clicked node: symbol facts + clickable
// caller/callee navigation (click a name to jump the selection).
if let Some(sel) = self.selected {
if sel >= self.nodes.len() {
self.selected = None;
} else {
let mut jump: Option<usize> = None;
let mut close = false;
egui::Panel::right("cg_details")
.default_size(300.0)
.show_inside(ui, |ui| {
let node = &self.nodes[sel];
ui.horizontal(|ui| {
ui.heading(&node.label);
if ui.button("✖").on_hover_text("close").clicked() {
close = true;
}
});
ui.label(format!("call degree: {}", node.deg));
ui.separator();
// symbol_facts lookup by item_name within the selected repo/crate.
if let (Some(syms), Some((repo, krate))) = (&self.sym_rows, &self.built_for)
{
if let (Some(ri), Some(ci), Some(ni)) = (
col(syms, "repo"),
col(syms, "crate_name"),
col(syms, "item_name"),
) {
let (fi, li, ki, si, vi) = (
col(syms, "file"),
col(syms, "line"),
col(syms, "item_kind"),
col(syms, "signature"),
col(syms, "visibility"),
);
for row in syms
.rows
.iter()
.filter(|r| {
cell(r, ri) == *repo
&& cell(r, ci) == *krate
&& cell(r, ni) == node.label
})
.take(3)
{
if let (Some(k), Some(v)) = (ki, vi) {
ui.label(format!("{} {}", cell(row, v), cell(row, k)));
}
if let (Some(f), Some(l)) = (fi, li) {
ui.monospace(format!("{}:{}", cell(row, f), cell(row, l)));
}
if let Some(s) = si {
let sig = cell(row, s);
if !sig.is_empty() {
ui.add(
egui::Label::new(
egui::RichText::new(sig).monospace().size(11.0),
)
.wrap(),
);
}
}
ui.add_space(4.0);
}
}
}
// Callers / callees from the laid-out edges — clickable.
let callers: Vec<usize> = self
.edges
.iter()
.filter(|(_, b)| *b == sel)
.map(|(a, _)| *a)
.collect();
let callees: Vec<usize> = self
.edges
.iter()
.filter(|(a, _)| *a == sel)
.map(|(_, b)| *b)
.collect();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.separator();
ui.strong(format!("called by ({})", callers.len()));
for a in callers {
if ui.link(&self.nodes[a].label).clicked() {
jump = Some(a);
}
}
ui.separator();
ui.strong(format!("calls ({})", callees.len()));
for b in callees {
if ui.link(&self.nodes[b].label).clicked() {
jump = Some(b);
}
}
});
});
if close {
self.selected = None;
} else if let Some(j) = jump {
self.selected = Some(j);
// Center the jumped-to node so navigation reads as movement.
self.pan = -(self.nodes[j].pos.to_vec2()) * self.zoom;
}
}
}
egui::CentralPanel::default().show_inside(ui, |ui| {
if let Some(err) = &self.error {
ui.colored_label(super::facett_theme::RED, err);
return;
}
if self.nodes.is_empty() {
// LAW 2: distinguish "the warehouse has no symbols for this repo
// yet" (the systemic empty-state — a populate hasn't landed) from
// "this crate simply has no call edges" (pick another). The shared
// `repo_pane` helper renders "⏳ in route for populate…" when a job
// is active, else "not scanned yet — run populate / deep-scan".
if self.repos.is_empty() {
let repo = self.sel_repo.clone().unwrap_or_default();
super::repo_pane::render_empty(
ui,
true,
&self.workspace,
&repo,
self.theme.text,
);
} else {
ui.label(
"no call edges for this crate (pick another repo/crate; \
monitoring fills call_edges as it republishes)",
);
}
return;
}
let (resp, painter) = ui.allocate_painter(ui.available_size(), Sense::click_and_drag());
let mut scroll = 0.0;
// N3 — pointer-drag ORBITS the node cloud around its vertical axis:
// horizontal drag feeds the same `spin` angle the auto-rotation uses,
// vertical drag pans. Dragging PAUSES the auto-spin (handled below) so
// the user can freely inspect the graph from any angle.
let dragging = resp.dragged();
if dragging {
self.spin += resp.drag_delta().x * 0.01;
self.pan.y += resp.drag_delta().y;
}
if resp.hovered() {
scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
self.zoom = (self.zoom * (1.0 + scroll * 0.001)).clamp(0.2, 4.0);
}
}
if dragging || resp.clicked() || scroll != 0.0 {
self.last_input = std::time::Instant::now();
}
// AUTO-SPIN (N3): the 3D node cloud rotates the instant the lens is
// active — no idle wait — so opening the Architecture tab shows a live,
// spinning graph. A pointer-drag pauses it (the drag orbits manually);
// the ⟳ spin toolbar toggle turns the auto-rotation off entirely.
if self.spin_enabled && !dragging {
self.spin += 0.006;
ui.ctx().request_repaint();
}
// Keep the rotation angle wrapped to (-π, π].
if self.spin > std::f32::consts::PI {
self.spin -= 2.0 * std::f32::consts::PI;
} else if self.spin < -std::f32::consts::PI {
self.spin += 2.0 * std::f32::consts::PI;
}
let origin = resp.rect.center() + self.pan;
let zoom = self.zoom;
let focal = 1100.0_f32;
let (sin, cos) = self.spin.sin_cos();
// Rotate a layout point around the vertical (Y) axis + perspective:
// returns the screen position and the perspective scale (near side
// grows, far side shrinks — the 3D depth cue as it spins).
let project = move |p: Pos2, z: f32| -> (Pos2, f32) {
// Rotate (x, z) around the vertical Y axis, then perspective.
let rx = p.x * cos + z * sin;
let rz = -p.x * sin + z * cos;
let persp = focal / (focal + rz);
(origin + Vec2::new(rx, p.y) * (zoom * persp), persp)
};
let radius = move |deg: usize, persp: f32| {
(3.0 + (deg as f32).sqrt() * 1.6) * zoom.clamp(0.6, 2.0) * persp
};
// Click-to-inspect: nearest node within its radius (+slop) selects it;
// clicking empty canvas clears the selection.
if resp.clicked() {
if let Some(click) = resp.interact_pointer_pos() {
self.selected = self
.nodes
.iter()
.enumerate()
.map(|(i, n)| {
let (sp, persp) = project(n.pos, n.z);
(i, (sp - click).length(), radius(n.deg, persp))
})
.filter(|(_, d, r)| *d <= r.max(6.0) + 4.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _, _)| i);
}
}
// Edges with a small arrowhead toward the callee; edges touching the
// selected node are highlighted (orange = outgoing, cyan = incoming).
// SCIP-PROVENANCE OVERLAY: a RESOLVED edge (the SCIP index spanned the
// call) draws SOLID + accent-tinted; a name-based SYN-fallback edge
// draws DASHED + dimmed — so resolved-vs-syn is visible on each edge.
for (e, &(a, b)) in self.edges.iter().enumerate() {
let resolved = self.edge_resolved.get(e).copied().unwrap_or(false);
let (ja, jb) = (
project(self.nodes[a].pos, self.nodes[a].z),
project(self.nodes[b].pos, self.nodes[b].z),
);
let (pa, pb) = (ja.0, jb.0);
let (stroke, dashed) = match self.selected {
Some(s) if a == s => (Stroke::new(1.6_f32, super::facett_theme::AMBER), false),
Some(s) if b == s => (Stroke::new(1.6_f32, theme.accent), false),
_ => {
// Volumetric fog: far edges dissolve into the haze colour,
// near edges read brighter + cooler. Depth is the mean of
// the two endpoints' projected depth.
let depth = 0.5 * (depth_norm(ja.1) + depth_norm(jb.1));
// RESOLVED = a brighter accent base + a heavier solid line;
// SYN = the dim neutral edge, drawn dashed.
let base = if resolved { theme.accent } else { theme.edge };
let w = if resolved { 1.4_f32 } else { 1.0_f32 };
(
Stroke::new(w, fog_blend(base, depth, self.fog_density)),
!resolved,
)
}
};
if dashed {
// Short dashes mark a syn-fallback edge.
let dir = (pb - pa).normalized();
let len = (pb - pa).length();
let (dash, gap) = (7.0_f32, 5.0_f32);
let mut t = 0.0;
while t < len {
let s = pa + dir * t;
let en = pa + dir * (t + dash).min(len);
painter.line_segment([s, en], stroke);
t += dash + gap;
}
} else {
painter.line_segment([pa, pb], stroke);
}
let dir = (pb - pa).normalized();
let head = pb - dir * (6.0 + 4.0 * self.zoom);
let perp = Vec2::new(-dir.y, dir.x) * 3.0;
let head_col = if resolved { theme.accent } else { theme.edge };
painter.line_segment([pb, head + perp], Stroke::new(1.0_f32, head_col));
painter.line_segment([pb, head - perp], Stroke::new(1.0_f32, head_col));
}
// Nodes: radius + color scale with call degree; selection ring on top.
// Draw far → near (by perspective depth) so near nodes overlay —
// the key 3D cue alongside size + icy fog fade.
let mut order: Vec<usize> = (0..self.nodes.len()).collect();
order.sort_by(|&a, &b| {
project(self.nodes[a].pos, self.nodes[a].z)
.1
.partial_cmp(&project(self.nodes[b].pos, self.nodes[b].z).1)
.unwrap_or(std::cmp::Ordering::Equal)
});
for &i in &order {
let node = &self.nodes[i];
let (p, persp) = project(node.pos, node.z);
let r = radius(node.deg, persp);
let col = fog_blend(heat(node.deg), depth_norm(persp), self.fog_density);
painter.circle_filled(p, r, col);
if self.selected == Some(i) {
painter.circle_stroke(p, r + 3.0, Stroke::new(2.0_f32, theme.selection()));
}
if self.zoom > 0.85 || node.deg >= 4 || self.selected == Some(i) {
let lf = (120.0 + 110.0 * (persp.clamp(0.6, 1.3) - 0.6) / 0.7) as u8;
let base = Color32::from_rgb(lf, lf, (lf as f32 * 1.08).min(255.0) as u8);
painter.text(
p + Vec2::new(r + 2.0, -r),
egui::Align2::LEFT_BOTTOM,
&node.label,
FontId::proportional(11.0),
fog_blend(base, depth_norm(persp), self.fog_density),
);
}
}
let (repo, krate) = self.built_for.clone().unwrap_or_default();
let note = format!(
"{repo} · {krate} — {} fns, {} calls{} · drag to pan, scroll to zoom",
self.nodes.len(),
self.edges.len(),
if self.truncated {
format!(" (top {MAX_NODES})")
} else {
String::new()
}
);
let note = format!("{note} · click a node to inspect");
painter.text(
resp.rect.left_top() + Vec2::new(8.0, 8.0),
egui::Align2::LEFT_TOP,
note,
FontId::proportional(12.0),
theme.text_dim,
);
});
// ── emit-fallback (testmatrix only): the 3D-projected paint is a side
// effect; record the painted node/edge count. ok ⇔ at least one node
// circle was projected + painted ⇒ the call-graph scene is NOT blank.
//
// Gate on `built_for.is_some()`: we only assert "non-blank" for a pane
// that was actually asked to draw a *built* graph (a repo+crate was
// selected and its symbols loaded). A pane that was merely rendered with
// nothing selected (e.g. the all-tabs headless walk over a warehouse
// with no symbol_facts) legitimately has no graph to paint — that is an
// empty selection, NOT a render failure, so it must not stamp a RED row
// for this check. The dedicated `test_render_callgraph_and_funnel3d_
// nonblank` hook injects a real graph (built_for=Some) and is the
// surface's positive proof.
#[cfg(feature = "testmatrix")]
if self.built_for.is_some() {
crate::selftest::emit(
"viz/callgraph (egui draw)",
"callgraph_rendered_non_blank",
!self.nodes.is_empty(),
&format!(
"painted {} node circle(s) + {} edge(s) for {:?}",
self.nodes.len(),
self.edges.len(),
self.built_for,
),
);
}
}
}
/// Column index by name in a [`TablePreview`].
fn col(p: &TablePreview, name: &str) -> Option<usize> {
p.columns.iter().position(|c| c == name)
}
fn cell(row: &[String], i: usize) -> String {
row.get(i).cloned().unwrap_or_default()
}
/// Last `::`-segment of a path, for compact node labels.
fn short(path: &str) -> String {
path.rsplit("::").next().unwrap_or(path).to_string()
}
/// Degree → warm color (cool=few calls, hot=hub).
/// Louvain community detection (single level, local modularity moves). Returns a
/// community id per node, relabelled to `0..k`. `adj` is the undirected
/// adjacency (each edge present both ways). Pure, deterministic (fixed order).
fn louvain(adj: &[Vec<usize>]) -> Vec<usize> {
use std::collections::HashMap;
let n = adj.len();
let m2: f64 = adj.iter().map(|a| a.len()).sum::<usize>() as f64; // 2m
if m2 == 0.0 {
return (0..n).collect();
}
let deg: Vec<f64> = adj.iter().map(|a| a.len() as f64).collect();
let mut comm: Vec<usize> = (0..n).collect();
let mut sigma_tot = deg.clone(); // Σ degrees per community
let mut improved = true;
let mut passes = 0;
while improved && passes < 30 {
improved = false;
passes += 1;
for i in 0..n {
let ci = comm[i];
sigma_tot[ci] -= deg[i];
// edges from i into each neighbouring community
let mut k_in: HashMap<usize, f64> = HashMap::new();
for &j in &adj[i] {
if j != i {
*k_in.entry(comm[j]).or_insert(0.0) += 1.0;
}
}
k_in.entry(ci).or_insert(0.0); // staying is always an option
let (mut best_c, mut best_gain) = (ci, f64::MIN);
for (&c, &kic) in &k_in {
let gain = kic - sigma_tot[c] * deg[i] / m2;
if gain > best_gain + 1e-9 {
best_gain = gain;
best_c = c;
}
}
comm[i] = best_c;
sigma_tot[best_c] += deg[i];
if best_c != ci {
improved = true;
}
}
}
let mut relabel: HashMap<usize, usize> = HashMap::new();
comm.iter()
.map(|&c| {
let next = relabel.len();
*relabel.entry(c).or_insert(next)
})
.collect()
}
/// Icy node colour: deep glacier-blue (low degree) → bright cyan/white (hubs).
/// Depth attenuation is applied separately by [`fog_blend`] so nodes and edges
/// share one volumetric model.
fn heat(deg: usize) -> Color32 {
let t = (deg as f32 / 12.0).clamp(0.0, 1.0);
let r = 40.0 + 150.0 * t;
let g = 120.0 + 115.0 * t;
let b = 190.0 + 60.0 * t;
Color32::from_rgb(r as u8, g as u8, b as u8)
}
/// Map a perspective scale (`persp = focal / (focal + rz)`, >1 near .. <1 far)
/// to a normalized depth in `[0, 1]` where 0 = nearest (crisp) and 1 = farthest
/// (deepest haze). Pure: the fog model's only coupling to the projection.
fn depth_norm(persp: f32) -> f32 {
// persp spans roughly [0.55 (far) .. 1.35 (near)] for this scene.
(1.0 - (persp.clamp(0.55, 1.35) - 0.55) / 0.8).clamp(0.0, 1.0)
}
/// Volumetric fog: blend `color` toward [`FOG_COLOR`] by an exponential
/// extinction `1 - exp(-density * depth_norm)`. Near geometry (`depth ≈ 0`)
/// stays crisp; far geometry dissolves into the haze. Pure math — unit-tested.
fn fog_blend(color: Color32, depth: f32, density: f32) -> Color32 {
let f = (1.0 - (-density * depth.clamp(0.0, 1.0)).exp()).clamp(0.0, 1.0);
let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * f).round() as u8;
Color32::from_rgb(
lerp(color.r(), FOG_COLOR.r()),
lerp(color.g(), FOG_COLOR.g()),
lerp(color.b(), FOG_COLOR.b()),
)
}
impl CallGraphState {
/// **testmatrix-only test hook** — inject a synthetic call graph + mark it
/// "built" so a headless `draw` paints real node circles WITHOUT a warehouse.
/// Lets the self-emitter matrix exercise the egui-draw emit-fallback path and
/// read back the implanted `callgraph_rendered_non_blank` row. Compiled out of
/// release.
#[cfg(feature = "testmatrix")]
pub fn test_inject_graph(&mut self, n: usize, edges: &[(usize, usize)]) {
self.nodes.clear();
self.edges.clear();
self.edge_resolved.clear();
let nn = n.max(1) as f32;
for i in 0..n {
let ang = std::f32::consts::TAU * (i as f32) / nn;
self.nodes.push(Node {
id: format!("f{i}"),
label: format!("f{i}"),
pos: Pos2::new(220.0 * ang.cos(), 220.0 * ang.sin()),
z: 0.0,
deg: 0,
});
}
for &(a, b) in edges {
if a < n && b < n && a != b {
self.nodes[a].deg += 1;
self.nodes[b].deg += 1;
self.edges.push((a, b));
self.edge_resolved.push(false);
}
}
self.layout();
self.assign_z3d();
// Mark loaded + built so `draw` skips the warehouse load and repaints the
// already-injected nodes (built_for non-None keeps build_graph a no-op).
self.loaded = true;
self.built_for = Some(("kc".to_string(), "kc".to_string()));
self.sel_repo = Some("kc".to_string());
self.sel_crate = Some("kc".to_string());
}
/// **Test hook** — inject a synthetic graph whose edges carry explicit
/// SCIP-provenance (`resolved[i]` parallel to `edges[i]`): `true` = a
/// SCIP-resolved edge, `false` = a name-based syn fallback. Lets the
/// inject-assert test feed BOTH a resolved and a syn edge and read the styled
/// counts back from `state_json` without a `--features scip` warehouse.
#[cfg(any(test, feature = "testmatrix"))]
pub fn test_inject_graph_with_provenance(
&mut self,
n: usize,
edges: &[(usize, usize)],
resolved: &[bool],
) {
self.nodes.clear();
self.edges.clear();
self.edge_resolved.clear();
let nn = n.max(1) as f32;
for i in 0..n {
let ang = std::f32::consts::TAU * (i as f32) / nn;
self.nodes.push(Node {
id: format!("f{i}"),
label: format!("f{i}"),
pos: Pos2::new(220.0 * ang.cos(), 220.0 * ang.sin()),
z: 0.0,
deg: 0,
});
}
for (k, &(a, b)) in edges.iter().enumerate() {
if a < n && b < n && a != b {
self.nodes[a].deg += 1;
self.nodes[b].deg += 1;
self.edges.push((a, b));
self.edge_resolved
.push(resolved.get(k).copied().unwrap_or(false));
}
}
self.layout();
self.assign_z3d();
self.loaded = true;
self.built_for = Some(("kc".to_string(), "kc".to_string()));
self.sel_repo = Some("kc".to_string());
self.sel_crate = Some("kc".to_string());
}
/// Test-only: inject a synthetic call graph (no warehouse) and run the full
/// layout pipeline for `layout`. Returns the wall-clock of the layout pass.
/// Lets the inject-assert tests feed a realistically-sized graph and assert
/// the layout completes under a budget.
#[cfg(test)]
fn build_synthetic(
&mut self,
n: usize,
edges: &[(usize, usize)],
layout: Layout3D,
) -> std::time::Duration {
self.nodes.clear();
self.edges.clear();
self.edge_resolved.clear();
let nn = n.max(1) as f32;
for i in 0..n {
let ang = std::f32::consts::TAU * (i as f32) / nn;
self.nodes.push(Node {
id: format!("f{i}"),
label: format!("f{i}"),
pos: Pos2::new(220.0 * ang.cos(), 220.0 * ang.sin()),
z: 0.0,
deg: 0,
});
}
for &(a, b) in edges {
if a < n && b < n && a != b {
self.nodes[a].deg += 1;
self.nodes[b].deg += 1;
self.edges.push((a, b));
self.edge_resolved.push(false);
}
}
self.layout3d = layout;
let t0 = std::time::Instant::now();
self.layout();
self.assign_z3d();
t0.elapsed()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn call_graph_defaults_to_spinning_and_no_scan_inflight() {
// N3 — the 3D node cloud auto-rotates the instant the lens is active (the
// Architecture default), so `spin` is true out of the box; N2 — the manual
// deep-scan is idle until fired.
let st = CallGraphState::local(std::path::PathBuf::from("/nonexistent"));
let js = st.state_json();
assert_eq!(js["spin"], true, "the 3D cloud auto-spins by default (N3)");
assert_eq!(js["deep_scan_inflight"], false);
}
/// Build a connected-ish synthetic graph of `n` nodes (a ring + a few hub
/// spokes) capped to MAX_NODES — the shape a real crate's call graph takes
/// after the degree cap.
fn synthetic_edges(n: usize) -> Vec<(usize, usize)> {
let mut e = Vec::new();
for i in 0..n {
e.push((i, (i + 1) % n)); // ring
if i % 7 == 0 {
e.push((i, (i + 13) % n)); // hub spokes
}
}
e
}
#[test]
fn default_layout_is_fast_for_a_capped_graph() {
// INJECT: a MAX_NODES-sized graph (the cap a big crate hits). ASSERT the
// DEFAULT 3D layout (ClusterZ) lays it out well under a frame budget so
// the UI never visibly stalls. Generous bound (debug build, shared CI).
let n = MAX_NODES;
let edges = synthetic_edges(n);
let mut st = CallGraphState::local(std::path::PathBuf::from("/nonexistent"));
let dt = st.build_synthetic(n, &edges, Layout3D::ClusterZ);
eprintln!("LAYOUT-PROFILE default(ClusterZ) {n} nodes: {dt:?}");
// Primary (deterministic) asserts: the layout actually ran.
assert_eq!(st.nodes.len(), n, "all nodes laid out");
assert!(
!st.nodes.iter().all(|nd| nd.z == 0.0),
"z axis was assigned"
);
// Secondary timing guard. The LAW notes wall-clock is flaky under
// parallel `cargo test` load (this measures ~95ms solo in debug), so the
// bound is generous — it exists to catch an accidental O(n^3) regression
// (which would land in the seconds), NOT to police milliseconds. The
// real win is the scan fix (≈10s→sub-second), not this layout.
assert!(
dt.as_millis() < 2000,
"default layout for {n} nodes took {dt:?}, expected well under 2s"
);
}
#[test]
fn heavy_layouts_complete_for_a_capped_graph() {
// INJECT the same capped graph; ASSERT even the heaviest opt-in layouts
// (Spectral / MDS — O(n^2) power iteration) finish under a bound, so the
// worker-thread offload (build_graph_async) keeps the UI responsive even
// for the slow ones. Bound is loose: this asserts "bounded", not "fast".
let n = MAX_NODES;
let edges = synthetic_edges(n);
for layout in [Layout3D::Spectral, Layout3D::Mds] {
let mut st = CallGraphState::local(std::path::PathBuf::from("/nonexistent"));
let dt = st.build_synthetic(n, &edges, layout);
eprintln!("LAYOUT-PROFILE {} {n} nodes: {dt:?}", layout.label());
assert_eq!(st.nodes.len(), n);
// Generous bound (MDS ~265ms solo in debug; loosened for parallel
// test-load jitter per the flaky-wall-clock LAW).
assert!(
dt.as_secs_f32() < 5.0,
"{} layout for {n} nodes took {dt:?}, expected well under 5s",
layout.label()
);
}
}
/// SCIP-PROVENANCE OVERLAY (LAW 1 inject-assert): inject a graph carrying BOTH
/// a RESOLVED edge and a SYN-fallback edge, then assert `state_json` reports
/// the per-class split (`resolved_edges`/`syn_edges`) — the resolved-index work
/// made visible as DATA, not just pixels. The legend counts read the same.
#[test]
fn provenance_split_surfaces_in_state_json() {
let mut st = CallGraphState::local(std::path::PathBuf::from("/nonexistent"));
// 3 nodes, 2 edges: edge 0 RESOLVED (scip), edge 1 SYN-fallback.
st.test_inject_graph_with_provenance(3, &[(0, 1), (1, 2)], &[true, false]);
// The model carries the provenance parallel to the edges.
assert_eq!(st.edges.len(), 2, "both edges laid out");
assert_eq!(
st.edge_resolved,
vec![true, false],
"per-edge provenance preserved"
);
// The counts the legend paints.
assert_eq!(
st.provenance_counts(),
(1, 1),
"one resolved, one syn-fallback"
);
// The readable DATA (LAW 6): the split is in state_json.
let js = st.state_json();
assert_eq!(js["edge_count"], serde_json::json!(2));
assert_eq!(
js["resolved_edges"],
serde_json::json!(1),
"a resolved edge is reported: {js}"
);
assert_eq!(
js["syn_edges"],
serde_json::json!(1),
"a syn-fallback edge is reported: {js}"
);
// The two classes account for every edge (no edge is unclassified).
assert_eq!(
js["resolved_edges"].as_u64().unwrap() + js["syn_edges"].as_u64().unwrap(),
js["edge_count"].as_u64().unwrap(),
"every edge is classified resolved-or-syn"
);
}
/// An all-syn graph (the warehouse default — `call_edges` has no provenance
/// column) reports zero resolved edges, so the overlay honestly says "nothing
/// resolved yet" rather than faking a resolved class.
#[test]
fn no_provenance_defaults_to_all_syn() {
let mut st = CallGraphState::local(std::path::PathBuf::from("/nonexistent"));
st.test_inject_graph_with_provenance(3, &[(0, 1), (1, 2)], &[]); // no flags
assert_eq!(
st.provenance_counts(),
(0, 2),
"no recorded provenance ⇒ all syn"
);
let js = st.state_json();
assert_eq!(js["resolved_edges"], serde_json::json!(0));
assert_eq!(js["syn_edges"], serde_json::json!(2));
}
#[test]
fn fog_near_is_crisp_far_is_haze() {
let col = Color32::from_rgb(200, 220, 250);
// Nearest depth → unchanged colour (no extinction).
assert_eq!(fog_blend(col, 0.0, 2.0), col);
// Far depth at a healthy density → very close to the haze colour.
let far = fog_blend(col, 1.0, 6.0);
let d = |a: u8, b: u8| (a as i32 - b as i32).abs();
assert!(d(far.r(), FOG_COLOR.r()) <= 2);
assert!(d(far.g(), FOG_COLOR.g()) <= 2);
assert!(d(far.b(), FOG_COLOR.b()) <= 2);
}
#[test]
fn fog_is_monotonic_in_depth() {
let col = Color32::from_rgb(40, 120, 190);
// Toward a darker/cooler haze the blue channel must decrease monotonically
// as depth grows (more extinction → closer to FOG_COLOR.b()=40).
let mut prev = col.b();
for k in 1..=10 {
let b = fog_blend(col, k as f32 / 10.0, 2.0).b();
assert!(b <= prev, "channel not monotonic at step {k}");
prev = b;
}
}
#[test]
fn zero_density_disables_fog() {
let col = Color32::from_rgb(123, 45, 67);
assert_eq!(fog_blend(col, 1.0, 0.0), col);
}
#[test]
fn depth_norm_endpoints() {
// Near (large persp) → 0, far (small persp) → 1.
assert!(depth_norm(1.35) < 1e-3);
assert!(depth_norm(0.55) > 1.0 - 1e-3);
// Monotonic decrease with persp.
assert!(depth_norm(0.7) > depth_norm(1.1));
}
/// Fat mode end to end: the lens takes its pickers and its rows from the
/// code room's reader and builds the selected crate's graph; a workspace
/// nothing was consumed into is `unavailable` with the reader's reason.
#[cfg(feature = "code-room")]
#[test]
fn a_fat_lens_draws_the_code_rooms_calls() {
use crate::code_room::reader::fixture;
let mut cg = CallGraphState::local(fixture::shop().builds.clone());
cg.ensure_loaded();
assert_eq!(cg.repos, vec!["shop".to_string(), "web".to_string()]);
assert_eq!((cg.sel_repo.as_deref(), cg.sel_crate.as_deref()), (Some("shop"), Some("shop")));
cg.build_graph();
let js = cg.state_json();
assert_eq!(js["state"], "code_room", "{js}");
assert!(js["error"].is_null());
// nodes are fns by bare name: checkout total charge send audit libc_write open adds_up add charges_once
assert_eq!(js["node_count"], 10, "{js}");
assert_eq!(js["edge_count"], 13, "one edge per call-site row between distinct names");
assert_eq!(js["resolved_edges"], js["edge_count"], "rustc resolved every callee: no name-guessed edge");
assert_eq!(js["syn_edges"], 0);
assert!(js["top_nodes"].as_array().unwrap().iter().any(|n| n == "charge"));
let mut cg = CallGraphState::local(fixture::unconsumed().builds.clone());
cg.ensure_loaded();
let js = cg.state_json();
assert_eq!(js["state"], "unavailable");
assert!(js["error"].as_str().unwrap().contains("[not_consumed]"), "{js}");
}
}