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 constants::{
23    SEQUENCE_FRAME_GEOM_PAD_PX, SEQUENCE_FRAME_SIDE_PAD_PX,
24    SEQUENCE_LEFT_OF_NOTE_FINAL_WRAP_SLACK_PX, SEQUENCE_MESSAGE_WRAP_SLACK_FACTOR,
25    SEQUENCE_NOTE_WRAP_SLACK_PX, SEQUENCE_SELF_MESSAGE_FRAME_EXTRA_Y_PX,
26    sequence_actor_popup_panel_height, sequence_text_dimensions_height_px,
27    sequence_text_line_step_px,
28};
29pub(crate) use metrics::{SequenceMathHeightMode, measure_sequence_math_label};
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 actor_width_min = config_f64(seq_cfg, &["width"]).unwrap_or(150.0);
88    let actor_height = config_f64(seq_cfg, &["height"]).unwrap_or(65.0);
89    let message_margin = config_f64(seq_cfg, &["messageMargin"]).unwrap_or(35.0);
90    let wrap_padding = config_f64(seq_cfg, &["wrapPadding"]).unwrap_or(10.0);
91    let box_text_margin = config_f64(seq_cfg, &["boxTextMargin"]).unwrap_or(5.0);
92    let label_box_height = config_f64(seq_cfg, &["labelBoxHeight"]).unwrap_or(20.0);
93    let mirror_actors = seq_cfg
94        .get("mirrorActors")
95        .and_then(|v| v.as_bool())
96        .unwrap_or(true);
97
98    // Mermaid's `sequenceRenderer.setConf(...)` overrides per-sequence font settings whenever the
99    // global `fontFamily` / `fontSize` / `fontWeight` are present (defaults are always present).
100    let global_font_family = config_string(effective_config, &["fontFamily"]);
101    let global_font_size = config_f64(effective_config, &["fontSize"]);
102    let global_font_weight = config_string(effective_config, &["fontWeight"]);
103
104    let message_font_family = global_font_family
105        .clone()
106        .or_else(|| config_string(seq_cfg, &["messageFontFamily"]));
107    let message_font_size = global_font_size
108        .or_else(|| config_f64(seq_cfg, &["messageFontSize"]))
109        .unwrap_or(16.0);
110    let message_font_weight = global_font_weight
111        .clone()
112        .or_else(|| config_string(seq_cfg, &["messageFontWeight"]));
113
114    let actor_font_family = global_font_family
115        .clone()
116        .or_else(|| config_string(seq_cfg, &["actorFontFamily"]));
117    let actor_font_size = global_font_size
118        .or_else(|| config_f64(seq_cfg, &["actorFontSize"]))
119        .unwrap_or(16.0);
120    let actor_font_weight = global_font_weight
121        .clone()
122        .or_else(|| config_string(seq_cfg, &["actorFontWeight"]));
123
124    // Upstream sequence uses `calculateTextDimensions(...).width` (SVG `getBBox`) when computing
125    // message widths for spacing. Keep this scale at 1.0 and handle any residual differences via
126    // the SVG-backed `TextMeasurer` implementation.
127    let message_width_scale = 1.0;
128
129    let actor_text_style = TextStyle {
130        font_family: actor_font_family,
131        font_size: actor_font_size,
132        font_weight: actor_font_weight,
133    };
134    let note_font_family = global_font_family
135        .clone()
136        .or_else(|| config_string(seq_cfg, &["noteFontFamily"]));
137    let note_font_size = global_font_size
138        .or_else(|| config_f64(seq_cfg, &["noteFontSize"]))
139        .unwrap_or(16.0);
140    let note_font_weight = global_font_weight
141        .clone()
142        .or_else(|| config_string(seq_cfg, &["noteFontWeight"]));
143    let note_text_style = TextStyle {
144        font_family: note_font_family,
145        font_size: note_font_size,
146        font_weight: note_font_weight,
147    };
148    let msg_text_style = TextStyle {
149        font_family: message_font_family,
150        font_size: message_font_size,
151        font_weight: message_font_weight,
152    };
153
154    let SequenceActorLayoutPlan {
155        actor_index,
156        actor_widths,
157        actor_base_heights,
158        actor_box,
159        actor_left_x,
160        actor_centers_x,
161        box_margins,
162        actor_top_offset_y,
163        max_actor_layout_height,
164        has_boxes,
165    } = plan_sequence_actors(SequenceActorLayoutPlanContext {
166        model,
167        measurer,
168        actor_text_style: &actor_text_style,
169        note_text_style: &note_text_style,
170        msg_text_style: &msg_text_style,
171        math_config: &math_config,
172        math_renderer,
173        actor_width_min,
174        actor_height,
175        actor_margin,
176        actor_font_size,
177        box_margin,
178        box_text_margin,
179        wrap_padding,
180        message_width_scale,
181        message_font_size,
182    })?;
183
184    let message_text_line_height = sequence_text_dimensions_height_px(message_font_size);
185    let message_step = box_margin + 2.0 * message_text_line_height;
186    let msg_label_offset = (2.0 * message_text_line_height - wrap_padding / 2.0).max(0.0);
187
188    let clusters: Vec<LayoutCluster> = Vec::new();
189
190    let activation_width = config_f64(seq_cfg, &["activationWidth"])
191        .unwrap_or(10.0)
192        .max(1.0);
193    let SequenceLayoutGraph {
194        mut nodes,
195        edges,
196        bottom_box_top_y,
197    } = build_sequence_layout_graph(SequenceLayoutGraphContext {
198        model,
199        actor_index: &actor_index,
200        actor_centers_x: &actor_centers_x,
201        actor_widths: &actor_widths,
202        actor_base_heights: &actor_base_heights,
203        actor_top_offset_y,
204        max_actor_layout_height,
205        actor_width_min,
206        actor_height,
207        message_margin,
208        box_margin,
209        box_text_margin,
210        bottom_margin_adj,
211        label_box_height,
212        message_step,
213        message_text_line_height,
214        msg_label_offset,
215        message_font_size,
216        message_width_scale,
217        wrap_padding,
218        mirror_actors,
219        activation_width,
220        measurer,
221        msg_text_style: &msg_text_style,
222        note_text_style: &note_text_style,
223        math_config: &math_config,
224        math_renderer,
225    });
226
227    // Mermaid's SVG `viewBox` is derived from `svg.getBBox()` plus diagram margins. Block frames
228    // (`alt`, `par`, `loop`, `opt`, `break`, `critical`) can extend beyond the node/edge graph we
229    // model in headless layout. Capture their extents so we can expand bounds before emitting the
230    // final `viewBox`.
231    let block_bounds = sequence_block_bounds(model, &nodes, &edges);
232
233    let rect_x_bounds = sequence_rect_stack_x_bounds(
234        model,
235        &actor_index,
236        &actor_centers_x,
237        &edges,
238        &nodes,
239        actor_width_min,
240        box_margin,
241    );
242    if !rect_x_bounds.is_empty() {
243        for n in &mut nodes {
244            let Some(start_id) = n.id.strip_prefix("rect-") else {
245                continue;
246            };
247            let Some((min_x, max_x)) = rect_x_bounds.get(start_id).copied() else {
248                continue;
249            };
250            n.x = (min_x + max_x) / 2.0;
251            n.width = (max_x - min_x).max(1.0);
252        }
253    }
254
255    let bounds = Some(sequence_root_bounds(SequenceRootBoundsContext {
256        model,
257        diagram_title,
258        nodes: &nodes,
259        edges: &edges,
260        block_bounds,
261        actor_index: &actor_index,
262        actor_centers_x: &actor_centers_x,
263        actor_left_x: &actor_left_x,
264        actor_widths: &actor_widths,
265        actor_box: &actor_box,
266        box_margins: &box_margins,
267        actor_width_min,
268        actor_height,
269        bottom_box_top_y,
270        diagram_margin_x,
271        diagram_margin_y,
272        bottom_margin_adj,
273        box_margin,
274        has_boxes,
275        mirror_actors,
276        measurer,
277        msg_text_style: &msg_text_style,
278        math_config: &math_config,
279        math_renderer,
280    }));
281
282    Ok(SequenceDiagramLayout {
283        nodes,
284        edges,
285        clusters,
286        bounds,
287    })
288}
289
290pub(crate) fn sequence_render_title<'a>(
291    model_title: Option<&'a str>,
292    diagram_title: Option<&'a str>,
293) -> Option<&'a str> {
294    if model_title.is_none_or(|t| t.trim().is_empty()) {
295        if let Some(title) = diagram_title.map(str::trim).filter(|t| !t.is_empty()) {
296            return Some(title);
297        }
298    }
299    model_title
300}