gpui_px/
contour.rs

1//! Contour chart (filled bands) - Plotly Express style API.
2
3use crate::color_scale::ColorScale;
4use crate::error::ChartError;
5use crate::{
6    DEFAULT_HEIGHT, DEFAULT_TITLE_FONT_SIZE, DEFAULT_WIDTH, ScaleType, TITLE_AREA_HEIGHT,
7    extent_padded, validate_data_array, validate_dimensions, validate_grid_dimensions,
8    validate_monotonic, validate_positive,
9};
10use d3rs::contour::ContourGenerator;
11use d3rs::scale::{LinearScale, LogScale};
12use d3rs::shape::{ContourConfig, render_contour_bands};
13use d3rs::text::{VectorFontConfig, render_vector_text};
14use gpui::prelude::*;
15use gpui::*;
16
17/// Contour chart builder (filled bands between thresholds).
18#[derive(Clone)]
19pub struct ContourChart {
20    z: Vec<f64>,
21    grid_width: usize,
22    grid_height: usize,
23    x_values: Option<Vec<f64>>,
24    y_values: Option<Vec<f64>>,
25    x_scale_type: ScaleType,
26    y_scale_type: ScaleType,
27    thresholds: Option<Vec<f64>>,
28    color_scale: ColorScale,
29    title: Option<String>,
30    opacity: f32,
31    width: f32,
32    height: f32,
33}
34
35impl std::fmt::Debug for ContourChart {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("ContourChart")
38            .field("grid_width", &self.grid_width)
39            .field("grid_height", &self.grid_height)
40            .field("x_scale_type", &self.x_scale_type)
41            .field("y_scale_type", &self.y_scale_type)
42            .field("thresholds", &self.thresholds)
43            .field("color_scale", &self.color_scale)
44            .field("title", &self.title)
45            .field("opacity", &self.opacity)
46            .field("width", &self.width)
47            .field("height", &self.height)
48            .finish()
49    }
50}
51
52impl ContourChart {
53    /// Set custom x axis values.
54    ///
55    /// Values must be strictly monotonically increasing.
56    /// Length must match grid_width.
57    pub fn x(mut self, values: &[f64]) -> Self {
58        self.x_values = Some(values.to_vec());
59        self
60    }
61
62    /// Set custom y axis values.
63    ///
64    /// Values must be strictly monotonically increasing.
65    /// Length must match grid_height.
66    pub fn y(mut self, values: &[f64]) -> Self {
67        self.y_values = Some(values.to_vec());
68        self
69    }
70
71    /// Set x-axis scale type.
72    pub fn x_scale(mut self, scale: ScaleType) -> Self {
73        self.x_scale_type = scale;
74        self
75    }
76
77    /// Set y-axis scale type.
78    pub fn y_scale(mut self, scale: ScaleType) -> Self {
79        self.y_scale_type = scale;
80        self
81    }
82
83    /// Set threshold values for contour bands.
84    ///
85    /// Bands are created between consecutive threshold values.
86    /// If not set, auto-generates 10 evenly spaced thresholds.
87    pub fn thresholds(mut self, thresholds: Vec<f64>) -> Self {
88        self.thresholds = Some(thresholds);
89        self
90    }
91
92    /// Set color scale.
93    pub fn color_scale(mut self, scale: ColorScale) -> Self {
94        self.color_scale = scale;
95        self
96    }
97
98    /// Set chart title (rendered at top of chart).
99    pub fn title(mut self, title: impl Into<String>) -> Self {
100        self.title = Some(title.into());
101        self
102    }
103
104    /// Set fill opacity (0.0 - 1.0).
105    pub fn opacity(mut self, opacity: f32) -> Self {
106        self.opacity = opacity.clamp(0.0, 1.0);
107        self
108    }
109
110    /// Set chart dimensions.
111    pub fn size(mut self, width: f32, height: f32) -> Self {
112        self.width = width;
113        self.height = height;
114        self
115    }
116
117    /// Build and validate the chart, returning renderable element.
118    pub fn build(self) -> Result<impl IntoElement, ChartError> {
119        // Validate inputs
120        validate_data_array(&self.z, "z")?;
121        validate_grid_dimensions(&self.z, self.grid_width, self.grid_height)?;
122        validate_dimensions(self.width, self.height)?;
123
124        // Generate or validate x values
125        let x_values = match self.x_values {
126            Some(ref v) => {
127                if v.len() != self.grid_width {
128                    return Err(ChartError::DataLengthMismatch {
129                        x_field: "x",
130                        y_field: "grid_width",
131                        x_len: v.len(),
132                        y_len: self.grid_width,
133                    });
134                }
135                validate_data_array(v, "x")?;
136                validate_monotonic(v, "x")?;
137                if self.x_scale_type == ScaleType::Log {
138                    validate_positive(v, "x")?;
139                }
140                v.clone()
141            }
142            None => (0..self.grid_width).map(|i| i as f64).collect(),
143        };
144
145        // Generate or validate y values
146        let y_values = match self.y_values {
147            Some(ref v) => {
148                if v.len() != self.grid_height {
149                    return Err(ChartError::DataLengthMismatch {
150                        x_field: "y",
151                        y_field: "grid_height",
152                        x_len: v.len(),
153                        y_len: self.grid_height,
154                    });
155                }
156                validate_data_array(v, "y")?;
157                validate_monotonic(v, "y")?;
158                if self.y_scale_type == ScaleType::Log {
159                    validate_positive(v, "y")?;
160                }
161                v.clone()
162            }
163            None => (0..self.grid_height).map(|i| i as f64).collect(),
164        };
165
166        // Calculate plot area (reserve space for title if present)
167        let title_height = if self.title.is_some() {
168            TITLE_AREA_HEIGHT
169        } else {
170            0.0
171        };
172        let plot_height = self.height - title_height;
173
174        // Calculate domains with padding
175        let (x_min, x_max) = extent_padded(&x_values, 0.0);
176        let (y_min, y_max) = extent_padded(&y_values, 0.0);
177
178        // Calculate z extent for auto-thresholds
179        let (z_min, z_max) = extent_padded(&self.z, 0.0);
180
181        // Generate thresholds if not provided
182        let thresholds = match self.thresholds {
183            Some(t) => t,
184            None => {
185                // Auto-generate 10 evenly spaced thresholds
186                let n = 10;
187                (0..=n)
188                    .map(|i| z_min + (z_max - z_min) * (i as f64) / (n as f64))
189                    .collect()
190            }
191        };
192
193        // Generate contour bands
194        let generator = ContourGenerator::new(self.grid_width, self.grid_height)
195            .x_values(x_values)
196            .y_values(y_values);
197        let bands = generator.contour_bands(&self.z, &thresholds);
198
199        // Build config with color scale
200        let color_fn = self.color_scale.to_fn();
201        let config = ContourConfig::new()
202            .fill(true)
203            .fill_opacity(self.opacity)
204            .stroke_width(0.5)
205            .stroke_opacity(0.3)
206            .color_scale(color_fn);
207
208        // Build the element based on scale types
209        let contour_element: AnyElement = match (self.x_scale_type, self.y_scale_type) {
210            (ScaleType::Linear, ScaleType::Linear) => {
211                let x_scale = LinearScale::new()
212                    .domain(x_min, x_max)
213                    .range(0.0, self.width as f64);
214                let y_scale = LinearScale::new()
215                    .domain(y_min, y_max)
216                    .range(plot_height as f64, 0.0);
217                render_contour_bands(bands, &x_scale, &y_scale, &config).into_any_element()
218            }
219            (ScaleType::Log, ScaleType::Linear) => {
220                let x_scale = LogScale::new()
221                    .domain(x_min.max(1e-10), x_max)
222                    .range(0.0, self.width as f64);
223                let y_scale = LinearScale::new()
224                    .domain(y_min, y_max)
225                    .range(plot_height as f64, 0.0);
226                render_contour_bands(bands, &x_scale, &y_scale, &config).into_any_element()
227            }
228            (ScaleType::Linear, ScaleType::Log) => {
229                let x_scale = LinearScale::new()
230                    .domain(x_min, x_max)
231                    .range(0.0, self.width as f64);
232                let y_scale = LogScale::new()
233                    .domain(y_min.max(1e-10), y_max)
234                    .range(plot_height as f64, 0.0);
235                render_contour_bands(bands, &x_scale, &y_scale, &config).into_any_element()
236            }
237            (ScaleType::Log, ScaleType::Log) => {
238                let x_scale = LogScale::new()
239                    .domain(x_min.max(1e-10), x_max)
240                    .range(0.0, self.width as f64);
241                let y_scale = LogScale::new()
242                    .domain(y_min.max(1e-10), y_max)
243                    .range(plot_height as f64, 0.0);
244                render_contour_bands(bands, &x_scale, &y_scale, &config).into_any_element()
245            }
246        };
247
248        // Build container with optional title
249        let mut container = div()
250            .w(px(self.width))
251            .h(px(self.height))
252            .relative()
253            .flex()
254            .flex_col();
255
256        // Add title if present
257        if let Some(title) = &self.title {
258            let font_config =
259                VectorFontConfig::horizontal(DEFAULT_TITLE_FONT_SIZE, hsla(0.0, 0.0, 0.2, 1.0));
260            container = container.child(
261                div()
262                    .w_full()
263                    .h(px(title_height))
264                    .flex()
265                    .justify_center()
266                    .items_center()
267                    .child(render_vector_text(title, &font_config)),
268            );
269        }
270
271        // Add plot area
272        container = container.child(
273            div()
274                .w(px(self.width))
275                .h(px(plot_height))
276                .relative()
277                .child(contour_element),
278        );
279
280        Ok(container)
281    }
282}
283
284/// Create a contour chart (filled bands) from z data with grid dimensions.
285///
286/// Data is in row-major order: `z[row * width + col]` where row 0 is at the bottom.
287///
288/// # Example
289///
290/// ```rust,no_run
291/// use gpui_px::{contour, ColorScale};
292///
293/// // 3x3 grid
294/// let z = vec![
295///     1.0, 2.0, 3.0,  // row 0 (bottom)
296///     4.0, 5.0, 6.0,  // row 1
297///     7.0, 8.0, 9.0,  // row 2 (top)
298/// ];
299///
300/// let chart = contour(&z, 3, 3)
301///     .title("My Contour Plot")
302///     .thresholds(vec![0.0, 3.0, 6.0, 9.0])
303///     .color_scale(ColorScale::Viridis)
304///     .build()?;
305/// # Ok::<(), gpui_px::ChartError>(())
306/// ```
307pub fn contour(z: &[f64], grid_width: usize, grid_height: usize) -> ContourChart {
308    ContourChart {
309        z: z.to_vec(),
310        grid_width,
311        grid_height,
312        x_values: None,
313        y_values: None,
314        x_scale_type: ScaleType::Linear,
315        y_scale_type: ScaleType::Linear,
316        thresholds: None,
317        color_scale: ColorScale::default(),
318        title: None,
319        opacity: 0.8,
320        width: DEFAULT_WIDTH,
321        height: DEFAULT_HEIGHT,
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_contour_empty_z() {
331        let result = contour(&[], 0, 0).build();
332        assert!(matches!(result, Err(ChartError::EmptyData { field: "z" })));
333    }
334
335    #[test]
336    fn test_contour_grid_mismatch() {
337        let z = vec![1.0, 2.0, 3.0, 4.0, 5.0]; // 5 values
338        let result = contour(&z, 2, 3).build(); // expects 6
339        assert!(matches!(
340            result,
341            Err(ChartError::GridDimensionMismatch {
342                z_len: 5,
343                width: 2,
344                height: 3,
345                expected: 6,
346            })
347        ));
348    }
349
350    #[test]
351    fn test_contour_successful_build() {
352        let z = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; // 3x3 grid
353        let result = contour(&z, 3, 3)
354            .title("Test Contour")
355            .color_scale(ColorScale::Viridis)
356            .build();
357        assert!(result.is_ok());
358    }
359
360    #[test]
361    fn test_contour_with_custom_thresholds() {
362        let z = vec![1.0; 9]; // 3x3 grid
363        let result = contour(&z, 3, 3)
364            .thresholds(vec![0.0, 0.5, 1.0, 1.5])
365            .build();
366        assert!(result.is_ok());
367    }
368
369    #[test]
370    fn test_contour_with_custom_axes() {
371        let z = vec![1.0; 6]; // 2x3 grid
372        let x = vec![10.0, 100.0];
373        let y = vec![0.0, 1.0, 2.0];
374        let result = contour(&z, 2, 3).x(&x).y(&y).build();
375        assert!(result.is_ok());
376    }
377
378    #[test]
379    fn test_contour_log_scale() {
380        let z = vec![1.0; 4]; // 2x2 grid
381        let x = vec![10.0, 100.0];
382        let y = vec![1.0, 10.0];
383        let result = contour(&z, 2, 2)
384            .x(&x)
385            .y(&y)
386            .x_scale(ScaleType::Log)
387            .y_scale(ScaleType::Log)
388            .build();
389        assert!(result.is_ok());
390    }
391
392    #[test]
393    fn test_contour_builder_chain() {
394        let z = vec![1.0; 9]; // 3x3 grid
395        let result = contour(&z, 3, 3)
396            .title("My Contour")
397            .color_scale(ColorScale::Plasma)
398            .thresholds(vec![0.0, 0.5, 1.0])
399            .opacity(0.8)
400            .size(800.0, 600.0)
401            .build();
402        assert!(result.is_ok());
403    }
404}