Skip to main content

forme/chart/
line.rs

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