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::axis::{AxisConfig, DefaultAxisTheme, render_axis};
11use d3rs::contour::ContourGenerator;
12use d3rs::grid::{GridConfig, render_grid};
13use d3rs::scale::{LinearScale, LogScale};
14use d3rs::shape::{ContourConfig, render_contour_bands};
15use d3rs::text::{VectorFontConfig, render_vector_text};
16use gpui::prelude::*;
17use gpui::*;
18
19/// Contour chart builder (filled bands between thresholds).
20#[derive(Clone)]
21pub struct ContourChart {
22    z: Vec<f64>,
23    grid_width: usize,
24    grid_height: usize,
25    x_values: Option<Vec<f64>>,
26    y_values: Option<Vec<f64>>,
27    x_scale_type: ScaleType,
28    y_scale_type: ScaleType,
29    thresholds: Option<Vec<f64>>,
30    color_scale: ColorScale,
31    title: Option<String>,
32    opacity: f32,
33    width: f32,
34    height: f32,
35}
36
37impl std::fmt::Debug for ContourChart {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("ContourChart")
40            .field("grid_width", &self.grid_width)
41            .field("grid_height", &self.grid_height)
42            .field("x_scale_type", &self.x_scale_type)
43            .field("y_scale_type", &self.y_scale_type)
44            .field("thresholds", &self.thresholds)
45            .field("color_scale", &self.color_scale)
46            .field("title", &self.title)
47            .field("opacity", &self.opacity)
48            .field("width", &self.width)
49            .field("height", &self.height)
50            .finish()
51    }
52}
53
54impl ContourChart {
55    /// Set custom x axis values.
56    ///
57    /// Values must be strictly monotonically increasing.
58    /// Length must match grid_width.
59    pub fn x(mut self, values: &[f64]) -> Self {
60        self.x_values = Some(values.to_vec());
61        self
62    }
63
64    /// Set custom y axis values.
65    ///
66    /// Values must be strictly monotonically increasing.
67    /// Length must match grid_height.
68    pub fn y(mut self, values: &[f64]) -> Self {
69        self.y_values = Some(values.to_vec());
70        self
71    }
72
73    /// Set x-axis scale type.
74    pub fn x_scale(mut self, scale: ScaleType) -> Self {
75        self.x_scale_type = scale;
76        self
77    }
78
79    /// Set y-axis scale type.
80    pub fn y_scale(mut self, scale: ScaleType) -> Self {
81        self.y_scale_type = scale;
82        self
83    }
84
85    /// Set threshold values for contour bands.
86    ///
87    /// Bands are created between consecutive threshold values.
88    /// If not set, auto-generates 10 evenly spaced thresholds.
89    pub fn thresholds(mut self, thresholds: Vec<f64>) -> Self {
90        self.thresholds = Some(thresholds);
91        self
92    }
93
94    /// Set color scale.
95    pub fn color_scale(mut self, scale: ColorScale) -> Self {
96        self.color_scale = scale;
97        self
98    }
99
100    /// Set chart title (rendered at top of chart).
101    pub fn title(mut self, title: impl Into<String>) -> Self {
102        self.title = Some(title.into());
103        self
104    }
105
106    /// Set fill opacity (0.0 - 1.0).
107    pub fn opacity(mut self, opacity: f32) -> Self {
108        self.opacity = opacity.clamp(0.0, 1.0);
109        self
110    }
111
112    /// Set chart dimensions.
113    pub fn size(mut self, width: f32, height: f32) -> Self {
114        self.width = width;
115        self.height = height;
116        self
117    }
118
119    /// Build and validate the chart, returning renderable element.
120    pub fn build(self) -> Result<impl IntoElement, ChartError> {
121        // Validate inputs
122        validate_data_array(&self.z, "z")?;
123        validate_grid_dimensions(&self.z, self.grid_width, self.grid_height)?;
124        validate_dimensions(self.width, self.height)?;
125
126        // Generate or validate x values
127        let x_values = match self.x_values {
128            Some(ref v) => {
129                if v.len() != self.grid_width {
130                    return Err(ChartError::DataLengthMismatch {
131                        x_field: "x",
132                        y_field: "grid_width",
133                        x_len: v.len(),
134                        y_len: self.grid_width,
135                    });
136                }
137                validate_data_array(v, "x")?;
138                validate_monotonic(v, "x")?;
139                if self.x_scale_type == ScaleType::Log {
140                    validate_positive(v, "x")?;
141                }
142                v.clone()
143            }
144            None => (0..self.grid_width).map(|i| i as f64).collect(),
145        };
146
147        // Generate or validate y values
148        let y_values = match self.y_values {
149            Some(ref v) => {
150                if v.len() != self.grid_height {
151                    return Err(ChartError::DataLengthMismatch {
152                        x_field: "y",
153                        y_field: "grid_height",
154                        x_len: v.len(),
155                        y_len: self.grid_height,
156                    });
157                }
158                validate_data_array(v, "y")?;
159                validate_monotonic(v, "y")?;
160                if self.y_scale_type == ScaleType::Log {
161                    validate_positive(v, "y")?;
162                }
163                v.clone()
164            }
165            None => (0..self.grid_height).map(|i| i as f64).collect(),
166        };
167
168        // Calculate plot area (reserve space for title and axes)
169        let title_height = if self.title.is_some() {
170            TITLE_AREA_HEIGHT
171        } else {
172            0.0
173        };
174
175        // Reserve space for axes
176        let left_margin = 60.0_f64;
177        let bottom_margin = 40.0_f64;
178        let plot_width = (self.width as f64) - left_margin;
179        let plot_height = (self.height as f64) - title_height as f64 - bottom_margin;
180
181        let theme = DefaultAxisTheme;
182
183        // Calculate domains with padding
184        let (x_min, x_max) = extent_padded(&x_values, 0.0);
185        let (y_min, y_max) = extent_padded(&y_values, 0.0);
186
187        // Calculate z extent for auto-thresholds
188        let (z_min, z_max) = extent_padded(&self.z, 0.0);
189
190        // Generate thresholds if not provided
191        let thresholds = match self.thresholds {
192            Some(t) => t,
193            None => {
194                // Auto-generate 10 evenly spaced thresholds
195                let n = 10;
196                (0..=n)
197                    .map(|i| z_min + (z_max - z_min) * (i as f64) / (n as f64))
198                    .collect()
199            }
200        };
201
202        // Generate contour bands
203        let generator = ContourGenerator::new(self.grid_width, self.grid_height)
204            .x_values(x_values)
205            .y_values(y_values);
206        let bands = generator.contour_bands(&self.z, &thresholds);
207
208        // Build config with color scale
209        let color_fn = self.color_scale.to_fn();
210        let config = ContourConfig::new()
211            .fill(true)
212            .fill_opacity(self.opacity)
213            .stroke_width(0.5)
214            .stroke_opacity(0.3)
215            .color_scale(color_fn);
216
217        // Build the element based on scale types
218        let contour_element: AnyElement = match (self.x_scale_type, self.y_scale_type) {
219            (ScaleType::Linear, ScaleType::Linear) => {
220                let x_scale = LinearScale::new()
221                    .domain(x_min, x_max)
222                    .range(0.0, plot_width);
223                let y_scale = LinearScale::new()
224                    .domain(y_min, y_max)
225                    .range(plot_height, 0.0);
226
227                div()
228                    .flex()
229                    .child(render_axis(
230                        &y_scale,
231                        &AxisConfig::left(),
232                        plot_height as f32,
233                        &theme,
234                    ))
235                    .child(
236                        div()
237                            .flex()
238                            .flex_col()
239                            .child(
240                                div()
241                                    .w(px(plot_width as f32))
242                                    .h(px(plot_height as f32))
243                                    .relative()
244                                    .bg(rgb(0xf8f8f8))
245                                    .child(render_grid(
246                                        &x_scale,
247                                        &y_scale,
248                                        &GridConfig::default(),
249                                        plot_width as f32,
250                                        plot_height as f32,
251                                        &theme,
252                                    ))
253                                    .child(div().absolute().inset_0().child(render_contour_bands(
254                                        bands, &x_scale, &y_scale, &config,
255                                    ))),
256                            )
257                            .child(render_axis(
258                                &x_scale,
259                                &AxisConfig::bottom(),
260                                plot_width as f32,
261                                &theme,
262                            )),
263                    )
264                    .into_any_element()
265            }
266            (ScaleType::Log, ScaleType::Linear) => {
267                let x_scale = LogScale::new()
268                    .domain(x_min.max(1e-10), x_max)
269                    .range(0.0, plot_width);
270                let y_scale = LinearScale::new()
271                    .domain(y_min, y_max)
272                    .range(plot_height, 0.0);
273
274                div()
275                    .flex()
276                    .child(render_axis(
277                        &y_scale,
278                        &AxisConfig::left(),
279                        plot_height as f32,
280                        &theme,
281                    ))
282                    .child(
283                        div()
284                            .flex()
285                            .flex_col()
286                            .child(
287                                div()
288                                    .w(px(plot_width as f32))
289                                    .h(px(plot_height as f32))
290                                    .relative()
291                                    .bg(rgb(0xf8f8f8))
292                                    .child(render_grid(
293                                        &x_scale,
294                                        &y_scale,
295                                        &GridConfig::default(),
296                                        plot_width as f32,
297                                        plot_height as f32,
298                                        &theme,
299                                    ))
300                                    .child(div().absolute().inset_0().child(render_contour_bands(
301                                        bands, &x_scale, &y_scale, &config,
302                                    ))),
303                            )
304                            .child(render_axis(
305                                &x_scale,
306                                &AxisConfig::bottom(),
307                                plot_width as f32,
308                                &theme,
309                            )),
310                    )
311                    .into_any_element()
312            }
313            (ScaleType::Linear, ScaleType::Log) => {
314                let x_scale = LinearScale::new()
315                    .domain(x_min, x_max)
316                    .range(0.0, plot_width);
317                let y_scale = LogScale::new()
318                    .domain(y_min.max(1e-10), y_max)
319                    .range(plot_height, 0.0);
320
321                div()
322                    .flex()
323                    .child(render_axis(
324                        &y_scale,
325                        &AxisConfig::left(),
326                        plot_height as f32,
327                        &theme,
328                    ))
329                    .child(
330                        div()
331                            .flex()
332                            .flex_col()
333                            .child(
334                                div()
335                                    .w(px(plot_width as f32))
336                                    .h(px(plot_height as f32))
337                                    .relative()
338                                    .bg(rgb(0xf8f8f8))
339                                    .child(render_grid(
340                                        &x_scale,
341                                        &y_scale,
342                                        &GridConfig::default(),
343                                        plot_width as f32,
344                                        plot_height as f32,
345                                        &theme,
346                                    ))
347                                    .child(div().absolute().inset_0().child(render_contour_bands(
348                                        bands, &x_scale, &y_scale, &config,
349                                    ))),
350                            )
351                            .child(render_axis(
352                                &x_scale,
353                                &AxisConfig::bottom(),
354                                plot_width as f32,
355                                &theme,
356                            )),
357                    )
358                    .into_any_element()
359            }
360            (ScaleType::Log, ScaleType::Log) => {
361                let x_scale = LogScale::new()
362                    .domain(x_min.max(1e-10), x_max)
363                    .range(0.0, plot_width);
364                let y_scale = LogScale::new()
365                    .domain(y_min.max(1e-10), y_max)
366                    .range(plot_height, 0.0);
367
368                div()
369                    .flex()
370                    .child(render_axis(
371                        &y_scale,
372                        &AxisConfig::left(),
373                        plot_height as f32,
374                        &theme,
375                    ))
376                    .child(
377                        div()
378                            .flex()
379                            .flex_col()
380                            .child(
381                                div()
382                                    .w(px(plot_width as f32))
383                                    .h(px(plot_height as f32))
384                                    .relative()
385                                    .bg(rgb(0xf8f8f8))
386                                    .child(render_grid(
387                                        &x_scale,
388                                        &y_scale,
389                                        &GridConfig::default(),
390                                        plot_width as f32,
391                                        plot_height as f32,
392                                        &theme,
393                                    ))
394                                    .child(div().absolute().inset_0().child(render_contour_bands(
395                                        bands, &x_scale, &y_scale, &config,
396                                    ))),
397                            )
398                            .child(render_axis(
399                                &x_scale,
400                                &AxisConfig::bottom(),
401                                plot_width as f32,
402                                &theme,
403                            )),
404                    )
405                    .into_any_element()
406            }
407        };
408
409        // Build container with optional title
410        let mut container = div()
411            .w(px(self.width))
412            .h(px(self.height))
413            .relative()
414            .flex()
415            .flex_col();
416
417        // Add title if present
418        if let Some(title) = &self.title {
419            let font_config =
420                VectorFontConfig::horizontal(DEFAULT_TITLE_FONT_SIZE, hsla(0.0, 0.0, 0.2, 1.0));
421            container = container.child(
422                div()
423                    .w_full()
424                    .h(px(title_height))
425                    .flex()
426                    .justify_center()
427                    .items_center()
428                    .child(render_vector_text(title, &font_config)),
429            );
430        }
431
432        // Add plot area with axes
433        container = container.child(contour_element);
434
435        Ok(container)
436    }
437}
438
439/// Create a contour chart (filled bands) from z data with grid dimensions.
440///
441/// Data is in row-major order: `z[row * width + col]` where row 0 is at the bottom.
442///
443/// # Example
444///
445/// ```rust,no_run
446/// use gpui_px::{contour, ColorScale};
447///
448/// // 3x3 grid
449/// let z = vec![
450///     1.0, 2.0, 3.0,  // row 0 (bottom)
451///     4.0, 5.0, 6.0,  // row 1
452///     7.0, 8.0, 9.0,  // row 2 (top)
453/// ];
454///
455/// let chart = contour(&z, 3, 3)
456///     .title("My Contour Plot")
457///     .thresholds(vec![0.0, 3.0, 6.0, 9.0])
458///     .color_scale(ColorScale::Viridis)
459///     .build()?;
460/// # Ok::<(), gpui_px::ChartError>(())
461/// ```
462pub fn contour(z: &[f64], grid_width: usize, grid_height: usize) -> ContourChart {
463    ContourChart {
464        z: z.to_vec(),
465        grid_width,
466        grid_height,
467        x_values: None,
468        y_values: None,
469        x_scale_type: ScaleType::Linear,
470        y_scale_type: ScaleType::Linear,
471        thresholds: None,
472        color_scale: ColorScale::default(),
473        title: None,
474        opacity: 0.8,
475        width: DEFAULT_WIDTH,
476        height: DEFAULT_HEIGHT,
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn test_contour_empty_z() {
486        let result = contour(&[], 0, 0).build();
487        assert!(matches!(result, Err(ChartError::EmptyData { field: "z" })));
488    }
489
490    #[test]
491    fn test_contour_grid_mismatch() {
492        let z = vec![1.0, 2.0, 3.0, 4.0, 5.0]; // 5 values
493        let result = contour(&z, 2, 3).build(); // expects 6
494        assert!(matches!(
495            result,
496            Err(ChartError::GridDimensionMismatch {
497                z_len: 5,
498                width: 2,
499                height: 3,
500                expected: 6,
501            })
502        ));
503    }
504
505    #[test]
506    fn test_contour_successful_build() {
507        let z = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; // 3x3 grid
508        let result = contour(&z, 3, 3)
509            .title("Test Contour")
510            .color_scale(ColorScale::Viridis)
511            .build();
512        assert!(result.is_ok());
513    }
514
515    #[test]
516    fn test_contour_with_custom_thresholds() {
517        let z = vec![1.0; 9]; // 3x3 grid
518        let result = contour(&z, 3, 3)
519            .thresholds(vec![0.0, 0.5, 1.0, 1.5])
520            .build();
521        assert!(result.is_ok());
522    }
523
524    #[test]
525    fn test_contour_with_custom_axes() {
526        let z = vec![1.0; 6]; // 2x3 grid
527        let x = vec![10.0, 100.0];
528        let y = vec![0.0, 1.0, 2.0];
529        let result = contour(&z, 2, 3).x(&x).y(&y).build();
530        assert!(result.is_ok());
531    }
532
533    #[test]
534    fn test_contour_log_scale() {
535        let z = vec![1.0; 4]; // 2x2 grid
536        let x = vec![10.0, 100.0];
537        let y = vec![1.0, 10.0];
538        let result = contour(&z, 2, 2)
539            .x(&x)
540            .y(&y)
541            .x_scale(ScaleType::Log)
542            .y_scale(ScaleType::Log)
543            .build();
544        assert!(result.is_ok());
545    }
546
547    #[test]
548    fn test_contour_builder_chain() {
549        let z = vec![1.0; 9]; // 3x3 grid
550        let result = contour(&z, 3, 3)
551            .title("My Contour")
552            .color_scale(ColorScale::Plasma)
553            .thresholds(vec![0.0, 0.5, 1.0])
554            .opacity(0.8)
555            .size(800.0, 600.0)
556            .build();
557        assert!(result.is_ok());
558    }
559}