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));
}
}
}