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

nordisk/facett

facett-graphview/src/decorated_view.rs

//! **DecoratedGraphView** — the egui Facet that paints a [`GraphModel`] under a
//! caller-supplied [`Decorations`] overlay (per-node status **rings** + corner
//! **badges**, plus emphasis **edges** drawn on top), with pan/drag + scroll-zoom
//! and click-to-select (lighting the clicked node's downstream subtree). It is
//! the egui twin of nornir's `src/viz/graph_render.rs::draw_graph` — the same
//! decorations layer the existing `facett-graphview` render model lacked a *view*
//! for (the CPU/GPU rasterizer renders to RGBA; this is the interactive egui
//! pane). Domain-agnostic: facett never learns what a "release gate" is — rings,
//! badges, and cut edges are opaque [`Decorations`].
//!
//! Canonical [`Facet`]: `local()`/`remote()`, `state_json()` (the painted nodes +
//! their decorations + the lit downstream set), `selection_json()`, `caps()`.

use std::collections::{BTreeSet, HashMap, VecDeque};

use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::nav::DrillNav;
use facett_core::{FacetCaps, Semantics};
use serde::{Deserialize, Serialize};

use crate::model::{Color, Decorations, GraphEdge, GraphModel, GraphNode, NodeDecoration, Pos, BOX_H, BOX_W};

/// The downstream-reachable set (BFS over forward edges) from `seed`, in node-id
/// space — the visual twin of an `arch trace`, used to dim everything off the lit
/// path when a node is selected.
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
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the pane through, the same effect a canvas
/// click/drag/scroll produces. Applied by [`DecoratedGraphView::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Select a node by stable id (lights its downstream subtree), or `None` to clear.
    Select(Option<String>),
    /// Pan the camera by a screen-space delta (the drag gesture).
    PanBy(f32, f32),
    /// Zoom by a scroll amount (multiplicative, clamped) — the scroll-wheel gesture.
    ZoomBy(f32),
    /// Reset pan + zoom (the ⊙ fit affordance).
    Fit,
    /// **Navigate back** up one drill level (the right-click gesture) — pops the nav
    /// history to the parent level. No-op at the root.
    Back,
    /// **Drill down** into node `id` — descend a level and unfold its children in place.
    Drill(String),
    /// **Unfold** node `id` in place — show its children WITHOUT closing it/ancestors.
    Unfold(String),
    /// **Fold** node `id` — hide its children again (the node itself stays visible).
    Fold(String),
    /// What a click does to the camera: 📌 Stay (never moves) or 🎯 Focus (flies the
    /// clicked node to the centre). See [`facett_core::fly`].
    SetNavMode(facett_core::fly::NavMode),
    /// **Fly** the camera to `(zoom, pan_x, pan_y)` on the smooth zoom-and-pan path,
    /// starting at `now` (seconds, the UI clock), for a canvas `viewport_w` wide. A
    /// flight already under way is retargeted from where the camera IS.
    FlyTo { zoom: f32, pan_x: f32, pan_y: f32, now: f64, viewport_w: f32 },
    /// Fly node `id` to the centre of a `viewport`-sized canvas at a readable zoom that
    /// frames it and its children (🎯 Focus).
    ///
    /// `viewport` is the VISIBLE part of the canvas (the canvas ∩ its clip), `offset`
    /// its centre minus the canvas centre (screen px): the node lands at the centre of
    /// what the viewer sees, not of a canvas a scroll area cut.
    FocusOn { id: String, now: f64, viewport: (f32, f32), offset: (f32, f32) },
    /// Fly to the whole model framed in a `viewport`-sized canvas, centred (⊙ fit).
    /// `viewport` / `offset` as in [`Msg::FocusOn`].
    FitView { now: f64, viewport: (f32, f32), offset: (f32, f32) },
    /// Advance a flight to `now`.
    Tick(f64),
}

/// Margin (px) ⊙ fit and 🎯 Focus leave inside the canvas.
pub const FIT_MARGIN: f32 = 24.0;

/// The zoom 🎯 Focus lands at when the node and its children fit: labels readable.
pub const FOCUS_ZOOM: f32 = 1.0;

/// A 2D flight: the shared path + the canvas width it maps zoom with.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Flight2D {
    pub flight: facett_core::fly::Flight,
    pub viewport_w: f32,
}

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

/// **The complete observable state (FC-1 / FC-3)** of a [`DecoratedGraphView`], in one
/// serializable, round-trippable struct: the laid-out [`GraphModel`], the
/// caller-supplied [`Decorations`] overlay, the selected node (keyed on its stable
/// id, FC-5), and the camera (pan + zoom). [`DecoratedGraphView::state`] hands back a
/// `&GraphViewState`; a headless driver ([`facett_core::harness`]) snapshots it after
/// feeding a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphViewState {
    /// The graph to paint (nodes laid out in world space + edges).
    pub model: GraphModel,
    /// The caller-supplied ring/badge + emphasis-edge overlay.
    pub decorations: Decorations,
    /// The selected node id, if any (lights its downstream subtree).
    pub selected: Option<String>,
    /// Camera pan (screen-space) — the x component.
    pub pan_x: f32,
    /// Camera pan (screen-space) — the y component.
    pub pan_y: f32,
    /// Camera zoom (world→screen scale), clamped to `[0.25, 4.0]` by interaction.
    pub zoom: f32,
    /// The shared drill-down navigator (levels + history/back + in-place unfold),
    /// reused from [`facett_core::nav`] (L5) so the 2D pane and the 3D Code Vault
    /// navigate identically. Seeded fully-unfolded when a model is set, so the pane
    /// shows the whole graph by default; folding / right-click-back are the opt-ins.
    #[serde(default = "DrillNav::warehouse")]
    pub nav: DrillNav,
    /// What a click does to the camera (📌 Stay by default).
    #[serde(default)]
    pub nav_mode: facett_core::fly::NavMode,
    /// The camera flight under way, if any (UI-time, never serialized).
    #[serde(skip)]
    pub flight: Option<Flight2D>,
}

impl Default for GraphViewState {
    fn default() -> Self {
        Self {
            model: GraphModel::default(),
            decorations: Decorations::default(),
            selected: None,
            pan_x: 0.0,
            pan_y: 0.0,
            zoom: 1.0,
            nav: DrillNav::warehouse(),
            nav_mode: facett_core::fly::NavMode::Stay,
            flight: None,
        }
    }
}

/// An interactive, decorated pan/zoom graph pane Facet.
pub struct DecoratedGraphView {
    title: String,
    /// All observable state (FC-3).
    state: GraphViewState,
    /// The screen-space rect the last [`view`](Self::view) painted its canvas into
    /// (interior-mutable so the FC-9 `view(&self)` stays pure). NOT part of
    /// [`GraphViewState`] — it is a paint artefact, not observable model state. A
    /// host that embeds this pane reads it (with [`world_to_screen`](Self::world_to_screen))
    /// to map a node's world position onto the screen for out-of-band hit-testing —
    /// e.g. a robot driver synthesising a click on a specific node.
    paint_rect: std::cell::Cell<Option<egui::Rect>>,
    /// Legibility + GPU opt-ins. Default all-off ⇒ the historical pane, pixel for pixel.
    opts: crate::legible::ViewOpts,
    /// The spatial pick index — so a click on a 100 000-node graph queries a
    /// neighbourhood instead of scanning every node. Ephemeral cache (FC-7).
    pick: std::cell::RefCell<crate::legible::PickIndex>,
    /// What the last painted frame's legibility work did (published in `state_json`).
    last_legibility: std::cell::RefCell<crate::legible::ViewReport>,
    /// 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,
    /// Bumped by every drill-navigator message (fold / unfold / drill / back): the unfold
    /// decides which nodes the resident cloud holds.
    vis_gen: u64,
    /// Bumped by every [`with_decorations`](Self::with_decorations): the resident lane
    /// keys its "which nodes carry a ring or badge" list on it.
    deco_gen: u64,
    /// `(rings, badges)` of the current decorations — counted once in
    /// [`with_decorations`](Self::with_decorations), not per frame in the header (a
    /// coderoom decorates EVERY node with its size, 100 000 entries at its ceiling).
    deco_counts: (usize, usize),
    /// The resident cloud lane's per-model work (ephemeral, FC-7).
    #[cfg(feature = "gpu-cloud")]
    resident: std::cell::RefCell<crate::resident::ResidentCache>,
    /// Whether the nav mode was set (by the viewer's remembered choice, the toggle or a
    /// host) — until then the first frame loads the viewer's remembered choice.
    nav_mode_set: std::cell::Cell<bool>,
}

impl DecoratedGraphView {
    /// An empty pane titled `title`.
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            state: GraphViewState::default(),
            paint_rect: std::cell::Cell::new(None),
            opts: crate::legible::ViewOpts::default(),
            pick: std::cell::RefCell::new(crate::legible::PickIndex::default()),
            last_legibility: std::cell::RefCell::new(crate::legible::ViewReport::default()),
            model_gen: 0,
            vis_gen: 0,
            deco_gen: 0,
            deco_counts: (0, 0),
            #[cfg(feature = "gpu-cloud")]
            resident: std::cell::RefCell::new(crate::resident::ResidentCache::default()),
            nav_mode_set: std::cell::Cell::new(false),
        }
    }

    /// Opt into the **shared instanced GPU cloud** (the renderer `facett-graph3d`
    /// installs). Safe unconditionally: with no renderer installed, or without the
    /// `gpu-cloud` feature, the CPU painter runs unchanged rather than a blank pane.
    #[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
    }

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

    /// The screen-space rect the last [`view`](Self::view) drew the graph canvas
    /// into, or `None` before the first paint. The projection origin is
    /// `rect.center() + (pan_x, pan_y)`.
    pub fn last_paint_rect(&self) -> Option<egui::Rect> {
        self.paint_rect.get()
    }

    /// Project a world-space [`Pos`] to the screen using the last painted canvas
    /// rect + the current camera (pan/zoom), matching [`view`](Self::view)'s own
    /// projection exactly. `None` until the first paint recorded a rect. This is the
    /// seam a host/robot uses to synthesise a pointer event on a given node.
    pub fn world_to_screen(&self, p: Pos) -> Option<egui::Pos2> {
        let rect = self.paint_rect.get()?;
        let zoom = if self.state.zoom > 0.0 { self.state.zoom } else { 1.0 };
        let origin = rect.center() + egui::vec2(self.state.pan_x, self.state.pan_y);
        Some(origin + egui::vec2(p.x, p.y) * zoom)
    }

    /// Set the graph model (nodes already laid out in world space + edges). Seeds the
    /// drill navigator fully-unfolded over this model so the whole graph shows by
    /// default (folding is the opt-in restriction).
    pub fn with_model(mut self, model: GraphModel) -> Self {
        let mut nav = DrillNav::warehouse();
        nav.unfold_all(model.nodes.iter().map(|n| n.id.as_str()));
        self.state.nav = nav;
        self.state.model = model;
        self.model_gen = self.model_gen.wrapping_add(1);
        self
    }

    /// Drill-navigator roots: nodes with no incoming edge (the top of the dep DAG).
    fn nav_roots(&self) -> Vec<String> {
        let incoming: BTreeSet<&str> = self.state.model.edges.iter().map(|e| e.to.as_str()).collect();
        self.state.model.nodes.iter().filter(|n| !incoming.contains(n.id.as_str())).map(|n| n.id.clone()).collect()
    }

    /// **The forward-adjacency index** — every node's children (forward edge targets)
    /// in ONE pass over the edge list. The same index [`downstream_of`] builds, for
    /// the same reason (LAW 5: one idiom, not two).
    ///
    /// It exists because the child lookup used to be a `children_of(id)` that FILTERED
    /// THE WHOLE EDGE LIST by string comparison, called once per expanded node — an
    /// `O(V · E)` frame. Under a few hundred nodes that is free; at this engine's
    /// declared 100 000-node ceiling it is ~10^10 string compares and the host stops
    /// repainting altogether (measured on oden 2026-09-03: 4 904 ms for a 20 000-node
    /// unfold, cost ratio 3.99 across a doubling — see `tests/unfold_scales.rs`).
    /// Borrowed `&str`, so building it allocates nothing per node.
    fn forward_index(&self) -> HashMap<&str, Vec<&str>> {
        let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
        for e in &self.state.model.edges {
            adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
        }
        adj
    }

    /// The set of node ids **visible** under the current progressive unfold (roots +
    /// every expanded node's children; ancestors always retained). Default = all.
    ///
    /// The unfold walk is unchanged — [`DrillNav::visible`] still asks for a node's
    /// children and still consults `expanded`, so a folded subtree stays folded. Only
    /// the LOOKUP moved, from a rescan per node to [`forward_index`](Self::forward_index).
    pub fn visible_ids(&self) -> BTreeSet<String> {
        let adj = self.forward_index();
        self.state
            .nav
            .visible(self.nav_roots(), |id| adj.get(id).map_or_else(Vec::new, |cs| cs.iter().map(|c| (*c).to_string()).collect()))
    }

    /// Read-only access to the drill navigator (levels / history / expanded).
    pub fn nav(&self) -> &DrillNav {
        &self.state.nav
    }

    /// Set the decorations overlay (rings/badges + emphasis edges).
    pub fn with_decorations(mut self, decorations: Decorations) -> Self {
        self.deco_counts = (
            decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
            decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
        );
        self.state.decorations = decorations;
        self.deco_gen = self.deco_gen.wrapping_add(1);
        self
    }

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

    /// **FC-3** — read the complete observable state at any frame boundary.
    pub fn state(&self) -> &GraphViewState {
        &self.state
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. The canvas click/drag/scroll
    /// and the fit button all route their [`Msg`] here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::Select(id) => self.state.selected = id,
            Msg::PanBy(dx, dy) => {
                // a drag takes the camera from wherever the flight had it
                self.state.flight = None;
                self.state.pan_x += dx;
                self.state.pan_y += dy;
            }
            Msg::ZoomBy(scroll) => {
                self.state.flight = None;
                self.state.zoom = (self.state.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
            }
            Msg::SetNavMode(m) => {
                self.state.nav_mode = m;
                self.nav_mode_set.set(true);
            }
            Msg::FlyTo { zoom, pan_x, pan_y, now, viewport_w } => self.fly_to(zoom, pan_x, pan_y, now, viewport_w),
            Msg::FocusOn { id, now, viewport, offset } => {
                if let Some((z, px, py)) = self.focus_camera(&id, viewport) {
                    self.fly_to(z, px + offset.0, py + offset.1, now, viewport.0);
                }
            }
            Msg::FitView { now, viewport, offset } => {
                if let Some((z, px, py)) = self.fit_camera(viewport, FIT_MARGIN) {
                    self.fly_to(z, px + offset.0, py + offset.1, now, viewport.0);
                }
            }
            Msg::Tick(now) => {
                if let Some(f) = self.state.flight {
                    let (c, w) = f.flight.at(now);
                    let (pan, zoom) = facett_core::fly::to_pan_zoom(c, w, f.viewport_w);
                    (self.state.pan_x, self.state.pan_y, self.state.zoom) = (pan[0], pan[1], zoom);
                    if f.flight.done(now) {
                        self.state.flight = None;
                    }
                }
            }
            Msg::Fit => {
                self.state.flight = None;
                self.state.pan_x = 0.0;
                self.state.pan_y = 0.0;
                self.state.zoom = 1.0;
            }
            Msg::Back => {
                self.state.nav.back();
                self.vis_gen += 1;
            }
            Msg::Drill(id) => {
                self.state.nav.drill(id);
                self.vis_gen += 1;
            }
            Msg::Unfold(id) => {
                self.state.nav.unfold(id);
                self.vis_gen += 1;
            }
            Msg::Fold(id) => {
                self.state.nav.fold(&id);
                self.vis_gen += 1;
            }
        }
        Vec::new()
    }

    /// Reset pan + zoom (the ⊙ fit affordance). A thin wrapper over the FC-2
    /// [`update`](Self::update) mutation path.
    ///
    /// Note this **recentres at zoom 1**; it does not frame the model. For a laid-out
    /// graph wider than the widget — which is every graph past a few dozen nodes — use
    /// [`fit_model`](Self::fit_model).
    pub fn fit(&mut self) {
        let _ = self.update(Msg::Fit);
    }

    /// **Frame the whole model in a `viewport`-sized widget** — the honest "⊙ fit" for a
    /// laid-out graph, and the one this crate was missing.
    ///
    /// [`fit`](Self::fit) only recentres at zoom 1, and the interactive
    /// [`Msg::ZoomBy`] clamps at `0.25`, so a host had **no way at all** to frame a
    /// graph whose world box is more than 4× the widget. Measured on korp
    /// (2026-09-01): a live 132-node FalkorDB scene laid out at one chip of clearance
    /// per node spans ~3 500 world units; in a 900 × 640 pane it added **0.006** of ink
    /// over the same pane holding an EMPTY model — the pane was chrome and a handful of
    /// stray chips. With this it frames.
    ///
    /// The math is `facett_graphpan::GraphView::fit_bounds` — the same camera model,
    /// the proven one (L5, deliberately not a second derivation): set `zoom` so the
    /// world box fills the viewport inside `margin` px, and `pan` so the box centre
    /// lands at the widget centre. The zoom floor here is `0.001`, not the interactive
    /// `0.25`: a fit is a computed answer, not a gesture, and refusing to zoom out far
    /// enough to see the data is not a safety rail.
    ///
    /// No-op on an empty model (nothing to frame), leaving the camera untouched.
    pub fn fit_model(&mut self, viewport: egui::Vec2, margin: f32) {
        if let Some((z, px, py)) = self.fit_camera((viewport.x, viewport.y), margin) {
            self.state.flight = None;
            (self.state.zoom, self.state.pan_x, self.state.pan_y) = (z, px, py);
        }
    }

    /// The camera `(zoom, pan_x, pan_y)` that frames the whole model, CENTRED, in a
    /// `viewport`-sized canvas with `margin` px around it. `None` on an empty model.
    #[must_use]
    pub fn fit_camera(&self, viewport: (f32, f32), margin: f32) -> Option<(f32, f32, f32)> {
        let (min_x, min_y, max_x, max_y) = self.state.model.world_bounds()?;
        let bw = (max_x - min_x).max(1.0);
        let bh = (max_y - min_y).max(1.0);
        let zoom = ((viewport.0 - 2.0 * margin) / bw).min((viewport.1 - 2.0 * margin) / bh).clamp(0.001, 4.0);
        Some((zoom, -(min_x + max_x) * 0.5 * zoom, -(min_y + max_y) * 0.5 * zoom))
    }

    /// The 🎯 Focus camera for node `id` in a `viewport`-sized canvas: the node EXACTLY
    /// at the centre, at [`FOCUS_ZOOM`] — or less, so the node and its (visible)
    /// children all fit around it. `None` when there is no such node.
    #[must_use]
    pub fn focus_camera(&self, id: &str, viewport: (f32, f32)) -> Option<(f32, f32, f32)> {
        let node = self.state.model.nodes.iter().find(|n| n.id == id)?;
        let visible = self.visible_ids();
        let pos: HashMap<&str, Pos> = self.state.model.nodes.iter().map(|n| (n.id.as_str(), n.pos)).collect();
        let (mut dx, mut dy) = (0.0f32, 0.0f32);
        for e in self.state.model.edges.iter().filter(|e| e.from == id && visible.contains(&e.to)) {
            if let Some(p) = pos.get(e.to.as_str()) {
                dx = dx.max((p.x - node.pos.x).abs());
                dy = dy.max((p.y - node.pos.y).abs());
            }
        }
        let half = |v: f32| (v * 0.5 - FIT_MARGIN).max(1.0);
        let zoom = FOCUS_ZOOM
            .min(half(viewport.0) / (dx + BOX_W * 0.5))
            .min(half(viewport.1) / (dy + BOX_H * 0.5))
            .clamp(0.001, 4.0);
        Some((zoom, -node.pos.x * zoom, -node.pos.y * zoom))
    }

    /// Start (or retarget) a flight to `(zoom, pan_x, pan_y)` from the camera as it is.
    fn fly_to(&mut self, zoom: f32, pan_x: f32, pan_y: f32, now: f64, viewport_w: f32) {
        use facett_core::fly;
        let (c1, w1) = fly::from_pan_zoom([pan_x, pan_y], zoom, viewport_w);
        let flight = match self.state.flight {
            Some(f) => {
                // retarget from where the camera IS (in the old flight's mapping)
                let (c, w) = f.flight.at(now);
                let (pan, z) = fly::to_pan_zoom(c, w, f.viewport_w);
                let (c0, w0) = fly::from_pan_zoom(pan, z, viewport_w);
                fly::Flight::new(now, c0, w0, c1, w1)
            }
            None => {
                let (c0, w0) = fly::from_pan_zoom([self.state.pan_x, self.state.pan_y], self.state.zoom, viewport_w);
                fly::Flight::new(now, c0, w0, c1, w1)
            }
        };
        self.state.flight = Some(Flight2D { flight, viewport_w });
    }

    /// Is the camera flying?
    #[must_use]
    pub fn flying(&self) -> bool {
        self.state.flight.is_some()
    }

    /// The nav mode (📌 Stay / 🎯 Focus).
    #[must_use]
    pub fn nav_mode(&self) -> facett_core::fly::NavMode {
        self.state.nav_mode
    }

    /// Whether the nav mode was set (remembered choice loaded, toggled, or a host's).
    #[must_use]
    pub fn nav_mode_set(&self) -> bool {
        self.nav_mode_set.get()
    }

    /// Put the camera at `(zoom, pan_x, pan_y)` — what [`state`](Self::state) reports —
    /// so a host that REBUILDS the pane (a new scene for the same graph: the coderoom's
    /// what-breaks colours) keeps the user's view instead of jumping back to zoom 1.
    /// The zoom is clamped to [`fit_model`](Self::fit_model)'s range.
    pub fn set_camera(&mut self, zoom: f32, pan_x: f32, pan_y: f32) {
        self.state.zoom = if zoom.is_finite() { zoom.clamp(0.001, 4.0) } else { 1.0 };
        self.state.pan_x = if pan_x.is_finite() { pan_x } else { 0.0 };
        self.state.pan_y = if pan_y.is_finite() { pan_y } else { 0.0 };
    }

    /// 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 _ = self.update(Msg::Select(id));
    }

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

    /// **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 pane
    /// 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: Color| GraphNode {
            id: id.into(),
            label: label.into(),
            fill,
            stroke: Color::WHITE,
            pos: Pos::new(x, y),
        };
        let e = |from: &str, to: &str, dashed: bool| GraphEdge {
            from: from.into(),
            to: to.into(),
            color: Color::rgb(200, 200, 220),
            dashed,
            label: None,
        };
        let model = GraphModel {
            nodes: vec![
                n("a", "alpha", 0.0, 0.0, Color::rgb(60, 90, 160)),
                n("b", "beta", 260.0, -90.0, Color::rgb(60, 140, 90)),
                n("c", "gamma", 260.0, 90.0, Color::rgb(160, 90, 60)),
                n("d", "delta", 520.0, 0.0, Color::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(Color::rgb(90, 200, 140)), ..Default::default() },
        );
        nodes.insert(
            "b".to_string(),
            NodeDecoration {
                badge: Some("⚠".into()),
                badge_color: Some(Color::rgb(230, 180, 60)),
                ..Default::default()
            },
        );
        let decorations = Decorations {
            nodes,
            edges: vec![GraphEdge {
                from: "d".into(),
                to: "a".into(),
                color: Color::rgb(230, 90, 90),
                dashed: true,
                label: Some("cut".into()),
            }],
        };
        Self::new("DecoratedGraph").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 = "DecoratedGraph (remote)".into();
        v
    }

    fn col(c: Color) -> egui::Color32 {
        c.into()
    }
}

impl DecoratedGraphView {
    /// The copyable text: the selected node's `label` when one is selected, else
    /// every node label, one per line.
    pub fn copy_text(&self) -> Option<String> {
        if self.state.model.nodes.is_empty() {
            return None;
        }
        if let Some(id) = self.state.selected.as_deref()
            && let Some(n) = self.state.model.nodes.iter().find(|n| n.id == id)
        {
            return Some(n.label.clone());
        }
        Some(self.state.model.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
    }
}

// ── typed copy (§16) — read-only: selected node label or the label list ───────
impl CopySource for DecoratedGraphView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<ClipPayload> {
        self.copy_text().map(ClipPayload::Text)
    }
}

impl DecoratedGraphView {
    /// **FC-9 render** — a **pure** function of `&self`: it paints the header, the
    /// edges + decorated chips, and *returns* the [`Msg`]s the interactions produced
    /// (fit / pan / zoom / node-select). It MUST NOT mutate the [`GraphViewState`];
    /// the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
    /// returned messages through [`update`](Self::update). One-frame latency on the
    /// camera + selection is the standard immediate-mode Elm trade (a repaint follows).
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        let mut fit_clicked = false;
        let now = ui.input(|i| i.time);
        // The viewer's remembered 📌 Stay / 🎯 Focus, on the first frame of a pane nobody
        // set it on (UI-local memory, one choice for every graph pane).
        if !self.nav_mode_set.get() {
            let m = facett_core::fly::NavMode::remembered(ui.ctx());
            msgs.push(Msg::SetNavMode(m));
        }
        // A flight under way: advance it, and keep repainting only while it flies.
        if self.state.flight.is_some() {
            msgs.push(Msg::Tick(now));
            ui.ctx().request_repaint();
        }
        let th = facett_core::theme(ui);
        // Effective zoom for this frame's projection: never paint at a non-positive
        // scale. Read-only — the stored zoom is kept valid by `update`'s clamp.
        let zoom = if self.state.zoom > 0.0 { self.state.zoom } else { 1.0 };

        // ── header: counts + a fit button ─────────────────────────────────
        ui.horizontal_wrapped(|ui| {
            ui.label(
                egui::RichText::new(format!("{} nodes · {} edges", self.state.model.nodes.len(), self.state.model.edges.len()))
                    .color(th.text)
                    .strong(),
            );
            ui.separator();
            ui.label(
                egui::RichText::new(format!("{} ring(s) · {} badge(s)", self.deco_counts.0, self.deco_counts.1))
                .color(th.text_dim),
            );
            if ui.button("⊙ fit").on_hover_text("frame the whole graph, centred").clicked() {
                fit_clicked = true;
            }
            if let Some(m) = facett_core::fly::NavMode::toggle(ui, self.state.nav_mode) {
                msgs.push(Msg::SetNavMode(m));
            }
        });
        ui.separator();

        if self.state.model.nodes.is_empty() {
            // The empty-scene state carries the stable UI error code
            // `facet-graphview-1` (`facett_core::errcode`) on its a11y atom, so tests +
            // consumers react to the CODE, not a matched string — and the pane is never
            // a silent blank canvas.
            let resp = ui.label(egui::RichText::new("no scene to lay out").color(th.text_dim));
            resp.widget_info(|| {
                facett_core::a11y::Semantics::error("no scene to lay out")
                    .error_code("facet-graphview-1")
                    .widget_info()
            });
        }

        let (resp, painter) = ui.allocate_painter(facett_core::nav::canvas_size(ui), egui::Sense::click_and_drag());
        // ⊙ fit and 🎯 Focus centre on what the viewer SEES: the canvas ∩ its clip (a
        // canvas inside a scroll area or a collapsing section is partly off-screen).
        let seen = resp.rect.intersect(ui.clip_rect());
        let seen = if seen.width() > 1.0 && seen.height() > 1.0 { seen } else { resp.rect };
        let viewport = (seen.width(), seen.height());
        let offset = (seen.center().x - resp.rect.center().x, seen.center().y - resp.rect.center().y);
        if fit_clicked {
            msgs.push(Msg::FitView { now, viewport, offset });
        }
        // Record the canvas rect so an embedding host can map world→screen for
        // out-of-band hit-testing (see `world_to_screen`). Interior-mutable: FC-9
        // keeps `view(&self)` pure w.r.t. the observable model.
        self.paint_rect.set(Some(resp.rect));
        painter.rect_filled(resp.rect, 4.0, th.bg);
        if resp.dragged() {
            let d = resp.drag_delta();
            msgs.push(Msg::PanBy(d.x, d.y));
        }
        if resp.hovered() {
            let scroll = ui.input(|i| i.smooth_scroll_delta.y);
            if scroll != 0.0 {
                msgs.push(Msg::ZoomBy(scroll));
            }
        }
        let origin = resp.rect.center() + egui::vec2(self.state.pan_x, self.state.pan_y);
        let project = |p: Pos| origin + egui::vec2(p.x, p.y) * zoom;

        // Right-click = navigate BACK up one drill level (the nav-history pop).
        if resp.secondary_clicked() {
            msgs.push(Msg::Back);
        }
        // left-click-to-select.
        if let Some(click) = resp.clicked().then(|| resp.interact_pointer_pos()).flatten() {
            // Picking goes through the SPATIAL INDEX, not a scan of every node: the
            // index is rebuilt only when the camera actually moved, so a pointer event
            // costs a neighbourhood query. `PickIndex` projects with the same affine
            // this frame paints with (origin = rect.centre + pan), and `tests/legibility.rs`
            // sweeps probes across both to prove the answers are identical.
            let cam = crate::model::Camera {
                pan_x: origin.x,
                pan_y: origin.y,
                zoom,
            };
            let mut pick = self.pick.borrow_mut();
            pick.ensure(&self.state.model, &cam);
            let hit = pick
                .pick(&cam, resp.rect, click)
                .map(|i| self.state.model.nodes[i].id.clone());
            // 📌 Stay: a click never moves the camera. 🎯 Focus: it flies the node to the
            // centre (a click mid-flight retargets from where the camera is).
            if let (facett_core::fly::NavMode::Focus, Some(id)) = (self.state.nav_mode, hit.as_ref()) {
                msgs.push(Msg::FocusOn { id: id.clone(), now, viewport, offset });
            }
            msgs.push(Msg::Select(hit));
        }

        // ── the RESIDENT cloud at scale: the model uploaded once, per frame the camera
        // and the overlays. Everything it needs per model (visible set, id map, lit
        // trace) is cached, so none of the per-frame work below runs. `None` (no
        // renderer, a zero-pixel rect) falls through to the per-frame / CPU road.
        #[cfg(feature = "gpu-cloud")]
        if self.opts.gpu_cloud
            && self.state.model.nodes.len() > facett_core::render::gpu::graphcloud::cloud::RESIDENT_ABOVE
            && let Some(report) = self.paint_resident(ui, &painter, resp.rect, origin, zoom, [th.text, th.text_dim, th.accent])
        {
            *self.last_legibility.borrow_mut() = report;
            self.finish_view(&resp, self.state.selected.as_ref().map_or(0, |_| self.resident.borrow().lit.len()));
            return msgs;
        }

        // The progressive-unfold visible set (default = all): folded children vanish
        // while ancestors stay. Edges/chips off this set are skipped.
        let visible = self.visible_ids();
        let lit = self.lit_set();
        let highlighting = self.state.selected.is_some();
        let dim = |c: egui::Color32| c.linear_multiply(0.22);

        let idx: HashMap<&str, &GraphNode> =
            self.state.model.nodes.iter().map(|n| (n.id.as_str(), n)).collect();

        // ── the GPU cloud lane + the legibility plan ─────────────────────────
        // `drew_on_gpu` is the ONLY thing that may suppress the CPU cloud, and it is
        // whatever the shared `draw_cloud` gate returns — which is `false` unless a
        // host really installed a renderer. Opting in without installing therefore
        // paints the unchanged CPU frame, never a blank pane.
        let drew_on_gpu = {
            let cam = crate::model::Camera { pan_x: origin.x, pan_y: origin.y, zoom };
            crate::legible::draw_gpu_cloud(ui, resp.rect, &self.state.model, &cam, self.opts.gpu_cloud)
        };
        let plan = (!self.opts.is_off()).then(|| {
            crate::legible::plan_view(
                &self.state.model,
                origin,
                zoom,
                resp.rect,
                &visible,
                &lit,
                self.state.selected.as_deref(),
                self.opts,
            )
        });
        {
            let mut r = plan.as_ref().map(|p| p.report.clone()).unwrap_or_default();
            r.gpu_cloud = drew_on_gpu;
            *self.last_legibility.borrow_mut() = r;
        }
        let thinned_labels = plan.as_ref().and_then(crate::legible::ViewPlan::labels).is_some();

        // edges first.
        let draw_edge = |e: &GraphEdge, emphasise: bool| {
            let (Some(fa), Some(fb)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
                return;
            };
            // Skip wiring to a folded-away node.
            if !visible.contains(&e.from) || !visible.contains(&e.to) {
                return;
            }
            let on_trace = highlighting && lit.contains(&e.from) && lit.contains(&e.to);
            let mut color = Self::col(e.color);
            if !emphasise && highlighting && !on_trace {
                color = dim(color);
            }
            let w = if emphasise { 2.6_f32 } else if on_trace { 2.4_f32 } else { 1.4_f32 };
            stroke_edge(&painter, project(fa.pos), project(fb.pos), e, zoom, color, w, emphasise, th.text_dim);
        };
        if !drew_on_gpu {
            match plan.as_ref().and_then(crate::legible::ViewPlan::edges) {
                // The ink budget decided; draw exactly the survivors, in index order,
                // so the paint order (and the over-draw) is the unthinned one minus
                // the dropped edges.
                Some(keep) => {
                    for &ei in keep {
                        if let Some(e) = self.state.model.edges.get(ei as usize) {
                            draw_edge(e, false);
                        }
                    }
                }
                None => {
                    for e in &self.state.model.edges {
                        draw_edge(e, false);
                    }
                }
            }
        }
        // Decoration edges are never thinned and never GPU-batched: they are the
        // answer the user asked for.
        for e in &self.state.decorations.edges {
            draw_edge(e, true);
        }

        // chips.
        for nd in &self.state.model.nodes {
            if !visible.contains(&nd.id) {
                continue;
            }
            let c = project(nd.pos);
            let on_trace = highlighting && lit.contains(&nd.id);
            let mut fill = Self::col(nd.fill);
            if highlighting && !on_trace {
                fill = dim(fill);
            }
            let rect = egui::Rect::from_center_size(c, egui::vec2(BOX_W, BOX_H) * zoom);
            // With the GPU cloud up the discs are already on screen; the CPU keeps the
            // thin overlays (rings, badges, labels) but not the 2.47 M-triangle cloud.
            if !drew_on_gpu {
                painter.rect_filled(rect, 5.0 * zoom, fill);
            }

            // ONE clean ring, and ONLY when the node carries a meaning: the current
            // selection, or a status (decoration) ring. Plain nodes get NO ring — the
            // old `else` painted a kind-stroke on every chip, which read as an ugly
            // second ring next to the status ring.
            let deco = self.state.decorations.nodes.get(&nd.id);
            let ring = if self.state.selected.as_deref() == Some(nd.id.as_str()) {
                Some(egui::Stroke::new(3.0_f32, th.accent))
            } else if let Some(rc) = deco.and_then(|d| d.ring) {
                let rc = Self::col(rc);
                Some(egui::Stroke::new(2.4_f32, if highlighting && !on_trace { dim(rc) } else { rc }))
            } else {
                None
            };
            if let Some(ring) = ring {
                painter.rect_stroke(rect, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
            }

            if zoom > 0.45 {
                // With collision avoidance on, labels are painted ONCE from the plan
                // (below) at the boxes that survived — not once per chip on top of
                // whatever was already there.
                if !thinned_labels {
                    painter.text(
                        c,
                        egui::Align2::CENTER_CENTER,
                        &nd.label,
                        egui::FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
                        th.text,
                    );
                }
                if let Some(badge) = deco.and_then(|d| d.badge.as_deref()) {
                    let bc = deco.and_then(|d| d.badge_color).map(Self::col).unwrap_or(th.text);
                    painter.text(
                        rect.right_top() + egui::vec2(-2.0, 1.0),
                        egui::Align2::RIGHT_TOP,
                        badge,
                        egui::FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
                        if highlighting && !on_trace { dim(bc) } else { bc },
                    );
                }
            }
        }

        // The surviving labels, painted once, at the boxes collision avoidance chose.
        if zoom > 0.45
            && let Some(p) = plan.as_ref().and_then(crate::legible::ViewPlan::labels)
        {
            for pl in &p.placed {
                let Some(nd) = self.state.model.nodes.get(pl.index) else { continue };
                painter.text(
                    pl.rect.center(),
                    egui::Align2::CENTER_CENTER,
                    &nd.label,
                    egui::FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
                    th.text,
                );
            }
        }

        self.finish_view(&resp, lit.len());
        msgs
    }

    /// The pane-level a11y summary + the testmatrix row — the end of every frame, on
    /// every lane.
    #[cfg_attr(not(feature = "testmatrix"), allow(unused_variables))]
    fn finish_view(&self, resp: &egui::Response, lit: usize) {
        resp.widget_info(|| {
            Semantics::image(format!(
                "decorated graph — {} nodes, {} edges, selected {}",
                self.state.model.nodes.len(),
                self.state.model.edges.len(),
                self.state.selected.as_deref().unwrap_or("none"),
            ))
            .widget_info()
        });

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-graphview::DecoratedGraphView::view",
            "ui_render",
            !self.state.model.nodes.is_empty(),
            &format!("nodes={} edges={} lit={}", self.state.model.nodes.len(), self.state.model.edges.len(), lit),
        );
    }

    /// **The resident lane's frame** (see [`crate::resident`]): sync the per-model caches,
    /// push the camera (and the model when it changed), then paint the overlays — the
    /// lit trace, decoration edges, rings + badges of the selected / decorated nodes, and
    /// the on-screen labels. `None` when the lane cannot draw.
    #[cfg(feature = "gpu-cloud")]
    #[allow(clippy::too_many_lines)]
    fn paint_resident(
        &self,
        ui: &egui::Ui,
        painter: &egui::Painter,
        rect: egui::Rect,
        origin: egui::Pos2,
        zoom: f32,
        [text, text_dim, accent]: [egui::Color32; 3],
    ) -> Option<crate::legible::ViewReport> {
        use crate::resident::{LABEL_CAP, LIT_EDGE_CAP};
        use facett_core::render::gpu::graphcloud::{cloud, CloudCamera};
        if !cloud::cloud_renderer_installed() {
            return None;
        }
        let mut cache = self.resident.borrow_mut();
        cache.sync(
            self.model_gen,
            self.vis_gen,
            &self.state.model,
            || self.visible_ids(),
            || self.lit_set(),
            self.state.selected.as_ref(),
        );
        cache.sync_decorated(self.model_gen, self.vis_gen, self.deco_gen, &self.state.decorations);
        let ppp = ui.ctx().pixels_per_point();
        let o = (origin - rect.min) * ppp;
        let cam = CloudCamera::plane(
            [rect.width() * ppp, rect.height() * ppp],
            [o.x, o.y],
            zoom * ppp,
            (BOX_H * 0.5 * zoom * ppp).max(1.0),
            // The CPU painter's edge: a 1.4 px stroke (half 0.7). The lane drew 1.5 px
            // halves until 2026-10-02 — twice the painter's ink, and at 100k long edges
            // (falkor's force layout, the coderoom's ranks) the iGPU's fill bound.
            0.7 * ppp,
            2.5,
        );
        if !cache.draw(ui, rect, &self.state.model, cam) {
            return None;
        }
        let cache = &*cache;
        let model = &self.state.model;
        let project = |p: Pos| origin + egui::vec2(p.x, p.y) * zoom;
        let at = |id: &str| cache.idx.get(id).map(|&i| i as usize).filter(|&i| cache.shown(i));
        let highlighting = self.state.selected.is_some();
        let dim = |c: egui::Color32| c.linear_multiply(0.22);
        let mut report = crate::legible::ViewReport { gpu_cloud: true, resident: true, ..Default::default() };

        // The lit downstream trace, over the cloud.
        for &ei in cache.lit_edges.iter().take(LIT_EDGE_CAP) {
            let e = &model.edges[ei as usize];
            if let (Some(a), Some(b)) = (at(&e.from), at(&e.to)) {
                painter.line_segment([project(model.nodes[a].pos), project(model.nodes[b].pos)], egui::Stroke::new(1.6, accent));
            }
        }
        // Decoration (cut) edges: never thinned, never in the cloud.
        for e in &self.state.decorations.edges {
            if let (Some(a), Some(b)) = (at(&e.from), at(&e.to)) {
                stroke_edge(painter, project(model.nodes[a].pos), project(model.nodes[b].pos), e, zoom, Self::col(e.color), 2.6, true, text_dim);
            }
        }
        // Rings + badges: the selected node and the decorated ones — not every node.
        // The decorated list is per model / unfold / decorations (a decoration that is
        // only a size — every coderoom node — draws nothing here, so it is not listed).
        let mut special: Vec<usize> = cache.decorated.clone();
        if let Some(s) = self.state.selected.as_deref().and_then(at) {
            special.push(s);
            special.sort_unstable();
            special.dedup();
        }
        for &i in &special {
            let nd = &model.nodes[i];
            let on_trace = highlighting && cache.lit.contains(&nd.id);
            let chip = egui::Rect::from_center_size(project(nd.pos), egui::vec2(BOX_W, BOX_H) * zoom);
            let deco = self.state.decorations.nodes.get(&nd.id);
            let ring = if self.state.selected.as_deref() == Some(nd.id.as_str()) {
                Some(egui::Stroke::new(3.0_f32, accent))
            } else {
                deco.and_then(|d| d.ring).map(|rc| {
                    let rc = Self::col(rc);
                    egui::Stroke::new(2.4_f32, if highlighting && !on_trace { dim(rc) } else { rc })
                })
            };
            if let Some(ring) = ring {
                painter.rect_stroke(chip, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
            }
            if zoom > 0.45
                && let Some(badge) = deco.and_then(|d| d.badge.as_deref())
            {
                let bc = deco.and_then(|d| d.badge_color).map(Self::col).unwrap_or(text);
                painter.text(
                    chip.right_top() + egui::vec2(-2.0, 1.0),
                    egui::Align2::RIGHT_TOP,
                    badge,
                    egui::FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
                    if highlighting && !on_trace { dim(bc) } else { bc },
                );
            }
        }
        // Labels: the visible chips on screen only.
        if zoom > 0.45 {
            let viewport = painter.clip_rect();
            let size = egui::vec2(BOX_W, BOX_H) * zoom;
            let on: Vec<usize> = (0..model.nodes.len())
                .filter(|&i| cache.shown(i) && viewport.intersects(egui::Rect::from_center_size(project(model.nodes[i].pos), size)))
                .take(LABEL_CAP)
                .collect();
            let font = egui::FontId::proportional(11.0 * zoom.clamp(0.7, 1.4));
            report.labels_considered = on.len();
            if self.opts.thin_labels {
                use facett_core::legibility::{plan_frame, FrameInput, FrameOpts};
                let char_px = 6.0 * zoom.clamp(0.7, 1.4);
                let centres: Vec<egui::Pos2> = on.iter().map(|&i| project(model.nodes[i].pos)).collect();
                let sizes: Vec<egui::Vec2> = on
                    .iter()
                    .map(|&i| egui::vec2((model.nodes[i].label.chars().count() as f32 * char_px).max(4.0), 11.0 * zoom.clamp(0.7, 1.4)))
                    .collect();
                let prio: Vec<f32> = on
                    .iter()
                    .map(|&i| (if cache.lit.contains(&model.nodes[i].id) { 1000.0 } else { 0.0 }) + model.nodes[i].label.chars().count() as f32)
                    .collect();
                let pinned: Vec<bool> = on.iter().map(|&i| self.state.selected.as_deref() == Some(model.nodes[i].id.as_str())).collect();
                let plan = plan_frame(
                    &FrameInput {
                        centres: &centres,
                        label_size: &sizes,
                        label_priority: &prio,
                        label_pinned: &pinned,
                        edges: &[],
                        edge_always: &[],
                        edge_weight: &[],
                        viewport,
                    },
                    FrameOpts { thin_labels: true, ..FrameOpts::default() },
                );
                if let Some(p) = &plan.labels {
                    for pl in &p.placed {
                        painter.text(pl.rect.center(), egui::Align2::CENTER_CENTER, &model.nodes[on[pl.index]].label, font.clone(), text);
                    }
                    report.labels_placed = p.placed.len();
                    report.labels_suppressed = p.suppressed;
                }
            } else {
                for &i in &on {
                    painter.text(project(model.nodes[i].pos), egui::Align2::CENTER_CENTER, &model.nodes[i].label, font.clone(), text);
                }
                report.labels_placed = on.len();
            }
        }
        report.edges_drawn = model.edges.len();
        report.resident_uploads = cache.slot.uploads();
        Some(report)
    }
}

/// Stroke ONE edge between two projected chip centres: chip side to chip side, dashed
/// when the edge says so, an arrowhead at the callee, and the mid-edge label (above
/// zoom 0.45). Shared by the CPU road and the resident lane's decoration edges.
#[allow(clippy::too_many_arguments)]
fn stroke_edge(
    painter: &egui::Painter,
    pa: egui::Pos2,
    pb: egui::Pos2,
    e: &GraphEdge,
    zoom: f32,
    color: egui::Color32,
    w: f32,
    emphasise: bool,
    text_dim: egui::Color32,
) {
    let a = pa + egui::vec2(BOX_W * 0.5 * zoom, 0.0);
    let b = pb - egui::vec2(BOX_W * 0.5 * zoom, 0.0);
    if e.dashed {
        // sampled dashed line.
        let n = 16;
        for i in 0..n {
            if i % 2 == 0 {
                let t0 = i as f32 / n as f32;
                let t1 = (i + 1) as f32 / n as f32;
                painter.line_segment([a.lerp(b, t0), a.lerp(b, t1)], egui::Stroke::new(w, color));
            }
        }
    } else {
        painter.line_segment([a, b], egui::Stroke::new(w, color));
    }
    // arrowhead.
    let dir = (b - a).normalized();
    let perp = egui::vec2(-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], egui::Stroke::new(w, color));
    painter.line_segment([b, b - dir * head - perp * head * 0.5], egui::Stroke::new(w, color));
    if let Some(lbl) = &e.label
        && zoom > 0.45
        && !lbl.is_empty()
    {
        painter.text(
            a.lerp(b, 0.5) - egui::vec2(0.0, 6.0 * zoom),
            egui::Align2::CENTER_BOTTOM,
            lbl,
            egui::FontId::proportional(10.0 * zoom.clamp(0.7, 1.3)),
            if emphasise { color } else { text_dim },
        );
    }
}

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

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

// The bridge macro writes `impl Facet for DecoratedGraphView` 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 pane publishes a
// RICHER `state_json` than a plain `serde(state())`: the derived counts (rings/badges),
// the lit downstream set, and the flattened per-node/per-edge introspection keys that
// facett-demo's mega_matrix reads are computed from the model + decorations, not the
// raw serde dump — so the default `state_json` is suppressed and this byte-for-byte
// one supplied.
facett_core::impl_facet_via_elm!(DecoratedGraphView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let lit = self.lit_set();
        serde_json::json!({
            "title": self.title,
            "node_count": self.state.model.nodes.len(),
            "edge_count": self.state.model.edges.len(),
            "decoration_edges": self.state.decorations.edges.len(),
            "rings": self.deco_counts.0,
            "badges": self.deco_counts.1,
            "selected": self.state.selected,
            "lit": lit.iter().cloned().collect::<Vec<_>>(),
            "zoom": self.state.zoom,
            "pan": [self.state.pan_x, self.state.pan_y],
            "nav_mode": format!("{:?}", self.state.nav_mode),
            "flying": self.state.flight.is_some(),
            // The shared drill navigator (levels / current level / history / expanded)
            // + how many chips are shown under the progressive unfold.
            "nav": self.state.nav.state_json(),
            "visible_count": self.visible_ids().len(),
            // What the last painted frame's legibility work did — so "labels do not
            // overplot" and "the GPU lane really painted" are assertable from outside.
            "legibility": self.last_legibility.borrow().state_json(),
            "nodes": self.state.model.nodes.iter().map(|n| {
                let d = self.state.decorations.nodes.get(&n.id);
                serde_json::json!({
                    "id": n.id,
                    "label": n.label,
                    "x": n.pos.x,
                    "y": n.pos.y,
                    "ring": d.and_then(|d| d.ring).is_some(),
                    "badge": d.and_then(|d| d.badge.clone()),
                })
            }).collect::<Vec<_>>(),
            "edges": self.state.model.edges.iter().map(|e| serde_json::json!({
                "from": e.from, "to": e.to, "dashed": e.dashed,
            })).collect::<Vec<_>>(),
            // The stable UI ERROR CODE for the empty-scene state
            // (`facett_core::errcode`, `facet-graphview-1` — "graphview has an empty
            // scene, nothing to lay out"). The code belongs to the graphview COMPONENT,
            // not to one view of it: `MetroView` published it already, so a host that
            // mounts the decorated view (dwarves' Funnel DAG, nornir's graph panes) got
            // silence for the very same condition and had nothing to react to.
            // Consumers react to the CODE out of this data — never a matched string.
            "error_code": if self.state.model.nodes.is_empty() {
                serde_json::json!("facet-graphview-1")
            } else {
                serde_json::Value::Null
            },
        })
    }

    /// STRUCTURAL severity (the Robot-UI gate signal): RED when there is no scene to
    /// lay out (the `facet-graphview-1` empty state), the Info floor otherwise —
    /// matching [`MetroView`](crate::metro::MetroView).
    fn severity(&self) -> facett_core::Severity {
        if self.state.model.nodes.is_empty() {
            facett_core::Severity::Error
        } else {
            facett_core::Severity::Info
        }
    }

    fn copy(&mut self) -> Option<String> {
        self.copy_payload().map(|p| p.as_text())
    }

    fn selection_json(&self) -> serde_json::Value {
        match &self.state.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().copyable()
    }

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

#[cfg(test)]
mod tests {
    use super::*;
    // `Facet` for `state_json`/`selection_json`/`title`/`caps`; `state()`/`select()`/
    // `update()`/`fit()` are inherent.
    use facett_core::Facet;

    /// The empty scene raises the graphview family's stable code, so a HOST that
    /// mounts the decorated view (dwarves' Funnel DAG) has a code to react to —
    /// exactly as `MetroView` already gave its hosts. Silence here was the reason
    /// `dwarves/funnel` could not be consumer-wired.
    #[test]
    fn an_empty_scene_publishes_facet_graphview_1_and_reads_red() {
        let empty = DecoratedGraphView::new("empty");
        assert_eq!(
            empty.state_json()["error_code"],
            serde_json::json!("facet-graphview-1"),
            "the empty-scene CODE is published in the pane's own data"
        );
        assert_eq!(empty.severity(), facett_core::Severity::Error);
        assert_eq!(
            facett_core::errcode::reaction_for("facet-graphview-1"),
            Some(facett_core::errcode::Reaction::EmptyState),
        );
        // A populated scene raises nothing.
        let full = DecoratedGraphView::local();
        assert_eq!(full.state_json()["error_code"], serde_json::Value::Null);
        assert_eq!(full.severity(), facett_core::Severity::Info);
    }

    #[test]
    fn typed_copy_is_selected_label_or_the_label_list() {
        use facett_core::clip::{ClipKind, CopySource};
        let mut v = DecoratedGraphView::local();
        // No selection -> a multi-line label list.
        let p = v.copy_payload().expect("populated graph copies");
        assert_eq!(p.kind(), ClipKind::Text);
        assert!(p.as_text().contains('\n'), "label list is multi-line: {}", p.as_text());
        // Select node "a" -> the copy narrows to that node's single label.
        v.select(Some("a".into()));
        let sel = v.copy_payload().unwrap().as_text();
        assert!(!sel.contains('\n'), "selected copy is one label: {sel}");
        assert_eq!(sel, v.state().model.nodes.iter().find(|n| n.id == "a").unwrap().label);
    }

    #[test]
    fn drill_back_and_in_place_unfold_over_the_local_graph() {
        // The 2D twin of the Code Vault nav: drill descends a level, right-click/Back
        // returns to the parent, and folding a node collapses its subtree while the
        // node (and ancestors) stay — RED-when-broken if unfold ever drops the parent.
        let mut v = DecoratedGraphView::local();
        // Default: fully unfolded → all four chips visible.
        assert_eq!(v.visible_ids().len(), 4);
        assert_eq!(v.state_json()["visible_count"], 4);

        // Drill descends a level; Back (the right-click) returns to the parent level.
        assert_eq!(v.nav().current_level_id(), "constellation");
        let _ = v.update(Msg::Drill("a".into()));
        assert_eq!(v.nav().current_level_id(), "repo");
        let _ = v.update(Msg::Back);
        assert_eq!(v.nav().current_level_id(), "constellation", "Back returns to the parent level");

        // Fold root "a": its subtree (b/c/d) collapses, but "a" itself stays visible.
        let _ = v.update(Msg::Fold("a".into()));
        let vis = v.visible_ids();
        assert!(vis.contains("a"), "the folded PARENT stays visible");
        assert!(!vis.contains("b") && !vis.contains("d"), "the collapsed subtree hides");
        assert_eq!(v.state_json()["visible_count"], 1);

        // Unfold "a": the children return, the parent is STILL present (in-place unfold).
        let _ = v.update(Msg::Unfold("a".into()));
        let vis = v.visible_ids();
        assert!(vis.contains("a") && vis.contains("b") && vis.contains("d"), "unfold reopens WITHOUT closing the parent");
    }

    #[test]
    fn downstream_is_bfs_closure_from_seed() {
        let v = DecoratedGraphView::local();
        // a → b, a → c, b → d, c → d. Downstream of a = all; of b = {b, d}.
        let from_a = downstream_of(&v.state().model, "a");
        assert!(from_a.contains("a") && from_a.contains("b") && from_a.contains("c") && from_a.contains("d"));
        let from_b = downstream_of(&v.state().model, "b");
        assert!(from_b.contains("b") && from_b.contains("d"));
        assert!(!from_b.contains("a"), "BFS is forward-only");
        assert!(!from_b.contains("c"), "c is not downstream of b");
    }

    /// inject-assert (LAW 6): the local view seeds a real decorated graph; assert
    /// its `state_json` reports the nodes, the ring + badge decorations, the
    /// emphasis edge, and — after a selection — the lit downstream set.
    #[test]
    fn local_view_reports_decorations_and_selection() {
        let mut v = DecoratedGraphView::local();
        let j = v.state_json();
        assert_eq!(<DecoratedGraphView as Facet>::title(&v), "DecoratedGraph");
        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`");
        // node `a` carries a ring, `b` carries the badge text.
        let nodes = j["nodes"].as_array().unwrap();
        let a = nodes.iter().find(|n| n["id"] == "a").unwrap();
        assert_eq!(a["ring"], true);
        let b = nodes.iter().find(|n| n["id"] == "b").unwrap();
        assert_eq!(b["badge"], "⚠");
        // no selection → null + empty lit set.
        assert_eq!(j["selected"], serde_json::Value::Null);
        assert!(j["lit"].as_array().unwrap().is_empty());

        // select `b` → the lit set is its downstream {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<_>>());
        let sel = v.selection_json();
        assert_eq!(sel["node"], "b");
    }

    #[test]
    fn fit_resets_pan_and_zoom() {
        let mut v = DecoratedGraphView::local();
        v.state.pan_x = 20.0;
        v.state.pan_y = 10.0;
        v.state.zoom = 2.5;
        v.fit();
        assert_eq!((v.state().pan_x, v.state().pan_y), (0.0, 0.0));
        assert_eq!(v.state().zoom, 1.0);
        assert_eq!(<DecoratedGraphView as Facet>::title(&DecoratedGraphView::remote()), "DecoratedGraph (remote)");
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness and assert the resulting `GraphViewState` — no egui, no GPU. ──────
    #[test]
    fn harness_snapshot_drives_camera_and_selection() {
        use facett_core::harness;
        let mut v = DecoratedGraphView::local();
        // Pan + zoom + select are all observable in the headless snapshot.
        let snap = harness::snapshot(
            &mut v,
            [Msg::PanBy(12.0, -4.0), Msg::ZoomBy(500.0), Msg::Select(Some("b".into()))],
        );
        assert_eq!((snap.pan_x, snap.pan_y), (12.0, -4.0), "pan accumulates");
        assert!(snap.zoom > 1.0 && snap.zoom <= 4.0, "zoom grows but stays clamped: {}", snap.zoom);
        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");
        // Fit resets the camera; ZoomBy is clamped at the low end too.
        let snap = harness::snapshot(&mut v, [Msg::ZoomBy(-100000.0), Msg::Fit]);
        assert_eq!((snap.pan_x, snap.pan_y, snap.zoom), (0.0, 0.0, 1.0), "Fit re-centers + unit-zooms");
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        use facett_core::harness;
        let mut v = DecoratedGraphView::local();
        // FC-8: the pane does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut v, [Msg::Select(Some("a".into())), Msg::PanBy(3.0, 3.0)]);
        assert!(effects.is_empty(), "FC-8: the pane emits no Effects");
        // FC-3: the observable Model (graph + decorations + camera + selection)
        // round-trips through serde.
        let json = serde_json::to_value(v.state()).unwrap();
        assert_eq!(json["selected"], "a");
        let back: GraphViewState = serde_json::from_value(json).unwrap();
        assert_eq!(&back, v.state(), "serde(state) -> state round-trips (the whole graph survives)");
    }

    /// The host hit-test seam: after a real paint, `world_to_screen` reproduces the
    /// pane's own projection, so a node's world position maps to the exact screen
    /// pixel it was drawn at (the anchor a robot/host clicks). RED-when-broken if the
    /// paint rect isn't recorded or the projection drifts from `view`'s.
    #[test]
    fn world_to_screen_matches_the_painted_projection() {
        let v = DecoratedGraphView::local();
        // No paint yet → no rect, no mapping.
        assert!(v.last_paint_rect().is_none());
        assert!(v.world_to_screen(Pos::new(0.0, 0.0)).is_none(), "no mapping before the first paint");

        // Drive one real egui frame so `view` allocates + records the canvas rect.
        let ctx = egui::Context::default();
        ctx.run_ui(egui::RawInput::default(), |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                let _ = v.view(ui);
            });
        }).textures_delta.clear();

        let rect = v.last_paint_rect().expect("view painted → canvas rect recorded");
        // local() seeds node `a` at world (0,0) and `d` at world (520,0); default
        // camera is pan (0,0) / zoom 1, so `a` lands on the canvas centre and `d`
        // lands 520px to its right — the exact anchors an out-of-band click targets.
        let a = v.world_to_screen(Pos::new(0.0, 0.0)).unwrap();
        assert!((a.x - rect.center().x).abs() < 1e-3, "node a maps to the canvas centre x");
        assert!((a.y - rect.center().y).abs() < 1e-3, "node a maps to the canvas centre y");
        let d = v.world_to_screen(Pos::new(520.0, 0.0)).unwrap();
        assert!((d.x - (rect.center().x + 520.0)).abs() < 1e-3, "node d is 520px right of centre (zoom 1, pan 0)");
    }

    /// FC-5: node selection is keyed on the **stable node id**, so it survives a full
    /// re-layout — moving every node to new world coordinates leaves the selection and
    /// its lit downstream set intact (they resolve by id, never by position).
    #[test]
    fn selection_survives_relayout_on_stable_id() {
        use facett_core::harness;
        let mut v = DecoratedGraphView::local();
        let _ = harness::drive(&mut v, [Msg::Select(Some("b".into()))]);
        let lit_before = v.lit_set();
        // Re-layout: shove every node to a fresh position (same ids + edges).
        let mut relaid = v.state().model.clone();
        for (i, n) in relaid.nodes.iter_mut().enumerate() {
            n.pos = Pos::new(1000.0 + i as f32 * 37.0, -500.0 - i as f32 * 19.0);
        }
        v = v.with_model(relaid);
        assert_eq!(v.state().selected.as_deref(), Some("b"), "selection keyed on id survives the move");
        assert_eq!(v.lit_set(), lit_before, "the lit downstream set is identical after re-layout");
        assert_eq!(v.selection_json()["node"], "b");
        // And the id still resolves to a (now-relocated) real node.
        assert!(v.state().model.nodes.iter().any(|n| n.id == "b"));
    }
    /// **`fit_model` frames a graph that `fit` cannot.** The gap this method exists to
    /// close: `Msg::Fit` recentres at zoom 1 and `Msg::ZoomBy` clamps at 0.25, so a
    /// world box more than 4× the widget was unreachable — the pane painted a corner of
    /// the graph and looked like chrome.
    ///
    /// RED (seen): calling `fit()` instead of `fit_model()` leaves zoom at 1.0 and the
    /// far corner projects to (2650, 1890) in a 900 × 640 widget — off-screen, which is
    /// exactly what the assertion below reports.
    #[test]
    fn fit_model_frames_a_graph_that_plain_fit_cannot() {
        let n = |id: &str, x: f32, y: f32| GraphNode {
            id: id.into(),
            label: id.into(),
            fill: Color::GRAY,
            stroke: Color::WHITE,
            pos: Pos::new(x, y),
        };
        // A world box ~3500 units across — the size korp's 132-node live scene lays out
        // to at one chip of clearance per node.
        let model = GraphModel {
            nodes: vec![n("a", -1750.0, -1750.0), n("b", 1750.0, 1750.0), n("c", 0.0, 0.0)],
            edges: vec![],
        };
        let viewport = egui::vec2(900.0, 640.0);

        let mut plain = DecoratedGraphView::new("t").with_model(model.clone());
        plain.fit();
        assert_eq!(plain.state().zoom, 1.0, "plain fit is a recentre, not a frame");

        let mut v = DecoratedGraphView::new("t").with_model(model);
        v.fit_model(viewport, 12.0);
        assert!(
            v.state().zoom < 0.25,
            "a 3500-unit world in a 900px widget needs a zoom below the interactive \
             floor of 0.25; fit_model chose {}",
            v.state().zoom
        );

        // Every node must land INSIDE the viewport. Project the way `view` does, with a
        // rect centred at the origin of a 900 x 640 widget.
        let rect = egui::Rect::from_center_size(egui::pos2(450.0, 320.0), viewport);
        let z = v.state().zoom;
        let origin = rect.center() + egui::vec2(v.state().pan_x, v.state().pan_y);
        for node in &v.state().model.nodes {
            let p = origin + egui::vec2(node.pos.x, node.pos.y) * z;
            assert!(
                rect.contains(p),
                "node {} projects to ({:.0}, {:.0}), outside the {} x {} widget",
                node.id,
                p.x,
                p.y,
                viewport.x,
                viewport.y
            );
            // And the SAME answer through the public projection seam, so a host and a
            // robot agree with this test.
            let _ = v.world_to_screen(node.pos);
        }

        // A model with nothing in it leaves the camera exactly as it was — a fit of
        // nothing is not a reason to move.
        let mut empty = DecoratedGraphView::new("t");
        empty.fit_model(viewport, 12.0);
        assert_eq!(empty.state().zoom, 1.0);
        assert_eq!((empty.state().pan_x, empty.state().pan_y), (0.0, 0.0));
    }
}