use std::collections::{BTreeMap, HashMap};
pub use facett_core::{Edge, Facet, FacetCaps, Layout, Node, Scene, Theme, draw, hash_color, set_theme, theme};
use serde::{Deserialize, Serialize};
mod depgraph;
pub use depgraph::{
DepEdge, DepGraphState, DepGraphView, DepNode, draw_arrow, Effect as DepGraphEffect, Msg as DepGraphMsg,
};
pub fn scene_from_labeled_edges<I>(rows: I) -> Scene
where
I: IntoIterator<Item = (i64, i64, String, String)>,
{
let mut scene = Scene::new();
let mut idx: HashMap<i64, usize> = HashMap::new();
for (s, d, sl, dl) in rows {
let si = *idx.entry(s).or_insert_with(|| scene.node(sl.clone(), hash_color(&sl)));
let di = *idx.entry(d).or_insert_with(|| scene.node(dl.clone(), hash_color(&dl)));
scene.edge(si, di);
}
scene
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GraphLayout {
#[default]
Circular,
Force,
}
impl From<Layout> for GraphLayout {
fn from(l: Layout) -> Self {
match l {
Layout::Circular => GraphLayout::Circular,
Layout::Force => GraphLayout::Force,
}
}
}
impl From<GraphLayout> for Layout {
fn from(l: GraphLayout) -> Self {
match l {
GraphLayout::Circular => Layout::Circular,
GraphLayout::Force => Layout::Force,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct GraphState {
pub layout: GraphLayout,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Msg {
SetLayout(GraphLayout),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Effect {}
pub struct GraphView {
pub scene: Scene,
pub empty_hint: String,
pub title: String,
state: GraphState,
}
impl GraphView {
pub fn new(scene: Scene) -> Self {
Self { scene, empty_hint: "empty".into(), title: "graph".into(), state: GraphState::default() }
}
pub fn empty_hint(mut self, hint: impl Into<String>) -> Self {
self.empty_hint = hint.into();
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn with_layout(mut self, layout: Layout) -> Self {
self.state.layout = layout.into();
self
}
pub fn state(&self) -> &GraphState {
&self.state
}
pub fn layout(&self) -> Layout {
self.state.layout.into()
}
pub fn set_layout(&mut self, layout: Layout) {
let _ = self.update(Msg::SetLayout(layout.into()));
}
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::SetLayout(layout) => self.state.layout = layout,
}
Vec::new()
}
pub fn show(&self, ui: &mut egui::Ui) {
draw(ui, &self.scene, self.state.layout.into(), &self.empty_hint);
}
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
self.show(ui);
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-graph::GraphView::view",
"ui_render",
!self.scene.nodes.is_empty() || !self.empty_hint.is_empty(),
&format!("nodes={} edges={}", self.scene.nodes.len(), self.scene.edges.len()),
);
Vec::new()
}
pub fn label_counts(&self) -> BTreeMap<String, usize> {
let mut m = BTreeMap::new();
for n in &self.scene.nodes {
*m.entry(n.label.clone()).or_insert(0) += 1;
}
m
}
}
impl GraphView {
pub fn copy_text(&self) -> Option<String> {
if self.scene.nodes.is_empty() {
return None;
}
Some(self.scene.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
}
}
impl facett_core::clip::CopySource for GraphView {
fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
use facett_core::clip::ClipKind;
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
self.copy_text().map(facett_core::clip::ClipPayload::Text)
}
}
impl facett_core::Elm for GraphView {
type Model = GraphState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &GraphState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
GraphView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
GraphView::view(self, ui)
}
}
facett_core::impl_facet_via_elm!(GraphView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"nodes": self.scene.nodes.len(),
"edges": self.scene.edges.len(),
"labels": self.label_counts(),
})
}
fn copy(&mut self) -> Option<String> {
use facett_core::clip::CopySource as _;
self.copy_payload().map(|p| p.as_text())
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().copyable()
}
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn typed_copy_is_the_node_label_list() {
use facett_core::clip::{ClipKind, CopySource};
let scene = scene_from_labeled_edges(vec![(1, 2, "Person".into(), "Company".into())]);
let mut g = GraphView::new(scene);
let p = g.copy_payload().expect("a populated scene copies");
assert_eq!(p.kind(), ClipKind::Text);
assert!(p.as_text().contains("Person") && p.as_text().contains("Company"));
assert_eq!(<GraphView as Facet>::copy(&mut g), Some(p.as_text()));
}
#[test]
fn builds_scene_from_labeled_edges() {
let scene = scene_from_labeled_edges(vec![
(1, 2, "Person".into(), "Company".into()),
(1, 3, "Person".into(), "Address".into()),
]);
assert_eq!(scene.nodes.len(), 3, "1, 2, 3 distinct");
assert_eq!(scene.edges.len(), 2);
assert_eq!(scene.nodes[0].label, "Person");
}
#[test]
fn state_json_preserves_the_pre_migration_keys() {
let scene = scene_from_labeled_edges(vec![
(1, 2, "Person".into(), "Company".into()),
(1, 3, "Person".into(), "Address".into()),
]);
let g = GraphView::new(scene);
let j = Facet::state_json(&g);
assert_eq!(j["nodes"], 3);
assert_eq!(j["edges"], 2);
assert_eq!(j["labels"]["Person"], 1);
let obj = j.as_object().unwrap();
assert_eq!(obj.len(), 3, "exactly nodes/edges/labels, no extra keys: {:?}", obj.keys().collect::<Vec<_>>());
assert!(obj.contains_key("nodes") && obj.contains_key("edges") && obj.contains_key("labels"));
}
#[test]
fn harness_drives_setlayout_and_snapshots_state() {
use facett_core::harness;
let mut g = GraphView::new(scene_from_labeled_edges(vec![(1, 2, "A".into(), "B".into())]));
assert_eq!(g.state().layout, GraphLayout::Circular, "default layout");
let snap = harness::snapshot(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
assert_eq!(snap.layout, GraphLayout::Force, "layout is observable headlessly");
assert_eq!(&snap, g.state(), "the snapshot is a clone of the live state");
assert_eq!(g.state().layout, GraphLayout::Force, "the live view reflects the driven layout");
assert!(matches!(g.layout(), Layout::Force), "the accessor maps back to the core Layout");
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut g = GraphView::new(Scene::new());
let effects = harness::drive(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
assert!(effects.is_empty(), "FC-8: graph view emits no Effects");
let json = serde_json::to_value(g.state()).unwrap();
assert_eq!(json["layout"], "force");
let back: GraphState = serde_json::from_value(json).unwrap();
assert_eq!(&back, g.state(), "serde(state) -> state round-trips");
}
#[test]
fn headless_render_draws_and_reports_state() {
use facett_core::harness;
let mut g = GraphView::new(scene_from_labeled_edges(vec![
(1, 2, "Person".into(), "Company".into()),
(2, 3, "Company".into(), "Address".into()),
]))
.with_title("graph");
let r = harness::headless_render(&mut g);
assert_eq!(r.title, "graph");
assert_eq!(r.state["nodes"], 3);
assert_eq!(r.state["edges"], 2);
assert!(r.drew(), "a 3-node graph tessellates to vertices");
}
}