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: 68.0, right_margin: 68.0, participant_gap: 85.0, header_height: 46.0, row_height: 32.0, participant_width: 92.0, font_size: 14.0, activation_width: 10.0,
56 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}
92
93#[derive(Debug, Clone)]
94struct LabelBox {
95 x_min: f64,
96 x_max: f64,
97 y_min: f64,
98 y_max: f64,
99}
100
101struct RenderState {
103 config: Config,
104 participants: Vec<Participant>,
105 participant_x: HashMap<String, f64>,
106 participant_widths: HashMap<String, f64>,
107 current_y: f64,
108 activations: HashMap<String, Vec<(f64, Option<f64>)>>,
109 autonumber: Option<u32>,
110 destroyed: HashMap<String, f64>,
111 has_title: bool,
112 total_width: f64,
113 block_backgrounds: Vec<BlockBackground>,
115 block_labels: Vec<BlockLabel>,
117 footer_style: FooterStyle,
119 else_return_pending: Vec<bool>,
121 serial_first_row_pending: Vec<bool>,
123 parallel_depth: usize,
125 message_label_boxes: Vec<LabelBox>,
127}
128
129const TEXT_WIDTH_PADDING: f64 = 41.0;
130const TEXT_WIDTH_SCALE: f64 = 1.3;
131const MESSAGE_WIDTH_PADDING: f64 = 4.0; const MESSAGE_WIDTH_SCALE: f64 = 0.835; const DELAY_UNIT: f64 = 18.0;
134const BLOCK_LABEL_HEIGHT: f64 = 21.8; const BLOCK_FOOTER_PADDING_LEVEL1: f64 = 0.87; const BLOCK_FOOTER_PADDING_DEEP: f64 = 0.87; const BLOCK_FOOTER_PADDING_TOP_FACTOR: f64 = 1.07; const BLOCK_ELSE_SPACING_LEVEL1: f64 = 0.79; const BLOCK_ELSE_SPACING_DEEP: f64 = 0.74; const BLOCK_ELSE_TOP_SPACING_FACTOR: f64 = 0.74; const BLOCK_NESTED_HEADER_ADJUST: f64 = 24.2; const BLOCK_NESTED_FRAME_SHIFT: f64 = 24.2; const PARALLEL_BLOCK_GAP: f64 = 13.5; const MESSAGE_SPACING_MULT: f64 = 0.375; const SELF_MESSAGE_MIN_SPACING: f64 = 54.0; const SELF_MESSAGE_GAP: f64 = 10.7; const CREATE_MESSAGE_SPACING: f64 = 27.5; const DESTROY_SPACING: f64 = 10.7; const NOTE_PADDING: f64 = 5.7; const NOTE_LINE_HEIGHT_EXTRA: f64 = 3.4; const NOTE_MARGIN: f64 = 7.5; const STATE_LINE_HEIGHT_EXTRA: f64 = 11.0;
153const REF_LINE_HEIGHT_EXTRA: f64 = 16.333333;
154const ELSE_RETURN_GAP: f64 = 1.0;
155const SERIAL_FIRST_ROW_GAP: f64 = 0.0;
156const SERIAL_FIRST_ROW_PARALLEL_GAP: f64 = 1.0;
157const SERIAL_SELF_MESSAGE_ADJUST: f64 = 1.0;
158const ACTIVATION_START_GAP: f64 = 0.0;
159const ACTIVATION_CHAIN_GAP: f64 = 1.0;
160const REF_EXTRA_GAP: f64 = 2.5;
161const SELF_MESSAGE_ACTIVE_ADJUST: f64 = 1.0;
162const STATE_EXTRA_GAP: f64 = 0.0;
163const MESSAGE_LABEL_COLLISION_PADDING: f64 = 2.0;
164const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
165const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
166const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
167
168fn block_header_space(config: &Config, depth: usize) -> f64 {
169 let base = config.row_height + BLOCK_LABEL_HEIGHT;
170 if depth == 0 {
171 base
172 } else {
173 (base - BLOCK_NESTED_HEADER_ADJUST).max(BLOCK_LABEL_HEIGHT)
174 }
175}
176
177fn block_frame_shift(depth: usize) -> f64 {
178 if depth == 0 {
179 0.0
180 } else if depth == 1 {
181 BLOCK_NESTED_FRAME_SHIFT
182 } else {
183 BLOCK_NESTED_FRAME_SHIFT
184 }
185}
186
187fn block_footer_padding(config: &Config, depth: usize) -> f64 {
188 let factor = if depth == 0 {
189 BLOCK_FOOTER_PADDING_TOP_FACTOR
190 } else if depth == 1 {
191 BLOCK_FOOTER_PADDING_LEVEL1
192 } else {
193 BLOCK_FOOTER_PADDING_DEEP
194 };
195 config.row_height * factor
196}
197
198fn block_else_spacing(config: &Config, depth: usize) -> f64 {
199 if depth == 0 {
200 config.row_height * BLOCK_ELSE_TOP_SPACING_FACTOR
201 } else if depth == 1 {
202 config.row_height * BLOCK_ELSE_SPACING_LEVEL1
203 } else {
204 config.row_height * BLOCK_ELSE_SPACING_DEEP
205 }
206}
207
208fn message_spacing_line_height(config: &Config) -> f64 {
209 config.row_height * MESSAGE_SPACING_MULT
210}
211
212fn self_message_spacing(config: &Config, lines: usize) -> f64 {
213 let line_height = config.font_size + 4.0;
214 let text_block_height = lines as f64 * line_height;
215 let loop_height = (text_block_height + 10.0).max(25.0);
216 let base = loop_height + SELF_MESSAGE_GAP;
217 if lines >= 3 {
218 base.max(SELF_MESSAGE_MIN_SPACING)
219 } else {
220 base
221 }
222}
223
224fn note_line_height(config: &Config) -> f64 {
225 config.font_size + NOTE_LINE_HEIGHT_EXTRA
226}
227
228fn note_padding(_config: &Config) -> f64 {
229 NOTE_PADDING
230}
231
232fn item_pre_gap(config: &Config) -> f64 {
233 config.font_size + 1.0
234}
235
236fn item_pre_shift(config: &Config) -> f64 {
237 (config.row_height - item_pre_gap(config)).max(0.0)
238}
239
240fn label_boxes_overlap(x_min: f64, x_max: f64, y_min: f64, y_max: f64, other: &LabelBox) -> bool {
241 let x_overlap = x_max >= other.x_min - MESSAGE_LABEL_COLLISION_PADDING
242 && x_min <= other.x_max + MESSAGE_LABEL_COLLISION_PADDING;
243 let y_overlap = y_max >= other.y_min - MESSAGE_LABEL_COLLISION_PADDING
244 && y_min <= other.y_max + MESSAGE_LABEL_COLLISION_PADDING;
245 x_overlap && y_overlap
246}
247
248fn actor_footer_extra(_participants: &[Participant], _config: &Config) -> f64 {
249 0.0
251}
252
253fn serial_first_row_gap(parallel_depth: usize) -> f64 {
254 if parallel_depth > 0 {
255 SERIAL_FIRST_ROW_PARALLEL_GAP
256 } else {
257 SERIAL_FIRST_ROW_GAP
258 }
259}
260
261fn state_line_height(config: &Config) -> f64 {
262 config.font_size + STATE_LINE_HEIGHT_EXTRA
263}
264
265fn ref_line_height(config: &Config) -> f64 {
266 config.font_size + REF_LINE_HEIGHT_EXTRA
267}
268
269const ARROWHEAD_SIZE: f64 = 10.0;
271
272fn arrowhead_points(x: f64, y: f64, direction: f64) -> String {
274 let size = ARROWHEAD_SIZE;
275 let half_width = size * 0.35;
276
277 let tip_x = x;
279 let tip_y = y;
280
281 let back_x = x - size * direction.cos();
283 let back_y = y - size * direction.sin();
284
285 let perp_x = -direction.sin() * half_width;
287 let perp_y = direction.cos() * half_width;
288
289 format!(
290 "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
291 back_x + perp_x,
292 back_y + perp_y,
293 tip_x,
294 tip_y,
295 back_x - perp_x,
296 back_y - perp_y
297 )
298}
299
300fn arrow_direction(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
302 (y2 - y1).atan2(x2 - x1)
303}
304
305fn block_has_frame(kind: &BlockKind) -> bool {
306 !matches!(kind, BlockKind::Parallel | BlockKind::Serial)
307}
308
309fn block_is_parallel(kind: &BlockKind) -> bool {
310 matches!(kind, BlockKind::Parallel)
311}
312
313fn parallel_needs_gap(items: &[Item]) -> bool {
314 items.iter().any(|item| matches!(item, Item::Block { .. }))
315}
316
317fn text_char_weight(c: char) -> f64 {
318 if c.is_ascii() {
319 if c.is_uppercase() {
320 0.7
321 } else {
322 0.5
323 }
324 } else {
325 1.0 }
327}
328
329fn max_weighted_line(text: &str) -> f64 {
330 text.split("\\n")
331 .map(|line| line.chars().map(text_char_weight).sum::<f64>())
332 .fold(0.0_f64, |a, b| a.max(b))
333}
334
335fn estimate_text_width(text: &str, font_size: f64) -> f64 {
337 let weighted = max_weighted_line(text);
338 weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
339}
340
341fn estimate_message_width(text: &str, font_size: f64) -> f64 {
342 let weighted = max_weighted_line(text);
343 weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
344}
345
346fn block_tab_width(kind: &str) -> f64 {
347 (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
348}
349
350fn calculate_note_width(text: &str, config: &Config) -> f64 {
352 let lines: Vec<&str> = text.split("\\n").collect();
353 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(10);
354 let padding = config.note_padding;
355 (max_line_len as f64 * 10.0 + padding * 2.0).max(80.0).min(300.0)
356}
357
358fn calculate_right_margin(
360 participants: &[Participant],
361 items: &[Item],
362 config: &Config,
363) -> f64 {
364 let rightmost_id = match participants.last() {
365 Some(p) => p.id().to_string(),
366 None => return config.right_margin,
367 };
368 let mut max_right_note_width: f64 = 0.0;
369
370 fn process_items_for_right_notes(
371 items: &[Item],
372 rightmost_id: &str,
373 max_width: &mut f64,
374 config: &Config,
375 ) {
376 for item in items {
377 match item {
378 Item::Note {
379 position: NotePosition::Right,
380 participants,
381 text,
382 } => {
383 if participants.first().map(|s| s.as_str()) == Some(rightmost_id) {
385 let note_width = calculate_note_width(text, config);
386 if note_width > *max_width {
387 *max_width = note_width;
388 }
389 }
390 }
391 Item::Block {
392 items, else_items, ..
393 } => {
394 process_items_for_right_notes(items, rightmost_id, max_width, config);
395 if let Some(else_items) = else_items {
396 process_items_for_right_notes(else_items, rightmost_id, max_width, config);
397 }
398 }
399 _ => {}
400 }
401 }
402 }
403
404 process_items_for_right_notes(items, &rightmost_id, &mut max_right_note_width, config);
405
406 if max_right_note_width > 0.0 {
408 (max_right_note_width + 10.0).max(config.right_margin)
409 } else {
410 config.right_margin
411 }
412}
413
414fn calculate_participant_gaps(
416 participants: &[Participant],
417 items: &[Item],
418 config: &Config,
419) -> Vec<f64> {
420 if participants.len() <= 1 {
421 return vec![];
422 }
423
424 let mut participant_index: HashMap<String, usize> = HashMap::new();
426 for (i, p) in participants.iter().enumerate() {
427 participant_index.insert(p.id().to_string(), i);
428 }
429
430 let min_gap = config.participant_gap;
433 let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
434
435 fn process_items(
437 items: &[Item],
438 participant_index: &HashMap<String, usize>,
439 gaps: &mut Vec<f64>,
440 config: &Config,
441 ) {
442 for item in items {
443 match item {
444 Item::Message { from, to, text, arrow, .. } => {
445 if let (Some(&from_idx), Some(&to_idx)) =
446 (participant_index.get(from), participant_index.get(to))
447 {
448 if from_idx != to_idx {
449 let (min_idx, max_idx) = if from_idx < to_idx {
450 (from_idx, to_idx)
451 } else {
452 (to_idx, from_idx)
453 };
454
455 let text_width = estimate_message_width(text, config.font_size);
456
457 let delay_extra = arrow.delay.map(|d| d as f64 * 79.5).unwrap_or(0.0);
460
461 let gap_count = (max_idx - min_idx) as f64;
463 let needed_gap = if gap_count == 1.0 {
464 text_width - 36.0 + delay_extra
466 } else {
467 text_width / gap_count - 20.0 + delay_extra
469 };
470
471 for gap_idx in min_idx..max_idx {
473 if needed_gap > gaps[gap_idx] {
474 gaps[gap_idx] = needed_gap;
475 }
476 }
477 }
478 }
479 }
480 Item::Note {
481 position: NotePosition::Right,
482 participants: note_participants,
483 ..
484 } => {
485 if let Some(participant) = note_participants.first() {
488 if let Some(&idx) = participant_index.get(participant) {
489 if idx < gaps.len() {
490 let needed_gap = config.participant_gap * 1.3;
493 if needed_gap > gaps[idx] {
494 gaps[idx] = needed_gap;
495 }
496 }
497 }
498 }
499 }
500 Item::Block {
501 items, else_items, ..
502 } => {
503 process_items(items, participant_index, gaps, config);
504 if let Some(else_items) = else_items {
505 process_items(else_items, participant_index, gaps, config);
506 }
507 }
508 _ => {}
509 }
510 }
511 }
512
513 process_items(items, &participant_index, &mut gaps, config);
514
515 let max_gap = 645.0;
521 for gap in &mut gaps {
522 if *gap > max_gap {
523 *gap = max_gap;
524 }
525 }
526
527 gaps
528}
529
530impl RenderState {
531 fn new(
532 config: Config,
533 participants: Vec<Participant>,
534 items: &[Item],
535 has_title: bool,
536 footer_style: FooterStyle,
537 ) -> Self {
538 let mut config = config;
539 let mut required_header_height = config.header_height;
544 for p in &participants {
545 let lines = p.name.split("\\n").count();
546 let needed = match p.kind {
547 ParticipantKind::Participant => {
548 if lines <= 1 {
550 46.0
551 } else {
552 108.0 }
554 }
555 ParticipantKind::Actor => {
556 if lines <= 1 {
559 85.0
560 } else {
561 108.0
562 }
563 }
564 };
565 if needed > required_header_height {
566 required_header_height = needed;
567 }
568 }
569 if required_header_height > config.header_height {
570 config.header_height = required_header_height;
571 }
572 let mut participant_widths: HashMap<String, f64> = HashMap::new();
574 let min_width = config.participant_width;
575
576 for p in &participants {
577 let lines: Vec<&str> = p.name.split("\\n").collect();
581 let max_chars = lines.iter().map(|l| l.chars().count()).max().unwrap_or(1);
582 let width = if lines.len() > 1 {
583 (max_chars as f64 * 11.6 + 22.0).max(min_width)
585 } else {
586 (max_chars as f64 * 9.7 + 22.0).max(min_width)
588 };
589 participant_widths.insert(p.id().to_string(), width);
590 }
591
592 let gaps = calculate_participant_gaps(&participants, items, &config);
593
594 let left_margin = config.left_margin;
596 let right_margin = calculate_right_margin(&participants, items, &config);
599
600 let mut participant_x = HashMap::new();
601 let first_width = participants
602 .first()
603 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
604 .unwrap_or(min_width);
605 let mut current_x = config.padding + left_margin + first_width / 2.0;
606
607 for (i, p) in participants.iter().enumerate() {
608 participant_x.insert(p.id().to_string(), current_x);
609 if i < gaps.len() {
610 let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
611 let next_p = participants.get(i + 1);
612 let next_width = next_p
613 .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
614 .unwrap_or(min_width);
615
616 let current_is_actor = p.kind == ParticipantKind::Actor;
619 let next_is_actor = next_p.map(|np| np.kind == ParticipantKind::Actor).unwrap_or(false);
620
621 let actor_gap_reduction = 0.0;
624 let _ = (current_is_actor, next_is_actor); let min_center_gap = (current_width + next_width) / 2.0 + 20.0 - actor_gap_reduction;
629 let actual_gap = (gaps[i] - actor_gap_reduction).max(min_center_gap).max(60.0);
630 current_x += actual_gap;
631 }
632 }
633
634 let last_width = participants
635 .last()
636 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
637 .unwrap_or(min_width);
638 let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
639
640 Self {
641 config,
642 participants,
643 participant_x,
644 participant_widths,
645 current_y: 0.0,
646 activations: HashMap::new(),
647 autonumber: None,
648 destroyed: HashMap::new(),
649 has_title,
650 total_width,
651 block_backgrounds: Vec::new(),
652 block_labels: Vec::new(),
653 footer_style,
654 else_return_pending: Vec::new(),
655 serial_first_row_pending: Vec::new(),
656 parallel_depth: 0,
657 message_label_boxes: Vec::new(),
658 }
659 }
660
661 fn get_participant_width(&self, name: &str) -> f64 {
662 *self
663 .participant_widths
664 .get(name)
665 .unwrap_or(&self.config.participant_width)
666 }
667
668 fn get_x(&self, name: &str) -> f64 {
669 *self.participant_x.get(name).unwrap_or(&0.0)
670 }
671
672 fn push_else_return_pending(&mut self) {
673 self.else_return_pending.push(true);
674 }
675
676 fn pop_else_return_pending(&mut self) {
677 self.else_return_pending.pop();
678 }
679
680 fn apply_else_return_gap(&mut self, arrow: &Arrow) {
681 if let Some(pending) = self.else_return_pending.last_mut() {
682 if *pending && matches!(arrow.line, LineStyle::Dashed) {
683 self.current_y += ELSE_RETURN_GAP;
684 *pending = false;
685 }
686 }
687 }
688
689 fn push_serial_first_row_pending(&mut self) {
690 self.serial_first_row_pending.push(true);
691 }
692
693 fn pop_serial_first_row_pending(&mut self) {
694 self.serial_first_row_pending.pop();
695 }
696
697 fn in_serial_block(&self) -> bool {
698 !self.serial_first_row_pending.is_empty()
699 }
700
701 fn apply_serial_first_row_gap(&mut self) {
702 if let Some(pending) = self.serial_first_row_pending.last_mut() {
703 if *pending {
704 self.current_y += serial_first_row_gap(self.parallel_depth);
705 *pending = false;
706 }
707 }
708 }
709
710 fn reserve_message_label(
711 &mut self,
712 x_min: f64,
713 x_max: f64,
714 mut y_min: f64,
715 mut y_max: f64,
716 step: f64,
717 ) -> f64 {
718 let mut offset = 0.0;
719 let mut attempts = 0;
720 while self
721 .message_label_boxes
722 .iter()
723 .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
724 && attempts < 20
725 {
726 y_min += step;
727 y_max += step;
728 offset += step;
729 attempts += 1;
730 }
731 self.message_label_boxes.push(LabelBox {
732 x_min,
733 x_max,
734 y_min,
735 y_max,
736 });
737 offset
738 }
739
740 fn push_parallel(&mut self) {
741 self.parallel_depth += 1;
742 }
743
744 fn pop_parallel(&mut self) {
745 if self.parallel_depth > 0 {
746 self.parallel_depth -= 1;
747 }
748 }
749
750 fn active_activation_count(&self) -> usize {
751 self.activations
752 .values()
753 .map(|acts| acts.iter().filter(|(_, end)| end.is_none()).count())
754 .sum()
755 }
756
757 fn diagram_width(&self) -> f64 {
758 self.total_width
759 }
760
761 fn leftmost_x(&self) -> f64 {
763 self.participants
764 .first()
765 .map(|p| self.get_x(p.id()))
766 .unwrap_or(self.config.padding)
767 }
768
769 fn rightmost_x(&self) -> f64 {
771 self.participants
772 .last()
773 .map(|p| self.get_x(p.id()))
774 .unwrap_or(self.total_width - self.config.padding)
775 }
776
777 fn block_left(&self) -> f64 {
779 let leftmost_width = self
780 .participants
781 .first()
782 .map(|p| self.get_participant_width(p.id()))
783 .unwrap_or(self.config.participant_width);
784 self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
785 }
786
787 fn block_right(&self) -> f64 {
789 let rightmost_width = self
790 .participants
791 .last()
792 .map(|p| self.get_participant_width(p.id()))
793 .unwrap_or(self.config.participant_width);
794 self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
795 }
796
797 fn header_top(&self) -> f64 {
798 if self.has_title {
799 self.config.padding + self.config.title_height
800 } else {
801 self.config.padding
802 }
803 }
804
805 fn content_start(&self) -> f64 {
806 self.header_top() + self.config.header_height + self.config.row_height + 3.97
810 }
811
812 fn next_number(&mut self) -> Option<u32> {
813 self.autonumber.map(|n| {
814 self.autonumber = Some(n + 1);
815 n
816 })
817 }
818
819 fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
821 self.block_backgrounds.push(BlockBackground {
822 x,
823 y,
824 width,
825 height,
826 });
827 }
828
829 fn add_block_label(
831 &mut self,
832 x1: f64,
833 start_y: f64,
834 end_y: f64,
835 x2: f64,
836 kind: &str,
837 label: &str,
838 else_y: Option<f64>,
839 ) {
840 self.block_labels.push(BlockLabel {
841 x1,
842 start_y,
843 end_y,
844 x2,
845 kind: kind.to_string(),
846 label: label.to_string(),
847 else_y,
848 });
849 }
850}
851
852fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
854 let mut min_left: Option<f64> = None;
855 let mut max_right: Option<f64> = None;
856 let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
857 let mut includes_leftmost = false;
858
859 fn update_bounds(
860 participant: &str,
861 state: &RenderState,
862 min_left: &mut Option<f64>,
863 max_right: &mut Option<f64>,
864 includes_leftmost: &mut bool,
865 leftmost_id: &str,
866 ) {
867 let x = state.get_x(participant);
868 if x > 0.0 {
869 let width = state.get_participant_width(participant);
870 let left = x - width / 2.0;
871 let right = x + width / 2.0;
872 *min_left = Some(min_left.map_or(left, |m| m.min(left)));
873 *max_right = Some(max_right.map_or(right, |m| m.max(right)));
874 if participant == leftmost_id {
875 *includes_leftmost = true;
876 }
877 }
878 }
879
880 fn process_items(
881 items: &[Item],
882 state: &RenderState,
883 min_left: &mut Option<f64>,
884 max_right: &mut Option<f64>,
885 includes_leftmost: &mut bool,
886 leftmost_id: &str,
887 ) {
888 for item in items {
889 match item {
890 Item::Message { from, to, .. } => {
891 update_bounds(
892 from,
893 state,
894 min_left,
895 max_right,
896 includes_leftmost,
897 leftmost_id,
898 );
899 update_bounds(
900 to,
901 state,
902 min_left,
903 max_right,
904 includes_leftmost,
905 leftmost_id,
906 );
907 }
908 Item::Note { participants, .. } => {
909 for p in participants {
910 update_bounds(
911 p,
912 state,
913 min_left,
914 max_right,
915 includes_leftmost,
916 leftmost_id,
917 );
918 }
919 }
920 Item::Block {
921 items, else_items, ..
922 } => {
923 process_items(
924 items,
925 state,
926 min_left,
927 max_right,
928 includes_leftmost,
929 leftmost_id,
930 );
931 if let Some(else_items) = else_items {
932 process_items(
933 else_items,
934 state,
935 min_left,
936 max_right,
937 includes_leftmost,
938 leftmost_id,
939 );
940 }
941 }
942 Item::Activate { participant }
943 | Item::Deactivate { participant }
944 | Item::Destroy { participant } => {
945 update_bounds(
946 participant,
947 state,
948 min_left,
949 max_right,
950 includes_leftmost,
951 leftmost_id,
952 );
953 }
954 _ => {}
955 }
956 }
957 }
958
959 process_items(
960 items,
961 state,
962 &mut min_left,
963 &mut max_right,
964 &mut includes_leftmost,
965 leftmost_id,
966 );
967
968 match (min_left, max_right) {
969 (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
970 _ => None,
971 }
972}
973
974fn calculate_block_bounds_with_label(
976 items: &[Item],
977 else_items: Option<&[Item]>,
978 label: &str,
979 kind: &str,
980 depth: usize,
981 state: &RenderState,
982) -> (f64, f64) {
983 let mut all_items: Vec<&Item> = items.iter().collect();
984 if let Some(else_items) = else_items {
985 all_items.extend(else_items.iter());
986 }
987
988 let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
990
991 let (base_x1, base_x2, includes_leftmost) =
992 if let Some((min_left, max_right, includes_leftmost)) =
993 find_involved_participants(&items_slice, state)
994 {
995 let margin = state.config.block_margin;
996 (min_left - margin, max_right + margin, includes_leftmost)
997 } else {
998 (state.block_left(), state.block_right(), false)
1000 };
1001
1002 let pentagon_width = block_tab_width(kind);
1005 let label_font_size = state.config.font_size - 1.0;
1006 let label_padding_x = 6.0;
1007 let condition_width = if label.is_empty() {
1008 0.0
1009 } else {
1010 let condition_text = format!("[{}]", label);
1011 let base_width =
1012 (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1013 base_width + label_padding_x * 2.0
1014 };
1015 let min_label_width = pentagon_width + 8.0 + condition_width + 20.0; let current_width = base_x2 - base_x1;
1019 let (mut x1, mut x2) = if current_width < min_label_width {
1020 (base_x1, base_x1 + min_label_width)
1022 } else {
1023 (base_x1, base_x2)
1024 };
1025
1026 let nested_padding = depth as f64 * 20.0;
1028 if nested_padding > 0.0 {
1029 let available = x2 - x1;
1030 let max_padding = ((available - min_label_width) / 2.0).max(0.0);
1031 let inset = nested_padding.min(max_padding);
1032 x1 += inset;
1033 x2 -= inset;
1034 }
1035
1036 if depth == 0 && includes_leftmost {
1037 x1 = x1.min(state.config.padding);
1038 }
1039
1040 (x1, x2)
1041}
1042
1043fn collect_block_backgrounds(
1045 state: &mut RenderState,
1046 items: &[Item],
1047 depth: usize,
1048 active_activation_count: &mut usize,
1049) {
1050 for item in items {
1051 match item {
1052 Item::Message {
1053 text,
1054 from,
1055 to,
1056 arrow,
1057 activate,
1058 deactivate,
1059 create,
1060 ..
1061 } => {
1062 state.apply_else_return_gap(arrow);
1063 let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
1064 ACTIVATION_CHAIN_GAP
1065 } else {
1066 0.0
1067 };
1068 let is_self = from == to;
1069 let lines: Vec<&str> = text.split("\\n").collect();
1070 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1071
1072 if is_self {
1073 let mut spacing = self_message_spacing(&state.config, lines.len());
1074 if state.in_serial_block() {
1075 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
1076 }
1077 if *active_activation_count > 0 {
1078 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
1079 }
1080 state.current_y += spacing;
1081 } else {
1082 let spacing_line_height = message_spacing_line_height(&state.config);
1083 let extra_height = if lines.len() > 1 {
1084 (lines.len() - 1) as f64 * spacing_line_height
1085 } else {
1086 0.0
1087 };
1088 if lines.len() > 1 {
1089 state.current_y += extra_height;
1090 }
1091 state.current_y += state.config.row_height + delay_offset;
1092 }
1093
1094 if *create {
1095 state.current_y += CREATE_MESSAGE_SPACING;
1096 }
1097
1098 state.apply_serial_first_row_gap();
1099
1100 if *activate && depth == 0 {
1101 state.current_y += ACTIVATION_START_GAP;
1102 }
1103 if chain_gap > 0.0 {
1104 state.current_y += chain_gap;
1105 }
1106 if *activate {
1107 *active_activation_count += 1;
1108 }
1109 if *deactivate && *active_activation_count > 0 {
1110 *active_activation_count -= 1;
1111 }
1112 }
1113 Item::Note { text, .. } => {
1114 let lines: Vec<&str> = text.split("\\n").collect();
1115 let line_height = note_line_height(&state.config);
1116 let note_height =
1117 note_padding(&state.config) * 2.0 + lines.len() as f64 * line_height;
1118 state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
1119 }
1120 Item::State { text, .. } => {
1121 let lines: Vec<&str> = text.split("\\n").collect();
1122 let line_height = state_line_height(&state.config);
1123 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
1124 state.current_y += box_height + item_pre_gap(&state.config) + STATE_EXTRA_GAP;
1125 }
1126 Item::Ref { text, .. } => {
1127 let lines: Vec<&str> = text.split("\\n").collect();
1128 let line_height = ref_line_height(&state.config);
1129 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
1130 state.current_y += box_height + item_pre_gap(&state.config) + REF_EXTRA_GAP;
1131 }
1132 Item::Description { text } => {
1133 let lines: Vec<&str> = text.split("\\n").collect();
1134 let line_height = state.config.font_size + 4.0;
1135 state.current_y += lines.len() as f64 * line_height + 10.0;
1136 }
1137 Item::Destroy { .. } => {
1138 state.current_y += DESTROY_SPACING;
1139 }
1140 Item::Activate { .. } => {
1141 *active_activation_count += 1;
1142 }
1143 Item::Deactivate { .. } => {
1144 if *active_activation_count > 0 {
1145 *active_activation_count -= 1;
1146 }
1147 }
1148 Item::Block {
1149 kind,
1150 label,
1151 items,
1152 else_items,
1153 } => {
1154 if block_is_parallel(kind) {
1155 state.push_parallel();
1156 let start_y = state.current_y;
1157 let mut max_end_y = start_y;
1158 let start_activation_count = *active_activation_count;
1159 for item in items {
1160 state.current_y = start_y;
1161 *active_activation_count = start_activation_count;
1162 collect_block_backgrounds(
1163 state,
1164 std::slice::from_ref(item),
1165 depth,
1166 active_activation_count,
1167 );
1168 if state.current_y > max_end_y {
1169 max_end_y = state.current_y;
1170 }
1171 }
1172 *active_activation_count = start_activation_count;
1173 let gap = if parallel_needs_gap(items) {
1174 PARALLEL_BLOCK_GAP
1175 } else {
1176 0.0
1177 };
1178 state.current_y = max_end_y + gap;
1179 state.pop_parallel();
1180 continue;
1181 }
1182
1183 if matches!(kind, BlockKind::Serial) {
1184 state.push_serial_first_row_pending();
1185 collect_block_backgrounds(state, items, depth, active_activation_count);
1186 if let Some(else_items) = else_items {
1187 collect_block_backgrounds(
1188 state,
1189 else_items,
1190 depth,
1191 active_activation_count,
1192 );
1193 }
1194 state.pop_serial_first_row_pending();
1195 continue;
1196 }
1197
1198 if !block_has_frame(kind) {
1199 collect_block_backgrounds(state, items, depth, active_activation_count);
1200 if let Some(else_items) = else_items {
1201 collect_block_backgrounds(
1202 state,
1203 else_items,
1204 depth,
1205 active_activation_count,
1206 );
1207 }
1208 continue;
1209 }
1210
1211 let start_y = state.current_y;
1212 let frame_shift = block_frame_shift(depth);
1213 let frame_start_y = start_y - frame_shift;
1214
1215 let (x1, x2) = calculate_block_bounds_with_label(
1217 items,
1218 else_items.as_deref(),
1219 label,
1220 kind.as_str(),
1221 depth,
1222 state,
1223 );
1224
1225 state.current_y += block_header_space(&state.config, depth);
1226 collect_block_backgrounds(state, items, depth + 1, active_activation_count);
1227
1228 let else_y = if else_items.is_some() {
1229 Some(state.current_y)
1230 } else {
1231 None
1232 };
1233
1234 if let Some(else_items) = else_items {
1235 state.push_else_return_pending();
1236 state.current_y += block_else_spacing(&state.config, depth);
1237 collect_block_backgrounds(
1238 state,
1239 else_items,
1240 depth + 1,
1241 active_activation_count,
1242 );
1243 state.pop_else_return_pending();
1244 }
1245
1246 let end_y = state.current_y - state.config.row_height
1247 + block_footer_padding(&state.config, depth);
1248 let frame_end_y = end_y - frame_shift;
1249 state.current_y = end_y + state.config.row_height * 1.0;
1250
1251 state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1253 state.add_block_label(
1255 x1,
1256 frame_start_y,
1257 frame_end_y,
1258 x2,
1259 kind.as_str(),
1260 label,
1261 else_y,
1262 );
1263 }
1264 _ => {}
1265 }
1266 }
1267}
1268
1269fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1271 let theme = &state.config.theme;
1272 for bg in &state.block_backgrounds {
1273 writeln!(
1274 svg,
1275 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1276 x = bg.x,
1277 y = bg.y,
1278 w = bg.width,
1279 h = bg.height,
1280 fill = theme.block_fill
1281 )
1282 .unwrap();
1283 }
1284}
1285
1286fn render_block_labels(svg: &mut String, state: &RenderState) {
1289 let theme = &state.config.theme;
1290
1291 for bl in &state.block_labels {
1292 let x1 = bl.x1;
1293 let x2 = bl.x2;
1294 let start_y = bl.start_y;
1295 let end_y = bl.end_y;
1296
1297 writeln!(
1299 svg,
1300 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1301 x = x1,
1302 y = start_y,
1303 w = x2 - x1,
1304 h = end_y - start_y
1305 )
1306 .unwrap();
1307
1308 let label_text = &bl.kind;
1310 let label_width = block_tab_width(label_text);
1311 let label_height = BLOCK_LABEL_HEIGHT;
1312 let label_text_offset = 16.0;
1313 let notch_size = 5.0;
1314 let label_font_size = state.config.font_size - 1.0;
1315 let label_padding_x = 6.0;
1316
1317 let pentagon_path = format!(
1319 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1320 x1 = x1,
1321 y1 = start_y,
1322 x2 = x1 + label_width,
1323 y2 = start_y + label_height - notch_size,
1324 x3 = x1 + label_width - notch_size,
1325 y3 = start_y + label_height
1326 );
1327
1328 writeln!(
1329 svg,
1330 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1331 path = pentagon_path,
1332 fill = theme.block_label_fill,
1333 stroke = theme.block_stroke
1334 )
1335 .unwrap();
1336
1337 writeln!(
1339 svg,
1340 r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1341 x = x1 + 5.0,
1342 y = start_y + label_text_offset,
1343 kind = label_text
1344 )
1345 .unwrap();
1346
1347 if !bl.label.is_empty() {
1349 let condition_text = format!("[{}]", bl.label);
1350 let text_x = x1 + label_width + 8.0;
1351 let text_y = start_y + label_text_offset;
1352 let base_width = (estimate_text_width(&condition_text, label_font_size)
1353 - TEXT_WIDTH_PADDING)
1354 .max(0.0);
1355 let bg_width = base_width + label_padding_x * 2.0;
1356
1357 writeln!(
1358 svg,
1359 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1360 x = text_x - label_padding_x,
1361 y = start_y,
1362 w = bg_width,
1363 h = label_height,
1364 fill = theme.block_label_fill,
1365 stroke = theme.block_stroke
1366 )
1367 .unwrap();
1368
1369 writeln!(
1370 svg,
1371 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1372 x = text_x,
1373 y = text_y,
1374 label = escape_xml(&condition_text)
1375 )
1376 .unwrap();
1377 }
1378
1379 if let Some(else_y) = bl.else_y {
1381 writeln!(
1382 svg,
1383 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1384 x1 = x1,
1385 y = else_y,
1386 x2 = x2,
1387 c = theme.block_stroke
1388 )
1389 .unwrap();
1390
1391 let else_text = "[else]";
1392 let else_text_x = x1 + 4.0;
1393 let else_base_width =
1394 (estimate_text_width(else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1395 let else_bg_width = else_base_width + label_padding_x * 2.0;
1396 let else_rect_y = else_y - label_height;
1397 let else_text_y = else_rect_y + label_text_offset;
1398
1399 writeln!(
1400 svg,
1401 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1402 x = else_text_x - label_padding_x,
1403 y = else_rect_y,
1404 w = else_bg_width,
1405 h = label_height,
1406 fill = theme.block_label_fill,
1407 stroke = theme.block_stroke
1408 )
1409 .unwrap();
1410
1411 writeln!(
1412 svg,
1413 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1414 x = else_text_x,
1415 y = else_text_y,
1416 label = else_text
1417 )
1418 .unwrap();
1419 }
1420 }
1421}
1422
1423pub fn render(diagram: &Diagram) -> String {
1425 render_with_config(diagram, Config::default())
1426}
1427
1428pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1430 let participants = diagram.participants();
1431 let has_title = diagram.title.is_some();
1432 let footer_style = diagram.options.footer;
1433 let mut state = RenderState::new(
1434 config,
1435 participants,
1436 &diagram.items,
1437 has_title,
1438 footer_style,
1439 );
1440 let mut svg = String::new();
1441
1442 let content_height = calculate_height(&diagram.items, &state.config, 0);
1444 let title_space = if has_title {
1445 state.config.title_height
1446 } else {
1447 0.0
1448 };
1449 let footer_space = match footer_style {
1450 FooterStyle::Box => state.config.header_height,
1451 FooterStyle::Bar | FooterStyle::None => 0.0,
1452 };
1453 let footer_label_extra = match footer_style {
1454 FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1455 FooterStyle::Bar | FooterStyle::None => 0.0,
1456 };
1457 let footer_margin = state.config.row_height; let base_total_height = state.config.padding * 2.0
1459 + title_space
1460 + state.config.header_height
1461 + content_height
1462 + footer_margin
1463 + footer_space;
1464 let total_height = base_total_height + footer_label_extra;
1465 let total_width = state.diagram_width();
1466
1467 writeln!(
1469 &mut svg,
1470 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1471 w = total_width,
1472 h = total_height
1473 )
1474 .unwrap();
1475
1476 let theme = &state.config.theme;
1478 let lifeline_dash = match theme.lifeline_style {
1479 LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1480 LifelineStyle::Solid => "",
1481 };
1482
1483 svg.push_str("<defs>\n");
1484 svg.push_str("<style>\n");
1485 writeln!(
1486 &mut svg,
1487 ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1488 fill = theme.participant_fill,
1489 stroke = theme.participant_stroke
1490 )
1491 .unwrap();
1492 writeln!(
1493 &mut svg,
1494 ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1495 f = theme.font_family,
1496 s = state.config.font_size,
1497 c = theme.participant_text
1498 )
1499 .unwrap();
1500 writeln!(
1501 &mut svg,
1502 ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1503 c = theme.lifeline_color,
1504 dash = lifeline_dash
1505 )
1506 .unwrap();
1507 writeln!(
1508 &mut svg,
1509 ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1510 c = theme.message_color
1511 )
1512 .unwrap();
1513 writeln!(
1514 &mut svg,
1515 ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1516 c = theme.message_color
1517 )
1518 .unwrap();
1519 writeln!(
1520 &mut svg,
1521 ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1522 f = theme.font_family,
1523 s = state.config.font_size,
1524 c = theme.message_text_color
1525 )
1526 .unwrap();
1527 writeln!(
1528 &mut svg,
1529 ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1530 fill = theme.note_fill,
1531 stroke = theme.note_stroke
1532 )
1533 .unwrap();
1534 writeln!(
1535 &mut svg,
1536 ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1537 f = theme.font_family,
1538 s = state.config.font_size - 1.0,
1539 c = theme.note_text_color
1540 )
1541 .unwrap();
1542 writeln!(
1543 &mut svg,
1544 ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1545 c = theme.block_stroke
1546 )
1547 .unwrap();
1548 writeln!(
1549 &mut svg,
1550 ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1551 f = theme.font_family,
1552 s = state.config.font_size - 1.0,
1553 c = theme.message_text_color
1554 )
1555 .unwrap();
1556 writeln!(
1557 &mut svg,
1558 ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1559 fill = theme.activation_fill,
1560 stroke = theme.activation_stroke
1561 )
1562 .unwrap();
1563 writeln!(
1564 &mut svg,
1565 ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1566 fill = theme.actor_fill,
1567 stroke = theme.actor_stroke
1568 )
1569 .unwrap();
1570 writeln!(
1571 &mut svg,
1572 ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1573 c = theme.actor_stroke
1574 )
1575 .unwrap();
1576 writeln!(
1577 &mut svg,
1578 ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1579 f = theme.font_family,
1580 s = state.config.font_size + 4.0,
1581 c = theme.message_text_color
1582 )
1583 .unwrap();
1584 writeln!(
1586 &mut svg,
1587 ".arrowhead {{ fill: {c}; stroke: none; }}",
1588 c = theme.message_color
1589 )
1590 .unwrap();
1591 writeln!(
1592 &mut svg,
1593 ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1594 c = theme.message_color
1595 )
1596 .unwrap();
1597 svg.push_str("</style>\n");
1598 svg.push_str("</defs>\n");
1599
1600 writeln!(
1602 &mut svg,
1603 r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1604 bg = theme.background
1605 )
1606 .unwrap();
1607
1608 if let Some(title) = &diagram.title {
1610 let title_y = state.config.padding + state.config.font_size + 7.36; writeln!(
1612 &mut svg,
1613 r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1614 x = total_width / 2.0,
1615 y = title_y,
1616 t = escape_xml(title)
1617 )
1618 .unwrap();
1619 }
1620
1621 let header_y = state.header_top();
1623 let footer_y = match footer_style {
1624 FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1625 FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1626 };
1627
1628 state.current_y = state.content_start();
1630 let mut active_activation_count = 0;
1631 collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count);
1632
1633 render_block_backgrounds(&mut svg, &state);
1635
1636 state.current_y = state.content_start();
1638
1639 let lifeline_start = header_y + state.config.header_height;
1641 let lifeline_end = footer_y;
1642
1643 for p in &state.participants {
1644 let x = state.get_x(p.id());
1645 writeln!(
1646 &mut svg,
1647 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1648 x = x,
1649 y1 = lifeline_start,
1650 y2 = lifeline_end
1651 )
1652 .unwrap();
1653 }
1654
1655 render_participant_headers(&mut svg, &state, header_y);
1657
1658 state.current_y = state.content_start();
1660 render_items(&mut svg, &mut state, &diagram.items, 0);
1661
1662 render_activations(&mut svg, &mut state, footer_y);
1664
1665 render_block_labels(&mut svg, &state);
1667
1668 match state.footer_style {
1670 FooterStyle::Box => {
1671 render_participant_headers(&mut svg, &state, footer_y);
1672 }
1673 FooterStyle::Bar => {
1674 let left = state.leftmost_x()
1676 - state.get_participant_width(
1677 state.participants.first().map(|p| p.id()).unwrap_or(""),
1678 ) / 2.0;
1679 let right = state.rightmost_x()
1680 + state
1681 .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1682 / 2.0;
1683 writeln!(
1684 &mut svg,
1685 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1686 x1 = left,
1687 y = footer_y,
1688 x2 = right,
1689 c = state.config.theme.lifeline_color
1690 )
1691 .unwrap();
1692 }
1693 FooterStyle::None => {
1694 }
1696 }
1697
1698 svg.push_str("</svg>\n");
1699 svg
1700}
1701
1702fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1703 fn inner(
1704 items: &[Item],
1705 config: &Config,
1706 depth: usize,
1707 else_pending: &mut Vec<bool>,
1708 serial_pending: &mut Vec<bool>,
1709 active_activation_count: &mut usize,
1710 parallel_depth: &mut usize,
1711 ) -> f64 {
1712 let mut height = 0.0;
1713 let line_height = config.font_size + 4.0;
1714 for item in items {
1715 match item {
1716 Item::Message {
1717 from,
1718 to,
1719 text,
1720 arrow,
1721 create,
1722 activate,
1723 deactivate,
1724 ..
1725 } => {
1726 if let Some(pending) = else_pending.last_mut() {
1727 if *pending && matches!(arrow.line, LineStyle::Dashed) {
1728 height += ELSE_RETURN_GAP;
1729 *pending = false;
1730 }
1731 }
1732 let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
1733 ACTIVATION_CHAIN_GAP
1734 } else {
1735 0.0
1736 };
1737 let is_self = from == to;
1738 let lines = text.split("\\n").count();
1739 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1740 if is_self {
1741 let mut spacing = self_message_spacing(config, lines);
1742 if !serial_pending.is_empty() {
1743 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
1744 }
1745 if *active_activation_count > 0 {
1746 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
1747 }
1748 height += spacing;
1749 } else {
1750 let spacing_line_height = message_spacing_line_height(config);
1751 height += config.row_height
1752 + (lines.saturating_sub(1)) as f64 * spacing_line_height
1753 + delay_offset;
1754 }
1755 if *create {
1756 height += CREATE_MESSAGE_SPACING;
1757 }
1758 if let Some(pending) = serial_pending.last_mut() {
1759 if *pending {
1760 height += serial_first_row_gap(*parallel_depth);
1761 *pending = false;
1762 }
1763 }
1764 if *activate && depth == 0 {
1765 height += ACTIVATION_START_GAP;
1766 }
1767 height += chain_gap;
1768 if *activate {
1769 *active_activation_count += 1;
1770 }
1771 if *deactivate && *active_activation_count > 0 {
1772 *active_activation_count -= 1;
1773 }
1774 }
1775 Item::Note { text, .. } => {
1776 let lines = text.split("\\n").count();
1777 let note_height =
1778 note_padding(config) * 2.0 + lines as f64 * note_line_height(config);
1779 height += note_height.max(config.row_height) + NOTE_MARGIN;
1780 }
1781 Item::State { text, .. } => {
1782 let lines = text.split("\\n").count();
1783 let box_height =
1784 config.note_padding * 2.0 + lines as f64 * state_line_height(config);
1785 height += box_height + item_pre_gap(config) + STATE_EXTRA_GAP;
1786 }
1787 Item::Ref { text, .. } => {
1788 let lines = text.split("\\n").count();
1789 let box_height =
1790 config.note_padding * 2.0 + lines as f64 * ref_line_height(config);
1791 height += box_height + item_pre_gap(config) + REF_EXTRA_GAP;
1792 }
1793 Item::Description { text } => {
1794 let lines = text.split("\\n").count();
1795 height += lines as f64 * line_height + 10.0;
1796 }
1797 Item::Block {
1798 kind,
1799 items,
1800 else_items,
1801 ..
1802 } => {
1803 if block_is_parallel(kind) {
1804 let mut max_branch_height = 0.0;
1805 let base_activation_count = *active_activation_count;
1806 *parallel_depth += 1;
1807 for item in items {
1808 *active_activation_count = base_activation_count;
1809 let branch_height = inner(
1810 std::slice::from_ref(item),
1811 config,
1812 depth,
1813 else_pending,
1814 serial_pending,
1815 active_activation_count,
1816 parallel_depth,
1817 );
1818 if branch_height > max_branch_height {
1819 max_branch_height = branch_height;
1820 }
1821 }
1822 *active_activation_count = base_activation_count;
1823 if *parallel_depth > 0 {
1824 *parallel_depth -= 1;
1825 }
1826 let gap = if parallel_needs_gap(items) {
1827 PARALLEL_BLOCK_GAP
1828 } else {
1829 0.0
1830 };
1831 height += max_branch_height + gap;
1832 continue;
1833 }
1834
1835 if matches!(kind, BlockKind::Serial) {
1836 serial_pending.push(true);
1837 height += inner(
1838 items,
1839 config,
1840 depth,
1841 else_pending,
1842 serial_pending,
1843 active_activation_count,
1844 parallel_depth,
1845 );
1846 if let Some(else_items) = else_items {
1847 height += inner(
1848 else_items,
1849 config,
1850 depth,
1851 else_pending,
1852 serial_pending,
1853 active_activation_count,
1854 parallel_depth,
1855 );
1856 }
1857 serial_pending.pop();
1858 } else if !block_has_frame(kind) {
1859 height += inner(
1860 items,
1861 config,
1862 depth,
1863 else_pending,
1864 serial_pending,
1865 active_activation_count,
1866 parallel_depth,
1867 );
1868 if let Some(else_items) = else_items {
1869 height += inner(
1870 else_items,
1871 config,
1872 depth,
1873 else_pending,
1874 serial_pending,
1875 active_activation_count,
1876 parallel_depth,
1877 );
1878 }
1879 } else {
1880 height += block_header_space(config, depth);
1881 height += inner(
1882 items,
1883 config,
1884 depth + 1,
1885 else_pending,
1886 serial_pending,
1887 active_activation_count,
1888 parallel_depth,
1889 );
1890 if let Some(else_items) = else_items {
1891 else_pending.push(true);
1892 height += block_else_spacing(config, depth);
1893 height += inner(
1894 else_items,
1895 config,
1896 depth + 1,
1897 else_pending,
1898 serial_pending,
1899 active_activation_count,
1900 parallel_depth,
1901 );
1902 else_pending.pop();
1903 }
1904 height += block_footer_padding(config, depth) + config.row_height * 1.0
1905 - config.row_height;
1906 }
1907 }
1908 Item::Activate { .. } => {
1909 *active_activation_count += 1;
1910 }
1911 Item::Deactivate { .. } => {
1912 if *active_activation_count > 0 {
1913 *active_activation_count -= 1;
1914 }
1915 }
1916 Item::Destroy { .. } => {
1917 height += DESTROY_SPACING;
1918 }
1919 Item::ParticipantDecl { .. } => {}
1920 Item::Autonumber { .. } => {}
1921 Item::DiagramOption { .. } => {} }
1923 }
1924 height
1925 }
1926
1927 let mut else_pending = Vec::new();
1928 let mut serial_pending = Vec::new();
1929 let mut active_activation_count = 0;
1930 let mut parallel_depth = 0;
1931 inner(
1932 items,
1933 config,
1934 depth,
1935 &mut else_pending,
1936 &mut serial_pending,
1937 &mut active_activation_count,
1938 &mut parallel_depth,
1939 )
1940}
1941
1942fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
1943 let shape = state.config.theme.participant_shape;
1944
1945 for p in &state.participants {
1946 let x = state.get_x(p.id());
1947 let p_width = state.get_participant_width(p.id());
1948 let box_x = x - p_width / 2.0;
1949
1950 match p.kind {
1951 ParticipantKind::Participant => {
1952 match shape {
1954 ParticipantShape::Rectangle => {
1955 writeln!(
1956 svg,
1957 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
1958 x = box_x,
1959 y = y,
1960 w = p_width,
1961 h = state.config.header_height
1962 )
1963 .unwrap();
1964 }
1965 ParticipantShape::RoundedRect => {
1966 writeln!(
1967 svg,
1968 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
1969 x = box_x,
1970 y = y,
1971 w = p_width,
1972 h = state.config.header_height
1973 )
1974 .unwrap();
1975 }
1976 ParticipantShape::Circle => {
1977 let rx = p_width / 2.0 - 5.0;
1979 let ry = state.config.header_height / 2.0 - 2.0;
1980 writeln!(
1981 svg,
1982 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
1983 cx = x,
1984 cy = y + state.config.header_height / 2.0,
1985 rx = rx,
1986 ry = ry
1987 )
1988 .unwrap();
1989 }
1990 }
1991 let lines: Vec<&str> = p.name.split("\\n").collect();
1993 if lines.len() == 1 {
1994 writeln!(
1995 svg,
1996 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
1997 x = x,
1998 y = y + state.config.header_height / 2.0 + 5.0,
1999 name = escape_xml(&p.name)
2000 )
2001 .unwrap();
2002 } else {
2003 let line_height = state.config.font_size + 2.0;
2004 let total_height = lines.len() as f64 * line_height;
2005 let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2006 + line_height * 0.8;
2007 write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2008 for (i, line) in lines.iter().enumerate() {
2009 let dy = if i == 0 { start_y } else { line_height };
2010 if i == 0 {
2011 writeln!(
2012 svg,
2013 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2014 x = x,
2015 y = dy,
2016 text = escape_xml(line)
2017 )
2018 .unwrap();
2019 } else {
2020 writeln!(
2021 svg,
2022 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2023 x = x,
2024 dy = dy,
2025 text = escape_xml(line)
2026 )
2027 .unwrap();
2028 }
2029 }
2030 writeln!(svg, "</text>").unwrap();
2031 }
2032 }
2033 ParticipantKind::Actor => {
2034 let head_r = 8.0;
2036 let body_len = 12.0;
2037 let arm_len = 10.0;
2038 let leg_len = 10.0;
2039 let figure_height = 38.0; let fig_top = y + 8.0;
2043 let fig_center_y = fig_top + head_r + body_len / 2.0;
2044 let arm_y = fig_center_y + 2.0;
2045
2046 writeln!(
2048 svg,
2049 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2050 x = x,
2051 cy = fig_center_y - body_len / 2.0 - head_r,
2052 r = head_r
2053 )
2054 .unwrap();
2055 writeln!(
2057 svg,
2058 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2059 x = x,
2060 y1 = fig_center_y - body_len / 2.0,
2061 y2 = fig_center_y + body_len / 2.0
2062 )
2063 .unwrap();
2064 writeln!(
2066 svg,
2067 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2068 x1 = x - arm_len,
2069 y = arm_y,
2070 x2 = x + arm_len
2071 )
2072 .unwrap();
2073 writeln!(
2075 svg,
2076 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2077 x = x,
2078 y1 = fig_center_y + body_len / 2.0,
2079 x2 = x - leg_len * 0.6,
2080 y2 = fig_center_y + body_len / 2.0 + leg_len
2081 )
2082 .unwrap();
2083 writeln!(
2085 svg,
2086 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2087 x = x,
2088 y1 = fig_center_y + body_len / 2.0,
2089 x2 = x + leg_len * 0.6,
2090 y2 = fig_center_y + body_len / 2.0 + leg_len
2091 )
2092 .unwrap();
2093 let name_lines: Vec<&str> = p.name.split("\\n").collect();
2095 let name_start_y = fig_top + figure_height + 5.0;
2096 if name_lines.len() == 1 {
2097 writeln!(
2098 svg,
2099 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2100 x = x,
2101 y = name_start_y + state.config.font_size,
2102 name = escape_xml(&p.name)
2103 )
2104 .unwrap();
2105 } else {
2106 let line_height = state.config.font_size + 2.0;
2108 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2109 for (i, line) in name_lines.iter().enumerate() {
2110 if i == 0 {
2111 writeln!(
2112 svg,
2113 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2114 x = x,
2115 y = name_start_y + state.config.font_size,
2116 text = escape_xml(line)
2117 )
2118 .unwrap();
2119 } else {
2120 writeln!(
2121 svg,
2122 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2123 x = x,
2124 dy = line_height,
2125 text = escape_xml(line)
2126 )
2127 .unwrap();
2128 }
2129 }
2130 writeln!(svg, "</text>").unwrap();
2131 }
2132 }
2133 }
2134 }
2135}
2136
2137fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2138 for item in items {
2139 match item {
2140 Item::Message {
2141 from,
2142 to,
2143 text,
2144 arrow,
2145 activate,
2146 deactivate,
2147 create,
2148 ..
2149 } => {
2150 render_message(
2151 svg,
2152 state,
2153 from,
2154 to,
2155 text,
2156 arrow,
2157 *activate,
2158 *deactivate,
2159 *create,
2160 depth,
2161 );
2162 }
2163 Item::Note {
2164 position,
2165 participants,
2166 text,
2167 } => {
2168 render_note(svg, state, position, participants, text);
2169 }
2170 Item::Block {
2171 kind,
2172 label,
2173 items,
2174 else_items,
2175 } => {
2176 render_block(svg, state, kind, label, items, else_items.as_deref(), depth);
2177 }
2178 Item::Activate { participant } => {
2179 let y = state.current_y;
2180 state
2181 .activations
2182 .entry(participant.clone())
2183 .or_default()
2184 .push((y, None));
2185 }
2186 Item::Deactivate { participant } => {
2187 if let Some(acts) = state.activations.get_mut(participant) {
2188 if let Some(act) = acts.last_mut() {
2189 if act.1.is_none() {
2190 act.1 = Some(state.current_y);
2191 }
2192 }
2193 }
2194 }
2195 Item::Destroy { participant } => {
2196 let destroy_y = state.current_y - state.config.row_height;
2199 state.destroyed.insert(participant.clone(), destroy_y);
2200 let x = state.get_x(participant);
2202 let y = destroy_y;
2203 let size = 15.0; let theme = &state.config.theme;
2205 writeln!(
2206 svg,
2207 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2208 x1 = x - size,
2209 y1 = y - size,
2210 x2 = x + size,
2211 y2 = y + size,
2212 stroke = theme.message_color
2213 )
2214 .unwrap();
2215 writeln!(
2216 svg,
2217 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2218 x1 = x + size,
2219 y1 = y - size,
2220 x2 = x - size,
2221 y2 = y + size,
2222 stroke = theme.message_color
2223 )
2224 .unwrap();
2225 state.current_y += DESTROY_SPACING;
2226 }
2227 Item::Autonumber { enabled, start } => {
2228 if *enabled {
2229 state.autonumber = Some(start.unwrap_or(1));
2230 } else {
2231 state.autonumber = None;
2232 }
2233 }
2234 Item::ParticipantDecl { .. } => {
2235 }
2237 Item::State { participants, text } => {
2238 render_state(svg, state, participants, text);
2239 }
2240 Item::Ref {
2241 participants,
2242 text,
2243 input_from,
2244 input_label,
2245 output_to,
2246 output_label,
2247 } => {
2248 render_ref(
2249 svg,
2250 state,
2251 participants,
2252 text,
2253 input_from.as_deref(),
2254 input_label.as_deref(),
2255 output_to.as_deref(),
2256 output_label.as_deref(),
2257 );
2258 }
2259 Item::DiagramOption { .. } => {
2260 }
2262 Item::Description { text } => {
2263 render_description(svg, state, text);
2264 }
2265 }
2266 }
2267}
2268
2269fn render_message(
2270 svg: &mut String,
2271 state: &mut RenderState,
2272 from: &str,
2273 to: &str,
2274 text: &str,
2275 arrow: &Arrow,
2276 activate: bool,
2277 deactivate: bool,
2278 create: bool,
2279 depth: usize,
2280) {
2281 let x1 = state.get_x(from);
2282 let x2 = state.get_x(to);
2283
2284 state.apply_else_return_gap(arrow);
2285 let active_count = state.active_activation_count();
2286 let chain_gap = if activate && depth == 0 && active_count == 1 {
2287 ACTIVATION_CHAIN_GAP
2288 } else {
2289 0.0
2290 };
2291
2292 let is_self = from == to;
2293 let line_class = match arrow.line {
2294 LineStyle::Solid => "message",
2295 LineStyle::Dashed => "message-dashed",
2296 };
2297 let is_filled = matches!(arrow.head, ArrowHead::Filled);
2298
2299 let num_prefix = state
2301 .next_number()
2302 .map(|n| format!("{}. ", n))
2303 .unwrap_or_default();
2304
2305 let display_text = format!("{}{}", num_prefix, text);
2307 let lines: Vec<&str> = display_text.split("\\n").collect();
2308 let line_height = state.config.font_size + 4.0;
2309 let extra_height = if !is_self && lines.len() > 1 {
2310 let spacing_line_height = message_spacing_line_height(&state.config);
2311 (lines.len() - 1) as f64 * spacing_line_height
2312 } else {
2313 0.0
2314 };
2315
2316 if !is_self && lines.len() > 1 {
2318 state.current_y += extra_height;
2319 }
2320
2321 let y = state.current_y;
2322 let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2323
2324 writeln!(svg, r#"<g class="message">"#).unwrap();
2326
2327 if is_self {
2328 let loop_width = 40.0;
2330 let text_block_height = lines.len() as f64 * line_height;
2331 let loop_height = (text_block_height + 10.0).max(25.0);
2332 let arrow_end_x = x1;
2333 let arrow_end_y = y + loop_height;
2334 let direction = std::f64::consts::PI;
2336 let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2337
2338 writeln!(
2339 svg,
2340 r#" <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2341 x1 = x1,
2342 y = y,
2343 x2 = x1 + loop_width,
2344 y2 = y + loop_height,
2345 arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2346 cls = line_class
2347 )
2348 .unwrap();
2349
2350 if is_filled {
2352 writeln!(
2353 svg,
2354 r#" <polygon points="{points}" class="arrowhead"/>"#,
2355 points = arrow_points
2356 )
2357 .unwrap();
2358 } else {
2359 writeln!(
2360 svg,
2361 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2362 points = arrow_points
2363 )
2364 .unwrap();
2365 }
2366
2367 let text_x = x1 + loop_width + 5.0;
2369 let max_width = lines
2370 .iter()
2371 .map(|line| estimate_message_width(line, state.config.font_size))
2372 .fold(0.0, f64::max);
2373 let top_line_y = y + 4.0 + 0.5 * line_height;
2374 let bottom_line_y = y + 4.0 + (lines.len() as f64 - 0.5) * line_height;
2375 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2376 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2377 let label_x_min = text_x;
2378 let label_x_max = text_x + max_width;
2379 let label_offset = if has_label_text {
2380 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2381 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2382 } else {
2383 0.0
2384 };
2385 for (i, line) in lines.iter().enumerate() {
2386 let line_y = y + 4.0 + (i as f64 + 0.5) * line_height + label_offset;
2387 writeln!(
2388 svg,
2389 r#" <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2390 x = text_x,
2391 y = line_y,
2392 t = escape_xml(line)
2393 )
2394 .unwrap();
2395 }
2396
2397 writeln!(svg, r#"</g>"#).unwrap();
2399
2400 let mut spacing = self_message_spacing(&state.config, lines.len());
2401 if state.in_serial_block() {
2402 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
2403 }
2404 if active_count > 0 {
2405 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
2406 }
2407 state.current_y += spacing;
2408 } else {
2409 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2411 let y2 = y + delay_offset;
2412
2413 let text_x = (x1 + x2) / 2.0;
2414 let text_y = (y + y2) / 2.0 - 8.0;
2415
2416 let direction = arrow_direction(x1, y, x2, y2);
2418 let arrow_points = arrowhead_points(x2, y2, direction);
2419
2420 let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2422 let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2423
2424 writeln!(
2426 svg,
2427 r#" <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2428 x1 = x1,
2429 y1 = y,
2430 lx2 = line_end_x,
2431 ly2 = line_end_y,
2432 cls = line_class
2433 )
2434 .unwrap();
2435
2436 if is_filled {
2438 writeln!(
2439 svg,
2440 r#" <polygon points="{points}" class="arrowhead"/>"#,
2441 points = arrow_points
2442 )
2443 .unwrap();
2444 } else {
2445 writeln!(
2446 svg,
2447 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2448 points = arrow_points
2449 )
2450 .unwrap();
2451 }
2452
2453 let max_width = lines
2455 .iter()
2456 .map(|line| estimate_message_width(line, state.config.font_size))
2457 .fold(0.0, f64::max);
2458 let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2459 let bottom_line_y = text_y;
2460 let label_offset = if has_label_text {
2461 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2462 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2463 let label_x_min = text_x - max_width / 2.0;
2464 let label_x_max = text_x + max_width / 2.0;
2465 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2466 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2467 } else {
2468 0.0
2469 };
2470 for (i, line) in lines.iter().enumerate() {
2471 let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2472 writeln!(
2473 svg,
2474 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2475 x = text_x,
2476 y = line_y,
2477 t = escape_xml(line)
2478 )
2479 .unwrap();
2480 }
2481
2482 writeln!(svg, r#"</g>"#).unwrap();
2484
2485 state.current_y += state.config.row_height + delay_offset;
2487 }
2488
2489 if create {
2490 state.current_y += CREATE_MESSAGE_SPACING;
2491 }
2492
2493 state.apply_serial_first_row_gap();
2494
2495 if activate && depth == 0 {
2496 state.current_y += ACTIVATION_START_GAP;
2497 }
2498 if chain_gap > 0.0 {
2499 state.current_y += chain_gap;
2500 }
2501
2502 if activate {
2504 state
2505 .activations
2506 .entry(to.to_string())
2507 .or_default()
2508 .push((y, None));
2509 }
2510 if deactivate {
2511 if let Some(acts) = state.activations.get_mut(from) {
2512 if let Some(act) = acts.last_mut() {
2513 if act.1.is_none() {
2514 act.1 = Some(y);
2515 }
2516 }
2517 }
2518 }
2519}
2520
2521fn render_note(
2522 svg: &mut String,
2523 state: &mut RenderState,
2524 position: &NotePosition,
2525 participants: &[String],
2526 text: &str,
2527) {
2528 let lines: Vec<&str> = text.split("\\n").collect();
2529 let line_height = note_line_height(&state.config);
2530 let padding = note_padding(&state.config);
2531 let note_height = padding * 2.0 + lines.len() as f64 * line_height;
2532
2533 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(10);
2536 let content_width = (max_line_len as f64 * 10.0 + padding * 2.0).max(80.0);
2537
2538 let (x, note_width, text_anchor) = match position {
2539 NotePosition::Left => {
2540 let px = state.get_x(&participants[0]);
2541 let p_width = state.get_participant_width(&participants[0]);
2542 let w = content_width.min(300.0);
2543 let x = (px - p_width / 2.0 - w - 10.0).max(state.config.padding);
2545 (x, w, "start")
2546 }
2547 NotePosition::Right => {
2548 let px = state.get_x(&participants[0]);
2549 let p_width = state.get_participant_width(&participants[0]);
2550 let w = content_width.min(300.0);
2551 (px + p_width / 2.0 + 5.0, w, "start")
2552 }
2553 NotePosition::Over => {
2554 if participants.len() == 1 {
2555 let px = state.get_x(&participants[0]);
2556 let w = content_width;
2558 let x = (px - w / 2.0).max(state.config.padding);
2560 (x, w, "middle")
2561 } else {
2562 let x1 = state.get_x(&participants[0]);
2564 let x2 = state.get_x(participants.last().unwrap());
2565 let p1_width = state.get_participant_width(&participants[0]);
2566 let p2_width = state.get_participant_width(participants.last().unwrap());
2567 let span_width = (x2 - x1).abs() + (p1_width + p2_width) / 2.0 * 0.8;
2568 let w = span_width.max(content_width);
2569 let center = (x1 + x2) / 2.0;
2570 let x = (center - w / 2.0).max(state.config.padding);
2572 (x, w, "middle")
2573 }
2574 }
2575 };
2576
2577 let y = state.current_y;
2578 let fold_size = 8.0; let note_path = format!(
2583 "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2584 x = x,
2585 y = y,
2586 x2 = x + note_width - fold_size,
2587 x3 = x + note_width,
2588 y2 = y + fold_size,
2589 y3 = y + note_height
2590 );
2591
2592 writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2593
2594 let theme = &state.config.theme;
2596 let fold_path = format!(
2598 "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2599 x1 = x + note_width - fold_size,
2600 y1 = y,
2601 x2 = x + note_width,
2602 y2 = y + fold_size
2603 );
2604
2605 writeln!(
2606 svg,
2607 r##"<path d="{path}" fill="#e0e0a0" stroke="{stroke}" stroke-width="1"/>"##,
2608 path = fold_path,
2609 stroke = theme.note_stroke
2610 )
2611 .unwrap();
2612
2613 let text_x = match text_anchor {
2615 "middle" => x + note_width / 2.0,
2616 "start" => x + padding,
2617 _ => x + note_width - padding,
2618 };
2619
2620 for (i, line) in lines.iter().enumerate() {
2621 let text_y = y + padding + (i as f64 + 0.8) * line_height;
2622 writeln!(
2623 svg,
2624 r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2625 x = text_x,
2626 y = text_y,
2627 anchor = if *position == NotePosition::Over {
2628 "middle"
2629 } else {
2630 "start"
2631 },
2632 t = escape_xml(line)
2633 )
2634 .unwrap();
2635 }
2636
2637 state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
2639}
2640
2641fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2643 let theme = &state.config.theme;
2644 let lines: Vec<&str> = text.split("\\n").collect();
2645 let line_height = state_line_height(&state.config);
2646 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2647
2648 let (x, box_width) = if participants.len() == 1 {
2650 let px = state.get_x(&participants[0]);
2651 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2652 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2653 (px - w / 2.0, w)
2654 } else {
2655 let x1 = state.get_x(&participants[0]);
2656 let x2 = state.get_x(participants.last().unwrap());
2657 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2658 let center = (x1 + x2) / 2.0;
2659 (center - span_width / 2.0, span_width)
2660 };
2661
2662 let shift = item_pre_shift(&state.config);
2663 let y = (state.current_y - shift).max(state.content_start());
2664
2665 writeln!(
2667 svg,
2668 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2669 x = x,
2670 y = y,
2671 w = box_width,
2672 h = box_height,
2673 fill = theme.state_fill,
2674 stroke = theme.state_stroke
2675 )
2676 .unwrap();
2677
2678 let text_x = x + box_width / 2.0;
2680 for (i, line) in lines.iter().enumerate() {
2681 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2682 writeln!(
2683 svg,
2684 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2685 x = text_x,
2686 y = text_y,
2687 fill = theme.state_text_color,
2688 font = theme.font_family,
2689 size = state.config.font_size,
2690 t = escape_xml(line)
2691 )
2692 .unwrap();
2693 }
2694
2695 state.current_y = y + box_height + state.config.row_height + REF_EXTRA_GAP;
2696}
2697
2698fn render_ref(
2700 svg: &mut String,
2701 state: &mut RenderState,
2702 participants: &[String],
2703 text: &str,
2704 input_from: Option<&str>,
2705 input_label: Option<&str>,
2706 output_to: Option<&str>,
2707 output_label: Option<&str>,
2708) {
2709 let theme = &state.config.theme;
2710 let lines: Vec<&str> = text.split("\\n").collect();
2711 let line_height = ref_line_height(&state.config);
2712 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2713 let notch_size = 10.0;
2714
2715 let (x, box_width) = if participants.len() == 1 {
2717 let px = state.get_x(&participants[0]);
2718 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2719 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2720 .max(100.0);
2721 (px - w / 2.0, w)
2722 } else {
2723 let x1 = state.get_x(&participants[0]);
2724 let x2 = state.get_x(participants.last().unwrap());
2725 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2726 let center = (x1 + x2) / 2.0;
2727 (center - span_width / 2.0, span_width)
2728 };
2729
2730 let shift = item_pre_shift(&state.config);
2731 let y = (state.current_y - shift).max(state.content_start());
2732 let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2733 let output_padding = state.config.note_padding + 3.0;
2734
2735 if let Some(from) = input_from {
2737 let from_x = state.get_x(from);
2738 let to_x = x; let arrow_y = y + input_offset;
2740
2741 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2743 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2744 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2745
2746 writeln!(
2748 svg,
2749 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2750 x1 = from_x,
2751 y = arrow_y,
2752 x2 = line_end_x
2753 )
2754 .unwrap();
2755
2756 writeln!(
2758 svg,
2759 r#"<polygon points="{points}" class="arrowhead"/>"#,
2760 points = arrow_points
2761 )
2762 .unwrap();
2763
2764 if let Some(label) = input_label {
2766 let text_x = (from_x + to_x) / 2.0;
2767 writeln!(
2768 svg,
2769 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2770 x = text_x,
2771 y = arrow_y - 8.0,
2772 t = escape_xml(label)
2773 )
2774 .unwrap();
2775 }
2776 }
2777
2778 let ref_path = format!(
2781 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2782 x1 = x + notch_size,
2783 y1 = y,
2784 x2 = x + box_width,
2785 y2 = y + box_height,
2786 x3 = x,
2787 y3 = y + box_height / 2.0
2788 );
2789
2790 writeln!(
2791 svg,
2792 r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2793 path = ref_path,
2794 fill = theme.ref_fill,
2795 stroke = theme.ref_stroke
2796 )
2797 .unwrap();
2798
2799 writeln!(
2801 svg,
2802 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
2803 x = x + notch_size + 4.0,
2804 y = y + state.config.font_size,
2805 fill = theme.ref_text_color,
2806 font = theme.font_family,
2807 size = state.config.font_size - 2.0
2808 )
2809 .unwrap();
2810
2811 let text_x = x + box_width / 2.0;
2813 for (i, line) in lines.iter().enumerate() {
2814 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2815 writeln!(
2816 svg,
2817 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2818 x = text_x,
2819 y = text_y,
2820 fill = theme.ref_text_color,
2821 font = theme.font_family,
2822 size = state.config.font_size,
2823 t = escape_xml(line)
2824 )
2825 .unwrap();
2826 }
2827
2828 if let Some(to) = output_to {
2830 let from_x = x + box_width; let to_x = state.get_x(to);
2832 let arrow_y = y + box_height - output_padding;
2833
2834 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2836 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2837 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2838
2839 writeln!(
2841 svg,
2842 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
2843 x1 = from_x,
2844 y = arrow_y,
2845 x2 = line_end_x
2846 )
2847 .unwrap();
2848
2849 writeln!(
2851 svg,
2852 r#"<polygon points="{points}" class="arrowhead"/>"#,
2853 points = arrow_points
2854 )
2855 .unwrap();
2856
2857 if let Some(label) = output_label {
2859 let text_x = (from_x + to_x) / 2.0;
2860 writeln!(
2861 svg,
2862 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2863 x = text_x,
2864 y = arrow_y - 8.0,
2865 t = escape_xml(label)
2866 )
2867 .unwrap();
2868 }
2869 }
2870
2871 state.current_y = y + box_height + state.config.row_height + STATE_EXTRA_GAP;
2872}
2873
2874fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
2876 let theme = &state.config.theme;
2877 let lines: Vec<&str> = text.split("\\n").collect();
2878 let line_height = state.config.font_size + 4.0;
2879
2880 let x = state.config.padding + 10.0;
2882 let y = state.current_y;
2883
2884 for (i, line) in lines.iter().enumerate() {
2885 let text_y = y + (i as f64 + 0.8) * line_height;
2886 writeln!(
2887 svg,
2888 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
2889 x = x,
2890 y = text_y,
2891 fill = theme.description_text_color,
2892 font = theme.font_family,
2893 size = state.config.font_size - 1.0,
2894 t = escape_xml(line)
2895 )
2896 .unwrap();
2897 }
2898
2899 state.current_y += lines.len() as f64 * line_height + 10.0;
2900}
2901
2902fn render_block(
2903 svg: &mut String,
2904 state: &mut RenderState,
2905 kind: &BlockKind,
2906 _label: &str,
2907 items: &[Item],
2908 else_items: Option<&[Item]>,
2909 depth: usize,
2910) {
2911 if block_is_parallel(kind) {
2912 state.push_parallel();
2913 let start_y = state.current_y;
2914 let mut max_end_y = start_y;
2915 for item in items {
2916 state.current_y = start_y;
2917 render_items(svg, state, std::slice::from_ref(item), depth);
2918 if state.current_y > max_end_y {
2919 max_end_y = state.current_y;
2920 }
2921 }
2922 let gap = if parallel_needs_gap(items) {
2923 PARALLEL_BLOCK_GAP
2924 } else {
2925 0.0
2926 };
2927 state.current_y = max_end_y + gap;
2928 state.pop_parallel();
2929 return;
2930 }
2931
2932 if matches!(kind, BlockKind::Serial) {
2933 state.push_serial_first_row_pending();
2934 render_items(svg, state, items, depth);
2935 if let Some(else_items) = else_items {
2936 render_items(svg, state, else_items, depth);
2937 }
2938 state.pop_serial_first_row_pending();
2939 return;
2940 }
2941
2942 if !block_has_frame(kind) {
2943 render_items(svg, state, items, depth);
2944 if let Some(else_items) = else_items {
2945 render_items(svg, state, else_items, depth);
2946 }
2947 return;
2948 }
2949
2950 state.current_y += block_header_space(&state.config, depth);
2955
2956 render_items(svg, state, items, depth + 1);
2958
2959 if let Some(else_items) = else_items {
2961 state.push_else_return_pending();
2962 state.current_y += block_else_spacing(&state.config, depth);
2963 render_items(svg, state, else_items, depth + 1);
2964 state.pop_else_return_pending();
2965 }
2966
2967 let end_y =
2968 state.current_y - state.config.row_height + block_footer_padding(&state.config, depth);
2969
2970 state.current_y = end_y + state.config.row_height * 1.0;
2972
2973 }
2976
2977fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
2978 for (participant, activations) in &state.activations {
2979 let x = state.get_x(participant);
2980 let box_x = x - state.config.activation_width / 2.0;
2981
2982 for (start_y, end_y) in activations {
2983 let end = end_y.unwrap_or(footer_y);
2985 let height = end - start_y;
2986
2987 if height > 0.0 {
2988 writeln!(
2989 svg,
2990 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
2991 x = box_x,
2992 y = start_y,
2993 w = state.config.activation_width,
2994 h = height
2995 )
2996 .unwrap();
2997 }
2998 }
2999 }
3000}
3001
3002fn escape_xml(s: &str) -> String {
3003 s.replace('&', "&")
3004 .replace('<', "<")
3005 .replace('>', ">")
3006 .replace('"', """)
3007 .replace('\'', "'")
3008}
3009
3010#[cfg(test)]
3011mod tests {
3012 use super::*;
3013 use crate::parser::parse;
3014
3015 #[test]
3016 fn test_render_simple() {
3017 let diagram = parse("Alice->Bob: Hello").unwrap();
3018 let svg = render(&diagram);
3019 assert!(svg.contains("<svg"));
3020 assert!(svg.contains("Alice"));
3021 assert!(svg.contains("Bob"));
3022 assert!(svg.contains("Hello"));
3023 }
3024
3025 #[test]
3026 fn test_render_with_note() {
3027 let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3028 let svg = render(&diagram);
3029 assert!(svg.contains("Thinking"));
3030 }
3031}