Skip to main content

forme/chart/
area.rs

1//! Area chart builder — like line chart but with filled regions under each series.
2
3use super::*;
4use crate::model::ChartSeries;
5
6/// Configuration for area chart rendering.
7pub struct AreaChartConfig {
8    pub show_grid: bool,
9    pub title: Option<String>,
10}
11
12/// Build area chart primitives from series data.
13pub fn build(
14    width: f64,
15    height: f64,
16    series: &[ChartSeries],
17    labels: &[String],
18    config: &AreaChartConfig,
19) -> Vec<ChartPrimitive> {
20    if series.is_empty() || labels.is_empty() {
21        return vec![];
22    }
23
24    let mut primitives = Vec::new();
25
26    let title_offset = if config.title.is_some() {
27        TITLE_HEIGHT
28    } else {
29        0.0
30    };
31
32    let plot_left = Y_AXIS_WIDTH;
33    let plot_top = title_offset;
34    let plot_right = width - LABEL_MARGIN;
35    let plot_bottom = height - X_AXIS_HEIGHT;
36    let plot_width = plot_right - plot_left;
37    let plot_height = plot_bottom - plot_top;
38
39    if plot_width <= 0.0 || plot_height <= 0.0 {
40        return vec![];
41    }
42
43    let max_value = series
44        .iter()
45        .flat_map(|s| s.data.iter())
46        .copied()
47        .fold(0.0_f64, f64::max);
48    let y_max = nice_number(max_value);
49    let y_ticks = 5;
50    let n_points = labels.len();
51
52    // Grid lines
53    if config.show_grid {
54        for i in 0..=y_ticks {
55            let frac = i as f64 / y_ticks as f64;
56            let y = plot_bottom - frac * plot_height;
57            primitives.push(ChartPrimitive::Line {
58                x1: plot_left,
59                y1: y,
60                x2: plot_right,
61                y2: y,
62                stroke: GRID_COLOR,
63                width: 0.5,
64            });
65        }
66    }
67
68    // Y-axis labels
69    for i in 0..=y_ticks {
70        let frac = i as f64 / y_ticks as f64;
71        let y = plot_bottom - frac * plot_height;
72        let value = y_max * frac;
73        primitives.push(ChartPrimitive::Label {
74            text: format_number(value),
75            x: plot_left - LABEL_MARGIN,
76            y: y + AXIS_LABEL_FONT * 0.35,
77            font_size: AXIS_LABEL_FONT,
78            color: LABEL_COLOR,
79            anchor: TextAnchor::Right,
80        });
81    }
82
83    // Axes
84    primitives.push(ChartPrimitive::Line {
85        x1: plot_left,
86        y1: plot_top,
87        x2: plot_left,
88        y2: plot_bottom,
89        stroke: AXIS_COLOR,
90        width: 1.0,
91    });
92    primitives.push(ChartPrimitive::Line {
93        x1: plot_left,
94        y1: plot_bottom,
95        x2: plot_right,
96        y2: plot_bottom,
97        stroke: AXIS_COLOR,
98        width: 1.0,
99    });
100
101    // X-axis labels
102    for (i, label) in labels.iter().enumerate() {
103        let x = if n_points > 1 {
104            plot_left + (i as f64 / (n_points - 1) as f64) * plot_width
105        } else {
106            plot_left + plot_width / 2.0
107        };
108        primitives.push(ChartPrimitive::Label {
109            text: label.clone(),
110            x,
111            y: plot_bottom + AXIS_LABEL_FONT + LABEL_MARGIN,
112            font_size: AXIS_LABEL_FONT,
113            color: LABEL_COLOR,
114            anchor: TextAnchor::Center,
115        });
116    }
117
118    // Filled areas + line overlays (paint in reverse order so first series is on top)
119    for (si, s) in series.iter().enumerate().rev() {
120        let color = resolve_color(s.color.as_deref(), si);
121        let mut line_points = Vec::new();
122
123        for (i, &value) in s.data.iter().enumerate() {
124            if i >= n_points {
125                break;
126            }
127            let x = if n_points > 1 {
128                plot_left + (i as f64 / (n_points - 1) as f64) * plot_width
129            } else {
130                plot_left + plot_width / 2.0
131            };
132            let y = if y_max > 0.0 {
133                plot_bottom - (value / y_max) * plot_height
134            } else {
135                plot_bottom
136            };
137            line_points.push((x, y));
138        }
139
140        if line_points.len() >= 2 {
141            // Build closed polygon: line points + bottom edge
142            let mut fill_points = line_points.clone();
143            // Close to baseline
144            fill_points.push((line_points.last().unwrap().0, plot_bottom));
145            fill_points.push((line_points[0].0, plot_bottom));
146
147            primitives.push(ChartPrimitive::FilledPath {
148                points: fill_points,
149                fill: color,
150                opacity: 0.3,
151            });
152
153            primitives.push(ChartPrimitive::Polyline {
154                points: line_points,
155                stroke: color,
156                width: 2.0,
157            });
158        }
159    }
160
161    // Title
162    if let Some(ref title) = config.title {
163        primitives.push(ChartPrimitive::Label {
164            text: title.clone(),
165            x: width / 2.0,
166            y: TITLE_FONT,
167            font_size: TITLE_FONT,
168            color: Color::BLACK,
169            anchor: TextAnchor::Center,
170        });
171    }
172
173    primitives
174}