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
1237const NESTED_BLOCK_INSET: f64 = 5.0;
1239
1240fn calculate_block_bounds_with_label(
1242 items: &[Item],
1243 else_sections: &[crate::ast::ElseSection],
1244 label: &str,
1245 kind: &str,
1246 _depth: usize,
1247 state: &RenderState,
1248 parent_bounds: Option<(f64, f64)>,
1249) -> (f64, f64) {
1250 let mut all_items: Vec<&Item> = items.iter().collect();
1251 for section in else_sections {
1252 all_items.extend(section.items.iter());
1253 }
1254
1255 let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1257
1258 let (constraint_x1, constraint_x2) = if let Some((px1, px2)) = parent_bounds {
1260 (px1 + NESTED_BLOCK_INSET, px2 - NESTED_BLOCK_INSET)
1261 } else {
1262 (state.block_left(), state.block_right())
1263 };
1264
1265 let (base_x1, base_x2) =
1266 if let Some((min_left, max_right, _includes_leftmost)) =
1267 find_involved_participants(&items_slice, state)
1268 {
1269 let margin = state.config.block_margin;
1270 let x1 = (min_left - margin).max(constraint_x1);
1272 let x2 = (max_right + margin).min(constraint_x2);
1273 (x1, x2)
1274 } else {
1275 (constraint_x1, constraint_x2)
1277 };
1278
1279 let pentagon_width = block_tab_width(kind);
1282 let label_font_size = state.config.font_size - 1.0;
1283 let label_padding_x = 6.0;
1284 let condition_width = if label.is_empty() {
1285 0.0
1286 } else {
1287 let condition_text = format!("[{}]", label);
1288 let base_width =
1289 (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1290 base_width + label_padding_x * 2.0
1291 };
1292
1293 let mut max_else_label_width = 0.0f64;
1295 for section in else_sections {
1296 if let Some(el) = §ion.label {
1297 if !el.is_empty() {
1298 let else_text = format!("[{}]", el);
1299 let base_width =
1300 (estimate_text_width(&else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1301 let width = base_width + label_padding_x * 2.0;
1302 max_else_label_width = max_else_label_width.max(width);
1303 }
1304 }
1305 }
1306
1307 let max_label_content_width = condition_width.max(max_else_label_width);
1309 let min_label_width = pentagon_width + 8.0 + max_label_content_width + 20.0; let available_width = constraint_x2 - constraint_x1;
1313
1314 let current_width = base_x2 - base_x1;
1316 let (x1, x2) = if current_width < min_label_width {
1317 let desired_x2 = base_x1 + min_label_width;
1319 if desired_x2 <= constraint_x2 {
1320 (base_x1, desired_x2)
1321 } else if min_label_width <= available_width {
1322 (constraint_x2 - min_label_width, constraint_x2)
1324 } else {
1325 (constraint_x1, constraint_x2)
1327 }
1328 } else {
1329 (base_x1, base_x2)
1330 };
1331
1332 (x1, x2)
1333}
1334
1335fn collect_block_backgrounds(
1337 state: &mut RenderState,
1338 items: &[Item],
1339 depth: usize,
1340 active_activation_count: &mut usize,
1341 parent_bounds: Option<(f64, f64)>,
1342) {
1343 for item in items {
1344 match item {
1345 Item::Message {
1346 text,
1347 from,
1348 to,
1349 arrow,
1350 activate,
1351 deactivate,
1352 create,
1353 ..
1354 } => {
1355 state.apply_else_return_gap(arrow);
1356 let is_self = from == to;
1357 let line_count = text.split("\\n").count();
1358 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1359
1360 if is_self {
1361 state.current_y += self_message_y_advance(&state.config, line_count);
1362 } else {
1363 state.current_y += regular_message_y_advance(&state.config, line_count, delay_offset);
1364 }
1365
1366 if *create {
1367 state.current_y += state.config.row_height;
1368 }
1369
1370 state.apply_serial_first_row_gap();
1371 if *activate {
1372 *active_activation_count += 1;
1373 }
1374 if *deactivate && *active_activation_count > 0 {
1375 *active_activation_count -= 1;
1376 }
1377 }
1378 Item::Note { text, .. } => {
1379 let line_count = text.split("\\n").count();
1380 state.current_y += note_y_advance(&state.config, line_count);
1381 }
1382 Item::State { text, .. } => {
1383 let line_count = text.split("\\n").count();
1384 state.current_y += state_y_advance(&state.config, line_count);
1385 }
1386 Item::Ref { text, .. } => {
1387 let line_count = text.split("\\n").count();
1388 state.current_y += ref_y_advance(&state.config, line_count);
1389 }
1390 Item::Description { text } => {
1391 let line_count = text.split("\\n").count();
1392 state.current_y += description_y_advance(&state.config, line_count);
1393 }
1394 Item::Destroy { .. } => {
1395 state.current_y += state.config.row_height;
1396 }
1397 Item::Activate { .. } => {
1398 *active_activation_count += 1;
1399 }
1400 Item::Deactivate { .. } => {
1401 if *active_activation_count > 0 {
1402 *active_activation_count -= 1;
1403 }
1404 }
1405 Item::Block {
1406 kind,
1407 label,
1408 items,
1409 else_sections,
1410 } => {
1411 if block_is_parallel(kind) {
1412 state.push_parallel();
1413 let start_y = state.current_y;
1414 let mut max_end_y = start_y;
1415 let start_activation_count = *active_activation_count;
1416 for item in items {
1417 state.current_y = start_y;
1418 *active_activation_count = start_activation_count;
1419 collect_block_backgrounds(
1420 state,
1421 std::slice::from_ref(item),
1422 depth,
1423 active_activation_count,
1424 parent_bounds,
1425 );
1426 if state.current_y > max_end_y {
1427 max_end_y = state.current_y;
1428 }
1429 }
1430 *active_activation_count = start_activation_count;
1431 let gap = if parallel_needs_gap(items) {
1432 state.config.row_height
1433 } else {
1434 0.0
1435 };
1436 state.current_y = max_end_y + gap;
1437 state.pop_parallel();
1438 continue;
1439 }
1440
1441 if matches!(kind, BlockKind::Serial) {
1442 state.push_serial_first_row_pending();
1443 collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1444 for section in else_sections {
1445 collect_block_backgrounds(
1446 state,
1447 §ion.items,
1448 depth,
1449 active_activation_count,
1450 parent_bounds,
1451 );
1452 }
1453 state.pop_serial_first_row_pending();
1454 continue;
1455 }
1456
1457 if !block_has_frame(kind) {
1458 collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1459 for section in else_sections {
1460 collect_block_backgrounds(
1461 state,
1462 §ion.items,
1463 depth,
1464 active_activation_count,
1465 parent_bounds,
1466 );
1467 }
1468 continue;
1469 }
1470
1471 let start_y = state.current_y;
1472 let frame_shift = block_frame_shift(depth);
1473 let frame_start_y = start_y - frame_shift;
1474
1475 let (x1, x2) = calculate_block_bounds_with_label(
1477 items,
1478 else_sections,
1479 label,
1480 kind.as_str(),
1481 depth,
1482 state,
1483 parent_bounds,
1484 );
1485
1486 state.current_y += block_header_space(&state.config, depth);
1487 collect_block_backgrounds(state, items, depth + 1, active_activation_count, Some((x1, x2)));
1489
1490 let mut else_section_info: Vec<(f64, Option<String>)> = Vec::new();
1492 for section in else_sections {
1493 state.current_y += block_else_before(&state.config, depth);
1495 let else_y = state.current_y;
1496 else_section_info.push((else_y, section.label.clone()));
1497
1498 state.push_else_return_pending();
1499 state.current_y += block_else_after(&state.config, depth);
1501 collect_block_backgrounds(
1503 state,
1504 §ion.items,
1505 depth + 1,
1506 active_activation_count,
1507 Some((x1, x2)),
1508 );
1509 state.pop_else_return_pending();
1510 }
1511
1512 let end_y = state.current_y + block_footer_padding(&state.config, depth);
1515 let frame_end_y = end_y - frame_shift;
1516 state.current_y = end_y + state.config.row_height;
1517
1518 state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1520 state.add_block_label(
1522 x1,
1523 frame_start_y,
1524 frame_end_y,
1525 x2,
1526 kind.as_str(),
1527 label,
1528 else_section_info,
1529 );
1530 }
1531 _ => {}
1532 }
1533 }
1534}
1535
1536fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1538 let theme = &state.config.theme;
1539 for bg in &state.block_backgrounds {
1540 writeln!(
1541 svg,
1542 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1543 x = bg.x,
1544 y = bg.y,
1545 w = bg.width,
1546 h = bg.height,
1547 fill = theme.block_fill
1548 )
1549 .unwrap();
1550 }
1551}
1552
1553fn render_block_labels(svg: &mut String, state: &RenderState) {
1556 let theme = &state.config.theme;
1557
1558 for bl in &state.block_labels {
1559 let x1 = bl.x1;
1560 let x2 = bl.x2;
1561 let start_y = bl.start_y;
1562 let end_y = bl.end_y;
1563
1564 writeln!(
1566 svg,
1567 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1568 x = x1,
1569 y = start_y,
1570 w = x2 - x1,
1571 h = end_y - start_y
1572 )
1573 .unwrap();
1574
1575 let label_text = &bl.kind;
1577 let label_width = block_tab_width(label_text);
1578 let label_height = BLOCK_LABEL_HEIGHT;
1579 let label_text_offset = 16.0;
1580 let notch_size = 5.0;
1581
1582 let pentagon_path = format!(
1584 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1585 x1 = x1,
1586 y1 = start_y,
1587 x2 = x1 + label_width,
1588 y2 = start_y + label_height - notch_size,
1589 x3 = x1 + label_width - notch_size,
1590 y3 = start_y + label_height
1591 );
1592
1593 writeln!(
1594 svg,
1595 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1596 path = pentagon_path,
1597 fill = theme.block_label_fill,
1598 stroke = theme.block_stroke
1599 )
1600 .unwrap();
1601
1602 writeln!(
1604 svg,
1605 r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1606 x = x1 + 5.0,
1607 y = start_y + label_text_offset,
1608 kind = label_text
1609 )
1610 .unwrap();
1611
1612 if !bl.label.is_empty() {
1614 let condition_text = format!("[{}]", bl.label);
1615 let text_x = x1 + label_width + 8.0;
1616 let text_y = start_y + label_text_offset;
1617
1618 writeln!(
1619 svg,
1620 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1621 x = text_x,
1622 y = text_y,
1623 label = escape_xml(&condition_text)
1624 )
1625 .unwrap();
1626 }
1627
1628 for (else_y, else_label_opt) in &bl.else_sections {
1630 writeln!(
1632 svg,
1633 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1634 x1 = x1,
1635 y = else_y,
1636 x2 = x2,
1637 c = theme.block_stroke
1638 )
1639 .unwrap();
1640
1641 if let Some(else_label_text) = else_label_opt {
1643 let condition_text = format!("[{}]", else_label_text);
1644 let text_x = x1 + label_width + 8.0; let text_y = else_y + label_text_offset; writeln!(
1648 svg,
1649 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1650 x = text_x,
1651 y = text_y,
1652 label = escape_xml(&condition_text)
1653 )
1654 .unwrap();
1655 }
1656 }
1657 }
1658}
1659
1660pub fn render(diagram: &Diagram) -> String {
1662 render_with_config(diagram, Config::default())
1663}
1664
1665pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1667 let participants = diagram.participants();
1668 let has_title = diagram.title.is_some();
1669 let footer_style = diagram.options.footer;
1670 let mut state = RenderState::new(
1671 config,
1672 participants,
1673 &diagram.items,
1674 has_title,
1675 footer_style,
1676 );
1677 let mut svg = String::new();
1678
1679 let content_height = calculate_height(&diagram.items, &state.config, 0);
1681 let title_space = if has_title {
1682 state.config.title_height
1683 } else {
1684 0.0
1685 };
1686 let footer_space = match footer_style {
1687 FooterStyle::Box => state.config.header_height,
1688 FooterStyle::Bar | FooterStyle::None => 0.0,
1689 };
1690 let footer_label_extra = match footer_style {
1691 FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1692 FooterStyle::Bar | FooterStyle::None => 0.0,
1693 };
1694 let footer_margin = state.config.row_height; let base_total_height = state.config.padding * 2.0
1696 + title_space
1697 + state.config.header_height
1698 + content_height
1699 + footer_margin
1700 + footer_space;
1701 let total_height = base_total_height + footer_label_extra;
1702
1703 state.current_y = state.content_start();
1705 let mut active_activation_count = 0;
1706 collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count, None);
1707
1708 let total_width = state.diagram_width();
1709
1710 writeln!(
1712 &mut svg,
1713 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1714 w = total_width,
1715 h = total_height
1716 )
1717 .unwrap();
1718
1719 let theme = &state.config.theme;
1721 let lifeline_dash = match theme.lifeline_style {
1722 LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1723 LifelineStyle::Solid => "",
1724 };
1725
1726 svg.push_str("<defs>\n");
1727 svg.push_str("<style>\n");
1728 writeln!(
1729 &mut svg,
1730 ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1731 fill = theme.participant_fill,
1732 stroke = theme.participant_stroke
1733 )
1734 .unwrap();
1735 writeln!(
1736 &mut svg,
1737 ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1738 f = theme.font_family,
1739 s = state.config.font_size,
1740 c = theme.participant_text
1741 )
1742 .unwrap();
1743 writeln!(
1744 &mut svg,
1745 ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1746 c = theme.lifeline_color,
1747 dash = lifeline_dash
1748 )
1749 .unwrap();
1750 writeln!(
1751 &mut svg,
1752 ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1753 c = theme.message_color
1754 )
1755 .unwrap();
1756 writeln!(
1757 &mut svg,
1758 ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1759 c = theme.message_color
1760 )
1761 .unwrap();
1762 writeln!(
1763 &mut svg,
1764 ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1765 f = theme.font_family,
1766 s = state.config.font_size,
1767 c = theme.message_text_color
1768 )
1769 .unwrap();
1770 writeln!(
1771 &mut svg,
1772 ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1773 fill = theme.note_fill,
1774 stroke = theme.note_stroke
1775 )
1776 .unwrap();
1777 writeln!(
1778 &mut svg,
1779 ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1780 f = theme.font_family,
1781 s = state.config.font_size - 1.0,
1782 c = theme.note_text_color
1783 )
1784 .unwrap();
1785 writeln!(
1786 &mut svg,
1787 ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1788 c = theme.block_stroke
1789 )
1790 .unwrap();
1791 writeln!(
1792 &mut svg,
1793 ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1794 f = theme.font_family,
1795 s = state.config.font_size - 1.0,
1796 c = theme.message_text_color
1797 )
1798 .unwrap();
1799 writeln!(
1800 &mut svg,
1801 ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1802 fill = theme.activation_fill,
1803 stroke = theme.activation_stroke
1804 )
1805 .unwrap();
1806 writeln!(
1807 &mut svg,
1808 ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1809 fill = theme.actor_fill,
1810 stroke = theme.actor_stroke
1811 )
1812 .unwrap();
1813 writeln!(
1814 &mut svg,
1815 ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1816 c = theme.actor_stroke
1817 )
1818 .unwrap();
1819 writeln!(
1820 &mut svg,
1821 ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1822 f = theme.font_family,
1823 s = state.config.font_size + 4.0,
1824 c = theme.message_text_color
1825 )
1826 .unwrap();
1827 writeln!(
1829 &mut svg,
1830 ".arrowhead {{ fill: {c}; stroke: none; }}",
1831 c = theme.message_color
1832 )
1833 .unwrap();
1834 writeln!(
1835 &mut svg,
1836 ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1837 c = theme.message_color
1838 )
1839 .unwrap();
1840 svg.push_str("</style>\n");
1841 svg.push_str("</defs>\n");
1842
1843 writeln!(
1845 &mut svg,
1846 r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1847 bg = theme.background
1848 )
1849 .unwrap();
1850
1851 if let Some(title) = &diagram.title {
1853 let title_y = state.config.padding + state.config.font_size + 7.36; writeln!(
1855 &mut svg,
1856 r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1857 x = total_width / 2.0,
1858 y = title_y,
1859 t = escape_xml(title)
1860 )
1861 .unwrap();
1862 }
1863
1864 let header_y = state.header_top();
1866 let footer_y = match footer_style {
1867 FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1868 FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1869 };
1870
1871 render_block_backgrounds(&mut svg, &state);
1874
1875 state.current_y = state.content_start();
1877
1878 let lifeline_start = header_y + state.config.header_height;
1880 let lifeline_end = footer_y;
1881
1882 for p in &state.participants {
1883 let x = state.get_x(p.id());
1884 writeln!(
1885 &mut svg,
1886 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1887 x = x,
1888 y1 = lifeline_start,
1889 y2 = lifeline_end
1890 )
1891 .unwrap();
1892 }
1893
1894 render_participant_headers(&mut svg, &state, header_y);
1896
1897 state.current_y = state.content_start();
1899 render_items(&mut svg, &mut state, &diagram.items, 0);
1900
1901 render_activations(&mut svg, &mut state, footer_y);
1903
1904 render_block_labels(&mut svg, &state);
1906
1907 match state.footer_style {
1909 FooterStyle::Box => {
1910 render_participant_headers(&mut svg, &state, footer_y);
1911 }
1912 FooterStyle::Bar => {
1913 let left = state.leftmost_x()
1915 - state.get_participant_width(
1916 state.participants.first().map(|p| p.id()).unwrap_or(""),
1917 ) / 2.0;
1918 let right = state.rightmost_x()
1919 + state
1920 .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1921 / 2.0;
1922 writeln!(
1923 &mut svg,
1924 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1925 x1 = left,
1926 y = footer_y,
1927 x2 = right,
1928 c = state.config.theme.lifeline_color
1929 )
1930 .unwrap();
1931 }
1932 FooterStyle::None => {
1933 }
1935 }
1936
1937 svg.push_str("</svg>\n");
1938 svg
1939}
1940
1941fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1942 fn inner(
1943 items: &[Item],
1944 config: &Config,
1945 depth: usize,
1946 else_pending: &mut Vec<bool>,
1947 serial_pending: &mut Vec<bool>,
1948 active_activation_count: &mut usize,
1949 parallel_depth: &mut usize,
1950 ) -> f64 {
1951 let mut height = 0.0;
1952 for item in items {
1953 match item {
1954 Item::Message {
1955 from,
1956 to,
1957 text,
1958 arrow,
1959 create,
1960 activate,
1961 deactivate,
1962 ..
1963 } => {
1964 if let Some(pending) = else_pending.last_mut() {
1965 if *pending && matches!(arrow.line, LineStyle::Dashed) {
1966 *pending = false;
1967 }
1968 }
1969 let is_self = from == to;
1970 let line_count = text.split("\\n").count();
1971 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1972 if is_self {
1973 height += self_message_y_advance(config, line_count);
1974 } else {
1975 height += regular_message_y_advance(config, line_count, delay_offset);
1976 }
1977 if *create {
1978 height += config.row_height;
1979 }
1980 if let Some(pending) = serial_pending.last_mut() {
1981 if *pending {
1982 height += serial_first_row_gap(*parallel_depth);
1983 *pending = false;
1984 }
1985 }
1986 if *activate {
1987 *active_activation_count += 1;
1988 }
1989 if *deactivate && *active_activation_count > 0 {
1990 *active_activation_count -= 1;
1991 }
1992 }
1993 Item::Note { text, .. } => {
1994 let line_count = text.split("\\n").count();
1995 height += note_y_advance(config, line_count);
1996 }
1997 Item::State { text, .. } => {
1998 let line_count = text.split("\\n").count();
1999 height += state_y_advance(config, line_count);
2000 }
2001 Item::Ref { text, .. } => {
2002 let line_count = text.split("\\n").count();
2003 height += ref_y_advance(config, line_count);
2004 }
2005 Item::Description { text } => {
2006 let line_count = text.split("\\n").count();
2007 height += description_y_advance(config, line_count);
2008 }
2009 Item::Block {
2010 kind,
2011 items,
2012 else_sections,
2013 ..
2014 } => {
2015 if block_is_parallel(kind) {
2016 let mut max_branch_height = 0.0;
2017 let base_activation_count = *active_activation_count;
2018 *parallel_depth += 1;
2019 for item in items {
2020 *active_activation_count = base_activation_count;
2021 let branch_height = inner(
2022 std::slice::from_ref(item),
2023 config,
2024 depth,
2025 else_pending,
2026 serial_pending,
2027 active_activation_count,
2028 parallel_depth,
2029 );
2030 if branch_height > max_branch_height {
2031 max_branch_height = branch_height;
2032 }
2033 }
2034 *active_activation_count = base_activation_count;
2035 if *parallel_depth > 0 {
2036 *parallel_depth -= 1;
2037 }
2038 let gap = if parallel_needs_gap(items) {
2039 config.row_height
2040 } else {
2041 0.0
2042 };
2043 height += max_branch_height + gap;
2044 continue;
2045 }
2046
2047 if matches!(kind, BlockKind::Serial) {
2048 serial_pending.push(true);
2049 height += inner(
2050 items,
2051 config,
2052 depth,
2053 else_pending,
2054 serial_pending,
2055 active_activation_count,
2056 parallel_depth,
2057 );
2058 for else_section in else_sections {
2059 height += inner(
2060 &else_section.items,
2061 config,
2062 depth,
2063 else_pending,
2064 serial_pending,
2065 active_activation_count,
2066 parallel_depth,
2067 );
2068 }
2069 serial_pending.pop();
2070 } else if !block_has_frame(kind) {
2071 height += inner(
2072 items,
2073 config,
2074 depth,
2075 else_pending,
2076 serial_pending,
2077 active_activation_count,
2078 parallel_depth,
2079 );
2080 for else_section in else_sections {
2081 height += inner(
2082 &else_section.items,
2083 config,
2084 depth,
2085 else_pending,
2086 serial_pending,
2087 active_activation_count,
2088 parallel_depth,
2089 );
2090 }
2091 } else {
2092 height += block_header_space(config, depth);
2093 height += inner(
2094 items,
2095 config,
2096 depth + 1,
2097 else_pending,
2098 serial_pending,
2099 active_activation_count,
2100 parallel_depth,
2101 );
2102 for else_section in else_sections {
2103 else_pending.push(true);
2104 height += block_else_before(config, depth) + block_else_after(config, depth);
2106 height += inner(
2107 &else_section.items,
2108 config,
2109 depth + 1,
2110 else_pending,
2111 serial_pending,
2112 active_activation_count,
2113 parallel_depth,
2114 );
2115 else_pending.pop();
2116 }
2117 height += block_end_y_advance(config, depth);
2119 }
2120 }
2121 Item::Activate { .. } => {
2122 *active_activation_count += 1;
2123 }
2124 Item::Deactivate { .. } => {
2125 if *active_activation_count > 0 {
2126 *active_activation_count -= 1;
2127 }
2128 }
2129 Item::Destroy { .. } => {
2130 height += config.row_height;
2131 }
2132 Item::ParticipantDecl { .. } => {}
2133 Item::Autonumber { .. } => {}
2134 Item::DiagramOption { .. } => {} }
2136 }
2137 height
2138 }
2139
2140 let mut else_pending = Vec::new();
2141 let mut serial_pending = Vec::new();
2142 let mut active_activation_count = 0;
2143 let mut parallel_depth = 0;
2144 inner(
2145 items,
2146 config,
2147 depth,
2148 &mut else_pending,
2149 &mut serial_pending,
2150 &mut active_activation_count,
2151 &mut parallel_depth,
2152 )
2153}
2154
2155fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
2156 let shape = state.config.theme.participant_shape;
2157
2158 for p in &state.participants {
2159 let x = state.get_x(p.id());
2160 let p_width = state.get_participant_width(p.id());
2161 let box_x = x - p_width / 2.0;
2162
2163 match p.kind {
2164 ParticipantKind::Participant => {
2165 match shape {
2167 ParticipantShape::Rectangle => {
2168 writeln!(
2169 svg,
2170 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2171 x = box_x,
2172 y = y,
2173 w = p_width,
2174 h = state.config.header_height
2175 )
2176 .unwrap();
2177 }
2178 ParticipantShape::RoundedRect => {
2179 writeln!(
2180 svg,
2181 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2182 x = box_x,
2183 y = y,
2184 w = p_width,
2185 h = state.config.header_height
2186 )
2187 .unwrap();
2188 }
2189 ParticipantShape::Circle => {
2190 let rx = p_width / 2.0 - 5.0;
2192 let ry = state.config.header_height / 2.0 - 2.0;
2193 writeln!(
2194 svg,
2195 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2196 cx = x,
2197 cy = y + state.config.header_height / 2.0,
2198 rx = rx,
2199 ry = ry
2200 )
2201 .unwrap();
2202 }
2203 }
2204 let lines: Vec<&str> = p.name.split("\\n").collect();
2206 if lines.len() == 1 {
2207 writeln!(
2208 svg,
2209 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2210 x = x,
2211 y = y + state.config.header_height / 2.0 + 5.0,
2212 name = escape_xml(&p.name)
2213 )
2214 .unwrap();
2215 } else {
2216 let line_height = state.config.font_size + 2.0;
2217 let total_height = lines.len() as f64 * line_height;
2218 let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2219 + line_height * 0.8;
2220 write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2221 for (i, line) in lines.iter().enumerate() {
2222 let dy = if i == 0 { start_y } else { line_height };
2223 if i == 0 {
2224 writeln!(
2225 svg,
2226 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2227 x = x,
2228 y = dy,
2229 text = escape_xml(line)
2230 )
2231 .unwrap();
2232 } else {
2233 writeln!(
2234 svg,
2235 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2236 x = x,
2237 dy = dy,
2238 text = escape_xml(line)
2239 )
2240 .unwrap();
2241 }
2242 }
2243 writeln!(svg, "</text>").unwrap();
2244 }
2245 }
2246 ParticipantKind::Actor => {
2247 let head_r = 8.0;
2249 let body_len = 12.0;
2250 let arm_len = 10.0;
2251 let leg_len = 10.0;
2252 let figure_height = 38.0; let fig_top = y + 8.0;
2256 let fig_center_y = fig_top + head_r + body_len / 2.0;
2257 let arm_y = fig_center_y + 2.0;
2258
2259 writeln!(
2261 svg,
2262 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2263 x = x,
2264 cy = fig_center_y - body_len / 2.0 - head_r,
2265 r = head_r
2266 )
2267 .unwrap();
2268 writeln!(
2270 svg,
2271 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2272 x = x,
2273 y1 = fig_center_y - body_len / 2.0,
2274 y2 = fig_center_y + body_len / 2.0
2275 )
2276 .unwrap();
2277 writeln!(
2279 svg,
2280 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2281 x1 = x - arm_len,
2282 y = arm_y,
2283 x2 = x + arm_len
2284 )
2285 .unwrap();
2286 writeln!(
2288 svg,
2289 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2290 x = x,
2291 y1 = fig_center_y + body_len / 2.0,
2292 x2 = x - leg_len * 0.6,
2293 y2 = fig_center_y + body_len / 2.0 + leg_len
2294 )
2295 .unwrap();
2296 writeln!(
2298 svg,
2299 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2300 x = x,
2301 y1 = fig_center_y + body_len / 2.0,
2302 x2 = x + leg_len * 0.6,
2303 y2 = fig_center_y + body_len / 2.0 + leg_len
2304 )
2305 .unwrap();
2306 let name_lines: Vec<&str> = p.name.split("\\n").collect();
2308 let name_start_y = fig_top + figure_height + 5.0;
2309 if name_lines.len() == 1 {
2310 writeln!(
2311 svg,
2312 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2313 x = x,
2314 y = name_start_y + state.config.font_size,
2315 name = escape_xml(&p.name)
2316 )
2317 .unwrap();
2318 } else {
2319 let line_height = state.config.font_size + 2.0;
2321 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2322 for (i, line) in name_lines.iter().enumerate() {
2323 if i == 0 {
2324 writeln!(
2325 svg,
2326 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2327 x = x,
2328 y = name_start_y + state.config.font_size,
2329 text = escape_xml(line)
2330 )
2331 .unwrap();
2332 } else {
2333 writeln!(
2334 svg,
2335 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2336 x = x,
2337 dy = line_height,
2338 text = escape_xml(line)
2339 )
2340 .unwrap();
2341 }
2342 }
2343 writeln!(svg, "</text>").unwrap();
2344 }
2345 }
2346 }
2347 }
2348}
2349
2350fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2351 for item in items {
2352 match item {
2353 Item::Message {
2354 from,
2355 to,
2356 text,
2357 arrow,
2358 activate,
2359 deactivate,
2360 create,
2361 ..
2362 } => {
2363 render_message(
2364 svg,
2365 state,
2366 from,
2367 to,
2368 text,
2369 arrow,
2370 *activate,
2371 *deactivate,
2372 *create,
2373 depth,
2374 );
2375 }
2376 Item::Note {
2377 position,
2378 participants,
2379 text,
2380 } => {
2381 render_note(svg, state, position, participants, text);
2382 }
2383 Item::Block {
2384 kind,
2385 label,
2386 items,
2387 else_sections,
2388 } => {
2389 render_block(svg, state, kind, label, items, else_sections, depth);
2390 }
2391 Item::Activate { participant } => {
2392 let y = state.current_y;
2393 state
2394 .activations
2395 .entry(participant.clone())
2396 .or_default()
2397 .push((y, None));
2398 }
2399 Item::Deactivate { participant } => {
2400 if let Some(acts) = state.activations.get_mut(participant) {
2401 if let Some(act) = acts.last_mut() {
2402 if act.1.is_none() {
2403 act.1 = Some(state.current_y);
2404 }
2405 }
2406 }
2407 }
2408 Item::Destroy { participant } => {
2409 let destroy_y = state.current_y - state.config.row_height;
2412 state.destroyed.insert(participant.clone(), destroy_y);
2413 let x = state.get_x(participant);
2415 let y = destroy_y;
2416 let size = 15.0; let theme = &state.config.theme;
2418 writeln!(
2419 svg,
2420 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2421 x1 = x - size,
2422 y1 = y - size,
2423 x2 = x + size,
2424 y2 = y + size,
2425 stroke = theme.message_color
2426 )
2427 .unwrap();
2428 writeln!(
2429 svg,
2430 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2431 x1 = x + size,
2432 y1 = y - size,
2433 x2 = x - size,
2434 y2 = y + size,
2435 stroke = theme.message_color
2436 )
2437 .unwrap();
2438 state.current_y += state.config.row_height;
2439 }
2440 Item::Autonumber { enabled, start } => {
2441 if *enabled {
2442 state.autonumber = Some(start.unwrap_or(1));
2443 } else {
2444 state.autonumber = None;
2445 }
2446 }
2447 Item::ParticipantDecl { .. } => {
2448 }
2450 Item::State { participants, text } => {
2451 render_state(svg, state, participants, text);
2452 }
2453 Item::Ref {
2454 participants,
2455 text,
2456 input_from,
2457 input_label,
2458 output_to,
2459 output_label,
2460 } => {
2461 render_ref(
2462 svg,
2463 state,
2464 participants,
2465 text,
2466 input_from.as_deref(),
2467 input_label.as_deref(),
2468 output_to.as_deref(),
2469 output_label.as_deref(),
2470 );
2471 }
2472 Item::DiagramOption { .. } => {
2473 }
2475 Item::Description { text } => {
2476 render_description(svg, state, text);
2477 }
2478 }
2479 }
2480}
2481
2482fn render_message(
2483 svg: &mut String,
2484 state: &mut RenderState,
2485 from: &str,
2486 to: &str,
2487 text: &str,
2488 arrow: &Arrow,
2489 activate: bool,
2490 deactivate: bool,
2491 create: bool,
2492 _depth: usize,
2493) {
2494 let base_x1 = state.get_x(from);
2496 let base_x2 = state.get_x(to);
2497
2498 state.apply_else_return_gap(arrow);
2499
2500 let is_self = from == to;
2501 let line_class = match arrow.line {
2502 LineStyle::Solid => "message",
2503 LineStyle::Dashed => "message-dashed",
2504 };
2505 let is_filled = matches!(arrow.head, ArrowHead::Filled);
2506
2507 let num_prefix = state
2509 .next_number()
2510 .map(|n| format!("{}. ", n))
2511 .unwrap_or_default();
2512
2513 let display_text = format!("{}{}", num_prefix, text);
2515 let lines: Vec<&str> = display_text.split("\\n").collect();
2516 let line_height = state.config.font_size + 4.0;
2517 let extra_height = if !is_self && lines.len() > 1 {
2518 (lines.len() - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
2521 } else {
2522 0.0
2523 };
2524
2525 if !is_self && lines.len() > 1 {
2527 state.current_y += extra_height;
2528 }
2529
2530 let y = state.current_y;
2531 let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2532
2533 let going_right = base_x2 > base_x1;
2535 let x1 = state.get_arrow_start_x(from, y, going_right);
2536 let x2 = state.get_arrow_end_x(to, y, !going_right);
2537
2538 writeln!(svg, r#"<g class="message">"#).unwrap();
2540
2541 if is_self {
2542 let loop_width = 40.0;
2544 let text_block_height = lines.len() as f64 * line_height;
2545 let loop_height = text_block_height.max(25.0);
2547 let arrow_end_x = x1;
2548 let arrow_end_y = y + loop_height;
2549 let direction = std::f64::consts::PI;
2551 let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2552
2553 writeln!(
2554 svg,
2555 r#" <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2556 x1 = x1,
2557 y = y,
2558 x2 = x1 + loop_width,
2559 y2 = y + loop_height,
2560 arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2561 cls = line_class
2562 )
2563 .unwrap();
2564
2565 if is_filled {
2567 writeln!(
2568 svg,
2569 r#" <polygon points="{points}" class="arrowhead"/>"#,
2570 points = arrow_points
2571 )
2572 .unwrap();
2573 } else {
2574 writeln!(
2575 svg,
2576 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2577 points = arrow_points
2578 )
2579 .unwrap();
2580 }
2581
2582 let text_x = x1 + loop_width + 5.0;
2586 for (i, line) in lines.iter().enumerate() {
2587 let line_y = y + 4.0 + (i as f64 + 0.5) * line_height;
2588 writeln!(
2589 svg,
2590 r#" <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2591 x = text_x,
2592 y = line_y,
2593 t = escape_xml(line)
2594 )
2595 .unwrap();
2596 }
2597
2598 writeln!(svg, r#"</g>"#).unwrap();
2600
2601 let spacing = self_message_spacing(&state.config, lines.len());
2602 state.current_y += spacing;
2603 } else {
2604 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2606 let y2 = y + delay_offset;
2607
2608 let text_x = (base_x1 + base_x2) / 2.0;
2610 let text_y = (y + y2) / 2.0 - 6.0; let direction = arrow_direction(x1, y, x2, y2);
2614 let arrow_points = arrowhead_points(x2, y2, direction);
2615
2616 let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2618 let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2619
2620 writeln!(
2622 svg,
2623 r#" <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2624 x1 = x1,
2625 y1 = y,
2626 lx2 = line_end_x,
2627 ly2 = line_end_y,
2628 cls = line_class
2629 )
2630 .unwrap();
2631
2632 if is_filled {
2634 writeln!(
2635 svg,
2636 r#" <polygon points="{points}" class="arrowhead"/>"#,
2637 points = arrow_points
2638 )
2639 .unwrap();
2640 } else {
2641 writeln!(
2642 svg,
2643 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2644 points = arrow_points
2645 )
2646 .unwrap();
2647 }
2648
2649 let max_width = lines
2651 .iter()
2652 .map(|line| estimate_message_width(line, state.config.font_size))
2653 .fold(0.0, f64::max);
2654 let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2655 let bottom_line_y = text_y;
2656 let label_offset = if has_label_text {
2657 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2658 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2659 let label_x_min = text_x - max_width / 2.0;
2660 let label_x_max = text_x + max_width / 2.0;
2661 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2662 let raw_offset = state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step);
2663 let max_offset = y - MESSAGE_TEXT_ABOVE_ARROW - bottom_line_y;
2665 raw_offset.min(max_offset.max(0.0))
2666 } else {
2667 0.0
2668 };
2669 let rotation = if delay_offset > 0.0 {
2671 let dx = x2 - x1;
2672 let dy = delay_offset;
2673 let angle_rad = dy.atan2(dx.abs());
2674 let angle_deg = angle_rad.to_degrees();
2675 if dx < 0.0 { -angle_deg } else { angle_deg }
2677 } else {
2678 0.0
2679 };
2680
2681 for (i, line) in lines.iter().enumerate() {
2682 let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2683 if rotation.abs() > 0.1 {
2684 writeln!(
2686 svg,
2687 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle" transform="rotate({rot},{cx},{cy})">{t}</text>"#,
2688 x = text_x,
2689 y = line_y,
2690 rot = rotation,
2691 cx = text_x,
2692 cy = line_y,
2693 t = escape_xml(line)
2694 )
2695 .unwrap();
2696 } else {
2697 writeln!(
2698 svg,
2699 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2700 x = text_x,
2701 y = line_y,
2702 t = escape_xml(line)
2703 )
2704 .unwrap();
2705 }
2706 }
2707
2708 writeln!(svg, r#"</g>"#).unwrap();
2710
2711 state.current_y += state.config.row_height + delay_offset;
2713 }
2714
2715 if create {
2716 state.current_y += state.config.row_height;
2717 }
2718
2719 state.apply_serial_first_row_gap();
2720
2721 if activate {
2723 state
2724 .activations
2725 .entry(to.to_string())
2726 .or_default()
2727 .push((y, None));
2728 }
2729 if deactivate {
2730 if let Some(acts) = state.activations.get_mut(from) {
2731 if let Some(act) = acts.last_mut() {
2732 if act.1.is_none() {
2733 act.1 = Some(y);
2734 }
2735 }
2736 }
2737 }
2738}
2739
2740fn render_note(
2741 svg: &mut String,
2742 state: &mut RenderState,
2743 position: &NotePosition,
2744 participants: &[String],
2745 text: &str,
2746) {
2747 let lines: Vec<&str> = text.split("\\n").collect();
2748 let line_height = note_line_height(&state.config);
2749
2750 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
2752 let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
2753 let content_width = (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH);
2754 let note_height = ELEMENT_PADDING * 2.0 + lines.len() as f64 * line_height;
2755
2756 let (x, note_width, text_anchor) = match position {
2757 NotePosition::Left => {
2758 let px = state.get_x(&participants[0]);
2759 let x = (px - NOTE_MARGIN - content_width).max(state.config.padding);
2761 (x, content_width, "start")
2762 }
2763 NotePosition::Right => {
2764 let px = state.get_x(&participants[0]);
2765 (px + NOTE_MARGIN, content_width, "start")
2767 }
2768 NotePosition::Over => {
2769 if participants.len() == 1 {
2770 let px = state.get_x(&participants[0]);
2771 let x = (px - content_width / 2.0).max(state.config.padding);
2773 (x, content_width, "middle")
2774 } else {
2775 let x1 = state.get_x(&participants[0]);
2777 let x2 = state.get_x(participants.last().unwrap());
2778 let span_width = (x2 - x1).abs() + NOTE_MARGIN * 2.0;
2779 let w = span_width.max(content_width);
2780 let x = (x1 - NOTE_MARGIN).max(state.config.padding);
2781 (x, w, "middle")
2782 }
2783 }
2784 };
2785
2786 let y = state.current_y;
2787 let fold_size = NOTE_FOLD_SIZE;
2788
2789 let note_path = format!(
2792 "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2793 x = x,
2794 y = y,
2795 x2 = x + note_width - fold_size,
2796 x3 = x + note_width,
2797 y2 = y + fold_size,
2798 y3 = y + note_height
2799 );
2800
2801 writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2802
2803 let theme = &state.config.theme;
2805 let fold_path = format!(
2807 "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2808 x1 = x + note_width - fold_size,
2809 y1 = y,
2810 x2 = x + note_width,
2811 y2 = y + fold_size
2812 );
2813
2814 writeln!(
2815 svg,
2816 r##"<path d="{path}" fill="none" stroke="{stroke}" stroke-width="1"/>"##,
2817 path = fold_path,
2818 stroke = theme.note_stroke
2819 )
2820 .unwrap();
2821
2822 let text_x = match text_anchor {
2824 "middle" => x + note_width / 2.0,
2825 _ => x + ELEMENT_PADDING,
2826 };
2827 let text_anchor_attr = if *position == NotePosition::Over { "middle" } else { "start" };
2828
2829 for (i, line) in lines.iter().enumerate() {
2830 let text_y = y + ELEMENT_PADDING + (i as f64 + 0.8) * line_height;
2831 writeln!(
2832 svg,
2833 r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2834 x = text_x,
2835 y = text_y,
2836 anchor = text_anchor_attr,
2837 t = escape_xml(line)
2838 )
2839 .unwrap();
2840 }
2841
2842 state.current_y += note_y_advance(&state.config, lines.len());
2844}
2845
2846fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2848 let theme = &state.config.theme;
2849 let lines: Vec<&str> = text.split("\\n").collect();
2850 let line_height = state_line_height(&state.config);
2851 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2852
2853 let (x, box_width) = if participants.len() == 1 {
2855 let px = state.get_x(&participants[0]);
2856 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2857 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2858 (px - w / 2.0, w)
2859 } else {
2860 let x1 = state.get_x(&participants[0]);
2861 let x2 = state.get_x(participants.last().unwrap());
2862 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2863 let center = (x1 + x2) / 2.0;
2864 (center - span_width / 2.0, span_width)
2865 };
2866
2867 let shift = item_pre_shift(&state.config);
2868 let y = (state.current_y - shift).max(state.content_start());
2869
2870 writeln!(
2872 svg,
2873 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2874 x = x,
2875 y = y,
2876 w = box_width,
2877 h = box_height,
2878 fill = theme.state_fill,
2879 stroke = theme.state_stroke
2880 )
2881 .unwrap();
2882
2883 let text_x = x + box_width / 2.0;
2885 for (i, line) in lines.iter().enumerate() {
2886 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2887 writeln!(
2888 svg,
2889 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2890 x = text_x,
2891 y = text_y,
2892 fill = theme.state_text_color,
2893 font = theme.font_family,
2894 size = state.config.font_size,
2895 t = escape_xml(line)
2896 )
2897 .unwrap();
2898 }
2899
2900 state.current_y = y + box_height + state.config.row_height;
2901}
2902
2903fn render_ref(
2905 svg: &mut String,
2906 state: &mut RenderState,
2907 participants: &[String],
2908 text: &str,
2909 input_from: Option<&str>,
2910 input_label: Option<&str>,
2911 output_to: Option<&str>,
2912 output_label: Option<&str>,
2913) {
2914 let theme = &state.config.theme;
2915 let lines: Vec<&str> = text.split("\\n").collect();
2916 let line_height = ref_line_height(&state.config);
2917 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2918 let notch_size = 10.0;
2919
2920 let (x, box_width) = if participants.len() == 1 {
2922 let px = state.get_x(&participants[0]);
2923 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2924 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2925 .max(100.0);
2926 (px - w / 2.0, w)
2927 } else {
2928 let x1 = state.get_x(&participants[0]);
2929 let x2 = state.get_x(participants.last().unwrap());
2930 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2931 let center = (x1 + x2) / 2.0;
2932 (center - span_width / 2.0, span_width)
2933 };
2934
2935 let shift = item_pre_shift(&state.config);
2936 let y = (state.current_y - shift).max(state.content_start());
2937 let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2938 let output_padding = state.config.note_padding + 3.0;
2939
2940 if let Some(from) = input_from {
2942 let from_x = state.get_x(from);
2943 let to_x = x; let arrow_y = y + input_offset;
2945
2946 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2948 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2949 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2950
2951 writeln!(
2953 svg,
2954 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2955 x1 = from_x,
2956 y = arrow_y,
2957 x2 = line_end_x
2958 )
2959 .unwrap();
2960
2961 writeln!(
2963 svg,
2964 r#"<polygon points="{points}" class="arrowhead"/>"#,
2965 points = arrow_points
2966 )
2967 .unwrap();
2968
2969 if let Some(label) = input_label {
2971 let text_x = (from_x + to_x) / 2.0;
2972 writeln!(
2973 svg,
2974 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2975 x = text_x,
2976 y = arrow_y - 8.0,
2977 t = escape_xml(label)
2978 )
2979 .unwrap();
2980 }
2981 }
2982
2983 let ref_path = format!(
2986 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2987 x1 = x + notch_size,
2988 y1 = y,
2989 x2 = x + box_width,
2990 y2 = y + box_height,
2991 x3 = x,
2992 y3 = y + box_height / 2.0
2993 );
2994
2995 writeln!(
2996 svg,
2997 r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2998 path = ref_path,
2999 fill = theme.ref_fill,
3000 stroke = theme.ref_stroke
3001 )
3002 .unwrap();
3003
3004 writeln!(
3006 svg,
3007 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
3008 x = x + notch_size + 4.0,
3009 y = y + state.config.font_size,
3010 fill = theme.ref_text_color,
3011 font = theme.font_family,
3012 size = state.config.font_size - 2.0
3013 )
3014 .unwrap();
3015
3016 let text_x = x + box_width / 2.0;
3018 for (i, line) in lines.iter().enumerate() {
3019 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
3020 writeln!(
3021 svg,
3022 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
3023 x = text_x,
3024 y = text_y,
3025 fill = theme.ref_text_color,
3026 font = theme.font_family,
3027 size = state.config.font_size,
3028 t = escape_xml(line)
3029 )
3030 .unwrap();
3031 }
3032
3033 if let Some(to) = output_to {
3035 let from_x = x + box_width; let to_x = state.get_x(to);
3037 let arrow_y = y + box_height - output_padding;
3038
3039 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
3041 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
3042 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
3043
3044 writeln!(
3046 svg,
3047 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
3048 x1 = from_x,
3049 y = arrow_y,
3050 x2 = line_end_x
3051 )
3052 .unwrap();
3053
3054 writeln!(
3056 svg,
3057 r#"<polygon points="{points}" class="arrowhead"/>"#,
3058 points = arrow_points
3059 )
3060 .unwrap();
3061
3062 if let Some(label) = output_label {
3064 let text_x = (from_x + to_x) / 2.0;
3065 writeln!(
3066 svg,
3067 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3068 x = text_x,
3069 y = arrow_y - 8.0,
3070 t = escape_xml(label)
3071 )
3072 .unwrap();
3073 }
3074 }
3075
3076 state.current_y = y + box_height + state.config.row_height;
3077}
3078
3079fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
3081 let theme = &state.config.theme;
3082 let lines: Vec<&str> = text.split("\\n").collect();
3083 let line_height = state.config.font_size + 4.0;
3084
3085 let x = state.config.padding + 10.0;
3087 let y = state.current_y;
3088
3089 for (i, line) in lines.iter().enumerate() {
3090 let text_y = y + (i as f64 + 0.8) * line_height;
3091 writeln!(
3092 svg,
3093 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
3094 x = x,
3095 y = text_y,
3096 fill = theme.description_text_color,
3097 font = theme.font_family,
3098 size = state.config.font_size - 1.0,
3099 t = escape_xml(line)
3100 )
3101 .unwrap();
3102 }
3103
3104 state.current_y += description_y_advance(&state.config, lines.len());
3105}
3106
3107fn render_block(
3108 svg: &mut String,
3109 state: &mut RenderState,
3110 kind: &BlockKind,
3111 _label: &str,
3112 items: &[Item],
3113 else_sections: &[crate::ast::ElseSection],
3114 depth: usize,
3115) {
3116 if block_is_parallel(kind) {
3117 state.push_parallel();
3118 let start_y = state.current_y;
3119 let mut max_end_y = start_y;
3120 for item in items {
3121 state.current_y = start_y;
3122 render_items(svg, state, std::slice::from_ref(item), depth);
3123 if state.current_y > max_end_y {
3124 max_end_y = state.current_y;
3125 }
3126 }
3127 let gap = if parallel_needs_gap(items) {
3128 state.config.row_height
3129 } else {
3130 0.0
3131 };
3132 state.current_y = max_end_y + gap;
3133 state.pop_parallel();
3134 return;
3135 }
3136
3137 if matches!(kind, BlockKind::Serial) {
3138 state.push_serial_first_row_pending();
3139 render_items(svg, state, items, depth);
3140 for else_section in else_sections {
3141 render_items(svg, state, &else_section.items, depth);
3142 }
3143 state.pop_serial_first_row_pending();
3144 return;
3145 }
3146
3147 if !block_has_frame(kind) {
3148 render_items(svg, state, items, depth);
3149 for else_section in else_sections {
3150 render_items(svg, state, &else_section.items, depth);
3151 }
3152 return;
3153 }
3154
3155 state.current_y += block_header_space(&state.config, depth);
3160
3161 render_items(svg, state, items, depth + 1);
3163
3164 for else_section in else_sections {
3166 state.push_else_return_pending();
3167 state.current_y += block_else_before(&state.config, depth);
3169 state.current_y += block_else_after(&state.config, depth);
3171 render_items(svg, state, &else_section.items, depth + 1);
3172 state.pop_else_return_pending();
3173 }
3174
3175 let end_y = state.current_y + block_footer_padding(&state.config, depth);
3178
3179 state.current_y = end_y + state.config.row_height;
3181
3182 }
3185
3186fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3187 for (participant, activations) in &state.activations {
3188 let x = state.get_x(participant);
3189 let box_x = x - state.config.activation_width / 2.0;
3190
3191 for (start_y, end_y) in activations {
3192 let end = end_y.unwrap_or(footer_y);
3194 let height = end - start_y;
3195
3196 if height > 0.0 {
3197 writeln!(
3198 svg,
3199 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3200 x = box_x,
3201 y = start_y,
3202 w = state.config.activation_width,
3203 h = height
3204 )
3205 .unwrap();
3206 }
3207 }
3208 }
3209}
3210
3211fn escape_xml(s: &str) -> String {
3212 s.replace('&', "&")
3213 .replace('<', "<")
3214 .replace('>', ">")
3215 .replace('"', """)
3216 .replace('\'', "'")
3217}
3218
3219#[cfg(test)]
3220mod tests {
3221 use super::*;
3222 use crate::parser::parse;
3223
3224 #[test]
3225 fn test_render_simple() {
3226 let diagram = parse("Alice->Bob: Hello").unwrap();
3227 let svg = render(&diagram);
3228 assert!(svg.contains("<svg"));
3229 assert!(svg.contains("Alice"));
3230 assert!(svg.contains("Bob"));
3231 assert!(svg.contains("Hello"));
3232 }
3233
3234 #[test]
3235 fn test_render_with_note() {
3236 let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3237 let svg = render(&diagram);
3238 assert!(svg.contains("Thinking"));
3239 }
3240}