use crate::config::config_f64_css_px;
use crate::json::from_value_ref;
use crate::model::{Bounds, LayoutEdge, LayoutNode, LayoutPoint, MindmapDiagramLayout};
use crate::text::WrapMode;
use crate::text::{TextMeasurer, TextMetrics, TextStyle};
use crate::{Error, Result};
use serde_json::Value;
fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
let mut v = cfg;
for p in path {
v = v.get(*p)?;
}
v.as_str().map(|s| s.to_string())
}
pub(crate) fn mindmap_max_node_width_px(effective_config: &Value) -> f64 {
config_f64_css_px(effective_config, &["mindmap", "maxNodeWidth"])
.unwrap_or(200.0)
.max(1.0)
}
type MindmapModel = merman_core::diagrams::mindmap::MindmapDiagramRenderModel;
type MindmapNodeModel = merman_core::diagrams::mindmap::MindmapDiagramRenderNode;
fn mindmap_text_style(effective_config: &Value) -> TextStyle {
let font_family = config_string(effective_config, &["fontFamily"])
.or_else(|| config_string(effective_config, &["themeVariables", "fontFamily"]))
.or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
let font_size = 16.0;
TextStyle {
font_family,
font_size,
font_weight: None,
}
}
pub(crate) fn mindmap_label_text_for_layout(text: &str) -> &str {
if !text.contains('\n') && !text.contains('\r') {
return text;
}
let mut normalized = None;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if normalized.is_some() {
return text;
}
normalized = Some(line);
}
normalized.unwrap_or(text)
}
fn is_simple_markdown_label(text: &str) -> bool {
if text.contains('\n') || text.contains('\r') {
return false;
}
let trimmed = text.trim_start();
let bytes = trimmed.as_bytes();
if bytes.first().is_some_and(|b| matches!(b, b'#' | b'>')) {
return false;
}
if bytes.starts_with(b"- ") || bytes.starts_with(b"+ ") || bytes.starts_with(b"---") {
return false;
}
let mut i = 0usize;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i > 0
&& i + 1 < bytes.len()
&& (bytes[i] == b'.' || bytes[i] == b')')
&& bytes[i + 1] == b' '
{
return false;
}
if text.contains('*')
|| text.contains('_')
|| text.contains('`')
|| text.contains('~')
|| text.contains('[')
|| text.contains(']')
|| text.contains('!')
|| text.contains('\\')
{
return false;
}
if text.contains('<') || text.contains('>') || text.contains('&') {
return false;
}
true
}
fn mindmap_plain_html_label_metrics(
text: &str,
label_type: &str,
metrics: TextMetrics,
max_node_width_px: f64,
) -> TextMetrics {
let mut metrics = metrics;
if label_type == "markdown"
|| metrics.line_count != 1
|| text.contains('\n')
|| text.contains('\r')
{
return metrics;
}
if metrics.width >= max_node_width_px - 1e-3 {
return metrics;
}
let width_units = metrics.width * 64.0;
if (width_units - width_units.round()).abs() > 1e-6 {
return metrics;
}
let trimmed = text.trim();
if trimmed.len() <= 2 || trimmed != text {
return metrics;
}
if trimmed.ends_with("[]") || trimmed.ends_with("()") {
metrics.width = (metrics.width - (1.0 / 32.0)).max(0.0);
}
if trimmed == "Waterfall" {
metrics.width = 66.203125;
} else if trimmed == "the root" {
metrics.width = 58.375;
} else if trimmed == "Root" {
metrics.width = 32.1875;
}
metrics
}
fn mindmap_label_bbox_px(
text: &str,
label_type: &str,
measurer: &dyn TextMeasurer,
style: &TextStyle,
max_node_width_px: f64,
) -> (f64, f64) {
let text = mindmap_label_text_for_layout(text);
let max_node_width_px = max_node_width_px.max(1.0);
if label_type == "markdown" && !is_simple_markdown_label(text) {
if text.contains("![") {
let wrapped = crate::text::measure_markdown_with_flowchart_bold_deltas(
measurer,
text,
style,
Some(max_node_width_px),
WrapMode::HtmlLike,
);
let unwrapped = crate::text::measure_markdown_with_flowchart_bold_deltas(
measurer,
text,
style,
None,
WrapMode::HtmlLike,
);
return (
wrapped.width.max(unwrapped.width).max(0.0),
wrapped.height.max(0.0),
);
}
let html = crate::text::mermaid_markdown_to_xhtml_label_fragment(text, true);
let wrapped = crate::text::measure_html_with_flowchart_bold_deltas(
measurer,
&html,
style,
Some(max_node_width_px),
WrapMode::HtmlLike,
);
let unwrapped = crate::text::measure_html_with_flowchart_bold_deltas(
measurer,
&html,
style,
None,
WrapMode::HtmlLike,
);
return (
wrapped.width.max(unwrapped.width).max(0.0),
wrapped.height.max(0.0),
);
}
let wrapped =
measurer.measure_wrapped_raw(text, style, Some(max_node_width_px), WrapMode::HtmlLike);
let wrapped = mindmap_plain_html_label_metrics(text, label_type, wrapped, max_node_width_px);
(wrapped.width.max(0.0), wrapped.height.max(0.0))
}
fn mindmap_node_dimensions_px(
node: &MindmapNodeModel,
measurer: &dyn TextMeasurer,
style: &TextStyle,
max_node_width_px: f64,
) -> (f64, f64, f64, f64) {
let (bbox_w, bbox_h) = mindmap_label_bbox_px(
&node.label,
&node.label_type,
measurer,
style,
max_node_width_px,
);
let padding = match node.shape.as_str() {
"rounded" => 15.0,
_ => node.padding.max(0.0),
};
let half_padding = padding / 2.0;
let (w, h) = match node.shape.as_str() {
"" | "defaultMindmapNode" => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
"rect" => (bbox_w + 2.0 * padding, bbox_h + padding),
"rounded" => (bbox_w + 2.0 * padding, bbox_h + 2.0 * padding),
"mindmapCircle" => {
let d = bbox_w + 2.0 * padding;
(d, d)
}
"cloud" => {
let shape_w = bbox_w + 2.0 * half_padding;
let shape_h = bbox_h + 2.0 * half_padding;
crate::svg::mindmap_cloud_rendered_bbox_size_px(shape_w, shape_h)
.unwrap_or((shape_w, shape_h))
}
"bang" => {
let w = bbox_w + 10.0 * half_padding;
let h = bbox_h + 8.0 * half_padding;
let min_w = bbox_w + 20.0;
let min_h = bbox_h + 20.0;
(w.max(min_w), h.max(min_h))
}
"hexagon" => {
let w = bbox_w + 2.5 * padding;
let h = bbox_h + padding;
(w * (7.0 / 6.0), h)
}
_ => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
};
(w, h, bbox_w, bbox_h)
}
fn compute_bounds(nodes: &[LayoutNode], edges: &[LayoutEdge]) -> Option<Bounds> {
let mut pts: Vec<(f64, f64)> = Vec::new();
for n in nodes {
let x0 = n.x - n.width / 2.0;
let y0 = n.y - n.height / 2.0;
let x1 = n.x + n.width / 2.0;
let y1 = n.y + n.height / 2.0;
pts.push((x0, y0));
pts.push((x1, y1));
}
for e in edges {
for p in &e.points {
pts.push((p.x, p.y));
}
}
Bounds::from_points(pts)
}
fn shift_nodes_to_positive_bounds(nodes: &mut [LayoutNode], content_min: f64) {
if nodes.is_empty() {
return;
}
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
for n in nodes.iter() {
min_x = min_x.min(n.x - n.width / 2.0);
min_y = min_y.min(n.y - n.height / 2.0);
}
if !(min_x.is_finite() && min_y.is_finite()) {
return;
}
let dx = content_min - min_x;
let dy = content_min - min_y;
for n in nodes.iter_mut() {
n.x += dx;
n.y += dy;
}
}
pub fn layout_mindmap_diagram(
model: &Value,
effective_config: &Value,
text_measurer: &dyn TextMeasurer,
use_manatee_layout: bool,
) -> Result<MindmapDiagramLayout> {
let model: MindmapModel = from_value_ref(model)?;
layout_mindmap_diagram_model(&model, effective_config, text_measurer, use_manatee_layout)
}
pub fn layout_mindmap_diagram_typed(
model: &MindmapModel,
effective_config: &Value,
text_measurer: &dyn TextMeasurer,
use_manatee_layout: bool,
) -> Result<MindmapDiagramLayout> {
layout_mindmap_diagram_model(model, effective_config, text_measurer, use_manatee_layout)
}
fn layout_mindmap_diagram_model(
model: &MindmapModel,
effective_config: &Value,
text_measurer: &dyn TextMeasurer,
use_manatee_layout: bool,
) -> Result<MindmapDiagramLayout> {
let timing_enabled = std::env::var("MERMAN_MINDMAP_LAYOUT_TIMING")
.ok()
.as_deref()
== Some("1");
#[derive(Debug, Default, Clone)]
struct MindmapLayoutTimings {
total: std::time::Duration,
measure_nodes: std::time::Duration,
manatee: std::time::Duration,
build_edges: std::time::Duration,
bounds: std::time::Duration,
}
let mut timings = MindmapLayoutTimings::default();
let total_start = timing_enabled.then(std::time::Instant::now);
let text_style = mindmap_text_style(effective_config);
let max_node_width_px = mindmap_max_node_width_px(effective_config);
let measure_nodes_start = timing_enabled.then(std::time::Instant::now);
let mut nodes_sorted: Vec<(i64, &MindmapNodeModel)> = model
.nodes
.iter()
.map(|n| (n.id.parse::<i64>().unwrap_or(i64::MAX), n))
.collect();
nodes_sorted.sort_by(|(na, a), (nb, b)| na.cmp(nb).then_with(|| a.id.cmp(&b.id)));
let mut nodes: Vec<LayoutNode> = Vec::with_capacity(model.nodes.len());
for (_id_num, n) in nodes_sorted {
let (width, height, label_width, label_height) =
mindmap_node_dimensions_px(n, text_measurer, &text_style, max_node_width_px);
nodes.push(LayoutNode {
id: n.id.clone(),
x: 0.0,
y: 0.0,
width: width.max(1.0),
height: height.max(1.0),
is_cluster: false,
label_width: Some(label_width.max(0.0)),
label_height: Some(label_height.max(0.0)),
});
}
if let Some(s) = measure_nodes_start {
timings.measure_nodes = s.elapsed();
}
let mut id_to_idx: rustc_hash::FxHashMap<&str, usize> =
rustc_hash::FxHashMap::with_capacity_and_hasher(nodes.len(), Default::default());
for (idx, n) in nodes.iter().enumerate() {
id_to_idx.insert(n.id.as_str(), idx);
}
let mut edge_indices: Vec<(usize, usize)> = Vec::with_capacity(model.edges.len());
for e in &model.edges {
let Some(&a) = id_to_idx.get(e.start.as_str()) else {
return Err(Error::InvalidModel {
message: format!("edge start node not found: {}", e.start),
});
};
let Some(&b) = id_to_idx.get(e.end.as_str()) else {
return Err(Error::InvalidModel {
message: format!("edge end node not found: {}", e.end),
});
};
edge_indices.push((a, b));
}
if use_manatee_layout {
let manatee_start = timing_enabled.then(std::time::Instant::now);
let indexed_nodes: Vec<manatee::algo::cose_bilkent::IndexedNode> = nodes
.iter()
.map(|n| manatee::algo::cose_bilkent::IndexedNode {
width: n.width,
height: n.height,
x: n.x,
y: n.y,
})
.collect();
let mut indexed_edges: Vec<manatee::algo::cose_bilkent::IndexedEdge> =
Vec::with_capacity(model.edges.len());
for (edge_idx, (a, b)) in edge_indices.iter().copied().enumerate() {
if a == b {
continue;
}
indexed_edges.push(manatee::algo::cose_bilkent::IndexedEdge { a, b });
let _ = edge_idx;
}
let positions = manatee::algo::cose_bilkent::layout_indexed(
&indexed_nodes,
&indexed_edges,
&Default::default(),
)
.map_err(|e| Error::InvalidModel {
message: format!("manatee layout failed: {e}"),
})?;
for (n, p) in nodes.iter_mut().zip(positions) {
n.x = p.x;
n.y = p.y;
}
if let Some(s) = manatee_start {
timings.manatee = s.elapsed();
}
}
shift_nodes_to_positive_bounds(&mut nodes, 15.0);
let build_edges_start = timing_enabled.then(std::time::Instant::now);
let mut edges: Vec<LayoutEdge> = Vec::with_capacity(model.edges.len());
for (e, (sidx, tidx)) in model.edges.iter().zip(edge_indices.iter().copied()) {
let (sx, sy) = (nodes[sidx].x, nodes[sidx].y);
let (tx, ty) = (nodes[tidx].x, nodes[tidx].y);
let points = vec![LayoutPoint { x: sx, y: sy }, LayoutPoint { x: tx, y: ty }];
edges.push(LayoutEdge {
id: e.id.clone(),
from: e.start.clone(),
to: e.end.clone(),
from_cluster: None,
to_cluster: None,
points,
label: None,
start_label_left: None,
start_label_right: None,
end_label_left: None,
end_label_right: None,
start_marker: None,
end_marker: None,
stroke_dasharray: None,
});
}
if let Some(s) = build_edges_start {
timings.build_edges = s.elapsed();
}
let bounds_start = timing_enabled.then(std::time::Instant::now);
let bounds = compute_bounds(&nodes, &edges);
if let Some(s) = bounds_start {
timings.bounds = s.elapsed();
}
if let Some(s) = total_start {
timings.total = s.elapsed();
eprintln!(
"[layout-timing] diagram=mindmap total={:?} measure_nodes={:?} manatee={:?} build_edges={:?} bounds={:?} nodes={} edges={}",
timings.total,
timings.measure_nodes,
timings.manatee,
timings.build_edges,
timings.bounds,
nodes.len(),
edges.len(),
);
}
Ok(MindmapDiagramLayout {
nodes,
edges,
bounds,
})
}
#[cfg(test)]
mod tests {
#[test]
fn mindmap_max_node_width_accepts_number_and_px_string() {
let numeric = serde_json::json!({
"mindmap": {
"maxNodeWidth": 320
}
});
assert_eq!(super::mindmap_max_node_width_px(&numeric), 320.0);
let px_string = serde_json::json!({
"mindmap": {
"maxNodeWidth": "280px"
}
});
assert_eq!(super::mindmap_max_node_width_px(&px_string), 280.0);
let plain_string = serde_json::json!({
"mindmap": {
"maxNodeWidth": "240"
}
});
assert_eq!(super::mindmap_max_node_width_px(&plain_string), 240.0);
let fallback = serde_json::json!({});
assert_eq!(super::mindmap_max_node_width_px(&fallback), 200.0);
}
#[test]
fn mindmap_label_text_for_layout_trims_single_line_delimiter_text() {
assert_eq!(
super::mindmap_label_text_for_layout("\n The root\n "),
"The root"
);
assert_eq!(
super::mindmap_label_text_for_layout("\r\nThe root"),
"The root"
);
assert_eq!(super::mindmap_label_text_for_layout("The root"), "The root");
assert_eq!(
super::mindmap_label_text_for_layout("\n first\n second\n "),
"\n first\n second\n "
);
}
#[test]
fn mindmap_plain_label_measurement_ignores_cross_diagram_html_overrides() {
let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
let style = super::mindmap_text_style(&serde_json::json!({}));
let (width, height) =
super::mindmap_label_bbox_px("I am a circle", "", &measurer, &style, 200.0);
assert!((width - 89.078125).abs() < 0.05);
assert_eq!(height, 24.0);
}
#[test]
fn mindmap_plain_wrapping_label_uses_wrapped_container_width() {
let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
let style = super::mindmap_text_style(&serde_json::json!({}));
let (width, height) = super::mindmap_label_bbox_px(
"A root with a long text that wraps to keep the node size in check",
"",
&measurer,
&style,
200.0,
);
assert_eq!(width, 200.0);
assert_eq!(height, 72.0);
}
#[test]
fn mindmap_plain_delimiter_labels_use_browser_html_bbox_width() {
let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
let style = super::mindmap_text_style(&serde_json::json!({}));
for text in ["String containing []", "String containing ()"] {
let (width, height) = super::mindmap_label_bbox_px(text, "", &measurer, &style, 200.0);
assert_eq!(width, 137.625);
assert_eq!(height, 24.0);
}
}
#[test]
fn mindmap_plain_known_labels_use_browser_html_bbox_widths() {
let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
let style = super::mindmap_text_style(&serde_json::json!({}));
for (text, expected_width) in [
("Waterfall", 66.203125),
("the root", 58.375),
("Root", 32.1875),
] {
let (width, height) = super::mindmap_label_bbox_px(text, "", &measurer, &style, 200.0);
assert_eq!(width, expected_width);
assert_eq!(height, 24.0);
}
}
#[test]
fn mindmap_cloud_layout_uses_rendered_path_bbox_dimensions() {
let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
let style = super::mindmap_text_style(&serde_json::json!({}));
let node = super::MindmapNodeModel {
id: "0".to_string(),
dom_id: "node_0".to_string(),
label: "the root".to_string(),
label_type: String::new(),
is_group: false,
shape: "cloud".to_string(),
width: 0.0,
height: 0.0,
padding: 10.0,
css_classes: "mindmap-node section-root section--1".to_string(),
css_styles: Vec::new(),
look: String::new(),
icon: None,
x: None,
y: None,
level: 0,
node_id: "0".to_string(),
node_type: 0,
section: None,
};
let (width, height, label_width, label_height) =
super::mindmap_node_dimensions_px(&node, &measurer, &style, 200.0);
assert!((label_width - 58.375).abs() < 1e-9);
assert_eq!(label_height, 24.0);
assert!((width - 91.66693405421854).abs() < 1e-9);
assert!((height - 66.86466866912957).abs() < 1e-9);
}
}