1use crate::color::Color;
8use crate::config::{AxisOptions, AxisScale};
9use crate::data::ColumnId;
10use crate::format::LabelFormat;
11use crate::line::LineStylePreset;
12use crate::text::RichText;
13use crate::tick::TickError;
14
15#[derive(Debug, Clone, PartialEq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub struct DataConfig {
18 pub data_id: String,
19}
20
21#[derive(Debug, Clone, PartialEq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct SeriesConfig {
30 pub series_id: String,
31 #[cfg_attr(
32 feature = "serde",
33 serde(default, skip_serializing_if = "Option::is_none")
34 )]
35 pub source_id: Option<String>,
36 pub label: Option<RichText>,
37 pub x_column: ColumnId,
39 pub y_column: ColumnId,
41 pub render_type: DataRenderType,
42}
43
44#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub enum ErrorRef {
49 Symmetric { column: ColumnId },
51 Asymmetric { lower: ColumnId, upper: ColumnId },
53}
54
55#[derive(Debug, Clone, PartialEq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub enum DataRenderType {
62 Scatter {
63 scatter: DataScatterStyleConfig,
64 },
65 Line {
66 line: DataLineStyleConfig,
67 },
68 ScatterLine {
69 scatter: DataScatterStyleConfig,
70 line: DataLineStyleConfig,
71 },
72 ScatterErrorbarX {
73 scatter: DataScatterStyleConfig,
74 err_x: ErrorRef,
75 err_style: DataErrorBarStyleConfig,
76 },
77 ScatterErrorbarY {
78 scatter: DataScatterStyleConfig,
79 err_y: ErrorRef,
80 err_style: DataErrorBarStyleConfig,
81 },
82 ScatterErrorbarXY {
83 scatter: DataScatterStyleConfig,
84 err_x: ErrorRef,
85 err_y: ErrorRef,
86 err_style: DataErrorBarStyleConfig,
87 },
88 LineScatterErrorbarX {
89 scatter: DataScatterStyleConfig,
90 line: DataLineStyleConfig,
91 err_x: ErrorRef,
92 err_style: DataErrorBarStyleConfig,
93 },
94 LineScatterErrorbarY {
95 scatter: DataScatterStyleConfig,
96 line: DataLineStyleConfig,
97 err_y: ErrorRef,
98 err_style: DataErrorBarStyleConfig,
99 },
100 LineScatterErrorbarXY {
101 scatter: DataScatterStyleConfig,
102 line: DataLineStyleConfig,
103 err_x: ErrorRef,
104 err_y: ErrorRef,
105 err_style: DataErrorBarStyleConfig,
106 },
107 Histogram {
114 bar: DataBarStyleConfig,
115 },
116 Heatmap {
119 matrix: MatrixRef,
120 fill: FieldFillConfig,
121 },
122 Contour {
124 matrix: MatrixRef,
125 contour: ContourConfig,
126 },
127 HeatmapContour {
129 matrix: MatrixRef,
130 fill: FieldFillConfig,
131 contour: ContourConfig,
132 },
133}
134
135#[derive(Debug, Clone, PartialEq)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137pub struct DataLineStyleConfig {
138 pub line_style: LineStylePreset,
139 pub line_color: Color,
140 pub line_width: f32,
141}
142
143#[derive(Debug, Clone, PartialEq)]
144#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
145pub struct DataScatterStyleConfig {
146 pub point_color: Color,
147 pub point_shape: ScatterShape,
148 pub point_size: f32,
149 #[cfg_attr(
150 feature = "serde",
151 serde(default, skip_serializing_if = "Option::is_none")
152 )]
153 pub point_style_table: Option<Vec<DataScatterPointStyleConfig>>,
154 #[cfg_attr(
155 feature = "serde",
156 serde(default, skip_serializing_if = "Option::is_none")
157 )]
158 pub point_style_index_column: Option<ColumnId>,
159 #[cfg_attr(
160 feature = "serde",
161 serde(default, skip_serializing_if = "Option::is_none")
162 )]
163 pub point_style_overrides: Option<Vec<DataScatterPointStyleOverride>>,
164}
165
166#[derive(Debug, Clone, PartialEq, Default)]
167#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
168pub struct DataScatterPointStyleConfig {
169 #[cfg_attr(
170 feature = "serde",
171 serde(default, skip_serializing_if = "Option::is_none")
172 )]
173 pub point_color: Option<Color>,
174 #[cfg_attr(
175 feature = "serde",
176 serde(default, skip_serializing_if = "Option::is_none")
177 )]
178 pub point_shape: Option<ScatterShape>,
179 #[cfg_attr(
180 feature = "serde",
181 serde(default, skip_serializing_if = "Option::is_none")
182 )]
183 pub point_size: Option<f32>,
184}
185
186#[derive(Debug, Clone, PartialEq)]
187#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
188pub struct DataScatterPointStyleOverride {
189 pub index: usize,
190 #[cfg_attr(feature = "serde", serde(flatten))]
191 pub style: DataScatterPointStyleConfig,
192}
193
194#[derive(Debug, Clone, PartialEq, Default)]
195#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
196pub struct DataErrorBarPointStyleConfig {
197 #[cfg_attr(
198 feature = "serde",
199 serde(default, skip_serializing_if = "Option::is_none")
200 )]
201 pub error_bar_color: Option<Color>,
202 #[cfg_attr(
203 feature = "serde",
204 serde(default, skip_serializing_if = "Option::is_none")
205 )]
206 pub error_bar_width: Option<f32>,
207 #[cfg_attr(
208 feature = "serde",
209 serde(default, skip_serializing_if = "Option::is_none")
210 )]
211 pub error_bar_cap_size: Option<f32>,
212 #[cfg_attr(
213 feature = "serde",
214 serde(default, skip_serializing_if = "Option::is_none")
215 )]
216 pub cap_width: Option<f32>,
217}
218
219#[derive(Debug, Clone, PartialEq)]
220#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
221pub struct DataErrorBarPointStyleOverride {
222 pub index: usize,
223 #[cfg_attr(feature = "serde", serde(flatten))]
224 pub style: DataErrorBarPointStyleConfig,
225}
226
227#[derive(Debug, Clone, PartialEq)]
228#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
229pub struct DataErrorBarStyleConfig {
230 pub error_bar_color: Color,
231 pub error_bar_width: f32,
232 pub error_bar_cap_size: f32,
233 pub cap_width: f32,
234 #[cfg_attr(
235 feature = "serde",
236 serde(default, skip_serializing_if = "Option::is_none")
237 )]
238 pub error_bar_style_table: Option<Vec<DataErrorBarPointStyleConfig>>,
239 #[cfg_attr(
240 feature = "serde",
241 serde(default, skip_serializing_if = "Option::is_none")
242 )]
243 pub error_bar_style_index_column: Option<ColumnId>,
244 #[cfg_attr(
245 feature = "serde",
246 serde(default, skip_serializing_if = "Option::is_none")
247 )]
248 pub error_bar_style_overrides: Option<Vec<DataErrorBarPointStyleOverride>>,
249}
250
251#[derive(Debug, Clone, PartialEq)]
262#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
263pub struct MatrixRef {
264 pub columns: Vec<ColumnId>,
266 pub orientation: MatrixOrientation,
268 pub grid_layout: GridLayout,
271}
272
273#[derive(Debug, Clone, PartialEq)]
275#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
276pub enum MatrixOrientation {
277 ColumnsAreX,
279 ColumnsAreY,
281}
282
283#[derive(Debug, Clone, PartialEq)]
285#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
286pub enum GridLayout {
287 Edges,
289 Centers,
291}
292
293impl MatrixRef {
294 pub fn effective_extent(
303 &self,
304 x_len: usize,
305 y_len: usize,
306 shortest_column_len: usize,
307 ) -> (usize, usize) {
308 let (along, across) = self.coordinate_cells(x_len, y_len);
309 (
310 self.columns.len().min(along),
311 shortest_column_len.min(across),
312 )
313 }
314
315 pub fn coordinate_cells(&self, x_len: usize, y_len: usize) -> (usize, usize) {
322 let (along, across) = match self.orientation {
323 MatrixOrientation::ColumnsAreX => (x_len, y_len),
324 MatrixOrientation::ColumnsAreY => (y_len, x_len),
325 };
326 let cells = |coordinates: usize| match self.grid_layout {
327 GridLayout::Edges => coordinates.saturating_sub(1),
328 GridLayout::Centers => coordinates,
329 };
330 (cells(along), cells(across))
331 }
332}
333
334#[derive(Debug, Clone, PartialEq)]
336#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
337pub struct FieldFillConfig {
338 pub mode: FillMode,
339 pub shading: Shading,
340 pub opacity: f32,
343}
344
345#[derive(Debug, Clone, PartialEq)]
347#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
348pub enum FillMode {
349 Continuous,
350 Bands,
353}
354
355#[derive(Debug, Clone, PartialEq)]
357#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
358pub enum Shading {
359 Flat,
361 Interpolated,
363}
364
365pub const MAX_CONTOUR_LEVELS: usize = 1024;
371
372fn push_contour_level(levels: &mut Vec<f64>, value: f64) -> Result<(), TickError> {
373 if levels.len() == MAX_CONTOUR_LEVELS {
374 return Err(TickError::InvalidRange);
375 }
376 levels.push(value);
377 Ok(())
378}
379
380#[derive(Debug, Clone, PartialEq)]
382#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
383pub struct ContourConfig {
384 pub levels: Vec<f64>,
389 pub line: DataLineStyleConfig,
390 #[cfg_attr(
394 feature = "serde",
395 serde(default, skip_serializing_if = "Option::is_none")
396 )]
397 pub per_level_color: Option<Vec<Color>>,
398 #[cfg_attr(
399 feature = "serde",
400 serde(default, skip_serializing_if = "Option::is_none")
401 )]
402 pub labels: Option<ContourLabelConfig>,
403}
404
405impl ContourConfig {
406 pub fn nice_levels(axis: &AxisOptions, target_count: usize) -> Result<Vec<f64>, TickError> {
425 let plan = AxisOptions::compute_nice_ticks(
428 axis.scale.clone(),
429 axis.min,
430 axis.max,
431 target_count.max(1),
432 )?;
433 let mut out = Vec::new();
434 let inside = |v: f64| v > axis.min && v < axis.max;
437 match axis.scale {
438 AxisScale::Linear => {
439 let step = plan.major_spacing;
440 if !(step.is_finite() && step > 0.0) {
441 return Err(TickError::InvalidRange);
442 }
443 let first = ((axis.min / step) - 1e-9).ceil() as i64;
444 let last = ((axis.max / step) + 1e-9).floor() as i64;
445 for i in first..=last {
446 let value = i as f64 * step;
447 if inside(value) {
448 push_contour_level(&mut out, value)?;
449 }
450 }
451 }
452 AxisScale::Logarithmic => {
453 let step = plan.major_spacing.max(1.0);
454 let start = axis.min.log10().ceil();
455 let end = axis.max.log10().floor();
456 if !(step.is_finite()
457 && step <= i32::MAX as f64
458 && start.is_finite()
459 && start >= i32::MIN as f64
460 && start <= i32::MAX as f64
461 && end.is_finite()
462 && end >= i32::MIN as f64
463 && end <= i32::MAX as f64)
464 {
465 return Err(TickError::InvalidRange);
466 }
467 let step = step as i32;
468 let start = start as i32;
469 let end = end as i32;
470 if start > end {
471 return Ok(out);
472 }
473 let exponent_span = end.checked_sub(start).ok_or(TickError::InvalidRange)?;
474 let iteration_count = exponent_span
475 .checked_div(step)
476 .and_then(|count| count.checked_add(1))
477 .and_then(|count| usize::try_from(count).ok())
478 .ok_or(TickError::InvalidRange)?;
479 let mut exponent = start;
480 for index in 0..iteration_count {
481 let value = 10f64.powf(f64::from(exponent));
484 if inside(value) {
485 push_contour_level(&mut out, value)?;
486 }
487 if index + 1 < iteration_count {
488 exponent = exponent.checked_add(step).ok_or(TickError::InvalidRange)?;
489 }
490 }
491 }
492 }
493 Ok(out)
494 }
495
496 pub fn set_nice_levels(
503 &mut self,
504 axis: &AxisOptions,
505 target_count: usize,
506 ) -> Result<(), TickError> {
507 self.levels = Self::nice_levels(axis, target_count)?;
508 Ok(())
509 }
510
511 pub fn set_colormap_colors(&mut self, bar: &crate::config::ColorBarOptions) {
519 self.per_level_color = Some(
520 self.levels
521 .iter()
522 .map(|level| bar.color_for_z(*level))
523 .collect(),
524 );
525 }
526}
527
528#[derive(Debug, Clone, PartialEq)]
530#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
531pub struct ContourLabelConfig {
532 pub visible: bool,
533 pub font_size: f32,
534 #[cfg_attr(feature = "serde", serde(default = "default_contour_label_color"))]
538 pub color: Color,
539 pub format: LabelFormat,
540 pub significant_digits: u8,
541 #[cfg_attr(
552 feature = "serde",
553 serde(deserialize_with = "deserialize_contour_label_spacing_px")
554 )]
555 pub spacing_px: f32,
556 pub anchors: Vec<ContourLabelAnchor>,
566 #[cfg_attr(
573 feature = "serde",
574 serde(default, skip_serializing_if = "Option::is_none")
575 )]
576 pub bg_color: Option<Color>,
577 #[cfg_attr(feature = "serde", serde(default))]
580 pub bg_padding_px: f32,
581}
582
583#[cfg(feature = "serde")]
584fn default_contour_label_color() -> Color {
585 Color::BLACK
586}
587
588impl ContourLabelConfig {
589 pub const INVALID_SPACING_REASON: &'static str =
590 "contour label spacing_px must be finite and greater than zero";
591
592 pub fn validate(&self) -> Result<(), &'static str> {
594 if !self.spacing_px.is_finite() || self.spacing_px <= 0.0 {
595 return Err(Self::INVALID_SPACING_REASON);
596 }
597 Ok(())
598 }
599}
600
601#[cfg(feature = "serde")]
602fn deserialize_contour_label_spacing_px<'de, D>(deserializer: D) -> Result<f32, D::Error>
603where
604 D: serde::Deserializer<'de>,
605{
606 let spacing_px = <f32 as serde::Deserialize>::deserialize(deserializer)?;
607 if !spacing_px.is_finite() || spacing_px <= 0.0 {
608 return Err(serde::de::Error::custom(
609 ContourLabelConfig::INVALID_SPACING_REASON,
610 ));
611 }
612 Ok(spacing_px)
613}
614
615#[derive(Debug, Clone, PartialEq)]
623#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
624pub struct ContourLabelAnchor {
625 pub level_index: u32,
627 pub x: f64,
628 pub y: f64,
629 pub tx: f64,
632 pub ty: f64,
633}
634
635#[derive(Debug, Clone, PartialEq)]
637#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
638pub struct DataBarStyleConfig {
639 pub fill_color: Color,
640 pub border_color: Color,
641 pub border_width: f32,
642 pub baseline: f64,
645 pub gap_px: f32,
650 #[cfg_attr(feature = "serde", serde(default = "default_bar_width_ratio"))]
654 pub width_ratio: f32,
655 pub orientation: BarOrientation,
658 #[cfg_attr(
661 feature = "serde",
662 serde(default, skip_serializing_if = "Option::is_none")
663 )]
664 pub bar_style_overrides: Option<Vec<DataBarStyleOverride>>,
665}
666
667#[cfg(feature = "serde")]
668fn default_bar_width_ratio() -> f32 {
669 1.0
670}
671
672#[derive(Debug, Clone, PartialEq, Default)]
677#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
678pub struct DataBarBinStyleConfig {
679 #[cfg_attr(
680 feature = "serde",
681 serde(default, skip_serializing_if = "Option::is_none")
682 )]
683 pub fill_color: Option<Color>,
684 #[cfg_attr(
685 feature = "serde",
686 serde(default, skip_serializing_if = "Option::is_none")
687 )]
688 pub border_color: Option<Color>,
689 #[cfg_attr(
690 feature = "serde",
691 serde(default, skip_serializing_if = "Option::is_none")
692 )]
693 pub border_width: Option<f32>,
694 #[cfg_attr(
695 feature = "serde",
696 serde(default, skip_serializing_if = "Option::is_none")
697 )]
698 pub gap_px: Option<f32>,
699 #[cfg_attr(
700 feature = "serde",
701 serde(default, skip_serializing_if = "Option::is_none")
702 )]
703 pub width_ratio: Option<f32>,
704}
705
706#[derive(Debug, Clone, PartialEq)]
708#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
709pub struct DataBarStyleOverride {
710 pub index: usize,
711 #[cfg_attr(feature = "serde", serde(flatten))]
712 pub style: DataBarBinStyleConfig,
713}
714
715#[derive(Debug, Clone, PartialEq)]
717#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
718pub enum BarOrientation {
719 Vertical,
721 Horizontal,
723}
724
725#[derive(Debug, Clone, PartialEq)]
726#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
727pub enum ScatterShape {
728 Circle,
729 Square,
730 Triangle,
731 Diamond,
732 Cross,
733 CircleFilled,
734 SquareFilled,
735 TriangleFilled,
736 DiamondFilled,
737 TriangleDown,
738 TriangleLeft,
739 TriangleRight,
740 Plus,
741 Pentagon,
742 Hexagon,
743 Octagon,
744 Star,
745 TriangleDownFilled,
746 TriangleLeftFilled,
747 TriangleRightFilled,
748 PlusFilled,
749 CrossFilled,
750 PentagonFilled,
751 HexagonFilled,
752 OctagonFilled,
753 StarFilled,
754}
755
756#[cfg(test)]
757mod matrix_tests {
758 use super::*;
759
760 fn matrix(
761 orientation: MatrixOrientation,
762 grid_layout: GridLayout,
763 columns: usize,
764 ) -> MatrixRef {
765 MatrixRef {
766 columns: (0..columns).map(|i| format!("z{i}")).collect(),
767 orientation,
768 grid_layout,
769 }
770 }
771
772 #[test]
774 fn a_well_formed_grid_uses_everything_it_declares() {
775 let centers = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Centers, 10);
776 assert_eq!(centers.effective_extent(10, 5, 5), (10, 5));
777
778 let edges = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Edges, 10);
780 assert_eq!(edges.effective_extent(11, 6, 5), (10, 5));
781 }
782
783 #[test]
786 fn a_mismatched_grid_shrinks_to_the_smallest_common_extent() {
787 let m = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Centers, 10);
788 assert_eq!(m.effective_extent(4, 5, 5), (4, 5));
790 assert_eq!(m.effective_extent(10, 5, 2), (10, 2));
792 assert_eq!(m.effective_extent(10, 3, 5), (10, 3));
794 assert_eq!(m.effective_extent(0, 0, 0), (0, 0));
796 }
797
798 #[test]
801 fn one_edge_bounds_no_cell() {
802 let m = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Edges, 10);
803 assert_eq!(m.effective_extent(1, 1, 5), (0, 0));
804 assert_eq!(m.effective_extent(0, 0, 5), (0, 0));
805 }
806
807 #[test]
810 fn orientation_decides_which_coordinate_bounds_the_columns() {
811 let columns_are_x = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Centers, 10);
812 let columns_are_y = matrix(MatrixOrientation::ColumnsAreY, GridLayout::Centers, 10);
813 assert_eq!(columns_are_x.effective_extent(4, 20, 20), (4, 20));
815 assert_eq!(columns_are_y.effective_extent(4, 20, 20), (10, 4));
816 }
817}
818
819#[cfg(all(test, feature = "serde"))]
820mod serde_tests {
821 use super::{
822 DataBarStyleConfig, DataErrorBarPointStyleConfig, DataErrorBarPointStyleOverride,
823 DataErrorBarStyleConfig, DataRenderType, DataScatterPointStyleConfig,
824 DataScatterPointStyleOverride, DataScatterStyleConfig, ScatterShape, SeriesConfig,
825 };
826 use crate::color::Color;
827
828 fn base_series_json() -> serde_json::Value {
829 serde_json::json!({
830 "series_id": "s",
831 "label": null,
832 "x_column": "x",
833 "y_column": "y",
834 "render_type": {
835 "Scatter": {
836 "scatter": {
837 "point_color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 },
838 "point_shape": "Circle",
839 "point_size": 3.0
840 }
841 }
842 }
843 })
844 }
845
846 #[test]
847 fn old_series_json_without_additive_fields_still_parses() {
848 let cfg: SeriesConfig =
849 serde_json::from_value(base_series_json()).expect("old series shape parses");
850 assert_eq!(cfg.source_id, None);
851 let DataRenderType::Scatter { scatter } = &cfg.render_type else {
852 panic!("expected scatter");
853 };
854 assert_eq!(scatter.point_style_table, None);
855 assert_eq!(scatter.point_style_index_column, None);
856 assert_eq!(scatter.point_style_overrides, None);
857
858 let json = serde_json::to_value(cfg).expect("serialize");
859 assert!(json.get("source_id").is_none());
860 let scatter = &json["render_type"]["Scatter"]["scatter"];
861 assert!(scatter.get("point_style_table").is_none());
862 assert!(scatter.get("point_style_index_column").is_none());
863 assert!(scatter.get("point_style_overrides").is_none());
864 }
865
866 #[test]
867 fn point_style_mapping_round_trips_with_flattened_override() {
868 let scatter = DataScatterStyleConfig {
869 point_color: Color::BLACK,
870 point_shape: ScatterShape::Circle,
871 point_size: 3.0,
872 point_style_table: Some(vec![
873 DataScatterPointStyleConfig {
874 point_color: Some(Color::from_rgb8(255, 0, 0)),
875 point_shape: None,
876 point_size: Some(5.0),
877 },
878 DataScatterPointStyleConfig {
879 point_color: None,
880 point_shape: Some(ScatterShape::DiamondFilled),
881 point_size: None,
882 },
883 ]),
884 point_style_index_column: Some("style_idx".into()),
885 point_style_overrides: Some(vec![DataScatterPointStyleOverride {
886 index: 7,
887 style: DataScatterPointStyleConfig {
888 point_color: None,
889 point_shape: Some(ScatterShape::StarFilled),
890 point_size: Some(9.0),
891 },
892 }]),
893 };
894
895 let json = serde_json::to_value(&scatter).expect("serialize scatter style");
896 assert_eq!(json["point_style_index_column"], "style_idx");
897 assert_eq!(json["point_style_overrides"][0]["index"], 7);
898 assert_eq!(
899 json["point_style_overrides"][0]["point_shape"],
900 "StarFilled"
901 );
902 assert_eq!(json["point_style_overrides"][0]["point_size"], 9.0);
903 assert!(json["point_style_overrides"][0].get("style").is_none());
904
905 let back: DataScatterStyleConfig =
906 serde_json::from_value(json).expect("parse scatter style");
907 assert_eq!(back, scatter);
908 }
909
910 #[test]
911 fn errorbar_style_mapping_round_trips_with_flattened_override() {
912 let err = DataErrorBarStyleConfig {
913 error_bar_color: Color::BLACK,
914 error_bar_width: 1.0,
915 error_bar_cap_size: 3.0,
916 cap_width: 1.0,
917 error_bar_style_table: Some(vec![
918 DataErrorBarPointStyleConfig {
919 error_bar_color: Some(Color::from_rgb8(255, 0, 0)),
920 error_bar_width: Some(2.0),
921 error_bar_cap_size: None,
922 cap_width: None,
923 },
924 DataErrorBarPointStyleConfig {
925 error_bar_color: None,
926 error_bar_width: None,
927 error_bar_cap_size: Some(8.0),
928 cap_width: Some(3.0),
929 },
930 ]),
931 error_bar_style_index_column: Some("err_style_idx".into()),
932 error_bar_style_overrides: Some(vec![DataErrorBarPointStyleOverride {
933 index: 4,
934 style: DataErrorBarPointStyleConfig {
935 error_bar_color: None,
936 error_bar_width: Some(4.0),
937 error_bar_cap_size: None,
938 cap_width: Some(2.0),
939 },
940 }]),
941 };
942
943 let json = serde_json::to_value(&err).expect("serialize errorbar style");
944 assert_eq!(json["error_bar_style_index_column"], "err_style_idx");
945 assert_eq!(json["error_bar_style_overrides"][0]["index"], 4);
946 assert_eq!(json["error_bar_style_overrides"][0]["error_bar_width"], 4.0);
947 assert_eq!(json["error_bar_style_overrides"][0]["cap_width"], 2.0);
948 assert!(json["error_bar_style_overrides"][0].get("style").is_none());
949
950 let back: DataErrorBarStyleConfig =
951 serde_json::from_value(json).expect("parse errorbar style");
952 assert_eq!(back, err);
953 }
954
955 #[test]
956 fn legacy_histogram_style_defaults_to_full_width_without_overrides() {
957 let legacy = serde_json::json!({
958 "fill_color": { "r": 0.2, "g": 0.3, "b": 0.4, "a": 1.0 },
959 "border_color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 },
960 "border_width": 1.0,
961 "baseline": 0.0,
962 "gap_px": 2.0,
963 "orientation": "Vertical"
964 });
965 let parsed: DataBarStyleConfig =
966 serde_json::from_value(legacy).expect("parse legacy histogram style");
967 assert_eq!(parsed.width_ratio, 1.0);
968 assert_eq!(parsed.bar_style_overrides, None);
969 }
970
971 #[test]
972 fn field_render_types_round_trip() {
973 use super::{
974 ContourConfig, ContourLabelAnchor, ContourLabelConfig, DataBarBinStyleConfig,
975 DataBarStyleConfig, DataBarStyleOverride, DataLineStyleConfig, FieldFillConfig,
976 FillMode, GridLayout, MatrixOrientation, MatrixRef, Shading,
977 };
978 use crate::format::LabelFormat;
979 use crate::line::LineStylePreset;
980
981 let matrix = MatrixRef {
982 columns: vec!["z0".into(), "z1".into()],
983 orientation: MatrixOrientation::ColumnsAreX,
984 grid_layout: GridLayout::Edges,
985 };
986 let fill = FieldFillConfig {
987 mode: FillMode::Bands,
988 shading: Shading::Interpolated,
989 opacity: 0.8,
990 };
991 let contour = ContourConfig {
992 levels: vec![1.0, 2.0, 4.0],
993 line: DataLineStyleConfig {
994 line_style: LineStylePreset::Solid,
995 line_color: Color::BLACK,
996 line_width: 1.5,
997 },
998 per_level_color: Some(vec![Color::from_rgb8(255, 0, 0)]),
999 labels: Some(ContourLabelConfig {
1000 visible: true,
1001 font_size: 11.0,
1002 color: Color::BLACK,
1003 format: LabelFormat::Decimal,
1004 significant_digits: 3,
1005 spacing_px: 120.0,
1006 anchors: vec![ContourLabelAnchor {
1007 level_index: 1,
1008 x: 0.25,
1009 y: 0.5,
1010 tx: 1.0,
1011 ty: 0.0,
1012 }],
1013 bg_color: None,
1014 bg_padding_px: 0.0,
1015 }),
1016 };
1017 let cases = [
1018 DataRenderType::Histogram {
1019 bar: DataBarStyleConfig {
1020 fill_color: Color::from_rgb8(70, 130, 180),
1021 border_color: Color::BLACK,
1022 border_width: 1.0,
1023 baseline: 0.0,
1024 gap_px: 1.0,
1025 width_ratio: 0.8,
1026 orientation: super::BarOrientation::Vertical,
1027 bar_style_overrides: Some(vec![DataBarStyleOverride {
1028 index: 2,
1029 style: DataBarBinStyleConfig {
1030 fill_color: Some(Color::from_rgb8(255, 0, 0)),
1031 border_color: None,
1032 border_width: Some(3.0),
1033 gap_px: None,
1034 width_ratio: Some(0.5),
1035 },
1036 }]),
1037 },
1038 },
1039 DataRenderType::Heatmap {
1040 matrix: matrix.clone(),
1041 fill: fill.clone(),
1042 },
1043 DataRenderType::Contour {
1044 matrix: matrix.clone(),
1045 contour: contour.clone(),
1046 },
1047 DataRenderType::HeatmapContour {
1048 matrix,
1049 fill,
1050 contour,
1051 },
1052 ];
1053 for case in cases {
1054 let json = serde_json::to_value(&case).expect("serialize render type");
1055 let back: DataRenderType = serde_json::from_value(json).expect("parse render type");
1056 assert_eq!(back, case);
1057 }
1058 }
1059
1060 #[test]
1064 fn contour_optional_keys_are_omitted_when_absent() {
1065 use super::{ContourConfig, DataLineStyleConfig, FillMode};
1066 use crate::line::LineStylePreset;
1067
1068 let contour = ContourConfig {
1069 levels: vec![1.0],
1070 line: DataLineStyleConfig {
1071 line_style: LineStylePreset::Solid,
1072 line_color: Color::BLACK,
1073 line_width: 1.0,
1074 },
1075 per_level_color: None,
1076 labels: None,
1077 };
1078 let json = serde_json::to_value(&contour).expect("serialize contour");
1079 assert!(json.get("per_level_color").is_none());
1080 assert!(json.get("labels").is_none());
1081 let back: ContourConfig = serde_json::from_value(json).expect("parse contour");
1082 assert_eq!(back, contour);
1083 assert_eq!(back.per_level_color, None);
1084
1085 assert_eq!(
1087 serde_json::to_value(FillMode::Continuous).expect("serialize fill mode"),
1088 serde_json::json!("Continuous")
1089 );
1090 }
1091
1092 #[test]
1093 fn contour_label_spacing_serde_requires_a_finite_positive_value() {
1094 use super::ContourLabelConfig;
1095
1096 let json = |spacing_px: f32| {
1097 serde_json::json!({
1098 "visible": false,
1099 "font_size": 12.0,
1100 "format": "Decimal",
1101 "significant_digits": 3,
1102 "spacing_px": spacing_px,
1103 "anchors": []
1104 })
1105 };
1106 let parsed = serde_json::from_value::<ContourLabelConfig>(json(0.5)).unwrap();
1107 assert_eq!(
1108 parsed.color,
1109 Color::BLACK,
1110 "older JSON without the independent label colour defaults to black"
1111 );
1112 for spacing_px in [0.0, -1.0] {
1113 let error = serde_json::from_value::<ContourLabelConfig>(json(spacing_px))
1114 .expect_err("invalid spacing must fail during deserialization");
1115 assert!(error.to_string().contains("spacing_px must be finite"));
1116 }
1117 }
1118}
1119
1120#[cfg(test)]
1121mod contour_level_tests {
1122 use super::*;
1123 use crate::config::AxisScale;
1124 use crate::default::{default_axis_options_colorbar, default_colorbar_options};
1125
1126 fn axis(min: f64, max: f64, scale: AxisScale) -> AxisOptions {
1127 let mut axis = default_axis_options_colorbar();
1128 axis.min = min;
1129 axis.max = max;
1130 axis.scale = scale;
1131 axis
1132 }
1133
1134 fn contour() -> ContourConfig {
1135 ContourConfig {
1136 levels: Vec::new(),
1137 line: DataLineStyleConfig {
1138 line_style: LineStylePreset::Solid,
1139 line_color: Color::BLACK,
1140 line_width: 1.0,
1141 },
1142 per_level_color: None,
1143 labels: None,
1144 }
1145 }
1146
1147 #[test]
1148 fn contour_label_spacing_rust_validation_applies_to_all_placement_states() {
1149 for spacing_px in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1150 let labels = ContourLabelConfig {
1151 visible: false,
1152 font_size: 12.0,
1153 color: Color::BLACK,
1154 format: LabelFormat::Decimal,
1155 significant_digits: 3,
1156 spacing_px,
1157 anchors: vec![ContourLabelAnchor {
1158 level_index: 0,
1159 x: 0.0,
1160 y: 0.0,
1161 tx: 1.0,
1162 ty: 0.0,
1163 }],
1164 bg_color: None,
1165 bg_padding_px: 0.0,
1166 };
1167 assert_eq!(
1168 labels.validate(),
1169 Err(ContourLabelConfig::INVALID_SPACING_REASON)
1170 );
1171 }
1172 }
1173
1174 #[test]
1178 fn nice_levels_are_round_and_strictly_inside() {
1179 let levels = ContourConfig::nice_levels(&axis(0.0, 100.0, AxisScale::Linear), 5).unwrap();
1180 assert_eq!(levels, vec![20.0, 40.0, 60.0, 80.0]);
1181 for level in &levels {
1182 assert!(*level > 0.0 && *level < 100.0, "{level} is not inside");
1183 }
1184 }
1185
1186 #[test]
1189 fn asking_for_more_levels_gets_more() {
1190 let sparse = ContourConfig::nice_levels(&axis(0.0, 1.0, AxisScale::Linear), 3).unwrap();
1191 let dense = ContourConfig::nice_levels(&axis(0.0, 1.0, AxisScale::Linear), 11).unwrap();
1192 assert!(
1193 dense.len() > sparse.len(),
1194 "sparse {sparse:?} vs dense {dense:?}"
1195 );
1196 for level in sparse.iter().chain(dense.iter()) {
1197 assert!(*level > 0.0 && *level < 1.0, "{level} is not inside");
1198 }
1199 }
1200
1201 #[test]
1202 fn nice_levels_accepts_exactly_the_linear_limit() {
1203 let levels =
1204 ContourConfig::nice_levels(&axis(0.0, 1025.0, AxisScale::Linear), 2001).unwrap();
1205
1206 assert_eq!(levels.len(), MAX_CONTOUR_LEVELS);
1207 assert_eq!(levels.first(), Some(&1.0));
1208 assert_eq!(levels.last(), Some(&1024.0));
1209 assert!(levels.windows(2).all(|pair| pair[1] - pair[0] == 1.0));
1210 }
1211
1212 #[test]
1213 fn nice_levels_rejects_the_1025th_linear_level() {
1214 assert_eq!(
1215 ContourConfig::nice_levels(&axis(0.0, 1026.0, AxisScale::Linear), 2001),
1216 Err(TickError::InvalidRange)
1217 );
1218 }
1219
1220 #[test]
1221 fn a_tiny_linear_spacing_is_bounded_by_the_level_limit() {
1222 assert_eq!(
1223 ContourConfig::nice_levels(&axis(0.0, 1.0, AxisScale::Linear), usize::MAX),
1224 Err(TickError::InvalidRange)
1225 );
1226 }
1227
1228 #[test]
1229 fn logarithmic_levels_are_not_silently_truncated() {
1230 let levels = ContourConfig::nice_levels(
1231 &axis(f64::from_bits(1), f64::MAX, AxisScale::Logarithmic),
1232 MAX_CONTOUR_LEVELS,
1233 )
1234 .unwrap();
1235
1236 assert_eq!(levels.len(), 632);
1237 assert_eq!(levels.first(), Some(&1e-323));
1238 assert_eq!(levels.last(), Some(&1e308));
1239 assert!(levels.windows(2).all(|pair| pair[0] < pair[1]));
1240 }
1241
1242 #[test]
1245 fn an_awkward_range_still_gets_round_levels() {
1246 let levels = ContourConfig::nice_levels(&axis(0.137, 9.42, AxisScale::Linear), 5).unwrap();
1247 assert!(!levels.is_empty());
1248 for level in &levels {
1249 assert!(
1250 (level / 2.0).fract().abs() < 1e-9,
1251 "{level} is not a multiple of the spacing"
1252 );
1253 }
1254 }
1255
1256 #[test]
1259 fn a_logarithmic_range_gets_decades() {
1260 let levels =
1261 ContourConfig::nice_levels(&axis(1.0, 10_000.0, AxisScale::Logarithmic), 5).unwrap();
1262 assert_eq!(levels, vec![10.0, 100.0, 1000.0]);
1263 }
1264
1265 #[test]
1266 fn target_count_zero_keeps_its_existing_one_tick_correction() {
1267 let corrected =
1268 ContourConfig::nice_levels(&axis(1.0, 10_000.0, AxisScale::Logarithmic), 0).unwrap();
1269 let explicit_one =
1270 ContourConfig::nice_levels(&axis(1.0, 10_000.0, AxisScale::Logarithmic), 1).unwrap();
1271 assert_eq!(corrected, explicit_one);
1272 }
1273
1274 #[test]
1275 fn a_valid_log_range_without_an_internal_decade_is_empty() {
1276 assert_eq!(
1277 ContourConfig::nice_levels(&axis(2.0, 9.0, AxisScale::Logarithmic), 5),
1278 Ok(Vec::new())
1279 );
1280 }
1281
1282 #[test]
1285 fn a_degenerate_range_is_refused() {
1286 assert!(ContourConfig::nice_levels(&axis(1.0, 1.0, AxisScale::Linear), 5).is_err());
1287 assert!(ContourConfig::nice_levels(&axis(-1.0, 10.0, AxisScale::Logarithmic), 5).is_err());
1288 }
1289
1290 #[test]
1294 fn set_nice_levels_writes_the_levels_and_keeps_the_colours() {
1295 let mut cfg = contour();
1296 cfg.per_level_color = Some(vec![Color::BLACK]);
1297 cfg.set_nice_levels(&axis(0.0, 100.0, AxisScale::Linear), 5)
1298 .unwrap();
1299 assert_eq!(cfg.levels, vec![20.0, 40.0, 60.0, 80.0]);
1300 assert_eq!(cfg.per_level_color, Some(vec![Color::BLACK]));
1301 }
1302
1303 #[test]
1304 fn set_nice_levels_preserves_the_config_when_the_limit_is_exceeded() {
1305 let mut cfg = contour();
1306 cfg.levels = vec![42.0];
1307 cfg.per_level_color = Some(vec![Color::BLACK]);
1308
1309 assert_eq!(
1310 cfg.set_nice_levels(&axis(0.0, 1026.0, AxisScale::Linear), 2001),
1311 Err(TickError::InvalidRange)
1312 );
1313 assert_eq!(cfg.levels, vec![42.0]);
1314 assert_eq!(cfg.per_level_color, Some(vec![Color::BLACK]));
1315 }
1316
1317 #[test]
1318 fn set_nice_levels_preserves_all_state_on_invalid_bounds() {
1319 let mut cfg = contour();
1320 cfg.levels = vec![42.0];
1321 cfg.per_level_color = Some(vec![Color::BLACK]);
1322 let before = cfg.clone();
1323
1324 assert_eq!(
1325 cfg.set_nice_levels(&axis(1.0, f64::INFINITY, AxisScale::Logarithmic), 5),
1326 Err(TickError::InvalidRange)
1327 );
1328 assert_eq!(cfg, before);
1329 }
1330
1331 #[test]
1332 fn set_nice_levels_replaces_levels_with_a_successful_empty_result() {
1333 let mut cfg = contour();
1334 cfg.levels = vec![42.0];
1335 cfg.per_level_color = Some(vec![Color::BLACK]);
1336
1337 cfg.set_nice_levels(&axis(2.0, 9.0, AxisScale::Logarithmic), 5)
1338 .unwrap();
1339 assert!(cfg.levels.is_empty());
1340 assert_eq!(cfg.per_level_color, Some(vec![Color::BLACK]));
1341 }
1342
1343 #[test]
1346 fn colormap_colours_come_from_color_for_z() {
1347 let mut bar = default_colorbar_options();
1348 bar.axis.min = 0.0;
1349 bar.axis.max = 100.0;
1350 let mut cfg = contour();
1351 cfg.set_nice_levels(&bar.axis, 5).unwrap();
1352 cfg.set_colormap_colors(&bar);
1353 let colors = cfg.per_level_color.as_ref().expect("colours written");
1354 assert_eq!(colors.len(), cfg.levels.len());
1355 for (level, color) in cfg.levels.iter().zip(colors) {
1356 assert_eq!(*color, bar.color_for_z(*level));
1357 }
1358 }
1359}