1use crate::ast::*;
4use crate::theme::{LifelineStyle, ParticipantShape, Theme};
5use std::collections::HashMap;
6use std::fmt::Write;
7
8#[derive(Debug, Clone)]
10pub struct Config {
11 pub padding: f64,
13 pub left_margin: f64,
15 pub right_margin: f64,
17 pub participant_gap: f64,
19 pub header_height: f64,
21 pub row_height: f64,
23 pub participant_width: f64,
25 pub font_size: f64,
27 pub activation_width: f64,
29 pub note_padding: f64,
31 pub block_margin: f64,
33 pub title_height: f64,
35 pub theme: Theme,
37}
38
39impl Default for Config {
40 fn default() -> Self {
41 Self {
47 padding: 10.5, left_margin: 66.0, right_margin: 10.0, participant_gap: 85.0, header_height: 46.0, row_height: 32.0, participant_width: 92.0, font_size: 14.0, activation_width: 8.0, note_padding: 6.0,
57 block_margin: 5.0,
58 title_height: 100.0, theme: Theme::default(),
60 }
61 }
62}
63
64impl Config {
65 pub fn with_theme(mut self, theme: Theme) -> Self {
67 self.theme = theme;
68 self
69 }
70}
71
72#[derive(Debug, Clone)]
74struct BlockBackground {
75 x: f64,
76 y: f64,
77 width: f64,
78 height: f64,
79}
80
81#[derive(Debug, Clone)]
83struct BlockLabel {
84 x1: f64,
85 start_y: f64,
86 end_y: f64,
87 x2: f64,
88 kind: String,
89 label: String,
90 else_y: Option<f64>,
91}
92
93#[derive(Debug, Clone)]
94struct LabelBox {
95 x_min: f64,
96 x_max: f64,
97 y_min: f64,
98 y_max: f64,
99}
100
101struct RenderState {
103 config: Config,
104 participants: Vec<Participant>,
105 participant_x: HashMap<String, f64>,
106 participant_widths: HashMap<String, f64>,
107 current_y: f64,
108 activations: HashMap<String, Vec<(f64, Option<f64>)>>,
109 autonumber: Option<u32>,
110 destroyed: HashMap<String, f64>,
111 has_title: bool,
112 total_width: f64,
113 block_backgrounds: Vec<BlockBackground>,
115 block_labels: Vec<BlockLabel>,
117 footer_style: FooterStyle,
119 else_return_pending: Vec<bool>,
121 serial_first_row_pending: Vec<bool>,
123 parallel_depth: usize,
125 message_label_boxes: Vec<LabelBox>,
127}
128
129const TEXT_WIDTH_PADDING: f64 = 41.0;
133const TEXT_WIDTH_SCALE: f64 = 1.3;
134const MESSAGE_WIDTH_PADDING: f64 = 4.0;
135const MESSAGE_WIDTH_SCALE: f64 = 0.82;
136
137const MESSAGE_TEXT_ABOVE_ARROW: f64 = 6.0; const SELF_MESSAGE_MIN_SPACING: f64 = 54.0; const SELF_MESSAGE_GAP: f64 = 14.0; const SELF_MESSAGE_PRE_GAP_REDUCTION: f64 = 9.0; const CREATE_MESSAGE_SPACING: f64 = 28.0; const DESTROY_SPACING: f64 = 11.0; const DELAY_UNIT: f64 = 18.0; const MESSAGE_MULTILINE_HEIGHT_MULT: f64 = 0.375; const BLOCK_LABEL_HEIGHT: f64 = 22.0; const BLOCK_TITLE_PADDING: f64 = 12.0; const BLOCK_FOOTER_PADDING: f64 = 8.0; const BLOCK_ELSE_BEFORE: f64 = 8.0; const BLOCK_ELSE_AFTER: f64 = 32.0; const BLOCK_GAP: f64 = 14.0; const NOTE_PADDING: f64 = 8.0; const NOTE_MARGIN: f64 = 10.0; const NOTE_FOLD_SIZE: f64 = 8.0; const NOTE_CHAR_WIDTH: f64 = 7.0; const NOTE_LINE_HEIGHT: f64 = 17.0; const NOTE_MIN_WIDTH: f64 = 50.0; const ROW_SPACING: f64 = 20.0; const STATE_LINE_HEIGHT_EXTRA: f64 = 11.0; const REF_LINE_HEIGHT_EXTRA: f64 = 16.0; const REF_EXTRA_GAP: f64 = 3.0; const MESSAGE_LABEL_COLLISION_PADDING: f64 = 2.0;
181const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
182const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
183const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
184
185fn block_header_space(_config: &Config, _depth: usize) -> f64 {
186 BLOCK_LABEL_HEIGHT + BLOCK_TITLE_PADDING + MESSAGE_TEXT_ABOVE_ARROW
189}
190
191fn block_frame_shift(_depth: usize) -> f64 {
192 0.0
194}
195
196fn block_footer_padding(_config: &Config, _depth: usize) -> f64 {
197 BLOCK_FOOTER_PADDING
199}
200
201fn block_else_before(_config: &Config, _depth: usize) -> f64 {
202 BLOCK_ELSE_BEFORE
203}
204
205fn block_else_after(_config: &Config, _depth: usize) -> f64 {
206 BLOCK_ELSE_AFTER
207}
208
209fn message_spacing_line_height(config: &Config) -> f64 {
210 config.row_height * MESSAGE_MULTILINE_HEIGHT_MULT
211}
212
213fn self_message_spacing(config: &Config, lines: usize) -> f64 {
214 let line_height = config.font_size + 4.0;
215 let text_block_height = lines as f64 * line_height;
216 let loop_height = text_block_height.max(25.0);
218 let base = loop_height + SELF_MESSAGE_GAP;
220 if lines >= 3 {
221 base.max(SELF_MESSAGE_MIN_SPACING)
222 } else {
223 base
224 }
225}
226
227fn note_line_height(_config: &Config) -> f64 {
228 NOTE_LINE_HEIGHT
230}
231
232fn note_padding(_config: &Config) -> f64 {
233 NOTE_PADDING
234}
235
236fn item_pre_gap(config: &Config) -> f64 {
237 config.font_size + 1.0
238}
239
240fn item_pre_shift(config: &Config) -> f64 {
241 (config.row_height - item_pre_gap(config)).max(0.0)
242}
243
244fn label_boxes_overlap(x_min: f64, x_max: f64, y_min: f64, y_max: f64, other: &LabelBox) -> bool {
245 let x_overlap = x_max >= other.x_min - MESSAGE_LABEL_COLLISION_PADDING
246 && x_min <= other.x_max + MESSAGE_LABEL_COLLISION_PADDING;
247 let y_overlap = y_max >= other.y_min - MESSAGE_LABEL_COLLISION_PADDING
248 && y_min <= other.y_max + MESSAGE_LABEL_COLLISION_PADDING;
249 x_overlap && y_overlap
250}
251
252fn actor_footer_extra(_participants: &[Participant], _config: &Config) -> f64 {
253 0.0
255}
256
257fn serial_first_row_gap(_parallel_depth: usize) -> f64 {
258 0.0
260}
261
262fn state_line_height(config: &Config) -> f64 {
263 config.font_size + STATE_LINE_HEIGHT_EXTRA
264}
265
266fn ref_line_height(config: &Config) -> f64 {
267 config.font_size + REF_LINE_HEIGHT_EXTRA
268}
269
270fn regular_message_y_advance(config: &Config, line_count: usize, delay_offset: f64) -> f64 {
278 let spacing_line_height = message_spacing_line_height(config);
279 let extra_height = if line_count > 1 {
280 (line_count - 1) as f64 * spacing_line_height
281 } else {
282 0.0
283 };
284 config.row_height + extra_height + delay_offset
285}
286
287fn self_message_y_advance(config: &Config, line_count: usize) -> f64 {
289 self_message_spacing(config, line_count)
290}
291
292fn note_y_advance(config: &Config, line_count: usize) -> f64 {
294 let note_height = note_padding(config) * 2.0 + line_count as f64 * note_line_height(config);
295 note_height.max(config.row_height) + ROW_SPACING
296}
297
298fn state_y_advance(config: &Config, line_count: usize) -> f64 {
300 let box_height = config.note_padding * 2.0 + line_count as f64 * state_line_height(config);
301 box_height + item_pre_gap(config)
302}
303
304fn ref_y_advance(config: &Config, line_count: usize) -> f64 {
306 let box_height = config.note_padding * 2.0 + line_count as f64 * ref_line_height(config);
307 box_height + item_pre_gap(config) + REF_EXTRA_GAP
308}
309
310fn description_y_advance(config: &Config, line_count: usize) -> f64 {
312 let line_height = config.font_size + 4.0;
313 line_count as f64 * line_height + 10.0
314}
315
316fn block_end_y_advance(config: &Config, depth: usize) -> f64 {
318 block_footer_padding(config, depth) + config.row_height
319}
320
321const ARROWHEAD_SIZE: f64 = 10.0;
323
324fn arrowhead_points(x: f64, y: f64, direction: f64) -> String {
326 let size = ARROWHEAD_SIZE;
327 let half_width = size * 0.35;
328
329 let tip_x = x;
331 let tip_y = y;
332
333 let back_x = x - size * direction.cos();
335 let back_y = y - size * direction.sin();
336
337 let perp_x = -direction.sin() * half_width;
339 let perp_y = direction.cos() * half_width;
340
341 format!(
342 "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
343 back_x + perp_x,
344 back_y + perp_y,
345 tip_x,
346 tip_y,
347 back_x - perp_x,
348 back_y - perp_y
349 )
350}
351
352fn arrow_direction(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
354 (y2 - y1).atan2(x2 - x1)
355}
356
357fn block_has_frame(kind: &BlockKind) -> bool {
358 !matches!(kind, BlockKind::Parallel | BlockKind::Serial)
359}
360
361fn block_is_parallel(kind: &BlockKind) -> bool {
362 matches!(kind, BlockKind::Parallel)
363}
364
365fn parallel_needs_gap(items: &[Item]) -> bool {
366 items.iter().any(|item| matches!(item, Item::Block { .. }))
367}
368
369fn text_char_weight(c: char) -> f64 {
370 if c.is_ascii() {
371 if c.is_uppercase() {
372 0.7
373 } else {
374 0.5
375 }
376 } else {
377 1.0 }
379}
380
381fn participant_char_width(c: char) -> f64 {
384 match c {
385 'W' | 'w' => 14.0,
387 'M' | 'm' => 12.5,
388 '@' | '%' => 14.0,
389 'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'H' | 'K' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'X' | 'Y' | 'Z' => 12.0,
391 'F' | 'I' | 'J' | 'L' => 7.0,
393 'o' | 'e' | 'a' | 'n' | 'u' | 'v' | 'x' | 'z' | 'b' | 'd' | 'g' | 'h' | 'k' | 'p' | 'q' | 's' | 'c' | 'y' => 8.5,
395 'i' | 'j' | 'l' => 4.0,
397 't' | 'f' | 'r' => 6.0,
398 ':' => 6.5,
400 '-' | '_' => 7.0,
401 '[' | ']' | '(' | ')' | '{' | '}' => 7.0,
402 '.' | ',' | '\'' | '`' | ';' => 4.0,
403 ' ' => 5.0,
404 '0'..='9' => 9.0,
406 _ if c.is_ascii() => 8.5,
408 _ => 14.0,
410 }
411}
412
413fn calculate_participant_width(name: &str, min_width: f64) -> f64 {
415 let lines: Vec<&str> = name.split("\\n").collect();
416 let max_line_width = lines
417 .iter()
418 .map(|line| line.chars().map(participant_char_width).sum::<f64>())
419 .fold(0.0_f64, |a, b| a.max(b));
420
421 let padding = 50.0;
423
424 (max_line_width + padding).max(min_width)
425}
426
427fn max_weighted_line(text: &str) -> f64 {
428 text.split("\\n")
429 .map(|line| line.chars().map(text_char_weight).sum::<f64>())
430 .fold(0.0_f64, |a, b| a.max(b))
431}
432
433fn estimate_text_width(text: &str, font_size: f64) -> f64 {
435 let weighted = max_weighted_line(text);
436 weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
437}
438
439fn estimate_message_width(text: &str, font_size: f64) -> f64 {
440 let weighted = max_weighted_line(text);
441 weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
442}
443
444fn block_tab_width(kind: &str) -> f64 {
445 (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
446}
447
448fn calculate_note_width(text: &str, _config: &Config) -> f64 {
450 let lines: Vec<&str> = text.split("\\n").collect();
451 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
452 let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
453 (NOTE_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH)
454}
455
456fn calculate_right_margin(
458 participants: &[Participant],
459 items: &[Item],
460 config: &Config,
461) -> f64 {
462 let rightmost_id = match participants.last() {
463 Some(p) => p.id().to_string(),
464 None => return config.right_margin,
465 };
466 let mut max_right_note_width: f64 = 0.0;
467
468 fn process_items_for_right_notes(
469 items: &[Item],
470 rightmost_id: &str,
471 max_width: &mut f64,
472 config: &Config,
473 ) {
474 for item in items {
475 match item {
476 Item::Note {
477 position: NotePosition::Right,
478 participants,
479 text,
480 } => {
481 if participants.first().map(|s| s.as_str()) == Some(rightmost_id) {
483 let note_width = calculate_note_width(text, config);
484 if note_width > *max_width {
485 *max_width = note_width;
486 }
487 }
488 }
489 Item::Block {
490 items, else_items, ..
491 } => {
492 process_items_for_right_notes(items, rightmost_id, max_width, config);
493 if let Some(else_items) = else_items {
494 process_items_for_right_notes(else_items, rightmost_id, max_width, config);
495 }
496 }
497 _ => {}
498 }
499 }
500 }
501
502 process_items_for_right_notes(items, &rightmost_id, &mut max_right_note_width, config);
503
504 if max_right_note_width > 0.0 {
506 (max_right_note_width + NOTE_MARGIN).max(config.right_margin)
507 } else {
508 config.right_margin
509 }
510}
511
512fn calculate_left_margin(
514 participants: &[Participant],
515 items: &[Item],
516 config: &Config,
517) -> f64 {
518 let leftmost_id = match participants.first() {
519 Some(p) => p.id().to_string(),
520 None => return config.padding,
521 };
522 let mut max_left_note_width: f64 = 0.0;
523
524 fn process_items_for_left_notes(
525 items: &[Item],
526 leftmost_id: &str,
527 max_width: &mut f64,
528 config: &Config,
529 ) {
530 for item in items {
531 match item {
532 Item::Note {
533 position: NotePosition::Left,
534 participants,
535 text,
536 } => {
537 if participants.first().map(|s| s.as_str()) == Some(leftmost_id) {
539 let note_width = calculate_note_width(text, config);
540 if note_width > *max_width {
541 *max_width = note_width;
542 }
543 }
544 }
545 Item::Block {
546 items, else_items, ..
547 } => {
548 process_items_for_left_notes(items, leftmost_id, max_width, config);
549 if let Some(else_items) = else_items {
550 process_items_for_left_notes(else_items, leftmost_id, max_width, config);
551 }
552 }
553 _ => {}
554 }
555 }
556 }
557
558 process_items_for_left_notes(items, &leftmost_id, &mut max_left_note_width, config);
559
560 if max_left_note_width > 0.0 {
562 (max_left_note_width + NOTE_MARGIN).max(config.padding)
563 } else {
564 config.padding
565 }
566}
567
568fn calculate_participant_gaps(
570 participants: &[Participant],
571 items: &[Item],
572 config: &Config,
573) -> Vec<f64> {
574 if participants.len() <= 1 {
575 return vec![];
576 }
577
578 let mut participant_index: HashMap<String, usize> = HashMap::new();
580 for (i, p) in participants.iter().enumerate() {
581 participant_index.insert(p.id().to_string(), i);
582 }
583
584 let min_gap = config.participant_gap;
587 let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
588
589 fn process_items(
591 items: &[Item],
592 participant_index: &HashMap<String, usize>,
593 gaps: &mut Vec<f64>,
594 config: &Config,
595 ) {
596 for item in items {
597 match item {
598 Item::Message { from, to, text, arrow, .. } => {
599 if let (Some(&from_idx), Some(&to_idx)) =
600 (participant_index.get(from), participant_index.get(to))
601 {
602 if from_idx != to_idx {
603 let (min_idx, max_idx) = if from_idx < to_idx {
604 (from_idx, to_idx)
605 } else {
606 (to_idx, from_idx)
607 };
608
609 let text_width = estimate_message_width(text, config.font_size);
610
611 let delay_extra = arrow.delay.map(|d| d as f64 * 86.4).unwrap_or(0.0);
614
615 let gap_count = (max_idx - min_idx) as f64;
617 let needed_gap = if gap_count == 1.0 {
618 text_width - 36.0 + delay_extra
620 } else {
621 text_width / gap_count - 20.0 + delay_extra
623 };
624
625 for gap_idx in min_idx..max_idx {
627 if needed_gap > gaps[gap_idx] {
628 gaps[gap_idx] = needed_gap;
629 }
630 }
631 }
632 }
633 }
634 Item::Note {
635 position,
636 participants: note_participants,
637 text,
638 } => {
639 let note_width = calculate_note_width(text, config);
641
642 if let Some(participant) = note_participants.first() {
643 if let Some(&idx) = participant_index.get(participant) {
644 match position {
645 NotePosition::Left => {
646 if idx > 0 {
648 let needed_gap = note_width + NOTE_MARGIN * 2.0;
650 if needed_gap > gaps[idx - 1] {
651 gaps[idx - 1] = needed_gap;
652 }
653 }
654 }
655 NotePosition::Right => {
656 if idx < gaps.len() {
658 let needed_gap = note_width + NOTE_MARGIN * 2.0;
659 if needed_gap > gaps[idx] {
660 gaps[idx] = needed_gap;
661 }
662 }
663 }
664 NotePosition::Over => {
665 }
668 }
669 }
670 }
671 }
672 Item::Block {
673 items, else_items, ..
674 } => {
675 process_items(items, participant_index, gaps, config);
676 if let Some(else_items) = else_items {
677 process_items(else_items, participant_index, gaps, config);
678 }
679 }
680 _ => {}
681 }
682 }
683 }
684
685 process_items(items, &participant_index, &mut gaps, config);
686
687 let max_gap = 645.0;
693 for gap in &mut gaps {
694 if *gap > max_gap {
695 *gap = max_gap;
696 }
697 }
698
699 gaps
700}
701
702impl RenderState {
703 fn new(
704 config: Config,
705 participants: Vec<Participant>,
706 items: &[Item],
707 has_title: bool,
708 footer_style: FooterStyle,
709 ) -> Self {
710 let mut config = config;
711 let mut required_header_height = config.header_height;
716 for p in &participants {
717 let lines = p.name.split("\\n").count();
718 let needed = match p.kind {
719 ParticipantKind::Participant => {
720 if lines <= 1 {
722 46.0
723 } else {
724 108.0 }
726 }
727 ParticipantKind::Actor => {
728 if lines <= 1 {
731 85.0
732 } else {
733 108.0
734 }
735 }
736 };
737 if needed > required_header_height {
738 required_header_height = needed;
739 }
740 }
741 if required_header_height > config.header_height {
742 config.header_height = required_header_height;
743 }
744 let mut participant_widths: HashMap<String, f64> = HashMap::new();
747 let min_width = config.participant_width;
748
749 for p in &participants {
750 let width = calculate_participant_width(&p.name, min_width);
751 participant_widths.insert(p.id().to_string(), width);
752 }
753
754 let gaps = calculate_participant_gaps(&participants, items, &config);
755
756 let left_margin = calculate_left_margin(&participants, items, &config);
758 let right_margin = calculate_right_margin(&participants, items, &config);
760
761 let mut participant_x = HashMap::new();
762 let first_width = participants
763 .first()
764 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
765 .unwrap_or(min_width);
766 let mut current_x = config.padding + left_margin + first_width / 2.0;
767
768 for (i, p) in participants.iter().enumerate() {
769 participant_x.insert(p.id().to_string(), current_x);
770 if i < gaps.len() {
771 let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
772 let next_p = participants.get(i + 1);
773 let next_width = next_p
774 .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
775 .unwrap_or(min_width);
776
777 let current_is_actor = p.kind == ParticipantKind::Actor;
780 let next_is_actor = next_p.map(|np| np.kind == ParticipantKind::Actor).unwrap_or(false);
781
782 let actor_gap_reduction = 0.0;
785 let _ = (current_is_actor, next_is_actor); let calculated_gap = gaps[i] - actor_gap_reduction;
790
791 let half_widths = (current_width + next_width) / 2.0;
794 let neither_is_actor = !current_is_actor && !next_is_actor;
795
796 let either_is_actor = current_is_actor || next_is_actor;
797 let edge_padding = if calculated_gap > 500.0 {
798 10.0
800 } else if either_is_actor && calculated_gap > 130.0 {
801 33.0
803 } else if neither_is_actor && half_widths > 155.0 && calculated_gap > 130.0 {
804 90.0
806 } else if calculated_gap > 130.0 {
807 49.0
809 } else if calculated_gap > config.participant_gap {
810 25.0
812 } else {
813 let max_width = current_width.max(next_width);
815 let min_width_val = current_width.min(next_width);
816 let width_diff = max_width - min_width_val;
817
818 if max_width > 160.0 && min_width_val > 160.0 {
819 1.8
822 } else if max_width > 160.0 && min_width_val > 140.0 {
823 -7.0
826 } else if max_width > 160.0 && min_width_val < 110.0 {
827 11.3
830 } else if max_width > 160.0 && width_diff > 45.0 {
831 -6.0
834 } else if min_width_val < 115.0 {
835 10.0
838 } else {
839 11.0
841 }
842 };
843
844 let min_center_gap = (current_width + next_width) / 2.0 + edge_padding - actor_gap_reduction;
845 let actual_gap = calculated_gap.max(min_center_gap).max(60.0);
846 current_x += actual_gap;
847 }
848 }
849
850 let last_width = participants
851 .last()
852 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
853 .unwrap_or(min_width);
854 let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
855
856 Self {
857 config,
858 participants,
859 participant_x,
860 participant_widths,
861 current_y: 0.0,
862 activations: HashMap::new(),
863 autonumber: None,
864 destroyed: HashMap::new(),
865 has_title,
866 total_width,
867 block_backgrounds: Vec::new(),
868 block_labels: Vec::new(),
869 footer_style,
870 else_return_pending: Vec::new(),
871 serial_first_row_pending: Vec::new(),
872 parallel_depth: 0,
873 message_label_boxes: Vec::new(),
874 }
875 }
876
877 fn get_participant_width(&self, name: &str) -> f64 {
878 *self
879 .participant_widths
880 .get(name)
881 .unwrap_or(&self.config.participant_width)
882 }
883
884 fn get_x(&self, name: &str) -> f64 {
885 *self.participant_x.get(name).unwrap_or(&0.0)
886 }
887
888 fn push_else_return_pending(&mut self) {
889 self.else_return_pending.push(true);
890 }
891
892 fn pop_else_return_pending(&mut self) {
893 self.else_return_pending.pop();
894 }
895
896 fn apply_else_return_gap(&mut self, arrow: &Arrow) {
897 if let Some(pending) = self.else_return_pending.last_mut() {
898 if *pending && matches!(arrow.line, LineStyle::Dashed) {
899 *pending = false;
901 }
902 }
903 }
904
905 fn push_serial_first_row_pending(&mut self) {
906 self.serial_first_row_pending.push(true);
907 }
908
909 fn pop_serial_first_row_pending(&mut self) {
910 self.serial_first_row_pending.pop();
911 }
912
913 fn apply_serial_first_row_gap(&mut self) {
914 if let Some(pending) = self.serial_first_row_pending.last_mut() {
915 if *pending {
916 self.current_y += serial_first_row_gap(self.parallel_depth);
917 *pending = false;
918 }
919 }
920 }
921
922 fn reserve_message_label(
923 &mut self,
924 x_min: f64,
925 x_max: f64,
926 mut y_min: f64,
927 mut y_max: f64,
928 step: f64,
929 ) -> f64 {
930 let mut offset = 0.0;
931 let mut attempts = 0;
932 while self
933 .message_label_boxes
934 .iter()
935 .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
936 && attempts < 20
937 {
938 y_min += step;
939 y_max += step;
940 offset += step;
941 attempts += 1;
942 }
943 self.message_label_boxes.push(LabelBox {
944 x_min,
945 x_max,
946 y_min,
947 y_max,
948 });
949 offset
950 }
951
952 fn push_parallel(&mut self) {
953 self.parallel_depth += 1;
954 }
955
956 fn pop_parallel(&mut self) {
957 if self.parallel_depth > 0 {
958 self.parallel_depth -= 1;
959 }
960 }
961
962 fn is_participant_active_at(&self, participant: &str, y: f64) -> bool {
964 if let Some(acts) = self.activations.get(participant) {
965 acts.iter().any(|(start_y, end_y)| {
966 *start_y <= y && end_y.map_or(true, |end| y <= end)
967 })
968 } else {
969 false
970 }
971 }
972
973 fn get_arrow_start_x(&self, participant: &str, y: f64, going_right: bool) -> f64 {
975 let x = self.get_x(participant);
976 if self.is_participant_active_at(participant, y) {
977 let half_width = self.config.activation_width / 2.0;
978 if going_right {
979 x + half_width } else {
981 x - half_width }
983 } else {
984 x
985 }
986 }
987
988 fn get_arrow_end_x(&self, participant: &str, y: f64, coming_from_right: bool) -> f64 {
990 let x = self.get_x(participant);
991 if self.is_participant_active_at(participant, y) {
992 let half_width = self.config.activation_width / 2.0;
993 if coming_from_right {
994 x + half_width } else {
996 x - half_width }
998 } else {
999 x
1000 }
1001 }
1002
1003 fn diagram_width(&self) -> f64 {
1004 self.total_width
1005 }
1006
1007 fn leftmost_x(&self) -> f64 {
1009 self.participants
1010 .first()
1011 .map(|p| self.get_x(p.id()))
1012 .unwrap_or(self.config.padding)
1013 }
1014
1015 fn rightmost_x(&self) -> f64 {
1017 self.participants
1018 .last()
1019 .map(|p| self.get_x(p.id()))
1020 .unwrap_or(self.total_width - self.config.padding)
1021 }
1022
1023 fn block_left(&self) -> f64 {
1025 let leftmost_width = self
1026 .participants
1027 .first()
1028 .map(|p| self.get_participant_width(p.id()))
1029 .unwrap_or(self.config.participant_width);
1030 self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
1031 }
1032
1033 fn block_right(&self) -> f64 {
1035 let rightmost_width = self
1036 .participants
1037 .last()
1038 .map(|p| self.get_participant_width(p.id()))
1039 .unwrap_or(self.config.participant_width);
1040 self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
1041 }
1042
1043 fn header_top(&self) -> f64 {
1044 if self.has_title {
1045 self.config.padding + self.config.title_height
1046 } else {
1047 self.config.padding
1048 }
1049 }
1050
1051 fn content_start(&self) -> f64 {
1052 self.header_top() + self.config.header_height + self.config.row_height
1055 }
1056
1057 fn next_number(&mut self) -> Option<u32> {
1058 self.autonumber.map(|n| {
1059 self.autonumber = Some(n + 1);
1060 n
1061 })
1062 }
1063
1064 fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
1066 self.block_backgrounds.push(BlockBackground {
1067 x,
1068 y,
1069 width,
1070 height,
1071 });
1072 }
1073
1074 fn add_block_label(
1076 &mut self,
1077 x1: f64,
1078 start_y: f64,
1079 end_y: f64,
1080 x2: f64,
1081 kind: &str,
1082 label: &str,
1083 else_y: Option<f64>,
1084 ) {
1085 self.block_labels.push(BlockLabel {
1086 x1,
1087 start_y,
1088 end_y,
1089 x2,
1090 kind: kind.to_string(),
1091 label: label.to_string(),
1092 else_y,
1093 });
1094 }
1095}
1096
1097fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
1099 let mut min_left: Option<f64> = None;
1100 let mut max_right: Option<f64> = None;
1101 let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
1102 let mut includes_leftmost = false;
1103
1104 fn update_bounds(
1105 participant: &str,
1106 state: &RenderState,
1107 min_left: &mut Option<f64>,
1108 max_right: &mut Option<f64>,
1109 includes_leftmost: &mut bool,
1110 leftmost_id: &str,
1111 ) {
1112 let x = state.get_x(participant);
1113 if x > 0.0 {
1114 let width = state.get_participant_width(participant);
1115 let left = x - width / 2.0;
1116 let right = x + width / 2.0;
1117 *min_left = Some(min_left.map_or(left, |m| m.min(left)));
1118 *max_right = Some(max_right.map_or(right, |m| m.max(right)));
1119 if participant == leftmost_id {
1120 *includes_leftmost = true;
1121 }
1122 }
1123 }
1124
1125 fn process_items(
1126 items: &[Item],
1127 state: &RenderState,
1128 min_left: &mut Option<f64>,
1129 max_right: &mut Option<f64>,
1130 includes_leftmost: &mut bool,
1131 leftmost_id: &str,
1132 ) {
1133 for item in items {
1134 match item {
1135 Item::Message { from, to, .. } => {
1136 update_bounds(
1137 from,
1138 state,
1139 min_left,
1140 max_right,
1141 includes_leftmost,
1142 leftmost_id,
1143 );
1144 update_bounds(
1145 to,
1146 state,
1147 min_left,
1148 max_right,
1149 includes_leftmost,
1150 leftmost_id,
1151 );
1152 }
1153 Item::Note { participants, .. } => {
1154 for p in participants {
1155 update_bounds(
1156 p,
1157 state,
1158 min_left,
1159 max_right,
1160 includes_leftmost,
1161 leftmost_id,
1162 );
1163 }
1164 }
1165 Item::Block {
1166 items, else_items, ..
1167 } => {
1168 process_items(
1169 items,
1170 state,
1171 min_left,
1172 max_right,
1173 includes_leftmost,
1174 leftmost_id,
1175 );
1176 if let Some(else_items) = else_items {
1177 process_items(
1178 else_items,
1179 state,
1180 min_left,
1181 max_right,
1182 includes_leftmost,
1183 leftmost_id,
1184 );
1185 }
1186 }
1187 Item::Activate { participant }
1188 | Item::Deactivate { participant }
1189 | Item::Destroy { participant } => {
1190 update_bounds(
1191 participant,
1192 state,
1193 min_left,
1194 max_right,
1195 includes_leftmost,
1196 leftmost_id,
1197 );
1198 }
1199 _ => {}
1200 }
1201 }
1202 }
1203
1204 process_items(
1205 items,
1206 state,
1207 &mut min_left,
1208 &mut max_right,
1209 &mut includes_leftmost,
1210 leftmost_id,
1211 );
1212
1213 match (min_left, max_right) {
1214 (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
1215 _ => None,
1216 }
1217}
1218
1219fn calculate_block_bounds_with_label(
1221 items: &[Item],
1222 else_items: Option<&[Item]>,
1223 label: &str,
1224 kind: &str,
1225 depth: usize,
1226 state: &RenderState,
1227) -> (f64, f64) {
1228 let mut all_items: Vec<&Item> = items.iter().collect();
1229 if let Some(else_items) = else_items {
1230 all_items.extend(else_items.iter());
1231 }
1232
1233 let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1235
1236 let (base_x1, base_x2) =
1237 if let Some((min_left, max_right, _includes_leftmost)) =
1238 find_involved_participants(&items_slice, state)
1239 {
1240 let margin = state.config.block_margin;
1241 (min_left - margin, max_right + margin)
1242 } else {
1243 (state.block_left(), state.block_right())
1245 };
1246
1247 let pentagon_width = block_tab_width(kind);
1250 let label_font_size = state.config.font_size - 1.0;
1251 let label_padding_x = 6.0;
1252 let condition_width = if label.is_empty() {
1253 0.0
1254 } else {
1255 let condition_text = format!("[{}]", label);
1256 let base_width =
1257 (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1258 base_width + label_padding_x * 2.0
1259 };
1260 let min_label_width = pentagon_width + 8.0 + condition_width + 20.0; let current_width = base_x2 - base_x1;
1264 let (mut x1, mut x2) = if current_width < min_label_width {
1265 (base_x1, base_x1 + min_label_width)
1267 } else {
1268 (base_x1, base_x2)
1269 };
1270
1271 let nested_padding = depth as f64 * 20.0;
1273 if nested_padding > 0.0 {
1274 let available = x2 - x1;
1275 let max_padding = ((available - min_label_width) / 2.0).max(0.0);
1276 let inset = nested_padding.min(max_padding);
1277 x1 += inset;
1278 x2 -= inset;
1279 }
1280
1281 (x1, x2)
1286}
1287
1288fn collect_block_backgrounds(
1290 state: &mut RenderState,
1291 items: &[Item],
1292 depth: usize,
1293 active_activation_count: &mut usize,
1294) {
1295 for item in items {
1296 match item {
1297 Item::Message {
1298 text,
1299 from,
1300 to,
1301 arrow,
1302 activate,
1303 deactivate,
1304 create,
1305 ..
1306 } => {
1307 state.apply_else_return_gap(arrow);
1308 let is_self = from == to;
1309 let line_count = text.split("\\n").count();
1310 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1311
1312 if is_self {
1313 state.current_y -= SELF_MESSAGE_PRE_GAP_REDUCTION;
1314 state.current_y += self_message_y_advance(&state.config, line_count);
1315 } else {
1316 state.current_y += regular_message_y_advance(&state.config, line_count, delay_offset);
1317 }
1318
1319 if *create {
1320 state.current_y += CREATE_MESSAGE_SPACING;
1321 }
1322
1323 state.apply_serial_first_row_gap();
1324 if *activate {
1325 *active_activation_count += 1;
1326 }
1327 if *deactivate && *active_activation_count > 0 {
1328 *active_activation_count -= 1;
1329 }
1330 }
1331 Item::Note { text, .. } => {
1332 let line_count = text.split("\\n").count();
1333 state.current_y += note_y_advance(&state.config, line_count);
1334 }
1335 Item::State { text, .. } => {
1336 let line_count = text.split("\\n").count();
1337 state.current_y += state_y_advance(&state.config, line_count);
1338 }
1339 Item::Ref { text, .. } => {
1340 let line_count = text.split("\\n").count();
1341 state.current_y += ref_y_advance(&state.config, line_count);
1342 }
1343 Item::Description { text } => {
1344 let line_count = text.split("\\n").count();
1345 state.current_y += description_y_advance(&state.config, line_count);
1346 }
1347 Item::Destroy { .. } => {
1348 state.current_y += DESTROY_SPACING;
1349 }
1350 Item::Activate { .. } => {
1351 *active_activation_count += 1;
1352 }
1353 Item::Deactivate { .. } => {
1354 if *active_activation_count > 0 {
1355 *active_activation_count -= 1;
1356 }
1357 }
1358 Item::Block {
1359 kind,
1360 label,
1361 items,
1362 else_items,
1363 } => {
1364 if block_is_parallel(kind) {
1365 state.push_parallel();
1366 let start_y = state.current_y;
1367 let mut max_end_y = start_y;
1368 let start_activation_count = *active_activation_count;
1369 for item in items {
1370 state.current_y = start_y;
1371 *active_activation_count = start_activation_count;
1372 collect_block_backgrounds(
1373 state,
1374 std::slice::from_ref(item),
1375 depth,
1376 active_activation_count,
1377 );
1378 if state.current_y > max_end_y {
1379 max_end_y = state.current_y;
1380 }
1381 }
1382 *active_activation_count = start_activation_count;
1383 let gap = if parallel_needs_gap(items) {
1384 BLOCK_GAP
1385 } else {
1386 0.0
1387 };
1388 state.current_y = max_end_y + gap;
1389 state.pop_parallel();
1390 continue;
1391 }
1392
1393 if matches!(kind, BlockKind::Serial) {
1394 state.push_serial_first_row_pending();
1395 collect_block_backgrounds(state, items, depth, active_activation_count);
1396 if let Some(else_items) = else_items {
1397 collect_block_backgrounds(
1398 state,
1399 else_items,
1400 depth,
1401 active_activation_count,
1402 );
1403 }
1404 state.pop_serial_first_row_pending();
1405 continue;
1406 }
1407
1408 if !block_has_frame(kind) {
1409 collect_block_backgrounds(state, items, depth, active_activation_count);
1410 if let Some(else_items) = else_items {
1411 collect_block_backgrounds(
1412 state,
1413 else_items,
1414 depth,
1415 active_activation_count,
1416 );
1417 }
1418 continue;
1419 }
1420
1421 let start_y = state.current_y;
1422 let frame_shift = block_frame_shift(depth);
1423 let frame_start_y = start_y - frame_shift;
1424
1425 let (x1, x2) = calculate_block_bounds_with_label(
1427 items,
1428 else_items.as_deref(),
1429 label,
1430 kind.as_str(),
1431 depth,
1432 state,
1433 );
1434
1435 state.current_y += block_header_space(&state.config, depth);
1436 collect_block_backgrounds(state, items, depth + 1, active_activation_count);
1437
1438 let else_y = if else_items.is_some() {
1440 state.current_y += block_else_before(&state.config, depth);
1441 Some(state.current_y)
1442 } else {
1443 None
1444 };
1445
1446 if let Some(else_items) = else_items {
1447 state.push_else_return_pending();
1448 state.current_y += block_else_after(&state.config, depth);
1450 collect_block_backgrounds(
1451 state,
1452 else_items,
1453 depth + 1,
1454 active_activation_count,
1455 );
1456 state.pop_else_return_pending();
1457 }
1458
1459 let end_y = state.current_y + block_footer_padding(&state.config, depth);
1462 let frame_end_y = end_y - frame_shift;
1463 state.current_y = end_y + state.config.row_height;
1464
1465 state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1467 state.add_block_label(
1469 x1,
1470 frame_start_y,
1471 frame_end_y,
1472 x2,
1473 kind.as_str(),
1474 label,
1475 else_y,
1476 );
1477 }
1478 _ => {}
1479 }
1480 }
1481}
1482
1483fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1485 let theme = &state.config.theme;
1486 for bg in &state.block_backgrounds {
1487 writeln!(
1488 svg,
1489 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1490 x = bg.x,
1491 y = bg.y,
1492 w = bg.width,
1493 h = bg.height,
1494 fill = theme.block_fill
1495 )
1496 .unwrap();
1497 }
1498}
1499
1500fn render_block_labels(svg: &mut String, state: &RenderState) {
1503 let theme = &state.config.theme;
1504
1505 for bl in &state.block_labels {
1506 let x1 = bl.x1;
1507 let x2 = bl.x2;
1508 let start_y = bl.start_y;
1509 let end_y = bl.end_y;
1510
1511 writeln!(
1513 svg,
1514 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1515 x = x1,
1516 y = start_y,
1517 w = x2 - x1,
1518 h = end_y - start_y
1519 )
1520 .unwrap();
1521
1522 let label_text = &bl.kind;
1524 let label_width = block_tab_width(label_text);
1525 let label_height = BLOCK_LABEL_HEIGHT;
1526 let label_text_offset = 16.0;
1527 let notch_size = 5.0;
1528
1529 let pentagon_path = format!(
1531 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1532 x1 = x1,
1533 y1 = start_y,
1534 x2 = x1 + label_width,
1535 y2 = start_y + label_height - notch_size,
1536 x3 = x1 + label_width - notch_size,
1537 y3 = start_y + label_height
1538 );
1539
1540 writeln!(
1541 svg,
1542 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1543 path = pentagon_path,
1544 fill = theme.block_label_fill,
1545 stroke = theme.block_stroke
1546 )
1547 .unwrap();
1548
1549 writeln!(
1551 svg,
1552 r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1553 x = x1 + 5.0,
1554 y = start_y + label_text_offset,
1555 kind = label_text
1556 )
1557 .unwrap();
1558
1559 if !bl.label.is_empty() {
1561 let condition_text = format!("[{}]", bl.label);
1562 let text_x = x1 + label_width + 8.0;
1563 let text_y = start_y + label_text_offset;
1564
1565 writeln!(
1566 svg,
1567 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1568 x = text_x,
1569 y = text_y,
1570 label = escape_xml(&condition_text)
1571 )
1572 .unwrap();
1573 }
1574
1575 if let Some(else_y) = bl.else_y {
1577 writeln!(
1578 svg,
1579 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1580 x1 = x1,
1581 y = else_y,
1582 x2 = x2,
1583 c = theme.block_stroke
1584 )
1585 .unwrap();
1586 }
1587 }
1588}
1589
1590pub fn render(diagram: &Diagram) -> String {
1592 render_with_config(diagram, Config::default())
1593}
1594
1595pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1597 let participants = diagram.participants();
1598 let has_title = diagram.title.is_some();
1599 let footer_style = diagram.options.footer;
1600 let mut state = RenderState::new(
1601 config,
1602 participants,
1603 &diagram.items,
1604 has_title,
1605 footer_style,
1606 );
1607 let mut svg = String::new();
1608
1609 let content_height = calculate_height(&diagram.items, &state.config, 0);
1611 let title_space = if has_title {
1612 state.config.title_height
1613 } else {
1614 0.0
1615 };
1616 let footer_space = match footer_style {
1617 FooterStyle::Box => state.config.header_height,
1618 FooterStyle::Bar | FooterStyle::None => 0.0,
1619 };
1620 let footer_label_extra = match footer_style {
1621 FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1622 FooterStyle::Bar | FooterStyle::None => 0.0,
1623 };
1624 let footer_margin = state.config.row_height; let base_total_height = state.config.padding * 2.0
1626 + title_space
1627 + state.config.header_height
1628 + content_height
1629 + footer_margin
1630 + footer_space;
1631 let total_height = base_total_height + footer_label_extra;
1632 let total_width = state.diagram_width();
1633
1634 writeln!(
1636 &mut svg,
1637 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1638 w = total_width,
1639 h = total_height
1640 )
1641 .unwrap();
1642
1643 let theme = &state.config.theme;
1645 let lifeline_dash = match theme.lifeline_style {
1646 LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1647 LifelineStyle::Solid => "",
1648 };
1649
1650 svg.push_str("<defs>\n");
1651 svg.push_str("<style>\n");
1652 writeln!(
1653 &mut svg,
1654 ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1655 fill = theme.participant_fill,
1656 stroke = theme.participant_stroke
1657 )
1658 .unwrap();
1659 writeln!(
1660 &mut svg,
1661 ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1662 f = theme.font_family,
1663 s = state.config.font_size,
1664 c = theme.participant_text
1665 )
1666 .unwrap();
1667 writeln!(
1668 &mut svg,
1669 ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1670 c = theme.lifeline_color,
1671 dash = lifeline_dash
1672 )
1673 .unwrap();
1674 writeln!(
1675 &mut svg,
1676 ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1677 c = theme.message_color
1678 )
1679 .unwrap();
1680 writeln!(
1681 &mut svg,
1682 ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1683 c = theme.message_color
1684 )
1685 .unwrap();
1686 writeln!(
1687 &mut svg,
1688 ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1689 f = theme.font_family,
1690 s = state.config.font_size,
1691 c = theme.message_text_color
1692 )
1693 .unwrap();
1694 writeln!(
1695 &mut svg,
1696 ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1697 fill = theme.note_fill,
1698 stroke = theme.note_stroke
1699 )
1700 .unwrap();
1701 writeln!(
1702 &mut svg,
1703 ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1704 f = theme.font_family,
1705 s = state.config.font_size - 1.0,
1706 c = theme.note_text_color
1707 )
1708 .unwrap();
1709 writeln!(
1710 &mut svg,
1711 ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1712 c = theme.block_stroke
1713 )
1714 .unwrap();
1715 writeln!(
1716 &mut svg,
1717 ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1718 f = theme.font_family,
1719 s = state.config.font_size - 1.0,
1720 c = theme.message_text_color
1721 )
1722 .unwrap();
1723 writeln!(
1724 &mut svg,
1725 ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1726 fill = theme.activation_fill,
1727 stroke = theme.activation_stroke
1728 )
1729 .unwrap();
1730 writeln!(
1731 &mut svg,
1732 ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1733 fill = theme.actor_fill,
1734 stroke = theme.actor_stroke
1735 )
1736 .unwrap();
1737 writeln!(
1738 &mut svg,
1739 ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1740 c = theme.actor_stroke
1741 )
1742 .unwrap();
1743 writeln!(
1744 &mut svg,
1745 ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1746 f = theme.font_family,
1747 s = state.config.font_size + 4.0,
1748 c = theme.message_text_color
1749 )
1750 .unwrap();
1751 writeln!(
1753 &mut svg,
1754 ".arrowhead {{ fill: {c}; stroke: none; }}",
1755 c = theme.message_color
1756 )
1757 .unwrap();
1758 writeln!(
1759 &mut svg,
1760 ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1761 c = theme.message_color
1762 )
1763 .unwrap();
1764 svg.push_str("</style>\n");
1765 svg.push_str("</defs>\n");
1766
1767 writeln!(
1769 &mut svg,
1770 r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1771 bg = theme.background
1772 )
1773 .unwrap();
1774
1775 if let Some(title) = &diagram.title {
1777 let title_y = state.config.padding + state.config.font_size + 7.36; writeln!(
1779 &mut svg,
1780 r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1781 x = total_width / 2.0,
1782 y = title_y,
1783 t = escape_xml(title)
1784 )
1785 .unwrap();
1786 }
1787
1788 let header_y = state.header_top();
1790 let footer_y = match footer_style {
1791 FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1792 FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1793 };
1794
1795 state.current_y = state.content_start();
1797 let mut active_activation_count = 0;
1798 collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count);
1799
1800 render_block_backgrounds(&mut svg, &state);
1802
1803 state.current_y = state.content_start();
1805
1806 let lifeline_start = header_y + state.config.header_height;
1808 let lifeline_end = footer_y;
1809
1810 for p in &state.participants {
1811 let x = state.get_x(p.id());
1812 writeln!(
1813 &mut svg,
1814 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1815 x = x,
1816 y1 = lifeline_start,
1817 y2 = lifeline_end
1818 )
1819 .unwrap();
1820 }
1821
1822 render_participant_headers(&mut svg, &state, header_y);
1824
1825 state.current_y = state.content_start();
1827 render_items(&mut svg, &mut state, &diagram.items, 0);
1828
1829 render_activations(&mut svg, &mut state, footer_y);
1831
1832 render_block_labels(&mut svg, &state);
1834
1835 match state.footer_style {
1837 FooterStyle::Box => {
1838 render_participant_headers(&mut svg, &state, footer_y);
1839 }
1840 FooterStyle::Bar => {
1841 let left = state.leftmost_x()
1843 - state.get_participant_width(
1844 state.participants.first().map(|p| p.id()).unwrap_or(""),
1845 ) / 2.0;
1846 let right = state.rightmost_x()
1847 + state
1848 .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1849 / 2.0;
1850 writeln!(
1851 &mut svg,
1852 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1853 x1 = left,
1854 y = footer_y,
1855 x2 = right,
1856 c = state.config.theme.lifeline_color
1857 )
1858 .unwrap();
1859 }
1860 FooterStyle::None => {
1861 }
1863 }
1864
1865 svg.push_str("</svg>\n");
1866 svg
1867}
1868
1869fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1870 fn inner(
1871 items: &[Item],
1872 config: &Config,
1873 depth: usize,
1874 else_pending: &mut Vec<bool>,
1875 serial_pending: &mut Vec<bool>,
1876 active_activation_count: &mut usize,
1877 parallel_depth: &mut usize,
1878 ) -> f64 {
1879 let mut height = 0.0;
1880 for item in items {
1881 match item {
1882 Item::Message {
1883 from,
1884 to,
1885 text,
1886 arrow,
1887 create,
1888 activate,
1889 deactivate,
1890 ..
1891 } => {
1892 if let Some(pending) = else_pending.last_mut() {
1893 if *pending && matches!(arrow.line, LineStyle::Dashed) {
1894 *pending = false;
1895 }
1896 }
1897 let is_self = from == to;
1898 let line_count = text.split("\\n").count();
1899 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1900 if is_self {
1901 height += self_message_y_advance(config, line_count);
1902 } else {
1903 height += regular_message_y_advance(config, line_count, delay_offset);
1904 }
1905 if *create {
1906 height += CREATE_MESSAGE_SPACING;
1907 }
1908 if let Some(pending) = serial_pending.last_mut() {
1909 if *pending {
1910 height += serial_first_row_gap(*parallel_depth);
1911 *pending = false;
1912 }
1913 }
1914 if *activate {
1915 *active_activation_count += 1;
1916 }
1917 if *deactivate && *active_activation_count > 0 {
1918 *active_activation_count -= 1;
1919 }
1920 }
1921 Item::Note { text, .. } => {
1922 let line_count = text.split("\\n").count();
1923 height += note_y_advance(config, line_count);
1924 }
1925 Item::State { text, .. } => {
1926 let line_count = text.split("\\n").count();
1927 height += state_y_advance(config, line_count);
1928 }
1929 Item::Ref { text, .. } => {
1930 let line_count = text.split("\\n").count();
1931 height += ref_y_advance(config, line_count);
1932 }
1933 Item::Description { text } => {
1934 let line_count = text.split("\\n").count();
1935 height += description_y_advance(config, line_count);
1936 }
1937 Item::Block {
1938 kind,
1939 items,
1940 else_items,
1941 ..
1942 } => {
1943 if block_is_parallel(kind) {
1944 let mut max_branch_height = 0.0;
1945 let base_activation_count = *active_activation_count;
1946 *parallel_depth += 1;
1947 for item in items {
1948 *active_activation_count = base_activation_count;
1949 let branch_height = inner(
1950 std::slice::from_ref(item),
1951 config,
1952 depth,
1953 else_pending,
1954 serial_pending,
1955 active_activation_count,
1956 parallel_depth,
1957 );
1958 if branch_height > max_branch_height {
1959 max_branch_height = branch_height;
1960 }
1961 }
1962 *active_activation_count = base_activation_count;
1963 if *parallel_depth > 0 {
1964 *parallel_depth -= 1;
1965 }
1966 let gap = if parallel_needs_gap(items) {
1967 BLOCK_GAP
1968 } else {
1969 0.0
1970 };
1971 height += max_branch_height + gap;
1972 continue;
1973 }
1974
1975 if matches!(kind, BlockKind::Serial) {
1976 serial_pending.push(true);
1977 height += inner(
1978 items,
1979 config,
1980 depth,
1981 else_pending,
1982 serial_pending,
1983 active_activation_count,
1984 parallel_depth,
1985 );
1986 if let Some(else_items) = else_items {
1987 height += inner(
1988 else_items,
1989 config,
1990 depth,
1991 else_pending,
1992 serial_pending,
1993 active_activation_count,
1994 parallel_depth,
1995 );
1996 }
1997 serial_pending.pop();
1998 } else if !block_has_frame(kind) {
1999 height += inner(
2000 items,
2001 config,
2002 depth,
2003 else_pending,
2004 serial_pending,
2005 active_activation_count,
2006 parallel_depth,
2007 );
2008 if let Some(else_items) = else_items {
2009 height += inner(
2010 else_items,
2011 config,
2012 depth,
2013 else_pending,
2014 serial_pending,
2015 active_activation_count,
2016 parallel_depth,
2017 );
2018 }
2019 } else {
2020 height += block_header_space(config, depth);
2021 height += inner(
2022 items,
2023 config,
2024 depth + 1,
2025 else_pending,
2026 serial_pending,
2027 active_activation_count,
2028 parallel_depth,
2029 );
2030 if let Some(else_items) = else_items {
2031 else_pending.push(true);
2032 height += block_else_before(config, depth) + block_else_after(config, depth);
2034 height += inner(
2035 else_items,
2036 config,
2037 depth + 1,
2038 else_pending,
2039 serial_pending,
2040 active_activation_count,
2041 parallel_depth,
2042 );
2043 else_pending.pop();
2044 }
2045 height += block_end_y_advance(config, depth);
2047 }
2048 }
2049 Item::Activate { .. } => {
2050 *active_activation_count += 1;
2051 }
2052 Item::Deactivate { .. } => {
2053 if *active_activation_count > 0 {
2054 *active_activation_count -= 1;
2055 }
2056 }
2057 Item::Destroy { .. } => {
2058 height += DESTROY_SPACING;
2059 }
2060 Item::ParticipantDecl { .. } => {}
2061 Item::Autonumber { .. } => {}
2062 Item::DiagramOption { .. } => {} }
2064 }
2065 height
2066 }
2067
2068 let mut else_pending = Vec::new();
2069 let mut serial_pending = Vec::new();
2070 let mut active_activation_count = 0;
2071 let mut parallel_depth = 0;
2072 inner(
2073 items,
2074 config,
2075 depth,
2076 &mut else_pending,
2077 &mut serial_pending,
2078 &mut active_activation_count,
2079 &mut parallel_depth,
2080 )
2081}
2082
2083fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
2084 let shape = state.config.theme.participant_shape;
2085
2086 for p in &state.participants {
2087 let x = state.get_x(p.id());
2088 let p_width = state.get_participant_width(p.id());
2089 let box_x = x - p_width / 2.0;
2090
2091 match p.kind {
2092 ParticipantKind::Participant => {
2093 match shape {
2095 ParticipantShape::Rectangle => {
2096 writeln!(
2097 svg,
2098 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2099 x = box_x,
2100 y = y,
2101 w = p_width,
2102 h = state.config.header_height
2103 )
2104 .unwrap();
2105 }
2106 ParticipantShape::RoundedRect => {
2107 writeln!(
2108 svg,
2109 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2110 x = box_x,
2111 y = y,
2112 w = p_width,
2113 h = state.config.header_height
2114 )
2115 .unwrap();
2116 }
2117 ParticipantShape::Circle => {
2118 let rx = p_width / 2.0 - 5.0;
2120 let ry = state.config.header_height / 2.0 - 2.0;
2121 writeln!(
2122 svg,
2123 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2124 cx = x,
2125 cy = y + state.config.header_height / 2.0,
2126 rx = rx,
2127 ry = ry
2128 )
2129 .unwrap();
2130 }
2131 }
2132 let lines: Vec<&str> = p.name.split("\\n").collect();
2134 if lines.len() == 1 {
2135 writeln!(
2136 svg,
2137 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2138 x = x,
2139 y = y + state.config.header_height / 2.0 + 5.0,
2140 name = escape_xml(&p.name)
2141 )
2142 .unwrap();
2143 } else {
2144 let line_height = state.config.font_size + 2.0;
2145 let total_height = lines.len() as f64 * line_height;
2146 let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2147 + line_height * 0.8;
2148 write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2149 for (i, line) in lines.iter().enumerate() {
2150 let dy = if i == 0 { start_y } else { line_height };
2151 if i == 0 {
2152 writeln!(
2153 svg,
2154 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2155 x = x,
2156 y = dy,
2157 text = escape_xml(line)
2158 )
2159 .unwrap();
2160 } else {
2161 writeln!(
2162 svg,
2163 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2164 x = x,
2165 dy = dy,
2166 text = escape_xml(line)
2167 )
2168 .unwrap();
2169 }
2170 }
2171 writeln!(svg, "</text>").unwrap();
2172 }
2173 }
2174 ParticipantKind::Actor => {
2175 let head_r = 8.0;
2177 let body_len = 12.0;
2178 let arm_len = 10.0;
2179 let leg_len = 10.0;
2180 let figure_height = 38.0; let fig_top = y + 8.0;
2184 let fig_center_y = fig_top + head_r + body_len / 2.0;
2185 let arm_y = fig_center_y + 2.0;
2186
2187 writeln!(
2189 svg,
2190 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2191 x = x,
2192 cy = fig_center_y - body_len / 2.0 - head_r,
2193 r = head_r
2194 )
2195 .unwrap();
2196 writeln!(
2198 svg,
2199 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2200 x = x,
2201 y1 = fig_center_y - body_len / 2.0,
2202 y2 = fig_center_y + body_len / 2.0
2203 )
2204 .unwrap();
2205 writeln!(
2207 svg,
2208 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2209 x1 = x - arm_len,
2210 y = arm_y,
2211 x2 = x + arm_len
2212 )
2213 .unwrap();
2214 writeln!(
2216 svg,
2217 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2218 x = x,
2219 y1 = fig_center_y + body_len / 2.0,
2220 x2 = x - leg_len * 0.6,
2221 y2 = fig_center_y + body_len / 2.0 + leg_len
2222 )
2223 .unwrap();
2224 writeln!(
2226 svg,
2227 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2228 x = x,
2229 y1 = fig_center_y + body_len / 2.0,
2230 x2 = x + leg_len * 0.6,
2231 y2 = fig_center_y + body_len / 2.0 + leg_len
2232 )
2233 .unwrap();
2234 let name_lines: Vec<&str> = p.name.split("\\n").collect();
2236 let name_start_y = fig_top + figure_height + 5.0;
2237 if name_lines.len() == 1 {
2238 writeln!(
2239 svg,
2240 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2241 x = x,
2242 y = name_start_y + state.config.font_size,
2243 name = escape_xml(&p.name)
2244 )
2245 .unwrap();
2246 } else {
2247 let line_height = state.config.font_size + 2.0;
2249 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2250 for (i, line) in name_lines.iter().enumerate() {
2251 if i == 0 {
2252 writeln!(
2253 svg,
2254 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2255 x = x,
2256 y = name_start_y + state.config.font_size,
2257 text = escape_xml(line)
2258 )
2259 .unwrap();
2260 } else {
2261 writeln!(
2262 svg,
2263 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2264 x = x,
2265 dy = line_height,
2266 text = escape_xml(line)
2267 )
2268 .unwrap();
2269 }
2270 }
2271 writeln!(svg, "</text>").unwrap();
2272 }
2273 }
2274 }
2275 }
2276}
2277
2278fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2279 for item in items {
2280 match item {
2281 Item::Message {
2282 from,
2283 to,
2284 text,
2285 arrow,
2286 activate,
2287 deactivate,
2288 create,
2289 ..
2290 } => {
2291 render_message(
2292 svg,
2293 state,
2294 from,
2295 to,
2296 text,
2297 arrow,
2298 *activate,
2299 *deactivate,
2300 *create,
2301 depth,
2302 );
2303 }
2304 Item::Note {
2305 position,
2306 participants,
2307 text,
2308 } => {
2309 render_note(svg, state, position, participants, text);
2310 }
2311 Item::Block {
2312 kind,
2313 label,
2314 items,
2315 else_items,
2316 } => {
2317 render_block(svg, state, kind, label, items, else_items.as_deref(), depth);
2318 }
2319 Item::Activate { participant } => {
2320 let y = state.current_y;
2321 state
2322 .activations
2323 .entry(participant.clone())
2324 .or_default()
2325 .push((y, None));
2326 }
2327 Item::Deactivate { participant } => {
2328 if let Some(acts) = state.activations.get_mut(participant) {
2329 if let Some(act) = acts.last_mut() {
2330 if act.1.is_none() {
2331 act.1 = Some(state.current_y);
2332 }
2333 }
2334 }
2335 }
2336 Item::Destroy { participant } => {
2337 let destroy_y = state.current_y - state.config.row_height;
2340 state.destroyed.insert(participant.clone(), destroy_y);
2341 let x = state.get_x(participant);
2343 let y = destroy_y;
2344 let size = 15.0; let theme = &state.config.theme;
2346 writeln!(
2347 svg,
2348 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2349 x1 = x - size,
2350 y1 = y - size,
2351 x2 = x + size,
2352 y2 = y + size,
2353 stroke = theme.message_color
2354 )
2355 .unwrap();
2356 writeln!(
2357 svg,
2358 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2359 x1 = x + size,
2360 y1 = y - size,
2361 x2 = x - size,
2362 y2 = y + size,
2363 stroke = theme.message_color
2364 )
2365 .unwrap();
2366 state.current_y += DESTROY_SPACING;
2367 }
2368 Item::Autonumber { enabled, start } => {
2369 if *enabled {
2370 state.autonumber = Some(start.unwrap_or(1));
2371 } else {
2372 state.autonumber = None;
2373 }
2374 }
2375 Item::ParticipantDecl { .. } => {
2376 }
2378 Item::State { participants, text } => {
2379 render_state(svg, state, participants, text);
2380 }
2381 Item::Ref {
2382 participants,
2383 text,
2384 input_from,
2385 input_label,
2386 output_to,
2387 output_label,
2388 } => {
2389 render_ref(
2390 svg,
2391 state,
2392 participants,
2393 text,
2394 input_from.as_deref(),
2395 input_label.as_deref(),
2396 output_to.as_deref(),
2397 output_label.as_deref(),
2398 );
2399 }
2400 Item::DiagramOption { .. } => {
2401 }
2403 Item::Description { text } => {
2404 render_description(svg, state, text);
2405 }
2406 }
2407 }
2408}
2409
2410fn render_message(
2411 svg: &mut String,
2412 state: &mut RenderState,
2413 from: &str,
2414 to: &str,
2415 text: &str,
2416 arrow: &Arrow,
2417 activate: bool,
2418 deactivate: bool,
2419 create: bool,
2420 _depth: usize,
2421) {
2422 let base_x1 = state.get_x(from);
2424 let base_x2 = state.get_x(to);
2425
2426 state.apply_else_return_gap(arrow);
2427
2428 let is_self = from == to;
2429 let line_class = match arrow.line {
2430 LineStyle::Solid => "message",
2431 LineStyle::Dashed => "message-dashed",
2432 };
2433 let is_filled = matches!(arrow.head, ArrowHead::Filled);
2434
2435 let num_prefix = state
2437 .next_number()
2438 .map(|n| format!("{}. ", n))
2439 .unwrap_or_default();
2440
2441 let display_text = format!("{}{}", num_prefix, text);
2443 let lines: Vec<&str> = display_text.split("\\n").collect();
2444 let line_height = state.config.font_size + 4.0;
2445 let extra_height = if !is_self && lines.len() > 1 {
2446 let spacing_line_height = message_spacing_line_height(&state.config);
2447 (lines.len() - 1) as f64 * spacing_line_height
2448 } else {
2449 0.0
2450 };
2451
2452 if !is_self && lines.len() > 1 {
2454 state.current_y += extra_height;
2455 }
2456
2457 if is_self {
2459 state.current_y -= SELF_MESSAGE_PRE_GAP_REDUCTION;
2460 }
2461
2462 let y = state.current_y;
2463 let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2464
2465 let going_right = base_x2 > base_x1;
2467 let x1 = state.get_arrow_start_x(from, y, going_right);
2468 let x2 = state.get_arrow_end_x(to, y, !going_right);
2469
2470 writeln!(svg, r#"<g class="message">"#).unwrap();
2472
2473 if is_self {
2474 let loop_width = 40.0;
2476 let text_block_height = lines.len() as f64 * line_height;
2477 let loop_height = text_block_height.max(25.0);
2479 let arrow_end_x = x1;
2480 let arrow_end_y = y + loop_height;
2481 let direction = std::f64::consts::PI;
2483 let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2484
2485 writeln!(
2486 svg,
2487 r#" <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2488 x1 = x1,
2489 y = y,
2490 x2 = x1 + loop_width,
2491 y2 = y + loop_height,
2492 arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2493 cls = line_class
2494 )
2495 .unwrap();
2496
2497 if is_filled {
2499 writeln!(
2500 svg,
2501 r#" <polygon points="{points}" class="arrowhead"/>"#,
2502 points = arrow_points
2503 )
2504 .unwrap();
2505 } else {
2506 writeln!(
2507 svg,
2508 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2509 points = arrow_points
2510 )
2511 .unwrap();
2512 }
2513
2514 let text_x = x1 + loop_width + 5.0;
2516 let max_width = lines
2517 .iter()
2518 .map(|line| estimate_message_width(line, state.config.font_size))
2519 .fold(0.0, f64::max);
2520 let top_line_y = y + 4.0 + 0.5 * line_height;
2521 let bottom_line_y = y + 4.0 + (lines.len() as f64 - 0.5) * line_height;
2522 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2523 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2524 let label_x_min = text_x;
2525 let label_x_max = text_x + max_width;
2526 let label_offset = if has_label_text {
2527 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2528 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2529 } else {
2530 0.0
2531 };
2532 for (i, line) in lines.iter().enumerate() {
2533 let line_y = y + 4.0 + (i as f64 + 0.5) * line_height + label_offset;
2534 writeln!(
2535 svg,
2536 r#" <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2537 x = text_x,
2538 y = line_y,
2539 t = escape_xml(line)
2540 )
2541 .unwrap();
2542 }
2543
2544 writeln!(svg, r#"</g>"#).unwrap();
2546
2547 let spacing = self_message_spacing(&state.config, lines.len());
2548 state.current_y += spacing;
2549 } else {
2550 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2552 let y2 = y + delay_offset;
2553
2554 let text_x = (base_x1 + base_x2) / 2.0;
2556 let text_y = (y + y2) / 2.0 - 6.0; let direction = arrow_direction(x1, y, x2, y2);
2560 let arrow_points = arrowhead_points(x2, y2, direction);
2561
2562 let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2564 let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2565
2566 writeln!(
2568 svg,
2569 r#" <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2570 x1 = x1,
2571 y1 = y,
2572 lx2 = line_end_x,
2573 ly2 = line_end_y,
2574 cls = line_class
2575 )
2576 .unwrap();
2577
2578 if is_filled {
2580 writeln!(
2581 svg,
2582 r#" <polygon points="{points}" class="arrowhead"/>"#,
2583 points = arrow_points
2584 )
2585 .unwrap();
2586 } else {
2587 writeln!(
2588 svg,
2589 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2590 points = arrow_points
2591 )
2592 .unwrap();
2593 }
2594
2595 let max_width = lines
2597 .iter()
2598 .map(|line| estimate_message_width(line, state.config.font_size))
2599 .fold(0.0, f64::max);
2600 let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2601 let bottom_line_y = text_y;
2602 let label_offset = if has_label_text {
2603 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2604 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2605 let label_x_min = text_x - max_width / 2.0;
2606 let label_x_max = text_x + max_width / 2.0;
2607 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2608 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2609 } else {
2610 0.0
2611 };
2612 let rotation = if delay_offset > 0.0 {
2614 let dx = x2 - x1;
2615 let dy = delay_offset;
2616 let angle_rad = dy.atan2(dx.abs());
2617 let angle_deg = angle_rad.to_degrees();
2618 if dx < 0.0 { -angle_deg } else { angle_deg }
2620 } else {
2621 0.0
2622 };
2623
2624 for (i, line) in lines.iter().enumerate() {
2625 let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2626 if rotation.abs() > 0.1 {
2627 writeln!(
2629 svg,
2630 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle" transform="rotate({rot},{cx},{cy})">{t}</text>"#,
2631 x = text_x,
2632 y = line_y,
2633 rot = rotation,
2634 cx = text_x,
2635 cy = line_y,
2636 t = escape_xml(line)
2637 )
2638 .unwrap();
2639 } else {
2640 writeln!(
2641 svg,
2642 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2643 x = text_x,
2644 y = line_y,
2645 t = escape_xml(line)
2646 )
2647 .unwrap();
2648 }
2649 }
2650
2651 writeln!(svg, r#"</g>"#).unwrap();
2653
2654 state.current_y += state.config.row_height + delay_offset;
2656 }
2657
2658 if create {
2659 state.current_y += CREATE_MESSAGE_SPACING;
2660 }
2661
2662 state.apply_serial_first_row_gap();
2663
2664 if activate {
2666 state
2667 .activations
2668 .entry(to.to_string())
2669 .or_default()
2670 .push((y, None));
2671 }
2672 if deactivate {
2673 if let Some(acts) = state.activations.get_mut(from) {
2674 if let Some(act) = acts.last_mut() {
2675 if act.1.is_none() {
2676 act.1 = Some(y);
2677 }
2678 }
2679 }
2680 }
2681}
2682
2683fn render_note(
2684 svg: &mut String,
2685 state: &mut RenderState,
2686 position: &NotePosition,
2687 participants: &[String],
2688 text: &str,
2689) {
2690 let lines: Vec<&str> = text.split("\\n").collect();
2691 let line_height = note_line_height(&state.config);
2692
2693 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
2695 let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
2696 let content_width = (NOTE_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH);
2697 let note_height = NOTE_PADDING * 2.0 + lines.len() as f64 * line_height;
2698
2699 let (x, note_width, text_anchor) = match position {
2700 NotePosition::Left => {
2701 let px = state.get_x(&participants[0]);
2702 let x = (px - NOTE_MARGIN - content_width).max(state.config.padding);
2704 (x, content_width, "start")
2705 }
2706 NotePosition::Right => {
2707 let px = state.get_x(&participants[0]);
2708 (px + NOTE_MARGIN, content_width, "start")
2710 }
2711 NotePosition::Over => {
2712 if participants.len() == 1 {
2713 let px = state.get_x(&participants[0]);
2714 let x = (px - content_width / 2.0).max(state.config.padding);
2716 (x, content_width, "middle")
2717 } else {
2718 let x1 = state.get_x(&participants[0]);
2720 let x2 = state.get_x(participants.last().unwrap());
2721 let span_width = (x2 - x1).abs() + NOTE_MARGIN * 2.0;
2722 let w = span_width.max(content_width);
2723 let x = (x1 - NOTE_MARGIN).max(state.config.padding);
2724 (x, w, "middle")
2725 }
2726 }
2727 };
2728
2729 let y = state.current_y;
2730 let fold_size = NOTE_FOLD_SIZE;
2731
2732 let note_path = format!(
2735 "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2736 x = x,
2737 y = y,
2738 x2 = x + note_width - fold_size,
2739 x3 = x + note_width,
2740 y2 = y + fold_size,
2741 y3 = y + note_height
2742 );
2743
2744 writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2745
2746 let theme = &state.config.theme;
2748 let fold_path = format!(
2750 "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2751 x1 = x + note_width - fold_size,
2752 y1 = y,
2753 x2 = x + note_width,
2754 y2 = y + fold_size
2755 );
2756
2757 writeln!(
2758 svg,
2759 r##"<path d="{path}" fill="none" stroke="{stroke}" stroke-width="1"/>"##,
2760 path = fold_path,
2761 stroke = theme.note_stroke
2762 )
2763 .unwrap();
2764
2765 let text_x = match text_anchor {
2767 "middle" => x + note_width / 2.0,
2768 _ => x + NOTE_PADDING,
2769 };
2770 let text_anchor_attr = if *position == NotePosition::Over { "middle" } else { "start" };
2771
2772 for (i, line) in lines.iter().enumerate() {
2773 let text_y = y + NOTE_PADDING + (i as f64 + 0.8) * line_height;
2774 writeln!(
2775 svg,
2776 r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2777 x = text_x,
2778 y = text_y,
2779 anchor = text_anchor_attr,
2780 t = escape_xml(line)
2781 )
2782 .unwrap();
2783 }
2784
2785 state.current_y += note_y_advance(&state.config, lines.len());
2787}
2788
2789fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2791 let theme = &state.config.theme;
2792 let lines: Vec<&str> = text.split("\\n").collect();
2793 let line_height = state_line_height(&state.config);
2794 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2795
2796 let (x, box_width) = if participants.len() == 1 {
2798 let px = state.get_x(&participants[0]);
2799 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2800 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2801 (px - w / 2.0, w)
2802 } else {
2803 let x1 = state.get_x(&participants[0]);
2804 let x2 = state.get_x(participants.last().unwrap());
2805 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2806 let center = (x1 + x2) / 2.0;
2807 (center - span_width / 2.0, span_width)
2808 };
2809
2810 let shift = item_pre_shift(&state.config);
2811 let y = (state.current_y - shift).max(state.content_start());
2812
2813 writeln!(
2815 svg,
2816 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2817 x = x,
2818 y = y,
2819 w = box_width,
2820 h = box_height,
2821 fill = theme.state_fill,
2822 stroke = theme.state_stroke
2823 )
2824 .unwrap();
2825
2826 let text_x = x + box_width / 2.0;
2828 for (i, line) in lines.iter().enumerate() {
2829 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2830 writeln!(
2831 svg,
2832 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2833 x = text_x,
2834 y = text_y,
2835 fill = theme.state_text_color,
2836 font = theme.font_family,
2837 size = state.config.font_size,
2838 t = escape_xml(line)
2839 )
2840 .unwrap();
2841 }
2842
2843 state.current_y = y + box_height + state.config.row_height + REF_EXTRA_GAP;
2844}
2845
2846fn render_ref(
2848 svg: &mut String,
2849 state: &mut RenderState,
2850 participants: &[String],
2851 text: &str,
2852 input_from: Option<&str>,
2853 input_label: Option<&str>,
2854 output_to: Option<&str>,
2855 output_label: Option<&str>,
2856) {
2857 let theme = &state.config.theme;
2858 let lines: Vec<&str> = text.split("\\n").collect();
2859 let line_height = ref_line_height(&state.config);
2860 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2861 let notch_size = 10.0;
2862
2863 let (x, box_width) = if participants.len() == 1 {
2865 let px = state.get_x(&participants[0]);
2866 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2867 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2868 .max(100.0);
2869 (px - w / 2.0, w)
2870 } else {
2871 let x1 = state.get_x(&participants[0]);
2872 let x2 = state.get_x(participants.last().unwrap());
2873 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2874 let center = (x1 + x2) / 2.0;
2875 (center - span_width / 2.0, span_width)
2876 };
2877
2878 let shift = item_pre_shift(&state.config);
2879 let y = (state.current_y - shift).max(state.content_start());
2880 let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2881 let output_padding = state.config.note_padding + 3.0;
2882
2883 if let Some(from) = input_from {
2885 let from_x = state.get_x(from);
2886 let to_x = x; let arrow_y = y + input_offset;
2888
2889 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2891 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2892 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2893
2894 writeln!(
2896 svg,
2897 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2898 x1 = from_x,
2899 y = arrow_y,
2900 x2 = line_end_x
2901 )
2902 .unwrap();
2903
2904 writeln!(
2906 svg,
2907 r#"<polygon points="{points}" class="arrowhead"/>"#,
2908 points = arrow_points
2909 )
2910 .unwrap();
2911
2912 if let Some(label) = input_label {
2914 let text_x = (from_x + to_x) / 2.0;
2915 writeln!(
2916 svg,
2917 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2918 x = text_x,
2919 y = arrow_y - 8.0,
2920 t = escape_xml(label)
2921 )
2922 .unwrap();
2923 }
2924 }
2925
2926 let ref_path = format!(
2929 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2930 x1 = x + notch_size,
2931 y1 = y,
2932 x2 = x + box_width,
2933 y2 = y + box_height,
2934 x3 = x,
2935 y3 = y + box_height / 2.0
2936 );
2937
2938 writeln!(
2939 svg,
2940 r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2941 path = ref_path,
2942 fill = theme.ref_fill,
2943 stroke = theme.ref_stroke
2944 )
2945 .unwrap();
2946
2947 writeln!(
2949 svg,
2950 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
2951 x = x + notch_size + 4.0,
2952 y = y + state.config.font_size,
2953 fill = theme.ref_text_color,
2954 font = theme.font_family,
2955 size = state.config.font_size - 2.0
2956 )
2957 .unwrap();
2958
2959 let text_x = x + box_width / 2.0;
2961 for (i, line) in lines.iter().enumerate() {
2962 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2963 writeln!(
2964 svg,
2965 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2966 x = text_x,
2967 y = text_y,
2968 fill = theme.ref_text_color,
2969 font = theme.font_family,
2970 size = state.config.font_size,
2971 t = escape_xml(line)
2972 )
2973 .unwrap();
2974 }
2975
2976 if let Some(to) = output_to {
2978 let from_x = x + box_width; let to_x = state.get_x(to);
2980 let arrow_y = y + box_height - output_padding;
2981
2982 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2984 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2985 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2986
2987 writeln!(
2989 svg,
2990 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
2991 x1 = from_x,
2992 y = arrow_y,
2993 x2 = line_end_x
2994 )
2995 .unwrap();
2996
2997 writeln!(
2999 svg,
3000 r#"<polygon points="{points}" class="arrowhead"/>"#,
3001 points = arrow_points
3002 )
3003 .unwrap();
3004
3005 if let Some(label) = output_label {
3007 let text_x = (from_x + to_x) / 2.0;
3008 writeln!(
3009 svg,
3010 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3011 x = text_x,
3012 y = arrow_y - 8.0,
3013 t = escape_xml(label)
3014 )
3015 .unwrap();
3016 }
3017 }
3018
3019 state.current_y = y + box_height + state.config.row_height;
3020}
3021
3022fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
3024 let theme = &state.config.theme;
3025 let lines: Vec<&str> = text.split("\\n").collect();
3026 let line_height = state.config.font_size + 4.0;
3027
3028 let x = state.config.padding + 10.0;
3030 let y = state.current_y;
3031
3032 for (i, line) in lines.iter().enumerate() {
3033 let text_y = y + (i as f64 + 0.8) * line_height;
3034 writeln!(
3035 svg,
3036 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
3037 x = x,
3038 y = text_y,
3039 fill = theme.description_text_color,
3040 font = theme.font_family,
3041 size = state.config.font_size - 1.0,
3042 t = escape_xml(line)
3043 )
3044 .unwrap();
3045 }
3046
3047 state.current_y += description_y_advance(&state.config, lines.len());
3048}
3049
3050fn render_block(
3051 svg: &mut String,
3052 state: &mut RenderState,
3053 kind: &BlockKind,
3054 _label: &str,
3055 items: &[Item],
3056 else_items: Option<&[Item]>,
3057 depth: usize,
3058) {
3059 if block_is_parallel(kind) {
3060 state.push_parallel();
3061 let start_y = state.current_y;
3062 let mut max_end_y = start_y;
3063 for item in items {
3064 state.current_y = start_y;
3065 render_items(svg, state, std::slice::from_ref(item), depth);
3066 if state.current_y > max_end_y {
3067 max_end_y = state.current_y;
3068 }
3069 }
3070 let gap = if parallel_needs_gap(items) {
3071 BLOCK_GAP
3072 } else {
3073 0.0
3074 };
3075 state.current_y = max_end_y + gap;
3076 state.pop_parallel();
3077 return;
3078 }
3079
3080 if matches!(kind, BlockKind::Serial) {
3081 state.push_serial_first_row_pending();
3082 render_items(svg, state, items, depth);
3083 if let Some(else_items) = else_items {
3084 render_items(svg, state, else_items, depth);
3085 }
3086 state.pop_serial_first_row_pending();
3087 return;
3088 }
3089
3090 if !block_has_frame(kind) {
3091 render_items(svg, state, items, depth);
3092 if let Some(else_items) = else_items {
3093 render_items(svg, state, else_items, depth);
3094 }
3095 return;
3096 }
3097
3098 state.current_y += block_header_space(&state.config, depth);
3103
3104 render_items(svg, state, items, depth + 1);
3106
3107 if let Some(else_items) = else_items {
3109 state.push_else_return_pending();
3110 state.current_y += block_else_before(&state.config, depth);
3112 state.current_y += block_else_after(&state.config, depth);
3114 render_items(svg, state, else_items, depth + 1);
3115 state.pop_else_return_pending();
3116 }
3117
3118 let end_y = state.current_y + block_footer_padding(&state.config, depth);
3121
3122 state.current_y = end_y + state.config.row_height;
3124
3125 }
3128
3129fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3130 for (participant, activations) in &state.activations {
3131 let x = state.get_x(participant);
3132 let box_x = x - state.config.activation_width / 2.0;
3133
3134 for (start_y, end_y) in activations {
3135 let end = end_y.unwrap_or(footer_y);
3137 let height = end - start_y;
3138
3139 if height > 0.0 {
3140 writeln!(
3141 svg,
3142 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3143 x = box_x,
3144 y = start_y,
3145 w = state.config.activation_width,
3146 h = height
3147 )
3148 .unwrap();
3149 }
3150 }
3151 }
3152}
3153
3154fn escape_xml(s: &str) -> String {
3155 s.replace('&', "&")
3156 .replace('<', "<")
3157 .replace('>', ">")
3158 .replace('"', """)
3159 .replace('\'', "'")
3160}
3161
3162#[cfg(test)]
3163mod tests {
3164 use super::*;
3165 use crate::parser::parse;
3166
3167 #[test]
3168 fn test_render_simple() {
3169 let diagram = parse("Alice->Bob: Hello").unwrap();
3170 let svg = render(&diagram);
3171 assert!(svg.contains("<svg"));
3172 assert!(svg.contains("Alice"));
3173 assert!(svg.contains("Bob"));
3174 assert!(svg.contains("Hello"));
3175 }
3176
3177 #[test]
3178 fn test_render_with_note() {
3179 let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3180 let svg = render(&diagram);
3181 assert!(svg.contains("Thinking"));
3182 }
3183}