Skip to main content

merman_render/
sequence.rs

1use crate::Result;
2use crate::math::MathRenderer;
3use crate::model::{LayoutCluster, SequenceDiagramLayout};
4use crate::text::{TextMeasurer, TextStyle};
5use merman_core::MermaidConfig;
6use merman_core::diagrams::sequence::SequenceDiagramRenderModel;
7use serde_json::Value;
8
9mod activation;
10mod actors;
11mod block_bounds;
12mod block_steps;
13mod config;
14mod constants;
15mod messages;
16mod metrics;
17mod notes;
18mod orchestration;
19mod rect;
20mod root_bounds;
21
22pub(crate) use activation::{sequence_activation_stack_bounds, sequence_activation_start_x};
23pub(crate) use constants::{
24    SEQUENCE_FRAME_GEOM_PAD_PX, SEQUENCE_FRAME_SIDE_PAD_PX, SEQUENCE_MESSAGE_WRAP_SLACK_FACTOR,
25    SEQUENCE_SELF_MESSAGE_FRAME_EXTRA_Y_PX, sequence_actor_popup_panel_height,
26    sequence_text_dimensions_height_px, sequence_text_line_step_px,
27};
28pub(crate) use metrics::{SequenceMathHeightMode, measure_sequence_math_label};
29pub(crate) use notes::sequence_note_final_wrapped_lines;
30
31use actors::{SequenceActorLayoutPlan, SequenceActorLayoutPlanContext, plan_sequence_actors};
32use block_bounds::sequence_block_bounds;
33use config::{config_f64, config_string};
34use orchestration::{SequenceLayoutGraph, SequenceLayoutGraphContext, build_sequence_layout_graph};
35use rect::sequence_rect_stack_x_bounds;
36use root_bounds::{SequenceRootBoundsContext, sequence_root_bounds};
37
38pub fn layout_sequence_diagram(
39    semantic: &Value,
40    effective_config: &Value,
41    measurer: &dyn TextMeasurer,
42    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
43) -> Result<SequenceDiagramLayout> {
44    layout_sequence_diagram_with_title(semantic, None, effective_config, measurer, math_renderer)
45}
46
47pub fn layout_sequence_diagram_with_title(
48    semantic: &Value,
49    diagram_title: Option<&str>,
50    effective_config: &Value,
51    measurer: &dyn TextMeasurer,
52    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
53) -> Result<SequenceDiagramLayout> {
54    let model: SequenceDiagramRenderModel = crate::json::from_value_ref(semantic)?;
55    layout_sequence_diagram_typed_with_title(
56        &model,
57        diagram_title,
58        effective_config,
59        measurer,
60        math_renderer,
61    )
62}
63
64pub fn layout_sequence_diagram_typed(
65    model: &SequenceDiagramRenderModel,
66    effective_config: &Value,
67    measurer: &dyn TextMeasurer,
68    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
69) -> Result<SequenceDiagramLayout> {
70    layout_sequence_diagram_typed_with_title(model, None, effective_config, measurer, math_renderer)
71}
72
73pub fn layout_sequence_diagram_typed_with_title(
74    model: &SequenceDiagramRenderModel,
75    diagram_title: Option<&str>,
76    effective_config: &Value,
77    measurer: &dyn TextMeasurer,
78    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
79) -> Result<SequenceDiagramLayout> {
80    let math_config = MermaidConfig::from_value(effective_config.clone());
81    let seq_cfg = effective_config.get("sequence").unwrap_or(&Value::Null);
82    let diagram_margin_x = config_f64(seq_cfg, &["diagramMarginX"]).unwrap_or(50.0);
83    let diagram_margin_y = config_f64(seq_cfg, &["diagramMarginY"]).unwrap_or(10.0);
84    let bottom_margin_adj = config_f64(seq_cfg, &["bottomMarginAdj"]).unwrap_or(1.0);
85    let box_margin = config_f64(seq_cfg, &["boxMargin"]).unwrap_or(10.0);
86    let actor_margin = config_f64(seq_cfg, &["actorMargin"]).unwrap_or(50.0);
87    let sequence_default_width = config_f64(seq_cfg, &["width"]).unwrap_or(150.0);
88    let actor_width_min = sequence_default_width;
89    let actor_height = config_f64(seq_cfg, &["height"]).unwrap_or(65.0);
90    let message_margin = config_f64(seq_cfg, &["messageMargin"]).unwrap_or(35.0);
91    let wrap_padding = config_f64(seq_cfg, &["wrapPadding"]).unwrap_or(10.0);
92    let box_text_margin = config_f64(seq_cfg, &["boxTextMargin"]).unwrap_or(5.0);
93    let label_box_height = config_f64(seq_cfg, &["labelBoxHeight"]).unwrap_or(20.0);
94    let mirror_actors = seq_cfg
95        .get("mirrorActors")
96        .and_then(|v| v.as_bool())
97        .unwrap_or(true);
98
99    // Mermaid's `sequenceRenderer.setConf(...)` overrides per-sequence font settings whenever the
100    // global `fontFamily` / `fontSize` / `fontWeight` are present (defaults are always present).
101    let global_font_family = config_string(effective_config, &["fontFamily"]);
102    let global_font_size = config_f64(effective_config, &["fontSize"]);
103    let global_font_weight = config_string(effective_config, &["fontWeight"]);
104
105    let message_font_family = global_font_family
106        .clone()
107        .or_else(|| config_string(seq_cfg, &["messageFontFamily"]));
108    let message_font_size = global_font_size
109        .or_else(|| config_f64(seq_cfg, &["messageFontSize"]))
110        .unwrap_or(16.0);
111    let message_font_weight = global_font_weight
112        .clone()
113        .or_else(|| config_string(seq_cfg, &["messageFontWeight"]));
114
115    let actor_font_family = global_font_family
116        .clone()
117        .or_else(|| config_string(seq_cfg, &["actorFontFamily"]));
118    let actor_font_size = global_font_size
119        .or_else(|| config_f64(seq_cfg, &["actorFontSize"]))
120        .unwrap_or(16.0);
121    let actor_font_weight = global_font_weight
122        .clone()
123        .or_else(|| config_string(seq_cfg, &["actorFontWeight"]));
124
125    // Upstream sequence uses `calculateTextDimensions(...).width` (SVG `getBBox`) when computing
126    // message widths for spacing. Keep this scale at 1.0 and handle any residual differences via
127    // the SVG-backed `TextMeasurer` implementation.
128    let message_width_scale = 1.0;
129
130    let actor_text_style = TextStyle {
131        font_family: actor_font_family,
132        font_size: actor_font_size,
133        font_weight: actor_font_weight,
134    };
135    let note_font_family = global_font_family
136        .clone()
137        .or_else(|| config_string(seq_cfg, &["noteFontFamily"]));
138    let note_font_size = global_font_size
139        .or_else(|| config_f64(seq_cfg, &["noteFontSize"]))
140        .unwrap_or(16.0);
141    let note_font_weight = global_font_weight
142        .clone()
143        .or_else(|| config_string(seq_cfg, &["noteFontWeight"]));
144    let note_text_style = TextStyle {
145        font_family: note_font_family,
146        font_size: note_font_size,
147        font_weight: note_font_weight,
148    };
149    let msg_text_style = TextStyle {
150        font_family: message_font_family,
151        font_size: message_font_size,
152        font_weight: message_font_weight,
153    };
154
155    let SequenceActorLayoutPlan {
156        actor_index,
157        actor_widths,
158        actor_base_heights,
159        actor_box,
160        actor_left_x,
161        actor_centers_x,
162        box_margins,
163        actor_top_offset_y,
164        max_actor_layout_height,
165        has_boxes,
166    } = plan_sequence_actors(SequenceActorLayoutPlanContext {
167        model,
168        measurer,
169        actor_text_style: &actor_text_style,
170        note_text_style: &note_text_style,
171        msg_text_style: &msg_text_style,
172        math_config: &math_config,
173        math_renderer,
174        actor_width_min,
175        actor_height,
176        actor_margin,
177        actor_font_size,
178        box_margin,
179        box_text_margin,
180        wrap_padding,
181        message_width_scale,
182        message_font_size,
183    })?;
184
185    let message_text_line_height = sequence_text_dimensions_height_px(message_font_size);
186    let message_step = box_margin + 2.0 * message_text_line_height;
187    let msg_label_offset = (2.0 * message_text_line_height - wrap_padding / 2.0).max(0.0);
188
189    let clusters: Vec<LayoutCluster> = Vec::new();
190
191    let activation_width = config_f64(seq_cfg, &["activationWidth"])
192        .unwrap_or(10.0)
193        .max(1.0);
194    let SequenceLayoutGraph {
195        mut nodes,
196        edges,
197        bottom_box_top_y,
198    } = build_sequence_layout_graph(SequenceLayoutGraphContext {
199        model,
200        actor_index: &actor_index,
201        actor_centers_x: &actor_centers_x,
202        actor_widths: &actor_widths,
203        actor_base_heights: &actor_base_heights,
204        actor_top_offset_y,
205        max_actor_layout_height,
206        actor_width_min,
207        sequence_default_width,
208        actor_height,
209        message_margin,
210        box_margin,
211        box_text_margin,
212        bottom_margin_adj,
213        label_box_height,
214        message_step,
215        message_text_line_height,
216        msg_label_offset,
217        message_font_size,
218        message_width_scale,
219        wrap_padding,
220        mirror_actors,
221        activation_width,
222        measurer,
223        msg_text_style: &msg_text_style,
224        note_text_style: &note_text_style,
225        math_config: &math_config,
226        math_renderer,
227    });
228
229    // Mermaid's SVG `viewBox` is derived from `svg.getBBox()` plus diagram margins. Block frames
230    // (`alt`, `par`, `loop`, `opt`, `break`, `critical`) can extend beyond the node/edge graph we
231    // model in headless layout. Capture their extents so we can expand bounds before emitting the
232    // final `viewBox`.
233    let block_bounds = sequence_block_bounds(model, &nodes, &edges);
234
235    let rect_x_bounds = sequence_rect_stack_x_bounds(
236        model,
237        &actor_index,
238        &actor_centers_x,
239        &edges,
240        &nodes,
241        actor_width_min,
242        box_margin,
243    );
244    if !rect_x_bounds.is_empty() {
245        for n in &mut nodes {
246            let Some(start_id) = n.id.strip_prefix("rect-") else {
247                continue;
248            };
249            let Some((min_x, max_x)) = rect_x_bounds.get(start_id).copied() else {
250                continue;
251            };
252            n.x = (min_x + max_x) / 2.0;
253            n.width = (max_x - min_x).max(1.0);
254        }
255    }
256
257    let bounds = Some(sequence_root_bounds(SequenceRootBoundsContext {
258        model,
259        diagram_title,
260        nodes: &nodes,
261        edges: &edges,
262        block_bounds,
263        actor_index: &actor_index,
264        actor_centers_x: &actor_centers_x,
265        actor_left_x: &actor_left_x,
266        actor_widths: &actor_widths,
267        actor_box: &actor_box,
268        box_margins: &box_margins,
269        actor_width_min,
270        actor_height,
271        bottom_box_top_y,
272        diagram_margin_x,
273        diagram_margin_y,
274        bottom_margin_adj,
275        box_margin,
276        wrap_padding,
277        has_boxes,
278        mirror_actors,
279        measurer,
280        msg_text_style: &msg_text_style,
281        note_text_style: &note_text_style,
282        math_config: &math_config,
283        math_renderer,
284    }));
285
286    Ok(SequenceDiagramLayout {
287        nodes,
288        edges,
289        clusters,
290        bounds,
291    })
292}
293
294pub(crate) fn sequence_render_title<'a>(
295    model_title: Option<&'a str>,
296    diagram_title: Option<&'a str>,
297) -> Option<&'a str> {
298    if model_title.is_none_or(|t| t.trim().is_empty()) {
299        if let Some(title) = diagram_title.map(str::trim).filter(|t| !t.is_empty()) {
300            return Some(title);
301        }
302    }
303    model_title
304}