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//!
6//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
7//! Both facets in this crate — [`GraphView`] (the 2D node/edge skin) and
8//! [`DepGraphView`] (the layered dep-DAG) — adopt the [`facett_core::Elm`] split:
9//! - **[`GraphState`]** / **[`depgraph::DepGraphState`]** — the complete observable,
10//!   serializable, round-trippable *interaction* state. The bulk graph data (the
11//!   [`Scene`] / the `nodes`+`edges`) is the *input* held on the struct — `Color32`
12//!   is not serde-round-trippable and it is not the state a headless driver mutates,
13//!   so it lives OUTSIDE the Model (mirrors how facett-grid keeps its Arrow data on
14//!   the struct, not in its `GridState`).
15//! - **[`Msg`] + `update`** — the single mutation path (FC-2): a host/robot toggles
16//!   the layout ([`GraphView`]) or selects/clears a node ([`DepGraphView`]); a
17//!   headless driver ([`facett_core::harness`]) reaches the identical transitions.
18//! - **`view`** — a **pure** paint (FC-9): reads `&self`, paints, and *returns* the
19//!   [`Msg`]s the interactions produced; the
20//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
21//! - **Rich `state_json`** — the node/edge/label counts (and the dep-graph's column
22//!   depth) a driver reads are *derived* from the input `Scene`/`nodes`, not the
23//!   interaction Model, so the macro is invoked in **form 3** (`custom_state_json`)
24//!   to publish those exact pre-migration keys instead of a plain `serde(state())`.
25
26use std::collections::{BTreeMap, HashMap};
27
28pub use facett_core::{Edge, Facet, FacetCaps, Layout, Node, Scene, Theme, draw, hash_color, set_theme, theme};
29
30use serde::{Deserialize, Serialize};
31
32mod depgraph;
33pub use depgraph::{
34    DepEdge, DepGraphState, DepGraphView, DepNode, draw_arrow, Effect as DepGraphEffect, Msg as DepGraphMsg,
35};
36
37/// Build a [`Scene`] from a labelled edge list — each row is
38/// `(src_id, dst_id, src_label, dst_label)`. Distinct ids become nodes, coloured
39/// per label (FNV-hashed). The reusable "graph from edges" adapter korp's Graph
40/// and Pipelines views both want.
41pub fn scene_from_labeled_edges<I>(rows: I) -> Scene
42where
43    I: IntoIterator<Item = (i64, i64, String, String)>,
44{
45    let mut scene = Scene::new();
46    let mut idx: HashMap<i64, usize> = HashMap::new();
47    for (s, d, sl, dl) in rows {
48        let si = *idx.entry(s).or_insert_with(|| scene.node(sl.clone(), hash_color(&sl)));
49        let di = *idx.entry(d).or_insert_with(|| scene.node(dl.clone(), hash_color(&dl)));
50        scene.edge(si, di);
51    }
52    scene
53}
54
55/// A serde-round-trippable mirror of [`Layout`] (which is `Copy` but neither
56/// `Serialize` nor `Debug`, so it cannot live in the [`GraphState`] Model directly).
57/// The [`Msg`] and the Model carry this; conversions to/from the core [`Layout`]
58/// keep the painter and public API speaking the core type.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum GraphLayout {
62    /// Nodes evenly spaced on a circle.
63    #[default]
64    Circular,
65    /// Deterministic Fruchterman–Reingold force layout.
66    Force,
67}
68
69impl From<Layout> for GraphLayout {
70    fn from(l: Layout) -> Self {
71        match l {
72            Layout::Circular => GraphLayout::Circular,
73            Layout::Force => GraphLayout::Force,
74        }
75    }
76}
77
78impl From<GraphLayout> for Layout {
79    fn from(l: GraphLayout) -> Self {
80        match l {
81            GraphLayout::Circular => Layout::Circular,
82            GraphLayout::Force => Layout::Force,
83        }
84    }
85}
86
87/// **The complete observable interaction state (FC-1 / FC-3)** of a [`GraphView`]:
88/// the layout knob a host can toggle. The bulk [`Scene`] is *input* held on
89/// [`GraphView`] (not serde-round-trippable, not driver-mutated), so it is
90/// deliberately kept OUT of this Model — the derived node/edge/label counts a driver
91/// reads are published via the form-3 `state_json` instead.
92#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
93pub struct GraphState {
94    /// The node-placement strategy (the one knob a host/robot toggles).
95    pub layout: GraphLayout,
96}
97
98/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
99/// driver (or a host) drives the [`GraphView`] through. Applied by
100/// [`GraphView::update`].
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub enum Msg {
103    /// Switch the node-placement strategy.
104    SetLayout(GraphLayout),
105}
106
107/// Side work as data (FC-8). The graph view does no I/O — every [`Msg`] mutates
108/// only the in-memory [`GraphState`] — so this is uninhabited on purpose.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub enum Effect {}
111
112/// A simple graph-view widget: a [`Scene`] + a [`Layout`]. Implements [`Facet`],
113/// so it drops into a `FacetDeck` and exposes `state_json` for free.
114pub struct GraphView {
115    /// The graph data being drawn — *input* (like facett-grid's Arrow data): held
116    /// on the struct, outside the observable [`GraphState`] Model.
117    pub scene: Scene,
118    /// Shown centred when the scene has no nodes.
119    pub empty_hint: String,
120    /// The deck keys facets off this.
121    pub title: String,
122    /// All observable interaction state (FC-3).
123    state: GraphState,
124}
125
126impl GraphView {
127    pub fn new(scene: Scene) -> Self {
128        Self { scene, empty_hint: "empty".into(), title: "graph".into(), state: GraphState::default() }
129    }
130    pub fn empty_hint(mut self, hint: impl Into<String>) -> Self {
131        self.empty_hint = hint.into();
132        self
133    }
134    pub fn with_title(mut self, title: impl Into<String>) -> Self {
135        self.title = title.into();
136        self
137    }
138    /// Set the layout strategy at build time.
139    pub fn with_layout(mut self, layout: Layout) -> Self {
140        self.state.layout = layout.into();
141        self
142    }
143
144    /// **FC-3** — read the complete observable interaction state.
145    pub fn state(&self) -> &GraphState {
146        &self.state
147    }
148
149    /// The active node-placement strategy (as the core [`Layout`]).
150    pub fn layout(&self) -> Layout {
151        self.state.layout.into()
152    }
153
154    /// Switch the layout — a thin wrapper over the FC-2 [`update`](Self::update)
155    /// mutation path (the sole way the Model changes).
156    pub fn set_layout(&mut self, layout: Layout) {
157        let _ = self.update(Msg::SetLayout(layout.into()));
158    }
159
160    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
161    /// (always empty) [`Effect`]s the host should run.
162    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
163        match msg {
164            Msg::SetLayout(layout) => self.state.layout = layout,
165        }
166        Vec::new()
167    }
168
169    /// Paint the scene (pure draw helper; no mutation).
170    pub fn show(&self, ui: &mut egui::Ui) {
171        draw(ui, &self.scene, self.state.layout.into(), &self.empty_hint);
172    }
173
174    /// **FC-9 render** — a **pure** function of `&self`: it paints the scene and
175    /// *returns* the [`Msg`]s the interactions produced (none — the 2D skin is a
176    /// static viewer; its layout is driven through the [`Msg`] surface by the host).
177    /// It MUST NOT mutate the Model or do I/O.
178    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
179        self.show(ui);
180        // ── render-lane emit: this pure-view paint path RAN ───────────────────
181        #[cfg(feature = "testmatrix")]
182        facett_core::testmatrix::emit(
183            "facett-graph::GraphView::view",
184            "ui_render",
185            !self.scene.nodes.is_empty() || !self.empty_hint.is_empty(),
186            &format!("nodes={} edges={}", self.scene.nodes.len(), self.scene.edges.len()),
187        );
188        Vec::new()
189    }
190
191    /// Per-label node counts (for the introspection).
192    pub fn label_counts(&self) -> BTreeMap<String, usize> {
193        let mut m = BTreeMap::new();
194        for n in &self.scene.nodes {
195            *m.entry(n.label.clone()).or_insert(0) += 1;
196        }
197        m
198    }
199}
200
201impl GraphView {
202    /// The copyable text: every node label, one per line (the graph carries no
203    /// per-node selection, so this is the whole scene's label list).
204    pub fn copy_text(&self) -> Option<String> {
205        if self.scene.nodes.is_empty() {
206            return None;
207        }
208        Some(self.scene.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
209    }
210}
211
212// ── typed copy (§16) — read-only: the scene's node labels as Text ─────────────
213impl facett_core::clip::CopySource for GraphView {
214    fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
215        use facett_core::clip::ClipKind;
216        &[ClipKind::Text]
217    }
218    fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
219        self.copy_text().map(facett_core::clip::ClipPayload::Text)
220    }
221}
222
223// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
224impl facett_core::Elm for GraphView {
225    type Model = GraphState;
226    type Msg = Msg;
227    type Effect = Effect;
228
229    fn title(&self) -> &str {
230        &self.title
231    }
232    fn state(&self) -> &GraphState {
233        &self.state
234    }
235    fn update(&mut self, msg: Msg) -> Vec<Effect> {
236        GraphView::update(self, msg)
237    }
238    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
239        GraphView::view(self, ui)
240    }
241}
242
243// The bridge macro writes `impl Facet for GraphView` from the `Elm` impl: `title`,
244// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the overrides below.
245// **Form 3** (`custom_state_json`) because the view publishes a RICHER `state_json`
246// than a plain `serde(state())`: the `nodes`/`edges`/`labels` counts facett-demo's
247// mega_matrix reads are *derived* from the input `Scene`, not the layout Model — so
248// the default `state_json` is suppressed and these EXACT pre-migration keys supplied.
249facett_core::impl_facet_via_elm!(GraphView, custom_state_json, {
250    fn state_json(&self) -> serde_json::Value {
251        serde_json::json!({
252            "nodes": self.scene.nodes.len(),
253            "edges": self.scene.edges.len(),
254            "labels": self.label_counts(),
255        })
256    }
257    fn copy(&mut self) -> Option<String> {
258        use facett_core::clip::CopySource as _;
259        self.copy_payload().map(|p| p.as_text())
260    }
261    /// Painted via [`draw`], which reads the active [`Theme`] (edges/labels/empty
262    /// hint) from the context; its canvas takes the host's `available_size`.
263    fn caps(&self) -> FacetCaps {
264        FacetCaps::NONE.themeable().resizable().copyable()
265    }
266});
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn typed_copy_is_the_node_label_list() {
274        use facett_core::clip::{ClipKind, CopySource};
275        let scene = scene_from_labeled_edges(vec![(1, 2, "Person".into(), "Company".into())]);
276        let mut g = GraphView::new(scene);
277        let p = g.copy_payload().expect("a populated scene copies");
278        assert_eq!(p.kind(), ClipKind::Text);
279        assert!(p.as_text().contains("Person") && p.as_text().contains("Company"));
280        assert_eq!(<GraphView as Facet>::copy(&mut g), Some(p.as_text()));
281    }
282
283    #[test]
284    fn builds_scene_from_labeled_edges() {
285        let scene = scene_from_labeled_edges(vec![
286            (1, 2, "Person".into(), "Company".into()),
287            (1, 3, "Person".into(), "Address".into()),
288        ]);
289        assert_eq!(scene.nodes.len(), 3, "1, 2, 3 distinct");
290        assert_eq!(scene.edges.len(), 2);
291        assert_eq!(scene.nodes[0].label, "Person");
292    }
293
294    // ── Elm split: state_json keys preserved byte-for-byte ───────────────────
295    #[test]
296    fn state_json_preserves_the_pre_migration_keys() {
297        let scene = scene_from_labeled_edges(vec![
298            (1, 2, "Person".into(), "Company".into()),
299            (1, 3, "Person".into(), "Address".into()),
300        ]);
301        let g = GraphView::new(scene);
302        let j = Facet::state_json(&g);
303        // Exactly `nodes` / `edges` / `labels` — no layout key leaks from the Model.
304        assert_eq!(j["nodes"], 3);
305        assert_eq!(j["edges"], 2);
306        assert_eq!(j["labels"]["Person"], 1);
307        let obj = j.as_object().unwrap();
308        assert_eq!(obj.len(), 3, "exactly nodes/edges/labels, no extra keys: {:?}", obj.keys().collect::<Vec<_>>());
309        assert!(obj.contains_key("nodes") && obj.contains_key("edges") && obj.contains_key("labels"));
310    }
311
312    // ── FC-2 → FC-3 as a *headless* property via the core harness ─────────────
313    #[test]
314    fn harness_drives_setlayout_and_snapshots_state() {
315        use facett_core::harness;
316        let mut g = GraphView::new(scene_from_labeled_edges(vec![(1, 2, "A".into(), "B".into())]));
317        assert_eq!(g.state().layout, GraphLayout::Circular, "default layout");
318        // SetLayout is observable in the snapshot.
319        let snap = harness::snapshot(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
320        assert_eq!(snap.layout, GraphLayout::Force, "layout is observable headlessly");
321        assert_eq!(&snap, g.state(), "the snapshot is a clone of the live state");
322        assert_eq!(g.state().layout, GraphLayout::Force, "the live view reflects the driven layout");
323        assert!(matches!(g.layout(), Layout::Force), "the accessor maps back to the core Layout");
324    }
325
326    #[test]
327    fn drive_reports_no_effects_and_state_round_trips() {
328        use facett_core::harness;
329        let mut g = GraphView::new(Scene::new());
330        // FC-8: the graph view does no I/O, so the effect stream is always empty.
331        let effects = harness::drive(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
332        assert!(effects.is_empty(), "FC-8: graph view emits no Effects");
333        // FC-3: the observable Model round-trips through serde.
334        let json = serde_json::to_value(g.state()).unwrap();
335        assert_eq!(json["layout"], "force");
336        let back: GraphState = serde_json::from_value(json).unwrap();
337        assert_eq!(&back, g.state(), "serde(state) -> state round-trips");
338    }
339
340    #[test]
341    fn headless_render_draws_and_reports_state() {
342        use facett_core::harness;
343        let mut g = GraphView::new(scene_from_labeled_edges(vec![
344            (1, 2, "Person".into(), "Company".into()),
345            (2, 3, "Company".into(), "Address".into()),
346        ]))
347        .with_title("graph");
348        let r = harness::headless_render(&mut g);
349        assert_eq!(r.title, "graph");
350        assert_eq!(r.state["nodes"], 3);
351        assert_eq!(r.state["edges"], 2);
352        assert!(r.drew(), "a 3-node graph tessellates to vertices");
353    }
354}