gpui_px/
bar.rs

1//! Bar 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::{BarConfig, BarDatum, render_bars};
14use d3rs::text::{VectorFontConfig, render_vector_text};
15use gpui::prelude::*;
16use gpui::*;
17
18/// Bar chart builder.
19#[derive(Debug, Clone)]
20pub struct BarChart {
21    categories: Vec<String>,
22    values: Vec<f64>,
23    title: Option<String>,
24    color: u32,
25    opacity: f32,
26    bar_gap: f32,
27    border_radius: f32,
28    width: f32,
29    height: f32,
30    y_scale_type: ScaleType,
31}
32
33impl BarChart {
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 bar color as 24-bit RGB hex value (format: 0xRRGGBB).
41    ///
42    /// # Example
43    /// ```rust,no_run
44    /// use gpui_px::bar;
45    /// let chart = bar(&["A"], &[1.0])
46    ///     .color(0x2ca02c)  // Plotly green
47    ///     .build();
48    /// ```
49    pub fn color(mut self, hex: u32) -> Self {
50        self.color = hex;
51        self
52    }
53
54    /// Set bar opacity (0.0 - 1.0).
55    pub fn opacity(mut self, opacity: f32) -> Self {
56        self.opacity = opacity.clamp(0.0, 1.0);
57        self
58    }
59
60    /// Set gap between bars in pixels.
61    pub fn bar_gap(mut self, gap: f32) -> Self {
62        self.bar_gap = gap;
63        self
64    }
65
66    /// Set bar corner radius.
67    pub fn border_radius(mut self, radius: f32) -> Self {
68        self.border_radius = radius;
69        self
70    }
71
72    /// Set chart dimensions.
73    pub fn size(mut self, width: f32, height: f32) -> Self {
74        self.width = width;
75        self.height = height;
76        self
77    }
78
79    /// Set Y-axis scale type (linear or log).
80    ///
81    /// # Example
82    /// ```rust,no_run
83    /// use gpui_px::{bar, ScaleType};
84    /// let chart = bar(&["A", "B", "C"], &[10.0, 100.0, 1000.0])
85    ///     .y_scale(ScaleType::Log)
86    ///     .build();
87    /// ```
88    pub fn y_scale(mut self, scale: ScaleType) -> Self {
89        self.y_scale_type = scale;
90        self
91    }
92
93    /// Build and validate the chart, returning renderable element.
94    pub fn build(self) -> Result<impl IntoElement, ChartError> {
95        // Validate inputs
96        if self.categories.is_empty() {
97            return Err(ChartError::EmptyData {
98                field: "categories",
99            });
100        }
101        validate_data_array(&self.values, "values")?;
102        validate_data_length(
103            self.categories.len(),
104            self.values.len(),
105            "categories",
106            "values",
107        )?;
108        validate_dimensions(self.width, self.height)?;
109
110        // Validate positive values for log scale
111        if self.y_scale_type == ScaleType::Log {
112            validate_positive(&self.values, "values")?;
113        }
114
115        // Define margins
116        let margin_left = 50.0;
117        let margin_bottom = 30.0;
118        let margin_top = 10.0;
119        let margin_right = 20.0;
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
128        let plot_width = (self.width as f64 - margin_left - margin_right).max(0.0);
129        let plot_height =
130            (self.height as f64 - title_height as f64 - margin_top - margin_bottom).max(0.0);
131
132        // Calculate y domain with padding
133        let (mut y_min, mut y_max) = extent_padded(&self.values, DEFAULT_PADDING_FRACTION);
134
135        // For linear scale, always include zero baseline for bar charts
136        // For log scale, we can't include zero
137        if self.y_scale_type == ScaleType::Linear {
138            y_min = y_min.min(0.0);
139            y_max = y_max.max(0.0);
140        }
141
142        // Create X scale (always linear for categories)
143        let x_scale = LinearScale::new()
144            .domain(0.0, self.categories.len() as f64)
145            .range(0.0, plot_width);
146
147        // Create data
148        let data: Vec<BarDatum> = self
149            .categories
150            .iter()
151            .zip(self.values.iter())
152            .map(|(cat, &val)| BarDatum::new(cat.clone(), val))
153            .collect();
154
155        // Create config
156        let config = BarConfig::new()
157            .fill_color(D3Color::from_hex(self.color))
158            .opacity(self.opacity)
159            .bar_gap(self.bar_gap)
160            .border_radius(self.border_radius);
161
162        let theme = DefaultAxisTheme;
163
164        // Build the element based on Y scale type
165        let chart_content: AnyElement = match self.y_scale_type {
166            ScaleType::Linear => {
167                let y_scale = LinearScale::new()
168                    .domain(y_min, y_max)
169                    .range(plot_height, 0.0);
170
171                div()
172                    .flex()
173                    .child(render_axis(
174                        &y_scale,
175                        &AxisConfig::left(),
176                        plot_height as f32,
177                        &theme,
178                    ))
179                    .child(
180                        div()
181                            .flex()
182                            .flex_col()
183                            .child(
184                                div()
185                                    .w(px(plot_width as f32))
186                                    .h(px(plot_height as f32))
187                                    .relative()
188                                    .bg(rgb(0xf8f8f8))
189                                    .child(render_grid(
190                                        &x_scale,
191                                        &y_scale,
192                                        &GridConfig::default(),
193                                        plot_width as f32,
194                                        plot_height as f32,
195                                        &theme,
196                                    ))
197                                    .child(render_bars(
198                                        &x_scale,
199                                        &y_scale,
200                                        &data,
201                                        plot_width as f32,
202                                        plot_height as f32,
203                                        &config,
204                                    )),
205                            )
206                            .child(render_axis(
207                                &x_scale,
208                                &AxisConfig::bottom(),
209                                plot_width as f32,
210                                &theme,
211                            )),
212                    )
213                    .into_any_element()
214            }
215            ScaleType::Log => {
216                let y_scale = LogScale::new()
217                    .domain(y_min.max(1e-10), y_max)
218                    .range(plot_height, 0.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_bars(
247                                        &x_scale,
248                                        &y_scale,
249                                        &data,
250                                        plot_width as f32,
251                                        plot_height as f32,
252                                        &config,
253                                    )),
254                            )
255                            .child(render_axis(
256                                &x_scale,
257                                &AxisConfig::bottom(),
258                                plot_width as f32,
259                                &theme,
260                            )),
261                    )
262                    .into_any_element()
263            }
264        };
265
266        // Build container with optional title
267        let mut container = div()
268            .w(px(self.width))
269            .h(px(self.height))
270            .relative()
271            .flex()
272            .flex_col();
273
274        // Add title if present
275        if let Some(title) = &self.title {
276            let font_config =
277                VectorFontConfig::horizontal(DEFAULT_TITLE_FONT_SIZE, hsla(0.0, 0.0, 0.2, 1.0));
278            container = container.child(
279                div()
280                    .w_full()
281                    .h(px(title_height))
282                    .flex()
283                    .justify_center()
284                    .items_center()
285                    .child(render_vector_text(title, &font_config)),
286            );
287        }
288
289        // Add chart content
290        container = container.child(div().relative().child(chart_content));
291
292        Ok(container)
293    }
294}
295
296/// Create a bar chart from categories and values.
297///
298/// # Example
299///
300/// ```rust,no_run
301/// use gpui_px::bar;
302///
303/// let categories = vec!["A", "B", "C", "D"];
304/// let values = vec![10.0, 25.0, 15.0, 30.0];
305///
306/// let chart = bar(&categories, &values)
307///     .title("My Bar Chart")
308///     .color(0x2ca02c)
309///     .build()?;
310/// # Ok::<(), gpui_px::ChartError>(())
311/// ```
312pub fn bar<S: AsRef<str>>(categories: &[S], values: &[f64]) -> BarChart {
313    BarChart {
314        categories: categories.iter().map(|s| s.as_ref().to_string()).collect(),
315        values: values.to_vec(),
316        title: None,
317        color: DEFAULT_COLOR,
318        opacity: 0.8,
319        bar_gap: 2.0,
320        border_radius: 2.0,
321        width: DEFAULT_WIDTH,
322        height: DEFAULT_HEIGHT,
323        y_scale_type: ScaleType::Linear,
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_bar_empty_categories() {
333        let empty_categories: Vec<&str> = vec![];
334        let result = bar(&empty_categories, &[1.0, 2.0, 3.0]).build();
335        assert!(matches!(
336            result,
337            Err(ChartError::EmptyData {
338                field: "categories"
339            })
340        ));
341    }
342
343    #[test]
344    fn test_bar_empty_values() {
345        let result = bar(&["A", "B", "C"], &[]).build();
346        assert!(matches!(
347            result,
348            Err(ChartError::EmptyData { field: "values" })
349        ));
350    }
351
352    #[test]
353    fn test_bar_data_length_mismatch() {
354        let result = bar(&["A", "B"], &[1.0, 2.0, 3.0]).build();
355        assert!(matches!(
356            result,
357            Err(ChartError::DataLengthMismatch {
358                x_field: "categories",
359                y_field: "values",
360                x_len: 2,
361                y_len: 3,
362            })
363        ));
364    }
365
366    #[test]
367    fn test_bar_invalid_value_nan() {
368        let result = bar(&["A", "B", "C"], &[1.0, f64::NAN, 3.0]).build();
369        assert!(matches!(
370            result,
371            Err(ChartError::InvalidData {
372                field: "values",
373                reason: "contains NaN or Infinity"
374            })
375        ));
376    }
377
378    #[test]
379    fn test_bar_successful_build() {
380        let categories = vec!["A", "B", "C", "D"];
381        let values = vec![10.0, 25.0, 15.0, 30.0];
382        let result = bar(&categories, &values)
383            .title("Test Bar Chart")
384            .color(0x2ca02c)
385            .build();
386        assert!(result.is_ok());
387    }
388
389    #[test]
390    fn test_bar_negative_values() {
391        let categories = vec!["A", "B", "C"];
392        let values = vec![-5.0, 10.0, -3.0];
393        let result = bar(&categories, &values).build();
394        assert!(result.is_ok());
395    }
396
397    #[test]
398    fn test_bar_builder_chain() {
399        let result = bar(&["X", "Y", "Z"], &[1.0, 2.0, 3.0])
400            .title("My Bar Chart")
401            .color(0xff0000)
402            .opacity(0.9)
403            .bar_gap(5.0)
404            .border_radius(4.0)
405            .size(800.0, 600.0)
406            .build();
407        assert!(result.is_ok());
408    }
409
410    #[test]
411    fn test_bar_log_y_scale() {
412        let categories = vec!["A", "B", "C", "D"];
413        let values = vec![10.0, 100.0, 1000.0, 10000.0];
414        let result = bar(&categories, &values).y_scale(ScaleType::Log).build();
415        assert!(result.is_ok());
416    }
417
418    #[test]
419    fn test_bar_log_y_scale_zero_value() {
420        let categories = vec!["A", "B", "C"];
421        let values = vec![0.0, 10.0, 100.0];
422        let result = bar(&categories, &values).y_scale(ScaleType::Log).build();
423        assert!(matches!(
424            result,
425            Err(ChartError::InvalidData {
426                field: "values",
427                reason: "contains non-positive values for log scale"
428            })
429        ));
430    }
431
432    #[test]
433    fn test_bar_log_y_scale_negative_value() {
434        let categories = vec!["A", "B", "C"];
435        let values = vec![-5.0, 10.0, 100.0];
436        let result = bar(&categories, &values).y_scale(ScaleType::Log).build();
437        assert!(matches!(
438            result,
439            Err(ChartError::InvalidData {
440                field: "values",
441                reason: "contains non-positive values for log scale"
442            })
443        ));
444    }
445
446    #[test]
447    fn test_bar_log_scale_with_title() {
448        let categories = vec!["Low", "Medium", "High"];
449        let values = vec![10.0, 100.0, 1000.0];
450        let result = bar(&categories, &values)
451            .title("Log Scale Bar Chart")
452            .y_scale(ScaleType::Log)
453            .color(0x2ca02c)
454            .build();
455        assert!(result.is_ok());
456    }
457}