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}