Skip to main content

forme/chart/
dot.rs

1//! Dot plot (scatter plot) builder.
2
3use super::*;
4use crate::model::DotPlotGroup;
5
6/// Configuration for dot plot rendering.
7pub 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
18/// Build dot plot primitives from grouped data.
19pub 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    // Legend space
32    let legend_width = if config.show_legend { 80.0 } else { 0.0 };
33
34    // An axis title gets its own line of room: the y title a column left of
35    // the tick numbers, the x title a row below them. Without one the plot
36    // keeps its old geometry.
37    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    // Compute data bounds
61    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    // Grid lines (5 ticks each axis)
86    let ticks = 5;
87    for i in 0..=ticks {
88        let frac = i as f64 / ticks as f64;
89        // Horizontal grid
90        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        // Y label
100        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        // X label
110        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    // Axes
123    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    // Dots — slight offset for overlapping groups
141    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    // Axis titles
163    if let Some(ref label) = config.y_label {
164        // `yLabel` was accepted and documented but never drawn.
165        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    // Legend
185    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    /// Left edges of the right-anchored y tick labels.
241    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    /// `yLabel` was accepted, documented ("Latency (ms)") and never drawn.
257    #[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    /// `xLabel` sat 6pt below the tick labels inside the same 20pt band, so
281    /// the two touched. With a title the plot keeps a line of room for it.
282    #[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        // The title's cap height (~0.72em) must start below the tick
301        // labels' descenders (~0.21em).
302        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        // The y axis still starts at Y_AXIS_WIDTH.
313        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}