Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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(&deg[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}");
    }
}