use crate::json::from_value_ref;
use crate::model::{
Bounds, LayoutEdge, LayoutLabel, LayoutNode, LayoutPoint, RequirementDiagramLayout,
};
use crate::text::{TextMeasurer, TextStyle, WrapMode};
use crate::{Error, Result};
use dugong::graphlib::{Graph, GraphOptions};
use dugong::{EdgeLabel, GraphLabel, LabelPos, NodeLabel, RankDir};
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RequirementNodeModel {
name: String,
#[serde(rename = "type")]
node_type: String,
#[serde(default)]
requirement_id: Option<String>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
risk: Option<String>,
#[serde(default)]
verify_method: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ElementNodeModel {
name: String,
#[serde(rename = "type")]
node_type: String,
#[serde(default)]
doc_ref: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct RequirementRelationshipModel {
#[serde(rename = "type")]
rel_type: String,
src: String,
dst: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RequirementDiagramModel {
#[serde(default)]
direction: Option<String>,
#[serde(default)]
requirements: Vec<RequirementNodeModel>,
#[serde(default)]
elements: Vec<ElementNodeModel>,
#[serde(default)]
relationships: Vec<RequirementRelationshipModel>,
}
fn json_f64(v: &Value) -> Option<f64> {
v.as_f64().or_else(|| v.as_i64().map(|n| n as f64))
}
fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
let mut cur = cfg;
for key in path {
cur = cur.get(*key)?;
}
json_f64(cur)
}
fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
let mut cur = cfg;
for key in path {
cur = cur.get(*key)?;
}
cur.as_str().map(|s| s.to_string()).or_else(|| {
cur.as_array()
.and_then(|values| values.first()?.as_str())
.map(|s| s.to_string())
})
}
fn parse_css_px_to_f64(s: &str) -> Option<f64> {
let raw = s.trim().trim_end_matches(';').trim();
let raw = raw.trim_end_matches("!important").trim();
let raw = raw.strip_suffix("px").unwrap_or(raw).trim();
raw.parse::<f64>().ok().filter(|value| value.is_finite())
}
fn config_f64_css_px(cfg: &Value, path: &[&str]) -> Option<f64> {
config_f64(cfg, path).or_else(|| {
let raw = config_string(cfg, path)?;
parse_css_px_to_f64(&raw)
})
}
fn normalize_dir(direction: &str) -> String {
match direction.trim().to_uppercase().as_str() {
"TB" | "TD" => "TB".to_string(),
"BT" => "BT".to_string(),
"LR" => "LR".to_string(),
"RL" => "RL".to_string(),
other => other.to_string(),
}
}
fn rank_dir_from(direction: &str) -> RankDir {
match normalize_dir(direction).as_str() {
"TB" => RankDir::TB,
"BT" => RankDir::BT,
"LR" => RankDir::LR,
"RL" => RankDir::RL,
_ => RankDir::TB,
}
}
#[derive(Debug, Clone)]
struct RequirementLabelMetrics {
width: f64,
height: f64,
#[allow(dead_code)]
max_width_px: i64,
}
fn requirement_label_uses_markdown_html(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
raw.contains('*') || raw.contains('_') || raw.contains('\n') || lower.contains("<br")
}
fn calculate_text_width_like_mermaid_px(
measurer: &dyn TextMeasurer,
style: &TextStyle,
text: &str,
) -> i64 {
fn round_i64(v: f64) -> i64 {
if !v.is_finite() {
return 0;
}
v.round() as i64
}
let mut sans = style.clone();
sans.font_family = Some("sans-serif".to_string());
sans.font_weight = None;
let mut fam = style.clone();
fam.font_weight = None;
let (l1, r1) = measurer.measure_svg_title_bbox_x(text, &sans);
let (l2, r2) = measurer.measure_svg_title_bbox_x(text, &fam);
let w1 = (l1 + r1).max(0.0);
let w2 = (l2 + r2).max(0.0);
round_i64(w1.max(w2))
}
fn measure_requirement_label_metrics(
measurer: &dyn TextMeasurer,
html_style: &TextStyle,
calc_style: &TextStyle,
display_text: &str,
calc_text: &str,
bold: bool,
) -> Option<RequirementLabelMetrics> {
if display_text.trim().is_empty() {
return None;
}
let font_size = html_style.font_size.max(1.0);
let looks_like_markdown_inline = requirement_label_uses_markdown_html(display_text);
let measured = if looks_like_markdown_inline {
crate::text::measure_markdown_with_flowchart_bold_deltas(
measurer,
display_text,
html_style,
None,
WrapMode::HtmlLike,
)
} else {
measurer.measure_wrapped(display_text, html_style, None, WrapMode::HtmlLike)
};
let height = measured.height.max(1.0);
let width = if let Some(em) =
crate::generated::requirement_text_overrides_11_12_2::lookup_requirement_html_label_width_em(
display_text,
bold,
) {
(em * font_size).max(1.0)
} else {
measured.width.max(1.0)
};
let max_width_px = if let Some(px) =
crate::generated::requirement_text_overrides_11_12_2::lookup_requirement_calc_max_width_px(
calc_text,
) {
px
} else {
let calc_input =
if calc_text.contains('\n') || calc_text.to_ascii_lowercase().contains("<br") {
crate::flowchart::flowchart_label_plain_text_for_layout(calc_text, "text", true)
} else {
calc_text.to_string()
};
let calc_w = calculate_text_width_like_mermaid_px(measurer, calc_style, &calc_input);
(calc_w + 50).max(0)
};
Some(RequirementLabelMetrics {
width,
height,
max_width_px,
})
}
#[derive(Debug, Clone)]
struct RequirementBoxLayout {
width: f64,
height: f64,
}
fn requirement_box_layout(
measurer: &dyn TextMeasurer,
calc_style: &TextStyle,
html_style_regular: &TextStyle,
html_style_bold: &TextStyle,
lines: &[(String, String, bool)],
gap: f64,
padding: f64,
) -> RequirementBoxLayout {
let mut html_metrics: Vec<Option<RequirementLabelMetrics>> = Vec::with_capacity(lines.len());
let mut max_w: f64 = 0.0;
for (display, calc, bold) in lines {
let html_style = if *bold {
html_style_bold
} else {
html_style_regular
};
let m = measure_requirement_label_metrics(
measurer, html_style, calc_style, display, calc, *bold,
);
if let Some(m) = &m {
max_w = max_w.max(m.width);
}
html_metrics.push(m);
}
let total_w = max_w + padding;
let mut min_y = 0.0;
let mut max_y = 0.0;
let mut y_offset = 0.0;
for (idx, m) in html_metrics.iter().enumerate() {
let Some(m) = m else {
continue;
};
if idx == 0 {
min_y = -m.height / 2.0;
max_y = m.height / 2.0;
y_offset = m.height;
continue;
}
if idx == 1 {
let top = -m.height / 2.0 + y_offset;
let bottom = m.height / 2.0 + y_offset;
min_y = min_y.min(top);
max_y = max_y.max(bottom);
y_offset += m.height + gap;
continue;
}
let top = -m.height / 2.0 + y_offset;
let bottom = m.height / 2.0 + y_offset;
min_y = min_y.min(top);
max_y = max_y.max(bottom);
y_offset += m.height;
}
let bbox_h = (max_y - min_y).max(1.0);
let total_h = bbox_h + padding;
RequirementBoxLayout {
width: total_w.max(1.0),
height: total_h.max(1.0),
}
}
fn requirement_edge_id(src: &str, dst: &str, idx: usize) -> String {
format!("{src}-{dst}-{idx}")
}
pub fn layout_requirement_diagram(
model: &Value,
effective_config: &Value,
text_measurer: &dyn TextMeasurer,
) -> Result<RequirementDiagramLayout> {
let model: RequirementDiagramModel = from_value_ref(model)?;
let direction = normalize_dir(model.direction.as_deref().unwrap_or("TB"));
let nodesep = config_f64(effective_config, &["nodeSpacing"])
.or_else(|| config_f64(effective_config, &["flowchart", "nodeSpacing"]))
.unwrap_or(50.0);
let ranksep = config_f64(effective_config, &["rankSpacing"])
.or_else(|| config_f64(effective_config, &["flowchart", "rankSpacing"]))
.unwrap_or(50.0);
let font_family = config_string(effective_config, &["themeVariables", "fontFamily"])
.or_else(|| config_string(effective_config, &["fontFamily"]))
.or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
let font_size = config_f64_css_px(effective_config, &["themeVariables", "fontSize"])
.or_else(|| config_f64_css_px(effective_config, &["fontSize"]))
.unwrap_or(16.0);
let calc_style = TextStyle {
font_family: font_family.clone(),
font_size,
font_weight: None,
};
let html_style_regular = TextStyle {
font_family: font_family.clone(),
font_size,
font_weight: None,
};
let html_style_bold = TextStyle {
font_family,
font_size,
font_weight: Some("bold".to_string()),
};
let padding = 20.0;
let gap = 20.0;
let mut g = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
directed: true,
multigraph: true,
compound: true,
});
g.set_graph(GraphLabel {
rankdir: rank_dir_from(&direction),
nodesep,
ranksep,
marginx: 8.0,
marginy: 8.0,
..Default::default()
});
for r in &model.requirements {
if r.name == "__proto__" {
continue;
}
let type_disp = format!("<<{}>>", r.node_type);
let type_calc = format!("<<{}>>", r.node_type);
let mut lines: Vec<(String, String, bool)> = Vec::new();
lines.push((type_disp, type_calc, false));
lines.push((r.name.clone(), r.name.clone(), true));
let id_line = r
.requirement_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| format!("ID: {s}"))
.unwrap_or_default();
lines.push((id_line.clone(), id_line, false));
let text_line = r
.text
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| format!("Text: {s}"))
.unwrap_or_default();
lines.push((text_line.clone(), text_line, false));
let risk_line = r
.risk
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| format!("Risk: {s}"))
.unwrap_or_default();
lines.push((risk_line.clone(), risk_line, false));
let verify_line = r
.verify_method
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| format!("Verification: {s}"))
.unwrap_or_default();
lines.push((verify_line.clone(), verify_line, false));
let box_layout = requirement_box_layout(
text_measurer,
&calc_style,
&html_style_regular,
&html_style_bold,
&lines,
gap,
padding,
);
g.set_node(
r.name.clone(),
NodeLabel {
width: box_layout.width,
height: box_layout.height,
..Default::default()
},
);
}
for e in &model.elements {
if e.name == "__proto__" {
continue;
}
let type_disp = "<<Element>>".to_string();
let type_calc = "<<Element>>".to_string();
let mut lines: Vec<(String, String, bool)> = Vec::new();
lines.push((type_disp, type_calc, false));
lines.push((e.name.clone(), e.name.clone(), true));
let type_line = e.node_type.trim().to_string();
let type_line = if type_line.is_empty() {
String::new()
} else {
format!("Type: {type_line}")
};
lines.push((type_line.clone(), type_line, false));
let doc_line = e
.doc_ref
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| format!("Doc Ref: {s}"))
.unwrap_or_default();
lines.push((doc_line.clone(), doc_line, false));
let box_layout = requirement_box_layout(
text_measurer,
&calc_style,
&html_style_regular,
&html_style_bold,
&lines,
gap,
padding,
);
g.set_node(
e.name.clone(),
NodeLabel {
width: box_layout.width,
height: box_layout.height,
..Default::default()
},
);
}
for rel in &model.relationships {
if !g.has_node(&rel.src) {
return Err(Error::InvalidModel {
message: format!("relationship src node not found: {}", rel.src),
});
}
if !g.has_node(&rel.dst) {
return Err(Error::InvalidModel {
message: format!("relationship dst node not found: {}", rel.dst),
});
}
let edge_id = requirement_edge_id(&rel.src, &rel.dst, 0);
let label_display = format!("<<{}>>", rel.rel_type);
let label_calc = format!("<<{}>>", rel.rel_type);
let metrics = measure_requirement_label_metrics(
text_measurer,
&html_style_regular,
&calc_style,
&label_display,
&label_calc,
false,
)
.unwrap_or(RequirementLabelMetrics {
width: 0.0,
height: 0.0,
max_width_px: 0,
});
let el = EdgeLabel {
width: metrics.width.max(0.0),
height: metrics.height.max(0.0),
labelpos: LabelPos::C,
labeloffset: 10.0,
minlen: 1,
weight: 1.0,
..Default::default()
};
g.set_edge_named(rel.src.clone(), rel.dst.clone(), Some(edge_id), Some(el));
}
dugong::layout_dagreish(&mut g);
let mut out_nodes: Vec<LayoutNode> = Vec::new();
for v in g.nodes() {
let Some(n) = g.node(v) else {
continue;
};
let (Some(cx), Some(cy)) = (n.x, n.y) else {
continue;
};
out_nodes.push(LayoutNode {
id: v.to_string(),
x: cx - n.width / 2.0,
y: cy - n.height / 2.0,
width: n.width,
height: n.height,
is_cluster: false,
label_width: None,
label_height: None,
});
}
let mut out_edges: Vec<LayoutEdge> = Vec::new();
for ek in g.edge_keys() {
let Some(e) = g.edge_by_key(&ek) else {
continue;
};
let points = e
.points
.iter()
.map(|p| LayoutPoint { x: p.x, y: p.y })
.collect::<Vec<_>>();
let label = match (e.x, e.y) {
(Some(x), Some(y)) if e.width > 0.0 && e.height > 0.0 => Some(LayoutLabel {
x,
y,
width: e.width,
height: e.height,
}),
_ => None,
};
out_edges.push(LayoutEdge {
id: ek
.name
.clone()
.unwrap_or_else(|| format!("{}-{}", ek.v, ek.w)),
from: ek.v.clone(),
to: ek.w.clone(),
from_cluster: None,
to_cluster: None,
points,
label,
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,
});
}
fn bounds_for_nodes_edges(nodes: &[LayoutNode], edges: &[LayoutEdge]) -> Option<Bounds> {
if nodes.is_empty() && edges.is_empty() {
return None;
}
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for n in nodes {
min_x = min_x.min(n.x);
min_y = min_y.min(n.y);
max_x = max_x.max(n.x + n.width);
max_y = max_y.max(n.y + n.height);
}
for e in edges {
for p in &e.points {
min_x = min_x.min(p.x);
min_y = min_y.min(p.y);
max_x = max_x.max(p.x);
max_y = max_y.max(p.y);
}
if let Some(l) = &e.label {
min_x = min_x.min(l.x - l.width / 2.0);
max_x = max_x.max(l.x + l.width / 2.0);
min_y = min_y.min(l.y - l.height / 2.0);
max_y = max_y.max(l.y + l.height / 2.0);
}
}
if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() {
return None;
}
Some(Bounds {
min_x,
min_y,
max_x,
max_y,
})
}
let bounds = bounds_for_nodes_edges(&out_nodes, &out_edges);
Ok(RequirementDiagramLayout {
nodes: out_nodes,
edges: out_edges,
bounds,
})
}
#[cfg(test)]
mod tests {
#[test]
fn generated_requirement_html_label_width_overrides_cover_known_literals() {
assert_eq!(
crate::generated::requirement_text_overrides_11_12_2::
lookup_requirement_html_label_width_em("<<Requirement>>", false),
Some(7.826171875)
);
assert_eq!(
crate::generated::requirement_text_overrides_11_12_2::
lookup_requirement_html_label_width_em("req_interface", true),
Some(6.4482421875)
);
assert_eq!(
crate::generated::requirement_text_overrides_11_12_2::
lookup_requirement_html_label_width_em("unknown", false),
None
);
}
#[test]
fn generated_requirement_calc_width_overrides_cover_known_literals() {
assert_eq!(
crate::generated::requirement_text_overrides_11_12_2::
lookup_requirement_calc_max_width_px("<<Requirement>>"),
Some(243)
);
assert_eq!(
crate::generated::requirement_text_overrides_11_12_2::
lookup_requirement_calc_max_width_px("Verification: Demonstration"),
Some(231)
);
assert_eq!(
crate::generated::requirement_text_overrides_11_12_2::
lookup_requirement_calc_max_width_px("unknown"),
None
);
}
}