Skip to main content

merman_render/
quadrantchart.rs

1use crate::Result;
2use crate::json::from_value_ref;
3use crate::model::{
4    QuadrantChartAxisLabelData, QuadrantChartBorderLineData, QuadrantChartDiagramLayout,
5    QuadrantChartPointData, QuadrantChartQuadrantData, QuadrantChartTextData,
6};
7use crate::text::TextMeasurer;
8use serde::Deserialize;
9use serde_json::Value;
10use std::collections::BTreeMap;
11
12#[derive(Debug, Clone, Default, Deserialize)]
13#[serde(rename_all = "camelCase")]
14struct QuadrantChartStyles {
15    radius: Option<f64>,
16    color: Option<String>,
17    stroke_color: Option<String>,
18    stroke_width: Option<String>,
19}
20
21#[derive(Debug, Clone, Deserialize)]
22#[serde(rename_all = "camelCase")]
23struct QuadrantChartPointModel {
24    text: String,
25    x: f64,
26    y: f64,
27    #[serde(default)]
28    class_name: Option<String>,
29    #[serde(default)]
30    styles: QuadrantChartStyles,
31}
32
33#[derive(Debug, Clone, Deserialize)]
34#[serde(rename_all = "camelCase")]
35struct QuadrantChartQuadrantsModel {
36    quadrant1_text: String,
37    quadrant2_text: String,
38    quadrant3_text: String,
39    quadrant4_text: String,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43#[serde(rename_all = "camelCase")]
44struct QuadrantChartAxesModel {
45    x_axis_left_text: String,
46    x_axis_right_text: String,
47    y_axis_bottom_text: String,
48    y_axis_top_text: String,
49}
50
51#[derive(Debug, Clone, Deserialize)]
52#[serde(rename_all = "camelCase")]
53struct QuadrantChartModel {
54    #[serde(default)]
55    title: Option<String>,
56    quadrants: QuadrantChartQuadrantsModel,
57    axes: QuadrantChartAxesModel,
58    #[serde(default)]
59    points: Vec<QuadrantChartPointModel>,
60    #[serde(default)]
61    classes: BTreeMap<String, QuadrantChartStyles>,
62}
63
64#[derive(Debug, Clone)]
65struct QuadrantChartConfig {
66    chart_width: f64,
67    chart_height: f64,
68    title_padding: f64,
69    title_font_size: f64,
70    quadrant_padding: f64,
71    x_axis_label_padding: f64,
72    y_axis_label_padding: f64,
73    x_axis_label_font_size: f64,
74    y_axis_label_font_size: f64,
75    quadrant_label_font_size: f64,
76    quadrant_text_top_padding: f64,
77    point_text_padding: f64,
78    point_label_font_size: f64,
79    point_radius: f64,
80    x_axis_position: String,
81    y_axis_position: String,
82    quadrant_internal_border_stroke_width: f64,
83    quadrant_external_border_stroke_width: f64,
84}
85
86fn json_f64(v: &Value) -> Option<f64> {
87    v.as_f64()
88        .or_else(|| v.as_i64().map(|n| n as f64))
89        .or_else(|| v.as_u64().map(|n| n as f64))
90}
91
92fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
93    let mut cur = cfg;
94    for key in path {
95        cur = cur.get(*key)?;
96    }
97    json_f64(cur)
98}
99
100fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
101    let mut cur = cfg;
102    for key in path {
103        cur = cur.get(*key)?;
104    }
105    cur.as_str().map(|s| s.to_string())
106}
107
108fn default_quadrant_config(effective_config: &Value) -> QuadrantChartConfig {
109    QuadrantChartConfig {
110        chart_width: config_f64(effective_config, &["quadrantChart", "chartWidth"])
111            .unwrap_or(500.0),
112        chart_height: config_f64(effective_config, &["quadrantChart", "chartHeight"])
113            .unwrap_or(500.0),
114        title_padding: config_f64(effective_config, &["quadrantChart", "titlePadding"])
115            .unwrap_or(10.0),
116        title_font_size: config_f64(effective_config, &["quadrantChart", "titleFontSize"])
117            .unwrap_or(20.0),
118        quadrant_padding: config_f64(effective_config, &["quadrantChart", "quadrantPadding"])
119            .unwrap_or(5.0),
120        x_axis_label_padding: config_f64(effective_config, &["quadrantChart", "xAxisLabelPadding"])
121            .unwrap_or(5.0),
122        y_axis_label_padding: config_f64(effective_config, &["quadrantChart", "yAxisLabelPadding"])
123            .unwrap_or(5.0),
124        x_axis_label_font_size: config_f64(
125            effective_config,
126            &["quadrantChart", "xAxisLabelFontSize"],
127        )
128        .unwrap_or(16.0),
129        y_axis_label_font_size: config_f64(
130            effective_config,
131            &["quadrantChart", "yAxisLabelFontSize"],
132        )
133        .unwrap_or(16.0),
134        quadrant_label_font_size: config_f64(
135            effective_config,
136            &["quadrantChart", "quadrantLabelFontSize"],
137        )
138        .unwrap_or(16.0),
139        quadrant_text_top_padding: config_f64(
140            effective_config,
141            &["quadrantChart", "quadrantTextTopPadding"],
142        )
143        .unwrap_or(5.0),
144        point_text_padding: config_f64(effective_config, &["quadrantChart", "pointTextPadding"])
145            .unwrap_or(5.0),
146        point_label_font_size: config_f64(
147            effective_config,
148            &["quadrantChart", "pointLabelFontSize"],
149        )
150        .unwrap_or(12.0),
151        point_radius: config_f64(effective_config, &["quadrantChart", "pointRadius"])
152            .unwrap_or(5.0),
153        x_axis_position: config_string(effective_config, &["quadrantChart", "xAxisPosition"])
154            .unwrap_or_else(|| "top".to_string()),
155        y_axis_position: config_string(effective_config, &["quadrantChart", "yAxisPosition"])
156            .unwrap_or_else(|| "left".to_string()),
157        quadrant_internal_border_stroke_width: config_f64(
158            effective_config,
159            &["quadrantChart", "quadrantInternalBorderStrokeWidth"],
160        )
161        .unwrap_or(1.0),
162        quadrant_external_border_stroke_width: config_f64(
163            effective_config,
164            &["quadrantChart", "quadrantExternalBorderStrokeWidth"],
165        )
166        .unwrap_or(2.0),
167    }
168}
169
170#[derive(Debug, Clone)]
171struct QuadrantThemeConfig {
172    quadrant1_fill: String,
173    quadrant2_fill: String,
174    quadrant3_fill: String,
175    quadrant4_fill: String,
176    quadrant1_text_fill: String,
177    quadrant2_text_fill: String,
178    quadrant3_text_fill: String,
179    quadrant4_text_fill: String,
180    quadrant_point_fill: String,
181    quadrant_point_text_fill: String,
182    quadrant_x_axis_text_fill: String,
183    quadrant_y_axis_text_fill: String,
184    quadrant_title_fill: String,
185    quadrant_internal_border_stroke_fill: String,
186    quadrant_external_border_stroke_fill: String,
187}
188
189fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
190    let t = s.trim().strip_prefix('#').unwrap_or(s.trim());
191    if t.len() != 6 || !t.chars().all(|c| c.is_ascii_hexdigit()) {
192        return None;
193    }
194    let r = u8::from_str_radix(&t[0..2], 16).ok()?;
195    let g = u8::from_str_radix(&t[2..4], 16).ok()?;
196    let b = u8::from_str_radix(&t[4..6], 16).ok()?;
197    Some((r, g, b))
198}
199
200fn invert_hex_rgb(hex: &str) -> Option<String> {
201    let (r, g, b) = parse_hex_rgb(hex)?;
202    Some(format!("#{:02x}{:02x}{:02x}", 255 - r, 255 - g, 255 - b))
203}
204
205fn adjust_hex_rgb(hex: &str, delta: i16) -> Option<String> {
206    let (r, g, b) = parse_hex_rgb(hex)?;
207    let adj = |c: u8| -> u8 {
208        let v = c as i16 + delta;
209        v.clamp(0, 255) as u8
210    };
211    Some(format!("#{:02x}{:02x}{:02x}", adj(r), adj(g), adj(b)))
212}
213
214fn fmt_rgb(r: u8, g: u8, b: u8) -> String {
215    format!("rgb({r}, {g}, {b})")
216}
217
218fn parse_hsl_css(s: &str) -> Option<(f64, f64, f64)> {
219    let inner = s.trim().strip_prefix("hsl(")?.strip_suffix(')')?;
220    let mut parts = inner.split(',').map(|p| p.trim());
221    let h = parts.next()?.parse::<f64>().ok()?;
222    let s = parts
223        .next()?
224        .strip_suffix('%')
225        .unwrap_or_default()
226        .parse::<f64>()
227        .ok()?;
228    let l = parts
229        .next()?
230        .strip_suffix('%')
231        .unwrap_or_default()
232        .parse::<f64>()
233        .ok()?;
234    Some((h, s, l))
235}
236
237fn hsl_to_rgb_u8(h_deg: f64, s_pct: f64, l_pct: f64) -> Option<(u8, u8, u8)> {
238    if !(h_deg.is_finite() && s_pct.is_finite() && l_pct.is_finite()) {
239        return None;
240    }
241
242    let h = (h_deg / 360.0).rem_euclid(1.0);
243    let s = (s_pct / 100.0).clamp(0.0, 1.0);
244    let l = (l_pct / 100.0).clamp(0.0, 1.0);
245
246    // HSL -> RGB (same parameterization as Python's `colorsys.hls_to_rgb`).
247    if s == 0.0 {
248        let v = (l * 255.0).round().clamp(0.0, 255.0) as u8;
249        return Some((v, v, v));
250    }
251
252    let q = if l < 0.5 {
253        l * (1.0 + s)
254    } else {
255        l + s - l * s
256    };
257    let p = 2.0 * l - q;
258
259    fn hue_to_rgb(p: f64, q: f64, mut t: f64) -> f64 {
260        if t < 0.0 {
261            t += 1.0;
262        }
263        if t > 1.0 {
264            t -= 1.0;
265        }
266        if t < 1.0 / 6.0 {
267            return p + (q - p) * 6.0 * t;
268        }
269        if t < 1.0 / 2.0 {
270            return q;
271        }
272        if t < 2.0 / 3.0 {
273            return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
274        }
275        p
276    }
277
278    let r = hue_to_rgb(p, q, h + 1.0 / 3.0);
279    let g = hue_to_rgb(p, q, h);
280    let b = hue_to_rgb(p, q, h - 1.0 / 3.0);
281
282    let to_u8 = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
283    Some((to_u8(r), to_u8(g), to_u8(b)))
284}
285
286fn css_color_to_rgb_string(s: &str) -> Option<String> {
287    let t = s.trim();
288    if t.starts_with("rgb(") {
289        return Some(t.to_string());
290    }
291    if let Some((r, g, b)) = parse_hex_rgb(t) {
292        return Some(fmt_rgb(r, g, b));
293    }
294    if let Some((h, s, l)) = parse_hsl_css(t) {
295        let (r, g, b) = hsl_to_rgb_u8(h, s, l)?;
296        return Some(fmt_rgb(r, g, b));
297    }
298    None
299}
300
301fn default_quadrant_theme(effective_config: &Value) -> QuadrantThemeConfig {
302    // Mermaid 11.12.2 quadrant defaults:
303    // - Quadrant fills are derived from the active theme's `primaryColor`.
304    // - Label text is derived from the active theme's `primaryTextColor` (or, for older configs,
305    //   an invert-of-primary heuristic).
306    // - Border strokes use `primaryBorderColor` which upstream serializes as `rgb(...)` even when
307    //   the config stores it as `hsl(...)`.
308    //
309    // Note: quadrant point fill currently resolves to an `hsl(...NaN%)` string in upstream.
310    // Keep that behavior for DOM parity at the pinned baseline.
311    let quadrant1_fill = config_string(effective_config, &["themeVariables", "primaryColor"])
312        .unwrap_or_else(|| "#ECECFF".to_string());
313    let primary_text = config_string(effective_config, &["themeVariables", "primaryTextColor"])
314        .or_else(|| invert_hex_rgb(&quadrant1_fill))
315        .unwrap_or_else(|| "#131300".to_string());
316    let border_stroke = config_string(effective_config, &["themeVariables", "primaryBorderColor"])
317        .and_then(|v| css_color_to_rgb_string(&v))
318        .unwrap_or_else(|| "rgb(199, 199, 241)".to_string());
319    QuadrantThemeConfig {
320        quadrant2_fill: adjust_hex_rgb(&quadrant1_fill, 5).unwrap_or_else(|| "#f1f1ff".to_string()),
321        quadrant3_fill: adjust_hex_rgb(&quadrant1_fill, 10)
322            .unwrap_or_else(|| "#f6f6ff".to_string()),
323        quadrant4_fill: adjust_hex_rgb(&quadrant1_fill, 15)
324            .unwrap_or_else(|| "#fbfbff".to_string()),
325        quadrant1_text_fill: primary_text.clone(),
326        quadrant2_text_fill: adjust_hex_rgb(&primary_text, -5)
327            .unwrap_or_else(|| "#0e0e00".to_string()),
328        quadrant3_text_fill: adjust_hex_rgb(&primary_text, -10)
329            .unwrap_or_else(|| "#090900".to_string()),
330        quadrant4_text_fill: adjust_hex_rgb(&primary_text, -15)
331            .unwrap_or_else(|| "#040400".to_string()),
332        quadrant_point_fill: "hsl(240, 100%, NaN%)".to_string(),
333        quadrant_point_text_fill: primary_text.clone(),
334        quadrant_x_axis_text_fill: primary_text.clone(),
335        quadrant_y_axis_text_fill: primary_text.clone(),
336        quadrant_title_fill: primary_text,
337        quadrant_internal_border_stroke_fill: border_stroke.clone(),
338        quadrant_external_border_stroke_fill: border_stroke,
339        quadrant1_fill,
340    }
341}
342
343fn quadrant_theme_with_overrides(effective_config: &Value) -> QuadrantThemeConfig {
344    let mut theme = default_quadrant_theme(effective_config);
345
346    // Mermaid applies theme variables as raw CSS tokens (some upstream examples omit the leading
347    // `#` in hex colors). Preserve the string verbatim for DOM parity.
348    let set = |field: &mut String, key: &str| {
349        if let Some(v) = config_string(effective_config, &["themeVariables", key]) {
350            *field = v;
351        }
352    };
353
354    set(&mut theme.quadrant1_fill, "quadrant1Fill");
355    set(&mut theme.quadrant2_fill, "quadrant2Fill");
356    set(&mut theme.quadrant3_fill, "quadrant3Fill");
357    set(&mut theme.quadrant4_fill, "quadrant4Fill");
358
359    set(&mut theme.quadrant1_text_fill, "quadrant1TextFill");
360    set(&mut theme.quadrant2_text_fill, "quadrant2TextFill");
361    set(&mut theme.quadrant3_text_fill, "quadrant3TextFill");
362    set(&mut theme.quadrant4_text_fill, "quadrant4TextFill");
363
364    set(&mut theme.quadrant_point_fill, "quadrantPointFill");
365    set(&mut theme.quadrant_point_text_fill, "quadrantPointTextFill");
366    set(
367        &mut theme.quadrant_x_axis_text_fill,
368        "quadrantXAxisTextFill",
369    );
370    set(
371        &mut theme.quadrant_y_axis_text_fill,
372        "quadrantYAxisTextFill",
373    );
374    set(&mut theme.quadrant_title_fill, "quadrantTitleFill");
375
376    set(
377        &mut theme.quadrant_internal_border_stroke_fill,
378        "quadrantInternalBorderStrokeFill",
379    );
380    set(
381        &mut theme.quadrant_external_border_stroke_fill,
382        "quadrantExternalBorderStrokeFill",
383    );
384
385    theme
386}
387
388fn scale_linear(domain: (f64, f64), range: (f64, f64), v: f64) -> f64 {
389    let (d0, d1) = domain;
390    let (r0, r1) = range;
391    if d1 == d0 {
392        return r0;
393    }
394    let t = (v - d0) / (d1 - d0);
395    r0 + t * (r1 - r0)
396}
397
398pub fn layout_quadrantchart_diagram(
399    model: &Value,
400    effective_config: &Value,
401    _text_measurer: &dyn TextMeasurer,
402) -> Result<QuadrantChartDiagramLayout> {
403    let model: QuadrantChartModel = from_value_ref(model)?;
404
405    let cfg = default_quadrant_config(effective_config);
406    let theme = quadrant_theme_with_overrides(effective_config);
407
408    let title_text = model.title.as_deref().unwrap_or("").trim();
409    let show_title = !title_text.is_empty();
410
411    let show_x_axis = !model.axes.x_axis_left_text.trim().is_empty()
412        || !model.axes.x_axis_right_text.trim().is_empty();
413    let show_y_axis = !model.axes.y_axis_top_text.trim().is_empty()
414        || !model.axes.y_axis_bottom_text.trim().is_empty();
415
416    let x_axis_position = if model.points.is_empty() {
417        cfg.x_axis_position.as_str()
418    } else {
419        "bottom"
420    };
421
422    let x_axis_space_calc = cfg.x_axis_label_padding * 2.0 + cfg.x_axis_label_font_size;
423    let x_axis_space_top = if x_axis_position == "top" && show_x_axis {
424        x_axis_space_calc
425    } else {
426        0.0
427    };
428    let x_axis_space_bottom = if x_axis_position == "bottom" && show_x_axis {
429        x_axis_space_calc
430    } else {
431        0.0
432    };
433
434    let y_axis_space_calc = cfg.y_axis_label_padding * 2.0 + cfg.y_axis_label_font_size;
435    let y_axis_space_left = if cfg.y_axis_position == "left" && show_y_axis {
436        y_axis_space_calc
437    } else {
438        0.0
439    };
440    let y_axis_space_right = if cfg.y_axis_position == "right" && show_y_axis {
441        y_axis_space_calc
442    } else {
443        0.0
444    };
445
446    let title_space_top = if show_title {
447        cfg.title_font_size + cfg.title_padding * 2.0
448    } else {
449        0.0
450    };
451
452    let quadrant_left = cfg.quadrant_padding + y_axis_space_left;
453    let quadrant_top = cfg.quadrant_padding + x_axis_space_top + title_space_top;
454    let quadrant_width =
455        cfg.chart_width - cfg.quadrant_padding * 2.0 - y_axis_space_left - y_axis_space_right;
456    let quadrant_height = cfg.chart_height
457        - cfg.quadrant_padding * 2.0
458        - x_axis_space_top
459        - x_axis_space_bottom
460        - title_space_top;
461    let quadrant_half_width = quadrant_width / 2.0;
462    let quadrant_half_height = quadrant_height / 2.0;
463
464    let mut quadrants: Vec<QuadrantChartQuadrantData> = vec![
465        QuadrantChartQuadrantData {
466            x: quadrant_left + quadrant_half_width,
467            y: quadrant_top,
468            width: quadrant_half_width,
469            height: quadrant_half_height,
470            fill: theme.quadrant1_fill.clone(),
471            text: QuadrantChartTextData {
472                text: model.quadrants.quadrant1_text,
473                fill: theme.quadrant1_text_fill.clone(),
474                x: 0.0,
475                y: 0.0,
476                font_size: cfg.quadrant_label_font_size,
477                vertical_pos: "center".to_string(),
478                horizontal_pos: "middle".to_string(),
479                rotation: 0.0,
480            },
481        },
482        QuadrantChartQuadrantData {
483            x: quadrant_left,
484            y: quadrant_top,
485            width: quadrant_half_width,
486            height: quadrant_half_height,
487            fill: theme.quadrant2_fill.clone(),
488            text: QuadrantChartTextData {
489                text: model.quadrants.quadrant2_text,
490                fill: theme.quadrant2_text_fill.clone(),
491                x: 0.0,
492                y: 0.0,
493                font_size: cfg.quadrant_label_font_size,
494                vertical_pos: "center".to_string(),
495                horizontal_pos: "middle".to_string(),
496                rotation: 0.0,
497            },
498        },
499        QuadrantChartQuadrantData {
500            x: quadrant_left,
501            y: quadrant_top + quadrant_half_height,
502            width: quadrant_half_width,
503            height: quadrant_half_height,
504            fill: theme.quadrant3_fill.clone(),
505            text: QuadrantChartTextData {
506                text: model.quadrants.quadrant3_text,
507                fill: theme.quadrant3_text_fill.clone(),
508                x: 0.0,
509                y: 0.0,
510                font_size: cfg.quadrant_label_font_size,
511                vertical_pos: "center".to_string(),
512                horizontal_pos: "middle".to_string(),
513                rotation: 0.0,
514            },
515        },
516        QuadrantChartQuadrantData {
517            x: quadrant_left + quadrant_half_width,
518            y: quadrant_top + quadrant_half_height,
519            width: quadrant_half_width,
520            height: quadrant_half_height,
521            fill: theme.quadrant4_fill.clone(),
522            text: QuadrantChartTextData {
523                text: model.quadrants.quadrant4_text,
524                fill: theme.quadrant4_text_fill.clone(),
525                x: 0.0,
526                y: 0.0,
527                font_size: cfg.quadrant_label_font_size,
528                vertical_pos: "center".to_string(),
529                horizontal_pos: "middle".to_string(),
530                rotation: 0.0,
531            },
532        },
533    ];
534    for q in &mut quadrants {
535        q.text.x = q.x + q.width / 2.0;
536        if model.points.is_empty() {
537            q.text.y = q.y + q.height / 2.0;
538            q.text.horizontal_pos = "middle".to_string();
539        } else {
540            q.text.y = q.y + cfg.quadrant_text_top_padding;
541            q.text.horizontal_pos = "top".to_string();
542        }
543    }
544
545    let draw_x_axis_labels_in_middle = !model.axes.x_axis_right_text.trim().is_empty();
546    let draw_y_axis_labels_in_middle = !model.axes.y_axis_top_text.trim().is_empty();
547
548    let mut axis_labels: Vec<QuadrantChartAxisLabelData> = Vec::new();
549    if !model.axes.x_axis_left_text.trim().is_empty() && show_x_axis {
550        axis_labels.push(QuadrantChartAxisLabelData {
551            text: model.axes.x_axis_left_text,
552            fill: theme.quadrant_x_axis_text_fill.clone(),
553            x: quadrant_left
554                + if draw_x_axis_labels_in_middle {
555                    quadrant_half_width / 2.0
556                } else {
557                    0.0
558                },
559            y: if x_axis_position == "top" {
560                cfg.x_axis_label_padding + title_space_top
561            } else {
562                cfg.x_axis_label_padding + quadrant_top + quadrant_height + cfg.quadrant_padding
563            },
564            font_size: cfg.x_axis_label_font_size,
565            vertical_pos: if draw_x_axis_labels_in_middle {
566                "center".to_string()
567            } else {
568                "left".to_string()
569            },
570            horizontal_pos: "top".to_string(),
571            rotation: 0.0,
572        });
573    }
574    if !model.axes.x_axis_right_text.trim().is_empty() && show_x_axis {
575        axis_labels.push(QuadrantChartAxisLabelData {
576            text: model.axes.x_axis_right_text,
577            fill: theme.quadrant_x_axis_text_fill.clone(),
578            x: quadrant_left
579                + quadrant_half_width
580                + if draw_x_axis_labels_in_middle {
581                    quadrant_half_width / 2.0
582                } else {
583                    0.0
584                },
585            y: if x_axis_position == "top" {
586                cfg.x_axis_label_padding + title_space_top
587            } else {
588                cfg.x_axis_label_padding + quadrant_top + quadrant_height + cfg.quadrant_padding
589            },
590            font_size: cfg.x_axis_label_font_size,
591            vertical_pos: if draw_x_axis_labels_in_middle {
592                "center".to_string()
593            } else {
594                "left".to_string()
595            },
596            horizontal_pos: "top".to_string(),
597            rotation: 0.0,
598        });
599    }
600    if !model.axes.y_axis_bottom_text.trim().is_empty() && show_y_axis {
601        axis_labels.push(QuadrantChartAxisLabelData {
602            text: model.axes.y_axis_bottom_text,
603            fill: theme.quadrant_y_axis_text_fill.clone(),
604            x: if cfg.y_axis_position == "left" {
605                cfg.y_axis_label_padding
606            } else {
607                cfg.y_axis_label_padding + quadrant_left + quadrant_width + cfg.quadrant_padding
608            },
609            y: quadrant_top + quadrant_height
610                - if draw_y_axis_labels_in_middle {
611                    quadrant_half_height / 2.0
612                } else {
613                    0.0
614                },
615            font_size: cfg.y_axis_label_font_size,
616            vertical_pos: if draw_y_axis_labels_in_middle {
617                "center".to_string()
618            } else {
619                "left".to_string()
620            },
621            horizontal_pos: "top".to_string(),
622            rotation: -90.0,
623        });
624    }
625    if !model.axes.y_axis_top_text.trim().is_empty() && show_y_axis {
626        axis_labels.push(QuadrantChartAxisLabelData {
627            text: model.axes.y_axis_top_text,
628            fill: theme.quadrant_y_axis_text_fill.clone(),
629            x: if cfg.y_axis_position == "left" {
630                cfg.y_axis_label_padding
631            } else {
632                cfg.y_axis_label_padding + quadrant_left + quadrant_width + cfg.quadrant_padding
633            },
634            y: quadrant_top + quadrant_half_height
635                - if draw_y_axis_labels_in_middle {
636                    quadrant_half_height / 2.0
637                } else {
638                    0.0
639                },
640            font_size: cfg.y_axis_label_font_size,
641            vertical_pos: if draw_y_axis_labels_in_middle {
642                "center".to_string()
643            } else {
644                "left".to_string()
645            },
646            horizontal_pos: "top".to_string(),
647            rotation: -90.0,
648        });
649    }
650
651    let half_external_border_width = cfg.quadrant_external_border_stroke_width / 2.0;
652    let border_lines = vec![
653        QuadrantChartBorderLineData {
654            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
655            stroke_width: cfg.quadrant_external_border_stroke_width,
656            x1: quadrant_left - half_external_border_width,
657            y1: quadrant_top,
658            x2: quadrant_left + quadrant_width + half_external_border_width,
659            y2: quadrant_top,
660        },
661        QuadrantChartBorderLineData {
662            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
663            stroke_width: cfg.quadrant_external_border_stroke_width,
664            x1: quadrant_left + quadrant_width,
665            y1: quadrant_top + half_external_border_width,
666            x2: quadrant_left + quadrant_width,
667            y2: quadrant_top + quadrant_height - half_external_border_width,
668        },
669        QuadrantChartBorderLineData {
670            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
671            stroke_width: cfg.quadrant_external_border_stroke_width,
672            x1: quadrant_left - half_external_border_width,
673            y1: quadrant_top + quadrant_height,
674            x2: quadrant_left + quadrant_width + half_external_border_width,
675            y2: quadrant_top + quadrant_height,
676        },
677        QuadrantChartBorderLineData {
678            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
679            stroke_width: cfg.quadrant_external_border_stroke_width,
680            x1: quadrant_left,
681            y1: quadrant_top + half_external_border_width,
682            x2: quadrant_left,
683            y2: quadrant_top + quadrant_height - half_external_border_width,
684        },
685        QuadrantChartBorderLineData {
686            stroke_fill: theme.quadrant_internal_border_stroke_fill.clone(),
687            stroke_width: cfg.quadrant_internal_border_stroke_width,
688            x1: quadrant_left + quadrant_half_width,
689            y1: quadrant_top + half_external_border_width,
690            x2: quadrant_left + quadrant_half_width,
691            y2: quadrant_top + quadrant_height - half_external_border_width,
692        },
693        QuadrantChartBorderLineData {
694            stroke_fill: theme.quadrant_internal_border_stroke_fill.clone(),
695            stroke_width: cfg.quadrant_internal_border_stroke_width,
696            x1: quadrant_left + half_external_border_width,
697            y1: quadrant_top + quadrant_half_height,
698            x2: quadrant_left + quadrant_width - half_external_border_width,
699            y2: quadrant_top + quadrant_half_height,
700        },
701    ];
702
703    let mut points: Vec<QuadrantChartPointData> = Vec::new();
704    for p in model.points {
705        let class_styles = p
706            .class_name
707            .as_deref()
708            .and_then(|name| model.classes.get(name));
709
710        let radius = p
711            .styles
712            .radius
713            .or_else(|| class_styles.and_then(|c| c.radius))
714            .unwrap_or(cfg.point_radius);
715        let fill = p
716            .styles
717            .color
718            .clone()
719            .or_else(|| class_styles.and_then(|c| c.color.clone()))
720            .unwrap_or_else(|| theme.quadrant_point_fill.clone());
721        let stroke_color = p
722            .styles
723            .stroke_color
724            .clone()
725            .or_else(|| class_styles.and_then(|c| c.stroke_color.clone()))
726            .unwrap_or_else(|| theme.quadrant_point_fill.clone());
727        let stroke_width = p
728            .styles
729            .stroke_width
730            .clone()
731            .or_else(|| class_styles.and_then(|c| c.stroke_width.clone()))
732            .unwrap_or_else(|| "0px".to_string());
733
734        let x = scale_linear(
735            (0.0, 1.0),
736            (quadrant_left, quadrant_width + quadrant_left),
737            p.x,
738        );
739        let y = scale_linear(
740            (0.0, 1.0),
741            (quadrant_height + quadrant_top, quadrant_top),
742            p.y,
743        );
744        points.push(QuadrantChartPointData {
745            x,
746            y,
747            fill: fill.clone(),
748            radius,
749            stroke_color,
750            stroke_width,
751            text: QuadrantChartTextData {
752                text: p.text,
753                fill: theme.quadrant_point_text_fill.clone(),
754                x,
755                y: y + cfg.point_text_padding,
756                font_size: cfg.point_label_font_size,
757                vertical_pos: "center".to_string(),
758                horizontal_pos: "top".to_string(),
759                rotation: 0.0,
760            },
761        });
762    }
763
764    let title = if show_title {
765        Some(QuadrantChartTextData {
766            text: title_text.to_string(),
767            fill: theme.quadrant_title_fill,
768            font_size: cfg.title_font_size,
769            horizontal_pos: "top".to_string(),
770            vertical_pos: "center".to_string(),
771            rotation: 0.0,
772            y: cfg.title_padding,
773            x: cfg.chart_width / 2.0,
774        })
775    } else {
776        None
777    };
778
779    Ok(QuadrantChartDiagramLayout {
780        width: cfg.chart_width,
781        height: cfg.chart_height,
782        title,
783        quadrants,
784        border_lines,
785        points,
786        axis_labels,
787    })
788}