Skip to main content

oxframe_components/
flow.rs

1//! A graph of named nodes, laid out in layers from the edges.
2
3use std::collections::BTreeMap;
4
5use oxframe_2d::{
6    Accent, BaseProps, Group, Icon, IconBuilder, Len, Lg, Line, Md, Muted, NodeBuilder, NodeId,
7    Rect, Surface, Txt, Xl,
8};
9use oxframe_core::Tween;
10
11use crate::host::{self, Host};
12
13/// Nodes and arrows. The diagram places itself: left to right, or top to
14/// bottom after [`down`](FlowDiagramBuilder::down). `next` highlights the
15/// next node.
16pub struct FlowDiagram {
17    host: Host,
18    icons: BTreeMap<String, Icon>,
19}
20
21impl FlowDiagram {
22    /// An empty diagram. Add links with [`edge`](FlowDiagramBuilder::edge).
23    #[allow(clippy::new_ret_no_self)]
24    pub fn new() -> FlowDiagramBuilder {
25        FlowDiagramBuilder {
26            base: BaseProps::default(),
27            edges: Vec::new(),
28            icons: BTreeMap::new(),
29            down: false,
30        }
31    }
32
33    /// Fades the diagram in. Highlights are still hidden.
34    pub fn enter(&self) -> Tween<f64> {
35        self.host.enter()
36    }
37    /// Shows the accent bar on the next node.
38    pub fn next(&self) -> Tween<f64> {
39        self.host.next()
40    }
41    /// Fades the diagram out.
42    pub fn exit(&self) -> Tween<f64> {
43        self.host.exit()
44    }
45    /// How many nodes `next` can highlight.
46    pub fn len(&self) -> usize {
47        self.host.len()
48    }
49    /// No nodes were declared.
50    pub fn is_empty(&self) -> bool {
51        self.host.is_empty()
52    }
53    /// The icon of the node `name`, from [`icon`](FlowDiagramBuilder::icon).
54    pub fn icon(&self, name: &str) -> Option<Icon> {
55        self.icons.get(name).cloned()
56    }
57}
58
59/// Builder for [`FlowDiagram`].
60#[must_use = "a builder does nothing until it is passed to `add`"]
61pub struct FlowDiagramBuilder {
62    base: BaseProps,
63    edges: Vec<(String, String)>,
64    icons: BTreeMap<String, IconBuilder>,
65    down: bool,
66}
67
68impl FlowDiagramBuilder {
69    /// A directed link. Nodes are created the first time their name appears.
70    pub fn edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
71        self.edges.push((from.into(), to.into()));
72        self
73    }
74    /// An icon at the start of the node `name`, such as
75    /// `.icon("db", icon!("lucide:database"))`. Its size is set to fit the card.
76    pub fn icon(mut self, name: impl Into<String>, icon: IconBuilder) -> Self {
77        self.icons.insert(name.into(), icon);
78        self
79    }
80    /// Stack layers top to bottom instead of left to right.
81    pub fn down(mut self) -> Self {
82        self.down = true;
83        self
84    }
85}
86
87impl NodeBuilder for FlowDiagramBuilder {
88    type Handle = FlowDiagram;
89    fn base(&mut self) -> &mut BaseProps {
90        &mut self.base
91    }
92    fn build(mut self, _: &oxframe_2d::SceneGraph, parent: NodeId) -> FlowDiagram {
93        let root: Group = host::root(parent, self.base);
94        let mut index = BTreeMap::<String, usize>::new();
95        let mut names = Vec::new();
96        let mut links = Vec::new();
97        for (from, to) in self.edges {
98            let a = *index.entry(from.clone()).or_insert_with(|| {
99                names.push(from);
100                names.len() - 1
101            });
102            let b = *index.entry(to.clone()).or_insert_with(|| {
103                names.push(to);
104                names.len() - 1
105            });
106            links.push((a, b));
107        }
108        let layers = layers_of(names.len(), &links);
109        // An icon takes the start of the card, so cards with icons are wider.
110        let card_w = if self.icons.is_empty() { 200.0 } else { 260.0 };
111        let card_h = 84.0;
112        let gap = Md.px();
113        let layer_gap = Xl.px();
114        let max_layer = layers.iter().copied().max().unwrap_or(0);
115        let mut counts = vec![0usize; max_layer + 1];
116        for layer in &layers {
117            counts[*layer] += 1;
118        }
119        let along = (max_layer as f64) * (card_w + layer_gap);
120        let mut slot = vec![0usize; max_layer + 1];
121        let mut pos = vec![(0.0, 0.0); names.len()];
122        for (i, layer) in layers.iter().copied().enumerate() {
123            let n = counts[layer].max(1) as f64;
124            let across = (n - 1.0) * (card_h + gap);
125            let a = -(along / 2.0) + layer as f64 * (card_w + layer_gap);
126            let b = -(across / 2.0) + slot[layer] as f64 * (card_h + gap);
127            slot[layer] += 1;
128            pos[i] = if self.down { (b, a) } else { (a, b) };
129        }
130        let mut reveals = Vec::new();
131        let mut icons = BTreeMap::new();
132        for (i, name) in names.iter().enumerate() {
133            let (x, y) = pos[i];
134            root.add(
135                Rect::new()
136                    .width(card_w)
137                    .height(card_h)
138                    .radius(Lg)
139                    .fill(Surface)
140                    .x(x)
141                    .y(y)
142                    .name(name.clone()),
143            );
144            let label = root.add(
145                Txt::new()
146                    .text(name.clone())
147                    .body()
148                    .x(x)
149                    .y(y)
150                    .name(format!("{name}-label")),
151            );
152            // With an icon, the label starts right of it.
153            if let Some(icon) = self.icons.remove(name) {
154                let size = 40.0;
155                let at = x - card_w / 2.0 + 36.0 + size / 2.0;
156                icons.insert(
157                    name.clone(),
158                    root.add(icon.size(size).x(at).y(y).name(format!("{name}-icon"))),
159                );
160                label.x().set(at + size / 2.0 + 16.0 + label.size().0 / 2.0);
161            }
162            let bar = root.add(
163                Rect::new()
164                    .width(8.0)
165                    .height(card_h - 28.0)
166                    .fill(Accent)
167                    .x(x - card_w / 2.0 + 16.0)
168                    .y(y)
169                    .opacity(0.0)
170                    .name(format!("{name}-bar")),
171            );
172            reveals.push(bar.node());
173        }
174        for (a, b) in links {
175            let (x1, y1) = pos[a];
176            let (x2, y2) = pos[b];
177            let (start, end) = if self.down {
178                ((x1, y1 + card_h / 2.0), (x2, y2 - card_h / 2.0))
179            } else {
180                ((x1 + card_w / 2.0, y1), (x2 - card_w / 2.0, y2))
181            };
182            root.add(
183                Line::new()
184                    .points([start, end])
185                    .end_arrow(true)
186                    .stroke(Muted)
187                    .line_width(4.0)
188                    .name("link"),
189            );
190        }
191        FlowDiagram {
192            host: Host::new(root.node(), reveals),
193            icons,
194        }
195    }
196}
197
198fn layers_of(n: usize, links: &[(usize, usize)]) -> Vec<usize> {
199    let mut layer = vec![0; n];
200    for _ in 0..n.max(1) {
201        let mut changed = false;
202        for &(from, to) in links {
203            let next = layer[from] + 1;
204            if next > layer[to] && next < n {
205                layer[to] = next;
206                changed = true;
207            }
208        }
209        if !changed {
210            break;
211        }
212    }
213    layer
214}