Skip to main content

merman_render/
quadrantchart.rs

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