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