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