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