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 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 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 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}