Skip to main content

facett_graph/
lib.rs

1//! **facett-graph** — the graph (node/edge) viewer component, on
2//! [`facett_core`]. Build a [`Scene`] from a labelled edge list (the common
3//! shape: a graph query, a dataflow DAG) and draw it. Source-agnostic — the
4//! caller turns its Arrow/Cypher/whatever into edges first.
5
6use std::collections::{BTreeMap, HashMap};
7
8pub use facett_core::{Edge, Facet, FacetCaps, Layout, Node, Scene, Theme, draw, hash_color, set_theme, theme};
9
10mod depgraph;
11pub use depgraph::{DepEdge, DepGraphView, DepNode, draw_arrow};
12
13/// Build a [`Scene`] from a labelled edge list — each row is
14/// `(src_id, dst_id, src_label, dst_label)`. Distinct ids become nodes, coloured
15/// per label (FNV-hashed). The reusable "graph from edges" adapter korp's Graph
16/// and Pipelines views both want.
17pub fn scene_from_labeled_edges<I>(rows: I) -> Scene
18where
19    I: IntoIterator<Item = (i64, i64, String, String)>,
20{
21    let mut scene = Scene::new();
22    let mut idx: HashMap<i64, usize> = HashMap::new();
23    for (s, d, sl, dl) in rows {
24        let si = *idx.entry(s).or_insert_with(|| scene.node(sl.clone(), hash_color(&sl)));
25        let di = *idx.entry(d).or_insert_with(|| scene.node(dl.clone(), hash_color(&dl)));
26        scene.edge(si, di);
27    }
28    scene
29}
30
31/// A simple graph-view widget: a [`Scene`] + a [`Layout`]. Implements [`Facet`],
32/// so it drops into a `FacetDeck` and exposes `state_json` for free.
33pub struct GraphView {
34    pub scene: Scene,
35    pub layout: Layout,
36    pub empty_hint: String,
37    pub title: String,
38}
39
40impl GraphView {
41    pub fn new(scene: Scene) -> Self {
42        Self { scene, layout: Layout::default(), empty_hint: "empty".into(), title: "graph".into() }
43    }
44    pub fn empty_hint(mut self, hint: impl Into<String>) -> Self {
45        self.empty_hint = hint.into();
46        self
47    }
48    pub fn with_title(mut self, title: impl Into<String>) -> Self {
49        self.title = title.into();
50        self
51    }
52    pub fn show(&self, ui: &mut egui::Ui) {
53        draw(ui, &self.scene, self.layout, &self.empty_hint);
54    }
55    /// Per-label node counts (for the introspection).
56    pub fn label_counts(&self) -> BTreeMap<String, usize> {
57        let mut m = BTreeMap::new();
58        for n in &self.scene.nodes {
59            *m.entry(n.label.clone()).or_insert(0) += 1;
60        }
61        m
62    }
63}
64
65impl GraphView {
66    /// The copyable text: every node label, one per line (the graph carries no
67    /// per-node selection, so this is the whole scene's label list).
68    pub fn copy_text(&self) -> Option<String> {
69        if self.scene.nodes.is_empty() {
70            return None;
71        }
72        Some(self.scene.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
73    }
74}
75
76// ── typed copy (§16) — read-only: the scene's node labels as Text ─────────────
77impl facett_core::clip::CopySource for GraphView {
78    fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
79        use facett_core::clip::ClipKind;
80        &[ClipKind::Text]
81    }
82    fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
83        self.copy_text().map(facett_core::clip::ClipPayload::Text)
84    }
85}
86
87impl Facet for GraphView {
88    fn title(&self) -> &str {
89        &self.title
90    }
91    fn copy(&mut self) -> Option<String> {
92        use facett_core::clip::CopySource as _;
93        self.copy_payload().map(|p| p.as_text())
94    }
95    fn ui(&mut self, ui: &mut egui::Ui) {
96        self.show(ui);
97        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
98        #[cfg(feature = "testmatrix")]
99        facett_core::testmatrix::emit(
100            "facett-graph::GraphView::ui",
101            "ui_render",
102            !self.scene.nodes.is_empty() || !self.empty_hint.is_empty(),
103            &format!("nodes={} edges={}", self.scene.nodes.len(), self.scene.edges.len()),
104        );
105    }
106    fn state_json(&self) -> serde_json::Value {
107        serde_json::json!({
108            "nodes": self.scene.nodes.len(),
109            "edges": self.scene.edges.len(),
110            "labels": self.label_counts(),
111        })
112    }
113    /// Painted via [`draw`], which reads the active [`Theme`] (edges/labels/empty
114    /// hint) from the context; its canvas takes the host's `available_size`.
115    fn caps(&self) -> FacetCaps {
116        FacetCaps::NONE.themeable().resizable().copyable()
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn typed_copy_is_the_node_label_list() {
126        use facett_core::clip::{ClipKind, CopySource};
127        let scene = scene_from_labeled_edges(vec![(1, 2, "Person".into(), "Company".into())]);
128        let mut g = GraphView::new(scene);
129        let p = g.copy_payload().expect("a populated scene copies");
130        assert_eq!(p.kind(), ClipKind::Text);
131        assert!(p.as_text().contains("Person") && p.as_text().contains("Company"));
132        assert_eq!(<GraphView as Facet>::copy(&mut g), Some(p.as_text()));
133    }
134
135    #[test]
136    fn builds_scene_from_labeled_edges() {
137        let scene = scene_from_labeled_edges(vec![
138            (1, 2, "Person".into(), "Company".into()),
139            (1, 3, "Person".into(), "Address".into()),
140        ]);
141        assert_eq!(scene.nodes.len(), 3, "1, 2, 3 distinct");
142        assert_eq!(scene.edges.len(), 2);
143        assert_eq!(scene.nodes[0].label, "Person");
144    }
145}