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

nordisk/facett

facett-graph/src/lib.rs

//! **facett-graph** — the graph (node/edge) viewer component, on
//! [`facett_core`]. Build a [`Scene`] from a labelled edge list (the common
//! shape: a graph query, a dataflow DAG) and draw it. Source-agnostic — the
//! caller turns its Arrow/Cypher/whatever into edges first.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! Both facets in this crate — [`GraphView`] (the 2D node/edge skin) and
//! [`DepGraphView`] (the layered dep-DAG) — adopt the [`facett_core::Elm`] split:
//! - **[`GraphState`]** / **[`depgraph::DepGraphState`]** — the complete observable,
//!   serializable, round-trippable *interaction* state. The bulk graph data (the
//!   [`Scene`] / the `nodes`+`edges`) is the *input* held on the struct — `Color32`
//!   is not serde-round-trippable and it is not the state a headless driver mutates,
//!   so it lives OUTSIDE the Model (mirrors how facett-grid keeps its Arrow data on
//!   the struct, not in its `GridState`).
//! - **[`Msg`] + `update`** — the single mutation path (FC-2): a host/robot toggles
//!   the layout ([`GraphView`]) or selects/clears a node ([`DepGraphView`]); a
//!   headless driver ([`facett_core::harness`]) reaches the identical transitions.
//! - **`view`** — a **pure** paint (FC-9): reads `&self`, paints, and *returns* the
//!   [`Msg`]s the interactions produced; the
//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
//! - **Rich `state_json`** — the node/edge/label counts (and the dep-graph's column
//!   depth) a driver reads are *derived* from the input `Scene`/`nodes`, not the
//!   interaction Model, so the macro is invoked in **form 3** (`custom_state_json`)
//!   to publish those exact pre-migration keys instead of a plain `serde(state())`.
//!
//! ## Within-pane addressability recipe (FC-4 / FC-5 — the headless-drive unlock)
//! A custom-*painted* sub-widget (a graph node, a matrix/grid cell, a pipeline DAG
//! box, a list row) draws pixels but emits **no** AccessKit node — so a headless
//! `cmd_json` / robotui driver can select the pane's tab but cannot reach *inside*
//! it (the recorded "probe_only" gap). The fix is one call per interactive element,
//! reusing the shared helper — **no second addressing scheme, AccessKit only**:
//!
//! ```ignore
//! // key on the element's STABLE domain identity (its name/id), NOT the paint index:
//! let resp = facett_core::a11y::node(
//!     ui, base_id, &node.label, egui::Sense::click(), hit_rect,
//!     facett_core::a11y::Semantics::button(node.label.clone()).selected(is_sel),
//! );
//! if resp.clicked() { msgs.push(Msg::SelectNode(node.label.clone())); }
//! ```
//!
//! Then (a) route the click through the FC-2 `Msg`/`update` path so live == headless,
//! and (b) publish the addressable identities + the selection in `state_json`
//! (`"node_targets"` + `"selected"` here) so a scenario author can *discover* the
//! targets and assert the click landed on the RIGHT one. A driver now does
//! `Locate { Target::label("<name>") } → Act(Click)` — the SAME `Target`/`Action`
//! vocabulary tab selection uses. [`GraphView`] (this recipe), [`DepGraphView`], and
//! `facett-syschart::SystemChart` are the worked examples; the **matrix cell /
//! pipeline node / list row** families follow the identical four steps
//! (stable-key `a11y::node` → `Msg` → `state_json` targets → robot test).

use std::collections::{BTreeMap, HashMap};

pub use facett_core::{Edge, Facet, FacetCaps, Layout, Node, Scene, Theme, draw, hash_color, set_theme, theme};

use serde::{Deserialize, Serialize};

/// Side length (px) of a node's square AccessKit hit target, centred on the node
/// dot — generous enough to click (the dot is ~5px) yet small enough that
/// well-separated nodes don't steal each other's clicks.
const NODE_HIT: f32 = 18.0;

mod depgraph;
pub mod scale;
pub use scale::{A11Y_BUDGET, SCALE_ABOVE};
pub use depgraph::{
    DepEdge, DepGraphState, DepGraphView, DepNode, draw_arrow, Effect as DepGraphEffect, Msg as DepGraphMsg,
};

/// Build a [`Scene`] from a labelled edge list — each row is
/// `(src_id, dst_id, src_label, dst_label)`. Distinct ids become nodes, coloured
/// per label (FNV-hashed). The reusable "graph from edges" adapter korp's Graph
/// and Pipelines views both want.
pub fn scene_from_labeled_edges<I>(rows: I) -> Scene
where
    I: IntoIterator<Item = (i64, i64, String, String)>,
{
    let mut scene = Scene::new();
    let mut idx: HashMap<i64, usize> = HashMap::new();
    for (s, d, sl, dl) in rows {
        let si = *idx.entry(s).or_insert_with(|| scene.node(sl.clone(), hash_color(&sl)));
        let di = *idx.entry(d).or_insert_with(|| scene.node(dl.clone(), hash_color(&dl)));
        scene.edge(si, di);
    }
    scene
}

/// A serde-round-trippable mirror of [`Layout`] (which is `Copy` but neither
/// `Serialize` nor `Debug`, so it cannot live in the [`GraphState`] Model directly).
/// The [`Msg`] and the Model carry this; conversions to/from the core [`Layout`]
/// keep the painter and public API speaking the core type.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GraphLayout {
    /// Nodes evenly spaced on a circle.
    #[default]
    Circular,
    /// Deterministic Fruchterman–Reingold force layout.
    Force,
}

impl From<Layout> for GraphLayout {
    fn from(l: Layout) -> Self {
        match l {
            Layout::Circular => GraphLayout::Circular,
            Layout::Force => GraphLayout::Force,
        }
    }
}

impl From<GraphLayout> for Layout {
    fn from(l: GraphLayout) -> Self {
        match l {
            GraphLayout::Circular => Layout::Circular,
            GraphLayout::Force => Layout::Force,
        }
    }
}

/// **The complete observable interaction state (FC-1 / FC-3)** of a [`GraphView`]:
/// the layout knob a host can toggle. The bulk [`Scene`] is *input* held on
/// [`GraphView`] (not serde-round-trippable, not driver-mutated), so it is
/// deliberately kept OUT of this Model — the derived node/edge/label counts a driver
/// reads are published via the form-3 `state_json` instead.
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct GraphState {
    /// The node-placement strategy (the one knob a host/robot toggles).
    pub layout: GraphLayout,
    /// The drilled-in node selection, keyed on the node's stable **label** (FC-5)
    /// so a re-layout / reorder can't silently re-point it the way a paint-order
    /// index would. `None` = nothing selected. A painted node click (or a headless
    /// [`Msg::SelectNode`]) toggles this — the within-pane interaction a robot
    /// driver reaches once the node rides an AccessKit id (see the module docs
    /// "within-pane addressability" recipe).
    pub selected: Option<String>,
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the [`GraphView`] through. Applied by
/// [`GraphView::update`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Msg {
    /// Switch the node-placement strategy.
    SetLayout(GraphLayout),
    /// Toggle-select a painted node by its stable **label** — the same effect a
    /// node click produces (re-selecting the open node clears it). This is the
    /// within-pane gesture a headless robot reaches once the node carries an
    /// AccessKit id (`Locate { node } → Act(Click)`).
    SelectNode(String),
    /// Clear the node selection.
    ClearSelection,
}

/// Side work as data (FC-8). The graph view does no I/O — every [`Msg`] mutates
/// only the in-memory [`GraphState`] — so this is uninhabited on purpose.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Effect {}

/// A simple graph-view widget: a [`Scene`] + a [`Layout`]. Implements [`Facet`],
/// so it drops into a `FacetDeck` and exposes `state_json` for free.
pub struct GraphView {
    /// The graph data being drawn — *input* (like facett-grid's Arrow data): held
    /// on the struct, outside the observable [`GraphState`] Model.
    pub scene: Scene,
    /// Shown centred when the scene has no nodes.
    pub empty_hint: String,
    /// The deck keys facets off this.
    pub title: String,
    /// All observable interaction state (FC-3).
    state: GraphState,
    /// **Converge-once-then-freeze** layout cache (not observable state) — the force
    /// layout is a pure function of the scene structure + rect, so it is computed once
    /// and reused every frame until the graph or rect changes. This stops the O(n log n)
    /// (Barnes–Hut) / O(n²) (small) layout from re-running each paint — the freeze half
    /// of scaling to 30 000 nodes. Interior-mutable so the FC-9 pure `show`/`view`
    /// (`&self`) can fill it. Rebuilt from scratch on `Clone`/default.
    layout_cache: std::cell::RefCell<facett_core::ForceCache>,
    /// The host selected the shared GPU cloud lane ([`GraphView::with_gpu`]).
    use_gpu: bool,
    /// What the LAST frame did with the node/edge cloud: `Some(true)` GPU instances,
    /// `Some(false)` the egui painter, `None` not painted yet. Written by the pure
    /// `view` (a `Cell`, like `layout_cache`), read by `state_json`.
    lane_drew: std::cell::Cell<Option<bool>>,
    /// The scale path's per-scene work (above [`SCALE_ABOVE`] nodes; see [`scale`]).
    scale: std::cell::RefCell<scale::ScaleCache>,
    /// The scale path's pan/zoom camera — a view transient like the layout cache, not
    /// the observable Model (published in `state_json` at scale).
    camera: std::cell::Cell<scale::ScaleCamera>,
    /// The rect the last scale frame painted into (for [`focus_node`](Self::focus_node)).
    scale_rect: std::cell::Cell<Option<egui::Rect>>,
}

impl GraphView {
    pub fn new(scene: Scene) -> Self {
        Self {
            scene,
            empty_hint: "empty".into(),
            title: "graph".into(),
            state: GraphState::default(),
            layout_cache: std::cell::RefCell::new(facett_core::ForceCache::default()),
            use_gpu: false,
            lane_drew: std::cell::Cell::new(None),
            scale: std::cell::RefCell::new(scale::ScaleCache::default()),
            camera: std::cell::Cell::new(scale::ScaleCamera::default()),
            scale_rect: std::cell::Cell::new(None),
        }
    }

    /// **Bring node `label` to the centre of the pane at `zoom`** (scale path; needs one
    /// painted frame for the pane size). The "pan / zoom to reach it" a robot or a
    /// search box uses to get an off-screen node into the a11y tree. `false` when the
    /// label is unknown, the scene is below [`SCALE_ABOVE`], or nothing was painted yet.
    pub fn focus_node(&self, label: &str, zoom: f32) -> bool {
        let (Some(rect), Some(i)) = (self.scale_rect.get(), self.scene.nodes.iter().position(|n| n.label == label)) else {
            return false;
        };
        let c = self.scale.borrow();
        let Some(p) = c.pos.get(i) else { return false };
        let mut cam = scale::ScaleCamera { zoom: zoom.clamp(0.05, 10_000.0), pan: egui::Vec2::ZERO };
        cam.pan = -p.to_vec2() * cam.scale(rect);
        self.camera.set(cam);
        true
    }

    /// Does this scene take the scale path (more than [`SCALE_ABOVE`] nodes)?
    #[must_use]
    pub fn at_scale(&self) -> bool {
        self.scene.nodes.len() > SCALE_ABOVE
    }

    /// The scale path's camera (zoom 1, no pan until the user moves it).
    #[must_use]
    pub fn camera(&self) -> scale::ScaleCamera {
        self.camera.get()
    }

    /// Move the scale path's camera (what a drag / scroll does; a host or robot may set
    /// it directly). No effect on the fixture painter below [`SCALE_ABOVE`].
    pub fn set_camera(&self, cam: scale::ScaleCamera) {
        self.camera.set(cam);
    }

    /// Scenes the scale path has laid out (once per scene change, never per frame).
    #[must_use]
    pub fn scale_layouts(&self) -> u64 {
        self.scale.borrow().layouts
    }

    /// Models the scale path has uploaded to the resident GPU cloud (`0` without one).
    #[must_use]
    pub fn resident_uploads(&self) -> u64 {
        #[cfg(feature = "wgpu")]
        {
            self.scale.borrow().slot.uploads()
        }
        #[cfg(not(feature = "wgpu"))]
        {
            0
        }
    }

    /// **The FC-4 budget, as data**: how many nodes the last frame had on screen, how
    /// many it put in the AccessKit tree, and the rule. Below [`SCALE_ABOVE`] every node
    /// is in the tree (`budget: null`).
    #[must_use]
    pub fn a11y_json(&self) -> serde_json::Value {
        let n = self.scene.nodes.len();
        if !self.at_scale() {
            return serde_json::json!({ "total": n, "emitted": n, "budget": null, "policy": "every node" });
        }
        let c = self.scale.borrow();
        serde_json::json!({
            "total": n,
            "on_screen": c.last.on_screen,
            "emitted": c.last.emitted.len(),
            "budget": A11Y_BUDGET,
            "labels": c.last.labels,
            "policy": format!(
                "above {SCALE_ABOVE} nodes: one AccessKit button per ON-SCREEN node, in scene order, at most {A11Y_BUDGET} \
                 (the selected node always); pan/zoom brings others in; Msg::SelectNode reaches every node"
            ),
        })
    }

    /// The cheap state at scale — counts, selection, lanes, camera, the a11y budget;
    /// nothing per node (a host publishing state every frame calls this, not
    /// `state_json`, above [`SCALE_ABOVE`]).
    #[must_use]
    pub fn scale_json(&self) -> serde_json::Value {
        let cam = self.camera.get();
        serde_json::json!({
            "nodes": self.scene.nodes.len(),
            "edges": self.scene.edges.len(),
            "selected": self.state.selected.clone(),
            "lane": facett_core::render::lane::cloud_lane_json(self.use_gpu, Self::gpu_installed(), self.lane_drew.get()),
            "resident_uploads": self.resident_uploads(),
            "scale_layouts": self.scale_layouts(),
            "layout_lane": self.scale.borrow().lane,
            "layout_pending": self.scale.borrow().pending(),
            "camera": { "zoom": cam.zoom, "pan": [cam.pan.x, cam.pan.y] },
            "a11y": self.a11y_json(),
        })
    }

    /// Select the shared instanced GPU cloud lane (facett-core `graphcloud`). The nodes
    /// and edges go to the GPU as instances; labels, the selection ring and the a11y
    /// nodes stay on egui. Takes effect only with the `wgpu` feature AND a renderer the
    /// host installed (`graphcloud::cloud::install_cloud_renderer`); otherwise the egui
    /// painter draws and `state_json()["lane"]` says so (`gpu: "no GPU"`).
    #[must_use]
    pub fn with_gpu(mut self, on: bool) -> Self {
        self.use_gpu = on;
        self
    }

    /// Push the node/edge cloud through the shared GPU lane; `true` only if it drew
    /// (the one gate the painter consults before skipping its CPU loops).
    #[cfg(feature = "wgpu")]
    fn draw_gpu_cloud(&self, ui: &egui::Ui, rect: egui::Rect, pos: &[egui::Pos2]) -> bool {
        use facett_core::render::gpu::graphcloud::{CloudBuilder, cloud};
        let n = self.scene.nodes.len();
        if n == 0 || pos.len() != n {
            return false;
        }
        let edge = facett_core::theme(ui).edge;
        cloud::draw_cloud(ui, rect, self.use_gpu, |px_w, px_h| {
            // The offscreen is in physical px; the positions are in points.
            let sx = px_w / rect.width().max(1.0);
            let sy = px_h / rect.height().max(1.0);
            let to_px = |p: egui::Pos2| egui::pos2((p.x - rect.min.x) * sx, (p.y - rect.min.y) * sy);
            CloudBuilder::default().scene(
                self.scene.nodes.iter().enumerate().map(|(i, nd)| (to_px(pos[i]), 5.0 * sy, nd.color)),
                self.scene
                    .edges
                    .iter()
                    .filter(|e| e.src < n && e.dst < n)
                    .map(|e| (to_px(pos[e.src]), to_px(pos[e.dst]), 0.6 * sy, edge)),
            )
        })
    }

    #[cfg(not(feature = "wgpu"))]
    fn draw_gpu_cloud(&self, _ui: &egui::Ui, _rect: egui::Rect, _pos: &[egui::Pos2]) -> bool {
        false
    }

    /// Is a GPU cloud renderer installed in this process (the lane can draw)?
    fn gpu_installed() -> bool {
        #[cfg(feature = "wgpu")]
        {
            facett_core::render::gpu::graphcloud::cloud::cloud_renderer_installed()
        }
        #[cfg(not(feature = "wgpu"))]
        {
            false
        }
    }
    pub fn empty_hint(mut self, hint: impl Into<String>) -> Self {
        self.empty_hint = hint.into();
        self
    }
    pub fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }
    /// Set the layout strategy at build time.
    pub fn with_layout(mut self, layout: Layout) -> Self {
        self.state.layout = layout.into();
        self
    }

    /// **FC-3** — read the complete observable interaction state.
    pub fn state(&self) -> &GraphState {
        &self.state
    }

    /// The active node-placement strategy (as the core [`Layout`]).
    pub fn layout(&self) -> Layout {
        self.state.layout.into()
    }

    /// Switch the layout — a thin wrapper over the FC-2 [`update`](Self::update)
    /// mutation path (the sole way the Model changes).
    pub fn set_layout(&mut self, layout: Layout) {
        let _ = self.update(Msg::SetLayout(layout.into()));
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::SetLayout(layout) => self.state.layout = layout,
            // Toggle on the stable label: re-selecting the open node clears it —
            // the identical transition the painted node click drives, so a headless
            // driver reaching this Msg reproduces the live click.
            Msg::SelectNode(label) => {
                self.state.selected =
                    if self.state.selected.as_deref() == Some(label.as_str()) { None } else { Some(label) };
            }
            Msg::ClearSelection => self.state.selected = None,
        }
        Vec::new()
    }

    /// The currently-selected node's stable label, if any.
    pub fn selected(&self) -> Option<&str> {
        self.state.selected.as_deref()
    }

    /// Drill into a node by its stable label (or `None` to clear) — a deterministic
    /// *set* (never a toggle), a thin wrapper over the FC-2 [`update`](Self::update)
    /// mutation path (the sole way the Model changes).
    pub fn select(&mut self, node: Option<String>) {
        let _ = self.update(Msg::ClearSelection);
        if let Some(n) = node {
            let _ = self.update(Msg::SelectNode(n));
        }
    }

    /// The distinct node **labels** a robot / scenario author can address (the
    /// AccessKit targets `Locate { Target::label(..) }` resolves to), sorted. A
    /// label is reliably addressable when it is unique in the scene; this is the
    /// discoverable target list published in [`state_json`](Facet::state_json).
    pub fn node_targets(&self) -> Vec<String> {
        self.label_counts().into_keys().collect()
    }

    /// Paint the scene (pure draw helper; no observable-state mutation). The layout is
    /// pulled through the freeze [`ForceCache`](facett_core::ForceCache): the force
    /// positions are computed once and reused until the scene structure or the paint
    /// rect changes, so a big graph does NOT re-run its O(n log n) layout every frame.
    pub fn show(&self, ui: &mut egui::Ui) {
        let (rect, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
        let layout: Layout = self.state.layout.into();
        let mut cache = self.layout_cache.borrow_mut();
        let pos = cache.positions(layout, &self.scene, rect);
        facett_core::draw_positions(ui, &self.scene, pos, rect, &self.empty_hint);
    }

    /// **FC-9 render** — a **pure** function of `&self`: it paints the scene and
    /// *returns* the [`Msg`]s the interactions produced. It MUST NOT mutate the
    /// Model or do I/O; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
    /// bridge applies the returned messages through [`update`](Self::update).
    ///
    /// **Within-pane addressability (FC-4 / FC-5).** Each painted node rides into
    /// the AccessKit tree as a clickable, labelled `Button` keyed on the node's
    /// **stable label** (via [`facett_core::a11y::node`]) — so a headless robot can
    /// `Locate { Target::label("<node>") } → Act(Click)` a *specific* node, not just
    /// the pane. A click emits [`Msg::SelectNode`]; the selected node gets an accent
    /// ring. Before this the nodes were `painter.circle_filled` only — invisible to
    /// `get_by_label` / the `cmd_json` driver (probe-only within the pane).
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
        if self.at_scale() {
            return self.view_at_scale(ui);
        }
        let mut msgs: Vec<Msg> = Vec::new();
        let (rect, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
        let layout: Layout = self.state.layout.into();
        // Frozen-cache positions, copied out so the RefCell borrow is released
        // before the per-node interaction loop (which borrows `ui` mutably).
        let pos: Vec<egui::Pos2> = {
            let mut cache = self.layout_cache.borrow_mut();
            cache.positions(layout, &self.scene, rect).to_vec()
        };
        // The node/edge cloud: the shared GPU lane when selected and installed, else
        // the egui painter. Labels stay on egui either way.
        let drew_gpu = self.draw_gpu_cloud(ui, rect, &pos);
        if drew_gpu {
            facett_core::draw_position_labels(ui, &self.scene, &pos, rect);
        } else {
            facett_core::draw_positions(ui, &self.scene, &pos, rect, &self.empty_hint);
        }
        self.lane_drew.set(Some(drew_gpu));
        if self.scene.nodes.is_empty() {
            // facet-graph-1: the empty state carries its stable code (pink, under the hint).
            facett_core::errcode::paint_code(&ui.painter_at(rect), rect.center() + egui::vec2(0.0, 16.0), "facet-graph-1");
        }

        // ── FC-4/FC-5: ride each painted node into the AccessKit tree as a
        //    clickable, labelled Button keyed on its STABLE label, so a headless
        //    driver can address a specific node. A click emits a toggle Msg (FC-9).
        if self.scene.nodes.len() == pos.len() {
            let base_id = ui.id().with("graph-nodes");
            let sel = self.state.selected.clone();
            for (i, node) in self.scene.nodes.iter().enumerate() {
                let is_sel = sel.as_deref() == Some(node.label.as_str());
                let hit = egui::Rect::from_center_size(pos[i], egui::vec2(NODE_HIT, NODE_HIT));
                let sem = facett_core::a11y::Semantics::button(node.label.clone()).selected(is_sel);
                let resp = facett_core::a11y::node(ui, base_id, &node.label, egui::Sense::click(), hit, sem);
                if resp.clicked() {
                    msgs.push(Msg::SelectNode(node.label.clone()));
                }
            }
            // Accent ring on the selected node — visible selection feedback, kept
            // inside the allocated rect (the render-confinement invariant holds).
            if let Some(sel_label) = sel.as_deref()
                && let Some(i) = self.scene.nodes.iter().position(|n| n.label == sel_label)
            {
                let th = facett_core::theme(ui);
                ui.painter_at(rect).circle_stroke(pos[i], 8.0, egui::Stroke::new(2.0_f32, th.accent));
            }
        }

        // ── render-lane emit: this pure-view paint path RAN ───────────────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-graph::GraphView::view",
            "ui_render",
            !self.scene.nodes.is_empty() || !self.empty_hint.is_empty(),
            &format!("nodes={} edges={}", self.scene.nodes.len(), self.scene.edges.len()),
        );
        msgs
    }

    /// **The scale path's frame** (see [`scale`]): one layout per scene, a pan/zoom
    /// camera, the resident GPU cloud (or the egui painter without one), labels and
    /// AccessKit buttons for the on-screen nodes only, under [`A11Y_BUDGET`].
    #[allow(clippy::too_many_lines)]
    fn view_at_scale(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), egui::Sense::click_and_drag());
        let n = self.scene.nodes.len();
        // ── camera: drag pans, scroll zooms about the pointer ─────────────────────
        let mut cam = self.camera.get();
        if resp.dragged() {
            cam.pan += resp.drag_delta();
        }
        if resp.hovered() {
            let scroll = ui.input(|i| i.smooth_scroll_delta.y);
            if scroll != 0.0 {
                let at = resp.hover_pos().unwrap_or(rect.center());
                cam.zoom_about(rect, at, (scroll * 0.0025).exp());
            }
        }
        self.camera.set(cam);

        self.scale_rect.set(Some(rect));
        let mut cache = self.scale.borrow_mut();
        cache.sync(self.state.layout.into(), &self.scene);
        if cache.pending() {
            ui.ctx().request_repaint();
        }
        let th = facett_core::theme(ui);
        let painter = ui.painter_at(rect);
        let k = cam.scale(rect);
        let project = |p: egui::Pos2| rect.center() + cam.pan + p.to_vec2() * k;

        // ── the cloud: resident on the GPU, else the egui painter ─────────────────
        let drew_gpu = self.draw_resident(ui, rect, &cache, cam);
        self.lane_drew.set(Some(drew_gpu));
        if !drew_gpu {
            for e in &self.scene.edges {
                if e.src < n && e.dst < n {
                    let (a, b) = (project(cache.pos[e.src]), project(cache.pos[e.dst]));
                    if rect.intersects(egui::Rect::from_two_pos(a, b)) {
                        painter.line_segment([a, b], egui::Stroke::new(0.6_f32, th.edge));
                    }
                }
            }
            for (i, node) in self.scene.nodes.iter().enumerate() {
                let p = project(cache.pos[i]);
                if rect.expand(5.0).contains(p) {
                    painter.circle_filled(p, 5.0, node.color);
                }
            }
        }

        // ── on screen: labels (≤ 60 on screen) + the budgeted AccessKit buttons ───
        let on: Vec<usize> = (0..n).filter(|&i| rect.contains(project(cache.pos[i]))).collect();
        let labels = if on.len() <= scale::LABELS_ON_SCREEN { on.len() } else { 0 };
        for &i in on.iter().take(labels) {
            painter.text(project(cache.pos[i]) + egui::vec2(7.0, 0.0), egui::Align2::LEFT_CENTER, &self.scene.nodes[i].label, egui::FontId::proportional(10.0), th.text);
        }
        let sel = self.state.selected.clone();
        let sel_idx = sel.as_deref().and_then(|l| self.scene.nodes.iter().position(|nd| nd.label == l));
        let mut emitted: Vec<usize> = on.iter().copied().take(A11Y_BUDGET).collect();
        if let Some(s) = sel_idx
            && rect.contains(project(cache.pos[s]))
            && !emitted.contains(&s)
        {
            emitted.push(s);
        }
        // The budget, in the tree: the canvas says how much of the graph it exposes.
        let summary = format!(
            "{}: {n} nodes · {} on screen · {} addressable (budget {A11Y_BUDGET}) — pan / zoom to reach the others",
            self.title,
            on.len(),
            emitted.len()
        );
        resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, summary.clone()));
        let base_id = ui.id().with("graph-nodes");
        for &i in &emitted {
            let node = &self.scene.nodes[i];
            let is_sel = sel_idx == Some(i);
            let hit = egui::Rect::from_center_size(project(cache.pos[i]), egui::vec2(NODE_HIT, NODE_HIT));
            let sem = facett_core::a11y::Semantics::button(node.label.clone()).selected(is_sel);
            if facett_core::a11y::node(ui, base_id, &node.label, egui::Sense::click(), hit, sem).clicked() {
                msgs.push(Msg::SelectNode(node.label.clone()));
            }
        }
        // A click on the canvas itself picks the nearest on-screen node within reach
        // (the buttons cover only the budget).
        if let Some(click) = resp.clicked().then(|| resp.interact_pointer_pos()).flatten() {
            let reach2 = (NODE_HIT * 0.5) * (NODE_HIT * 0.5);
            let hit = on
                .iter()
                .map(|&i| (i, project(cache.pos[i]).distance_sq(click)))
                .filter(|&(_, d)| d <= reach2)
                .min_by(|a, b| a.1.total_cmp(&b.1));
            if let Some((i, _)) = hit {
                msgs.push(Msg::SelectNode(self.scene.nodes[i].label.clone()));
            }
        }
        if let Some(s) = sel_idx {
            painter.circle_stroke(project(cache.pos[s]), 8.0, egui::Stroke::new(2.0_f32, th.accent));
        }
        cache.last = scale::A11yFrame { on_screen: on.len(), emitted, labels };
        msgs
    }

    /// Push the resident cloud: the camera every frame, the scene once per layout.
    #[cfg(feature = "wgpu")]
    fn draw_resident(&self, ui: &egui::Ui, rect: egui::Rect, cache: &scale::ScaleCache, cam: scale::ScaleCamera) -> bool {
        use facett_core::render::gpu::graphcloud::{CloudCamera, CloudPoint};
        if !self.use_gpu {
            return false;
        }
        let ppp = ui.ctx().pixels_per_point();
        let o = (rect.center() - rect.min + cam.pan) * ppp;
        let ccam = CloudCamera::plane([rect.width() * ppp, rect.height() * ppp], [o.x, o.y], cam.scale(rect) * ppp, 5.0 * ppp, 0.5 * ppp, 1.5);
        let n = self.scene.nodes.len();
        let build = || {
            let pts = self.scene.nodes.iter().enumerate().map(|(i, nd)| CloudPoint::new([cache.pos[i].x, -cache.pos[i].y, 0.0], nd.color)).collect();
            let eds = self.scene.edges.iter().filter(|e| e.src < n && e.dst < n).map(|e| [e.src as u32, e.dst as u32]).collect();
            (pts, eds)
        };
        cache.slot.draw(ui, rect, cache.generation, build, ccam)
    }

    #[cfg(not(feature = "wgpu"))]
    fn draw_resident(&self, _ui: &egui::Ui, _rect: egui::Rect, _cache: &scale::ScaleCache, _cam: scale::ScaleCamera) -> bool {
        false
    }

    /// Per-label node counts (for the introspection).
    pub fn label_counts(&self) -> BTreeMap<String, usize> {
        let mut m = BTreeMap::new();
        for n in &self.scene.nodes {
            *m.entry(n.label.clone()).or_insert(0) += 1;
        }
        m
    }
}

impl GraphView {
    /// The copyable text: every node label, one per line (the graph carries no
    /// per-node selection, so this is the whole scene's label list).
    pub fn copy_text(&self) -> Option<String> {
        if self.scene.nodes.is_empty() {
            return None;
        }
        Some(self.scene.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
    }
}

// ── typed copy (§16) — read-only: the scene's node labels as Text ─────────────
impl facett_core::clip::CopySource for GraphView {
    fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
        use facett_core::clip::ClipKind;
        &[ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
        self.copy_text().map(facett_core::clip::ClipPayload::Text)
    }
}

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

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

// The bridge macro writes `impl Facet for GraphView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the overrides below.
// **Form 3** (`custom_state_json`) because the view publishes a RICHER `state_json`
// than a plain `serde(state())`: the `nodes`/`edges`/`labels` counts facett-demo's
// mega_matrix reads are *derived* from the input `Scene`, not the layout Model — so
// the default `state_json` is suppressed and these EXACT pre-migration keys supplied.
facett_core::impl_facet_via_elm!(GraphView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        if self.at_scale() {
            // The tree holds the budgeted on-screen buttons, so those ARE the targets.
            let mut j = self.scale_json();
            let c = self.scale.borrow();
            j["labels"] = serde_json::json!(self.label_counts());
            j["node_targets"] = c.last.emitted.iter().map(|&i| self.scene.nodes[i].label.clone()).collect::<Vec<_>>().into();
            j["error_code"] = serde_json::Value::Null;
            return j;
        }
        serde_json::json!({
            "nodes": self.scene.nodes.len(),
            "edges": self.scene.edges.len(),
            "labels": self.label_counts(),
            // Within-pane interaction state a driver reads/asserts:
            "selected": self.state.selected.clone(),
            // Discoverable AccessKit targets (Locate { Target::label(..) }).
            "node_targets": self.node_targets(),
            // Which lane drew the node/edge cloud (facett-core `lane::cloud_lane_json`).
            "lane": facett_core::render::lane::cloud_lane_json(self.use_gpu, Self::gpu_installed(), self.lane_drew.get()),
            // The stable UI error code for the empty state (facett_core::errcode).
            "error_code": if self.scene.nodes.is_empty() { serde_json::json!("facet-graph-1") } else { serde_json::Value::Null },
        })
    }
    fn copy(&mut self) -> Option<String> {
        use facett_core::clip::CopySource as _;
        self.copy_payload().map(|p| p.as_text())
    }
    /// Painted via [`draw`], which reads the active [`Theme`] (edges/labels/empty
    /// hint) from the context; each node is click-selectable (drives the accent
    /// ring); the canvas takes the host's `available_size`.
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().selectable().resizable().copyable()
    }
    /// STRUCTURAL severity: RED for the empty scene (`facet-graph-1`), Info otherwise.
    fn severity(&self) -> facett_core::Severity {
        if self.scene.nodes.is_empty() { facett_core::Severity::Error } else { facett_core::Severity::Info }
    }
    fn selection_json(&self) -> serde_json::Value {
        match &self.state.selected {
            Some(l) => serde_json::json!({ "node": l }),
            None => serde_json::Value::Null,
        }
    }
});

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn typed_copy_is_the_node_label_list() {
        use facett_core::clip::{ClipKind, CopySource};
        let scene = scene_from_labeled_edges(vec![(1, 2, "Person".into(), "Company".into())]);
        let mut g = GraphView::new(scene);
        let p = g.copy_payload().expect("a populated scene copies");
        assert_eq!(p.kind(), ClipKind::Text);
        assert!(p.as_text().contains("Person") && p.as_text().contains("Company"));
        assert_eq!(<GraphView as Facet>::copy(&mut g), Some(p.as_text()));
    }

    #[test]
    fn builds_scene_from_labeled_edges() {
        let scene = scene_from_labeled_edges(vec![
            (1, 2, "Person".into(), "Company".into()),
            (1, 3, "Person".into(), "Address".into()),
        ]);
        assert_eq!(scene.nodes.len(), 3, "1, 2, 3 distinct");
        assert_eq!(scene.edges.len(), 2);
        assert_eq!(scene.nodes[0].label, "Person");
    }

    // ── Elm split: state_json keys preserved byte-for-byte ───────────────────
    #[test]
    fn state_json_preserves_the_pre_migration_keys() {
        let scene = scene_from_labeled_edges(vec![
            (1, 2, "Person".into(), "Company".into()),
            (1, 3, "Person".into(), "Address".into()),
        ]);
        let g = GraphView::new(scene);
        let j = Facet::state_json(&g);
        // The pre-migration `nodes` / `edges` / `labels` count keys are preserved
        // byte-for-byte (no layout key leaks from the Model).
        assert_eq!(j["nodes"], 3);
        assert_eq!(j["edges"], 2);
        assert_eq!(j["labels"]["Person"], 1);
        // Plus the within-pane addressability keys: the selection (null when nothing
        // is selected) and the discoverable AccessKit target list.
        assert!(j["selected"].is_null(), "nothing selected initially");
        assert_eq!(
            j["node_targets"],
            serde_json::json!(["Address", "Company", "Person"]),
            "node_targets lists the distinct addressable node labels, sorted"
        );
        let obj = j.as_object().unwrap();
        assert_eq!(
            obj.len(),
            7,
            "nodes/edges/labels + selected/node_targets + lane/error_code: {:?}",
            obj.keys().collect::<Vec<_>>()
        );
        for k in ["nodes", "edges", "labels", "selected", "node_targets", "lane", "error_code"] {
            assert!(obj.contains_key(k), "state_json carries {k}");
        }
        // Not painted yet, no GPU lane selected, no renderer installed in a unit test:
        // the lane says so plainly instead of reading `cpu`.
        assert_eq!(j["lane"]["lane_flag"], "pending");
        assert_eq!(j["lane"]["gpu"], "no GPU");
        assert!(j["error_code"].is_null(), "a populated graph carries no error code");
        let empty = Facet::state_json(&GraphView::new(facett_core::Scene::new()));
        assert_eq!(empty["error_code"], "facet-graph-1");
    }

    // ── FC-2 → FC-3: the within-pane node selection drives headlessly (the same
    //    transition the painted-node click produces). ─────────────────────────────
    #[test]
    fn harness_drives_selectnode_toggle_and_clear() {
        use facett_core::harness;
        let mut g = GraphView::new(scene_from_labeled_edges(vec![
            (1, 2, "A".into(), "B".into()),
            (2, 3, "B".into(), "C".into()),
        ]));
        assert!(g.selected().is_none(), "starts unselected");
        // Selecting a node by its stable label is observable in the snapshot.
        let snap = harness::snapshot(&mut g, [Msg::SelectNode("B".into())]);
        assert_eq!(snap.selected.as_deref(), Some("B"), "selection observable headlessly");
        assert_eq!(Facet::selection_json(&g)["node"], "B");
        assert_eq!(Facet::state_json(&g)["selected"], "B");
        // Re-selecting the open node toggles it off (the click behaviour).
        let snap = harness::snapshot(&mut g, [Msg::SelectNode("B".into())]);
        assert!(snap.selected.is_none(), "re-select toggles off");
        // ClearSelection wipes any selection.
        let snap = harness::snapshot(&mut g, [Msg::SelectNode("A".into()), Msg::ClearSelection]);
        assert!(snap.selected.is_none(), "ClearSelection empties the selection");
        assert!(Facet::selection_json(&g).is_null());
    }

    // ── FC-2 → FC-3 as a *headless* property via the core harness ─────────────
    #[test]
    fn harness_drives_setlayout_and_snapshots_state() {
        use facett_core::harness;
        let mut g = GraphView::new(scene_from_labeled_edges(vec![(1, 2, "A".into(), "B".into())]));
        assert_eq!(g.state().layout, GraphLayout::Circular, "default layout");
        // SetLayout is observable in the snapshot.
        let snap = harness::snapshot(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
        assert_eq!(snap.layout, GraphLayout::Force, "layout is observable headlessly");
        assert_eq!(&snap, g.state(), "the snapshot is a clone of the live state");
        assert_eq!(g.state().layout, GraphLayout::Force, "the live view reflects the driven layout");
        assert!(matches!(g.layout(), Layout::Force), "the accessor maps back to the core Layout");
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        use facett_core::harness;
        let mut g = GraphView::new(Scene::new());
        // FC-8: the graph view does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
        assert!(effects.is_empty(), "FC-8: graph view emits no Effects");
        // FC-3: the observable Model round-trips through serde.
        let json = serde_json::to_value(g.state()).unwrap();
        assert_eq!(json["layout"], "force");
        let back: GraphState = serde_json::from_value(json).unwrap();
        assert_eq!(&back, g.state(), "serde(state) -> state round-trips");
    }

    #[test]
    fn headless_render_draws_and_reports_state() {
        use facett_core::harness;
        let mut g = GraphView::new(scene_from_labeled_edges(vec![
            (1, 2, "Person".into(), "Company".into()),
            (2, 3, "Company".into(), "Address".into()),
        ]))
        .with_title("graph");
        let r = harness::headless_render(&mut g);
        assert_eq!(r.title, "graph");
        assert_eq!(r.state["nodes"], 3);
        assert_eq!(r.state["edges"], 2);
        assert!(r.drew(), "a 3-node graph tessellates to vertices");
    }

    /// The GPU lane selected in a process with no cloud renderer (this unit test has no
    /// device): the state says `no GPU` plainly, the frame is reported as the egui
    /// painter's, and the painter really drew. With a renderer installed the same pane
    /// reads `lane_flag: gpu` — measured in a WebGPU browser by `gallery_probe`.
    #[test]
    fn a_selected_gpu_lane_without_a_renderer_says_no_gpu() {
        use facett_core::harness;
        let mut g = GraphView::new(scene_from_labeled_edges(vec![(1, 2, "A".into(), "B".into())])).with_gpu(true);
        let r = harness::headless_render(&mut g);
        assert!(r.drew());
        let lane = &r.state["lane"];
        assert_eq!(lane["selected"], true);
        assert_eq!(lane["gpu"], "no GPU");
        assert_eq!(lane["lane_flag"], "cpu", "the frame went through the egui painter: {lane}");
        assert_eq!(lane["gpu_cloud"], false);
    }
}