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(' ', " ");
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}