1#![allow(clippy::too_many_arguments)]
2
3use crate::Result;
4use crate::model::{
5 Bounds, TimelineDiagramLayout, TimelineLineLayout, TimelineNodeLayout, TimelineSectionLayout,
6 TimelineTaskLayout,
7};
8use crate::text::{TextMeasurer, TextStyle};
9use serde::Deserialize;
10use std::borrow::Cow;
11
12const MAX_SECTIONS: i64 = 12;
13
14const BASE_MARGIN: f64 = 50.0;
15const NODE_PADDING: f64 = 20.0;
16const TASK_STEP_X: f64 = 200.0;
17const TASK_CONTENT_WIDTH_DEFAULT: f64 = 150.0;
18const EVENT_VERTICAL_OFFSET_FROM_TASK_Y: f64 = 200.0;
19const EVENT_GAP_Y: f64 = 10.0;
20
21const TITLE_Y: f64 = 20.0;
22const DEFAULT_VIEWBOX_PADDING: f64 = 50.0;
23
24#[derive(Debug, Clone, Deserialize)]
25struct TimelineTaskModel {
26 #[allow(dead_code)]
27 id: i64,
28 section: String,
29 #[serde(rename = "type")]
30 #[allow(dead_code)]
31 task_type: String,
32 task: String,
33 #[allow(dead_code)]
34 score: i64,
35 #[serde(default)]
36 events: Vec<String>,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40struct TimelineModel {
41 #[serde(rename = "accTitle")]
42 acc_title: Option<String>,
43 #[serde(rename = "accDescr")]
44 acc_descr: Option<String>,
45 #[serde(default)]
46 sections: Vec<String>,
47 #[serde(default)]
48 tasks: Vec<TimelineTaskModel>,
49 title: Option<String>,
50 #[serde(rename = "type")]
51 diagram_type: String,
52}
53
54fn cfg_f64(cfg: &serde_json::Value, path: &[&str]) -> Option<f64> {
55 let mut cur = cfg;
56 for k in path {
57 cur = cur.get(*k)?;
58 }
59 cur.as_f64()
60}
61
62fn cfg_bool(cfg: &serde_json::Value, path: &[&str]) -> Option<bool> {
63 let mut cur = cfg;
64 for k in path {
65 cur = cur.get(*k)?;
66 }
67 cur.as_bool()
68}
69
70fn cfg_string(cfg: &serde_json::Value, path: &[&str]) -> Option<String> {
71 let mut cur = cfg;
72 for k in path {
73 cur = cur.get(*k)?;
74 }
75 cur.as_str().map(|s| s.to_string())
76}
77
78fn cfg_f64_css_px(cfg: &serde_json::Value, path: &[&str]) -> Option<f64> {
79 let mut cur = cfg;
80 for k in path {
81 cur = cur.get(*k)?;
82 }
83 cur.as_f64()
84 .or_else(|| cur.as_i64().map(|n| n as f64))
85 .or_else(|| cur.as_u64().map(|n| n as f64))
86 .or_else(|| {
87 let raw = cur.as_str()?;
88 let t = raw.trim().trim_end_matches(';').trim();
89 let t = t.trim_end_matches("!important").trim();
90 let t = t.trim_end_matches("px").trim();
91 t.parse::<f64>().ok()
92 })
93}
94
95fn timeline_text_style(effective_config: &serde_json::Value) -> TextStyle {
96 let font_family = cfg_string(effective_config, &["themeVariables", "fontFamily"])
97 .or_else(|| cfg_string(effective_config, &["fontFamily"]))
98 .map(|s| s.trim().trim_end_matches(';').trim().to_string())
99 .filter(|s| !s.is_empty());
100 let font_size = cfg_f64_css_px(effective_config, &["themeVariables", "fontSize"])
101 .or_else(|| cfg_f64_css_px(effective_config, &["fontSize"]))
102 .unwrap_or(16.0)
103 .max(1.0);
104 TextStyle {
105 font_family,
106 font_size,
107 font_weight: None,
108 }
109}
110
111fn section_index(full_section: i64) -> i64 {
112 (full_section % MAX_SECTIONS) - 1
113}
114
115fn section_class(full_section: i64) -> String {
116 format!("section-{}", section_index(full_section))
117}
118
119fn wrap_tokens(text: &str) -> Vec<String> {
120 let mut out: Vec<String> = Vec::new();
121 let mut buf = String::new();
122 let bytes = text.as_bytes();
123 let mut i = 0;
124 while i < bytes.len() {
125 let ch = text[i..].chars().next().unwrap();
126 if ch.is_whitespace() {
127 if !buf.is_empty() {
128 out.push(std::mem::take(&mut buf));
129 }
130 while i < bytes.len() {
132 let c = text[i..].chars().next().unwrap();
133 if !c.is_whitespace() {
134 break;
135 }
136 i += c.len_utf8();
137 }
138 out.push(" ".to_string());
139 continue;
140 }
141
142 let rest = &text[i..];
143 if rest.starts_with("<br>") || rest.starts_with("<br/>") || rest.starts_with("<br />") {
144 if !buf.is_empty() {
145 out.push(std::mem::take(&mut buf));
146 }
147 if rest.starts_with("<br>") {
148 i += "<br>".len();
149 } else if rest.starts_with("<br/>") {
150 i += "<br/>".len();
151 } else {
152 i += "<br />".len();
153 }
154 out.push("<br>".to_string());
155 continue;
156 }
157
158 buf.push(ch);
159 i += ch.len_utf8();
160 }
161 if !buf.is_empty() {
162 out.push(buf);
163 }
164 out
165}
166
167fn join_trim(tokens: &[String]) -> String {
168 tokens.join(" ").trim().to_string()
169}
170
171fn svg_collapse_whitespace_for_measure(s: &str) -> Cow<'_, str> {
172 let mut out: Option<String> = None;
176 let mut last_space = false;
177 let mut saw_non_space = false;
178
179 for ch in s.chars() {
180 if ch.is_whitespace() {
181 if !saw_non_space || last_space {
182 continue;
183 }
184 out.get_or_insert_with(|| String::with_capacity(s.len()))
185 .push(' ');
186 last_space = true;
187 } else {
188 saw_non_space = true;
189 out.get_or_insert_with(|| String::with_capacity(s.len()))
190 .push(ch);
191 last_space = false;
192 }
193 }
194
195 let Some(mut out) = out else {
196 return Cow::Borrowed(s.trim());
197 };
198 if out.ends_with(' ') {
199 out.pop();
200 }
201 Cow::Owned(out)
202}
203
204fn wrap_lines(
205 text: &str,
206 max_width: f64,
207 style: &TextStyle,
208 measurer: &dyn TextMeasurer,
209) -> Vec<String> {
210 let tokens = wrap_tokens(text);
211 if tokens.is_empty() {
212 return vec![String::new()];
213 }
214
215 let mut lines: Vec<String> = Vec::new();
216 let mut cur: Vec<String> = Vec::new();
217
218 for tok in tokens {
219 cur.push(tok.clone());
220 let candidate = join_trim(&cur);
221 let candidate = svg_collapse_whitespace_for_measure(&candidate);
222 let candidate_width = measurer.measure_svg_simple_text_bbox_width_px(&candidate, style);
227 if candidate_width > max_width || tok == "<br>" {
228 cur.pop();
229 lines.push(join_trim(&cur));
230 if tok == "<br>" {
231 cur = vec![String::new()];
232 } else {
233 cur = vec![tok];
234 }
235 }
236 }
237
238 lines.push(join_trim(&cur));
239 if lines.is_empty() {
240 vec![String::new()]
241 } else {
242 lines
243 }
244}
245
246fn text_bbox_height(lines: &[String], font_size: f64) -> f64 {
247 let font_size = font_size.max(1.0);
253 let lines = lines.iter().filter(|l| !l.trim().is_empty()).count();
254 if lines == 0 {
255 return 0.0;
256 }
257 let first_line_em = 1.1875;
258 let first = (font_size * first_line_em).floor();
263 let additional = (lines.saturating_sub(1) as f64) * font_size * 1.1;
264 first + additional
265}
266
267fn virtual_node_height(
268 text: &str,
269 content_width: f64,
270 style: &TextStyle,
271 layout_font_size: f64,
272 padding: f64,
273 measurer: &dyn TextMeasurer,
274) -> (f64, Vec<String>) {
275 let lines = wrap_lines(text, content_width.max(1.0), style, measurer);
278 let bbox_h = text_bbox_height(&lines, style.font_size);
279 let h = bbox_h + layout_font_size.max(1.0) * 1.1 * 0.5 + padding;
282 (h, lines)
283}
284
285fn compute_node(
286 kind: &str,
287 label: &str,
288 full_section: i64,
289 x: f64,
290 y: f64,
291 content_width: f64,
292 max_height: f64,
293 style: &TextStyle,
294 layout_font_size: f64,
295 measurer: &dyn TextMeasurer,
296) -> TimelineNodeLayout {
297 let (h0, label_lines) = virtual_node_height(
298 label,
299 content_width,
300 style,
301 layout_font_size,
302 NODE_PADDING,
303 measurer,
304 );
305 let height = h0.max(max_height).max(1.0);
306 let width = (content_width + NODE_PADDING * 2.0).max(1.0);
307 TimelineNodeLayout {
308 x,
309 y,
310 width,
311 height,
312 content_width: content_width.max(1.0),
313 padding: NODE_PADDING,
314 section_class: section_class(full_section),
315 label: label.to_string(),
316 label_lines,
317 kind: kind.to_string(),
318 }
319}
320
321fn bounds_from_nodes_and_lines<'a, 'b>(
322 nodes: impl IntoIterator<Item = &'a TimelineNodeLayout>,
323 lines: impl IntoIterator<Item = &'b TimelineLineLayout>,
324) -> Option<(f64, f64, f64, f64)> {
325 let mut min_x = f64::INFINITY;
326 let mut min_y = f64::INFINITY;
327 let mut max_x = f64::NEG_INFINITY;
328 let mut max_y = f64::NEG_INFINITY;
329
330 let mut any = false;
331 for n in nodes {
332 any = true;
333 min_x = min_x.min(n.x);
334 min_y = min_y.min(n.y);
335 max_x = max_x.max(n.x + n.width);
336 max_y = max_y.max(n.y + n.height);
337 }
338 for l in lines {
339 any = true;
340 min_x = min_x.min(l.x1.min(l.x2));
341 min_y = min_y.min(l.y1.min(l.y2));
342 max_x = max_x.max(l.x1.max(l.x2));
343 max_y = max_y.max(l.y1.max(l.y2));
344 }
345
346 if any {
347 Some((min_x, min_y, max_x, max_y))
348 } else {
349 None
350 }
351}
352
353fn expand_bounds_for_node_text(
354 min_x: &mut f64,
355 _min_y: &mut f64,
356 max_x: &mut f64,
357 _max_y: &mut f64,
358 nodes: &[TimelineNodeLayout],
359 style: &TextStyle,
360 measurer: &dyn TextMeasurer,
361) {
362 for n in nodes {
363 if n.kind == "title-bounds" {
364 continue;
365 }
366
367 let anchor_x = n.x + n.width / 2.0;
368 for line in &n.label_lines {
369 if line.trim().is_empty() {
370 continue;
371 }
372 let (left, right) = crate::generated::timeline_text_overrides_11_12_2::
376 lookup_timeline_svg_bbox_x_with_ascii_overhang_px(
377 style.font_family.as_deref().unwrap_or_default(),
378 style.font_size,
379 line,
380 )
381 .unwrap_or_else(|| measurer.measure_svg_text_bbox_x_with_ascii_overhang(line, &style));
382 *min_x = (*min_x).min(anchor_x - left);
383 *max_x = (*max_x).max(anchor_x + right);
384 }
385 }
386}
387
388pub fn layout_timeline_diagram(
389 semantic: &serde_json::Value,
390 effective_config: &serde_json::Value,
391 measurer: &dyn TextMeasurer,
392) -> Result<TimelineDiagramLayout> {
393 let model: TimelineModel = crate::json::from_value_ref(semantic)?;
394 let _ = (
395 model.acc_title.as_deref(),
396 model.acc_descr.as_deref(),
397 model.diagram_type.as_str(),
398 );
399
400 let text_style = timeline_text_style(effective_config);
401 let render_font_size = text_style.font_size;
402 let layout_font_size = cfg_f64_css_px(effective_config, &["fontSize"])
403 .unwrap_or(render_font_size)
404 .max(1.0);
405
406 let left_margin = cfg_f64(effective_config, &["timeline", "leftMargin"])
407 .unwrap_or(150.0)
408 .max(0.0);
409 let disable_multicolor =
410 cfg_bool(effective_config, &["timeline", "disableMulticolor"]).unwrap_or(false);
411 let task_content_width = TASK_CONTENT_WIDTH_DEFAULT;
417 let _ = cfg_f64(effective_config, &["timeline", "width"]);
418
419 let mut max_section_height: f64 = 0.0;
420 for section in &model.sections {
421 let (h, _lines) = virtual_node_height(
422 section,
423 task_content_width,
424 &text_style,
425 layout_font_size,
426 NODE_PADDING,
427 measurer,
428 );
429 max_section_height = max_section_height.max(h + 20.0);
430 }
431
432 let mut max_task_height: f64 = 0.0;
433 let mut max_event_line_length: f64 = 0.0;
434 for task in &model.tasks {
435 let virtual_task_label = "[object Object]";
439 let (h, _lines) = virtual_node_height(
440 virtual_task_label,
441 task_content_width,
442 &text_style,
443 layout_font_size,
444 NODE_PADDING,
445 measurer,
446 );
447 max_task_height = max_task_height.max(h + 20.0);
448
449 let mut task_event_len: f64 = 0.0;
450 for ev in &task.events {
451 let (eh, _lines) = virtual_node_height(
452 ev,
453 task_content_width,
454 &text_style,
455 layout_font_size,
456 NODE_PADDING,
457 measurer,
458 );
459 task_event_len += eh;
460 }
461 if !task.events.is_empty() {
462 task_event_len += (task.events.len().saturating_sub(1) as f64) * EVENT_GAP_Y;
463 }
464 max_event_line_length = max_event_line_length.max(task_event_len);
465 }
466
467 let base_x = BASE_MARGIN + left_margin;
468 let base_y = BASE_MARGIN;
469
470 let mut sections: Vec<TimelineSectionLayout> = Vec::new();
471 let mut orphan_tasks: Vec<TimelineTaskLayout> = Vec::new();
472
473 let mut all_nodes_pre_title: Vec<TimelineNodeLayout> = Vec::new();
474 let mut all_lines_pre_title: Vec<TimelineLineLayout> = Vec::new();
475
476 let has_sections = !model.sections.is_empty();
477
478 if has_sections {
479 let mut master_x = base_x;
480 let section_y = base_y;
481
482 for (section_number, section_label) in model.sections.iter().enumerate() {
483 let section_number = section_number as i64;
484 let tasks_for_section: Vec<&TimelineTaskModel> = model
485 .tasks
486 .iter()
487 .filter(|t| t.section == *section_label)
488 .collect();
489 let tasks_for_section_count = tasks_for_section.len().max(1);
490
491 let content_width = TASK_STEP_X * (tasks_for_section_count as f64) - 50.0;
492 let section_node = compute_node(
493 "section",
494 section_label,
495 section_number,
496 master_x,
497 section_y,
498 content_width,
499 max_section_height,
500 &text_style,
501 layout_font_size,
502 measurer,
503 );
504 all_nodes_pre_title.push(section_node.clone());
505
506 let mut tasks: Vec<TimelineTaskLayout> = Vec::new();
507 let mut task_x = master_x;
508 let task_y = section_y + max_section_height + 50.0;
509
510 for task in &tasks_for_section {
511 let full_section = section_number;
512 let task_node = compute_node(
513 "task",
514 &task.task,
515 full_section,
516 task_x,
517 task_y,
518 task_content_width,
519 max_task_height,
520 &text_style,
521 layout_font_size,
522 measurer,
523 );
524 all_nodes_pre_title.push(task_node.clone());
525
526 let connector = TimelineLineLayout {
527 kind: "task-events".to_string(),
528 x1: task_x + (task_node.width / 2.0),
529 y1: task_y + max_task_height,
530 x2: task_x + (task_node.width / 2.0),
531 y2: task_y + max_task_height + 100.0 + max_event_line_length + 100.0,
532 };
533 all_lines_pre_title.push(connector.clone());
534
535 let mut events: Vec<TimelineNodeLayout> = Vec::new();
536 let mut event_y = task_y + EVENT_VERTICAL_OFFSET_FROM_TASK_Y;
537 for ev in &task.events {
538 let event_node = compute_node(
539 "event",
540 ev,
541 full_section,
542 task_x,
543 event_y,
544 task_content_width,
545 50.0,
546 &text_style,
547 layout_font_size,
548 measurer,
549 );
550 event_y += event_node.height + EVENT_GAP_Y;
551 all_nodes_pre_title.push(event_node.clone());
552 events.push(event_node);
553 }
554
555 tasks.push(TimelineTaskLayout {
556 node: task_node,
557 connector,
558 events,
559 });
560
561 task_x += TASK_STEP_X;
562 }
563
564 sections.push(TimelineSectionLayout {
565 node: section_node,
566 tasks,
567 });
568
569 master_x += TASK_STEP_X * (tasks_for_section_count as f64);
570 }
571 } else {
572 let mut master_x = base_x;
573 let master_y = base_y;
574 let mut section_color: i64 = 0;
575
576 for task in &model.tasks {
577 let task_node = compute_node(
578 "task",
579 &task.task,
580 section_color,
581 master_x,
582 master_y,
583 task_content_width,
584 max_task_height,
585 &text_style,
586 layout_font_size,
587 measurer,
588 );
589 all_nodes_pre_title.push(task_node.clone());
590
591 let connector = TimelineLineLayout {
592 kind: "task-events".to_string(),
593 x1: master_x + (task_node.width / 2.0),
594 y1: master_y + max_task_height,
595 x2: master_x + (task_node.width / 2.0),
596 y2: master_y + max_task_height + 100.0 + max_event_line_length + 100.0,
597 };
598 all_lines_pre_title.push(connector.clone());
599
600 let mut events: Vec<TimelineNodeLayout> = Vec::new();
601 let mut event_y = master_y + EVENT_VERTICAL_OFFSET_FROM_TASK_Y;
602 for ev in &task.events {
603 let event_node = compute_node(
604 "event",
605 ev,
606 section_color,
607 master_x,
608 event_y,
609 task_content_width,
610 50.0,
611 &text_style,
612 layout_font_size,
613 measurer,
614 );
615 event_y += event_node.height + EVENT_GAP_Y;
616 all_nodes_pre_title.push(event_node.clone());
617 events.push(event_node);
618 }
619
620 orphan_tasks.push(TimelineTaskLayout {
621 node: task_node,
622 connector,
623 events,
624 });
625
626 master_x += TASK_STEP_X;
627 if !disable_multicolor {
628 section_color += 1;
629 }
630 }
631 }
632
633 let (mut pre_min_x, mut pre_min_y, mut pre_max_x, mut pre_max_y) =
634 bounds_from_nodes_and_lines(&all_nodes_pre_title, &all_lines_pre_title)
635 .unwrap_or((0.0, 0.0, 100.0, 100.0));
636 expand_bounds_for_node_text(
637 &mut pre_min_x,
638 &mut pre_min_y,
639 &mut pre_max_x,
640 &mut pre_max_y,
641 &all_nodes_pre_title,
642 &text_style,
643 measurer,
644 );
645 let pre_title_box_width = (pre_max_x - pre_min_x).max(1.0);
646
647 let title = model
648 .title
649 .as_deref()
650 .map(|s| s.trim().to_string())
651 .filter(|s| !s.is_empty());
652 let title_x = pre_title_box_width / 2.0 - left_margin;
653
654 let depth_y = if has_sections {
655 max_section_height + max_task_height + 150.0
656 } else {
657 max_task_height + 100.0
658 };
659
660 let activity_line = TimelineLineLayout {
661 kind: "activity".to_string(),
662 x1: left_margin,
663 y1: depth_y,
664 x2: pre_title_box_width + 3.0 * left_margin,
665 y2: depth_y,
666 };
667
668 let mut all_nodes_full: Vec<TimelineNodeLayout> = all_nodes_pre_title.clone();
669 let mut all_lines_full: Vec<TimelineLineLayout> = all_lines_pre_title.clone();
670 all_lines_full.push(activity_line.clone());
671
672 if let Some(t) = title.as_deref() {
673 let title_font_size = render_font_size * 1.9375;
678 let title_style = TextStyle {
679 font_family: text_style.font_family.clone(),
680 font_size: title_font_size,
681 font_weight: Some("bold".to_string()),
682 };
683 let metrics = measurer.measure(t, &title_style);
684 all_nodes_full.push(TimelineNodeLayout {
685 x: title_x,
686 y: TITLE_Y - title_style.font_size,
687 width: metrics.width.max(1.0),
688 height: title_style.font_size.max(1.0),
689 content_width: metrics.width.max(1.0),
690 padding: 0.0,
691 section_class: "section-root".to_string(),
692 label: t.to_string(),
693 label_lines: vec![t.to_string()],
694 kind: "title-bounds".to_string(),
695 });
696 }
697
698 let (mut full_min_x, mut full_min_y, mut full_max_x, mut full_max_y) =
699 bounds_from_nodes_and_lines(&all_nodes_full, &all_lines_full)
700 .unwrap_or((pre_min_x, pre_min_y, pre_max_x, pre_max_y));
701 expand_bounds_for_node_text(
702 &mut full_min_x,
703 &mut full_min_y,
704 &mut full_max_x,
705 &mut full_max_y,
706 &all_nodes_full,
707 &text_style,
708 measurer,
709 );
710
711 let viewbox_padding =
712 cfg_f64(effective_config, &["timeline", "padding"]).unwrap_or(DEFAULT_VIEWBOX_PADDING);
713 let vb_min_x = full_min_x - viewbox_padding;
714 let vb_min_y = full_min_y - viewbox_padding;
715 let vb_max_x = full_max_x + viewbox_padding;
716 let vb_max_y = full_max_y + viewbox_padding;
717
718 Ok(TimelineDiagramLayout {
719 bounds: Some(Bounds {
720 min_x: vb_min_x,
721 min_y: vb_min_y,
722 max_x: vb_max_x,
723 max_y: vb_max_y,
724 }),
725 left_margin,
726 base_x,
727 base_y,
728 pre_title_box_width,
729 sections,
730 orphan_tasks,
731 activity_line,
732 title,
733 title_x,
734 title_y: TITLE_Y,
735 })
736}