Skip to main content

merman_render/
eventmodeling.rs

1use crate::Result;
2use crate::config::{config_bool, config_f64};
3use crate::model::{
4    Bounds, EventModelingBoxLayout, EventModelingDiagramLayout, EventModelingRelationLayout,
5    EventModelingSwimlaneLayout,
6};
7use crate::svg::render_theme::EventModelingTheme;
8use crate::text::{TextMeasurer, TextStyle, split_html_br_lines, wrap_label_like_mermaid_lines};
9use crate::theme::PresentationTheme;
10use merman_core::diagrams::eventmodeling::{
11    EventModelingDataEntityRenderModel, EventModelingDiagramRenderModel,
12    EventModelingFrameRenderModel,
13};
14use serde_json::Value;
15use std::collections::{BTreeMap, HashMap};
16
17const SWIMLANE_MIN_HEIGHT: f64 = 70.0;
18const SWIMLANE_PADDING: f64 = 15.0;
19const SWIMLANE_GAP: f64 = 10.0;
20const BOX_PADDING: f64 = 10.0;
21const BOX_OVERLAP: f64 = 90.0;
22const BOX_MIN_WIDTH: f64 = 80.0;
23const BOX_MAX_WIDTH: f64 = 450.0;
24const BOX_MIN_HEIGHT: f64 = 80.0;
25const BOX_MAX_HEIGHT: f64 = 750.0;
26const CONTENT_START_X: f64 = 250.0;
27const TEXT_MAX_WIDTH: f64 = 430.0;
28const BOX_TEXT_PADDING: f64 = 10.0;
29const TEXT_FONT_SIZE: f64 = 16.0;
30const HTML_LABEL_TEXT_WIDTH_OFFSET: f64 = 6.0;
31const HTML_LABEL_DATA_WIDTH_SCALE: f64 = 1.047;
32const HTML_LABEL_BBOX_LINE_HEIGHT: f64 = 19.0;
33
34#[derive(Debug, Clone)]
35struct EventModelingConfig {
36    padding: f64,
37    use_max_width: bool,
38}
39
40pub fn layout_eventmodeling_diagram(
41    semantic: &Value,
42    effective_config: &Value,
43    measurer: &dyn TextMeasurer,
44) -> Result<EventModelingDiagramLayout> {
45    let model: EventModelingDiagramRenderModel = crate::json::from_value_ref(semantic)?;
46    layout_eventmodeling_diagram_typed(&model, effective_config, measurer)
47}
48
49pub fn layout_eventmodeling_diagram_typed(
50    model: &EventModelingDiagramRenderModel,
51    effective_config: &Value,
52    measurer: &dyn TextMeasurer,
53) -> Result<EventModelingDiagramLayout> {
54    let cfg = eventmodeling_config(effective_config);
55    let theme = PresentationTheme::new(effective_config).eventmodeling();
56    let data_entities: HashMap<&str, &EventModelingDataEntityRenderModel> = model
57        .data_entities
58        .iter()
59        .map(|entity| (entity.name.as_str(), entity))
60        .collect();
61
62    let mut swimlanes: BTreeMap<i64, SwimlaneState> = BTreeMap::new();
63    let mut boxes = Vec::new();
64    let mut frame_to_box = HashMap::new();
65    let mut relation_specs = Vec::new();
66    let mut previous_swimlane_index = None;
67    let mut max_r: f64 = 0.0;
68
69    for (index, frame) in model.frames.iter().enumerate() {
70        let text = frame_text(frame, &data_entities);
71        let text_dimension = measure_frame_text(frame, &data_entities, measurer);
72        let event_width = text_dimension.width + 2.0 * BOX_TEXT_PADDING;
73        let event_height = text_dimension.height + 2.0 * BOX_TEXT_PADDING;
74        let width = event_width.clamp(BOX_MIN_WIDTH, BOX_MAX_WIDTH) + 2.0 * BOX_PADDING;
75        let height = event_height.clamp(BOX_MIN_HEIGHT, BOX_MAX_HEIGHT) + 2.0 * BOX_PADDING;
76        let swimlane_props = calculate_swimlane_props(frame, &swimlanes);
77        let swimlane = swimlanes
78            .entry(swimlane_props.index)
79            .or_insert_with(|| SwimlaneState {
80                index: swimlane_props.index,
81                label: swimlane_props.label.clone(),
82                namespace: swimlane_props.namespace.clone(),
83                r: 0.0,
84                y: 0.0,
85                height: SWIMLANE_MIN_HEIGHT,
86                max_height: SWIMLANE_MIN_HEIGHT,
87            });
88
89        let x = calculate_x(swimlane, previous_swimlane_index, boxes.last());
90        let r = x + width + BOX_PADDING;
91        swimlane.r = x + width;
92        swimlane.max_height = swimlane.max_height.max(height);
93        swimlane.height = swimlane.max_height.max(SWIMLANE_MIN_HEIGHT) + 2.0 * SWIMLANE_PADDING;
94        max_r = max_r.max(swimlane.r).max(r);
95
96        let visual = entity_visual_props(&theme, &frame.model_entity_type);
97        let box_state = BoxState {
98            index,
99            frame_name: frame.name.clone(),
100            frame_kind: frame.frame_kind.clone(),
101            model_entity_type: frame.model_entity_type.clone(),
102            entity_identifier: frame.entity_identifier.clone(),
103            text,
104            x,
105            width,
106            height,
107            fill: visual.fill,
108            stroke: visual.stroke,
109            swimlane_index: swimlane.index,
110            r,
111        };
112        let target_box_idx = boxes.len();
113        frame_to_box.insert(frame.name.clone(), target_box_idx);
114        boxes.push(box_state);
115
116        if frame.frame_kind != "resetframe" && !(index == 0 && frame.source_frames.is_empty()) {
117            if frame.source_frames.is_empty() {
118                if let Some(source_idx) =
119                    find_previous_cross_swimlane_box(&boxes, swimlane_props.index, index)
120                {
121                    relation_specs.push((source_idx, target_box_idx));
122                }
123            } else {
124                for source_name in &frame.source_frames {
125                    if let Some(source_idx) = frame_to_box.get(source_name).copied() {
126                        relation_specs.push((source_idx, target_box_idx));
127                    }
128                }
129            }
130        }
131
132        previous_swimlane_index = Some(swimlane_props.index);
133        recalculate_swimlane_y(&mut swimlanes);
134    }
135
136    recalculate_swimlane_y(&mut swimlanes);
137    let swimlane_width = max_r + SWIMLANE_PADDING;
138    let swimlane_layouts: Vec<_> = swimlanes
139        .values()
140        .map(|swimlane| EventModelingSwimlaneLayout {
141            index: swimlane.index,
142            label: swimlane.label.clone(),
143            namespace: swimlane.namespace.clone(),
144            x: 0.0,
145            y: swimlane.y,
146            width: swimlane_width.max(1.0),
147            height: swimlane.height,
148        })
149        .collect();
150
151    let box_layouts: Vec<_> = boxes
152        .iter()
153        .map(|box_state| {
154            let swimlane = &swimlanes[&box_state.swimlane_index];
155            EventModelingBoxLayout {
156                index: box_state.index,
157                frame_name: box_state.frame_name.clone(),
158                frame_kind: box_state.frame_kind.clone(),
159                model_entity_type: box_state.model_entity_type.clone(),
160                entity_identifier: box_state.entity_identifier.clone(),
161                text: box_state.text.clone(),
162                x: box_state.x,
163                y: swimlane.y + SWIMLANE_PADDING,
164                width: box_state.width,
165                height: box_state.height,
166                fill: box_state.fill.clone(),
167                stroke: box_state.stroke.clone(),
168                swimlane_index: box_state.swimlane_index,
169            }
170        })
171        .collect();
172
173    let relation_stroke = theme.relation_stroke.clone();
174    let relation_layouts: Vec<_> = relation_specs
175        .into_iter()
176        .map(|(source_idx, target_idx)| {
177            let source = &box_layouts[source_idx];
178            let target = &box_layouts[target_idx];
179            let upwards = source.y > target.y;
180            let x1 = source.x + (source.width * 2.0) / 3.0;
181            let x2 = target.x + target.width / 3.0;
182            let (y1, y2) = if upwards {
183                (source.y, target.y + target.height)
184            } else {
185                (source.y + source.height, target.y)
186            };
187            EventModelingRelationLayout {
188                source_frame: source.frame_name.clone(),
189                target_frame: target.frame_name.clone(),
190                x1,
191                y1,
192                x2,
193                y2,
194                stroke: relation_stroke.clone(),
195            }
196        })
197        .collect();
198
199    let mut bounds = BoundsAcc::new();
200    for swimlane in &swimlane_layouts {
201        bounds.include_rect(swimlane.x, swimlane.y, swimlane.width, swimlane.height);
202    }
203    for box_layout in &box_layouts {
204        bounds.include_rect(
205            box_layout.x,
206            box_layout.y,
207            box_layout.width,
208            box_layout.height,
209        );
210    }
211    for relation in &relation_layouts {
212        bounds.include_point(relation.x1, relation.y1);
213        bounds.include_point(relation.x2, relation.y2);
214    }
215
216    let padded = bounds.finish(cfg.padding).unwrap_or(Bounds {
217        min_x: 0.0,
218        min_y: 0.0,
219        max_x: 1.0,
220        max_y: 1.0,
221    });
222    let total_width = (padded.max_x - padded.min_x).max(1.0);
223    let total_height = (padded.max_y - padded.min_y).max(1.0);
224    let viewbox_x = padded.min_x;
225    let viewbox_y = padded.min_y;
226
227    Ok(EventModelingDiagramLayout {
228        bounds: Some(padded),
229        total_width,
230        total_height,
231        viewbox_x,
232        viewbox_y,
233        padding: cfg.padding,
234        use_max_width: cfg.use_max_width,
235        swimlanes: swimlane_layouts,
236        boxes: box_layouts,
237        relations: relation_layouts,
238    })
239}
240
241#[derive(Debug, Clone)]
242struct SwimlaneProps {
243    index: i64,
244    label: String,
245    namespace: Option<String>,
246}
247
248#[derive(Debug, Clone)]
249struct SwimlaneState {
250    index: i64,
251    label: String,
252    namespace: Option<String>,
253    r: f64,
254    y: f64,
255    height: f64,
256    max_height: f64,
257}
258
259#[derive(Debug, Clone)]
260struct BoxState {
261    index: usize,
262    frame_name: String,
263    frame_kind: String,
264    model_entity_type: String,
265    entity_identifier: String,
266    text: String,
267    x: f64,
268    width: f64,
269    height: f64,
270    fill: String,
271    stroke: String,
272    swimlane_index: i64,
273    r: f64,
274}
275
276#[derive(Debug, Clone)]
277struct VisualProps {
278    fill: String,
279    stroke: String,
280}
281
282#[derive(Debug, Clone, Copy)]
283struct TextDimension {
284    width: f64,
285    height: f64,
286}
287
288fn calculate_swimlane_props(
289    frame: &EventModelingFrameRenderModel,
290    swimlanes: &BTreeMap<i64, SwimlaneState>,
291) -> SwimlaneProps {
292    let namespace = extract_namespace(&frame.entity_identifier);
293    match frame.model_entity_type.as_str() {
294        "ui" | "pcr" | "processor" => {
295            namespaced_or_default_swimlane(swimlanes, namespace, 0, 100, "UI/Automation", "UI/A: ")
296        }
297        "rmo" | "readmodel" | "cmd" | "command" => namespaced_or_default_swimlane(
298            swimlanes,
299            namespace,
300            100,
301            200,
302            "Command/Read Model",
303            "C/RM: ",
304        ),
305        "evt" | "event" => {
306            namespaced_or_default_swimlane(swimlanes, namespace, 200, 300, "Events", "Stream: ")
307        }
308        _ => namespaced_or_default_swimlane(swimlanes, namespace, 200, 300, "Events", "Stream: "),
309    }
310}
311
312fn namespaced_or_default_swimlane(
313    swimlanes: &BTreeMap<i64, SwimlaneState>,
314    namespace: Option<String>,
315    boundary_min: i64,
316    boundary_max: i64,
317    default_label: &str,
318    prefix: &str,
319) -> SwimlaneProps {
320    if let Some(namespace) = namespace {
321        if let Some(swimlane) =
322            find_swimlane_by_namespace(swimlanes, &namespace, boundary_min, boundary_max)
323        {
324            return SwimlaneProps {
325                index: swimlane.index,
326                label: swimlane.label.clone(),
327                namespace: Some(namespace),
328            };
329        }
330
331        SwimlaneProps {
332            index: find_next_available_index(swimlanes, boundary_min, boundary_max),
333            label: format!("{prefix}{namespace}"),
334            namespace: Some(namespace),
335        }
336    } else {
337        SwimlaneProps {
338            index: boundary_min,
339            label: default_label.to_string(),
340            namespace: None,
341        }
342    }
343}
344
345fn extract_namespace(entity_identifier: &str) -> Option<String> {
346    let mut parts = entity_identifier.split('.');
347    let namespace = parts.next()?;
348    let name = parts.next()?;
349    parts
350        .next()
351        .is_none()
352        .then(|| (!namespace.is_empty() && !name.is_empty()).then(|| namespace.to_string()))
353        .flatten()
354}
355
356fn extract_name(entity_identifier: &str) -> &str {
357    let mut parts = entity_identifier.split('.');
358    let Some(first) = parts.next() else {
359        return entity_identifier;
360    };
361    let Some(second) = parts.next() else {
362        return entity_identifier;
363    };
364    if parts.next().is_none() {
365        second
366    } else {
367        first
368    }
369}
370
371fn find_swimlane_by_namespace<'a>(
372    swimlanes: &'a BTreeMap<i64, SwimlaneState>,
373    namespace: &str,
374    boundary_min: i64,
375    boundary_max: i64,
376) -> Option<&'a SwimlaneState> {
377    swimlanes.values().find(|swimlane| {
378        swimlane.index > boundary_min
379            && swimlane.index < boundary_max
380            && swimlane.namespace.as_deref() == Some(namespace)
381    })
382}
383
384fn find_next_available_index(
385    swimlanes: &BTreeMap<i64, SwimlaneState>,
386    boundary_min: i64,
387    boundary_max: i64,
388) -> i64 {
389    swimlanes
390        .keys()
391        .copied()
392        .filter(|index| *index > boundary_min && *index < boundary_max)
393        .fold(boundary_min, i64::max)
394        + 1
395}
396
397fn calculate_x(
398    swimlane: &SwimlaneState,
399    previous_swimlane_index: Option<i64>,
400    last_box: Option<&BoxState>,
401) -> f64 {
402    if previous_swimlane_index.is_none() {
403        return CONTENT_START_X;
404    }
405    if previous_swimlane_index == Some(swimlane.index) && swimlane.r > 0.0 {
406        return swimlane.r + BOX_PADDING;
407    }
408    if let Some(last_box) = last_box {
409        return last_box.r - BOX_OVERLAP + BOX_PADDING;
410    }
411    CONTENT_START_X
412}
413
414fn find_previous_cross_swimlane_box(
415    boxes: &[BoxState],
416    target_swimlane_index: i64,
417    line_index: usize,
418) -> Option<usize> {
419    if line_index == 0 {
420        return None;
421    }
422    (0..line_index)
423        .rev()
424        .find(|idx| boxes[*idx].swimlane_index != target_swimlane_index)
425}
426
427fn recalculate_swimlane_y(swimlanes: &mut BTreeMap<i64, SwimlaneState>) {
428    let mut next_y = 0.0;
429    for swimlane in swimlanes.values_mut() {
430        swimlane.y = next_y;
431        next_y += swimlane.height + SWIMLANE_GAP;
432    }
433}
434
435fn frame_text(
436    frame: &EventModelingFrameRenderModel,
437    data_entities: &HashMap<&str, &EventModelingDataEntityRenderModel>,
438) -> String {
439    let mut text = extract_name(&frame.entity_identifier).to_string();
440    if let Some(data) = frame.data_inline_value.as_deref() {
441        text.push('\n');
442        text.push_str(data);
443    } else if let Some(reference) = frame.data_reference.as_deref() {
444        if let Some(entity) = data_entities.get(reference) {
445            text.push('\n');
446            text.push_str(&entity.data_block_value);
447        }
448    }
449    text
450}
451
452fn measure_frame_text(
453    frame: &EventModelingFrameRenderModel,
454    data_entities: &HashMap<&str, &EventModelingDataEntityRenderModel>,
455    measurer: &dyn TextMeasurer,
456) -> TextDimension {
457    let style = TextStyle {
458        font_size: TEXT_FONT_SIZE,
459        font_weight: Some("700".to_string()),
460        ..Default::default()
461    };
462    let (html, has_data) = frame_label_html_for_measurement(frame, data_entities, measurer, &style);
463    let mut dimension = measure_eventmodeling_label_html(&html, measurer, &style);
464    if has_data {
465        dimension.width = (dimension.width * HTML_LABEL_DATA_WIDTH_SCALE) / 3.0;
466    } else {
467        dimension.width += HTML_LABEL_TEXT_WIDTH_OFFSET;
468    }
469    TextDimension {
470        width: dimension.width.min(TEXT_MAX_WIDTH),
471        height: dimension.height,
472    }
473}
474
475fn measure_eventmodeling_label_html(
476    html: &str,
477    measurer: &dyn TextMeasurer,
478    style: &TextStyle,
479) -> TextDimension {
480    let sans_style = TextStyle {
481        font_family: Some("sans-serif".to_string()),
482        font_size: style.font_size,
483        font_weight: style.font_weight.clone(),
484    };
485    let default_font_style = TextStyle {
486        font_family: Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()),
487        font_size: style.font_size,
488        font_weight: style.font_weight.clone(),
489    };
490    let sans = measure_eventmodeling_label_html_with_style(html, measurer, &sans_style);
491    let default_font =
492        measure_eventmodeling_label_html_with_style(html, measurer, &default_font_style);
493
494    if sans.height > default_font.height
495        && sans.width > default_font.width
496        && sans.line_height > default_font.line_height
497    {
498        sans.into_dimension()
499    } else {
500        default_font.into_dimension()
501    }
502}
503
504#[derive(Debug, Clone, Copy)]
505struct EventModelingHtmlTextDimension {
506    width: f64,
507    height: f64,
508    line_height: f64,
509}
510
511impl EventModelingHtmlTextDimension {
512    fn into_dimension(self) -> TextDimension {
513        TextDimension {
514            width: self.width,
515            height: self.height,
516        }
517    }
518}
519
520fn measure_eventmodeling_label_html_with_style(
521    html: &str,
522    measurer: &dyn TextMeasurer,
523    style: &TextStyle,
524) -> EventModelingHtmlTextDimension {
525    let mut width: f64 = 0.0;
526    let mut height: f64 = 0.0;
527    let mut line_height: f64 = 0.0;
528    for line in split_html_br_lines(html) {
529        let line = line.replace(['\r', '\n'], " ");
530        width = width.max(
531            measurer
532                .measure_svg_simple_text_bbox_width_px(&line, style)
533                .round(),
534        );
535        let current_height = measurer
536            .measure_svg_simple_text_bbox_height_px(&line, style)
537            .round()
538            .max(HTML_LABEL_BBOX_LINE_HEIGHT);
539        height += current_height;
540        line_height = line_height.max(current_height);
541    }
542    EventModelingHtmlTextDimension {
543        width,
544        height,
545        line_height,
546    }
547}
548
549fn frame_label_html_for_measurement(
550    frame: &EventModelingFrameRenderModel,
551    data_entities: &HashMap<&str, &EventModelingDataEntityRenderModel>,
552    measurer: &dyn TextMeasurer,
553    style: &TextStyle,
554) -> (String, bool) {
555    let title = wrap_eventmodeling_label(extract_name(&frame.entity_identifier), measurer, style);
556    let mut html = format!("<b>{title}</b>");
557    let data = frame
558        .data_inline_value
559        .as_deref()
560        .or_else(|| {
561            frame
562                .data_reference
563                .as_deref()
564                .and_then(|reference| data_entities.get(reference))
565                .map(|entity| entity.data_block_value.as_str())
566        })
567        .map(normalize_eventmodeling_data_for_measurement);
568
569    if let Some(data) = data {
570        let wrapped_data = wrap_eventmodeling_label(&data, measurer, style).replace(' ', "&nbsp;");
571        html.push_str(
572            r#"<br/><br/><code style="text-align: left; display: block;max-width:430px">"#,
573        );
574        html.push_str(&wrapped_data);
575        if frame.data_reference.is_some() {
576            html.push_str("<br/>");
577        }
578        html.push_str("</code>");
579        (html, true)
580    } else {
581        (html, false)
582    }
583}
584
585fn wrap_eventmodeling_label(label: &str, measurer: &dyn TextMeasurer, style: &TextStyle) -> String {
586    wrap_label_like_mermaid_lines(label, measurer, style, TEXT_MAX_WIDTH).join("<br/>")
587}
588
589fn normalize_eventmodeling_data_for_measurement(raw: &str) -> String {
590    let trimmed = raw.trim();
591    let without_outer_braces = trimmed
592        .strip_prefix('{')
593        .and_then(|s| s.strip_suffix('}'))
594        .unwrap_or(trimmed);
595    without_outer_braces.trim().to_string()
596}
597
598fn entity_visual_props(theme: &EventModelingTheme, entity_type: &str) -> VisualProps {
599    match entity_type {
600        "ui" => VisualProps {
601            fill: theme.ui_fill.clone(),
602            stroke: theme.ui_stroke.clone(),
603        },
604        "pcr" | "processor" => VisualProps {
605            fill: theme.processor_fill.clone(),
606            stroke: theme.processor_stroke.clone(),
607        },
608        "rmo" | "readmodel" => VisualProps {
609            fill: theme.read_model_fill.clone(),
610            stroke: theme.read_model_stroke.clone(),
611        },
612        "cmd" | "command" => VisualProps {
613            fill: theme.command_fill.clone(),
614            stroke: theme.command_stroke.clone(),
615        },
616        "evt" | "event" => VisualProps {
617            fill: theme.event_fill.clone(),
618            stroke: theme.event_stroke.clone(),
619        },
620        _ => VisualProps {
621            fill: "red".to_string(),
622            stroke: "black".to_string(),
623        },
624    }
625}
626
627fn eventmodeling_config(effective_config: &Value) -> EventModelingConfig {
628    EventModelingConfig {
629        padding: config_f64(effective_config, &["eventmodeling", "padding"])
630            .unwrap_or(30.0)
631            .max(0.0),
632        use_max_width: config_bool(effective_config, &["eventmodeling", "useMaxWidth"])
633            .unwrap_or(true),
634    }
635}
636
637#[derive(Debug, Clone, Copy)]
638struct BoundsAcc {
639    min_x: f64,
640    min_y: f64,
641    max_x: f64,
642    max_y: f64,
643    has_value: bool,
644}
645
646impl BoundsAcc {
647    fn new() -> Self {
648        Self {
649            min_x: 0.0,
650            min_y: 0.0,
651            max_x: 0.0,
652            max_y: 0.0,
653            has_value: false,
654        }
655    }
656
657    fn include_point(&mut self, x: f64, y: f64) {
658        if !self.has_value {
659            self.min_x = x;
660            self.max_x = x;
661            self.min_y = y;
662            self.max_y = y;
663            self.has_value = true;
664            return;
665        }
666        self.min_x = self.min_x.min(x);
667        self.max_x = self.max_x.max(x);
668        self.min_y = self.min_y.min(y);
669        self.max_y = self.max_y.max(y);
670    }
671
672    fn include_rect(&mut self, x: f64, y: f64, width: f64, height: f64) {
673        self.include_point(x, y);
674        self.include_point(x + width, y + height);
675    }
676
677    fn finish(self, padding: f64) -> Option<Bounds> {
678        self.has_value.then_some(Bounds {
679            min_x: self.min_x - padding,
680            min_y: self.min_y - padding,
681            max_x: self.max_x + padding,
682            max_y: self.max_y + padding,
683        })
684    }
685}