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}