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