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 default_quadrant_theme() -> QuadrantThemeConfig {
215    // Mermaid 11.12.2 default theme values (derived from `theme-default.js`).
216    //
217    // Note: quadrant point fill currently resolves to an `hsl(...NaN%)` string in upstream.
218    // Keep that behavior for DOM parity at the pinned baseline.
219    let quadrant1_fill = "#ECECFF".to_string();
220    let primary_text = invert_hex_rgb(&quadrant1_fill).unwrap_or_else(|| "#131300".to_string());
221    QuadrantThemeConfig {
222        quadrant2_fill: adjust_hex_rgb(&quadrant1_fill, 5).unwrap_or_else(|| "#f1f1ff".to_string()),
223        quadrant3_fill: adjust_hex_rgb(&quadrant1_fill, 10)
224            .unwrap_or_else(|| "#f6f6ff".to_string()),
225        quadrant4_fill: adjust_hex_rgb(&quadrant1_fill, 15)
226            .unwrap_or_else(|| "#fbfbff".to_string()),
227        quadrant1_text_fill: primary_text.clone(),
228        quadrant2_text_fill: adjust_hex_rgb(&primary_text, -5)
229            .unwrap_or_else(|| "#0e0e00".to_string()),
230        quadrant3_text_fill: adjust_hex_rgb(&primary_text, -10)
231            .unwrap_or_else(|| "#090900".to_string()),
232        quadrant4_text_fill: adjust_hex_rgb(&primary_text, -15)
233            .unwrap_or_else(|| "#040400".to_string()),
234        quadrant_point_fill: "hsl(240, 100%, NaN%)".to_string(),
235        quadrant_point_text_fill: primary_text.clone(),
236        quadrant_x_axis_text_fill: primary_text.clone(),
237        quadrant_y_axis_text_fill: primary_text.clone(),
238        quadrant_title_fill: primary_text,
239        quadrant_internal_border_stroke_fill: "rgb(199, 199, 241)".to_string(),
240        quadrant_external_border_stroke_fill: "rgb(199, 199, 241)".to_string(),
241        quadrant1_fill,
242    }
243}
244
245fn quadrant_theme_with_overrides(effective_config: &Value) -> QuadrantThemeConfig {
246    let mut theme = default_quadrant_theme();
247
248    // Mermaid applies theme variables as raw CSS tokens (some upstream examples omit the leading
249    // `#` in hex colors). Preserve the string verbatim for DOM parity.
250    let set = |field: &mut String, key: &str| {
251        if let Some(v) = config_string(effective_config, &["themeVariables", key]) {
252            *field = v;
253        }
254    };
255
256    set(&mut theme.quadrant1_fill, "quadrant1Fill");
257    set(&mut theme.quadrant2_fill, "quadrant2Fill");
258    set(&mut theme.quadrant3_fill, "quadrant3Fill");
259    set(&mut theme.quadrant4_fill, "quadrant4Fill");
260
261    set(&mut theme.quadrant1_text_fill, "quadrant1TextFill");
262    set(&mut theme.quadrant2_text_fill, "quadrant2TextFill");
263    set(&mut theme.quadrant3_text_fill, "quadrant3TextFill");
264    set(&mut theme.quadrant4_text_fill, "quadrant4TextFill");
265
266    set(&mut theme.quadrant_point_fill, "quadrantPointFill");
267    set(&mut theme.quadrant_point_text_fill, "quadrantPointTextFill");
268    set(
269        &mut theme.quadrant_x_axis_text_fill,
270        "quadrantXAxisTextFill",
271    );
272    set(
273        &mut theme.quadrant_y_axis_text_fill,
274        "quadrantYAxisTextFill",
275    );
276    set(&mut theme.quadrant_title_fill, "quadrantTitleFill");
277
278    set(
279        &mut theme.quadrant_internal_border_stroke_fill,
280        "quadrantInternalBorderStrokeFill",
281    );
282    set(
283        &mut theme.quadrant_external_border_stroke_fill,
284        "quadrantExternalBorderStrokeFill",
285    );
286
287    theme
288}
289
290fn scale_linear(domain: (f64, f64), range: (f64, f64), v: f64) -> f64 {
291    let (d0, d1) = domain;
292    let (r0, r1) = range;
293    if d1 == d0 {
294        return r0;
295    }
296    let t = (v - d0) / (d1 - d0);
297    r0 + t * (r1 - r0)
298}
299
300pub fn layout_quadrantchart_diagram(
301    model: &Value,
302    effective_config: &Value,
303    _text_measurer: &dyn TextMeasurer,
304) -> Result<QuadrantChartDiagramLayout> {
305    let model: QuadrantChartModel = from_value_ref(model)?;
306
307    let cfg = default_quadrant_config(effective_config);
308    let theme = quadrant_theme_with_overrides(effective_config);
309
310    let title_text = model.title.as_deref().unwrap_or("").trim();
311    let show_title = !title_text.is_empty();
312
313    let show_x_axis = !model.axes.x_axis_left_text.trim().is_empty()
314        || !model.axes.x_axis_right_text.trim().is_empty();
315    let show_y_axis = !model.axes.y_axis_top_text.trim().is_empty()
316        || !model.axes.y_axis_bottom_text.trim().is_empty();
317
318    let x_axis_position = if model.points.is_empty() {
319        cfg.x_axis_position.as_str()
320    } else {
321        "bottom"
322    };
323
324    let x_axis_space_calc = cfg.x_axis_label_padding * 2.0 + cfg.x_axis_label_font_size;
325    let x_axis_space_top = if x_axis_position == "top" && show_x_axis {
326        x_axis_space_calc
327    } else {
328        0.0
329    };
330    let x_axis_space_bottom = if x_axis_position == "bottom" && show_x_axis {
331        x_axis_space_calc
332    } else {
333        0.0
334    };
335
336    let y_axis_space_calc = cfg.y_axis_label_padding * 2.0 + cfg.y_axis_label_font_size;
337    let y_axis_space_left = if cfg.y_axis_position == "left" && show_y_axis {
338        y_axis_space_calc
339    } else {
340        0.0
341    };
342    let y_axis_space_right = if cfg.y_axis_position == "right" && show_y_axis {
343        y_axis_space_calc
344    } else {
345        0.0
346    };
347
348    let title_space_top = if show_title {
349        cfg.title_font_size + cfg.title_padding * 2.0
350    } else {
351        0.0
352    };
353
354    let quadrant_left = cfg.quadrant_padding + y_axis_space_left;
355    let quadrant_top = cfg.quadrant_padding + x_axis_space_top + title_space_top;
356    let quadrant_width =
357        cfg.chart_width - cfg.quadrant_padding * 2.0 - y_axis_space_left - y_axis_space_right;
358    let quadrant_height = cfg.chart_height
359        - cfg.quadrant_padding * 2.0
360        - x_axis_space_top
361        - x_axis_space_bottom
362        - title_space_top;
363    let quadrant_half_width = quadrant_width / 2.0;
364    let quadrant_half_height = quadrant_height / 2.0;
365
366    let mut quadrants: Vec<QuadrantChartQuadrantData> = vec![
367        QuadrantChartQuadrantData {
368            x: quadrant_left + quadrant_half_width,
369            y: quadrant_top,
370            width: quadrant_half_width,
371            height: quadrant_half_height,
372            fill: theme.quadrant1_fill.clone(),
373            text: QuadrantChartTextData {
374                text: model.quadrants.quadrant1_text,
375                fill: theme.quadrant1_text_fill.clone(),
376                x: 0.0,
377                y: 0.0,
378                font_size: cfg.quadrant_label_font_size,
379                vertical_pos: "center".to_string(),
380                horizontal_pos: "middle".to_string(),
381                rotation: 0.0,
382            },
383        },
384        QuadrantChartQuadrantData {
385            x: quadrant_left,
386            y: quadrant_top,
387            width: quadrant_half_width,
388            height: quadrant_half_height,
389            fill: theme.quadrant2_fill.clone(),
390            text: QuadrantChartTextData {
391                text: model.quadrants.quadrant2_text,
392                fill: theme.quadrant2_text_fill.clone(),
393                x: 0.0,
394                y: 0.0,
395                font_size: cfg.quadrant_label_font_size,
396                vertical_pos: "center".to_string(),
397                horizontal_pos: "middle".to_string(),
398                rotation: 0.0,
399            },
400        },
401        QuadrantChartQuadrantData {
402            x: quadrant_left,
403            y: quadrant_top + quadrant_half_height,
404            width: quadrant_half_width,
405            height: quadrant_half_height,
406            fill: theme.quadrant3_fill.clone(),
407            text: QuadrantChartTextData {
408                text: model.quadrants.quadrant3_text,
409                fill: theme.quadrant3_text_fill.clone(),
410                x: 0.0,
411                y: 0.0,
412                font_size: cfg.quadrant_label_font_size,
413                vertical_pos: "center".to_string(),
414                horizontal_pos: "middle".to_string(),
415                rotation: 0.0,
416            },
417        },
418        QuadrantChartQuadrantData {
419            x: quadrant_left + quadrant_half_width,
420            y: quadrant_top + quadrant_half_height,
421            width: quadrant_half_width,
422            height: quadrant_half_height,
423            fill: theme.quadrant4_fill.clone(),
424            text: QuadrantChartTextData {
425                text: model.quadrants.quadrant4_text,
426                fill: theme.quadrant4_text_fill.clone(),
427                x: 0.0,
428                y: 0.0,
429                font_size: cfg.quadrant_label_font_size,
430                vertical_pos: "center".to_string(),
431                horizontal_pos: "middle".to_string(),
432                rotation: 0.0,
433            },
434        },
435    ];
436    for q in &mut quadrants {
437        q.text.x = q.x + q.width / 2.0;
438        if model.points.is_empty() {
439            q.text.y = q.y + q.height / 2.0;
440            q.text.horizontal_pos = "middle".to_string();
441        } else {
442            q.text.y = q.y + cfg.quadrant_text_top_padding;
443            q.text.horizontal_pos = "top".to_string();
444        }
445    }
446
447    let draw_x_axis_labels_in_middle = !model.axes.x_axis_right_text.trim().is_empty();
448    let draw_y_axis_labels_in_middle = !model.axes.y_axis_top_text.trim().is_empty();
449
450    let mut axis_labels: Vec<QuadrantChartAxisLabelData> = Vec::new();
451    if !model.axes.x_axis_left_text.trim().is_empty() && show_x_axis {
452        axis_labels.push(QuadrantChartAxisLabelData {
453            text: model.axes.x_axis_left_text,
454            fill: theme.quadrant_x_axis_text_fill.clone(),
455            x: quadrant_left
456                + if draw_x_axis_labels_in_middle {
457                    quadrant_half_width / 2.0
458                } else {
459                    0.0
460                },
461            y: if x_axis_position == "top" {
462                cfg.x_axis_label_padding + title_space_top
463            } else {
464                cfg.x_axis_label_padding + quadrant_top + quadrant_height + cfg.quadrant_padding
465            },
466            font_size: cfg.x_axis_label_font_size,
467            vertical_pos: if draw_x_axis_labels_in_middle {
468                "center".to_string()
469            } else {
470                "left".to_string()
471            },
472            horizontal_pos: "top".to_string(),
473            rotation: 0.0,
474        });
475    }
476    if !model.axes.x_axis_right_text.trim().is_empty() && show_x_axis {
477        axis_labels.push(QuadrantChartAxisLabelData {
478            text: model.axes.x_axis_right_text,
479            fill: theme.quadrant_x_axis_text_fill.clone(),
480            x: quadrant_left
481                + quadrant_half_width
482                + if draw_x_axis_labels_in_middle {
483                    quadrant_half_width / 2.0
484                } else {
485                    0.0
486                },
487            y: if x_axis_position == "top" {
488                cfg.x_axis_label_padding + title_space_top
489            } else {
490                cfg.x_axis_label_padding + quadrant_top + quadrant_height + cfg.quadrant_padding
491            },
492            font_size: cfg.x_axis_label_font_size,
493            vertical_pos: if draw_x_axis_labels_in_middle {
494                "center".to_string()
495            } else {
496                "left".to_string()
497            },
498            horizontal_pos: "top".to_string(),
499            rotation: 0.0,
500        });
501    }
502    if !model.axes.y_axis_bottom_text.trim().is_empty() && show_y_axis {
503        axis_labels.push(QuadrantChartAxisLabelData {
504            text: model.axes.y_axis_bottom_text,
505            fill: theme.quadrant_y_axis_text_fill.clone(),
506            x: if cfg.y_axis_position == "left" {
507                cfg.y_axis_label_padding
508            } else {
509                cfg.y_axis_label_padding + quadrant_left + quadrant_width + cfg.quadrant_padding
510            },
511            y: quadrant_top + quadrant_height
512                - if draw_y_axis_labels_in_middle {
513                    quadrant_half_height / 2.0
514                } else {
515                    0.0
516                },
517            font_size: cfg.y_axis_label_font_size,
518            vertical_pos: if draw_y_axis_labels_in_middle {
519                "center".to_string()
520            } else {
521                "left".to_string()
522            },
523            horizontal_pos: "top".to_string(),
524            rotation: -90.0,
525        });
526    }
527    if !model.axes.y_axis_top_text.trim().is_empty() && show_y_axis {
528        axis_labels.push(QuadrantChartAxisLabelData {
529            text: model.axes.y_axis_top_text,
530            fill: theme.quadrant_y_axis_text_fill.clone(),
531            x: if cfg.y_axis_position == "left" {
532                cfg.y_axis_label_padding
533            } else {
534                cfg.y_axis_label_padding + quadrant_left + quadrant_width + cfg.quadrant_padding
535            },
536            y: quadrant_top + quadrant_half_height
537                - if draw_y_axis_labels_in_middle {
538                    quadrant_half_height / 2.0
539                } else {
540                    0.0
541                },
542            font_size: cfg.y_axis_label_font_size,
543            vertical_pos: if draw_y_axis_labels_in_middle {
544                "center".to_string()
545            } else {
546                "left".to_string()
547            },
548            horizontal_pos: "top".to_string(),
549            rotation: -90.0,
550        });
551    }
552
553    let half_external_border_width = cfg.quadrant_external_border_stroke_width / 2.0;
554    let border_lines = vec![
555        QuadrantChartBorderLineData {
556            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
557            stroke_width: cfg.quadrant_external_border_stroke_width,
558            x1: quadrant_left - half_external_border_width,
559            y1: quadrant_top,
560            x2: quadrant_left + quadrant_width + half_external_border_width,
561            y2: quadrant_top,
562        },
563        QuadrantChartBorderLineData {
564            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
565            stroke_width: cfg.quadrant_external_border_stroke_width,
566            x1: quadrant_left + quadrant_width,
567            y1: quadrant_top + half_external_border_width,
568            x2: quadrant_left + quadrant_width,
569            y2: quadrant_top + quadrant_height - half_external_border_width,
570        },
571        QuadrantChartBorderLineData {
572            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
573            stroke_width: cfg.quadrant_external_border_stroke_width,
574            x1: quadrant_left - half_external_border_width,
575            y1: quadrant_top + quadrant_height,
576            x2: quadrant_left + quadrant_width + half_external_border_width,
577            y2: quadrant_top + quadrant_height,
578        },
579        QuadrantChartBorderLineData {
580            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
581            stroke_width: cfg.quadrant_external_border_stroke_width,
582            x1: quadrant_left,
583            y1: quadrant_top + half_external_border_width,
584            x2: quadrant_left,
585            y2: quadrant_top + quadrant_height - half_external_border_width,
586        },
587        QuadrantChartBorderLineData {
588            stroke_fill: theme.quadrant_internal_border_stroke_fill.clone(),
589            stroke_width: cfg.quadrant_internal_border_stroke_width,
590            x1: quadrant_left + quadrant_half_width,
591            y1: quadrant_top + half_external_border_width,
592            x2: quadrant_left + quadrant_half_width,
593            y2: quadrant_top + quadrant_height - half_external_border_width,
594        },
595        QuadrantChartBorderLineData {
596            stroke_fill: theme.quadrant_internal_border_stroke_fill.clone(),
597            stroke_width: cfg.quadrant_internal_border_stroke_width,
598            x1: quadrant_left + half_external_border_width,
599            y1: quadrant_top + quadrant_half_height,
600            x2: quadrant_left + quadrant_width - half_external_border_width,
601            y2: quadrant_top + quadrant_half_height,
602        },
603    ];
604
605    let mut points: Vec<QuadrantChartPointData> = Vec::new();
606    for p in model.points {
607        let class_styles = p
608            .class_name
609            .as_deref()
610            .and_then(|name| model.classes.get(name));
611
612        let radius = p
613            .styles
614            .radius
615            .or_else(|| class_styles.and_then(|c| c.radius))
616            .unwrap_or(cfg.point_radius);
617        let fill = p
618            .styles
619            .color
620            .clone()
621            .or_else(|| class_styles.and_then(|c| c.color.clone()))
622            .unwrap_or_else(|| theme.quadrant_point_fill.clone());
623        let stroke_color = p
624            .styles
625            .stroke_color
626            .clone()
627            .or_else(|| class_styles.and_then(|c| c.stroke_color.clone()))
628            .unwrap_or_else(|| theme.quadrant_point_fill.clone());
629        let stroke_width = p
630            .styles
631            .stroke_width
632            .clone()
633            .or_else(|| class_styles.and_then(|c| c.stroke_width.clone()))
634            .unwrap_or_else(|| "0px".to_string());
635
636        let x = scale_linear(
637            (0.0, 1.0),
638            (quadrant_left, quadrant_width + quadrant_left),
639            p.x,
640        );
641        let y = scale_linear(
642            (0.0, 1.0),
643            (quadrant_height + quadrant_top, quadrant_top),
644            p.y,
645        );
646        points.push(QuadrantChartPointData {
647            x,
648            y,
649            fill: fill.clone(),
650            radius,
651            stroke_color,
652            stroke_width,
653            text: QuadrantChartTextData {
654                text: p.text,
655                fill: theme.quadrant_point_text_fill.clone(),
656                x,
657                y: y + cfg.point_text_padding,
658                font_size: cfg.point_label_font_size,
659                vertical_pos: "center".to_string(),
660                horizontal_pos: "top".to_string(),
661                rotation: 0.0,
662            },
663        });
664    }
665
666    let title = if show_title {
667        Some(QuadrantChartTextData {
668            text: title_text.to_string(),
669            fill: theme.quadrant_title_fill,
670            font_size: cfg.title_font_size,
671            horizontal_pos: "top".to_string(),
672            vertical_pos: "center".to_string(),
673            rotation: 0.0,
674            y: cfg.title_padding,
675            x: cfg.chart_width / 2.0,
676        })
677    } else {
678        None
679    };
680
681    Ok(QuadrantChartDiagramLayout {
682        width: cfg.chart_width,
683        height: cfg.chart_height,
684        title,
685        quadrants,
686        border_lines,
687        points,
688        axis_labels,
689    })
690}