merman-render 0.8.0

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use crate::model::{LayoutPoint, SwimlaneDirection, SwimlaneTitleRect};
use indexmap::IndexMap;
#[cfg(any(feature = "diagram-flowchart", feature = "diagram-swimlane"))]
use std::collections::{HashMap, HashSet};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum WorkingNodeKind {
    Group,
    Content,
    EdgeLabel,
    Dummy,
}

#[derive(Debug, Clone)]
pub(super) struct WorkingNode {
    pub id: String,
    pub label: String,
    pub label_type: String,
    pub shape: String,
    pub kind: WorkingNodeKind,
    pub parent_id: Option<String>,
    pub top_lane_id: Option<String>,
    pub requested_dir: Option<String>,
    pub padding: f64,
    pub x: f64,
    pub y: f64,
    pub width: f64,
    pub height: f64,
    pub label_width: f64,
    pub label_height: f64,
    pub layer: usize,
    pub order: usize,
    pub content_top: Option<f64>,
    pub title_rect: Option<SwimlaneTitleRect>,
}

impl WorkingNode {
    pub fn is_group(&self) -> bool {
        self.kind == WorkingNodeKind::Group
    }

    pub fn is_layout_node(&self) -> bool {
        matches!(
            self.kind,
            WorkingNodeKind::Content | WorkingNodeKind::EdgeLabel | WorkingNodeKind::Dummy
        )
    }
}

#[derive(Debug, Clone)]
pub(super) struct WorkingEdge {
    pub id: String,
    pub from: String,
    pub to: String,
    pub reference_id: String,
    pub label_node_id: Option<String>,
    pub reversed_for_layout: bool,
    pub points: Vec<LayoutPoint>,
}

impl WorkingEdge {
    pub fn layout_key(&self) -> String {
        format!("{}:{}->{}", self.id, self.from, self.to)
    }
}

#[derive(Debug, Clone)]
pub(super) struct WorkingLayout {
    pub direction: SwimlaneDirection,
    pub nodes: IndexMap<String, WorkingNode>,
    pub graph_edges: Vec<WorkingEdge>,
    pub original_edges: Vec<WorkingEdge>,
    pub top_lane_order: Vec<String>,
}

impl WorkingLayout {
    #[cfg(any(feature = "diagram-flowchart", feature = "diagram-swimlane"))]
    pub fn refresh_top_lane_ids(&mut self) {
        let parents: HashMap<String, Option<String>> = self
            .nodes
            .iter()
            .map(|(id, node)| (id.clone(), node.parent_id.clone()))
            .collect();
        let groups: HashSet<String> = self
            .nodes
            .iter()
            .filter(|(_, node)| node.is_group())
            .map(|(id, _)| id.clone())
            .collect();

        for node in self.nodes.values_mut() {
            let mut current = node.parent_id.clone();
            let mut top = None;
            let mut visited = HashSet::new();
            while let Some(parent) = current {
                if !visited.insert(parent.clone()) || !groups.contains(&parent) {
                    break;
                }
                top = Some(parent.clone());
                current = parents.get(&parent).and_then(Clone::clone);
            }
            node.top_lane_id = top;
        }
    }

    pub fn top_lane_of(&self, id: &str) -> Option<&str> {
        self.nodes
            .get(id)
            .and_then(|node| node.top_lane_id.as_deref())
    }

    pub fn children_of<'a>(&'a self, parent_id: &'a str) -> impl Iterator<Item = &'a WorkingNode> {
        self.nodes
            .values()
            .filter(move |node| node.parent_id.as_deref() == Some(parent_id))
    }
}