1use super::*;
4use crate::model::DotPlotGroup;
5
6pub struct DotPlotConfig {
8 pub x_min: Option<f64>,
9 pub x_max: Option<f64>,
10 pub y_min: Option<f64>,
11 pub y_max: Option<f64>,
12 pub x_label: Option<String>,
13 pub y_label: Option<String>,
14 pub show_legend: bool,
15 pub dot_size: f64,
16}
17
18pub fn build(
20 width: f64,
21 height: f64,
22 groups: &[DotPlotGroup],
23 config: &DotPlotConfig,
24) -> Vec<ChartPrimitive> {
25 if groups.is_empty() {
26 return vec![];
27 }
28
29 let mut primitives = Vec::new();
30
31 let legend_width = if config.show_legend { 80.0 } else { 0.0 };
33
34 let plot_left = Y_AXIS_WIDTH;
35 let plot_top = LABEL_MARGIN;
36 let plot_right = width - LABEL_MARGIN - legend_width;
37 let plot_bottom = height - X_AXIS_HEIGHT;
38 let plot_width = plot_right - plot_left;
39 let plot_height = plot_bottom - plot_top;
40
41 if plot_width <= 0.0 || plot_height <= 0.0 {
42 return vec![];
43 }
44
45 let all_points: Vec<(f64, f64)> = groups.iter().flat_map(|g| g.data.iter().copied()).collect();
47 if all_points.is_empty() {
48 return vec![];
49 }
50
51 let data_x_min = all_points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
52 let data_x_max = all_points
53 .iter()
54 .map(|p| p.0)
55 .fold(f64::NEG_INFINITY, f64::max);
56 let data_y_min = all_points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
57 let data_y_max = all_points
58 .iter()
59 .map(|p| p.1)
60 .fold(f64::NEG_INFINITY, f64::max);
61
62 let x_min = config.x_min.unwrap_or(data_x_min.min(0.0));
63 let x_max = config.x_max.unwrap_or(nice_number(data_x_max));
64 let y_min = config.y_min.unwrap_or(data_y_min.min(0.0));
65 let y_max = config.y_max.unwrap_or(nice_number(data_y_max));
66
67 let x_range = (x_max - x_min).max(1.0);
68 let y_range = (y_max - y_min).max(1.0);
69
70 let ticks = 5;
72 for i in 0..=ticks {
73 let frac = i as f64 / ticks as f64;
74 let y = plot_bottom - frac * plot_height;
76 primitives.push(ChartPrimitive::Line {
77 x1: plot_left,
78 y1: y,
79 x2: plot_right,
80 y2: y,
81 stroke: GRID_COLOR,
82 width: 0.5,
83 });
84 let y_val = y_min + frac * y_range;
86 primitives.push(ChartPrimitive::Label {
87 text: format_number(y_val),
88 x: plot_left - LABEL_MARGIN,
89 y: y + AXIS_LABEL_FONT * 0.35,
90 font_size: AXIS_LABEL_FONT,
91 color: LABEL_COLOR,
92 anchor: TextAnchor::Right,
93 });
94 let x = plot_left + frac * plot_width;
96 let x_val = x_min + frac * x_range;
97 primitives.push(ChartPrimitive::Label {
98 text: format_number(x_val),
99 x,
100 y: plot_bottom + AXIS_LABEL_FONT + LABEL_MARGIN,
101 font_size: AXIS_LABEL_FONT,
102 color: LABEL_COLOR,
103 anchor: TextAnchor::Center,
104 });
105 }
106
107 primitives.push(ChartPrimitive::Line {
109 x1: plot_left,
110 y1: plot_top,
111 x2: plot_left,
112 y2: plot_bottom,
113 stroke: AXIS_COLOR,
114 width: 1.0,
115 });
116 primitives.push(ChartPrimitive::Line {
117 x1: plot_left,
118 y1: plot_bottom,
119 x2: plot_right,
120 y2: plot_bottom,
121 stroke: AXIS_COLOR,
122 width: 1.0,
123 });
124
125 let n_groups = groups.len() as f64;
127 for (gi, group) in groups.iter().enumerate() {
128 let color = resolve_color(group.color.as_deref(), gi);
129 let offset = if n_groups > 1.0 {
130 (gi as f64 - (n_groups - 1.0) / 2.0) * config.dot_size * 0.4
131 } else {
132 0.0
133 };
134
135 for &(dx, dy) in &group.data {
136 let px = plot_left + ((dx - x_min) / x_range) * plot_width + offset;
137 let py = plot_bottom - ((dy - y_min) / y_range) * plot_height;
138 primitives.push(ChartPrimitive::Circle {
139 cx: px,
140 cy: py,
141 r: config.dot_size,
142 fill: color,
143 });
144 }
145 }
146
147 if let Some(ref label) = config.x_label {
149 primitives.push(ChartPrimitive::Label {
150 text: label.clone(),
151 x: plot_left + plot_width / 2.0,
152 y: height - 2.0,
153 font_size: AXIS_LABEL_FONT,
154 color: LABEL_COLOR,
155 anchor: TextAnchor::Center,
156 });
157 }
158
159 if config.show_legend {
161 let legend_x = plot_right + LABEL_MARGIN;
162 let legend_y_start = plot_top + LABEL_MARGIN;
163 let swatch_size = 8.0;
164 let line_height = 14.0;
165
166 for (i, group) in groups.iter().enumerate() {
167 let ly = legend_y_start + i as f64 * line_height;
168 let color = resolve_color(group.color.as_deref(), i);
169
170 primitives.push(ChartPrimitive::Circle {
171 cx: legend_x + swatch_size / 2.0,
172 cy: ly + swatch_size / 2.0,
173 r: swatch_size / 2.0,
174 fill: color,
175 });
176 primitives.push(ChartPrimitive::Label {
177 text: group.name.clone(),
178 x: legend_x + swatch_size + LABEL_MARGIN,
179 y: ly + swatch_size - 1.0,
180 font_size: AXIS_LABEL_FONT,
181 color: LABEL_COLOR,
182 anchor: TextAnchor::Left,
183 });
184 }
185 }
186
187 primitives
188}