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 area;
152mod bar;
153mod boxplot;
154mod color_scale;
155mod contour;
156mod error;
157mod heatmap;
158mod isoline;
159mod line;
160mod pie;
161mod scatter;
162mod surface3d;
163
164pub use area::{AreaChart, area};
165pub use bar::{BarChart, bar};
166pub use boxplot::{BoxPlotChart, boxplot};
167pub use color_scale::ColorScale;
168pub use contour::{ContourChart, contour};
169pub use error::ChartError;
170pub use heatmap::{HeatmapChart, heatmap};
171pub use isoline::{IsolineChart, isoline};
172pub use line::{LineChart, line};
173pub use pie::{PieChart, donut, pie};
174pub use scatter::{ScatterChart, scatter};
175pub use surface3d::{Surface3DChart, surface3d};
176
177// Re-export d3rs types users might need
178pub use d3rs::color::D3Color;
179pub use d3rs::shape::CurveType;
180
181// ============================================================================
182// Scale Types
183// ============================================================================
184
185/// Scale type for axis transformations.
186#[derive(Debug, Clone, Copy, Default, PartialEq)]
187pub enum ScaleType {
188    /// Linear scale (default).
189    #[default]
190    Linear,
191    /// Logarithmic scale (base 10).
192    Log,
193}
194
195// ============================================================================
196// Shared Constants
197// ============================================================================
198
199/// Default chart color (Plotly blue)
200pub(crate) const DEFAULT_COLOR: u32 = 0x1f77b4;
201
202/// Default chart width in pixels
203pub(crate) const DEFAULT_WIDTH: f32 = 600.0;
204
205/// Default chart height in pixels
206pub(crate) const DEFAULT_HEIGHT: f32 = 400.0;
207
208/// Default padding fraction for auto-domain calculation
209pub(crate) const DEFAULT_PADDING_FRACTION: f64 = 0.05;
210
211/// Default title font size
212pub(crate) const DEFAULT_TITLE_FONT_SIZE: f32 = 16.0;
213
214/// Title area height (font size + padding)
215pub(crate) const TITLE_AREA_HEIGHT: f32 = 24.0;
216
217// ============================================================================
218// Shared Utilities
219// ============================================================================
220
221/// Calculate extent (min, max) with padding.
222///
223/// Returns `(min - padding, max + padding)` where padding is calculated
224/// as `range * padding_fraction`.
225///
226/// ## Special Case: Constant Values
227///
228/// When all values are identical (range ≈ 0), uses a **hardcoded padding of 1.0**
229/// to ensure a meaningful range for visualization. This prevents collapsed
230/// axes and ensures the constant value is visible in the chart.
231///
232/// For example, `[5.0, 5.0, 5.0]` returns `(4.0, 6.0)` instead of `(5.0, 5.0)`.
233pub(crate) fn extent_padded(values: &[f64], padding_fraction: f64) -> (f64, f64) {
234    let (min, max) = values
235        .iter()
236        .copied()
237        .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), val| {
238            (min.min(val), max.max(val))
239        });
240
241    let range = max - min;
242    let padding = if range.abs() < f64::EPSILON {
243        1.0 // Default padding for constant values
244    } else {
245        range * padding_fraction
246    };
247    (min - padding, max + padding)
248}
249
250/// Validate that a data array is not empty and contains only finite values.
251pub(crate) fn validate_data_array(values: &[f64], field: &'static str) -> Result<(), ChartError> {
252    if values.is_empty() {
253        return Err(ChartError::EmptyData { field });
254    }
255    if values.iter().any(|x| !x.is_finite()) {
256        return Err(ChartError::InvalidData {
257            field,
258            reason: "contains NaN or Infinity",
259        });
260    }
261    Ok(())
262}
263
264/// Validate that two arrays have the same length.
265pub(crate) fn validate_data_length(
266    x_len: usize,
267    y_len: usize,
268    x_field: &'static str,
269    y_field: &'static str,
270) -> Result<(), ChartError> {
271    if x_len != y_len {
272        return Err(ChartError::DataLengthMismatch {
273            x_field,
274            y_field,
275            x_len,
276            y_len,
277        });
278    }
279    Ok(())
280}
281
282/// Validate chart dimensions are positive.
283pub(crate) fn validate_dimensions(width: f32, height: f32) -> Result<(), ChartError> {
284    if width <= 0.0 {
285        return Err(ChartError::InvalidDimension {
286            field: "width",
287            value: width,
288        });
289    }
290    if height <= 0.0 {
291        return Err(ChartError::InvalidDimension {
292            field: "height",
293            value: height,
294        });
295    }
296    Ok(())
297}
298
299/// Validate that grid dimensions match the z array length.
300pub(crate) fn validate_grid_dimensions(
301    z: &[f64],
302    grid_width: usize,
303    grid_height: usize,
304) -> Result<(), ChartError> {
305    let expected = grid_width * grid_height;
306    if z.len() != expected {
307        return Err(ChartError::GridDimensionMismatch {
308            z_len: z.len(),
309            width: grid_width,
310            height: grid_height,
311            expected,
312        });
313    }
314    Ok(())
315}
316
317/// Validate that axis values are strictly monotonic (increasing).
318pub(crate) fn validate_monotonic(values: &[f64], field: &'static str) -> Result<(), ChartError> {
319    for window in values.windows(2) {
320        if window[1] <= window[0] {
321            return Err(ChartError::InvalidData {
322                field,
323                reason: "must be strictly monotonically increasing",
324            });
325        }
326    }
327    Ok(())
328}
329
330/// Validate that all values are positive (for log scale).
331pub(crate) fn validate_positive(values: &[f64], field: &'static str) -> Result<(), ChartError> {
332    if values.iter().any(|&v| v <= 0.0) {
333        return Err(ChartError::InvalidData {
334            field,
335            reason: "log scale requires positive values",
336        });
337    }
338    Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    // extent_padded tests
346    #[test]
347    fn test_extent_padded_normal_values() {
348        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
349        let (min, max) = extent_padded(&values, 0.05);
350        // Min should be 1.0 - 0.05 * 4.0 = 0.8
351        // Max should be 5.0 + 0.05 * 4.0 = 5.2
352        assert!((min - 0.8).abs() < 1e-10);
353        assert!((max - 5.2).abs() < 1e-10);
354    }
355
356    #[test]
357    fn test_extent_padded_constant_values() {
358        let values = vec![5.0, 5.0, 5.0, 5.0];
359        let (min, max) = extent_padded(&values, 0.05);
360        // Range is 0, so padding should be 1.0
361        assert!((min - 4.0).abs() < 1e-10);
362        assert!((max - 6.0).abs() < 1e-10);
363    }
364
365    #[test]
366    fn test_extent_padded_single_value() {
367        let values = vec![3.0];
368        let (min, max) = extent_padded(&values, 0.1);
369        // Range is 0, so padding should be 1.0
370        assert!((min - 2.0).abs() < 1e-10);
371        assert!((max - 4.0).abs() < 1e-10);
372    }
373
374    // validate_data_array tests
375    #[test]
376    fn test_validate_data_array_valid() {
377        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
378        assert!(validate_data_array(&values, "test").is_ok());
379    }
380
381    #[test]
382    fn test_validate_data_array_empty() {
383        let values: Vec<f64> = vec![];
384        let result = validate_data_array(&values, "test");
385        assert!(matches!(
386            result,
387            Err(ChartError::EmptyData { field: "test" })
388        ));
389    }
390
391    #[test]
392    fn test_validate_data_array_nan() {
393        let values = vec![1.0, 2.0, f64::NAN, 4.0];
394        let result = validate_data_array(&values, "test");
395        assert!(matches!(
396            result,
397            Err(ChartError::InvalidData {
398                field: "test",
399                reason: "contains NaN or Infinity"
400            })
401        ));
402    }
403
404    #[test]
405    fn test_validate_data_array_infinity() {
406        let values = vec![1.0, f64::INFINITY, 3.0];
407        let result = validate_data_array(&values, "test");
408        assert!(matches!(
409            result,
410            Err(ChartError::InvalidData {
411                field: "test",
412                reason: "contains NaN or Infinity"
413            })
414        ));
415    }
416
417    #[test]
418    fn test_validate_data_array_neg_infinity() {
419        let values = vec![1.0, 2.0, f64::NEG_INFINITY];
420        let result = validate_data_array(&values, "test");
421        assert!(matches!(
422            result,
423            Err(ChartError::InvalidData {
424                field: "test",
425                reason: "contains NaN or Infinity"
426            })
427        ));
428    }
429
430    // validate_data_length tests
431    #[test]
432    fn test_validate_data_length_matching() {
433        assert!(validate_data_length(5, 5, "x", "y").is_ok());
434    }
435
436    #[test]
437    fn test_validate_data_length_mismatched() {
438        let result = validate_data_length(3, 5, "x", "y");
439        assert!(matches!(
440            result,
441            Err(ChartError::DataLengthMismatch {
442                x_field: "x",
443                y_field: "y",
444                x_len: 3,
445                y_len: 5,
446            })
447        ));
448    }
449
450    #[test]
451    fn test_validate_data_length_zero() {
452        assert!(validate_data_length(0, 0, "x", "y").is_ok());
453    }
454
455    // validate_dimensions tests
456    #[test]
457    fn test_validate_dimensions_valid() {
458        assert!(validate_dimensions(600.0, 400.0).is_ok());
459    }
460
461    #[test]
462    fn test_validate_dimensions_zero_width() {
463        let result = validate_dimensions(0.0, 400.0);
464        assert!(matches!(
465            result,
466            Err(ChartError::InvalidDimension {
467                field: "width",
468                value: 0.0
469            })
470        ));
471    }
472
473    #[test]
474    fn test_validate_dimensions_negative_width() {
475        let result = validate_dimensions(-100.0, 400.0);
476        assert!(matches!(
477            result,
478            Err(ChartError::InvalidDimension {
479                field: "width",
480                value: -100.0
481            })
482        ));
483    }
484
485    #[test]
486    fn test_validate_dimensions_zero_height() {
487        let result = validate_dimensions(600.0, 0.0);
488        assert!(matches!(
489            result,
490            Err(ChartError::InvalidDimension {
491                field: "height",
492                value: 0.0
493            })
494        ));
495    }
496
497    #[test]
498    fn test_validate_dimensions_negative_height() {
499        let result = validate_dimensions(600.0, -50.0);
500        assert!(matches!(
501            result,
502            Err(ChartError::InvalidDimension {
503                field: "height",
504                value: -50.0
505            })
506        ));
507    }
508
509    // validate_grid_dimensions tests
510    #[test]
511    fn test_validate_grid_dimensions_valid() {
512        let z = vec![1.0; 12]; // 3x4 grid
513        assert!(validate_grid_dimensions(&z, 3, 4).is_ok());
514    }
515
516    #[test]
517    fn test_validate_grid_dimensions_mismatch() {
518        let z = vec![1.0; 10];
519        let result = validate_grid_dimensions(&z, 3, 4);
520        assert!(matches!(
521            result,
522            Err(ChartError::GridDimensionMismatch {
523                z_len: 10,
524                width: 3,
525                height: 4,
526                expected: 12,
527            })
528        ));
529    }
530
531    // validate_monotonic tests
532    #[test]
533    fn test_validate_monotonic_valid() {
534        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
535        assert!(validate_monotonic(&values, "x").is_ok());
536    }
537
538    #[test]
539    fn test_validate_monotonic_not_increasing() {
540        let values = vec![1.0, 2.0, 2.0, 4.0]; // 2.0 == 2.0
541        let result = validate_monotonic(&values, "x");
542        assert!(matches!(
543            result,
544            Err(ChartError::InvalidData {
545                field: "x",
546                reason: "must be strictly monotonically increasing"
547            })
548        ));
549    }
550
551    #[test]
552    fn test_validate_monotonic_decreasing() {
553        let values = vec![1.0, 3.0, 2.0, 4.0];
554        let result = validate_monotonic(&values, "x");
555        assert!(matches!(
556            result,
557            Err(ChartError::InvalidData {
558                field: "x",
559                reason: "must be strictly monotonically increasing"
560            })
561        ));
562    }
563
564    // validate_positive tests
565    #[test]
566    fn test_validate_positive_valid() {
567        let values = vec![0.1, 1.0, 10.0, 100.0];
568        assert!(validate_positive(&values, "x").is_ok());
569    }
570
571    #[test]
572    fn test_validate_positive_with_zero() {
573        let values = vec![0.0, 1.0, 2.0];
574        let result = validate_positive(&values, "x");
575        assert!(matches!(
576            result,
577            Err(ChartError::InvalidData {
578                field: "x",
579                reason: "log scale requires positive values"
580            })
581        ));
582    }
583
584    #[test]
585    fn test_validate_positive_with_negative() {
586        let values = vec![-1.0, 1.0, 2.0];
587        let result = validate_positive(&values, "x");
588        assert!(matches!(
589            result,
590            Err(ChartError::InvalidData {
591                field: "x",
592                reason: "log scale requires positive values"
593            })
594        ));
595    }
596}