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/style.rs

//! **Styling by property** — colour nodes by a categorical field (their label, or any
//! property key). Distinct values get distinct hues from a fixed brand-neutral wheel,
//! so the canvas reads as a legend without the engine learning what the field means.

use std::collections::BTreeMap;

use crate::model::PropGraph;

/// A categorical colour wheel (RGB), spread around the hue circle. Deterministic.
pub const WHEEL: [[u8; 3]; 8] = [
    [78, 158, 246],  // blue
    [246, 143, 78],  // orange
    [96, 200, 128],  // green
    [201, 104, 214], // magenta
    [232, 197, 71],  // yellow
    [92, 206, 214],  // cyan
    [226, 96, 104],  // red
    [150, 132, 246], // violet
];

/// The `i`-th wheel colour (wraps). Index-based — the right rule for *positional*
/// categories (partition / community ids), where the number IS the meaning.
#[must_use]
pub fn wheel(i: usize) -> [u8; 3] {
    WHEEL[i % WHEEL.len()]
}

/// A **stable, content-independent** 64-bit hash (FNV-1a). Byte-stable across processes,
/// runs, and platforms — unlike `std::hash::DefaultHasher`, whose SipHash seed is fixed
/// today but is explicitly not contractually stable. Used to pick an ontology label's hue
/// from the label STRING alone, so the colour never depends on graph content or order.
#[must_use]
fn stable_hash(s: &str) -> u64 {
    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
    const PRIME: u64 = 0x0000_0100_0000_01b3;
    let mut h = OFFSET;
    for b in s.as_bytes() {
        h ^= u64::from(*b);
        h = h.wrapping_mul(PRIME);
    }
    h
}

/// The **stable ontology colour** for a label. A given label STRING always maps to the
/// same wheel hue regardless of which other labels are present, their order, or the graph
/// size — the fix for a node "changing colour" between a 3k and a 30k graph, where the old
/// first-seen `wheel(index)` shifted every hue when the label set changed.
///
/// Deterministic: `wheel(stable_hash(label) % WHEEL.len())`. Both facett's 2D canvas
/// ([`StyleBy::Label`]) and korp's 3D cloud (`korp::graphnav::ontology_colors`) consume
/// THIS one function, so the two views agree by construction and are invariant to content.
#[must_use]
pub fn label_color(label: &str) -> [u8; 3] {
    wheel((stable_hash(label) % WHEEL.len() as u64) as usize)
}

/// What to colour nodes by.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StyleBy {
    /// Uniform (no property styling).
    None,
    /// Colour by node label (type).
    Label,
    /// Colour by a named property's value.
    Property(String),
}

impl StyleBy {
    /// The stable key for `state_json`.
    #[must_use]
    pub fn key(&self) -> String {
        match self {
            StyleBy::None => "none".into(),
            StyleBy::Label => "label".into(),
            StyleBy::Property(p) => format!("prop:{p}"),
        }
    }
}

/// A computed styling: the ordered distinct categories (the legend) + a per-node
/// category index (`None` = the field is absent on that node).
#[derive(Clone, Debug, PartialEq)]
pub struct Styling {
    /// Distinct category values in first-seen order — the legend.
    pub categories: Vec<String>,
    /// `of[i]` = category index of node `i`, or `None` if the field is absent.
    pub of: Vec<Option<usize>>,
    /// When `true` (ontology-label styling) a node's hue comes from its label STRING via
    /// [`label_color`] — stable across graph content/order/scale. When `false` (property
    /// styling) it comes from the first-seen `wheel(index)`. The legend (`categories`) is
    /// first-seen either way; only the colour rule differs.
    pub stable: bool,
}

impl Styling {
    /// The RGB colour for node `i` under this styling, falling back to `default` when
    /// the field is absent. Label styling hashes the label to a stable hue; other
    /// categorical styling uses the first-seen wheel index.
    #[must_use]
    pub fn color(&self, i: usize, default: [u8; 3]) -> [u8; 3] {
        match self.of.get(i).copied().flatten() {
            Some(c) if self.stable => label_color(&self.categories[c]),
            Some(c) => wheel(c),
            None => default,
        }
    }
}

/// Compute the [`Styling`] for `g` under `by`.
#[must_use]
pub fn compute(g: &PropGraph, by: &StyleBy) -> Styling {
    let field = |i: usize| -> Option<String> {
        let n = &g.nodes[i];
        match by {
            StyleBy::None => None,
            StyleBy::Label => Some(n.label.clone()),
            StyleBy::Property(p) => n.props.get(p).cloned(),
        }
    };
    let mut categories: Vec<String> = Vec::new();
    let mut cat_index: BTreeMap<String, usize> = BTreeMap::new();
    let mut of = Vec::with_capacity(g.nodes.len());
    for i in 0..g.nodes.len() {
        match field(i) {
            Some(v) => {
                let idx = *cat_index.entry(v.clone()).or_insert_with(|| {
                    categories.push(v);
                    categories.len() - 1
                });
                of.push(Some(idx));
            }
            None => of.push(None),
        }
    }
    // Ontology-LABEL styling colours each type by its label string (stable across content /
    // order / scale); any other categorical field keeps the positional first-seen wheel.
    let stable = matches!(by, StyleBy::Label);
    Styling { categories, of, stable }
}

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

    #[test]
    fn style_by_label_gives_two_categories() {
        let g = PropGraph::demo();
        let s = compute(&g, &StyleBy::Label);
        // Person + Company.
        assert_eq!(s.categories.len(), 2);
        assert!(s.categories.contains(&"Person".to_string()));
        // Persons and companies get different wheel colours.
        let p1 = g.index_of("p1").unwrap();
        let c1 = g.index_of("c1").unwrap();
        assert_ne!(s.color(p1, [0, 0, 0]), s.color(c1, [0, 0, 0]));
    }

    #[test]
    fn style_by_absent_property_falls_back_to_default() {
        let g = PropGraph::demo();
        let s = compute(&g, &StyleBy::Property("age".into()));
        // Companies have no `age` → default colour.
        let c1 = g.index_of("c1").unwrap();
        assert_eq!(s.color(c1, [1, 2, 3]), [1, 2, 3]);
        // People do have distinct ages → coloured.
        let p1 = g.index_of("p1").unwrap();
        assert_ne!(s.color(p1, [1, 2, 3]), [1, 2, 3]);
    }

    #[test]
    fn style_none_colours_nothing() {
        let g = PropGraph::demo();
        let s = compute(&g, &StyleBy::None);
        assert!(s.categories.is_empty());
        assert!(s.of.iter().all(Option::is_none));
    }

    /// A graph with `first`'s label first, then four filler types, then a `Vessel`.
    fn graph_with(first: &str) -> PropGraph {
        use crate::model::Node;
        let mut g = PropGraph::new();
        for (i, lbl) in [first, "A", "B", "C", "D", "Vessel"].iter().enumerate() {
            g.add_node(Node::new(format!("n{i}"), *lbl));
        }
        g
    }

    /// **RED-when-broken — stable ontology palette (the reported bug).** The type `Vessel`
    /// must render the SAME colour whether it is the FIRST label seen or the SIXTH. Under
    /// the old first-seen `wheel(index)` scheme `Vessel`-first got `wheel(0)` and
    /// `Vessel`-sixth got `wheel(5)` — a different colour — so this asserts the fix: a
    /// type's colour is invariant to graph content and node order.
    ///
    /// `functional_status: stable_label_colour` (testmatrix).
    #[test]
    fn label_colour_is_stable_across_node_order() {
        let vessel_first = graph_with("Vessel");
        let vessel_last = graph_with("Zzz");

        let sf = compute(&vessel_first, &StyleBy::Label);
        let sl = compute(&vessel_last, &StyleBy::Label);

        let vf = vessel_first.index_of("n0").unwrap(); // Vessel at first-seen index 0
        let vl = vessel_last.index_of("n5").unwrap(); // Vessel at first-seen index 5
        assert_eq!(
            sf.color(vf, [0, 0, 0]),
            sl.color(vl, [0, 0, 0]),
            "Vessel must be the same colour regardless of node order",
        );
        // …and it equals the content-independent primitive korp also consumes.
        assert_eq!(sf.color(vf, [0, 0, 0]), label_color("Vessel"));
    }

    /// `label_color` is deterministic (byte-stable) and separates distinct labels — two
    /// different types are independently determined, so they do not collapse to one hue by
    /// accident of insertion order. `functional_status: stable_label_colour`.
    #[test]
    fn label_color_is_deterministic_and_discriminates() {
        assert_eq!(label_color("Vessel"), label_color("Vessel"));
        assert_ne!(label_color("Person"), label_color("Company"));
        // Independent of surrounding labels: same value computed in isolation.
        assert_eq!(label_color("Person"), wheel((stable_hash("Person") % WHEEL.len() as u64) as usize));
    }
}