Skip to main content

presentar_core/
chart.rs

1#![allow(clippy::unwrap_used, clippy::disallowed_methods)]
2//! Chart rendering algorithms for Presentar.
3//!
4//! This module provides mathematical algorithms for chart rendering:
5//! - Interpolation (linear, cubic spline, Catmull-Rom, Bezier)
6//! - Path tessellation for GPU rendering
7//! - Histogram binning
8//! - Arc geometry computation
9//! - Data normalization and scaling
10//!
11//! # Example
12//!
13//! ```
14//! use presentar_core::chart::{Interpolator, CubicSpline, Point2D};
15//!
16//! // Create a spline from control points
17//! let points = vec![
18//!     Point2D::new(0.0, 0.0),
19//!     Point2D::new(1.0, 2.0),
20//!     Point2D::new(2.0, 1.5),
21//!     Point2D::new(3.0, 3.0),
22//! ];
23//! let spline = CubicSpline::from_points(&points);
24//!
25//! // Interpolate at any x value
26//! let y = spline.interpolate(1.5);
27//! ```
28
29use serde::{Deserialize, Serialize};
30use std::f64::consts::PI;
31
32/// 2D point for chart calculations.
33#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
34pub struct Point2D {
35    pub x: f64,
36    pub y: f64,
37}
38
39impl Point2D {
40    /// Create a new point.
41    #[must_use]
42    pub const fn new(x: f64, y: f64) -> Self {
43        Self { x, y }
44    }
45
46    /// Origin point (0, 0).
47    pub const ORIGIN: Self = Self { x: 0.0, y: 0.0 };
48
49    /// Distance to another point.
50    #[must_use]
51    pub fn distance(&self, other: &Self) -> f64 {
52        let dx = self.x - other.x;
53        let dy = self.y - other.y;
54        dx.hypot(dy)
55    }
56
57    /// Linear interpolation between two points.
58    #[must_use]
59    pub fn lerp(&self, other: &Self, t: f64) -> Self {
60        Self {
61            x: (other.x - self.x).mul_add(t, self.x),
62            y: (other.y - self.y).mul_add(t, self.y),
63        }
64    }
65}
66
67impl std::ops::Add for Point2D {
68    type Output = Self;
69
70    fn add(self, rhs: Self) -> Self::Output {
71        Self {
72            x: self.x + rhs.x,
73            y: self.y + rhs.y,
74        }
75    }
76}
77
78impl std::ops::Sub for Point2D {
79    type Output = Self;
80
81    fn sub(self, rhs: Self) -> Self::Output {
82        Self {
83            x: self.x - rhs.x,
84            y: self.y - rhs.y,
85        }
86    }
87}
88
89impl std::ops::Mul<f64> for Point2D {
90    type Output = Self;
91
92    fn mul(self, rhs: f64) -> Self::Output {
93        Self {
94            x: self.x * rhs,
95            y: self.y * rhs,
96        }
97    }
98}
99
100/// Interpolation trait for different curve types.
101pub trait Interpolator {
102    /// Interpolate y value at given x.
103    fn interpolate(&self, x: f64) -> f64;
104
105    /// Generate points along the curve.
106    fn sample(&self, start: f64, end: f64, num_points: usize) -> Vec<Point2D> {
107        if num_points < 2 {
108            return vec![];
109        }
110        let step = (end - start) / (num_points - 1) as f64;
111        (0..num_points)
112            .map(|i| {
113                let x = (i as f64).mul_add(step, start);
114                Point2D::new(x, self.interpolate(x))
115            })
116            .collect()
117    }
118}
119
120/// Linear interpolation between points.
121#[derive(Debug, Clone)]
122pub struct LinearInterpolator {
123    points: Vec<Point2D>,
124}
125
126impl LinearInterpolator {
127    /// Create from points (must be sorted by x).
128    #[must_use]
129    pub fn from_points(points: &[Point2D]) -> Self {
130        let mut sorted = points.to_vec();
131        sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
132        Self { points: sorted }
133    }
134
135    /// Create from x,y data.
136    #[must_use]
137    pub fn from_xy(xs: &[f64], ys: &[f64]) -> Self {
138        let points: Vec<_> = xs
139            .iter()
140            .zip(ys.iter())
141            .map(|(&x, &y)| Point2D::new(x, y))
142            .collect();
143        Self::from_points(&points)
144    }
145
146    /// Get the underlying points.
147    #[must_use]
148    pub fn points(&self) -> &[Point2D] {
149        &self.points
150    }
151
152    /// Find the segment containing x.
153    fn find_segment(&self, x: f64) -> Option<(usize, f64)> {
154        if self.points.len() < 2 {
155            return None;
156        }
157        for i in 0..self.points.len() - 1 {
158            let p1 = &self.points[i];
159            let p2 = &self.points[i + 1];
160            if x >= p1.x && x <= p2.x {
161                let t = if (p2.x - p1.x).abs() < 1e-10 {
162                    0.0
163                } else {
164                    (x - p1.x) / (p2.x - p1.x)
165                };
166                return Some((i, t));
167            }
168        }
169        // Extrapolate
170        if x < self.points[0].x {
171            Some((
172                0,
173                (x - self.points[0].x) / (self.points[1].x - self.points[0].x),
174            ))
175        } else {
176            let n = self.points.len();
177            Some((
178                n - 2,
179                (x - self.points[n - 2].x) / (self.points[n - 1].x - self.points[n - 2].x),
180            ))
181        }
182    }
183}
184
185impl Interpolator for LinearInterpolator {
186    fn interpolate(&self, x: f64) -> f64 {
187        if self.points.is_empty() {
188            return 0.0;
189        }
190        if self.points.len() == 1 {
191            return self.points[0].y;
192        }
193
194        if let Some((i, t)) = self.find_segment(x) {
195            let p1 = &self.points[i];
196            let p2 = &self.points[i + 1];
197            (p2.y - p1.y).mul_add(t, p1.y)
198        } else {
199            0.0
200        }
201    }
202}
203
204/// Cubic spline interpolation (natural spline).
205#[derive(Debug, Clone)]
206pub struct CubicSpline {
207    points: Vec<Point2D>,
208    /// Second derivatives at each point
209    y2: Vec<f64>,
210}
211
212impl CubicSpline {
213    /// Create from points (must have at least 3 points).
214    #[must_use]
215    pub fn from_points(points: &[Point2D]) -> Self {
216        let mut sorted = points.to_vec();
217        sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
218
219        let n = sorted.len();
220        if n < 3 {
221            return Self {
222                points: sorted,
223                y2: vec![0.0; n],
224            };
225        }
226
227        // Compute second derivatives using natural spline boundary conditions
228        let mut y2 = vec![0.0; n];
229        let mut u = vec![0.0; n];
230
231        // Forward sweep
232        for i in 1..n - 1 {
233            let h_prev = sorted[i].x - sorted[i - 1].x;
234            let h_next = sorted[i + 1].x - sorted[i].x;
235
236            if h_prev.abs() < 1e-10 || h_next.abs() < 1e-10 {
237                continue;
238            }
239
240            let sig = h_prev / (h_prev + h_next);
241            let p = sig.mul_add(y2[i - 1], 2.0);
242            y2[i] = (sig - 1.0) / p;
243            u[i] =
244                (sorted[i + 1].y - sorted[i].y) / h_next - (sorted[i].y - sorted[i - 1].y) / h_prev;
245            u[i] = sig.mul_add(-u[i - 1], 6.0 * u[i] / (h_prev + h_next)) / p;
246        }
247
248        // Back substitution
249        for k in (0..n - 1).rev() {
250            y2[k] = y2[k].mul_add(y2[k + 1], u[k]);
251        }
252
253        Self { points: sorted, y2 }
254    }
255
256    /// Create from x,y data.
257    #[must_use]
258    pub fn from_xy(xs: &[f64], ys: &[f64]) -> Self {
259        let points: Vec<_> = xs
260            .iter()
261            .zip(ys.iter())
262            .map(|(&x, &y)| Point2D::new(x, y))
263            .collect();
264        Self::from_points(&points)
265    }
266
267    /// Get the underlying points.
268    #[must_use]
269    pub fn points(&self) -> &[Point2D] {
270        &self.points
271    }
272}
273
274impl Interpolator for CubicSpline {
275    fn interpolate(&self, x: f64) -> f64 {
276        let n = self.points.len();
277        if n == 0 {
278            return 0.0;
279        }
280        if n == 1 {
281            return self.points[0].y;
282        }
283        if n == 2 {
284            // Fall back to linear
285            let t = (x - self.points[0].x) / (self.points[1].x - self.points[0].x);
286            return (self.points[1].y - self.points[0].y).mul_add(t, self.points[0].y);
287        }
288
289        // Find segment
290        let mut lo = 0;
291        let mut hi = n - 1;
292        while hi - lo > 1 {
293            let mid = (hi + lo) / 2;
294            if self.points[mid].x > x {
295                hi = mid;
296            } else {
297                lo = mid;
298            }
299        }
300
301        let h = self.points[hi].x - self.points[lo].x;
302        if h.abs() < 1e-10 {
303            return self.points[lo].y;
304        }
305
306        let a = (self.points[hi].x - x) / h;
307        let b = (x - self.points[lo].x) / h;
308
309        a.mul_add(self.points[lo].y, b * self.points[hi].y)
310            + (a * a)
311                .mul_add(a, -a)
312                .mul_add(self.y2[lo], (b * b).mul_add(b, -b) * self.y2[hi])
313                * h
314                * h
315                / 6.0
316    }
317}
318
319/// Catmull-Rom spline interpolation.
320#[derive(Debug, Clone)]
321pub struct CatmullRom {
322    points: Vec<Point2D>,
323    /// Tension parameter (0.0 to 1.0)
324    tension: f64,
325}
326
327impl CatmullRom {
328    /// Create from points with default tension.
329    #[must_use]
330    pub fn from_points(points: &[Point2D]) -> Self {
331        Self::with_tension(points, 0.5)
332    }
333
334    /// Create from points with custom tension.
335    #[must_use]
336    pub fn with_tension(points: &[Point2D], tension: f64) -> Self {
337        let mut sorted = points.to_vec();
338        sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
339        Self {
340            points: sorted,
341            tension: tension.clamp(0.0, 1.0),
342        }
343    }
344
345    /// Get the underlying points.
346    #[must_use]
347    pub fn points(&self) -> &[Point2D] {
348        &self.points
349    }
350
351    /// Generate a smooth path through the points.
352    #[must_use]
353    pub fn to_path(&self, segments_per_span: usize) -> Vec<Point2D> {
354        if self.points.len() < 2 {
355            return self.points.clone();
356        }
357        if self.points.len() == 2 {
358            return self.points.clone();
359        }
360
361        let mut path = Vec::new();
362        let n = self.points.len();
363
364        for i in 0..n - 1 {
365            let p0 = if i == 0 {
366                self.points[0]
367            } else {
368                self.points[i - 1]
369            };
370            let p1 = self.points[i];
371            let p2 = self.points[i + 1];
372            let p3 = if i + 2 < n {
373                self.points[i + 2]
374            } else {
375                self.points[n - 1]
376            };
377
378            for j in 0..segments_per_span {
379                let t = j as f64 / segments_per_span as f64;
380                let point = self.catmull_rom_point(p0, p1, p2, p3, t);
381                path.push(point);
382            }
383        }
384
385        // Add final point
386        path.push(self.points[n - 1]);
387        path
388    }
389
390    /// Compute point on Catmull-Rom curve.
391    fn catmull_rom_point(
392        &self,
393        p0: Point2D,
394        p1: Point2D,
395        p2: Point2D,
396        p3: Point2D,
397        t: f64,
398    ) -> Point2D {
399        let t2 = t * t;
400        let t3 = t2 * t;
401
402        let tau = self.tension;
403
404        // Catmull-Rom basis matrix with tension
405        let c0 = tau.mul_add(-t, (-tau).mul_add(t3, 2.0 * tau * t2));
406        let c1 = (2.0 - tau).mul_add(t3, (tau - 3.0) * t2) + 1.0;
407        let c2 = tau.mul_add(t, (tau - 2.0).mul_add(t3, 2.0f64.mul_add(-tau, 3.0) * t2));
408        let c3 = tau.mul_add(t3, -(tau * t2));
409
410        Point2D::new(
411            c3.mul_add(p3.x, c0 * p0.x + c1 * p1.x + c2 * p2.x),
412            c3.mul_add(p3.y, c0 * p0.y + c1 * p1.y + c2 * p2.y),
413        )
414    }
415}
416
417impl Interpolator for CatmullRom {
418    fn interpolate(&self, x: f64) -> f64 {
419        // For Catmull-Rom, we need to find the segment and interpolate
420        if self.points.is_empty() {
421            return 0.0;
422        }
423        if self.points.len() == 1 {
424            return self.points[0].y;
425        }
426
427        // Find segment containing x
428        let mut idx = 0;
429        for i in 0..self.points.len() - 1 {
430            if x >= self.points[i].x && x <= self.points[i + 1].x {
431                idx = i;
432                break;
433            }
434            if x < self.points[i].x {
435                idx = i.saturating_sub(1);
436                break;
437            }
438            idx = i;
439        }
440
441        let p1 = &self.points[idx];
442        let p2 = &self.points[(idx + 1).min(self.points.len() - 1)];
443
444        let t = if (p2.x - p1.x).abs() < 1e-10 {
445            0.0
446        } else {
447            ((x - p1.x) / (p2.x - p1.x)).clamp(0.0, 1.0)
448        };
449
450        let p0 = if idx == 0 { *p1 } else { self.points[idx - 1] };
451        let p3 = if idx + 2 < self.points.len() {
452            self.points[idx + 2]
453        } else {
454            *p2
455        };
456
457        self.catmull_rom_point(p0, *p1, *p2, p3, t).y
458    }
459}
460
461/// Bezier curve segment.
462#[derive(Debug, Clone, Copy, PartialEq)]
463pub struct CubicBezier {
464    /// Start point
465    pub p0: Point2D,
466    /// First control point
467    pub p1: Point2D,
468    /// Second control point
469    pub p2: Point2D,
470    /// End point
471    pub p3: Point2D,
472}
473
474impl CubicBezier {
475    /// Create a new cubic Bezier curve.
476    #[must_use]
477    pub const fn new(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D) -> Self {
478        Self { p0, p1, p2, p3 }
479    }
480
481    /// Evaluate the curve at parameter t (0 to 1).
482    #[must_use]
483    pub fn evaluate(&self, t: f64) -> Point2D {
484        let t = t.clamp(0.0, 1.0);
485        let mt = 1.0 - t;
486        let mt2 = mt * mt;
487        let mt3 = mt2 * mt;
488        let t2 = t * t;
489        let t3 = t2 * t;
490
491        Point2D::new(
492            (3.0 * mt * t2).mul_add(self.p2.x, mt3 * self.p0.x + 3.0 * mt2 * t * self.p1.x)
493                + t3 * self.p3.x,
494            (3.0 * mt * t2).mul_add(self.p2.y, mt3 * self.p0.y + 3.0 * mt2 * t * self.p1.y)
495                + t3 * self.p3.y,
496        )
497    }
498
499    /// Convert to a polyline with given number of segments.
500    #[must_use]
501    pub fn to_polyline(&self, segments: usize) -> Vec<Point2D> {
502        let segments = segments.max(1);
503        (0..=segments)
504            .map(|i| self.evaluate(i as f64 / segments as f64))
505            .collect()
506    }
507
508    /// Compute approximate arc length.
509    #[must_use]
510    pub fn arc_length(&self, segments: usize) -> f64 {
511        let points = self.to_polyline(segments);
512        points.windows(2).map(|w| w[0].distance(&w[1])).sum()
513    }
514
515    /// Split curve at parameter t.
516    #[must_use]
517    pub fn split(&self, t: f64) -> (Self, Self) {
518        let t = t.clamp(0.0, 1.0);
519
520        // De Casteljau's algorithm
521        let p01 = self.p0.lerp(&self.p1, t);
522        let p12 = self.p1.lerp(&self.p2, t);
523        let p23 = self.p2.lerp(&self.p3, t);
524
525        let p012 = p01.lerp(&p12, t);
526        let p123 = p12.lerp(&p23, t);
527
528        let p0123 = p012.lerp(&p123, t);
529
530        let left = Self::new(self.p0, p01, p012, p0123);
531        let right = Self::new(p0123, p123, p23, self.p3);
532
533        (left, right)
534    }
535}
536
537/// Histogram binning configuration.
538#[derive(Debug, Clone)]
539pub struct HistogramBins {
540    /// Bin edges (n+1 edges for n bins)
541    pub edges: Vec<f64>,
542    /// Bin counts
543    pub counts: Vec<usize>,
544    /// Bin densities (normalized)
545    pub densities: Vec<f64>,
546}
547
548impl HistogramBins {
549    /// Create histogram from data with specified number of bins.
550    #[must_use]
551    pub fn from_data(data: &[f64], num_bins: usize) -> Self {
552        if data.is_empty() || num_bins == 0 {
553            return Self {
554                edges: vec![],
555                counts: vec![],
556                densities: vec![],
557            };
558        }
559
560        let num_bins = num_bins.max(1);
561        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
562        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
563
564        Self::from_data_range(data, num_bins, min, max)
565    }
566
567    /// Create histogram from data with explicit range.
568    #[must_use]
569    pub fn from_data_range(data: &[f64], num_bins: usize, min: f64, max: f64) -> Self {
570        let num_bins = num_bins.max(1);
571        let range = (max - min).max(1e-10);
572        let bin_width = range / num_bins as f64;
573
574        // Create edges
575        let edges: Vec<f64> = (0..=num_bins)
576            .map(|i| (i as f64).mul_add(bin_width, min))
577            .collect();
578
579        // Count values in each bin
580        let mut counts = vec![0usize; num_bins];
581        for &value in data {
582            let bin = ((value - min) / bin_width).floor() as usize;
583            let bin = bin.min(num_bins - 1); // Handle edge case where value == max
584            counts[bin] += 1;
585        }
586
587        // Compute densities (probability density)
588        let total = data.len() as f64;
589        let densities: Vec<f64> = counts
590            .iter()
591            .map(|&c| (c as f64) / (total * bin_width))
592            .collect();
593
594        Self {
595            edges,
596            counts,
597            densities,
598        }
599    }
600
601    /// Get number of bins.
602    #[must_use]
603    pub fn num_bins(&self) -> usize {
604        self.counts.len()
605    }
606
607    /// Get bin width (assumes uniform bins).
608    #[must_use]
609    pub fn bin_width(&self) -> f64 {
610        if self.edges.len() < 2 {
611            return 0.0;
612        }
613        self.edges[1] - self.edges[0]
614    }
615
616    /// Get bin center for given index.
617    #[must_use]
618    pub fn bin_center(&self, index: usize) -> Option<f64> {
619        if index >= self.counts.len() {
620            return None;
621        }
622        Some((self.edges[index] + self.edges[index + 1]) / 2.0)
623    }
624
625    /// Get bin range for given index.
626    #[must_use]
627    pub fn bin_range(&self, index: usize) -> Option<(f64, f64)> {
628        if index >= self.counts.len() {
629            return None;
630        }
631        Some((self.edges[index], self.edges[index + 1]))
632    }
633
634    /// Total count across all bins.
635    #[must_use]
636    pub fn total_count(&self) -> usize {
637        self.counts.iter().sum()
638    }
639
640    /// Maximum count in any bin.
641    #[must_use]
642    pub fn max_count(&self) -> usize {
643        self.counts.iter().copied().max().unwrap_or(0)
644    }
645}
646
647/// Arc geometry for pie charts.
648#[derive(Debug, Clone, Copy, PartialEq)]
649pub struct ArcGeometry {
650    /// Center point
651    pub center: Point2D,
652    /// Radius
653    pub radius: f64,
654    /// Start angle (radians)
655    pub start_angle: f64,
656    /// End angle (radians)
657    pub end_angle: f64,
658}
659
660impl ArcGeometry {
661    /// Create a new arc.
662    #[must_use]
663    pub const fn new(center: Point2D, radius: f64, start_angle: f64, end_angle: f64) -> Self {
664        Self {
665            center,
666            radius,
667            start_angle,
668            end_angle,
669        }
670    }
671
672    /// Create a full circle.
673    #[must_use]
674    pub fn circle(center: Point2D, radius: f64) -> Self {
675        Self::new(center, radius, 0.0, 2.0 * PI)
676    }
677
678    /// Get the sweep angle.
679    #[must_use]
680    pub fn sweep(&self) -> f64 {
681        self.end_angle - self.start_angle
682    }
683
684    /// Point on arc at given angle.
685    #[must_use]
686    pub fn point_at_angle(&self, angle: f64) -> Point2D {
687        Point2D::new(
688            self.radius.mul_add(angle.cos(), self.center.x),
689            self.radius.mul_add(angle.sin(), self.center.y),
690        )
691    }
692
693    /// Start point of arc.
694    #[must_use]
695    pub fn start_point(&self) -> Point2D {
696        self.point_at_angle(self.start_angle)
697    }
698
699    /// End point of arc.
700    #[must_use]
701    pub fn end_point(&self) -> Point2D {
702        self.point_at_angle(self.end_angle)
703    }
704
705    /// Midpoint of arc.
706    #[must_use]
707    pub fn mid_point(&self) -> Point2D {
708        let mid_angle = (self.start_angle + self.end_angle) / 2.0;
709        self.point_at_angle(mid_angle)
710    }
711
712    /// Arc length.
713    #[must_use]
714    pub fn arc_length(&self) -> f64 {
715        self.radius * self.sweep().abs()
716    }
717
718    /// Convert arc to polyline for rendering.
719    #[must_use]
720    pub fn to_polyline(&self, segments: usize) -> Vec<Point2D> {
721        let segments = segments.max(1);
722        let sweep = self.sweep();
723        (0..=segments)
724            .map(|i| {
725                let t = i as f64 / segments as f64;
726                let angle = self.start_angle + t * sweep;
727                self.point_at_angle(angle)
728            })
729            .collect()
730    }
731
732    /// Convert arc to pie slice (includes center point).
733    #[must_use]
734    pub fn to_pie_slice(&self, segments: usize) -> Vec<Point2D> {
735        let mut points = vec![self.center];
736        points.extend(self.to_polyline(segments));
737        points.push(self.center);
738        points
739    }
740
741    /// Check if angle is within arc sweep.
742    #[must_use]
743    pub fn contains_angle(&self, angle: f64) -> bool {
744        let normalized = Self::normalize_angle(angle);
745        let start = Self::normalize_angle(self.start_angle);
746        let end = Self::normalize_angle(self.end_angle);
747
748        if start <= end {
749            normalized >= start && normalized <= end
750        } else {
751            normalized >= start || normalized <= end
752        }
753    }
754
755    /// Normalize angle to [0, 2π).
756    fn normalize_angle(angle: f64) -> f64 {
757        let mut a = angle % (2.0 * PI);
758        if a < 0.0 {
759            a += 2.0 * PI;
760        }
761        a
762    }
763}
764
765/// Data normalization for chart rendering.
766#[derive(Debug, Clone, Copy)]
767pub struct DataNormalizer {
768    /// Minimum value
769    pub min: f64,
770    /// Maximum value
771    pub max: f64,
772}
773
774impl DataNormalizer {
775    /// Create normalizer from data range.
776    #[must_use]
777    pub fn new(min: f64, max: f64) -> Self {
778        Self { min, max }
779    }
780
781    /// Create normalizer from data.
782    #[must_use]
783    pub fn from_data(data: &[f64]) -> Self {
784        if data.is_empty() {
785            return Self::new(0.0, 1.0);
786        }
787        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
788        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
789        Self::new(min, max)
790    }
791
792    /// Normalize a value to [0, 1].
793    #[must_use]
794    pub fn normalize(&self, value: f64) -> f64 {
795        let range = self.max - self.min;
796        if range.abs() < 1e-10 {
797            return 0.5;
798        }
799        (value - self.min) / range
800    }
801
802    /// Denormalize a value from [0, 1] to original range.
803    #[must_use]
804    pub fn denormalize(&self, normalized: f64) -> f64 {
805        normalized.mul_add(self.max - self.min, self.min)
806    }
807
808    /// Normalize all values in a slice.
809    #[must_use]
810    pub fn normalize_all(&self, data: &[f64]) -> Vec<f64> {
811        data.iter().map(|&v| self.normalize(v)).collect()
812    }
813
814    /// Get nice axis bounds (rounded for display).
815    #[must_use]
816    pub fn nice_bounds(&self) -> (f64, f64) {
817        let range = self.max - self.min;
818        if range.abs() < 1e-10 {
819            return (self.min - 1.0, self.max + 1.0);
820        }
821
822        let magnitude = 10.0_f64.powf(range.log10().floor());
823        let nice_min = (self.min / magnitude).floor() * magnitude;
824        let nice_max = (self.max / magnitude).ceil() * magnitude;
825
826        (nice_min, nice_max)
827    }
828}
829
830/// Path tessellation for GPU rendering.
831#[derive(Debug, Clone, Default)]
832pub struct PathTessellator {
833    /// Tolerance for curve flattening
834    pub tolerance: f64,
835    /// Generated vertices (x, y)
836    pub vertices: Vec<[f32; 2]>,
837    /// Triangle indices
838    pub indices: Vec<u32>,
839}
840
841impl PathTessellator {
842    /// Create a new tessellator.
843    #[must_use]
844    pub fn new(tolerance: f64) -> Self {
845        Self {
846            tolerance: tolerance.max(0.001),
847            vertices: Vec::new(),
848            indices: Vec::new(),
849        }
850    }
851
852    /// Create with default tolerance.
853    #[must_use]
854    pub fn with_default_tolerance() -> Self {
855        Self::new(0.25)
856    }
857
858    /// Clear the tessellator.
859    pub fn clear(&mut self) {
860        self.vertices.clear();
861        self.indices.clear();
862    }
863
864    /// Tessellate a filled polygon.
865    pub fn tessellate_polygon(&mut self, points: &[Point2D]) {
866        if points.len() < 3 {
867            return;
868        }
869
870        let base_idx = self.vertices.len() as u32;
871
872        // Add vertices
873        for p in points {
874            self.vertices.push([p.x as f32, p.y as f32]);
875        }
876
877        // Fan triangulation (simple, works for convex polygons)
878        for i in 1..points.len() as u32 - 1 {
879            self.indices.push(base_idx);
880            self.indices.push(base_idx + i);
881            self.indices.push(base_idx + i + 1);
882        }
883    }
884
885    /// Tessellate a stroked polyline.
886    pub fn tessellate_stroke(&mut self, points: &[Point2D], width: f64) {
887        if points.len() < 2 {
888            return;
889        }
890
891        let half_width = width / 2.0;
892
893        for window in points.windows(2) {
894            let p1 = window[0];
895            let p2 = window[1];
896
897            // Compute perpendicular direction
898            let dx = p2.x - p1.x;
899            let dy = p2.y - p1.y;
900            let len = dx.hypot(dy);
901            if len < 1e-10 {
902                continue;
903            }
904
905            let nx = -dy / len * half_width;
906            let ny = dx / len * half_width;
907
908            let base_idx = self.vertices.len() as u32;
909
910            // Add quad vertices
911            self.vertices.push([(p1.x + nx) as f32, (p1.y + ny) as f32]);
912            self.vertices.push([(p1.x - nx) as f32, (p1.y - ny) as f32]);
913            self.vertices.push([(p2.x + nx) as f32, (p2.y + ny) as f32]);
914            self.vertices.push([(p2.x - nx) as f32, (p2.y - ny) as f32]);
915
916            // Two triangles for quad
917            self.indices.push(base_idx);
918            self.indices.push(base_idx + 1);
919            self.indices.push(base_idx + 2);
920
921            self.indices.push(base_idx + 1);
922            self.indices.push(base_idx + 3);
923            self.indices.push(base_idx + 2);
924        }
925    }
926
927    /// Tessellate a circle.
928    pub fn tessellate_circle(&mut self, center: Point2D, radius: f64, segments: usize) {
929        let segments = segments.max(8);
930        let base_idx = self.vertices.len() as u32;
931
932        // Center vertex
933        self.vertices.push([center.x as f32, center.y as f32]);
934
935        // Perimeter vertices
936        for i in 0..segments {
937            let angle = 2.0 * PI * i as f64 / segments as f64;
938            let x = radius.mul_add(angle.cos(), center.x);
939            let y = radius.mul_add(angle.sin(), center.y);
940            self.vertices.push([x as f32, y as f32]);
941        }
942
943        // Fan triangles
944        for i in 0..segments as u32 {
945            self.indices.push(base_idx); // Center
946            self.indices.push(base_idx + 1 + i);
947            self.indices.push(base_idx + 1 + (i + 1) % segments as u32);
948        }
949    }
950
951    /// Tessellate a rectangle.
952    pub fn tessellate_rect(&mut self, x: f64, y: f64, width: f64, height: f64) {
953        let base_idx = self.vertices.len() as u32;
954
955        self.vertices.push([x as f32, y as f32]);
956        self.vertices.push([(x + width) as f32, y as f32]);
957        self.vertices
958            .push([(x + width) as f32, (y + height) as f32]);
959        self.vertices.push([x as f32, (y + height) as f32]);
960
961        // Two triangles
962        self.indices.push(base_idx);
963        self.indices.push(base_idx + 1);
964        self.indices.push(base_idx + 2);
965
966        self.indices.push(base_idx);
967        self.indices.push(base_idx + 2);
968        self.indices.push(base_idx + 3);
969    }
970
971    /// Get vertex count.
972    #[must_use]
973    pub fn vertex_count(&self) -> usize {
974        self.vertices.len()
975    }
976
977    /// Get index count.
978    #[must_use]
979    pub fn index_count(&self) -> usize {
980        self.indices.len()
981    }
982
983    /// Get triangle count.
984    #[must_use]
985    pub fn triangle_count(&self) -> usize {
986        self.indices.len() / 3
987    }
988}
989
990/// Draw call batching for GPU efficiency.
991#[derive(Debug, Clone, Default)]
992pub struct DrawBatch {
993    /// Batched circles (`center_x`, `center_y`, radius, `color_rgba`)
994    pub circles: Vec<[f32; 7]>,
995    /// Batched rectangles (x, y, w, h, `color_rgba`)
996    pub rects: Vec<[f32; 8]>,
997    /// Batched lines (x1, y1, x2, y2, width, `color_rgba`)
998    pub lines: Vec<[f32; 9]>,
999}
1000
1001impl DrawBatch {
1002    /// Create a new batch.
1003    #[must_use]
1004    pub fn new() -> Self {
1005        Self::default()
1006    }
1007
1008    /// Add a circle to the batch.
1009    pub fn add_circle(&mut self, x: f32, y: f32, radius: f32, r: f32, g: f32, b: f32, a: f32) {
1010        self.circles.push([x, y, radius, r, g, b, a]);
1011    }
1012
1013    /// Add a rectangle to the batch.
1014    #[allow(clippy::too_many_arguments)]
1015    pub fn add_rect(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32, g: f32, b: f32, a: f32) {
1016        self.rects.push([x, y, w, h, r, g, b, a]);
1017    }
1018
1019    /// Add a line to the batch.
1020    #[allow(clippy::too_many_arguments)]
1021    pub fn add_line(
1022        &mut self,
1023        x1: f32,
1024        y1: f32,
1025        x2: f32,
1026        y2: f32,
1027        width: f32,
1028        r: f32,
1029        g: f32,
1030        b: f32,
1031        a: f32,
1032    ) {
1033        self.lines.push([x1, y1, x2, y2, width, r, g, b, a]);
1034    }
1035
1036    /// Clear all batches.
1037    pub fn clear(&mut self) {
1038        self.circles.clear();
1039        self.rects.clear();
1040        self.lines.clear();
1041    }
1042
1043    /// Total draw calls if not batched.
1044    #[must_use]
1045    pub fn unbatched_draw_calls(&self) -> usize {
1046        self.circles.len() + self.rects.len() + self.lines.len()
1047    }
1048
1049    /// Actual draw calls with batching (3 max: circles, rects, lines).
1050    #[must_use]
1051    pub fn batched_draw_calls(&self) -> usize {
1052        let mut calls = 0;
1053        if !self.circles.is_empty() {
1054            calls += 1;
1055        }
1056        if !self.rects.is_empty() {
1057            calls += 1;
1058        }
1059        if !self.lines.is_empty() {
1060            calls += 1;
1061        }
1062        calls
1063    }
1064}