ariel-rs 0.2.0

A faithful Rust port of Mermaid JS — headless SVG diagram rendering without a browser
Documentation
/// Faithful Rust port of Mermaid's wardleyRenderer.ts.
///
/// Key rendering details:
/// - Default canvas: 900×600 px with 48px padding on all sides
/// - X axis = Evolution (left=Genesis, right=Commodity)
/// - Y axis = Visibility (top=visible=100, bottom=invisible=0)
/// - Nodes store coordinates as 0-100 percentage values
/// - projectX(v) = padding + v/100 * chartWidth
/// - projectY(v) = height - padding - v/100 * chartHeight
/// - Anchors: text-only (no circle), bold, centered
/// - Components: circle r=6 with label
/// - Trends: red dashed arrows showing future evolution positions
use super::constants::*;
use super::parser::{Sourcing, WardleyDiagram, WardleyNode, WardleyNodeKind};
use super::templates::{self, esc, fmt_f};
use crate::theme::Theme;

pub fn render(diag: &WardleyDiagram, _theme: Theme) -> String {
    let width = diag.width;
    let height = diag.height;
    let chart_width = width - PADDING * 2.0;
    let chart_height = height - PADDING * 2.0;

    // SVG ID for marker references
    let svg_id = "wardley-svg";

    let mut out = String::new();

    // ── SVG root ──────────────────────────────────────────────────────────────
    out.push_str(&templates::svg_root(width, height));

    // ── <defs> with arrow markers ─────────────────────────────────────────────
    out.push_str(&templates::defs_block(svg_id));

    // ── wardley-map group ─────────────────────────────────────────────────────
    out.push_str("<g class=\"wardley-map\">");

    // ── Background rect ───────────────────────────────────────────────────────
    out.push_str(&templates::background_rect(width, height));

    // ── Title ─────────────────────────────────────────────────────────────────
    if let Some(ref title) = diag.title {
        out.push_str(&templates::title_text(
            width / 2.0,
            PADDING / 2.0,
            TITLE_FONT_SIZE,
            &esc(title),
        ));
    }

    // ── Axes ──────────────────────────────────────────────────────────────────
    out.push_str("<g class=\"wardley-axes\">");
    // X axis (bottom)
    out.push_str(&templates::axis_line(
        PADDING,
        width - PADDING,
        height - PADDING,
        height - PADDING,
    ));
    // Y axis (left)
    out.push_str(&templates::axis_line(
        PADDING,
        PADDING,
        PADDING,
        height - PADDING,
    ));
    // X axis label "Evolution"
    out.push_str(&templates::axis_label_x(
        PADDING + chart_width / 2.0,
        height - PADDING / 4.0,
        AXIS_FONT_SIZE,
    ));
    // Y axis label "Visibility" (rotated)
    let ry = PADDING + chart_height / 2.0;
    let rx = PADDING / 3.0;
    out.push_str(&templates::axis_label_y(rx, ry, AXIS_FONT_SIZE));
    out.push_str("</g>"); // wardley-axes

    // ── Evolution stages ──────────────────────────────────────────────────────
    let stages = &diag.evolution.stages;
    if !stages.is_empty() {
        out.push_str("<g class=\"wardley-stages\">");

        // Build stage positions as (start_0_100, end_0_100)
        let stage_positions: Vec<(f64, f64)> = stages
            .iter()
            .enumerate()
            .map(|(i, (_, start))| {
                let end = if i + 1 < stages.len() {
                    stages[i + 1].1
                } else {
                    100.0
                };
                (*start, end)
            })
            .collect();

        for (i, (stage_label, _)) in stages.iter().enumerate() {
            let (start_pct, end_pct) = stage_positions[i];
            let start_x = PADDING + start_pct / 100.0 * chart_width;
            let end_x = PADDING + end_pct / 100.0 * chart_width;
            let center_x = (start_x + end_x) / 2.0;

            // Stage boundary vertical dashed line (not for first stage)
            if i > 0 {
                out.push_str(&templates::stage_line(start_x, PADDING, height - PADDING));
            }

            // Stage label below x axis
            out.push_str(&templates::stage_label(
                center_x,
                height - PADDING / 1.5,
                STAGE_FONT_SIZE,
                &esc(stage_label),
            ));
        }

        out.push_str("</g>"); // wardley-stages
    }

    // ── Compute node positions ────────────────────────────────────────────────
    // positions[i] = (svgX, svgY) for diag.nodes[i]
    let positions: Vec<(f64, f64)> = diag
        .nodes
        .iter()
        .map(|n| {
            (
                project_x(n.evolution, PADDING, chart_width),
                project_y(n.visibility, height, PADDING, chart_height),
            )
        })
        .collect();

    let find_pos = |id: &str| -> Option<(f64, f64)> {
        diag.nodes
            .iter()
            .enumerate()
            .find(|(_, n)| n.id == id || n.label == id)
            .map(|(i, _)| positions[i])
    };

    // ── Links ─────────────────────────────────────────────────────────────────
    out.push_str("<g class=\"wardley-links\">");
    for link in &diag.links {
        let src_pos = find_pos(&link.from);
        let tgt_pos = find_pos(&link.to);
        if let (Some((x1, y1)), Some((x2, y2))) = (src_pos, tgt_pos) {
            let (sx1, sy1, sx2, sy2) = shorten_line(x1, y1, x2, y2, NODE_RADIUS, NODE_RADIUS);
            let dash_attr = if link.dashed {
                " stroke-dasharray=\"6 6\"".to_string()
            } else {
                String::new()
            };

            out.push_str(&templates::link_line(
                &fmt_f(sx1),
                &fmt_f(sy1),
                &fmt_f(sx2),
                &fmt_f(sy2),
                &dash_attr,
            ));

            // Link label
            if let Some(ref label) = link.label {
                let mx = (x1 + x2) / 2.0;
                let my = (y1 + y2) / 2.0;
                out.push_str(&templates::link_label(
                    &fmt_f(mx),
                    &fmt_f(my - 4.0),
                    LABEL_FONT_SIZE,
                    &esc(label),
                ));
            }
        }
    }
    out.push_str("</g>"); // wardley-links

    // ── Trends (evolve arrows) ────────────────────────────────────────────────
    out.push_str("<g class=\"wardley-trends\">");
    for trend in &diag.trends {
        if let Some((ox, oy)) = find_pos(&trend.node_id) {
            let target_x = project_x(trend.target_x, PADDING, chart_width);
            let target_y = project_y(trend.target_y, height, PADDING, chart_height);
            let dx = target_x - ox;
            let dy = target_y - oy;
            let dist = (dx * dx + dy * dy).sqrt();
            let shorten_by = NODE_RADIUS + 2.0;
            let (ax2, ay2) = if dist > shorten_by {
                (
                    target_x - dx / dist * shorten_by,
                    target_y - dy / dist * shorten_by,
                )
            } else {
                (target_x, target_y)
            };
            out.push_str(&templates::trend_arrow(
                &fmt_f(ox),
                &fmt_f(oy),
                &fmt_f(ax2),
                &fmt_f(ay2),
                svg_id,
            ));
        }
    }
    out.push_str("</g>"); // wardley-trends

    // ── Nodes ─────────────────────────────────────────────────────────────────
    out.push_str("<g class=\"wardley-nodes\">");
    for (i, node) in diag.nodes.iter().enumerate() {
        let (cx, cy) = positions[i];
        render_node(&mut out, node, cx, cy);
    }
    out.push_str("</g>"); // wardley-nodes

    // ── Annotations ───────────────────────────────────────────────────────────
    if !diag.annotations.is_empty() {
        out.push_str("<g class=\"wardley-annotations\">");
        for ann in &diag.annotations {
            let ax = project_x(ann.evolution, PADDING, chart_width);
            let ay = project_y(ann.visibility, height, PADDING, chart_height);
            out.push_str(&templates::annotation(&fmt_f(ax), &fmt_f(ay), ann.number));
        }
        out.push_str("</g>"); // wardley-annotations
    }

    out.push_str("</g>"); // wardley-map
    out.push_str("</svg>");
    out
}

/// Map evolution percentage (0-100) to SVG x coordinate.
fn project_x(value: f64, padding: f64, chart_width: f64) -> f64 {
    padding + value / 100.0 * chart_width
}

/// Map visibility percentage (0-100) to SVG y coordinate.
/// High visibility → top of chart (small y); low visibility → bottom (large y).
fn project_y(value: f64, height: f64, padding: f64, chart_height: f64) -> f64 {
    height - padding - value / 100.0 * chart_height
}

fn render_node(out: &mut String, node: &WardleyNode, cx: f64, cy: f64) {
    let class_suffix = match node.kind {
        WardleyNodeKind::Anchor => "anchor",
        WardleyNodeKind::Note => "note",
        WardleyNodeKind::Component => "component",
    };
    out.push_str(&templates::node_group_open(class_suffix));

    match node.kind {
        WardleyNodeKind::Anchor => {
            // Anchors: text only, bold, centered
            let lx = node.label_offset_x.map(|dx| cx + dx).unwrap_or(cx);
            let ly = node.label_offset_y.map(|dy| cy + dy).unwrap_or(cy - 3.0);
            out.push_str(&templates::anchor_label(
                &fmt_f(lx),
                &fmt_f(ly),
                LABEL_FONT_SIZE,
                &esc(&node.label),
            ));
        }
        WardleyNodeKind::Note => {
            // Notes: text only
            out.push_str(&templates::note_text(
                &fmt_f(cx),
                &fmt_f(cy),
                &esc(&node.label),
            ));
        }
        WardleyNodeKind::Component => {
            // Sourcing overlays
            match node.sourcing {
                Sourcing::Outsource => {
                    out.push_str(&templates::sourcing_overlay_circle(
                        "wardley-outsource-overlay",
                        &fmt_f(cx),
                        &fmt_f(cy),
                        NODE_RADIUS * 2.0,
                        "#666",
                        "#333333",
                    ));
                }
                Sourcing::Buy => {
                    out.push_str(&templates::sourcing_overlay_circle(
                        "wardley-buy-overlay",
                        &fmt_f(cx),
                        &fmt_f(cy),
                        NODE_RADIUS * 2.0,
                        "#ccc",
                        "#333333",
                    ));
                }
                Sourcing::Build => {
                    out.push_str(&templates::sourcing_overlay_circle(
                        "wardley-build-overlay",
                        &fmt_f(cx),
                        &fmt_f(cy),
                        NODE_RADIUS * 2.0,
                        "#eee",
                        "#000",
                    ));
                }
                _ => {}
            }

            // Component circle
            out.push_str(&templates::component_circle(
                &fmt_f(cx),
                &fmt_f(cy),
                NODE_RADIUS,
            ));

            // Inertia: vertical line to the right of the node
            if node.inertia {
                let line_x = cx + NODE_RADIUS + 15.0;
                let half_h = NODE_RADIUS;
                out.push_str(&templates::inertia_line(
                    &fmt_f(line_x),
                    &fmt_f(cy - half_h),
                    &fmt_f(cy + half_h),
                ));
            }

            // Component label
            let lx = cx + node.label_offset_x.unwrap_or(NODE_LABEL_OFFSET);
            let ly = cy + node.label_offset_y.unwrap_or(-NODE_LABEL_OFFSET);
            out.push_str(&templates::component_label(
                &fmt_f(lx),
                &fmt_f(ly),
                LABEL_FONT_SIZE,
                &esc(&node.label),
            ));
        }
    }

    out.push_str("</g>");
}

/// Shorten a line segment: start end moves `r1` toward target, end moves `r2` toward source.
fn shorten_line(x1: f64, y1: f64, x2: f64, y2: f64, r1: f64, r2: f64) -> (f64, f64, f64, f64) {
    let dx = x2 - x1;
    let dy = y2 - y1;
    let len = (dx * dx + dy * dy).sqrt();
    if len < r1 + r2 + 1.0 {
        return (x1, y1, x2, y2);
    }
    let ux = dx / len;
    let uy = dy / len;
    (x1 + ux * r1, y1 + uy * r1, x2 - ux * r2, y2 - uy * r2)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::diagrams::wardley::parser;

    #[test]
    fn render_produces_svg() {
        let input = "wardley\n    title My Wardley Map\n    component UserNeed [0.9, 0.1]\n    component Backend [0.5, 0.7]\n    UserNeed->Backend\n";
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, Theme::Default);
        assert!(svg.contains("<svg"));
        assert!(svg.contains("wardley-node--component"));
        assert!(svg.contains("wardley-link"));
    }

    #[test]
    fn render_wardley_beta_corpus() {
        let input = "wardley-beta\ntitle Tea Shop\nanchor Business [0.95, 0.63]\nanchor Public [0.95, 0.78]\ncomponent Cup of Tea [0.79, 0.61] label [19, -4]\ncomponent Cup [0.73, 0.78]\ncomponent Tea [0.63, 0.81]\ncomponent Hot Water [0.52, 0.80]\ncomponent Water [0.38, 0.82]\ncomponent Kettle [0.43, 0.35] label [-57, 4]\ncomponent Power [0.1, 0.7] label [-27, 20]\nBusiness -> Cup of Tea\nPublic -> Cup of Tea\nCup of Tea -> Cup\nCup of Tea -> Tea\nCup of Tea -> Hot Water\nHot Water -> Water\nHot Water -> Kettle\nKettle -> Power\nevolve Kettle 0.62\nevolve Power 0.89\n";
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, Theme::Default);
        assert!(
            svg.contains("viewBox=\"0 0 900 600\""),
            "expected 900x600 viewBox, got: {}",
            &svg[..200]
        );
        assert!(svg.contains("Tea Shop"));
        assert!(svg.contains("wardley-trend"));
    }

    #[test]
    fn snapshot_default_theme() {
        let input = "wardley\n    title My Wardley Map\n    component UserNeed [0.9, 0.1]\n    component Backend [0.5, 0.7]\n    UserNeed->Backend\n";
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, crate::theme::Theme::Default);
        insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
    }
}