1use crate::model::{
2 XyChartDiagramLayout, XyChartDrawableElem, XyChartPathData, XyChartRectData, XyChartTextData,
3};
4use crate::text::{TextMeasurer, TextStyle};
5use crate::{Error, Result};
6use serde::Deserialize;
7use serde_json::Value;
8
9#[derive(Debug, Clone, Deserialize)]
10struct XyChartModel {
11 #[serde(default)]
12 pub orientation: String,
13 #[serde(default)]
14 pub title: Option<String>,
15 #[serde(default)]
16 pub plots: Vec<XyChartPlotModel>,
17 #[serde(rename = "xAxis")]
18 pub x_axis: XyChartAxisModel,
19 #[serde(rename = "yAxis")]
20 pub y_axis: XyChartAxisModel,
21}
22
23#[derive(Debug, Clone, Deserialize)]
24struct XyChartPlotModel {
25 #[serde(rename = "type")]
26 pub plot_type: String,
27 #[serde(default)]
28 pub data: Vec<(String, Option<f64>)>,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32#[serde(tag = "type")]
33enum XyChartAxisModel {
34 #[serde(rename = "band")]
35 Band {
36 #[serde(default)]
37 title: String,
38 #[serde(default)]
39 categories: Vec<String>,
40 },
41 #[serde(rename = "linear")]
42 Linear {
43 #[serde(default)]
44 title: String,
45 #[serde(default)]
46 min: Option<f64>,
47 #[serde(default)]
48 max: Option<f64>,
49 },
50}
51
52#[derive(Debug, Clone)]
53struct ChartThemeConfig {
54 background_color: String,
55 title_color: String,
56 x_axis_title_color: String,
57 x_axis_label_color: String,
58 x_axis_tick_color: String,
59 x_axis_line_color: String,
60 y_axis_title_color: String,
61 y_axis_label_color: String,
62 y_axis_tick_color: String,
63 y_axis_line_color: String,
64 plot_color_palette: Vec<String>,
65}
66
67#[derive(Debug, Clone)]
68struct AxisThemeConfig {
69 title_color: String,
70 label_color: String,
71 tick_color: String,
72 axis_line_color: String,
73}
74
75#[derive(Debug, Clone)]
76struct AxisConfig {
77 show_label: bool,
78 label_font_size: f64,
79 label_padding: f64,
80 show_title: bool,
81 title_font_size: f64,
82 title_padding: f64,
83 show_tick: bool,
84 tick_length: f64,
85 tick_width: f64,
86 show_axis_line: bool,
87 axis_line_width: f64,
88}
89
90#[derive(Debug, Clone)]
91struct ChartConfig {
92 width: f64,
93 height: f64,
94 plot_reserved_space_percent: f64,
95 show_data_label: bool,
96 show_title: bool,
97 title_font_size: f64,
98 title_padding: f64,
99 chart_orientation: String,
100 x_axis: AxisConfig,
101 y_axis: AxisConfig,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105enum AxisPosition {
106 Left,
107 Bottom,
108 Top,
109}
110
111#[derive(Debug, Clone, Copy)]
112struct Dimension {
113 width: f64,
114 height: f64,
115}
116
117type Point = merman_core::geom::Point;
118
119fn pt(x: f64, y: f64) -> Point {
120 merman_core::geom::point(x, y)
121}
122
123#[derive(Debug, Clone, Copy)]
124struct BoundingRect {
125 x: f64,
126 y: f64,
127 width: f64,
128 height: f64,
129}
130
131fn json_f64(v: &Value) -> Option<f64> {
132 v.as_f64()
133 .or_else(|| v.as_i64().map(|n| n as f64))
134 .or_else(|| v.as_u64().map(|n| n as f64))
135 .or_else(|| {
136 let s = v.as_str()?.trim();
137 let s = s.strip_suffix("px").unwrap_or(s).trim();
138 s.parse::<f64>().ok()
139 })
140}
141
142fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
143 let mut cur = cfg;
144 for key in path {
145 cur = cur.get(*key)?;
146 }
147 json_f64(cur)
148}
149
150fn config_bool(cfg: &Value, path: &[&str]) -> Option<bool> {
151 let mut cur = cfg;
152 for key in path {
153 cur = cur.get(*key)?;
154 }
155 cur.as_bool()
156}
157
158fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
159 let mut cur = cfg;
160 for key in path {
161 cur = cur.get(*key)?;
162 }
163 cur.as_str().map(|s| s.to_string())
164}
165
166fn is_ref_only_object(v: &Value) -> bool {
167 v.as_object()
168 .is_some_and(|m| m.len() == 1 && m.contains_key("$ref"))
169}
170
171fn default_axis_config() -> AxisConfig {
172 AxisConfig {
173 show_label: true,
174 label_font_size: 14.0,
175 label_padding: 5.0,
176 show_title: true,
177 title_font_size: 16.0,
178 title_padding: 5.0,
179 show_tick: true,
180 tick_length: 5.0,
181 tick_width: 2.0,
182 show_axis_line: true,
183 axis_line_width: 2.0,
184 }
185}
186
187fn parse_axis_config(effective_config: &Value, axis_key: &str) -> AxisConfig {
188 let base = default_axis_config();
189 let Some(v) = effective_config
190 .get("xyChart")
191 .and_then(|c| c.get(axis_key))
192 else {
193 return base;
194 };
195 if !v.is_object() || is_ref_only_object(v) {
200 return base;
201 }
202
203 AxisConfig {
204 show_label: config_bool(effective_config, &["xyChart", axis_key, "showLabel"])
205 .unwrap_or(base.show_label),
206 label_font_size: config_f64(effective_config, &["xyChart", axis_key, "labelFontSize"])
207 .unwrap_or(base.label_font_size),
208 label_padding: config_f64(effective_config, &["xyChart", axis_key, "labelPadding"])
209 .unwrap_or(base.label_padding),
210 show_title: config_bool(effective_config, &["xyChart", axis_key, "showTitle"])
211 .unwrap_or(base.show_title),
212 title_font_size: config_f64(effective_config, &["xyChart", axis_key, "titleFontSize"])
213 .unwrap_or(base.title_font_size),
214 title_padding: config_f64(effective_config, &["xyChart", axis_key, "titlePadding"])
215 .unwrap_or(base.title_padding),
216 show_tick: config_bool(effective_config, &["xyChart", axis_key, "showTick"])
217 .unwrap_or(base.show_tick),
218 tick_length: config_f64(effective_config, &["xyChart", axis_key, "tickLength"])
219 .unwrap_or(base.tick_length),
220 tick_width: config_f64(effective_config, &["xyChart", axis_key, "tickWidth"])
221 .unwrap_or(base.tick_width),
222 show_axis_line: config_bool(effective_config, &["xyChart", axis_key, "showAxisLine"])
223 .unwrap_or(base.show_axis_line),
224 axis_line_width: config_f64(effective_config, &["xyChart", axis_key, "axisLineWidth"])
225 .unwrap_or(base.axis_line_width),
226 }
227}
228
229fn default_plot_color_palette() -> Vec<String> {
230 "#ECECFF,#8493A6,#FFC3A0,#DCDDE1,#B8E994,#D1A36F,#C3CDE6,#FFB6C1,#496078,#F8F3E3"
231 .split(',')
232 .map(|s| s.trim().to_string())
233 .collect()
234}
235
236fn theme_xychart_color(effective_config: &Value, key: &str) -> Option<String> {
237 config_string(effective_config, &["themeVariables", "xyChart", key])
238}
239
240fn theme_color(effective_config: &Value, key: &str) -> Option<String> {
241 config_string(effective_config, &["themeVariables", key])
242}
243
244fn invert_hex_color(s: &str) -> Option<String> {
245 let s = s.trim();
246 let hex = s.strip_prefix('#')?;
247 if hex.len() != 6 {
248 return None;
249 }
250 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
251 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
252 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
253 Some(format!("#{:02x}{:02x}{:02x}", 255 - r, 255 - g, 255 - b))
254}
255
256fn parse_theme_config(effective_config: &Value) -> ChartThemeConfig {
257 let background = theme_xychart_color(effective_config, "backgroundColor")
258 .or_else(|| theme_color(effective_config, "background"))
259 .unwrap_or_else(|| "white".to_string());
260 let primary_color =
261 theme_color(effective_config, "primaryColor").unwrap_or_else(|| "#ECECFF".to_string());
262 let primary_text = theme_color(effective_config, "primaryTextColor")
263 .or_else(|| invert_hex_color(&primary_color))
264 .unwrap_or_else(|| "#333".to_string());
265
266 let palette_raw = theme_xychart_color(effective_config, "plotColorPalette");
267 let plot_color_palette = palette_raw
268 .map(|s| {
269 s.split(',')
270 .map(|c| c.trim().to_string())
271 .filter(|c| !c.is_empty())
272 .collect()
273 })
274 .unwrap_or_else(default_plot_color_palette);
275
276 ChartThemeConfig {
277 background_color: background,
278 title_color: theme_xychart_color(effective_config, "titleColor")
279 .unwrap_or_else(|| primary_text.clone()),
280 x_axis_title_color: theme_xychart_color(effective_config, "xAxisTitleColor")
281 .unwrap_or_else(|| primary_text.clone()),
282 x_axis_label_color: theme_xychart_color(effective_config, "xAxisLabelColor")
283 .unwrap_or_else(|| primary_text.clone()),
284 x_axis_tick_color: theme_xychart_color(effective_config, "xAxisTickColor")
285 .unwrap_or_else(|| primary_text.clone()),
286 x_axis_line_color: theme_xychart_color(effective_config, "xAxisLineColor")
287 .unwrap_or_else(|| primary_text.clone()),
288 y_axis_title_color: theme_xychart_color(effective_config, "yAxisTitleColor")
289 .unwrap_or_else(|| primary_text.clone()),
290 y_axis_label_color: theme_xychart_color(effective_config, "yAxisLabelColor")
291 .unwrap_or_else(|| primary_text.clone()),
292 y_axis_tick_color: theme_xychart_color(effective_config, "yAxisTickColor")
293 .unwrap_or_else(|| primary_text.clone()),
294 y_axis_line_color: theme_xychart_color(effective_config, "yAxisLineColor")
295 .unwrap_or_else(|| primary_text.clone()),
296 plot_color_palette,
297 }
298}
299
300fn parse_chart_config(effective_config: &Value, model: &XyChartModel) -> ChartConfig {
301 ChartConfig {
302 width: config_f64(effective_config, &["xyChart", "width"]).unwrap_or(700.0),
303 height: config_f64(effective_config, &["xyChart", "height"]).unwrap_or(500.0),
304 plot_reserved_space_percent: config_f64(
305 effective_config,
306 &["xyChart", "plotReservedSpacePercent"],
307 )
308 .unwrap_or(50.0),
309 show_data_label: config_bool(effective_config, &["xyChart", "showDataLabel"])
310 .unwrap_or(false),
311 show_title: config_bool(effective_config, &["xyChart", "showTitle"]).unwrap_or(true),
312 title_font_size: config_f64(effective_config, &["xyChart", "titleFontSize"])
313 .unwrap_or(20.0),
314 title_padding: config_f64(effective_config, &["xyChart", "titlePadding"]).unwrap_or(10.0),
315 chart_orientation: match model.orientation.as_str() {
316 "horizontal" => "horizontal".to_string(),
317 _ => "vertical".to_string(),
318 },
319 x_axis: parse_axis_config(effective_config, "xAxis"),
320 y_axis: parse_axis_config(effective_config, "yAxis"),
321 }
322}
323
324fn max_text_dimension(texts: &[String], font_size: f64, measurer: &dyn TextMeasurer) -> Dimension {
325 let style = TextStyle {
326 font_size,
327 ..Default::default()
328 };
329 let mut max_w: f64 = 0.0;
330 let mut max_h: f64 = 0.0;
331 if texts.is_empty() {
332 return Dimension {
333 width: 0.0,
334 height: 0.0,
335 };
336 }
337 for t in texts {
338 let m = measurer.measure(t, &style);
339 max_w = max_w.max(m.width);
340 max_h = max_h.max(measurer.measure_svg_simple_text_bbox_height_px(t, &style));
344 }
345 Dimension {
346 width: max_w,
347 height: max_h,
348 }
349}
350
351fn d3_ticks(start: f64, stop: f64, count: usize) -> Vec<f64> {
352 fn tick_spec(start: f64, stop: f64, count: f64) -> Option<(i64, i64, f64)> {
353 if count <= 0.0 {
354 return None;
355 }
356
357 let step = (stop - start) / count.max(0.0);
358 if !step.is_finite() || step == 0.0 {
359 return None;
360 }
361 let power = step.log10().floor();
362 let error = step / 10f64.powf(power);
363 let e10 = 50f64.sqrt();
364 let e5 = 10f64.sqrt();
365 let e2 = 2f64.sqrt();
366 let factor = if error >= e10 {
367 10.0
368 } else if error >= e5 {
369 5.0
370 } else if error >= e2 {
371 2.0
372 } else {
373 1.0
374 };
375
376 let (i1, i2, inc) = if power < 0.0 {
377 let inc = 10f64.powf(-power) / factor;
378 let mut i1 = (start * inc).round() as i64;
379 let mut i2 = (stop * inc).round() as i64;
380 if (i1 as f64) / inc < start {
381 i1 += 1;
382 }
383 if (i2 as f64) / inc > stop {
384 i2 -= 1;
385 }
386 (i1, i2, -inc)
387 } else {
388 let inc = 10f64.powf(power) * factor;
389 let mut i1 = (start / inc).round() as i64;
390 let mut i2 = (stop / inc).round() as i64;
391 if (i1 as f64) * inc < start {
392 i1 += 1;
393 }
394 if (i2 as f64) * inc > stop {
395 i2 -= 1;
396 }
397 (i1, i2, inc)
398 };
399
400 if i2 < i1 && (0.5..2.0).contains(&count) {
401 return tick_spec(start, stop, count * 2.0);
402 }
403
404 if !inc.is_finite() {
405 return None;
406 }
407 if inc == 0.0 {
408 return None;
409 }
410
411 Some((i1, i2, inc))
412 }
413
414 if !start.is_finite() || !stop.is_finite() {
415 return Vec::new();
416 }
417 let count = count as f64;
418 if count <= 0.0 {
419 return Vec::new();
420 }
421 if start == stop {
422 return vec![start];
423 }
424
425 let reverse = stop < start;
426 let (a, b) = if reverse {
427 (stop, start)
428 } else {
429 (start, stop)
430 };
431 let Some((i1, i2, inc)) = tick_spec(a, b, count) else {
432 return Vec::new();
433 };
434 if i2 < i1 {
435 return Vec::new();
436 }
437
438 let n = (i2 - i1 + 1).max(0) as usize;
439 let mut out = Vec::with_capacity(n);
440
441 if reverse {
442 if inc < 0.0 {
443 for i in 0..n {
444 out.push((i2 - i as i64) as f64 / -inc);
445 }
446 } else {
447 for i in 0..n {
448 out.push((i2 - i as i64) as f64 * inc);
449 }
450 }
451 } else if inc < 0.0 {
452 for i in 0..n {
453 out.push((i1 + i as i64) as f64 / -inc);
454 }
455 } else {
456 for i in 0..n {
457 out.push((i1 + i as i64) as f64 * inc);
458 }
459 }
460
461 out
462}
463
464#[derive(Debug, Clone)]
465enum AxisKind {
466 Band { categories: Vec<String> },
467 Linear { domain: (f64, f64) },
468}
469
470#[derive(Debug, Clone)]
471struct Axis {
472 kind: AxisKind,
473 axis_config: AxisConfig,
474 axis_theme: AxisThemeConfig,
475 axis_position: AxisPosition,
476 bounding_rect: BoundingRect,
477 range: (f64, f64),
478 show_title: bool,
479 show_label: bool,
480 show_tick: bool,
481 show_axis_line: bool,
482 outer_padding: f64,
483 title: String,
484 title_text_height: f64,
485}
486
487impl Axis {
488 fn new(
489 kind: AxisKind,
490 axis_config: AxisConfig,
491 axis_theme: AxisThemeConfig,
492 title: String,
493 ) -> Self {
494 Self {
495 kind,
496 axis_config,
497 axis_theme,
498 axis_position: AxisPosition::Left,
499 bounding_rect: BoundingRect {
500 x: 0.0,
501 y: 0.0,
502 width: 0.0,
503 height: 0.0,
504 },
505 range: (0.0, 10.0),
506 show_title: false,
507 show_label: false,
508 show_tick: false,
509 show_axis_line: false,
510 outer_padding: 0.0,
511 title,
512 title_text_height: 0.0,
513 }
514 }
515
516 fn set_axis_position(&mut self, pos: AxisPosition) {
517 self.axis_position = pos;
518 let range = self.range;
519 self.set_range(range);
520 }
521
522 fn set_range(&mut self, range: (f64, f64)) {
523 self.range = range;
524 if matches!(self.axis_position, AxisPosition::Left) {
525 self.bounding_rect.height = range.1 - range.0;
526 } else {
527 self.bounding_rect.width = range.1 - range.0;
528 }
529 }
530
531 fn set_bounding_box_xy(&mut self, pt: Point) {
532 self.bounding_rect.x = pt.x;
533 self.bounding_rect.y = pt.y;
534 }
535
536 fn get_range(&self) -> (f64, f64) {
537 (
538 self.range.0 + self.outer_padding,
539 self.range.1 - self.outer_padding,
540 )
541 }
542
543 fn tick_values(&self) -> Vec<String> {
544 match &self.kind {
545 AxisKind::Band { categories } => categories.clone(),
546 AxisKind::Linear { domain } => {
547 let (mut a, mut b) = *domain;
548 if matches!(self.axis_position, AxisPosition::Left) {
549 std::mem::swap(&mut a, &mut b);
550 }
551 d3_ticks(a, b, 10)
552 .into_iter()
553 .map(|v| format!("{v}"))
554 .collect()
555 }
556 }
557 }
558
559 fn tick_distance(&self) -> f64 {
560 let ticks = self.tick_values();
561 let (a, b) = self.get_range();
562 let span = (a - b).abs();
563 if ticks.is_empty() {
564 return 0.0;
565 }
566 span / (ticks.len() as f64)
567 }
568
569 fn get_scale_value(&self, value: &str) -> f64 {
570 match &self.kind {
571 AxisKind::Band { categories } => {
572 let (a, b) = self.get_range();
573 let n = categories.len();
574 if n == 0 {
575 return a;
576 }
577 if n == 1 {
578 return a + (b - a) * 0.5;
579 }
580 let step = (b - a) / ((n - 1) as f64);
581 let idx = categories.iter().position(|c| c == value).unwrap_or(0);
582 a + step * (idx as f64)
583 }
584 AxisKind::Linear { domain } => {
585 let Ok(v) = value.parse::<f64>() else {
586 return self.get_range().0;
587 };
588 if v.is_nan() {
589 return f64::NAN;
590 }
591 let (mut d0, mut d1) = *domain;
592 if matches!(self.axis_position, AxisPosition::Left) {
593 std::mem::swap(&mut d0, &mut d1);
594 }
595 let (r0, r1) = self.get_range();
596 if d0 == d1 {
597 return r0 + (r1 - r0) * 0.5;
598 }
599 let t = (v - d0) / (d1 - d0);
600 r0 + t * (r1 - r0)
601 }
602 }
603 }
604
605 fn recalculate_outer_padding_to_draw_bar(&mut self) {
606 const BAR_WIDTH_TO_TICK_WIDTH_RATIO: f64 = 0.7;
607 let target = BAR_WIDTH_TO_TICK_WIDTH_RATIO * self.tick_distance();
608 if target > self.outer_padding * 2.0 {
609 self.outer_padding = (target / 2.0).floor();
610 }
611 }
612
613 fn calculate_space(&mut self, available: Dimension, measurer: &dyn TextMeasurer) -> Dimension {
614 self.show_title = false;
615 self.show_label = false;
616 self.show_tick = false;
617 self.show_axis_line = false;
618 self.outer_padding = 0.0;
619 self.title_text_height = 0.0;
620
621 if matches!(self.axis_position, AxisPosition::Left) {
622 let mut available_width = available.width;
623
624 if self.axis_config.show_axis_line && available_width > self.axis_config.axis_line_width
625 {
626 available_width -= self.axis_config.axis_line_width;
627 self.show_axis_line = true;
628 }
629
630 if self.axis_config.show_label {
631 let ticks = self.tick_values();
632 let dim = max_text_dimension(&ticks, self.axis_config.label_font_size, measurer);
633 let max_padding = 0.2 * available.height;
634 self.outer_padding = (dim.height / 2.0).min(max_padding);
635 let width_required = dim.width + self.axis_config.label_padding * 2.0;
636 if width_required <= available_width {
637 available_width -= width_required;
638 self.show_label = true;
639 }
640 }
641
642 if self.axis_config.show_tick && available_width >= self.axis_config.tick_length {
643 self.show_tick = true;
644 available_width -= self.axis_config.tick_length;
645 }
646
647 if self.axis_config.show_title && !self.title.is_empty() {
648 let dim = max_text_dimension(
649 std::slice::from_ref(&self.title),
650 self.axis_config.title_font_size,
651 measurer,
652 );
653 let width_required = dim.height + self.axis_config.title_padding * 2.0;
654 self.title_text_height = dim.height;
655 if width_required <= available_width {
656 available_width -= width_required;
657 self.show_title = true;
658 }
659 }
660
661 self.bounding_rect.width = available.width - available_width;
662 self.bounding_rect.height = available.height;
663 Dimension {
664 width: self.bounding_rect.width,
665 height: self.bounding_rect.height,
666 }
667 } else {
668 let mut available_height = available.height;
669
670 if self.axis_config.show_axis_line
671 && available_height > self.axis_config.axis_line_width
672 {
673 available_height -= self.axis_config.axis_line_width;
674 self.show_axis_line = true;
675 }
676
677 if self.axis_config.show_label {
678 let ticks = self.tick_values();
679 let dim = max_text_dimension(&ticks, self.axis_config.label_font_size, measurer);
680 let max_padding = 0.2 * available.width;
681 self.outer_padding = (dim.width / 2.0).min(max_padding);
682 let height_required = dim.height + self.axis_config.label_padding * 2.0;
683 if height_required <= available_height {
684 available_height -= height_required;
685 self.show_label = true;
686 }
687 }
688
689 if self.axis_config.show_tick && available_height >= self.axis_config.tick_length {
690 self.show_tick = true;
691 available_height -= self.axis_config.tick_length;
692 }
693
694 if self.axis_config.show_title && !self.title.is_empty() {
695 let dim = max_text_dimension(
696 std::slice::from_ref(&self.title),
697 self.axis_config.title_font_size,
698 measurer,
699 );
700 let height_required = dim.height + self.axis_config.title_padding * 2.0;
701 self.title_text_height = dim.height;
702 if height_required <= available_height {
703 available_height -= height_required;
704 self.show_title = true;
705 }
706 }
707
708 self.bounding_rect.width = available.width;
709 self.bounding_rect.height = available.height - available_height;
710 Dimension {
711 width: self.bounding_rect.width,
712 height: self.bounding_rect.height,
713 }
714 }
715 }
716
717 fn drawable_elements(&self) -> Vec<XyChartDrawableElem> {
718 match self.axis_position {
719 AxisPosition::Left => self.drawable_elements_for_left_axis(),
720 AxisPosition::Bottom => self.drawable_elements_for_bottom_axis(),
721 AxisPosition::Top => self.drawable_elements_for_top_axis(),
722 }
723 }
724
725 fn drawable_elements_for_left_axis(&self) -> Vec<XyChartDrawableElem> {
726 let mut out: Vec<XyChartDrawableElem> = Vec::new();
727 if self.show_axis_line {
728 let x = self.bounding_rect.x + self.bounding_rect.width
729 - self.axis_config.axis_line_width / 2.0;
730 out.push(XyChartDrawableElem::Path {
731 group_texts: vec!["left-axis".to_string(), "axisl-line".to_string()],
732 data: vec![XyChartPathData {
733 path: format!(
734 "M {x},{} L {x},{} ",
735 self.bounding_rect.y,
736 self.bounding_rect.y + self.bounding_rect.height
737 ),
738 fill: None,
739 stroke_fill: self.axis_theme.axis_line_color.clone(),
740 stroke_width: self.axis_config.axis_line_width,
741 }],
742 });
743 }
744 if self.show_label {
745 let x = self.bounding_rect.x + self.bounding_rect.width
746 - (if self.show_label {
747 self.axis_config.label_padding
748 } else {
749 0.0
750 })
751 - (if self.show_tick {
752 self.axis_config.tick_length
753 } else {
754 0.0
755 })
756 - (if self.show_axis_line {
757 self.axis_config.axis_line_width
758 } else {
759 0.0
760 });
761 let ticks = self.tick_values();
762 out.push(XyChartDrawableElem::Text {
763 group_texts: vec!["left-axis".to_string(), "label".to_string()],
764 data: ticks
765 .iter()
766 .map(|t| XyChartTextData {
767 text: t.clone(),
768 x,
769 y: self.get_scale_value(t),
770 fill: self.axis_theme.label_color.clone(),
771 font_size: self.axis_config.label_font_size,
772 rotation: 0.0,
773 vertical_pos: "middle".to_string(),
774 horizontal_pos: "right".to_string(),
775 })
776 .collect(),
777 });
778 }
779 if self.show_tick {
780 let x = self.bounding_rect.x + self.bounding_rect.width
781 - (if self.show_axis_line {
782 self.axis_config.axis_line_width
783 } else {
784 0.0
785 });
786 let ticks = self.tick_values();
787 out.push(XyChartDrawableElem::Path {
788 group_texts: vec!["left-axis".to_string(), "ticks".to_string()],
789 data: ticks
790 .iter()
791 .map(|t| {
792 let y = self.get_scale_value(t);
793 XyChartPathData {
794 path: format!("M {x},{y} L {},{y}", x - self.axis_config.tick_length),
795 fill: None,
796 stroke_fill: self.axis_theme.tick_color.clone(),
797 stroke_width: self.axis_config.tick_width,
798 }
799 })
800 .collect(),
801 });
802 }
803 if self.show_title {
804 out.push(XyChartDrawableElem::Text {
805 group_texts: vec!["left-axis".to_string(), "title".to_string()],
806 data: vec![XyChartTextData {
807 text: self.title.clone(),
808 x: self.bounding_rect.x + self.axis_config.title_padding,
809 y: self.bounding_rect.y + self.bounding_rect.height / 2.0,
810 fill: self.axis_theme.title_color.clone(),
811 font_size: self.axis_config.title_font_size,
812 rotation: 270.0,
813 vertical_pos: "top".to_string(),
814 horizontal_pos: "center".to_string(),
815 }],
816 });
817 }
818 out
819 }
820
821 fn drawable_elements_for_bottom_axis(&self) -> Vec<XyChartDrawableElem> {
822 let mut out: Vec<XyChartDrawableElem> = Vec::new();
823 if self.show_axis_line {
824 let y = self.bounding_rect.y + self.axis_config.axis_line_width / 2.0;
825 out.push(XyChartDrawableElem::Path {
826 group_texts: vec!["bottom-axis".to_string(), "axis-line".to_string()],
827 data: vec![XyChartPathData {
828 path: format!(
829 "M {},{y} L {},{y}",
830 self.bounding_rect.x,
831 self.bounding_rect.x + self.bounding_rect.width
832 ),
833 fill: None,
834 stroke_fill: self.axis_theme.axis_line_color.clone(),
835 stroke_width: self.axis_config.axis_line_width,
836 }],
837 });
838 }
839 if self.show_label {
840 let ticks = self.tick_values();
841 out.push(XyChartDrawableElem::Text {
842 group_texts: vec!["bottom-axis".to_string(), "label".to_string()],
843 data: ticks
844 .iter()
845 .map(|t| XyChartTextData {
846 text: t.clone(),
847 x: self.get_scale_value(t),
848 y: self.bounding_rect.y
849 + self.axis_config.label_padding
850 + (if self.show_tick {
851 self.axis_config.tick_length
852 } else {
853 0.0
854 })
855 + (if self.show_axis_line {
856 self.axis_config.axis_line_width
857 } else {
858 0.0
859 }),
860 fill: self.axis_theme.label_color.clone(),
861 font_size: self.axis_config.label_font_size,
862 rotation: 0.0,
863 vertical_pos: "top".to_string(),
864 horizontal_pos: "center".to_string(),
865 })
866 .collect(),
867 });
868 }
869 if self.show_tick {
870 let y = self.bounding_rect.y
871 + (if self.show_axis_line {
872 self.axis_config.axis_line_width
873 } else {
874 0.0
875 });
876 let ticks = self.tick_values();
877 out.push(XyChartDrawableElem::Path {
878 group_texts: vec!["bottom-axis".to_string(), "ticks".to_string()],
879 data: ticks
880 .iter()
881 .map(|t| {
882 let x = self.get_scale_value(t);
883 XyChartPathData {
884 path: format!("M {x},{y} L {x},{}", y + self.axis_config.tick_length),
885 fill: None,
886 stroke_fill: self.axis_theme.tick_color.clone(),
887 stroke_width: self.axis_config.tick_width,
888 }
889 })
890 .collect(),
891 });
892 }
893 if self.show_title {
894 out.push(XyChartDrawableElem::Text {
895 group_texts: vec!["bottom-axis".to_string(), "title".to_string()],
896 data: vec![XyChartTextData {
897 text: self.title.clone(),
898 x: self.range.0 + (self.range.1 - self.range.0) / 2.0,
899 y: self.bounding_rect.y + self.bounding_rect.height
900 - self.axis_config.title_padding
901 - self.title_text_height,
902 fill: self.axis_theme.title_color.clone(),
903 font_size: self.axis_config.title_font_size,
904 rotation: 0.0,
905 vertical_pos: "top".to_string(),
906 horizontal_pos: "center".to_string(),
907 }],
908 });
909 }
910 out
911 }
912
913 fn drawable_elements_for_top_axis(&self) -> Vec<XyChartDrawableElem> {
914 let mut out: Vec<XyChartDrawableElem> = Vec::new();
915 if self.show_axis_line {
916 let y = self.bounding_rect.y + self.bounding_rect.height
917 - self.axis_config.axis_line_width / 2.0;
918 out.push(XyChartDrawableElem::Path {
919 group_texts: vec!["top-axis".to_string(), "axis-line".to_string()],
920 data: vec![XyChartPathData {
921 path: format!(
922 "M {},{y} L {},{y}",
923 self.bounding_rect.x,
924 self.bounding_rect.x + self.bounding_rect.width
925 ),
926 fill: None,
927 stroke_fill: self.axis_theme.axis_line_color.clone(),
928 stroke_width: self.axis_config.axis_line_width,
929 }],
930 });
931 }
932 if self.show_label {
933 let ticks = self.tick_values();
934 out.push(XyChartDrawableElem::Text {
935 group_texts: vec!["top-axis".to_string(), "label".to_string()],
936 data: ticks
937 .iter()
938 .map(|t| XyChartTextData {
939 text: t.clone(),
940 x: self.get_scale_value(t),
941 y: self.bounding_rect.y
942 + (if self.show_title {
943 self.title_text_height + self.axis_config.title_padding * 2.0
944 } else {
945 0.0
946 })
947 + self.axis_config.label_padding,
948 fill: self.axis_theme.label_color.clone(),
949 font_size: self.axis_config.label_font_size,
950 rotation: 0.0,
951 vertical_pos: "top".to_string(),
952 horizontal_pos: "center".to_string(),
953 })
954 .collect(),
955 });
956 }
957 if self.show_tick {
958 let y = self.bounding_rect.y;
959 let ticks = self.tick_values();
960 out.push(XyChartDrawableElem::Path {
961 group_texts: vec!["top-axis".to_string(), "ticks".to_string()],
962 data: ticks
963 .iter()
964 .map(|t| {
965 let x = self.get_scale_value(t);
966 let y0 = y + self.bounding_rect.height
967 - (if self.show_axis_line {
968 self.axis_config.axis_line_width
969 } else {
970 0.0
971 });
972 let y1 = y + self.bounding_rect.height
973 - self.axis_config.tick_length
974 - (if self.show_axis_line {
975 self.axis_config.axis_line_width
976 } else {
977 0.0
978 });
979 XyChartPathData {
980 path: format!("M {x},{y0} L {x},{y1}"),
981 fill: None,
982 stroke_fill: self.axis_theme.tick_color.clone(),
983 stroke_width: self.axis_config.tick_width,
984 }
985 })
986 .collect(),
987 });
988 }
989 if self.show_title {
990 out.push(XyChartDrawableElem::Text {
991 group_texts: vec!["top-axis".to_string(), "title".to_string()],
992 data: vec![XyChartTextData {
993 text: self.title.clone(),
994 x: self.bounding_rect.x + self.bounding_rect.width / 2.0,
995 y: self.bounding_rect.y + self.axis_config.title_padding,
996 fill: self.axis_theme.title_color.clone(),
997 font_size: self.axis_config.title_font_size,
998 rotation: 0.0,
999 vertical_pos: "top".to_string(),
1000 horizontal_pos: "center".to_string(),
1001 }],
1002 });
1003 }
1004 out
1005 }
1006}
1007
1008fn plot_color_from_palette(palette: &[String], plot_index: usize) -> String {
1009 if palette.is_empty() {
1010 return String::new();
1011 }
1012 let idx = if plot_index == 0 {
1013 0
1014 } else {
1015 plot_index % palette.len()
1016 };
1017 palette[idx].clone()
1018}
1019
1020fn line_path(points: &[(f64, f64)]) -> Option<String> {
1021 let (first, rest) = points.split_first()?;
1022 if rest.is_empty() {
1023 return Some(format!("M{},{}Z", first.0, first.1));
1024 }
1025 let mut out = format!("M{},{}", first.0, first.1);
1026 for p in rest {
1027 out.push_str(&format!("L{},{}", p.0, p.1));
1028 }
1029 Some(out)
1030}
1031
1032pub(crate) fn layout_xychart_diagram(
1033 semantic: &Value,
1034 effective_config: &Value,
1035 text_measurer: &dyn TextMeasurer,
1036) -> Result<XyChartDiagramLayout> {
1037 let model: XyChartModel = crate::json::from_value_ref(semantic).map_err(Error::Json)?;
1038
1039 if model
1040 .orientation
1041 .as_str()
1042 .split_whitespace()
1043 .next()
1044 .is_some_and(|t| t != "vertical" && t != "horizontal" && !t.is_empty())
1045 {
1046 return Err(Error::InvalidModel {
1047 message: format!("unexpected xychart orientation: {}", model.orientation),
1048 });
1049 }
1050
1051 let chart_cfg = parse_chart_config(effective_config, &model);
1052 let theme_cfg = parse_theme_config(effective_config);
1053
1054 let title = model.title.clone().unwrap_or_default();
1055 let title_dim = max_text_dimension(
1056 std::slice::from_ref(&title),
1057 chart_cfg.title_font_size,
1058 text_measurer,
1059 );
1060 let title_height = title_dim.height + 2.0 * chart_cfg.title_padding;
1061 let show_chart_title =
1062 chart_cfg.show_title && !title.is_empty() && title_height <= chart_cfg.height;
1063
1064 let mut drawables: Vec<XyChartDrawableElem> = Vec::new();
1065 if show_chart_title {
1066 drawables.push(XyChartDrawableElem::Text {
1067 group_texts: vec!["chart-title".to_string()],
1068 data: vec![XyChartTextData {
1069 text: title.clone(),
1070 x: chart_cfg.width / 2.0,
1071 y: title_height / 2.0,
1072 fill: theme_cfg.title_color.clone(),
1073 font_size: chart_cfg.title_font_size,
1074 rotation: 0.0,
1075 vertical_pos: "middle".to_string(),
1076 horizontal_pos: "center".to_string(),
1077 }],
1078 });
1079 }
1080
1081 let (x_axis_kind, x_axis_title) = match &model.x_axis {
1082 XyChartAxisModel::Band { title, categories } => (
1083 AxisKind::Band {
1084 categories: categories.clone(),
1085 },
1086 title.clone(),
1087 ),
1088 XyChartAxisModel::Linear { title, min, max } => (
1089 AxisKind::Linear {
1090 domain: (min.unwrap_or(0.0), max.unwrap_or(1.0)),
1091 },
1092 title.clone(),
1093 ),
1094 };
1095 let (y_axis_kind, y_axis_title) = match &model.y_axis {
1096 XyChartAxisModel::Band { title, categories } => (
1097 AxisKind::Band {
1098 categories: categories.clone(),
1099 },
1100 title.clone(),
1101 ),
1102 XyChartAxisModel::Linear { title, min, max } => (
1103 AxisKind::Linear {
1104 domain: (min.unwrap_or(0.0), max.unwrap_or(1.0)),
1105 },
1106 title.clone(),
1107 ),
1108 };
1109
1110 let x_axis_theme = AxisThemeConfig {
1111 title_color: theme_cfg.x_axis_title_color.clone(),
1112 label_color: theme_cfg.x_axis_label_color.clone(),
1113 tick_color: theme_cfg.x_axis_tick_color.clone(),
1114 axis_line_color: theme_cfg.x_axis_line_color.clone(),
1115 };
1116 let y_axis_theme = AxisThemeConfig {
1117 title_color: theme_cfg.y_axis_title_color.clone(),
1118 label_color: theme_cfg.y_axis_label_color.clone(),
1119 tick_color: theme_cfg.y_axis_tick_color.clone(),
1120 axis_line_color: theme_cfg.y_axis_line_color.clone(),
1121 };
1122
1123 let mut x_axis = Axis::new(
1124 x_axis_kind,
1125 chart_cfg.x_axis.clone(),
1126 x_axis_theme,
1127 x_axis_title,
1128 );
1129 let mut y_axis = Axis::new(
1130 y_axis_kind,
1131 chart_cfg.y_axis.clone(),
1132 y_axis_theme,
1133 y_axis_title,
1134 );
1135
1136 let mut chart_width = (chart_cfg.width * chart_cfg.plot_reserved_space_percent / 100.0).floor();
1137 let mut chart_height =
1138 (chart_cfg.height * chart_cfg.plot_reserved_space_percent / 100.0).floor();
1139
1140 let mut available_width = chart_cfg.width - chart_width;
1141 let mut available_height = chart_cfg.height - chart_height;
1142
1143 let plot_rect = if chart_cfg.chart_orientation == "horizontal" {
1144 let title_y_end = if show_chart_title { title_height } else { 0.0 };
1145 available_height = (available_height - title_y_end).max(0.0);
1146
1147 x_axis.set_axis_position(AxisPosition::Left);
1148 let space_used_x = x_axis.calculate_space(
1149 Dimension {
1150 width: available_width,
1151 height: available_height,
1152 },
1153 text_measurer,
1154 );
1155 available_width = (available_width - space_used_x.width).max(0.0);
1156 let plot_x = space_used_x.width;
1157
1158 y_axis.set_axis_position(AxisPosition::Top);
1159 let space_used_y = y_axis.calculate_space(
1160 Dimension {
1161 width: available_width,
1162 height: available_height,
1163 },
1164 text_measurer,
1165 );
1166 available_height = (available_height - space_used_y.height).max(0.0);
1167 let plot_y = title_y_end + space_used_y.height;
1168
1169 if available_width > 0.0 {
1170 chart_width += available_width;
1171 }
1172 if available_height > 0.0 {
1173 chart_height += available_height;
1174 }
1175
1176 let plot_rect = BoundingRect {
1177 x: plot_x,
1178 y: plot_y,
1179 width: chart_width,
1180 height: chart_height,
1181 };
1182
1183 y_axis.set_range((plot_x, plot_x + chart_width));
1184 y_axis.set_bounding_box_xy(pt(plot_x, title_y_end));
1185 x_axis.set_range((plot_y, plot_y + chart_height));
1186 x_axis.set_bounding_box_xy(pt(0.0, plot_y));
1187 plot_rect
1188 } else {
1189 let plot_y = if show_chart_title { title_height } else { 0.0 };
1190 available_height = (available_height - plot_y).max(0.0);
1191
1192 x_axis.set_axis_position(AxisPosition::Bottom);
1193 let space_used_x = x_axis.calculate_space(
1194 Dimension {
1195 width: available_width,
1196 height: available_height,
1197 },
1198 text_measurer,
1199 );
1200 available_height = (available_height - space_used_x.height).max(0.0);
1201
1202 y_axis.set_axis_position(AxisPosition::Left);
1203 let space_used_y = y_axis.calculate_space(
1204 Dimension {
1205 width: available_width,
1206 height: available_height,
1207 },
1208 text_measurer,
1209 );
1210 let plot_x = space_used_y.width;
1211 available_width = (available_width - space_used_y.width).max(0.0);
1212
1213 if available_width > 0.0 {
1214 chart_width += available_width;
1215 }
1216 if available_height > 0.0 {
1217 chart_height += available_height;
1218 }
1219
1220 let plot_rect = BoundingRect {
1221 x: plot_x,
1222 y: plot_y,
1223 width: chart_width,
1224 height: chart_height,
1225 };
1226
1227 x_axis.set_range((plot_x, plot_x + chart_width));
1228 x_axis.set_bounding_box_xy(pt(plot_x, plot_y + chart_height));
1229 y_axis.set_range((plot_y, plot_y + chart_height));
1230 y_axis.set_bounding_box_xy(pt(0.0, plot_y));
1231 plot_rect
1232 };
1233
1234 if model.plots.iter().any(|p| p.plot_type == "bar") {
1235 x_axis.recalculate_outer_padding_to_draw_bar();
1236 }
1237
1238 for (plot_index, plot) in model.plots.iter().enumerate() {
1239 let color = plot_color_from_palette(&theme_cfg.plot_color_palette, plot_index);
1240
1241 match plot.plot_type.as_str() {
1242 "bar" => {
1243 let bar_padding_percent = 0.05;
1244 let bar_width = (x_axis.outer_padding * 2.0).min(x_axis.tick_distance())
1245 * (1.0 - bar_padding_percent);
1246 let bar_width_half = bar_width / 2.0;
1247
1248 let mut rects: Vec<XyChartRectData> = Vec::new();
1249 for (cat, value) in &plot.data {
1250 let x = x_axis.get_scale_value(cat);
1251 let y = match value {
1252 Some(v) => y_axis.get_scale_value(&format!("{v}")),
1253 None => y_axis.get_scale_value("NaN"),
1254 };
1255 if chart_cfg.chart_orientation == "horizontal" {
1256 rects.push(XyChartRectData {
1257 x: plot_rect.x,
1258 y: x - bar_width_half,
1259 width: y - plot_rect.x,
1260 height: bar_width,
1261 fill: color.clone(),
1262 stroke_fill: color.clone(),
1263 stroke_width: 0.0,
1264 });
1265 } else {
1266 rects.push(XyChartRectData {
1267 x: x - bar_width_half,
1268 y,
1269 width: bar_width,
1270 height: plot_rect.y + plot_rect.height - y,
1271 fill: color.clone(),
1272 stroke_fill: color.clone(),
1273 stroke_width: 0.0,
1274 });
1275 }
1276 }
1277
1278 drawables.push(XyChartDrawableElem::Rect {
1279 group_texts: vec!["plot".to_string(), format!("bar-plot-{plot_index}")],
1280 data: rects,
1281 });
1282 }
1283 "line" => {
1284 let mut points: Vec<(f64, f64)> = Vec::new();
1285 for (cat, value) in &plot.data {
1286 let x = x_axis.get_scale_value(cat);
1287 let y = match value {
1288 Some(v) => y_axis.get_scale_value(&format!("{v}")),
1289 None => y_axis.get_scale_value("NaN"),
1290 };
1291 points.push(if chart_cfg.chart_orientation == "horizontal" {
1292 (y, x)
1293 } else {
1294 (x, y)
1295 });
1296 }
1297 if let Some(path) = line_path(&points) {
1298 drawables.push(XyChartDrawableElem::Path {
1299 group_texts: vec!["plot".to_string(), format!("line-plot-{plot_index}")],
1300 data: vec![XyChartPathData {
1301 path,
1302 fill: None,
1303 stroke_fill: color,
1304 stroke_width: 2.0,
1305 }],
1306 });
1307 }
1308 }
1309 _ => {}
1310 }
1311 }
1312
1313 drawables.extend(x_axis.drawable_elements());
1314 drawables.extend(y_axis.drawable_elements());
1315
1316 let label_data = model
1317 .plots
1318 .first()
1319 .map(|p| {
1320 p.data
1321 .iter()
1322 .map(|(_, y)| {
1323 y.map(|v| format!("{v}"))
1324 .unwrap_or_else(|| "null".to_string())
1325 })
1326 .collect()
1327 })
1328 .unwrap_or_default();
1329
1330 Ok(XyChartDiagramLayout {
1331 width: chart_cfg.width,
1332 height: chart_cfg.height,
1333 chart_orientation: chart_cfg.chart_orientation,
1334 show_data_label: chart_cfg.show_data_label,
1335 background_color: theme_cfg.background_color,
1336 label_data,
1337 drawables,
1338 })
1339}