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

nordisk/facett

facett-graphnav/src/appearance.rs

//! **Appearance = Ranking + Partition** — the wiring from the analysis toolbox
//! ([`facett_graphview::analysis`]) to the canvas's *visual encoding*, the #1
//! functional gap versus Gephi's "Appearance" panel.
//!
//! Three independent channels:
//! - **size by a ranking metric** ([`Metric`]) — degree / PageRank / betweenness /
//!   closeness / eigenvector / k-core map to a node radius ramp;
//! - **colour by a continuous metric** ([`Metric`]) — the same rankings drive a
//!   sequential colour ramp (low→high);
//! - **colour by a partition** ([`Partition`]) — Louvain community / node label /
//!   connected component map to distinct categorical hues.
//!
//! All algorithms already live in [`facett_graphview`]; this module only maps a
//! [`crate::PropGraph`] to indices, runs them, and normalises the results into the
//! `[radius]` / `[rgb]` the painter consumes. Pure + deterministic + headless-testable.

use serde::{Deserialize, Serialize};

use facett_graphview::analysis::{centrality, components, kcore, Adjacency};
use facett_graphview::community;

use crate::model::PropGraph;

/// A **ranking metric** — a per-node score the appearance panel encodes as size or a
/// sequential colour. Every one is a `Vec<f32>` from [`facett_graphview::analysis`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Metric {
    /// Undirected degree centrality.
    Degree,
    /// PageRank (d = 0.85).
    PageRank,
    /// Betweenness centrality.
    Betweenness,
    /// Closeness centrality.
    Closeness,
    /// Eigenvector centrality.
    Eigenvector,
    /// k-core number (degeneracy peel).
    KCore,
}

impl Metric {
    /// The stable lowercase key (`state_json` / robot control).
    #[must_use]
    pub fn key(self) -> &'static str {
        match self {
            Metric::Degree => "degree",
            Metric::PageRank => "pagerank",
            Metric::Betweenness => "betweenness",
            Metric::Closeness => "closeness",
            Metric::Eigenvector => "eigenvector",
            Metric::KCore => "kcore",
        }
    }
    /// All metrics in menu order.
    pub const ALL: [Metric; 6] = [
        Metric::Degree,
        Metric::PageRank,
        Metric::Betweenness,
        Metric::Closeness,
        Metric::Eigenvector,
        Metric::KCore,
    ];
    /// Parse from a key.
    #[must_use]
    pub fn from_key(k: &str) -> Option<Metric> {
        Metric::ALL.into_iter().find(|m| m.key() == k)
    }

    /// Whether this metric is **superlinear** — all-pairs shortest paths, O(V·E) — and so
    /// must be skipped above the graphnav `ANALYTICS_SCALE_GUARD` rather than freeze a
    /// single core at scale. Only betweenness + closeness qualify; the rest (degree,
    /// pagerank / eigenvector power-iteration, k-core) are near-linear and stay enabled at
    /// any node count.
    #[must_use]
    pub fn heavy_at_scale(self) -> bool {
        matches!(self, Metric::Betweenness | Metric::Closeness)
    }

    /// The raw per-node scores (indexed like `graph.nodes`).
    #[must_use]
    pub fn values(self, graph: &PropGraph) -> Vec<f32> {
        let n = graph.nodes.len();
        let edges = graph.edge_indices();
        let g = Adjacency::from_edges(n, &edges);
        match self {
            Metric::Degree => centrality::degree(&g),
            Metric::PageRank => centrality::pagerank(&g, 0.85, 100),
            Metric::Betweenness => centrality::betweenness(&g),
            Metric::Closeness => centrality::closeness(&g),
            Metric::Eigenvector => centrality::eigenvector(&g, 100),
            Metric::KCore => kcore::core_numbers(&g).into_iter().map(|c| c as f32).collect(),
        }
    }
}

/// A **partition** — a categorical labelling the appearance panel encodes as hue.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Partition {
    /// Louvain community (modularity local-moving).
    Louvain,
    /// The node's own `label` field (its type).
    Label,
    /// The undirected connected component.
    Component,
}

impl Partition {
    /// The stable lowercase key.
    #[must_use]
    pub fn key(self) -> &'static str {
        match self {
            Partition::Louvain => "louvain",
            Partition::Label => "label",
            Partition::Component => "component",
        }
    }
    /// All partitions in menu order.
    pub const ALL: [Partition; 3] = [Partition::Louvain, Partition::Label, Partition::Component];
    /// Parse from a key.
    #[must_use]
    pub fn from_key(k: &str) -> Option<Partition> {
        Partition::ALL.into_iter().find(|p| p.key() == k)
    }

    /// The per-node partition id (compact `0..count`) and the community count.
    #[must_use]
    pub fn assign(self, graph: &PropGraph) -> (Vec<usize>, usize) {
        let n = graph.nodes.len();
        let edges = graph.edge_indices();
        match self {
            Partition::Louvain => {
                let c = community::louvain(n, &edges);
                (c.of, c.count)
            }
            Partition::Component => {
                let g = Adjacency::from_edges(n, &edges);
                let c = components::connected(&g);
                (c.of, c.count)
            }
            Partition::Label => {
                let mut of = Vec::with_capacity(n);
                let mut seen: Vec<String> = Vec::new();
                for node in &graph.nodes {
                    let idx = seen.iter().position(|l| l == &node.label).unwrap_or_else(|| {
                        seen.push(node.label.clone());
                        seen.len() - 1
                    });
                    of.push(idx);
                }
                let count = seen.len();
                (of, count)
            }
        }
    }
}

/// Minimum / maximum node radius (screen px) the size ramp spans.
pub const R_MIN: f32 = 4.0;
pub const R_MAX: f32 = 15.0;

/// Normalise `values` to `[0, 1]` (min→0, max→1); a flat field maps to all `0.5`.
#[must_use]
pub fn normalize(values: &[f32]) -> Vec<f32> {
    let (mut lo, mut hi) = (f32::MAX, f32::MIN);
    for &v in values {
        if v.is_finite() {
            lo = lo.min(v);
            hi = hi.max(v);
        }
    }
    if !(lo.is_finite() && hi.is_finite()) || (hi - lo).abs() < 1e-9 {
        return vec![0.5; values.len()];
    }
    values.iter().map(|&v| ((v - lo) / (hi - lo)).clamp(0.0, 1.0)).collect()
}

/// Map normalised scores to node radii in `[R_MIN, R_MAX]`.
#[must_use]
pub fn radii(values: &[f32]) -> Vec<f32> {
    normalize(values).into_iter().map(|t| R_MIN + t * (R_MAX - R_MIN)).collect()
}

/// A **sequential colour ramp** (deep blue → teal → yellow), `t ∈ [0, 1]`. A
/// brand-neutral, perceptually-monotone low→high ramp (viridis-adjacent) so a
/// continuous metric reads as a legend.
#[must_use]
pub fn ramp(t: f32) -> [u8; 3] {
    // Piecewise-linear through four control stops.
    const STOPS: [[f32; 3]; 4] = [
        [40.0, 46.0, 120.0],   // deep indigo (low)
        [33.0, 120.0, 155.0],  // teal
        [90.0, 190.0, 120.0],  // green
        [240.0, 220.0, 70.0],  // yellow (high)
    ];
    let t = t.clamp(0.0, 1.0);
    let seg = (t * 3.0).min(2.999);
    let i = seg as usize;
    let f = seg - i as f32;
    let a = STOPS[i];
    let b = STOPS[i + 1];
    [
        (a[0] + (b[0] - a[0]) * f) as u8,
        (a[1] + (b[1] - a[1]) * f) as u8,
        (a[2] + (b[2] - a[2]) * f) as u8,
    ]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn degree_ranks_the_bridge_high() {
        let g = PropGraph::demo();
        let v = Metric::Degree.values(&g);
        assert_eq!(v.len(), g.nodes.len());
        // p3 bridges the two clusters + is in a triangle → high degree; a company leaf lower.
        let p3 = g.index_of("p3").unwrap();
        let c1 = g.index_of("c1").unwrap();
        assert!(v[p3] > v[c1], "the bridge node outranks a company leaf: {} vs {}", v[p3], v[c1]);
    }

    #[test]
    fn radii_span_the_ramp() {
        let g = PropGraph::demo();
        let r = radii(&Metric::Degree.values(&g));
        assert!(r.iter().all(|&x| (R_MIN..=R_MAX).contains(&x)));
        assert!(r.iter().cloned().fold(f32::MIN, f32::max) > r.iter().cloned().fold(f32::MAX, f32::min));
    }

    #[test]
    fn louvain_partition_splits_the_two_clusters() {
        let g = PropGraph::demo();
        let (of, count) = Partition::Louvain.assign(&g);
        assert_eq!(of.len(), g.nodes.len());
        assert!(count >= 2, "the two KNOWS-triangles form ≥2 communities");
    }

    #[test]
    fn label_partition_matches_the_schema() {
        let g = PropGraph::demo();
        let (_of, count) = Partition::Label.assign(&g);
        assert_eq!(count, 2, "Person + Company");
    }

    #[test]
    fn ramp_is_monotone_and_bounded() {
        assert_eq!(ramp(-1.0), ramp(0.0));
        assert_eq!(ramp(2.0), ramp(1.0));
        // low end is bluer (more blue than red), high end yellower (more red than blue).
        let lo = ramp(0.0);
        let hi = ramp(1.0);
        assert!(lo[2] > lo[0], "low = blue-dominant");
        assert!(hi[0] > hi[2], "high = warm-dominant");
    }

    #[test]
    fn keys_round_trip() {
        for m in Metric::ALL {
            assert_eq!(Metric::from_key(m.key()), Some(m));
        }
        for p in Partition::ALL {
            assert_eq!(Partition::from_key(p.key()), Some(p));
        }
    }
}