gpui_px/
line.rs

1//! Line chart - Plotly Express style API.
2
3use crate::error::ChartError;
4use crate::{
5    DEFAULT_COLOR, DEFAULT_HEIGHT, DEFAULT_PADDING_FRACTION, DEFAULT_TITLE_FONT_SIZE,
6    DEFAULT_WIDTH, TITLE_AREA_HEIGHT, ScaleType, extent_padded, validate_data_array,
7    validate_data_length, validate_dimensions, validate_positive,
8};
9use d3rs::color::D3Color;
10use d3rs::scale::{LinearScale, LogScale};
11use d3rs::shape::{CurveType, LineConfig, LinePoint, render_line};
12use d3rs::text::{VectorFontConfig, render_vector_text};
13use gpui::prelude::*;
14use gpui::*;
15
16/// Line chart builder.
17#[derive(Debug, Clone)]
18pub struct LineChart {
19    x: Vec<f64>,
20    y: Vec<f64>,
21    title: Option<String>,
22    color: u32,
23    stroke_width: f32,
24    opacity: f32,
25    curve: CurveType,
26    show_points: bool,
27    width: f32,
28    height: f32,
29    x_scale_type: ScaleType,
30    y_scale_type: ScaleType,
31}
32
33impl LineChart {
34    /// Set chart title (rendered at top of chart).
35    pub fn title(mut self, title: impl Into<String>) -> Self {
36        self.title = Some(title.into());
37        self
38    }
39
40    /// Set line color as 24-bit RGB hex value (format: 0xRRGGBB).
41    ///
42    /// # Example
43    /// ```rust,no_run
44    /// use gpui_px::line;
45    /// let chart = line(&[1.0], &[1.0])
46    ///     .color(0xff7f0e)  // Plotly orange
47    ///     .build();
48    /// ```
49    pub fn color(mut self, hex: u32) -> Self {
50        self.color = hex;
51        self
52    }
53
54    /// Set line stroke width in pixels.
55    pub fn stroke_width(mut self, width: f32) -> Self {
56        self.stroke_width = width;
57        self
58    }
59
60    /// Set line opacity (0.0 - 1.0).
61    pub fn opacity(mut self, opacity: f32) -> Self {
62        self.opacity = opacity.clamp(0.0, 1.0);
63        self
64    }
65
66    /// Set curve interpolation type.
67    pub fn curve(mut self, curve: CurveType) -> Self {
68        self.curve = curve;
69        self
70    }
71
72    /// Show data points on the line.
73    pub fn show_points(mut self, show: bool) -> Self {
74        self.show_points = show;
75        self
76    }
77
78    /// Set chart dimensions.
79    pub fn size(mut self, width: f32, height: f32) -> Self {
80        self.width = width;
81        self.height = height;
82        self
83    }
84
85    /// Set X-axis scale type (linear or log).
86    ///
87    /// # Example
88    /// ```rust,no_run
89    /// use gpui_px::{line, ScaleType};
90    /// let chart = line(&[10.0, 100.0, 1000.0], &[1.0, 2.0, 3.0])
91    ///     .x_scale(ScaleType::Log)
92    ///     .build();
93    /// ```
94    pub fn x_scale(mut self, scale: ScaleType) -> Self {
95        self.x_scale_type = scale;
96        self
97    }
98
99    /// Set Y-axis scale type (linear or log).
100    pub fn y_scale(mut self, scale: ScaleType) -> Self {
101        self.y_scale_type = scale;
102        self
103    }
104
105    /// Build and validate the chart, returning renderable element.
106    pub fn build(self) -> Result<impl IntoElement, ChartError> {
107        // Validate inputs
108        validate_data_array(&self.x, "x")?;
109        validate_data_array(&self.y, "y")?;
110        validate_data_length(self.x.len(), self.y.len(), "x", "y")?;
111        validate_dimensions(self.width, self.height)?;
112
113        // Validate positive values for log scales
114        if self.x_scale_type == ScaleType::Log {
115            validate_positive(&self.x, "x")?;
116        }
117        if self.y_scale_type == ScaleType::Log {
118            validate_positive(&self.y, "y")?;
119        }
120
121        // Calculate plot area (reserve space for title if present)
122        let title_height = if self.title.is_some() {
123            TITLE_AREA_HEIGHT
124        } else {
125            0.0
126        };
127        let plot_height = self.height - title_height;
128
129        // Calculate domains with padding
130        let (x_min, x_max) = extent_padded(&self.x, DEFAULT_PADDING_FRACTION);
131        let (y_min, y_max) = extent_padded(&self.y, DEFAULT_PADDING_FRACTION);
132
133        // Create data points
134        let data: Vec<LinePoint> = self
135            .x
136            .iter()
137            .zip(self.y.iter())
138            .map(|(&x, &y)| LinePoint::new(x, y))
139            .collect();
140
141        // Create config
142        let config = LineConfig::new()
143            .stroke_color(D3Color::from_hex(self.color))
144            .stroke_width(self.stroke_width)
145            .opacity(self.opacity)
146            .curve(self.curve)
147            .show_points(self.show_points);
148
149        // Build the element based on scale types
150        let line_element: AnyElement = match (self.x_scale_type, self.y_scale_type) {
151            (ScaleType::Linear, ScaleType::Linear) => {
152                let x_scale = LinearScale::new()
153                    .domain(x_min, x_max)
154                    .range(0.0, self.width as f64);
155                let y_scale = LinearScale::new()
156                    .domain(y_min, y_max)
157                    .range(plot_height as f64, 0.0);
158                render_line(&x_scale, &y_scale, &data, &config).into_any_element()
159            }
160            (ScaleType::Log, ScaleType::Linear) => {
161                let x_scale = LogScale::new()
162                    .domain(x_min.max(1e-10), x_max)
163                    .range(0.0, self.width as f64);
164                let y_scale = LinearScale::new()
165                    .domain(y_min, y_max)
166                    .range(plot_height as f64, 0.0);
167                render_line(&x_scale, &y_scale, &data, &config).into_any_element()
168            }
169            (ScaleType::Linear, ScaleType::Log) => {
170                let x_scale = LinearScale::new()
171                    .domain(x_min, x_max)
172                    .range(0.0, self.width as f64);
173                let y_scale = LogScale::new()
174                    .domain(y_min.max(1e-10), y_max)
175                    .range(plot_height as f64, 0.0);
176                render_line(&x_scale, &y_scale, &data, &config).into_any_element()
177            }
178            (ScaleType::Log, ScaleType::Log) => {
179                let x_scale = LogScale::new()
180                    .domain(x_min.max(1e-10), x_max)
181                    .range(0.0, self.width as f64);
182                let y_scale = LogScale::new()
183                    .domain(y_min.max(1e-10), y_max)
184                    .range(plot_height as f64, 0.0);
185                render_line(&x_scale, &y_scale, &data, &config).into_any_element()
186            }
187        };
188
189        // Build container with optional title
190        let mut container = div()
191            .w(px(self.width))
192            .h(px(self.height))
193            .relative()
194            .flex()
195            .flex_col();
196
197        // Add title if present
198        if let Some(title) = &self.title {
199            let font_config =
200                VectorFontConfig::horizontal(DEFAULT_TITLE_FONT_SIZE, hsla(0.0, 0.0, 0.2, 1.0));
201            container = container.child(
202                div()
203                    .w_full()
204                    .h(px(title_height))
205                    .flex()
206                    .justify_center()
207                    .items_center()
208                    .child(render_vector_text(title, &font_config)),
209            );
210        }
211
212        // Add plot area
213        container = container.child(
214            div()
215                .w(px(self.width))
216                .h(px(plot_height))
217                .relative()
218                .child(line_element),
219        );
220
221        Ok(container)
222    }
223}
224
225/// Create a line chart from x and y data.
226///
227/// # Example
228///
229/// ```rust,no_run
230/// use gpui_px::{line, CurveType};
231///
232/// let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
233/// let y = vec![2.0, 4.0, 3.0, 5.0, 4.5];
234///
235/// let chart = line(&x, &y)
236///     .title("My Line Chart")
237///     .color(0xff7f0e)
238///     .curve(CurveType::Linear)
239///     .show_points(true)
240///     .build()?;
241/// # Ok::<(), gpui_px::ChartError>(())
242/// ```
243pub fn line(x: &[f64], y: &[f64]) -> LineChart {
244    LineChart {
245        x: x.to_vec(),
246        y: y.to_vec(),
247        title: None,
248        color: DEFAULT_COLOR,
249        stroke_width: 2.0,
250        opacity: 1.0,
251        curve: CurveType::Linear,
252        show_points: false,
253        width: DEFAULT_WIDTH,
254        height: DEFAULT_HEIGHT,
255        x_scale_type: ScaleType::Linear,
256        y_scale_type: ScaleType::Linear,
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn test_line_empty_x_data() {
266        let result = line(&[], &[1.0, 2.0, 3.0]).build();
267        assert!(matches!(result, Err(ChartError::EmptyData { field: "x" })));
268    }
269
270    #[test]
271    fn test_line_empty_y_data() {
272        let result = line(&[1.0, 2.0, 3.0], &[]).build();
273        assert!(matches!(result, Err(ChartError::EmptyData { field: "y" })));
274    }
275
276    #[test]
277    fn test_line_data_length_mismatch() {
278        let result = line(&[1.0, 2.0, 3.0, 4.0], &[1.0, 2.0]).build();
279        assert!(matches!(
280            result,
281            Err(ChartError::DataLengthMismatch {
282                x_field: "x",
283                y_field: "y",
284                x_len: 4,
285                y_len: 2,
286            })
287        ));
288    }
289
290    #[test]
291    fn test_line_infinity_in_x() {
292        let result = line(&[1.0, 2.0, f64::NEG_INFINITY], &[1.0, 2.0, 3.0]).build();
293        assert!(matches!(
294            result,
295            Err(ChartError::InvalidData {
296                field: "x",
297                reason: "contains NaN or Infinity"
298            })
299        ));
300    }
301
302    #[test]
303    fn test_line_nan_in_y() {
304        let result = line(&[1.0, 2.0, 3.0], &[1.0, f64::NAN, 3.0]).build();
305        assert!(matches!(
306            result,
307            Err(ChartError::InvalidData {
308                field: "y",
309                reason: "contains NaN or Infinity"
310            })
311        ));
312    }
313
314    #[test]
315    fn test_line_successful_build() {
316        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
317        let y = vec![2.0, 4.0, 3.0, 5.0, 4.5];
318        let result = line(&x, &y).title("Test Line").color(0xff7f0e).build();
319        assert!(result.is_ok());
320    }
321
322    #[test]
323    fn test_line_builder_chain() {
324        let result = line(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0])
325            .title("My Line")
326            .color(0x00ff00)
327            .stroke_width(3.0)
328            .opacity(0.8)
329            .curve(CurveType::Linear)
330            .show_points(true)
331            .size(800.0, 600.0)
332            .build();
333        assert!(result.is_ok());
334    }
335
336    #[test]
337    fn test_line_log_x_scale() {
338        let x = vec![10.0, 100.0, 1000.0, 10000.0];
339        let y = vec![1.0, 2.0, 3.0, 4.0];
340        let result = line(&x, &y)
341            .x_scale(ScaleType::Log)
342            .build();
343        assert!(result.is_ok());
344    }
345
346    #[test]
347    fn test_line_log_y_scale() {
348        let x = vec![1.0, 2.0, 3.0, 4.0];
349        let y = vec![10.0, 100.0, 1000.0, 10000.0];
350        let result = line(&x, &y)
351            .y_scale(ScaleType::Log)
352            .build();
353        assert!(result.is_ok());
354    }
355
356    #[test]
357    fn test_line_log_xy_scale() {
358        let x = vec![10.0, 100.0, 1000.0];
359        let y = vec![20.0, 200.0, 2000.0];
360        let result = line(&x, &y)
361            .x_scale(ScaleType::Log)
362            .y_scale(ScaleType::Log)
363            .build();
364        assert!(result.is_ok());
365    }
366
367    #[test]
368    fn test_line_log_x_negative_values() {
369        let x = vec![-10.0, -5.0, 5.0, 10.0];
370        let y = vec![1.0, 2.0, 3.0, 4.0];
371        let result = line(&x, &y)
372            .x_scale(ScaleType::Log)
373            .build();
374        assert!(matches!(
375            result,
376            Err(ChartError::InvalidData {
377                field: "x",
378                reason: "contains non-positive values for log scale"
379            })
380        ));
381    }
382
383    #[test]
384    fn test_line_log_y_zero_value() {
385        let x = vec![1.0, 2.0, 3.0, 4.0];
386        let y = vec![0.0, 1.0, 2.0, 3.0];
387        let result = line(&x, &y)
388            .y_scale(ScaleType::Log)
389            .build();
390        assert!(matches!(
391            result,
392            Err(ChartError::InvalidData {
393                field: "y",
394                reason: "contains non-positive values for log scale"
395            })
396        ));
397    }
398
399    #[test]
400    fn test_line_log_scale_with_curve() {
401        let x = vec![10.0, 100.0, 1000.0];
402        let y = vec![1.0, 2.0, 3.0];
403        let result = line(&x, &y)
404            .title("Log Scale Line")
405            .x_scale(ScaleType::Log)
406            .curve(CurveType::Linear)
407            .show_points(true)
408            .build();
409        assert!(result.is_ok());
410    }
411}