gpui_px/
isoline.rs

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