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    /// Generate an SVG path AND its total length (for animation dasharray).
48    ///
49    /// Returns `(path_d, length)`. The length is an upper-bound estimate
50    /// suitable for `stroke-dasharray`/`stroke-dashoffset` draw animation.
51    pub fn generate_with_length(&self, points: &[(f64, f64)]) -> (String, f64) {
52        let path = self.generate(points);
53        let length = self.compute_length(points);
54        (path, length)
55    }
56
57    /// Compute an upper-bound estimate of the path length for the given points.
58    fn compute_length(&self, points: &[(f64, f64)]) -> f64 {
59        if points.len() < 2 {
60            return 0.0;
61        }
62        match self.curve {
63            CurveType::Linear => {
64                points
65                    .windows(2)
66                    .map(|w| {
67                        let dx = w[1].0 - w[0].0;
68                        let dy = w[1].1 - w[0].1;
69                        (dx * dx + dy * dy).sqrt()
70                    })
71                    .sum()
72            }
73            CurveType::Step => {
74                // Step path traces: M(x0,y0), then for each subsequent point
75                // L(x_mid,prev_y) L(x_mid,y), ending with L(last_x,last_y).
76                // Track the cursor's x position to measure each horizontal.
77                let mut length = 0.0;
78                let mut cursor_x = points[0].0;
79                let mut prev_y = points[0].1;
80                let mut raw_prev_x = points[0].0;
81                for &(x, y) in &points[1..] {
82                    let x_mid = (raw_prev_x + x) * 0.5;
83                    length += (x_mid - cursor_x).abs();
84                    length += (y - prev_y).abs();
85                    cursor_x = x_mid;
86                    prev_y = y;
87                    raw_prev_x = x;
88                }
89                length += (points[points.len() - 1].0 - cursor_x).abs();
90                length
91            }
92            CurveType::MonotoneX => {
93                // Control polygon length approximation for cubic bezier segments.
94                // The true arc length <= control polygon length, so multiply
95                // by 1.05 safety factor to guarantee >= true length.
96                let n = points.len();
97                if n == 2 {
98                    // Falls back to linear for 2 points.
99                    let dx = points[1].0 - points[0].0;
100                    let dy = points[1].1 - points[0].1;
101                    return (dx * dx + dy * dy).sqrt();
102                }
103
104                // Reconstruct tangents (same algorithm as generate_monotone_x)
105                let mut secants = Vec::with_capacity(n - 1);
106                for i in 0..n - 1 {
107                    let dx = points[i + 1].0 - points[i].0;
108                    if dx == 0.0 {
109                        secants.push(0.0);
110                    } else {
111                        secants.push((points[i + 1].1 - points[i].1) / dx);
112                    }
113                }
114                let mut tangents = vec![0.0; n];
115                tangents[0] = secants[0];
116                tangents[n - 1] = secants[n - 2];
117                for i in 1..n - 1 {
118                    if secants[i - 1].signum() != secants[i].signum() {
119                        tangents[i] = 0.0;
120                    } else {
121                        tangents[i] = (secants[i - 1] + secants[i]) / 2.0;
122                    }
123                }
124                for i in 0..n - 1 {
125                    if secants[i] == 0.0 {
126                        tangents[i] = 0.0;
127                        tangents[i + 1] = 0.0;
128                    } else {
129                        let alpha = tangents[i] / secants[i];
130                        let beta = tangents[i + 1] / secants[i];
131                        let sum_sq = alpha * alpha + beta * beta;
132                        if sum_sq > 9.0 {
133                            let tau = 3.0 / sum_sq.sqrt();
134                            tangents[i] = tau * alpha * secants[i];
135                            tangents[i + 1] = tau * beta * secants[i];
136                        }
137                    }
138                }
139
140                // Sum control polygon lengths for each cubic segment
141                let mut total = 0.0;
142                for i in 0..n - 1 {
143                    let dx = points[i + 1].0 - points[i].0;
144                    let p0 = points[i];
145                    let p1 = (points[i].0 + dx / 3.0, points[i].1 + tangents[i] * dx / 3.0);
146                    let p2 = (points[i + 1].0 - dx / 3.0, points[i + 1].1 - tangents[i + 1] * dx / 3.0);
147                    let p3 = points[i + 1];
148
149                    let d01 = ((p1.0 - p0.0).powi(2) + (p1.1 - p0.1).powi(2)).sqrt();
150                    let d12 = ((p2.0 - p1.0).powi(2) + (p2.1 - p1.1).powi(2)).sqrt();
151                    let d23 = ((p3.0 - p2.0).powi(2) + (p3.1 - p2.1).powi(2)).sqrt();
152                    total += d01 + d12 + d23;
153                }
154                total * 1.05
155            }
156        }
157    }
158}
159
160impl Default for LineGenerator {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166/// Format a float value for SVG output, trimming unnecessary trailing zeros.
167pub(crate) fn fmt(v: f64) -> String {
168    if v == v.round() && v.abs() < 1e10 {
169        format!("{}", v as i64)
170    } else {
171        let s = format!("{:.6}", v);
172        s.trim_end_matches('0').trim_end_matches('.').to_string()
173    }
174}
175
176fn generate_linear(points: &[(f64, f64)]) -> String {
177    let mut path = String::new();
178    for (i, &(x, y)) in points.iter().enumerate() {
179        if i == 0 {
180            path.push_str(&format!("M{},{}", fmt(x), fmt(y)));
181        } else {
182            path.push_str(&format!("L{},{}", fmt(x), fmt(y)));
183        }
184    }
185    path
186}
187
188/// Step interpolation (D3 `curveStep`, t = 0.5).
189///
190/// For each consecutive pair of points, draws:
191///   1. A horizontal segment to the midpoint of their x-coordinates.
192///   2. A vertical segment to the new y value.
193///
194/// This produces a staircase where each step transitions at the midpoint
195/// between adjacent x values.
196fn generate_step(points: &[(f64, f64)]) -> String {
197    let n = points.len();
198    if n == 0 {
199        return String::new();
200    }
201    if n == 1 {
202        return format!("M{},{}", fmt(points[0].0), fmt(points[0].1));
203    }
204
205    // D3 step with t = 0.5 (center):
206    // First point: moveTo(x0, y0)
207    // For each subsequent point (x, y):
208    //   x_mid = prev_x * 0.5 + x * 0.5
209    //   lineTo(x_mid, prev_y)
210    //   lineTo(x_mid, y)
211    // After the last point, the lineEnd adds lineTo(x_last, y_last)
212    // because 0 < t < 1 and point == 2.
213    let mut path = format!("M{},{}", fmt(points[0].0), fmt(points[0].1));
214
215    let mut prev_x = points[0].0;
216    let mut prev_y = points[0].1;
217
218    for &(x, y) in &points[1..] {
219        let x_mid = prev_x * 0.5 + x * 0.5;
220        path.push_str(&format!("L{},{}", fmt(x_mid), fmt(prev_y)));
221        path.push_str(&format!("L{},{}", fmt(x_mid), fmt(y)));
222        prev_x = x;
223        prev_y = y;
224    }
225
226    // D3's lineEnd: when 0 < t < 1 and point == 2, it adds lineTo(x, y)
227    // for the last stored point. This ensures the path extends all the way
228    // to the final data point's x coordinate.
229    path.push_str(&format!("L{},{}", fmt(prev_x), fmt(prev_y)));
230
231    path
232}
233
234fn generate_monotone_x(points: &[(f64, f64)]) -> String {
235    let n = points.len();
236    if n == 0 {
237        return String::new();
238    }
239    if n == 1 {
240        return format!("M{},{}", fmt(points[0].0), fmt(points[0].1));
241    }
242    if n == 2 {
243        // With only 2 points, fall back to linear.
244        return generate_linear(points);
245    }
246
247    // Step 1: Calculate secants
248    let mut secants = Vec::with_capacity(n - 1);
249    for i in 0..n - 1 {
250        let dx = points[i + 1].0 - points[i].0;
251        if dx == 0.0 {
252            secants.push(0.0);
253        } else {
254            secants.push((points[i + 1].1 - points[i].1) / dx);
255        }
256    }
257
258    // Step 2: Calculate tangents using Fritsch-Carlson method
259    let mut tangents = vec![0.0; n];
260    tangents[0] = secants[0];
261    tangents[n - 1] = secants[n - 2];
262    for i in 1..n - 1 {
263        if secants[i - 1].signum() != secants[i].signum() {
264            tangents[i] = 0.0;
265        } else {
266            tangents[i] = (secants[i - 1] + secants[i]) / 2.0;
267        }
268    }
269
270    // Step 3: Adjust for monotonicity
271    for i in 0..n - 1 {
272        if secants[i] == 0.0 {
273            tangents[i] = 0.0;
274            tangents[i + 1] = 0.0;
275        } else {
276            let alpha = tangents[i] / secants[i];
277            let beta = tangents[i + 1] / secants[i];
278            let sum_sq = alpha * alpha + beta * beta;
279            if sum_sq > 9.0 {
280                let tau = 3.0 / sum_sq.sqrt();
281                tangents[i] = tau * alpha * secants[i];
282                tangents[i + 1] = tau * beta * secants[i];
283            }
284        }
285    }
286
287    // Step 4: Generate cubic bezier path
288    let mut path = format!("M{},{}", fmt(points[0].0), fmt(points[0].1));
289    for i in 0..n - 1 {
290        let dx = points[i + 1].0 - points[i].0;
291        let cp1x = points[i].0 + dx / 3.0;
292        let cp1y = points[i].1 + tangents[i] * dx / 3.0;
293        let cp2x = points[i + 1].0 - dx / 3.0;
294        let cp2y = points[i + 1].1 - tangents[i + 1] * dx / 3.0;
295        path.push_str(&format!(
296            "C{},{} {},{} {},{}",
297            fmt(cp1x),
298            fmt(cp1y),
299            fmt(cp2x),
300            fmt(cp2y),
301            fmt(points[i + 1].0),
302            fmt(points[i + 1].1),
303        ));
304    }
305    path
306}
307
308#[cfg(test)]
309mod tests {
310    #![allow(clippy::unwrap_used)]
311    use super::*;
312
313    #[test]
314    fn line_linear_basic() {
315        let gen = LineGenerator::new();
316        let path = gen.generate(&[(0.0, 10.0), (50.0, 20.0), (100.0, 5.0)]);
317        assert_eq!(path, "M0,10L50,20L100,5");
318    }
319
320    #[test]
321    fn line_linear_single_point() {
322        let gen = LineGenerator::new();
323        let path = gen.generate(&[(0.0, 10.0)]);
324        assert_eq!(path, "M0,10");
325    }
326
327    #[test]
328    fn line_linear_two_points() {
329        let gen = LineGenerator::new();
330        let path = gen.generate(&[(0.0, 10.0), (50.0, 20.0)]);
331        assert_eq!(path, "M0,10L50,20");
332    }
333
334    #[test]
335    fn line_linear_empty() {
336        let gen = LineGenerator::new();
337        let path = gen.generate(&[]);
338        assert_eq!(path, "");
339    }
340
341    #[test]
342    fn line_step_basic() {
343        let gen = LineGenerator::new().curve(CurveType::Step);
344        let path = gen.generate(&[(0.0, 10.0), (50.0, 20.0), (100.0, 5.0)]);
345        // Step with t=0.5: midpoints at x=25 and x=75
346        // M0,10 L25,10 L25,20 L75,20 L75,5 L100,5
347        assert_eq!(path, "M0,10L25,10L25,20L75,20L75,5L100,5");
348        assert!(!path.contains("C"), "Step path should NOT contain C commands");
349    }
350
351    #[test]
352    fn line_step_single_point() {
353        let gen = LineGenerator::new().curve(CurveType::Step);
354        let path = gen.generate(&[(42.0, 7.0)]);
355        assert_eq!(path, "M42,7");
356    }
357
358    #[test]
359    fn line_step_two_points() {
360        let gen = LineGenerator::new().curve(CurveType::Step);
361        let path = gen.generate(&[(0.0, 10.0), (100.0, 20.0)]);
362        // Midpoint at x=50
363        assert_eq!(path, "M0,10L50,10L50,20L100,20");
364    }
365
366    #[test]
367    fn line_monotone_basic() {
368        let gen = LineGenerator::new().curve(CurveType::MonotoneX);
369        let path = gen.generate(&[
370            (0.0, 10.0),
371            (50.0, 20.0),
372            (100.0, 5.0),
373            (150.0, 15.0),
374        ]);
375        assert!(path.starts_with("M"), "Path should start with M, got: {}", path);
376        assert!(path.contains("C"), "Path should contain C commands, got: {}", path);
377    }
378
379    // ── generate_with_length tests ──
380
381    #[test]
382    fn line_linear_length() {
383        // 3 points forming a right triangle: (0,0) -> (3,0) -> (3,4)
384        // Segment 1: horizontal 3, Segment 2: vertical 4, Total: 7
385        let gen = LineGenerator::new();
386        let (path, length) = gen.generate_with_length(&[(0.0, 0.0), (3.0, 0.0), (3.0, 4.0)]);
387        assert!(!path.is_empty());
388        assert!((length - 7.0).abs() < 1e-10, "expected 7.0, got {}", length);
389    }
390
391    #[test]
392    fn line_step_length() {
393        // Two points: (0, 10) -> (100, 20)
394        // Step path: M0,10 L50,10 L50,20 L100,20
395        // Segments: |50-0|=50 + |20-10|=10 + |100-50|=50 = 110
396        let gen = LineGenerator::new().curve(CurveType::Step);
397        let (path, length) = gen.generate_with_length(&[(0.0, 10.0), (100.0, 20.0)]);
398        assert!(!path.is_empty());
399        assert!((length - 110.0).abs() < 1e-10, "expected 110.0, got {}", length);
400
401        // Three points: (0, 10) -> (50, 20) -> (100, 5)
402        // Step path: M0,10 L25,10 L25,20 L75,20 L75,5 L100,5
403        // Segments: |25-0|=25 + |20-10|=10 + |75-25|=50 + |5-20|=15 + |100-75|=25 = 125
404        let (_, length3) = gen.generate_with_length(&[(0.0, 10.0), (50.0, 20.0), (100.0, 5.0)]);
405        assert!((length3 - 125.0).abs() < 1e-10, "expected 125.0, got {}", length3);
406    }
407
408    #[test]
409    fn line_monotone_length() {
410        // MonotoneX length should be >= chord length sum (straight-line between consecutive points)
411        let gen = LineGenerator::new().curve(CurveType::MonotoneX);
412        let points = [(0.0, 10.0), (50.0, 20.0), (100.0, 5.0), (150.0, 15.0)];
413        let (path, length) = gen.generate_with_length(&points);
414        assert!(!path.is_empty());
415
416        let chord_sum: f64 = points.windows(2).map(|w| {
417            let dx = w[1].0 - w[0].0;
418            let dy = w[1].1 - w[0].1;
419            (dx * dx + dy * dy).sqrt()
420        }).sum();
421        assert!(
422            length >= chord_sum,
423            "monotone length {} should be >= chord sum {}",
424            length, chord_sum
425        );
426    }
427
428    #[test]
429    fn line_single_point_length() {
430        let gen = LineGenerator::new();
431        let (_, length) = gen.generate_with_length(&[(42.0, 7.0)]);
432        assert!((length - 0.0).abs() < 1e-10, "single point should have length 0.0, got {}", length);
433    }
434
435    #[test]
436    fn line_empty_length() {
437        let gen = LineGenerator::new();
438        let (_, length) = gen.generate_with_length(&[]);
439        assert!((length - 0.0).abs() < 1e-10, "empty should have length 0.0, got {}", length);
440    }
441}