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