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