osd_core/
renderer.rs

1//! SVG renderer for sequence diagrams
2
3use crate::ast::*;
4use crate::theme::{LifelineStyle, ParticipantShape, Theme};
5use std::collections::HashMap;
6use std::fmt::Write;
7
8/// Rendering configuration
9#[derive(Debug, Clone)]
10pub struct Config {
11    /// Padding around the diagram
12    pub padding: f64,
13    /// Left margin for diagram content
14    pub left_margin: f64,
15    /// Right margin for diagram content
16    pub right_margin: f64,
17    /// Space between participants
18    pub participant_gap: f64,
19    /// Height of participant header/footer box
20    pub header_height: f64,
21    /// Height of each row (message, note, etc.)
22    pub row_height: f64,
23    /// Width of participant box
24    pub participant_width: f64,
25    /// Font size
26    pub font_size: f64,
27    /// Activation box width
28    pub activation_width: f64,
29    /// Note padding
30    pub note_padding: f64,
31    /// Block margin
32    pub block_margin: f64,
33    /// Title height (when title exists)
34    pub title_height: f64,
35    /// Theme for styling
36    pub theme: Theme,
37}
38
39impl Default for Config {
40    fn default() -> Self {
41        // Values calibrated to match websequencediagrams.com output exactly
42        // Reference: WSD Ultimate Stress Test produces:
43        //   - First participant box: x=78.5, y=110.5
44        //   - Box height: 46px (1 line), 108px (2+ lines)
45        //   - Box width: dynamic based on text
46        Self {
47            padding: 10.5,           // WSD: padding from SVG edge
48            left_margin: 66.0,       // WSD: Mobile lifeline at 124.5, box left edge at 78.5
49            right_margin: 10.0,      // WSD: minimal right margin, dynamically expanded for notes
50            participant_gap: 85.0,   // WSD: minimum gap for participants with no messages between
51            header_height: 46.0,     // WSD: participant box height = 46px (single line)
52            row_height: 32.0,        // WSD: actual row height = 32px
53            participant_width: 92.0, // WSD: minimum participant width = 92px
54            font_size: 14.0,         // WSD: uses 14px font
55            activation_width: 8.0,   // WSD: narrower activation bars
56            note_padding: 6.0,
57            block_margin: 5.0,
58            title_height: 100.0,     // WSD: title + space before participant boxes (y=110.5)
59            theme: Theme::default(),
60        }
61    }
62}
63
64impl Config {
65    /// Set the theme
66    pub fn with_theme(mut self, theme: Theme) -> Self {
67        self.theme = theme;
68        self
69    }
70}
71
72/// Block background info for deferred rendering
73#[derive(Debug, Clone)]
74struct BlockBackground {
75    x: f64,
76    y: f64,
77    width: f64,
78    height: f64,
79}
80
81/// Block label info for deferred rendering (rendered above activations/lifelines)
82#[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    /// Y coordinates and labels for each else section
91    else_sections: Vec<(f64, Option<String>)>,
92}
93
94#[derive(Debug, Clone)]
95struct LabelBox {
96    x_min: f64,
97    x_max: f64,
98    y_min: f64,
99    y_max: f64,
100}
101
102/// Render state
103struct RenderState {
104    config: Config,
105    participants: Vec<Participant>,
106    participant_x: HashMap<String, f64>,
107    participant_widths: HashMap<String, f64>,
108    current_y: f64,
109    activations: HashMap<String, Vec<(f64, Option<f64>)>>,
110    autonumber: Option<u32>,
111    destroyed: HashMap<String, f64>,
112    has_title: bool,
113    total_width: f64,
114    /// Collected block backgrounds for deferred rendering
115    block_backgrounds: Vec<BlockBackground>,
116    /// Collected block labels for deferred rendering (above activations/lifelines)
117    block_labels: Vec<BlockLabel>,
118    /// Footer style from diagram options
119    footer_style: FooterStyle,
120    /// Tracks whether the first return message in an else branch needs extra spacing
121    else_return_pending: Vec<bool>,
122    /// Tracks whether a serial block needs extra spacing after its first row
123    serial_first_row_pending: Vec<bool>,
124    /// Tracks nested parallel depth for serial row spacing
125    parallel_depth: usize,
126    /// Tracks message label bounding boxes to avoid overlap
127    message_label_boxes: Vec<LabelBox>,
128}
129
130// ============================================
131// Text width calculation
132// ============================================
133const TEXT_WIDTH_PADDING: f64 = 41.0;
134const TEXT_WIDTH_SCALE: f64 = 1.3;
135const MESSAGE_WIDTH_PADDING: f64 = 4.0;
136const MESSAGE_WIDTH_SCALE: f64 = 0.82;
137
138// ============================================
139// Group Spacing (unified element spacing)
140// ============================================
141// All element spacing is controlled by this single function.
142// "Group" = visual unit (text + arrow/loop/box)
143// Spacing = gap between visual bottom of one group and visual top of next group
144fn group_spacing(config: &Config) -> f64 {
145    config.row_height  // 32px by default
146}
147
148// ============================================
149// Message
150// ============================================
151const MESSAGE_TEXT_ABOVE_ARROW: f64 = 6.0;       // Text is rendered 6px above arrow
152const DELAY_UNIT: f64 = 18.0;                    // Pixels per delay unit
153
154// ============================================
155// Block (alt, opt, loop, etc.)
156// ============================================
157const BLOCK_LABEL_HEIGHT: f64 = 22.0;            // Pentagon label height
158const ELEMENT_PADDING: f64 = 8.0;                // Unified internal padding for all elements
159
160// ============================================
161// Note
162// ============================================
163const NOTE_MARGIN: f64 = 10.0;                   // Margin between note and lifeline
164const NOTE_FOLD_SIZE: f64 = 8.0;                 // Corner fold size
165const NOTE_CHAR_WIDTH: f64 = 7.0;                // Estimated character width
166const NOTE_LINE_HEIGHT: f64 = 17.0;              // Line height (font 13px + 4px)
167const NOTE_MIN_WIDTH: f64 = 50.0;                // Minimum width
168
169// ============================================
170// Other elements
171// ============================================
172const STATE_LINE_HEIGHT_EXTRA: f64 = 11.0;       // Extra line height for state
173const REF_LINE_HEIGHT_EXTRA: f64 = 16.0;         // Extra line height for ref
174
175// ============================================
176// Message label collision avoidance
177// ============================================
178const MESSAGE_LABEL_COLLISION_PADDING: f64 = 2.0;
179const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
180const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
181const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
182
183fn block_header_space(config: &Config, _depth: usize) -> f64 {
184    // Pentagon height + group spacing + text offset absorption
185    BLOCK_LABEL_HEIGHT + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
186}
187
188fn block_frame_shift(_depth: usize) -> f64 {
189    0.0
190}
191
192fn block_footer_padding(_config: &Config, _depth: usize) -> f64 {
193    ELEMENT_PADDING
194}
195
196fn block_else_before(_config: &Config, _depth: usize) -> f64 {
197    ELEMENT_PADDING
198}
199
200fn block_else_after(config: &Config, _depth: usize) -> f64 {
201    // Space after else line to match alt header spacing
202    // Else label text is rendered at else_y + 16 (label_text_offset)
203    // Need space for label text height + group spacing + message text offset
204    16.0 + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
205}
206
207fn self_message_spacing(config: &Config, lines: usize) -> f64 {
208    let line_height = config.font_size + 4.0;
209    let text_block_height = lines as f64 * line_height;
210    let loop_height = text_block_height.max(25.0);
211    // Group height (loop) + group spacing + next message text offset
212    // Next message text is rendered MESSAGE_TEXT_ABOVE_ARROW above arrow,
213    // so we add that offset to maintain consistent visual spacing
214    loop_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
215}
216
217fn note_line_height(_config: &Config) -> f64 {
218    // Simplified: fixed value (font 13px + padding 4px = 17px)
219    NOTE_LINE_HEIGHT
220}
221
222fn note_padding(_config: &Config) -> f64 {
223    ELEMENT_PADDING
224}
225
226fn item_pre_gap(config: &Config) -> f64 {
227    config.font_size + 1.0
228}
229
230fn item_pre_shift(config: &Config) -> f64 {
231    (config.row_height - item_pre_gap(config)).max(0.0)
232}
233
234fn label_boxes_overlap(x_min: f64, x_max: f64, y_min: f64, y_max: f64, other: &LabelBox) -> bool {
235    let x_overlap = x_max >= other.x_min - MESSAGE_LABEL_COLLISION_PADDING
236        && x_min <= other.x_max + MESSAGE_LABEL_COLLISION_PADDING;
237    let y_overlap = y_max >= other.y_min - MESSAGE_LABEL_COLLISION_PADDING
238        && y_min <= other.y_max + MESSAGE_LABEL_COLLISION_PADDING;
239    x_overlap && y_overlap
240}
241
242fn actor_footer_extra(_participants: &[Participant], _config: &Config) -> f64 {
243    // Actor names are now rendered within the header, so no extra footer space needed
244    0.0
245}
246
247fn serial_first_row_gap(_parallel_depth: usize) -> f64 {
248    // Simplified: always 0 (no fine-tuning needed)
249    0.0
250}
251
252fn state_line_height(config: &Config) -> f64 {
253    config.font_size + STATE_LINE_HEIGHT_EXTRA
254}
255
256fn ref_line_height(config: &Config) -> f64 {
257    config.font_size + REF_LINE_HEIGHT_EXTRA
258}
259
260// ============================================
261// Common Y advancement functions
262// These are the single source of truth for Y position calculations.
263// Used by: collect_block_backgrounds, calculate_height, render_*
264// ============================================
265
266/// Calculate Y advancement for a regular (non-self) message
267fn regular_message_y_advance(config: &Config, line_count: usize, delay_offset: f64) -> f64 {
268    // Use actual line_height (font_size + 4) to match text rendering
269    let line_height = config.font_size + 4.0;
270    let extra_height = if line_count > 1 {
271        // Extra lines + MESSAGE_TEXT_ABOVE_ARROW offset (text is rendered above arrow)
272        (line_count - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
273    } else {
274        0.0
275    };
276    // Group spacing + multiline extra + delay
277    // For regular messages, text is above arrow on both sides, so offsets cancel out
278    group_spacing(config) + extra_height + delay_offset
279}
280
281/// Calculate Y advancement for a self-message
282fn self_message_y_advance(config: &Config, line_count: usize) -> f64 {
283    self_message_spacing(config, line_count)
284}
285
286/// Calculate Y advancement for a note
287fn note_y_advance(config: &Config, line_count: usize) -> f64 {
288    let note_height = note_padding(config) * 2.0 + line_count as f64 * note_line_height(config);
289    // Group height (note box) + group spacing + next message text offset
290    note_height.max(group_spacing(config)) + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
291}
292
293/// Calculate Y advancement for a state box
294fn state_y_advance(config: &Config, line_count: usize) -> f64 {
295    let box_height = ELEMENT_PADDING * 2.0 + line_count as f64 * state_line_height(config);
296    // Group height (state box) + group spacing + next message text offset
297    box_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
298}
299
300/// Calculate Y advancement for a ref box
301fn ref_y_advance(config: &Config, line_count: usize) -> f64 {
302    let box_height = ELEMENT_PADDING * 2.0 + line_count as f64 * ref_line_height(config);
303    // Group height (ref box) + group spacing + next message text offset
304    box_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
305}
306
307/// Calculate Y advancement for a description
308fn description_y_advance(config: &Config, line_count: usize) -> f64 {
309    let line_height = config.font_size + 4.0;
310    // Group height (text block) + group spacing + next message text offset
311    line_count as f64 * line_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
312}
313
314/// Calculate Y advancement for a block end (footer + row margin)
315fn block_end_y_advance(config: &Config, depth: usize) -> f64 {
316    block_footer_padding(config, depth) + group_spacing(config)
317}
318
319/// Arrowhead size constant
320const ARROWHEAD_SIZE: f64 = 10.0;
321
322/// Generate arrowhead polygon points for a given end position and direction
323fn arrowhead_points(x: f64, y: f64, direction: f64) -> String {
324    let size = ARROWHEAD_SIZE;
325    let half_width = size * 0.35;
326
327    // Tip of the arrow
328    let tip_x = x;
329    let tip_y = y;
330
331    // Back points of the arrow (rotated by direction)
332    let back_x = x - size * direction.cos();
333    let back_y = y - size * direction.sin();
334
335    // Perpendicular offset for the two back points
336    let perp_x = -direction.sin() * half_width;
337    let perp_y = direction.cos() * half_width;
338
339    format!(
340        "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
341        back_x + perp_x,
342        back_y + perp_y,
343        tip_x,
344        tip_y,
345        back_x - perp_x,
346        back_y - perp_y
347    )
348}
349
350/// Calculate direction angle from (x1, y1) to (x2, y2)
351fn arrow_direction(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
352    (y2 - y1).atan2(x2 - x1)
353}
354
355fn block_has_frame(kind: &BlockKind) -> bool {
356    !matches!(kind, BlockKind::Parallel | BlockKind::Serial)
357}
358
359fn block_is_parallel(kind: &BlockKind) -> bool {
360    matches!(kind, BlockKind::Parallel)
361}
362
363fn parallel_needs_gap(items: &[Item]) -> bool {
364    items.iter().any(|item| matches!(item, Item::Block { .. }))
365}
366
367fn text_char_weight(c: char) -> f64 {
368    if c.is_ascii() {
369        if c.is_uppercase() {
370            0.7
371        } else {
372            0.5
373        }
374    } else {
375        1.0 // CJK and other characters are wider
376    }
377}
378
379/// Character width for participant box calculation (WSD proportional font metrics)
380/// Based on analysis of WSD SVG glyph definitions and actual output comparison
381fn participant_char_width(c: char) -> f64 {
382    match c {
383        // Very wide: W, M, m, w, @
384        'W' | 'w' => 14.0,
385        'M' | 'm' => 12.5,
386        '@' | '%' => 14.0,
387        // Wide uppercase
388        'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'H' | 'K' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'X' | 'Y' | 'Z' => 12.0,
389        // Narrow uppercase
390        'F' | 'I' | 'J' | 'L' => 7.0,
391        // Wide lowercase
392        'o' | 'e' | 'a' | 'n' | 'u' | 'v' | 'x' | 'z' | 'b' | 'd' | 'g' | 'h' | 'k' | 'p' | 'q' | 's' | 'c' | 'y' => 8.5,
393        // Narrow lowercase
394        'i' | 'j' | 'l' => 4.0,
395        't' | 'f' | 'r' => 6.0,
396        // Punctuation and special chars (WSD uses wider glyphs for these)
397        ':' => 6.5,
398        '-' | '_' => 7.0,
399        '[' | ']' | '(' | ')' | '{' | '}' => 7.0,
400        '.' | ',' | '\'' | '`' | ';' => 4.0,
401        ' ' => 5.0,
402        // Numbers
403        '0'..='9' => 9.0,
404        // Default for other ASCII
405        _ if c.is_ascii() => 8.5,
406        // CJK and other characters
407        _ => 14.0,
408    }
409}
410
411/// Calculate participant box width based on WSD proportional font metrics
412fn calculate_participant_width(name: &str, min_width: f64) -> f64 {
413    let lines: Vec<&str> = name.split("\\n").collect();
414    let max_line_width = lines
415        .iter()
416        .map(|line| line.chars().map(participant_char_width).sum::<f64>())
417        .fold(0.0_f64, |a, b| a.max(b));
418
419    // WSD uses consistent padding for all participant boxes
420    let padding = 50.0;
421
422    (max_line_width + padding).max(min_width)
423}
424
425fn max_weighted_line(text: &str) -> f64 {
426    text.split("\\n")
427        .map(|line| line.chars().map(text_char_weight).sum::<f64>())
428        .fold(0.0_f64, |a, b| a.max(b))
429}
430
431/// Estimate text width in pixels (rough approximation)
432fn estimate_text_width(text: &str, font_size: f64) -> f64 {
433    let weighted = max_weighted_line(text);
434    weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
435}
436
437fn estimate_message_width(text: &str, font_size: f64) -> f64 {
438    let weighted = max_weighted_line(text);
439    weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
440}
441
442fn block_tab_width(kind: &str) -> f64 {
443    (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
444}
445
446/// Calculate note width based on text content
447fn calculate_note_width(text: &str, _config: &Config) -> f64 {
448    let lines: Vec<&str> = text.split("\\n").collect();
449    let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
450    let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
451    (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH)
452}
453
454/// Calculate required right margin based on right-side notes on the rightmost participant only
455fn calculate_right_margin(
456    participants: &[Participant],
457    items: &[Item],
458    config: &Config,
459) -> f64 {
460    let rightmost_id = match participants.last() {
461        Some(p) => p.id().to_string(),
462        None => return config.right_margin,
463    };
464    let mut max_right_note_width: f64 = 0.0;
465
466    fn process_items_for_right_notes(
467        items: &[Item],
468        rightmost_id: &str,
469        max_width: &mut f64,
470        config: &Config,
471    ) {
472        for item in items {
473            match item {
474                Item::Note {
475                    position: NotePosition::Right,
476                    participants,
477                    text,
478                } => {
479                    // Only consider notes on the rightmost participant
480                    if participants.first().map(|s| s.as_str()) == Some(rightmost_id) {
481                        let note_width = calculate_note_width(text, config);
482                        if note_width > *max_width {
483                            *max_width = note_width;
484                        }
485                    }
486                }
487                Item::Block {
488                    items, else_sections, ..
489                } => {
490                    process_items_for_right_notes(items, rightmost_id, max_width, config);
491                    for section in else_sections {
492                        process_items_for_right_notes(&section.items, rightmost_id, max_width, config);
493                    }
494                }
495                _ => {}
496            }
497        }
498    }
499
500    process_items_for_right_notes(items, &rightmost_id, &mut max_right_note_width, config);
501
502    // right_margin needs to accommodate: NOTE_MARGIN + note_width
503    if max_right_note_width > 0.0 {
504        (max_right_note_width + NOTE_MARGIN).max(config.right_margin)
505    } else {
506        config.right_margin
507    }
508}
509
510/// Calculate required left margin based on left-side notes on the leftmost participant
511fn calculate_left_margin(
512    participants: &[Participant],
513    items: &[Item],
514    config: &Config,
515) -> f64 {
516    let leftmost_id = match participants.first() {
517        Some(p) => p.id().to_string(),
518        None => return config.padding,
519    };
520    let mut max_left_note_width: f64 = 0.0;
521
522    fn process_items_for_left_notes(
523        items: &[Item],
524        leftmost_id: &str,
525        max_width: &mut f64,
526        config: &Config,
527    ) {
528        for item in items {
529            match item {
530                Item::Note {
531                    position: NotePosition::Left,
532                    participants,
533                    text,
534                } => {
535                    // Only consider notes on the leftmost participant
536                    if participants.first().map(|s| s.as_str()) == Some(leftmost_id) {
537                        let note_width = calculate_note_width(text, config);
538                        if note_width > *max_width {
539                            *max_width = note_width;
540                        }
541                    }
542                }
543                Item::Block {
544                    items, else_sections, ..
545                } => {
546                    process_items_for_left_notes(items, leftmost_id, max_width, config);
547                    for section in else_sections {
548                        process_items_for_left_notes(&section.items, leftmost_id, max_width, config);
549                    }
550                }
551                _ => {}
552            }
553        }
554    }
555
556    process_items_for_left_notes(items, &leftmost_id, &mut max_left_note_width, config);
557
558    // left_margin needs to accommodate: note_width + NOTE_MARGIN
559    if max_left_note_width > 0.0 {
560        (max_left_note_width + NOTE_MARGIN).max(config.padding)
561    } else {
562        config.padding
563    }
564}
565
566/// Calculate dynamic gaps between participants based on message text lengths
567fn calculate_participant_gaps(
568    participants: &[Participant],
569    items: &[Item],
570    config: &Config,
571) -> Vec<f64> {
572    if participants.len() <= 1 {
573        return vec![];
574    }
575
576    // Create a map from participant id to index
577    let mut participant_index: HashMap<String, usize> = HashMap::new();
578    for (i, p) in participants.iter().enumerate() {
579        participant_index.insert(p.id().to_string(), i);
580    }
581
582    // Initialize gaps with WSD-compatible minimum gap
583    // WSD uses ~59px center-to-center for simple diagrams
584    let min_gap = config.participant_gap;
585    let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
586
587    // Calculate max text width for each adjacent pair
588    fn process_items(
589        items: &[Item],
590        participant_index: &HashMap<String, usize>,
591        gaps: &mut Vec<f64>,
592        config: &Config,
593    ) {
594        for item in items {
595            match item {
596                Item::Message { from, to, text, arrow, .. } => {
597                    if let (Some(&from_idx), Some(&to_idx)) =
598                        (participant_index.get(from), participant_index.get(to))
599                    {
600                        if from_idx != to_idx {
601                            let (min_idx, max_idx) = if from_idx < to_idx {
602                                (from_idx, to_idx)
603                            } else {
604                                (to_idx, from_idx)
605                            };
606
607                            let text_width = estimate_message_width(text, config.font_size);
608
609                            // WSD: delay messages need extra horizontal space for diagonal lines
610                            // Delay coefficient 86.4 for WSD gap matching (645px for delay(7))
611                            let delay_extra = arrow.delay.map(|d| d as f64 * 86.4).unwrap_or(0.0);
612
613                            // WSD: distribute text width across gaps with appropriate spacing
614                            let gap_count = (max_idx - min_idx) as f64;
615                            let needed_gap = if gap_count == 1.0 {
616                                // Adjacent: text width minus overlap allowance
617                                text_width - 36.0 + delay_extra
618                            } else {
619                                // Non-adjacent: distribute evenly with margin
620                                text_width / gap_count - 20.0 + delay_extra
621                            };
622
623                            // Update gaps between the participants
624                            for gap_idx in min_idx..max_idx {
625                                if needed_gap > gaps[gap_idx] {
626                                    gaps[gap_idx] = needed_gap;
627                                }
628                            }
629                        }
630                    }
631                }
632                Item::Note {
633                    position,
634                    participants: note_participants,
635                    text,
636                } => {
637                    // Calculate note width
638                    let note_width = calculate_note_width(text, config);
639
640                    if let Some(participant) = note_participants.first() {
641                        if let Some(&idx) = participant_index.get(participant) {
642                            match position {
643                                NotePosition::Left => {
644                                    // Left note: needs space between left neighbor
645                                    if idx > 0 {
646                                        // Need gap for note width + margins
647                                        let needed_gap = note_width + NOTE_MARGIN * 2.0;
648                                        if needed_gap > gaps[idx - 1] {
649                                            gaps[idx - 1] = needed_gap;
650                                        }
651                                    }
652                                }
653                                NotePosition::Right => {
654                                    // Right note: needs space between right neighbor
655                                    if idx < gaps.len() {
656                                        let needed_gap = note_width + NOTE_MARGIN * 2.0;
657                                        if needed_gap > gaps[idx] {
658                                            gaps[idx] = needed_gap;
659                                        }
660                                    }
661                                }
662                                NotePosition::Over => {
663                                    // Over note: only process if spanning multiple participants
664                                    // Single participant case is fine as long as width doesn't exceed
665                                }
666                            }
667                        }
668                    }
669                }
670                Item::Block {
671                    items, else_sections, ..
672                } => {
673                    process_items(items, participant_index, gaps, config);
674                    for section in else_sections {
675                        process_items(&section.items, participant_index, gaps, config);
676                    }
677                }
678                _ => {}
679            }
680        }
681    }
682
683    process_items(items, &participant_index, &mut gaps, config);
684
685    // WSD: participant name lengths don't directly increase gaps
686    // The participant box widths (already calculated elsewhere) handle this
687    // No additional gap increase needed for names
688
689    // Cap maximum gap (WSD allows up to ~645px for long messages)
690    let max_gap = 645.0;
691    for gap in &mut gaps {
692        if *gap > max_gap {
693            *gap = max_gap;
694        }
695    }
696
697    gaps
698}
699
700impl RenderState {
701    fn new(
702        config: Config,
703        participants: Vec<Participant>,
704        items: &[Item],
705        has_title: bool,
706        footer_style: FooterStyle,
707    ) -> Self {
708        let mut config = config;
709        // WSD header height calculation:
710        // - 1 line: 46px
711        // - 2+ lines: 108px (WSD caps at 108px regardless of line count)
712        // - Actor: ~108px for 2-line names
713        let mut required_header_height = config.header_height;
714        for p in &participants {
715            let lines = p.name.split("\\n").count();
716            let needed = match p.kind {
717                ParticipantKind::Participant => {
718                    // WSD: 46px for 1 line, 108px for 2+ lines (capped)
719                    if lines <= 1 {
720                        46.0
721                    } else {
722                        108.0 // WSD uses fixed 108px for multi-line
723                    }
724                }
725                ParticipantKind::Actor => {
726                    // WSD: Actor has stick figure + name below
727                    // ~85px for 1-line, ~108px for 2+ lines
728                    if lines <= 1 {
729                        85.0
730                    } else {
731                        108.0
732                    }
733                }
734            };
735            if needed > required_header_height {
736                required_header_height = needed;
737            }
738        }
739        if required_header_height > config.header_height {
740            config.header_height = required_header_height;
741        }
742        // Calculate individual participant widths based on their names
743        // Using WSD proportional font metrics for accurate box widths
744        let mut participant_widths: HashMap<String, f64> = HashMap::new();
745        let min_width = config.participant_width;
746
747        for p in &participants {
748            let width = calculate_participant_width(&p.name, min_width);
749            participant_widths.insert(p.id().to_string(), width);
750        }
751
752        let gaps = calculate_participant_gaps(&participants, items, &config);
753
754        // Left margin for notes on leftmost participant (dynamic)
755        let left_margin = calculate_left_margin(&participants, items, &config);
756        // Right margin for self-loops and notes on rightmost participant (dynamic)
757        let right_margin = calculate_right_margin(&participants, items, &config);
758
759        let mut participant_x = HashMap::new();
760        let first_width = participants
761            .first()
762            .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
763            .unwrap_or(min_width);
764        let mut current_x = config.padding + left_margin + first_width / 2.0;
765
766        for (i, p) in participants.iter().enumerate() {
767            participant_x.insert(p.id().to_string(), current_x);
768            if i < gaps.len() {
769                let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
770                let next_p = participants.get(i + 1);
771                let next_width = next_p
772                    .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
773                    .unwrap_or(min_width);
774
775                // WSD: Actor doesn't have a header box, so it takes less horizontal space
776                // Reduce gap when current or next participant is an Actor
777                let current_is_actor = p.kind == ParticipantKind::Actor;
778                let next_is_actor = next_p.map(|np| np.kind == ParticipantKind::Actor).unwrap_or(false);
779
780                // Note: Actor gap reduction disabled - it changes total width
781                // WSD and OSD have different actor placement algorithms
782                let actor_gap_reduction = 0.0;
783                let _ = (current_is_actor, next_is_actor); // suppress warnings
784
785                // WSD: edge-to-edge gap varies by message density
786                // Variable edge padding: more messages = more edge padding
787                let calculated_gap = gaps[i] - actor_gap_reduction;
788
789                // Determine edge padding based on message density and participant types
790                // WSD uses variable edge padding based on content
791                let half_widths = (current_width + next_width) / 2.0;
792                let neither_is_actor = !current_is_actor && !next_is_actor;
793
794                let either_is_actor = current_is_actor || next_is_actor;
795                let edge_padding = if calculated_gap > 500.0 {
796                    // Very high (delay messages): minimal extra padding
797                    10.0
798                } else if either_is_actor && calculated_gap > 130.0 {
799                    // Actor-adjacent gaps: WSD uses tighter spacing around actors
800                    33.0
801                } else if neither_is_actor && half_widths > 155.0 && calculated_gap > 130.0 {
802                    // Two large normal boxes with medium traffic: extra padding
803                    90.0
804                } else if calculated_gap > 130.0 {
805                    // Medium-high traffic: WSD uses ~49px for these gaps
806                    49.0
807                } else if calculated_gap > config.participant_gap {
808                    // Medium traffic: moderate padding
809                    25.0
810                } else {
811                    // Low traffic: edge_padding depends on individual participant widths
812                    let max_width = current_width.max(next_width);
813                    let min_width_val = current_width.min(next_width);
814                    let width_diff = max_width - min_width_val;
815
816                    if max_width > 160.0 && min_width_val > 160.0 {
817                        // Both participants are very wide (>160): small positive padding
818                        // WSD UserDB→Cache: both 161.2, gap=163, ep≈1.8
819                        1.8
820                    } else if max_width > 160.0 && min_width_val > 140.0 {
821                        // One very wide, one large: negative padding
822                        // WSD ML→Notify: max=161.2, min=149.6, gap=148.5, ep≈-7
823                        -7.0
824                    } else if max_width > 160.0 && min_width_val < 110.0 {
825                        // One very wide, one small: large positive padding
826                        // WSD Cache→Kafka: max=161.2, min=103.2, gap=143.5, ep≈11.3
827                        11.3
828                    } else if max_width > 160.0 && width_diff > 45.0 {
829                        // One very wide, one medium-small: negative padding
830                        // WSD Notify→Payment: max=161.2, min=114.8, diff=46.4, gap=132, ep≈-6
831                        -6.0
832                    } else if min_width_val < 115.0 {
833                        // One small participant: moderate padding
834                        // WSD Kafka→ML, Payment→Worker
835                        10.0
836                    } else {
837                        // Medium participants: moderate padding
838                        11.0
839                    }
840                };
841
842                let min_center_gap = (current_width + next_width) / 2.0 + edge_padding - actor_gap_reduction;
843                let actual_gap = calculated_gap.max(min_center_gap).max(60.0);
844                current_x += actual_gap;
845            }
846        }
847
848        let last_width = participants
849            .last()
850            .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
851            .unwrap_or(min_width);
852        let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
853
854        Self {
855            config,
856            participants,
857            participant_x,
858            participant_widths,
859            current_y: 0.0,
860            activations: HashMap::new(),
861            autonumber: None,
862            destroyed: HashMap::new(),
863            has_title,
864            total_width,
865            block_backgrounds: Vec::new(),
866            block_labels: Vec::new(),
867            footer_style,
868            else_return_pending: Vec::new(),
869            serial_first_row_pending: Vec::new(),
870            parallel_depth: 0,
871            message_label_boxes: Vec::new(),
872        }
873    }
874
875    fn get_participant_width(&self, name: &str) -> f64 {
876        *self
877            .participant_widths
878            .get(name)
879            .unwrap_or(&self.config.participant_width)
880    }
881
882    fn get_x(&self, name: &str) -> f64 {
883        *self.participant_x.get(name).unwrap_or(&0.0)
884    }
885
886    fn push_else_return_pending(&mut self) {
887        self.else_return_pending.push(true);
888    }
889
890    fn pop_else_return_pending(&mut self) {
891        self.else_return_pending.pop();
892    }
893
894    fn apply_else_return_gap(&mut self, arrow: &Arrow) {
895        if let Some(pending) = self.else_return_pending.last_mut() {
896            if *pending && matches!(arrow.line, LineStyle::Dashed) {
897                // Simplified: removed fine-tuning after else return
898                *pending = false;
899            }
900        }
901    }
902
903    fn push_serial_first_row_pending(&mut self) {
904        self.serial_first_row_pending.push(true);
905    }
906
907    fn pop_serial_first_row_pending(&mut self) {
908        self.serial_first_row_pending.pop();
909    }
910
911    fn apply_serial_first_row_gap(&mut self) {
912        if let Some(pending) = self.serial_first_row_pending.last_mut() {
913            if *pending {
914                self.current_y += serial_first_row_gap(self.parallel_depth);
915                *pending = false;
916            }
917        }
918    }
919
920    fn reserve_message_label(
921        &mut self,
922        x_min: f64,
923        x_max: f64,
924        mut y_min: f64,
925        mut y_max: f64,
926        step: f64,
927    ) -> f64 {
928        // Only check collision with boxes that could actually overlap
929        // Skip boxes whose y_max is significantly above our y_min (they're in previous rows)
930        let relevance_threshold = step * 2.0;
931        let relevant_boxes: Vec<&LabelBox> = self
932            .message_label_boxes
933            .iter()
934            .filter(|b| b.y_max + relevance_threshold >= y_min)
935            .collect();
936
937        let mut offset = 0.0;
938        let mut attempts = 0;
939        while relevant_boxes
940            .iter()
941            .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
942            && attempts < 20
943        {
944            y_min += step;
945            y_max += step;
946            offset += step;
947            attempts += 1;
948        }
949        self.message_label_boxes.push(LabelBox {
950            x_min,
951            x_max,
952            y_min,
953            y_max,
954        });
955        offset
956    }
957
958    fn push_parallel(&mut self) {
959        self.parallel_depth += 1;
960    }
961
962    fn pop_parallel(&mut self) {
963        if self.parallel_depth > 0 {
964            self.parallel_depth -= 1;
965        }
966    }
967
968    /// Check if a participant has an active activation at the given Y position
969    fn is_participant_active_at(&self, participant: &str, y: f64) -> bool {
970        if let Some(acts) = self.activations.get(participant) {
971            acts.iter().any(|(start_y, end_y)| {
972                *start_y <= y && end_y.map_or(true, |end| y <= end)
973            })
974        } else {
975            false
976        }
977    }
978
979    /// Get arrow start X position, accounting for activation bar
980    fn get_arrow_start_x(&self, participant: &str, y: f64, going_right: bool) -> f64 {
981        let x = self.get_x(participant);
982        if self.is_participant_active_at(participant, y) {
983            let half_width = self.config.activation_width / 2.0;
984            if going_right {
985                x + half_width // Arrow starts from right edge of activation bar
986            } else {
987                x - half_width // Arrow starts from left edge of activation bar
988            }
989        } else {
990            x
991        }
992    }
993
994    /// Get arrow end X position, accounting for activation bar
995    fn get_arrow_end_x(&self, participant: &str, y: f64, coming_from_right: bool) -> f64 {
996        let x = self.get_x(participant);
997        if self.is_participant_active_at(participant, y) {
998            let half_width = self.config.activation_width / 2.0;
999            if coming_from_right {
1000                x + half_width // Arrow ends at right edge of activation bar
1001            } else {
1002                x - half_width // Arrow ends at left edge of activation bar
1003            }
1004        } else {
1005            x
1006        }
1007    }
1008
1009    fn diagram_width(&self) -> f64 {
1010        // Consider block labels that may extend beyond participant-based width
1011        let max_block_x2 = self
1012            .block_labels
1013            .iter()
1014            .map(|bl| bl.x2)
1015            .fold(0.0f64, |a, b| a.max(b));
1016        // Add right margin for blocks
1017        let block_width = if max_block_x2 > 0.0 {
1018            max_block_x2 + self.config.padding
1019        } else {
1020            0.0
1021        };
1022        self.total_width.max(block_width)
1023    }
1024
1025    /// Get the x position of the leftmost participant
1026    fn leftmost_x(&self) -> f64 {
1027        self.participants
1028            .first()
1029            .map(|p| self.get_x(p.id()))
1030            .unwrap_or(self.config.padding)
1031    }
1032
1033    /// Get the x position of the rightmost participant
1034    fn rightmost_x(&self) -> f64 {
1035        self.participants
1036            .last()
1037            .map(|p| self.get_x(p.id()))
1038            .unwrap_or(self.total_width - self.config.padding)
1039    }
1040
1041    /// Get block left boundary (based on leftmost participant)
1042    fn block_left(&self) -> f64 {
1043        let leftmost_width = self
1044            .participants
1045            .first()
1046            .map(|p| self.get_participant_width(p.id()))
1047            .unwrap_or(self.config.participant_width);
1048        self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
1049    }
1050
1051    /// Get block right boundary (based on rightmost participant)
1052    fn block_right(&self) -> f64 {
1053        let rightmost_width = self
1054            .participants
1055            .last()
1056            .map(|p| self.get_participant_width(p.id()))
1057            .unwrap_or(self.config.participant_width);
1058        self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
1059    }
1060
1061    fn header_top(&self) -> f64 {
1062        if self.has_title {
1063            self.config.padding + self.config.title_height
1064        } else {
1065            self.config.padding
1066        }
1067    }
1068
1069    fn content_start(&self) -> f64 {
1070        // WSD first message Y: 250.5
1071        // header_top (110.5) + header_height (108) + row_height (32) = 250.5
1072        self.header_top() + self.config.header_height + self.config.row_height
1073    }
1074
1075    fn next_number(&mut self) -> Option<u32> {
1076        self.autonumber.map(|n| {
1077            self.autonumber = Some(n + 1);
1078            n
1079        })
1080    }
1081
1082    /// Add a block background to be rendered later
1083    fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
1084        self.block_backgrounds.push(BlockBackground {
1085            x,
1086            y,
1087            width,
1088            height,
1089        });
1090    }
1091
1092    /// Add a block label to be rendered later (above activations/lifelines)
1093    fn add_block_label(
1094        &mut self,
1095        x1: f64,
1096        start_y: f64,
1097        end_y: f64,
1098        x2: f64,
1099        kind: &str,
1100        label: &str,
1101        else_sections: Vec<(f64, Option<String>)>,
1102    ) {
1103        self.block_labels.push(BlockLabel {
1104            x1,
1105            start_y,
1106            end_y,
1107            x2,
1108            kind: kind.to_string(),
1109            label: label.to_string(),
1110            else_sections,
1111        });
1112    }
1113}
1114
1115/// Find participants involved in a list of items (returns min/max edges and whether leftmost is included)
1116fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
1117    let mut min_left: Option<f64> = None;
1118    let mut max_right: Option<f64> = None;
1119    let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
1120    let mut includes_leftmost = false;
1121
1122    fn update_bounds(
1123        participant: &str,
1124        state: &RenderState,
1125        min_left: &mut Option<f64>,
1126        max_right: &mut Option<f64>,
1127        includes_leftmost: &mut bool,
1128        leftmost_id: &str,
1129    ) {
1130        let x = state.get_x(participant);
1131        if x > 0.0 {
1132            let width = state.get_participant_width(participant);
1133            let left = x - width / 2.0;
1134            let right = x + width / 2.0;
1135            *min_left = Some(min_left.map_or(left, |m| m.min(left)));
1136            *max_right = Some(max_right.map_or(right, |m| m.max(right)));
1137            if participant == leftmost_id {
1138                *includes_leftmost = true;
1139            }
1140        }
1141    }
1142
1143    fn process_items(
1144        items: &[Item],
1145        state: &RenderState,
1146        min_left: &mut Option<f64>,
1147        max_right: &mut Option<f64>,
1148        includes_leftmost: &mut bool,
1149        leftmost_id: &str,
1150    ) {
1151        for item in items {
1152            match item {
1153                Item::Message { from, to, .. } => {
1154                    update_bounds(
1155                        from,
1156                        state,
1157                        min_left,
1158                        max_right,
1159                        includes_leftmost,
1160                        leftmost_id,
1161                    );
1162                    update_bounds(
1163                        to,
1164                        state,
1165                        min_left,
1166                        max_right,
1167                        includes_leftmost,
1168                        leftmost_id,
1169                    );
1170                }
1171                Item::Note { participants, .. } => {
1172                    for p in participants {
1173                        update_bounds(
1174                            p,
1175                            state,
1176                            min_left,
1177                            max_right,
1178                            includes_leftmost,
1179                            leftmost_id,
1180                        );
1181                    }
1182                }
1183                Item::Block {
1184                    items, else_sections, ..
1185                } => {
1186                    process_items(
1187                        items,
1188                        state,
1189                        min_left,
1190                        max_right,
1191                        includes_leftmost,
1192                        leftmost_id,
1193                    );
1194                    for section in else_sections {
1195                        process_items(
1196                            &section.items,
1197                            state,
1198                            min_left,
1199                            max_right,
1200                            includes_leftmost,
1201                            leftmost_id,
1202                        );
1203                    }
1204                }
1205                Item::Activate { participant }
1206                | Item::Deactivate { participant }
1207                | Item::Destroy { participant } => {
1208                    update_bounds(
1209                        participant,
1210                        state,
1211                        min_left,
1212                        max_right,
1213                        includes_leftmost,
1214                        leftmost_id,
1215                    );
1216                }
1217                _ => {}
1218            }
1219        }
1220    }
1221
1222    process_items(
1223        items,
1224        state,
1225        &mut min_left,
1226        &mut max_right,
1227        &mut includes_leftmost,
1228        leftmost_id,
1229    );
1230
1231    match (min_left, max_right) {
1232        (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
1233        _ => None,
1234    }
1235}
1236
1237/// Padding for nested blocks inside parent
1238const NESTED_BLOCK_INSET: f64 = 5.0;
1239
1240/// Calculate block x boundaries based on involved participants and label length
1241fn calculate_block_bounds_with_label(
1242    items: &[Item],
1243    else_sections: &[crate::ast::ElseSection],
1244    label: &str,
1245    kind: &str,
1246    _depth: usize,
1247    state: &RenderState,
1248    parent_bounds: Option<(f64, f64)>,
1249) -> (f64, f64) {
1250    let mut all_items: Vec<&Item> = items.iter().collect();
1251    for section in else_sections {
1252        all_items.extend(section.items.iter());
1253    }
1254
1255    // Convert Vec<&Item> to slice for find_involved_participants
1256    let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1257
1258    // If we have parent bounds, apply nested inset as constraint
1259    let (constraint_x1, constraint_x2) = if let Some((px1, px2)) = parent_bounds {
1260        (px1 + NESTED_BLOCK_INSET, px2 - NESTED_BLOCK_INSET)
1261    } else {
1262        (state.block_left(), state.block_right())
1263    };
1264
1265    let (base_x1, base_x2) =
1266        if let Some((min_left, max_right, _includes_leftmost)) =
1267            find_involved_participants(&items_slice, state)
1268        {
1269            let margin = state.config.block_margin;
1270            // Apply parent constraint: nested block must be within parent bounds
1271            let x1 = (min_left - margin).max(constraint_x1);
1272            let x2 = (max_right + margin).min(constraint_x2);
1273            (x1, x2)
1274        } else {
1275            // Fallback to constrained width if no participants found
1276            (constraint_x1, constraint_x2)
1277        };
1278
1279    // Calculate minimum width needed for label
1280    // Pentagon width + gap + condition label width + right margin
1281    let pentagon_width = block_tab_width(kind);
1282    let label_font_size = state.config.font_size - 1.0;
1283    let label_padding_x = 6.0;
1284    let condition_width = if label.is_empty() {
1285        0.0
1286    } else {
1287        let condition_text = format!("[{}]", label);
1288        let base_width =
1289            (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1290        base_width + label_padding_x * 2.0
1291    };
1292
1293    // Calculate max else label width (else labels start at same X as condition label)
1294    let mut max_else_label_width = 0.0f64;
1295    for section in else_sections {
1296        if let Some(el) = &section.label {
1297            if !el.is_empty() {
1298                let else_text = format!("[{}]", el);
1299                let base_width =
1300                    (estimate_text_width(&else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1301                let width = base_width + label_padding_x * 2.0;
1302                max_else_label_width = max_else_label_width.max(width);
1303            }
1304        }
1305    }
1306
1307    // Use the wider of all labels
1308    let max_label_content_width = condition_width.max(max_else_label_width);
1309    let min_label_width = pentagon_width + 8.0 + max_label_content_width + 20.0; // Extra right margin
1310
1311    // Calculate available width within parent constraint
1312    let available_width = constraint_x2 - constraint_x1;
1313
1314    // Ensure block is wide enough for the label, but don't exceed parent bounds
1315    let current_width = base_x2 - base_x1;
1316    let (x1, x2) = if current_width < min_label_width {
1317        // Extend the right side to accommodate the label, but respect parent bounds
1318        let desired_x2 = base_x1 + min_label_width;
1319        if desired_x2 <= constraint_x2 {
1320            (base_x1, desired_x2)
1321        } else if min_label_width <= available_width {
1322            // Shift left to fit within parent
1323            (constraint_x2 - min_label_width, constraint_x2)
1324        } else {
1325            // Label is wider than available space, use full parent width
1326            (constraint_x1, constraint_x2)
1327        }
1328    } else {
1329        (base_x1, base_x2)
1330    };
1331
1332    (x1, x2)
1333}
1334
1335/// Pre-calculate block backgrounds by doing a dry run
1336fn collect_block_backgrounds(
1337    state: &mut RenderState,
1338    items: &[Item],
1339    depth: usize,
1340    active_activation_count: &mut usize,
1341    parent_bounds: Option<(f64, f64)>,
1342) {
1343    for item in items {
1344        match item {
1345            Item::Message {
1346                text,
1347                from,
1348                to,
1349                arrow,
1350                activate,
1351                deactivate,
1352                create,
1353                ..
1354            } => {
1355                state.apply_else_return_gap(arrow);
1356                let is_self = from == to;
1357                let line_count = text.split("\\n").count();
1358                let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1359
1360                if is_self {
1361                    state.current_y += self_message_y_advance(&state.config, line_count);
1362                } else {
1363                    state.current_y += regular_message_y_advance(&state.config, line_count, delay_offset);
1364                }
1365
1366                if *create {
1367                    state.current_y += state.config.row_height;
1368                }
1369
1370                state.apply_serial_first_row_gap();
1371                if *activate {
1372                    *active_activation_count += 1;
1373                }
1374                if *deactivate && *active_activation_count > 0 {
1375                    *active_activation_count -= 1;
1376                }
1377            }
1378            Item::Note { text, .. } => {
1379                let line_count = text.split("\\n").count();
1380                state.current_y += note_y_advance(&state.config, line_count);
1381            }
1382            Item::State { text, .. } => {
1383                let line_count = text.split("\\n").count();
1384                state.current_y += state_y_advance(&state.config, line_count);
1385            }
1386            Item::Ref { text, .. } => {
1387                let line_count = text.split("\\n").count();
1388                state.current_y += ref_y_advance(&state.config, line_count);
1389            }
1390            Item::Description { text } => {
1391                let line_count = text.split("\\n").count();
1392                state.current_y += description_y_advance(&state.config, line_count);
1393            }
1394            Item::Destroy { .. } => {
1395                state.current_y += state.config.row_height;
1396            }
1397            Item::Activate { .. } => {
1398                *active_activation_count += 1;
1399            }
1400            Item::Deactivate { .. } => {
1401                if *active_activation_count > 0 {
1402                    *active_activation_count -= 1;
1403                }
1404            }
1405            Item::Block {
1406                kind,
1407                label,
1408                items,
1409                else_sections,
1410            } => {
1411                if block_is_parallel(kind) {
1412                    state.push_parallel();
1413                    let start_y = state.current_y;
1414                    let mut max_end_y = start_y;
1415                    let start_activation_count = *active_activation_count;
1416                    for item in items {
1417                        state.current_y = start_y;
1418                        *active_activation_count = start_activation_count;
1419                        collect_block_backgrounds(
1420                            state,
1421                            std::slice::from_ref(item),
1422                            depth,
1423                            active_activation_count,
1424                            parent_bounds,
1425                        );
1426                        if state.current_y > max_end_y {
1427                            max_end_y = state.current_y;
1428                        }
1429                    }
1430                    *active_activation_count = start_activation_count;
1431                    let gap = if parallel_needs_gap(items) {
1432                        state.config.row_height
1433                    } else {
1434                        0.0
1435                    };
1436                    state.current_y = max_end_y + gap;
1437                    state.pop_parallel();
1438                    continue;
1439                }
1440
1441                if matches!(kind, BlockKind::Serial) {
1442                    state.push_serial_first_row_pending();
1443                    collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1444                    for section in else_sections {
1445                        collect_block_backgrounds(
1446                            state,
1447                            &section.items,
1448                            depth,
1449                            active_activation_count,
1450                            parent_bounds,
1451                        );
1452                    }
1453                    state.pop_serial_first_row_pending();
1454                    continue;
1455                }
1456
1457                if !block_has_frame(kind) {
1458                    collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1459                    for section in else_sections {
1460                        collect_block_backgrounds(
1461                            state,
1462                            &section.items,
1463                            depth,
1464                            active_activation_count,
1465                            parent_bounds,
1466                        );
1467                    }
1468                    continue;
1469                }
1470
1471                let start_y = state.current_y;
1472                let frame_shift = block_frame_shift(depth);
1473                let frame_start_y = start_y - frame_shift;
1474
1475                // Calculate bounds based on involved participants and label width
1476                let (x1, x2) = calculate_block_bounds_with_label(
1477                    items,
1478                    else_sections,
1479                    label,
1480                    kind.as_str(),
1481                    depth,
1482                    state,
1483                    parent_bounds,
1484                );
1485
1486                state.current_y += block_header_space(&state.config, depth);
1487                // Pass current block bounds as parent bounds to nested blocks
1488                collect_block_backgrounds(state, items, depth + 1, active_activation_count, Some((x1, x2)));
1489
1490                // Process each else section
1491                let mut else_section_info: Vec<(f64, Option<String>)> = Vec::new();
1492                for section in else_sections {
1493                    // Add padding before else line
1494                    state.current_y += block_else_before(&state.config, depth);
1495                    let else_y = state.current_y;
1496                    else_section_info.push((else_y, section.label.clone()));
1497
1498                    state.push_else_return_pending();
1499                    // Add padding after else line
1500                    state.current_y += block_else_after(&state.config, depth);
1501                    // Pass current block bounds as parent bounds to nested blocks
1502                    collect_block_backgrounds(
1503                        state,
1504                        &section.items,
1505                        depth + 1,
1506                        active_activation_count,
1507                        Some((x1, x2)),
1508                    );
1509                    state.pop_else_return_pending();
1510                }
1511
1512                // Block bottom = current Y + footer padding
1513                // (prevent messages from overflowing block)
1514                let end_y = state.current_y + block_footer_padding(&state.config, depth);
1515                let frame_end_y = end_y - frame_shift;
1516                state.current_y = end_y + state.config.row_height;
1517
1518                // Collect this block's background
1519                state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1520                // Collect this block's label for rendering above activations/lifelines
1521                state.add_block_label(
1522                    x1,
1523                    frame_start_y,
1524                    frame_end_y,
1525                    x2,
1526                    kind.as_str(),
1527                    label,
1528                    else_section_info,
1529                );
1530            }
1531            _ => {}
1532        }
1533    }
1534}
1535
1536/// Render all collected block backgrounds
1537fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1538    let theme = &state.config.theme;
1539    for bg in &state.block_backgrounds {
1540        writeln!(
1541            svg,
1542            r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1543            x = bg.x,
1544            y = bg.y,
1545            w = bg.width,
1546            h = bg.height,
1547            fill = theme.block_fill
1548        )
1549        .unwrap();
1550    }
1551}
1552
1553/// Render all collected block labels (frame, pentagon, condition text, else divider)
1554/// This is called AFTER activations are drawn so labels appear on top
1555fn render_block_labels(svg: &mut String, state: &RenderState) {
1556    let theme = &state.config.theme;
1557
1558    for bl in &state.block_labels {
1559        let x1 = bl.x1;
1560        let x2 = bl.x2;
1561        let start_y = bl.start_y;
1562        let end_y = bl.end_y;
1563
1564        // Draw block frame
1565        writeln!(
1566            svg,
1567            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1568            x = x1,
1569            y = start_y,
1570            w = x2 - x1,
1571            h = end_y - start_y
1572        )
1573        .unwrap();
1574
1575        // Pentagon/tab-shaped label (WSD style)
1576        let label_text = &bl.kind;
1577        let label_width = block_tab_width(label_text);
1578        let label_height = BLOCK_LABEL_HEIGHT;
1579        let label_text_offset = 16.0;
1580        let notch_size = 5.0;
1581
1582        // Pentagon path
1583        let pentagon_path = format!(
1584            "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1585            x1 = x1,
1586            y1 = start_y,
1587            x2 = x1 + label_width,
1588            y2 = start_y + label_height - notch_size,
1589            x3 = x1 + label_width - notch_size,
1590            y3 = start_y + label_height
1591        );
1592
1593        writeln!(
1594            svg,
1595            r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1596            path = pentagon_path,
1597            fill = theme.block_label_fill,
1598            stroke = theme.block_stroke
1599        )
1600        .unwrap();
1601
1602        // Block type label text
1603        writeln!(
1604            svg,
1605            r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1606            x = x1 + 5.0,
1607            y = start_y + label_text_offset,
1608            kind = label_text
1609        )
1610        .unwrap();
1611
1612        // Condition label (text only, no background per WSD style)
1613        if !bl.label.is_empty() {
1614            let condition_text = format!("[{}]", bl.label);
1615            let text_x = x1 + label_width + 8.0;
1616            let text_y = start_y + label_text_offset;
1617
1618            writeln!(
1619                svg,
1620                r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1621                x = text_x,
1622                y = text_y,
1623                label = escape_xml(&condition_text)
1624            )
1625            .unwrap();
1626        }
1627
1628        // Else separators (dashed line + else label text for each else section)
1629        for (else_y, else_label_opt) in &bl.else_sections {
1630            // Draw dashed line
1631            writeln!(
1632                svg,
1633                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1634                x1 = x1,
1635                y = else_y,
1636                x2 = x2,
1637                c = theme.block_stroke
1638            )
1639            .unwrap();
1640
1641            // Draw else label text at same X position as alt condition label
1642            if let Some(else_label_text) = else_label_opt {
1643                let condition_text = format!("[{}]", else_label_text);
1644                let text_x = x1 + label_width + 8.0; // Same X as alt's [Success case]
1645                let text_y = else_y + label_text_offset; // Below the dashed line
1646
1647                writeln!(
1648                    svg,
1649                    r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1650                    x = text_x,
1651                    y = text_y,
1652                    label = escape_xml(&condition_text)
1653                )
1654                .unwrap();
1655            }
1656        }
1657    }
1658}
1659
1660/// Render a diagram to SVG
1661pub fn render(diagram: &Diagram) -> String {
1662    render_with_config(diagram, Config::default())
1663}
1664
1665/// Render a diagram to SVG with custom config
1666pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1667    let participants = diagram.participants();
1668    let has_title = diagram.title.is_some();
1669    let footer_style = diagram.options.footer;
1670    let mut state = RenderState::new(
1671        config,
1672        participants,
1673        &diagram.items,
1674        has_title,
1675        footer_style,
1676    );
1677    let mut svg = String::new();
1678
1679    // Pre-calculate height
1680    let content_height = calculate_height(&diagram.items, &state.config, 0);
1681    let title_space = if has_title {
1682        state.config.title_height
1683    } else {
1684        0.0
1685    };
1686    let footer_space = match footer_style {
1687        FooterStyle::Box => state.config.header_height,
1688        FooterStyle::Bar | FooterStyle::None => 0.0,
1689    };
1690    let footer_label_extra = match footer_style {
1691        FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1692        FooterStyle::Bar | FooterStyle::None => 0.0,
1693    };
1694    let footer_margin = state.config.row_height; // Space between content and footer
1695    let base_total_height = state.config.padding * 2.0
1696        + title_space
1697        + state.config.header_height
1698        + content_height
1699        + footer_margin
1700        + footer_space;
1701    let total_height = base_total_height + footer_label_extra;
1702
1703    // Pre-calculate block backgrounds to determine max width (before SVG header)
1704    state.current_y = state.content_start();
1705    let mut active_activation_count = 0;
1706    collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count, None);
1707
1708    let total_width = state.diagram_width();
1709
1710    // SVG header
1711    writeln!(
1712        &mut svg,
1713        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1714        w = total_width,
1715        h = total_height
1716    )
1717    .unwrap();
1718
1719    // Styles based on theme
1720    let theme = &state.config.theme;
1721    let lifeline_dash = match theme.lifeline_style {
1722        LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1723        LifelineStyle::Solid => "",
1724    };
1725
1726    svg.push_str("<defs>\n");
1727    svg.push_str("<style>\n");
1728    writeln!(
1729        &mut svg,
1730        ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1731        fill = theme.participant_fill,
1732        stroke = theme.participant_stroke
1733    )
1734    .unwrap();
1735    writeln!(
1736        &mut svg,
1737        ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1738        f = theme.font_family,
1739        s = state.config.font_size,
1740        c = theme.participant_text
1741    )
1742    .unwrap();
1743    writeln!(
1744        &mut svg,
1745        ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1746        c = theme.lifeline_color,
1747        dash = lifeline_dash
1748    )
1749    .unwrap();
1750    writeln!(
1751        &mut svg,
1752        ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1753        c = theme.message_color
1754    )
1755    .unwrap();
1756    writeln!(
1757        &mut svg,
1758        ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1759        c = theme.message_color
1760    )
1761    .unwrap();
1762    writeln!(
1763        &mut svg,
1764        ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1765        f = theme.font_family,
1766        s = state.config.font_size,
1767        c = theme.message_text_color
1768    )
1769    .unwrap();
1770    writeln!(
1771        &mut svg,
1772        ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1773        fill = theme.note_fill,
1774        stroke = theme.note_stroke
1775    )
1776    .unwrap();
1777    writeln!(
1778        &mut svg,
1779        ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1780        f = theme.font_family,
1781        s = state.config.font_size - 1.0,
1782        c = theme.note_text_color
1783    )
1784    .unwrap();
1785    writeln!(
1786        &mut svg,
1787        ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1788        c = theme.block_stroke
1789    )
1790    .unwrap();
1791    writeln!(
1792        &mut svg,
1793        ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1794        f = theme.font_family,
1795        s = state.config.font_size - 1.0,
1796        c = theme.message_text_color
1797    )
1798    .unwrap();
1799    writeln!(
1800        &mut svg,
1801        ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1802        fill = theme.activation_fill,
1803        stroke = theme.activation_stroke
1804    )
1805    .unwrap();
1806    writeln!(
1807        &mut svg,
1808        ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1809        fill = theme.actor_fill,
1810        stroke = theme.actor_stroke
1811    )
1812    .unwrap();
1813    writeln!(
1814        &mut svg,
1815        ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1816        c = theme.actor_stroke
1817    )
1818    .unwrap();
1819    writeln!(
1820        &mut svg,
1821        ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1822        f = theme.font_family,
1823        s = state.config.font_size + 4.0,
1824        c = theme.message_text_color
1825    )
1826    .unwrap();
1827    // Arrowhead styles
1828    writeln!(
1829        &mut svg,
1830        ".arrowhead {{ fill: {c}; stroke: none; }}",
1831        c = theme.message_color
1832    )
1833    .unwrap();
1834    writeln!(
1835        &mut svg,
1836        ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1837        c = theme.message_color
1838    )
1839    .unwrap();
1840    svg.push_str("</style>\n");
1841    svg.push_str("</defs>\n");
1842
1843    // Background with theme color
1844    writeln!(
1845        &mut svg,
1846        r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1847        bg = theme.background
1848    )
1849    .unwrap();
1850
1851    // Title
1852    if let Some(title) = &diagram.title {
1853        let title_y = state.config.padding + state.config.font_size + 7.36; // WSD: 31.86
1854        writeln!(
1855            &mut svg,
1856            r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1857            x = total_width / 2.0,
1858            y = title_y,
1859            t = escape_xml(title)
1860        )
1861        .unwrap();
1862    }
1863
1864    // Calculate footer position
1865    let header_y = state.header_top();
1866    let footer_y = match footer_style {
1867        FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1868        FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1869    };
1870
1871    // Draw block backgrounds FIRST (behind lifelines)
1872    // (block_backgrounds already calculated above for width calculation)
1873    render_block_backgrounds(&mut svg, &state);
1874
1875    // Reset current_y for actual rendering
1876    state.current_y = state.content_start();
1877
1878    // Draw lifelines (behind messages but above block backgrounds)
1879    let lifeline_start = header_y + state.config.header_height;
1880    let lifeline_end = footer_y;
1881
1882    for p in &state.participants {
1883        let x = state.get_x(p.id());
1884        writeln!(
1885            &mut svg,
1886            r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1887            x = x,
1888            y1 = lifeline_start,
1889            y2 = lifeline_end
1890        )
1891        .unwrap();
1892    }
1893
1894    // Draw participant headers
1895    render_participant_headers(&mut svg, &state, header_y);
1896
1897    // Render items
1898    state.current_y = state.content_start();
1899    render_items(&mut svg, &mut state, &diagram.items, 0);
1900
1901    // Draw activation bars
1902    render_activations(&mut svg, &mut state, footer_y);
1903
1904    // Draw block labels AFTER activations so they appear on top
1905    render_block_labels(&mut svg, &state);
1906
1907    // Draw participant footers based on footer style option
1908    match state.footer_style {
1909        FooterStyle::Box => {
1910            render_participant_headers(&mut svg, &state, footer_y);
1911        }
1912        FooterStyle::Bar => {
1913            // Draw simple horizontal line across all participants
1914            let left = state.leftmost_x()
1915                - state.get_participant_width(
1916                    state.participants.first().map(|p| p.id()).unwrap_or(""),
1917                ) / 2.0;
1918            let right = state.rightmost_x()
1919                + state
1920                    .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1921                    / 2.0;
1922            writeln!(
1923                &mut svg,
1924                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1925                x1 = left,
1926                y = footer_y,
1927                x2 = right,
1928                c = state.config.theme.lifeline_color
1929            )
1930            .unwrap();
1931        }
1932        FooterStyle::None => {
1933            // No footer at all
1934        }
1935    }
1936
1937    svg.push_str("</svg>\n");
1938    svg
1939}
1940
1941fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1942    fn inner(
1943        items: &[Item],
1944        config: &Config,
1945        depth: usize,
1946        else_pending: &mut Vec<bool>,
1947        serial_pending: &mut Vec<bool>,
1948        active_activation_count: &mut usize,
1949        parallel_depth: &mut usize,
1950    ) -> f64 {
1951        let mut height = 0.0;
1952        for item in items {
1953            match item {
1954                Item::Message {
1955                    from,
1956                    to,
1957                    text,
1958                    arrow,
1959                    create,
1960                    activate,
1961                    deactivate,
1962                    ..
1963                } => {
1964                    if let Some(pending) = else_pending.last_mut() {
1965                        if *pending && matches!(arrow.line, LineStyle::Dashed) {
1966                            *pending = false;
1967                        }
1968                    }
1969                    let is_self = from == to;
1970                    let line_count = text.split("\\n").count();
1971                    let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1972                    if is_self {
1973                        height += self_message_y_advance(config, line_count);
1974                    } else {
1975                        height += regular_message_y_advance(config, line_count, delay_offset);
1976                    }
1977                    if *create {
1978                        height += config.row_height;
1979                    }
1980                    if let Some(pending) = serial_pending.last_mut() {
1981                        if *pending {
1982                            height += serial_first_row_gap(*parallel_depth);
1983                            *pending = false;
1984                        }
1985                    }
1986                    if *activate {
1987                        *active_activation_count += 1;
1988                    }
1989                    if *deactivate && *active_activation_count > 0 {
1990                        *active_activation_count -= 1;
1991                    }
1992                }
1993                Item::Note { text, .. } => {
1994                    let line_count = text.split("\\n").count();
1995                    height += note_y_advance(config, line_count);
1996                }
1997                Item::State { text, .. } => {
1998                    let line_count = text.split("\\n").count();
1999                    height += state_y_advance(config, line_count);
2000                }
2001                Item::Ref { text, .. } => {
2002                    let line_count = text.split("\\n").count();
2003                    height += ref_y_advance(config, line_count);
2004                }
2005                Item::Description { text } => {
2006                    let line_count = text.split("\\n").count();
2007                    height += description_y_advance(config, line_count);
2008                }
2009                Item::Block {
2010                    kind,
2011                    items,
2012                    else_sections,
2013                    ..
2014                } => {
2015                    if block_is_parallel(kind) {
2016                        let mut max_branch_height = 0.0;
2017                        let base_activation_count = *active_activation_count;
2018                        *parallel_depth += 1;
2019                        for item in items {
2020                            *active_activation_count = base_activation_count;
2021                            let branch_height = inner(
2022                                std::slice::from_ref(item),
2023                                config,
2024                                depth,
2025                                else_pending,
2026                                serial_pending,
2027                                active_activation_count,
2028                                parallel_depth,
2029                            );
2030                            if branch_height > max_branch_height {
2031                                max_branch_height = branch_height;
2032                            }
2033                        }
2034                        *active_activation_count = base_activation_count;
2035                        if *parallel_depth > 0 {
2036                            *parallel_depth -= 1;
2037                        }
2038                        let gap = if parallel_needs_gap(items) {
2039                            config.row_height
2040                        } else {
2041                            0.0
2042                        };
2043                        height += max_branch_height + gap;
2044                        continue;
2045                    }
2046
2047                    if matches!(kind, BlockKind::Serial) {
2048                        serial_pending.push(true);
2049                        height += inner(
2050                            items,
2051                            config,
2052                            depth,
2053                            else_pending,
2054                            serial_pending,
2055                            active_activation_count,
2056                            parallel_depth,
2057                        );
2058                        for else_section in else_sections {
2059                            height += inner(
2060                                &else_section.items,
2061                                config,
2062                                depth,
2063                                else_pending,
2064                                serial_pending,
2065                                active_activation_count,
2066                                parallel_depth,
2067                            );
2068                        }
2069                        serial_pending.pop();
2070                    } else if !block_has_frame(kind) {
2071                        height += inner(
2072                            items,
2073                            config,
2074                            depth,
2075                            else_pending,
2076                            serial_pending,
2077                            active_activation_count,
2078                            parallel_depth,
2079                        );
2080                        for else_section in else_sections {
2081                            height += inner(
2082                                &else_section.items,
2083                                config,
2084                                depth,
2085                                else_pending,
2086                                serial_pending,
2087                                active_activation_count,
2088                                parallel_depth,
2089                            );
2090                        }
2091                    } else {
2092                        height += block_header_space(config, depth);
2093                        height += inner(
2094                            items,
2095                            config,
2096                            depth + 1,
2097                            else_pending,
2098                            serial_pending,
2099                            active_activation_count,
2100                            parallel_depth,
2101                        );
2102                        for else_section in else_sections {
2103                            else_pending.push(true);
2104                            // Padding before and after else line
2105                            height += block_else_before(config, depth) + block_else_after(config, depth);
2106                            height += inner(
2107                                &else_section.items,
2108                                config,
2109                                depth + 1,
2110                                else_pending,
2111                                serial_pending,
2112                                active_activation_count,
2113                                parallel_depth,
2114                            );
2115                            else_pending.pop();
2116                        }
2117                        // Block bottom and trailing margin
2118                        height += block_end_y_advance(config, depth);
2119                    }
2120                }
2121                Item::Activate { .. } => {
2122                    *active_activation_count += 1;
2123                }
2124                Item::Deactivate { .. } => {
2125                    if *active_activation_count > 0 {
2126                        *active_activation_count -= 1;
2127                    }
2128                }
2129                Item::Destroy { .. } => {
2130                    height += config.row_height;
2131                }
2132                Item::ParticipantDecl { .. } => {}
2133                Item::Autonumber { .. } => {}
2134                Item::DiagramOption { .. } => {} // Options don't take space
2135            }
2136        }
2137        height
2138    }
2139
2140    let mut else_pending = Vec::new();
2141    let mut serial_pending = Vec::new();
2142    let mut active_activation_count = 0;
2143    let mut parallel_depth = 0;
2144    inner(
2145        items,
2146        config,
2147        depth,
2148        &mut else_pending,
2149        &mut serial_pending,
2150        &mut active_activation_count,
2151        &mut parallel_depth,
2152    )
2153}
2154
2155fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
2156    let shape = state.config.theme.participant_shape;
2157
2158    for p in &state.participants {
2159        let x = state.get_x(p.id());
2160        let p_width = state.get_participant_width(p.id());
2161        let box_x = x - p_width / 2.0;
2162
2163        match p.kind {
2164            ParticipantKind::Participant => {
2165                // Draw shape based on theme
2166                match shape {
2167                    ParticipantShape::Rectangle => {
2168                        writeln!(
2169                            svg,
2170                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2171                            x = box_x,
2172                            y = y,
2173                            w = p_width,
2174                            h = state.config.header_height
2175                        )
2176                        .unwrap();
2177                    }
2178                    ParticipantShape::RoundedRect => {
2179                        writeln!(
2180                            svg,
2181                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2182                            x = box_x,
2183                            y = y,
2184                            w = p_width,
2185                            h = state.config.header_height
2186                        )
2187                        .unwrap();
2188                    }
2189                    ParticipantShape::Circle => {
2190                        // Draw ellipse/circle that fits in the header area
2191                        let rx = p_width / 2.0 - 5.0;
2192                        let ry = state.config.header_height / 2.0 - 2.0;
2193                        writeln!(
2194                            svg,
2195                            r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2196                            cx = x,
2197                            cy = y + state.config.header_height / 2.0,
2198                            rx = rx,
2199                            ry = ry
2200                        )
2201                        .unwrap();
2202                    }
2203                }
2204                // Name centered in box (handle multiline with \n)
2205                let lines: Vec<&str> = p.name.split("\\n").collect();
2206                if lines.len() == 1 {
2207                    writeln!(
2208                        svg,
2209                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2210                        x = x,
2211                        y = y + state.config.header_height / 2.0 + 5.0,
2212                        name = escape_xml(&p.name)
2213                    )
2214                    .unwrap();
2215                } else {
2216                    let line_height = state.config.font_size + 2.0;
2217                    let total_height = lines.len() as f64 * line_height;
2218                    let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2219                        + line_height * 0.8;
2220                    write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2221                    for (i, line) in lines.iter().enumerate() {
2222                        let dy = if i == 0 { start_y } else { line_height };
2223                        if i == 0 {
2224                            writeln!(
2225                                svg,
2226                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2227                                x = x,
2228                                y = dy,
2229                                text = escape_xml(line)
2230                            )
2231                            .unwrap();
2232                        } else {
2233                            writeln!(
2234                                svg,
2235                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2236                                x = x,
2237                                dy = dy,
2238                                text = escape_xml(line)
2239                            )
2240                            .unwrap();
2241                        }
2242                    }
2243                    writeln!(svg, "</text>").unwrap();
2244                }
2245            }
2246            ParticipantKind::Actor => {
2247                // Stick figure at top of header area, name below within header
2248                let head_r = 8.0;
2249                let body_len = 12.0;
2250                let arm_len = 10.0;
2251                let leg_len = 10.0;
2252                let figure_height = 38.0; // head(16) + body(12) + legs(10)
2253
2254                // Position figure at top with small margin
2255                let fig_top = y + 8.0;
2256                let fig_center_y = fig_top + head_r + body_len / 2.0;
2257                let arm_y = fig_center_y + 2.0;
2258
2259                // Head
2260                writeln!(
2261                    svg,
2262                    r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2263                    x = x,
2264                    cy = fig_center_y - body_len / 2.0 - head_r,
2265                    r = head_r
2266                )
2267                .unwrap();
2268                // Body
2269                writeln!(
2270                    svg,
2271                    r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2272                    x = x,
2273                    y1 = fig_center_y - body_len / 2.0,
2274                    y2 = fig_center_y + body_len / 2.0
2275                )
2276                .unwrap();
2277                // Arms
2278                writeln!(
2279                    svg,
2280                    r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2281                    x1 = x - arm_len,
2282                    y = arm_y,
2283                    x2 = x + arm_len
2284                )
2285                .unwrap();
2286                // Left leg
2287                writeln!(
2288                    svg,
2289                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2290                    x = x,
2291                    y1 = fig_center_y + body_len / 2.0,
2292                    x2 = x - leg_len * 0.6,
2293                    y2 = fig_center_y + body_len / 2.0 + leg_len
2294                )
2295                .unwrap();
2296                // Right leg
2297                writeln!(
2298                    svg,
2299                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2300                    x = x,
2301                    y1 = fig_center_y + body_len / 2.0,
2302                    x2 = x + leg_len * 0.6,
2303                    y2 = fig_center_y + body_len / 2.0 + leg_len
2304                )
2305                .unwrap();
2306                // Name below figure (within header)
2307                let name_lines: Vec<&str> = p.name.split("\\n").collect();
2308                let name_start_y = fig_top + figure_height + 5.0;
2309                if name_lines.len() == 1 {
2310                    writeln!(
2311                        svg,
2312                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2313                        x = x,
2314                        y = name_start_y + state.config.font_size,
2315                        name = escape_xml(&p.name)
2316                    )
2317                    .unwrap();
2318                } else {
2319                    // Multiline actor name using tspan
2320                    let line_height = state.config.font_size + 2.0;
2321                    writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2322                    for (i, line) in name_lines.iter().enumerate() {
2323                        if i == 0 {
2324                            writeln!(
2325                                svg,
2326                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2327                                x = x,
2328                                y = name_start_y + state.config.font_size,
2329                                text = escape_xml(line)
2330                            )
2331                            .unwrap();
2332                        } else {
2333                            writeln!(
2334                                svg,
2335                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2336                                x = x,
2337                                dy = line_height,
2338                                text = escape_xml(line)
2339                            )
2340                            .unwrap();
2341                        }
2342                    }
2343                    writeln!(svg, "</text>").unwrap();
2344                }
2345            }
2346        }
2347    }
2348}
2349
2350fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2351    for item in items {
2352        match item {
2353            Item::Message {
2354                from,
2355                to,
2356                text,
2357                arrow,
2358                activate,
2359                deactivate,
2360                create,
2361                ..
2362            } => {
2363                render_message(
2364                    svg,
2365                    state,
2366                    from,
2367                    to,
2368                    text,
2369                    arrow,
2370                    *activate,
2371                    *deactivate,
2372                    *create,
2373                    depth,
2374                );
2375            }
2376            Item::Note {
2377                position,
2378                participants,
2379                text,
2380            } => {
2381                render_note(svg, state, position, participants, text);
2382            }
2383            Item::Block {
2384                kind,
2385                label,
2386                items,
2387                else_sections,
2388            } => {
2389                render_block(svg, state, kind, label, items, else_sections, depth);
2390            }
2391            Item::Activate { participant } => {
2392                let y = state.current_y;
2393                state
2394                    .activations
2395                    .entry(participant.clone())
2396                    .or_default()
2397                    .push((y, None));
2398            }
2399            Item::Deactivate { participant } => {
2400                if let Some(acts) = state.activations.get_mut(participant) {
2401                    if let Some(act) = acts.last_mut() {
2402                        if act.1.is_none() {
2403                            act.1 = Some(state.current_y);
2404                        }
2405                    }
2406                }
2407            }
2408            Item::Destroy { participant } => {
2409                // X mark should be at the previous message's arrow position (WSD compatible)
2410                // After a message, current_y is incremented by row_height, so we subtract it back
2411                let destroy_y = state.current_y - state.config.row_height;
2412                state.destroyed.insert(participant.clone(), destroy_y);
2413                // Draw X mark on the lifeline
2414                let x = state.get_x(participant);
2415                let y = destroy_y;
2416                let size = 15.0; // WSD uses 15px for X mark size
2417                let theme = &state.config.theme;
2418                writeln!(
2419                    svg,
2420                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2421                    x1 = x - size,
2422                    y1 = y - size,
2423                    x2 = x + size,
2424                    y2 = y + size,
2425                    stroke = theme.message_color
2426                )
2427                .unwrap();
2428                writeln!(
2429                    svg,
2430                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2431                    x1 = x + size,
2432                    y1 = y - size,
2433                    x2 = x - size,
2434                    y2 = y + size,
2435                    stroke = theme.message_color
2436                )
2437                .unwrap();
2438                state.current_y += state.config.row_height;
2439            }
2440            Item::Autonumber { enabled, start } => {
2441                if *enabled {
2442                    state.autonumber = Some(start.unwrap_or(1));
2443                } else {
2444                    state.autonumber = None;
2445                }
2446            }
2447            Item::ParticipantDecl { .. } => {
2448                // Already processed
2449            }
2450            Item::State { participants, text } => {
2451                render_state(svg, state, participants, text);
2452            }
2453            Item::Ref {
2454                participants,
2455                text,
2456                input_from,
2457                input_label,
2458                output_to,
2459                output_label,
2460            } => {
2461                render_ref(
2462                    svg,
2463                    state,
2464                    participants,
2465                    text,
2466                    input_from.as_deref(),
2467                    input_label.as_deref(),
2468                    output_to.as_deref(),
2469                    output_label.as_deref(),
2470                );
2471            }
2472            Item::DiagramOption { .. } => {
2473                // Options are processed at render start, not during item rendering
2474            }
2475            Item::Description { text } => {
2476                render_description(svg, state, text);
2477            }
2478        }
2479    }
2480}
2481
2482fn render_message(
2483    svg: &mut String,
2484    state: &mut RenderState,
2485    from: &str,
2486    to: &str,
2487    text: &str,
2488    arrow: &Arrow,
2489    activate: bool,
2490    deactivate: bool,
2491    create: bool,
2492    _depth: usize,
2493) {
2494    // Get base lifeline positions (used for text centering and direction calculation)
2495    let base_x1 = state.get_x(from);
2496    let base_x2 = state.get_x(to);
2497
2498    state.apply_else_return_gap(arrow);
2499
2500    let is_self = from == to;
2501    let line_class = match arrow.line {
2502        LineStyle::Solid => "message",
2503        LineStyle::Dashed => "message-dashed",
2504    };
2505    let is_filled = matches!(arrow.head, ArrowHead::Filled);
2506
2507    // Get autonumber prefix
2508    let num_prefix = state
2509        .next_number()
2510        .map(|n| format!("{}. ", n))
2511        .unwrap_or_default();
2512
2513    // Calculate text lines and height
2514    let display_text = format!("{}{}", num_prefix, text);
2515    let lines: Vec<&str> = display_text.split("\\n").collect();
2516    let line_height = state.config.font_size + 4.0;
2517    let extra_height = if !is_self && lines.len() > 1 {
2518        // Use actual line_height (not spacing_line_height) to match text rendering
2519        // Extra lines + MESSAGE_TEXT_ABOVE_ARROW offset (text is rendered above arrow)
2520        (lines.len() - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
2521    } else {
2522        0.0
2523    };
2524
2525    // Add space BEFORE the message for multiline text (text is rendered above arrow)
2526    if !is_self && lines.len() > 1 {
2527        state.current_y += extra_height;
2528    }
2529
2530    let y = state.current_y;
2531    let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2532
2533    // Calculate activation-aware arrow endpoints
2534    let going_right = base_x2 > base_x1;
2535    let x1 = state.get_arrow_start_x(from, y, going_right);
2536    let x2 = state.get_arrow_end_x(to, y, !going_right);
2537
2538    // Open message group
2539    writeln!(svg, r#"<g class="message">"#).unwrap();
2540
2541    if is_self {
2542        // Self message - loop back
2543        let loop_width = 40.0;
2544        let text_block_height = lines.len() as f64 * line_height;
2545        // WSD: loop height equals text block height, no extra padding
2546        let loop_height = text_block_height.max(25.0);
2547        let arrow_end_x = x1;
2548        let arrow_end_y = y + loop_height;
2549        // Arrowhead points left (PI radians)
2550        let direction = std::f64::consts::PI;
2551        let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2552
2553        writeln!(
2554            svg,
2555            r#"  <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2556            x1 = x1,
2557            y = y,
2558            x2 = x1 + loop_width,
2559            y2 = y + loop_height,
2560            arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2561            cls = line_class
2562        )
2563        .unwrap();
2564
2565        // Draw arrowhead as polygon or polyline
2566        if is_filled {
2567            writeln!(
2568                svg,
2569                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2570                points = arrow_points
2571            )
2572            .unwrap();
2573        } else {
2574            writeln!(
2575                svg,
2576                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2577                points = arrow_points
2578            )
2579            .unwrap();
2580        }
2581
2582        // Text - multiline support
2583        // Self-messages don't need collision detection - they're positioned to the right
2584        // of the loop and won't collide with regular centered messages
2585        let text_x = x1 + loop_width + 5.0;
2586        for (i, line) in lines.iter().enumerate() {
2587            let line_y = y + 4.0 + (i as f64 + 0.5) * line_height;
2588            writeln!(
2589                svg,
2590                r#"  <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2591                x = text_x,
2592                y = line_y,
2593                t = escape_xml(line)
2594            )
2595            .unwrap();
2596        }
2597
2598        // Close message group
2599        writeln!(svg, r#"</g>"#).unwrap();
2600
2601        let spacing = self_message_spacing(&state.config, lines.len());
2602        state.current_y += spacing;
2603    } else {
2604        // Regular message - check for delay
2605        let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2606        let y2 = y + delay_offset;
2607
2608        // Text is centered between lifelines (not activation bar edges)
2609        let text_x = (base_x1 + base_x2) / 2.0;
2610        let text_y = (y + y2) / 2.0 - 6.0;  // WSD: label slightly above arrow
2611
2612        // Calculate arrowhead direction and shorten line to not overlap with arrowhead
2613        let direction = arrow_direction(x1, y, x2, y2);
2614        let arrow_points = arrowhead_points(x2, y2, direction);
2615
2616        // Shorten the line so it doesn't overlap with the arrowhead
2617        let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2618        let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2619
2620        // Draw arrow line (slanted if delay)
2621        writeln!(
2622            svg,
2623            r#"  <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2624            x1 = x1,
2625            y1 = y,
2626            lx2 = line_end_x,
2627            ly2 = line_end_y,
2628            cls = line_class
2629        )
2630        .unwrap();
2631
2632        // Draw arrowhead as polygon or polyline
2633        if is_filled {
2634            writeln!(
2635                svg,
2636                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2637                points = arrow_points
2638            )
2639            .unwrap();
2640        } else {
2641            writeln!(
2642                svg,
2643                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2644                points = arrow_points
2645            )
2646            .unwrap();
2647        }
2648
2649        // Text with multiline support (positioned at midpoint of slanted line)
2650        let max_width = lines
2651            .iter()
2652            .map(|line| estimate_message_width(line, state.config.font_size))
2653            .fold(0.0, f64::max);
2654        let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2655        let bottom_line_y = text_y;
2656        let label_offset = if has_label_text {
2657            let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2658            let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2659            let label_x_min = text_x - max_width / 2.0;
2660            let label_x_max = text_x + max_width / 2.0;
2661            let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2662            let raw_offset = state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step);
2663            // Limit offset to prevent text from going below the arrow
2664            let max_offset = y - MESSAGE_TEXT_ABOVE_ARROW - bottom_line_y;
2665            raw_offset.min(max_offset.max(0.0))
2666        } else {
2667            0.0
2668        };
2669        // Calculate rotation angle for delayed messages (slanted arrow)
2670        let rotation = if delay_offset > 0.0 {
2671            let dx = x2 - x1;
2672            let dy = delay_offset;
2673            let angle_rad = dy.atan2(dx.abs());
2674            let angle_deg = angle_rad.to_degrees();
2675            // Rotate in the direction of the arrow
2676            if dx < 0.0 { -angle_deg } else { angle_deg }
2677        } else {
2678            0.0
2679        };
2680
2681        for (i, line) in lines.iter().enumerate() {
2682            let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2683            if rotation.abs() > 0.1 {
2684                // Apply rotation transform for delayed messages
2685                writeln!(
2686                    svg,
2687                    r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="middle" transform="rotate({rot},{cx},{cy})">{t}</text>"#,
2688                    x = text_x,
2689                    y = line_y,
2690                    rot = rotation,
2691                    cx = text_x,
2692                    cy = line_y,
2693                    t = escape_xml(line)
2694                )
2695                .unwrap();
2696            } else {
2697                writeln!(
2698                    svg,
2699                    r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2700                    x = text_x,
2701                    y = line_y,
2702                    t = escape_xml(line)
2703                )
2704                .unwrap();
2705            }
2706        }
2707
2708        // Close message group
2709        writeln!(svg, r#"</g>"#).unwrap();
2710
2711        // Add row_height plus delay offset
2712        state.current_y += state.config.row_height + delay_offset;
2713    }
2714
2715    if create {
2716        state.current_y += state.config.row_height;
2717    }
2718
2719    state.apply_serial_first_row_gap();
2720
2721    // Handle activation
2722    if activate {
2723        state
2724            .activations
2725            .entry(to.to_string())
2726            .or_default()
2727            .push((y, None));
2728    }
2729    if deactivate {
2730        if let Some(acts) = state.activations.get_mut(from) {
2731            if let Some(act) = acts.last_mut() {
2732                if act.1.is_none() {
2733                    act.1 = Some(y);
2734                }
2735            }
2736        }
2737    }
2738}
2739
2740fn render_note(
2741    svg: &mut String,
2742    state: &mut RenderState,
2743    position: &NotePosition,
2744    participants: &[String],
2745    text: &str,
2746) {
2747    let lines: Vec<&str> = text.split("\\n").collect();
2748    let line_height = note_line_height(&state.config);
2749
2750    // Calculate note size (same padding on all sides)
2751    let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
2752    let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
2753    let content_width = (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH);
2754    let note_height = ELEMENT_PADDING * 2.0 + lines.len() as f64 * line_height;
2755
2756    let (x, note_width, text_anchor) = match position {
2757        NotePosition::Left => {
2758            let px = state.get_x(&participants[0]);
2759            // Note right edge = px - NOTE_MARGIN
2760            let x = (px - NOTE_MARGIN - content_width).max(state.config.padding);
2761            (x, content_width, "start")
2762        }
2763        NotePosition::Right => {
2764            let px = state.get_x(&participants[0]);
2765            // Note left edge = px + NOTE_MARGIN
2766            (px + NOTE_MARGIN, content_width, "start")
2767        }
2768        NotePosition::Over => {
2769            if participants.len() == 1 {
2770                let px = state.get_x(&participants[0]);
2771                // Center on lifeline
2772                let x = (px - content_width / 2.0).max(state.config.padding);
2773                (x, content_width, "middle")
2774            } else {
2775                // Span multiple participants
2776                let x1 = state.get_x(&participants[0]);
2777                let x2 = state.get_x(participants.last().unwrap());
2778                let span_width = (x2 - x1).abs() + NOTE_MARGIN * 2.0;
2779                let w = span_width.max(content_width);
2780                let x = (x1 - NOTE_MARGIN).max(state.config.padding);
2781                (x, w, "middle")
2782            }
2783        }
2784    };
2785
2786    let y = state.current_y;
2787    let fold_size = NOTE_FOLD_SIZE;
2788
2789    // Note background with dog-ear (folded corner) effect
2790    // Path: start at top-left, go right (leaving space for fold), diagonal fold, down, left, up
2791    let note_path = format!(
2792        "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2793        x = x,
2794        y = y,
2795        x2 = x + note_width - fold_size,
2796        x3 = x + note_width,
2797        y2 = y + fold_size,
2798        y3 = y + note_height
2799    );
2800
2801    writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2802
2803    // Draw the fold triangle (represents the folded corner)
2804    let theme = &state.config.theme;
2805    // Triangle: from fold start, to diagonal corner, to bottom of fold
2806    let fold_path = format!(
2807        "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2808        x1 = x + note_width - fold_size,
2809        y1 = y,
2810        x2 = x + note_width,
2811        y2 = y + fold_size
2812    );
2813
2814    writeln!(
2815        svg,
2816        r##"<path d="{path}" fill="none" stroke="{stroke}" stroke-width="1"/>"##,
2817        path = fold_path,
2818        stroke = theme.note_stroke
2819    )
2820    .unwrap();
2821
2822    // Text position (same padding on all sides)
2823    let text_x = match text_anchor {
2824        "middle" => x + note_width / 2.0,
2825        _ => x + ELEMENT_PADDING,
2826    };
2827    let text_anchor_attr = if *position == NotePosition::Over { "middle" } else { "start" };
2828
2829    for (i, line) in lines.iter().enumerate() {
2830        let text_y = y + ELEMENT_PADDING + (i as f64 + 0.8) * line_height;
2831        writeln!(
2832            svg,
2833            r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2834            x = text_x,
2835            y = text_y,
2836            anchor = text_anchor_attr,
2837            t = escape_xml(line)
2838        )
2839        .unwrap();
2840    }
2841
2842    // Add spacing between elements
2843    state.current_y += note_y_advance(&state.config, lines.len());
2844}
2845
2846/// Render a state box (rounded rectangle)
2847fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2848    let theme = &state.config.theme;
2849    let lines: Vec<&str> = text.split("\\n").collect();
2850    let line_height = state_line_height(&state.config);
2851    let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2852
2853    // Calculate box position and width
2854    let (x, box_width) = if participants.len() == 1 {
2855        let px = state.get_x(&participants[0]);
2856        let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2857        let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2858        (px - w / 2.0, w)
2859    } else {
2860        let x1 = state.get_x(&participants[0]);
2861        let x2 = state.get_x(participants.last().unwrap());
2862        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2863        let center = (x1 + x2) / 2.0;
2864        (center - span_width / 2.0, span_width)
2865    };
2866
2867    let shift = item_pre_shift(&state.config);
2868    let y = (state.current_y - shift).max(state.content_start());
2869
2870    // Draw rounded rectangle
2871    writeln!(
2872        svg,
2873        r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2874        x = x,
2875        y = y,
2876        w = box_width,
2877        h = box_height,
2878        fill = theme.state_fill,
2879        stroke = theme.state_stroke
2880    )
2881    .unwrap();
2882
2883    // Draw text
2884    let text_x = x + box_width / 2.0;
2885    for (i, line) in lines.iter().enumerate() {
2886        let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2887        writeln!(
2888            svg,
2889            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2890            x = text_x,
2891            y = text_y,
2892            fill = theme.state_text_color,
2893            font = theme.font_family,
2894            size = state.config.font_size,
2895            t = escape_xml(line)
2896        )
2897        .unwrap();
2898    }
2899
2900    state.current_y = y + box_height + state.config.row_height;
2901}
2902
2903/// Render a ref box (hexagon-like shape)
2904fn render_ref(
2905    svg: &mut String,
2906    state: &mut RenderState,
2907    participants: &[String],
2908    text: &str,
2909    input_from: Option<&str>,
2910    input_label: Option<&str>,
2911    output_to: Option<&str>,
2912    output_label: Option<&str>,
2913) {
2914    let theme = &state.config.theme;
2915    let lines: Vec<&str> = text.split("\\n").collect();
2916    let line_height = ref_line_height(&state.config);
2917    let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2918    let notch_size = 10.0;
2919
2920    // Calculate box position and width
2921    let (x, box_width) = if participants.len() == 1 {
2922        let px = state.get_x(&participants[0]);
2923        let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2924        let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2925            .max(100.0);
2926        (px - w / 2.0, w)
2927    } else {
2928        let x1 = state.get_x(&participants[0]);
2929        let x2 = state.get_x(participants.last().unwrap());
2930        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2931        let center = (x1 + x2) / 2.0;
2932        (center - span_width / 2.0, span_width)
2933    };
2934
2935    let shift = item_pre_shift(&state.config);
2936    let y = (state.current_y - shift).max(state.content_start());
2937    let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2938    let output_padding = state.config.note_padding + 3.0;
2939
2940    // Draw input signal arrow if present
2941    if let Some(from) = input_from {
2942        let from_x = state.get_x(from);
2943        let to_x = x; // Left edge of ref box
2944        let arrow_y = y + input_offset;
2945
2946        // Calculate arrowhead
2947        let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2948        let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2949        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2950
2951        // Draw arrow line
2952        writeln!(
2953            svg,
2954            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2955            x1 = from_x,
2956            y = arrow_y,
2957            x2 = line_end_x
2958        )
2959        .unwrap();
2960
2961        // Draw arrowhead
2962        writeln!(
2963            svg,
2964            r#"<polygon points="{points}" class="arrowhead"/>"#,
2965            points = arrow_points
2966        )
2967        .unwrap();
2968
2969        // Draw label if present
2970        if let Some(label) = input_label {
2971            let text_x = (from_x + to_x) / 2.0;
2972            writeln!(
2973                svg,
2974                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2975                x = text_x,
2976                y = arrow_y - 8.0,
2977                t = escape_xml(label)
2978            )
2979            .unwrap();
2980        }
2981    }
2982
2983    // Draw hexagon-like shape (ref box in WSD style)
2984    // Left side has a notch cut
2985    let ref_path = format!(
2986        "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2987        x1 = x + notch_size,
2988        y1 = y,
2989        x2 = x + box_width,
2990        y2 = y + box_height,
2991        x3 = x,
2992        y3 = y + box_height / 2.0
2993    );
2994
2995    writeln!(
2996        svg,
2997        r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2998        path = ref_path,
2999        fill = theme.ref_fill,
3000        stroke = theme.ref_stroke
3001    )
3002    .unwrap();
3003
3004    // Add "ref" label in top-left
3005    writeln!(
3006        svg,
3007        r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
3008        x = x + notch_size + 4.0,
3009        y = y + state.config.font_size,
3010        fill = theme.ref_text_color,
3011        font = theme.font_family,
3012        size = state.config.font_size - 2.0
3013    )
3014    .unwrap();
3015
3016    // Draw text centered
3017    let text_x = x + box_width / 2.0;
3018    for (i, line) in lines.iter().enumerate() {
3019        let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
3020        writeln!(
3021            svg,
3022            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
3023            x = text_x,
3024            y = text_y,
3025            fill = theme.ref_text_color,
3026            font = theme.font_family,
3027            size = state.config.font_size,
3028            t = escape_xml(line)
3029        )
3030        .unwrap();
3031    }
3032
3033    // Draw output signal arrow if present
3034    if let Some(to) = output_to {
3035        let from_x = x + box_width; // Right edge of ref box
3036        let to_x = state.get_x(to);
3037        let arrow_y = y + box_height - output_padding;
3038
3039        // Calculate arrowhead
3040        let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
3041        let arrow_points = arrowhead_points(to_x, arrow_y, direction);
3042        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
3043
3044        // Draw dashed arrow line (response style)
3045        writeln!(
3046            svg,
3047            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
3048            x1 = from_x,
3049            y = arrow_y,
3050            x2 = line_end_x
3051        )
3052        .unwrap();
3053
3054        // Draw arrowhead
3055        writeln!(
3056            svg,
3057            r#"<polygon points="{points}" class="arrowhead"/>"#,
3058            points = arrow_points
3059        )
3060        .unwrap();
3061
3062        // Draw label if present
3063        if let Some(label) = output_label {
3064            let text_x = (from_x + to_x) / 2.0;
3065            writeln!(
3066                svg,
3067                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3068                x = text_x,
3069                y = arrow_y - 8.0,
3070                t = escape_xml(label)
3071            )
3072            .unwrap();
3073        }
3074    }
3075
3076    state.current_y = y + box_height + state.config.row_height;
3077}
3078
3079/// Render a description (extended text explanation)
3080fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
3081    let theme = &state.config.theme;
3082    let lines: Vec<&str> = text.split("\\n").collect();
3083    let line_height = state.config.font_size + 4.0;
3084
3085    // Draw text on the left side of the diagram
3086    let x = state.config.padding + 10.0;
3087    let y = state.current_y;
3088
3089    for (i, line) in lines.iter().enumerate() {
3090        let text_y = y + (i as f64 + 0.8) * line_height;
3091        writeln!(
3092            svg,
3093            r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
3094            x = x,
3095            y = text_y,
3096            fill = theme.description_text_color,
3097            font = theme.font_family,
3098            size = state.config.font_size - 1.0,
3099            t = escape_xml(line)
3100        )
3101        .unwrap();
3102    }
3103
3104    state.current_y += description_y_advance(&state.config, lines.len());
3105}
3106
3107fn render_block(
3108    svg: &mut String,
3109    state: &mut RenderState,
3110    kind: &BlockKind,
3111    _label: &str,
3112    items: &[Item],
3113    else_sections: &[crate::ast::ElseSection],
3114    depth: usize,
3115) {
3116    if block_is_parallel(kind) {
3117        state.push_parallel();
3118        let start_y = state.current_y;
3119        let mut max_end_y = start_y;
3120        for item in items {
3121            state.current_y = start_y;
3122            render_items(svg, state, std::slice::from_ref(item), depth);
3123            if state.current_y > max_end_y {
3124                max_end_y = state.current_y;
3125            }
3126        }
3127        let gap = if parallel_needs_gap(items) {
3128            state.config.row_height
3129        } else {
3130            0.0
3131        };
3132        state.current_y = max_end_y + gap;
3133        state.pop_parallel();
3134        return;
3135    }
3136
3137    if matches!(kind, BlockKind::Serial) {
3138        state.push_serial_first_row_pending();
3139        render_items(svg, state, items, depth);
3140        for else_section in else_sections {
3141            render_items(svg, state, &else_section.items, depth);
3142        }
3143        state.pop_serial_first_row_pending();
3144        return;
3145    }
3146
3147    if !block_has_frame(kind) {
3148        render_items(svg, state, items, depth);
3149        for else_section in else_sections {
3150            render_items(svg, state, &else_section.items, depth);
3151        }
3152        return;
3153    }
3154
3155    // Note: Block frame, labels, and else separators are rendered by render_block_labels()
3156    // This function only handles Y position tracking and rendering of inner items
3157    // svg is still used for rendering inner items via render_items()
3158
3159    state.current_y += block_header_space(&state.config, depth);
3160
3161    // Render items
3162    render_items(svg, state, items, depth + 1);
3163
3164    // Render else sections if present
3165    for else_section in else_sections {
3166        state.push_else_return_pending();
3167        // Padding before else line (same as collect_block_backgrounds)
3168        state.current_y += block_else_before(&state.config, depth);
3169        // Padding after else line
3170        state.current_y += block_else_after(&state.config, depth);
3171        render_items(svg, state, &else_section.items, depth + 1);
3172        state.pop_else_return_pending();
3173    }
3174
3175    // Block bottom = current Y + footer padding
3176    // (ensures messages don't overflow outside the block)
3177    let end_y = state.current_y + block_footer_padding(&state.config, depth);
3178
3179    // Set current_y to end of block + margin
3180    state.current_y = end_y + state.config.row_height;
3181
3182    // Block frame, labels, and else separators are rendered later by render_block_labels()
3183    // which is called after activations are drawn, so labels appear on top
3184}
3185
3186fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3187    for (participant, activations) in &state.activations {
3188        let x = state.get_x(participant);
3189        let box_x = x - state.config.activation_width / 2.0;
3190
3191        for (start_y, end_y) in activations {
3192            // If no end_y, extend to footer
3193            let end = end_y.unwrap_or(footer_y);
3194            let height = end - start_y;
3195
3196            if height > 0.0 {
3197                writeln!(
3198                    svg,
3199                    r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3200                    x = box_x,
3201                    y = start_y,
3202                    w = state.config.activation_width,
3203                    h = height
3204                )
3205                .unwrap();
3206            }
3207        }
3208    }
3209}
3210
3211fn escape_xml(s: &str) -> String {
3212    s.replace('&', "&amp;")
3213        .replace('<', "&lt;")
3214        .replace('>', "&gt;")
3215        .replace('"', "&quot;")
3216        .replace('\'', "&apos;")
3217}
3218
3219#[cfg(test)]
3220mod tests {
3221    use super::*;
3222    use crate::parser::parse;
3223
3224    #[test]
3225    fn test_render_simple() {
3226        let diagram = parse("Alice->Bob: Hello").unwrap();
3227        let svg = render(&diagram);
3228        assert!(svg.contains("<svg"));
3229        assert!(svg.contains("Alice"));
3230        assert!(svg.contains("Bob"));
3231        assert!(svg.contains("Hello"));
3232    }
3233
3234    #[test]
3235    fn test_render_with_note() {
3236        let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3237        let svg = render(&diagram);
3238        assert!(svg.contains("Thinking"));
3239    }
3240}