//! The history graph's lane layout is `oxy_core::lanes`, shared with `oxy //! log`; this module only adapts hex hashes from views to it. pub use oxy_core::lanes::{width, Row}; /// `changes` newest first as `(hash hex, deps hex)`. pub fn layout(changes: &[(String, Vec)]) -> Vec { let parse = |h: &str| oxy_core::hash::ObjectRef::from_hex(h).unwrap_or(oxy_core::hash::ObjectRef([0; 32])); let input: Vec<(oxy_core::hash::ChangeHash, Vec)> = changes.iter().map(|(h, d)| (parse(h), d.iter().map(|x| parse(x)).collect())).collect(); oxy_core::lanes::layout(&input) } /// A card on the swimlane board (hub spec §6.7): one change, placed by its /// lane (row) and its age (column, oldest on the left). #[derive(Clone, Debug, PartialEq)] pub struct Card { pub hash: String, pub short: String, pub title: String, pub x: usize, pub y: usize, pub head: bool, } /// The swimlane board: cards and the dependency edges between them. #[derive(Clone, Debug, PartialEq)] pub struct Board { pub cards: Vec, /// SVG path data, from a change to each change of the board it depends on. pub edges: Vec, pub width: usize, pub height: usize, pub lanes: usize, } pub const CARD_W: usize = 150; pub const CARD_H: usize = 34; const GAP_X: usize = 40; const GAP_Y: usize = 22; /// Lay `changes` (newest first, as for [`layout`]) out as swimlanes: each /// lane a row, time running left to right. Independent work sits in /// separate lanes; there are no merge nodes, only dependency edges. pub fn board(changes: &[crate::repos::view::ChangeView]) -> Board { let input: Vec<(String, Vec)> = changes.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect(); let rows = layout(&input); let n = changes.len(); let pos: std::collections::HashMap<&str, (usize, usize)> = changes .iter() .zip(&rows) .enumerate() .map(|(i, (c, r))| (c.hash.as_str(), ((n - 1 - i) * (CARD_W + GAP_X) + 10, r.lane * (CARD_H + GAP_Y) + 10))) .collect(); let cards = changes .iter() .map(|c| { let (x, y) = pos[c.hash.as_str()]; let mut title: String = c.title.chars().take(22).collect(); if c.title.chars().count() > 22 { title.push('…'); } Card { hash: c.hash.clone(), short: c.short.clone(), title, x, y, head: c.head } }) .collect(); let mut edges = Vec::new(); for c in changes { let (cx, cy) = pos[c.hash.as_str()]; for d in &c.deps { if let Some(&(dx, dy)) = pos.get(d.as_str()) { // From the dependency's right edge to the change's left edge. let (x1, y1, x2, y2) = (dx + CARD_W, dy + CARD_H / 2, cx, cy + CARD_H / 2); let mid = (x1 + x2) / 2; edges.push(format!("M{x1} {y1} C{mid} {y1} {mid} {y2} {x2} {y2}")); } } } let lanes = width(&rows).max(1); Board { cards, edges, width: n.max(1) * (CARD_W + GAP_X) + 20, height: lanes * (CARD_H + GAP_Y) + 20, lanes } } #[cfg(test)] mod board_tests { use super::*; use crate::repos::view::{AuthorView, ChangeView, SigView}; fn change(hash: char, title: &str, deps: &[char]) -> ChangeView { let h = |c: char| std::iter::repeat_n(c, 64).collect::(); ChangeView { hash: h(hash), short: h(hash)[..8].to_string(), title: title.into(), body: String::new(), author: AuthorView { name: String::new(), email: String::new(), key: String::new(), unknown: true, account: None }, when_ms: 0, deps: deps.iter().map(|d| h(*d)).collect(), head: false, resolution: false, signature: SigView::Unsigned, via: None, labels: vec![], } } #[test] fn independent_work_gets_its_own_lane() { // Newest first: c depends on a; b is independent of both. let changes = vec![change('c', "c on a", &['a']), change('b', "b alone", &[]), change('a', "base", &[])]; let b = board(&changes); assert_eq!(b.cards.len(), 3); let card = |h: char| b.cards.iter().find(|c| c.hash.starts_with(h)).unwrap().clone(); assert!(card('a').x < card('b').x && card('b').x < card('c').x, "oldest on the left"); assert_eq!(card('a').y, card('c').y, "a chain stays in one lane"); assert_ne!(card('b').y, card('a').y, "independent work in its own lane"); assert_eq!(b.edges.len(), 1, "one dependency, no merge nodes"); assert!(b.lanes >= 2); let long = board(&[change('d', "a title far longer than the card can hold", &[])]); assert!(long.cards[0].title.ends_with('…') && long.cards[0].title.chars().count() == 23); } }