facett-graphpan/src/lib.rs
//! **facett-graphpan** — the bare, domain-agnostic **pan/zoom graph canvas**: a
//! caller-laid-out [`GraphModel`] (nodes in world space + edges by id) painted
//! through ONE egui renderer ([`draw_graph`] / [`draw_graph_scrolled`]) that
//! drives pan/drag + scroll-zoom and click-to-select (lighting the clicked node's
//! downstream subtree). Adopted verbatim from nornir's `src/viz/graph_render.rs`
//! (the `draw_graph` routine behind both the 🏛 Architecture wiring board and the
//! 🧬 release dashboard), with the `eframe::egui` skew dropped for plain `egui`.
//!
//! # How this differs from `facett-graphview`
//! `facett-graphview` adopted the same data *shape* and an **owning** Facet
//! ([`DecoratedGraphView`](https://docs.rs/facett-graphview)) — the egui twin that
//! holds its own pan/zoom + model. This crate is the **borrowed canvas + the
//! transform**: a free [`draw_graph`] function a host calls every frame against a
//! [`GraphView`] *transform* it owns (so pan/zoom/selection survive across frames),
//! plus the [`content_extent`] + the [`draw_graph_scrolled`] honest-scrollbar
//! variant for a static board. The two are complementary: graphview is the
//! consolidated render *engine* (CPU/GPU rasterizer + a Facet), graphpan is the
//! minimal "paint this model onto an egui Ui, return what was clicked" routine.
//!
//! # Domain-agnostic by design
//! This crate knows NOTHING about architecture node-kinds or release gates. The
//! caller hands it a [`GraphModel`] (already laid out — the caller owns its
//! layout) and an optional [`Decorations`] overlay (per-node status rings + corner
//! badges + emphasis cut-edges). Colours are plain `egui::Color32`; nothing
//! nornir-specific crosses the seam.
//!
//! Surfaces:
//! * [`GraphModel`] / [`GraphNode`] / [`GraphEdge`] — the caller-laid-out graph.
//! * [`Decorations`] / [`NodeDecoration`] — the opaque overlay layer.
//! * [`GraphView`] — the persistent pan/zoom/selection transform (caller-owned),
//! with [`GraphView::project`] (world→screen) + [`GraphView::state_json`].
//! * [`draw_graph`] / [`draw_graph_scrolled`] — THE renderer (+ [`GraphResponse`]).
//! * [`GraphPanView`] — an owning canonical [`Facet`] so a deck mounts the canvas
//! as a tab and a headless driver reads + asserts it.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`GraphView`]** — the complete observable, serializable, round-trippable
//! navigation transform (pan + zoom + selected node). It IS the [`GraphPanView`]
//! Model: [`GraphPanView::state`] hands back a `&GraphView` and a headless driver
//! ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`. The graph
//! *data* ([`GraphModel`] + [`Decorations`]) is the *input* (held on the view, not
//! in the Model — it is not the state a driver mutates, and its `egui::Color32`
//! fills aren't serde-friendly); the rich node/screen keys are *derived* and
//! published via a form-3 `custom_state_json`.
//! - **[`Msg`] + [`GraphPanView::update`]** — the single mutation path (FC-2): every
//! input (pan drag, scroll-zoom, click-select, ⊙ fit) is a `Msg`; a headless driver
//! reaches the identical transitions the canvas gestures drive.
//! - **[`GraphPanView::view`]** (the [`facett_core::Elm`] view) — a **pure** paint
//! (FC-9): it reads `&self`, paints the canvas, and *returns* the [`Msg`]s the
//! interactions produced; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
//! bridge applies them via [`update`](GraphPanView::update). The only `&self`-interior
//! mutation is the ephemeral `last_origin` paint cache (FC-7), outside the Model.
//! - **FC-5** — the selection keys on the **stable node id**, invariant across pan/zoom.
/// **Legibility at scale + the shared instanced GPU cloud lane** — collision-avoiding
/// label placement, a per-cell edge ink budget, a spatial pick index, and the opt-in
/// GPU painter. All default OFF, so [`draw_graph`] is byte-for-byte unchanged. See
/// [`legible`].
pub mod legible;
pub use legible::{FramePlan, LegibilityReport, PaintOpts, PickIndex};
/// **The RESIDENT GPU cloud lane** at scale (feature `wgpu`): the model uploaded once, the
/// camera per frame. See [`resident`].
#[cfg(feature = "wgpu")]
pub mod resident;
#[cfg(feature = "wgpu")]
pub use facett_core::render::gpu::graphcloud::cloud::RESIDENT_ABOVE;
#[cfg(not(feature = "wgpu"))]
mod resident {
/// No `wgpu`: there is no resident lane, so nothing to cache.
#[derive(Default)]
pub(crate) struct ResidentCache;
}
use std::cell::{Cell, RefCell};
use std::collections::{BTreeSet, HashMap, VecDeque};
use egui::style::ScrollAnimation;
use egui::{Align2, Color32, FontId, Pos2, Sense, Stroke, Vec2};
use serde::{Deserialize, Serialize};
use facett_core::FacetCaps;
/// Default node chip size (world units, before zoom). Matches the arch board.
pub const BOX_W: f32 = 184.0;
pub const BOX_H: f32 = 34.0;
/// One laid-out node: its stable id, the label painted in the chip, the chip
/// fill + stroke (kind colour, caller's choice), and its world-space centre.
#[derive(Clone)]
pub struct GraphNode {
pub id: String,
pub label: String,
pub fill: Color32,
pub stroke: Color32,
pub pos: Pos2,
}
/// One edge: endpoints by node id, its colour, a `dashed` flag (transitive /
/// suggested-cut style), and an optional mid-edge label (e.g. a cut rationale).
#[derive(Clone)]
pub struct GraphEdge {
pub from: String,
pub to: String,
pub color: Color32,
pub dashed: bool,
pub label: Option<String>,
}
/// The full graph to paint — nodes (laid out in world space) + edges.
#[derive(Clone, Default)]
pub struct GraphModel {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
/// A per-node decoration the caller layers over the base chip: an optional status
/// **ring** colour (drawn outside the kind stroke) and an optional **badge** (a
/// short glyph/text painted at the chip's top-right corner in `badge_color`).
#[derive(Clone, Default)]
pub struct NodeDecoration {
pub ring: Option<Color32>,
pub badge: Option<String>,
pub badge_color: Option<Color32>,
}
/// The caller-supplied overlay layer (domain-agnostic). Per-node rings/badges
/// keyed by node id, plus extra edges to draw on top dashed-and-labelled (e.g. a
/// dependency cycle's suggested cut edge + its rationale). Empty = no overlay.
#[derive(Clone, Default)]
pub struct Decorations {
/// node id → its ring/badge overlay.
pub nodes: HashMap<String, NodeDecoration>,
/// Extra emphasis edges painted ON TOP of the base edges (the cut lines).
pub edges: Vec<GraphEdge>,
}
/// Serde bridge for `egui::Vec2` (which carries no serde impls in this build) — it
/// (de)serializes as a plain `[x, y]` pair, the same shape `state_json` prints, so
/// a [`GraphView`] round-trips through JSON (FC-3) without an egui `serde` feature.
mod vec2_serde {
use egui::Vec2;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &Vec2, s: S) -> Result<S::Ok, S::Error> {
serde::Serialize::serialize(&[v.x, v.y], s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec2, D::Error> {
let [x, y] = <[f32; 2]>::deserialize(d)?;
Ok(Vec2::new(x, y))
}
}
/// **The complete observable navigation state (FC-1 / FC-3)**: the persistent
/// pan/zoom transform + selection for the canvas. Owned by the caller (the
/// tab/dashboard state) so pan/zoom/selection survive across frames — and, as the
/// [`GraphPanView`] Model, it is serializable + round-trippable so a headless driver
/// snapshots it after feeding a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphView {
#[serde(with = "vec2_serde")]
pub pan: Vec2,
pub zoom: f32,
/// The selected node id (its downstream subtree is highlighted).
pub selected: Option<String>,
/// **Point-cloud (DOT) mode.** `None` (default) → the classic labelled chips
/// (`BOX_W × BOX_H` world-scaled rects). `Some(r)` → each node is a small filled
/// **circle** of radius `r` **screen px** (constant, zoom-independent) and labels are
/// skipped — the mode a big graph (thousands of nodes: the world/warehouse cloud)
/// needs so nodes read as separated points instead of overlapping 184-px chips. Also
/// switches the click pick to nearest-centre-within-radius.
#[serde(default)]
pub dot_px: Option<f32>,
}
impl Default for GraphView {
fn default() -> Self {
Self { pan: Vec2::ZERO, zoom: 1.0, selected: None, dot_px: None }
}
}
impl GraphView {
/// Reset pan + zoom (the ⊙ fit button).
pub fn fit(&mut self) {
self.pan = Vec2::ZERO;
self.zoom = 1.0;
}
/// **Fit a world-space bounding box to a viewport** — the honest "⊙ fit" for a laid-out
/// graph (the plain [`fit`](Self::fit) only recentres at zoom 1, which leaves a large
/// spread cloud overflowing the viewport, overlapping into a blob). Sets `zoom` so
/// `(min..max)` fills a `viewport` box inside `margin` px, and `pan` so the box centre
/// lands at the viewport centre (matching `draw_graph`'s `origin = rect.center()+pan`
/// with the graph centred on world 0,0). Use node CENTRES for the box in dot mode.
pub fn fit_bounds(&mut self, min: Pos2, max: Pos2, viewport: Vec2, margin: f32) {
let bw = (max.x - min.x).max(1.0);
let bh = (max.y - min.y).max(1.0);
let z = ((viewport.x - 2.0 * margin) / bw)
.min((viewport.y - 2.0 * margin) / bh)
.clamp(0.01, 4.0);
self.zoom = z;
let cx = (min.x + max.x) * 0.5;
let cy = (min.y + max.y) * 0.5;
self.pan = -Vec2::new(cx, cy) * z;
}
/// Clear the selection (and thus the downstream highlight).
pub fn clear_selection(&mut self) {
self.selected = None;
}
/// World→screen projection for a world point, given the viewport `origin`
/// (the screen position world (0,0) maps to — typically `rect.center() +
/// pan`). The exact transform [`draw_graph`] paints with, exposed so a caller
/// (or a headless test) can compute where a node lands on screen without
/// re-deriving the math: `origin + p·zoom`.
pub fn project(&self, origin: Pos2, p: Pos2) -> Pos2 {
origin + p.to_vec2() * self.zoom
}
/// The transform as readable data (the "components expose readable data"
/// LAW): the pan vector, the zoom, the selected node, and — when `origin` is
/// supplied — the projected screen position of world (0,0) and of a unit
/// world step, so a driver can assert the exact pan/zoom mapping headlessly.
pub fn state_json(&self, origin: Option<Pos2>) -> serde_json::Value {
let mut j = serde_json::json!({
"pan": [self.pan.x, self.pan.y],
"zoom": self.zoom,
"selected": self.selected,
});
if let (Some(o), Some(obj)) = (origin, j.as_object_mut()) {
let o0 = self.project(o, Pos2::ZERO);
let o1 = self.project(o, Pos2::new(1.0, 1.0));
obj.insert("origin".into(), serde_json::json!([o.x, o.y]));
obj.insert("project_zero".into(), serde_json::json!([o0.x, o0.y]));
obj.insert("project_unit".into(), serde_json::json!([o1.x, o1.y]));
}
j
}
}
/// The downstream-reachable set (BFS over forward edges) from `seed`, in node-id
/// space. The visual twin of `nornir arch trace` — used to dim everything off the
/// lit path when a node is selected. `pub` so a caller can fold the lit set into
/// its own `state_json` without re-deriving it.
pub fn downstream_of(model: &GraphModel, seed: &str) -> BTreeSet<String> {
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &model.edges {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
let mut lit: BTreeSet<String> = BTreeSet::new();
let mut q: VecDeque<&str> = VecDeque::new();
lit.insert(seed.to_string());
q.push_back(seed);
while let Some(cur) = q.pop_front() {
if let Some(outs) = adj.get(cur) {
for &nxt in outs {
if lit.insert(nxt.to_string()) {
q.push_back(nxt);
}
}
}
}
lit
}
/// Dim a colour toward transparency for the un-traced background when a node is
/// selected (so the lit downstream path pops). Verbatim from the arch board.
fn dim(c: Color32) -> Color32 {
c.linear_multiply(0.22)
}
/// What a [`draw_graph`] frame did: the node (if any) the user clicked this frame
/// (so the caller can run its own side effects — click-to-code, scope a release),
/// and whether the click landed on empty space (a deselect). The view's
/// `selected` + pan/zoom are already mutated in place.
#[derive(Default)]
pub struct GraphResponse {
/// `Some(node_id)` if a node was clicked this frame.
pub clicked_node: Option<String>,
/// `true` if the click hit empty canvas (cleared the selection).
pub clicked_empty: bool,
}
// ── 📌 Stay / 🎯 Focus + the fly-to (facett_core::fly) ───────────────────────────
//
// A canvas's flight is UI-time state, so it lives in the UI's own memory under the
// canvas id — not in [`GraphView`], whose literal every host builds (a new field would
// break them all for state that must never be serialized anyway).
type FlightSlot = (facett_core::fly::Flight, f32);
/// Is the canvas `id` flying?
pub fn flying(ctx: &egui::Context, id: egui::Id) -> bool {
ctx.data(|d| d.get_temp::<FlightSlot>(id)).is_some()
}
/// **Fly** canvas `id`'s camera to `(zoom, pan)` on the shared smooth zoom-and-pan path
/// from `now`, for a canvas `viewport_w` wide — retargeted from where the camera IS
/// when it is already flying.
pub fn fly_to(ctx: &egui::Context, id: egui::Id, view: &GraphView, zoom: f32, pan: Vec2, now: f64, viewport_w: f32) {
use facett_core::fly;
let (c1, w1) = fly::from_pan_zoom([pan.x, pan.y], zoom, viewport_w);
let (cam_pan, cam_zoom) = match ctx.data(|d| d.get_temp::<FlightSlot>(id)) {
Some((f, vw)) => {
let (c, w) = f.at(now);
let (p, z) = fly::to_pan_zoom(c, w, vw);
(Vec2::new(p[0], p[1]), z)
}
None => (view.pan, view.zoom),
};
let (c0, w0) = fly::from_pan_zoom([cam_pan.x, cam_pan.y], cam_zoom, viewport_w);
let slot: FlightSlot = (fly::Flight::new(now, c0, w0, c1, w1), viewport_w);
ctx.data_mut(|d| d.insert_temp(id, slot));
ctx.request_repaint();
}
/// ⊙ fit as a flight: fly canvas `id` to the world box `min..max`, framed and centred.
#[allow(clippy::too_many_arguments)]
pub fn fly_to_bounds(ctx: &egui::Context, id: egui::Id, view: &GraphView, min: Pos2, max: Pos2, viewport: Vec2, margin: f32, now: f64) {
let mut target = view.clone();
target.fit_bounds(min, max, viewport, margin);
fly_to(ctx, id, view, target.zoom, target.pan, now, viewport.x);
}
/// **The camera step every graphpan canvas runs each frame** (before it projects): a
/// drag or a scroll takes the camera from wherever a flight had it (the flight ends);
/// a flight under way advances, repainting only while it flies.
pub fn camera_step(ui: &egui::Ui, view: &mut GraphView, resp: &egui::Response) {
let id = resp.id;
if resp.dragged() || (resp.hovered() && ui.input(|i| i.smooth_scroll_delta.y) != 0.0) {
ui.data_mut(|d| d.remove::<FlightSlot>(id));
return;
}
let Some((f, vw)) = ui.data(|d| d.get_temp::<FlightSlot>(id)) else { return };
let now = ui.input(|i| i.time);
let (c, w) = f.at(now);
let (p, z) = facett_core::fly::to_pan_zoom(c, w, vw);
(view.pan, view.zoom) = (Vec2::new(p[0], p[1]), z);
if f.done(now) {
ui.data_mut(|d| d.remove::<FlightSlot>(id));
} else {
ui.ctx().request_repaint();
}
}
/// The zoom 🎯 Focus lands at (labels readable), unless the camera is already closer.
pub const FOCUS_ZOOM: f32 = 1.0;
/// **After a click**: in the viewer's 🎯 Focus mode ([`facett_core::fly::NavMode`]), fly
/// the clicked node to the centre of the canvas `resp` at [`FOCUS_ZOOM`]; in 📌 Stay,
/// nothing — a click never moves the camera.
pub fn focus_click(ui: &egui::Ui, view: &GraphView, model: &GraphModel, resp: &egui::Response, clicked: Option<&str>) {
let Some(id) = clicked else { return };
if facett_core::fly::NavMode::remembered(ui.ctx()) != facett_core::fly::NavMode::Focus {
return;
}
if let Some(n) = model.nodes.iter().find(|n| n.id == id) {
let zoom = view.zoom.max(FOCUS_ZOOM).min(4.0);
let pan = -Vec2::new(n.pos.x, n.pos.y) * zoom;
fly_to(ui.ctx(), resp.id, view, zoom, pan, ui.input(|i| i.time), resp.rect.width());
}
}
/// THE shared renderer. Paints `model` decorated by `decorations` onto the egui
/// `ui`, driving pan/drag + scroll-zoom and click-to-select (which lights the
/// clicked node's downstream subtree). Mutates `view` (pan/zoom/selected) and
/// returns what was clicked so the caller can run domain side effects.
#[allow(clippy::too_many_arguments)]
pub fn draw_graph(
ui: &mut egui::Ui,
model: &GraphModel,
decorations: &Decorations,
view: &mut GraphView,
bg: Color32,
text: Color32,
selection_ring: Color32,
text_dim: Color32,
) -> GraphResponse {
if view.zoom <= 0.0 {
view.zoom = 1.0;
}
let (resp, painter) = ui.allocate_painter(facett_core::nav::canvas_size(ui), Sense::click_and_drag());
painter.rect_filled(resp.rect, 4.0, bg);
camera_step(ui, view, &resp);
if resp.dragged() {
view.pan += resp.drag_delta();
}
if resp.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
view.zoom = (view.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
}
}
// Origin is the viewport centre offset by pan; the graph is centred on (0,0).
let origin = resp.rect.center() + view.pan;
let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();
let out = paint_graph(
&painter, model, decorations, view, origin, click, text, selection_ring, text_dim,
);
focus_click(ui, view, model, &resp, out.clicked_node.as_deref());
out
}
/// [`draw_graph`] **with [`PaintOpts`]** — the one-call interactive canvas that can route
/// the node/edge cloud through the **shared instanced GPU renderer**
/// ([`facett_core::render::gpu::graphcloud`], the SAME cloud
/// [`facett_graphview::world_still`] rasterises headless for the 100k/1M "universe" still)
/// instead of tessellating every disc on the egui UI thread.
///
/// This is the seam a scale host mounts for a **UNIVERSE view**: the full node cloud with
/// **no LOD/budget cap**, labels off, coloured by category — a pure GPU star-field that
/// stays interactive (pan/drag + scroll-zoom + click-select, exactly as [`draw_graph`])
/// where the CPU painter would stall (a 30 000-node cloud is 2.47 M triangles / 33 ms on
/// one core; the GPU draws it as instances). Everything [`draw_graph`] does is preserved;
/// the ONLY additions are the [`PaintOpts`] (opt into `gpu_cloud`, label/edge thinning) and
/// the returned [`LegibilityReport`] (its `gpu_cloud` flag tells the host WHICH lane drew).
///
/// The GPU lane is **fail-safe**: it paints only when the host installed the cloud renderer
/// ([`legible::gpu_cloud_available`]); otherwise [`paint_scene_opts`] transparently runs the
/// CPU painter, so a box with no GPU still renders every node (`report.gpu_cloud == false`).
#[allow(clippy::too_many_arguments)]
pub fn draw_graph_opts(
ui: &mut egui::Ui,
model: &GraphModel,
decorations: &Decorations,
view: &mut GraphView,
bg: Color32,
text: Color32,
selection_ring: Color32,
text_dim: Color32,
opts: PaintOpts,
) -> (GraphResponse, LegibilityReport) {
if view.zoom <= 0.0 {
view.zoom = 1.0;
}
let (resp, painter) = ui.allocate_painter(facett_core::nav::canvas_size(ui), Sense::click_and_drag());
painter.rect_filled(resp.rect, 4.0, bg);
camera_step(ui, view, &resp);
if resp.dragged() {
view.pan += resp.drag_delta();
}
if resp.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
view.zoom = (view.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
}
}
let origin = resp.rect.center() + view.pan;
let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();
// Click-to-select (identical to `paint_graph`): nearest node within its hit shape;
// clicking empty clears. Kept here so the GPU lane keeps `draw_graph`'s interaction.
let mut out = GraphResponse::default();
if let Some(click) = click {
match hit_test(model, origin, click, view.zoom, view.dot_px) {
Some(id) => {
view.selected = Some(id.clone());
out.clicked_node = Some(id);
}
None => {
view.selected = None;
out.clicked_empty = true;
}
}
}
// `Some((ui, rect))` unlocks the GPU cloud callback; whether it actually draws is
// decided downstream by the installed-renderer gate (never here).
let report = paint_scene_opts(
&painter,
model,
decorations,
view,
origin,
text,
selection_ring,
text_dim,
opts,
Some((ui, resp.rect)),
);
focus_click(ui, view, model, &resp, out.clicked_node.as_deref());
(out, report)
}
/// The painted extent of `model` in WORLD units (before zoom): the axis-aligned
/// bounding box of every chip (node centre ± half-box) grown by a margin. This is
/// the TRUE content size — the figure a wrapping `ScrollArea` must allocate so its
/// scrollbar handle scales to the real overflow (rather than to the viewport).
/// Returns at least one box so an empty/one-node board still has a sane size.
pub fn content_extent(model: &GraphModel) -> Vec2 {
const MARGIN: f32 = 48.0;
if model.nodes.is_empty() {
return Vec2::new(BOX_W, BOX_H) + Vec2::splat(2.0 * MARGIN);
}
let (mut min, mut max) = (Pos2::new(f32::MAX, f32::MAX), Pos2::new(f32::MIN, f32::MIN));
let half = Vec2::new(BOX_W, BOX_H) * 0.5;
for n in &model.nodes {
min.x = min.x.min(n.pos.x - half.x);
min.y = min.y.min(n.pos.y - half.y);
max.x = max.x.max(n.pos.x + half.x);
max.y = max.y.max(n.pos.y + half.y);
}
(max - min) + Vec2::splat(2.0 * MARGIN)
}
/// THE shared renderer in a **scrolling** frame, for a static wiring board.
///
/// Unlike [`draw_graph`] (which pans a viewport-sized canvas — drag to move), this
/// allocates a canvas at the graph's TRUE [`content_extent`] (× zoom) inside a
/// styled [`egui::ScrollArea`]. Because the allocated size is the real painted
/// bounds, the scrollbar handle scales correctly. Scrolling is smooth/kinetic via
/// the context's `scroll_animation`, and the bars are made wide + always-visible.
/// `view.pan` is NOT used for navigation here (the ScrollArea offset is); zoom
/// still works (it resizes the allocated canvas, so the bars re-scale).
#[allow(clippy::too_many_arguments)]
pub fn draw_graph_scrolled(
ui: &mut egui::Ui,
model: &GraphModel,
decorations: &Decorations,
view: &mut GraphView,
bg: Color32,
text: Color32,
selection_ring: Color32,
text_dim: Color32,
) -> GraphResponse {
if view.zoom <= 0.0 {
view.zoom = 1.0;
}
// Smooth/kinetic scroll: glide to the target instead of jumping.
ui.style_mut().scroll_animation = ScrollAnimation::new(1200.0, egui::Rangef::new(0.08, 0.30));
// A wider, always-on, high-contrast bar so the handle is easy to grab + read.
{
let s = &mut ui.style_mut().spacing.scroll;
s.bar_width = 12.0;
s.handle_min_length = 24.0;
s.floating = false;
s.dormant_background_opacity = 1.0;
s.active_background_opacity = 1.0;
s.interact_background_opacity = 1.0;
}
let extent = content_extent(model) * view.zoom;
let mut out = GraphResponse::default();
egui::ScrollArea::both()
.id_salt("graph_scroll")
.auto_shrink([false, false])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
.show(ui, |ui| {
// Allocate the canvas at the TRUE extent — this is what makes the
// scrollbar honest. The graph is centred on world (0,0), so shift the
// projection origin to the canvas centre.
let (resp, painter) =
ui.allocate_painter(extent.max(ui.available_size()), Sense::click_and_drag());
painter.rect_filled(resp.rect, 4.0, bg);
// Zoom on hover with the modifier held (plain wheel = smooth scroll).
if resp.hovered() {
let (scroll, zoom_mod) =
ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.command || i.modifiers.ctrl));
if zoom_mod && scroll != 0.0 {
view.zoom = (view.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
}
}
let origin = resp.rect.center();
let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();
out = paint_graph(
&painter, model, decorations, view, origin, click, text, selection_ring, text_dim,
);
});
out
}
/// Hit-test a `click` against the chips of `model` under the projection `origin` +
/// `zoom` (graph centred on world 0,0). Returns the id of the node whose box
/// contains the click, or `None` for an empty-canvas click. **Pure** — it does not
/// mutate any selection; the caller decides what a hit means.
fn hit_test(model: &GraphModel, origin: Pos2, click: Pos2, zoom: f32, dot_px: Option<f32>) -> Option<String> {
let project = |p: Pos2| origin + p.to_vec2() * zoom;
// DOT mode: pick the node whose CENTRE is nearest the click, within a forgiving pick
// radius (so a small point cloud is clickable). Chip mode keeps the original
// first-chip-that-contains behaviour (unchanged for every existing board).
if let Some(r) = dot_px {
let pick = (r * 3.0).max(7.0);
let pick2 = pick * pick;
let mut best: Option<(&str, f32)> = None;
for n in &model.nodes {
let d2 = (project(n.pos) - click).length_sq();
if d2 <= pick2 && best.is_none_or(|(_, bd)| d2 < bd) {
best = Some((n.id.as_str(), d2));
}
}
return best.map(|(id, _)| id.to_string());
}
model.nodes.iter().find_map(|n| {
let c = project(n.pos);
let half = Vec2::new(BOX_W, BOX_H) * 0.5 * zoom;
let rect = egui::Rect::from_center_size(c, half * 2.0);
rect.contains(click).then(|| n.id.clone())
})
}
/// The shared paint + hit-test core used by both [`draw_graph`] (pan/zoom canvas)
/// and [`draw_graph_scrolled`] (ScrollArea canvas). Given the projection `origin`
/// (graph centred on world 0,0 → screen) and an optional `click` position, it
/// hit-tests the click, lights the selection's downstream subtree, and paints the
/// edges + chips. Mutates `view.selected`; returns the click outcome. The paint
/// itself is factored into the **pure** [`paint_scene`] the FC-9 [`GraphPanView::view`]
/// reuses without any mutation.
#[allow(clippy::too_many_arguments)]
fn paint_graph(
painter: &egui::Painter,
model: &GraphModel,
decorations: &Decorations,
view: &mut GraphView,
origin: Pos2,
click: Option<Pos2>,
text: Color32,
selection_ring: Color32,
text_dim: Color32,
) -> GraphResponse {
let mut out = GraphResponse::default();
// Click-to-select: nearest node within its box; clicking empty clears.
if let Some(click) = click {
match hit_test(model, origin, click, view.zoom, view.dot_px) {
Some(id) => {
view.selected = Some(id.clone());
out.clicked_node = Some(id);
}
None => {
view.selected = None;
out.clicked_empty = true;
}
}
}
paint_scene(painter, model, decorations, view, origin, text, selection_ring, text_dim);
out
}
/// **Pure** edges + chips paint: reads `view` (zoom + selection) and draws the
/// scene at the projection `origin`. It NEVER mutates `view` — the selection's
/// downstream-lit set is computed from the *current* `view.selected`. Shared by the
/// mutating [`paint_graph`] wrapper and the FC-9 [`GraphPanView::view`].
#[allow(clippy::too_many_arguments)]
fn paint_scene(
painter: &egui::Painter,
model: &GraphModel,
decorations: &Decorations,
view: &GraphView,
origin: Pos2,
text: Color32,
selection_ring: Color32,
text_dim: Color32,
) {
// `PaintOpts::default()` is all-off, and `paint_scene_opts` short-circuits on
// `is_off()` to this exact code path — so the historical canvas is reached through
// one function, not duplicated beside a "legible" twin (L5).
let _ = paint_scene_opts(
painter,
model,
decorations,
view,
origin,
text,
selection_ring,
text_dim,
PaintOpts::default(),
None,
);
}
/// [`paint_scene`] with **legibility + GPU** options — the one painter both the plain
/// and the scaled-up canvas go through.
///
/// `gpu` is `Some((ui, rect))` when the caller can host an `egui_wgpu` paint callback.
/// Whether the instanced cloud actually paints is decided by
/// [`legible::draw_gpu_cloud`], never here: if it returns `false` (feature off, host
/// never installed a renderer, empty model) the CPU chip/edge loops run exactly as
/// they always have. There is no code path that skips the CPU painter on anything but
/// a `true` from a renderer that provably exists.
///
/// Returns what the frame's legibility work did, for `state_json`.
#[allow(clippy::too_many_arguments)]
pub fn paint_scene_opts(
painter: &egui::Painter,
model: &GraphModel,
decorations: &Decorations,
view: &GraphView,
origin: Pos2,
text: Color32,
selection_ring: Color32,
text_dim: Color32,
opts: PaintOpts,
gpu: Option<(&egui::Ui, egui::Rect)>,
) -> LegibilityReport {
paint_scene_impl(painter, model, decorations, view, origin, text, selection_ring, text_dim, opts, gpu, None)
}
/// [`paint_scene_opts`] with the owner's resident cache: `owner` is `Some((cache,
/// generation))` from a [`GraphPanView`] (which numbers its own models), `None` from the
/// free function (which fingerprints the model it is handed).
///
/// Above [`RESIDENT_ABOVE`] nodes, with `gpu_cloud` on and a renderer installed, the
/// cloud goes on the RESIDENT lane ([`resident`]): uploaded once per model, each frame
/// the camera + the small overlays. Anything that stops that lane (no renderer, a
/// zero-pixel rect) falls through to the per-frame lane / CPU painter below, unchanged.
#[allow(clippy::too_many_arguments)]
fn paint_scene_impl(
painter: &egui::Painter,
model: &GraphModel,
decorations: &Decorations,
view: &GraphView,
origin: Pos2,
text: Color32,
selection_ring: Color32,
text_dim: Color32,
opts: PaintOpts,
gpu: Option<(&egui::Ui, egui::Rect)>,
owner: Option<(&RefCell<resident::ResidentCache>, u64)>,
) -> LegibilityReport {
#[cfg(feature = "wgpu")]
if let Some((ui, rect)) = gpu
&& opts.gpu_cloud
&& model.nodes.len() > RESIDENT_ABOVE
{
let r = match owner {
Some((cache, generation)) => resident::paint(
&mut cache.borrow_mut(),
|| resident::ModelKey::Owned(generation),
painter, model, decorations, view, origin, text, selection_ring, text_dim, opts, ui, rect,
),
None => resident::with_free(|cache| {
resident::paint(
cache,
|| resident::ModelKey::Fingerprint(resident::fingerprint(model)),
painter, model, decorations, view, origin, text, selection_ring, text_dim, opts, ui, rect,
)
}),
};
if let Some(r) = r {
return r;
}
}
#[cfg(not(feature = "wgpu"))]
let _ = owner;
let zoom = view.zoom;
let project = |p: Pos2| origin + p.to_vec2() * zoom;
// id → index for edge endpoint lookup.
let idx: HashMap<&str, usize> =
model.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i)).collect();
// The downstream-lit set for the current selection (dims everything else).
let lit: BTreeSet<String> = view
.selected
.as_deref()
.map(|s| downstream_of(model, s))
.unwrap_or_default();
let highlighting = view.selected.is_some();
let viewport = painter.clip_rect();
let mut report = LegibilityReport::default();
// ── the GPU cloud lane ───────────────────────────────────────────────────
// `drew_on_gpu` is the ONLY thing that may suppress the CPU cloud, and it can only
// be true when a renderer is installed. Labels/rings/badges stay CPU overlays.
let drew_on_gpu = match gpu {
Some((ui, rect)) if opts.gpu_cloud => {
legible::draw_gpu_cloud(ui, rect, model, origin, zoom, true)
}
_ => false,
};
report.gpu_cloud = drew_on_gpu;
// ── the legibility plan (pure) ───────────────────────────────────────────
let plan = (!opts.is_off()).then(|| {
legible::plan_frame(
model,
origin,
zoom,
viewport,
view.selected.as_deref(),
&lit,
6.0 * zoom.clamp(0.7, 1.4),
opts,
)
});
if let Some(p) = &plan {
report.labels_placed = p.report.labels_placed;
report.labels_suppressed = p.report.labels_suppressed;
report.labels_considered = p.report.labels_considered;
report.edges_drawn = p.report.edges_drawn;
report.edges_thinned = p.report.edges_thinned;
report.edge_peak_before = p.report.edge_peak_before;
report.edge_peak_after = p.report.edge_peak_after;
}
let keep: Option<&Vec<u32>> = plan.as_ref().and_then(FramePlan::edges);
// ── edges first (so chips sit on top) ────────────────────────────────────
if !drew_on_gpu {
match keep {
Some(keep) => {
for &ei in keep {
let Some(e) = model.edges.get(ei as usize) else { continue };
draw_edge(painter, model, &idx, &project, e, zoom, view.dot_px, highlighting, &lit, text_dim, false);
}
}
None => {
for e in &model.edges {
draw_edge(painter, model, &idx, &project, e, zoom, view.dot_px, highlighting, &lit, text_dim, false);
}
}
}
}
// Decoration edges painted ON TOP (the cut lines) — always emphasised, and never
// thinned or GPU-batched: they are the answer the user asked for.
for e in &decorations.edges {
draw_edge(painter, model, &idx, &project, e, zoom, view.dot_px, false, &lit, text_dim, true);
}
// ── chips (nodes) ────────────────────────────────────────────────────────
// With the GPU cloud up, the discs are already on screen; the CPU still paints the
// rings/badges/labels (thin overlays, not the 2.47 M-triangle cloud).
let paint_chips = !drew_on_gpu;
let thinned_labels = plan.as_ref().and_then(FramePlan::labels).is_some();
for n in &model.nodes {
let c = project(n.pos);
let on_trace = highlighting && lit.contains(&n.id);
let (fill, stroke) = if highlighting && !on_trace {
(dim(n.fill), dim(n.stroke))
} else {
(n.fill, n.stroke)
};
// DOT mode: a constant-size filled circle per node (the point-cloud lane for big
// graphs) — no wide chip, no label. Selection/decoration read as a ring.
if let Some(r) = view.dot_px {
if paint_chips {
painter.circle_filled(c, r, fill);
}
let selected = view.selected.as_deref() == Some(n.id.as_str());
if selected {
painter.circle_stroke(c, r + 2.0, Stroke::new(2.0, selection_ring));
} else if let Some(rc) = decorations.nodes.get(&n.id).and_then(|d| d.ring) {
let rc = if highlighting && !on_trace { dim(rc) } else { rc };
painter.circle_stroke(c, r + 2.0, Stroke::new(1.8, rc));
}
continue;
}
let size = Vec2::new(BOX_W, BOX_H) * zoom;
let rect = egui::Rect::from_center_size(c, size);
if paint_chips {
painter.rect_filled(rect, 5.0 * zoom, fill);
}
let deco = decorations.nodes.get(&n.id);
// Ring precedence: explicit selection wins; then a decoration ring; then
// the node's own stroke (the arch coverage ring rides in here already).
let ring = if view.selected.as_deref() == Some(n.id.as_str()) {
Stroke::new(3.0_f32, selection_ring)
} else if let Some(rc) = deco.and_then(|d| d.ring) {
let rc = if highlighting && !on_trace { dim(rc) } else { rc };
Stroke::new(2.4_f32, rc)
} else {
Stroke::new(1.4_f32, stroke)
};
if paint_chips || deco.is_some() || view.selected.as_deref() == Some(n.id.as_str()) {
painter.rect_stroke(rect, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
}
if zoom > 0.45 {
// With label thinning on, the labels are painted ONCE from the collision
// plan below (in placement order, at their chosen boxes) instead of here.
if !thinned_labels {
painter.text(
c,
Align2::CENTER_CENTER,
&n.label,
FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
text,
);
}
// The decoration badge at the top-right corner (e.g. 🟡 / ⛔).
if let Some(badge) = deco.and_then(|d| d.badge.as_deref()) {
let col = deco.and_then(|d| d.badge_color).unwrap_or(text);
let col = if highlighting && !on_trace { dim(col) } else { col };
painter.text(
rect.right_top() + Vec2::new(-2.0, 1.0),
Align2::RIGHT_TOP,
badge,
FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
col,
);
}
}
}
if zoom > 0.45 && let Some(p) = &plan {
legible::paint_planned_labels(painter, model, p, zoom, text);
}
report
}
/// Draw ONE edge as a PCB-style cubic with a mid breakpoint + an arrowhead, dimmed
/// off-trace when a selection is active. `emphasise` (decoration edges) forces a
/// thicker, undimmed stroke and paints the optional mid-label.
#[allow(clippy::too_many_arguments)]
fn draw_edge(
painter: &egui::Painter,
model: &GraphModel,
idx: &HashMap<&str, usize>,
project: &impl Fn(Pos2) -> Pos2,
e: &GraphEdge,
zoom: f32,
dot_px: Option<f32>,
highlighting: bool,
lit: &BTreeSet<String>,
text_dim: Color32,
emphasise: bool,
) {
let (Some(&fi), Some(&ti)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
return;
};
let on_trace = highlighting && lit.contains(&e.from) && lit.contains(&e.to);
let (pa, pb) = (project(model.nodes[fi].pos), project(model.nodes[ti].pos));
stroke_edge(painter, pa, pb, e, zoom, dot_px, highlighting, on_trace, text_dim, emphasise);
}
/// Half the on-screen WIDTH of a node as it is drawn, in screen px: half a chip
/// (`BOX_W · zoom / 2`) for the chip look, the dot's radius in DOT mode
/// ([`GraphView::dot_px`] — a constant screen size that does not follow the zoom).
///
/// An edge leaves its source at `centre + this` and its arrow lands on its target at
/// `centre − this`, so the edge meets whatever shape the node really has. Anchoring a
/// DOT's edges at the chip rim left every arrow `92 · zoom` px short of its dot.
pub(crate) fn node_half_width(zoom: f32, dot_px: Option<f32>) -> f32 {
dot_px.unwrap_or(BOX_W * 0.5 * zoom)
}
/// The stroke half of [`draw_edge`]: ONE edge between two projected chip centres `pa` →
/// `pb` (the endpoint lookup is the caller's — the resident lane holds its own index).
#[allow(clippy::too_many_arguments)]
pub(crate) fn stroke_edge(
painter: &egui::Painter,
pa: Pos2,
pb: Pos2,
e: &GraphEdge,
zoom: f32,
dot_px: Option<f32>,
highlighting: bool,
on_trace: bool,
text_dim: Color32,
emphasise: bool,
) {
// Rim to rim of the node AS DRAWN (chip or dot) — see `node_half_width`.
let half = Vec2::new(node_half_width(zoom, dot_px), 0.0);
let a = pa + half;
let b = pb - half;
let color = if !emphasise && highlighting && !on_trace { dim(e.color) } else { e.color };
let w = if emphasise { 2.6_f32 } else if on_trace { 2.4_f32 } else { 1.4_f32 };
let mid = Pos2::new((a.x + b.x) * 0.5, a.y);
let mid2 = Pos2::new((a.x + b.x) * 0.5, b.y);
let curve = egui::epaint::CubicBezierShape::from_points_stroke(
[a, mid, mid2, b],
false,
Color32::TRANSPARENT,
Stroke::new(w, color),
);
if e.dashed {
// Dashed cubic — sample the curve and stroke alternate segments.
let pts: Vec<Pos2> = (0..=20).map(|i| curve.sample(i as f32 / 20.0)).collect();
for (i, win) in pts.windows(2).enumerate() {
if i % 2 == 0 {
painter.line_segment([win[0], win[1]], Stroke::new(w, color));
}
}
} else {
painter.add(egui::Shape::CubicBezier(curve));
}
// Arrowhead at the callee.
let dir = (b - mid2).normalized();
let perp = Vec2::new(-dir.y, dir.x);
let head = 6.0 * zoom.clamp(0.6, 1.6);
painter.line_segment([b, b - dir * head + perp * head * 0.5], Stroke::new(w, color));
painter.line_segment([b, b - dir * head - perp * head * 0.5], Stroke::new(w, color));
// Mid-edge label (cut rationale), painted for emphasis edges that carry one.
if let Some(lbl) = &e.label
&& zoom > 0.45
&& !lbl.is_empty()
{
let mp = Pos2::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5 - 6.0 * zoom);
painter.text(
mp,
Align2::CENTER_BOTTOM,
lbl,
FontId::proportional(10.0 * zoom.clamp(0.7, 1.3)),
if emphasise { color } else { text_dim },
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// GraphPanView — an OWNING canonical Facet around the bare canvas, so a deck
// mounts it and a headless driver reads + drives the pan/zoom + selection.
// ─────────────────────────────────────────────────────────────────────────────
/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the [`GraphPanView`] transform through, the same effect
/// a canvas gesture produces. Applied by [`GraphPanView::update`], the **only**
/// mutation path for the [`GraphView`] Model. Serde-tagged (`snake_case`) so the
/// robot `update_json` seam parses one message straight off the wire; the `Vec2`
/// payloads reuse the same `[x, y]` [`vec2_serde`] codec as the Model's `pan`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
/// Pan the viewport by a screen-space delta (a drag gesture).
PanBy(#[serde(with = "vec2_serde")] Vec2),
/// Set the pan vector directly (programmatic / a pasted portable state).
SetPan(#[serde(with = "vec2_serde")] Vec2),
/// Scroll-zoom by a wheel delta: `zoom *= 1 + delta·0.001`, clamped 0.25..4.0.
ZoomBy(f32),
/// Set the zoom directly (programmatic / a pasted portable state). A `<= 0`
/// value falls back to `1.0` (mirrors the canvas's own zoom guard).
SetZoom(f32),
/// Select a node by its **stable id** (lights its downstream subtree; FC-5).
Select(String),
/// Clear the selection (an empty-canvas click, or a programmatic clear).
ClearSelection,
/// Reset pan + zoom (the ⊙ fit button); the selection is kept.
Fit,
}
/// Side work as data (FC-8). The canvas does no I/O — every [`Msg`] mutates only
/// the in-memory [`GraphView`] — so this is uninhabited on purpose: the type-checked
/// statement that [`GraphPanView::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// An **owning** canonical [`Facet`] wrapping the bare [`draw_graph`] canvas: it
/// holds its own [`GraphModel`] + [`Decorations`] + a [`GraphView`] transform,
/// paints the canvas (pan/drag + scroll-zoom + click-select), and exposes the
/// whole rendered canvas through [`Facet::state_json`] (the laid-out node screen
/// positions under the live transform, the edge count, the pan/zoom) so a deck
/// mounts it and a headless driver reads + asserts the transform.
pub struct GraphPanView {
title: String,
/// The graph data + overlay: the *input* the canvas renders (not the Model —
/// its `Color32` fills aren't serde-friendly and no [`Msg`] mutates it).
model: GraphModel,
decorations: Decorations,
/// All observable navigation state (FC-1 / FC-3) — the [`facett_core::Elm`] Model.
view: GraphView,
/// The last viewport origin (rect.center() + pan) the canvas painted with, so
/// `state_json` can report real on-screen node positions after a render.
/// **Ephemeral paint cache, not observable state** — interior-mutable so the
/// FC-9 pure [`view`](Self::view) can record it through a `&self` borrow (FC-7);
/// deliberately kept OUT of the [`GraphView`] Model.
last_origin: Cell<Option<Pos2>>,
/// Legibility + GPU opt-ins. Default all-off ⇒ the historical canvas.
opts: PaintOpts,
/// The spatial pick index (ephemeral cache, rebuilt only when the projection
/// changes) — so a click on a 100 000-node board does not scan 100 000 nodes.
pick: RefCell<PickIndex>,
/// What the last painted frame's legibility work did, published through
/// `state_json` so a robot asserts on measured numbers. Ephemeral (FC-7).
last_legibility: RefCell<LegibilityReport>,
/// Bumped by every [`with_model`](Self::with_model) — what the resident cloud lane
/// keys its upload on, so the pane never hashes its own model.
model_gen: u64,
/// The resident cloud lane's per-model work (ephemeral, FC-7).
resident: RefCell<resident::ResidentCache>,
}
impl GraphPanView {
/// An empty pane titled `title`.
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
model: GraphModel::default(),
decorations: Decorations::default(),
view: GraphView::default(),
last_origin: Cell::new(None),
opts: PaintOpts::default(),
pick: RefCell::new(PickIndex::default()),
last_legibility: RefCell::new(LegibilityReport::default()),
model_gen: 0,
resident: RefCell::new(resident::ResidentCache::default()),
}
}
/// Opt into the **instanced GPU cloud** — the same renderer `Graph3D` installs
/// (`facett_core::render::gpu::graphcloud::cloud::install_cloud_renderer`).
///
/// Safe to turn on unconditionally: if the host never installed the renderer, or
/// the crate was built without the `wgpu` feature, the CPU painter runs unchanged.
/// Read it back from `state_json()["legibility"]["gpu_cloud"]` to find out which
/// lane actually painted.
#[must_use]
pub fn with_gpu(mut self, on: bool) -> Self {
self.opts.gpu_cloud = on;
self
}
/// Opt into label collision avoidance + the edge ink budget.
#[must_use]
pub fn with_legibility(mut self, on: bool) -> Self {
self.opts.thin_labels = on;
self.opts.thin_edges = on;
self
}
/// Set the paint options wholesale.
#[must_use]
pub fn with_paint_opts(mut self, opts: PaintOpts) -> Self {
self.opts = opts;
self
}
/// The paint options in force.
#[must_use]
pub fn paint_opts(&self) -> PaintOpts {
self.opts
}
/// What the last painted frame's legibility work did.
#[must_use]
pub fn legibility(&self) -> LegibilityReport {
self.last_legibility.borrow().clone()
}
/// Set the graph model (nodes already laid out in world space + edges).
pub fn with_model(mut self, model: GraphModel) -> Self {
self.model = model;
self.model_gen = self.model_gen.wrapping_add(1);
self
}
/// Set the decorations overlay (rings/badges + emphasis edges).
pub fn with_decorations(mut self, decorations: Decorations) -> Self {
self.decorations = decorations;
self
}
/// Re-title (the deck keys facets off [`Facet::title`]).
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
}
/// Reset pan + zoom (the ⊙ fit affordance). Routes through the FC-2
/// [`update`](Self::update) mutation path.
pub fn fit(&mut self) {
let _ = self.update(Msg::Fit);
}
/// Select a node by id (lights its downstream subtree), or `None` to clear. A
/// thin wrapper over the FC-2 [`update`](Self::update) mutation path.
pub fn select(&mut self, id: Option<String>) {
let _ = match id {
Some(id) => self.update(Msg::Select(id)),
None => self.update(Msg::ClearSelection),
};
}
/// **FC-3** — read the complete observable navigation state (the pan/zoom/selection
/// transform) at any frame boundary. The caller may read pan/zoom/selected.
pub fn state(&self) -> &GraphView {
&self.view
}
/// The downstream-lit set for the current selection (empty when none).
pub fn lit_set(&self) -> BTreeSet<String> {
self.view.selected.as_deref().map(|s| downstream_of(&self.model, s)).unwrap_or_default()
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. Every canvas gesture routes its
/// [`Msg`] here too, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::PanBy(d) => self.view.pan += d,
Msg::SetPan(p) => self.view.pan = p,
Msg::ZoomBy(scroll) => {
let z = if self.view.zoom <= 0.0 { 1.0 } else { self.view.zoom };
self.view.zoom = (z * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
}
Msg::SetZoom(z) => self.view.zoom = if z <= 0.0 { 1.0 } else { z },
Msg::Select(id) => self.view.selected = Some(id),
Msg::ClearSelection => self.view.selected = None,
Msg::Fit => self.view.fit(),
}
Vec::new()
}
/// **FC-9 render** — a **pure** function of `&self`: it paints the toolbar + the
/// pan/zoom canvas (chips, rings, badges, edges) and *returns* the [`Msg`]s the
/// interactions produced (pan drag, scroll-zoom, click-select, ⊙ fit). It MUST
/// NOT mutate the [`GraphView`] Model; the
/// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
/// returned messages through [`update`](Self::update). The only `&self`-interior
/// mutation is the ephemeral `last_origin` paint cache (FC-7), outside the Model.
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let th = facett_core::theme(ui);
ui.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new(format!("{} nodes · {} edges", self.model.nodes.len(), self.model.edges.len()))
.color(th.text)
.strong(),
);
ui.separator();
ui.label(
egui::RichText::new(format!("zoom {:.2}× · pan ({:.0},{:.0})", self.view.zoom, self.view.pan.x, self.view.pan.y))
.color(th.text_dim),
);
if ui.button("⊙ fit").clicked() {
msgs.push(Msg::Fit);
}
});
ui.separator();
// Remember the origin the canvas will paint with, for `state_json`. This
// mirrors `draw_graph`'s own `rect.center() + pan` (computed before it
// allocates), so the reported screen positions match what was painted.
let avail = ui.available_size();
let cursor = ui.cursor().min;
let origin = Pos2::new(cursor.x + avail.x * 0.5, cursor.y + avail.y * 0.5) + self.view.pan;
self.last_origin.set(Some(origin));
// The pan/zoom canvas — the FC-9 pure twin of `draw_graph`: it allocates the
// painter, reads the drag/scroll/click, and RETURNS the transitions as `Msg`s
// instead of mutating `self.view` in place.
let (resp, painter) = ui.allocate_painter(facett_core::nav::canvas_size(ui), Sense::click_and_drag());
painter.rect_filled(resp.rect, 4.0, th.bg);
if resp.dragged() {
let d = resp.drag_delta();
if d != Vec2::ZERO {
msgs.push(Msg::PanBy(d));
}
}
if resp.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
msgs.push(Msg::ZoomBy(scroll));
}
}
let paint_origin = resp.rect.center() + self.view.pan;
let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();
if let Some(click) = click {
// Picking goes through the SPATIAL INDEX, not a scan of every node. The
// index rebuilds only when the projection changed, so a click costs a
// neighbourhood query — the same discipline the map's PointGrid learned.
let mut pick = self.pick.borrow_mut();
pick.ensure(&self.model, paint_origin, self.view.zoom);
match pick.pick(click, self.view.zoom) {
Some(i) => msgs.push(Msg::Select(self.model.nodes[i].id.clone())),
None => msgs.push(Msg::ClearSelection),
}
}
// Paint reads the CURRENT (pre-update) transform — pure, no mutation. The pane
// numbers its own models, so the resident lane keys on that, not on a hash.
let report = paint_scene_impl(
&painter,
&self.model,
&self.decorations,
&self.view,
paint_origin,
th.text,
th.accent,
th.text_dim,
self.opts,
Some((ui, resp.rect)),
Some((&self.resident, self.model_gen)),
);
*self.last_legibility.borrow_mut() = report;
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-graphpan::GraphPanView::view",
"ui_render",
!self.model.nodes.is_empty(),
&format!("nodes={} edges={} zoom={:.2}", self.model.nodes.len(), self.model.edges.len(), self.view.zoom),
);
msgs
}
/// **Discovery-contract `local()`** — a realistic 4-node DAG (a → b, a → c,
/// b → d, c → d) decorated with a green coverage ring on `a`, an amber badge
/// on `b`, and an emphasis "cut" edge `d → a` (dashed, labelled), so the
/// canvas paints real chips, rings, a badge, and an overlay edge with no host.
pub fn local() -> Self {
let n = |id: &str, label: &str, x: f32, y: f32, fill: Color32| GraphNode {
id: id.into(),
label: label.into(),
fill,
stroke: Color32::WHITE,
pos: Pos2::new(x, y),
};
let e = |from: &str, to: &str, dashed: bool| GraphEdge {
from: from.into(),
to: to.into(),
color: Color32::from_rgb(200, 200, 220),
dashed,
label: None,
};
let model = GraphModel {
nodes: vec![
n("a", "alpha", 0.0, 0.0, Color32::from_rgb(60, 90, 160)),
n("b", "beta", 260.0, -90.0, Color32::from_rgb(60, 140, 90)),
n("c", "gamma", 260.0, 90.0, Color32::from_rgb(160, 90, 60)),
n("d", "delta", 520.0, 0.0, Color32::from_rgb(120, 90, 160)),
],
edges: vec![e("a", "b", false), e("a", "c", false), e("b", "d", false), e("c", "d", false)],
};
let mut nodes = HashMap::new();
nodes.insert(
"a".to_string(),
NodeDecoration { ring: Some(Color32::from_rgb(90, 200, 140)), ..Default::default() },
);
nodes.insert(
"b".to_string(),
NodeDecoration {
badge: Some("⚠".into()),
badge_color: Some(Color32::from_rgb(230, 180, 60)),
..Default::default()
},
);
let decorations = Decorations {
nodes,
edges: vec![GraphEdge {
from: "d".into(),
to: "a".into(),
color: Color32::from_rgb(230, 90, 90),
dashed: true,
label: Some("cut".into()),
}],
};
Self::new("GraphPan").with_model(model).with_decorations(decorations)
}
/// **Discovery-contract `remote()`** — same surface, re-titled; the variant a
/// host builds from a server-streamed model. Kept so the matrix sees both ctors.
pub fn remote() -> Self {
let mut v = Self::local();
v.title = "GraphPan (remote)".into();
v
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for GraphPanView {
type Model = GraphView;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &GraphView {
&self.view
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
GraphPanView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
GraphPanView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for GraphPanView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides
// below. **Form 3** (`custom_state_json`) because the canvas publishes a RICHER
// `state_json` than a plain `serde(state())`: the derived node/screen positions,
// edge list, ring/badge tallies, and the world→screen `origin` that facett-demo's
// mega_matrix reads are computed from the model + the recorded paint origin, not from
// the raw `GraphView` — so the default `state_json` is suppressed and this one supplied.
facett_core::impl_facet_via_elm!(GraphPanView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
let lit = self.lit_set();
let origin = self.last_origin.get();
let mut j = serde_json::json!({
"title": self.title,
"node_count": self.model.nodes.len(),
"edge_count": self.model.edges.len(),
"decoration_edges": self.decorations.edges.len(),
"rings": self.decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
"badges": self.decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
"selected": self.view.selected,
"lit": lit.iter().cloned().collect::<Vec<_>>(),
"transform": self.view.state_json(origin),
"nodes": self.model.nodes.iter().map(|n| {
let screen = origin.map(|o| self.view.project(o, n.pos));
serde_json::json!({
"id": n.id,
"label": n.label,
"x": n.pos.x,
"y": n.pos.y,
"screen": screen.map(|s| serde_json::json!([s.x, s.y])),
})
}).collect::<Vec<_>>(),
"edges": self.model.edges.iter().map(|e| serde_json::json!({
"from": e.from, "to": e.to, "dashed": e.dashed,
})).collect::<Vec<_>>(),
});
if let Some(o) = origin
&& let Some(obj) = j.as_object_mut()
{
obj.insert("origin".into(), serde_json::json!([o.x, o.y]));
}
// What the last painted frame's legibility work did — the observable that
// makes "labels do not overplot" and "the GPU lane really painted" assertable
// from outside instead of describable in a doc.
if let Some(obj) = j.as_object_mut() {
obj.insert("legibility".into(), self.last_legibility.borrow().state_json());
obj.insert(
"paint_opts".into(),
serde_json::json!({
"gpu_cloud": self.opts.gpu_cloud,
"thin_labels": self.opts.thin_labels,
"thin_edges": self.opts.thin_edges,
}),
);
}
j
}
/// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
/// apply it through the single [`Elm::update`](facett_core::Elm::update)
/// mutation path. The `impl_facet_via_elm!` bridge does NOT generate an
/// `update_json`, so without this override the JSON drive seam is a dead no-op
/// (a headless robot's pan / zoom / select messages mutate nothing). Malformed
/// / unknown JSON is ignored (never a panic).
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
fn selection_json(&self) -> serde_json::Value {
match &self.view.selected {
Some(id) => serde_json::json!({ "node": id, "downstream": self.lit_set().iter().cloned().collect::<Vec<_>>() }),
None => serde_json::Value::Null,
}
}
/// Themed (chips/rings follow the palette) + resizable + selectable (a node
/// drill-in) + navigable (pan/drag + scroll-zoom + fit).
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().selectable().navigable()
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
// ── cross-instance clone (copy/paste BETWEEN two GraphPanViews) ───────────
fn kind(&self) -> &'static str {
"graphpan"
}
/// The portable transform: pan, zoom, and the selected node id — the fields a
/// sibling `graphpan` can adopt to mirror this view. (The graph *model* itself
/// is not portable; this clones the VIEW state onto whatever model the sibling
/// holds, exactly like dragging both panes into the same pose.)
fn portable_state(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({
"pan": [self.view.pan.x, self.view.pan.y],
"zoom": self.view.zoom,
"selected": self.view.selected,
}))
}
fn load_state(&mut self, state: &serde_json::Value) -> bool {
let obj = match state.as_object() {
Some(o) => o,
None => return false,
};
// Route every field through the FC-2 `update` mutation path.
if let Some(pan) = obj.get("pan").and_then(|p| p.as_array())
&& let (Some(x), Some(y)) = (pan.first().and_then(|v| v.as_f64()), pan.get(1).and_then(|v| v.as_f64()))
{
let _ = self.update(Msg::SetPan(Vec2::new(x as f32, y as f32)));
}
if let Some(z) = obj.get("zoom").and_then(|v| v.as_f64()) {
let _ = self.update(Msg::SetZoom(z as f32));
}
// `selected` may be a string id or JSON null (clear).
match obj.get("selected") {
Some(serde_json::Value::String(s)) => {
let _ = self.update(Msg::Select(s.clone()));
}
Some(serde_json::Value::Null) => {
let _ = self.update(Msg::ClearSelection);
}
_ => {}
}
true
}
});
#[cfg(test)]
mod tests {
use super::*;
// `Facet` for `state_json`/`selection_json`/`title`/`ui`; `state()` is inherent.
use facett_core::Facet;
fn node(id: &str, x: f32) -> GraphNode {
GraphNode {
id: id.into(),
label: id.into(),
fill: Color32::GRAY,
stroke: Color32::WHITE,
pos: Pos2::new(x, 0.0),
}
}
fn edge(from: &str, to: &str) -> GraphEdge {
GraphEdge { from: from.into(), to: to.into(), color: Color32::WHITE, dashed: false, label: None }
}
#[test]
fn portable_state_round_trips_pan_zoom_selection_between_instances() {
use facett_core::clipboard;
// A is configured: panned, zoomed, a node selected.
let mut a = GraphPanView::local();
a.view.pan = Vec2::new(37.0, -11.0);
a.view.zoom = 2.5;
a.select(Some("b".to_string()));
// Encode A's portable state through the OS-clipboard envelope.
let env = clipboard::encode_component(a.kind(), &a.portable_state().unwrap());
let (kind, state) = clipboard::decode_component(&env).unwrap();
assert_eq!(kind, "graphpan");
// B starts at the default pose; adopting A's state makes them equal.
let mut b = GraphPanView::new("other");
assert_ne!(b.portable_state(), a.portable_state(), "they differ before the paste");
assert!(b.load_state(&state), "same-kind load is accepted");
assert_eq!(b.portable_state(), a.portable_state(), "B mirrors A's pan/zoom/selection");
assert_eq!(b.view.zoom, 2.5);
assert_eq!(b.view.selected.as_deref(), Some("b"));
}
#[test]
fn deck_copy_then_paste_clones_selection_between_two_graphpans() {
// Integration: drive TWO REAL GraphPanViews through the actual deck
// copy→paste seam (the type-match guard live), not load_state directly.
use facett_core::{clipboard, FacetDeck};
// Source deck: a panned/zoomed graphpan with node "b" SELECTED.
let mut a = GraphPanView::local();
a.view.pan = Vec2::new(37.0, -11.0);
a.view.zoom = 2.5;
a.select(Some("b".to_string()));
let deck_a = FacetDeck::new(vec![Box::new(a)]);
// The deck encodes the active facet's portable state into the envelope
// that would land on the OS clipboard (same path `copy_component` puts).
let env = deck_a
.copy_component_envelope()
.expect("a configured graphpan copies its portable state");
// Destination deck: a DIFFERENT graphpan at the default pose, no selection.
let mut deck_b = FacetDeck::new(vec![Box::new(GraphPanView::new("other"))]);
assert!(deck_b.component_toast().is_none(), "clean start, no toast");
// Paste through the deck seam (kind() == "graphpan" passes the guard).
assert!(deck_b.paste_component(&env, 0.0), "same-kind paste is accepted");
assert!(
deck_b.component_toast().is_none(),
"a successful paste raises no mismatch toast",
);
// ASSERT ON DATA (LAW 6): the destination deck's portable state now carries
// the cloned pan/zoom + the SELECTED entity id "b", proving the selection
// crossed the clipboard seam into the second instance's model.
let pasted = deck_b
.copy_component_envelope()
.expect("the destination re-exports its now-cloned state");
let (kind, state) = clipboard::decode_component(&pasted).expect("a valid envelope");
assert_eq!(kind, "graphpan");
assert_eq!(state["selected"], serde_json::json!("b"), "selection id pasted");
assert_eq!(state["zoom"], serde_json::json!(2.5), "zoom pasted");
assert_eq!(state["pan"], serde_json::json!([37.0, -11.0]), "pan pasted");
// And the type-match guard holds at the deck: a `table` envelope handed to
// this graphpan deck is REJECTED with a toast that names both kinds, and
// leaves the just-pasted graphpan state untouched.
let table_env = clipboard::encode_component("table", &serde_json::json!({ "rows": 9 }));
let before = deck_b.copy_component_envelope();
assert!(!deck_b.paste_component(&table_env, 1.0), "cross-type paste is a no-op");
assert_eq!(deck_b.copy_component_envelope(), before, "graphpan state untouched");
let toast = deck_b.component_toast().expect("mismatch raises a toast");
assert!(
toast.contains("table") && toast.contains("graphpan"),
"toast names both kinds: {toast}",
);
}
#[test]
fn downstream_is_bfs_closure_from_seed() {
// A → B → C, A → C. Downstream of A = {A, B, C}; of B = {B, C}.
let model = GraphModel {
nodes: vec![node("A", 0.0), node("B", 100.0), node("C", 200.0)],
edges: vec![edge("A", "B"), edge("B", "C"), edge("A", "C")],
};
let from_a = downstream_of(&model, "A");
assert!(from_a.contains("A") && from_a.contains("B") && from_a.contains("C"));
let from_b = downstream_of(&model, "B");
assert!(from_b.contains("B") && from_b.contains("C"));
assert!(!from_b.contains("A"), "BFS is forward-only — A is not downstream of B");
}
#[test]
fn content_extent_is_the_true_bounding_box_not_the_viewport() {
const MARGIN: f32 = 48.0;
let empty = GraphModel::default();
let e0 = content_extent(&empty);
assert_eq!(e0, Vec2::new(BOX_W, BOX_H) + Vec2::splat(2.0 * MARGIN));
let two = GraphModel { nodes: vec![node("A", 0.0), node("B", 300.0)], edges: vec![] };
let e = content_extent(&two);
assert_eq!(e.x, 300.0 + BOX_W + 2.0 * MARGIN, "width spans both chips + margins");
assert_eq!(e.y, BOX_H + 2.0 * MARGIN, "height is one chip row + margins");
let wider = GraphModel { nodes: vec![node("A", 0.0), node("B", 900.0)], edges: vec![] };
assert!(content_extent(&wider).x > e.x, "a wider graph reports a wider extent");
}
#[test]
fn view_project_is_origin_plus_world_times_zoom() {
// The pan/zoom transform contract: project(origin, p) = origin + p·zoom.
let v = GraphView { pan: Vec2::new(5.0, 5.0), zoom: 2.0, selected: None, dot_px: None };
let origin = Pos2::new(100.0, 50.0);
// world (0,0) lands exactly on the origin.
assert_eq!(v.project(origin, Pos2::ZERO), origin);
// a world point at (10,20) lands at origin + (20,40) under 2× zoom.
assert_eq!(v.project(origin, Pos2::new(10.0, 20.0)), Pos2::new(120.0, 90.0));
// the transform's state_json reports the same projected coords a driver reads.
let j = v.state_json(Some(origin));
assert_eq!(j["zoom"], 2.0);
assert_eq!(j["pan"], serde_json::json!([5.0, 5.0]));
assert_eq!(j["project_zero"], serde_json::json!([100.0, 50.0]));
assert_eq!(j["project_unit"], serde_json::json!([102.0, 52.0]));
}
#[test]
fn view_fit_and_clear_reset_state() {
let mut v = GraphView { pan: Vec2::new(5.0, 5.0), zoom: 2.0, selected: Some("A".into()), dot_px: None };
v.fit();
assert_eq!(v.pan, Vec2::ZERO);
assert_eq!(v.zoom, 1.0);
assert_eq!(v.selected.as_deref(), Some("A"));
v.clear_selection();
assert!(v.selected.is_none());
}
/// FC-3: the `GraphView` Model round-trips byte-for-byte through serde JSON —
/// pan as `[x, y]`, zoom, and the selected id, with no egui `serde` feature.
#[test]
fn graphview_model_serde_round_trips() {
let v = GraphView { pan: Vec2::new(-7.5, 3.25), zoom: 1.75, selected: Some("d".into()), dot_px: None };
let json = serde_json::to_value(&v).unwrap();
assert_eq!(json["pan"], serde_json::json!([-7.5, 3.25]), "pan (a Vec2) serialises as [x, y]");
assert_eq!(json["zoom"], 1.75);
assert_eq!(json["selected"], "d");
let back: GraphView = serde_json::from_value(json).unwrap();
assert_eq!(back, v, "serde(GraphView) -> GraphView round-trips");
}
/// inject-assert (LAW 6): drive a headless render of the local canvas, then
/// assert `state_json` reports the laid-out nodes, the decorations, the live
/// transform, AND the exact on-screen position of a known node under pan/zoom.
#[test]
fn local_canvas_render_reports_nodes_decorations_and_screen_transform() {
let mut v = GraphPanView::local();
assert_eq!(<GraphPanView as Facet>::title(&v), "GraphPan");
// Render headless at a known size so `last_origin` is populated.
let ctx = egui::Context::default();
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0))),
..Default::default()
};
ctx.run_ui(input, |ui| {
egui::CentralPanel::default().show(ui, |ui| {
v.ui(ui);
});
}).textures_delta.clear();
let j = v.state_json();
assert_eq!(j["node_count"], 4);
assert_eq!(j["edge_count"], 4);
assert_eq!(j["decoration_edges"], 1, "the dashed cut edge");
assert_eq!(j["rings"], 1, "a green coverage ring on `a`");
assert_eq!(j["badges"], 1, "a ⚠ badge on `b`");
// The transform painted at a real origin → every node has a screen pos.
let origin = j["origin"].as_array().expect("origin recorded after render");
let (ox, oy) = (origin[0].as_f64().unwrap() as f32, origin[1].as_f64().unwrap() as f32);
let nodes = j["nodes"].as_array().unwrap();
// node `a` is at world (0,0), zoom 1, pan 0 → it lands ON the origin.
let a = nodes.iter().find(|n| n["id"] == "a").unwrap();
let a_screen = a["screen"].as_array().unwrap();
assert!((a_screen[0].as_f64().unwrap() as f32 - ox).abs() < 0.001, "node a x = origin x");
assert!((a_screen[1].as_f64().unwrap() as f32 - oy).abs() < 0.001, "node a y = origin y");
// node `d` is at world (520,0), zoom 1 → it lands at origin.x + 520.
let d = nodes.iter().find(|n| n["id"] == "d").unwrap();
let d_screen = d["screen"].as_array().unwrap();
assert!((d_screen[0].as_f64().unwrap() as f32 - (ox + 520.0)).abs() < 0.001, "node d is 520px right of origin");
// select `b` → lit downstream is {b, d}.
v.select(Some("b".into()));
let j2 = v.state_json();
assert_eq!(j2["selected"], "b");
let lit: BTreeSet<String> =
j2["lit"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
assert_eq!(lit, ["b", "d"].iter().map(|s| s.to_string()).collect::<BTreeSet<_>>());
assert_eq!(v.selection_json()["node"], "b");
}
#[test]
fn local_and_remote_ctors_seed_a_real_canvas() {
let v = GraphPanView::local();
assert_eq!(v.model.nodes.len(), 4);
assert_eq!(<GraphPanView as Facet>::title(&GraphPanView::remote()), "GraphPan (remote)");
}
// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
// harness and assert the resulting `GraphView` transform — no egui, no GPU. ──
#[test]
fn harness_snapshot_drives_pan_zoom_and_selection() {
use facett_core::harness;
let mut v = GraphPanView::local();
// Pan deltas accumulate; selection is observable — both in one Vec<Msg>.
let snap = harness::snapshot(
&mut v,
[Msg::PanBy(Vec2::new(10.0, -4.0)), Msg::PanBy(Vec2::new(2.0, 1.0)), Msg::Select("b".into())],
);
assert_eq!(snap.pan, Vec2::new(12.0, -3.0), "pan deltas accumulate");
assert_eq!(snap.selected.as_deref(), Some("b"), "selection is observable headlessly");
assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
// Scroll-zoom applies the `1 + delta·0.001` factor, clamped to 0.25..4.0.
let snap = harness::snapshot(&mut v, [Msg::SetZoom(1.0), Msg::ZoomBy(500.0)]);
assert!((snap.zoom - 1.5).abs() < 1e-6, "zoom *= 1 + 500·0.001 = 1.5, got {}", snap.zoom);
// A huge scroll clamps at the 4.0 ceiling.
let snap = harness::snapshot(&mut v, [Msg::SetZoom(4.0), Msg::ZoomBy(100000.0)]);
assert_eq!(snap.zoom, 4.0, "zoom clamps at the 4.0 ceiling");
// Fit resets pan+zoom but keeps the selection; ClearSelection then wipes it.
let snap = harness::snapshot(&mut v, [Msg::Fit]);
assert_eq!(snap.pan, Vec2::ZERO);
assert_eq!(snap.zoom, 1.0);
assert_eq!(snap.selected.as_deref(), Some("b"), "fit keeps the selection");
let snap = harness::snapshot(&mut v, [Msg::ClearSelection]);
assert_eq!(snap.selected, None, "ClearSelection empties the selection");
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut v = GraphPanView::local();
// FC-8: the canvas does no I/O, so the effect stream is always empty.
let effects = harness::drive(&mut v, [Msg::PanBy(Vec2::new(3.0, 3.0)), Msg::Select("a".into())]);
assert!(effects.is_empty(), "FC-8: graphpan emits no Effects");
// FC-3: the observable Model (the GraphView transform) round-trips through serde.
let json = serde_json::to_value(v.state()).unwrap();
assert_eq!(json["pan"], serde_json::json!([3.0, 3.0]));
assert_eq!(json["zoom"], 1.0);
assert_eq!(json["selected"], "a");
let back: GraphView = serde_json::from_value(json).unwrap();
assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
}
#[test]
fn public_fit_and_select_route_through_update() {
// The public affordances are thin wrappers over the FC-2 mutation path.
let mut v = GraphPanView::local();
let _ = v.update(Msg::PanBy(Vec2::new(9.0, 9.0)));
let _ = v.update(Msg::SetZoom(3.0));
v.select(Some("c".into()));
assert_eq!(v.state().selected.as_deref(), Some("c"));
v.fit();
assert_eq!(v.state().pan, Vec2::ZERO, "fit resets pan via update");
assert_eq!(v.state().zoom, 1.0, "fit resets zoom via update");
assert_eq!(v.state().selected.as_deref(), Some("c"), "fit keeps selection");
v.select(None);
assert_eq!(v.state().selected, None, "select(None) clears via update");
}
}