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 title_room = AXIS_LABEL_FONT + LABEL_MARGIN;
38 let y_title_room = if config.y_label.is_some() {
39 title_room
40 } else {
41 0.0
42 };
43 let x_title_room = if config.x_label.is_some() {
44 title_room
45 } else {
46 0.0
47 };
48
49 let plot_left = Y_AXIS_WIDTH + y_title_room;
50 let plot_top = LABEL_MARGIN;
51 let plot_right = width - LABEL_MARGIN - legend_width;
52 let plot_bottom = height - X_AXIS_HEIGHT - x_title_room;
53 let plot_width = plot_right - plot_left;
54 let plot_height = plot_bottom - plot_top;
55
56 if plot_width <= 0.0 || plot_height <= 0.0 {
57 return vec![];
58 }
59
60 let all_points: Vec<(f64, f64)> = groups.iter().flat_map(|g| g.data.iter().copied()).collect();
62 if all_points.is_empty() {
63 return vec![];
64 }
65
66 let data_x_min = all_points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
67 let data_x_max = all_points
68 .iter()
69 .map(|p| p.0)
70 .fold(f64::NEG_INFINITY, f64::max);
71 let data_y_min = all_points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
72 let data_y_max = all_points
73 .iter()
74 .map(|p| p.1)
75 .fold(f64::NEG_INFINITY, f64::max);
76
77 let x_min = config.x_min.unwrap_or(data_x_min.min(0.0));
78 let x_max = config.x_max.unwrap_or(nice_number(data_x_max));
79 let y_min = config.y_min.unwrap_or(data_y_min.min(0.0));
80 let y_max = config.y_max.unwrap_or(nice_number(data_y_max));
81
82 let x_range = (x_max - x_min).max(1.0);
83 let y_range = (y_max - y_min).max(1.0);
84
85 let ticks = 5;
87 for i in 0..=ticks {
88 let frac = i as f64 / ticks as f64;
89 let y = plot_bottom - frac * plot_height;
91 primitives.push(ChartPrimitive::Line {
92 x1: plot_left,
93 y1: y,
94 x2: plot_right,
95 y2: y,
96 stroke: GRID_COLOR,
97 width: 0.5,
98 });
99 let y_val = y_min + frac * y_range;
101 primitives.push(ChartPrimitive::Label {
102 text: format_number(y_val),
103 x: plot_left - LABEL_MARGIN,
104 y: y + AXIS_LABEL_FONT * 0.35,
105 font_size: AXIS_LABEL_FONT,
106 color: LABEL_COLOR,
107 anchor: TextAnchor::Right,
108 });
109 let x = plot_left + frac * plot_width;
111 let x_val = x_min + frac * x_range;
112 primitives.push(ChartPrimitive::Label {
113 text: format_number(x_val),
114 x,
115 y: plot_bottom + AXIS_LABEL_FONT + LABEL_MARGIN,
116 font_size: AXIS_LABEL_FONT,
117 color: LABEL_COLOR,
118 anchor: TextAnchor::Center,
119 });
120 }
121
122 primitives.push(ChartPrimitive::Line {
124 x1: plot_left,
125 y1: plot_top,
126 x2: plot_left,
127 y2: plot_bottom,
128 stroke: AXIS_COLOR,
129 width: 1.0,
130 });
131 primitives.push(ChartPrimitive::Line {
132 x1: plot_left,
133 y1: plot_bottom,
134 x2: plot_right,
135 y2: plot_bottom,
136 stroke: AXIS_COLOR,
137 width: 1.0,
138 });
139
140 let n_groups = groups.len() as f64;
142 for (gi, group) in groups.iter().enumerate() {
143 let color = resolve_color(group.color.as_deref(), gi);
144 let offset = if n_groups > 1.0 {
145 (gi as f64 - (n_groups - 1.0) / 2.0) * config.dot_size * 0.4
146 } else {
147 0.0
148 };
149
150 for &(dx, dy) in &group.data {
151 let px = plot_left + ((dx - x_min) / x_range) * plot_width + offset;
152 let py = plot_bottom - ((dy - y_min) / y_range) * plot_height;
153 primitives.push(ChartPrimitive::Circle {
154 cx: px,
155 cy: py,
156 r: config.dot_size,
157 fill: color,
158 });
159 }
160 }
161
162 if let Some(ref label) = config.y_label {
164 primitives.push(ChartPrimitive::VerticalLabel {
166 text: label.clone(),
167 x: AXIS_LABEL_FONT / 2.0 + 1.0,
168 y: plot_top + plot_height / 2.0,
169 font_size: AXIS_LABEL_FONT,
170 color: LABEL_COLOR,
171 });
172 }
173 if let Some(ref label) = config.x_label {
174 primitives.push(ChartPrimitive::Label {
175 text: label.clone(),
176 x: plot_left + plot_width / 2.0,
177 y: height - 2.0,
178 font_size: AXIS_LABEL_FONT,
179 color: LABEL_COLOR,
180 anchor: TextAnchor::Center,
181 });
182 }
183
184 if config.show_legend {
186 let legend_x = plot_right + LABEL_MARGIN;
187 let legend_y_start = plot_top + LABEL_MARGIN;
188 let swatch_size = 8.0;
189 let line_height = 14.0;
190
191 for (i, group) in groups.iter().enumerate() {
192 let ly = legend_y_start + i as f64 * line_height;
193 let color = resolve_color(group.color.as_deref(), i);
194
195 primitives.push(ChartPrimitive::Circle {
196 cx: legend_x + swatch_size / 2.0,
197 cy: ly + swatch_size / 2.0,
198 r: swatch_size / 2.0,
199 fill: color,
200 });
201 primitives.push(ChartPrimitive::Label {
202 text: group.name.clone(),
203 x: legend_x + swatch_size + LABEL_MARGIN,
204 y: ly + swatch_size - 1.0,
205 font_size: AXIS_LABEL_FONT,
206 color: LABEL_COLOR,
207 anchor: TextAnchor::Left,
208 });
209 }
210 }
211
212 primitives
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn groups() -> Vec<DotPlotGroup> {
220 vec![DotPlotGroup {
221 name: "p50".to_string(),
222 color: None,
223 data: vec![(1.0, 120.0), (2.0, 340.0), (3.0, 910.0)],
224 }]
225 }
226
227 fn config(x_label: Option<&str>, y_label: Option<&str>) -> DotPlotConfig {
228 DotPlotConfig {
229 x_min: None,
230 x_max: None,
231 y_min: None,
232 y_max: None,
233 x_label: x_label.map(str::to_string),
234 y_label: y_label.map(str::to_string),
235 show_legend: false,
236 dot_size: 3.0,
237 }
238 }
239
240 fn tick_label_left_edges(p: &[ChartPrimitive]) -> Vec<f64> {
242 p.iter()
243 .filter_map(|p| match p {
244 ChartPrimitive::Label {
245 text,
246 x,
247 font_size,
248 anchor: TextAnchor::Right,
249 ..
250 } => Some(x - measure_label(text, *font_size)),
251 _ => None,
252 })
253 .collect()
254 }
255
256 #[test]
258 fn y_label_is_drawn_vertically_clear_of_the_tick_labels() {
259 let p = build(300.0, 200.0, &groups(), &config(None, Some("Latency (ms)")));
260 let title = p
261 .iter()
262 .find_map(|p| match p {
263 ChartPrimitive::VerticalLabel {
264 text, x, font_size, ..
265 } => Some((text.clone(), *x, *font_size)),
266 _ => None,
267 })
268 .expect("the y-axis title is drawn");
269 assert_eq!(title.0, "Latency (ms)");
270 let title_right = title.1 + title.2 / 2.0;
271 let ticks_left = tick_label_left_edges(&p)
272 .into_iter()
273 .fold(f64::INFINITY, f64::min);
274 assert!(
275 title_right < ticks_left,
276 "title (right edge {title_right:.1}) must clear the tick labels (from {ticks_left:.1})"
277 );
278 }
279
280 #[test]
283 fn x_label_has_its_own_line_below_the_tick_labels() {
284 let p = build(300.0, 200.0, &groups(), &config(Some("Run"), None));
285 let baseline = |want_center: bool, text_is_title: bool| -> f64 {
286 p.iter()
287 .filter_map(|p| match p {
288 ChartPrimitive::Label {
289 text,
290 y,
291 anchor: TextAnchor::Center,
292 ..
293 } if want_center && (text == "Run") == text_is_title => Some(*y),
294 _ => None,
295 })
296 .fold(f64::MIN, f64::max)
297 };
298 let ticks = baseline(true, false);
299 let title = baseline(true, true);
300 let gap = (title - AXIS_LABEL_FONT * 0.72) - (ticks + AXIS_LABEL_FONT * 0.21);
303 assert!(gap >= 2.0, "x title crowds the tick labels: gap {gap:.2}pt");
304 }
305
306 #[test]
307 fn no_titles_leaves_the_plot_where_it_was() {
308 let p = build(300.0, 200.0, &groups(), &config(None, None));
309 assert!(!p
310 .iter()
311 .any(|p| matches!(p, ChartPrimitive::VerticalLabel { .. })));
312 let axis_x = p
314 .iter()
315 .find_map(|p| match p {
316 ChartPrimitive::Line { x1, x2, width, .. }
317 if (x1 - x2).abs() < 1e-9 && *width == 1.0 =>
318 {
319 Some(*x1)
320 }
321 _ => None,
322 })
323 .unwrap();
324 assert_eq!(axis_x, Y_AXIS_WIDTH);
325 }
326}