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

nordisk/facett

facet-wrapped/src/graph.rs

//! **The graph family** — FIVE surfaces, ONE payload contract.
//!
//! facett has five graph crates (`facett-graph`, `-graph3d`, `-graphnav`, `-graphpan`,
//! `-graphview`). Giving each its own embed payload would force five graph JSON
//! dialects on every caller. There is instead **one** contract, parsed once into
//! [`GraphPayload`] and lowered per component.
//!
//! ```json
//! { "title": "cases",
//!   "nodes": [{ "id": 1, "label": "Person", "key": "Person/1",
//!               "group": "people", "pos": [x, y], "props": {"kommun": "Uppsala"} }],
//!   "edges": [{ "src": 1, "dst": 2, "rel": "OWNS", "dashed": false, "label": "2019" }] }
//! ```
//!
//! Only `id` and `src`/`dst` are required. `from`/`to` alias `src`/`dst`; ids may be
//! JSON numbers or strings and normalise to strings.
//!
//! # Semantics in, styling out
//!
//! The payload carries **meaning** (`label`, `group`, `rel`) and never appearance —
//! there is no `color` field, deliberately. Colour is resolved HERE by [`group_color`],
//! so one entity type is one colour across all five components and across runs.
//!
//! `pos` is optional: `graphpan` and `graphview` are *caller-laid-out* surfaces, so
//! when it is absent this module supplies a deterministic layout rather than making
//! every caller invent one.

use facett_core::Facet;
use serde_json::Value;

use crate::registry::{Entry, GRAPH, Registry};

/// Register the graph family.
pub(crate) fn register(r: &mut Registry) {
    r.register(
        Entry::new("graphnav", "Graph-DB IDE: query bar, canvas, inspector, layouts, paths.", || {
            Box::new(facett_graphnav::GraphNav::demo())
        })
        .in_group(GRAPH)
        .with_data(graphnav)
        .with_generator(crate::generated::RESIDENT, &facett_graphgen::GENERATED_SOURCES, crate::generated::graphnav),
    );
    r.register(
        Entry::new("graph2d", "A scene+layout graph widget (circular/force).", || {
            Box::new(facett_graph::GraphView::new(facett_core::Scene::new()).with_gpu(true))
        })
        .in_group(GRAPH)
        .with_data(graph2d)
        .with_generator(crate::generated::RESIDENT, &facett_graphgen::GENERATED_SOURCES, crate::generated::graph2d),
    );
    r.register(
        Entry::new("graph3d", "A 3D node cloud (sphere/helix/force/grid layouts).", || {
            Box::new(facett_graph3d::Graph3D::new(
                Vec::new(),
                Vec::new(),
                facett_graph3d::Layout3D::Force,
            ))
        })
        .in_group(GRAPH)
        .with_data(graph3d)
        .with_generator(crate::generated::RESIDENT, &facett_graphgen::GENERATED_SOURCES, crate::generated::graph3d),
    );
    r.register(
        Entry::new("graphpan", "A bare pan/zoom graph canvas (caller-laid-out).", || {
            Box::new(facett_graphpan::GraphPanView::local())
        })
        .in_group(GRAPH)
        .with_data(graphpan)
        .with_generator(crate::generated::RESIDENT, &facett_graphgen::GENERATED_SOURCES, crate::generated::graphpan),
    );
    r.register(
        Entry::new("graphview", "The 2D graph render engine + legibility pass.", || {
            Box::new(facett_graphview::decorated_view::DecoratedGraphView::local())
        })
        .in_group(GRAPH)
        .with_data(graphview)
        .with_generator(crate::generated::RESIDENT, &facett_graphgen::GENERATED_SOURCES, crate::generated::graphview),
    );
    r.register(
        Entry::new("lineage", "A data-lineage graph (datasets = nodes, runs = edges).", || {
            // The lineage canvas is a GraphNav: the same GPU cloud lane as `graphnav`.
            let mut v = facett_lineage::LineageView::demo();
            v.nav_mut().legibility.gpu_cloud = true;
            Box::new(v)
        })
        .in_group(GRAPH)
        .with_generator(crate::generated::RESIDENT, &facett_graphgen::GENERATED_SOURCES, crate::generated::lineage),
    );
    crate::generated::register(r);
}

/// One node of the shared contract.
#[derive(Clone, Debug, PartialEq)]
pub struct PayloadNode {
    pub id: String,
    /// Type/class caption (`Person`, `Vessel`, …).
    pub label: String,
    /// Optional business key, shown as the chip caption.
    pub key: Option<String>,
    /// Optional colour-grouping key; defaults to [`label`](Self::label).
    pub group: Option<String>,
    /// Optional caller-supplied position (2D `[x,y]` or 3D `[x,y,z]`).
    pub pos: Option<[f32; 3]>,
    /// Optional property bag (the graph IDE's inspector shows these).
    pub props: Vec<(String, String)>,
}

impl PayloadNode {
    /// Colour grouping: explicit `group`, else the node `label`.
    #[must_use]
    pub fn group_key(&self) -> &str {
        self.group.as_deref().unwrap_or(&self.label)
    }
    /// Chip caption: the business `key` when present, else the `label`.
    #[must_use]
    pub fn caption(&self) -> &str {
        self.key.as_deref().unwrap_or(&self.label)
    }
}

/// One edge of the shared contract.
#[derive(Clone, Debug, PartialEq)]
pub struct PayloadEdge {
    pub src: String,
    pub dst: String,
    pub rel: String,
    pub dashed: bool,
    pub label: Option<String>,
}

/// The parsed shared graph payload.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GraphPayload {
    pub title: String,
    pub nodes: Vec<PayloadNode>,
    pub edges: Vec<PayloadEdge>,
}

impl GraphPayload {
    /// Parse the shared contract. `None` when nothing is renderable (no nodes).
    #[must_use]
    pub fn parse(v: &Value) -> Option<Self> {
        let mut nodes = Vec::new();
        for n in v.get("nodes")?.as_array()? {
            let Some(id) = id_of(n.get("id")) else { continue };
            nodes.push(PayloadNode {
                id,
                label: n
                    .get("label")
                    .and_then(Value::as_str)
                    .filter(|s| !s.is_empty())
                    .unwrap_or("Node")
                    .to_string(),
                key: str_opt(n.get("key")),
                group: str_opt(n.get("group")),
                pos: pos_of(n.get("pos")),
                props: props_of(n.get("props")),
            });
        }
        if nodes.is_empty() {
            return None;
        }
        // Edges reference nodes; drop danglers rather than rendering a broken graph.
        let known: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
        let mut edges = Vec::new();
        if let Some(arr) = v.get("edges").and_then(Value::as_array) {
            for e in arr {
                let src = id_of(e.get("src").or_else(|| e.get("from")));
                let dst = id_of(e.get("dst").or_else(|| e.get("to")));
                let (Some(src), Some(dst)) = (src, dst) else { continue };
                if !known.contains(src.as_str()) || !known.contains(dst.as_str()) {
                    continue;
                }
                edges.push(PayloadEdge {
                    src,
                    dst,
                    rel: e
                        .get("rel")
                        .or_else(|| e.get("etype"))
                        .and_then(Value::as_str)
                        .unwrap_or("REL")
                        .to_string(),
                    dashed: e.get("dashed").and_then(Value::as_bool).unwrap_or(false),
                    label: str_opt(e.get("label")),
                });
            }
        }
        Some(Self {
            title: v
                .get("title")
                .and_then(Value::as_str)
                .filter(|s| !s.trim().is_empty())
                .unwrap_or("graph")
                .to_string(),
            nodes,
            edges,
        })
    }

    fn index_of(&self, id: &str) -> Option<usize> {
        self.nodes.iter().position(|n| n.id == id)
    }

    /// Edges as `(src_idx, dst_idx)` — the shape the index-edge components use.
    #[must_use]
    pub fn index_edges(&self) -> Vec<(usize, usize)> {
        self.edges
            .iter()
            .filter_map(|e| Some((self.index_of(&e.src)?, self.index_of(&e.dst)?)))
            .collect()
    }

    /// Positions for every node: the caller's where given, else [`layout_positions`].
    #[must_use]
    pub fn positions(&self) -> Vec<[f32; 3]> {
        let fallback = layout_positions(self.nodes.len());
        self.nodes.iter().enumerate().map(|(i, n)| n.pos.unwrap_or(fallback[i])).collect()
    }
}

/// The shared categorical palette — **facett decides colour, not the caller**. A
/// stable FNV-1a hash of the group key picks a slot, so the same entity type is the
/// same colour in every graph component and across runs.
#[must_use]
pub fn group_color(group: &str) -> (u8, u8, u8) {
    const PALETTE: [(u8, u8, u8); 10] = [
        (60, 110, 200),
        (200, 90, 60),
        (70, 150, 100),
        (190, 150, 60),
        (130, 90, 180),
        (80, 160, 190),
        (190, 100, 140),
        (140, 110, 80),
        (110, 120, 135),
        (95, 95, 95),
    ];
    let mut h: u32 = 0x811c_9dc5;
    for b in group.as_bytes() {
        h ^= u32::from(*b);
        h = h.wrapping_mul(0x0100_0193);
    }
    PALETTE[(h as usize) % PALETTE.len()]
}

/// A deterministic fallback layout: a golden-angle spiral, which spreads evenly
/// without the clumping a plain circle shows at high `n` and needs no iteration.
#[must_use]
pub fn layout_positions(n: usize) -> Vec<[f32; 3]> {
    const GOLDEN: f32 = 2.399_963_2;
    const SPACING: f32 = 78.0;
    (0..n)
        .map(|i| {
            let a = i as f32 * GOLDEN;
            let r = SPACING * (i as f32 + 0.5).sqrt();
            [r * a.cos(), r * a.sin(), 0.0]
        })
        .collect()
}

fn id_of(v: Option<&Value>) -> Option<String> {
    match v? {
        Value::String(s) if !s.is_empty() => Some(s.clone()),
        Value::Number(n) => Some(n.to_string()),
        _ => None,
    }
}

fn str_opt(v: Option<&Value>) -> Option<String> {
    v.and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string)
}

fn pos_of(v: Option<&Value>) -> Option<[f32; 3]> {
    let a = v?.as_array()?;
    Some([
        a.first()?.as_f64()? as f32,
        a.get(1)?.as_f64()? as f32,
        a.get(2).and_then(Value::as_f64).unwrap_or(0.0) as f32,
    ])
}

fn props_of(v: Option<&Value>) -> Vec<(String, String)> {
    let Some(Value::Object(m)) = v else { return Vec::new() };
    m.iter()
        .map(|(k, val)| {
            let s = match val {
                Value::String(s) => s.clone(),
                other => other.to_string(),
            };
            (k.clone(), s)
        })
        .collect()
}

// ── one adapter per graph component ─────────────────────────────────────────────

/// **Build the graphnav MODEL from the shared payload** — the ONE writer for how a
/// payload node becomes a `PropGraph` node.
///
/// Public, and separate from [`graphnav`], on purpose. The adapter returns a
/// `Box<dyn Facet>`, so a test cannot get the concrete `GraphNav` back to drive a
/// selection — and graphnav's inspector only publishes a node's props when one is
/// SELECTED. The first version of that test rebuilt the graph by copying this loop,
/// which would have kept passing while the adapter drifted underneath it: a guard
/// watching two copies agree (LAW 5). One function, two callers, nothing to drift.
#[must_use]
pub fn graphnav_model(p: &GraphPayload) -> facett_graphnav::model::PropGraph {
    use facett_graphnav::model::{Edge, Node, PropGraph};
    let mut g = PropGraph::new();
    for n in &p.nodes {
        let mut node = Node::new(n.id.clone(), n.label.clone());
        if let Some(key) = &n.key {
            node = node.with("key", key.clone()).with("name", key.clone());
        }
        // The `group` — the payload's word for "what kind of thing is this", and what
        // `group_color` keys on for every other surface in this family. graphnav
        // colours by LABEL and has its own pinned-ontology palette, so the group is
        // carried as a PROPERTY rather than re-invented here as a second colour rule:
        // one meaning, two readers, no third palette to drift.
        //
        // Written BEFORE the caller's own props, so a caller who puts a deliberate
        // `group` in `props` is not overwritten by the shorthand field.
        if let Some(gr) = &n.group {
            node = node.with("group", gr.clone());
        }
        for (k, val) in &n.props {
            node = node.with(k.clone(), val.clone());
        }
        g.add_node(node);
    }
    for e in &p.edges {
        let mut edge = Edge::new(e.src.clone(), e.dst.clone(), e.rel.clone());
        if let Some(l) = &e.label {
            edge = edge.with("label", l.clone());
        }
        g.add_edge(edge);
    }
    g
}

/// **Build the graphnav SURFACE** — the model, plus the caller's coordinates and the
/// layout those make available.
///
/// ## The caller's coordinates
///
/// Every other surface in this family honours `pos`; graphnav did not, so a caller
/// that had already projected its nodes onto a map plane got a FORCE layout and its
/// geometry was silently discarded. MEASURED before this, feeding `pos: [111,222]`
/// and `[333,444]`: `canvas.layout == "force"` and two nodes at `x = ±0.9` on a
/// horizontal line — the shape `force` makes of one edge, with nothing in it that
/// depended on the input.
///
/// Both halves of the seam already existed. graphnav has `with_places`, a
/// `LayoutKind::Geographic`, and `places_cover` as THE one predicate its switcher and
/// its layout share; `GraphPayload` already parsed `pos`. They were simply not
/// connected.
///
/// `Place` is `Option<[f64; 2]>` and deliberately NOT lon/lat — the units are the
/// host's, and `geographic` only ever takes differences and one uniform scale. The
/// payload's `pos` is exactly that: a plane the caller already chose. A node WITHOUT
/// `pos` stays `None` rather than being given a made-up place, because `places_cover`
/// demands EVERY node: a geographic layout with holes puts nodes somewhere they are
/// not, and that is worse than not offering the lane.
#[must_use]
pub fn graphnav_view(p: &GraphPayload) -> facett_graphnav::GraphNav {
    let places: Vec<facett_graphnav::layout::Place> =
        p.nodes.iter().map(|n| n.pos.map(|q| [f64::from(q[0]), f64::from(q[1])])).collect();
    let mut nav = facett_graphnav::GraphNav::new(p.title.clone(), graphnav_model(p))
        .with_places(places);

    // SELECT the lane the places make available. Supplying coordinates and then laying
    // the graph out by force would discard them just as thoroughly as not parsing
    // them: `has_places` would read true, the switcher would offer `geographic`, and
    // the canvas would still show a force layout until a human clicked. A caller who
    // sent positions asked for them to be used.
    //
    // Gated on graphnav's OWN predicate rather than on "did the payload carry any
    // pos" — `places_cover` demands every node, and it is the same function the
    // switcher and the layout consult, so this can never select a lane the layout
    // would then fall back out of. A partially-placed graph stays on force, which is
    // the honest answer.
    if nav.has_places() {
        nav.update(facett_graphnav::Msg::SetLayout(facett_graphnav::LayoutKind::Geographic));
    }
    // The GPU paint lane (facett-core `graphcloud`, installed by `install_gpu_lanes`).
    // Measured before this line in a headful WebGPU browser: `legibility.gpu_cloud:
    // false` on an RTX 4090. The CPU painter stays the fallback when no renderer is in.
    nav.legibility.gpu_cloud = true;
    nav
}

/// [`facett_graphnav::GraphNav`] — the property-graph IDE.
#[must_use]
pub fn graphnav(v: &Value) -> Option<Box<dyn Facet>> {
    let p = GraphPayload::parse(v)?;
    Some(Box::new(graphnav_view(&p)))
}

/// [`facett_graph::GraphView`] — the scene+layout widget.
#[must_use]
pub fn graph2d(v: &Value) -> Option<Box<dyn Facet>> {
    let p = GraphPayload::parse(v)?;
    let mut scene = facett_core::Scene::new();
    for n in &p.nodes {
        let (r, g, b) = group_color(n.group_key());
        scene.node(n.caption().to_string(), egui::Color32::from_rgb(r, g, b));
    }
    for (s, d) in p.index_edges() {
        scene.edge(s, d);
    }
    // The GPU cloud lane (facett-core `graphcloud`, installed by `install_gpu_lanes`);
    // `lane` in the state says which drew, and `gpu: "no GPU"` when nothing is installed.
    let mut view = facett_graph::GraphView::new(scene).with_gpu(true);
    view.title = p.title;
    Some(Box::new(view))
}

/// [`facett_graph3d::Graph3D`] — the 3D node cloud.
#[must_use]
pub fn graph3d(v: &Value) -> Option<Box<dyn Facet>> {
    let p = GraphPayload::parse(v)?;
    let nodes: Vec<(String, egui::Color32)> = p
        .nodes
        .iter()
        .map(|n| {
            let (r, g, b) = group_color(n.group_key());
            (n.caption().to_string(), egui::Color32::from_rgb(r, g, b))
        })
        .collect();
    let caller_placed = p.nodes.iter().all(|n| n.pos.is_some());
    let mut g3 =
        facett_graph3d::Graph3D::new(nodes, p.index_edges(), facett_graph3d::Layout3D::Force);
    if caller_placed {
        for (n, pos) in g3.nodes.iter_mut().zip(p.positions()) {
            n.pos = pos;
        }
        // Written through the public `nodes` seam, which the model version cannot see.
        g3.touch();
    } else {
        g3.layout();
    }
    g3.title = p.title;
    // SELECT the instanced GPU cloud lane. Same reasoning as osm3d: the manifest
    // enabling `facett-graph3d/wgpu` only puts the renderer in the binary, and
    // `Graph3D` starts with `use_gpu: false`. `with_gpu` is `#[cfg(feature = "wgpu")]`,
    // so this call is also the compile-time proof that the feature reached this crate.
    //
    // ⚠ HONESTLY WEAKER THAN THE osm3d ONE, and worth saying rather than glossing:
    // `facett-graph3d` publishes NO lane in its `state_json` and has no public getter
    // for `use_gpu` (the field is private, `lib.rs:494`). So unlike the 3D map — where
    // `gpu_lane_selected`/`backend`/`lane_flag` let a test and a headless robot read
    // the selection back — nothing outside this crate can observe whether this line
    // took effect. It is asserted at COMPILE time only (see
    // `tests/gpu_lane_is_observable.rs`). The three-line fix is in graph3d's own
    // `state_json`, mirroring what `facett-map3d` already does with
    // `LaneFlag::token()`; it is posted for that crate's owner rather than made here,
    // and the facett lane has live uncommitted work in that exact GPU cloud path.
    let g3 = g3.with_gpu(true);
    Some(Box::new(g3))
}

/// [`facett_graphpan::GraphPanView`] — the bare pan/zoom canvas.
#[must_use]
pub fn graphpan(v: &Value) -> Option<Box<dyn Facet>> {
    use facett_graphpan::{GraphEdge, GraphModel, GraphNode};
    let p = GraphPayload::parse(v)?;
    let pos = p.positions();
    let nodes = p
        .nodes
        .iter()
        .enumerate()
        .map(|(i, n)| {
            let (r, g, b) = group_color(n.group_key());
            GraphNode {
                id: n.id.clone(),
                label: n.caption().to_string(),
                fill: egui::Color32::from_rgb(r, g, b),
                stroke: egui::Color32::WHITE,
                pos: egui::Pos2::new(pos[i][0], pos[i][1]),
            }
        })
        .collect();
    let edges = p
        .edges
        .iter()
        .map(|e| GraphEdge {
            from: e.src.clone(),
            to: e.dst.clone(),
            color: egui::Color32::from_rgb(200, 200, 220),
            dashed: e.dashed,
            label: e.label.clone(),
        })
        .collect();
    Some(Box::new(
        facett_graphpan::GraphPanView::new(p.title).with_model(GraphModel { nodes, edges })
            // The GPU cloud lane (facett-core `graphcloud`, the renderer `install_gpu_lanes`
            // installs for graph3d and graphview too). Falls back to the CPU painter when
            // no renderer is installed; `legibility.gpu_cloud` in the state says which drew.
            .with_gpu(true),
    ))
}

/// [`facett_graphview::decorated_view::DecoratedGraphView`] — the 2D render engine.
#[must_use]
pub fn graphview(v: &Value) -> Option<Box<dyn Facet>> {
    use facett_graphview::model::{Color, GraphEdge, GraphModel, GraphNode, Pos};
    let p = GraphPayload::parse(v)?;
    let pos = p.positions();
    let nodes = p
        .nodes
        .iter()
        .enumerate()
        .map(|(i, n)| {
            let (r, g, b) = group_color(n.group_key());
            GraphNode {
                id: n.id.clone(),
                label: n.caption().to_string(),
                fill: Color::rgb(r, g, b),
                stroke: Color::WHITE,
                pos: Pos::new(pos[i][0], pos[i][1]),
            }
        })
        .collect();
    let edges = p
        .edges
        .iter()
        .map(|e| GraphEdge {
            from: e.src.clone(),
            to: e.dst.clone(),
            color: Color::rgb(200, 200, 220),
            dashed: e.dashed,
            label: e.label.clone(),
        })
        .collect();
    Some(Box::new(
        facett_graphview::decorated_view::DecoratedGraphView::new(p.title)
            .with_model(GraphModel { nodes, edges })
            // The GPU cloud lane; measured `legibility.gpu_cloud: false` without it.
            .with_gpu(true),
    ))
}

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

    fn payload() -> Value {
        json!({
            "title": "cases",
            "nodes": [
                { "id": 0, "label": "Company", "key": "Company/5560160680" },
                { "id": 1, "label": "Vessel",  "key": "Vessel/265547890" },
                { "id": 2, "label": "Vessel",  "key": "Vessel/211223334" }
            ],
            "edges": [
                { "src": 0, "dst": 1, "rel": "OWNS" },
                { "src": 0, "dst": 2, "rel": "OWNS" }
            ]
        })
    }

    #[test]
    fn the_one_contract_parses_wire_shaped_rows() {
        let p = GraphPayload::parse(&payload()).expect("parses");
        assert_eq!(p.title, "cases");
        assert_eq!(p.nodes.len(), 3);
        assert_eq!(p.edges.len(), 2);
        assert_eq!(p.nodes[0].id, "0", "integer ids normalise to strings");
        assert_eq!(p.nodes[0].caption(), "Company/5560160680");
        assert_eq!(p.nodes[0].group_key(), "Company");
        assert_eq!(p.index_edges(), vec![(0, 1), (0, 2)]);
    }

    /// THE point of the contract: ONE document drives ALL FIVE components.
    #[test]
    fn every_graph_component_accepts_the_same_payload() {
        let v = payload();
        for (name, built) in [
            ("graphnav", graphnav(&v)),
            ("graph2d", graph2d(&v)),
            ("graph3d", graph3d(&v)),
            ("graphpan", graphpan(&v)),
            ("graphview", graphview(&v)),
        ] {
            let f = built.unwrap_or_else(|| panic!("{name} builds from the shared payload"));
            assert!(!f.title().is_empty(), "{name} has a title");
            let st = f.state_json();
            assert!(st.is_object() || st.is_array(), "{name} yields structured state");
        }
    }

    #[test]
    fn from_to_and_etype_are_accepted_as_aliases() {
        let v = json!({
            "nodes": [{ "id": "a", "label": "A" }, { "id": "b", "label": "B" }],
            "edges": [{ "from": "a", "to": "b", "etype": "LINKS" }]
        });
        let p = GraphPayload::parse(&v).expect("parses");
        assert_eq!(p.edges[0].rel, "LINKS");
    }

    #[test]
    fn dangling_edges_are_dropped_not_rendered_broken() {
        let v = json!({
            "nodes": [{ "id": "a", "label": "A" }],
            "edges": [{ "src": "a", "dst": "ghost", "rel": "X" }]
        });
        assert!(GraphPayload::parse(&v).unwrap().edges.is_empty());
    }

    #[test]
    fn empty_payloads_yield_none_for_every_component() {
        assert!(GraphPayload::parse(&json!({})).is_none());
        assert!(GraphPayload::parse(&json!({ "nodes": [] })).is_none());
        assert!(GraphPayload::parse(&json!({ "nodes": [{ "label": "no id" }] })).is_none());
        for f in [graphnav, graph2d, graph3d, graphpan, graphview] {
            assert!(f(&json!({})).is_none(), "no fixture substituted for an empty payload");
        }
    }

    /// Colour is facett's decision and must be STABLE across components and runs.
    #[test]
    fn group_colour_is_deterministic_and_shared() {
        assert_eq!(group_color("Person"), group_color("Person"));
        assert_ne!(group_color("Person"), group_color("Vessel"));
        let v = json!({ "nodes": [{ "id": 1, "label": "Person", "group": "actors" }] });
        assert_eq!(GraphPayload::parse(&v).unwrap().nodes[0].group_key(), "actors");
    }

    #[test]
    fn caller_positions_win_and_missing_ones_get_a_deterministic_layout() {
        let v = json!({
            "nodes": [{ "id": "a", "label": "A", "pos": [10.0, 20.0] },
                      { "id": "b", "label": "B", "pos": [30.0, 40.0, 5.0] }]
        });
        let p = GraphPayload::parse(&v).unwrap();
        assert_eq!(p.positions()[0], [10.0, 20.0, 0.0]);
        assert_eq!(p.positions()[1], [30.0, 40.0, 5.0]);

        let a = layout_positions(25);
        assert_eq!(a, layout_positions(25), "layout is deterministic");
        for i in 1..a.len() {
            assert!(a[i] != a[i - 1], "no two nodes share a spot");
        }
    }

    #[test]
    fn node_props_reach_the_property_graph() {
        let v = json!({
            "nodes": [{ "id": 1, "label": "Person", "props": { "kommun": "Uppsala" } }]
        });
        let p = GraphPayload::parse(&v).unwrap();
        assert_eq!(p.nodes[0].props, vec![("kommun".to_string(), "Uppsala".to_string())]);
        // The inspector only materialises a node's props once it is selected, so the
        // observable proof here is that the labelled node reached the component.
        let f = graphnav(&v).expect("builds");
        let st = f.state_json();
        assert_eq!(st["schema"]["node_labels"]["Person"], serde_json::json!(1), "{st}");
        assert_eq!(st["canvas"]["total_nodes"], serde_json::json!(1));
    }
}