1use crate::ast::*;
4use crate::theme::{LifelineStyle, ParticipantShape, Theme};
5use std::collections::HashMap;
6use std::fmt::Write;
7
8#[derive(Debug, Clone)]
10pub struct Config {
11 pub padding: f64,
13 pub left_margin: f64,
15 pub right_margin: f64,
17 pub participant_gap: f64,
19 pub header_height: f64,
21 pub row_height: f64,
23 pub participant_width: f64,
25 pub font_size: f64,
27 pub activation_width: f64,
29 pub note_padding: f64,
31 pub block_margin: f64,
33 pub title_height: f64,
35 pub theme: Theme,
37}
38
39impl Default for Config {
40 fn default() -> Self {
41 Self {
47 padding: 10.5, left_margin: 66.0, right_margin: 10.0, participant_gap: 85.0, header_height: 46.0, row_height: 32.0, participant_width: 92.0, font_size: 14.0, activation_width: 8.0, note_padding: 6.0,
57 block_margin: 5.0,
58 title_height: 100.0, theme: Theme::default(),
60 }
61 }
62}
63
64impl Config {
65 pub fn with_theme(mut self, theme: Theme) -> Self {
67 self.theme = theme;
68 self
69 }
70}
71
72#[derive(Debug, Clone)]
74struct BlockBackground {
75 x: f64,
76 y: f64,
77 width: f64,
78 height: f64,
79}
80
81#[derive(Debug, Clone)]
83struct BlockLabel {
84 x1: f64,
85 start_y: f64,
86 end_y: f64,
87 x2: f64,
88 kind: String,
89 label: String,
90 else_sections: Vec<(f64, Option<String>)>,
92}
93
94#[derive(Debug, Clone)]
95struct LabelBox {
96 x_min: f64,
97 x_max: f64,
98 y_min: f64,
99 y_max: f64,
100}
101
102struct RenderState {
104 config: Config,
105 participants: Vec<Participant>,
106 participant_x: HashMap<String, f64>,
107 participant_widths: HashMap<String, f64>,
108 current_y: f64,
109 activations: HashMap<String, Vec<(f64, Option<f64>)>>,
110 autonumber: Option<u32>,
111 destroyed: HashMap<String, f64>,
112 has_title: bool,
113 total_width: f64,
114 block_backgrounds: Vec<BlockBackground>,
116 block_labels: Vec<BlockLabel>,
118 footer_style: FooterStyle,
120 else_return_pending: Vec<bool>,
122 serial_first_row_pending: Vec<bool>,
124 parallel_depth: usize,
126 message_label_boxes: Vec<LabelBox>,
128}
129
130const TEXT_WIDTH_PADDING: f64 = 41.0;
134const TEXT_WIDTH_SCALE: f64 = 1.3;
135const MESSAGE_WIDTH_PADDING: f64 = 4.0;
136const MESSAGE_WIDTH_SCALE: f64 = 0.82;
137
138fn group_spacing(config: &Config) -> f64 {
145 config.row_height }
147
148const MESSAGE_TEXT_ABOVE_ARROW: f64 = 6.0; const DELAY_UNIT: f64 = 18.0; const BLOCK_LABEL_HEIGHT: f64 = 22.0; const ELEMENT_PADDING: f64 = 8.0; const NOTE_MARGIN: f64 = 10.0; const NOTE_FOLD_SIZE: f64 = 8.0; const NOTE_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;
178const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
179const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
180const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
181
182fn block_header_space(config: &Config, _depth: usize) -> f64 {
183 BLOCK_LABEL_HEIGHT + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
185}
186
187fn block_frame_shift(_depth: usize) -> f64 {
188 0.0
189}
190
191fn block_footer_padding(_config: &Config, _depth: usize) -> f64 {
192 ELEMENT_PADDING
193}
194
195fn block_else_before(_config: &Config, _depth: usize) -> f64 {
196 ELEMENT_PADDING
197}
198
199fn block_else_after(config: &Config, _depth: usize) -> f64 {
200 16.0 + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
204}
205
206fn self_message_spacing(config: &Config, lines: usize) -> f64 {
207 let line_height = config.font_size + 4.0;
208 let text_block_height = lines as f64 * line_height;
209 let loop_height = text_block_height.max(25.0);
210 loop_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
214}
215
216fn note_line_height(_config: &Config) -> f64 {
217 NOTE_LINE_HEIGHT
219}
220
221fn note_padding(_config: &Config) -> f64 {
222 ELEMENT_PADDING
223}
224
225fn item_pre_gap(config: &Config) -> f64 {
226 config.font_size + 1.0
227}
228
229fn item_pre_shift(config: &Config) -> f64 {
230 (config.row_height - item_pre_gap(config)).max(0.0)
231}
232
233fn label_boxes_overlap(x_min: f64, x_max: f64, y_min: f64, y_max: f64, other: &LabelBox) -> bool {
234 let x_overlap = x_max >= other.x_min - MESSAGE_LABEL_COLLISION_PADDING
235 && x_min <= other.x_max + MESSAGE_LABEL_COLLISION_PADDING;
236 let y_overlap = y_max >= other.y_min - MESSAGE_LABEL_COLLISION_PADDING
237 && y_min <= other.y_max + MESSAGE_LABEL_COLLISION_PADDING;
238 x_overlap && y_overlap
239}
240
241fn actor_footer_extra(_participants: &[Participant], _config: &Config) -> f64 {
242 0.0
244}
245
246fn serial_first_row_gap(_parallel_depth: usize) -> f64 {
247 0.0
249}
250
251fn state_line_height(config: &Config) -> f64 {
252 config.font_size + STATE_LINE_HEIGHT_EXTRA
253}
254
255fn ref_line_height(config: &Config) -> f64 {
256 config.font_size + REF_LINE_HEIGHT_EXTRA
257}
258
259fn regular_message_y_advance(config: &Config, line_count: usize, delay_offset: f64) -> f64 {
267 let line_height = config.font_size + 4.0;
269 let extra_height = if line_count > 1 {
270 (line_count - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
272 } else {
273 0.0
274 };
275 group_spacing(config) + extra_height + delay_offset
278}
279
280fn self_message_y_advance(config: &Config, line_count: usize) -> f64 {
282 self_message_spacing(config, line_count)
283}
284
285fn note_y_advance(config: &Config, line_count: usize) -> f64 {
287 let note_height = note_padding(config) * 2.0 + line_count as f64 * note_line_height(config);
288 note_height.max(group_spacing(config)) + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
290}
291
292fn state_y_advance(config: &Config, line_count: usize) -> f64 {
294 let box_height = ELEMENT_PADDING * 2.0 + line_count as f64 * state_line_height(config);
295 box_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
297}
298
299fn ref_y_advance(config: &Config, line_count: usize) -> f64 {
301 let box_height = BLOCK_LABEL_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 note_font_size = NOTE_LINE_HEIGHT - 4.0;
450 let text_width = estimate_text_width(text, note_font_size);
451 (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH)
452}
453
454fn calculate_right_margin(
456 participants: &[Participant],
457 items: &[Item],
458 config: &Config,
459) -> f64 {
460 let rightmost_id = match participants.last() {
461 Some(p) => p.id().to_string(),
462 None => return config.right_margin,
463 };
464 let mut max_right_note_width: f64 = 0.0;
465
466 fn process_items_for_right_notes(
467 items: &[Item],
468 rightmost_id: &str,
469 max_width: &mut f64,
470 config: &Config,
471 ) {
472 for item in items {
473 match item {
474 Item::Note {
475 position: NotePosition::Right,
476 participants,
477 text,
478 } => {
479 if participants.first().map(|s| s.as_str()) == Some(rightmost_id) {
481 let note_width = calculate_note_width(text, config);
482 if note_width > *max_width {
483 *max_width = note_width;
484 }
485 }
486 }
487 Item::Block {
488 items, else_sections, ..
489 } => {
490 process_items_for_right_notes(items, rightmost_id, max_width, config);
491 for section in else_sections {
492 process_items_for_right_notes(§ion.items, rightmost_id, max_width, config);
493 }
494 }
495 _ => {}
496 }
497 }
498 }
499
500 process_items_for_right_notes(items, &rightmost_id, &mut max_right_note_width, config);
501
502 if max_right_note_width > 0.0 {
504 (max_right_note_width + NOTE_MARGIN).max(config.right_margin)
505 } else {
506 config.right_margin
507 }
508}
509
510fn calculate_left_margin(
512 participants: &[Participant],
513 items: &[Item],
514 config: &Config,
515) -> f64 {
516 let leftmost_id = match participants.first() {
517 Some(p) => p.id().to_string(),
518 None => return config.padding,
519 };
520 let mut max_left_note_width: f64 = 0.0;
521 let mut max_self_msg_text_width: f64 = 0.0;
522
523 fn process_items_for_left_margin(
524 items: &[Item],
525 leftmost_id: &str,
526 max_note_width: &mut f64,
527 max_self_msg_width: &mut f64,
528 config: &Config,
529 ) {
530 for item in items {
531 match item {
532 Item::Note {
533 position: NotePosition::Left,
534 participants,
535 text,
536 } => {
537 if participants.first().map(|s| s.as_str()) == Some(leftmost_id) {
539 let note_width = calculate_note_width(text, config);
540 if note_width > *max_note_width {
541 *max_note_width = note_width;
542 }
543 }
544 }
545 Item::Message { from, to, text, .. } => {
546 if from == to && from == leftmost_id {
548 let text_width = estimate_message_width(text, config.font_size);
549 if text_width > *max_self_msg_width {
550 *max_self_msg_width = text_width;
551 }
552 }
553 }
554 Item::Block {
555 items, else_sections, ..
556 } => {
557 process_items_for_left_margin(items, leftmost_id, max_note_width, max_self_msg_width, config);
558 for section in else_sections {
559 process_items_for_left_margin(§ion.items, leftmost_id, max_note_width, max_self_msg_width, config);
560 }
561 }
562 _ => {}
563 }
564 }
565 }
566
567 process_items_for_left_margin(items, &leftmost_id, &mut max_left_note_width, &mut max_self_msg_text_width, config);
568
569 let note_margin = if max_left_note_width > 0.0 {
571 max_left_note_width + NOTE_MARGIN
572 } else {
573 0.0
574 };
575 let self_msg_margin = if max_self_msg_text_width > 0.0 {
580 (max_self_msg_text_width - 41.0 + config.padding).max(0.0)
581 } else {
582 0.0
583 };
584
585 note_margin.max(self_msg_margin).max(config.padding)
586}
587
588fn calculate_participant_gaps(
590 participants: &[Participant],
591 items: &[Item],
592 config: &Config,
593) -> Vec<f64> {
594 if participants.len() <= 1 {
595 return vec![];
596 }
597
598 let mut participant_index: HashMap<String, usize> = HashMap::new();
600 for (i, p) in participants.iter().enumerate() {
601 participant_index.insert(p.id().to_string(), i);
602 }
603
604 let min_gap = config.participant_gap;
607 let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
608
609 fn process_items(
611 items: &[Item],
612 participant_index: &HashMap<String, usize>,
613 gaps: &mut Vec<f64>,
614 config: &Config,
615 ) {
616 for item in items {
617 match item {
618 Item::Message { from, to, text, arrow, .. } => {
619 if let (Some(&from_idx), Some(&to_idx)) =
620 (participant_index.get(from), participant_index.get(to))
621 {
622 if from_idx != to_idx {
623 let (min_idx, max_idx) = if from_idx < to_idx {
624 (from_idx, to_idx)
625 } else {
626 (to_idx, from_idx)
627 };
628
629 let text_width = estimate_message_width(text, config.font_size);
630
631 let delay_extra = arrow.delay.map(|d| d as f64 * 86.4).unwrap_or(0.0);
634
635 let gap_count = (max_idx - min_idx) as f64;
637 let needed_gap = if gap_count == 1.0 {
638 text_width - 36.0 + delay_extra
640 } else {
641 text_width / gap_count - 20.0 + delay_extra
643 };
644
645 for gap_idx in min_idx..max_idx {
647 if needed_gap > gaps[gap_idx] {
648 gaps[gap_idx] = needed_gap;
649 }
650 }
651 }
652 }
653 }
654 Item::Note {
655 position,
656 participants: note_participants,
657 text,
658 } => {
659 let note_width = calculate_note_width(text, config);
661
662 if let Some(participant) = note_participants.first() {
663 if let Some(&idx) = participant_index.get(participant) {
664 match position {
665 NotePosition::Left => {
666 if idx > 0 {
668 let needed_gap = note_width + NOTE_MARGIN * 2.0;
670 if needed_gap > gaps[idx - 1] {
671 gaps[idx - 1] = needed_gap;
672 }
673 }
674 }
675 NotePosition::Right => {
676 if idx < gaps.len() {
678 let needed_gap = note_width + NOTE_MARGIN * 2.0;
679 if needed_gap > gaps[idx] {
680 gaps[idx] = needed_gap;
681 }
682 }
683 }
684 NotePosition::Over => {
685 }
688 }
689 }
690 }
691 }
692 Item::Block {
693 items, else_sections, ..
694 } => {
695 process_items(items, participant_index, gaps, config);
696 for section in else_sections {
697 process_items(§ion.items, participant_index, gaps, config);
698 }
699 }
700 _ => {}
701 }
702 }
703 }
704
705 process_items(items, &participant_index, &mut gaps, config);
706
707 let max_gap = 645.0;
713 for gap in &mut gaps {
714 if *gap > max_gap {
715 *gap = max_gap;
716 }
717 }
718
719 gaps
720}
721
722impl RenderState {
723 fn new(
724 config: Config,
725 participants: Vec<Participant>,
726 items: &[Item],
727 has_title: bool,
728 footer_style: FooterStyle,
729 ) -> Self {
730 let mut config = config;
731 let mut required_header_height = config.header_height;
736 for p in &participants {
737 let lines = p.name.split("\\n").count();
738 let needed = match p.kind {
739 ParticipantKind::Participant => {
740 if lines <= 1 {
742 46.0
743 } else {
744 108.0 }
746 }
747 ParticipantKind::Actor => {
748 if lines <= 1 {
751 85.0
752 } else {
753 108.0
754 }
755 }
756 };
757 if needed > required_header_height {
758 required_header_height = needed;
759 }
760 }
761 if required_header_height > config.header_height {
762 config.header_height = required_header_height;
763 }
764 let mut participant_widths: HashMap<String, f64> = HashMap::new();
767 let min_width = config.participant_width;
768
769 for p in &participants {
770 let width = calculate_participant_width(&p.name, min_width);
771 participant_widths.insert(p.id().to_string(), width);
772 }
773
774 let gaps = calculate_participant_gaps(&participants, items, &config);
775
776 let left_margin = calculate_left_margin(&participants, items, &config);
778 let right_margin = calculate_right_margin(&participants, items, &config);
780
781 let mut participant_x = HashMap::new();
782 let first_width = participants
783 .first()
784 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
785 .unwrap_or(min_width);
786 let mut current_x = config.padding + left_margin + first_width / 2.0;
787
788 for (i, p) in participants.iter().enumerate() {
789 participant_x.insert(p.id().to_string(), current_x);
790 if i < gaps.len() {
791 let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
792 let next_p = participants.get(i + 1);
793 let next_width = next_p
794 .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
795 .unwrap_or(min_width);
796
797 let current_is_actor = p.kind == ParticipantKind::Actor;
800 let next_is_actor = next_p.map(|np| np.kind == ParticipantKind::Actor).unwrap_or(false);
801
802 let actor_gap_reduction = 0.0;
805 let _ = (current_is_actor, next_is_actor); let calculated_gap = gaps[i] - actor_gap_reduction;
810
811 let half_widths = (current_width + next_width) / 2.0;
814 let neither_is_actor = !current_is_actor && !next_is_actor;
815
816 let either_is_actor = current_is_actor || next_is_actor;
817 let edge_padding = if calculated_gap > 500.0 {
818 10.0
820 } else if either_is_actor && calculated_gap > 130.0 {
821 33.0
823 } else if neither_is_actor && half_widths > 155.0 && calculated_gap > 130.0 {
824 90.0
826 } else if calculated_gap > 130.0 {
827 49.0
829 } else if calculated_gap > config.participant_gap {
830 25.0
832 } else {
833 let max_width = current_width.max(next_width);
835 let min_width_val = current_width.min(next_width);
836 let width_diff = max_width - min_width_val;
837
838 if max_width > 160.0 && min_width_val > 160.0 {
839 1.8
842 } else if max_width > 160.0 && min_width_val > 140.0 {
843 -7.0
846 } else if max_width > 160.0 && min_width_val < 110.0 {
847 11.3
850 } else if max_width > 160.0 && width_diff > 45.0 {
851 -6.0
854 } else if min_width_val < 115.0 {
855 10.0
858 } else {
859 11.0
861 }
862 };
863
864 let min_center_gap = (current_width + next_width) / 2.0 + edge_padding - actor_gap_reduction;
865 let actual_gap = calculated_gap.max(min_center_gap).max(60.0);
866 current_x += actual_gap;
867 }
868 }
869
870 let last_width = participants
871 .last()
872 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
873 .unwrap_or(min_width);
874 let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
875
876 Self {
877 config,
878 participants,
879 participant_x,
880 participant_widths,
881 current_y: 0.0,
882 activations: HashMap::new(),
883 autonumber: None,
884 destroyed: HashMap::new(),
885 has_title,
886 total_width,
887 block_backgrounds: Vec::new(),
888 block_labels: Vec::new(),
889 footer_style,
890 else_return_pending: Vec::new(),
891 serial_first_row_pending: Vec::new(),
892 parallel_depth: 0,
893 message_label_boxes: Vec::new(),
894 }
895 }
896
897 fn get_participant_width(&self, name: &str) -> f64 {
898 *self
899 .participant_widths
900 .get(name)
901 .unwrap_or(&self.config.participant_width)
902 }
903
904 fn get_x(&self, name: &str) -> f64 {
905 if name == "[" {
907 return self.config.padding;
908 }
909 if name == "]" {
910 return self.total_width - self.config.padding;
911 }
912 *self.participant_x.get(name).unwrap_or(&0.0)
913 }
914
915 fn push_else_return_pending(&mut self) {
916 self.else_return_pending.push(true);
917 }
918
919 fn pop_else_return_pending(&mut self) {
920 self.else_return_pending.pop();
921 }
922
923 fn apply_else_return_gap(&mut self, arrow: &Arrow) {
924 if let Some(pending) = self.else_return_pending.last_mut() {
925 if *pending && matches!(arrow.line, LineStyle::Dashed) {
926 *pending = false;
928 }
929 }
930 }
931
932 fn push_serial_first_row_pending(&mut self) {
933 self.serial_first_row_pending.push(true);
934 }
935
936 fn pop_serial_first_row_pending(&mut self) {
937 self.serial_first_row_pending.pop();
938 }
939
940 fn apply_serial_first_row_gap(&mut self) {
941 if let Some(pending) = self.serial_first_row_pending.last_mut() {
942 if *pending {
943 self.current_y += serial_first_row_gap(self.parallel_depth);
944 *pending = false;
945 }
946 }
947 }
948
949 fn reserve_message_label(
950 &mut self,
951 x_min: f64,
952 x_max: f64,
953 mut y_min: f64,
954 mut y_max: f64,
955 step: f64,
956 ) -> f64 {
957 let relevance_threshold = step * 2.0;
960 let relevant_boxes: Vec<&LabelBox> = self
961 .message_label_boxes
962 .iter()
963 .filter(|b| b.y_max + relevance_threshold >= y_min)
964 .collect();
965
966 let mut offset = 0.0;
967 let mut attempts = 0;
968 while relevant_boxes
969 .iter()
970 .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
971 && attempts < 20
972 {
973 y_min += step;
974 y_max += step;
975 offset += step;
976 attempts += 1;
977 }
978 self.message_label_boxes.push(LabelBox {
979 x_min,
980 x_max,
981 y_min,
982 y_max,
983 });
984 offset
985 }
986
987 fn push_parallel(&mut self) {
988 self.parallel_depth += 1;
989 }
990
991 fn pop_parallel(&mut self) {
992 if self.parallel_depth > 0 {
993 self.parallel_depth -= 1;
994 }
995 }
996
997 fn is_participant_active_at(&self, participant: &str, y: f64) -> bool {
999 if let Some(acts) = self.activations.get(participant) {
1000 acts.iter().any(|(start_y, end_y)| {
1001 *start_y <= y && end_y.map_or(true, |end| y <= end)
1002 })
1003 } else {
1004 false
1005 }
1006 }
1007
1008 fn get_arrow_start_x(&self, participant: &str, y: f64, going_right: bool) -> f64 {
1010 let x = self.get_x(participant);
1011 if self.is_participant_active_at(participant, y) {
1012 let half_width = self.config.activation_width / 2.0;
1013 if going_right {
1014 x + half_width } else {
1016 x - half_width }
1018 } else {
1019 x
1020 }
1021 }
1022
1023 fn get_arrow_end_x(&self, participant: &str, y: f64, coming_from_right: bool) -> f64 {
1025 let x = self.get_x(participant);
1026 if self.is_participant_active_at(participant, y) {
1027 let half_width = self.config.activation_width / 2.0;
1028 if coming_from_right {
1029 x + half_width } else {
1031 x - half_width }
1033 } else {
1034 x
1035 }
1036 }
1037
1038 fn diagram_width(&self) -> f64 {
1039 let max_block_x2 = self
1041 .block_labels
1042 .iter()
1043 .map(|bl| bl.x2)
1044 .fold(0.0f64, |a, b| a.max(b));
1045 let block_width = if max_block_x2 > 0.0 {
1047 max_block_x2 + self.config.padding
1048 } else {
1049 0.0
1050 };
1051 self.total_width.max(block_width)
1052 }
1053
1054 fn leftmost_x(&self) -> f64 {
1056 self.participants
1057 .first()
1058 .map(|p| self.get_x(p.id()))
1059 .unwrap_or(self.config.padding)
1060 }
1061
1062 fn rightmost_x(&self) -> f64 {
1064 self.participants
1065 .last()
1066 .map(|p| self.get_x(p.id()))
1067 .unwrap_or(self.total_width - self.config.padding)
1068 }
1069
1070 fn block_left(&self) -> f64 {
1072 let leftmost_width = self
1073 .participants
1074 .first()
1075 .map(|p| self.get_participant_width(p.id()))
1076 .unwrap_or(self.config.participant_width);
1077 self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
1078 }
1079
1080 fn block_right(&self) -> f64 {
1082 let rightmost_width = self
1083 .participants
1084 .last()
1085 .map(|p| self.get_participant_width(p.id()))
1086 .unwrap_or(self.config.participant_width);
1087 self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
1088 }
1089
1090 fn header_top(&self) -> f64 {
1091 if self.has_title {
1092 self.config.padding + self.config.title_height
1093 } else {
1094 self.config.padding
1095 }
1096 }
1097
1098 fn content_start(&self) -> f64 {
1099 self.header_top() + self.config.header_height + self.config.row_height
1102 }
1103
1104 fn next_number(&mut self) -> Option<u32> {
1105 self.autonumber.map(|n| {
1106 self.autonumber = Some(n + 1);
1107 n
1108 })
1109 }
1110
1111 fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
1113 self.block_backgrounds.push(BlockBackground {
1114 x,
1115 y,
1116 width,
1117 height,
1118 });
1119 }
1120
1121 fn add_block_label(
1123 &mut self,
1124 x1: f64,
1125 start_y: f64,
1126 end_y: f64,
1127 x2: f64,
1128 kind: &str,
1129 label: &str,
1130 else_sections: Vec<(f64, Option<String>)>,
1131 ) {
1132 self.block_labels.push(BlockLabel {
1133 x1,
1134 start_y,
1135 end_y,
1136 x2,
1137 kind: kind.to_string(),
1138 label: label.to_string(),
1139 else_sections,
1140 });
1141 }
1142}
1143
1144fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
1146 let mut min_left: Option<f64> = None;
1147 let mut max_right: Option<f64> = None;
1148 let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
1149 let mut includes_leftmost = false;
1150
1151 fn update_bounds(
1152 participant: &str,
1153 state: &RenderState,
1154 min_left: &mut Option<f64>,
1155 max_right: &mut Option<f64>,
1156 includes_leftmost: &mut bool,
1157 leftmost_id: &str,
1158 ) {
1159 let x = state.get_x(participant);
1160 if x > 0.0 {
1161 let width = state.get_participant_width(participant);
1162 let left = x - width / 2.0;
1163 let right = x + width / 2.0;
1164 *min_left = Some(min_left.map_or(left, |m| m.min(left)));
1165 *max_right = Some(max_right.map_or(right, |m| m.max(right)));
1166 if participant == leftmost_id {
1167 *includes_leftmost = true;
1168 }
1169 }
1170 }
1171
1172 fn process_items(
1173 items: &[Item],
1174 state: &RenderState,
1175 min_left: &mut Option<f64>,
1176 max_right: &mut Option<f64>,
1177 includes_leftmost: &mut bool,
1178 leftmost_id: &str,
1179 ) {
1180 for item in items {
1181 match item {
1182 Item::Message { from, to, .. } => {
1183 update_bounds(
1184 from,
1185 state,
1186 min_left,
1187 max_right,
1188 includes_leftmost,
1189 leftmost_id,
1190 );
1191 update_bounds(
1192 to,
1193 state,
1194 min_left,
1195 max_right,
1196 includes_leftmost,
1197 leftmost_id,
1198 );
1199 }
1200 Item::Note { participants, .. } => {
1201 for p in participants {
1202 update_bounds(
1203 p,
1204 state,
1205 min_left,
1206 max_right,
1207 includes_leftmost,
1208 leftmost_id,
1209 );
1210 }
1211 }
1212 Item::Block {
1213 items, else_sections, ..
1214 } => {
1215 process_items(
1216 items,
1217 state,
1218 min_left,
1219 max_right,
1220 includes_leftmost,
1221 leftmost_id,
1222 );
1223 for section in else_sections {
1224 process_items(
1225 §ion.items,
1226 state,
1227 min_left,
1228 max_right,
1229 includes_leftmost,
1230 leftmost_id,
1231 );
1232 }
1233 }
1234 Item::Activate { participant }
1235 | Item::Deactivate { participant }
1236 | Item::Destroy { participant } => {
1237 update_bounds(
1238 participant,
1239 state,
1240 min_left,
1241 max_right,
1242 includes_leftmost,
1243 leftmost_id,
1244 );
1245 }
1246 _ => {}
1247 }
1248 }
1249 }
1250
1251 process_items(
1252 items,
1253 state,
1254 &mut min_left,
1255 &mut max_right,
1256 &mut includes_leftmost,
1257 leftmost_id,
1258 );
1259
1260 match (min_left, max_right) {
1261 (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
1262 _ => None,
1263 }
1264}
1265
1266const NESTED_BLOCK_INSET: f64 = 5.0;
1268
1269fn calculate_block_bounds_with_label(
1271 items: &[Item],
1272 else_sections: &[crate::ast::ElseSection],
1273 label: &str,
1274 kind: &str,
1275 _depth: usize,
1276 state: &RenderState,
1277 parent_bounds: Option<(f64, f64)>,
1278) -> (f64, f64) {
1279 let mut all_items: Vec<&Item> = items.iter().collect();
1280 for section in else_sections {
1281 all_items.extend(section.items.iter());
1282 }
1283
1284 let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1286
1287 let (constraint_x1, constraint_x2) = if let Some((px1, px2)) = parent_bounds {
1289 (px1 + NESTED_BLOCK_INSET, px2 - NESTED_BLOCK_INSET)
1290 } else {
1291 (state.block_left(), state.block_right())
1292 };
1293
1294 let (base_x1, base_x2) =
1295 if let Some((min_left, max_right, _includes_leftmost)) =
1296 find_involved_participants(&items_slice, state)
1297 {
1298 let margin = state.config.block_margin;
1299 let x1 = (min_left - margin).max(constraint_x1);
1301 let x2 = (max_right + margin).min(constraint_x2);
1302 (x1, x2)
1303 } else {
1304 (constraint_x1, constraint_x2)
1306 };
1307
1308 let pentagon_width = block_tab_width(kind);
1311 let label_font_size = state.config.font_size - 1.0;
1312 let label_padding_x = 6.0;
1313 let condition_width = if label.is_empty() {
1314 0.0
1315 } else {
1316 let condition_text = format!("[{}]", label);
1317 let base_width =
1318 (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1319 base_width + label_padding_x * 2.0
1320 };
1321
1322 let mut max_else_label_width = 0.0f64;
1324 for section in else_sections {
1325 if let Some(el) = §ion.label {
1326 if !el.is_empty() {
1327 let else_text = format!("[{}]", el);
1328 let base_width =
1329 (estimate_text_width(&else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1330 let width = base_width + label_padding_x * 2.0;
1331 max_else_label_width = max_else_label_width.max(width);
1332 }
1333 }
1334 }
1335
1336 let max_label_content_width = condition_width.max(max_else_label_width);
1338 let min_label_width = pentagon_width + 8.0 + max_label_content_width + 20.0; let available_width = constraint_x2 - constraint_x1;
1342
1343 let current_width = base_x2 - base_x1;
1345 let (x1, x2) = if current_width < min_label_width {
1346 let desired_x2 = base_x1 + min_label_width;
1348 if desired_x2 <= constraint_x2 {
1349 (base_x1, desired_x2)
1350 } else if min_label_width <= available_width {
1351 (constraint_x2 - min_label_width, constraint_x2)
1353 } else {
1354 (constraint_x1, constraint_x2)
1356 }
1357 } else {
1358 (base_x1, base_x2)
1359 };
1360
1361 (x1, x2)
1362}
1363
1364fn collect_block_backgrounds(
1366 state: &mut RenderState,
1367 items: &[Item],
1368 depth: usize,
1369 active_activation_count: &mut usize,
1370 parent_bounds: Option<(f64, f64)>,
1371) {
1372 for item in items {
1373 match item {
1374 Item::Message {
1375 text,
1376 from,
1377 to,
1378 arrow,
1379 activate,
1380 deactivate,
1381 create,
1382 ..
1383 } => {
1384 state.apply_else_return_gap(arrow);
1385 let is_self = from == to;
1386 let line_count = text.split("\\n").count();
1387 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1388
1389 if is_self {
1390 state.current_y += self_message_y_advance(&state.config, line_count);
1391 } else {
1392 state.current_y += regular_message_y_advance(&state.config, line_count, delay_offset);
1393 }
1394
1395 if *create {
1396 state.current_y += state.config.row_height;
1397 }
1398
1399 state.apply_serial_first_row_gap();
1400 if *activate {
1401 *active_activation_count += 1;
1402 }
1403 if *deactivate && *active_activation_count > 0 {
1404 *active_activation_count -= 1;
1405 }
1406 }
1407 Item::Note { text, .. } => {
1408 let line_count = text.split("\\n").count();
1409 state.current_y += note_y_advance(&state.config, line_count);
1410 }
1411 Item::State { text, .. } => {
1412 let line_count = text.split("\\n").count();
1413 state.current_y += state_y_advance(&state.config, line_count);
1414 }
1415 Item::Ref { text, .. } => {
1416 let line_count = text.split("\\n").count();
1417 state.current_y += ref_y_advance(&state.config, line_count);
1418 }
1419 Item::Description { text } => {
1420 let line_count = text.split("\\n").count();
1421 state.current_y += description_y_advance(&state.config, line_count);
1422 }
1423 Item::Destroy { .. } => {
1424 state.current_y += state.config.row_height;
1425 }
1426 Item::Activate { .. } => {
1427 *active_activation_count += 1;
1428 }
1429 Item::Deactivate { .. } => {
1430 if *active_activation_count > 0 {
1431 *active_activation_count -= 1;
1432 }
1433 }
1434 Item::Block {
1435 kind,
1436 label,
1437 items,
1438 else_sections,
1439 } => {
1440 if block_is_parallel(kind) {
1441 state.push_parallel();
1442 let start_y = state.current_y;
1443 let mut max_end_y = start_y;
1444 let start_activation_count = *active_activation_count;
1445 for item in items {
1446 state.current_y = start_y;
1447 *active_activation_count = start_activation_count;
1448 collect_block_backgrounds(
1449 state,
1450 std::slice::from_ref(item),
1451 depth,
1452 active_activation_count,
1453 parent_bounds,
1454 );
1455 if state.current_y > max_end_y {
1456 max_end_y = state.current_y;
1457 }
1458 }
1459 *active_activation_count = start_activation_count;
1460 let gap = if parallel_needs_gap(items) {
1461 state.config.row_height
1462 } else {
1463 0.0
1464 };
1465 state.current_y = max_end_y + gap;
1466 state.pop_parallel();
1467 continue;
1468 }
1469
1470 if matches!(kind, BlockKind::Serial) {
1471 state.push_serial_first_row_pending();
1472 collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1473 for section in else_sections {
1474 collect_block_backgrounds(
1475 state,
1476 §ion.items,
1477 depth,
1478 active_activation_count,
1479 parent_bounds,
1480 );
1481 }
1482 state.pop_serial_first_row_pending();
1483 continue;
1484 }
1485
1486 if !block_has_frame(kind) {
1487 collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1488 for section in else_sections {
1489 collect_block_backgrounds(
1490 state,
1491 §ion.items,
1492 depth,
1493 active_activation_count,
1494 parent_bounds,
1495 );
1496 }
1497 continue;
1498 }
1499
1500 let start_y = state.current_y;
1501 let frame_shift = block_frame_shift(depth);
1502 let frame_start_y = start_y - frame_shift;
1503
1504 let (x1, x2) = calculate_block_bounds_with_label(
1506 items,
1507 else_sections,
1508 label,
1509 kind.as_str(),
1510 depth,
1511 state,
1512 parent_bounds,
1513 );
1514
1515 state.current_y += block_header_space(&state.config, depth);
1516 collect_block_backgrounds(state, items, depth + 1, active_activation_count, Some((x1, x2)));
1518
1519 let mut else_section_info: Vec<(f64, Option<String>)> = Vec::new();
1521 for section in else_sections {
1522 state.current_y += block_else_before(&state.config, depth);
1524 let else_y = state.current_y;
1525 else_section_info.push((else_y, section.label.clone()));
1526
1527 state.push_else_return_pending();
1528 state.current_y += block_else_after(&state.config, depth);
1530 collect_block_backgrounds(
1532 state,
1533 §ion.items,
1534 depth + 1,
1535 active_activation_count,
1536 Some((x1, x2)),
1537 );
1538 state.pop_else_return_pending();
1539 }
1540
1541 let end_y = state.current_y + block_footer_padding(&state.config, depth);
1544 let frame_end_y = end_y - frame_shift;
1545 state.current_y = end_y + state.config.row_height;
1546
1547 state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1549 state.add_block_label(
1551 x1,
1552 frame_start_y,
1553 frame_end_y,
1554 x2,
1555 kind.as_str(),
1556 label,
1557 else_section_info,
1558 );
1559 }
1560 _ => {}
1561 }
1562 }
1563}
1564
1565fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1567 let theme = &state.config.theme;
1568 for bg in &state.block_backgrounds {
1569 writeln!(
1570 svg,
1571 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1572 x = bg.x,
1573 y = bg.y,
1574 w = bg.width,
1575 h = bg.height,
1576 fill = theme.block_fill
1577 )
1578 .unwrap();
1579 }
1580}
1581
1582fn render_block_labels(svg: &mut String, state: &RenderState) {
1585 let theme = &state.config.theme;
1586
1587 for bl in &state.block_labels {
1588 let x1 = bl.x1;
1589 let x2 = bl.x2;
1590 let start_y = bl.start_y;
1591 let end_y = bl.end_y;
1592
1593 writeln!(
1595 svg,
1596 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1597 x = x1,
1598 y = start_y,
1599 w = x2 - x1,
1600 h = end_y - start_y
1601 )
1602 .unwrap();
1603
1604 let label_text = &bl.kind;
1606 let label_width = block_tab_width(label_text);
1607 let label_height = BLOCK_LABEL_HEIGHT;
1608 let label_text_offset = 16.0;
1609 let notch_size = 5.0;
1610
1611 let pentagon_path = format!(
1613 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1614 x1 = x1,
1615 y1 = start_y,
1616 x2 = x1 + label_width,
1617 y2 = start_y + label_height - notch_size,
1618 x3 = x1 + label_width - notch_size,
1619 y3 = start_y + label_height
1620 );
1621
1622 writeln!(
1623 svg,
1624 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1625 path = pentagon_path,
1626 fill = theme.block_label_fill,
1627 stroke = theme.block_stroke
1628 )
1629 .unwrap();
1630
1631 writeln!(
1633 svg,
1634 r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1635 x = x1 + 5.0,
1636 y = start_y + label_text_offset,
1637 kind = label_text
1638 )
1639 .unwrap();
1640
1641 if !bl.label.is_empty() {
1643 let condition_text = format!("[{}]", bl.label);
1644 let text_x = x1 + label_width + 8.0;
1645 let text_y = start_y + label_text_offset;
1646
1647 writeln!(
1648 svg,
1649 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1650 x = text_x,
1651 y = text_y,
1652 label = escape_xml(&condition_text)
1653 )
1654 .unwrap();
1655 }
1656
1657 for (else_y, else_label_opt) in &bl.else_sections {
1659 writeln!(
1661 svg,
1662 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1663 x1 = x1,
1664 y = else_y,
1665 x2 = x2,
1666 c = theme.block_stroke
1667 )
1668 .unwrap();
1669
1670 if let Some(else_label_text) = else_label_opt {
1672 let condition_text = format!("[{}]", else_label_text);
1673 let text_x = x1 + label_width + 8.0; let text_y = else_y + label_text_offset; writeln!(
1677 svg,
1678 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1679 x = text_x,
1680 y = text_y,
1681 label = escape_xml(&condition_text)
1682 )
1683 .unwrap();
1684 }
1685 }
1686 }
1687}
1688
1689pub fn render(diagram: &Diagram) -> String {
1691 render_with_config(diagram, Config::default())
1692}
1693
1694pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1696 let participants = diagram.participants();
1697 let has_title = diagram.title.is_some();
1698 let footer_style = diagram.options.footer;
1699 let mut state = RenderState::new(
1700 config,
1701 participants,
1702 &diagram.items,
1703 has_title,
1704 footer_style,
1705 );
1706 let mut svg = String::new();
1707
1708 let content_height = calculate_height(&diagram.items, &state.config, 0);
1710 let title_space = if has_title {
1711 state.config.title_height
1712 } else {
1713 0.0
1714 };
1715 let footer_space = match footer_style {
1716 FooterStyle::Box => state.config.header_height,
1717 FooterStyle::Bar | FooterStyle::None => 0.0,
1718 };
1719 let footer_label_extra = match footer_style {
1720 FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1721 FooterStyle::Bar | FooterStyle::None => 0.0,
1722 };
1723 let footer_margin = state.config.row_height; let base_total_height = state.config.padding * 2.0
1725 + title_space
1726 + state.config.header_height
1727 + content_height
1728 + footer_margin
1729 + footer_space;
1730 let total_height = base_total_height + footer_label_extra;
1731
1732 state.current_y = state.content_start();
1734 let mut active_activation_count = 0;
1735 collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count, None);
1736
1737 let total_width = state.diagram_width();
1738
1739 writeln!(
1741 &mut svg,
1742 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1743 w = total_width,
1744 h = total_height
1745 )
1746 .unwrap();
1747
1748 let theme = &state.config.theme;
1750 let lifeline_dash = match theme.lifeline_style {
1751 LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1752 LifelineStyle::Solid => "",
1753 };
1754
1755 svg.push_str("<defs>\n");
1756 svg.push_str("<style>\n");
1757 writeln!(
1758 &mut svg,
1759 ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1760 fill = theme.participant_fill,
1761 stroke = theme.participant_stroke
1762 )
1763 .unwrap();
1764 writeln!(
1765 &mut svg,
1766 ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1767 f = theme.font_family,
1768 s = state.config.font_size,
1769 c = theme.participant_text
1770 )
1771 .unwrap();
1772 writeln!(
1773 &mut svg,
1774 ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1775 c = theme.lifeline_color,
1776 dash = lifeline_dash
1777 )
1778 .unwrap();
1779 writeln!(
1780 &mut svg,
1781 ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1782 c = theme.message_color
1783 )
1784 .unwrap();
1785 writeln!(
1786 &mut svg,
1787 ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1788 c = theme.message_color
1789 )
1790 .unwrap();
1791 writeln!(
1792 &mut svg,
1793 ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1794 f = theme.font_family,
1795 s = state.config.font_size,
1796 c = theme.message_text_color
1797 )
1798 .unwrap();
1799 writeln!(
1800 &mut svg,
1801 ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1802 fill = theme.note_fill,
1803 stroke = theme.note_stroke
1804 )
1805 .unwrap();
1806 writeln!(
1807 &mut svg,
1808 ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1809 f = theme.font_family,
1810 s = state.config.font_size - 1.0,
1811 c = theme.note_text_color
1812 )
1813 .unwrap();
1814 writeln!(
1815 &mut svg,
1816 ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1817 c = theme.block_stroke
1818 )
1819 .unwrap();
1820 writeln!(
1821 &mut svg,
1822 ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1823 f = theme.font_family,
1824 s = state.config.font_size - 1.0,
1825 c = theme.message_text_color
1826 )
1827 .unwrap();
1828 writeln!(
1829 &mut svg,
1830 ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1831 fill = theme.activation_fill,
1832 stroke = theme.activation_stroke
1833 )
1834 .unwrap();
1835 writeln!(
1836 &mut svg,
1837 ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1838 fill = theme.actor_fill,
1839 stroke = theme.actor_stroke
1840 )
1841 .unwrap();
1842 writeln!(
1843 &mut svg,
1844 ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1845 c = theme.actor_stroke
1846 )
1847 .unwrap();
1848 writeln!(
1849 &mut svg,
1850 ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1851 f = theme.font_family,
1852 s = state.config.font_size + 4.0,
1853 c = theme.message_text_color
1854 )
1855 .unwrap();
1856 writeln!(
1858 &mut svg,
1859 ".arrowhead {{ fill: {c}; stroke: none; }}",
1860 c = theme.message_color
1861 )
1862 .unwrap();
1863 writeln!(
1864 &mut svg,
1865 ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1866 c = theme.message_color
1867 )
1868 .unwrap();
1869 svg.push_str("</style>\n");
1870 svg.push_str("</defs>\n");
1871
1872 writeln!(
1874 &mut svg,
1875 r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1876 bg = theme.background
1877 )
1878 .unwrap();
1879
1880 if let Some(title) = &diagram.title {
1882 let title_y = state.config.padding + state.config.font_size + 7.36; writeln!(
1884 &mut svg,
1885 r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1886 x = total_width / 2.0,
1887 y = title_y,
1888 t = escape_xml(title)
1889 )
1890 .unwrap();
1891 }
1892
1893 let header_y = state.header_top();
1895 let footer_y = match footer_style {
1896 FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1897 FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1898 };
1899
1900 render_block_backgrounds(&mut svg, &state);
1903
1904 state.current_y = state.content_start();
1906
1907 let lifeline_start = header_y + state.config.header_height;
1909 let lifeline_end = footer_y;
1910
1911 for p in &state.participants {
1912 let x = state.get_x(p.id());
1913 writeln!(
1914 &mut svg,
1915 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1916 x = x,
1917 y1 = lifeline_start,
1918 y2 = lifeline_end
1919 )
1920 .unwrap();
1921 }
1922
1923 render_participant_headers(&mut svg, &state, header_y);
1925
1926 state.current_y = state.content_start();
1928 render_items(&mut svg, &mut state, &diagram.items, 0);
1929
1930 render_activations(&mut svg, &mut state, footer_y);
1932
1933 render_block_labels(&mut svg, &state);
1935
1936 match state.footer_style {
1938 FooterStyle::Box => {
1939 render_participant_headers(&mut svg, &state, footer_y);
1940 }
1941 FooterStyle::Bar => {
1942 let left = state.leftmost_x()
1944 - state.get_participant_width(
1945 state.participants.first().map(|p| p.id()).unwrap_or(""),
1946 ) / 2.0;
1947 let right = state.rightmost_x()
1948 + state
1949 .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1950 / 2.0;
1951 writeln!(
1952 &mut svg,
1953 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1954 x1 = left,
1955 y = footer_y,
1956 x2 = right,
1957 c = state.config.theme.lifeline_color
1958 )
1959 .unwrap();
1960 }
1961 FooterStyle::None => {
1962 }
1964 }
1965
1966 svg.push_str("</svg>\n");
1967 svg
1968}
1969
1970fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1971 fn inner(
1972 items: &[Item],
1973 config: &Config,
1974 depth: usize,
1975 else_pending: &mut Vec<bool>,
1976 serial_pending: &mut Vec<bool>,
1977 active_activation_count: &mut usize,
1978 parallel_depth: &mut usize,
1979 ) -> f64 {
1980 let mut height = 0.0;
1981 for item in items {
1982 match item {
1983 Item::Message {
1984 from,
1985 to,
1986 text,
1987 arrow,
1988 create,
1989 activate,
1990 deactivate,
1991 ..
1992 } => {
1993 if let Some(pending) = else_pending.last_mut() {
1994 if *pending && matches!(arrow.line, LineStyle::Dashed) {
1995 *pending = false;
1996 }
1997 }
1998 let is_self = from == to;
1999 let line_count = text.split("\\n").count();
2000 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2001 if is_self {
2002 height += self_message_y_advance(config, line_count);
2003 } else {
2004 height += regular_message_y_advance(config, line_count, delay_offset);
2005 }
2006 if *create {
2007 height += config.row_height;
2008 }
2009 if let Some(pending) = serial_pending.last_mut() {
2010 if *pending {
2011 height += serial_first_row_gap(*parallel_depth);
2012 *pending = false;
2013 }
2014 }
2015 if *activate {
2016 *active_activation_count += 1;
2017 }
2018 if *deactivate && *active_activation_count > 0 {
2019 *active_activation_count -= 1;
2020 }
2021 }
2022 Item::Note { text, .. } => {
2023 let line_count = text.split("\\n").count();
2024 height += note_y_advance(config, line_count);
2025 }
2026 Item::State { text, .. } => {
2027 let line_count = text.split("\\n").count();
2028 height += state_y_advance(config, line_count);
2029 }
2030 Item::Ref { text, .. } => {
2031 let line_count = text.split("\\n").count();
2032 height += ref_y_advance(config, line_count);
2033 }
2034 Item::Description { text } => {
2035 let line_count = text.split("\\n").count();
2036 height += description_y_advance(config, line_count);
2037 }
2038 Item::Block {
2039 kind,
2040 items,
2041 else_sections,
2042 ..
2043 } => {
2044 if block_is_parallel(kind) {
2045 let mut max_branch_height = 0.0;
2046 let base_activation_count = *active_activation_count;
2047 *parallel_depth += 1;
2048 for item in items {
2049 *active_activation_count = base_activation_count;
2050 let branch_height = inner(
2051 std::slice::from_ref(item),
2052 config,
2053 depth,
2054 else_pending,
2055 serial_pending,
2056 active_activation_count,
2057 parallel_depth,
2058 );
2059 if branch_height > max_branch_height {
2060 max_branch_height = branch_height;
2061 }
2062 }
2063 *active_activation_count = base_activation_count;
2064 if *parallel_depth > 0 {
2065 *parallel_depth -= 1;
2066 }
2067 let gap = if parallel_needs_gap(items) {
2068 config.row_height
2069 } else {
2070 0.0
2071 };
2072 height += max_branch_height + gap;
2073 continue;
2074 }
2075
2076 if matches!(kind, BlockKind::Serial) {
2077 serial_pending.push(true);
2078 height += inner(
2079 items,
2080 config,
2081 depth,
2082 else_pending,
2083 serial_pending,
2084 active_activation_count,
2085 parallel_depth,
2086 );
2087 for else_section in else_sections {
2088 height += inner(
2089 &else_section.items,
2090 config,
2091 depth,
2092 else_pending,
2093 serial_pending,
2094 active_activation_count,
2095 parallel_depth,
2096 );
2097 }
2098 serial_pending.pop();
2099 } else if !block_has_frame(kind) {
2100 height += inner(
2101 items,
2102 config,
2103 depth,
2104 else_pending,
2105 serial_pending,
2106 active_activation_count,
2107 parallel_depth,
2108 );
2109 for else_section in else_sections {
2110 height += inner(
2111 &else_section.items,
2112 config,
2113 depth,
2114 else_pending,
2115 serial_pending,
2116 active_activation_count,
2117 parallel_depth,
2118 );
2119 }
2120 } else {
2121 height += block_header_space(config, depth);
2122 height += inner(
2123 items,
2124 config,
2125 depth + 1,
2126 else_pending,
2127 serial_pending,
2128 active_activation_count,
2129 parallel_depth,
2130 );
2131 for else_section in else_sections {
2132 else_pending.push(true);
2133 height += block_else_before(config, depth) + block_else_after(config, depth);
2135 height += inner(
2136 &else_section.items,
2137 config,
2138 depth + 1,
2139 else_pending,
2140 serial_pending,
2141 active_activation_count,
2142 parallel_depth,
2143 );
2144 else_pending.pop();
2145 }
2146 height += block_end_y_advance(config, depth);
2148 }
2149 }
2150 Item::Activate { .. } => {
2151 *active_activation_count += 1;
2152 }
2153 Item::Deactivate { .. } => {
2154 if *active_activation_count > 0 {
2155 *active_activation_count -= 1;
2156 }
2157 }
2158 Item::Destroy { .. } => {
2159 height += config.row_height;
2160 }
2161 Item::ParticipantDecl { .. } => {}
2162 Item::Autonumber { .. } => {}
2163 Item::DiagramOption { .. } => {} }
2165 }
2166 height
2167 }
2168
2169 let mut else_pending = Vec::new();
2170 let mut serial_pending = Vec::new();
2171 let mut active_activation_count = 0;
2172 let mut parallel_depth = 0;
2173 inner(
2174 items,
2175 config,
2176 depth,
2177 &mut else_pending,
2178 &mut serial_pending,
2179 &mut active_activation_count,
2180 &mut parallel_depth,
2181 )
2182}
2183
2184fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
2185 let shape = state.config.theme.participant_shape;
2186
2187 for p in &state.participants {
2188 let x = state.get_x(p.id());
2189 let p_width = state.get_participant_width(p.id());
2190 let box_x = x - p_width / 2.0;
2191
2192 match p.kind {
2193 ParticipantKind::Participant => {
2194 match shape {
2196 ParticipantShape::Rectangle => {
2197 writeln!(
2198 svg,
2199 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2200 x = box_x,
2201 y = y,
2202 w = p_width,
2203 h = state.config.header_height
2204 )
2205 .unwrap();
2206 }
2207 ParticipantShape::RoundedRect => {
2208 writeln!(
2209 svg,
2210 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2211 x = box_x,
2212 y = y,
2213 w = p_width,
2214 h = state.config.header_height
2215 )
2216 .unwrap();
2217 }
2218 ParticipantShape::Circle => {
2219 let rx = p_width / 2.0 - 5.0;
2221 let ry = state.config.header_height / 2.0 - 2.0;
2222 writeln!(
2223 svg,
2224 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2225 cx = x,
2226 cy = y + state.config.header_height / 2.0,
2227 rx = rx,
2228 ry = ry
2229 )
2230 .unwrap();
2231 }
2232 }
2233 let lines: Vec<&str> = p.name.split("\\n").collect();
2235 if lines.len() == 1 {
2236 writeln!(
2237 svg,
2238 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2239 x = x,
2240 y = y + state.config.header_height / 2.0,
2241 name = escape_xml(&p.name)
2242 )
2243 .unwrap();
2244 } else {
2245 let line_height = state.config.font_size + 2.0;
2246 let total_height = lines.len() as f64 * line_height;
2247 let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2248 + line_height * 0.8;
2249 write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2250 for (i, line) in lines.iter().enumerate() {
2251 let dy = if i == 0 { start_y } else { line_height };
2252 if i == 0 {
2253 writeln!(
2254 svg,
2255 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2256 x = x,
2257 y = dy,
2258 text = escape_xml(line)
2259 )
2260 .unwrap();
2261 } else {
2262 writeln!(
2263 svg,
2264 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2265 x = x,
2266 dy = dy,
2267 text = escape_xml(line)
2268 )
2269 .unwrap();
2270 }
2271 }
2272 writeln!(svg, "</text>").unwrap();
2273 }
2274 }
2275 ParticipantKind::Actor => {
2276 let head_r = 8.0;
2278 let body_len = 12.0;
2279 let arm_len = 10.0;
2280 let leg_len = 10.0;
2281 let figure_height = 38.0; let fig_top = y + 8.0;
2285 let fig_center_y = fig_top + head_r + body_len / 2.0;
2286 let arm_y = fig_center_y + 2.0;
2287
2288 writeln!(
2290 svg,
2291 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2292 x = x,
2293 cy = fig_center_y - body_len / 2.0 - head_r,
2294 r = head_r
2295 )
2296 .unwrap();
2297 writeln!(
2299 svg,
2300 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2301 x = x,
2302 y1 = fig_center_y - body_len / 2.0,
2303 y2 = fig_center_y + body_len / 2.0
2304 )
2305 .unwrap();
2306 writeln!(
2308 svg,
2309 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2310 x1 = x - arm_len,
2311 y = arm_y,
2312 x2 = x + arm_len
2313 )
2314 .unwrap();
2315 writeln!(
2317 svg,
2318 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2319 x = x,
2320 y1 = fig_center_y + body_len / 2.0,
2321 x2 = x - leg_len * 0.6,
2322 y2 = fig_center_y + body_len / 2.0 + leg_len
2323 )
2324 .unwrap();
2325 writeln!(
2327 svg,
2328 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2329 x = x,
2330 y1 = fig_center_y + body_len / 2.0,
2331 x2 = x + leg_len * 0.6,
2332 y2 = fig_center_y + body_len / 2.0 + leg_len
2333 )
2334 .unwrap();
2335 let name_lines: Vec<&str> = p.name.split("\\n").collect();
2337 let name_start_y = fig_top + figure_height + 5.0;
2338 if name_lines.len() == 1 {
2339 writeln!(
2340 svg,
2341 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2342 x = x,
2343 y = name_start_y + state.config.font_size,
2344 name = escape_xml(&p.name)
2345 )
2346 .unwrap();
2347 } else {
2348 let line_height = state.config.font_size + 2.0;
2350 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2351 for (i, line) in name_lines.iter().enumerate() {
2352 if i == 0 {
2353 writeln!(
2354 svg,
2355 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2356 x = x,
2357 y = name_start_y + state.config.font_size,
2358 text = escape_xml(line)
2359 )
2360 .unwrap();
2361 } else {
2362 writeln!(
2363 svg,
2364 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2365 x = x,
2366 dy = line_height,
2367 text = escape_xml(line)
2368 )
2369 .unwrap();
2370 }
2371 }
2372 writeln!(svg, "</text>").unwrap();
2373 }
2374 }
2375 }
2376 }
2377}
2378
2379fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2380 for item in items {
2381 match item {
2382 Item::Message {
2383 from,
2384 to,
2385 text,
2386 arrow,
2387 activate,
2388 deactivate,
2389 create,
2390 ..
2391 } => {
2392 render_message(
2393 svg,
2394 state,
2395 from,
2396 to,
2397 text,
2398 arrow,
2399 *activate,
2400 *deactivate,
2401 *create,
2402 depth,
2403 );
2404 }
2405 Item::Note {
2406 position,
2407 participants,
2408 text,
2409 } => {
2410 render_note(svg, state, position, participants, text);
2411 }
2412 Item::Block {
2413 kind,
2414 label,
2415 items,
2416 else_sections,
2417 } => {
2418 render_block(svg, state, kind, label, items, else_sections, depth);
2419 }
2420 Item::Activate { participant } => {
2421 let y = state.current_y;
2422 state
2423 .activations
2424 .entry(participant.clone())
2425 .or_default()
2426 .push((y, None));
2427 }
2428 Item::Deactivate { participant } => {
2429 if let Some(acts) = state.activations.get_mut(participant) {
2430 if let Some(act) = acts.last_mut() {
2431 if act.1.is_none() {
2432 act.1 = Some(state.current_y);
2433 }
2434 }
2435 }
2436 }
2437 Item::Destroy { participant } => {
2438 let destroy_y = state.current_y - state.config.row_height;
2441 state.destroyed.insert(participant.clone(), destroy_y);
2442 let x = state.get_x(participant);
2444 let y = destroy_y;
2445 let size = 15.0; let theme = &state.config.theme;
2447 writeln!(
2448 svg,
2449 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2450 x1 = x - size,
2451 y1 = y - size,
2452 x2 = x + size,
2453 y2 = y + size,
2454 stroke = theme.message_color
2455 )
2456 .unwrap();
2457 writeln!(
2458 svg,
2459 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2460 x1 = x + size,
2461 y1 = y - size,
2462 x2 = x - size,
2463 y2 = y + size,
2464 stroke = theme.message_color
2465 )
2466 .unwrap();
2467 state.current_y += state.config.row_height;
2468 }
2469 Item::Autonumber { enabled, start } => {
2470 if *enabled {
2471 state.autonumber = Some(start.unwrap_or(1));
2472 } else {
2473 state.autonumber = None;
2474 }
2475 }
2476 Item::ParticipantDecl { .. } => {
2477 }
2479 Item::State { participants, text } => {
2480 render_state(svg, state, participants, text);
2481 }
2482 Item::Ref {
2483 participants,
2484 text,
2485 input_from,
2486 input_label,
2487 output_to,
2488 output_label,
2489 } => {
2490 render_ref(
2491 svg,
2492 state,
2493 participants,
2494 text,
2495 input_from.as_deref(),
2496 input_label.as_deref(),
2497 output_to.as_deref(),
2498 output_label.as_deref(),
2499 );
2500 }
2501 Item::DiagramOption { .. } => {
2502 }
2504 Item::Description { text } => {
2505 render_description(svg, state, text);
2506 }
2507 }
2508 }
2509}
2510
2511fn render_message(
2512 svg: &mut String,
2513 state: &mut RenderState,
2514 from: &str,
2515 to: &str,
2516 text: &str,
2517 arrow: &Arrow,
2518 activate: bool,
2519 deactivate: bool,
2520 create: bool,
2521 _depth: usize,
2522) {
2523 let base_x1 = state.get_x(from);
2525 let base_x2 = state.get_x(to);
2526
2527 state.apply_else_return_gap(arrow);
2528
2529 let is_self = from == to;
2530 let line_class = match arrow.line {
2531 LineStyle::Solid => "message",
2532 LineStyle::Dashed => "message-dashed",
2533 };
2534 let is_filled = matches!(arrow.head, ArrowHead::Filled);
2535
2536 let num_prefix = state
2538 .next_number()
2539 .map(|n| format!("{}. ", n))
2540 .unwrap_or_default();
2541
2542 let display_text = format!("{}{}", num_prefix, text);
2544 let lines: Vec<&str> = display_text.split("\\n").collect();
2545 let line_height = state.config.font_size + 4.0;
2546 let extra_height = if !is_self && lines.len() > 1 {
2547 (lines.len() - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
2550 } else {
2551 0.0
2552 };
2553
2554 if !is_self && lines.len() > 1 {
2556 state.current_y += extra_height;
2557 }
2558
2559 let y = state.current_y;
2560 let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2561
2562 let going_right = base_x2 > base_x1;
2564 let x1 = state.get_arrow_start_x(from, y, going_right);
2565 let x2 = state.get_arrow_end_x(to, y, !going_right);
2566
2567 writeln!(svg, r#"<g class="message">"#).unwrap();
2569
2570 if is_self {
2571 let loop_width = 40.0;
2573 let text_block_height = lines.len() as f64 * line_height;
2574 let loop_height = text_block_height.max(25.0);
2576 let arrow_end_x = x1;
2577 let arrow_end_y = y + loop_height;
2578 let direction = std::f64::consts::PI;
2580 let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2581
2582 writeln!(
2583 svg,
2584 r#" <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2585 x1 = x1,
2586 y = y,
2587 x2 = x1 + loop_width,
2588 y2 = y + loop_height,
2589 arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2590 cls = line_class
2591 )
2592 .unwrap();
2593
2594 if is_filled {
2596 writeln!(
2597 svg,
2598 r#" <polygon points="{points}" class="arrowhead"/>"#,
2599 points = arrow_points
2600 )
2601 .unwrap();
2602 } else {
2603 writeln!(
2604 svg,
2605 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2606 points = arrow_points
2607 )
2608 .unwrap();
2609 }
2610
2611 let text_x = x1 - 5.0;
2614 for (i, line) in lines.iter().enumerate() {
2615 let line_y = y + 4.0 + (i as f64 + 0.5) * line_height;
2616 writeln!(
2617 svg,
2618 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="end">{t}</text>"#,
2619 x = text_x,
2620 y = line_y,
2621 t = escape_xml(line)
2622 )
2623 .unwrap();
2624 }
2625
2626 writeln!(svg, r#"</g>"#).unwrap();
2628
2629 let spacing = self_message_spacing(&state.config, lines.len());
2630 state.current_y += spacing;
2631 } else {
2632 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2634 let y2 = y + delay_offset;
2635
2636 let text_x = (base_x1 + base_x2) / 2.0;
2638 let text_y = (y + y2) / 2.0 - 6.0; let direction = arrow_direction(x1, y, x2, y2);
2642 let arrow_points = arrowhead_points(x2, y2, direction);
2643
2644 let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2646 let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2647
2648 writeln!(
2650 svg,
2651 r#" <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2652 x1 = x1,
2653 y1 = y,
2654 lx2 = line_end_x,
2655 ly2 = line_end_y,
2656 cls = line_class
2657 )
2658 .unwrap();
2659
2660 if is_filled {
2662 writeln!(
2663 svg,
2664 r#" <polygon points="{points}" class="arrowhead"/>"#,
2665 points = arrow_points
2666 )
2667 .unwrap();
2668 } else {
2669 writeln!(
2670 svg,
2671 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2672 points = arrow_points
2673 )
2674 .unwrap();
2675 }
2676
2677 let max_width = lines
2679 .iter()
2680 .map(|line| estimate_message_width(line, state.config.font_size))
2681 .fold(0.0, f64::max);
2682 let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2683 let bottom_line_y = text_y;
2684 let label_offset = if has_label_text {
2685 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2686 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2687 let label_x_min = text_x - max_width / 2.0;
2688 let label_x_max = text_x + max_width / 2.0;
2689 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2690 let raw_offset = state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step);
2691 let max_offset = y - MESSAGE_TEXT_ABOVE_ARROW - bottom_line_y;
2693 raw_offset.min(max_offset.max(0.0))
2694 } else {
2695 0.0
2696 };
2697 let rotation = if delay_offset > 0.0 {
2699 let dx = x2 - x1;
2700 let dy = delay_offset;
2701 let angle_rad = dy.atan2(dx.abs());
2702 let angle_deg = angle_rad.to_degrees();
2703 if dx < 0.0 { -angle_deg } else { angle_deg }
2705 } else {
2706 0.0
2707 };
2708
2709 for (i, line) in lines.iter().enumerate() {
2710 let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2711 if rotation.abs() > 0.1 {
2712 writeln!(
2714 svg,
2715 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle" transform="rotate({rot},{cx},{cy})">{t}</text>"#,
2716 x = text_x,
2717 y = line_y,
2718 rot = rotation,
2719 cx = text_x,
2720 cy = line_y,
2721 t = escape_xml(line)
2722 )
2723 .unwrap();
2724 } else {
2725 writeln!(
2726 svg,
2727 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2728 x = text_x,
2729 y = line_y,
2730 t = escape_xml(line)
2731 )
2732 .unwrap();
2733 }
2734 }
2735
2736 writeln!(svg, r#"</g>"#).unwrap();
2738
2739 state.current_y += state.config.row_height + delay_offset;
2741 }
2742
2743 if create {
2744 state.current_y += state.config.row_height;
2745 }
2746
2747 state.apply_serial_first_row_gap();
2748
2749 if activate {
2751 state
2752 .activations
2753 .entry(to.to_string())
2754 .or_default()
2755 .push((y, None));
2756 }
2757 if deactivate {
2758 if let Some(acts) = state.activations.get_mut(from) {
2759 if let Some(act) = acts.last_mut() {
2760 if act.1.is_none() {
2761 act.1 = Some(y);
2762 }
2763 }
2764 }
2765 }
2766}
2767
2768fn render_note(
2769 svg: &mut String,
2770 state: &mut RenderState,
2771 position: &NotePosition,
2772 participants: &[String],
2773 text: &str,
2774) {
2775 let lines: Vec<&str> = text.split("\\n").collect();
2776 let line_height = note_line_height(&state.config);
2777
2778 let note_font_size = NOTE_LINE_HEIGHT - 4.0; let text_width = estimate_text_width(text, note_font_size);
2782 let content_width = (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH);
2783 let note_height = ELEMENT_PADDING * 2.0 + lines.len() as f64 * line_height;
2784
2785 let (x, note_width, text_anchor) = match position {
2786 NotePosition::Left => {
2787 let px = state.get_x(&participants[0]);
2788 let x = (px - NOTE_MARGIN - content_width).max(state.config.padding);
2790 (x, content_width, "start")
2791 }
2792 NotePosition::Right => {
2793 let px = state.get_x(&participants[0]);
2794 (px + NOTE_MARGIN, content_width, "start")
2796 }
2797 NotePosition::Over => {
2798 if participants.len() == 1 {
2799 let px = state.get_x(&participants[0]);
2800 let x = (px - content_width / 2.0).max(state.config.padding);
2802 (x, content_width, "middle")
2803 } else {
2804 let x1 = state.get_x(&participants[0]);
2806 let x2 = state.get_x(participants.last().unwrap());
2807 let span_width = (x2 - x1).abs() + NOTE_MARGIN * 2.0;
2808 let w = span_width.max(content_width);
2809 let x = (x1 - NOTE_MARGIN).max(state.config.padding);
2810 (x, w, "middle")
2811 }
2812 }
2813 };
2814
2815 let y = state.current_y;
2816 let fold_size = NOTE_FOLD_SIZE;
2817
2818 let note_path = format!(
2821 "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2822 x = x,
2823 y = y,
2824 x2 = x + note_width - fold_size,
2825 x3 = x + note_width,
2826 y2 = y + fold_size,
2827 y3 = y + note_height
2828 );
2829
2830 writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2831
2832 let theme = &state.config.theme;
2834 let fold_path = format!(
2836 "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2837 x1 = x + note_width - fold_size,
2838 y1 = y,
2839 x2 = x + note_width,
2840 y2 = y + fold_size
2841 );
2842
2843 writeln!(
2844 svg,
2845 r##"<path d="{path}" fill="none" stroke="{stroke}" stroke-width="1"/>"##,
2846 path = fold_path,
2847 stroke = theme.note_stroke
2848 )
2849 .unwrap();
2850
2851 let text_x = match text_anchor {
2853 "middle" => x + note_width / 2.0,
2854 _ => x + ELEMENT_PADDING,
2855 };
2856 let text_anchor_attr = if *position == NotePosition::Over { "middle" } else { "start" };
2857
2858 for (i, line) in lines.iter().enumerate() {
2859 let text_y = y + ELEMENT_PADDING + (i as f64 + 0.8) * line_height;
2860 writeln!(
2861 svg,
2862 r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2863 x = text_x,
2864 y = text_y,
2865 anchor = text_anchor_attr,
2866 t = escape_xml(line)
2867 )
2868 .unwrap();
2869 }
2870
2871 state.current_y += note_y_advance(&state.config, lines.len());
2873}
2874
2875fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2877 let theme = &state.config.theme;
2878 let lines: Vec<&str> = text.split("\\n").collect();
2879 let line_height = state_line_height(&state.config);
2880 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2881
2882 let (x, box_width) = if participants.len() == 1 {
2884 let px = state.get_x(&participants[0]);
2885 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2886 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2887 (px - w / 2.0, w)
2888 } else {
2889 let x1 = state.get_x(&participants[0]);
2890 let x2 = state.get_x(participants.last().unwrap());
2891 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2892 let center = (x1 + x2) / 2.0;
2893 (center - span_width / 2.0, span_width)
2894 };
2895
2896 let shift = item_pre_shift(&state.config);
2897 let y = (state.current_y - shift).max(state.content_start());
2898
2899 writeln!(
2901 svg,
2902 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2903 x = x,
2904 y = y,
2905 w = box_width,
2906 h = box_height,
2907 fill = theme.state_fill,
2908 stroke = theme.state_stroke
2909 )
2910 .unwrap();
2911
2912 let text_x = x + box_width / 2.0;
2914 for (i, line) in lines.iter().enumerate() {
2915 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2916 writeln!(
2917 svg,
2918 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2919 x = text_x,
2920 y = text_y,
2921 fill = theme.state_text_color,
2922 font = theme.font_family,
2923 size = state.config.font_size,
2924 t = escape_xml(line)
2925 )
2926 .unwrap();
2927 }
2928
2929 let line_count = lines.len();
2931 state.current_y += state_y_advance(&state.config, line_count);
2932}
2933
2934fn render_ref(
2936 svg: &mut String,
2937 state: &mut RenderState,
2938 participants: &[String],
2939 text: &str,
2940 input_from: Option<&str>,
2941 input_label: Option<&str>,
2942 output_to: Option<&str>,
2943 output_label: Option<&str>,
2944) {
2945 let theme = &state.config.theme;
2946 let lines: Vec<&str> = text.split("\\n").collect();
2947 let line_height = ref_line_height(&state.config);
2948 let label_height = BLOCK_LABEL_HEIGHT;
2950 let box_height = label_height + state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2951
2952 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2954 let text_width = max_line_len as f64 * 8.0 + state.config.note_padding * 4.0;
2955
2956 let (x, box_width) = if participants.len() == 1 {
2957 let px = state.get_x(&participants[0]);
2958 let w = (text_width + 40.0).max(100.0);
2959 (px - w / 2.0, w)
2960 } else {
2961 let x1 = state.get_x(&participants[0]);
2962 let x2 = state.get_x(participants.last().unwrap());
2963 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2964 let final_width = span_width.max(text_width);
2966 let center = (x1 + x2) / 2.0;
2967 (center - final_width / 2.0, final_width)
2968 };
2969
2970 let shift = item_pre_shift(&state.config);
2971 let y = (state.current_y - shift).max(state.content_start());
2972 let input_arrow_y = y + label_height / 2.0 + 4.0;
2973 let output_arrow_y = y + box_height - label_height / 2.0 - 4.0;
2974
2975 if let Some(from) = input_from {
2977 let from_x = state.get_x(from);
2978 let to_x = if from_x < x + box_width / 2.0 {
2980 x } else {
2982 x + box_width };
2984
2985 let direction = arrow_direction(from_x, input_arrow_y, to_x, input_arrow_y);
2987 let arrow_points = arrowhead_points(to_x, input_arrow_y, direction);
2988 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2989
2990 writeln!(
2992 svg,
2993 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2994 x1 = from_x,
2995 y = input_arrow_y,
2996 x2 = line_end_x
2997 )
2998 .unwrap();
2999
3000 writeln!(
3002 svg,
3003 r#"<polygon points="{points}" class="arrowhead"/>"#,
3004 points = arrow_points
3005 )
3006 .unwrap();
3007
3008 if let Some(label) = input_label {
3010 let text_x = (from_x + to_x) / 2.0;
3011 writeln!(
3012 svg,
3013 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3014 x = text_x,
3015 y = input_arrow_y - 8.0,
3016 t = escape_xml(label)
3017 )
3018 .unwrap();
3019 }
3020 }
3021
3022 writeln!(
3024 svg,
3025 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
3026 x = x,
3027 y = y,
3028 w = box_width,
3029 h = box_height
3030 )
3031 .unwrap();
3032
3033 let label_text = "ref";
3035 let tab_width = block_tab_width(label_text);
3036 let notch_size = 5.0;
3037 let label_text_offset = 16.0;
3038
3039 let pentagon_path = format!(
3040 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
3041 x1 = x,
3042 y1 = y,
3043 x2 = x + tab_width,
3044 y2 = y + label_height - notch_size,
3045 x3 = x + tab_width - notch_size,
3046 y3 = y + label_height
3047 );
3048
3049 writeln!(
3050 svg,
3051 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
3052 path = pentagon_path,
3053 fill = theme.block_label_fill,
3054 stroke = theme.block_stroke
3055 )
3056 .unwrap();
3057
3058 writeln!(
3060 svg,
3061 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
3062 x = x + 5.0,
3063 y = y + label_text_offset,
3064 label = label_text
3065 )
3066 .unwrap();
3067
3068 let text_x = x + box_width / 2.0;
3070 for (i, line) in lines.iter().enumerate() {
3071 let text_y = y + label_height + state.config.note_padding + (i as f64 + 0.5) * line_height;
3072 writeln!(
3073 svg,
3074 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
3075 x = text_x,
3076 y = text_y,
3077 fill = theme.ref_text_color,
3078 font = theme.font_family,
3079 size = state.config.font_size,
3080 t = escape_xml(line)
3081 )
3082 .unwrap();
3083 }
3084
3085 if let Some(to) = output_to {
3087 let to_x = state.get_x(to);
3088 let from_x = if to_x < x + box_width / 2.0 {
3090 x } else {
3092 x + box_width };
3094
3095 let direction = arrow_direction(from_x, output_arrow_y, to_x, output_arrow_y);
3097 let arrow_points = arrowhead_points(to_x, output_arrow_y, direction);
3098 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
3099
3100 writeln!(
3102 svg,
3103 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
3104 x1 = from_x,
3105 y = output_arrow_y,
3106 x2 = line_end_x
3107 )
3108 .unwrap();
3109
3110 writeln!(
3112 svg,
3113 r#"<polygon points="{points}" class="arrowhead"/>"#,
3114 points = arrow_points
3115 )
3116 .unwrap();
3117
3118 if let Some(label) = output_label {
3120 let text_x = (from_x + to_x) / 2.0;
3121 writeln!(
3122 svg,
3123 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3124 x = text_x,
3125 y = output_arrow_y - 8.0,
3126 t = escape_xml(label)
3127 )
3128 .unwrap();
3129 }
3130 }
3131
3132 let line_count = lines.len();
3134 state.current_y += ref_y_advance(&state.config, line_count);
3135}
3136
3137fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
3139 let theme = &state.config.theme;
3140 let lines: Vec<&str> = text.split("\\n").collect();
3141 let line_height = state.config.font_size + 4.0;
3142
3143 let x = state.config.padding + 10.0;
3145 let y = state.current_y;
3146
3147 for (i, line) in lines.iter().enumerate() {
3148 let text_y = y + (i as f64 + 0.8) * line_height;
3149 writeln!(
3150 svg,
3151 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
3152 x = x,
3153 y = text_y,
3154 fill = theme.description_text_color,
3155 font = theme.font_family,
3156 size = state.config.font_size - 1.0,
3157 t = escape_xml(line)
3158 )
3159 .unwrap();
3160 }
3161
3162 state.current_y += description_y_advance(&state.config, lines.len());
3163}
3164
3165fn render_block(
3166 svg: &mut String,
3167 state: &mut RenderState,
3168 kind: &BlockKind,
3169 _label: &str,
3170 items: &[Item],
3171 else_sections: &[crate::ast::ElseSection],
3172 depth: usize,
3173) {
3174 if block_is_parallel(kind) {
3175 state.push_parallel();
3176 let start_y = state.current_y;
3177 let mut max_end_y = start_y;
3178 for item in items {
3179 state.current_y = start_y;
3180 render_items(svg, state, std::slice::from_ref(item), depth);
3181 if state.current_y > max_end_y {
3182 max_end_y = state.current_y;
3183 }
3184 }
3185 let gap = if parallel_needs_gap(items) {
3186 state.config.row_height
3187 } else {
3188 0.0
3189 };
3190 state.current_y = max_end_y + gap;
3191 state.pop_parallel();
3192 return;
3193 }
3194
3195 if matches!(kind, BlockKind::Serial) {
3196 state.push_serial_first_row_pending();
3197 render_items(svg, state, items, depth);
3198 for else_section in else_sections {
3199 render_items(svg, state, &else_section.items, depth);
3200 }
3201 state.pop_serial_first_row_pending();
3202 return;
3203 }
3204
3205 if !block_has_frame(kind) {
3206 render_items(svg, state, items, depth);
3207 for else_section in else_sections {
3208 render_items(svg, state, &else_section.items, depth);
3209 }
3210 return;
3211 }
3212
3213 state.current_y += block_header_space(&state.config, depth);
3218
3219 render_items(svg, state, items, depth + 1);
3221
3222 for else_section in else_sections {
3224 state.push_else_return_pending();
3225 state.current_y += block_else_before(&state.config, depth);
3227 state.current_y += block_else_after(&state.config, depth);
3229 render_items(svg, state, &else_section.items, depth + 1);
3230 state.pop_else_return_pending();
3231 }
3232
3233 let end_y = state.current_y + block_footer_padding(&state.config, depth);
3236
3237 state.current_y = end_y + state.config.row_height;
3239
3240 }
3243
3244fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3245 for (participant, activations) in &state.activations {
3246 let x = state.get_x(participant);
3247 let box_x = x - state.config.activation_width / 2.0;
3248
3249 for (start_y, end_y) in activations {
3250 let end = end_y.unwrap_or(footer_y);
3252 let height = end - start_y;
3253
3254 if height > 0.0 {
3255 writeln!(
3256 svg,
3257 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3258 x = box_x,
3259 y = start_y,
3260 w = state.config.activation_width,
3261 h = height
3262 )
3263 .unwrap();
3264 }
3265 }
3266 }
3267}
3268
3269fn escape_xml(s: &str) -> String {
3270 s.replace('&', "&")
3271 .replace('<', "<")
3272 .replace('>', ">")
3273 .replace('"', """)
3274 .replace('\'', "'")
3275}
3276
3277#[cfg(test)]
3278mod tests {
3279 use super::*;
3280 use crate::parser::parse;
3281
3282 #[test]
3283 fn test_render_simple() {
3284 let diagram = parse("Alice->Bob: Hello").unwrap();
3285 let svg = render(&diagram);
3286 assert!(svg.contains("<svg"));
3287 assert!(svg.contains("Alice"));
3288 assert!(svg.contains("Bob"));
3289 assert!(svg.contains("Hello"));
3290 }
3291
3292 #[test]
3293 fn test_render_with_note() {
3294 let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3295 let svg = render(&diagram);
3296 assert!(svg.contains("Thinking"));
3297 }
3298}