1#![allow(clippy::too_many_arguments)]
2
3use crate::model::{
4 Bounds, LayoutCluster, LayoutEdge, LayoutLabel, LayoutNode, LayoutPoint, SequenceDiagramLayout,
5};
6use crate::text::{
7 TextMeasurer, TextStyle, WrapMode, split_html_br_lines, wrap_label_like_mermaid_lines,
8 wrap_label_like_mermaid_lines_floored_bbox,
9};
10use crate::{Error, Result};
11use serde::Deserialize;
12use serde_json::Value;
13
14#[derive(Debug, Clone, Deserialize)]
15struct SequenceActor {
16 #[allow(dead_code)]
17 name: String,
18 description: String,
19 #[serde(rename = "type")]
20 actor_type: String,
21 #[allow(dead_code)]
22 wrap: bool,
23}
24
25#[derive(Debug, Clone, Deserialize)]
26struct SequenceMessage {
27 id: String,
28 #[serde(default)]
29 from: Option<String>,
30 #[serde(default)]
31 to: Option<String>,
32 #[serde(rename = "type")]
33 message_type: i32,
34 message: Value,
35 #[allow(dead_code)]
36 wrap: bool,
37 activate: bool,
38 #[serde(default)]
39 placement: Option<i32>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43struct SequenceBox {
44 #[serde(rename = "actorKeys")]
45 actor_keys: Vec<String>,
46 #[allow(dead_code)]
47 fill: String,
48 name: Option<String>,
49 #[allow(dead_code)]
50 wrap: bool,
51}
52
53#[derive(Debug, Clone, Deserialize)]
54struct SequenceModel {
55 #[serde(rename = "actorOrder")]
56 actor_order: Vec<String>,
57 actors: std::collections::BTreeMap<String, SequenceActor>,
58 #[serde(default)]
59 boxes: Vec<SequenceBox>,
60 messages: Vec<SequenceMessage>,
61 title: Option<String>,
62 #[serde(rename = "createdActors", default)]
63 created_actors: std::collections::BTreeMap<String, usize>,
64 #[serde(rename = "destroyedActors", default)]
65 destroyed_actors: std::collections::BTreeMap<String, usize>,
66}
67
68fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
69 let mut cur = cfg;
70 for key in path {
71 cur = cur.get(*key)?;
72 }
73 cur.as_f64()
74 .or_else(|| cur.as_i64().map(|n| n as f64))
75 .or_else(|| cur.as_u64().map(|n| n as f64))
76}
77
78fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
79 let mut cur = cfg;
80 for key in path {
81 cur = cur.get(*key)?;
82 }
83 cur.as_str().map(|s| s.to_string())
84}
85
86fn measure_svg_like_with_html_br(
87 measurer: &dyn TextMeasurer,
88 text: &str,
89 style: &TextStyle,
90) -> (f64, f64) {
91 let lines = split_html_br_lines(text);
92 let default_line_height = (style.font_size.max(1.0) * 1.1).max(1.0);
93 if lines.len() <= 1 {
94 let metrics = measurer.measure_wrapped(text, style, None, WrapMode::SvgLikeSingleRun);
95 let h = if metrics.height > 0.0 {
96 metrics.height
97 } else {
98 default_line_height
99 };
100 return (metrics.width.max(0.0), h.max(0.0));
101 }
102 let mut max_w: f64 = 0.0;
103 let mut line_h: f64 = 0.0;
104 for line in &lines {
105 let metrics = measurer.measure_wrapped(line, style, None, WrapMode::SvgLikeSingleRun);
106 max_w = max_w.max(metrics.width.max(0.0));
107 let h = if metrics.height > 0.0 {
108 metrics.height
109 } else {
110 default_line_height
111 };
112 line_h = line_h.max(h.max(0.0));
113 }
114 (
115 max_w,
116 (line_h * lines.len() as f64).max(default_line_height),
117 )
118}
119
120const SEQUENCE_NOTE_WRAP_SLACK_PX: f64 = 12.0;
125
126fn sequence_actor_visual_height(
127 actor_type: &str,
128 base_width: f64,
129 base_height: f64,
130 label_box_height: f64,
131) -> f64 {
132 match actor_type {
133 "boundary" => (60.0 + label_box_height).max(1.0),
136 "database" => ((base_width / 4.0) + label_box_height).max(1.0),
139 "entity" => (36.0 + label_box_height).max(1.0),
140 "control" => (36.0 + 2.0 * label_box_height).max(1.0),
142 _ => base_height.max(1.0),
143 }
144}
145
146fn sequence_actor_lifeline_start_y(
147 actor_type: &str,
148 base_height: f64,
149 box_text_margin: f64,
150) -> f64 {
151 match actor_type {
152 "actor" | "boundary" => 80.0,
154 "control" | "entity" => 75.0,
155 "database" => base_height + 2.0 * box_text_margin,
157 _ => base_height,
158 }
159}
160
161pub fn layout_sequence_diagram(
162 semantic: &Value,
163 effective_config: &Value,
164 measurer: &dyn TextMeasurer,
165) -> Result<SequenceDiagramLayout> {
166 let model: SequenceModel = crate::json::from_value_ref(semantic)?;
167
168 let seq_cfg = effective_config.get("sequence").unwrap_or(&Value::Null);
169 let diagram_margin_x = config_f64(seq_cfg, &["diagramMarginX"]).unwrap_or(50.0);
170 let diagram_margin_y = config_f64(seq_cfg, &["diagramMarginY"]).unwrap_or(10.0);
171 let bottom_margin_adj = config_f64(seq_cfg, &["bottomMarginAdj"]).unwrap_or(1.0);
172 let box_margin = config_f64(seq_cfg, &["boxMargin"]).unwrap_or(10.0);
173 let actor_margin = config_f64(seq_cfg, &["actorMargin"]).unwrap_or(50.0);
174 let actor_width_min = config_f64(seq_cfg, &["width"]).unwrap_or(150.0);
175 let actor_height = config_f64(seq_cfg, &["height"]).unwrap_or(65.0);
176 let message_margin = config_f64(seq_cfg, &["messageMargin"]).unwrap_or(35.0);
177 let wrap_padding = config_f64(seq_cfg, &["wrapPadding"]).unwrap_or(10.0);
178 let box_text_margin = config_f64(seq_cfg, &["boxTextMargin"]).unwrap_or(5.0);
179 let label_box_height = config_f64(seq_cfg, &["labelBoxHeight"]).unwrap_or(20.0);
180 let mirror_actors = seq_cfg
181 .get("mirrorActors")
182 .and_then(|v| v.as_bool())
183 .unwrap_or(true);
184
185 let global_font_family = config_string(effective_config, &["fontFamily"]);
188 let global_font_size = config_f64(effective_config, &["fontSize"]);
189 let global_font_weight = config_string(effective_config, &["fontWeight"]);
190
191 let message_font_family = global_font_family
192 .clone()
193 .or_else(|| config_string(seq_cfg, &["messageFontFamily"]));
194 let message_font_size = global_font_size
195 .or_else(|| config_f64(seq_cfg, &["messageFontSize"]))
196 .unwrap_or(16.0);
197 let message_font_weight = global_font_weight
198 .clone()
199 .or_else(|| config_string(seq_cfg, &["messageFontWeight"]));
200
201 let actor_font_family = global_font_family
202 .clone()
203 .or_else(|| config_string(seq_cfg, &["actorFontFamily"]));
204 let actor_font_size = global_font_size
205 .or_else(|| config_f64(seq_cfg, &["actorFontSize"]))
206 .unwrap_or(16.0);
207 let actor_font_weight = global_font_weight
208 .clone()
209 .or_else(|| config_string(seq_cfg, &["actorFontWeight"]));
210
211 let message_width_scale = 1.0;
215
216 let actor_text_style = TextStyle {
217 font_family: actor_font_family,
218 font_size: actor_font_size,
219 font_weight: actor_font_weight,
220 };
221 let note_font_family = global_font_family
222 .clone()
223 .or_else(|| config_string(seq_cfg, &["noteFontFamily"]));
224 let note_font_size = global_font_size
225 .or_else(|| config_f64(seq_cfg, &["noteFontSize"]))
226 .unwrap_or(16.0);
227 let note_font_weight = global_font_weight
228 .clone()
229 .or_else(|| config_string(seq_cfg, &["noteFontWeight"]));
230 let note_text_style = TextStyle {
231 font_family: note_font_family,
232 font_size: note_font_size,
233 font_weight: note_font_weight,
234 };
235 let msg_text_style = TextStyle {
236 font_family: message_font_family,
237 font_size: message_font_size,
238 font_weight: message_font_weight,
239 };
240
241 let has_boxes = !model.boxes.is_empty();
242 let has_box_titles = model
243 .boxes
244 .iter()
245 .any(|b| b.name.as_deref().is_some_and(|s| !s.trim().is_empty()));
246
247 fn mermaid_text_dimensions_height_px(font_size: f64) -> f64 {
253 (font_size.max(1.0) * (17.0 / 16.0)).max(1.0)
255 }
256
257 let max_box_title_height = if has_box_titles {
258 let line_h = mermaid_text_dimensions_height_px(message_font_size);
259 model
260 .boxes
261 .iter()
262 .filter_map(|b| b.name.as_deref())
263 .map(|s| split_html_br_lines(s).len().max(1) as f64 * line_h)
264 .fold(0.0, f64::max)
265 } else {
266 0.0
267 };
268
269 if model.actor_order.is_empty() {
270 return Err(Error::InvalidModel {
271 message: "sequence model has no actorOrder".to_string(),
272 });
273 }
274
275 let mut actor_widths: Vec<f64> = Vec::with_capacity(model.actor_order.len());
277 let mut actor_base_heights: Vec<f64> = Vec::with_capacity(model.actor_order.len());
278 for id in &model.actor_order {
279 let a = model.actors.get(id).ok_or_else(|| Error::InvalidModel {
280 message: format!("missing actor {id}"),
281 })?;
282 if a.wrap {
283 let wrap_w = (actor_width_min - 2.0 * wrap_padding).max(1.0);
286 let wrapped_lines =
287 wrap_label_like_mermaid_lines(&a.description, measurer, &actor_text_style, wrap_w);
288 let line_count = wrapped_lines.len().max(1) as f64;
289 let text_h = mermaid_text_dimensions_height_px(actor_font_size) * line_count;
290 actor_base_heights.push(actor_height.max(text_h).max(1.0));
291 actor_widths.push(actor_width_min.max(1.0));
292 } else {
293 let (w0, _h0) =
294 measure_svg_like_with_html_br(measurer, &a.description, &actor_text_style);
295 let w = (w0 + 2.0 * wrap_padding).max(actor_width_min);
296 actor_base_heights.push(actor_height.max(1.0));
297 actor_widths.push(w.max(1.0));
298 }
299 }
300
301 let mut actor_index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
304 for (i, id) in model.actor_order.iter().enumerate() {
305 actor_index.insert(id.as_str(), i);
306 }
307
308 let mut actor_to_message_width: Vec<f64> = vec![0.0; model.actor_order.len()];
309 for msg in &model.messages {
310 let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
311 continue;
312 };
313 let Some(&from_idx) = actor_index.get(from) else {
314 continue;
315 };
316 let Some(&to_idx) = actor_index.get(to) else {
317 continue;
318 };
319
320 let placement = msg.placement;
321 if placement == Some(0) && to_idx == 0 {
323 continue;
324 }
325 if placement == Some(1) && to_idx + 1 == model.actor_order.len() {
327 continue;
328 }
329
330 let is_note = placement.is_some();
331 let is_message = !is_note;
332 let style = if is_note {
333 ¬e_text_style
334 } else {
335 &msg_text_style
336 };
337 let text = msg.message.as_str().unwrap_or_default();
338 if text.is_empty() {
339 continue;
340 }
341
342 let measured_text = if msg.wrap {
343 let wrap_w = (actor_width_min - 2.0 * wrap_padding).max(1.0);
346 let lines = wrap_label_like_mermaid_lines(text, measurer, style, wrap_w);
347 lines.join("<br>")
348 } else {
349 text.to_string()
350 };
351 let (w0, _h0) = measure_svg_like_with_html_br(measurer, &measured_text, style);
352 let w0 = w0 * message_width_scale;
353 let message_w = (w0 + 2.0 * wrap_padding).max(0.0);
354
355 let prev_idx = if to_idx > 0 { Some(to_idx - 1) } else { None };
356 let next_idx = if to_idx + 1 < model.actor_order.len() {
357 Some(to_idx + 1)
358 } else {
359 None
360 };
361
362 if is_message && next_idx.is_some_and(|n| n == from_idx) {
363 actor_to_message_width[to_idx] = actor_to_message_width[to_idx].max(message_w);
364 } else if is_message && prev_idx.is_some_and(|p| p == from_idx) {
365 actor_to_message_width[from_idx] = actor_to_message_width[from_idx].max(message_w);
366 } else if is_message && from_idx == to_idx {
367 let half = message_w / 2.0;
368 actor_to_message_width[from_idx] = actor_to_message_width[from_idx].max(half);
369 actor_to_message_width[to_idx] = actor_to_message_width[to_idx].max(half);
370 } else if placement == Some(1) {
371 actor_to_message_width[from_idx] = actor_to_message_width[from_idx].max(message_w);
373 } else if placement == Some(0) {
374 if let Some(p) = prev_idx {
376 actor_to_message_width[p] = actor_to_message_width[p].max(message_w);
377 }
378 } else if placement == Some(2) {
379 if let Some(p) = prev_idx {
381 actor_to_message_width[p] = actor_to_message_width[p].max(message_w / 2.0);
382 }
383 if next_idx.is_some() {
384 actor_to_message_width[from_idx] =
385 actor_to_message_width[from_idx].max(message_w / 2.0);
386 }
387 }
388 }
389
390 let mut actor_margins: Vec<f64> = vec![actor_margin; model.actor_order.len()];
391 for i in 0..model.actor_order.len() {
392 let msg_w = actor_to_message_width[i];
393 if msg_w <= 0.0 {
394 continue;
395 }
396 let w0 = actor_widths[i];
397 let actor_w = if i + 1 < model.actor_order.len() {
398 let w1 = actor_widths[i + 1];
399 msg_w + actor_margin - (w0 / 2.0) - (w1 / 2.0)
400 } else {
401 msg_w + actor_margin - (w0 / 2.0)
402 };
403 actor_margins[i] = actor_w.max(actor_margin);
404 }
405
406 let mut box_margins: Vec<f64> = vec![box_text_margin; model.boxes.len()];
410 for (box_idx, b) in model.boxes.iter().enumerate() {
411 let mut total_width = 0.0;
412 for actor_key in &b.actor_keys {
413 let Some(&i) = actor_index.get(actor_key.as_str()) else {
414 continue;
415 };
416 let actor_margin_for_box = if actor_to_message_width[i] > 0.0 {
417 actor_margins[i]
418 } else {
419 0.0
420 };
421 total_width += actor_widths[i] + actor_margin_for_box;
422 }
423
424 total_width += box_margin * 8.0;
425 total_width -= 2.0 * box_text_margin;
426
427 let Some(name) = b.name.as_deref().filter(|s| !s.trim().is_empty()) else {
428 continue;
429 };
430
431 let (text_w, _text_h) = measure_svg_like_with_html_br(measurer, name, &msg_text_style);
432 let min_width = total_width.max(text_w + 2.0 * wrap_padding);
433 if total_width < min_width {
434 box_margins[box_idx] += (min_width - total_width) / 2.0;
435 }
436 }
437
438 let mut actor_top_offset_y = 0.0;
440 if has_boxes {
441 actor_top_offset_y += box_margin;
442 if has_box_titles {
443 actor_top_offset_y += max_box_title_height;
444 }
445 }
446
447 let mut actor_box: Vec<Option<usize>> = vec![None; model.actor_order.len()];
449 for (box_idx, b) in model.boxes.iter().enumerate() {
450 for actor_key in &b.actor_keys {
451 let Some(&i) = actor_index.get(actor_key.as_str()) else {
452 continue;
453 };
454 actor_box[i] = Some(box_idx);
455 }
456 }
457
458 let mut actor_left_x: Vec<f64> = Vec::with_capacity(model.actor_order.len());
459 let mut prev_width = 0.0;
460 let mut prev_margin = 0.0;
461 let mut prev_box: Option<usize> = None;
462 for i in 0..model.actor_order.len() {
463 let w = actor_widths[i];
464 let cur_box = actor_box[i];
465
466 if prev_box.is_some() && prev_box != cur_box {
468 if let Some(prev) = prev_box {
469 prev_margin += box_margin + box_margins[prev];
470 }
471 }
472
473 if cur_box.is_some() && cur_box != prev_box {
475 if let Some(bi) = cur_box {
476 prev_margin += box_margins[bi];
477 }
478 }
479
480 if model.created_actors.contains_key(&model.actor_order[i]) {
482 prev_margin += w / 2.0;
483 }
484 let x = prev_width + prev_margin;
485 actor_left_x.push(x);
486 prev_width += w + prev_margin;
487 prev_margin = actor_margins[i];
488 prev_box = cur_box;
489 }
490
491 let mut actor_centers_x: Vec<f64> = Vec::with_capacity(model.actor_order.len());
492 for i in 0..model.actor_order.len() {
493 actor_centers_x.push(actor_left_x[i] + actor_widths[i] / 2.0);
494 }
495
496 let message_step = message_margin + (message_font_size / 2.0) + bottom_margin_adj;
497 let msg_label_offset = (message_step - message_font_size) + bottom_margin_adj;
498
499 let mut edges: Vec<LayoutEdge> = Vec::new();
500 let mut nodes: Vec<LayoutNode> = Vec::new();
501 let clusters: Vec<LayoutCluster> = Vec::new();
502
503 let mut max_actor_visual_height: f64 = 0.0;
507 for (idx, id) in model.actor_order.iter().enumerate() {
508 let w = actor_widths[idx];
509 let cx = actor_centers_x[idx];
510 let base_h = actor_base_heights[idx];
511 let actor_type = model
512 .actors
513 .get(id)
514 .map(|a| a.actor_type.as_str())
515 .unwrap_or("participant");
516 let visual_h = sequence_actor_visual_height(actor_type, w, base_h, label_box_height);
517 max_actor_visual_height = max_actor_visual_height.max(visual_h.max(1.0));
518 let top_y = actor_top_offset_y + visual_h / 2.0;
519 nodes.push(LayoutNode {
520 id: format!("actor-top-{id}"),
521 x: cx,
522 y: top_y,
523 width: w,
524 height: visual_h,
525 is_cluster: false,
526 label_width: None,
527 label_height: None,
528 });
529 }
530
531 fn bracketize(s: &str) -> String {
534 let t = s.trim();
535 if t.is_empty() {
536 return "\u{200B}".to_string();
537 }
538 if t.starts_with('[') && t.ends_with(']') {
539 return t.to_string();
540 }
541 format!("[{t}]")
542 }
543
544 fn block_label_text(raw_label: &str) -> String {
545 bracketize(raw_label)
546 }
547
548 let block_base_step = (2.0 * box_margin + box_text_margin + label_box_height).max(0.0);
562 let block_base_step_empty = (block_base_step - label_box_height).max(0.0);
563 let line_step = message_font_size * 1.1875;
564 let block_extra_per_line = (line_step - box_text_margin).max(0.0);
565 let block_end_step = 10.0;
566
567 let mut msg_by_id: std::collections::HashMap<&str, &SequenceMessage> =
568 std::collections::HashMap::new();
569 for msg in &model.messages {
570 msg_by_id.insert(msg.id.as_str(), msg);
571 }
572
573 fn is_self_message_id(
574 msg_id: &str,
575 msg_by_id: &std::collections::HashMap<&str, &SequenceMessage>,
576 ) -> bool {
577 let Some(msg) = msg_by_id.get(msg_id).copied() else {
578 return false;
579 };
580 if msg.message_type == 2 {
582 return false;
583 }
584 msg.from
585 .as_deref()
586 .is_some_and(|from| Some(from) == msg.to.as_deref())
587 }
588
589 fn message_span_x(
590 msg: &SequenceMessage,
591 actor_index: &std::collections::HashMap<&str, usize>,
592 actor_centers_x: &[f64],
593 measurer: &dyn TextMeasurer,
594 msg_text_style: &TextStyle,
595 message_width_scale: f64,
596 ) -> Option<(f64, f64)> {
597 let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
598 return None;
599 };
600 let (Some(fi), Some(ti)) = (actor_index.get(from).copied(), actor_index.get(to).copied())
601 else {
602 return None;
603 };
604 let from_x = actor_centers_x[fi];
605 let to_x = actor_centers_x[ti];
606 let sign = if to_x >= from_x { 1.0 } else { -1.0 };
607 let x1 = from_x + sign * 1.0;
608 let x2 = if from == to { x1 } else { to_x - sign * 4.0 };
609 let cx = (x1 + x2) / 2.0;
610
611 let text = msg.message.as_str().unwrap_or_default();
612 let w = if text.is_empty() {
613 1.0
614 } else {
615 let (w, _h) = measure_svg_like_with_html_br(measurer, text, msg_text_style);
616 (w * message_width_scale).max(1.0)
617 };
618 Some((cx - w / 2.0, cx + w / 2.0))
619 }
620
621 fn block_frame_width(
622 message_ids: &[String],
623 msg_by_id: &std::collections::HashMap<&str, &SequenceMessage>,
624 actor_index: &std::collections::HashMap<&str, usize>,
625 actor_centers_x: &[f64],
626 actor_widths: &[f64],
627 message_margin: f64,
628 box_text_margin: f64,
629 bottom_margin_adj: f64,
630 measurer: &dyn TextMeasurer,
631 msg_text_style: &TextStyle,
632 message_width_scale: f64,
633 ) -> Option<f64> {
634 let mut actor_idxs: Vec<usize> = Vec::new();
635 for msg_id in message_ids {
636 let Some(msg) = msg_by_id.get(msg_id.as_str()).copied() else {
637 continue;
638 };
639 let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
640 continue;
641 };
642 if let Some(i) = actor_index.get(from).copied() {
643 actor_idxs.push(i);
644 }
645 if let Some(i) = actor_index.get(to).copied() {
646 actor_idxs.push(i);
647 }
648 }
649 actor_idxs.sort();
650 actor_idxs.dedup();
651 if actor_idxs.is_empty() {
652 return None;
653 }
654
655 if actor_idxs.len() == 1 {
656 let i = actor_idxs[0];
657 let actor_w = actor_widths.get(i).copied().unwrap_or(150.0);
658 let half_width =
659 actor_w / 2.0 + (message_margin / 2.0) + box_text_margin + bottom_margin_adj;
660 let w = (2.0 * half_width).max(1.0);
661 return Some(w);
662 }
663
664 let min_i = actor_idxs.first().copied()?;
665 let max_i = actor_idxs.last().copied()?;
666 let mut x1 = actor_centers_x[min_i] - 11.0;
667 let mut x2 = actor_centers_x[max_i] + 11.0;
668
669 for msg_id in message_ids {
671 let Some(msg) = msg_by_id.get(msg_id.as_str()).copied() else {
672 continue;
673 };
674 let Some((l, r)) = message_span_x(
675 msg,
676 actor_index,
677 actor_centers_x,
678 measurer,
679 msg_text_style,
680 message_width_scale,
681 ) else {
682 continue;
683 };
684 if l < x1 {
685 x1 = l.floor();
686 }
687 if r > x2 {
688 x2 = r.ceil();
689 }
690 }
691
692 Some((x2 - x1).max(1.0))
693 }
694
695 #[derive(Debug, Clone)]
696 enum BlockStackEntry {
697 Loop {
698 start_id: String,
699 raw_label: String,
700 messages: Vec<String>,
701 },
702 Opt {
703 start_id: String,
704 raw_label: String,
705 messages: Vec<String>,
706 },
707 Break {
708 start_id: String,
709 raw_label: String,
710 messages: Vec<String>,
711 },
712 Alt {
713 section_directives: Vec<(String, String)>,
714 sections: Vec<Vec<String>>,
715 },
716 Par {
717 section_directives: Vec<(String, String)>,
718 sections: Vec<Vec<String>>,
719 },
720 Critical {
721 section_directives: Vec<(String, String)>,
722 sections: Vec<Vec<String>>,
723 },
724 }
725
726 let mut directive_steps: std::collections::HashMap<String, f64> =
727 std::collections::HashMap::new();
728 let mut stack: Vec<BlockStackEntry> = Vec::new();
729 for msg in &model.messages {
730 let raw_label = msg.message.as_str().unwrap_or_default();
731 match msg.message_type {
732 10 => stack.push(BlockStackEntry::Loop {
734 start_id: msg.id.clone(),
735 raw_label: raw_label.to_string(),
736 messages: Vec::new(),
737 }),
738 11 => {
739 if let Some(BlockStackEntry::Loop {
740 start_id,
741 raw_label,
742 messages,
743 }) = stack.pop()
744 {
745 let loop_has_self_message = messages
746 .iter()
747 .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
748 let loop_end_step = if loop_has_self_message {
749 40.0
750 } else {
751 block_end_step
752 };
753
754 if raw_label.trim().is_empty() {
755 directive_steps.insert(start_id, block_base_step_empty);
756 } else if let Some(w) = block_frame_width(
757 &messages,
758 &msg_by_id,
759 &actor_index,
760 &actor_centers_x,
761 &actor_widths,
762 message_margin,
763 box_text_margin,
764 bottom_margin_adj,
765 measurer,
766 &msg_text_style,
767 message_width_scale,
768 ) {
769 let label = block_label_text(&raw_label);
770 let metrics = measurer.measure_wrapped(
771 &label,
772 &msg_text_style,
773 Some(w),
774 WrapMode::SvgLikeSingleRun,
775 );
776 let extra =
777 (metrics.line_count.saturating_sub(1) as f64) * block_extra_per_line;
778 directive_steps.insert(start_id, block_base_step + extra);
779 } else {
780 directive_steps.insert(start_id, block_base_step);
781 }
782
783 directive_steps.insert(msg.id.clone(), loop_end_step);
784 }
785 }
786 15 => stack.push(BlockStackEntry::Opt {
788 start_id: msg.id.clone(),
789 raw_label: raw_label.to_string(),
790 messages: Vec::new(),
791 }),
792 16 => {
793 let mut end_step = block_end_step;
794 if let Some(BlockStackEntry::Opt {
795 start_id,
796 raw_label,
797 messages,
798 }) = stack.pop()
799 {
800 let has_self = messages
801 .iter()
802 .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
803 end_step = if has_self { 40.0 } else { block_end_step };
804 if raw_label.trim().is_empty() {
805 directive_steps.insert(start_id, block_base_step_empty);
806 } else if let Some(w) = block_frame_width(
807 &messages,
808 &msg_by_id,
809 &actor_index,
810 &actor_centers_x,
811 &actor_widths,
812 message_margin,
813 box_text_margin,
814 bottom_margin_adj,
815 measurer,
816 &msg_text_style,
817 message_width_scale,
818 ) {
819 let label = block_label_text(&raw_label);
820 let metrics = measurer.measure_wrapped(
821 &label,
822 &msg_text_style,
823 Some(w),
824 WrapMode::SvgLikeSingleRun,
825 );
826 let extra =
827 (metrics.line_count.saturating_sub(1) as f64) * block_extra_per_line;
828 directive_steps.insert(start_id, block_base_step + extra);
829 } else {
830 directive_steps.insert(start_id, block_base_step);
831 }
832 }
833 directive_steps.insert(msg.id.clone(), end_step);
834 }
835 30 => stack.push(BlockStackEntry::Break {
837 start_id: msg.id.clone(),
838 raw_label: raw_label.to_string(),
839 messages: Vec::new(),
840 }),
841 31 => {
842 let mut end_step = block_end_step;
843 if let Some(BlockStackEntry::Break {
844 start_id,
845 raw_label,
846 messages,
847 }) = stack.pop()
848 {
849 let has_self = messages
850 .iter()
851 .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
852 end_step = if has_self { 40.0 } else { block_end_step };
853 if raw_label.trim().is_empty() {
854 directive_steps.insert(start_id, block_base_step_empty);
855 } else if let Some(w) = block_frame_width(
856 &messages,
857 &msg_by_id,
858 &actor_index,
859 &actor_centers_x,
860 &actor_widths,
861 message_margin,
862 box_text_margin,
863 bottom_margin_adj,
864 measurer,
865 &msg_text_style,
866 message_width_scale,
867 ) {
868 let label = block_label_text(&raw_label);
869 let metrics = measurer.measure_wrapped(
870 &label,
871 &msg_text_style,
872 Some(w),
873 WrapMode::SvgLikeSingleRun,
874 );
875 let extra =
876 (metrics.line_count.saturating_sub(1) as f64) * block_extra_per_line;
877 directive_steps.insert(start_id, block_base_step + extra);
878 } else {
879 directive_steps.insert(start_id, block_base_step);
880 }
881 }
882 directive_steps.insert(msg.id.clone(), end_step);
883 }
884 12 => stack.push(BlockStackEntry::Alt {
886 section_directives: vec![(msg.id.clone(), raw_label.to_string())],
887 sections: vec![Vec::new()],
888 }),
889 13 => {
890 if let Some(BlockStackEntry::Alt {
891 section_directives,
892 sections,
893 }) = stack.last_mut()
894 {
895 section_directives.push((msg.id.clone(), raw_label.to_string()));
896 sections.push(Vec::new());
897 }
898 }
899 14 => {
900 let mut end_step = block_end_step;
901 if let Some(BlockStackEntry::Alt {
902 section_directives,
903 sections,
904 }) = stack.pop()
905 {
906 let has_self = sections
907 .iter()
908 .flatten()
909 .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
910 end_step = if has_self { 40.0 } else { block_end_step };
911 let mut message_ids: Vec<String> = Vec::new();
912 for sec in §ions {
913 message_ids.extend(sec.iter().cloned());
914 }
915 if let Some(w) = block_frame_width(
916 &message_ids,
917 &msg_by_id,
918 &actor_index,
919 &actor_centers_x,
920 &actor_widths,
921 message_margin,
922 box_text_margin,
923 bottom_margin_adj,
924 measurer,
925 &msg_text_style,
926 message_width_scale,
927 ) {
928 for (idx, (id, raw)) in section_directives.into_iter().enumerate() {
929 let is_empty = raw.trim().is_empty();
930 if is_empty {
931 directive_steps.insert(id, block_base_step_empty);
932 continue;
933 }
934 let _ = idx;
935 let label = block_label_text(&raw);
936 let metrics = measurer.measure_wrapped(
937 &label,
938 &msg_text_style,
939 Some(w),
940 WrapMode::SvgLikeSingleRun,
941 );
942 let extra = (metrics.line_count.saturating_sub(1) as f64)
943 * block_extra_per_line;
944 directive_steps.insert(id, block_base_step + extra);
945 }
946 } else {
947 for (id, raw) in section_directives {
948 let step = if raw.trim().is_empty() {
949 block_base_step_empty
950 } else {
951 block_base_step
952 };
953 directive_steps.insert(id, step);
954 }
955 }
956 }
957 directive_steps.insert(msg.id.clone(), end_step);
958 }
959 19 | 32 => stack.push(BlockStackEntry::Par {
961 section_directives: vec![(msg.id.clone(), raw_label.to_string())],
962 sections: vec![Vec::new()],
963 }),
964 20 => {
965 if let Some(BlockStackEntry::Par {
966 section_directives,
967 sections,
968 }) = stack.last_mut()
969 {
970 section_directives.push((msg.id.clone(), raw_label.to_string()));
971 sections.push(Vec::new());
972 }
973 }
974 21 => {
975 let mut end_step = block_end_step;
976 if let Some(BlockStackEntry::Par {
977 section_directives,
978 sections,
979 }) = stack.pop()
980 {
981 let has_self = sections
982 .iter()
983 .flatten()
984 .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
985 end_step = if has_self { 40.0 } else { block_end_step };
986 let mut message_ids: Vec<String> = Vec::new();
987 for sec in §ions {
988 message_ids.extend(sec.iter().cloned());
989 }
990 if let Some(w) = block_frame_width(
991 &message_ids,
992 &msg_by_id,
993 &actor_index,
994 &actor_centers_x,
995 &actor_widths,
996 message_margin,
997 box_text_margin,
998 bottom_margin_adj,
999 measurer,
1000 &msg_text_style,
1001 message_width_scale,
1002 ) {
1003 for (idx, (id, raw)) in section_directives.into_iter().enumerate() {
1004 let is_empty = raw.trim().is_empty();
1005 if is_empty {
1006 directive_steps.insert(id, block_base_step_empty);
1007 continue;
1008 }
1009 let _ = idx;
1010 let label = block_label_text(&raw);
1011 let metrics = measurer.measure_wrapped(
1012 &label,
1013 &msg_text_style,
1014 Some(w),
1015 WrapMode::SvgLikeSingleRun,
1016 );
1017 let extra = (metrics.line_count.saturating_sub(1) as f64)
1018 * block_extra_per_line;
1019 directive_steps.insert(id, block_base_step + extra);
1020 }
1021 } else {
1022 for (id, raw) in section_directives {
1023 let step = if raw.trim().is_empty() {
1024 block_base_step_empty
1025 } else {
1026 block_base_step
1027 };
1028 directive_steps.insert(id, step);
1029 }
1030 }
1031 }
1032 directive_steps.insert(msg.id.clone(), end_step);
1033 }
1034 27 => stack.push(BlockStackEntry::Critical {
1036 section_directives: vec![(msg.id.clone(), raw_label.to_string())],
1037 sections: vec![Vec::new()],
1038 }),
1039 28 => {
1040 if let Some(BlockStackEntry::Critical {
1041 section_directives,
1042 sections,
1043 }) = stack.last_mut()
1044 {
1045 section_directives.push((msg.id.clone(), raw_label.to_string()));
1046 sections.push(Vec::new());
1047 }
1048 }
1049 29 => {
1050 let mut end_step = block_end_step;
1051 if let Some(BlockStackEntry::Critical {
1052 section_directives,
1053 sections,
1054 }) = stack.pop()
1055 {
1056 let has_self = sections
1057 .iter()
1058 .flatten()
1059 .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
1060 end_step = if has_self { 40.0 } else { block_end_step };
1061 let mut message_ids: Vec<String> = Vec::new();
1062 for sec in §ions {
1063 message_ids.extend(sec.iter().cloned());
1064 }
1065 if let Some(w) = block_frame_width(
1066 &message_ids,
1067 &msg_by_id,
1068 &actor_index,
1069 &actor_centers_x,
1070 &actor_widths,
1071 message_margin,
1072 box_text_margin,
1073 bottom_margin_adj,
1074 measurer,
1075 &msg_text_style,
1076 message_width_scale,
1077 ) {
1078 for (idx, (id, raw)) in section_directives.into_iter().enumerate() {
1079 let is_empty = raw.trim().is_empty();
1080 if is_empty {
1081 directive_steps.insert(id, block_base_step_empty);
1082 continue;
1083 }
1084 let _ = idx;
1085 let label = block_label_text(&raw);
1086 let metrics = measurer.measure_wrapped(
1087 &label,
1088 &msg_text_style,
1089 Some(w),
1090 WrapMode::SvgLikeSingleRun,
1091 );
1092 let extra = (metrics.line_count.saturating_sub(1) as f64)
1093 * block_extra_per_line;
1094 directive_steps.insert(id, block_base_step + extra);
1095 }
1096 } else {
1097 for (id, raw) in section_directives {
1098 let step = if raw.trim().is_empty() {
1099 block_base_step_empty
1100 } else {
1101 block_base_step
1102 };
1103 directive_steps.insert(id, step);
1104 }
1105 }
1106 }
1107 directive_steps.insert(msg.id.clone(), end_step);
1108 }
1109 _ => {
1110 if msg.from.is_some() && msg.to.is_some() {
1113 for entry in stack.iter_mut() {
1114 match entry {
1115 BlockStackEntry::Alt { sections, .. }
1116 | BlockStackEntry::Par { sections, .. }
1117 | BlockStackEntry::Critical { sections, .. } => {
1118 if let Some(cur) = sections.last_mut() {
1119 cur.push(msg.id.clone());
1120 }
1121 }
1122 BlockStackEntry::Loop { messages, .. }
1123 | BlockStackEntry::Opt { messages, .. }
1124 | BlockStackEntry::Break { messages, .. } => {
1125 messages.push(msg.id.clone());
1126 }
1127 }
1128 }
1129 }
1130 }
1131 }
1132 }
1133
1134 #[derive(Debug, Clone)]
1135 struct RectOpen {
1136 start_id: String,
1137 top_y: f64,
1138 bounds: Option<merman_core::geom::Box2>,
1139 }
1140
1141 impl RectOpen {
1142 fn include_min_max(&mut self, min_x: f64, max_x: f64, max_y: f64) {
1143 let r = merman_core::geom::Box2::from_min_max(min_x, self.top_y, max_x, max_y);
1144 if let Some(ref mut cur) = self.bounds {
1145 cur.union(r);
1146 } else {
1147 self.bounds = Some(r);
1148 }
1149 }
1150 }
1151
1152 let note_width_single = actor_width_min;
1156 let rect_step_start = 20.0;
1157 let rect_step_end = 10.0;
1158 let note_gap = 10.0;
1159 let note_text_pad_total = 20.0;
1162 let note_top_offset = message_step - note_gap;
1163
1164 let mut cursor_y = actor_top_offset_y + max_actor_visual_height + message_step;
1165 let mut rect_stack: Vec<RectOpen> = Vec::new();
1166 let activation_width = config_f64(seq_cfg, &["activationWidth"])
1167 .unwrap_or(10.0)
1168 .max(1.0);
1169 let mut activation_stacks: std::collections::BTreeMap<&str, Vec<f64>> =
1170 std::collections::BTreeMap::new();
1171
1172 let mut created_actor_top_center_y: std::collections::BTreeMap<String, f64> =
1177 std::collections::BTreeMap::new();
1178 let mut destroyed_actor_bottom_top_y: std::collections::BTreeMap<String, f64> =
1179 std::collections::BTreeMap::new();
1180
1181 let actor_visual_height_for_id = |actor_id: &str| -> f64 {
1182 let Some(idx) = actor_index.get(actor_id).copied() else {
1183 return actor_height.max(1.0);
1184 };
1185 let w = actor_widths.get(idx).copied().unwrap_or(actor_width_min);
1186 let base_h = actor_base_heights.get(idx).copied().unwrap_or(actor_height);
1187 model
1188 .actors
1189 .get(actor_id)
1190 .map(|a| a.actor_type.as_str())
1191 .map(|t| sequence_actor_visual_height(t, w, base_h, label_box_height))
1192 .unwrap_or(base_h.max(1.0))
1193 };
1194 let actor_is_type_width_limited = |actor_id: &str| -> bool {
1195 model
1196 .actors
1197 .get(actor_id)
1198 .map(|a| {
1199 matches!(
1200 a.actor_type.as_str(),
1201 "actor" | "control" | "entity" | "database"
1202 )
1203 })
1204 .unwrap_or(false)
1205 };
1206
1207 for (msg_idx, msg) in model.messages.iter().enumerate() {
1208 match msg.message_type {
1209 17 => {
1211 let Some(actor_id) = msg.from.as_deref() else {
1212 continue;
1213 };
1214 let Some(&idx) = actor_index.get(actor_id) else {
1215 continue;
1216 };
1217 let cx = actor_centers_x[idx];
1218 let stack = activation_stacks.entry(actor_id).or_default();
1219 let stacked_size = stack.len();
1220 let startx = cx + (((stacked_size as f64) - 1.0) * activation_width) / 2.0;
1221 stack.push(startx);
1222 continue;
1223 }
1224 18 => {
1226 let Some(actor_id) = msg.from.as_deref() else {
1227 continue;
1228 };
1229 if let Some(stack) = activation_stacks.get_mut(actor_id) {
1230 let _ = stack.pop();
1231 }
1232 continue;
1233 }
1234 _ => {}
1235 }
1236
1237 if let Some(step) = directive_steps.get(msg.id.as_str()).copied() {
1238 cursor_y += step;
1239 continue;
1240 }
1241 match msg.message_type {
1242 22 => {
1244 rect_stack.push(RectOpen {
1245 start_id: msg.id.clone(),
1246 top_y: cursor_y - note_top_offset,
1247 bounds: None,
1248 });
1249 cursor_y += rect_step_start;
1250 continue;
1251 }
1252 23 => {
1254 if let Some(open) = rect_stack.pop() {
1255 let rect_left = open.bounds.map(|b| b.min_x()).unwrap_or_else(|| {
1256 actor_centers_x
1257 .iter()
1258 .copied()
1259 .fold(f64::INFINITY, f64::min)
1260 - 11.0
1261 });
1262 let rect_right = open.bounds.map(|b| b.max_x()).unwrap_or_else(|| {
1263 actor_centers_x
1264 .iter()
1265 .copied()
1266 .fold(f64::NEG_INFINITY, f64::max)
1267 + 11.0
1268 });
1269 let rect_bottom = open
1270 .bounds
1271 .map(|b| b.max_y() + 10.0)
1272 .unwrap_or(open.top_y + 10.0);
1273 let rect_w = (rect_right - rect_left).max(1.0);
1274 let rect_h = (rect_bottom - open.top_y).max(1.0);
1275
1276 nodes.push(LayoutNode {
1277 id: format!("rect-{}", open.start_id),
1278 x: rect_left + rect_w / 2.0,
1279 y: open.top_y + rect_h / 2.0,
1280 width: rect_w,
1281 height: rect_h,
1282 is_cluster: false,
1283 label_width: None,
1284 label_height: None,
1285 });
1286
1287 if let Some(parent) = rect_stack.last_mut() {
1288 parent.include_min_max(rect_left - 10.0, rect_right + 10.0, rect_bottom);
1289 }
1290 }
1291 cursor_y += rect_step_end;
1292 continue;
1293 }
1294 _ => {}
1295 }
1296
1297 if msg.message_type == 2 {
1299 let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
1300 continue;
1301 };
1302 let (Some(fi), Some(ti)) =
1303 (actor_index.get(from).copied(), actor_index.get(to).copied())
1304 else {
1305 continue;
1306 };
1307 let fx = actor_centers_x[fi];
1308 let tx = actor_centers_x[ti];
1309
1310 let placement = msg.placement.unwrap_or(2);
1311 let (mut note_x, mut note_w) = match placement {
1312 0 => (fx - 25.0 - note_width_single, note_width_single),
1314 1 => (fx + 25.0, note_width_single),
1316 _ => {
1318 if (fx - tx).abs() < 0.0001 {
1319 let mut w = note_width_single;
1326 if msg.wrap {
1327 w = w.max(actor_widths.get(fi).copied().unwrap_or(note_width_single));
1328 }
1329 (fx - (w / 2.0), w)
1330 } else {
1331 let left = fx.min(tx) - 25.0;
1332 let right = fx.max(tx) + 25.0;
1333 let w = (right - left).max(note_width_single);
1334 (left, w)
1335 }
1336 }
1337 };
1338
1339 let text = msg.message.as_str().unwrap_or_default();
1340 let (text_w, h) = if msg.wrap {
1341 let w0 = {
1349 let init_lines = wrap_label_like_mermaid_lines_floored_bbox(
1350 text,
1351 measurer,
1352 ¬e_text_style,
1353 (note_width_single + SEQUENCE_NOTE_WRAP_SLACK_PX).max(1.0),
1354 );
1355 let init_wrapped = init_lines.join("<br/>");
1356 let (w, _h) =
1357 measure_svg_like_with_html_br(measurer, &init_wrapped, ¬e_text_style);
1358 w.max(0.0)
1359 };
1360
1361 if placement == 0 {
1362 note_w = note_w.max((w0 + note_text_pad_total).round().max(1.0));
1365 note_x = fx - 25.0 - note_w;
1366 }
1367
1368 let wrap_w = (note_w - note_text_pad_total).max(1.0);
1369 let lines = wrap_label_like_mermaid_lines_floored_bbox(
1370 text,
1371 measurer,
1372 ¬e_text_style,
1373 (wrap_w + SEQUENCE_NOTE_WRAP_SLACK_PX).max(1.0),
1374 );
1375 let wrapped = lines.join("<br/>");
1376 let (w, h) = measure_svg_like_with_html_br(measurer, &wrapped, ¬e_text_style);
1377 (w.max(0.0), h.max(0.0))
1378 } else {
1379 measure_svg_like_with_html_br(measurer, text, ¬e_text_style)
1380 };
1381
1382 let padded_w = (text_w + note_text_pad_total).round().max(1.0);
1386 if !msg.wrap {
1387 match placement {
1388 0 | 1 => {
1390 note_w = note_w.max(padded_w);
1391 }
1392 _ => {
1394 if (fx - tx).abs() < 0.0001 {
1395 note_w = note_w.max(padded_w);
1396 }
1397 }
1398 }
1399 }
1400 let note_h = (h + note_text_pad_total).round().max(1.0);
1401 let note_y = (cursor_y - note_top_offset).round();
1402
1403 nodes.push(LayoutNode {
1404 id: format!("note-{}", msg.id),
1405 x: note_x + note_w / 2.0,
1406 y: note_y + note_h / 2.0,
1407 width: note_w.max(1.0),
1408 height: note_h,
1409 is_cluster: false,
1410 label_width: None,
1411 label_height: None,
1412 });
1413
1414 for open in rect_stack.iter_mut() {
1415 open.include_min_max(note_x - 10.0, note_x + note_w + 10.0, note_y + note_h);
1416 }
1417
1418 cursor_y += note_h + note_gap;
1419 continue;
1420 }
1421
1422 let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
1424 continue;
1425 };
1426 let (Some(fi), Some(ti)) = (actor_index.get(from).copied(), actor_index.get(to).copied())
1427 else {
1428 continue;
1429 };
1430 let from_x = actor_centers_x[fi];
1431 let to_x = actor_centers_x[ti];
1432
1433 let (from_left, from_right) = activation_stacks
1434 .get(from)
1435 .and_then(|s| s.last().copied())
1436 .map(|startx| (startx, startx + activation_width))
1437 .unwrap_or((from_x - 1.0, from_x + 1.0));
1438
1439 let (to_left, to_right) = activation_stacks
1440 .get(to)
1441 .and_then(|s| s.last().copied())
1442 .map(|startx| (startx, startx + activation_width))
1443 .unwrap_or((to_x - 1.0, to_x + 1.0));
1444
1445 let is_arrow_to_right = from_left <= to_left;
1446 let mut startx = if is_arrow_to_right {
1447 from_right
1448 } else {
1449 from_left
1450 };
1451 let mut stopx = if is_arrow_to_right { to_left } else { to_right };
1452
1453 let adjust_value = |v: f64| if is_arrow_to_right { -v } else { v };
1454 let is_arrow_to_activation = (to_left - to_right).abs() > 2.0;
1455
1456 let is_self = from == to;
1457 if is_self {
1458 stopx = startx;
1459 } else {
1460 if msg.activate && !is_arrow_to_activation {
1461 stopx += adjust_value(activation_width / 2.0 - 1.0);
1462 }
1463
1464 if !matches!(msg.message_type, 5 | 6) {
1465 stopx += adjust_value(3.0);
1466 }
1467
1468 if matches!(msg.message_type, 33 | 34) {
1469 startx -= adjust_value(3.0);
1470 }
1471 }
1472
1473 if !is_self {
1474 const ACTOR_TYPE_WIDTH_HALF: f64 = 18.0;
1476 if model
1477 .created_actors
1478 .get(to)
1479 .is_some_and(|&idx| idx == msg_idx)
1480 {
1481 let adjustment = if actor_is_type_width_limited(to) {
1482 ACTOR_TYPE_WIDTH_HALF + 3.0
1483 } else {
1484 actor_widths[ti] / 2.0 + 3.0
1485 };
1486 if to_x < from_x {
1487 stopx += adjustment;
1488 } else {
1489 stopx -= adjustment;
1490 }
1491 } else if model
1492 .destroyed_actors
1493 .get(from)
1494 .is_some_and(|&idx| idx == msg_idx)
1495 {
1496 let adjustment = if actor_is_type_width_limited(from) {
1497 ACTOR_TYPE_WIDTH_HALF
1498 } else {
1499 actor_widths[fi] / 2.0
1500 };
1501 if from_x < to_x {
1502 startx += adjustment;
1503 } else {
1504 startx -= adjustment;
1505 }
1506 } else if model
1507 .destroyed_actors
1508 .get(to)
1509 .is_some_and(|&idx| idx == msg_idx)
1510 {
1511 let adjustment = if actor_is_type_width_limited(to) {
1512 ACTOR_TYPE_WIDTH_HALF + 3.0
1513 } else {
1514 actor_widths[ti] / 2.0 + 3.0
1515 };
1516 if to_x < from_x {
1517 stopx += adjustment;
1518 } else {
1519 stopx -= adjustment;
1520 }
1521 }
1522 }
1523
1524 let text = msg.message.as_str().unwrap_or_default();
1525 let bounded_width = (startx - stopx).abs().max(0.0);
1526 let wrapped_text = if !text.is_empty() && msg.wrap {
1527 let wrap_w = (bounded_width + 3.0 * wrap_padding)
1531 .max(actor_width_min)
1532 .max(1.0);
1533 let lines =
1534 wrap_label_like_mermaid_lines_floored_bbox(text, measurer, &msg_text_style, wrap_w);
1535 Some(lines.join("<br>"))
1536 } else {
1537 None
1538 };
1539 let effective_text = wrapped_text.as_deref().unwrap_or(text);
1540
1541 let (line_y, label_base_y, cursor_step) = if effective_text.is_empty() {
1542 let line_y = cursor_y - (message_step - box_margin);
1550 (line_y, cursor_y, box_margin)
1551 } else {
1552 let lines = split_html_br_lines(effective_text).len().max(1);
1557 let bbox_line_h = (message_font_size + bottom_margin_adj).max(0.0);
1561 let extra = (lines.saturating_sub(1) as f64) * bbox_line_h;
1562 (cursor_y + extra, cursor_y, message_step + extra)
1563 };
1564
1565 let x1 = startx;
1566 let x2 = stopx;
1567
1568 let label = if effective_text.is_empty() {
1569 Some(LayoutLabel {
1572 x: ((x1 + x2) / 2.0).round(),
1573 y: (label_base_y - msg_label_offset).round(),
1574 width: 1.0,
1575 height: message_font_size.max(1.0),
1576 })
1577 } else {
1578 let (w, h) = measure_svg_like_with_html_br(measurer, effective_text, &msg_text_style);
1579 Some(LayoutLabel {
1580 x: ((x1 + x2) / 2.0).round(),
1581 y: (label_base_y - msg_label_offset).round(),
1582 width: (w * message_width_scale).max(1.0),
1583 height: h.max(1.0),
1584 })
1585 };
1586
1587 edges.push(LayoutEdge {
1588 id: format!("msg-{}", msg.id),
1589 from: from.to_string(),
1590 to: to.to_string(),
1591 from_cluster: None,
1592 to_cluster: None,
1593 points: vec![
1594 LayoutPoint { x: x1, y: line_y },
1595 LayoutPoint { x: x2, y: line_y },
1596 ],
1597 label,
1598 start_label_left: None,
1599 start_label_right: None,
1600 end_label_left: None,
1601 end_label_right: None,
1602 start_marker: None,
1603 end_marker: None,
1604 stroke_dasharray: None,
1605 });
1606
1607 for open in rect_stack.iter_mut() {
1608 let lx = from_x.min(to_x) - 11.0;
1609 let rx = from_x.max(to_x) + 11.0;
1610 open.include_min_max(lx, rx, line_y);
1611 }
1612
1613 cursor_y += cursor_step;
1614 if is_self {
1615 cursor_y += 30.0;
1617 }
1618
1619 if model
1621 .created_actors
1622 .get(to)
1623 .is_some_and(|&idx| idx == msg_idx)
1624 {
1625 let h = actor_visual_height_for_id(to);
1626 created_actor_top_center_y.insert(to.to_string(), line_y);
1627 cursor_y += h / 2.0;
1628 } else if model
1629 .destroyed_actors
1630 .get(from)
1631 .is_some_and(|&idx| idx == msg_idx)
1632 {
1633 let h = actor_visual_height_for_id(from);
1634 destroyed_actor_bottom_top_y.insert(from.to_string(), line_y - h / 2.0);
1635 cursor_y += h / 2.0;
1636 } else if model
1637 .destroyed_actors
1638 .get(to)
1639 .is_some_and(|&idx| idx == msg_idx)
1640 {
1641 let h = actor_visual_height_for_id(to);
1642 destroyed_actor_bottom_top_y.insert(to.to_string(), line_y - h / 2.0);
1643 cursor_y += h / 2.0;
1644 }
1645 }
1646
1647 let bottom_margin = message_margin - message_font_size + bottom_margin_adj;
1648 let bottom_box_top_y = (cursor_y - message_step) + bottom_margin;
1649
1650 for n in nodes.iter_mut() {
1652 let Some(actor_id) = n.id.strip_prefix("actor-top-") else {
1653 continue;
1654 };
1655 if let Some(y) = created_actor_top_center_y.get(actor_id).copied() {
1656 n.y = y;
1657 }
1658 }
1659
1660 for (idx, id) in model.actor_order.iter().enumerate() {
1661 let w = actor_widths[idx];
1662 let cx = actor_centers_x[idx];
1663 let base_h = actor_base_heights[idx];
1664 let actor_type = model
1665 .actors
1666 .get(id)
1667 .map(|a| a.actor_type.as_str())
1668 .unwrap_or("participant");
1669 let visual_h = sequence_actor_visual_height(actor_type, w, base_h, label_box_height);
1670 let bottom_top_y = destroyed_actor_bottom_top_y
1671 .get(id)
1672 .copied()
1673 .unwrap_or(bottom_box_top_y);
1674 let bottom_visual_h = if mirror_actors { visual_h } else { 0.0 };
1675 nodes.push(LayoutNode {
1676 id: format!("actor-bottom-{id}"),
1677 x: cx,
1678 y: bottom_top_y + bottom_visual_h / 2.0,
1679 width: w,
1680 height: bottom_visual_h,
1681 is_cluster: false,
1682 label_width: None,
1683 label_height: None,
1684 });
1685
1686 let top_center_y = created_actor_top_center_y
1687 .get(id)
1688 .copied()
1689 .unwrap_or(actor_top_offset_y + visual_h / 2.0);
1690 let top_left_y = top_center_y - visual_h / 2.0;
1691 let lifeline_start_y =
1692 top_left_y + sequence_actor_lifeline_start_y(actor_type, base_h, box_text_margin);
1693
1694 edges.push(LayoutEdge {
1695 id: format!("lifeline-{id}"),
1696 from: format!("actor-top-{id}"),
1697 to: format!("actor-bottom-{id}"),
1698 from_cluster: None,
1699 to_cluster: None,
1700 points: vec![
1701 LayoutPoint {
1702 x: cx,
1703 y: lifeline_start_y,
1704 },
1705 LayoutPoint {
1706 x: cx,
1707 y: bottom_top_y,
1708 },
1709 ],
1710 label: None,
1711 start_label_left: None,
1712 start_label_right: None,
1713 end_label_left: None,
1714 end_label_right: None,
1715 start_marker: None,
1716 end_marker: None,
1717 stroke_dasharray: None,
1718 });
1719 }
1720
1721 let block_bounds = {
1726 use std::collections::HashMap;
1727
1728 let nodes_by_id: HashMap<&str, &LayoutNode> = nodes
1729 .iter()
1730 .map(|n| (n.id.as_str(), n))
1731 .collect::<HashMap<_, _>>();
1732 let edges_by_id: HashMap<&str, &LayoutEdge> = edges
1733 .iter()
1734 .map(|e| (e.id.as_str(), e))
1735 .collect::<HashMap<_, _>>();
1736
1737 let mut msg_endpoints: HashMap<&str, (&str, &str)> = HashMap::new();
1738 for msg in &model.messages {
1739 let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
1740 continue;
1741 };
1742 msg_endpoints.insert(msg.id.as_str(), (from, to));
1743 }
1744
1745 fn item_y_range(
1746 item_id: &str,
1747 nodes_by_id: &HashMap<&str, &LayoutNode>,
1748 edges_by_id: &HashMap<&str, &LayoutEdge>,
1749 msg_endpoints: &HashMap<&str, (&str, &str)>,
1750 ) -> Option<(f64, f64)> {
1751 const SELF_MESSAGE_EXTRA_Y: f64 = 60.0;
1755 let edge_id = format!("msg-{item_id}");
1756 if let Some(e) = edges_by_id.get(edge_id.as_str()).copied() {
1757 let y = e.points.first()?.y;
1758 let extra = msg_endpoints
1759 .get(item_id)
1760 .copied()
1761 .filter(|(from, to)| from == to)
1762 .map(|_| SELF_MESSAGE_EXTRA_Y)
1763 .unwrap_or(0.0);
1764 return Some((y, y + extra));
1765 }
1766
1767 let node_id = format!("note-{item_id}");
1768 let n = nodes_by_id.get(node_id.as_str()).copied()?;
1769 let top = n.y - n.height / 2.0;
1770 let bottom = n.y + n.height / 2.0;
1771 Some((top, bottom))
1772 }
1773
1774 fn frame_x_from_item_ids<'a>(
1775 item_ids: impl IntoIterator<Item = &'a String>,
1776 nodes_by_id: &HashMap<&str, &LayoutNode>,
1777 edges_by_id: &HashMap<&str, &LayoutEdge>,
1778 msg_endpoints: &HashMap<&str, (&str, &str)>,
1779 ) -> Option<(f64, f64, f64)> {
1780 const SIDE_PAD: f64 = 11.0;
1781 const GEOM_PAD: f64 = 10.0;
1782 let mut min_cx = f64::INFINITY;
1783 let mut max_cx = f64::NEG_INFINITY;
1784 let mut min_left = f64::INFINITY;
1785 let mut geom_min_x = f64::INFINITY;
1786 let mut geom_max_x = f64::NEG_INFINITY;
1787
1788 for id in item_ids {
1789 let note_id = format!("note-{id}");
1791 if let Some(n) = nodes_by_id.get(note_id.as_str()).copied() {
1792 geom_min_x = geom_min_x.min(n.x - n.width / 2.0 - GEOM_PAD);
1793 geom_max_x = geom_max_x.max(n.x + n.width / 2.0 + GEOM_PAD);
1794 }
1795
1796 let Some((from, to)) = msg_endpoints.get(id.as_str()).copied() else {
1797 continue;
1798 };
1799 for actor_id in [from, to] {
1800 let actor_node_id = format!("actor-top-{actor_id}");
1801 let Some(n) = nodes_by_id.get(actor_node_id.as_str()).copied() else {
1802 continue;
1803 };
1804 min_cx = min_cx.min(n.x);
1805 max_cx = max_cx.max(n.x);
1806 min_left = min_left.min(n.x - n.width / 2.0);
1807 }
1808
1809 let edge_id = format!("msg-{id}");
1811 if let Some(e) = edges_by_id.get(edge_id.as_str()).copied() {
1812 for p in &e.points {
1813 geom_min_x = geom_min_x.min(p.x);
1814 geom_max_x = geom_max_x.max(p.x);
1815 }
1816 if let Some(label) = e.label.as_ref() {
1817 geom_min_x = geom_min_x.min(label.x - (label.width / 2.0) - GEOM_PAD);
1818 geom_max_x = geom_max_x.max(label.x + (label.width / 2.0) + GEOM_PAD);
1819 }
1820 }
1821 }
1822
1823 if !min_cx.is_finite() || !max_cx.is_finite() {
1824 return None;
1825 }
1826 let mut x1 = min_cx - SIDE_PAD;
1827 let mut x2 = max_cx + SIDE_PAD;
1828 if geom_min_x.is_finite() {
1829 x1 = x1.min(geom_min_x);
1830 }
1831 if geom_max_x.is_finite() {
1832 x2 = x2.max(geom_max_x);
1833 }
1834 Some((x1, x2, min_left))
1835 }
1836
1837 #[derive(Debug)]
1838 enum BlockStackEntry {
1839 Loop { items: Vec<String> },
1840 Opt { items: Vec<String> },
1841 Break { items: Vec<String> },
1842 Alt { sections: Vec<Vec<String>> },
1843 Par { sections: Vec<Vec<String>> },
1844 Critical { sections: Vec<Vec<String>> },
1845 }
1846
1847 let mut block_min_x = f64::INFINITY;
1848 let mut block_min_y = f64::INFINITY;
1849 let mut block_max_x = f64::NEG_INFINITY;
1850 let mut block_max_y = f64::NEG_INFINITY;
1851
1852 let mut stack: Vec<BlockStackEntry> = Vec::new();
1853 for msg in &model.messages {
1854 let msg_id = msg.id.clone();
1855 match msg.message_type {
1856 10 => stack.push(BlockStackEntry::Loop { items: Vec::new() }),
1857 11 => {
1858 if let Some(BlockStackEntry::Loop { items }) = stack.pop() {
1859 if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1860 frame_x_from_item_ids(
1861 &items,
1862 &nodes_by_id,
1863 &edges_by_id,
1864 &msg_endpoints,
1865 ),
1866 items
1867 .iter()
1868 .filter_map(|id| {
1869 item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1870 })
1871 .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1872 ) {
1873 let frame_y1 = y0 - 79.0;
1874 let frame_y2 = y1 + 10.0;
1875 block_min_x = block_min_x.min(x1);
1876 block_max_x = block_max_x.max(x2);
1877 block_min_y = block_min_y.min(frame_y1);
1878 block_max_y = block_max_y.max(frame_y2);
1879 }
1880 }
1881 }
1882 15 => stack.push(BlockStackEntry::Opt { items: Vec::new() }),
1883 16 => {
1884 if let Some(BlockStackEntry::Opt { items }) = stack.pop() {
1885 if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1886 frame_x_from_item_ids(
1887 &items,
1888 &nodes_by_id,
1889 &edges_by_id,
1890 &msg_endpoints,
1891 ),
1892 items
1893 .iter()
1894 .filter_map(|id| {
1895 item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1896 })
1897 .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1898 ) {
1899 let frame_y1 = y0 - 79.0;
1900 let frame_y2 = y1 + 10.0;
1901 block_min_x = block_min_x.min(x1);
1902 block_max_x = block_max_x.max(x2);
1903 block_min_y = block_min_y.min(frame_y1);
1904 block_max_y = block_max_y.max(frame_y2);
1905 }
1906 }
1907 }
1908 30 => stack.push(BlockStackEntry::Break { items: Vec::new() }),
1909 31 => {
1910 if let Some(BlockStackEntry::Break { items }) = stack.pop() {
1911 if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1912 frame_x_from_item_ids(
1913 &items,
1914 &nodes_by_id,
1915 &edges_by_id,
1916 &msg_endpoints,
1917 ),
1918 items
1919 .iter()
1920 .filter_map(|id| {
1921 item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1922 })
1923 .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1924 ) {
1925 let frame_y1 = y0 - 93.0;
1926 let frame_y2 = y1 + 10.0;
1927 block_min_x = block_min_x.min(x1);
1928 block_max_x = block_max_x.max(x2);
1929 block_min_y = block_min_y.min(frame_y1);
1930 block_max_y = block_max_y.max(frame_y2);
1931 }
1932 }
1933 }
1934 12 => stack.push(BlockStackEntry::Alt {
1935 sections: vec![Vec::new()],
1936 }),
1937 13 => {
1938 if let Some(BlockStackEntry::Alt { sections }) = stack.last_mut() {
1939 sections.push(Vec::new());
1940 }
1941 }
1942 14 => {
1943 if let Some(BlockStackEntry::Alt { sections }) = stack.pop() {
1944 let items: Vec<String> = sections.into_iter().flatten().collect();
1945 if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1946 frame_x_from_item_ids(
1947 &items,
1948 &nodes_by_id,
1949 &edges_by_id,
1950 &msg_endpoints,
1951 ),
1952 items
1953 .iter()
1954 .filter_map(|id| {
1955 item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1956 })
1957 .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1958 ) {
1959 let frame_y1 = y0 - 79.0;
1960 let frame_y2 = y1 + 10.0;
1961 block_min_x = block_min_x.min(x1);
1962 block_max_x = block_max_x.max(x2);
1963 block_min_y = block_min_y.min(frame_y1);
1964 block_max_y = block_max_y.max(frame_y2);
1965 }
1966 }
1967 }
1968 19 | 32 => stack.push(BlockStackEntry::Par {
1969 sections: vec![Vec::new()],
1970 }),
1971 20 => {
1972 if let Some(BlockStackEntry::Par { sections }) = stack.last_mut() {
1973 sections.push(Vec::new());
1974 }
1975 }
1976 21 => {
1977 if let Some(BlockStackEntry::Par { sections }) = stack.pop() {
1978 let items: Vec<String> = sections.into_iter().flatten().collect();
1979 if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1980 frame_x_from_item_ids(
1981 &items,
1982 &nodes_by_id,
1983 &edges_by_id,
1984 &msg_endpoints,
1985 ),
1986 items
1987 .iter()
1988 .filter_map(|id| {
1989 item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1990 })
1991 .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1992 ) {
1993 let frame_y1 = y0 - 79.0;
1994 let frame_y2 = y1 + 10.0;
1995 block_min_x = block_min_x.min(x1);
1996 block_max_x = block_max_x.max(x2);
1997 block_min_y = block_min_y.min(frame_y1);
1998 block_max_y = block_max_y.max(frame_y2);
1999 }
2000 }
2001 }
2002 27 => stack.push(BlockStackEntry::Critical {
2003 sections: vec![Vec::new()],
2004 }),
2005 28 => {
2006 if let Some(BlockStackEntry::Critical { sections }) = stack.last_mut() {
2007 sections.push(Vec::new());
2008 }
2009 }
2010 29 => {
2011 if let Some(BlockStackEntry::Critical { sections }) = stack.pop() {
2012 let section_count = sections.len();
2013 let items: Vec<String> = sections.into_iter().flatten().collect();
2014 if let (Some((mut x1, x2, min_left)), Some((y0, y1))) = (
2015 frame_x_from_item_ids(
2016 &items,
2017 &nodes_by_id,
2018 &edges_by_id,
2019 &msg_endpoints,
2020 ),
2021 items
2022 .iter()
2023 .filter_map(|id| {
2024 item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
2025 })
2026 .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
2027 ) {
2028 if min_left.is_finite() && !items.is_empty() && section_count > 1 {
2029 x1 = x1.min(min_left - 9.0);
2030 }
2031 let frame_y1 = y0 - 79.0;
2032 let frame_y2 = y1 + 10.0;
2033 block_min_x = block_min_x.min(x1);
2034 block_max_x = block_max_x.max(x2);
2035 block_min_y = block_min_y.min(frame_y1);
2036 block_max_y = block_max_y.max(frame_y2);
2037 }
2038 }
2039 }
2040 2 => {
2041 for entry in stack.iter_mut() {
2042 match entry {
2043 BlockStackEntry::Alt { sections }
2044 | BlockStackEntry::Par { sections }
2045 | BlockStackEntry::Critical { sections } => {
2046 if let Some(cur) = sections.last_mut() {
2047 cur.push(msg_id.clone());
2048 }
2049 }
2050 BlockStackEntry::Loop { items }
2051 | BlockStackEntry::Opt { items }
2052 | BlockStackEntry::Break { items } => {
2053 items.push(msg_id.clone());
2054 }
2055 }
2056 }
2057 }
2058 _ => {
2059 if msg.from.is_some() && msg.to.is_some() {
2060 for entry in stack.iter_mut() {
2061 match entry {
2062 BlockStackEntry::Alt { sections }
2063 | BlockStackEntry::Par { sections }
2064 | BlockStackEntry::Critical { sections } => {
2065 if let Some(cur) = sections.last_mut() {
2066 cur.push(msg_id.clone());
2067 }
2068 }
2069 BlockStackEntry::Loop { items }
2070 | BlockStackEntry::Opt { items }
2071 | BlockStackEntry::Break { items } => {
2072 items.push(msg_id.clone());
2073 }
2074 }
2075 }
2076 }
2077 }
2078 }
2079 }
2080
2081 if block_min_x.is_finite() && block_min_y.is_finite() {
2082 Some((block_min_x, block_min_y, block_max_x, block_max_y))
2083 } else {
2084 None
2085 }
2086 };
2087
2088 let mut content_min_x = f64::INFINITY;
2089 let mut content_max_x = f64::NEG_INFINITY;
2090 let mut content_max_y = f64::NEG_INFINITY;
2091 for n in &nodes {
2092 let left = n.x - n.width / 2.0;
2093 let right = n.x + n.width / 2.0;
2094 let bottom = n.y + n.height / 2.0;
2095 content_min_x = content_min_x.min(left);
2096 content_max_x = content_max_x.max(right);
2097 content_max_y = content_max_y.max(bottom);
2098 }
2099 if !content_min_x.is_finite() {
2100 content_min_x = 0.0;
2101 content_max_x = actor_width_min.max(1.0);
2102 content_max_y = (bottom_box_top_y + actor_height).max(1.0);
2103 }
2104
2105 if let Some((min_x, _min_y, max_x, max_y)) = block_bounds {
2106 content_min_x = content_min_x.min(min_x);
2107 content_max_x = content_max_x.max(max_x);
2108 content_max_y = content_max_y.max(max_y);
2109 }
2110
2111 let extra_vert_for_title = if model.title.is_some() { 40.0 } else { 0.0 };
2114
2115 let vb_min_y = -(diagram_margin_y + extra_vert_for_title);
2118
2119 let mut bounds_box_stopy = (content_max_y + bottom_margin_adj).max(0.0);
2126 if has_boxes {
2127 bounds_box_stopy += box_margin;
2128 }
2129
2130 let mut bounds_box_startx = content_min_x;
2133 let mut bounds_box_stopx = content_max_x;
2134 for i in 0..model.actor_order.len() {
2135 let left = actor_left_x[i];
2136 let right = left + actor_widths[i];
2137 if let Some(bi) = actor_box[i] {
2138 let m = box_margins[bi];
2139 bounds_box_startx = bounds_box_startx.min(left - m);
2140 bounds_box_stopx = bounds_box_stopx.max(right + m);
2141 } else {
2142 bounds_box_startx = bounds_box_startx.min(left);
2143 bounds_box_stopx = bounds_box_stopx.max(right);
2144 }
2145 }
2146
2147 for msg in &model.messages {
2151 let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
2152 continue;
2153 };
2154 if from != to {
2155 continue;
2156 }
2157 if msg.message_type == 2 {
2159 continue;
2160 }
2161 let Some(&i) = actor_index.get(from) else {
2162 continue;
2163 };
2164 let center_x = actor_centers_x[i] + 1.0;
2165 let text = msg.message.as_str().unwrap_or_default();
2166 let (text_w, _text_h) = if text.is_empty() {
2167 (1.0, 1.0)
2168 } else {
2169 measure_svg_like_with_html_br(measurer, text, &msg_text_style)
2170 };
2171 let dx = (text_w.max(1.0) / 2.0).max(actor_width_min / 2.0);
2172 bounds_box_startx = bounds_box_startx.min(center_x - dx);
2173 bounds_box_stopx = bounds_box_stopx.max(center_x + dx);
2174 }
2175
2176 let bounds = Some(Bounds {
2177 min_x: bounds_box_startx - diagram_margin_x,
2178 min_y: vb_min_y,
2179 max_x: bounds_box_stopx + diagram_margin_x,
2180 max_y: bounds_box_stopy + diagram_margin_y,
2181 });
2182
2183 Ok(SequenceDiagramLayout {
2184 nodes,
2185 edges,
2186 clusters,
2187 bounds,
2188 })
2189}