1use 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
20pub 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
44pub 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 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 pub fn with_model(mut self, model: GraphModel) -> Self {
69 self.model = model;
70 self
71 }
72
73 pub fn with_decorations(mut self, decorations: Decorations) -> Self {
75 self.decorations = decorations;
76 self
77 }
78
79 pub fn set_title(&mut self, title: impl Into<String>) {
81 self.title = title.into();
82 }
83
84 pub fn fit(&mut self) {
86 self.pan = egui::Vec2::ZERO;
87 self.zoom = 1.0;
88 }
89
90 pub fn select(&mut self, id: Option<String>) {
92 self.selected = id;
93 }
94
95 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 assert_eq!(j["selected"], serde_json::Value::Null);
431 assert!(j["lit"].as_array().unwrap().is_empty());
432
433 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}