Skip to main content

merman_render/
quadrantchart.rs

1use crate::Result;
2use crate::config::{config_f64, config_string};
3use crate::json::from_value_ref;
4use crate::model::{
5    QuadrantChartAxisLabelData, QuadrantChartBorderLineData, QuadrantChartDiagramLayout,
6    QuadrantChartPointData, QuadrantChartQuadrantData, QuadrantChartTextData,
7};
8use crate::text::TextMeasurer;
9use crate::theme::PresentationTheme;
10use merman_core::diagrams::quadrant_chart::QuadrantChartRenderModel;
11use serde_json::Value;
12
13#[derive(Debug, Clone)]
14struct QuadrantChartConfig {
15    chart_width: f64,
16    chart_height: f64,
17    title_padding: f64,
18    title_font_size: f64,
19    quadrant_padding: f64,
20    x_axis_label_padding: f64,
21    y_axis_label_padding: f64,
22    x_axis_label_font_size: f64,
23    y_axis_label_font_size: f64,
24    quadrant_label_font_size: f64,
25    quadrant_text_top_padding: f64,
26    point_text_padding: f64,
27    point_label_font_size: f64,
28    point_radius: f64,
29    x_axis_position: String,
30    y_axis_position: String,
31    quadrant_internal_border_stroke_width: f64,
32    quadrant_external_border_stroke_width: f64,
33}
34
35fn default_quadrant_config(effective_config: &Value) -> QuadrantChartConfig {
36    QuadrantChartConfig {
37        chart_width: config_f64(effective_config, &["quadrantChart", "chartWidth"])
38            .unwrap_or(500.0),
39        chart_height: config_f64(effective_config, &["quadrantChart", "chartHeight"])
40            .unwrap_or(500.0),
41        title_padding: config_f64(effective_config, &["quadrantChart", "titlePadding"])
42            .unwrap_or(10.0),
43        title_font_size: config_f64(effective_config, &["quadrantChart", "titleFontSize"])
44            .unwrap_or(20.0),
45        quadrant_padding: config_f64(effective_config, &["quadrantChart", "quadrantPadding"])
46            .unwrap_or(5.0),
47        x_axis_label_padding: config_f64(effective_config, &["quadrantChart", "xAxisLabelPadding"])
48            .unwrap_or(5.0),
49        y_axis_label_padding: config_f64(effective_config, &["quadrantChart", "yAxisLabelPadding"])
50            .unwrap_or(5.0),
51        x_axis_label_font_size: config_f64(
52            effective_config,
53            &["quadrantChart", "xAxisLabelFontSize"],
54        )
55        .unwrap_or(16.0),
56        y_axis_label_font_size: config_f64(
57            effective_config,
58            &["quadrantChart", "yAxisLabelFontSize"],
59        )
60        .unwrap_or(16.0),
61        quadrant_label_font_size: config_f64(
62            effective_config,
63            &["quadrantChart", "quadrantLabelFontSize"],
64        )
65        .unwrap_or(16.0),
66        quadrant_text_top_padding: config_f64(
67            effective_config,
68            &["quadrantChart", "quadrantTextTopPadding"],
69        )
70        .unwrap_or(5.0),
71        point_text_padding: config_f64(effective_config, &["quadrantChart", "pointTextPadding"])
72            .unwrap_or(5.0),
73        point_label_font_size: config_f64(
74            effective_config,
75            &["quadrantChart", "pointLabelFontSize"],
76        )
77        .unwrap_or(12.0),
78        point_radius: config_f64(effective_config, &["quadrantChart", "pointRadius"])
79            .unwrap_or(5.0),
80        x_axis_position: config_string(effective_config, &["quadrantChart", "xAxisPosition"])
81            .unwrap_or_else(|| "top".to_string()),
82        y_axis_position: config_string(effective_config, &["quadrantChart", "yAxisPosition"])
83            .unwrap_or_else(|| "left".to_string()),
84        quadrant_internal_border_stroke_width: config_f64(
85            effective_config,
86            &["quadrantChart", "quadrantInternalBorderStrokeWidth"],
87        )
88        .unwrap_or(1.0),
89        quadrant_external_border_stroke_width: config_f64(
90            effective_config,
91            &["quadrantChart", "quadrantExternalBorderStrokeWidth"],
92        )
93        .unwrap_or(2.0),
94    }
95}
96
97fn default_quadrant_theme(effective_config: &Value) -> crate::theme::QuadrantChartTheme {
98    PresentationTheme::new(effective_config).quadrantchart()
99}
100
101fn scale_linear(domain: (f64, f64), range: (f64, f64), v: f64) -> f64 {
102    let (d0, d1) = domain;
103    let (r0, r1) = range;
104    if d1 == d0 {
105        return r0;
106    }
107    let t = (v - d0) / (d1 - d0);
108    r0 + t * (r1 - r0)
109}
110
111pub fn layout_quadrantchart_diagram(
112    model: &Value,
113    effective_config: &Value,
114    _text_measurer: &dyn TextMeasurer,
115) -> Result<QuadrantChartDiagramLayout> {
116    let model: QuadrantChartRenderModel = from_value_ref(model)?;
117    layout_quadrantchart_diagram_typed(&model, effective_config, _text_measurer)
118}
119
120pub fn layout_quadrantchart_diagram_typed(
121    model: &QuadrantChartRenderModel,
122    effective_config: &Value,
123    _text_measurer: &dyn TextMeasurer,
124) -> Result<QuadrantChartDiagramLayout> {
125    let cfg = default_quadrant_config(effective_config);
126    let theme = default_quadrant_theme(effective_config);
127
128    let title_text = model.title.as_deref().unwrap_or("").trim();
129    let show_title = !title_text.is_empty();
130
131    let show_x_axis = !model.axes.x_axis_left_text.trim().is_empty()
132        || !model.axes.x_axis_right_text.trim().is_empty();
133    let show_y_axis = !model.axes.y_axis_top_text.trim().is_empty()
134        || !model.axes.y_axis_bottom_text.trim().is_empty();
135
136    let x_axis_position = if model.points.is_empty() {
137        cfg.x_axis_position.as_str()
138    } else {
139        "bottom"
140    };
141
142    let x_axis_space_calc = cfg.x_axis_label_padding * 2.0 + cfg.x_axis_label_font_size;
143    let x_axis_space_top = if x_axis_position == "top" && show_x_axis {
144        x_axis_space_calc
145    } else {
146        0.0
147    };
148    let x_axis_space_bottom = if x_axis_position == "bottom" && show_x_axis {
149        x_axis_space_calc
150    } else {
151        0.0
152    };
153
154    let y_axis_space_calc = cfg.y_axis_label_padding * 2.0 + cfg.y_axis_label_font_size;
155    let y_axis_space_left = if cfg.y_axis_position == "left" && show_y_axis {
156        y_axis_space_calc
157    } else {
158        0.0
159    };
160    let y_axis_space_right = if cfg.y_axis_position == "right" && show_y_axis {
161        y_axis_space_calc
162    } else {
163        0.0
164    };
165
166    let title_space_top = if show_title {
167        cfg.title_font_size + cfg.title_padding * 2.0
168    } else {
169        0.0
170    };
171
172    let quadrant_left = cfg.quadrant_padding + y_axis_space_left;
173    let quadrant_top = cfg.quadrant_padding + x_axis_space_top + title_space_top;
174    let quadrant_width =
175        cfg.chart_width - cfg.quadrant_padding * 2.0 - y_axis_space_left - y_axis_space_right;
176    let quadrant_height = cfg.chart_height
177        - cfg.quadrant_padding * 2.0
178        - x_axis_space_top
179        - x_axis_space_bottom
180        - title_space_top;
181    let quadrant_half_width = quadrant_width / 2.0;
182    let quadrant_half_height = quadrant_height / 2.0;
183
184    let mut quadrants: Vec<QuadrantChartQuadrantData> = vec![
185        QuadrantChartQuadrantData {
186            x: quadrant_left + quadrant_half_width,
187            y: quadrant_top,
188            width: quadrant_half_width,
189            height: quadrant_half_height,
190            fill: theme.quadrant1_fill.clone(),
191            text: QuadrantChartTextData {
192                text: model.quadrants.quadrant1_text.clone(),
193                fill: theme.quadrant1_text_fill.clone(),
194                x: 0.0,
195                y: 0.0,
196                font_size: cfg.quadrant_label_font_size,
197                vertical_pos: "center".to_string(),
198                horizontal_pos: "middle".to_string(),
199                rotation: 0.0,
200            },
201        },
202        QuadrantChartQuadrantData {
203            x: quadrant_left,
204            y: quadrant_top,
205            width: quadrant_half_width,
206            height: quadrant_half_height,
207            fill: theme.quadrant2_fill.clone(),
208            text: QuadrantChartTextData {
209                text: model.quadrants.quadrant2_text.clone(),
210                fill: theme.quadrant2_text_fill.clone(),
211                x: 0.0,
212                y: 0.0,
213                font_size: cfg.quadrant_label_font_size,
214                vertical_pos: "center".to_string(),
215                horizontal_pos: "middle".to_string(),
216                rotation: 0.0,
217            },
218        },
219        QuadrantChartQuadrantData {
220            x: quadrant_left,
221            y: quadrant_top + quadrant_half_height,
222            width: quadrant_half_width,
223            height: quadrant_half_height,
224            fill: theme.quadrant3_fill.clone(),
225            text: QuadrantChartTextData {
226                text: model.quadrants.quadrant3_text.clone(),
227                fill: theme.quadrant3_text_fill.clone(),
228                x: 0.0,
229                y: 0.0,
230                font_size: cfg.quadrant_label_font_size,
231                vertical_pos: "center".to_string(),
232                horizontal_pos: "middle".to_string(),
233                rotation: 0.0,
234            },
235        },
236        QuadrantChartQuadrantData {
237            x: quadrant_left + quadrant_half_width,
238            y: quadrant_top + quadrant_half_height,
239            width: quadrant_half_width,
240            height: quadrant_half_height,
241            fill: theme.quadrant4_fill.clone(),
242            text: QuadrantChartTextData {
243                text: model.quadrants.quadrant4_text.clone(),
244                fill: theme.quadrant4_text_fill.clone(),
245                x: 0.0,
246                y: 0.0,
247                font_size: cfg.quadrant_label_font_size,
248                vertical_pos: "center".to_string(),
249                horizontal_pos: "middle".to_string(),
250                rotation: 0.0,
251            },
252        },
253    ];
254    for q in &mut quadrants {
255        q.text.x = q.x + q.width / 2.0;
256        if model.points.is_empty() {
257            q.text.y = q.y + q.height / 2.0;
258            q.text.horizontal_pos = "middle".to_string();
259        } else {
260            q.text.y = q.y + cfg.quadrant_text_top_padding;
261            q.text.horizontal_pos = "top".to_string();
262        }
263    }
264
265    let draw_x_axis_labels_in_middle = !model.axes.x_axis_right_text.trim().is_empty();
266    let draw_y_axis_labels_in_middle = !model.axes.y_axis_top_text.trim().is_empty();
267
268    let mut axis_labels: Vec<QuadrantChartAxisLabelData> = Vec::new();
269    if !model.axes.x_axis_left_text.trim().is_empty() && show_x_axis {
270        axis_labels.push(QuadrantChartAxisLabelData {
271            text: model.axes.x_axis_left_text.clone(),
272            fill: theme.quadrant_x_axis_text_fill.clone(),
273            x: quadrant_left
274                + if draw_x_axis_labels_in_middle {
275                    quadrant_half_width / 2.0
276                } else {
277                    0.0
278                },
279            y: if x_axis_position == "top" {
280                cfg.x_axis_label_padding + title_space_top
281            } else {
282                cfg.x_axis_label_padding + quadrant_top + quadrant_height + cfg.quadrant_padding
283            },
284            font_size: cfg.x_axis_label_font_size,
285            vertical_pos: if draw_x_axis_labels_in_middle {
286                "center".to_string()
287            } else {
288                "left".to_string()
289            },
290            horizontal_pos: "top".to_string(),
291            rotation: 0.0,
292        });
293    }
294    if !model.axes.x_axis_right_text.trim().is_empty() && show_x_axis {
295        axis_labels.push(QuadrantChartAxisLabelData {
296            text: model.axes.x_axis_right_text.clone(),
297            fill: theme.quadrant_x_axis_text_fill.clone(),
298            x: quadrant_left
299                + quadrant_half_width
300                + if draw_x_axis_labels_in_middle {
301                    quadrant_half_width / 2.0
302                } else {
303                    0.0
304                },
305            y: if x_axis_position == "top" {
306                cfg.x_axis_label_padding + title_space_top
307            } else {
308                cfg.x_axis_label_padding + quadrant_top + quadrant_height + cfg.quadrant_padding
309            },
310            font_size: cfg.x_axis_label_font_size,
311            vertical_pos: if draw_x_axis_labels_in_middle {
312                "center".to_string()
313            } else {
314                "left".to_string()
315            },
316            horizontal_pos: "top".to_string(),
317            rotation: 0.0,
318        });
319    }
320    if !model.axes.y_axis_bottom_text.trim().is_empty() && show_y_axis {
321        axis_labels.push(QuadrantChartAxisLabelData {
322            text: model.axes.y_axis_bottom_text.clone(),
323            fill: theme.quadrant_y_axis_text_fill.clone(),
324            x: if cfg.y_axis_position == "left" {
325                cfg.y_axis_label_padding
326            } else {
327                cfg.y_axis_label_padding + quadrant_left + quadrant_width + cfg.quadrant_padding
328            },
329            y: quadrant_top + quadrant_height
330                - if draw_y_axis_labels_in_middle {
331                    quadrant_half_height / 2.0
332                } else {
333                    0.0
334                },
335            font_size: cfg.y_axis_label_font_size,
336            vertical_pos: if draw_y_axis_labels_in_middle {
337                "center".to_string()
338            } else {
339                "left".to_string()
340            },
341            horizontal_pos: "top".to_string(),
342            rotation: -90.0,
343        });
344    }
345    if !model.axes.y_axis_top_text.trim().is_empty() && show_y_axis {
346        axis_labels.push(QuadrantChartAxisLabelData {
347            text: model.axes.y_axis_top_text.clone(),
348            fill: theme.quadrant_y_axis_text_fill.clone(),
349            x: if cfg.y_axis_position == "left" {
350                cfg.y_axis_label_padding
351            } else {
352                cfg.y_axis_label_padding + quadrant_left + quadrant_width + cfg.quadrant_padding
353            },
354            y: quadrant_top + quadrant_half_height
355                - if draw_y_axis_labels_in_middle {
356                    quadrant_half_height / 2.0
357                } else {
358                    0.0
359                },
360            font_size: cfg.y_axis_label_font_size,
361            vertical_pos: if draw_y_axis_labels_in_middle {
362                "center".to_string()
363            } else {
364                "left".to_string()
365            },
366            horizontal_pos: "top".to_string(),
367            rotation: -90.0,
368        });
369    }
370
371    let half_external_border_width = cfg.quadrant_external_border_stroke_width / 2.0;
372    let border_lines = vec![
373        QuadrantChartBorderLineData {
374            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
375            stroke_width: cfg.quadrant_external_border_stroke_width,
376            x1: quadrant_left - half_external_border_width,
377            y1: quadrant_top,
378            x2: quadrant_left + quadrant_width + half_external_border_width,
379            y2: quadrant_top,
380        },
381        QuadrantChartBorderLineData {
382            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
383            stroke_width: cfg.quadrant_external_border_stroke_width,
384            x1: quadrant_left + quadrant_width,
385            y1: quadrant_top + half_external_border_width,
386            x2: quadrant_left + quadrant_width,
387            y2: quadrant_top + quadrant_height - half_external_border_width,
388        },
389        QuadrantChartBorderLineData {
390            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
391            stroke_width: cfg.quadrant_external_border_stroke_width,
392            x1: quadrant_left - half_external_border_width,
393            y1: quadrant_top + quadrant_height,
394            x2: quadrant_left + quadrant_width + half_external_border_width,
395            y2: quadrant_top + quadrant_height,
396        },
397        QuadrantChartBorderLineData {
398            stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
399            stroke_width: cfg.quadrant_external_border_stroke_width,
400            x1: quadrant_left,
401            y1: quadrant_top + half_external_border_width,
402            x2: quadrant_left,
403            y2: quadrant_top + quadrant_height - half_external_border_width,
404        },
405        QuadrantChartBorderLineData {
406            stroke_fill: theme.quadrant_internal_border_stroke_fill.clone(),
407            stroke_width: cfg.quadrant_internal_border_stroke_width,
408            x1: quadrant_left + quadrant_half_width,
409            y1: quadrant_top + half_external_border_width,
410            x2: quadrant_left + quadrant_half_width,
411            y2: quadrant_top + quadrant_height - half_external_border_width,
412        },
413        QuadrantChartBorderLineData {
414            stroke_fill: theme.quadrant_internal_border_stroke_fill.clone(),
415            stroke_width: cfg.quadrant_internal_border_stroke_width,
416            x1: quadrant_left + half_external_border_width,
417            y1: quadrant_top + quadrant_half_height,
418            x2: quadrant_left + quadrant_width - half_external_border_width,
419            y2: quadrant_top + quadrant_half_height,
420        },
421    ];
422
423    let mut points: Vec<QuadrantChartPointData> = Vec::new();
424    for p in &model.points {
425        let class_styles = p
426            .class_name
427            .as_deref()
428            .and_then(|name| model.classes.get(name));
429
430        let radius = p
431            .styles
432            .radius
433            .map(|v| v as f64)
434            .or_else(|| class_styles.and_then(|c| c.radius.map(|v| v as f64)))
435            .unwrap_or(cfg.point_radius);
436        let fill = p
437            .styles
438            .color
439            .clone()
440            .or_else(|| class_styles.and_then(|c| c.color.clone()))
441            .unwrap_or_else(|| theme.quadrant_point_fill.clone());
442        let stroke_color = p
443            .styles
444            .stroke_color
445            .clone()
446            .or_else(|| class_styles.and_then(|c| c.stroke_color.clone()))
447            .unwrap_or_else(|| theme.quadrant_point_fill.clone());
448        let stroke_width = p
449            .styles
450            .stroke_width
451            .clone()
452            .or_else(|| class_styles.and_then(|c| c.stroke_width.clone()))
453            .unwrap_or_else(|| "0px".to_string());
454
455        let x = scale_linear(
456            (0.0, 1.0),
457            (quadrant_left, quadrant_width + quadrant_left),
458            p.x,
459        );
460        let y = scale_linear(
461            (0.0, 1.0),
462            (quadrant_height + quadrant_top, quadrant_top),
463            p.y,
464        );
465        points.push(QuadrantChartPointData {
466            x,
467            y,
468            fill: fill.clone(),
469            radius,
470            stroke_color,
471            stroke_width,
472            text: QuadrantChartTextData {
473                text: p.text.clone(),
474                fill: theme.quadrant_point_text_fill.clone(),
475                x,
476                y: y + cfg.point_text_padding,
477                font_size: cfg.point_label_font_size,
478                vertical_pos: "center".to_string(),
479                horizontal_pos: "top".to_string(),
480                rotation: 0.0,
481            },
482        });
483    }
484
485    let title = if show_title {
486        Some(QuadrantChartTextData {
487            text: title_text.to_string(),
488            fill: theme.quadrant_title_fill,
489            font_size: cfg.title_font_size,
490            horizontal_pos: "top".to_string(),
491            vertical_pos: "center".to_string(),
492            rotation: 0.0,
493            y: cfg.title_padding,
494            x: cfg.chart_width / 2.0,
495        })
496    } else {
497        None
498    };
499
500    Ok(QuadrantChartDiagramLayout {
501        width: cfg.chart_width,
502        height: cfg.chart_height,
503        title,
504        quadrants,
505        border_lines,
506        points,
507        axis_labels,
508    })
509}