facett-demo/src/vg_lab_tab.rs
//! **The `Graphs` tab** (formerly `V.G`) — the GRAPH NAVIGATOR ([`facett_graphnav`])
//! mounted as a first-class demo surface, driven by the demo's own Swedish population
//! graph generator (`popgraph::population_scene`), plus a **3-D rotating node cloud**
//! ([`facett_graph3d::Graph3D`], orbit/spin) over the SAME graph. It makes every
//! graph-navigator capability reachable and demoable in one place:
//!
//! - **navigation** — the facett-graphpan pan/zoom camera (drag / scroll / fit / focus);
//! - **appearance** — size + colour nodes by any ranking metric or partition;
//! - **table** — the Arrow node table through [`facett_grid::Grid`] with row⇄canvas
//! selection sync;
//! - **export** — GEXF / GraphML (+ JSON / DOT / CSV);
//! - **stats** — the whole-graph scalar panel.
//!
//! A `canvas ⇄ table ⇄ 3D` toggle switches the lower panel; everything else is the
//! navigator's own [`Facet`] UI. State (nav + view mode + grid selection + the 3-D
//! cloud's spin/yaw/layout) rides in `state_json` so the robot deck drives it headlessly.
use facett::Facet;
use facett::core::FacetCaps;
// The reusable CPU-painted 3-D node cloud (orbit/spin), via the umbrella re-export
// (`facett::graph3d`) — the same path lib.rs uses for the standalone `graph3d` tab.
use facett::graph3d::{Graph3D, Layout3D};
use facett_core::Scene;
use facett_graphnav::{GraphNav, Msg};
use facett_graphnav::model::{Edge, Node, PropGraph};
use facett_grid::Grid;
use crate::popgraph;
/// The stable tab title (the deck/discovery-contract key + the legend key).
pub const TITLE: &str = "Graphs";
/// The largest graph the Arrow node table is built for (the navigator's own scale line).
pub const TABLE_MAX_NODES: usize = facett_graphnav::SCALE_NODES;
/// Which sub-view the tab shows below the toolbar: the 2-D navigator canvas, the
/// Arrow node table, or the 3-D rotating node cloud.
#[derive(Clone, Copy, PartialEq, Eq)]
enum VgView {
Canvas,
Table,
Graph3D,
}
/// The V.G navigator tab: a real [`GraphNav`] over the population graph, plus the
/// Arrow node table and a canvas/table toggle.
pub struct VgLabTab {
title: &'static str,
nav: GraphNav,
/// The Arrow node table. `None` above [`TABLE_MAX_NODES`]: its betweenness column is
/// an exact O(V·E) CPU pass (Brandes), which at 100 000 nodes blocks the page for
/// hours (measured 2026-10-01: the generated 100k `Graphs` tab never drew).
node_grid: Option<Grid>,
/// The same population graph rendered as a 3-D rotating node cloud (orbit/spin).
graph3d: Graph3D,
/// Which sub-view is active below the toolbar (canvas / table / 3-D).
view: VgView,
/// The node-table row selected last frame (to drive grid → canvas sync once).
last_row: Option<usize>,
}
impl VgLabTab {
/// Build the tab over a deterministic sample of the demo's population graph.
pub fn new(title: &'static str) -> Self {
// One scene drives BOTH views, so the 3-D cloud shows the exact same
// nodes/edges as the 2-D navigator + Arrow table. A RICHER, LONGER sample
// (#82: "make the graphs more interesting, longer") — many more people across
// more kommuner (communities), so the auto-demo's 5 s dwell has a dense,
// colourful social/financial web to spin through instead of a sparse handful.
let scene = popgraph::population_scene(V_G_PERSONS, V_G_KOMMUNER, V_G_SEED);
let pg = propgraph_from_scene(&scene);
// GIVE IT A PLACE, so the `geographic` layout chip is real here and not only in
// korp's road slices. See `swedish_places`.
let places = swedish_places(pg.nodes.len());
let mut nav = GraphNav::new(title, pg).with_places(places);
// The GPU cloud lane the demo never turned on (2026-10-01); self-disables with no
// installed renderer.
nav.legibility.gpu_cloud = true;
let node_grid = Some(Grid::from_record_batch("nodes", &nav.node_batch()).pin_columns(1));
let (g3_nodes, g3_edges) = graph3d_from_scene(&scene);
// Auto-spin the cloud livelier (a fuller turn per dwell) so the motion reads as
// ALIVE during the attract-mode dwell — reuses Graph3D's existing spin control.
let mut graph3d = Graph3D::new(g3_nodes, g3_edges, Layout3D::Force).with_title("Graphs · 3D");
graph3d.set_spin_rate(0.6);
// N3: the 3-D rotating cloud is the DEFAULT view (spins on open); the 2-D
// navigator and table remain one click away in the toggle row.
Self { title, nav, node_grid, graph3d, view: VgView::Graph3D, last_row: None }
}
/// The same tab over a GENERATED graph (the size + generator control,
/// [`crate::graphgen`]): the navigator over its property graph with its placement as
/// the `geographic` places, and the 3-D cloud over the same nodes. Above 10 000 nodes
/// the cloud takes the generator's placement (`Layout3D::Given`) instead of a force
/// pass; the navigator lays out on its own lanes (GPU above `SCALE_NODES` with a
/// device).
pub fn from_generated(title: &'static str, gg: &crate::graphgen::GenGraph) -> Self {
// The navigator as facet-wrapped's `graphnav` builds it from the same request
// (`facett_graphgen::build::graphnav`: places + the instanced GPU cloud, resident
// above `SCALE_NODES`; without an installed renderer it reports
// `gpu_cloud: false` and the painter it had draws).
let nav = facett_graphgen::build::graphnav(title, gg);
let node_grid = (gg.g.n <= TABLE_MAX_NODES)
.then(|| Grid::from_record_batch("nodes", &nav.node_batch()).pin_columns(1));
let (nodes, edges, pos) = crate::graphgen::to_graph3d(gg);
let big = nodes.len() > 10_000;
let graph3d = if big {
Graph3D::new(nodes, edges, Layout3D::Given).with_positions(pos)
} else {
Graph3D::new(nodes, edges, Layout3D::Force)
}
.with_title("Graphs · 3D");
#[cfg(feature = "gpu")]
let graph3d = graph3d.with_gpu(true);
let mut graph3d = graph3d;
graph3d.set_spin_rate(0.6);
// A generated graph opens on the 2-D navigator: that is the lane the size
// selector is about (GPU cloud + GPU layout at scale).
Self { title, nav, node_grid, graph3d, view: VgView::Canvas, last_row: None }
}
/// Node / edge counts of the driven graph (tests + observability).
pub fn node_count(&self) -> usize {
self.nav.visible_count()
}
/// Apply a navigator [`Msg`] (the robot drive path for the canvas capabilities).
pub fn nav_update(&mut self, msg: Msg) -> bool {
self.nav.update(msg)
}
/// Toggle the table panel (the robot drive path for "open the table panel"). `on`
/// selects the table; `off` returns to the 2-D navigator canvas.
pub fn set_show_table(&mut self, on: bool) {
self.view = if on { VgView::Table } else { VgView::Canvas };
}
/// Toggle the 3-D rotating node cloud (the robot drive path for "open the 3D view").
/// `on` selects the 3-D view; `off` returns to the 2-D navigator canvas.
pub fn set_show_3d(&mut self, on: bool) {
self.view = if on { VgView::Graph3D } else { VgView::Canvas };
}
/// The 3-D node cloud (for spin-state assertions + programmatic drive).
pub fn graph3d(&self) -> &Graph3D {
&self.graph3d
}
/// Mutable access to the 3-D node cloud (drive camera/layout via its `Msg`s).
pub fn graph3d_mut(&mut self) -> &mut Graph3D {
&mut self.graph3d
}
/// Select node-table row `row` (grid → canvas sync), if in range.
pub fn select_row(&mut self, row: usize) -> bool {
match self.nav.select_node_row(row) {
Some(msg) => {
self.last_row = Some(row);
self.nav.update(msg)
}
None => false,
}
}
/// The navigator (for `export` + deeper assertions).
pub fn nav(&self) -> &GraphNav {
&self.nav
}
/// The merged observable state: the navigator's full `state_json` plus the tab's
/// own view mode + grid dimensions.
pub fn state(&self) -> serde_json::Value {
let mut j = self.nav.state_json();
if let Some(obj) = j.as_object_mut() {
obj.insert("vglab".into(), self.view_json());
}
j
}
/// The tab's own part of the state (view mode, node table, the 3-D cloud) — the
/// `vglab` object, without the navigator's per-node state.
pub fn view_json(&self) -> serde_json::Value {
serde_json::json!({
"show_table": self.view == VgView::Table,
"show_3d": self.view == VgView::Graph3D,
"node_grid_rows": self.node_grid.as_ref().map_or(0, |g| g.data().nrows()),
"node_grid_cols": self.node_grid.as_ref().map_or(0, |g| g.data().ncols()),
"node_table": self.node_grid.is_some(),
"table_max_nodes": TABLE_MAX_NODES,
"selected_row": self.nav.selected_node_row(),
// The 3-D view's presence + live spin state (yaw/spin/layout/nodes),
// so the robot deck can observe the rotating cloud headlessly.
"graph3d": self.graph3d.state_json(),
})
}
}
impl Facet for VgLabTab {
fn title(&self) -> &str {
self.title
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().selectable().resizable()
}
fn kind(&self) -> &'static str {
"vglab"
}
fn ui(&mut self, ui: &mut egui::Ui) {
// View toggle: the 2-D navigator canvas, the Arrow table, or the 3-D cloud.
ui.horizontal(|ui| {
if ui.selectable_label(self.view == VgView::Canvas, "🖈 canvas").clicked() {
self.view = VgView::Canvas;
}
if ui.selectable_label(self.view == VgView::Table, "▦ table").clicked() {
self.view = VgView::Table;
}
if ui.selectable_label(self.view == VgView::Graph3D, "🌐 3D").clicked() {
self.view = VgView::Graph3D;
}
});
ui.separator();
match self.view {
VgView::Table => {
// The data-laboratory node table; a row click syncs the canvas selection.
let Some(grid) = self.node_grid.as_mut() else {
ui.label(format!(
"node table off above {TABLE_MAX_NODES} nodes — its betweenness column is an exact O(V·E) CPU pass; the canvas and the 3-D cloud carry this graph"
));
return;
};
grid.ui(ui);
if let Some((row, _col)) = grid.selection().anchor && self.last_row != Some(row) {
if let Some(msg) = self.nav.select_node_row(row) {
self.nav.update(msg);
}
self.last_row = Some(row);
}
}
VgView::Graph3D => {
// The same graph as a rotating, depth-shaded 3-D node cloud (orbit/spin).
self.graph3d.ui(ui);
}
VgView::Canvas => {
// The full navigator canvas + its toolbars (camera / appearance / stats / export).
self.nav.ui(ui);
}
}
}
fn selection_json(&self) -> serde_json::Value {
self.nav.selection_json()
}
fn state_json(&self) -> serde_json::Value {
self.state()
}
}
/// A seed constant spelled so the intent is obvious in the source.
#[allow(non_upper_case_globals)]
const V_G_SEED: u64 = 0x7654_3210;
/// The sampled population size + community count for the `Graphs` tab (#82). A richer,
/// denser web than the original `28 / 4`: more people across more kommuner so the
/// auto-demo dwell shows a full, colour-coded social/financial graph. Kept modest
/// enough that the CPU-painted 3-D cloud stays smooth in the wasm web demo.
const V_G_PERSONS: usize = 64;
/// The number of synthetic municipalities (community colours) the sample spans.
const V_G_KOMMUNER: usize = 8;
/// **A place for every node in the population graph**, so the navigator's `geographic`
/// layout chip has something to draw and the demo shows the layout instead of only
/// describing it.
///
/// The coordinates are REAL — twelve Swedish population centres — and the assignment is
/// not: the demo's people have no addresses, so node `i` is placed at city `i % 12` with
/// a deterministic sub-degree jitter. Said plainly rather than dressed up: what the card
/// demonstrates is the LAYOUT (one uniform scale, north up, distances proportional), and
/// for that the point set has to be a real geography, which Sweden's cities are.
///
/// The HOST projects — this is the whole seam. `facett-graphnav` never sees a degree:
/// [`facett_map::mercator`] is the constellation's one Web Mercator (korp's own
/// `mapquery::merc_xy` agrees with it to 0.000000 m over 6 502 real junctions), and the
/// navigator is handed the plane.
fn swedish_places(n: usize) -> Vec<facett_graphnav::layout::Place> {
/// (lon, lat) of twelve places, north to south.
const CITIES: [(f64, f64); 12] = [
(20.2253, 67.8558), // Kiruna
(22.1567, 65.5842), // Luleå
(20.2630, 63.8258), // Umeå
(17.3069, 62.3908), // Sundsvall
(17.6389, 59.8586), // Uppsala
(18.0686, 59.3293), // Stockholm
(15.2066, 59.2741), // Örebro
(18.2948, 57.6349), // Visby
(15.6214, 58.4109), // Linköping
(11.9746, 57.7089), // Göteborg
(14.1618, 57.7826), // Jönköping
(13.0038, 55.6050), // Malmö
];
(0..n)
.map(|i| {
let (lon, lat) = CITIES[i % CITIES.len()];
// A deterministic spread inside the city so 3 000 nodes are not 12 dots.
let k = (i / CITIES.len()) as f64;
let (dx, dy) = ((k * 0.7548776662).fract() - 0.5, (k * 0.5698402909).fract() - 0.5);
let (x, y) = facett_map::mercator(lon + dx * 0.55, lat + dy * 0.28);
Some([x, y])
})
.collect()
}
/// Map the demo's population [`Scene`](facett_core::Scene) into the navigator's
/// [`PropGraph`] model: each scene node becomes a labelled node (its label is the
/// entity type, e.g. `Person` / `Debt`), each scene edge a typed relationship. This
/// reuses the demo's real Swedish-population generator so the viewer runs on the
/// same data as the `Sverige` / `graph3d` tabs.
fn propgraph_from_scene(scene: &Scene) -> PropGraph {
let mut g = PropGraph::new();
for (i, node) in scene.nodes.iter().enumerate() {
g.add_node(
Node::new(format!("n{i}"), node.label.clone()).with("name", format!("{} {i}", node.label)),
);
}
for e in &scene.edges {
if e.src < scene.nodes.len() && e.dst < scene.nodes.len() {
let etype = format!("{}_{}", scene.nodes[e.src].label, scene.nodes[e.dst].label).to_uppercase();
g.add_edge(Edge::new(format!("n{}", e.src), format!("n{}", e.dst), etype));
}
}
g
}
/// Map the SAME population [`Scene`](facett_core::Scene) into the `(label, color)`
/// nodes + index edges [`Graph3D`] renders, so the 3-D cloud is the identical graph
/// as the 2-D navigator (each node keeps its label + category/community colour).
fn graph3d_from_scene(scene: &Scene) -> crate::pop_model::Graph3dNodesEdges {
let nodes = scene.nodes.iter().map(|n| (n.label.clone(), n.color)).collect();
let edges = scene
.edges
.iter()
.filter(|e| e.src < scene.nodes.len() && e.dst < scene.nodes.len())
.map(|e| (e.src, e.dst))
.collect();
(nodes, edges)
}