Skip to main content

facett_graphview/
decorated_view.rs

1//! **DecoratedGraphView** — the egui Facet that paints a [`GraphModel`] under a
2//! caller-supplied [`Decorations`] overlay (per-node status **rings** + corner
3//! **badges**, plus emphasis **edges** drawn on top), with pan/drag + scroll-zoom
4//! and click-to-select (lighting the clicked node's downstream subtree). It is
5//! the egui twin of nornir's `src/viz/graph_render.rs::draw_graph` — the same
6//! decorations layer the existing `facett-graphview` render model lacked a *view*
7//! for (the CPU/GPU rasterizer renders to RGBA; this is the interactive egui
8//! pane). Domain-agnostic: facett never learns what a "release gate" is — rings,
9//! badges, and cut edges are opaque [`Decorations`].
10//!
11//! Canonical [`Facet`]: `local()`/`remote()`, `state_json()` (the painted nodes +
12//! their decorations + the lit downstream set), `selection_json()`, `caps()`.
13
14use std::collections::{BTreeSet, HashMap, VecDeque};
15
16use facett_core::{Facet, FacetCaps, Semantics};
17
18use crate::model::{Color, Decorations, GraphEdge, GraphModel, GraphNode, NodeDecoration, Pos, BOX_H, BOX_W};
19
20/// The downstream-reachable set (BFS over forward edges) from `seed`, in node-id
21/// space — the visual twin of an `arch trace`, used to dim everything off the lit
22/// path when a node is selected.
23pub fn downstream_of(model: &GraphModel, seed: &str) -> BTreeSet<String> {
24    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
25    for e in &model.edges {
26        adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
27    }
28    let mut lit: BTreeSet<String> = BTreeSet::new();
29    let mut q: VecDeque<&str> = VecDeque::new();
30    lit.insert(seed.to_string());
31    q.push_back(seed);
32    while let Some(cur) = q.pop_front() {
33        if let Some(outs) = adj.get(cur) {
34            for &nxt in outs {
35                if lit.insert(nxt.to_string()) {
36                    q.push_back(nxt);
37                }
38            }
39        }
40    }
41    lit
42}
43
44/// An interactive, decorated pan/zoom graph pane Facet.
45pub struct DecoratedGraphView {
46    title: String,
47    model: GraphModel,
48    decorations: Decorations,
49    pan: egui::Vec2,
50    zoom: f32,
51    selected: Option<String>,
52}
53
54impl DecoratedGraphView {
55    /// An empty pane titled `title`.
56    pub fn new(title: impl Into<String>) -> Self {
57        Self {
58            title: title.into(),
59            model: GraphModel::default(),
60            decorations: Decorations::default(),
61            pan: egui::Vec2::ZERO,
62            zoom: 1.0,
63            selected: None,
64        }
65    }
66
67    /// Set the graph model (nodes already laid out in world space + edges).
68    pub fn with_model(mut self, model: GraphModel) -> Self {
69        self.model = model;
70        self
71    }
72
73    /// Set the decorations overlay (rings/badges + emphasis edges).
74    pub fn with_decorations(mut self, decorations: Decorations) -> Self {
75        self.decorations = decorations;
76        self
77    }
78
79    /// Re-title (the deck keys facets off [`Facet::title`]).
80    pub fn set_title(&mut self, title: impl Into<String>) {
81        self.title = title.into();
82    }
83
84    /// Reset pan + zoom (the ⊙ fit affordance).
85    pub fn fit(&mut self) {
86        self.pan = egui::Vec2::ZERO;
87        self.zoom = 1.0;
88    }
89
90    /// Select a node by id (lights its downstream subtree), or `None` to clear.
91    pub fn select(&mut self, id: Option<String>) {
92        self.selected = id;
93    }
94
95    /// The downstream-lit set for the current selection (empty when none).
96    pub fn lit_set(&self) -> BTreeSet<String> {
97        self.selected.as_deref().map(|s| downstream_of(&self.model, s)).unwrap_or_default()
98    }
99
100    /// **Discovery-contract `local()`** — a realistic 4-node DAG (a → b, a → c,
101    /// b → d, c → d) decorated with a green coverage ring on `a`, an amber badge
102    /// on `b`, and an emphasis "cut" edge `d → a` (dashed, labelled), so the pane
103    /// paints real chips, rings, a badge, and an overlay edge with no host.
104    pub fn local() -> Self {
105        let n = |id: &str, label: &str, x: f32, y: f32, fill: Color| GraphNode {
106            id: id.into(),
107            label: label.into(),
108            fill,
109            stroke: Color::WHITE,
110            pos: Pos::new(x, y),
111        };
112        let e = |from: &str, to: &str, dashed: bool| GraphEdge {
113            from: from.into(),
114            to: to.into(),
115            color: Color::rgb(200, 200, 220),
116            dashed,
117            label: None,
118        };
119        let model = GraphModel {
120            nodes: vec![
121                n("a", "alpha", 0.0, 0.0, Color::rgb(60, 90, 160)),
122                n("b", "beta", 260.0, -90.0, Color::rgb(60, 140, 90)),
123                n("c", "gamma", 260.0, 90.0, Color::rgb(160, 90, 60)),
124                n("d", "delta", 520.0, 0.0, Color::rgb(120, 90, 160)),
125            ],
126            edges: vec![e("a", "b", false), e("a", "c", false), e("b", "d", false), e("c", "d", false)],
127        };
128        let mut nodes = HashMap::new();
129        nodes.insert(
130            "a".to_string(),
131            NodeDecoration { ring: Some(Color::rgb(90, 200, 140)), ..Default::default() },
132        );
133        nodes.insert(
134            "b".to_string(),
135            NodeDecoration {
136                badge: Some("⚠".into()),
137                badge_color: Some(Color::rgb(230, 180, 60)),
138                ..Default::default()
139            },
140        );
141        let decorations = Decorations {
142            nodes,
143            edges: vec![GraphEdge {
144                from: "d".into(),
145                to: "a".into(),
146                color: Color::rgb(230, 90, 90),
147                dashed: true,
148                label: Some("cut".into()),
149            }],
150        };
151        Self::new("DecoratedGraph").with_model(model).with_decorations(decorations)
152    }
153
154    /// **Discovery-contract `remote()`** — same surface, re-titled; the variant a
155    /// host builds from a server-streamed model. Kept so the matrix sees both ctors.
156    pub fn remote() -> Self {
157        let mut v = Self::local();
158        v.title = "DecoratedGraph (remote)".into();
159        v
160    }
161
162    fn col(c: Color) -> egui::Color32 {
163        c.into()
164    }
165}
166
167impl Facet for DecoratedGraphView {
168    fn title(&self) -> &str {
169        &self.title
170    }
171
172    fn ui(&mut self, ui: &mut egui::Ui) {
173        let th = facett_core::theme(ui);
174        if self.zoom <= 0.0 {
175            self.zoom = 1.0;
176        }
177
178        // ── header: counts + a fit button ─────────────────────────────────
179        ui.horizontal_wrapped(|ui| {
180            ui.label(
181                egui::RichText::new(format!("{} nodes · {} edges", self.model.nodes.len(), self.model.edges.len()))
182                    .color(th.text)
183                    .strong(),
184            );
185            ui.separator();
186            ui.label(
187                egui::RichText::new(format!("{} ring(s) · {} badge(s)",
188                    self.decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
189                    self.decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
190                ))
191                .color(th.text_dim),
192            );
193            if ui.button("⊙ fit").clicked() {
194                self.fit();
195            }
196        });
197        ui.separator();
198
199        let (resp, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
200        painter.rect_filled(resp.rect, 4.0, th.bg);
201        if resp.dragged() {
202            self.pan += resp.drag_delta();
203        }
204        if resp.hovered() {
205            let scroll = ui.input(|i| i.smooth_scroll_delta.y);
206            if scroll != 0.0 {
207                self.zoom = (self.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
208            }
209        }
210        let origin = resp.rect.center() + self.pan;
211        let zoom = self.zoom;
212        let project = |p: Pos| origin + egui::vec2(p.x, p.y) * zoom;
213
214        // click-to-select.
215        if let Some(click) = resp.clicked().then(|| resp.interact_pointer_pos()).flatten() {
216            let hit = self.model.nodes.iter().find_map(|nd| {
217                let c = project(nd.pos);
218                let rect = egui::Rect::from_center_size(c, egui::vec2(BOX_W, BOX_H) * zoom);
219                rect.contains(click).then(|| nd.id.clone())
220            });
221            self.selected = hit;
222        }
223
224        let lit = self.lit_set();
225        let highlighting = self.selected.is_some();
226        let dim = |c: egui::Color32| c.linear_multiply(0.22);
227
228        let idx: HashMap<&str, &GraphNode> =
229            self.model.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
230
231        // edges first.
232        let draw_edge = |e: &GraphEdge, emphasise: bool| {
233            let (Some(fa), Some(fb)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
234                return;
235            };
236            let on_trace = highlighting && lit.contains(&e.from) && lit.contains(&e.to);
237            let a = project(fa.pos) + egui::vec2(BOX_W * 0.5 * zoom, 0.0);
238            let b = project(fb.pos) - egui::vec2(BOX_W * 0.5 * zoom, 0.0);
239            let mut color = Self::col(e.color);
240            if !emphasise && highlighting && !on_trace {
241                color = dim(color);
242            }
243            let w = if emphasise { 2.6 } else if on_trace { 2.4 } else { 1.4 };
244            if e.dashed {
245                // sampled dashed line.
246                let n = 16;
247                for i in 0..n {
248                    if i % 2 == 0 {
249                        let t0 = i as f32 / n as f32;
250                        let t1 = (i + 1) as f32 / n as f32;
251                        painter.line_segment([a.lerp(b, t0), a.lerp(b, t1)], egui::Stroke::new(w, color));
252                    }
253                }
254            } else {
255                painter.line_segment([a, b], egui::Stroke::new(w, color));
256            }
257            // arrowhead.
258            let dir = (b - a).normalized();
259            let perp = egui::vec2(-dir.y, dir.x);
260            let head = 6.0 * zoom.clamp(0.6, 1.6);
261            painter.line_segment([b, b - dir * head + perp * head * 0.5], egui::Stroke::new(w, color));
262            painter.line_segment([b, b - dir * head - perp * head * 0.5], egui::Stroke::new(w, color));
263            if let Some(lbl) = &e.label {
264                if zoom > 0.45 && !lbl.is_empty() {
265                    painter.text(
266                        a.lerp(b, 0.5) - egui::vec2(0.0, 6.0 * zoom),
267                        egui::Align2::CENTER_BOTTOM,
268                        lbl,
269                        egui::FontId::proportional(10.0 * zoom.clamp(0.7, 1.3)),
270                        if emphasise { color } else { th.text_dim },
271                    );
272                }
273            }
274        };
275        for e in &self.model.edges {
276            draw_edge(e, false);
277        }
278        for e in &self.decorations.edges {
279            draw_edge(e, true);
280        }
281
282        // chips.
283        for nd in &self.model.nodes {
284            let c = project(nd.pos);
285            let on_trace = highlighting && lit.contains(&nd.id);
286            let mut fill = Self::col(nd.fill);
287            let mut stroke = Self::col(nd.stroke);
288            if highlighting && !on_trace {
289                fill = dim(fill);
290                stroke = dim(stroke);
291            }
292            let rect = egui::Rect::from_center_size(c, egui::vec2(BOX_W, BOX_H) * zoom);
293            painter.rect_filled(rect, 5.0 * zoom, fill);
294
295            let deco = self.decorations.nodes.get(&nd.id);
296            let ring = if self.selected.as_deref() == Some(nd.id.as_str()) {
297                egui::Stroke::new(3.0, th.accent)
298            } else if let Some(rc) = deco.and_then(|d| d.ring) {
299                let rc = Self::col(rc);
300                egui::Stroke::new(2.4, if highlighting && !on_trace { dim(rc) } else { rc })
301            } else {
302                egui::Stroke::new(1.4, stroke)
303            };
304            painter.rect_stroke(rect, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
305
306            if zoom > 0.45 {
307                painter.text(
308                    c,
309                    egui::Align2::CENTER_CENTER,
310                    &nd.label,
311                    egui::FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
312                    th.text,
313                );
314                if let Some(badge) = deco.and_then(|d| d.badge.as_deref()) {
315                    let bc = deco.and_then(|d| d.badge_color).map(Self::col).unwrap_or(th.text);
316                    painter.text(
317                        rect.right_top() + egui::vec2(-2.0, 1.0),
318                        egui::Align2::RIGHT_TOP,
319                        badge,
320                        egui::FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
321                        if highlighting && !on_trace { dim(bc) } else { bc },
322                    );
323                }
324            }
325        }
326
327        // pane-level a11y summary.
328        resp.widget_info(|| {
329            Semantics::image(format!(
330                "decorated graph — {} nodes, {} edges, selected {}",
331                self.model.nodes.len(),
332                self.model.edges.len(),
333                self.selected.as_deref().unwrap_or("none"),
334            ))
335            .widget_info()
336        });
337
338        #[cfg(feature = "testmatrix")]
339        facett_core::testmatrix::emit(
340            "facett-graphview::DecoratedGraphView::ui",
341            "ui_render",
342            !self.model.nodes.is_empty(),
343            &format!("nodes={} edges={} lit={}", self.model.nodes.len(), self.model.edges.len(), lit.len()),
344        );
345    }
346
347    fn state_json(&self) -> serde_json::Value {
348        let lit = self.lit_set();
349        serde_json::json!({
350            "title": self.title,
351            "node_count": self.model.nodes.len(),
352            "edge_count": self.model.edges.len(),
353            "decoration_edges": self.decorations.edges.len(),
354            "rings": self.decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
355            "badges": self.decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
356            "selected": self.selected,
357            "lit": lit.iter().cloned().collect::<Vec<_>>(),
358            "zoom": self.zoom,
359            "nodes": self.model.nodes.iter().map(|n| {
360                let d = self.decorations.nodes.get(&n.id);
361                serde_json::json!({
362                    "id": n.id,
363                    "label": n.label,
364                    "x": n.pos.x,
365                    "y": n.pos.y,
366                    "ring": d.and_then(|d| d.ring).is_some(),
367                    "badge": d.and_then(|d| d.badge.clone()),
368                })
369            }).collect::<Vec<_>>(),
370            "edges": self.model.edges.iter().map(|e| serde_json::json!({
371                "from": e.from, "to": e.to, "dashed": e.dashed,
372            })).collect::<Vec<_>>(),
373        })
374    }
375
376    fn selection_json(&self) -> serde_json::Value {
377        match &self.selected {
378            Some(id) => serde_json::json!({ "node": id, "downstream": self.lit_set().iter().cloned().collect::<Vec<_>>() }),
379            None => serde_json::Value::Null,
380        }
381    }
382
383    /// Themed (chips/rings follow the palette) + resizable + selectable (a node
384    /// drill-in) + navigable (pan/drag + scroll-zoom + fit).
385    fn caps(&self) -> FacetCaps {
386        FacetCaps::NONE.themeable().resizable().selectable().navigable()
387    }
388
389    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
390        Some(self)
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn downstream_is_bfs_closure_from_seed() {
400        let v = DecoratedGraphView::local();
401        // a → b, a → c, b → d, c → d. Downstream of a = all; of b = {b, d}.
402        let from_a = downstream_of(&v.model, "a");
403        assert!(from_a.contains("a") && from_a.contains("b") && from_a.contains("c") && from_a.contains("d"));
404        let from_b = downstream_of(&v.model, "b");
405        assert!(from_b.contains("b") && from_b.contains("d"));
406        assert!(!from_b.contains("a"), "BFS is forward-only");
407        assert!(!from_b.contains("c"), "c is not downstream of b");
408    }
409
410    /// inject-assert (LAW 6): the local view seeds a real decorated graph; assert
411    /// its `state_json` reports the nodes, the ring + badge decorations, the
412    /// emphasis edge, and — after a selection — the lit downstream set.
413    #[test]
414    fn local_view_reports_decorations_and_selection() {
415        let mut v = DecoratedGraphView::local();
416        let j = v.state_json();
417        assert_eq!(<DecoratedGraphView as Facet>::title(&v), "DecoratedGraph");
418        assert_eq!(j["node_count"], 4);
419        assert_eq!(j["edge_count"], 4);
420        assert_eq!(j["decoration_edges"], 1, "the dashed cut edge");
421        assert_eq!(j["rings"], 1, "a green coverage ring on `a`");
422        assert_eq!(j["badges"], 1, "a ⚠ badge on `b`");
423        // node `a` carries a ring, `b` carries the badge text.
424        let nodes = j["nodes"].as_array().unwrap();
425        let a = nodes.iter().find(|n| n["id"] == "a").unwrap();
426        assert_eq!(a["ring"], true);
427        let b = nodes.iter().find(|n| n["id"] == "b").unwrap();
428        assert_eq!(b["badge"], "⚠");
429        // no selection → null + empty lit set.
430        assert_eq!(j["selected"], serde_json::Value::Null);
431        assert!(j["lit"].as_array().unwrap().is_empty());
432
433        // select `b` → the lit set is its downstream {b, d}.
434        v.select(Some("b".into()));
435        let j2 = v.state_json();
436        assert_eq!(j2["selected"], "b");
437        let lit: BTreeSet<String> =
438            j2["lit"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
439        assert_eq!(lit, ["b", "d"].iter().map(|s| s.to_string()).collect::<BTreeSet<_>>());
440        let sel = v.selection_json();
441        assert_eq!(sel["node"], "b");
442    }
443
444    #[test]
445    fn fit_resets_pan_and_zoom() {
446        let mut v = DecoratedGraphView::local();
447        v.pan = egui::vec2(20.0, 10.0);
448        v.zoom = 2.5;
449        v.fit();
450        assert_eq!(v.pan, egui::Vec2::ZERO);
451        assert_eq!(v.zoom, 1.0);
452        assert_eq!(<DecoratedGraphView as Facet>::title(&DecoratedGraphView::remote()), "DecoratedGraph (remote)");
453    }
454}