ariel-rs 0.2.0

A faithful Rust port of Mermaid JS — headless SVG diagram rendering without a browser
Documentation
use super::constants::*;
use super::parser::PieDiagram;
use super::templates::{self, build_style, esc, fmt, fmt_value};
use crate::text::measure;
use crate::theme::Theme;
/// Faithful Rust port of Mermaid's pieRenderer.ts.
///
/// Key algorithm details:
/// - D3 pie layout: arcs are sorted by insertion order (sort: null), starting at -π/2 (top)
/// - Arc path uses SVG arc command with large-arc-flag based on sweep > π
/// - Label position at textPosition=0.75 of radius (centroid of arc at that radius)
/// - Legend: 12*LEGEND_RECT_SIZE horizontal offset, vertically centered
/// - viewBox width = max(chartAndLegendWidth, titleRight) where title is centered at pieWidth/2
///
/// Colors from Mermaid default theme (khroma adjust on #ECECFF primary):
///   pie1  = #ECECFF       (primaryColor, HSL 240,100%,96.27%)
///   pie2  = #ffffde       (secondaryColor, HSL 60,100%,93.53%)
///   pie3  = hsl(80 ,100%,56.27%)   tertiaryColor(h-160)+l-40
///   pie4  = hsl(240,100%,86.27%)   primary+l-10
///   pie5  = hsl(60 ,100%,63.53%)   secondary+l-30
///   pie6  = hsl(80 ,100%,76.27%)   tertiary+l-20
///   pie7  = hsl(300,100%,76.27%)   primary+h+60+l-20
///   pie8  = hsl(180,100%,56.27%)   primary+h-60+l-40
///   pie9  = hsl(0  ,100%,56.27%)   primary+h+120+l-40
///   pie10 = hsl(300,100%,56.27%)   primary+h+60+l-40
///   pie11 = hsl(150,100%,56.27%)   primary+h-90+l-40
///   pie12 = hsl(0  ,100%,66.27%)   primary+h+120+l-30
use std::f64::consts::PI;

/// One arc segment ready for rendering.
struct ArcDatum {
    label: String,
    value: f64,
    start_angle: f64, // radians, measured from top (−π/2)
    end_angle: f64,
}

/// Build D3-style pie arcs from the section map.
/// D3 pie with sort=null starts at top (−π/2) and proceeds clockwise.
fn create_pie_arcs(sections: &indexmap::IndexMap<String, f64>) -> Vec<ArcDatum> {
    let sum: f64 = sections.values().sum();
    if sum == 0.0 {
        return Vec::new();
    }

    // Filter out slices that would be < 1% (same as Mermaid's createPieArcs)
    let filtered: Vec<(&String, &f64)> = sections
        .iter()
        .filter(|(_, v)| ((*v) / sum) * 100.0 >= 1.0)
        .collect();

    let filtered_sum: f64 = filtered.iter().map(|(_, v)| **v).sum();

    let mut arcs = Vec::new();
    let mut current_angle = 0.0_f64; // D3 pie startAngle=0 = top of circle

    for (label, value) in &filtered {
        let fraction = **value / filtered_sum;
        let sweep = fraction * 2.0 * PI;
        let start = current_angle;
        let end = current_angle + sweep;
        arcs.push(ArcDatum {
            label: (*label).clone(),
            value: **value,
            start_angle: start,
            end_angle: end,
        });
        current_angle = end;
    }

    arcs
}

/// Compute the SVG arc path for a pie slice (innerRadius=0, outerRadius=radius).
/// Matches D3's arc generator exactly.
fn arc_path(start_angle: f64, end_angle: f64, radius: f64) -> String {
    let x0 = start_angle.sin() * radius;
    let y0 = -start_angle.cos() * radius; // SVG y-axis is flipped vs math
    let x1 = end_angle.sin() * radius;
    let y1 = -end_angle.cos() * radius;

    let sweep = end_angle - start_angle;
    let large_arc = if sweep > PI { 1 } else { 0 };

    // D3 arc: M start_x,start_y  A r,r,0,large,1,end_x,end_y  L 0,0  Z
    format!(
        "M{},{:.3}A{},{},0,{},1,{},{:.3}L0,0Z",
        fmt(x0),
        y0,
        fmt(radius),
        fmt(radius),
        large_arc,
        fmt(x1),
        y1,
    )
}

/// Compute the centroid point of an arc at a given radius (for label placement).
/// D3: centroid = midAngle, at given radius.
fn arc_centroid(start_angle: f64, end_angle: f64, radius: f64) -> (f64, f64) {
    let mid = (start_angle + end_angle) / 2.0;
    let x = mid.sin() * radius;
    let y = -mid.cos() * radius;
    (x, y)
}

/// Pick a color for a slice by index (wraps around after 12).
fn slice_color(index: usize) -> &'static str {
    PIE_COLORS[index % PIE_COLORS.len()]
}

pub fn render(diag: &PieDiagram, theme: Theme, _use_foreign_object: bool) -> String {
    let vars = theme.resolve();
    let ff = vars.font_family;
    let radius = (PIE_WIDTH.min(HEIGHT) / 2.0) - MARGIN; // = 185.0
    let label_radius = radius * TEXT_POSITION; // = 138.75

    let arcs = create_pie_arcs(&diag.sections);

    // Sum of all sections (for percentage display)
    let total_sum: f64 = diag.sections.values().sum();

    // Filter arcs that would display as "0%" (toFixed(0) == "0")
    let filtered_arcs: Vec<&ArcDatum> = arcs
        .iter()
        .filter(|a| (a.value / total_sum * 100.0).round() as u64 != 0)
        .collect();

    // ── Collect all section data for legend (all sections, not filtered) ──────
    let all_sections: Vec<(&String, &f64)> = diag.sections.iter().collect();

    // ── Measure legend text widths ─────────────────────────────────────────────
    let legend_text_width = all_sections
        .iter()
        .map(|(label, value)| {
            let text = if diag.show_data {
                format!("{} [{}]", label, fmt_value(**value))
            } else {
                (*label).clone()
            };
            measure(&text, LEGEND_FONT_SIZE).0
        })
        .fold(0.0_f64, f64::max)
        * LEGEND_TEXT_SCALE;

    // ── Compute viewBox ────────────────────────────────────────────────────────
    let chart_and_legend_width =
        PIE_WIDTH + MARGIN + LEGEND_RECT_SIZE + LEGEND_SPACING + legend_text_width;

    // Title width (centered at pieWidth/2)
    let title_text_str = diag.title.as_deref().unwrap_or("");
    let title_width = if title_text_str.is_empty() {
        0.0
    } else {
        measure(title_text_str, TITLE_FONT_SIZE).0
    };
    let title_left = PIE_WIDTH / 2.0 - title_width / 2.0;
    let title_right = PIE_WIDTH / 2.0 + title_width / 2.0;

    let view_box_x = 0.0_f64.min(title_left);
    let view_box_right = chart_and_legend_width.max(title_right);
    let total_width = view_box_right - view_box_x;

    // ── Generate SVG ──────────────────────────────────────────────────────────
    let id = "mermaid-pie";
    let style = build_style(id, ff);

    // The main group is translated to the center of the pie area
    let group_tx = PIE_WIDTH / 2.0; // 225.0
    let group_ty = HEIGHT / 2.0; // 225.0

    let mut svg_parts: Vec<String> = Vec::new();

    // SVG root
    svg_parts.push(templates::svg_root(
        id,
        &fmt(view_box_x),
        &fmt(total_width),
        &fmt(HEIGHT),
        &fmt(total_width),
    ));
    svg_parts.push(format!("<style>{}</style>", style));

    // Empty first group (Mermaid always emits this)
    svg_parts.push("<g></g>".to_string());

    // Main group translated to pie center
    svg_parts.push(templates::main_group(&fmt(group_tx), &fmt(group_ty)));

    // Outer circle
    svg_parts.push(templates::outer_circle(&fmt(
        radius + OUTER_STROKE_WIDTH / 2.0
    )));

    // Pie slices (paths)
    for (i, arc) in filtered_arcs.iter().enumerate() {
        // Find original index in all_sections for color assignment
        let color_idx = diag.sections.get_index_of(&arc.label).unwrap_or(i);
        let color = slice_color(color_idx);
        let d = arc_path(arc.start_angle, arc.end_angle, radius);
        svg_parts.push(templates::pie_slice(&d, color));
    }

    // Percentage labels
    for arc in &filtered_arcs {
        let pct = (arc.value / total_sum * 100.0).round() as u64;
        let (cx, cy) = arc_centroid(arc.start_angle, arc.end_angle, label_radius);
        svg_parts.push(templates::slice_label(&fmt(cx), &fmt(cy), pct));
    }

    // Title text
    let title_y = -((HEIGHT - 50.0) / 2.0); // -(200.0)
    svg_parts.push(templates::title_text(&fmt(title_y), &esc(title_text_str)));

    // Legend items
    let legend_height = LEGEND_RECT_SIZE + LEGEND_SPACING;
    let legend_offset = (legend_height * all_sections.len() as f64) / 2.0;

    for (i, (label, value)) in all_sections.iter().enumerate() {
        // Use insertion-order index for color
        let color = slice_color(i);
        let vertical = (i as f64) * legend_height - legend_offset;
        let legend_text = if diag.show_data {
            format!("{} [{}]", label, fmt_value(**value))
        } else {
            (*label).to_string()
        };

        // Colors are pre-computed as rgb(...) strings in constants — use directly.
        svg_parts.push(templates::legend_item(
            &fmt(LEGEND_HORIZONTAL_OFFSET),
            &fmt(vertical),
            color,
            &esc(&legend_text),
        ));
    }

    // Close main group and SVG
    svg_parts.push("</g>".to_string());
    svg_parts.push("</svg>".to_string());

    svg_parts.join("")
}

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

    #[test]
    fn basic_render_produces_svg() {
        let input = "pie\n    \"Dogs\" : 386\n    \"Cats\" : 85\n    \"Rats\" : 15";
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, Theme::Default, false);
        assert!(svg.contains("<svg"));
        assert!(svg.contains("pieCircle"));
        assert!(svg.contains("pieOuterCircle"));
        assert!(svg.contains("79%"));
        assert!(svg.contains("Dogs"));
    }

    #[test]
    fn arc_path_full_circle_large_arc() {
        // A slice > 180° must have large-arc-flag=1
        // Arc format: A rx,ry,x-rot,large-arc,sweep,x,y — so "0,1,1," = no-rotation,large,sweep
        let path = arc_path(-PI / 2.0, PI / 2.0 * 3.0, 185.0);
        assert!(
            path.contains(",0,1,1,"),
            "Expected large-arc-flag=1 in: {}",
            path
        );
    }

    #[test]
    fn fmt_value_integer() {
        assert_eq!(fmt_value(386.0), "386");
        assert_eq!(fmt_value(42.96), "42.96");
    }

    #[test]
    fn pie_color_0_is_valid() {
        // PIE_COLORS[0] is the primary color (#ECECFF)
        assert!(
            PIE_COLORS[0].starts_with('#')
                || PIE_COLORS[0].starts_with("hsl(")
                || PIE_COLORS[0].starts_with("rgb("),
            "Expected a valid color string, got: {}",
            PIE_COLORS[0]
        );
    }

    #[test]
    fn pie_color_1_is_valid() {
        assert!(
            PIE_COLORS[1].starts_with('#')
                || PIE_COLORS[1].starts_with("hsl(")
                || PIE_COLORS[1].starts_with("rgb("),
            "Expected a valid color string, got: {}",
            PIE_COLORS[1]
        );
    }

    #[test]
    fn snapshot_default_theme() {
        let input = "pie title Pets\n    \"Dogs\" : 386\n    \"Cats\" : 85\n    \"Rats\" : 15";
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, crate::theme::Theme::Default, false);
        insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
    }
}