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