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