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: 58.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.82; 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 * 86.4).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 calculated_gap = gaps[i] - actor_gap_reduction;
629
630 let half_widths = (current_width + next_width) / 2.0;
633 let neither_is_actor = !current_is_actor && !next_is_actor;
634
635 let edge_padding = if calculated_gap > 500.0 {
636 10.0
638 } else if neither_is_actor && half_widths > 155.0 && calculated_gap > 130.0 {
639 85.0
641 } else if calculated_gap > 130.0 {
642 42.0
644 } else if calculated_gap > config.participant_gap {
645 18.0
647 } else {
648 let max_width = current_width.max(next_width);
650 let min_width_val = current_width.min(next_width);
651 let width_diff = max_width - min_width_val;
652
653 if max_width > 160.0 && min_width_val > 160.0 {
654 1.8
657 } else if max_width > 160.0 && min_width_val > 140.0 {
658 -7.0
661 } else if max_width > 160.0 && min_width_val < 110.0 {
662 11.3
665 } else if max_width > 160.0 && width_diff > 45.0 {
666 -6.0
669 } else if min_width_val < 110.0 {
670 12.0
673 } else {
674 10.0
676 }
677 };
678
679 let min_center_gap = (current_width + next_width) / 2.0 + edge_padding - actor_gap_reduction;
680 let actual_gap = calculated_gap.max(min_center_gap).max(60.0);
681 current_x += actual_gap;
682 }
683 }
684
685 let last_width = participants
686 .last()
687 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
688 .unwrap_or(min_width);
689 let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
690
691 Self {
692 config,
693 participants,
694 participant_x,
695 participant_widths,
696 current_y: 0.0,
697 activations: HashMap::new(),
698 autonumber: None,
699 destroyed: HashMap::new(),
700 has_title,
701 total_width,
702 block_backgrounds: Vec::new(),
703 block_labels: Vec::new(),
704 footer_style,
705 else_return_pending: Vec::new(),
706 serial_first_row_pending: Vec::new(),
707 parallel_depth: 0,
708 message_label_boxes: Vec::new(),
709 }
710 }
711
712 fn get_participant_width(&self, name: &str) -> f64 {
713 *self
714 .participant_widths
715 .get(name)
716 .unwrap_or(&self.config.participant_width)
717 }
718
719 fn get_x(&self, name: &str) -> f64 {
720 *self.participant_x.get(name).unwrap_or(&0.0)
721 }
722
723 fn push_else_return_pending(&mut self) {
724 self.else_return_pending.push(true);
725 }
726
727 fn pop_else_return_pending(&mut self) {
728 self.else_return_pending.pop();
729 }
730
731 fn apply_else_return_gap(&mut self, arrow: &Arrow) {
732 if let Some(pending) = self.else_return_pending.last_mut() {
733 if *pending && matches!(arrow.line, LineStyle::Dashed) {
734 self.current_y += ELSE_RETURN_GAP;
735 *pending = false;
736 }
737 }
738 }
739
740 fn push_serial_first_row_pending(&mut self) {
741 self.serial_first_row_pending.push(true);
742 }
743
744 fn pop_serial_first_row_pending(&mut self) {
745 self.serial_first_row_pending.pop();
746 }
747
748 fn in_serial_block(&self) -> bool {
749 !self.serial_first_row_pending.is_empty()
750 }
751
752 fn apply_serial_first_row_gap(&mut self) {
753 if let Some(pending) = self.serial_first_row_pending.last_mut() {
754 if *pending {
755 self.current_y += serial_first_row_gap(self.parallel_depth);
756 *pending = false;
757 }
758 }
759 }
760
761 fn reserve_message_label(
762 &mut self,
763 x_min: f64,
764 x_max: f64,
765 mut y_min: f64,
766 mut y_max: f64,
767 step: f64,
768 ) -> f64 {
769 let mut offset = 0.0;
770 let mut attempts = 0;
771 while self
772 .message_label_boxes
773 .iter()
774 .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
775 && attempts < 20
776 {
777 y_min += step;
778 y_max += step;
779 offset += step;
780 attempts += 1;
781 }
782 self.message_label_boxes.push(LabelBox {
783 x_min,
784 x_max,
785 y_min,
786 y_max,
787 });
788 offset
789 }
790
791 fn push_parallel(&mut self) {
792 self.parallel_depth += 1;
793 }
794
795 fn pop_parallel(&mut self) {
796 if self.parallel_depth > 0 {
797 self.parallel_depth -= 1;
798 }
799 }
800
801 fn active_activation_count(&self) -> usize {
802 self.activations
803 .values()
804 .map(|acts| acts.iter().filter(|(_, end)| end.is_none()).count())
805 .sum()
806 }
807
808 fn diagram_width(&self) -> f64 {
809 self.total_width
810 }
811
812 fn leftmost_x(&self) -> f64 {
814 self.participants
815 .first()
816 .map(|p| self.get_x(p.id()))
817 .unwrap_or(self.config.padding)
818 }
819
820 fn rightmost_x(&self) -> f64 {
822 self.participants
823 .last()
824 .map(|p| self.get_x(p.id()))
825 .unwrap_or(self.total_width - self.config.padding)
826 }
827
828 fn block_left(&self) -> f64 {
830 let leftmost_width = self
831 .participants
832 .first()
833 .map(|p| self.get_participant_width(p.id()))
834 .unwrap_or(self.config.participant_width);
835 self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
836 }
837
838 fn block_right(&self) -> f64 {
840 let rightmost_width = self
841 .participants
842 .last()
843 .map(|p| self.get_participant_width(p.id()))
844 .unwrap_or(self.config.participant_width);
845 self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
846 }
847
848 fn header_top(&self) -> f64 {
849 if self.has_title {
850 self.config.padding + self.config.title_height
851 } else {
852 self.config.padding
853 }
854 }
855
856 fn content_start(&self) -> f64 {
857 self.header_top() + self.config.header_height + self.config.row_height + 3.97
861 }
862
863 fn next_number(&mut self) -> Option<u32> {
864 self.autonumber.map(|n| {
865 self.autonumber = Some(n + 1);
866 n
867 })
868 }
869
870 fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
872 self.block_backgrounds.push(BlockBackground {
873 x,
874 y,
875 width,
876 height,
877 });
878 }
879
880 fn add_block_label(
882 &mut self,
883 x1: f64,
884 start_y: f64,
885 end_y: f64,
886 x2: f64,
887 kind: &str,
888 label: &str,
889 else_y: Option<f64>,
890 ) {
891 self.block_labels.push(BlockLabel {
892 x1,
893 start_y,
894 end_y,
895 x2,
896 kind: kind.to_string(),
897 label: label.to_string(),
898 else_y,
899 });
900 }
901}
902
903fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
905 let mut min_left: Option<f64> = None;
906 let mut max_right: Option<f64> = None;
907 let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
908 let mut includes_leftmost = false;
909
910 fn update_bounds(
911 participant: &str,
912 state: &RenderState,
913 min_left: &mut Option<f64>,
914 max_right: &mut Option<f64>,
915 includes_leftmost: &mut bool,
916 leftmost_id: &str,
917 ) {
918 let x = state.get_x(participant);
919 if x > 0.0 {
920 let width = state.get_participant_width(participant);
921 let left = x - width / 2.0;
922 let right = x + width / 2.0;
923 *min_left = Some(min_left.map_or(left, |m| m.min(left)));
924 *max_right = Some(max_right.map_or(right, |m| m.max(right)));
925 if participant == leftmost_id {
926 *includes_leftmost = true;
927 }
928 }
929 }
930
931 fn process_items(
932 items: &[Item],
933 state: &RenderState,
934 min_left: &mut Option<f64>,
935 max_right: &mut Option<f64>,
936 includes_leftmost: &mut bool,
937 leftmost_id: &str,
938 ) {
939 for item in items {
940 match item {
941 Item::Message { from, to, .. } => {
942 update_bounds(
943 from,
944 state,
945 min_left,
946 max_right,
947 includes_leftmost,
948 leftmost_id,
949 );
950 update_bounds(
951 to,
952 state,
953 min_left,
954 max_right,
955 includes_leftmost,
956 leftmost_id,
957 );
958 }
959 Item::Note { participants, .. } => {
960 for p in participants {
961 update_bounds(
962 p,
963 state,
964 min_left,
965 max_right,
966 includes_leftmost,
967 leftmost_id,
968 );
969 }
970 }
971 Item::Block {
972 items, else_items, ..
973 } => {
974 process_items(
975 items,
976 state,
977 min_left,
978 max_right,
979 includes_leftmost,
980 leftmost_id,
981 );
982 if let Some(else_items) = else_items {
983 process_items(
984 else_items,
985 state,
986 min_left,
987 max_right,
988 includes_leftmost,
989 leftmost_id,
990 );
991 }
992 }
993 Item::Activate { participant }
994 | Item::Deactivate { participant }
995 | Item::Destroy { participant } => {
996 update_bounds(
997 participant,
998 state,
999 min_left,
1000 max_right,
1001 includes_leftmost,
1002 leftmost_id,
1003 );
1004 }
1005 _ => {}
1006 }
1007 }
1008 }
1009
1010 process_items(
1011 items,
1012 state,
1013 &mut min_left,
1014 &mut max_right,
1015 &mut includes_leftmost,
1016 leftmost_id,
1017 );
1018
1019 match (min_left, max_right) {
1020 (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
1021 _ => None,
1022 }
1023}
1024
1025fn calculate_block_bounds_with_label(
1027 items: &[Item],
1028 else_items: Option<&[Item]>,
1029 label: &str,
1030 kind: &str,
1031 depth: usize,
1032 state: &RenderState,
1033) -> (f64, f64) {
1034 let mut all_items: Vec<&Item> = items.iter().collect();
1035 if let Some(else_items) = else_items {
1036 all_items.extend(else_items.iter());
1037 }
1038
1039 let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1041
1042 let (base_x1, base_x2, includes_leftmost) =
1043 if let Some((min_left, max_right, includes_leftmost)) =
1044 find_involved_participants(&items_slice, state)
1045 {
1046 let margin = state.config.block_margin;
1047 (min_left - margin, max_right + margin, includes_leftmost)
1048 } else {
1049 (state.block_left(), state.block_right(), false)
1051 };
1052
1053 let pentagon_width = block_tab_width(kind);
1056 let label_font_size = state.config.font_size - 1.0;
1057 let label_padding_x = 6.0;
1058 let condition_width = if label.is_empty() {
1059 0.0
1060 } else {
1061 let condition_text = format!("[{}]", label);
1062 let base_width =
1063 (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1064 base_width + label_padding_x * 2.0
1065 };
1066 let min_label_width = pentagon_width + 8.0 + condition_width + 20.0; let current_width = base_x2 - base_x1;
1070 let (mut x1, mut x2) = if current_width < min_label_width {
1071 (base_x1, base_x1 + min_label_width)
1073 } else {
1074 (base_x1, base_x2)
1075 };
1076
1077 let nested_padding = depth as f64 * 20.0;
1079 if nested_padding > 0.0 {
1080 let available = x2 - x1;
1081 let max_padding = ((available - min_label_width) / 2.0).max(0.0);
1082 let inset = nested_padding.min(max_padding);
1083 x1 += inset;
1084 x2 -= inset;
1085 }
1086
1087 if depth == 0 && includes_leftmost {
1088 x1 = x1.min(state.config.padding);
1089 }
1090
1091 (x1, x2)
1092}
1093
1094fn collect_block_backgrounds(
1096 state: &mut RenderState,
1097 items: &[Item],
1098 depth: usize,
1099 active_activation_count: &mut usize,
1100) {
1101 for item in items {
1102 match item {
1103 Item::Message {
1104 text,
1105 from,
1106 to,
1107 arrow,
1108 activate,
1109 deactivate,
1110 create,
1111 ..
1112 } => {
1113 state.apply_else_return_gap(arrow);
1114 let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
1115 ACTIVATION_CHAIN_GAP
1116 } else {
1117 0.0
1118 };
1119 let is_self = from == to;
1120 let lines: Vec<&str> = text.split("\\n").collect();
1121 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1122
1123 if is_self {
1124 let mut spacing = self_message_spacing(&state.config, lines.len());
1125 if state.in_serial_block() {
1126 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
1127 }
1128 if *active_activation_count > 0 {
1129 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
1130 }
1131 state.current_y += spacing;
1132 } else {
1133 let spacing_line_height = message_spacing_line_height(&state.config);
1134 let extra_height = if lines.len() > 1 {
1135 (lines.len() - 1) as f64 * spacing_line_height
1136 } else {
1137 0.0
1138 };
1139 if lines.len() > 1 {
1140 state.current_y += extra_height;
1141 }
1142 state.current_y += state.config.row_height + delay_offset;
1143 }
1144
1145 if *create {
1146 state.current_y += CREATE_MESSAGE_SPACING;
1147 }
1148
1149 state.apply_serial_first_row_gap();
1150
1151 if *activate && depth == 0 {
1152 state.current_y += ACTIVATION_START_GAP;
1153 }
1154 if chain_gap > 0.0 {
1155 state.current_y += chain_gap;
1156 }
1157 if *activate {
1158 *active_activation_count += 1;
1159 }
1160 if *deactivate && *active_activation_count > 0 {
1161 *active_activation_count -= 1;
1162 }
1163 }
1164 Item::Note { text, .. } => {
1165 let lines: Vec<&str> = text.split("\\n").collect();
1166 let line_height = note_line_height(&state.config);
1167 let note_height =
1168 note_padding(&state.config) * 2.0 + lines.len() as f64 * line_height;
1169 state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
1170 }
1171 Item::State { text, .. } => {
1172 let lines: Vec<&str> = text.split("\\n").collect();
1173 let line_height = state_line_height(&state.config);
1174 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
1175 state.current_y += box_height + item_pre_gap(&state.config) + STATE_EXTRA_GAP;
1176 }
1177 Item::Ref { text, .. } => {
1178 let lines: Vec<&str> = text.split("\\n").collect();
1179 let line_height = ref_line_height(&state.config);
1180 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
1181 state.current_y += box_height + item_pre_gap(&state.config) + REF_EXTRA_GAP;
1182 }
1183 Item::Description { text } => {
1184 let lines: Vec<&str> = text.split("\\n").collect();
1185 let line_height = state.config.font_size + 4.0;
1186 state.current_y += lines.len() as f64 * line_height + 10.0;
1187 }
1188 Item::Destroy { .. } => {
1189 state.current_y += DESTROY_SPACING;
1190 }
1191 Item::Activate { .. } => {
1192 *active_activation_count += 1;
1193 }
1194 Item::Deactivate { .. } => {
1195 if *active_activation_count > 0 {
1196 *active_activation_count -= 1;
1197 }
1198 }
1199 Item::Block {
1200 kind,
1201 label,
1202 items,
1203 else_items,
1204 } => {
1205 if block_is_parallel(kind) {
1206 state.push_parallel();
1207 let start_y = state.current_y;
1208 let mut max_end_y = start_y;
1209 let start_activation_count = *active_activation_count;
1210 for item in items {
1211 state.current_y = start_y;
1212 *active_activation_count = start_activation_count;
1213 collect_block_backgrounds(
1214 state,
1215 std::slice::from_ref(item),
1216 depth,
1217 active_activation_count,
1218 );
1219 if state.current_y > max_end_y {
1220 max_end_y = state.current_y;
1221 }
1222 }
1223 *active_activation_count = start_activation_count;
1224 let gap = if parallel_needs_gap(items) {
1225 PARALLEL_BLOCK_GAP
1226 } else {
1227 0.0
1228 };
1229 state.current_y = max_end_y + gap;
1230 state.pop_parallel();
1231 continue;
1232 }
1233
1234 if matches!(kind, BlockKind::Serial) {
1235 state.push_serial_first_row_pending();
1236 collect_block_backgrounds(state, items, depth, active_activation_count);
1237 if let Some(else_items) = else_items {
1238 collect_block_backgrounds(
1239 state,
1240 else_items,
1241 depth,
1242 active_activation_count,
1243 );
1244 }
1245 state.pop_serial_first_row_pending();
1246 continue;
1247 }
1248
1249 if !block_has_frame(kind) {
1250 collect_block_backgrounds(state, items, depth, active_activation_count);
1251 if let Some(else_items) = else_items {
1252 collect_block_backgrounds(
1253 state,
1254 else_items,
1255 depth,
1256 active_activation_count,
1257 );
1258 }
1259 continue;
1260 }
1261
1262 let start_y = state.current_y;
1263 let frame_shift = block_frame_shift(depth);
1264 let frame_start_y = start_y - frame_shift;
1265
1266 let (x1, x2) = calculate_block_bounds_with_label(
1268 items,
1269 else_items.as_deref(),
1270 label,
1271 kind.as_str(),
1272 depth,
1273 state,
1274 );
1275
1276 state.current_y += block_header_space(&state.config, depth);
1277 collect_block_backgrounds(state, items, depth + 1, active_activation_count);
1278
1279 let else_y = if else_items.is_some() {
1280 Some(state.current_y)
1281 } else {
1282 None
1283 };
1284
1285 if let Some(else_items) = else_items {
1286 state.push_else_return_pending();
1287 state.current_y += block_else_spacing(&state.config, depth);
1288 collect_block_backgrounds(
1289 state,
1290 else_items,
1291 depth + 1,
1292 active_activation_count,
1293 );
1294 state.pop_else_return_pending();
1295 }
1296
1297 let end_y = state.current_y - state.config.row_height
1298 + block_footer_padding(&state.config, depth);
1299 let frame_end_y = end_y - frame_shift;
1300 state.current_y = end_y + state.config.row_height * 1.0;
1301
1302 state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1304 state.add_block_label(
1306 x1,
1307 frame_start_y,
1308 frame_end_y,
1309 x2,
1310 kind.as_str(),
1311 label,
1312 else_y,
1313 );
1314 }
1315 _ => {}
1316 }
1317 }
1318}
1319
1320fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1322 let theme = &state.config.theme;
1323 for bg in &state.block_backgrounds {
1324 writeln!(
1325 svg,
1326 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1327 x = bg.x,
1328 y = bg.y,
1329 w = bg.width,
1330 h = bg.height,
1331 fill = theme.block_fill
1332 )
1333 .unwrap();
1334 }
1335}
1336
1337fn render_block_labels(svg: &mut String, state: &RenderState) {
1340 let theme = &state.config.theme;
1341
1342 for bl in &state.block_labels {
1343 let x1 = bl.x1;
1344 let x2 = bl.x2;
1345 let start_y = bl.start_y;
1346 let end_y = bl.end_y;
1347
1348 writeln!(
1350 svg,
1351 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1352 x = x1,
1353 y = start_y,
1354 w = x2 - x1,
1355 h = end_y - start_y
1356 )
1357 .unwrap();
1358
1359 let label_text = &bl.kind;
1361 let label_width = block_tab_width(label_text);
1362 let label_height = BLOCK_LABEL_HEIGHT;
1363 let label_text_offset = 16.0;
1364 let notch_size = 5.0;
1365 let label_font_size = state.config.font_size - 1.0;
1366 let label_padding_x = 6.0;
1367
1368 let pentagon_path = format!(
1370 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1371 x1 = x1,
1372 y1 = start_y,
1373 x2 = x1 + label_width,
1374 y2 = start_y + label_height - notch_size,
1375 x3 = x1 + label_width - notch_size,
1376 y3 = start_y + label_height
1377 );
1378
1379 writeln!(
1380 svg,
1381 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1382 path = pentagon_path,
1383 fill = theme.block_label_fill,
1384 stroke = theme.block_stroke
1385 )
1386 .unwrap();
1387
1388 writeln!(
1390 svg,
1391 r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1392 x = x1 + 5.0,
1393 y = start_y + label_text_offset,
1394 kind = label_text
1395 )
1396 .unwrap();
1397
1398 if !bl.label.is_empty() {
1400 let condition_text = format!("[{}]", bl.label);
1401 let text_x = x1 + label_width + 8.0;
1402 let text_y = start_y + label_text_offset;
1403 let base_width = (estimate_text_width(&condition_text, label_font_size)
1404 - TEXT_WIDTH_PADDING)
1405 .max(0.0);
1406 let bg_width = base_width + label_padding_x * 2.0;
1407
1408 writeln!(
1409 svg,
1410 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1411 x = text_x - label_padding_x,
1412 y = start_y,
1413 w = bg_width,
1414 h = label_height,
1415 fill = theme.block_label_fill,
1416 stroke = theme.block_stroke
1417 )
1418 .unwrap();
1419
1420 writeln!(
1421 svg,
1422 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1423 x = text_x,
1424 y = text_y,
1425 label = escape_xml(&condition_text)
1426 )
1427 .unwrap();
1428 }
1429
1430 if let Some(else_y) = bl.else_y {
1432 writeln!(
1433 svg,
1434 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1435 x1 = x1,
1436 y = else_y,
1437 x2 = x2,
1438 c = theme.block_stroke
1439 )
1440 .unwrap();
1441
1442 let else_text = "[else]";
1443 let else_text_x = x1 + 4.0;
1444 let else_base_width =
1445 (estimate_text_width(else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1446 let else_bg_width = else_base_width + label_padding_x * 2.0;
1447 let else_rect_y = else_y - label_height;
1448 let else_text_y = else_rect_y + label_text_offset;
1449
1450 writeln!(
1451 svg,
1452 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1453 x = else_text_x - label_padding_x,
1454 y = else_rect_y,
1455 w = else_bg_width,
1456 h = label_height,
1457 fill = theme.block_label_fill,
1458 stroke = theme.block_stroke
1459 )
1460 .unwrap();
1461
1462 writeln!(
1463 svg,
1464 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1465 x = else_text_x,
1466 y = else_text_y,
1467 label = else_text
1468 )
1469 .unwrap();
1470 }
1471 }
1472}
1473
1474pub fn render(diagram: &Diagram) -> String {
1476 render_with_config(diagram, Config::default())
1477}
1478
1479pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1481 let participants = diagram.participants();
1482 let has_title = diagram.title.is_some();
1483 let footer_style = diagram.options.footer;
1484 let mut state = RenderState::new(
1485 config,
1486 participants,
1487 &diagram.items,
1488 has_title,
1489 footer_style,
1490 );
1491 let mut svg = String::new();
1492
1493 let content_height = calculate_height(&diagram.items, &state.config, 0);
1495 let title_space = if has_title {
1496 state.config.title_height
1497 } else {
1498 0.0
1499 };
1500 let footer_space = match footer_style {
1501 FooterStyle::Box => state.config.header_height,
1502 FooterStyle::Bar | FooterStyle::None => 0.0,
1503 };
1504 let footer_label_extra = match footer_style {
1505 FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1506 FooterStyle::Bar | FooterStyle::None => 0.0,
1507 };
1508 let footer_margin = state.config.row_height; let base_total_height = state.config.padding * 2.0
1510 + title_space
1511 + state.config.header_height
1512 + content_height
1513 + footer_margin
1514 + footer_space;
1515 let total_height = base_total_height + footer_label_extra;
1516 let total_width = state.diagram_width();
1517
1518 writeln!(
1520 &mut svg,
1521 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1522 w = total_width,
1523 h = total_height
1524 )
1525 .unwrap();
1526
1527 let theme = &state.config.theme;
1529 let lifeline_dash = match theme.lifeline_style {
1530 LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1531 LifelineStyle::Solid => "",
1532 };
1533
1534 svg.push_str("<defs>\n");
1535 svg.push_str("<style>\n");
1536 writeln!(
1537 &mut svg,
1538 ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1539 fill = theme.participant_fill,
1540 stroke = theme.participant_stroke
1541 )
1542 .unwrap();
1543 writeln!(
1544 &mut svg,
1545 ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1546 f = theme.font_family,
1547 s = state.config.font_size,
1548 c = theme.participant_text
1549 )
1550 .unwrap();
1551 writeln!(
1552 &mut svg,
1553 ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1554 c = theme.lifeline_color,
1555 dash = lifeline_dash
1556 )
1557 .unwrap();
1558 writeln!(
1559 &mut svg,
1560 ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1561 c = theme.message_color
1562 )
1563 .unwrap();
1564 writeln!(
1565 &mut svg,
1566 ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1567 c = theme.message_color
1568 )
1569 .unwrap();
1570 writeln!(
1571 &mut svg,
1572 ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1573 f = theme.font_family,
1574 s = state.config.font_size,
1575 c = theme.message_text_color
1576 )
1577 .unwrap();
1578 writeln!(
1579 &mut svg,
1580 ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1581 fill = theme.note_fill,
1582 stroke = theme.note_stroke
1583 )
1584 .unwrap();
1585 writeln!(
1586 &mut svg,
1587 ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1588 f = theme.font_family,
1589 s = state.config.font_size - 1.0,
1590 c = theme.note_text_color
1591 )
1592 .unwrap();
1593 writeln!(
1594 &mut svg,
1595 ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1596 c = theme.block_stroke
1597 )
1598 .unwrap();
1599 writeln!(
1600 &mut svg,
1601 ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1602 f = theme.font_family,
1603 s = state.config.font_size - 1.0,
1604 c = theme.message_text_color
1605 )
1606 .unwrap();
1607 writeln!(
1608 &mut svg,
1609 ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1610 fill = theme.activation_fill,
1611 stroke = theme.activation_stroke
1612 )
1613 .unwrap();
1614 writeln!(
1615 &mut svg,
1616 ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1617 fill = theme.actor_fill,
1618 stroke = theme.actor_stroke
1619 )
1620 .unwrap();
1621 writeln!(
1622 &mut svg,
1623 ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1624 c = theme.actor_stroke
1625 )
1626 .unwrap();
1627 writeln!(
1628 &mut svg,
1629 ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1630 f = theme.font_family,
1631 s = state.config.font_size + 4.0,
1632 c = theme.message_text_color
1633 )
1634 .unwrap();
1635 writeln!(
1637 &mut svg,
1638 ".arrowhead {{ fill: {c}; stroke: none; }}",
1639 c = theme.message_color
1640 )
1641 .unwrap();
1642 writeln!(
1643 &mut svg,
1644 ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1645 c = theme.message_color
1646 )
1647 .unwrap();
1648 svg.push_str("</style>\n");
1649 svg.push_str("</defs>\n");
1650
1651 writeln!(
1653 &mut svg,
1654 r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1655 bg = theme.background
1656 )
1657 .unwrap();
1658
1659 if let Some(title) = &diagram.title {
1661 let title_y = state.config.padding + state.config.font_size + 7.36; writeln!(
1663 &mut svg,
1664 r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1665 x = total_width / 2.0,
1666 y = title_y,
1667 t = escape_xml(title)
1668 )
1669 .unwrap();
1670 }
1671
1672 let header_y = state.header_top();
1674 let footer_y = match footer_style {
1675 FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1676 FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1677 };
1678
1679 state.current_y = state.content_start();
1681 let mut active_activation_count = 0;
1682 collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count);
1683
1684 render_block_backgrounds(&mut svg, &state);
1686
1687 state.current_y = state.content_start();
1689
1690 let lifeline_start = header_y + state.config.header_height;
1692 let lifeline_end = footer_y;
1693
1694 for p in &state.participants {
1695 let x = state.get_x(p.id());
1696 writeln!(
1697 &mut svg,
1698 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1699 x = x,
1700 y1 = lifeline_start,
1701 y2 = lifeline_end
1702 )
1703 .unwrap();
1704 }
1705
1706 render_participant_headers(&mut svg, &state, header_y);
1708
1709 state.current_y = state.content_start();
1711 render_items(&mut svg, &mut state, &diagram.items, 0);
1712
1713 render_activations(&mut svg, &mut state, footer_y);
1715
1716 render_block_labels(&mut svg, &state);
1718
1719 match state.footer_style {
1721 FooterStyle::Box => {
1722 render_participant_headers(&mut svg, &state, footer_y);
1723 }
1724 FooterStyle::Bar => {
1725 let left = state.leftmost_x()
1727 - state.get_participant_width(
1728 state.participants.first().map(|p| p.id()).unwrap_or(""),
1729 ) / 2.0;
1730 let right = state.rightmost_x()
1731 + state
1732 .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1733 / 2.0;
1734 writeln!(
1735 &mut svg,
1736 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1737 x1 = left,
1738 y = footer_y,
1739 x2 = right,
1740 c = state.config.theme.lifeline_color
1741 )
1742 .unwrap();
1743 }
1744 FooterStyle::None => {
1745 }
1747 }
1748
1749 svg.push_str("</svg>\n");
1750 svg
1751}
1752
1753fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1754 fn inner(
1755 items: &[Item],
1756 config: &Config,
1757 depth: usize,
1758 else_pending: &mut Vec<bool>,
1759 serial_pending: &mut Vec<bool>,
1760 active_activation_count: &mut usize,
1761 parallel_depth: &mut usize,
1762 ) -> f64 {
1763 let mut height = 0.0;
1764 let line_height = config.font_size + 4.0;
1765 for item in items {
1766 match item {
1767 Item::Message {
1768 from,
1769 to,
1770 text,
1771 arrow,
1772 create,
1773 activate,
1774 deactivate,
1775 ..
1776 } => {
1777 if let Some(pending) = else_pending.last_mut() {
1778 if *pending && matches!(arrow.line, LineStyle::Dashed) {
1779 height += ELSE_RETURN_GAP;
1780 *pending = false;
1781 }
1782 }
1783 let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
1784 ACTIVATION_CHAIN_GAP
1785 } else {
1786 0.0
1787 };
1788 let is_self = from == to;
1789 let lines = text.split("\\n").count();
1790 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1791 if is_self {
1792 let mut spacing = self_message_spacing(config, lines);
1793 if !serial_pending.is_empty() {
1794 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
1795 }
1796 if *active_activation_count > 0 {
1797 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
1798 }
1799 height += spacing;
1800 } else {
1801 let spacing_line_height = message_spacing_line_height(config);
1802 height += config.row_height
1803 + (lines.saturating_sub(1)) as f64 * spacing_line_height
1804 + delay_offset;
1805 }
1806 if *create {
1807 height += CREATE_MESSAGE_SPACING;
1808 }
1809 if let Some(pending) = serial_pending.last_mut() {
1810 if *pending {
1811 height += serial_first_row_gap(*parallel_depth);
1812 *pending = false;
1813 }
1814 }
1815 if *activate && depth == 0 {
1816 height += ACTIVATION_START_GAP;
1817 }
1818 height += chain_gap;
1819 if *activate {
1820 *active_activation_count += 1;
1821 }
1822 if *deactivate && *active_activation_count > 0 {
1823 *active_activation_count -= 1;
1824 }
1825 }
1826 Item::Note { text, .. } => {
1827 let lines = text.split("\\n").count();
1828 let note_height =
1829 note_padding(config) * 2.0 + lines as f64 * note_line_height(config);
1830 height += note_height.max(config.row_height) + NOTE_MARGIN;
1831 }
1832 Item::State { text, .. } => {
1833 let lines = text.split("\\n").count();
1834 let box_height =
1835 config.note_padding * 2.0 + lines as f64 * state_line_height(config);
1836 height += box_height + item_pre_gap(config) + STATE_EXTRA_GAP;
1837 }
1838 Item::Ref { text, .. } => {
1839 let lines = text.split("\\n").count();
1840 let box_height =
1841 config.note_padding * 2.0 + lines as f64 * ref_line_height(config);
1842 height += box_height + item_pre_gap(config) + REF_EXTRA_GAP;
1843 }
1844 Item::Description { text } => {
1845 let lines = text.split("\\n").count();
1846 height += lines as f64 * line_height + 10.0;
1847 }
1848 Item::Block {
1849 kind,
1850 items,
1851 else_items,
1852 ..
1853 } => {
1854 if block_is_parallel(kind) {
1855 let mut max_branch_height = 0.0;
1856 let base_activation_count = *active_activation_count;
1857 *parallel_depth += 1;
1858 for item in items {
1859 *active_activation_count = base_activation_count;
1860 let branch_height = inner(
1861 std::slice::from_ref(item),
1862 config,
1863 depth,
1864 else_pending,
1865 serial_pending,
1866 active_activation_count,
1867 parallel_depth,
1868 );
1869 if branch_height > max_branch_height {
1870 max_branch_height = branch_height;
1871 }
1872 }
1873 *active_activation_count = base_activation_count;
1874 if *parallel_depth > 0 {
1875 *parallel_depth -= 1;
1876 }
1877 let gap = if parallel_needs_gap(items) {
1878 PARALLEL_BLOCK_GAP
1879 } else {
1880 0.0
1881 };
1882 height += max_branch_height + gap;
1883 continue;
1884 }
1885
1886 if matches!(kind, BlockKind::Serial) {
1887 serial_pending.push(true);
1888 height += inner(
1889 items,
1890 config,
1891 depth,
1892 else_pending,
1893 serial_pending,
1894 active_activation_count,
1895 parallel_depth,
1896 );
1897 if let Some(else_items) = else_items {
1898 height += inner(
1899 else_items,
1900 config,
1901 depth,
1902 else_pending,
1903 serial_pending,
1904 active_activation_count,
1905 parallel_depth,
1906 );
1907 }
1908 serial_pending.pop();
1909 } else if !block_has_frame(kind) {
1910 height += inner(
1911 items,
1912 config,
1913 depth,
1914 else_pending,
1915 serial_pending,
1916 active_activation_count,
1917 parallel_depth,
1918 );
1919 if let Some(else_items) = else_items {
1920 height += inner(
1921 else_items,
1922 config,
1923 depth,
1924 else_pending,
1925 serial_pending,
1926 active_activation_count,
1927 parallel_depth,
1928 );
1929 }
1930 } else {
1931 height += block_header_space(config, depth);
1932 height += inner(
1933 items,
1934 config,
1935 depth + 1,
1936 else_pending,
1937 serial_pending,
1938 active_activation_count,
1939 parallel_depth,
1940 );
1941 if let Some(else_items) = else_items {
1942 else_pending.push(true);
1943 height += block_else_spacing(config, depth);
1944 height += inner(
1945 else_items,
1946 config,
1947 depth + 1,
1948 else_pending,
1949 serial_pending,
1950 active_activation_count,
1951 parallel_depth,
1952 );
1953 else_pending.pop();
1954 }
1955 height += block_footer_padding(config, depth) + config.row_height * 1.0
1956 - config.row_height;
1957 }
1958 }
1959 Item::Activate { .. } => {
1960 *active_activation_count += 1;
1961 }
1962 Item::Deactivate { .. } => {
1963 if *active_activation_count > 0 {
1964 *active_activation_count -= 1;
1965 }
1966 }
1967 Item::Destroy { .. } => {
1968 height += DESTROY_SPACING;
1969 }
1970 Item::ParticipantDecl { .. } => {}
1971 Item::Autonumber { .. } => {}
1972 Item::DiagramOption { .. } => {} }
1974 }
1975 height
1976 }
1977
1978 let mut else_pending = Vec::new();
1979 let mut serial_pending = Vec::new();
1980 let mut active_activation_count = 0;
1981 let mut parallel_depth = 0;
1982 inner(
1983 items,
1984 config,
1985 depth,
1986 &mut else_pending,
1987 &mut serial_pending,
1988 &mut active_activation_count,
1989 &mut parallel_depth,
1990 )
1991}
1992
1993fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
1994 let shape = state.config.theme.participant_shape;
1995
1996 for p in &state.participants {
1997 let x = state.get_x(p.id());
1998 let p_width = state.get_participant_width(p.id());
1999 let box_x = x - p_width / 2.0;
2000
2001 match p.kind {
2002 ParticipantKind::Participant => {
2003 match shape {
2005 ParticipantShape::Rectangle => {
2006 writeln!(
2007 svg,
2008 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2009 x = box_x,
2010 y = y,
2011 w = p_width,
2012 h = state.config.header_height
2013 )
2014 .unwrap();
2015 }
2016 ParticipantShape::RoundedRect => {
2017 writeln!(
2018 svg,
2019 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2020 x = box_x,
2021 y = y,
2022 w = p_width,
2023 h = state.config.header_height
2024 )
2025 .unwrap();
2026 }
2027 ParticipantShape::Circle => {
2028 let rx = p_width / 2.0 - 5.0;
2030 let ry = state.config.header_height / 2.0 - 2.0;
2031 writeln!(
2032 svg,
2033 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2034 cx = x,
2035 cy = y + state.config.header_height / 2.0,
2036 rx = rx,
2037 ry = ry
2038 )
2039 .unwrap();
2040 }
2041 }
2042 let lines: Vec<&str> = p.name.split("\\n").collect();
2044 if lines.len() == 1 {
2045 writeln!(
2046 svg,
2047 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2048 x = x,
2049 y = y + state.config.header_height / 2.0 + 5.0,
2050 name = escape_xml(&p.name)
2051 )
2052 .unwrap();
2053 } else {
2054 let line_height = state.config.font_size + 2.0;
2055 let total_height = lines.len() as f64 * line_height;
2056 let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2057 + line_height * 0.8;
2058 write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2059 for (i, line) in lines.iter().enumerate() {
2060 let dy = if i == 0 { start_y } else { line_height };
2061 if i == 0 {
2062 writeln!(
2063 svg,
2064 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2065 x = x,
2066 y = dy,
2067 text = escape_xml(line)
2068 )
2069 .unwrap();
2070 } else {
2071 writeln!(
2072 svg,
2073 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2074 x = x,
2075 dy = dy,
2076 text = escape_xml(line)
2077 )
2078 .unwrap();
2079 }
2080 }
2081 writeln!(svg, "</text>").unwrap();
2082 }
2083 }
2084 ParticipantKind::Actor => {
2085 let head_r = 8.0;
2087 let body_len = 12.0;
2088 let arm_len = 10.0;
2089 let leg_len = 10.0;
2090 let figure_height = 38.0; let fig_top = y + 8.0;
2094 let fig_center_y = fig_top + head_r + body_len / 2.0;
2095 let arm_y = fig_center_y + 2.0;
2096
2097 writeln!(
2099 svg,
2100 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2101 x = x,
2102 cy = fig_center_y - body_len / 2.0 - head_r,
2103 r = head_r
2104 )
2105 .unwrap();
2106 writeln!(
2108 svg,
2109 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2110 x = x,
2111 y1 = fig_center_y - body_len / 2.0,
2112 y2 = fig_center_y + body_len / 2.0
2113 )
2114 .unwrap();
2115 writeln!(
2117 svg,
2118 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2119 x1 = x - arm_len,
2120 y = arm_y,
2121 x2 = x + arm_len
2122 )
2123 .unwrap();
2124 writeln!(
2126 svg,
2127 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2128 x = x,
2129 y1 = fig_center_y + body_len / 2.0,
2130 x2 = x - leg_len * 0.6,
2131 y2 = fig_center_y + body_len / 2.0 + leg_len
2132 )
2133 .unwrap();
2134 writeln!(
2136 svg,
2137 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2138 x = x,
2139 y1 = fig_center_y + body_len / 2.0,
2140 x2 = x + leg_len * 0.6,
2141 y2 = fig_center_y + body_len / 2.0 + leg_len
2142 )
2143 .unwrap();
2144 let name_lines: Vec<&str> = p.name.split("\\n").collect();
2146 let name_start_y = fig_top + figure_height + 5.0;
2147 if name_lines.len() == 1 {
2148 writeln!(
2149 svg,
2150 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2151 x = x,
2152 y = name_start_y + state.config.font_size,
2153 name = escape_xml(&p.name)
2154 )
2155 .unwrap();
2156 } else {
2157 let line_height = state.config.font_size + 2.0;
2159 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2160 for (i, line) in name_lines.iter().enumerate() {
2161 if i == 0 {
2162 writeln!(
2163 svg,
2164 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2165 x = x,
2166 y = name_start_y + state.config.font_size,
2167 text = escape_xml(line)
2168 )
2169 .unwrap();
2170 } else {
2171 writeln!(
2172 svg,
2173 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2174 x = x,
2175 dy = line_height,
2176 text = escape_xml(line)
2177 )
2178 .unwrap();
2179 }
2180 }
2181 writeln!(svg, "</text>").unwrap();
2182 }
2183 }
2184 }
2185 }
2186}
2187
2188fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2189 for item in items {
2190 match item {
2191 Item::Message {
2192 from,
2193 to,
2194 text,
2195 arrow,
2196 activate,
2197 deactivate,
2198 create,
2199 ..
2200 } => {
2201 render_message(
2202 svg,
2203 state,
2204 from,
2205 to,
2206 text,
2207 arrow,
2208 *activate,
2209 *deactivate,
2210 *create,
2211 depth,
2212 );
2213 }
2214 Item::Note {
2215 position,
2216 participants,
2217 text,
2218 } => {
2219 render_note(svg, state, position, participants, text);
2220 }
2221 Item::Block {
2222 kind,
2223 label,
2224 items,
2225 else_items,
2226 } => {
2227 render_block(svg, state, kind, label, items, else_items.as_deref(), depth);
2228 }
2229 Item::Activate { participant } => {
2230 let y = state.current_y;
2231 state
2232 .activations
2233 .entry(participant.clone())
2234 .or_default()
2235 .push((y, None));
2236 }
2237 Item::Deactivate { participant } => {
2238 if let Some(acts) = state.activations.get_mut(participant) {
2239 if let Some(act) = acts.last_mut() {
2240 if act.1.is_none() {
2241 act.1 = Some(state.current_y);
2242 }
2243 }
2244 }
2245 }
2246 Item::Destroy { participant } => {
2247 let destroy_y = state.current_y - state.config.row_height;
2250 state.destroyed.insert(participant.clone(), destroy_y);
2251 let x = state.get_x(participant);
2253 let y = destroy_y;
2254 let size = 15.0; let theme = &state.config.theme;
2256 writeln!(
2257 svg,
2258 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2259 x1 = x - size,
2260 y1 = y - size,
2261 x2 = x + size,
2262 y2 = y + size,
2263 stroke = theme.message_color
2264 )
2265 .unwrap();
2266 writeln!(
2267 svg,
2268 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2269 x1 = x + size,
2270 y1 = y - size,
2271 x2 = x - size,
2272 y2 = y + size,
2273 stroke = theme.message_color
2274 )
2275 .unwrap();
2276 state.current_y += DESTROY_SPACING;
2277 }
2278 Item::Autonumber { enabled, start } => {
2279 if *enabled {
2280 state.autonumber = Some(start.unwrap_or(1));
2281 } else {
2282 state.autonumber = None;
2283 }
2284 }
2285 Item::ParticipantDecl { .. } => {
2286 }
2288 Item::State { participants, text } => {
2289 render_state(svg, state, participants, text);
2290 }
2291 Item::Ref {
2292 participants,
2293 text,
2294 input_from,
2295 input_label,
2296 output_to,
2297 output_label,
2298 } => {
2299 render_ref(
2300 svg,
2301 state,
2302 participants,
2303 text,
2304 input_from.as_deref(),
2305 input_label.as_deref(),
2306 output_to.as_deref(),
2307 output_label.as_deref(),
2308 );
2309 }
2310 Item::DiagramOption { .. } => {
2311 }
2313 Item::Description { text } => {
2314 render_description(svg, state, text);
2315 }
2316 }
2317 }
2318}
2319
2320fn render_message(
2321 svg: &mut String,
2322 state: &mut RenderState,
2323 from: &str,
2324 to: &str,
2325 text: &str,
2326 arrow: &Arrow,
2327 activate: bool,
2328 deactivate: bool,
2329 create: bool,
2330 depth: usize,
2331) {
2332 let x1 = state.get_x(from);
2333 let x2 = state.get_x(to);
2334
2335 state.apply_else_return_gap(arrow);
2336 let active_count = state.active_activation_count();
2337 let chain_gap = if activate && depth == 0 && active_count == 1 {
2338 ACTIVATION_CHAIN_GAP
2339 } else {
2340 0.0
2341 };
2342
2343 let is_self = from == to;
2344 let line_class = match arrow.line {
2345 LineStyle::Solid => "message",
2346 LineStyle::Dashed => "message-dashed",
2347 };
2348 let is_filled = matches!(arrow.head, ArrowHead::Filled);
2349
2350 let num_prefix = state
2352 .next_number()
2353 .map(|n| format!("{}. ", n))
2354 .unwrap_or_default();
2355
2356 let display_text = format!("{}{}", num_prefix, text);
2358 let lines: Vec<&str> = display_text.split("\\n").collect();
2359 let line_height = state.config.font_size + 4.0;
2360 let extra_height = if !is_self && lines.len() > 1 {
2361 let spacing_line_height = message_spacing_line_height(&state.config);
2362 (lines.len() - 1) as f64 * spacing_line_height
2363 } else {
2364 0.0
2365 };
2366
2367 if !is_self && lines.len() > 1 {
2369 state.current_y += extra_height;
2370 }
2371
2372 let y = state.current_y;
2373 let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2374
2375 writeln!(svg, r#"<g class="message">"#).unwrap();
2377
2378 if is_self {
2379 let loop_width = 40.0;
2381 let text_block_height = lines.len() as f64 * line_height;
2382 let loop_height = (text_block_height + 10.0).max(25.0);
2383 let arrow_end_x = x1;
2384 let arrow_end_y = y + loop_height;
2385 let direction = std::f64::consts::PI;
2387 let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2388
2389 writeln!(
2390 svg,
2391 r#" <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2392 x1 = x1,
2393 y = y,
2394 x2 = x1 + loop_width,
2395 y2 = y + loop_height,
2396 arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2397 cls = line_class
2398 )
2399 .unwrap();
2400
2401 if is_filled {
2403 writeln!(
2404 svg,
2405 r#" <polygon points="{points}" class="arrowhead"/>"#,
2406 points = arrow_points
2407 )
2408 .unwrap();
2409 } else {
2410 writeln!(
2411 svg,
2412 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2413 points = arrow_points
2414 )
2415 .unwrap();
2416 }
2417
2418 let text_x = x1 + loop_width + 5.0;
2420 let max_width = lines
2421 .iter()
2422 .map(|line| estimate_message_width(line, state.config.font_size))
2423 .fold(0.0, f64::max);
2424 let top_line_y = y + 4.0 + 0.5 * line_height;
2425 let bottom_line_y = y + 4.0 + (lines.len() as f64 - 0.5) * line_height;
2426 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2427 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2428 let label_x_min = text_x;
2429 let label_x_max = text_x + max_width;
2430 let label_offset = if has_label_text {
2431 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2432 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2433 } else {
2434 0.0
2435 };
2436 for (i, line) in lines.iter().enumerate() {
2437 let line_y = y + 4.0 + (i as f64 + 0.5) * line_height + label_offset;
2438 writeln!(
2439 svg,
2440 r#" <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2441 x = text_x,
2442 y = line_y,
2443 t = escape_xml(line)
2444 )
2445 .unwrap();
2446 }
2447
2448 writeln!(svg, r#"</g>"#).unwrap();
2450
2451 let mut spacing = self_message_spacing(&state.config, lines.len());
2452 if state.in_serial_block() {
2453 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
2454 }
2455 if active_count > 0 {
2456 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
2457 }
2458 state.current_y += spacing;
2459 } else {
2460 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2462 let y2 = y + delay_offset;
2463
2464 let text_x = (x1 + x2) / 2.0;
2465 let text_y = (y + y2) / 2.0 - 8.0;
2466
2467 let direction = arrow_direction(x1, y, x2, y2);
2469 let arrow_points = arrowhead_points(x2, y2, direction);
2470
2471 let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2473 let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2474
2475 writeln!(
2477 svg,
2478 r#" <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2479 x1 = x1,
2480 y1 = y,
2481 lx2 = line_end_x,
2482 ly2 = line_end_y,
2483 cls = line_class
2484 )
2485 .unwrap();
2486
2487 if is_filled {
2489 writeln!(
2490 svg,
2491 r#" <polygon points="{points}" class="arrowhead"/>"#,
2492 points = arrow_points
2493 )
2494 .unwrap();
2495 } else {
2496 writeln!(
2497 svg,
2498 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2499 points = arrow_points
2500 )
2501 .unwrap();
2502 }
2503
2504 let max_width = lines
2506 .iter()
2507 .map(|line| estimate_message_width(line, state.config.font_size))
2508 .fold(0.0, f64::max);
2509 let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2510 let bottom_line_y = text_y;
2511 let label_offset = if has_label_text {
2512 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2513 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2514 let label_x_min = text_x - max_width / 2.0;
2515 let label_x_max = text_x + max_width / 2.0;
2516 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2517 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2518 } else {
2519 0.0
2520 };
2521 for (i, line) in lines.iter().enumerate() {
2522 let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2523 writeln!(
2524 svg,
2525 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2526 x = text_x,
2527 y = line_y,
2528 t = escape_xml(line)
2529 )
2530 .unwrap();
2531 }
2532
2533 writeln!(svg, r#"</g>"#).unwrap();
2535
2536 state.current_y += state.config.row_height + delay_offset;
2538 }
2539
2540 if create {
2541 state.current_y += CREATE_MESSAGE_SPACING;
2542 }
2543
2544 state.apply_serial_first_row_gap();
2545
2546 if activate && depth == 0 {
2547 state.current_y += ACTIVATION_START_GAP;
2548 }
2549 if chain_gap > 0.0 {
2550 state.current_y += chain_gap;
2551 }
2552
2553 if activate {
2555 state
2556 .activations
2557 .entry(to.to_string())
2558 .or_default()
2559 .push((y, None));
2560 }
2561 if deactivate {
2562 if let Some(acts) = state.activations.get_mut(from) {
2563 if let Some(act) = acts.last_mut() {
2564 if act.1.is_none() {
2565 act.1 = Some(y);
2566 }
2567 }
2568 }
2569 }
2570}
2571
2572fn render_note(
2573 svg: &mut String,
2574 state: &mut RenderState,
2575 position: &NotePosition,
2576 participants: &[String],
2577 text: &str,
2578) {
2579 let lines: Vec<&str> = text.split("\\n").collect();
2580 let line_height = note_line_height(&state.config);
2581 let padding = note_padding(&state.config);
2582 let note_height = padding * 2.0 + lines.len() as f64 * line_height;
2583
2584 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(10);
2587 let content_width = (max_line_len as f64 * 10.0 + padding * 2.0).max(80.0);
2588
2589 let (x, note_width, text_anchor) = match position {
2590 NotePosition::Left => {
2591 let px = state.get_x(&participants[0]);
2592 let p_width = state.get_participant_width(&participants[0]);
2593 let w = content_width.min(300.0);
2594 let x = (px - p_width / 2.0 - w - 10.0).max(state.config.padding);
2596 (x, w, "start")
2597 }
2598 NotePosition::Right => {
2599 let px = state.get_x(&participants[0]);
2600 let p_width = state.get_participant_width(&participants[0]);
2601 let w = content_width.min(300.0);
2602 (px + p_width / 2.0 + 5.0, w, "start")
2603 }
2604 NotePosition::Over => {
2605 if participants.len() == 1 {
2606 let px = state.get_x(&participants[0]);
2607 let w = content_width;
2609 let x = (px - w / 2.0).max(state.config.padding);
2611 (x, w, "middle")
2612 } else {
2613 let x1 = state.get_x(&participants[0]);
2615 let x2 = state.get_x(participants.last().unwrap());
2616 let p1_width = state.get_participant_width(&participants[0]);
2617 let p2_width = state.get_participant_width(participants.last().unwrap());
2618 let span_width = (x2 - x1).abs() + (p1_width + p2_width) / 2.0 * 0.8;
2619 let w = span_width.max(content_width);
2620 let center = (x1 + x2) / 2.0;
2621 let x = (center - w / 2.0).max(state.config.padding);
2623 (x, w, "middle")
2624 }
2625 }
2626 };
2627
2628 let y = state.current_y;
2629 let fold_size = 8.0; let note_path = format!(
2634 "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2635 x = x,
2636 y = y,
2637 x2 = x + note_width - fold_size,
2638 x3 = x + note_width,
2639 y2 = y + fold_size,
2640 y3 = y + note_height
2641 );
2642
2643 writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2644
2645 let theme = &state.config.theme;
2647 let fold_path = format!(
2649 "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2650 x1 = x + note_width - fold_size,
2651 y1 = y,
2652 x2 = x + note_width,
2653 y2 = y + fold_size
2654 );
2655
2656 writeln!(
2657 svg,
2658 r##"<path d="{path}" fill="#e0e0a0" stroke="{stroke}" stroke-width="1"/>"##,
2659 path = fold_path,
2660 stroke = theme.note_stroke
2661 )
2662 .unwrap();
2663
2664 let text_x = match text_anchor {
2666 "middle" => x + note_width / 2.0,
2667 "start" => x + padding,
2668 _ => x + note_width - padding,
2669 };
2670
2671 for (i, line) in lines.iter().enumerate() {
2672 let text_y = y + padding + (i as f64 + 0.8) * line_height;
2673 writeln!(
2674 svg,
2675 r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2676 x = text_x,
2677 y = text_y,
2678 anchor = if *position == NotePosition::Over {
2679 "middle"
2680 } else {
2681 "start"
2682 },
2683 t = escape_xml(line)
2684 )
2685 .unwrap();
2686 }
2687
2688 state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
2690}
2691
2692fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2694 let theme = &state.config.theme;
2695 let lines: Vec<&str> = text.split("\\n").collect();
2696 let line_height = state_line_height(&state.config);
2697 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2698
2699 let (x, box_width) = if participants.len() == 1 {
2701 let px = state.get_x(&participants[0]);
2702 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2703 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2704 (px - w / 2.0, w)
2705 } else {
2706 let x1 = state.get_x(&participants[0]);
2707 let x2 = state.get_x(participants.last().unwrap());
2708 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2709 let center = (x1 + x2) / 2.0;
2710 (center - span_width / 2.0, span_width)
2711 };
2712
2713 let shift = item_pre_shift(&state.config);
2714 let y = (state.current_y - shift).max(state.content_start());
2715
2716 writeln!(
2718 svg,
2719 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2720 x = x,
2721 y = y,
2722 w = box_width,
2723 h = box_height,
2724 fill = theme.state_fill,
2725 stroke = theme.state_stroke
2726 )
2727 .unwrap();
2728
2729 let text_x = x + box_width / 2.0;
2731 for (i, line) in lines.iter().enumerate() {
2732 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2733 writeln!(
2734 svg,
2735 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2736 x = text_x,
2737 y = text_y,
2738 fill = theme.state_text_color,
2739 font = theme.font_family,
2740 size = state.config.font_size,
2741 t = escape_xml(line)
2742 )
2743 .unwrap();
2744 }
2745
2746 state.current_y = y + box_height + state.config.row_height + REF_EXTRA_GAP;
2747}
2748
2749fn render_ref(
2751 svg: &mut String,
2752 state: &mut RenderState,
2753 participants: &[String],
2754 text: &str,
2755 input_from: Option<&str>,
2756 input_label: Option<&str>,
2757 output_to: Option<&str>,
2758 output_label: Option<&str>,
2759) {
2760 let theme = &state.config.theme;
2761 let lines: Vec<&str> = text.split("\\n").collect();
2762 let line_height = ref_line_height(&state.config);
2763 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2764 let notch_size = 10.0;
2765
2766 let (x, box_width) = if participants.len() == 1 {
2768 let px = state.get_x(&participants[0]);
2769 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2770 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2771 .max(100.0);
2772 (px - w / 2.0, w)
2773 } else {
2774 let x1 = state.get_x(&participants[0]);
2775 let x2 = state.get_x(participants.last().unwrap());
2776 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2777 let center = (x1 + x2) / 2.0;
2778 (center - span_width / 2.0, span_width)
2779 };
2780
2781 let shift = item_pre_shift(&state.config);
2782 let y = (state.current_y - shift).max(state.content_start());
2783 let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2784 let output_padding = state.config.note_padding + 3.0;
2785
2786 if let Some(from) = input_from {
2788 let from_x = state.get_x(from);
2789 let to_x = x; let arrow_y = y + input_offset;
2791
2792 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2794 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2795 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2796
2797 writeln!(
2799 svg,
2800 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2801 x1 = from_x,
2802 y = arrow_y,
2803 x2 = line_end_x
2804 )
2805 .unwrap();
2806
2807 writeln!(
2809 svg,
2810 r#"<polygon points="{points}" class="arrowhead"/>"#,
2811 points = arrow_points
2812 )
2813 .unwrap();
2814
2815 if let Some(label) = input_label {
2817 let text_x = (from_x + to_x) / 2.0;
2818 writeln!(
2819 svg,
2820 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2821 x = text_x,
2822 y = arrow_y - 8.0,
2823 t = escape_xml(label)
2824 )
2825 .unwrap();
2826 }
2827 }
2828
2829 let ref_path = format!(
2832 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2833 x1 = x + notch_size,
2834 y1 = y,
2835 x2 = x + box_width,
2836 y2 = y + box_height,
2837 x3 = x,
2838 y3 = y + box_height / 2.0
2839 );
2840
2841 writeln!(
2842 svg,
2843 r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2844 path = ref_path,
2845 fill = theme.ref_fill,
2846 stroke = theme.ref_stroke
2847 )
2848 .unwrap();
2849
2850 writeln!(
2852 svg,
2853 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
2854 x = x + notch_size + 4.0,
2855 y = y + state.config.font_size,
2856 fill = theme.ref_text_color,
2857 font = theme.font_family,
2858 size = state.config.font_size - 2.0
2859 )
2860 .unwrap();
2861
2862 let text_x = x + box_width / 2.0;
2864 for (i, line) in lines.iter().enumerate() {
2865 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2866 writeln!(
2867 svg,
2868 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2869 x = text_x,
2870 y = text_y,
2871 fill = theme.ref_text_color,
2872 font = theme.font_family,
2873 size = state.config.font_size,
2874 t = escape_xml(line)
2875 )
2876 .unwrap();
2877 }
2878
2879 if let Some(to) = output_to {
2881 let from_x = x + box_width; let to_x = state.get_x(to);
2883 let arrow_y = y + box_height - output_padding;
2884
2885 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2887 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2888 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2889
2890 writeln!(
2892 svg,
2893 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
2894 x1 = from_x,
2895 y = arrow_y,
2896 x2 = line_end_x
2897 )
2898 .unwrap();
2899
2900 writeln!(
2902 svg,
2903 r#"<polygon points="{points}" class="arrowhead"/>"#,
2904 points = arrow_points
2905 )
2906 .unwrap();
2907
2908 if let Some(label) = output_label {
2910 let text_x = (from_x + to_x) / 2.0;
2911 writeln!(
2912 svg,
2913 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2914 x = text_x,
2915 y = arrow_y - 8.0,
2916 t = escape_xml(label)
2917 )
2918 .unwrap();
2919 }
2920 }
2921
2922 state.current_y = y + box_height + state.config.row_height + STATE_EXTRA_GAP;
2923}
2924
2925fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
2927 let theme = &state.config.theme;
2928 let lines: Vec<&str> = text.split("\\n").collect();
2929 let line_height = state.config.font_size + 4.0;
2930
2931 let x = state.config.padding + 10.0;
2933 let y = state.current_y;
2934
2935 for (i, line) in lines.iter().enumerate() {
2936 let text_y = y + (i as f64 + 0.8) * line_height;
2937 writeln!(
2938 svg,
2939 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
2940 x = x,
2941 y = text_y,
2942 fill = theme.description_text_color,
2943 font = theme.font_family,
2944 size = state.config.font_size - 1.0,
2945 t = escape_xml(line)
2946 )
2947 .unwrap();
2948 }
2949
2950 state.current_y += lines.len() as f64 * line_height + 10.0;
2951}
2952
2953fn render_block(
2954 svg: &mut String,
2955 state: &mut RenderState,
2956 kind: &BlockKind,
2957 _label: &str,
2958 items: &[Item],
2959 else_items: Option<&[Item]>,
2960 depth: usize,
2961) {
2962 if block_is_parallel(kind) {
2963 state.push_parallel();
2964 let start_y = state.current_y;
2965 let mut max_end_y = start_y;
2966 for item in items {
2967 state.current_y = start_y;
2968 render_items(svg, state, std::slice::from_ref(item), depth);
2969 if state.current_y > max_end_y {
2970 max_end_y = state.current_y;
2971 }
2972 }
2973 let gap = if parallel_needs_gap(items) {
2974 PARALLEL_BLOCK_GAP
2975 } else {
2976 0.0
2977 };
2978 state.current_y = max_end_y + gap;
2979 state.pop_parallel();
2980 return;
2981 }
2982
2983 if matches!(kind, BlockKind::Serial) {
2984 state.push_serial_first_row_pending();
2985 render_items(svg, state, items, depth);
2986 if let Some(else_items) = else_items {
2987 render_items(svg, state, else_items, depth);
2988 }
2989 state.pop_serial_first_row_pending();
2990 return;
2991 }
2992
2993 if !block_has_frame(kind) {
2994 render_items(svg, state, items, depth);
2995 if let Some(else_items) = else_items {
2996 render_items(svg, state, else_items, depth);
2997 }
2998 return;
2999 }
3000
3001 state.current_y += block_header_space(&state.config, depth);
3006
3007 render_items(svg, state, items, depth + 1);
3009
3010 if let Some(else_items) = else_items {
3012 state.push_else_return_pending();
3013 state.current_y += block_else_spacing(&state.config, depth);
3014 render_items(svg, state, else_items, depth + 1);
3015 state.pop_else_return_pending();
3016 }
3017
3018 let end_y =
3019 state.current_y - state.config.row_height + block_footer_padding(&state.config, depth);
3020
3021 state.current_y = end_y + state.config.row_height * 1.0;
3023
3024 }
3027
3028fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3029 for (participant, activations) in &state.activations {
3030 let x = state.get_x(participant);
3031 let box_x = x - state.config.activation_width / 2.0;
3032
3033 for (start_y, end_y) in activations {
3034 let end = end_y.unwrap_or(footer_y);
3036 let height = end - start_y;
3037
3038 if height > 0.0 {
3039 writeln!(
3040 svg,
3041 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3042 x = box_x,
3043 y = start_y,
3044 w = state.config.activation_width,
3045 h = height
3046 )
3047 .unwrap();
3048 }
3049 }
3050 }
3051}
3052
3053fn escape_xml(s: &str) -> String {
3054 s.replace('&', "&")
3055 .replace('<', "<")
3056 .replace('>', ">")
3057 .replace('"', """)
3058 .replace('\'', "'")
3059}
3060
3061#[cfg(test)]
3062mod tests {
3063 use super::*;
3064 use crate::parser::parse;
3065
3066 #[test]
3067 fn test_render_simple() {
3068 let diagram = parse("Alice->Bob: Hello").unwrap();
3069 let svg = render(&diagram);
3070 assert!(svg.contains("<svg"));
3071 assert!(svg.contains("Alice"));
3072 assert!(svg.contains("Bob"));
3073 assert!(svg.contains("Hello"));
3074 }
3075
3076 #[test]
3077 fn test_render_with_note() {
3078 let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3079 let svg = render(&diagram);
3080 assert!(svg.contains("Thinking"));
3081 }
3082}