Skip to main content

chartml_core/shapes/
line.rs

1/// Generates SVG path `d` strings for line charts.
2/// Equivalent to D3's `d3.line()`.
3/// Curve interpolation strategy.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub enum CurveType {
6    /// Straight line segments between points.
7    Linear,
8    /// Monotone cubic Hermite interpolation in x (smooth, no overshooting).
9    MonotoneX,
10    /// Step interpolation (horizontal-then-vertical at midpoint between x values).
11    /// Equivalent to D3's `curveStep` with t=0.5.
12    Step,
13}
14
15/// Generates SVG path `d` strings from a series of (x, y) points.
16pub struct LineGenerator {
17    curve: CurveType,
18}
19
20impl LineGenerator {
21    /// Create a new LineGenerator with Linear curve type.
22    pub fn new() -> Self {
23        Self {
24            curve: CurveType::Linear,
25        }
26    }
27
28    /// Set the curve interpolation type.
29    pub fn curve(mut self, curve: CurveType) -> Self {
30        self.curve = curve;
31        self
32    }
33
34    /// Generate an SVG path from a series of (x, y) points.
35    pub fn generate(&self, points: &[(f64, f64)]) -> String {
36        if points.is_empty() {
37            return String::new();
38        }
39
40        match self.curve {
41            CurveType::Linear => generate_linear(points),
42            CurveType::MonotoneX => generate_monotone_x(points),
43            CurveType::Step => generate_step(points),
44        }
45    }
46}
47
48impl Default for LineGenerator {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54/// Format a float value for SVG output, trimming unnecessary trailing zeros.
55pub(crate) fn fmt(v: f64) -> String {
56    if v == v.round() && v.abs() < 1e10 {
57        format!("{}", v as i64)
58    } else {
59        let s = format!("{:.6}", v);
60        s.trim_end_matches('0').trim_end_matches('.').to_string()
61    }
62}
63
64fn generate_linear(points: &[(f64, f64)]) -> String {
65    let mut path = String::new();
66    for (i, &(x, y)) in points.iter().enumerate() {
67        if i == 0 {
68            path.push_str(&format!("M{},{}", fmt(x), fmt(y)));
69        } else {
70            path.push_str(&format!("L{},{}", fmt(x), fmt(y)));
71        }
72    }
73    path
74}
75
76/// Step interpolation (D3 `curveStep`, t = 0.5).
77///
78/// For each consecutive pair of points, draws:
79///   1. A horizontal segment to the midpoint of their x-coordinates.
80///   2. A vertical segment to the new y value.
81///
82/// This produces a staircase where each step transitions at the midpoint
83/// between adjacent x values.
84fn generate_step(points: &[(f64, f64)]) -> String {
85    let n = points.len();
86    if n == 0 {
87        return String::new();
88    }
89    if n == 1 {
90        return format!("M{},{}", fmt(points[0].0), fmt(points[0].1));
91    }
92
93    // D3 step with t = 0.5 (center):
94    // First point: moveTo(x0, y0)
95    // For each subsequent point (x, y):
96    //   x_mid = prev_x * 0.5 + x * 0.5
97    //   lineTo(x_mid, prev_y)
98    //   lineTo(x_mid, y)
99    // After the last point, the lineEnd adds lineTo(x_last, y_last)
100    // because 0 < t < 1 and point == 2.
101    let mut path = format!("M{},{}", fmt(points[0].0), fmt(points[0].1));
102
103    let mut prev_x = points[0].0;
104    let mut prev_y = points[0].1;
105
106    for &(x, y) in &points[1..] {
107        let x_mid = prev_x * 0.5 + x * 0.5;
108        path.push_str(&format!("L{},{}", fmt(x_mid), fmt(prev_y)));
109        path.push_str(&format!("L{},{}", fmt(x_mid), fmt(y)));
110        prev_x = x;
111        prev_y = y;
112    }
113
114    // D3's lineEnd: when 0 < t < 1 and point == 2, it adds lineTo(x, y)
115    // for the last stored point. This ensures the path extends all the way
116    // to the final data point's x coordinate.
117    path.push_str(&format!("L{},{}", fmt(prev_x), fmt(prev_y)));
118
119    path
120}
121
122fn generate_monotone_x(points: &[(f64, f64)]) -> String {
123    let n = points.len();
124    if n == 0 {
125        return String::new();
126    }
127    if n == 1 {
128        return format!("M{},{}", fmt(points[0].0), fmt(points[0].1));
129    }
130    if n == 2 {
131        // With only 2 points, fall back to linear.
132        return generate_linear(points);
133    }
134
135    // Step 1: Calculate secants
136    let mut secants = Vec::with_capacity(n - 1);
137    for i in 0..n - 1 {
138        let dx = points[i + 1].0 - points[i].0;
139        if dx == 0.0 {
140            secants.push(0.0);
141        } else {
142            secants.push((points[i + 1].1 - points[i].1) / dx);
143        }
144    }
145
146    // Step 2: Calculate tangents using Fritsch-Carlson method
147    let mut tangents = vec![0.0; n];
148    tangents[0] = secants[0];
149    tangents[n - 1] = secants[n - 2];
150    for i in 1..n - 1 {
151        if secants[i - 1].signum() != secants[i].signum() {
152            tangents[i] = 0.0;
153        } else {
154            tangents[i] = (secants[i - 1] + secants[i]) / 2.0;
155        }
156    }
157
158    // Step 3: Adjust for monotonicity
159    for i in 0..n - 1 {
160        if secants[i] == 0.0 {
161            tangents[i] = 0.0;
162            tangents[i + 1] = 0.0;
163        } else {
164            let alpha = tangents[i] / secants[i];
165            let beta = tangents[i + 1] / secants[i];
166            let sum_sq = alpha * alpha + beta * beta;
167            if sum_sq > 9.0 {
168                let tau = 3.0 / sum_sq.sqrt();
169                tangents[i] = tau * alpha * secants[i];
170                tangents[i + 1] = tau * beta * secants[i];
171            }
172        }
173    }
174
175    // Step 4: Generate cubic bezier path
176    let mut path = format!("M{},{}", fmt(points[0].0), fmt(points[0].1));
177    for i in 0..n - 1 {
178        let dx = points[i + 1].0 - points[i].0;
179        let cp1x = points[i].0 + dx / 3.0;
180        let cp1y = points[i].1 + tangents[i] * dx / 3.0;
181        let cp2x = points[i + 1].0 - dx / 3.0;
182        let cp2y = points[i + 1].1 - tangents[i + 1] * dx / 3.0;
183        path.push_str(&format!(
184            "C{},{} {},{} {},{}",
185            fmt(cp1x),
186            fmt(cp1y),
187            fmt(cp2x),
188            fmt(cp2y),
189            fmt(points[i + 1].0),
190            fmt(points[i + 1].1),
191        ));
192    }
193    path
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn line_linear_basic() {
202        let gen = LineGenerator::new();
203        let path = gen.generate(&[(0.0, 10.0), (50.0, 20.0), (100.0, 5.0)]);
204        assert_eq!(path, "M0,10L50,20L100,5");
205    }
206
207    #[test]
208    fn line_linear_single_point() {
209        let gen = LineGenerator::new();
210        let path = gen.generate(&[(0.0, 10.0)]);
211        assert_eq!(path, "M0,10");
212    }
213
214    #[test]
215    fn line_linear_two_points() {
216        let gen = LineGenerator::new();
217        let path = gen.generate(&[(0.0, 10.0), (50.0, 20.0)]);
218        assert_eq!(path, "M0,10L50,20");
219    }
220
221    #[test]
222    fn line_linear_empty() {
223        let gen = LineGenerator::new();
224        let path = gen.generate(&[]);
225        assert_eq!(path, "");
226    }
227
228    #[test]
229    fn line_step_basic() {
230        let gen = LineGenerator::new().curve(CurveType::Step);
231        let path = gen.generate(&[(0.0, 10.0), (50.0, 20.0), (100.0, 5.0)]);
232        // Step with t=0.5: midpoints at x=25 and x=75
233        // M0,10 L25,10 L25,20 L75,20 L75,5 L100,5
234        assert_eq!(path, "M0,10L25,10L25,20L75,20L75,5L100,5");
235        assert!(!path.contains("C"), "Step path should NOT contain C commands");
236    }
237
238    #[test]
239    fn line_step_single_point() {
240        let gen = LineGenerator::new().curve(CurveType::Step);
241        let path = gen.generate(&[(42.0, 7.0)]);
242        assert_eq!(path, "M42,7");
243    }
244
245    #[test]
246    fn line_step_two_points() {
247        let gen = LineGenerator::new().curve(CurveType::Step);
248        let path = gen.generate(&[(0.0, 10.0), (100.0, 20.0)]);
249        // Midpoint at x=50
250        assert_eq!(path, "M0,10L50,10L50,20L100,20");
251    }
252
253    #[test]
254    fn line_monotone_basic() {
255        let gen = LineGenerator::new().curve(CurveType::MonotoneX);
256        let path = gen.generate(&[
257            (0.0, 10.0),
258            (50.0, 20.0),
259            (100.0, 5.0),
260            (150.0, 15.0),
261        ]);
262        assert!(path.starts_with("M"), "Path should start with M, got: {}", path);
263        assert!(path.contains("C"), "Path should contain C commands, got: {}", path);
264    }
265}