use super::super::*;
use merman_core::diagrams::xychart::XyChartDiagramRenderModel;
fn javascript_string_length(text: &str) -> f64 {
text.encode_utf16().count() as f64
}
fn font_size_after_unit_decrements(initial: f64, maximum_that_fits: f64) -> f64 {
if !(initial.is_finite() && initial > 0.0) || maximum_that_fits.is_nan() {
return 0.0;
}
if maximum_that_fits >= initial {
return initial;
}
if maximum_that_fits <= 0.0 {
return 0.0;
}
let decrements = (initial - maximum_that_fits).ceil();
let candidate = initial - decrements;
if candidate.is_finite() && candidate > 0.0 {
candidate
} else {
0.0
}
}
fn horizontal_label_font_size(item_width: f64, label: &str, initial: f64, inset_px: f64) -> f64 {
let denominator = javascript_string_length(label) * 0.7;
let maximum_that_fits = if denominator > 0.0 {
(item_width - inset_px) / denominator
} else {
f64::INFINITY
};
font_size_after_unit_decrements(initial, maximum_that_fits)
}
fn vertical_label_font_size(
item_width: f64,
item_height: f64,
label: &str,
initial: f64,
y_offset: f64,
) -> f64 {
let denominator = javascript_string_length(label) * 0.7;
let horizontal_maximum = if denominator > 0.0 {
item_width / denominator
} else {
f64::INFINITY
};
let maximum_that_fits = horizontal_maximum.min(item_height - y_offset);
font_size_after_unit_decrements(initial, maximum_that_fits)
}
pub(crate) fn render_xychart_diagram_svg(
layout: &XyChartDiagramLayout,
model: &XyChartDiagramRenderModel,
_effective_config: &serde_json::Value,
options: &SvgExecution<'_>,
) -> Result<root_svg::RootedSvg> {
use rustc_hash::FxHashMap;
use std::collections::hash_map::Entry;
struct Node {
tag: &'static str,
attrs: Vec<(&'static str, String)>,
text: Option<String>,
children: Vec<usize>,
}
impl Node {
fn attr(&mut self, name: &'static str, value: impl Into<String>) {
self.attrs.push((name, value.into()));
}
}
fn node(tag: &'static str) -> Node {
Node {
tag,
attrs: Vec::with_capacity(6),
text: None,
children: Vec::with_capacity(2),
}
}
fn push_child(arena: &mut Vec<Node>, parent: usize, child: Node) -> usize {
let id = arena.len();
arena.push(child);
arena[parent].children.push(id);
id
}
fn render_node(out: &mut String, arena: &[Node], id: usize) {
let n = &arena[id];
out.push('<');
out.push_str(n.tag);
for (k, v) in &n.attrs {
let _ = write!(out, r#" {k}="{v}""#);
}
if n.children.is_empty() && n.text.as_deref().unwrap_or("").is_empty() {
out.push_str("/>");
return;
}
out.push('>');
if let Some(t) = n.text.as_deref() {
out.push_str(t);
}
for c in &n.children {
render_node(out, arena, *c);
}
let _ = write!(out, "</{}>", n.tag);
}
fn text_anchor(horizontal_pos: &str) -> &'static str {
match horizontal_pos {
"left" => "start",
"right" => "end",
_ => "middle",
}
}
fn ensure_group_path<'a>(
arena: &mut Vec<Node>,
groups_by_path: &mut FxHashMap<(usize, &'a str), usize>,
group_texts: &'a [String],
) -> usize {
let mut parent = 0usize;
for seg in group_texts {
let class = seg.as_str();
let gid = match groups_by_path.entry((parent, class)) {
Entry::Occupied(entry) => *entry.get(),
Entry::Vacant(entry) => {
let mut g = node("g");
g.attr("class", class);
let id = push_child(arena, parent, g);
entry.insert(id);
id
}
};
parent = gid;
}
parent
}
fn dominant_baseline(vertical_pos: &str) -> &'static str {
if vertical_pos == "top" {
"text-before-edge"
} else {
"middle"
}
}
fn fmt_xy(v: f64) -> String {
if v.is_nan() {
return "NaN".to_string();
}
if !v.is_finite() {
return "NaN".to_string();
}
fmt_string(v)
}
fn data_label_color(effective_config: &serde_json::Value) -> String {
let configured = config_string(
effective_config,
&["themeVariables", "xyChart", "dataLabelColor"],
);
configured
.or_else(|| config_string(effective_config, &["themeVariables", "primaryTextColor"]))
.unwrap_or_else(|| "black".to_string())
}
let diagram_id = options.diagram_id.as_deref().unwrap_or("xychart");
let diagram_id_esc = escape_xml(diagram_id);
let acc_title = model
.acc_title
.as_deref()
.map(str::trim)
.filter(|title| !title.is_empty());
let acc_descr = model
.acc_descr
.as_deref()
.map(|description| description.trim_end_matches('\n'))
.filter(|description| !description.trim().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 data_label_config = if layout.show_data_label {
Some((
layout.show_data_label_outside_bar,
data_label_color(_effective_config),
))
} else {
None
};
let mut out = String::new();
let root_bounds = root_svg::DiagramBounds::from_view_box(0.0, 0.0, layout.width, layout.height);
let root_spec = root_svg::RootViewportSpec::responsive(root_bounds);
let mut root_chrome = root_svg::RootChrome::new(diagram_id, "xychart");
root_chrome.aria_labelledby = aria_labelledby.as_deref();
root_chrome.aria_describedby = aria_describedby.as_deref();
root_chrome.dom.style_viewbox_order = root_svg::SvgRootStyleViewBoxOrder::ViewBoxThenStyle;
root_chrome.dom.trailing_newline = false;
let root_document =
root_svg::RootViewportContext::new(crate::family::RenderFamilyKind::XyChart, 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-{diagram_id_esc}">{}</title>"#,
escape_xml(title)
);
}
if let Some(description) = acc_descr {
let _ = write!(
&mut out,
r#"<desc id="chart-desc-{diagram_id_esc}">{}</desc>"#,
escape_xml(description)
);
}
out.push_str("<style>");
push_xychart_css(&mut out, diagram_id);
out.push_str("</style>");
out.push_str(r#"<g/>"#);
let mut arena: Vec<Node> = Vec::with_capacity(layout.drawables.len().saturating_mul(4) + 2);
arena.push(node("g"));
arena[0].attr("class", "main");
let mut bg = node("rect");
bg.attr("width", fmt_xy(layout.width));
bg.attr("height", fmt_xy(layout.height));
bg.attr("class", "background");
bg.attr("fill", escape_xml(&layout.background_color));
push_child(&mut arena, 0, bg);
let mut groups_by_path: FxHashMap<(usize, &str), usize> = FxHashMap::with_capacity_and_hasher(
layout.drawables.len().saturating_mul(2) + 4,
Default::default(),
);
for shape in &layout.drawables {
match shape {
crate::model::XyChartDrawableElem::Rect { group_texts, data } => {
if data.is_empty() {
continue;
}
let parent = ensure_group_path(&mut arena, &mut groups_by_path, group_texts);
for r in data {
let mut n = node("rect");
n.attr("x", fmt_xy(r.x));
if !r.y.is_nan() {
n.attr("y", fmt_xy(r.y));
}
n.attr("width", fmt_xy(r.width));
n.attr("height", fmt_xy(r.height));
n.attr("fill", escape_xml(&r.fill));
n.attr("stroke", escape_xml(&r.stroke_fill));
n.attr("stroke-width", fmt_xy(r.stroke_width));
push_child(&mut arena, parent, n);
}
if let Some((show_data_label_outside_bar, data_label_color)) = &data_label_config {
let bar_data_label_scale_factor = 0.7;
let bar_data_label_inset_px = 10.0;
#[derive(Clone)]
struct BarItem<'a> {
rect: &'a crate::model::XyChartRectData,
label: &'a str,
}
let mut valid_items: Vec<BarItem<'_>> = Vec::with_capacity(data.len());
for (idx, r) in data.iter().enumerate() {
let Some(label) = layout.label_data.get(idx) else {
continue;
};
if r.width > 0.0 && r.height > 0.0 {
valid_items.push(BarItem { rect: r, label });
}
}
if !valid_items.is_empty() {
if layout.chart_orientation == "horizontal" {
let mut min_font = f64::INFINITY;
for item in &valid_items {
let fs = horizontal_label_font_size(
item.rect.width,
item.label,
item.rect.height * bar_data_label_scale_factor,
bar_data_label_inset_px,
);
min_font = min_font.min(fs);
}
let uniform = if min_font.is_finite() { min_font } else { 0.0 }
.floor()
.max(0.0);
for item in &valid_items {
let mut t = node("text");
let x = if *show_data_label_outside_bar {
item.rect.x + item.rect.width + bar_data_label_inset_px
} else {
item.rect.x + item.rect.width - bar_data_label_inset_px
};
t.attr("x", fmt_xy(x));
t.attr("y", fmt_xy(item.rect.y + item.rect.height / 2.0));
t.attr(
"text-anchor",
if *show_data_label_outside_bar {
"start"
} else {
"end"
},
);
t.attr("dominant-baseline", "middle");
t.attr("fill", escape_xml(data_label_color));
t.attr("font-size", format!("{}px", fmt_xy(uniform)));
t.text = Some(escape_xml(item.label));
push_child(&mut arena, parent, t);
}
} else {
let y_offset = bar_data_label_inset_px;
let mut min_font = f64::INFINITY;
for item in &valid_items {
let denominator = javascript_string_length(item.label)
* bar_data_label_scale_factor;
let initial = if denominator <= 0.0 {
0.0
} else {
item.rect.width / denominator
};
let fs = vertical_label_font_size(
item.rect.width,
item.rect.height,
item.label,
initial,
y_offset,
);
min_font = min_font.min(fs);
}
let uniform = if min_font.is_finite() { min_font } else { 0.0 }
.floor()
.max(0.0);
for item in &valid_items {
let mut t = node("text");
t.attr("x", fmt_xy(item.rect.x + item.rect.width / 2.0));
let y = if *show_data_label_outside_bar {
item.rect.y - y_offset
} else {
item.rect.y + y_offset
};
t.attr("y", fmt_xy(y));
t.attr("text-anchor", "middle");
t.attr(
"dominant-baseline",
if *show_data_label_outside_bar {
"auto"
} else {
"hanging"
},
);
t.attr("fill", escape_xml(data_label_color));
t.attr("font-size", format!("{}px", fmt_xy(uniform)));
t.text = Some(escape_xml(item.label));
push_child(&mut arena, parent, t);
}
}
}
}
}
crate::model::XyChartDrawableElem::Text { group_texts, data } => {
if data.is_empty() {
continue;
}
let parent = ensure_group_path(&mut arena, &mut groups_by_path, group_texts);
for t in data {
let mut n = node("text");
n.attr("x", "0");
n.attr("y", "0");
n.attr("fill", escape_xml(&t.fill));
n.attr("font-size", fmt_string(t.font_size));
n.attr("dominant-baseline", dominant_baseline(&t.vertical_pos));
n.attr("text-anchor", text_anchor(&t.horizontal_pos));
let rot = t.rotation;
n.attr(
"transform",
format!(
"translate({}, {}) rotate({})",
fmt_xy(t.x),
fmt_xy(t.y),
fmt_xy(rot)
),
);
n.text = Some(escape_xml(&t.text));
push_child(&mut arena, parent, n);
}
}
crate::model::XyChartDrawableElem::Path { group_texts, data } => {
if data.is_empty() {
continue;
}
let parent = ensure_group_path(&mut arena, &mut groups_by_path, group_texts);
for p in data {
let mut n = node("path");
n.attr("d", escape_xml(&p.path));
n.attr("fill", escape_xml(p.fill.as_deref().unwrap_or("none")));
n.attr("stroke", escape_xml(&p.stroke_fill));
n.attr("stroke-width", fmt_xy(p.stroke_width));
push_child(&mut arena, parent, n);
}
}
}
}
render_node(&mut out, &arena, 0);
out.push_str(r#"<g class="mermaid-tmp-group"/>"#);
out.push_str("</svg>\n");
root_document.complete(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn upstream_decrement(initial: f64, maximum_that_fits: f64) -> f64 {
let mut font_size = initial;
while font_size > maximum_that_fits && font_size > 0.0 {
font_size -= 1.0;
}
font_size
}
#[test]
fn closed_form_font_sizing_matches_mermaid_for_normal_dimensions() {
for (initial, maximum_that_fits) in [(10.2, 8.0), (10.0, 8.8), (8.0, 8.0), (0.5, 0.0)] {
assert_eq!(
font_size_after_unit_decrements(initial, maximum_that_fits)
.floor()
.max(0.0),
upstream_decrement(initial, maximum_that_fits)
.floor()
.max(0.0),
);
}
}
#[test]
fn huge_finite_bar_dimensions_complete_without_a_decrement_loop() {
let font_size = horizontal_label_font_size(1e308, "123", 7e307, 10.0);
assert!(font_size.is_finite());
assert!(font_size > 0.0);
}
#[test]
fn bar_label_length_uses_javascript_utf16_code_units() {
assert_eq!(javascript_string_length("A"), 1.0);
assert_eq!(javascript_string_length("\u{1F469}\u{200D}\u{1F4BB}"), 5.0);
}
}