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 fmt_rgb(r: u8, g: u8, b: u8) -> String {
215 format!("rgb({r}, {g}, {b})")
216}
217
218fn parse_hsl_css(s: &str) -> Option<(f64, f64, f64)> {
219 let inner = s.trim().strip_prefix("hsl(")?.strip_suffix(')')?;
220 let mut parts = inner.split(',').map(|p| p.trim());
221 let h = parts.next()?.parse::<f64>().ok()?;
222 let s = parts
223 .next()?
224 .strip_suffix('%')
225 .unwrap_or_default()
226 .parse::<f64>()
227 .ok()?;
228 let l = parts
229 .next()?
230 .strip_suffix('%')
231 .unwrap_or_default()
232 .parse::<f64>()
233 .ok()?;
234 Some((h, s, l))
235}
236
237fn hsl_to_rgb_u8(h_deg: f64, s_pct: f64, l_pct: f64) -> Option<(u8, u8, u8)> {
238 if !(h_deg.is_finite() && s_pct.is_finite() && l_pct.is_finite()) {
239 return None;
240 }
241
242 let h = (h_deg / 360.0).rem_euclid(1.0);
243 let s = (s_pct / 100.0).clamp(0.0, 1.0);
244 let l = (l_pct / 100.0).clamp(0.0, 1.0);
245
246 if s == 0.0 {
248 let v = (l * 255.0).round().clamp(0.0, 255.0) as u8;
249 return Some((v, v, v));
250 }
251
252 let q = if l < 0.5 {
253 l * (1.0 + s)
254 } else {
255 l + s - l * s
256 };
257 let p = 2.0 * l - q;
258
259 fn hue_to_rgb(p: f64, q: f64, mut t: f64) -> f64 {
260 if t < 0.0 {
261 t += 1.0;
262 }
263 if t > 1.0 {
264 t -= 1.0;
265 }
266 if t < 1.0 / 6.0 {
267 return p + (q - p) * 6.0 * t;
268 }
269 if t < 1.0 / 2.0 {
270 return q;
271 }
272 if t < 2.0 / 3.0 {
273 return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
274 }
275 p
276 }
277
278 let r = hue_to_rgb(p, q, h + 1.0 / 3.0);
279 let g = hue_to_rgb(p, q, h);
280 let b = hue_to_rgb(p, q, h - 1.0 / 3.0);
281
282 let to_u8 = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
283 Some((to_u8(r), to_u8(g), to_u8(b)))
284}
285
286fn css_color_to_rgb_string(s: &str) -> Option<String> {
287 let t = s.trim();
288 if t.starts_with("rgb(") {
289 return Some(t.to_string());
290 }
291 if let Some((r, g, b)) = parse_hex_rgb(t) {
292 return Some(fmt_rgb(r, g, b));
293 }
294 if let Some((h, s, l)) = parse_hsl_css(t) {
295 let (r, g, b) = hsl_to_rgb_u8(h, s, l)?;
296 return Some(fmt_rgb(r, g, b));
297 }
298 None
299}
300
301fn default_quadrant_theme(effective_config: &Value) -> QuadrantThemeConfig {
302 let quadrant1_fill = config_string(effective_config, &["themeVariables", "primaryColor"])
312 .unwrap_or_else(|| "#ECECFF".to_string());
313 let primary_text = config_string(effective_config, &["themeVariables", "primaryTextColor"])
314 .or_else(|| invert_hex_rgb(&quadrant1_fill))
315 .unwrap_or_else(|| "#131300".to_string());
316 let border_stroke = config_string(effective_config, &["themeVariables", "primaryBorderColor"])
317 .and_then(|v| css_color_to_rgb_string(&v))
318 .unwrap_or_else(|| "rgb(199, 199, 241)".to_string());
319 QuadrantThemeConfig {
320 quadrant2_fill: adjust_hex_rgb(&quadrant1_fill, 5).unwrap_or_else(|| "#f1f1ff".to_string()),
321 quadrant3_fill: adjust_hex_rgb(&quadrant1_fill, 10)
322 .unwrap_or_else(|| "#f6f6ff".to_string()),
323 quadrant4_fill: adjust_hex_rgb(&quadrant1_fill, 15)
324 .unwrap_or_else(|| "#fbfbff".to_string()),
325 quadrant1_text_fill: primary_text.clone(),
326 quadrant2_text_fill: adjust_hex_rgb(&primary_text, -5)
327 .unwrap_or_else(|| "#0e0e00".to_string()),
328 quadrant3_text_fill: adjust_hex_rgb(&primary_text, -10)
329 .unwrap_or_else(|| "#090900".to_string()),
330 quadrant4_text_fill: adjust_hex_rgb(&primary_text, -15)
331 .unwrap_or_else(|| "#040400".to_string()),
332 quadrant_point_fill: "hsl(240, 100%, NaN%)".to_string(),
333 quadrant_point_text_fill: primary_text.clone(),
334 quadrant_x_axis_text_fill: primary_text.clone(),
335 quadrant_y_axis_text_fill: primary_text.clone(),
336 quadrant_title_fill: primary_text,
337 quadrant_internal_border_stroke_fill: border_stroke.clone(),
338 quadrant_external_border_stroke_fill: border_stroke,
339 quadrant1_fill,
340 }
341}
342
343fn quadrant_theme_with_overrides(effective_config: &Value) -> QuadrantThemeConfig {
344 let mut theme = default_quadrant_theme(effective_config);
345
346 let set = |field: &mut String, key: &str| {
349 if let Some(v) = config_string(effective_config, &["themeVariables", key]) {
350 *field = v;
351 }
352 };
353
354 set(&mut theme.quadrant1_fill, "quadrant1Fill");
355 set(&mut theme.quadrant2_fill, "quadrant2Fill");
356 set(&mut theme.quadrant3_fill, "quadrant3Fill");
357 set(&mut theme.quadrant4_fill, "quadrant4Fill");
358
359 set(&mut theme.quadrant1_text_fill, "quadrant1TextFill");
360 set(&mut theme.quadrant2_text_fill, "quadrant2TextFill");
361 set(&mut theme.quadrant3_text_fill, "quadrant3TextFill");
362 set(&mut theme.quadrant4_text_fill, "quadrant4TextFill");
363
364 set(&mut theme.quadrant_point_fill, "quadrantPointFill");
365 set(&mut theme.quadrant_point_text_fill, "quadrantPointTextFill");
366 set(
367 &mut theme.quadrant_x_axis_text_fill,
368 "quadrantXAxisTextFill",
369 );
370 set(
371 &mut theme.quadrant_y_axis_text_fill,
372 "quadrantYAxisTextFill",
373 );
374 set(&mut theme.quadrant_title_fill, "quadrantTitleFill");
375
376 set(
377 &mut theme.quadrant_internal_border_stroke_fill,
378 "quadrantInternalBorderStrokeFill",
379 );
380 set(
381 &mut theme.quadrant_external_border_stroke_fill,
382 "quadrantExternalBorderStrokeFill",
383 );
384
385 theme
386}
387
388fn scale_linear(domain: (f64, f64), range: (f64, f64), v: f64) -> f64 {
389 let (d0, d1) = domain;
390 let (r0, r1) = range;
391 if d1 == d0 {
392 return r0;
393 }
394 let t = (v - d0) / (d1 - d0);
395 r0 + t * (r1 - r0)
396}
397
398pub fn layout_quadrantchart_diagram(
399 model: &Value,
400 effective_config: &Value,
401 _text_measurer: &dyn TextMeasurer,
402) -> Result<QuadrantChartDiagramLayout> {
403 let model: QuadrantChartModel = from_value_ref(model)?;
404
405 let cfg = default_quadrant_config(effective_config);
406 let theme = quadrant_theme_with_overrides(effective_config);
407
408 let title_text = model.title.as_deref().unwrap_or("").trim();
409 let show_title = !title_text.is_empty();
410
411 let show_x_axis = !model.axes.x_axis_left_text.trim().is_empty()
412 || !model.axes.x_axis_right_text.trim().is_empty();
413 let show_y_axis = !model.axes.y_axis_top_text.trim().is_empty()
414 || !model.axes.y_axis_bottom_text.trim().is_empty();
415
416 let x_axis_position = if model.points.is_empty() {
417 cfg.x_axis_position.as_str()
418 } else {
419 "bottom"
420 };
421
422 let x_axis_space_calc = cfg.x_axis_label_padding * 2.0 + cfg.x_axis_label_font_size;
423 let x_axis_space_top = if x_axis_position == "top" && show_x_axis {
424 x_axis_space_calc
425 } else {
426 0.0
427 };
428 let x_axis_space_bottom = if x_axis_position == "bottom" && show_x_axis {
429 x_axis_space_calc
430 } else {
431 0.0
432 };
433
434 let y_axis_space_calc = cfg.y_axis_label_padding * 2.0 + cfg.y_axis_label_font_size;
435 let y_axis_space_left = if cfg.y_axis_position == "left" && show_y_axis {
436 y_axis_space_calc
437 } else {
438 0.0
439 };
440 let y_axis_space_right = if cfg.y_axis_position == "right" && show_y_axis {
441 y_axis_space_calc
442 } else {
443 0.0
444 };
445
446 let title_space_top = if show_title {
447 cfg.title_font_size + cfg.title_padding * 2.0
448 } else {
449 0.0
450 };
451
452 let quadrant_left = cfg.quadrant_padding + y_axis_space_left;
453 let quadrant_top = cfg.quadrant_padding + x_axis_space_top + title_space_top;
454 let quadrant_width =
455 cfg.chart_width - cfg.quadrant_padding * 2.0 - y_axis_space_left - y_axis_space_right;
456 let quadrant_height = cfg.chart_height
457 - cfg.quadrant_padding * 2.0
458 - x_axis_space_top
459 - x_axis_space_bottom
460 - title_space_top;
461 let quadrant_half_width = quadrant_width / 2.0;
462 let quadrant_half_height = quadrant_height / 2.0;
463
464 let mut quadrants: Vec<QuadrantChartQuadrantData> = vec![
465 QuadrantChartQuadrantData {
466 x: quadrant_left + quadrant_half_width,
467 y: quadrant_top,
468 width: quadrant_half_width,
469 height: quadrant_half_height,
470 fill: theme.quadrant1_fill.clone(),
471 text: QuadrantChartTextData {
472 text: model.quadrants.quadrant1_text,
473 fill: theme.quadrant1_text_fill.clone(),
474 x: 0.0,
475 y: 0.0,
476 font_size: cfg.quadrant_label_font_size,
477 vertical_pos: "center".to_string(),
478 horizontal_pos: "middle".to_string(),
479 rotation: 0.0,
480 },
481 },
482 QuadrantChartQuadrantData {
483 x: quadrant_left,
484 y: quadrant_top,
485 width: quadrant_half_width,
486 height: quadrant_half_height,
487 fill: theme.quadrant2_fill.clone(),
488 text: QuadrantChartTextData {
489 text: model.quadrants.quadrant2_text,
490 fill: theme.quadrant2_text_fill.clone(),
491 x: 0.0,
492 y: 0.0,
493 font_size: cfg.quadrant_label_font_size,
494 vertical_pos: "center".to_string(),
495 horizontal_pos: "middle".to_string(),
496 rotation: 0.0,
497 },
498 },
499 QuadrantChartQuadrantData {
500 x: quadrant_left,
501 y: quadrant_top + quadrant_half_height,
502 width: quadrant_half_width,
503 height: quadrant_half_height,
504 fill: theme.quadrant3_fill.clone(),
505 text: QuadrantChartTextData {
506 text: model.quadrants.quadrant3_text,
507 fill: theme.quadrant3_text_fill.clone(),
508 x: 0.0,
509 y: 0.0,
510 font_size: cfg.quadrant_label_font_size,
511 vertical_pos: "center".to_string(),
512 horizontal_pos: "middle".to_string(),
513 rotation: 0.0,
514 },
515 },
516 QuadrantChartQuadrantData {
517 x: quadrant_left + quadrant_half_width,
518 y: quadrant_top + quadrant_half_height,
519 width: quadrant_half_width,
520 height: quadrant_half_height,
521 fill: theme.quadrant4_fill.clone(),
522 text: QuadrantChartTextData {
523 text: model.quadrants.quadrant4_text,
524 fill: theme.quadrant4_text_fill.clone(),
525 x: 0.0,
526 y: 0.0,
527 font_size: cfg.quadrant_label_font_size,
528 vertical_pos: "center".to_string(),
529 horizontal_pos: "middle".to_string(),
530 rotation: 0.0,
531 },
532 },
533 ];
534 for q in &mut quadrants {
535 q.text.x = q.x + q.width / 2.0;
536 if model.points.is_empty() {
537 q.text.y = q.y + q.height / 2.0;
538 q.text.horizontal_pos = "middle".to_string();
539 } else {
540 q.text.y = q.y + cfg.quadrant_text_top_padding;
541 q.text.horizontal_pos = "top".to_string();
542 }
543 }
544
545 let draw_x_axis_labels_in_middle = !model.axes.x_axis_right_text.trim().is_empty();
546 let draw_y_axis_labels_in_middle = !model.axes.y_axis_top_text.trim().is_empty();
547
548 let mut axis_labels: Vec<QuadrantChartAxisLabelData> = Vec::new();
549 if !model.axes.x_axis_left_text.trim().is_empty() && show_x_axis {
550 axis_labels.push(QuadrantChartAxisLabelData {
551 text: model.axes.x_axis_left_text,
552 fill: theme.quadrant_x_axis_text_fill.clone(),
553 x: quadrant_left
554 + if draw_x_axis_labels_in_middle {
555 quadrant_half_width / 2.0
556 } else {
557 0.0
558 },
559 y: if x_axis_position == "top" {
560 cfg.x_axis_label_padding + title_space_top
561 } else {
562 cfg.x_axis_label_padding + quadrant_top + quadrant_height + cfg.quadrant_padding
563 },
564 font_size: cfg.x_axis_label_font_size,
565 vertical_pos: if draw_x_axis_labels_in_middle {
566 "center".to_string()
567 } else {
568 "left".to_string()
569 },
570 horizontal_pos: "top".to_string(),
571 rotation: 0.0,
572 });
573 }
574 if !model.axes.x_axis_right_text.trim().is_empty() && show_x_axis {
575 axis_labels.push(QuadrantChartAxisLabelData {
576 text: model.axes.x_axis_right_text,
577 fill: theme.quadrant_x_axis_text_fill.clone(),
578 x: quadrant_left
579 + quadrant_half_width
580 + if draw_x_axis_labels_in_middle {
581 quadrant_half_width / 2.0
582 } else {
583 0.0
584 },
585 y: if x_axis_position == "top" {
586 cfg.x_axis_label_padding + title_space_top
587 } else {
588 cfg.x_axis_label_padding + quadrant_top + quadrant_height + cfg.quadrant_padding
589 },
590 font_size: cfg.x_axis_label_font_size,
591 vertical_pos: if draw_x_axis_labels_in_middle {
592 "center".to_string()
593 } else {
594 "left".to_string()
595 },
596 horizontal_pos: "top".to_string(),
597 rotation: 0.0,
598 });
599 }
600 if !model.axes.y_axis_bottom_text.trim().is_empty() && show_y_axis {
601 axis_labels.push(QuadrantChartAxisLabelData {
602 text: model.axes.y_axis_bottom_text,
603 fill: theme.quadrant_y_axis_text_fill.clone(),
604 x: if cfg.y_axis_position == "left" {
605 cfg.y_axis_label_padding
606 } else {
607 cfg.y_axis_label_padding + quadrant_left + quadrant_width + cfg.quadrant_padding
608 },
609 y: quadrant_top + quadrant_height
610 - if draw_y_axis_labels_in_middle {
611 quadrant_half_height / 2.0
612 } else {
613 0.0
614 },
615 font_size: cfg.y_axis_label_font_size,
616 vertical_pos: if draw_y_axis_labels_in_middle {
617 "center".to_string()
618 } else {
619 "left".to_string()
620 },
621 horizontal_pos: "top".to_string(),
622 rotation: -90.0,
623 });
624 }
625 if !model.axes.y_axis_top_text.trim().is_empty() && show_y_axis {
626 axis_labels.push(QuadrantChartAxisLabelData {
627 text: model.axes.y_axis_top_text,
628 fill: theme.quadrant_y_axis_text_fill.clone(),
629 x: if cfg.y_axis_position == "left" {
630 cfg.y_axis_label_padding
631 } else {
632 cfg.y_axis_label_padding + quadrant_left + quadrant_width + cfg.quadrant_padding
633 },
634 y: quadrant_top + quadrant_half_height
635 - if draw_y_axis_labels_in_middle {
636 quadrant_half_height / 2.0
637 } else {
638 0.0
639 },
640 font_size: cfg.y_axis_label_font_size,
641 vertical_pos: if draw_y_axis_labels_in_middle {
642 "center".to_string()
643 } else {
644 "left".to_string()
645 },
646 horizontal_pos: "top".to_string(),
647 rotation: -90.0,
648 });
649 }
650
651 let half_external_border_width = cfg.quadrant_external_border_stroke_width / 2.0;
652 let border_lines = vec![
653 QuadrantChartBorderLineData {
654 stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
655 stroke_width: cfg.quadrant_external_border_stroke_width,
656 x1: quadrant_left - half_external_border_width,
657 y1: quadrant_top,
658 x2: quadrant_left + quadrant_width + half_external_border_width,
659 y2: quadrant_top,
660 },
661 QuadrantChartBorderLineData {
662 stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
663 stroke_width: cfg.quadrant_external_border_stroke_width,
664 x1: quadrant_left + quadrant_width,
665 y1: quadrant_top + half_external_border_width,
666 x2: quadrant_left + quadrant_width,
667 y2: quadrant_top + quadrant_height - half_external_border_width,
668 },
669 QuadrantChartBorderLineData {
670 stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
671 stroke_width: cfg.quadrant_external_border_stroke_width,
672 x1: quadrant_left - half_external_border_width,
673 y1: quadrant_top + quadrant_height,
674 x2: quadrant_left + quadrant_width + half_external_border_width,
675 y2: quadrant_top + quadrant_height,
676 },
677 QuadrantChartBorderLineData {
678 stroke_fill: theme.quadrant_external_border_stroke_fill.clone(),
679 stroke_width: cfg.quadrant_external_border_stroke_width,
680 x1: quadrant_left,
681 y1: quadrant_top + half_external_border_width,
682 x2: quadrant_left,
683 y2: quadrant_top + quadrant_height - half_external_border_width,
684 },
685 QuadrantChartBorderLineData {
686 stroke_fill: theme.quadrant_internal_border_stroke_fill.clone(),
687 stroke_width: cfg.quadrant_internal_border_stroke_width,
688 x1: quadrant_left + quadrant_half_width,
689 y1: quadrant_top + half_external_border_width,
690 x2: quadrant_left + quadrant_half_width,
691 y2: quadrant_top + quadrant_height - half_external_border_width,
692 },
693 QuadrantChartBorderLineData {
694 stroke_fill: theme.quadrant_internal_border_stroke_fill.clone(),
695 stroke_width: cfg.quadrant_internal_border_stroke_width,
696 x1: quadrant_left + half_external_border_width,
697 y1: quadrant_top + quadrant_half_height,
698 x2: quadrant_left + quadrant_width - half_external_border_width,
699 y2: quadrant_top + quadrant_half_height,
700 },
701 ];
702
703 let mut points: Vec<QuadrantChartPointData> = Vec::new();
704 for p in model.points {
705 let class_styles = p
706 .class_name
707 .as_deref()
708 .and_then(|name| model.classes.get(name));
709
710 let radius = p
711 .styles
712 .radius
713 .or_else(|| class_styles.and_then(|c| c.radius))
714 .unwrap_or(cfg.point_radius);
715 let fill = p
716 .styles
717 .color
718 .clone()
719 .or_else(|| class_styles.and_then(|c| c.color.clone()))
720 .unwrap_or_else(|| theme.quadrant_point_fill.clone());
721 let stroke_color = p
722 .styles
723 .stroke_color
724 .clone()
725 .or_else(|| class_styles.and_then(|c| c.stroke_color.clone()))
726 .unwrap_or_else(|| theme.quadrant_point_fill.clone());
727 let stroke_width = p
728 .styles
729 .stroke_width
730 .clone()
731 .or_else(|| class_styles.and_then(|c| c.stroke_width.clone()))
732 .unwrap_or_else(|| "0px".to_string());
733
734 let x = scale_linear(
735 (0.0, 1.0),
736 (quadrant_left, quadrant_width + quadrant_left),
737 p.x,
738 );
739 let y = scale_linear(
740 (0.0, 1.0),
741 (quadrant_height + quadrant_top, quadrant_top),
742 p.y,
743 );
744 points.push(QuadrantChartPointData {
745 x,
746 y,
747 fill: fill.clone(),
748 radius,
749 stroke_color,
750 stroke_width,
751 text: QuadrantChartTextData {
752 text: p.text,
753 fill: theme.quadrant_point_text_fill.clone(),
754 x,
755 y: y + cfg.point_text_padding,
756 font_size: cfg.point_label_font_size,
757 vertical_pos: "center".to_string(),
758 horizontal_pos: "top".to_string(),
759 rotation: 0.0,
760 },
761 });
762 }
763
764 let title = if show_title {
765 Some(QuadrantChartTextData {
766 text: title_text.to_string(),
767 fill: theme.quadrant_title_fill,
768 font_size: cfg.title_font_size,
769 horizontal_pos: "top".to_string(),
770 vertical_pos: "center".to_string(),
771 rotation: 0.0,
772 y: cfg.title_padding,
773 x: cfg.chart_width / 2.0,
774 })
775 } else {
776 None
777 };
778
779 Ok(QuadrantChartDiagramLayout {
780 width: cfg.chart_width,
781 height: cfg.chart_height,
782 title,
783 quadrants,
784 border_lines,
785 points,
786 axis_labels,
787 })
788}