Skip to main content

merman_render/
xychart.rs

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    // The default Mermaid config uses `$ref` placeholders (schema references) for axis configs.
196    // When users override axis fields via directives/frontmatter, a deep-merge will keep the `$ref`
197    // key while adding the override keys. Treat a *pure* `$ref` object as "no concrete config",
198    // but do not discard user overrides just because `$ref` is present.
199    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        // Mermaid XYChart uses `computeDimensionOfText(...)` which probes a `<tspan>`'s
341        // `getBoundingClientRect().height`. That tends to be closer to "simple text bbox height"
342        // than the slightly taller wrapped SVG `<text>.getBBox().height` heuristic.
343        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}