use crate::color::Color;
use crate::config::{AxisOptions, AxisScale};
use crate::data::ColumnId;
use crate::format::LabelFormat;
use crate::line::LineStylePreset;
use crate::text::RichText;
use crate::tick::TickError;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataConfig {
pub data_id: String,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SeriesConfig {
pub series_id: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub source_id: Option<String>,
pub label: Option<RichText>,
pub x_column: ColumnId,
pub y_column: ColumnId,
pub render_type: DataRenderType,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ErrorRef {
Symmetric { column: ColumnId },
Asymmetric { lower: ColumnId, upper: ColumnId },
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DataRenderType {
Scatter {
scatter: DataScatterStyleConfig,
},
Line {
line: DataLineStyleConfig,
},
ScatterLine {
scatter: DataScatterStyleConfig,
line: DataLineStyleConfig,
},
ScatterErrorbarX {
scatter: DataScatterStyleConfig,
err_x: ErrorRef,
err_style: DataErrorBarStyleConfig,
},
ScatterErrorbarY {
scatter: DataScatterStyleConfig,
err_y: ErrorRef,
err_style: DataErrorBarStyleConfig,
},
ScatterErrorbarXY {
scatter: DataScatterStyleConfig,
err_x: ErrorRef,
err_y: ErrorRef,
err_style: DataErrorBarStyleConfig,
},
LineScatterErrorbarX {
scatter: DataScatterStyleConfig,
line: DataLineStyleConfig,
err_x: ErrorRef,
err_style: DataErrorBarStyleConfig,
},
LineScatterErrorbarY {
scatter: DataScatterStyleConfig,
line: DataLineStyleConfig,
err_y: ErrorRef,
err_style: DataErrorBarStyleConfig,
},
LineScatterErrorbarXY {
scatter: DataScatterStyleConfig,
line: DataLineStyleConfig,
err_x: ErrorRef,
err_y: ErrorRef,
err_style: DataErrorBarStyleConfig,
},
Histogram {
bar: DataBarStyleConfig,
},
Heatmap {
matrix: MatrixRef,
fill: FieldFillConfig,
},
Contour {
matrix: MatrixRef,
contour: ContourConfig,
},
HeatmapContour {
matrix: MatrixRef,
fill: FieldFillConfig,
contour: ContourConfig,
},
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataLineStyleConfig {
pub line_style: LineStylePreset,
pub line_color: Color,
pub line_width: f32,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataScatterStyleConfig {
pub point_color: Color,
pub point_shape: ScatterShape,
pub point_size: f32,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub point_style_table: Option<Vec<DataScatterPointStyleConfig>>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub point_style_index_column: Option<ColumnId>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub point_style_overrides: Option<Vec<DataScatterPointStyleOverride>>,
}
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataScatterPointStyleConfig {
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub point_color: Option<Color>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub point_shape: Option<ScatterShape>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub point_size: Option<f32>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataScatterPointStyleOverride {
pub index: usize,
#[cfg_attr(feature = "serde", serde(flatten))]
pub style: DataScatterPointStyleConfig,
}
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataErrorBarPointStyleConfig {
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub error_bar_color: Option<Color>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub error_bar_width: Option<f32>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub error_bar_cap_size: Option<f32>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub cap_width: Option<f32>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataErrorBarPointStyleOverride {
pub index: usize,
#[cfg_attr(feature = "serde", serde(flatten))]
pub style: DataErrorBarPointStyleConfig,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataErrorBarStyleConfig {
pub error_bar_color: Color,
pub error_bar_width: f32,
pub error_bar_cap_size: f32,
pub cap_width: f32,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub error_bar_style_table: Option<Vec<DataErrorBarPointStyleConfig>>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub error_bar_style_index_column: Option<ColumnId>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub error_bar_style_overrides: Option<Vec<DataErrorBarPointStyleOverride>>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MatrixRef {
pub columns: Vec<ColumnId>,
pub orientation: MatrixOrientation,
pub grid_layout: GridLayout,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum MatrixOrientation {
ColumnsAreX,
ColumnsAreY,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum GridLayout {
Edges,
Centers,
}
impl MatrixRef {
pub fn effective_extent(
&self,
x_len: usize,
y_len: usize,
shortest_column_len: usize,
) -> (usize, usize) {
let (along, across) = self.coordinate_cells(x_len, y_len);
(
self.columns.len().min(along),
shortest_column_len.min(across),
)
}
pub fn coordinate_cells(&self, x_len: usize, y_len: usize) -> (usize, usize) {
let (along, across) = match self.orientation {
MatrixOrientation::ColumnsAreX => (x_len, y_len),
MatrixOrientation::ColumnsAreY => (y_len, x_len),
};
let cells = |coordinates: usize| match self.grid_layout {
GridLayout::Edges => coordinates.saturating_sub(1),
GridLayout::Centers => coordinates,
};
(cells(along), cells(across))
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FieldFillConfig {
pub mode: FillMode,
pub shading: Shading,
pub opacity: f32,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FillMode {
Continuous,
Bands,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Shading {
Flat,
Interpolated,
}
pub const MAX_CONTOUR_LEVELS: usize = 1024;
fn push_contour_level(levels: &mut Vec<f64>, value: f64) -> Result<(), TickError> {
if levels.len() == MAX_CONTOUR_LEVELS {
return Err(TickError::InvalidRange);
}
levels.push(value);
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ContourConfig {
pub levels: Vec<f64>,
pub line: DataLineStyleConfig,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub per_level_color: Option<Vec<Color>>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub labels: Option<ContourLabelConfig>,
}
impl ContourConfig {
pub fn nice_levels(axis: &AxisOptions, target_count: usize) -> Result<Vec<f64>, TickError> {
let plan = AxisOptions::compute_nice_ticks(
axis.scale.clone(),
axis.min,
axis.max,
target_count.max(1),
)?;
let mut out = Vec::new();
let inside = |v: f64| v > axis.min && v < axis.max;
match axis.scale {
AxisScale::Linear => {
let step = plan.major_spacing;
if !(step.is_finite() && step > 0.0) {
return Err(TickError::InvalidRange);
}
let first = ((axis.min / step) - 1e-9).ceil() as i64;
let last = ((axis.max / step) + 1e-9).floor() as i64;
for i in first..=last {
let value = i as f64 * step;
if inside(value) {
push_contour_level(&mut out, value)?;
}
}
}
AxisScale::Logarithmic => {
let step = plan.major_spacing.max(1.0);
let start = axis.min.log10().ceil();
let end = axis.max.log10().floor();
if !(step.is_finite()
&& step <= i32::MAX as f64
&& start.is_finite()
&& start >= i32::MIN as f64
&& start <= i32::MAX as f64
&& end.is_finite()
&& end >= i32::MIN as f64
&& end <= i32::MAX as f64)
{
return Err(TickError::InvalidRange);
}
let step = step as i32;
let start = start as i32;
let end = end as i32;
if start > end {
return Ok(out);
}
let exponent_span = end.checked_sub(start).ok_or(TickError::InvalidRange)?;
let iteration_count = exponent_span
.checked_div(step)
.and_then(|count| count.checked_add(1))
.and_then(|count| usize::try_from(count).ok())
.ok_or(TickError::InvalidRange)?;
let mut exponent = start;
for index in 0..iteration_count {
let value = 10f64.powf(f64::from(exponent));
if inside(value) {
push_contour_level(&mut out, value)?;
}
if index + 1 < iteration_count {
exponent = exponent.checked_add(step).ok_or(TickError::InvalidRange)?;
}
}
}
}
Ok(out)
}
pub fn set_nice_levels(
&mut self,
axis: &AxisOptions,
target_count: usize,
) -> Result<(), TickError> {
self.levels = Self::nice_levels(axis, target_count)?;
Ok(())
}
pub fn set_colormap_colors(&mut self, bar: &crate::config::ColorBarOptions) {
self.per_level_color = Some(
self.levels
.iter()
.map(|level| bar.color_for_z(*level))
.collect(),
);
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ContourLabelConfig {
pub visible: bool,
pub font_size: f32,
#[cfg_attr(feature = "serde", serde(default = "default_contour_label_color"))]
pub color: Color,
pub format: LabelFormat,
pub significant_digits: u8,
#[cfg_attr(
feature = "serde",
serde(deserialize_with = "deserialize_contour_label_spacing_px")
)]
pub spacing_px: f32,
pub anchors: Vec<ContourLabelAnchor>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub bg_color: Option<Color>,
#[cfg_attr(feature = "serde", serde(default))]
pub bg_padding_px: f32,
}
#[cfg(feature = "serde")]
fn default_contour_label_color() -> Color {
Color::BLACK
}
impl ContourLabelConfig {
pub const INVALID_SPACING_REASON: &'static str =
"contour label spacing_px must be finite and greater than zero";
pub fn validate(&self) -> Result<(), &'static str> {
if !self.spacing_px.is_finite() || self.spacing_px <= 0.0 {
return Err(Self::INVALID_SPACING_REASON);
}
Ok(())
}
}
#[cfg(feature = "serde")]
fn deserialize_contour_label_spacing_px<'de, D>(deserializer: D) -> Result<f32, D::Error>
where
D: serde::Deserializer<'de>,
{
let spacing_px = <f32 as serde::Deserialize>::deserialize(deserializer)?;
if !spacing_px.is_finite() || spacing_px <= 0.0 {
return Err(serde::de::Error::custom(
ContourLabelConfig::INVALID_SPACING_REASON,
));
}
Ok(spacing_px)
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ContourLabelAnchor {
pub level_index: u32,
pub x: f64,
pub y: f64,
pub tx: f64,
pub ty: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataBarStyleConfig {
pub fill_color: Color,
pub border_color: Color,
pub border_width: f32,
pub baseline: f64,
pub gap_px: f32,
#[cfg_attr(feature = "serde", serde(default = "default_bar_width_ratio"))]
pub width_ratio: f32,
pub orientation: BarOrientation,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub bar_style_overrides: Option<Vec<DataBarStyleOverride>>,
}
#[cfg(feature = "serde")]
fn default_bar_width_ratio() -> f32 {
1.0
}
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataBarBinStyleConfig {
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub fill_color: Option<Color>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub border_color: Option<Color>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub border_width: Option<f32>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub gap_px: Option<f32>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub width_ratio: Option<f32>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DataBarStyleOverride {
pub index: usize,
#[cfg_attr(feature = "serde", serde(flatten))]
pub style: DataBarBinStyleConfig,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BarOrientation {
Vertical,
Horizontal,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ScatterShape {
Circle,
Square,
Triangle,
Diamond,
Cross,
CircleFilled,
SquareFilled,
TriangleFilled,
DiamondFilled,
TriangleDown,
TriangleLeft,
TriangleRight,
Plus,
Pentagon,
Hexagon,
Octagon,
Star,
TriangleDownFilled,
TriangleLeftFilled,
TriangleRightFilled,
PlusFilled,
CrossFilled,
PentagonFilled,
HexagonFilled,
OctagonFilled,
StarFilled,
}
#[cfg(test)]
mod matrix_tests {
use super::*;
fn matrix(
orientation: MatrixOrientation,
grid_layout: GridLayout,
columns: usize,
) -> MatrixRef {
MatrixRef {
columns: (0..columns).map(|i| format!("z{i}")).collect(),
orientation,
grid_layout,
}
}
#[test]
fn a_well_formed_grid_uses_everything_it_declares() {
let centers = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Centers, 10);
assert_eq!(centers.effective_extent(10, 5, 5), (10, 5));
let edges = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Edges, 10);
assert_eq!(edges.effective_extent(11, 6, 5), (10, 5));
}
#[test]
fn a_mismatched_grid_shrinks_to_the_smallest_common_extent() {
let m = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Centers, 10);
assert_eq!(m.effective_extent(4, 5, 5), (4, 5));
assert_eq!(m.effective_extent(10, 5, 2), (10, 2));
assert_eq!(m.effective_extent(10, 3, 5), (10, 3));
assert_eq!(m.effective_extent(0, 0, 0), (0, 0));
}
#[test]
fn one_edge_bounds_no_cell() {
let m = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Edges, 10);
assert_eq!(m.effective_extent(1, 1, 5), (0, 0));
assert_eq!(m.effective_extent(0, 0, 5), (0, 0));
}
#[test]
fn orientation_decides_which_coordinate_bounds_the_columns() {
let columns_are_x = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Centers, 10);
let columns_are_y = matrix(MatrixOrientation::ColumnsAreY, GridLayout::Centers, 10);
assert_eq!(columns_are_x.effective_extent(4, 20, 20), (4, 20));
assert_eq!(columns_are_y.effective_extent(4, 20, 20), (10, 4));
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
use super::{
DataBarStyleConfig, DataErrorBarPointStyleConfig, DataErrorBarPointStyleOverride,
DataErrorBarStyleConfig, DataRenderType, DataScatterPointStyleConfig,
DataScatterPointStyleOverride, DataScatterStyleConfig, ScatterShape, SeriesConfig,
};
use crate::color::Color;
fn base_series_json() -> serde_json::Value {
serde_json::json!({
"series_id": "s",
"label": null,
"x_column": "x",
"y_column": "y",
"render_type": {
"Scatter": {
"scatter": {
"point_color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 },
"point_shape": "Circle",
"point_size": 3.0
}
}
}
})
}
#[test]
fn old_series_json_without_additive_fields_still_parses() {
let cfg: SeriesConfig =
serde_json::from_value(base_series_json()).expect("old series shape parses");
assert_eq!(cfg.source_id, None);
let DataRenderType::Scatter { scatter } = &cfg.render_type else {
panic!("expected scatter");
};
assert_eq!(scatter.point_style_table, None);
assert_eq!(scatter.point_style_index_column, None);
assert_eq!(scatter.point_style_overrides, None);
let json = serde_json::to_value(cfg).expect("serialize");
assert!(json.get("source_id").is_none());
let scatter = &json["render_type"]["Scatter"]["scatter"];
assert!(scatter.get("point_style_table").is_none());
assert!(scatter.get("point_style_index_column").is_none());
assert!(scatter.get("point_style_overrides").is_none());
}
#[test]
fn point_style_mapping_round_trips_with_flattened_override() {
let scatter = DataScatterStyleConfig {
point_color: Color::BLACK,
point_shape: ScatterShape::Circle,
point_size: 3.0,
point_style_table: Some(vec![
DataScatterPointStyleConfig {
point_color: Some(Color::from_rgb8(255, 0, 0)),
point_shape: None,
point_size: Some(5.0),
},
DataScatterPointStyleConfig {
point_color: None,
point_shape: Some(ScatterShape::DiamondFilled),
point_size: None,
},
]),
point_style_index_column: Some("style_idx".into()),
point_style_overrides: Some(vec![DataScatterPointStyleOverride {
index: 7,
style: DataScatterPointStyleConfig {
point_color: None,
point_shape: Some(ScatterShape::StarFilled),
point_size: Some(9.0),
},
}]),
};
let json = serde_json::to_value(&scatter).expect("serialize scatter style");
assert_eq!(json["point_style_index_column"], "style_idx");
assert_eq!(json["point_style_overrides"][0]["index"], 7);
assert_eq!(
json["point_style_overrides"][0]["point_shape"],
"StarFilled"
);
assert_eq!(json["point_style_overrides"][0]["point_size"], 9.0);
assert!(json["point_style_overrides"][0].get("style").is_none());
let back: DataScatterStyleConfig =
serde_json::from_value(json).expect("parse scatter style");
assert_eq!(back, scatter);
}
#[test]
fn errorbar_style_mapping_round_trips_with_flattened_override() {
let err = DataErrorBarStyleConfig {
error_bar_color: Color::BLACK,
error_bar_width: 1.0,
error_bar_cap_size: 3.0,
cap_width: 1.0,
error_bar_style_table: Some(vec![
DataErrorBarPointStyleConfig {
error_bar_color: Some(Color::from_rgb8(255, 0, 0)),
error_bar_width: Some(2.0),
error_bar_cap_size: None,
cap_width: None,
},
DataErrorBarPointStyleConfig {
error_bar_color: None,
error_bar_width: None,
error_bar_cap_size: Some(8.0),
cap_width: Some(3.0),
},
]),
error_bar_style_index_column: Some("err_style_idx".into()),
error_bar_style_overrides: Some(vec![DataErrorBarPointStyleOverride {
index: 4,
style: DataErrorBarPointStyleConfig {
error_bar_color: None,
error_bar_width: Some(4.0),
error_bar_cap_size: None,
cap_width: Some(2.0),
},
}]),
};
let json = serde_json::to_value(&err).expect("serialize errorbar style");
assert_eq!(json["error_bar_style_index_column"], "err_style_idx");
assert_eq!(json["error_bar_style_overrides"][0]["index"], 4);
assert_eq!(json["error_bar_style_overrides"][0]["error_bar_width"], 4.0);
assert_eq!(json["error_bar_style_overrides"][0]["cap_width"], 2.0);
assert!(json["error_bar_style_overrides"][0].get("style").is_none());
let back: DataErrorBarStyleConfig =
serde_json::from_value(json).expect("parse errorbar style");
assert_eq!(back, err);
}
#[test]
fn legacy_histogram_style_defaults_to_full_width_without_overrides() {
let legacy = serde_json::json!({
"fill_color": { "r": 0.2, "g": 0.3, "b": 0.4, "a": 1.0 },
"border_color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 },
"border_width": 1.0,
"baseline": 0.0,
"gap_px": 2.0,
"orientation": "Vertical"
});
let parsed: DataBarStyleConfig =
serde_json::from_value(legacy).expect("parse legacy histogram style");
assert_eq!(parsed.width_ratio, 1.0);
assert_eq!(parsed.bar_style_overrides, None);
}
#[test]
fn field_render_types_round_trip() {
use super::{
ContourConfig, ContourLabelAnchor, ContourLabelConfig, DataBarBinStyleConfig,
DataBarStyleConfig, DataBarStyleOverride, DataLineStyleConfig, FieldFillConfig,
FillMode, GridLayout, MatrixOrientation, MatrixRef, Shading,
};
use crate::format::LabelFormat;
use crate::line::LineStylePreset;
let matrix = MatrixRef {
columns: vec!["z0".into(), "z1".into()],
orientation: MatrixOrientation::ColumnsAreX,
grid_layout: GridLayout::Edges,
};
let fill = FieldFillConfig {
mode: FillMode::Bands,
shading: Shading::Interpolated,
opacity: 0.8,
};
let contour = ContourConfig {
levels: vec![1.0, 2.0, 4.0],
line: DataLineStyleConfig {
line_style: LineStylePreset::Solid,
line_color: Color::BLACK,
line_width: 1.5,
},
per_level_color: Some(vec![Color::from_rgb8(255, 0, 0)]),
labels: Some(ContourLabelConfig {
visible: true,
font_size: 11.0,
color: Color::BLACK,
format: LabelFormat::Decimal,
significant_digits: 3,
spacing_px: 120.0,
anchors: vec![ContourLabelAnchor {
level_index: 1,
x: 0.25,
y: 0.5,
tx: 1.0,
ty: 0.0,
}],
bg_color: None,
bg_padding_px: 0.0,
}),
};
let cases = [
DataRenderType::Histogram {
bar: DataBarStyleConfig {
fill_color: Color::from_rgb8(70, 130, 180),
border_color: Color::BLACK,
border_width: 1.0,
baseline: 0.0,
gap_px: 1.0,
width_ratio: 0.8,
orientation: super::BarOrientation::Vertical,
bar_style_overrides: Some(vec![DataBarStyleOverride {
index: 2,
style: DataBarBinStyleConfig {
fill_color: Some(Color::from_rgb8(255, 0, 0)),
border_color: None,
border_width: Some(3.0),
gap_px: None,
width_ratio: Some(0.5),
},
}]),
},
},
DataRenderType::Heatmap {
matrix: matrix.clone(),
fill: fill.clone(),
},
DataRenderType::Contour {
matrix: matrix.clone(),
contour: contour.clone(),
},
DataRenderType::HeatmapContour {
matrix,
fill,
contour,
},
];
for case in cases {
let json = serde_json::to_value(&case).expect("serialize render type");
let back: DataRenderType = serde_json::from_value(json).expect("parse render type");
assert_eq!(back, case);
}
}
#[test]
fn contour_optional_keys_are_omitted_when_absent() {
use super::{ContourConfig, DataLineStyleConfig, FillMode};
use crate::line::LineStylePreset;
let contour = ContourConfig {
levels: vec![1.0],
line: DataLineStyleConfig {
line_style: LineStylePreset::Solid,
line_color: Color::BLACK,
line_width: 1.0,
},
per_level_color: None,
labels: None,
};
let json = serde_json::to_value(&contour).expect("serialize contour");
assert!(json.get("per_level_color").is_none());
assert!(json.get("labels").is_none());
let back: ContourConfig = serde_json::from_value(json).expect("parse contour");
assert_eq!(back, contour);
assert_eq!(back.per_level_color, None);
assert_eq!(
serde_json::to_value(FillMode::Continuous).expect("serialize fill mode"),
serde_json::json!("Continuous")
);
}
#[test]
fn contour_label_spacing_serde_requires_a_finite_positive_value() {
use super::ContourLabelConfig;
let json = |spacing_px: f32| {
serde_json::json!({
"visible": false,
"font_size": 12.0,
"format": "Decimal",
"significant_digits": 3,
"spacing_px": spacing_px,
"anchors": []
})
};
let parsed = serde_json::from_value::<ContourLabelConfig>(json(0.5)).unwrap();
assert_eq!(
parsed.color,
Color::BLACK,
"older JSON without the independent label colour defaults to black"
);
for spacing_px in [0.0, -1.0] {
let error = serde_json::from_value::<ContourLabelConfig>(json(spacing_px))
.expect_err("invalid spacing must fail during deserialization");
assert!(error.to_string().contains("spacing_px must be finite"));
}
}
}
#[cfg(test)]
mod contour_level_tests {
use super::*;
use crate::config::AxisScale;
use crate::default::{default_axis_options_colorbar, default_colorbar_options};
fn axis(min: f64, max: f64, scale: AxisScale) -> AxisOptions {
let mut axis = default_axis_options_colorbar();
axis.min = min;
axis.max = max;
axis.scale = scale;
axis
}
fn contour() -> ContourConfig {
ContourConfig {
levels: Vec::new(),
line: DataLineStyleConfig {
line_style: LineStylePreset::Solid,
line_color: Color::BLACK,
line_width: 1.0,
},
per_level_color: None,
labels: None,
}
}
#[test]
fn contour_label_spacing_rust_validation_applies_to_all_placement_states() {
for spacing_px in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let labels = ContourLabelConfig {
visible: false,
font_size: 12.0,
color: Color::BLACK,
format: LabelFormat::Decimal,
significant_digits: 3,
spacing_px,
anchors: vec![ContourLabelAnchor {
level_index: 0,
x: 0.0,
y: 0.0,
tx: 1.0,
ty: 0.0,
}],
bg_color: None,
bg_padding_px: 0.0,
};
assert_eq!(
labels.validate(),
Err(ContourLabelConfig::INVALID_SPACING_REASON)
);
}
}
#[test]
fn nice_levels_are_round_and_strictly_inside() {
let levels = ContourConfig::nice_levels(&axis(0.0, 100.0, AxisScale::Linear), 5).unwrap();
assert_eq!(levels, vec![20.0, 40.0, 60.0, 80.0]);
for level in &levels {
assert!(*level > 0.0 && *level < 100.0, "{level} is not inside");
}
}
#[test]
fn asking_for_more_levels_gets_more() {
let sparse = ContourConfig::nice_levels(&axis(0.0, 1.0, AxisScale::Linear), 3).unwrap();
let dense = ContourConfig::nice_levels(&axis(0.0, 1.0, AxisScale::Linear), 11).unwrap();
assert!(
dense.len() > sparse.len(),
"sparse {sparse:?} vs dense {dense:?}"
);
for level in sparse.iter().chain(dense.iter()) {
assert!(*level > 0.0 && *level < 1.0, "{level} is not inside");
}
}
#[test]
fn nice_levels_accepts_exactly_the_linear_limit() {
let levels =
ContourConfig::nice_levels(&axis(0.0, 1025.0, AxisScale::Linear), 2001).unwrap();
assert_eq!(levels.len(), MAX_CONTOUR_LEVELS);
assert_eq!(levels.first(), Some(&1.0));
assert_eq!(levels.last(), Some(&1024.0));
assert!(levels.windows(2).all(|pair| pair[1] - pair[0] == 1.0));
}
#[test]
fn nice_levels_rejects_the_1025th_linear_level() {
assert_eq!(
ContourConfig::nice_levels(&axis(0.0, 1026.0, AxisScale::Linear), 2001),
Err(TickError::InvalidRange)
);
}
#[test]
fn a_tiny_linear_spacing_is_bounded_by_the_level_limit() {
assert_eq!(
ContourConfig::nice_levels(&axis(0.0, 1.0, AxisScale::Linear), usize::MAX),
Err(TickError::InvalidRange)
);
}
#[test]
fn logarithmic_levels_are_not_silently_truncated() {
let levels = ContourConfig::nice_levels(
&axis(f64::from_bits(1), f64::MAX, AxisScale::Logarithmic),
MAX_CONTOUR_LEVELS,
)
.unwrap();
assert_eq!(levels.len(), 632);
assert_eq!(levels.first(), Some(&1e-323));
assert_eq!(levels.last(), Some(&1e308));
assert!(levels.windows(2).all(|pair| pair[0] < pair[1]));
}
#[test]
fn an_awkward_range_still_gets_round_levels() {
let levels = ContourConfig::nice_levels(&axis(0.137, 9.42, AxisScale::Linear), 5).unwrap();
assert!(!levels.is_empty());
for level in &levels {
assert!(
(level / 2.0).fract().abs() < 1e-9,
"{level} is not a multiple of the spacing"
);
}
}
#[test]
fn a_logarithmic_range_gets_decades() {
let levels =
ContourConfig::nice_levels(&axis(1.0, 10_000.0, AxisScale::Logarithmic), 5).unwrap();
assert_eq!(levels, vec![10.0, 100.0, 1000.0]);
}
#[test]
fn target_count_zero_keeps_its_existing_one_tick_correction() {
let corrected =
ContourConfig::nice_levels(&axis(1.0, 10_000.0, AxisScale::Logarithmic), 0).unwrap();
let explicit_one =
ContourConfig::nice_levels(&axis(1.0, 10_000.0, AxisScale::Logarithmic), 1).unwrap();
assert_eq!(corrected, explicit_one);
}
#[test]
fn a_valid_log_range_without_an_internal_decade_is_empty() {
assert_eq!(
ContourConfig::nice_levels(&axis(2.0, 9.0, AxisScale::Logarithmic), 5),
Ok(Vec::new())
);
}
#[test]
fn a_degenerate_range_is_refused() {
assert!(ContourConfig::nice_levels(&axis(1.0, 1.0, AxisScale::Linear), 5).is_err());
assert!(ContourConfig::nice_levels(&axis(-1.0, 10.0, AxisScale::Logarithmic), 5).is_err());
}
#[test]
fn set_nice_levels_writes_the_levels_and_keeps_the_colours() {
let mut cfg = contour();
cfg.per_level_color = Some(vec![Color::BLACK]);
cfg.set_nice_levels(&axis(0.0, 100.0, AxisScale::Linear), 5)
.unwrap();
assert_eq!(cfg.levels, vec![20.0, 40.0, 60.0, 80.0]);
assert_eq!(cfg.per_level_color, Some(vec![Color::BLACK]));
}
#[test]
fn set_nice_levels_preserves_the_config_when_the_limit_is_exceeded() {
let mut cfg = contour();
cfg.levels = vec![42.0];
cfg.per_level_color = Some(vec![Color::BLACK]);
assert_eq!(
cfg.set_nice_levels(&axis(0.0, 1026.0, AxisScale::Linear), 2001),
Err(TickError::InvalidRange)
);
assert_eq!(cfg.levels, vec![42.0]);
assert_eq!(cfg.per_level_color, Some(vec![Color::BLACK]));
}
#[test]
fn set_nice_levels_preserves_all_state_on_invalid_bounds() {
let mut cfg = contour();
cfg.levels = vec![42.0];
cfg.per_level_color = Some(vec![Color::BLACK]);
let before = cfg.clone();
assert_eq!(
cfg.set_nice_levels(&axis(1.0, f64::INFINITY, AxisScale::Logarithmic), 5),
Err(TickError::InvalidRange)
);
assert_eq!(cfg, before);
}
#[test]
fn set_nice_levels_replaces_levels_with_a_successful_empty_result() {
let mut cfg = contour();
cfg.levels = vec![42.0];
cfg.per_level_color = Some(vec![Color::BLACK]);
cfg.set_nice_levels(&axis(2.0, 9.0, AxisScale::Logarithmic), 5)
.unwrap();
assert!(cfg.levels.is_empty());
assert_eq!(cfg.per_level_color, Some(vec![Color::BLACK]));
}
#[test]
fn colormap_colours_come_from_color_for_z() {
let mut bar = default_colorbar_options();
bar.axis.min = 0.0;
bar.axis.max = 100.0;
let mut cfg = contour();
cfg.set_nice_levels(&bar.axis, 5).unwrap();
cfg.set_colormap_colors(&bar);
let colors = cfg.per_level_color.as_ref().expect("colours written");
assert_eq!(colors.len(), cfg.levels.len());
for (level, color) in cfg.levels.iter().zip(colors) {
assert_eq!(*color, bar.color_for_z(*level));
}
}
}