Skip to main content

astrelis_geometry/chart/
types.rs

1//! Core chart types.
2
3use super::style::{AxisStyle, SeriesStyle};
4use astrelis_render::Color;
5use glam::Vec2;
6
7/// A unique identifier for an axis.
8///
9/// Supports both standard axes (X/Y primary/secondary) and unlimited custom axes.
10/// Custom axes can be created by ID or by name using hash-based ID generation.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
12pub struct AxisId(pub u32);
13
14impl AxisId {
15    /// Primary X axis (bottom).
16    pub const X_PRIMARY: AxisId = AxisId(0);
17    /// Primary Y axis (left).
18    pub const Y_PRIMARY: AxisId = AxisId(1);
19    /// Secondary X axis (top).
20    pub const X_SECONDARY: AxisId = AxisId(2);
21    /// Secondary Y axis (right).
22    pub const Y_SECONDARY: AxisId = AxisId(3);
23
24    /// Create a custom axis ID.
25    ///
26    /// IDs 0-3 are reserved for standard axes.
27    pub fn custom(id: u32) -> Self {
28        Self(id + 4) // Reserve 0-3 for standard axes
29    }
30
31    /// Create an axis ID from a name using FNV-1a hash.
32    ///
33    /// This allows referencing axes by name in a consistent way.
34    /// The same name will always produce the same ID.
35    ///
36    /// # Example
37    ///
38    /// ```ignore
39    /// let pressure_axis = AxisId::from_name("pressure");
40    /// // Use the same name to reference the axis later
41    /// series.y_axis = AxisId::from_name("pressure");
42    /// ```
43    pub fn from_name(name: &str) -> Self {
44        // FNV-1a hash
45        const FNV_OFFSET_BASIS: u32 = 2166136261;
46        const FNV_PRIME: u32 = 16777619;
47
48        let mut hash = FNV_OFFSET_BASIS;
49        for byte in name.bytes() {
50            hash ^= u32::from(byte);
51            hash = hash.wrapping_mul(FNV_PRIME);
52        }
53
54        // Ensure we don't collide with reserved IDs
55        Self(hash | 0x8000_0000)
56    }
57
58    /// Check if this is a standard (built-in) axis.
59    pub fn is_standard(&self) -> bool {
60        self.0 < 4
61    }
62
63    /// Check if this is a custom axis.
64    pub fn is_custom(&self) -> bool {
65        !self.is_standard()
66    }
67
68    /// Get the raw ID value.
69    pub fn raw(&self) -> u32 {
70        self.0
71    }
72}
73
74/// Position of an axis on the chart.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum AxisPosition {
77    /// Left side (for Y axes)
78    #[default]
79    Left,
80    /// Right side (for Y axes)
81    Right,
82    /// Top (for X axes)
83    Top,
84    /// Bottom (for X axes)
85    Bottom,
86}
87
88/// Axis orientation.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum AxisOrientation {
91    /// Horizontal axis (X)
92    #[default]
93    Horizontal,
94    /// Vertical axis (Y)
95    Vertical,
96}
97
98/// A unique identifier for a data series.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
100pub struct SeriesId(pub u32);
101
102impl SeriesId {
103    /// Create a series ID from an index.
104    pub fn from_index(index: usize) -> Self {
105        Self(index as u32)
106    }
107
108    /// Create a series ID from a name using hash.
109    pub fn from_name(name: &str) -> Self {
110        // FNV-1a hash
111        const FNV_OFFSET_BASIS: u32 = 2166136261;
112        const FNV_PRIME: u32 = 16777619;
113
114        let mut hash = FNV_OFFSET_BASIS;
115        for byte in name.bytes() {
116            hash ^= u32::from(byte);
117            hash = hash.wrapping_mul(FNV_PRIME);
118        }
119
120        Self(hash)
121    }
122
123    /// Get the raw ID value.
124    pub fn raw(&self) -> u32 {
125        self.0
126    }
127}
128
129/// A data point in a chart.
130#[derive(Debug, Clone, Copy, PartialEq, Default)]
131pub struct DataPoint {
132    /// X coordinate
133    pub x: f64,
134    /// Y coordinate
135    pub y: f64,
136}
137
138impl DataPoint {
139    /// Create a new data point.
140    pub fn new(x: f64, y: f64) -> Self {
141        Self { x, y }
142    }
143}
144
145impl From<(f64, f64)> for DataPoint {
146    fn from((x, y): (f64, f64)) -> Self {
147        Self { x, y }
148    }
149}
150
151impl From<(f32, f32)> for DataPoint {
152    fn from((x, y): (f32, f32)) -> Self {
153        Self {
154            x: x as f64,
155            y: y as f64,
156        }
157    }
158}
159
160/// A data series in a chart.
161#[derive(Debug, Clone)]
162pub struct Series {
163    /// Series name (for legend)
164    pub name: String,
165    /// Data points
166    pub data: Vec<DataPoint>,
167    /// Visual style
168    pub style: SeriesStyle,
169    /// Which X axis this series uses
170    pub x_axis: AxisId,
171    /// Which Y axis this series uses
172    pub y_axis: AxisId,
173}
174
175impl Series {
176    /// Create a new series.
177    pub fn new(name: impl Into<String>, data: Vec<DataPoint>, style: SeriesStyle) -> Self {
178        Self {
179            name: name.into(),
180            data,
181            style,
182            x_axis: AxisId::X_PRIMARY,
183            y_axis: AxisId::Y_PRIMARY,
184        }
185    }
186
187    /// Create a series from tuples.
188    pub fn from_tuples<T: Into<DataPoint> + Copy>(
189        name: impl Into<String>,
190        data: &[T],
191        style: SeriesStyle,
192    ) -> Self {
193        Self {
194            name: name.into(),
195            data: data.iter().map(|&d| d.into()).collect(),
196            style,
197            x_axis: AxisId::X_PRIMARY,
198            y_axis: AxisId::Y_PRIMARY,
199        }
200    }
201
202    /// Set which axes this series uses.
203    pub fn with_axes(mut self, x_axis: AxisId, y_axis: AxisId) -> Self {
204        self.x_axis = x_axis;
205        self.y_axis = y_axis;
206        self
207    }
208
209    /// Get the min/max bounds of this series.
210    pub fn bounds(&self) -> Option<(DataPoint, DataPoint)> {
211        if self.data.is_empty() {
212            return None;
213        }
214
215        let mut min = DataPoint::new(f64::INFINITY, f64::INFINITY);
216        let mut max = DataPoint::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
217
218        for p in &self.data {
219            min.x = min.x.min(p.x);
220            min.y = min.y.min(p.y);
221            max.x = max.x.max(p.x);
222            max.y = max.y.max(p.y);
223        }
224
225        Some((min, max))
226    }
227}
228
229/// Chart type.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
231pub enum ChartType {
232    /// Line chart
233    #[default]
234    Line,
235    /// Bar chart
236    Bar,
237    /// Scatter plot
238    Scatter,
239    /// Area chart (filled line)
240    Area,
241}
242
243/// Axis configuration.
244#[derive(Debug, Clone)]
245pub struct Axis {
246    /// Unique identifier
247    pub id: AxisId,
248    /// Axis label
249    pub label: Option<String>,
250    /// Minimum value (None = auto)
251    pub min: Option<f64>,
252    /// Maximum value (None = auto)
253    pub max: Option<f64>,
254    /// Number of tick marks
255    pub tick_count: usize,
256    /// Show grid lines
257    pub grid_lines: bool,
258    /// Visual style
259    pub style: AxisStyle,
260    /// Position on the chart
261    pub position: AxisPosition,
262    /// Orientation
263    pub orientation: AxisOrientation,
264    /// Whether this axis is visible
265    pub visible: bool,
266    /// Custom tick values (if provided, overrides auto ticks)
267    pub custom_ticks: Option<Vec<(f64, String)>>,
268}
269
270impl Default for Axis {
271    fn default() -> Self {
272        Self {
273            id: AxisId::default(),
274            label: None,
275            min: None,
276            max: None,
277            tick_count: 5,
278            grid_lines: true,
279            style: AxisStyle::default(),
280            position: AxisPosition::Left,
281            orientation: AxisOrientation::Vertical,
282            visible: true,
283            custom_ticks: None,
284        }
285    }
286}
287
288impl Axis {
289    /// Create a new X axis.
290    pub fn x() -> Self {
291        Self {
292            id: AxisId::X_PRIMARY,
293            orientation: AxisOrientation::Horizontal,
294            position: AxisPosition::Bottom,
295            ..Default::default()
296        }
297    }
298
299    /// Create a new Y axis.
300    pub fn y() -> Self {
301        Self {
302            id: AxisId::Y_PRIMARY,
303            orientation: AxisOrientation::Vertical,
304            position: AxisPosition::Left,
305            ..Default::default()
306        }
307    }
308
309    /// Create a secondary X axis (top).
310    pub fn x_secondary() -> Self {
311        Self {
312            id: AxisId::X_SECONDARY,
313            orientation: AxisOrientation::Horizontal,
314            position: AxisPosition::Top,
315            ..Default::default()
316        }
317    }
318
319    /// Create a secondary Y axis (right).
320    pub fn y_secondary() -> Self {
321        Self {
322            id: AxisId::Y_SECONDARY,
323            orientation: AxisOrientation::Vertical,
324            position: AxisPosition::Right,
325            ..Default::default()
326        }
327    }
328
329    /// Create a new axis with a label.
330    pub fn new(label: impl Into<String>) -> Self {
331        Self {
332            label: Some(label.into()),
333            ..Default::default()
334        }
335    }
336
337    /// Set the axis ID.
338    pub fn with_id(mut self, id: AxisId) -> Self {
339        self.id = id;
340        self
341    }
342
343    /// Set the min/max range.
344    pub fn with_range(mut self, min: f64, max: f64) -> Self {
345        self.min = Some(min);
346        self.max = Some(max);
347        self
348    }
349
350    /// Set tick count.
351    pub fn with_ticks(mut self, count: usize) -> Self {
352        self.tick_count = count;
353        self
354    }
355
356    /// Set custom tick values.
357    pub fn with_custom_ticks(mut self, ticks: Vec<(f64, String)>) -> Self {
358        self.custom_ticks = Some(ticks);
359        self
360    }
361
362    /// Enable/disable grid lines.
363    pub fn with_grid(mut self, enabled: bool) -> Self {
364        self.grid_lines = enabled;
365        self
366    }
367
368    /// Set the axis position.
369    pub fn with_position(mut self, position: AxisPosition) -> Self {
370        self.position = position;
371        self
372    }
373
374    /// Set visibility.
375    pub fn with_visible(mut self, visible: bool) -> Self {
376        self.visible = visible;
377        self
378    }
379
380    /// Set the axis label.
381    pub fn with_label(mut self, label: impl Into<String>) -> Self {
382        self.label = Some(label.into());
383        self
384    }
385}
386
387/// Legend position.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
389pub enum LegendPosition {
390    /// Top-left corner
391    TopLeft,
392    /// Top-right corner
393    #[default]
394    TopRight,
395    /// Bottom-left corner
396    BottomLeft,
397    /// Bottom-right corner
398    BottomRight,
399    /// No legend
400    None,
401}
402
403/// Legend configuration.
404#[derive(Debug, Clone)]
405pub struct LegendConfig {
406    /// Position
407    pub position: LegendPosition,
408    /// Padding from edge
409    pub padding: f32,
410}
411
412impl Default for LegendConfig {
413    fn default() -> Self {
414        Self {
415            position: LegendPosition::TopRight,
416            padding: 10.0,
417        }
418    }
419}
420
421/// Bar chart configuration.
422#[derive(Debug, Clone, Copy)]
423pub struct BarConfig {
424    /// Width of each bar
425    pub bar_width: f32,
426    /// Gap between bars
427    pub gap: f32,
428}
429
430impl Default for BarConfig {
431    fn default() -> Self {
432        Self {
433            bar_width: 20.0,
434            gap: 5.0,
435        }
436    }
437}
438
439/// Text annotation on the chart.
440#[derive(Debug, Clone)]
441pub struct TextAnnotation {
442    /// Text content
443    pub text: String,
444    /// Position in data coordinates (None = pixel coordinates)
445    pub data_position: Option<DataPoint>,
446    /// Position in pixel coordinates (used if data_position is None)
447    pub pixel_position: Vec2,
448    /// Text color
449    pub color: Color,
450    /// Font size
451    pub font_size: f32,
452    /// Anchor point (0,0 = top-left, 0.5,0.5 = center, 1,1 = bottom-right)
453    pub anchor: Vec2,
454    /// Which axes to use for data coordinates
455    pub x_axis: AxisId,
456    pub y_axis: AxisId,
457}
458
459impl TextAnnotation {
460    /// Create a text annotation at data coordinates.
461    pub fn at_data(text: impl Into<String>, x: f64, y: f64) -> Self {
462        Self {
463            text: text.into(),
464            data_position: Some(DataPoint::new(x, y)),
465            pixel_position: Vec2::ZERO,
466            color: Color::WHITE,
467            font_size: 12.0,
468            anchor: Vec2::new(0.5, 0.5),
469            x_axis: AxisId::X_PRIMARY,
470            y_axis: AxisId::Y_PRIMARY,
471        }
472    }
473
474    /// Create a text annotation at pixel coordinates.
475    pub fn at_pixel(text: impl Into<String>, x: f32, y: f32) -> Self {
476        Self {
477            text: text.into(),
478            data_position: None,
479            pixel_position: Vec2::new(x, y),
480            color: Color::WHITE,
481            font_size: 12.0,
482            anchor: Vec2::new(0.5, 0.5),
483            x_axis: AxisId::X_PRIMARY,
484            y_axis: AxisId::Y_PRIMARY,
485        }
486    }
487
488    /// Set the text color.
489    pub fn with_color(mut self, color: Color) -> Self {
490        self.color = color;
491        self
492    }
493
494    /// Set the font size.
495    pub fn with_font_size(mut self, size: f32) -> Self {
496        self.font_size = size;
497        self
498    }
499
500    /// Set the anchor point.
501    pub fn with_anchor(mut self, anchor: Vec2) -> Self {
502        self.anchor = anchor;
503        self
504    }
505}
506
507/// Line annotation on the chart.
508#[derive(Debug, Clone)]
509pub struct LineAnnotation {
510    /// Start point in data coordinates
511    pub start: DataPoint,
512    /// End point in data coordinates
513    pub end: DataPoint,
514    /// Line color
515    pub color: Color,
516    /// Line width
517    pub width: f32,
518    /// Dash pattern (None = solid)
519    pub dash: Option<f32>,
520    /// Which axes to use
521    pub x_axis: AxisId,
522    pub y_axis: AxisId,
523}
524
525impl LineAnnotation {
526    /// Create a horizontal line at a Y value.
527    pub fn horizontal(y: f64, x_min: f64, x_max: f64) -> Self {
528        Self {
529            start: DataPoint::new(x_min, y),
530            end: DataPoint::new(x_max, y),
531            color: Color::rgba(0.5, 0.5, 0.5, 0.8),
532            width: 1.0,
533            dash: None,
534            x_axis: AxisId::X_PRIMARY,
535            y_axis: AxisId::Y_PRIMARY,
536        }
537    }
538
539    /// Create a vertical line at an X value.
540    pub fn vertical(x: f64, y_min: f64, y_max: f64) -> Self {
541        Self {
542            start: DataPoint::new(x, y_min),
543            end: DataPoint::new(x, y_max),
544            color: Color::rgba(0.5, 0.5, 0.5, 0.8),
545            width: 1.0,
546            dash: None,
547            x_axis: AxisId::X_PRIMARY,
548            y_axis: AxisId::Y_PRIMARY,
549        }
550    }
551
552    /// Set the line color.
553    pub fn with_color(mut self, color: Color) -> Self {
554        self.color = color;
555        self
556    }
557
558    /// Set the line width.
559    pub fn with_width(mut self, width: f32) -> Self {
560        self.width = width;
561        self
562    }
563
564    /// Set dash pattern.
565    pub fn with_dash(mut self, dash: f32) -> Self {
566        self.dash = Some(dash);
567        self
568    }
569}
570
571/// A filled region on the chart.
572#[derive(Debug, Clone)]
573pub struct FillRegion {
574    /// Region type
575    pub kind: FillRegionKind,
576    /// Fill color
577    pub color: Color,
578    /// Which axes to use
579    pub x_axis: AxisId,
580    pub y_axis: AxisId,
581}
582
583/// Types of fill regions.
584#[derive(Debug, Clone)]
585pub enum FillRegionKind {
586    /// Fill between two Y values across the entire X range
587    HorizontalBand { y_min: f64, y_max: f64 },
588    /// Fill between two X values across the entire Y range
589    VerticalBand { x_min: f64, x_max: f64 },
590    /// Fill between a series and a constant Y value
591    BelowSeries {
592        series_index: usize,
593        y_baseline: f64,
594    },
595    /// Fill between two series
596    BetweenSeries {
597        series_index_1: usize,
598        series_index_2: usize,
599    },
600    /// Fill a rectangular region
601    Rectangle {
602        x_min: f64,
603        y_min: f64,
604        x_max: f64,
605        y_max: f64,
606    },
607    /// Fill a custom polygon
608    Polygon { points: Vec<DataPoint> },
609}
610
611impl FillRegion {
612    /// Create a horizontal band fill.
613    pub fn horizontal_band(y_min: f64, y_max: f64, color: Color) -> Self {
614        Self {
615            kind: FillRegionKind::HorizontalBand { y_min, y_max },
616            color,
617            x_axis: AxisId::X_PRIMARY,
618            y_axis: AxisId::Y_PRIMARY,
619        }
620    }
621
622    /// Create a vertical band fill.
623    pub fn vertical_band(x_min: f64, x_max: f64, color: Color) -> Self {
624        Self {
625            kind: FillRegionKind::VerticalBand { x_min, x_max },
626            color,
627            x_axis: AxisId::X_PRIMARY,
628            y_axis: AxisId::Y_PRIMARY,
629        }
630    }
631
632    /// Create a fill below a series.
633    pub fn below_series(series_index: usize, y_baseline: f64, color: Color) -> Self {
634        Self {
635            kind: FillRegionKind::BelowSeries {
636                series_index,
637                y_baseline,
638            },
639            color,
640            x_axis: AxisId::X_PRIMARY,
641            y_axis: AxisId::Y_PRIMARY,
642        }
643    }
644
645    /// Create a fill between two series.
646    pub fn between_series(series_index_1: usize, series_index_2: usize, color: Color) -> Self {
647        Self {
648            kind: FillRegionKind::BetweenSeries {
649                series_index_1,
650                series_index_2,
651            },
652            color,
653            x_axis: AxisId::X_PRIMARY,
654            y_axis: AxisId::Y_PRIMARY,
655        }
656    }
657
658    /// Create a rectangular fill region.
659    pub fn rectangle(x_min: f64, y_min: f64, x_max: f64, y_max: f64, color: Color) -> Self {
660        Self {
661            kind: FillRegionKind::Rectangle {
662                x_min,
663                y_min,
664                x_max,
665                y_max,
666            },
667            color,
668            x_axis: AxisId::X_PRIMARY,
669            y_axis: AxisId::Y_PRIMARY,
670        }
671    }
672
673    /// Create a polygon fill.
674    pub fn polygon(points: Vec<DataPoint>, color: Color) -> Self {
675        Self {
676            kind: FillRegionKind::Polygon { points },
677            color,
678            x_axis: AxisId::X_PRIMARY,
679            y_axis: AxisId::Y_PRIMARY,
680        }
681    }
682
683    /// Set which axes this region uses.
684    pub fn with_axes(mut self, x_axis: AxisId, y_axis: AxisId) -> Self {
685        self.x_axis = x_axis;
686        self.y_axis = y_axis;
687        self
688    }
689}
690
691/// Chart title configuration.
692#[derive(Debug, Clone)]
693pub struct ChartTitle {
694    /// Title text
695    pub text: String,
696    /// Font size
697    pub font_size: f32,
698    /// Text color
699    pub color: Color,
700    /// Position (relative to chart, 0-1 range)
701    pub position: TitlePosition,
702}
703
704/// Position of a title.
705#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
706pub enum TitlePosition {
707    /// Centered at top
708    #[default]
709    Top,
710    /// Centered at bottom
711    Bottom,
712    /// Left side (rotated)
713    Left,
714    /// Right side (rotated)
715    Right,
716}
717
718impl ChartTitle {
719    /// Create a new title.
720    pub fn new(text: impl Into<String>) -> Self {
721        Self {
722            text: text.into(),
723            font_size: 16.0,
724            color: Color::WHITE,
725            position: TitlePosition::Top,
726        }
727    }
728
729    /// Set font size.
730    pub fn with_font_size(mut self, size: f32) -> Self {
731        self.font_size = size;
732        self
733    }
734
735    /// Set color.
736    pub fn with_color(mut self, color: Color) -> Self {
737        self.color = color;
738        self
739    }
740
741    /// Set position.
742    pub fn with_position(mut self, position: TitlePosition) -> Self {
743        self.position = position;
744        self
745    }
746}
747
748/// Interactive state for a chart.
749#[derive(Debug, Clone)]
750pub struct InteractiveState {
751    /// Pan offset in data coordinates
752    pub pan_offset: Vec2,
753    /// Zoom level (1.0 = default)
754    pub zoom: Vec2,
755    /// Whether panning is enabled
756    pub pan_enabled: bool,
757    /// Whether zooming is enabled
758    pub zoom_enabled: bool,
759    /// Minimum zoom level
760    pub zoom_min: f32,
761    /// Maximum zoom level
762    pub zoom_max: f32,
763    /// Currently hovered data point (series_index, point_index)
764    pub hovered_point: Option<(usize, usize)>,
765    /// Selected data points
766    pub selected_points: Vec<(usize, usize)>,
767    /// Whether the chart is being dragged
768    pub is_dragging: bool,
769    /// Last mouse position during drag
770    pub drag_start: Option<Vec2>,
771}
772
773impl Default for InteractiveState {
774    fn default() -> Self {
775        Self {
776            pan_offset: Vec2::ZERO,
777            zoom: Vec2::ONE,
778            pan_enabled: true,
779            zoom_enabled: true,
780            zoom_min: 0.1,
781            zoom_max: 10.0,
782            hovered_point: None,
783            selected_points: Vec::new(),
784            is_dragging: false,
785            drag_start: None,
786        }
787    }
788}
789
790impl InteractiveState {
791    /// Reset to default view.
792    pub fn reset(&mut self) {
793        self.pan_offset = Vec2::ZERO;
794        self.zoom = Vec2::ONE;
795    }
796
797    /// Apply uniform zoom (centered on current view center).
798    pub fn zoom_by(&mut self, factor: f32) {
799        self.zoom =
800            (self.zoom * factor).clamp(Vec2::splat(self.zoom_min), Vec2::splat(self.zoom_max));
801    }
802
803    /// Apply independent X and Y zoom factors.
804    pub fn zoom_xy(&mut self, factor_x: f32, factor_y: f32) {
805        self.zoom.x = (self.zoom.x * factor_x).clamp(self.zoom_min, self.zoom_max);
806        self.zoom.y = (self.zoom.y * factor_y).clamp(self.zoom_min, self.zoom_max);
807    }
808
809    /// Apply zoom only on X axis.
810    pub fn zoom_x(&mut self, factor: f32) {
811        self.zoom.x = (self.zoom.x * factor).clamp(self.zoom_min, self.zoom_max);
812    }
813
814    /// Apply zoom only on Y axis.
815    pub fn zoom_y(&mut self, factor: f32) {
816        self.zoom.y = (self.zoom.y * factor).clamp(self.zoom_min, self.zoom_max);
817    }
818
819    /// Apply zoom centered on a point in normalized coordinates (0-1 range within plot area).
820    ///
821    /// This adjusts pan to keep the specified point visually fixed during zoom.
822    /// `normalized_center` should be (0.5, 0.5) for center, (0, 0) for top-left, etc.
823    pub fn zoom_at_normalized(&mut self, normalized_center: Vec2, factor: f32) {
824        let old_zoom = self.zoom;
825        let new_zoom =
826            (self.zoom * factor).clamp(Vec2::splat(self.zoom_min), Vec2::splat(self.zoom_max));
827
828        if new_zoom == old_zoom {
829            return;
830        }
831
832        // The normalized center represents a position in the visible data range.
833        // When we zoom, we want that position to stay at the same screen location.
834        //
835        // Before zoom: data_pos = center + (normalized - 0.5) * range / old_zoom
836        // After zoom:  data_pos = new_center + (normalized - 0.5) * range / new_zoom
837        //
838        // For the same data_pos:
839        // new_center = center + (normalized - 0.5) * range * (1/old_zoom - 1/new_zoom)
840        //
841        // Since pan_offset IS the center offset in data coordinates, we adjust it:
842        let offset_from_center = normalized_center - Vec2::splat(0.5);
843        let zoom_diff = Vec2::new(
844            1.0 / old_zoom.x - 1.0 / new_zoom.x,
845            1.0 / old_zoom.y - 1.0 / new_zoom.y,
846        );
847
848        // We don't know the actual data range here, so we scale by a reasonable factor
849        // The pan_offset is in "data units", and the zoom change affects how much
850        // of the data range is visible. This is a simplified approximation.
851        self.pan_offset += offset_from_center * zoom_diff * 2.0;
852        self.zoom = new_zoom;
853    }
854
855    /// Apply zoom centered on a pixel position.
856    ///
857    /// DEPRECATED: Use `zoom_at_normalized` with proper coordinate conversion instead.
858    /// This function just applies uniform zoom without center adjustment.
859    pub fn zoom_at(&mut self, _center: Vec2, factor: f32) {
860        // For now, just do uniform zoom - the center adjustment was broken
861        self.zoom_by(factor);
862    }
863
864    /// Pan by a delta amount (in data coordinates).
865    pub fn pan(&mut self, delta: Vec2) {
866        if self.pan_enabled {
867            self.pan_offset += delta;
868        }
869    }
870}
871
872/// Complete chart data.
873#[derive(Debug, Clone)]
874pub struct Chart {
875    /// Chart type
876    pub chart_type: ChartType,
877    /// Data series
878    pub series: Vec<Series>,
879    /// All axes (indexed by AxisId)
880    pub axes: Vec<Axis>,
881    /// Main title
882    pub title: Option<ChartTitle>,
883    /// Subtitle
884    pub subtitle: Option<ChartTitle>,
885    /// Legend configuration
886    pub legend: Option<LegendConfig>,
887    /// Background color
888    pub background_color: Color,
889    /// Bar configuration (if bar chart)
890    pub bar_config: BarConfig,
891    /// Padding around the chart area
892    pub padding: f32,
893    /// Text annotations
894    pub text_annotations: Vec<TextAnnotation>,
895    /// Line annotations
896    pub line_annotations: Vec<LineAnnotation>,
897    /// Fill regions
898    pub fill_regions: Vec<FillRegion>,
899    /// Interactive state
900    pub interactive: InteractiveState,
901    /// Whether to show crosshair on hover
902    pub show_crosshair: bool,
903    /// Whether to show tooltips on hover
904    pub show_tooltips: bool,
905}
906
907impl Default for Chart {
908    fn default() -> Self {
909        Self {
910            chart_type: ChartType::Line,
911            series: Vec::new(),
912            axes: vec![Axis::x(), Axis::y()],
913            title: None,
914            subtitle: None,
915            legend: Some(LegendConfig::default()),
916            background_color: Color::rgba(0.12, 0.12, 0.14, 1.0),
917            bar_config: BarConfig::default(),
918            padding: 50.0,
919            text_annotations: Vec::new(),
920            line_annotations: Vec::new(),
921            fill_regions: Vec::new(),
922            interactive: InteractiveState::default(),
923            show_crosshair: false,
924            show_tooltips: true,
925        }
926    }
927}
928
929impl Chart {
930    // =========================================================================
931    // Streaming/Live Data API
932    // =========================================================================
933
934    /// Append data points to a series efficiently.
935    ///
936    /// This is more efficient than replacing all data when you're only adding
937    /// new points, as it allows caches to perform partial updates.
938    ///
939    /// # Example
940    ///
941    /// ```ignore
942    /// // Add new sensor readings
943    /// chart.append_data(0, &[DataPoint::new(10.0, 25.5), DataPoint::new(11.0, 26.0)]);
944    /// ```
945    pub fn append_data(&mut self, series_idx: usize, points: &[DataPoint]) {
946        if let Some(series) = self.series.get_mut(series_idx) {
947            series.data.extend_from_slice(points);
948        }
949    }
950
951    /// Push a single data point to a series with an optional sliding window.
952    ///
953    /// If `max_points` is Some, the oldest points will be removed to keep
954    /// the series at or below the specified size. This is useful for
955    /// real-time charts that show a fixed time window.
956    ///
957    /// # Example
958    ///
959    /// ```ignore
960    /// // Keep only the last 1000 points
961    /// chart.push_point(0, DataPoint::new(timestamp, value), Some(1000));
962    /// ```
963    pub fn push_point(&mut self, series_idx: usize, point: DataPoint, max_points: Option<usize>) {
964        if let Some(series) = self.series.get_mut(series_idx) {
965            series.data.push(point);
966
967            // Apply sliding window if specified
968            if let Some(max) = max_points
969                && series.data.len() > max
970            {
971                let excess = series.data.len() - max;
972                series.data.drain(..excess);
973            }
974        }
975    }
976
977    /// Replace all data in a series.
978    ///
979    /// Use this when you need to completely replace the data, not just append.
980    ///
981    /// # Example
982    ///
983    /// ```ignore
984    /// chart.set_data(0, new_data_points);
985    /// ```
986    pub fn set_data(&mut self, series_idx: usize, data: Vec<DataPoint>) {
987        if let Some(series) = self.series.get_mut(series_idx) {
988            series.data = data;
989        }
990    }
991
992    /// Clear all data from a series.
993    pub fn clear_data(&mut self, series_idx: usize) {
994        if let Some(series) = self.series.get_mut(series_idx) {
995            series.data.clear();
996        }
997    }
998
999    /// Get mutable access to a series for direct manipulation.
1000    pub fn series_mut(&mut self, series_idx: usize) -> Option<&mut Series> {
1001        self.series.get_mut(series_idx)
1002    }
1003
1004    /// Get the number of data points in a series.
1005    pub fn series_len(&self, series_idx: usize) -> usize {
1006        self.series
1007            .get(series_idx)
1008            .map(|s| s.data.len())
1009            .unwrap_or(0)
1010    }
1011
1012    /// Get the total number of data points across all series.
1013    pub fn total_points(&self) -> usize {
1014        self.series.iter().map(|s| s.data.len()).sum()
1015    }
1016
1017    // =========================================================================
1018    // Axis Management
1019    // =========================================================================
1020
1021    /// Get an axis by ID.
1022    pub fn get_axis(&self, id: AxisId) -> Option<&Axis> {
1023        self.axes.iter().find(|a| a.id == id)
1024    }
1025
1026    /// Get a mutable axis by ID.
1027    pub fn get_axis_mut(&mut self, id: AxisId) -> Option<&mut Axis> {
1028        self.axes.iter_mut().find(|a| a.id == id)
1029    }
1030
1031    /// Add or replace an axis.
1032    pub fn set_axis(&mut self, axis: Axis) {
1033        if let Some(existing) = self.axes.iter_mut().find(|a| a.id == axis.id) {
1034            *existing = axis;
1035        } else {
1036            self.axes.push(axis);
1037        }
1038    }
1039
1040    /// Get the X axis (primary).
1041    pub fn x_axis(&self) -> Option<&Axis> {
1042        self.get_axis(AxisId::X_PRIMARY)
1043    }
1044
1045    /// Get the Y axis (primary).
1046    pub fn y_axis(&self) -> Option<&Axis> {
1047        self.get_axis(AxisId::Y_PRIMARY)
1048    }
1049
1050    /// Get the combined bounds of all series for a specific axis.
1051    pub fn data_bounds_for_axis(&self, axis_id: AxisId) -> Option<(f64, f64)> {
1052        let mut min = f64::INFINITY;
1053        let mut max = f64::NEG_INFINITY;
1054        let mut has_data = false;
1055
1056        for series in &self.series {
1057            let is_x_axis = series.x_axis == axis_id;
1058            let is_y_axis = series.y_axis == axis_id;
1059
1060            if !is_x_axis && !is_y_axis {
1061                continue;
1062            }
1063
1064            if let Some((series_min, series_max)) = series.bounds() {
1065                has_data = true;
1066                if is_x_axis {
1067                    min = min.min(series_min.x);
1068                    max = max.max(series_max.x);
1069                } else {
1070                    min = min.min(series_min.y);
1071                    max = max.max(series_max.y);
1072                }
1073            }
1074        }
1075
1076        if has_data { Some((min, max)) } else { None }
1077    }
1078
1079    /// Get the combined bounds of all series.
1080    pub fn data_bounds(&self) -> Option<(DataPoint, DataPoint)> {
1081        let mut combined_min = DataPoint::new(f64::INFINITY, f64::INFINITY);
1082        let mut combined_max = DataPoint::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
1083        let mut has_data = false;
1084
1085        for series in &self.series {
1086            if let Some((min, max)) = series.bounds() {
1087                has_data = true;
1088                combined_min.x = combined_min.x.min(min.x);
1089                combined_min.y = combined_min.y.min(min.y);
1090                combined_max.x = combined_max.x.max(max.x);
1091                combined_max.y = combined_max.y.max(max.y);
1092            }
1093        }
1094
1095        if has_data {
1096            Some((combined_min, combined_max))
1097        } else {
1098            None
1099        }
1100    }
1101
1102    /// Get the effective range for an axis.
1103    pub fn axis_range(&self, axis_id: AxisId) -> (f64, f64) {
1104        let axis = self.get_axis(axis_id);
1105        let bounds = self.data_bounds_for_axis(axis_id);
1106
1107        let (data_min, data_max) = bounds.unwrap_or((0.0, 1.0));
1108
1109        let min = axis.and_then(|a| a.min).unwrap_or(data_min);
1110        let max = axis.and_then(|a| a.max).unwrap_or(data_max);
1111
1112        // Apply interactive zoom/pan
1113        let zoom = if axis.map(|a| a.orientation) == Some(AxisOrientation::Horizontal) {
1114            self.interactive.zoom.x
1115        } else {
1116            self.interactive.zoom.y
1117        };
1118
1119        let pan = if axis.map(|a| a.orientation) == Some(AxisOrientation::Horizontal) {
1120            self.interactive.pan_offset.x as f64
1121        } else {
1122            self.interactive.pan_offset.y as f64
1123        };
1124
1125        let range = max - min;
1126        let zoomed_range = range / zoom as f64;
1127        let center = (min + max) / 2.0 + pan;
1128
1129        (center - zoomed_range / 2.0, center + zoomed_range / 2.0)
1130    }
1131
1132    /// Get the effective X range (respecting axis min/max overrides).
1133    pub fn x_range(&self) -> (f64, f64) {
1134        self.axis_range(AxisId::X_PRIMARY)
1135    }
1136
1137    /// Get the effective Y range (respecting axis min/max overrides).
1138    pub fn y_range(&self) -> (f64, f64) {
1139        self.axis_range(AxisId::Y_PRIMARY)
1140    }
1141}