OXYGENHUB
◆ job-waits oxygenhub / crates / hub / src / render / graph.rs
4.8 KiB · Rust · last changed Who did the work: via AGENT, rotated identities link themselves, a Flow badge 1 month ago source blame history raw
9ff27da5 Andy McCall ↶ 1
//! The history graph's lane layout is `oxy_core::lanes`, shared with `oxy
2
//! log`; this module only adapts hex hashes from views to it.
d23464c0 Andy McCall ↶ 3
9ff27da5 Andy McCall ↶ 4
pub use oxy_core::lanes::{width, Row};
d23464c0 Andy McCall ↶ 5
9ff27da5 Andy McCall ↶ 6
/// `changes` newest first as `(hash hex, deps hex)`.
d23464c0 Andy McCall ↶ 7
pub fn layout(changes: &[(String, Vec<String>)]) -> Vec<Row> {
9ff27da5 Andy McCall ↶ 8
    let parse = |h: &str| oxy_core::hash::ObjectRef::from_hex(h).unwrap_or(oxy_core::hash::ObjectRef([0; 32]));
9
    let input: Vec<(oxy_core::hash::ChangeHash, Vec<oxy_core::hash::ChangeHash>)> =
10
        changes.iter().map(|(h, d)| (parse(h), d.iter().map(|x| parse(x)).collect())).collect();
11
    oxy_core::lanes::layout(&input)
d23464c0 Andy McCall ↶ 12
}
92574625 Andy McCall ↶ 13
14
/// A card on the swimlane board (hub spec §6.7): one change, placed by its
15
/// lane (row) and its age (column, oldest on the left).
16
#[derive(Clone, Debug, PartialEq)]
17
pub struct Card {
18
    pub hash: String,
19
    pub short: String,
20
    pub title: String,
21
    pub x: usize,
22
    pub y: usize,
23
    pub head: bool,
24
}
25
26
/// The swimlane board: cards and the dependency edges between them.
27
#[derive(Clone, Debug, PartialEq)]
28
pub struct Board {
29
    pub cards: Vec<Card>,
30
    /// SVG path data, from a change to each change of the board it depends on.
31
    pub edges: Vec<String>,
32
    pub width: usize,
33
    pub height: usize,
34
    pub lanes: usize,
35
}
36
37
pub const CARD_W: usize = 150;
38
pub const CARD_H: usize = 34;
39
const GAP_X: usize = 40;
40
const GAP_Y: usize = 22;
41
42
/// Lay `changes` (newest first, as for [`layout`]) out as swimlanes: each
43
/// lane a row, time running left to right. Independent work sits in
44
/// separate lanes; there are no merge nodes, only dependency edges.
45
pub fn board(changes: &[crate::repos::view::ChangeView]) -> Board {
46
    let input: Vec<(String, Vec<String>)> = changes.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect();
47
    let rows = layout(&input);
48
    let n = changes.len();
49
    let pos: std::collections::HashMap<&str, (usize, usize)> = changes
50
        .iter()
51
        .zip(&rows)
52
        .enumerate()
53
        .map(|(i, (c, r))| (c.hash.as_str(), ((n - 1 - i) * (CARD_W + GAP_X) + 10, r.lane * (CARD_H + GAP_Y) + 10)))
54
        .collect();
55
    let cards = changes
56
        .iter()
57
        .map(|c| {
58
            let (x, y) = pos[c.hash.as_str()];
59
            let mut title: String = c.title.chars().take(22).collect();
60
            if c.title.chars().count() > 22 {
61
                title.push('…');
62
            }
63
            Card { hash: c.hash.clone(), short: c.short.clone(), title, x, y, head: c.head }
64
        })
65
        .collect();
66
    let mut edges = Vec::new();
67
    for c in changes {
68
        let (cx, cy) = pos[c.hash.as_str()];
69
        for d in &c.deps {
70
            if let Some(&(dx, dy)) = pos.get(d.as_str()) {
71
                // From the dependency's right edge to the change's left edge.
72
                let (x1, y1, x2, y2) = (dx + CARD_W, dy + CARD_H / 2, cx, cy + CARD_H / 2);
73
                let mid = (x1 + x2) / 2;
74
                edges.push(format!("M{x1} {y1} C{mid} {y1} {mid} {y2} {x2} {y2}"));
75
            }
76
        }
77
    }
78
    let lanes = width(&rows).max(1);
79
    Board { cards, edges, width: n.max(1) * (CARD_W + GAP_X) + 20, height: lanes * (CARD_H + GAP_Y) + 20, lanes }
80
}
81
82
#[cfg(test)]
83
mod board_tests {
84
    use super::*;
85
    use crate::repos::view::{AuthorView, ChangeView, SigView};
86
87
    fn change(hash: char, title: &str, deps: &[char]) -> ChangeView {
88
        let h = |c: char| std::iter::repeat_n(c, 64).collect::<String>();
89
        ChangeView {
90
            hash: h(hash),
91
            short: h(hash)[..8].to_string(),
92
            title: title.into(),
93
            body: String::new(),
94
            author: AuthorView { name: String::new(), email: String::new(), key: String::new(), unknown: true, account: None },
95
            when_ms: 0,
96
            deps: deps.iter().map(|d| h(*d)).collect(),
97
            head: false,
98
            resolution: false,
99
            signature: SigView::Unsigned,
8933faff Andy McCall ↶ 100
            via: None,
92574625 Andy McCall ↶ 101
            labels: vec![],
102
        }
103
    }
104
105
    #[test]
106
    fn independent_work_gets_its_own_lane() {
107
        // Newest first: c depends on a; b is independent of both.
108
        let changes = vec![change('c', "c on a", &['a']), change('b', "b alone", &[]), change('a', "base", &[])];
109
        let b = board(&changes);
110
        assert_eq!(b.cards.len(), 3);
111
        let card = |h: char| b.cards.iter().find(|c| c.hash.starts_with(h)).unwrap().clone();
112
        assert!(card('a').x < card('b').x && card('b').x < card('c').x, "oldest on the left");
113
        assert_eq!(card('a').y, card('c').y, "a chain stays in one lane");
114
        assert_ne!(card('b').y, card('a').y, "independent work in its own lane");
115
        assert_eq!(b.edges.len(), 1, "one dependency, no merge nodes");
116
        assert!(b.lanes >= 2);
117
        let long = board(&[change('d', "a title far longer than the card can hold", &[])]);
118
        assert!(long.cards[0].title.ends_with('…') && long.cards[0].title.chars().count() == 23);
119
    }
120
}