gpui_px/
lib.rs

1#![recursion_limit = "512"]
2
3//! # gpui-px - High-level charting API for GPUI
4//!
5//! Plotly Express-style API built on top of d3rs primitives.
6//!
7//! ## Chart Types
8//!
9//! ### Scatter Charts
10//! Use [`scatter()`] for:
11//! - Displaying individual data points with x,y coordinates
12//! - Exploring correlations between two continuous variables
13//! - Identifying outliers or clusters in data
14//! - Showing distributions in 2D space
15//!
16//! ### Line Charts
17//! Use [`line()`] for:
18//! - Time series or sequential data
19//! - Showing trends over continuous domains
20//! - Connecting related data points with smooth or linear interpolation
21//! - Comparing multiple series over the same range
22//!
23//! ### Bar Charts
24//! Use [`bar()`] for:
25//! - Categorical data with discrete categories
26//! - Comparing values across different groups
27//! - Displaying counts or aggregated metrics
28//! - Visualizing rankings or distributions by category
29//!
30//! ### Heatmaps
31//! Use [`heatmap()`] for:
32//! - Visualizing 2D scalar fields with color
33//! - Spectrograms, correlation matrices, geographic data
34//! - Supports log scale axes and multiple color scales
35//!
36//! ### Contour Charts (Filled)
37//! Use [`contour()`] for:
38//! - Filled bands between threshold values
39//! - Topographic-style visualizations
40//! - Density estimation results
41//!
42//! ### Isoline Charts (Unfilled)
43//! Use [`isoline()`] for:
44//! - Unfilled contour lines at specific levels
45//! - Elevation or pressure maps
46//! - Level curves of scalar fields
47//!
48//! ## Coordinate System
49//!
50//! All charts use standard mathematical coordinates:
51//! - **Y-axis**: 0 at bottom, increases upward
52//! - **X-axis**: 0 at left, increases rightward
53//!
54//! ## Color Format
55//!
56//! For 1D charts (scatter, line, bar, isoline), color parameters accept
57//! 24-bit RGB hex values in format `0xRRGGBB`:
58//! - `0x1f77b4` - Plotly blue (default)
59//! - `0xff7f0e` - Plotly orange
60//! - `0x2ca02c` - Plotly green
61//! - `0xd62728` - Plotly red
62//!
63//! For 2D charts (heatmap, contour), use [`ColorScale`]:
64//! - `ColorScale::Viridis` - perceptually uniform (default)
65//! - `ColorScale::Plasma` - perceptually uniform
66//! - `ColorScale::Inferno` - perceptually uniform
67//! - `ColorScale::Magma` - perceptually uniform
68//! - `ColorScale::Heat` - diverging (blue → white → red)
69//! - `ColorScale::Coolwarm` - diverging
70//! - `ColorScale::Greys` - sequential grayscale
71//! - `ColorScale::custom(|t| ...)` - custom function
72//!
73//! ## Logarithmic Scales
74//!
75//! All chart types support logarithmic axis scaling via the `ScaleType` enum:
76//!
77//! ### Scatter Charts
78//! - Both X and Y axes can be logarithmic independently
79//! - Use `.x_scale(ScaleType::Log)` and `.y_scale(ScaleType::Log)`
80//! - Ideal for power-law relationships and data spanning multiple orders of magnitude
81//!
82//! ### Line Charts
83//! - Both X and Y axes can be logarithmic independently
84//! - Perfect for frequency response plots (audio engineering)
85//! - Example: frequency axis from 20 Hz to 20 kHz
86//!
87//! ### Bar Charts
88//! - Only Y-axis (values) can be logarithmic
89//! - X-axis is categorical (always linear)
90//! - Use `.y_scale(ScaleType::Log)` for values spanning magnitudes
91//!
92//! ### Heatmaps, Contours, and Isolines
93//! - Both X and Y axes support logarithmic scaling
94//! - Use `.x_scale(ScaleType::Log)` and `.y_scale(ScaleType::Log)`
95//!
96//! **Important**: Logarithmic scales require all values to be positive.
97//! Zero or negative values will cause validation errors.
98//!
99//! ## Example
100//!
101//! ```rust,no_run
102//! use gpui_px::{scatter, line, bar, heatmap, contour, isoline, ColorScale, ScaleType};
103//!
104//! // Scatter plot in 3 lines
105//! let chart = scatter(&x_data, &y_data)
106//!     .title("My Chart")
107//!     .build()?;
108//!
109//! // Scatter plot with logarithmic scales
110//! let chart = scatter(&x_data, &y_data)
111//!     .x_scale(ScaleType::Log)
112//!     .y_scale(ScaleType::Log)
113//!     .build()?;
114//!
115//! // Line chart with custom color
116//! let chart = line(&x_data, &y_data)
117//!     .color(0x1f77b4)  // Plotly blue
118//!     .build()?;
119//!
120//! // Frequency response plot with log frequency axis
121//! let chart = line(&frequency, &magnitude_db)
122//!     .x_scale(ScaleType::Log)
123//!     .build()?;
124//!
125//! // Bar chart
126//! let chart = bar(&categories, &values)
127//!     .build()?;
128//!
129//! // Heatmap with log scale x-axis
130//! let z = vec![1.0; 12]; // 3x4 grid
131//! let chart = heatmap(&z, 3, 4)
132//!     .x(&[20.0, 200.0, 2000.0])
133//!     .x_scale(ScaleType::Log)
134//!     .color_scale(ColorScale::Inferno)
135//!     .build()?;
136//!
137//! // Contour plot with custom thresholds
138//! let chart = contour(&z, 3, 4)
139//!     .thresholds(vec![0.0, 0.5, 1.0, 1.5])
140//!     .color_scale(ColorScale::Viridis)
141//!     .build()?;
142//!
143//! // Isoline plot
144//! let chart = isoline(&z, 3, 4)
145//!     .levels(vec![0.5, 1.0, 1.5])
146//!     .color(0x333333)
147//!     .stroke_width(1.5)
148//!     .build()?;
149//! ```
150
151mod bar;
152mod color_scale;
153mod contour;
154mod error;
155mod heatmap;
156mod isoline;
157mod line;
158mod scatter;
159
160pub use bar::{BarChart, bar};
161pub use color_scale::ColorScale;
162pub use contour::{ContourChart, contour};
163pub use error::ChartError;
164pub use heatmap::{HeatmapChart, heatmap};
165pub use isoline::{IsolineChart, isoline};
166pub use line::{LineChart, line};
167pub use scatter::{ScatterChart, scatter};
168
169// Re-export d3rs types users might need
170pub use d3rs::color::D3Color;
171pub use d3rs::shape::CurveType;
172
173// ============================================================================
174// Scale Types
175// ============================================================================
176
177/// Scale type for axis transformations.
178#[derive(Debug, Clone, Copy, Default, PartialEq)]
179pub enum ScaleType {
180    /// Linear scale (default).
181    #[default]
182    Linear,
183    /// Logarithmic scale (base 10).
184    Log,
185}
186
187// ============================================================================
188// Shared Constants
189// ============================================================================
190
191/// Default chart color (Plotly blue)
192pub(crate) const DEFAULT_COLOR: u32 = 0x1f77b4;
193
194/// Default chart width in pixels
195pub(crate) const DEFAULT_WIDTH: f32 = 600.0;
196
197/// Default chart height in pixels
198pub(crate) const DEFAULT_HEIGHT: f32 = 400.0;
199
200/// Default padding fraction for auto-domain calculation
201pub(crate) const DEFAULT_PADDING_FRACTION: f64 = 0.05;
202
203/// Default title font size
204pub(crate) const DEFAULT_TITLE_FONT_SIZE: f32 = 16.0;
205
206/// Title area height (font size + padding)
207pub(crate) const TITLE_AREA_HEIGHT: f32 = 24.0;
208
209// ============================================================================
210// Shared Utilities
211// ============================================================================
212
213/// Calculate extent (min, max) with padding.
214///
215/// Returns `(min - padding, max + padding)` where padding is calculated
216/// as `range * padding_fraction`.
217///
218/// ## Special Case: Constant Values
219///
220/// When all values are identical (range ≈ 0), uses a **hardcoded padding of 1.0**
221/// to ensure a meaningful range for visualization. This prevents collapsed
222/// axes and ensures the constant value is visible in the chart.
223///
224/// For example, `[5.0, 5.0, 5.0]` returns `(4.0, 6.0)` instead of `(5.0, 5.0)`.
225pub(crate) fn extent_padded(values: &[f64], padding_fraction: f64) -> (f64, f64) {
226    let (min, max) = values
227        .iter()
228        .copied()
229        .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), val| {
230            (min.min(val), max.max(val))
231        });
232
233    let range = max - min;
234    let padding = if range.abs() < f64::EPSILON {
235        1.0 // Default padding for constant values
236    } else {
237        range * padding_fraction
238    };
239    (min - padding, max + padding)
240}
241
242/// Validate that a data array is not empty and contains only finite values.
243pub(crate) fn validate_data_array(values: &[f64], field: &'static str) -> Result<(), ChartError> {
244    if values.is_empty() {
245        return Err(ChartError::EmptyData { field });
246    }
247    if values.iter().any(|x| !x.is_finite()) {
248        return Err(ChartError::InvalidData {
249            field,
250            reason: "contains NaN or Infinity",
251        });
252    }
253    Ok(())
254}
255
256/// Validate that two arrays have the same length.
257pub(crate) fn validate_data_length(
258    x_len: usize,
259    y_len: usize,
260    x_field: &'static str,
261    y_field: &'static str,
262) -> Result<(), ChartError> {
263    if x_len != y_len {
264        return Err(ChartError::DataLengthMismatch {
265            x_field,
266            y_field,
267            x_len,
268            y_len,
269        });
270    }
271    Ok(())
272}
273
274/// Validate chart dimensions are positive.
275pub(crate) fn validate_dimensions(width: f32, height: f32) -> Result<(), ChartError> {
276    if width <= 0.0 {
277        return Err(ChartError::InvalidDimension {
278            field: "width",
279            value: width,
280        });
281    }
282    if height <= 0.0 {
283        return Err(ChartError::InvalidDimension {
284            field: "height",
285            value: height,
286        });
287    }
288    Ok(())
289}
290
291/// Validate that grid dimensions match the z array length.
292pub(crate) fn validate_grid_dimensions(
293    z: &[f64],
294    grid_width: usize,
295    grid_height: usize,
296) -> Result<(), ChartError> {
297    let expected = grid_width * grid_height;
298    if z.len() != expected {
299        return Err(ChartError::GridDimensionMismatch {
300            z_len: z.len(),
301            width: grid_width,
302            height: grid_height,
303            expected,
304        });
305    }
306    Ok(())
307}
308
309/// Validate that axis values are strictly monotonic (increasing).
310pub(crate) fn validate_monotonic(values: &[f64], field: &'static str) -> Result<(), ChartError> {
311    for window in values.windows(2) {
312        if window[1] <= window[0] {
313            return Err(ChartError::InvalidData {
314                field,
315                reason: "must be strictly monotonically increasing",
316            });
317        }
318    }
319    Ok(())
320}
321
322/// Validate that all values are positive (for log scale).
323pub(crate) fn validate_positive(values: &[f64], field: &'static str) -> Result<(), ChartError> {
324    if values.iter().any(|&v| v <= 0.0) {
325        return Err(ChartError::InvalidData {
326            field,
327            reason: "log scale requires positive values",
328        });
329    }
330    Ok(())
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    // extent_padded tests
338    #[test]
339    fn test_extent_padded_normal_values() {
340        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
341        let (min, max) = extent_padded(&values, 0.05);
342        // Min should be 1.0 - 0.05 * 4.0 = 0.8
343        // Max should be 5.0 + 0.05 * 4.0 = 5.2
344        assert!((min - 0.8).abs() < 1e-10);
345        assert!((max - 5.2).abs() < 1e-10);
346    }
347
348    #[test]
349    fn test_extent_padded_constant_values() {
350        let values = vec![5.0, 5.0, 5.0, 5.0];
351        let (min, max) = extent_padded(&values, 0.05);
352        // Range is 0, so padding should be 1.0
353        assert!((min - 4.0).abs() < 1e-10);
354        assert!((max - 6.0).abs() < 1e-10);
355    }
356
357    #[test]
358    fn test_extent_padded_single_value() {
359        let values = vec![3.0];
360        let (min, max) = extent_padded(&values, 0.1);
361        // Range is 0, so padding should be 1.0
362        assert!((min - 2.0).abs() < 1e-10);
363        assert!((max - 4.0).abs() < 1e-10);
364    }
365
366    // validate_data_array tests
367    #[test]
368    fn test_validate_data_array_valid() {
369        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
370        assert!(validate_data_array(&values, "test").is_ok());
371    }
372
373    #[test]
374    fn test_validate_data_array_empty() {
375        let values: Vec<f64> = vec![];
376        let result = validate_data_array(&values, "test");
377        assert!(matches!(
378            result,
379            Err(ChartError::EmptyData { field: "test" })
380        ));
381    }
382
383    #[test]
384    fn test_validate_data_array_nan() {
385        let values = vec![1.0, 2.0, f64::NAN, 4.0];
386        let result = validate_data_array(&values, "test");
387        assert!(matches!(
388            result,
389            Err(ChartError::InvalidData {
390                field: "test",
391                reason: "contains NaN or Infinity"
392            })
393        ));
394    }
395
396    #[test]
397    fn test_validate_data_array_infinity() {
398        let values = vec![1.0, f64::INFINITY, 3.0];
399        let result = validate_data_array(&values, "test");
400        assert!(matches!(
401            result,
402            Err(ChartError::InvalidData {
403                field: "test",
404                reason: "contains NaN or Infinity"
405            })
406        ));
407    }
408
409    #[test]
410    fn test_validate_data_array_neg_infinity() {
411        let values = vec![1.0, 2.0, f64::NEG_INFINITY];
412        let result = validate_data_array(&values, "test");
413        assert!(matches!(
414            result,
415            Err(ChartError::InvalidData {
416                field: "test",
417                reason: "contains NaN or Infinity"
418            })
419        ));
420    }
421
422    // validate_data_length tests
423    #[test]
424    fn test_validate_data_length_matching() {
425        assert!(validate_data_length(5, 5, "x", "y").is_ok());
426    }
427
428    #[test]
429    fn test_validate_data_length_mismatched() {
430        let result = validate_data_length(3, 5, "x", "y");
431        assert!(matches!(
432            result,
433            Err(ChartError::DataLengthMismatch {
434                x_field: "x",
435                y_field: "y",
436                x_len: 3,
437                y_len: 5,
438            })
439        ));
440    }
441
442    #[test]
443    fn test_validate_data_length_zero() {
444        assert!(validate_data_length(0, 0, "x", "y").is_ok());
445    }
446
447    // validate_dimensions tests
448    #[test]
449    fn test_validate_dimensions_valid() {
450        assert!(validate_dimensions(600.0, 400.0).is_ok());
451    }
452
453    #[test]
454    fn test_validate_dimensions_zero_width() {
455        let result = validate_dimensions(0.0, 400.0);
456        assert!(matches!(
457            result,
458            Err(ChartError::InvalidDimension {
459                field: "width",
460                value: 0.0
461            })
462        ));
463    }
464
465    #[test]
466    fn test_validate_dimensions_negative_width() {
467        let result = validate_dimensions(-100.0, 400.0);
468        assert!(matches!(
469            result,
470            Err(ChartError::InvalidDimension {
471                field: "width",
472                value: -100.0
473            })
474        ));
475    }
476
477    #[test]
478    fn test_validate_dimensions_zero_height() {
479        let result = validate_dimensions(600.0, 0.0);
480        assert!(matches!(
481            result,
482            Err(ChartError::InvalidDimension {
483                field: "height",
484                value: 0.0
485            })
486        ));
487    }
488
489    #[test]
490    fn test_validate_dimensions_negative_height() {
491        let result = validate_dimensions(600.0, -50.0);
492        assert!(matches!(
493            result,
494            Err(ChartError::InvalidDimension {
495                field: "height",
496                value: -50.0
497            })
498        ));
499    }
500
501    // validate_grid_dimensions tests
502    #[test]
503    fn test_validate_grid_dimensions_valid() {
504        let z = vec![1.0; 12]; // 3x4 grid
505        assert!(validate_grid_dimensions(&z, 3, 4).is_ok());
506    }
507
508    #[test]
509    fn test_validate_grid_dimensions_mismatch() {
510        let z = vec![1.0; 10];
511        let result = validate_grid_dimensions(&z, 3, 4);
512        assert!(matches!(
513            result,
514            Err(ChartError::GridDimensionMismatch {
515                z_len: 10,
516                width: 3,
517                height: 4,
518                expected: 12,
519            })
520        ));
521    }
522
523    // validate_monotonic tests
524    #[test]
525    fn test_validate_monotonic_valid() {
526        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
527        assert!(validate_monotonic(&values, "x").is_ok());
528    }
529
530    #[test]
531    fn test_validate_monotonic_not_increasing() {
532        let values = vec![1.0, 2.0, 2.0, 4.0]; // 2.0 == 2.0
533        let result = validate_monotonic(&values, "x");
534        assert!(matches!(
535            result,
536            Err(ChartError::InvalidData {
537                field: "x",
538                reason: "must be strictly monotonically increasing"
539            })
540        ));
541    }
542
543    #[test]
544    fn test_validate_monotonic_decreasing() {
545        let values = vec![1.0, 3.0, 2.0, 4.0];
546        let result = validate_monotonic(&values, "x");
547        assert!(matches!(
548            result,
549            Err(ChartError::InvalidData {
550                field: "x",
551                reason: "must be strictly monotonically increasing"
552            })
553        ));
554    }
555
556    // validate_positive tests
557    #[test]
558    fn test_validate_positive_valid() {
559        let values = vec![0.1, 1.0, 10.0, 100.0];
560        assert!(validate_positive(&values, "x").is_ok());
561    }
562
563    #[test]
564    fn test_validate_positive_with_zero() {
565        let values = vec![0.0, 1.0, 2.0];
566        let result = validate_positive(&values, "x");
567        assert!(matches!(
568            result,
569            Err(ChartError::InvalidData {
570                field: "x",
571                reason: "log scale requires positive values"
572            })
573        ));
574    }
575
576    #[test]
577    fn test_validate_positive_with_negative() {
578        let values = vec![-1.0, 1.0, 2.0];
579        let result = validate_positive(&values, "x");
580        assert!(matches!(
581            result,
582            Err(ChartError::InvalidData {
583                field: "x",
584                reason: "log scale requires positive values"
585            })
586        ));
587    }
588}