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

nordisk/facett

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