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 ingraph-design.md; the why ingraph-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:
- Edge bundling:
fdeb::bundle(&node_positions, &edge_pairs, &BundleParams::default())→Vec<BundledEdge>, each carrying apath: Vec<[f32;2]>polyline to paint instead of a straight line. - Community/semantic zoom:
community::louvain(n, &edges)→Communities, thencommunity::meta_graph(&positions, &edges, &comms)→ oneMetaNodeper cluster;community::fracture_t(zoom, collapse_below, span, ease_fn)drives the open/closed interpolation for the current zoom. - GPU-parallel force layout:
gpu_layout::GraphCsr::from_edges(n, &edges, None)+gpu_layout::relax_cpu(&csr, ¶ms, iters)(CPU) or theGpuLayoutwgpu compute path (featuregpu). - The L0 kernel lane:
l0::render_to_rgba_l0(backend, &model, &decorations, &cam, w, h, bg)— same call shape asrender_to_rgba, different (SDF) geometry; use it to prove a graph routes through the shared coreCanvas, not for pixel parity with the reference path.
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:
- It is retained. Select another layout and select
Givenback and you get your placement, not the sphere it was drawn with in between. Useset_positions(..)to update it live (it re-lays out immediately whenGivenis the active layout). - Cover every node. A placement shorter than the node list is not drawn: the pane falls back to
Layout3D::Gridand 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 — becauseGraph3D::newlays out once beforewith_positionscan 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. - 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, andstate_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.