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