1use 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
21pub 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
45pub 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 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 pub fn with_model(mut self, model: GraphModel) -> Self {
70 self.model = model;
71 self
72 }
73
74 pub fn with_decorations(mut self, decorations: Decorations) -> Self {
76 self.decorations = decorations;
77 self
78 }
79
80 pub fn set_title(&mut self, title: impl Into<String>) {
82 self.title = title.into();
83 }
84
85 pub fn fit(&mut self) {
87 self.pan = egui::Vec2::ZERO;
88 self.zoom = 1.0;
89 }
90
91 pub fn select(&mut self, id: Option<String>) {
93 self.selected = id;
94 }
95
96 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 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 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 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
184impl 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 assert_eq!(j["selected"], serde_json::Value::Null);
477 assert!(j["lit"].as_array().unwrap().is_empty());
478
479 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}