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

nordisk/facett

.nornir/graph-guide.md

The graph subsystem — guide

Class: guide · audience: a techie wiring one of the five graph surfaces into a host, or driving one headless. The what is in graph-design.md; the why in graph-reasoning.md. This file is the workflow.

f0 — picking a surface, end to end

Five crates, one question first: what does the host actually need?

you have… reach for crate
a query result / dataflow edge list, static scene_from_labeled_edges → GraphView facett-graph
a dependency DAG with a deps/dependents drill-down DepGraphView facett-graph
a big graph that must scale (LOD/bundling/semantic zoom) or needs a CPU/GPU pixel readback GraphModel → decide → render_to_rgba / DecoratedGraphView facett-graphview
a static wiring board or a live pan/drag canvas, no rasterizer dependency GraphModel → draw_graph / GraphPanView facett-graphpan
a 3D node cloud (sphere/helix/force, or a call-graph depth layout) Graph3D facett-graph3d
3-10 peer services with badges + a click-through detail panel SystemChart facett-syschart

Once picked, the workflow is the same shape everywhere (the Elm loop from elm-guide.md f0): build the model → mount as a Facet (or call the free paint function) → drive it with Msgs in a test.

f1 — GraphView / DepGraphView (facett-graph)

use facett_graph::{scene_from_labeled_edges, GraphView, DepGraphView, DepNode, DepEdge};

// A query result: (src_id, dst_id, src_label, dst_label) rows.
let scene = scene_from_labeled_edges(rows);
let view = GraphView::new(scene).with_title("query result");
// view.show(ui) inside a Facet host; or drive headless:
use facett_core::harness;
let mut v = view;
let snap = harness::snapshot(&mut v, [facett_graph::Msg::SetLayout(Default::default())]);
assert_eq!(snap.layout, facett_graph::GraphLayout::Circular);

// A dependency DAG with a drill-down:
let nodes = vec![DepNode { label: "a".into(), sublabel: None, color: egui::Color32::GRAY }];
let edges: Vec<DepEdge> = vec![];
let mut dg = DepGraphView::new(nodes, edges).with_title("deps");
dg.select(Some("a".into()));               // deterministic set, not a toggle
assert_eq!(dg.selected(), Some("a"));

DepGraphView::columns() is pure and callable without painting — use it to assert layering in a test before ever touching egui.

f2 — the graphview engine (facett-graphview)

The render-to-pixels path, for a host that wants raw RGBA (a PNG export, an offscreen thumbnail) or the full interactive DecoratedGraphView pane:

use facett_graphview::{GraphModel, GraphNode, GraphEdge, Decorations, Camera, Color,
                        Pos, decide, GpuProbe, render_to_rgba};

let model = GraphModel {
    nodes: vec![GraphNode { id: "a".into(), label: "alpha".into(),
                             fill: Color::rgb(60,90,160), stroke: Color::WHITE, pos: Pos::new(0.0,0.0) }],
    edges: vec![],
};
let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
let backend = decide(GpuProbe::from_env(/* usable_gpu: */ false));
let frame = render_to_rgba(backend, &model, &Decorations::default(), &cam, 640, 480, Color::rgb(18,20,28));
// frame.rgba: Vec<u8>, straight RGBA8, 640*480*4 bytes — write it out, or hand to egui.

For the interactive pane, mount DecoratedGraphView in a FacetDeck (or drive it headless):

use facett_graphview::DecoratedGraphView;
use facett_core::harness;

let mut pane = DecoratedGraphView::local(); // discovery-contract sample data
let snap = harness::snapshot(&mut pane, [
    facett_graphview::decorated_view::Msg::Select(Some("a".into())),
    facett_graphview::decorated_view::Msg::ZoomBy(500.0),
]);
assert_eq!(snap.selected.as_deref(), Some("a"));

Scaling a big graph — the engine ships four independent, composable primitives; none of them are wired into render_to_rgba automatically, the host calls them explicitly:

GPU feature. cargo build -p facett-graphview --features gpu lights up vello/wgpu/pollster/bytemuck and facett-core/wgpu. Without it, decide always resolves to Backend::VelloCpu regardless of what the probe reports.

f3 — graphpan (facett-graphpan)

The bare function, when you don't want the render-engine's vello/wgpu dependency tree — a host that already paints its own boxes and just wants pan/zoom/select:

use facett_graphpan::{GraphModel, GraphView, Decorations, draw_graph};

let mut view = GraphView::default();  // persist this across frames (host-owned)
let model = GraphModel { nodes: vec![], edges: vec![] };
// inside your egui update loop:
let resp = draw_graph(ui, &model, &Decorations::default(), &mut view,
                       th.bg, th.text, th.accent, th.text_dim);
if let Some(clicked) = resp.clicked_node {
    // run your own side effect — click-to-code, scope a release, …
}

For a static board where the scrollbar must reflect the real content size, use content_extent(&model) to size the ScrollArea and draw_graph_scrolled instead of draw_graph.

To mount graphpan as a deck tab instead, use the owning GraphPanView — same Msg-driven headless pattern as f2's DecoratedGraphView:

use facett_graphpan::{GraphPanView, Msg};
let mut pane = GraphPanView::new("board").with_model(model);
let _ = pane.update(Msg::Select("a".into()));
assert_eq!(pane.state().selected.as_deref(), Some("a"));

f4 — graph3d (facett-graph3d)

use facett_graph3d::{Graph3D, Layout3D, Msg};

let nodes = vec![("alpha".to_string(), egui::Color32::WHITE), ("beta".to_string(), egui::Color32::GRAY)];
let edges = vec![(0, 1)];
let mut g = Graph3D::new(nodes, edges, Layout3D::Sphere).with_title("callgraph");

// Deterministic tests: pin the clock so the spin is a pure function of state.
g.set_clock(1.5);
let _ = g.update(Msg::SelectNode(0));
assert_eq!(g.state().selected, Some(0));

// Switch to a structure-aware depth layout (7 variants) instead of Layout3D:
use facett_graph3d::CallGraphLayout;
let positions = CallGraphLayout::ClusterZ.layout(nodes_len, &undirected_edges);

Placing the cloud YOURSELF — Layout3D::Given

Every other Layout3D derives a position from the node index or the edge set. When your nodes have a real place (a projected lon/lat) and a real measurement that earns the vertical axis (a speed, a rank, a score), hand them over instead — the crate owns no projection, you do:

use facett_graph3d::{Graph3D, Layout3D};

// One [x, y, z] per node, in NODE ORDER, in the normalised ~[-1, 1] cube.
let pos: Vec<[f32; 3]> = sensors
    .iter()
    .map(|s| {
        let (x, y) = project(s.lon, s.lat);      // YOUR projection
        [x, y, s.speed_kmh / 120.0 * 1.8 - 0.9]  // z = the DATA
    })
    .collect();

let g = Graph3D::new(nodes, edges, Layout3D::Given)
    .with_title("sensors")
    .with_positions(pos);

assert!(g.placement_is_complete());
assert_eq!(g.state_json()["layout"], "given");
assert_eq!(g.state_json()["given_positions"], g.state_json()["nodes"]);
assert!(g.state_json()["layout_fallback"].is_null()); // nothing degraded

Three rules to know:

  1. It is retained. Select another layout and select Given back and you get your placement, not the sphere it was drawn with in between. Use set_positions(..) to update it live (it re-lays out immediately when Given is the active layout).
  2. Cover every node. A placement shorter than the node list is not drawn: the pane falls back to Layout3D::Grid and says so — layout_fallback: "grid", error_code: "facet-graph3d-5", severity() == Severity::Warning. It degrades loudly rather than parking the unplaced tail at the origin, where it would read as data. (Longer than the node list is fine — the tail is ignored.) The fallback is the O(n) grid — not the force layout — because Graph3D::new lays out once before with_positions can supply anything (a force pass would relax the whole cloud and throw it away), and because a lattice reads as "nobody placed this" while a force blob reads as a graph.
  3. Read the two new keys, not the layout name. state_json()["layout"] is what you ASKED for; state_json()["layout_fallback"] is what is on screen, and state_json()["given_positions"] is how many positions you actually supplied. Assert on those in tests — a cloud that merely "has nodes" would pass with the placement thrown away.

The live example is facett-demo's graph3d-given tab (facett-demo/src/demo_graph3d_given.rs): traffic sensors over a synthetic road grid, x/y from a projected lon/lat, z from the measured speed — three road classes become three terraces and a congestion basin makes every ribbon dip.

Graph3D::with_decor(DecorPolicy::Full) forces the neon mode regardless of the active theme — useful in a snapshot test that wants to assert glow/bloom without depending on which theme is installed. Default (FromTheme) tracks the host's LookTheme::EffectsPolicy, so a device/airgap console renders flat automatically.

The GPU bloom lane is headless-only today:

#[cfg(feature = "wgpu")]
{
    use facett_graph3d::graph_gpu::{render_graph_offscreen, GraphScene, BloomParams};
    let render = render_graph_offscreen(1280, 720, &scene, BloomParams::default());
    // render.mean_luma(), render.luma_at(x, y) — read the framebuffer back as DATA.
}

f5 — syschart (facett-syschart)

use facett_syschart::{SysNode, SysEdge, SystemChart};
use egui::Color32;

let nodes = vec![
    SysNode::new("pki",  "PKI",  Color32::from_rgb(120,200,255), (0.12, 0.15)).badge(3),
    SysNode::new("oidc", "OIDC", Color32::from_rgb(200,160,255), (0.88, 0.15)).badge(7),
    SysNode::new("nexus","Nexus",Color32::from_rgb(160,255,180), (0.5, 0.9)),
];
let edges = vec![SysEdge::new("pki","oidc"), SysEdge::new("pki","nexus"), SysEdge::new("oidc","nexus")];
let mut chart = SystemChart::new("System Map", nodes, edges).with_canvas_height(320.0);

// live host updates, e.g. once per event:
chart.set_badge("pki", 12);
chart.set_detail("pki", "12:00:01 → issue cert\n12:00:02 ← list issued");

// headless drive + assert:
chart.select("oidc");
assert_eq!(chart.selected(), Some("oidc"));

For richer per-node detail than a monospace string, leave detail empty and draw your own widgets after chart.view(ui) (or the Facet::ui call), keyed off chart.selected() — exactly what holger's mannequin does (a facett Table of the selected system's event log, plus a Regenerate button).

The headless seam, once, everywhere

Every surface above is driven the same way in a test — no window, no GPU:

use facett_core::harness;
let snap = harness::snapshot(&mut view, [Msg::Foo, Msg::Bar]);   // drive + return the Model
let json = harness::snapshot_json(&mut view, [Msg::Foo]);        // Model as json (golden-file)
assert!(harness::headless_render(&mut view).drew());             // it actually painted

This is the same f3 seam documented in elm-guide.md — nornir's test-matrix and robot-UI drivers consume it at scale across all five graph crates identically.