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    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    // Compute data bounds
46    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    // Grid lines (5 ticks each axis)
71    let ticks = 5;
72    for i in 0..=ticks {
73        let frac = i as f64 / ticks as f64;
74        // Horizontal grid
75        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        // Y label
85        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        // X label
95        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    // Axes
108    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    // Dots — slight offset for overlapping groups
126    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    // Axis labels
148    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    // Legend
160    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}