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, ScaleType, TITLE_AREA_HEIGHT, extent_padded, validate_data_array,
7    validate_data_length, validate_dimensions, validate_positive,
8};
9use d3rs::axis::{AxisConfig, DefaultAxisTheme, render_axis};
10use d3rs::color::D3Color;
11use d3rs::grid::{GridConfig, render_grid};
12use d3rs::scale::{LinearScale, LogScale};
13use d3rs::shape::{CurveType, LineConfig, LinePoint, render_line};
14use d3rs::text::{VectorFontConfig, render_vector_text};
15use gpui::prelude::*;
16use gpui::*;
17
18/// Line chart builder.
19#[derive(Debug, Clone)]
20pub struct LineChart {
21    x: Vec<f64>,
22    y: Vec<f64>,
23    title: Option<String>,
24    color: u32,
25    stroke_width: f32,
26    opacity: f32,
27    curve: CurveType,
28    show_points: bool,
29    width: f32,
30    height: f32,
31    x_scale_type: ScaleType,
32    y_scale_type: ScaleType,
33}
34
35impl LineChart {
36    /// Set chart title (rendered at top of chart).
37    pub fn title(mut self, title: impl Into<String>) -> Self {
38        self.title = Some(title.into());
39        self
40    }
41
42    /// Set line color as 24-bit RGB hex value (format: 0xRRGGBB).
43    ///
44    /// # Example
45    /// ```rust,no_run
46    /// use gpui_px::line;
47    /// let chart = line(&[1.0], &[1.0])
48    ///     .color(0xff7f0e)  // Plotly orange
49    ///     .build();
50    /// ```
51    pub fn color(mut self, hex: u32) -> Self {
52        self.color = hex;
53        self
54    }
55
56    /// Set line stroke width in pixels.
57    pub fn stroke_width(mut self, width: f32) -> Self {
58        self.stroke_width = width;
59        self
60    }
61
62    /// Set line opacity (0.0 - 1.0).
63    pub fn opacity(mut self, opacity: f32) -> Self {
64        self.opacity = opacity.clamp(0.0, 1.0);
65        self
66    }
67
68    /// Set curve interpolation type.
69    pub fn curve(mut self, curve: CurveType) -> Self {
70        self.curve = curve;
71        self
72    }
73
74    /// Show data points on the line.
75    pub fn show_points(mut self, show: bool) -> Self {
76        self.show_points = show;
77        self
78    }
79
80    /// Set chart dimensions.
81    pub fn size(mut self, width: f32, height: f32) -> Self {
82        self.width = width;
83        self.height = height;
84        self
85    }
86
87    /// Set X-axis scale type (linear or log).
88    ///
89    /// # Example
90    /// ```rust,no_run
91    /// use gpui_px::{line, ScaleType};
92    /// let chart = line(&[10.0, 100.0, 1000.0], &[1.0, 2.0, 3.0])
93    ///     .x_scale(ScaleType::Log)
94    ///     .build();
95    /// ```
96    pub fn x_scale(mut self, scale: ScaleType) -> Self {
97        self.x_scale_type = scale;
98        self
99    }
100
101    /// Set Y-axis scale type (linear or log).
102    pub fn y_scale(mut self, scale: ScaleType) -> Self {
103        self.y_scale_type = scale;
104        self
105    }
106
107    /// Build and validate the chart, returning renderable element.
108    pub fn build(self) -> Result<impl IntoElement, ChartError> {
109        // Validate inputs
110        validate_data_array(&self.x, "x")?;
111        validate_data_array(&self.y, "y")?;
112        validate_data_length(self.x.len(), self.y.len(), "x", "y")?;
113        validate_dimensions(self.width, self.height)?;
114
115        // Validate positive values for log scales
116        if self.x_scale_type == ScaleType::Log {
117            validate_positive(&self.x, "x")?;
118        }
119        if self.y_scale_type == ScaleType::Log {
120            validate_positive(&self.y, "y")?;
121        }
122
123        // Define margins
124        let margin_left = 50.0;
125        let margin_bottom = 30.0;
126        let margin_top = 10.0;
127        let margin_right = 20.0;
128
129        // Calculate plot area (reserve space for title if present)
130        let title_height = if self.title.is_some() {
131            TITLE_AREA_HEIGHT
132        } else {
133            0.0
134        };
135
136        let plot_width = (self.width as f64 - margin_left - margin_right).max(0.0);
137        let plot_height =
138            (self.height as f64 - title_height as f64 - margin_top - margin_bottom).max(0.0);
139
140        // Calculate domains with padding
141        let (x_min, x_max) = extent_padded(&self.x, DEFAULT_PADDING_FRACTION);
142        let (y_min, y_max) = extent_padded(&self.y, DEFAULT_PADDING_FRACTION);
143
144        // Create data points
145        let data: Vec<LinePoint> = self
146            .x
147            .iter()
148            .zip(self.y.iter())
149            .map(|(&x, &y)| LinePoint::new(x, y))
150            .collect();
151
152        // Create config
153        let config = LineConfig::new()
154            .stroke_color(D3Color::from_hex(self.color))
155            .stroke_width(self.stroke_width)
156            .opacity(self.opacity)
157            .curve(self.curve)
158            .show_points(self.show_points);
159
160        let theme = DefaultAxisTheme;
161
162        // Build the element based on scale types
163        let chart_content: AnyElement = match (self.x_scale_type, self.y_scale_type) {
164            (ScaleType::Linear, ScaleType::Linear) => {
165                let x_scale = LinearScale::new()
166                    .domain(x_min, x_max)
167                    .range(0.0, plot_width);
168                let y_scale = LinearScale::new()
169                    .domain(y_min, y_max)
170                    .range(plot_height, 0.0);
171
172                div()
173                    .flex()
174                    .child(render_axis(
175                        &y_scale,
176                        &AxisConfig::left(),
177                        plot_height as f32,
178                        &theme,
179                    ))
180                    .child(
181                        div()
182                            .flex()
183                            .flex_col()
184                            .child(
185                                div()
186                                    .w(px(plot_width as f32))
187                                    .h(px(plot_height as f32))
188                                    .relative()
189                                    .bg(rgb(0xf8f8f8))
190                                    .child(render_grid(
191                                        &x_scale,
192                                        &y_scale,
193                                        &GridConfig::default(),
194                                        plot_width as f32,
195                                        plot_height as f32,
196                                        &theme,
197                                    ))
198                                    .child(render_line(&x_scale, &y_scale, &data, &config)),
199                            )
200                            .child(render_axis(
201                                &x_scale,
202                                &AxisConfig::bottom(),
203                                plot_width as f32,
204                                &theme,
205                            )),
206                    )
207                    .into_any_element()
208            }
209            (ScaleType::Log, ScaleType::Linear) => {
210                let x_scale = LogScale::new()
211                    .domain(x_min.max(1e-10), x_max)
212                    .range(0.0, plot_width);
213                let y_scale = LinearScale::new()
214                    .domain(y_min, y_max)
215                    .range(plot_height, 0.0);
216
217                // Use angled labels for log scale X axis (long frequency labels)
218                let x_axis_config = AxisConfig::bottom().with_label_angle(-45.0);
219
220                div()
221                    .flex()
222                    .child(render_axis(
223                        &y_scale,
224                        &AxisConfig::left(),
225                        plot_height as f32,
226                        &theme,
227                    ))
228                    .child(
229                        div()
230                            .flex()
231                            .flex_col()
232                            .child(
233                                div()
234                                    .w(px(plot_width as f32))
235                                    .h(px(plot_height as f32))
236                                    .relative()
237                                    .bg(rgb(0xf8f8f8))
238                                    .child(render_grid(
239                                        &x_scale,
240                                        &y_scale,
241                                        &GridConfig::default(),
242                                        plot_width as f32,
243                                        plot_height as f32,
244                                        &theme,
245                                    ))
246                                    .child(render_line(&x_scale, &y_scale, &data, &config)),
247                            )
248                            .child(render_axis(
249                                &x_scale,
250                                &x_axis_config,
251                                plot_width as f32,
252                                &theme,
253                            )),
254                    )
255                    .into_any_element()
256            }
257            (ScaleType::Linear, ScaleType::Log) => {
258                let x_scale = LinearScale::new()
259                    .domain(x_min, x_max)
260                    .range(0.0, plot_width);
261                let y_scale = LogScale::new()
262                    .domain(y_min.max(1e-10), y_max)
263                    .range(plot_height, 0.0);
264
265                div()
266                    .flex()
267                    .child(render_axis(
268                        &y_scale,
269                        &AxisConfig::left(),
270                        plot_height as f32,
271                        &theme,
272                    ))
273                    .child(
274                        div()
275                            .flex()
276                            .flex_col()
277                            .child(
278                                div()
279                                    .w(px(plot_width as f32))
280                                    .h(px(plot_height as f32))
281                                    .relative()
282                                    .bg(rgb(0xf8f8f8))
283                                    .child(render_grid(
284                                        &x_scale,
285                                        &y_scale,
286                                        &GridConfig::default(),
287                                        plot_width as f32,
288                                        plot_height as f32,
289                                        &theme,
290                                    ))
291                                    .child(render_line(&x_scale, &y_scale, &data, &config)),
292                            )
293                            .child(render_axis(
294                                &x_scale,
295                                &AxisConfig::bottom(),
296                                plot_width as f32,
297                                &theme,
298                            )),
299                    )
300                    .into_any_element()
301            }
302            (ScaleType::Log, ScaleType::Log) => {
303                let x_scale = LogScale::new()
304                    .domain(x_min.max(1e-10), x_max)
305                    .range(0.0, plot_width);
306                let y_scale = LogScale::new()
307                    .domain(y_min.max(1e-10), y_max)
308                    .range(plot_height, 0.0);
309
310                // Use angled labels for log scale X axis (long frequency labels)
311                let x_axis_config = AxisConfig::bottom().with_label_angle(-45.0);
312
313                div()
314                    .flex()
315                    .child(render_axis(
316                        &y_scale,
317                        &AxisConfig::left(),
318                        plot_height as f32,
319                        &theme,
320                    ))
321                    .child(
322                        div()
323                            .flex()
324                            .flex_col()
325                            .child(
326                                div()
327                                    .w(px(plot_width as f32))
328                                    .h(px(plot_height as f32))
329                                    .relative()
330                                    .bg(rgb(0xf8f8f8))
331                                    .child(render_grid(
332                                        &x_scale,
333                                        &y_scale,
334                                        &GridConfig::default(),
335                                        plot_width as f32,
336                                        plot_height as f32,
337                                        &theme,
338                                    ))
339                                    .child(render_line(&x_scale, &y_scale, &data, &config)),
340                            )
341                            .child(render_axis(
342                                &x_scale,
343                                &x_axis_config,
344                                plot_width as f32,
345                                &theme,
346                            )),
347                    )
348                    .into_any_element()
349            }
350        };
351
352        // Build container with optional title
353        let mut container = div()
354            .w(px(self.width))
355            .h(px(self.height))
356            .relative()
357            .flex()
358            .flex_col();
359
360        // Add title if present
361        if let Some(title) = &self.title {
362            let font_config =
363                VectorFontConfig::horizontal(DEFAULT_TITLE_FONT_SIZE, hsla(0.0, 0.0, 0.2, 1.0));
364            container = container.child(
365                div()
366                    .w_full()
367                    .h(px(title_height))
368                    .flex()
369                    .justify_center()
370                    .items_center()
371                    .child(render_vector_text(title, &font_config)),
372            );
373        }
374
375        // Add chart content
376        container = container.child(div().relative().child(chart_content));
377
378        Ok(container)
379    }
380}
381
382/// Create a line chart from x and y data.
383///
384/// # Example
385///
386/// ```rust,no_run
387/// use gpui_px::{line, CurveType};
388///
389/// let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
390/// let y = vec![2.0, 4.0, 3.0, 5.0, 4.5];
391///
392/// let chart = line(&x, &y)
393///     .title("My Line Chart")
394///     .color(0xff7f0e)
395///     .curve(CurveType::Linear)
396///     .show_points(true)
397///     .build()?;
398/// # Ok::<(), gpui_px::ChartError>(())
399/// ```
400pub fn line(x: &[f64], y: &[f64]) -> LineChart {
401    LineChart {
402        x: x.to_vec(),
403        y: y.to_vec(),
404        title: None,
405        color: DEFAULT_COLOR,
406        stroke_width: 2.0,
407        opacity: 1.0,
408        curve: CurveType::Linear,
409        show_points: false,
410        width: DEFAULT_WIDTH,
411        height: DEFAULT_HEIGHT,
412        x_scale_type: ScaleType::Linear,
413        y_scale_type: ScaleType::Linear,
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_line_empty_x_data() {
423        let result = line(&[], &[1.0, 2.0, 3.0]).build();
424        assert!(matches!(result, Err(ChartError::EmptyData { field: "x" })));
425    }
426
427    #[test]
428    fn test_line_empty_y_data() {
429        let result = line(&[1.0, 2.0, 3.0], &[]).build();
430        assert!(matches!(result, Err(ChartError::EmptyData { field: "y" })));
431    }
432
433    #[test]
434    fn test_line_data_length_mismatch() {
435        let result = line(&[1.0, 2.0, 3.0, 4.0], &[1.0, 2.0]).build();
436        assert!(matches!(
437            result,
438            Err(ChartError::DataLengthMismatch {
439                x_field: "x",
440                y_field: "y",
441                x_len: 4,
442                y_len: 2,
443            })
444        ));
445    }
446
447    #[test]
448    fn test_line_infinity_in_x() {
449        let result = line(&[1.0, 2.0, f64::NEG_INFINITY], &[1.0, 2.0, 3.0]).build();
450        assert!(matches!(
451            result,
452            Err(ChartError::InvalidData {
453                field: "x",
454                reason: "contains NaN or Infinity"
455            })
456        ));
457    }
458
459    #[test]
460    fn test_line_nan_in_y() {
461        let result = line(&[1.0, 2.0, 3.0], &[1.0, f64::NAN, 3.0]).build();
462        assert!(matches!(
463            result,
464            Err(ChartError::InvalidData {
465                field: "y",
466                reason: "contains NaN or Infinity"
467            })
468        ));
469    }
470
471    #[test]
472    fn test_line_successful_build() {
473        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
474        let y = vec![2.0, 4.0, 3.0, 5.0, 4.5];
475        let result = line(&x, &y).title("Test Line").color(0xff7f0e).build();
476        assert!(result.is_ok());
477    }
478
479    #[test]
480    fn test_line_builder_chain() {
481        let result = line(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0])
482            .title("My Line")
483            .color(0x00ff00)
484            .stroke_width(3.0)
485            .opacity(0.8)
486            .curve(CurveType::Linear)
487            .show_points(true)
488            .size(800.0, 600.0)
489            .build();
490        assert!(result.is_ok());
491    }
492
493    #[test]
494    fn test_line_log_x_scale() {
495        let x = vec![10.0, 100.0, 1000.0, 10000.0];
496        let y = vec![1.0, 2.0, 3.0, 4.0];
497        let result = line(&x, &y).x_scale(ScaleType::Log).build();
498        assert!(result.is_ok());
499    }
500
501    #[test]
502    fn test_line_log_y_scale() {
503        let x = vec![1.0, 2.0, 3.0, 4.0];
504        let y = vec![10.0, 100.0, 1000.0, 10000.0];
505        let result = line(&x, &y).y_scale(ScaleType::Log).build();
506        assert!(result.is_ok());
507    }
508
509    #[test]
510    fn test_line_log_xy_scale() {
511        let x = vec![10.0, 100.0, 1000.0];
512        let y = vec![20.0, 200.0, 2000.0];
513        let result = line(&x, &y)
514            .x_scale(ScaleType::Log)
515            .y_scale(ScaleType::Log)
516            .build();
517        assert!(result.is_ok());
518    }
519
520    #[test]
521    fn test_line_log_x_negative_values() {
522        let x = vec![-10.0, -5.0, 5.0, 10.0];
523        let y = vec![1.0, 2.0, 3.0, 4.0];
524        let result = line(&x, &y).x_scale(ScaleType::Log).build();
525        assert!(matches!(
526            result,
527            Err(ChartError::InvalidData {
528                field: "x",
529                reason: "contains non-positive values for log scale"
530            })
531        ));
532    }
533
534    #[test]
535    fn test_line_log_y_zero_value() {
536        let x = vec![1.0, 2.0, 3.0, 4.0];
537        let y = vec![0.0, 1.0, 2.0, 3.0];
538        let result = line(&x, &y).y_scale(ScaleType::Log).build();
539        assert!(matches!(
540            result,
541            Err(ChartError::InvalidData {
542                field: "y",
543                reason: "contains non-positive values for log scale"
544            })
545        ));
546    }
547
548    #[test]
549    fn test_line_log_scale_with_curve() {
550        let x = vec![10.0, 100.0, 1000.0];
551        let y = vec![1.0, 2.0, 3.0];
552        let result = line(&x, &y)
553            .title("Log Scale Line")
554            .x_scale(ScaleType::Log)
555            .curve(CurveType::Linear)
556            .show_points(true)
557            .build();
558        assert!(result.is_ok());
559    }
560}