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}