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

nordisk/facett

facett-graphpan/src/lib.rs

//! **facett-graphpan** — the bare, domain-agnostic **pan/zoom graph canvas**: a
//! caller-laid-out [`GraphModel`] (nodes in world space + edges by id) painted
//! through ONE egui renderer ([`draw_graph`] / [`draw_graph_scrolled`]) that
//! drives pan/drag + scroll-zoom and click-to-select (lighting the clicked node's
//! downstream subtree). Adopted verbatim from nornir's `src/viz/graph_render.rs`
//! (the `draw_graph` routine behind both the 🏛 Architecture wiring board and the
//! 🧬 release dashboard), with the `eframe::egui` skew dropped for plain `egui`.
//!
//! # How this differs from `facett-graphview`
//! `facett-graphview` adopted the same data *shape* and an **owning** Facet
//! ([`DecoratedGraphView`](https://docs.rs/facett-graphview)) — the egui twin that
//! holds its own pan/zoom + model. This crate is the **borrowed canvas + the
//! transform**: a free [`draw_graph`] function a host calls every frame against a
//! [`GraphView`] *transform* it owns (so pan/zoom/selection survive across frames),
//! plus the [`content_extent`] + the [`draw_graph_scrolled`] honest-scrollbar
//! variant for a static board. The two are complementary: graphview is the
//! consolidated render *engine* (CPU/GPU rasterizer + a Facet), graphpan is the
//! minimal "paint this model onto an egui Ui, return what was clicked" routine.
//!
//! # Domain-agnostic by design
//! This crate knows NOTHING about architecture node-kinds or release gates. The
//! caller hands it a [`GraphModel`] (already laid out — the caller owns its
//! layout) and an optional [`Decorations`] overlay (per-node status rings + corner
//! badges + emphasis cut-edges). Colours are plain `egui::Color32`; nothing
//! nornir-specific crosses the seam.
//!
//! Surfaces:
//!  * [`GraphModel`] / [`GraphNode`] / [`GraphEdge`] — the caller-laid-out graph.
//!  * [`Decorations`] / [`NodeDecoration`] — the opaque overlay layer.
//!  * [`GraphView`] — the persistent pan/zoom/selection transform (caller-owned),
//!    with [`GraphView::project`] (world→screen) + [`GraphView::state_json`].
//!  * [`draw_graph`] / [`draw_graph_scrolled`] — THE renderer (+ [`GraphResponse`]).
//!  * [`GraphPanView`] — an owning canonical [`Facet`] so a deck mounts the canvas
//!    as a tab and a headless driver reads + asserts it.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`GraphView`]** — the complete observable, serializable, round-trippable
//!   navigation transform (pan + zoom + selected node). It IS the [`GraphPanView`]
//!   Model: [`GraphPanView::state`] hands back a `&GraphView` and a headless driver
//!   ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`. The graph
//!   *data* ([`GraphModel`] + [`Decorations`]) is the *input* (held on the view, not
//!   in the Model — it is not the state a driver mutates, and its `egui::Color32`
//!   fills aren't serde-friendly); the rich node/screen keys are *derived* and
//!   published via a form-3 `custom_state_json`.
//! - **[`Msg`] + [`GraphPanView::update`]** — the single mutation path (FC-2): every
//!   input (pan drag, scroll-zoom, click-select, ⊙ fit) is a `Msg`; a headless driver
//!   reaches the identical transitions the canvas gestures drive.
//! - **[`GraphPanView::view`]** (the [`facett_core::Elm`] view) — a **pure** paint
//!   (FC-9): it reads `&self`, paints the canvas, and *returns* the [`Msg`]s the
//!   interactions produced; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
//!   bridge applies them via [`update`](GraphPanView::update). The only `&self`-interior
//!   mutation is the ephemeral `last_origin` paint cache (FC-7), outside the Model.
//! - **FC-5** — the selection keys on the **stable node id**, invariant across pan/zoom.

/// **Legibility at scale + the shared instanced GPU cloud lane** — collision-avoiding
/// label placement, a per-cell edge ink budget, a spatial pick index, and the opt-in
/// GPU painter. All default OFF, so [`draw_graph`] is byte-for-byte unchanged. See
/// [`legible`].
pub mod legible;
pub use legible::{FramePlan, LegibilityReport, PaintOpts, PickIndex};

/// **The RESIDENT GPU cloud lane** at scale (feature `wgpu`): the model uploaded once, the
/// camera per frame. See [`resident`].
#[cfg(feature = "wgpu")]
pub mod resident;
#[cfg(feature = "wgpu")]
pub use facett_core::render::gpu::graphcloud::cloud::RESIDENT_ABOVE;
#[cfg(not(feature = "wgpu"))]
mod resident {
    /// No `wgpu`: there is no resident lane, so nothing to cache.
    #[derive(Default)]
    pub(crate) struct ResidentCache;
}

use std::cell::{Cell, RefCell};
use std::collections::{BTreeSet, HashMap, VecDeque};

use egui::style::ScrollAnimation;
use egui::{Align2, Color32, FontId, Pos2, Sense, Stroke, Vec2};
use serde::{Deserialize, Serialize};

use facett_core::FacetCaps;

/// Default node chip size (world units, before zoom). Matches the arch board.
pub const BOX_W: f32 = 184.0;
pub const BOX_H: f32 = 34.0;

/// One laid-out node: its stable id, the label painted in the chip, the chip
/// fill + stroke (kind colour, caller's choice), and its world-space centre.
#[derive(Clone)]
pub struct GraphNode {
    pub id: String,
    pub label: String,
    pub fill: Color32,
    pub stroke: Color32,
    pub pos: Pos2,
}

/// One edge: endpoints by node id, its colour, a `dashed` flag (transitive /
/// suggested-cut style), and an optional mid-edge label (e.g. a cut rationale).
#[derive(Clone)]
pub struct GraphEdge {
    pub from: String,
    pub to: String,
    pub color: Color32,
    pub dashed: bool,
    pub label: Option<String>,
}

/// The full graph to paint — nodes (laid out in world space) + edges.
#[derive(Clone, Default)]
pub struct GraphModel {
    pub nodes: Vec<GraphNode>,
    pub edges: Vec<GraphEdge>,
}

/// A per-node decoration the caller layers over the base chip: an optional status
/// **ring** colour (drawn outside the kind stroke) and an optional **badge** (a
/// short glyph/text painted at the chip's top-right corner in `badge_color`).
#[derive(Clone, Default)]
pub struct NodeDecoration {
    pub ring: Option<Color32>,
    pub badge: Option<String>,
    pub badge_color: Option<Color32>,
}

/// The caller-supplied overlay layer (domain-agnostic). Per-node rings/badges
/// keyed by node id, plus extra edges to draw on top dashed-and-labelled (e.g. a
/// dependency cycle's suggested cut edge + its rationale). Empty = no overlay.
#[derive(Clone, Default)]
pub struct Decorations {
    /// node id → its ring/badge overlay.
    pub nodes: HashMap<String, NodeDecoration>,
    /// Extra emphasis edges painted ON TOP of the base edges (the cut lines).
    pub edges: Vec<GraphEdge>,
}

/// Serde bridge for `egui::Vec2` (which carries no serde impls in this build) — it
/// (de)serializes as a plain `[x, y]` pair, the same shape `state_json` prints, so
/// a [`GraphView`] round-trips through JSON (FC-3) without an egui `serde` feature.
mod vec2_serde {
    use egui::Vec2;
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S: Serializer>(v: &Vec2, s: S) -> Result<S::Ok, S::Error> {
        serde::Serialize::serialize(&[v.x, v.y], s)
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec2, D::Error> {
        let [x, y] = <[f32; 2]>::deserialize(d)?;
        Ok(Vec2::new(x, y))
    }
}

/// **The complete observable navigation state (FC-1 / FC-3)**: the persistent
/// pan/zoom transform + selection for the canvas. Owned by the caller (the
/// tab/dashboard state) so pan/zoom/selection survive across frames — and, as the
/// [`GraphPanView`] Model, it is serializable + round-trippable so a headless driver
/// snapshots it after feeding a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphView {
    #[serde(with = "vec2_serde")]
    pub pan: Vec2,
    pub zoom: f32,
    /// The selected node id (its downstream subtree is highlighted).
    pub selected: Option<String>,
    /// **Point-cloud (DOT) mode.** `None` (default) → the classic labelled chips
    /// (`BOX_W × BOX_H` world-scaled rects). `Some(r)` → each node is a small filled
    /// **circle** of radius `r` **screen px** (constant, zoom-independent) and labels are
    /// skipped — the mode a big graph (thousands of nodes: the world/warehouse cloud)
    /// needs so nodes read as separated points instead of overlapping 184-px chips. Also
    /// switches the click pick to nearest-centre-within-radius.
    #[serde(default)]
    pub dot_px: Option<f32>,
}

impl Default for GraphView {
    fn default() -> Self {
        Self { pan: Vec2::ZERO, zoom: 1.0, selected: None, dot_px: None }
    }
}

impl GraphView {
    /// Reset pan + zoom (the ⊙ fit button).
    pub fn fit(&mut self) {
        self.pan = Vec2::ZERO;
        self.zoom = 1.0;
    }

    /// **Fit a world-space bounding box to a viewport** — the honest "⊙ fit" for a laid-out
    /// graph (the plain [`fit`](Self::fit) only recentres at zoom 1, which leaves a large
    /// spread cloud overflowing the viewport, overlapping into a blob). Sets `zoom` so
    /// `(min..max)` fills a `viewport` box inside `margin` px, and `pan` so the box centre
    /// lands at the viewport centre (matching `draw_graph`'s `origin = rect.center()+pan`
    /// with the graph centred on world 0,0). Use node CENTRES for the box in dot mode.
    pub fn fit_bounds(&mut self, min: Pos2, max: Pos2, viewport: Vec2, margin: f32) {
        let bw = (max.x - min.x).max(1.0);
        let bh = (max.y - min.y).max(1.0);
        let z = ((viewport.x - 2.0 * margin) / bw)
            .min((viewport.y - 2.0 * margin) / bh)
            .clamp(0.01, 4.0);
        self.zoom = z;
        let cx = (min.x + max.x) * 0.5;
        let cy = (min.y + max.y) * 0.5;
        self.pan = -Vec2::new(cx, cy) * z;
    }
    /// Clear the selection (and thus the downstream highlight).
    pub fn clear_selection(&mut self) {
        self.selected = None;
    }

    /// World→screen projection for a world point, given the viewport `origin`
    /// (the screen position world (0,0) maps to — typically `rect.center() +
    /// pan`). The exact transform [`draw_graph`] paints with, exposed so a caller
    /// (or a headless test) can compute where a node lands on screen without
    /// re-deriving the math: `origin + p·zoom`.
    pub fn project(&self, origin: Pos2, p: Pos2) -> Pos2 {
        origin + p.to_vec2() * self.zoom
    }

    /// The transform as readable data (the "components expose readable data"
    /// LAW): the pan vector, the zoom, the selected node, and — when `origin` is
    /// supplied — the projected screen position of world (0,0) and of a unit
    /// world step, so a driver can assert the exact pan/zoom mapping headlessly.
    pub fn state_json(&self, origin: Option<Pos2>) -> serde_json::Value {
        let mut j = serde_json::json!({
            "pan": [self.pan.x, self.pan.y],
            "zoom": self.zoom,
            "selected": self.selected,
        });
        if let (Some(o), Some(obj)) = (origin, j.as_object_mut()) {
            let o0 = self.project(o, Pos2::ZERO);
            let o1 = self.project(o, Pos2::new(1.0, 1.0));
            obj.insert("origin".into(), serde_json::json!([o.x, o.y]));
            obj.insert("project_zero".into(), serde_json::json!([o0.x, o0.y]));
            obj.insert("project_unit".into(), serde_json::json!([o1.x, o1.y]));
        }
        j
    }
}

/// The downstream-reachable set (BFS over forward edges) from `seed`, in node-id
/// space. The visual twin of `nornir arch trace` — used to dim everything off the
/// lit path when a node is selected. `pub` so a caller can fold the lit set into
/// its own `state_json` without re-deriving it.
pub fn downstream_of(model: &GraphModel, seed: &str) -> BTreeSet<String> {
    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
    for e in &model.edges {
        adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
    }
    let mut lit: BTreeSet<String> = BTreeSet::new();
    let mut q: VecDeque<&str> = VecDeque::new();
    lit.insert(seed.to_string());
    q.push_back(seed);
    while let Some(cur) = q.pop_front() {
        if let Some(outs) = adj.get(cur) {
            for &nxt in outs {
                if lit.insert(nxt.to_string()) {
                    q.push_back(nxt);
                }
            }
        }
    }
    lit
}

/// Dim a colour toward transparency for the un-traced background when a node is
/// selected (so the lit downstream path pops). Verbatim from the arch board.
fn dim(c: Color32) -> Color32 {
    c.linear_multiply(0.22)
}

/// What a [`draw_graph`] frame did: the node (if any) the user clicked this frame
/// (so the caller can run its own side effects — click-to-code, scope a release),
/// and whether the click landed on empty space (a deselect). The view's
/// `selected` + pan/zoom are already mutated in place.
#[derive(Default)]
pub struct GraphResponse {
    /// `Some(node_id)` if a node was clicked this frame.
    pub clicked_node: Option<String>,
    /// `true` if the click hit empty canvas (cleared the selection).
    pub clicked_empty: bool,
}

// ── 📌 Stay / 🎯 Focus + the fly-to (facett_core::fly) ───────────────────────────
//
// A canvas's flight is UI-time state, so it lives in the UI's own memory under the
// canvas id — not in [`GraphView`], whose literal every host builds (a new field would
// break them all for state that must never be serialized anyway).

type FlightSlot = (facett_core::fly::Flight, f32);

/// Is the canvas `id` flying?
pub fn flying(ctx: &egui::Context, id: egui::Id) -> bool {
    ctx.data(|d| d.get_temp::<FlightSlot>(id)).is_some()
}

/// **Fly** canvas `id`'s camera to `(zoom, pan)` on the shared smooth zoom-and-pan path
/// from `now`, for a canvas `viewport_w` wide — retargeted from where the camera IS
/// when it is already flying.
pub fn fly_to(ctx: &egui::Context, id: egui::Id, view: &GraphView, zoom: f32, pan: Vec2, now: f64, viewport_w: f32) {
    use facett_core::fly;
    let (c1, w1) = fly::from_pan_zoom([pan.x, pan.y], zoom, viewport_w);
    let (cam_pan, cam_zoom) = match ctx.data(|d| d.get_temp::<FlightSlot>(id)) {
        Some((f, vw)) => {
            let (c, w) = f.at(now);
            let (p, z) = fly::to_pan_zoom(c, w, vw);
            (Vec2::new(p[0], p[1]), z)
        }
        None => (view.pan, view.zoom),
    };
    let (c0, w0) = fly::from_pan_zoom([cam_pan.x, cam_pan.y], cam_zoom, viewport_w);
    let slot: FlightSlot = (fly::Flight::new(now, c0, w0, c1, w1), viewport_w);
    ctx.data_mut(|d| d.insert_temp(id, slot));
    ctx.request_repaint();
}

/// ⊙ fit as a flight: fly canvas `id` to the world box `min..max`, framed and centred.
#[allow(clippy::too_many_arguments)]
pub fn fly_to_bounds(ctx: &egui::Context, id: egui::Id, view: &GraphView, min: Pos2, max: Pos2, viewport: Vec2, margin: f32, now: f64) {
    let mut target = view.clone();
    target.fit_bounds(min, max, viewport, margin);
    fly_to(ctx, id, view, target.zoom, target.pan, now, viewport.x);
}

/// **The camera step every graphpan canvas runs each frame** (before it projects): a
/// drag or a scroll takes the camera from wherever a flight had it (the flight ends);
/// a flight under way advances, repainting only while it flies.
pub fn camera_step(ui: &egui::Ui, view: &mut GraphView, resp: &egui::Response) {
    let id = resp.id;
    if resp.dragged() || (resp.hovered() && ui.input(|i| i.smooth_scroll_delta.y) != 0.0) {
        ui.data_mut(|d| d.remove::<FlightSlot>(id));
        return;
    }
    let Some((f, vw)) = ui.data(|d| d.get_temp::<FlightSlot>(id)) else { return };
    let now = ui.input(|i| i.time);
    let (c, w) = f.at(now);
    let (p, z) = facett_core::fly::to_pan_zoom(c, w, vw);
    (view.pan, view.zoom) = (Vec2::new(p[0], p[1]), z);
    if f.done(now) {
        ui.data_mut(|d| d.remove::<FlightSlot>(id));
    } else {
        ui.ctx().request_repaint();
    }
}

/// The zoom 🎯 Focus lands at (labels readable), unless the camera is already closer.
pub const FOCUS_ZOOM: f32 = 1.0;

/// **After a click**: in the viewer's 🎯 Focus mode ([`facett_core::fly::NavMode`]), fly
/// the clicked node to the centre of the canvas `resp` at [`FOCUS_ZOOM`]; in 📌 Stay,
/// nothing — a click never moves the camera.
pub fn focus_click(ui: &egui::Ui, view: &GraphView, model: &GraphModel, resp: &egui::Response, clicked: Option<&str>) {
    let Some(id) = clicked else { return };
    if facett_core::fly::NavMode::remembered(ui.ctx()) != facett_core::fly::NavMode::Focus {
        return;
    }
    if let Some(n) = model.nodes.iter().find(|n| n.id == id) {
        let zoom = view.zoom.max(FOCUS_ZOOM).min(4.0);
        let pan = -Vec2::new(n.pos.x, n.pos.y) * zoom;
        fly_to(ui.ctx(), resp.id, view, zoom, pan, ui.input(|i| i.time), resp.rect.width());
    }
}

/// THE shared renderer. Paints `model` decorated by `decorations` onto the egui
/// `ui`, driving pan/drag + scroll-zoom and click-to-select (which lights the
/// clicked node's downstream subtree). Mutates `view` (pan/zoom/selected) and
/// returns what was clicked so the caller can run domain side effects.
#[allow(clippy::too_many_arguments)]
pub fn draw_graph(
    ui: &mut egui::Ui,
    model: &GraphModel,
    decorations: &Decorations,
    view: &mut GraphView,
    bg: Color32,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
) -> GraphResponse {
    if view.zoom <= 0.0 {
        view.zoom = 1.0;
    }

    let (resp, painter) = ui.allocate_painter(facett_core::nav::canvas_size(ui), Sense::click_and_drag());
    painter.rect_filled(resp.rect, 4.0, bg);
    camera_step(ui, view, &resp);
    if resp.dragged() {
        view.pan += resp.drag_delta();
    }
    if resp.hovered() {
        let scroll = ui.input(|i| i.smooth_scroll_delta.y);
        if scroll != 0.0 {
            view.zoom = (view.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
        }
    }
    // Origin is the viewport centre offset by pan; the graph is centred on (0,0).
    let origin = resp.rect.center() + view.pan;
    let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();
    let out = paint_graph(
        &painter, model, decorations, view, origin, click, text, selection_ring, text_dim,
    );
    focus_click(ui, view, model, &resp, out.clicked_node.as_deref());
    out
}

/// [`draw_graph`] **with [`PaintOpts`]** — the one-call interactive canvas that can route
/// the node/edge cloud through the **shared instanced GPU renderer**
/// ([`facett_core::render::gpu::graphcloud`], the SAME cloud
/// [`facett_graphview::world_still`] rasterises headless for the 100k/1M "universe" still)
/// instead of tessellating every disc on the egui UI thread.
///
/// This is the seam a scale host mounts for a **UNIVERSE view**: the full node cloud with
/// **no LOD/budget cap**, labels off, coloured by category — a pure GPU star-field that
/// stays interactive (pan/drag + scroll-zoom + click-select, exactly as [`draw_graph`])
/// where the CPU painter would stall (a 30 000-node cloud is 2.47 M triangles / 33 ms on
/// one core; the GPU draws it as instances). Everything [`draw_graph`] does is preserved;
/// the ONLY additions are the [`PaintOpts`] (opt into `gpu_cloud`, label/edge thinning) and
/// the returned [`LegibilityReport`] (its `gpu_cloud` flag tells the host WHICH lane drew).
///
/// The GPU lane is **fail-safe**: it paints only when the host installed the cloud renderer
/// ([`legible::gpu_cloud_available`]); otherwise [`paint_scene_opts`] transparently runs the
/// CPU painter, so a box with no GPU still renders every node (`report.gpu_cloud == false`).
#[allow(clippy::too_many_arguments)]
pub fn draw_graph_opts(
    ui: &mut egui::Ui,
    model: &GraphModel,
    decorations: &Decorations,
    view: &mut GraphView,
    bg: Color32,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
    opts: PaintOpts,
) -> (GraphResponse, LegibilityReport) {
    if view.zoom <= 0.0 {
        view.zoom = 1.0;
    }

    let (resp, painter) = ui.allocate_painter(facett_core::nav::canvas_size(ui), Sense::click_and_drag());
    painter.rect_filled(resp.rect, 4.0, bg);
    camera_step(ui, view, &resp);
    if resp.dragged() {
        view.pan += resp.drag_delta();
    }
    if resp.hovered() {
        let scroll = ui.input(|i| i.smooth_scroll_delta.y);
        if scroll != 0.0 {
            view.zoom = (view.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
        }
    }
    let origin = resp.rect.center() + view.pan;
    let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();

    // Click-to-select (identical to `paint_graph`): nearest node within its hit shape;
    // clicking empty clears. Kept here so the GPU lane keeps `draw_graph`'s interaction.
    let mut out = GraphResponse::default();
    if let Some(click) = click {
        match hit_test(model, origin, click, view.zoom, view.dot_px) {
            Some(id) => {
                view.selected = Some(id.clone());
                out.clicked_node = Some(id);
            }
            None => {
                view.selected = None;
                out.clicked_empty = true;
            }
        }
    }

    // `Some((ui, rect))` unlocks the GPU cloud callback; whether it actually draws is
    // decided downstream by the installed-renderer gate (never here).
    let report = paint_scene_opts(
        &painter,
        model,
        decorations,
        view,
        origin,
        text,
        selection_ring,
        text_dim,
        opts,
        Some((ui, resp.rect)),
    );
    focus_click(ui, view, model, &resp, out.clicked_node.as_deref());
    (out, report)
}

/// The painted extent of `model` in WORLD units (before zoom): the axis-aligned
/// bounding box of every chip (node centre ± half-box) grown by a margin. This is
/// the TRUE content size — the figure a wrapping `ScrollArea` must allocate so its
/// scrollbar handle scales to the real overflow (rather than to the viewport).
/// Returns at least one box so an empty/one-node board still has a sane size.
pub fn content_extent(model: &GraphModel) -> Vec2 {
    const MARGIN: f32 = 48.0;
    if model.nodes.is_empty() {
        return Vec2::new(BOX_W, BOX_H) + Vec2::splat(2.0 * MARGIN);
    }
    let (mut min, mut max) = (Pos2::new(f32::MAX, f32::MAX), Pos2::new(f32::MIN, f32::MIN));
    let half = Vec2::new(BOX_W, BOX_H) * 0.5;
    for n in &model.nodes {
        min.x = min.x.min(n.pos.x - half.x);
        min.y = min.y.min(n.pos.y - half.y);
        max.x = max.x.max(n.pos.x + half.x);
        max.y = max.y.max(n.pos.y + half.y);
    }
    (max - min) + Vec2::splat(2.0 * MARGIN)
}

/// THE shared renderer in a **scrolling** frame, for a static wiring board.
///
/// Unlike [`draw_graph`] (which pans a viewport-sized canvas — drag to move), this
/// allocates a canvas at the graph's TRUE [`content_extent`] (× zoom) inside a
/// styled [`egui::ScrollArea`]. Because the allocated size is the real painted
/// bounds, the scrollbar handle scales correctly. Scrolling is smooth/kinetic via
/// the context's `scroll_animation`, and the bars are made wide + always-visible.
/// `view.pan` is NOT used for navigation here (the ScrollArea offset is); zoom
/// still works (it resizes the allocated canvas, so the bars re-scale).
#[allow(clippy::too_many_arguments)]
pub fn draw_graph_scrolled(
    ui: &mut egui::Ui,
    model: &GraphModel,
    decorations: &Decorations,
    view: &mut GraphView,
    bg: Color32,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
) -> GraphResponse {
    if view.zoom <= 0.0 {
        view.zoom = 1.0;
    }
    // Smooth/kinetic scroll: glide to the target instead of jumping.
    ui.style_mut().scroll_animation = ScrollAnimation::new(1200.0, egui::Rangef::new(0.08, 0.30));
    // A wider, always-on, high-contrast bar so the handle is easy to grab + read.
    {
        let s = &mut ui.style_mut().spacing.scroll;
        s.bar_width = 12.0;
        s.handle_min_length = 24.0;
        s.floating = false;
        s.dormant_background_opacity = 1.0;
        s.active_background_opacity = 1.0;
        s.interact_background_opacity = 1.0;
    }

    let extent = content_extent(model) * view.zoom;
    let mut out = GraphResponse::default();
    egui::ScrollArea::both()
        .id_salt("graph_scroll")
        .auto_shrink([false, false])
        .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
        .show(ui, |ui| {
            // Allocate the canvas at the TRUE extent — this is what makes the
            // scrollbar honest. The graph is centred on world (0,0), so shift the
            // projection origin to the canvas centre.
            let (resp, painter) =
                ui.allocate_painter(extent.max(ui.available_size()), Sense::click_and_drag());
            painter.rect_filled(resp.rect, 4.0, bg);
            // Zoom on hover with the modifier held (plain wheel = smooth scroll).
            if resp.hovered() {
                let (scroll, zoom_mod) =
                    ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.command || i.modifiers.ctrl));
                if zoom_mod && scroll != 0.0 {
                    view.zoom = (view.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
                }
            }
            let origin = resp.rect.center();
            let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();
            out = paint_graph(
                &painter, model, decorations, view, origin, click, text, selection_ring, text_dim,
            );
        });
    out
}

/// Hit-test a `click` against the chips of `model` under the projection `origin` +
/// `zoom` (graph centred on world 0,0). Returns the id of the node whose box
/// contains the click, or `None` for an empty-canvas click. **Pure** — it does not
/// mutate any selection; the caller decides what a hit means.
fn hit_test(model: &GraphModel, origin: Pos2, click: Pos2, zoom: f32, dot_px: Option<f32>) -> Option<String> {
    let project = |p: Pos2| origin + p.to_vec2() * zoom;
    // DOT mode: pick the node whose CENTRE is nearest the click, within a forgiving pick
    // radius (so a small point cloud is clickable). Chip mode keeps the original
    // first-chip-that-contains behaviour (unchanged for every existing board).
    if let Some(r) = dot_px {
        let pick = (r * 3.0).max(7.0);
        let pick2 = pick * pick;
        let mut best: Option<(&str, f32)> = None;
        for n in &model.nodes {
            let d2 = (project(n.pos) - click).length_sq();
            if d2 <= pick2 && best.is_none_or(|(_, bd)| d2 < bd) {
                best = Some((n.id.as_str(), d2));
            }
        }
        return best.map(|(id, _)| id.to_string());
    }
    model.nodes.iter().find_map(|n| {
        let c = project(n.pos);
        let half = Vec2::new(BOX_W, BOX_H) * 0.5 * zoom;
        let rect = egui::Rect::from_center_size(c, half * 2.0);
        rect.contains(click).then(|| n.id.clone())
    })
}

/// The shared paint + hit-test core used by both [`draw_graph`] (pan/zoom canvas)
/// and [`draw_graph_scrolled`] (ScrollArea canvas). Given the projection `origin`
/// (graph centred on world 0,0 → screen) and an optional `click` position, it
/// hit-tests the click, lights the selection's downstream subtree, and paints the
/// edges + chips. Mutates `view.selected`; returns the click outcome. The paint
/// itself is factored into the **pure** [`paint_scene`] the FC-9 [`GraphPanView::view`]
/// reuses without any mutation.
#[allow(clippy::too_many_arguments)]
fn paint_graph(
    painter: &egui::Painter,
    model: &GraphModel,
    decorations: &Decorations,
    view: &mut GraphView,
    origin: Pos2,
    click: Option<Pos2>,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
) -> GraphResponse {
    let mut out = GraphResponse::default();

    // Click-to-select: nearest node within its box; clicking empty clears.
    if let Some(click) = click {
        match hit_test(model, origin, click, view.zoom, view.dot_px) {
            Some(id) => {
                view.selected = Some(id.clone());
                out.clicked_node = Some(id);
            }
            None => {
                view.selected = None;
                out.clicked_empty = true;
            }
        }
    }

    paint_scene(painter, model, decorations, view, origin, text, selection_ring, text_dim);
    out
}

/// **Pure** edges + chips paint: reads `view` (zoom + selection) and draws the
/// scene at the projection `origin`. It NEVER mutates `view` — the selection's
/// downstream-lit set is computed from the *current* `view.selected`. Shared by the
/// mutating [`paint_graph`] wrapper and the FC-9 [`GraphPanView::view`].
#[allow(clippy::too_many_arguments)]
fn paint_scene(
    painter: &egui::Painter,
    model: &GraphModel,
    decorations: &Decorations,
    view: &GraphView,
    origin: Pos2,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
) {
    // `PaintOpts::default()` is all-off, and `paint_scene_opts` short-circuits on
    // `is_off()` to this exact code path — so the historical canvas is reached through
    // one function, not duplicated beside a "legible" twin (L5).
    let _ = paint_scene_opts(
        painter,
        model,
        decorations,
        view,
        origin,
        text,
        selection_ring,
        text_dim,
        PaintOpts::default(),
        None,
    );
}

/// [`paint_scene`] with **legibility + GPU** options — the one painter both the plain
/// and the scaled-up canvas go through.
///
/// `gpu` is `Some((ui, rect))` when the caller can host an `egui_wgpu` paint callback.
/// Whether the instanced cloud actually paints is decided by
/// [`legible::draw_gpu_cloud`], never here: if it returns `false` (feature off, host
/// never installed a renderer, empty model) the CPU chip/edge loops run exactly as
/// they always have. There is no code path that skips the CPU painter on anything but
/// a `true` from a renderer that provably exists.
///
/// Returns what the frame's legibility work did, for `state_json`.
#[allow(clippy::too_many_arguments)]
pub fn paint_scene_opts(
    painter: &egui::Painter,
    model: &GraphModel,
    decorations: &Decorations,
    view: &GraphView,
    origin: Pos2,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
    opts: PaintOpts,
    gpu: Option<(&egui::Ui, egui::Rect)>,
) -> LegibilityReport {
    paint_scene_impl(painter, model, decorations, view, origin, text, selection_ring, text_dim, opts, gpu, None)
}

/// [`paint_scene_opts`] with the owner's resident cache: `owner` is `Some((cache,
/// generation))` from a [`GraphPanView`] (which numbers its own models), `None` from the
/// free function (which fingerprints the model it is handed).
///
/// Above [`RESIDENT_ABOVE`] nodes, with `gpu_cloud` on and a renderer installed, the
/// cloud goes on the RESIDENT lane ([`resident`]): uploaded once per model, each frame
/// the camera + the small overlays. Anything that stops that lane (no renderer, a
/// zero-pixel rect) falls through to the per-frame lane / CPU painter below, unchanged.
#[allow(clippy::too_many_arguments)]
fn paint_scene_impl(
    painter: &egui::Painter,
    model: &GraphModel,
    decorations: &Decorations,
    view: &GraphView,
    origin: Pos2,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
    opts: PaintOpts,
    gpu: Option<(&egui::Ui, egui::Rect)>,
    owner: Option<(&RefCell<resident::ResidentCache>, u64)>,
) -> LegibilityReport {
    #[cfg(feature = "wgpu")]
    if let Some((ui, rect)) = gpu
        && opts.gpu_cloud
        && model.nodes.len() > RESIDENT_ABOVE
    {
        let r = match owner {
            Some((cache, generation)) => resident::paint(
                &mut cache.borrow_mut(),
                || resident::ModelKey::Owned(generation),
                painter, model, decorations, view, origin, text, selection_ring, text_dim, opts, ui, rect,
            ),
            None => resident::with_free(|cache| {
                resident::paint(
                    cache,
                    || resident::ModelKey::Fingerprint(resident::fingerprint(model)),
                    painter, model, decorations, view, origin, text, selection_ring, text_dim, opts, ui, rect,
                )
            }),
        };
        if let Some(r) = r {
            return r;
        }
    }
    #[cfg(not(feature = "wgpu"))]
    let _ = owner;
    let zoom = view.zoom;
    let project = |p: Pos2| origin + p.to_vec2() * zoom;

    // id → index for edge endpoint lookup.
    let idx: HashMap<&str, usize> =
        model.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i)).collect();

    // The downstream-lit set for the current selection (dims everything else).
    let lit: BTreeSet<String> = view
        .selected
        .as_deref()
        .map(|s| downstream_of(model, s))
        .unwrap_or_default();
    let highlighting = view.selected.is_some();

    let viewport = painter.clip_rect();
    let mut report = LegibilityReport::default();

    // ── the GPU cloud lane ───────────────────────────────────────────────────
    // `drew_on_gpu` is the ONLY thing that may suppress the CPU cloud, and it can only
    // be true when a renderer is installed. Labels/rings/badges stay CPU overlays.
    let drew_on_gpu = match gpu {
        Some((ui, rect)) if opts.gpu_cloud => {
            legible::draw_gpu_cloud(ui, rect, model, origin, zoom, true)
        }
        _ => false,
    };
    report.gpu_cloud = drew_on_gpu;

    // ── the legibility plan (pure) ───────────────────────────────────────────
    let plan = (!opts.is_off()).then(|| {
        legible::plan_frame(
            model,
            origin,
            zoom,
            viewport,
            view.selected.as_deref(),
            &lit,
            6.0 * zoom.clamp(0.7, 1.4),
            opts,
        )
    });
    if let Some(p) = &plan {
        report.labels_placed = p.report.labels_placed;
        report.labels_suppressed = p.report.labels_suppressed;
        report.labels_considered = p.report.labels_considered;
        report.edges_drawn = p.report.edges_drawn;
        report.edges_thinned = p.report.edges_thinned;
        report.edge_peak_before = p.report.edge_peak_before;
        report.edge_peak_after = p.report.edge_peak_after;
    }
    let keep: Option<&Vec<u32>> = plan.as_ref().and_then(FramePlan::edges);

    // ── edges first (so chips sit on top) ────────────────────────────────────
    if !drew_on_gpu {
        match keep {
            Some(keep) => {
                for &ei in keep {
                    let Some(e) = model.edges.get(ei as usize) else { continue };
                    draw_edge(painter, model, &idx, &project, e, zoom, view.dot_px, highlighting, &lit, text_dim, false);
                }
            }
            None => {
                for e in &model.edges {
                    draw_edge(painter, model, &idx, &project, e, zoom, view.dot_px, highlighting, &lit, text_dim, false);
                }
            }
        }
    }
    // Decoration edges painted ON TOP (the cut lines) — always emphasised, and never
    // thinned or GPU-batched: they are the answer the user asked for.
    for e in &decorations.edges {
        draw_edge(painter, model, &idx, &project, e, zoom, view.dot_px, false, &lit, text_dim, true);
    }

    // ── chips (nodes) ────────────────────────────────────────────────────────
    // With the GPU cloud up, the discs are already on screen; the CPU still paints the
    // rings/badges/labels (thin overlays, not the 2.47 M-triangle cloud).
    let paint_chips = !drew_on_gpu;
    let thinned_labels = plan.as_ref().and_then(FramePlan::labels).is_some();
    for n in &model.nodes {
        let c = project(n.pos);
        let on_trace = highlighting && lit.contains(&n.id);
        let (fill, stroke) = if highlighting && !on_trace {
            (dim(n.fill), dim(n.stroke))
        } else {
            (n.fill, n.stroke)
        };
        // DOT mode: a constant-size filled circle per node (the point-cloud lane for big
        // graphs) — no wide chip, no label. Selection/decoration read as a ring.
        if let Some(r) = view.dot_px {
            if paint_chips {
                painter.circle_filled(c, r, fill);
            }
            let selected = view.selected.as_deref() == Some(n.id.as_str());
            if selected {
                painter.circle_stroke(c, r + 2.0, Stroke::new(2.0, selection_ring));
            } else if let Some(rc) = decorations.nodes.get(&n.id).and_then(|d| d.ring) {
                let rc = if highlighting && !on_trace { dim(rc) } else { rc };
                painter.circle_stroke(c, r + 2.0, Stroke::new(1.8, rc));
            }
            continue;
        }
        let size = Vec2::new(BOX_W, BOX_H) * zoom;
        let rect = egui::Rect::from_center_size(c, size);
        if paint_chips {
            painter.rect_filled(rect, 5.0 * zoom, fill);
        }

        let deco = decorations.nodes.get(&n.id);
        // Ring precedence: explicit selection wins; then a decoration ring; then
        // the node's own stroke (the arch coverage ring rides in here already).
        let ring = if view.selected.as_deref() == Some(n.id.as_str()) {
            Stroke::new(3.0_f32, selection_ring)
        } else if let Some(rc) = deco.and_then(|d| d.ring) {
            let rc = if highlighting && !on_trace { dim(rc) } else { rc };
            Stroke::new(2.4_f32, rc)
        } else {
            Stroke::new(1.4_f32, stroke)
        };
        if paint_chips || deco.is_some() || view.selected.as_deref() == Some(n.id.as_str()) {
            painter.rect_stroke(rect, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
        }

        if zoom > 0.45 {
            // With label thinning on, the labels are painted ONCE from the collision
            // plan below (in placement order, at their chosen boxes) instead of here.
            if !thinned_labels {
                painter.text(
                    c,
                    Align2::CENTER_CENTER,
                    &n.label,
                    FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
                    text,
                );
            }
            // The decoration badge at the top-right corner (e.g. 🟡 / ⛔).
            if let Some(badge) = deco.and_then(|d| d.badge.as_deref()) {
                let col = deco.and_then(|d| d.badge_color).unwrap_or(text);
                let col = if highlighting && !on_trace { dim(col) } else { col };
                painter.text(
                    rect.right_top() + Vec2::new(-2.0, 1.0),
                    Align2::RIGHT_TOP,
                    badge,
                    FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
                    col,
                );
            }
        }
    }

    if zoom > 0.45 && let Some(p) = &plan {
        legible::paint_planned_labels(painter, model, p, zoom, text);
    }
    report
}

/// Draw ONE edge as a PCB-style cubic with a mid breakpoint + an arrowhead, dimmed
/// off-trace when a selection is active. `emphasise` (decoration edges) forces a
/// thicker, undimmed stroke and paints the optional mid-label.
#[allow(clippy::too_many_arguments)]
fn draw_edge(
    painter: &egui::Painter,
    model: &GraphModel,
    idx: &HashMap<&str, usize>,
    project: &impl Fn(Pos2) -> Pos2,
    e: &GraphEdge,
    zoom: f32,
    dot_px: Option<f32>,
    highlighting: bool,
    lit: &BTreeSet<String>,
    text_dim: Color32,
    emphasise: bool,
) {
    let (Some(&fi), Some(&ti)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
        return;
    };
    let on_trace = highlighting && lit.contains(&e.from) && lit.contains(&e.to);
    let (pa, pb) = (project(model.nodes[fi].pos), project(model.nodes[ti].pos));
    stroke_edge(painter, pa, pb, e, zoom, dot_px, highlighting, on_trace, text_dim, emphasise);
}

/// Half the on-screen WIDTH of a node as it is drawn, in screen px: half a chip
/// (`BOX_W · zoom / 2`) for the chip look, the dot's radius in DOT mode
/// ([`GraphView::dot_px`] — a constant screen size that does not follow the zoom).
///
/// An edge leaves its source at `centre + this` and its arrow lands on its target at
/// `centre − this`, so the edge meets whatever shape the node really has. Anchoring a
/// DOT's edges at the chip rim left every arrow `92 · zoom` px short of its dot.
pub(crate) fn node_half_width(zoom: f32, dot_px: Option<f32>) -> f32 {
    dot_px.unwrap_or(BOX_W * 0.5 * zoom)
}

/// The stroke half of [`draw_edge`]: ONE edge between two projected chip centres `pa` →
/// `pb` (the endpoint lookup is the caller's — the resident lane holds its own index).
#[allow(clippy::too_many_arguments)]
pub(crate) fn stroke_edge(
    painter: &egui::Painter,
    pa: Pos2,
    pb: Pos2,
    e: &GraphEdge,
    zoom: f32,
    dot_px: Option<f32>,
    highlighting: bool,
    on_trace: bool,
    text_dim: Color32,
    emphasise: bool,
) {
    // Rim to rim of the node AS DRAWN (chip or dot) — see `node_half_width`.
    let half = Vec2::new(node_half_width(zoom, dot_px), 0.0);
    let a = pa + half;
    let b = pb - half;
    let color = if !emphasise && highlighting && !on_trace { dim(e.color) } else { e.color };
    let w = if emphasise { 2.6_f32 } else if on_trace { 2.4_f32 } else { 1.4_f32 };

    let mid = Pos2::new((a.x + b.x) * 0.5, a.y);
    let mid2 = Pos2::new((a.x + b.x) * 0.5, b.y);
    let curve = egui::epaint::CubicBezierShape::from_points_stroke(
        [a, mid, mid2, b],
        false,
        Color32::TRANSPARENT,
        Stroke::new(w, color),
    );
    if e.dashed {
        // Dashed cubic — sample the curve and stroke alternate segments.
        let pts: Vec<Pos2> = (0..=20).map(|i| curve.sample(i as f32 / 20.0)).collect();
        for (i, win) in pts.windows(2).enumerate() {
            if i % 2 == 0 {
                painter.line_segment([win[0], win[1]], Stroke::new(w, color));
            }
        }
    } else {
        painter.add(egui::Shape::CubicBezier(curve));
    }
    // Arrowhead at the callee.
    let dir = (b - mid2).normalized();
    let perp = Vec2::new(-dir.y, dir.x);
    let head = 6.0 * zoom.clamp(0.6, 1.6);
    painter.line_segment([b, b - dir * head + perp * head * 0.5], Stroke::new(w, color));
    painter.line_segment([b, b - dir * head - perp * head * 0.5], Stroke::new(w, color));

    // Mid-edge label (cut rationale), painted for emphasis edges that carry one.
    if let Some(lbl) = &e.label
        && zoom > 0.45
        && !lbl.is_empty()
    {
        let mp = Pos2::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5 - 6.0 * zoom);
        painter.text(
            mp,
            Align2::CENTER_BOTTOM,
            lbl,
            FontId::proportional(10.0 * zoom.clamp(0.7, 1.3)),
            if emphasise { color } else { text_dim },
        );
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// GraphPanView — an OWNING canonical Facet around the bare canvas, so a deck
// mounts it and a headless driver reads + drives the pan/zoom + selection.
// ─────────────────────────────────────────────────────────────────────────────

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the [`GraphPanView`] transform through, the same effect
/// a canvas gesture produces. Applied by [`GraphPanView::update`], the **only**
/// mutation path for the [`GraphView`] Model. Serde-tagged (`snake_case`) so the
/// robot `update_json` seam parses one message straight off the wire; the `Vec2`
/// payloads reuse the same `[x, y]` [`vec2_serde`] codec as the Model's `pan`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
    /// Pan the viewport by a screen-space delta (a drag gesture).
    PanBy(#[serde(with = "vec2_serde")] Vec2),
    /// Set the pan vector directly (programmatic / a pasted portable state).
    SetPan(#[serde(with = "vec2_serde")] Vec2),
    /// Scroll-zoom by a wheel delta: `zoom *= 1 + delta·0.001`, clamped 0.25..4.0.
    ZoomBy(f32),
    /// Set the zoom directly (programmatic / a pasted portable state). A `<= 0`
    /// value falls back to `1.0` (mirrors the canvas's own zoom guard).
    SetZoom(f32),
    /// Select a node by its **stable id** (lights its downstream subtree; FC-5).
    Select(String),
    /// Clear the selection (an empty-canvas click, or a programmatic clear).
    ClearSelection,
    /// Reset pan + zoom (the ⊙ fit button); the selection is kept.
    Fit,
}

/// Side work as data (FC-8). The canvas does no I/O — every [`Msg`] mutates only
/// the in-memory [`GraphView`] — so this is uninhabited on purpose: the type-checked
/// statement that [`GraphPanView::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// An **owning** canonical [`Facet`] wrapping the bare [`draw_graph`] canvas: it
/// holds its own [`GraphModel`] + [`Decorations`] + a [`GraphView`] transform,
/// paints the canvas (pan/drag + scroll-zoom + click-select), and exposes the
/// whole rendered canvas through [`Facet::state_json`] (the laid-out node screen
/// positions under the live transform, the edge count, the pan/zoom) so a deck
/// mounts it and a headless driver reads + asserts the transform.
pub struct GraphPanView {
    title: String,
    /// The graph data + overlay: the *input* the canvas renders (not the Model —
    /// its `Color32` fills aren't serde-friendly and no [`Msg`] mutates it).
    model: GraphModel,
    decorations: Decorations,
    /// All observable navigation state (FC-1 / FC-3) — the [`facett_core::Elm`] Model.
    view: GraphView,
    /// The last viewport origin (rect.center() + pan) the canvas painted with, so
    /// `state_json` can report real on-screen node positions after a render.
    /// **Ephemeral paint cache, not observable state** — interior-mutable so the
    /// FC-9 pure [`view`](Self::view) can record it through a `&self` borrow (FC-7);
    /// deliberately kept OUT of the [`GraphView`] Model.
    last_origin: Cell<Option<Pos2>>,
    /// Legibility + GPU opt-ins. Default all-off ⇒ the historical canvas.
    opts: PaintOpts,
    /// The spatial pick index (ephemeral cache, rebuilt only when the projection
    /// changes) — so a click on a 100 000-node board does not scan 100 000 nodes.
    pick: RefCell<PickIndex>,
    /// What the last painted frame's legibility work did, published through
    /// `state_json` so a robot asserts on measured numbers. Ephemeral (FC-7).
    last_legibility: RefCell<LegibilityReport>,
    /// Bumped by every [`with_model`](Self::with_model) — what the resident cloud lane
    /// keys its upload on, so the pane never hashes its own model.
    model_gen: u64,
    /// The resident cloud lane's per-model work (ephemeral, FC-7).
    resident: RefCell<resident::ResidentCache>,
}

impl GraphPanView {
    /// An empty pane titled `title`.
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            model: GraphModel::default(),
            decorations: Decorations::default(),
            view: GraphView::default(),
            last_origin: Cell::new(None),
            opts: PaintOpts::default(),
            pick: RefCell::new(PickIndex::default()),
            last_legibility: RefCell::new(LegibilityReport::default()),
            model_gen: 0,
            resident: RefCell::new(resident::ResidentCache::default()),
        }
    }

    /// Opt into the **instanced GPU cloud** — the same renderer `Graph3D` installs
    /// (`facett_core::render::gpu::graphcloud::cloud::install_cloud_renderer`).
    ///
    /// Safe to turn on unconditionally: if the host never installed the renderer, or
    /// the crate was built without the `wgpu` feature, the CPU painter runs unchanged.
    /// Read it back from `state_json()["legibility"]["gpu_cloud"]` to find out which
    /// lane actually painted.
    #[must_use]
    pub fn with_gpu(mut self, on: bool) -> Self {
        self.opts.gpu_cloud = on;
        self
    }

    /// Opt into label collision avoidance + the edge ink budget.
    #[must_use]
    pub fn with_legibility(mut self, on: bool) -> Self {
        self.opts.thin_labels = on;
        self.opts.thin_edges = on;
        self
    }

    /// Set the paint options wholesale.
    #[must_use]
    pub fn with_paint_opts(mut self, opts: PaintOpts) -> Self {
        self.opts = opts;
        self
    }

    /// The paint options in force.
    #[must_use]
    pub fn paint_opts(&self) -> PaintOpts {
        self.opts
    }

    /// What the last painted frame's legibility work did.
    #[must_use]
    pub fn legibility(&self) -> LegibilityReport {
        self.last_legibility.borrow().clone()
    }

    /// Set the graph model (nodes already laid out in world space + edges).
    pub fn with_model(mut self, model: GraphModel) -> Self {
        self.model = model;
        self.model_gen = self.model_gen.wrapping_add(1);
        self
    }

    /// Set the decorations overlay (rings/badges + emphasis edges).
    pub fn with_decorations(mut self, decorations: Decorations) -> Self {
        self.decorations = decorations;
        self
    }

    /// Re-title (the deck keys facets off [`Facet::title`]).
    pub fn set_title(&mut self, title: impl Into<String>) {
        self.title = title.into();
    }

    /// Reset pan + zoom (the ⊙ fit affordance). Routes through the FC-2
    /// [`update`](Self::update) mutation path.
    pub fn fit(&mut self) {
        let _ = self.update(Msg::Fit);
    }

    /// Select a node by id (lights its downstream subtree), or `None` to clear. A
    /// thin wrapper over the FC-2 [`update`](Self::update) mutation path.
    pub fn select(&mut self, id: Option<String>) {
        let _ = match id {
            Some(id) => self.update(Msg::Select(id)),
            None => self.update(Msg::ClearSelection),
        };
    }

    /// **FC-3** — read the complete observable navigation state (the pan/zoom/selection
    /// transform) at any frame boundary. The caller may read pan/zoom/selected.
    pub fn state(&self) -> &GraphView {
        &self.view
    }

    /// The downstream-lit set for the current selection (empty when none).
    pub fn lit_set(&self) -> BTreeSet<String> {
        self.view.selected.as_deref().map(|s| downstream_of(&self.model, s)).unwrap_or_default()
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. Every canvas gesture routes its
    /// [`Msg`] here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::PanBy(d) => self.view.pan += d,
            Msg::SetPan(p) => self.view.pan = p,
            Msg::ZoomBy(scroll) => {
                let z = if self.view.zoom <= 0.0 { 1.0 } else { self.view.zoom };
                self.view.zoom = (z * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
            }
            Msg::SetZoom(z) => self.view.zoom = if z <= 0.0 { 1.0 } else { z },
            Msg::Select(id) => self.view.selected = Some(id),
            Msg::ClearSelection => self.view.selected = None,
            Msg::Fit => self.view.fit(),
        }
        Vec::new()
    }

    /// **FC-9 render** — a **pure** function of `&self`: it paints the toolbar + the
    /// pan/zoom canvas (chips, rings, badges, edges) and *returns* the [`Msg`]s the
    /// interactions produced (pan drag, scroll-zoom, click-select, ⊙ fit). It MUST
    /// NOT mutate the [`GraphView`] Model; the
    /// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
    /// returned messages through [`update`](Self::update). The only `&self`-interior
    /// mutation is the ephemeral `last_origin` paint cache (FC-7), outside the Model.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        let th = facett_core::theme(ui);

        ui.horizontal_wrapped(|ui| {
            ui.label(
                egui::RichText::new(format!("{} nodes · {} edges", self.model.nodes.len(), self.model.edges.len()))
                    .color(th.text)
                    .strong(),
            );
            ui.separator();
            ui.label(
                egui::RichText::new(format!("zoom {:.2}× · pan ({:.0},{:.0})", self.view.zoom, self.view.pan.x, self.view.pan.y))
                    .color(th.text_dim),
            );
            if ui.button("⊙ fit").clicked() {
                msgs.push(Msg::Fit);
            }
        });
        ui.separator();

        // Remember the origin the canvas will paint with, for `state_json`. This
        // mirrors `draw_graph`'s own `rect.center() + pan` (computed before it
        // allocates), so the reported screen positions match what was painted.
        let avail = ui.available_size();
        let cursor = ui.cursor().min;
        let origin = Pos2::new(cursor.x + avail.x * 0.5, cursor.y + avail.y * 0.5) + self.view.pan;
        self.last_origin.set(Some(origin));

        // The pan/zoom canvas — the FC-9 pure twin of `draw_graph`: it allocates the
        // painter, reads the drag/scroll/click, and RETURNS the transitions as `Msg`s
        // instead of mutating `self.view` in place.
        let (resp, painter) = ui.allocate_painter(facett_core::nav::canvas_size(ui), Sense::click_and_drag());
        painter.rect_filled(resp.rect, 4.0, th.bg);
        if resp.dragged() {
            let d = resp.drag_delta();
            if d != Vec2::ZERO {
                msgs.push(Msg::PanBy(d));
            }
        }
        if resp.hovered() {
            let scroll = ui.input(|i| i.smooth_scroll_delta.y);
            if scroll != 0.0 {
                msgs.push(Msg::ZoomBy(scroll));
            }
        }
        let paint_origin = resp.rect.center() + self.view.pan;
        let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();
        if let Some(click) = click {
            // Picking goes through the SPATIAL INDEX, not a scan of every node. The
            // index rebuilds only when the projection changed, so a click costs a
            // neighbourhood query — the same discipline the map's PointGrid learned.
            let mut pick = self.pick.borrow_mut();
            pick.ensure(&self.model, paint_origin, self.view.zoom);
            match pick.pick(click, self.view.zoom) {
                Some(i) => msgs.push(Msg::Select(self.model.nodes[i].id.clone())),
                None => msgs.push(Msg::ClearSelection),
            }
        }
        // Paint reads the CURRENT (pre-update) transform — pure, no mutation. The pane
        // numbers its own models, so the resident lane keys on that, not on a hash.
        let report = paint_scene_impl(
            &painter,
            &self.model,
            &self.decorations,
            &self.view,
            paint_origin,
            th.text,
            th.accent,
            th.text_dim,
            self.opts,
            Some((ui, resp.rect)),
            Some((&self.resident, self.model_gen)),
        );
        *self.last_legibility.borrow_mut() = report;

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-graphpan::GraphPanView::view",
            "ui_render",
            !self.model.nodes.is_empty(),
            &format!("nodes={} edges={} zoom={:.2}", self.model.nodes.len(), self.model.edges.len(), self.view.zoom),
        );

        msgs
    }

    /// **Discovery-contract `local()`** — a realistic 4-node DAG (a → b, a → c,
    /// b → d, c → d) decorated with a green coverage ring on `a`, an amber badge
    /// on `b`, and an emphasis "cut" edge `d → a` (dashed, labelled), so the
    /// canvas paints real chips, rings, a badge, and an overlay edge with no host.
    pub fn local() -> Self {
        let n = |id: &str, label: &str, x: f32, y: f32, fill: Color32| GraphNode {
            id: id.into(),
            label: label.into(),
            fill,
            stroke: Color32::WHITE,
            pos: Pos2::new(x, y),
        };
        let e = |from: &str, to: &str, dashed: bool| GraphEdge {
            from: from.into(),
            to: to.into(),
            color: Color32::from_rgb(200, 200, 220),
            dashed,
            label: None,
        };
        let model = GraphModel {
            nodes: vec![
                n("a", "alpha", 0.0, 0.0, Color32::from_rgb(60, 90, 160)),
                n("b", "beta", 260.0, -90.0, Color32::from_rgb(60, 140, 90)),
                n("c", "gamma", 260.0, 90.0, Color32::from_rgb(160, 90, 60)),
                n("d", "delta", 520.0, 0.0, Color32::from_rgb(120, 90, 160)),
            ],
            edges: vec![e("a", "b", false), e("a", "c", false), e("b", "d", false), e("c", "d", false)],
        };
        let mut nodes = HashMap::new();
        nodes.insert(
            "a".to_string(),
            NodeDecoration { ring: Some(Color32::from_rgb(90, 200, 140)), ..Default::default() },
        );
        nodes.insert(
            "b".to_string(),
            NodeDecoration {
                badge: Some("⚠".into()),
                badge_color: Some(Color32::from_rgb(230, 180, 60)),
                ..Default::default()
            },
        );
        let decorations = Decorations {
            nodes,
            edges: vec![GraphEdge {
                from: "d".into(),
                to: "a".into(),
                color: Color32::from_rgb(230, 90, 90),
                dashed: true,
                label: Some("cut".into()),
            }],
        };
        Self::new("GraphPan").with_model(model).with_decorations(decorations)
    }

    /// **Discovery-contract `remote()`** — same surface, re-titled; the variant a
    /// host builds from a server-streamed model. Kept so the matrix sees both ctors.
    pub fn remote() -> Self {
        let mut v = Self::local();
        v.title = "GraphPan (remote)".into();
        v
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for GraphPanView {
    type Model = GraphView;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &GraphView {
        &self.view
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        GraphPanView::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        GraphPanView::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for GraphPanView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides
// below. **Form 3** (`custom_state_json`) because the canvas publishes a RICHER
// `state_json` than a plain `serde(state())`: the derived node/screen positions,
// edge list, ring/badge tallies, and the world→screen `origin` that facett-demo's
// mega_matrix reads are computed from the model + the recorded paint origin, not from
// the raw `GraphView` — so the default `state_json` is suppressed and this one supplied.
facett_core::impl_facet_via_elm!(GraphPanView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let lit = self.lit_set();
        let origin = self.last_origin.get();
        let mut j = serde_json::json!({
            "title": self.title,
            "node_count": self.model.nodes.len(),
            "edge_count": self.model.edges.len(),
            "decoration_edges": self.decorations.edges.len(),
            "rings": self.decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
            "badges": self.decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
            "selected": self.view.selected,
            "lit": lit.iter().cloned().collect::<Vec<_>>(),
            "transform": self.view.state_json(origin),
            "nodes": self.model.nodes.iter().map(|n| {
                let screen = origin.map(|o| self.view.project(o, n.pos));
                serde_json::json!({
                    "id": n.id,
                    "label": n.label,
                    "x": n.pos.x,
                    "y": n.pos.y,
                    "screen": screen.map(|s| serde_json::json!([s.x, s.y])),
                })
            }).collect::<Vec<_>>(),
            "edges": self.model.edges.iter().map(|e| serde_json::json!({
                "from": e.from, "to": e.to, "dashed": e.dashed,
            })).collect::<Vec<_>>(),
        });
        if let Some(o) = origin
            && let Some(obj) = j.as_object_mut()
        {
            obj.insert("origin".into(), serde_json::json!([o.x, o.y]));
        }
        // What the last painted frame's legibility work did — the observable that
        // makes "labels do not overplot" and "the GPU lane really painted" assertable
        // from outside instead of describable in a doc.
        if let Some(obj) = j.as_object_mut() {
            obj.insert("legibility".into(), self.last_legibility.borrow().state_json());
            obj.insert(
                "paint_opts".into(),
                serde_json::json!({
                    "gpu_cloud": self.opts.gpu_cloud,
                    "thin_labels": self.opts.thin_labels,
                    "thin_edges": self.opts.thin_edges,
                }),
            );
        }
        j
    }

    /// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
    /// apply it through the single [`Elm::update`](facett_core::Elm::update)
    /// mutation path. The `impl_facet_via_elm!` bridge does NOT generate an
    /// `update_json`, so without this override the JSON drive seam is a dead no-op
    /// (a headless robot's pan / zoom / select messages mutate nothing). Malformed
    /// / unknown JSON is ignored (never a panic).
    fn update_json(&mut self, msg_json: &str) {
        if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
            let _ = <Self as facett_core::Elm>::update(self, msg);
        }
    }

    fn selection_json(&self) -> serde_json::Value {
        match &self.view.selected {
            Some(id) => serde_json::json!({ "node": id, "downstream": self.lit_set().iter().cloned().collect::<Vec<_>>() }),
            None => serde_json::Value::Null,
        }
    }

    /// Themed (chips/rings follow the palette) + resizable + selectable (a node
    /// drill-in) + navigable (pan/drag + scroll-zoom + fit).
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().selectable().navigable()
    }

    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }

    // ── cross-instance clone (copy/paste BETWEEN two GraphPanViews) ───────────

    fn kind(&self) -> &'static str {
        "graphpan"
    }

    /// The portable transform: pan, zoom, and the selected node id — the fields a
    /// sibling `graphpan` can adopt to mirror this view. (The graph *model* itself
    /// is not portable; this clones the VIEW state onto whatever model the sibling
    /// holds, exactly like dragging both panes into the same pose.)
    fn portable_state(&self) -> Option<serde_json::Value> {
        Some(serde_json::json!({
            "pan": [self.view.pan.x, self.view.pan.y],
            "zoom": self.view.zoom,
            "selected": self.view.selected,
        }))
    }

    fn load_state(&mut self, state: &serde_json::Value) -> bool {
        let obj = match state.as_object() {
            Some(o) => o,
            None => return false,
        };
        // Route every field through the FC-2 `update` mutation path.
        if let Some(pan) = obj.get("pan").and_then(|p| p.as_array())
            && let (Some(x), Some(y)) = (pan.first().and_then(|v| v.as_f64()), pan.get(1).and_then(|v| v.as_f64()))
        {
            let _ = self.update(Msg::SetPan(Vec2::new(x as f32, y as f32)));
        }
        if let Some(z) = obj.get("zoom").and_then(|v| v.as_f64()) {
            let _ = self.update(Msg::SetZoom(z as f32));
        }
        // `selected` may be a string id or JSON null (clear).
        match obj.get("selected") {
            Some(serde_json::Value::String(s)) => {
                let _ = self.update(Msg::Select(s.clone()));
            }
            Some(serde_json::Value::Null) => {
                let _ = self.update(Msg::ClearSelection);
            }
            _ => {}
        }
        true
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    // `Facet` for `state_json`/`selection_json`/`title`/`ui`; `state()` is inherent.
    use facett_core::Facet;

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

    #[test]
    fn portable_state_round_trips_pan_zoom_selection_between_instances() {
        use facett_core::clipboard;
        // A is configured: panned, zoomed, a node selected.
        let mut a = GraphPanView::local();
        a.view.pan = Vec2::new(37.0, -11.0);
        a.view.zoom = 2.5;
        a.select(Some("b".to_string()));

        // Encode A's portable state through the OS-clipboard envelope.
        let env = clipboard::encode_component(a.kind(), &a.portable_state().unwrap());
        let (kind, state) = clipboard::decode_component(&env).unwrap();
        assert_eq!(kind, "graphpan");

        // B starts at the default pose; adopting A's state makes them equal.
        let mut b = GraphPanView::new("other");
        assert_ne!(b.portable_state(), a.portable_state(), "they differ before the paste");
        assert!(b.load_state(&state), "same-kind load is accepted");
        assert_eq!(b.portable_state(), a.portable_state(), "B mirrors A's pan/zoom/selection");
        assert_eq!(b.view.zoom, 2.5);
        assert_eq!(b.view.selected.as_deref(), Some("b"));
    }

    #[test]
    fn deck_copy_then_paste_clones_selection_between_two_graphpans() {
        // Integration: drive TWO REAL GraphPanViews through the actual deck
        // copy→paste seam (the type-match guard live), not load_state directly.
        use facett_core::{clipboard, FacetDeck};

        // Source deck: a panned/zoomed graphpan with node "b" SELECTED.
        let mut a = GraphPanView::local();
        a.view.pan = Vec2::new(37.0, -11.0);
        a.view.zoom = 2.5;
        a.select(Some("b".to_string()));
        let deck_a = FacetDeck::new(vec![Box::new(a)]);

        // The deck encodes the active facet's portable state into the envelope
        // that would land on the OS clipboard (same path `copy_component` puts).
        let env = deck_a
            .copy_component_envelope()
            .expect("a configured graphpan copies its portable state");

        // Destination deck: a DIFFERENT graphpan at the default pose, no selection.
        let mut deck_b = FacetDeck::new(vec![Box::new(GraphPanView::new("other"))]);
        assert!(deck_b.component_toast().is_none(), "clean start, no toast");

        // Paste through the deck seam (kind() == "graphpan" passes the guard).
        assert!(deck_b.paste_component(&env, 0.0), "same-kind paste is accepted");
        assert!(
            deck_b.component_toast().is_none(),
            "a successful paste raises no mismatch toast",
        );

        // ASSERT ON DATA (LAW 6): the destination deck's portable state now carries
        // the cloned pan/zoom + the SELECTED entity id "b", proving the selection
        // crossed the clipboard seam into the second instance's model.
        let pasted = deck_b
            .copy_component_envelope()
            .expect("the destination re-exports its now-cloned state");
        let (kind, state) = clipboard::decode_component(&pasted).expect("a valid envelope");
        assert_eq!(kind, "graphpan");
        assert_eq!(state["selected"], serde_json::json!("b"), "selection id pasted");
        assert_eq!(state["zoom"], serde_json::json!(2.5), "zoom pasted");
        assert_eq!(state["pan"], serde_json::json!([37.0, -11.0]), "pan pasted");

        // And the type-match guard holds at the deck: a `table` envelope handed to
        // this graphpan deck is REJECTED with a toast that names both kinds, and
        // leaves the just-pasted graphpan state untouched.
        let table_env = clipboard::encode_component("table", &serde_json::json!({ "rows": 9 }));
        let before = deck_b.copy_component_envelope();
        assert!(!deck_b.paste_component(&table_env, 1.0), "cross-type paste is a no-op");
        assert_eq!(deck_b.copy_component_envelope(), before, "graphpan state untouched");
        let toast = deck_b.component_toast().expect("mismatch raises a toast");
        assert!(
            toast.contains("table") && toast.contains("graphpan"),
            "toast names both kinds: {toast}",
        );
    }

    #[test]
    fn downstream_is_bfs_closure_from_seed() {
        // A → B → C, A → C. Downstream of A = {A, B, C}; of B = {B, C}.
        let model = 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")],
        };
        let from_a = downstream_of(&model, "A");
        assert!(from_a.contains("A") && from_a.contains("B") && from_a.contains("C"));
        let from_b = downstream_of(&model, "B");
        assert!(from_b.contains("B") && from_b.contains("C"));
        assert!(!from_b.contains("A"), "BFS is forward-only — A is not downstream of B");
    }

    #[test]
    fn content_extent_is_the_true_bounding_box_not_the_viewport() {
        const MARGIN: f32 = 48.0;

        let empty = GraphModel::default();
        let e0 = content_extent(&empty);
        assert_eq!(e0, Vec2::new(BOX_W, BOX_H) + Vec2::splat(2.0 * MARGIN));

        let two = GraphModel { nodes: vec![node("A", 0.0), node("B", 300.0)], edges: vec![] };
        let e = content_extent(&two);
        assert_eq!(e.x, 300.0 + BOX_W + 2.0 * MARGIN, "width spans both chips + margins");
        assert_eq!(e.y, BOX_H + 2.0 * MARGIN, "height is one chip row + margins");

        let wider = GraphModel { nodes: vec![node("A", 0.0), node("B", 900.0)], edges: vec![] };
        assert!(content_extent(&wider).x > e.x, "a wider graph reports a wider extent");
    }

    #[test]
    fn view_project_is_origin_plus_world_times_zoom() {
        // The pan/zoom transform contract: project(origin, p) = origin + p·zoom.
        let v = GraphView { pan: Vec2::new(5.0, 5.0), zoom: 2.0, selected: None, dot_px: None };
        let origin = Pos2::new(100.0, 50.0);
        // world (0,0) lands exactly on the origin.
        assert_eq!(v.project(origin, Pos2::ZERO), origin);
        // a world point at (10,20) lands at origin + (20,40) under 2× zoom.
        assert_eq!(v.project(origin, Pos2::new(10.0, 20.0)), Pos2::new(120.0, 90.0));
        // the transform's state_json reports the same projected coords a driver reads.
        let j = v.state_json(Some(origin));
        assert_eq!(j["zoom"], 2.0);
        assert_eq!(j["pan"], serde_json::json!([5.0, 5.0]));
        assert_eq!(j["project_zero"], serde_json::json!([100.0, 50.0]));
        assert_eq!(j["project_unit"], serde_json::json!([102.0, 52.0]));
    }

    #[test]
    fn view_fit_and_clear_reset_state() {
        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"));
        v.clear_selection();
        assert!(v.selected.is_none());
    }

    /// FC-3: the `GraphView` Model round-trips byte-for-byte through serde JSON —
    /// pan as `[x, y]`, zoom, and the selected id, with no egui `serde` feature.
    #[test]
    fn graphview_model_serde_round_trips() {
        let v = GraphView { pan: Vec2::new(-7.5, 3.25), zoom: 1.75, selected: Some("d".into()), dot_px: None };
        let json = serde_json::to_value(&v).unwrap();
        assert_eq!(json["pan"], serde_json::json!([-7.5, 3.25]), "pan (a Vec2) serialises as [x, y]");
        assert_eq!(json["zoom"], 1.75);
        assert_eq!(json["selected"], "d");
        let back: GraphView = serde_json::from_value(json).unwrap();
        assert_eq!(back, v, "serde(GraphView) -> GraphView round-trips");
    }

    /// inject-assert (LAW 6): drive a headless render of the local canvas, then
    /// assert `state_json` reports the laid-out nodes, the decorations, the live
    /// transform, AND the exact on-screen position of a known node under pan/zoom.
    #[test]
    fn local_canvas_render_reports_nodes_decorations_and_screen_transform() {
        let mut v = GraphPanView::local();
        assert_eq!(<GraphPanView as Facet>::title(&v), "GraphPan");

        // Render headless at a known size so `last_origin` is populated.
        let ctx = egui::Context::default();
        let input = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0))),
            ..Default::default()
        };
        ctx.run_ui(input, |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                v.ui(ui);
            });
        }).textures_delta.clear();

        let j = v.state_json();
        assert_eq!(j["node_count"], 4);
        assert_eq!(j["edge_count"], 4);
        assert_eq!(j["decoration_edges"], 1, "the dashed cut edge");
        assert_eq!(j["rings"], 1, "a green coverage ring on `a`");
        assert_eq!(j["badges"], 1, "a ⚠ badge on `b`");

        // The transform painted at a real origin → every node has a screen pos.
        let origin = j["origin"].as_array().expect("origin recorded after render");
        let (ox, oy) = (origin[0].as_f64().unwrap() as f32, origin[1].as_f64().unwrap() as f32);
        let nodes = j["nodes"].as_array().unwrap();
        // node `a` is at world (0,0), zoom 1, pan 0 → it lands ON the origin.
        let a = nodes.iter().find(|n| n["id"] == "a").unwrap();
        let a_screen = a["screen"].as_array().unwrap();
        assert!((a_screen[0].as_f64().unwrap() as f32 - ox).abs() < 0.001, "node a x = origin x");
        assert!((a_screen[1].as_f64().unwrap() as f32 - oy).abs() < 0.001, "node a y = origin y");
        // node `d` is at world (520,0), zoom 1 → it lands at origin.x + 520.
        let d = nodes.iter().find(|n| n["id"] == "d").unwrap();
        let d_screen = d["screen"].as_array().unwrap();
        assert!((d_screen[0].as_f64().unwrap() as f32 - (ox + 520.0)).abs() < 0.001, "node d is 520px right of origin");

        // select `b` → lit downstream is {b, d}.
        v.select(Some("b".into()));
        let j2 = v.state_json();
        assert_eq!(j2["selected"], "b");
        let lit: BTreeSet<String> =
            j2["lit"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
        assert_eq!(lit, ["b", "d"].iter().map(|s| s.to_string()).collect::<BTreeSet<_>>());
        assert_eq!(v.selection_json()["node"], "b");
    }

    #[test]
    fn local_and_remote_ctors_seed_a_real_canvas() {
        let v = GraphPanView::local();
        assert_eq!(v.model.nodes.len(), 4);
        assert_eq!(<GraphPanView as Facet>::title(&GraphPanView::remote()), "GraphPan (remote)");
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness and assert the resulting `GraphView` transform — no egui, no GPU. ──

    #[test]
    fn harness_snapshot_drives_pan_zoom_and_selection() {
        use facett_core::harness;
        let mut v = GraphPanView::local();

        // Pan deltas accumulate; selection is observable — both in one Vec<Msg>.
        let snap = harness::snapshot(
            &mut v,
            [Msg::PanBy(Vec2::new(10.0, -4.0)), Msg::PanBy(Vec2::new(2.0, 1.0)), Msg::Select("b".into())],
        );
        assert_eq!(snap.pan, Vec2::new(12.0, -3.0), "pan deltas accumulate");
        assert_eq!(snap.selected.as_deref(), Some("b"), "selection is observable headlessly");
        assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");

        // Scroll-zoom applies the `1 + delta·0.001` factor, clamped to 0.25..4.0.
        let snap = harness::snapshot(&mut v, [Msg::SetZoom(1.0), Msg::ZoomBy(500.0)]);
        assert!((snap.zoom - 1.5).abs() < 1e-6, "zoom *= 1 + 500·0.001 = 1.5, got {}", snap.zoom);
        // A huge scroll clamps at the 4.0 ceiling.
        let snap = harness::snapshot(&mut v, [Msg::SetZoom(4.0), Msg::ZoomBy(100000.0)]);
        assert_eq!(snap.zoom, 4.0, "zoom clamps at the 4.0 ceiling");

        // Fit resets pan+zoom but keeps the selection; ClearSelection then wipes it.
        let snap = harness::snapshot(&mut v, [Msg::Fit]);
        assert_eq!(snap.pan, Vec2::ZERO);
        assert_eq!(snap.zoom, 1.0);
        assert_eq!(snap.selected.as_deref(), Some("b"), "fit keeps the selection");
        let snap = harness::snapshot(&mut v, [Msg::ClearSelection]);
        assert_eq!(snap.selected, None, "ClearSelection empties the selection");
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        use facett_core::harness;
        let mut v = GraphPanView::local();
        // FC-8: the canvas does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut v, [Msg::PanBy(Vec2::new(3.0, 3.0)), Msg::Select("a".into())]);
        assert!(effects.is_empty(), "FC-8: graphpan emits no Effects");
        // FC-3: the observable Model (the GraphView transform) round-trips through serde.
        let json = serde_json::to_value(v.state()).unwrap();
        assert_eq!(json["pan"], serde_json::json!([3.0, 3.0]));
        assert_eq!(json["zoom"], 1.0);
        assert_eq!(json["selected"], "a");
        let back: GraphView = serde_json::from_value(json).unwrap();
        assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
    }

    #[test]
    fn public_fit_and_select_route_through_update() {
        // The public affordances are thin wrappers over the FC-2 mutation path.
        let mut v = GraphPanView::local();
        let _ = v.update(Msg::PanBy(Vec2::new(9.0, 9.0)));
        let _ = v.update(Msg::SetZoom(3.0));
        v.select(Some("c".into()));
        assert_eq!(v.state().selected.as_deref(), Some("c"));
        v.fit();
        assert_eq!(v.state().pan, Vec2::ZERO, "fit resets pan via update");
        assert_eq!(v.state().zoom, 1.0, "fit resets zoom via update");
        assert_eq!(v.state().selected.as_deref(), Some("c"), "fit keeps selection");
        v.select(None);
        assert_eq!(v.state().selected, None, "select(None) clears via update");
    }
}