merman-render 0.8.0-alpha.5

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use merman_core::diagrams::sequence::SequenceMessage;
use std::collections::BTreeMap;

pub(crate) fn sequence_activation_start_x(center_x: f64, stacked_size: usize, width: f64) -> f64 {
    center_x + (((stacked_size as f64) - 1.0) * width) / 2.0
}

pub(crate) fn sequence_activation_stack_bounds(
    depth: usize,
    center_x: f64,
    width: f64,
) -> (f64, f64) {
    let mut left = center_x - 1.0;
    let mut right = center_x + 1.0;
    if depth == 0 {
        return (left, right);
    }

    let first_start_x = sequence_activation_start_x(center_x, 0, width);
    let last_start_x = sequence_activation_start_x(center_x, depth - 1, width);
    left = left.min(first_start_x).min(last_start_x);
    right = right.max(first_start_x + width).max(last_start_x + width);
    (left, right)
}

pub(super) struct SequenceActivationState {
    width: f64,
    depths: BTreeMap<usize, usize>,
}

impl SequenceActivationState {
    pub(super) fn new(width: f64) -> Self {
        Self {
            width,
            depths: BTreeMap::new(),
        }
    }

    pub(super) fn handle_directive(
        &mut self,
        msg: &SequenceMessage,
        actor_index: &std::collections::HashMap<&str, usize>,
        actor_centers_x: &[f64],
    ) -> bool {
        match msg.message_type {
            // ACTIVE_START
            17 => {
                let Some(actor_id) = msg.from.as_deref() else {
                    return true;
                };
                let Some(&idx) = actor_index.get(actor_id) else {
                    return true;
                };
                if actor_centers_x.get(idx).is_none() {
                    return true;
                }
                let depth = self.depths.entry(idx).or_default();
                *depth = depth.saturating_add(1);
                true
            }
            // ACTIVE_END
            18 => {
                let Some(actor_id) = msg.from.as_deref() else {
                    return true;
                };
                let Some(&idx) = actor_index.get(actor_id) else {
                    return true;
                };
                if let Some(depth) = self.depths.get_mut(&idx) {
                    *depth = depth.saturating_sub(1);
                }
                true
            }
            _ => false,
        }
    }

    pub(super) fn actor_bounds(&self, actor_index: usize, center_x: f64) -> (f64, f64) {
        sequence_activation_stack_bounds(
            self.depths.get(&actor_index).copied().unwrap_or_default(),
            center_x,
            self.width,
        )
    }

    pub(super) fn width(&self) -> f64 {
        self.width
    }
}

#[cfg(test)]
mod tests {
    use super::{sequence_activation_stack_bounds, sequence_activation_start_x};

    #[test]
    fn activation_start_x_matches_mermaid_stack_offsets() {
        assert_eq!(sequence_activation_start_x(100.0, 0, 10.0), 95.0);
        assert_eq!(sequence_activation_start_x(100.0, 1, 10.0), 100.0);
        assert_eq!(sequence_activation_start_x(100.0, 2, 10.0), 105.0);
    }

    #[test]
    fn activation_stack_bounds_fold_full_active_stack() {
        assert_eq!(
            sequence_activation_stack_bounds(0, 100.0, 10.0),
            (99.0, 101.0)
        );
        assert_eq!(
            sequence_activation_stack_bounds(1, 100.0, 10.0),
            (95.0, 105.0)
        );
        assert_eq!(
            sequence_activation_stack_bounds(2, 100.0, 10.0),
            (95.0, 110.0)
        );
        assert_eq!(
            sequence_activation_stack_bounds(2, 100.0, -10.0),
            (99.0, 101.0)
        );
    }

    #[test]
    fn activation_stack_bounds_only_need_progression_endpoints() {
        assert_eq!(
            sequence_activation_stack_bounds(10_000, 100.0, 10.0),
            (95.0, 50_100.0)
        );
    }
}