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