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