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

nordisk/nornir

src/viz/graph_render.rs

//! Shared pan/zoom **dependency-graph renderer** — the single egui draw routine
//! behind BOTH the 🏛 Architecture wiring board (`arch_tab`) and the 🧬 nornir
//! RELEASE DASHBOARD (`nornir_tab`).
//!
//! # This module is a THIN RE-EXPORT of `facett-graphpan` (L5)
//! The canvas itself no longer lives here. It was extracted from
//! `arch_tab::draw_board` into this module, and facett then **adopted it
//! verbatim** as the `facett-graphpan` crate ("Adopted from nornir's viz
//! `graph_render`" — its own crate doc). Keeping a second copy here was exactly
//! the twin L5 forbids: the same `draw_graph` / `draw_graph_scrolled` /
//! `content_extent` / `downstream_of` bodies in two repos, drifting apart the
//! moment either side is touched. facett is the egui component framework and
//! OWNS the canvas; nornir CONSUMES it.
//!
//! The module path stays (`viz::graph_render::draw_graph`, …) so every call site
//! — `arch_tab`, `nornir_dashboard` — is unchanged; only the implementation moved.
//! Consuming the leaf also picks up work nornir never had: `GraphView::project` /
//! `GraphView::state_json` (headless assertions of the exact pan/zoom mapping),
//! the `GraphPanView` Facet, and facett's opt-in legibility/GPU-cloud lane (all
//! default OFF, so `draw_graph` paints byte-for-byte what it painted here).
//!
//! # Domain-agnostic by design
//! `facett-graphpan` knows NOTHING about architecture node-kinds or release
//! gates. The caller hands it:
//!   * a [`GraphModel`] — nodes (id + label + fill/stroke colour) + edges
//!     (from/to + colour + dashed flag + optional mid-label), already laid out in
//!     world space (caller owns the layout so each domain keeps its own columns);
//!   * a [`Decorations`] overlay — **caller-supplied**, domain-agnostic: a per-node
//!     status ring colour + an optional corner badge (glyph/text + colour), and a
//!     set of edges to draw dashed-and-labelled (e.g. a cycle's suggested cut).
//!
//! The 🏛 Architecture tab calls [`draw_graph`] with [`Decorations::default()`]
//! (no overlay — its own coverage rings ride in the model's node ring colour);
//! the 🧬 dashboard calls the SAME function passing gate markers. No copy-pasted
//! canvas/pan-zoom code, and facett needn't learn what a "release gate" is.
//!
//! **egui version note (the old vendoring reason, now void):** this module used
//! to carry its own copy because nornir's viz was on `eframe::egui` 0.33 while
//! facett was on egui 0.34. Both are on **egui 0.34.3** now — `eframe::egui` IS
//! the `egui` crate facett paints with — so the types unify and the copy is gone.

pub use facett_graphpan::{
    BOX_H, BOX_W, Decorations, GraphEdge, GraphModel, GraphNode, GraphResponse, GraphView,
    NodeDecoration, content_extent, downstream_of, draw_graph, draw_graph_scrolled,
};

#[cfg(test)]
mod tests {
    //! The canvas's OWN behaviour (BFS closure, extent, fit/clear) is tested in
    //! `facett-graphpan` — the leaf that owns it. What nornir must prove is the
    //! **seam**: that `viz::graph_render` really resolves to facett's canvas and
    //! that the surface nornir's two boards call is present and behaves. A
    //! RED-when-broken assertion here fires the moment the re-export is dropped,
    //! renamed, or silently re-forked into a local copy.

    use super::*;
    use eframe::egui::{Color32, Pos2, Vec2};

    fn node(id: &str, x: f32) -> GraphNode {
        GraphNode {
            id: id.into(),
            label: id.into(),
            fill: Color32::GRAY,
            stroke: Color32::WHITE,
            pos: Pos2::new(x, 0.0),
        }
    }
    fn edge(from: &str, to: &str) -> GraphEdge {
        GraphEdge {
            from: from.into(),
            to: to.into(),
            color: Color32::WHITE,
            dashed: false,
            label: None,
        }
    }

    /// The seam itself: nornir's `graph_render::GraphModel` and facett's are the
    /// SAME type (not two structurally-identical twins), and the re-exported
    /// entry points are facett's. If someone re-forks a local copy this stops
    /// compiling / stops being the same type.
    #[test]
    fn graph_render_is_facett_graphpan_not_a_local_copy() {
        let m: GraphModel = GraphModel {
            nodes: vec![node("A", 0.0), node("B", 100.0)],
            edges: vec![edge("A", "B")],
        };
        // Same-type proof: hand nornir's alias straight to facett's function.
        let facett_side: &facett_graphpan::GraphModel = &m;
        assert_eq!(
            facett_graphpan::content_extent(facett_side),
            content_extent(&m),
            "viz::graph_render::content_extent must BE facett_graphpan::content_extent"
        );
        // And the egui types unify: `eframe::egui::Vec2` is what facett returns.
        let _: Vec2 = content_extent(&m);
    }

    /// The two surfaces nornir's boards depend on, asserted through the nornir
    /// path so a facett API change that breaks `arch_tab`/`nornir_dashboard` is
    /// caught by nornir's own suite, not only by facett's.
    #[test]
    fn consumed_surface_covers_what_the_arch_board_and_dashboard_call() {
        let m = GraphModel {
            nodes: vec![node("A", 0.0), node("B", 100.0), node("C", 200.0)],
            edges: vec![edge("A", "B"), edge("B", "C"), edge("A", "C")],
        };
        // `nornir_dashboard` lights the downstream subtree of the selection.
        let lit = downstream_of(&m, "B");
        assert!(lit.contains("B") && lit.contains("C"));
        assert!(
            !lit.contains("A"),
            "BFS is forward-only — A is not downstream of B"
        );

        // `arch_tab` sizes the wiring board's ScrollArea from the TRUE extent.
        // (task #23 root cause: the canvas was allocated at `available_size`, so
        // the scrollbar reported the viewport instead of the overflow — the
        // extent MUST grow with the laid-out graph, never with the viewport.)
        let narrow = GraphModel {
            nodes: vec![node("A", 0.0), node("B", 300.0)],
            edges: vec![],
        };
        let wide = GraphModel {
            nodes: vec![node("A", 0.0), node("B", 900.0)],
            edges: vec![],
        };
        assert!(
            content_extent(&wide).x > content_extent(&narrow).x,
            "a wider graph must report a wider extent (else the scrollbar lies)"
        );
        assert!(content_extent(&m).x > BOX_W, "extent exceeds one chip");
        assert!(content_extent(&m).y > BOX_H, "extent exceeds one chip row");

        // The transform both boards own across frames.
        let mut v = GraphView {
            pan: Vec2::new(5.0, 5.0),
            zoom: 2.0,
            selected: Some("A".into()),
            dot_px: None,
        };
        v.fit();
        assert_eq!(v.pan, Vec2::ZERO);
        assert_eq!(v.zoom, 1.0);
        assert_eq!(
            v.selected.as_deref(),
            Some("A"),
            "fit resets the view, not the selection"
        );
        v.clear_selection();
        assert!(v.selected.is_none());

        // A default overlay is what the 🏛 board passes; the 🧬 dashboard fills it.
        let empty = Decorations::default();
        assert!(empty.nodes.is_empty() && empty.edges.is_empty());
        let mut deco = Decorations::default();
        deco.nodes.insert(
            "A".into(),
            NodeDecoration {
                ring: Some(Color32::RED),
                badge: Some("!".into()),
                badge_color: None,
            },
        );
        assert_eq!(deco.nodes.len(), 1, "gate rings/badges ride in Decorations");

        // The frame result the boards read to run their domain side effects.
        let r = GraphResponse::default();
        assert!(r.clicked_node.is_none() && !r.clicked_empty);
    }
}