merman-render 0.8.0-alpha.4

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use super::*;
use merman_core::diagrams::cynefin::CynefinDiagramRenderModel;

pub(crate) fn render_cynefin_diagram_svg_model(
    layout: &CynefinDiagramLayout,
    model: &CynefinDiagramRenderModel,
    effective_config: &serde_json::Value,
    diagram_title: Option<&str>,
    options: &SvgExecution<'_>,
) -> Result<root_svg::RootedSvg> {
    let diagram_id = options.diagram_id.as_deref().unwrap_or("cynefin");
    let diagram_id_esc = escape_xml(diagram_id);
    let acc_title = model.acc_title.as_deref().filter(|value| !value.is_empty());
    let acc_descr = model.acc_descr.as_deref().filter(|value| !value.is_empty());
    let aria_labelledby = acc_title.map(|_| format!("chart-title-{diagram_id}"));
    let aria_describedby = acc_descr.map(|_| format!("chart-desc-{diagram_id}"));
    let root_bounds =
        root_svg::DiagramBounds::from_view_box(0.0, 0.0, layout.total_width, layout.total_height);
    let root_spec = root_svg::RootViewportSpec::mermaid(root_bounds, layout.use_max_width);
    let theme = crate::cynefin::cynefin_theme(effective_config);
    let title = model
        .title
        .as_deref()
        .filter(|value| !value.is_empty())
        .or_else(|| diagram_title.filter(|value| !value.is_empty()));
    let seed = crate::cynefin::resolve_seed(layout.seed, diagram_id);
    let marker_id = format!("cynefin-arrow-{diagram_id}");

    let mut out = String::new();
    let mut root_chrome = root_svg::RootChrome::new(diagram_id, "cynefin");
    root_chrome.aria_labelledby = aria_labelledby.as_deref();
    root_chrome.aria_describedby = aria_describedby.as_deref();
    root_chrome.dom.trailing_newline = false;
    let root_document =
        root_svg::RootViewportContext::new(crate::family::RenderFamilyKind::Cynefin, diagram_id)
            .write_open(&mut out, root_spec, root_chrome)?;

    if let Some(title) = acc_title {
        let _ = write!(
            &mut out,
            r#"<title id="chart-title-{}">{}</title>"#,
            diagram_id_esc,
            escape_xml_display(title)
        );
    }
    if let Some(descr) = acc_descr {
        let _ = write!(
            &mut out,
            r#"<desc id="chart-desc-{}">{}</desc>"#,
            diagram_id_esc,
            escape_xml_display(descr)
        );
    }

    let _ = write!(
        &mut out,
        "<style>{}</style>",
        cynefin_css(diagram_id, effective_config, &theme)
    );
    out.push_str("<g/>");
    if let Some(title) = acc_title {
        let _ = write!(&mut out, "<title>{}</title>", escape_xml_display(title));
    }
    if let Some(descr) = acc_descr {
        let _ = write!(&mut out, "<desc>{}</desc>", escape_xml_display(descr));
    }

    let _ = write!(
        &mut out,
        r#"<g transform="translate({}, {})">"#,
        fmt(layout.padding),
        fmt(layout.padding)
    );
    push_backgrounds(&mut out, layout, &theme);
    push_boundaries(&mut out, layout, seed, &theme);
    push_labels(&mut out, layout);
    if layout.show_domain_descriptions {
        push_subtitles(&mut out, layout);
    }
    push_items(&mut out, layout, &theme);
    push_transitions(&mut out, layout, &marker_id);
    if let Some(title) = title {
        let _ = write!(
            &mut out,
            r#"<text class="cynefinTitle" x="{}" y="{}" text-anchor="middle" dominant-baseline="middle">{}</text>"#,
            fmt(layout.width / 2.0),
            fmt(-layout.padding / 2.0),
            escape_xml_display(title)
        );
    }
    out.push_str("</g>");

    if !layout.transitions.is_empty() {
        let _ = write!(
            &mut out,
            r#"<defs><marker id="{}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" class="cynefinArrowHead"></path></marker></defs>"#,
            escape_attr_display(&marker_id)
        );
    }
    out.push_str("</svg>\n");
    root_document.complete(out)
}

fn push_backgrounds(
    out: &mut String,
    layout: &CynefinDiagramLayout,
    theme: &crate::cynefin::CynefinTheme,
) {
    out.push_str(r#"<g class="cynefin-backgrounds">"#);
    for domain_name in crate::cynefin::quadrant_domains() {
        let Some(domain) = layout
            .domain_layouts
            .iter()
            .find(|item| item.name == *domain_name)
        else {
            continue;
        };
        let _ = write!(
            out,
            r#"<rect class="cynefinDomain" x="{}" y="{}" width="{}" height="{}" fill="{}" fill-opacity="0.4" stroke="none"></rect>"#,
            fmt(domain.x),
            fmt(domain.y),
            fmt(domain.width),
            fmt(domain.height),
            escape_attr_display(crate::cynefin::domain_fill(theme, domain_name))
        );
    }
    out.push_str("</g>");
}

fn push_boundaries(
    out: &mut String,
    layout: &CynefinDiagramLayout,
    seed: f64,
    theme: &crate::cynefin::CynefinTheme,
) {
    out.push_str(r#"<g class="cynefin-boundaries">"#);
    let fold_path = crate::cynefin::generate_fold_path(
        layout.width,
        layout.height,
        seed,
        Some(layout.boundary_amplitude),
    );
    let horizontal_path = crate::cynefin::generate_horizontal_boundary(
        layout.width,
        layout.height,
        seed + 100.0,
        Some(layout.boundary_amplitude),
    );
    let cliff_path = crate::cynefin::generate_cliff_path(layout.width, layout.height);
    let _ = write!(
        out,
        r#"<path class="cynefinBoundary" d="{}" fill="none"></path><path class="cynefinBoundary" d="{}" fill="none"></path><path class="cynefinCliff" d="{}" fill="none"></path>"#,
        escape_attr_display(&fold_path),
        escape_attr_display(&horizontal_path),
        escape_attr_display(&cliff_path)
    );
    out.push_str("</g>");

    let confusion_path = crate::cynefin::generate_confusion_path(
        layout.width / 2.0,
        layout.height / 2.0,
        layout.width * 0.15,
        layout.height * 0.15,
    );
    let _ = write!(
        out,
        r#"<path class="cynefinConfusion" d="{}" fill="{}" fill-opacity="0.5"></path>"#,
        escape_attr_display(&confusion_path),
        escape_attr_display(&theme.confusion_bg)
    );
}

fn push_labels(out: &mut String, layout: &CynefinDiagramLayout) {
    out.push_str(r#"<g class="cynefin-labels">"#);
    for domain_name in crate::cynefin::quadrant_domains() {
        let Some(domain) = layout
            .domain_layouts
            .iter()
            .find(|item| item.name == *domain_name)
        else {
            continue;
        };
        let y = if layout.show_domain_descriptions {
            domain.cy - 30.0
        } else {
            domain.cy
        };
        let _ = write!(
            out,
            r#"<text class="cynefinDomainLabel" x="{}" y="{}" text-anchor="middle" dominant-baseline="middle">{}</text>"#,
            fmt(domain.cx),
            fmt(y),
            escape_xml_display(crate::cynefin::domain_title(domain_name))
        );
    }
    let y = if layout.show_domain_descriptions {
        layout.height / 2.0 - 10.0
    } else {
        layout.height / 2.0
    };
    let _ = write!(
        out,
        r#"<text class="cynefinDomainLabel" x="{}" y="{}" text-anchor="middle" dominant-baseline="middle">Confusion</text>"#,
        fmt(layout.width / 2.0),
        fmt(y)
    );
    out.push_str("</g>");
}

fn push_subtitles(out: &mut String, layout: &CynefinDiagramLayout) {
    out.push_str(r#"<g class="cynefin-subtitles">"#);
    for domain_name in crate::cynefin::quadrant_domains() {
        let Some(domain) = layout
            .domain_layouts
            .iter()
            .find(|item| item.name == *domain_name)
        else {
            continue;
        };
        let (model, practice) = crate::cynefin::domain_model_and_practice(domain_name);
        let _ = write!(
            out,
            r#"<text class="cynefinSubtitle" x="{}" y="{}" text-anchor="middle" dominant-baseline="middle">{}</text><text class="cynefinSubtitle" x="{}" y="{}" text-anchor="middle" dominant-baseline="middle">{}</text>"#,
            fmt(domain.cx),
            fmt(domain.cy - 10.0),
            escape_xml_display(model),
            fmt(domain.cx),
            fmt(domain.cy + 5.0),
            escape_xml_display(practice)
        );
    }
    let _ = write!(
        out,
        r#"<text class="cynefinSubtitle" x="{}" y="{}" text-anchor="middle" dominant-baseline="middle">Disorder</text>"#,
        fmt(layout.width / 2.0),
        fmt(layout.height / 2.0 + 8.0)
    );
    out.push_str("</g>");
}

fn push_items(
    out: &mut String,
    layout: &CynefinDiagramLayout,
    theme: &crate::cynefin::CynefinTheme,
) {
    out.push_str(r#"<g class="cynefin-items">"#);
    for item in &layout.items {
        let fill = crate::cynefin::domain_fill(theme, &item.domain);
        let rect_class = if item.overflow {
            "cynefinItemOverflow"
        } else {
            "cynefinItem"
        };
        let _ = write!(
            out,
            r#"<g transform="translate({}, {})"><rect class="{}" x="0" y="0" width="{}" height="{}" rx="4" ry="4" fill="{}" fill-opacity="{}"></rect><text class="cynefinItemText" x="{}" y="{}" text-anchor="middle" dominant-baseline="central">{}</text></g>"#,
            fmt(item.x),
            fmt(item.y),
            rect_class,
            fmt(item.width),
            fmt(item.height),
            escape_attr_display(fill),
            if item.overflow { "0.6" } else { "0.95" },
            fmt(item.text_x),
            fmt(item.text_y),
            escape_xml_display(&item.label)
        );
    }
    out.push_str("</g>");
}

fn push_transitions(out: &mut String, layout: &CynefinDiagramLayout, marker_id: &str) {
    if layout.transitions.is_empty() {
        return;
    }
    out.push_str(r#"<g class="cynefin-arrows">"#);
    for transition in &layout.transitions {
        let d = format!(
            "M{},{} Q{},{} {},{}",
            fmt(transition.x1),
            fmt(transition.y1),
            fmt(transition.cpx),
            fmt(transition.cpy),
            fmt(transition.x2),
            fmt(transition.y2)
        );
        let _ = write!(
            out,
            r#"<path class="cynefinArrowLine" d="{}" fill="none" marker-end="url(#{})"></path>"#,
            escape_attr_display(&d),
            escape_attr_display(marker_id)
        );
        if let Some(label) = transition
            .label
            .as_deref()
            .filter(|value| !value.is_empty())
        {
            let _ = write!(
                out,
                r#"<text class="cynefinArrowLabel" x="{}" y="{}" text-anchor="middle" dominant-baseline="auto">{}</text>"#,
                fmt(transition.cpx),
                fmt(transition.cpy - 6.0),
                escape_xml_display(label)
            );
        }
    }
    out.push_str("</g>");
}

fn cynefin_css(
    diagram_id: &str,
    effective_config: &serde_json::Value,
    theme: &crate::cynefin::CynefinTheme,
) -> String {
    let id = escape_xml(diagram_id);
    let parts = info_css_parts_with_config(diagram_id, effective_config);
    let mut out = parts.css_prefix;
    let _ = write!(
        &mut out,
        "#{id} .cynefinDomain{{stroke:none;}}\
#{id} .cynefinDomainLabel{{font-size:{}px;font-weight:bold;fill:{};}}\
#{id} .cynefinSubtitle{{font-size:{}px;fill:{};font-style:italic;}}\
#{id} .cynefinItem{{fill-opacity:0.95;stroke:{};stroke-width:1;}}\
#{id} .cynefinItemText{{font-size:{}px;fill:{};}}\
#{id} .cynefinItemOverflow{{fill-opacity:0.6;stroke:{};stroke-width:1;stroke-dasharray:3 2;}}\
#{id} .cynefinBoundary{{stroke:{};stroke-width:{};stroke-dasharray:6 3;}}\
#{id} .cynefinCliff{{stroke:{};stroke-width:{};}}\
#{id} .cynefinConfusion{{stroke:{};stroke-width:1.5;stroke-dasharray:4 2;}}\
#{id} .cynefinArrowLine{{stroke:{};stroke-width:{};fill:none;}}\
#{id} .cynefinArrowHead{{fill:{};stroke:none;}}\
#{id} .cynefinArrowLabel{{font-size:{}px;fill:{};}}\
#{id} .cynefinTitle{{font-size:{}px;font-weight:bold;fill:{};}}",
        fmt(theme.domain_font_size),
        theme.label_color,
        fmt((theme.item_font_size - 1.0).max(1.0)),
        theme.text_color,
        theme.boundary_color,
        fmt(theme.item_font_size),
        theme.text_color,
        theme.boundary_color,
        theme.boundary_color,
        fmt(theme.boundary_width),
        theme.cliff_color,
        fmt(theme.cliff_width),
        theme.boundary_color,
        theme.arrow_color,
        fmt(theme.arrow_width),
        theme.arrow_color,
        fmt((theme.item_font_size - 1.0).max(1.0)),
        theme.text_color,
        fmt(theme.domain_font_size + 2.0),
        theme.label_color
    );
    out.push_str(&parts.root_rule);
    out
}