pub mod constructors;
pub use constructors::*;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::color::{Color, ColorSpace};
#[cfg(test)]
use crate::scales::value::DataColumn;
use crate::scales::value::{LinetypeStep, Value};
use crate::scales::Locale;
pub use crate::scales::{
binned_band_width, binned_band_width_at, binned_breaks, binned_map, binned_map_break, breaks,
chrome, continuous_breaks, continuous_map, continuous_minor_breaks, discrete_band_width,
discrete_breaks, discrete_map, extended_breaks, identity_map, input, linear_breaks,
linear_minor_breaks_between, log_minor_breaks, log_pretty_breaks, ordinal_map, output,
scale_type, sqrt_breaks, symlog_breaks, symlog_minor_breaks, temporal_breaks,
temporal_breaks_with_interval, temporal_minor_breaks, temporal_minor_breaks_with_interval,
transform, transform_allowed_domain, transform_forward, transform_inverse, value,
wrap_temporal_value, AxisSide, CalendarUnit, Direction, InputRange, LegendSide, OutputRange,
ScaleTypeKind, TemporalInterval, TemporalUnit, Transform, TransformKind, DEFAULT_BREAK_COUNT,
};
pub type LabelFormatter = dyn Fn(&Value, &Locale) -> String + Send + Sync;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FormatSpec {
Default,
Named(Arc<str>),
Custom,
}
enum FormatterSlot {
Default,
Named(Arc<str>, Arc<LabelFormatter>),
Custom(Arc<LabelFormatter>),
}
impl Clone for FormatterSlot {
fn clone(&self) -> Self {
match self {
Self::Default => Self::Default,
Self::Named(n, f) => Self::Named(n.clone(), f.clone()),
Self::Custom(f) => Self::Custom(f.clone()),
}
}
}
impl FormatterSlot {
fn formatter(&self) -> Option<&Arc<LabelFormatter>> {
match self {
Self::Default => None,
Self::Named(_, f) | Self::Custom(f) => Some(f),
}
}
fn spec(&self) -> FormatSpec {
match self {
Self::Default => FormatSpec::Default,
Self::Named(n, _) => FormatSpec::Named(n.clone()),
Self::Custom(_) => FormatSpec::Custom,
}
}
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum BinEdgeError {
#[error("binned scale needs at least two bin edges, got {found}")]
TooFew {
found: usize,
},
#[error("binned scale bin edge at index {index} is not finite: {value}")]
NotFinite {
index: usize,
value: f64,
},
#[error("binned scale bin edges must strictly increase, but edge {index} ({value}) does not exceed edge {previous_index} ({previous})")]
NotIncreasing {
index: usize,
value: f64,
previous_index: usize,
previous: f64,
},
}
fn check_bin_edges(edges: &[f64]) -> Result<(), BinEdgeError> {
if edges.len() < 2 {
return Err(BinEdgeError::TooFew { found: edges.len() });
}
if let Some((index, &value)) = edges.iter().enumerate().find(|(_, e)| !e.is_finite()) {
return Err(BinEdgeError::NotFinite { index, value });
}
for (i, w) in edges.windows(2).enumerate() {
if w[0] >= w[1] {
return Err(BinEdgeError::NotIncreasing {
index: i + 1,
value: w[1],
previous_index: i,
previous: w[0],
});
}
}
Ok(())
}
#[derive(Clone, Debug)]
pub enum BreaksSpec {
Explicit(Vec<Value>),
Labeled {
breaks: Vec<Value>,
labels: Vec<String>,
},
NumericInterval(f64),
TemporalInterval(TemporalInterval),
}
#[derive(Clone, Debug)]
pub enum MinorBreaksSpec {
Explicit(Vec<Value>),
CountBetween(usize),
NumericInterval(f64),
TemporalInterval(TemporalInterval),
}
pub struct Scale {
scale_type: ScaleTypeKind,
transform: Transform,
input_range: Option<InputRange>,
output_range: Option<OutputRange>,
bins: Option<Vec<f64>>,
breaks_spec: Option<BreaksSpec>,
minor_breaks_spec: Option<MinorBreaksSpec>,
color_space: ColorSpace,
direction: Direction,
formatter: FormatterSlot,
generation: u64,
breaks_cache: Mutex<Option<(u64, usize, Vec<Value>)>>,
}
impl Clone for Scale {
fn clone(&self) -> Self {
Scale {
scale_type: self.scale_type,
transform: self.transform,
input_range: self.input_range.clone(),
output_range: self.output_range.clone(),
bins: self.bins.clone(),
breaks_spec: self.breaks_spec.clone(),
minor_breaks_spec: self.minor_breaks_spec.clone(),
color_space: self.color_space,
direction: self.direction,
formatter: self.formatter.clone(),
generation: self.generation,
breaks_cache: Mutex::new(None),
}
}
}
impl Scale {
pub fn new(scale_type: ScaleTypeKind) -> Self {
Scale {
scale_type,
transform: Transform::default(),
input_range: None,
output_range: None,
bins: None,
breaks_spec: None,
minor_breaks_spec: None,
color_space: ColorSpace::default(),
direction: Direction::default(),
formatter: FormatterSlot::Default,
generation: 0,
breaks_cache: Mutex::new(None),
}
}
pub fn domain_continuous<T>(self, min: T, max: T) -> Self
where
T: Into<Value>,
{
match self.try_domain_continuous(min, max) {
Ok(scale) => scale,
Err(v) => {
panic!("domain_continuous: expected numeric or temporal endpoints, got {v:?}")
}
}
}
pub fn try_domain_continuous<T>(mut self, min: T, max: T) -> Result<Self, Value>
where
T: Into<Value>,
{
let (min, max) = (min.into(), max.into());
let lo = min.as_number().ok_or(min)?;
let hi = max.as_number().ok_or(max)?;
self.input_range = Some(InputRange::Continuous { min: lo, max: hi });
Ok(self)
}
pub fn domain_discrete(mut self, values: impl IntoIterator<Item = Value>) -> Self {
self.input_range = Some(InputRange::Discrete(values.into_iter().collect()));
self
}
pub fn with_bins(self, edges: impl IntoIterator<Item = f64>) -> Self {
match self.try_with_bins(edges) {
Ok(scale) => scale,
Err(e) => panic!("with_bins: {e}"),
}
}
pub fn try_with_bins(
mut self,
edges: impl IntoIterator<Item = f64>,
) -> Result<Self, BinEdgeError> {
let edges: Vec<f64> = edges.into_iter().collect();
check_bin_edges(&edges)?;
self.bins = Some(edges);
Ok(self)
}
pub fn range_numbers(mut self, vs: impl IntoIterator<Item = f64>) -> Self {
self.output_range = Some(OutputRange::Numbers(vs.into_iter().collect()));
self
}
pub fn range_colors(mut self, vs: impl IntoIterator<Item = Color>) -> Self {
self.output_range = Some(OutputRange::Colors(vs.into_iter().collect()));
self
}
pub fn range_strings(mut self, vs: impl IntoIterator<Item = Arc<str>>) -> Self {
self.output_range = Some(OutputRange::Strings(vs.into_iter().collect()));
self
}
pub fn range_linetypes(mut self, vs: impl IntoIterator<Item = Arc<[LinetypeStep]>>) -> Self {
self.output_range = Some(OutputRange::Linetypes(vs.into_iter().collect()));
self
}
pub fn with_transform(mut self, t: TransformKind) -> Self {
self.transform = Transform::of(t);
self
}
pub fn with_color_space(mut self, space: ColorSpace) -> Self {
self.color_space = space;
self
}
pub fn with_direction(mut self, direction: Direction) -> Self {
self.direction = direction;
self
}
pub fn set_domain_continuous<T>(&mut self, min: T, max: T)
where
T: Into<Value>,
{
if let Err(v) = self.try_set_domain_continuous(min, max) {
panic!("set_domain_continuous: expected numeric or temporal endpoints, got {v:?}");
}
}
pub fn try_set_domain_continuous<T>(&mut self, min: T, max: T) -> Result<(), Value>
where
T: Into<Value>,
{
let (min, max) = (min.into(), max.into());
let lo = min.as_number().ok_or(min)?;
let hi = max.as_number().ok_or(max)?;
self.input_range = Some(InputRange::Continuous { min: lo, max: hi });
self.bump_generation();
Ok(())
}
pub fn set_domain_discrete(&mut self, values: Vec<Value>) {
self.input_range = Some(InputRange::Discrete(values));
self.bump_generation();
}
pub fn set_bins(&mut self, edges: Vec<f64>) {
if let Err(e) = self.try_set_bins(edges) {
panic!("set_bins: {e}");
}
}
pub fn try_set_bins(&mut self, edges: Vec<f64>) -> Result<(), BinEdgeError> {
check_bin_edges(&edges)?;
self.bins = Some(edges);
self.bump_generation();
Ok(())
}
pub fn set_range_numbers(&mut self, vs: Vec<f64>) {
self.output_range = Some(OutputRange::Numbers(vs));
self.bump_generation();
}
pub fn set_range_colors(&mut self, vs: Vec<Color>) {
self.output_range = Some(OutputRange::Colors(vs));
self.bump_generation();
}
pub fn set_range_strings(&mut self, vs: Vec<Arc<str>>) {
self.output_range = Some(OutputRange::Strings(vs));
self.bump_generation();
}
pub fn set_range_linetypes(&mut self, vs: Vec<Arc<[LinetypeStep]>>) {
self.output_range = Some(OutputRange::Linetypes(vs));
self.bump_generation();
}
pub fn set_transform(&mut self, t: TransformKind) {
self.transform = Transform::of(t);
self.bump_generation();
}
pub fn set_color_space(&mut self, space: ColorSpace) {
self.color_space = space;
self.bump_generation();
}
pub fn set_direction(&mut self, direction: Direction) {
self.direction = direction;
self.bump_generation();
}
pub fn with_breaks(mut self, breaks: Vec<Value>) -> Self {
self.breaks_spec = Some(BreaksSpec::Explicit(breaks));
self
}
pub fn set_breaks(&mut self, breaks: Vec<Value>) {
self.breaks_spec = Some(BreaksSpec::Explicit(breaks));
self.bump_generation();
}
pub fn with_breaks_labeled(mut self, pairs: Vec<(Value, String)>) -> Self {
let (breaks, labels): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
self.breaks_spec = Some(BreaksSpec::Labeled { breaks, labels });
self
}
pub fn set_breaks_labeled(&mut self, pairs: Vec<(Value, String)>) {
let (breaks, labels): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
self.breaks_spec = Some(BreaksSpec::Labeled { breaks, labels });
self.bump_generation();
}
pub fn with_interval(mut self, step: f64) -> Self {
self.breaks_spec = Some(BreaksSpec::NumericInterval(step));
self
}
pub fn set_interval(&mut self, step: f64) {
self.breaks_spec = Some(BreaksSpec::NumericInterval(step));
self.bump_generation();
}
pub fn with_temporal_interval(mut self, interval: TemporalInterval) -> Self {
self.breaks_spec = Some(BreaksSpec::TemporalInterval(interval));
self
}
pub fn set_temporal_interval(&mut self, interval: TemporalInterval) {
self.breaks_spec = Some(BreaksSpec::TemporalInterval(interval));
self.bump_generation();
}
pub fn clear_breaks(&mut self) {
self.breaks_spec = None;
self.bump_generation();
}
pub fn with_minor_breaks(mut self, breaks: Vec<Value>) -> Self {
self.minor_breaks_spec = Some(MinorBreaksSpec::Explicit(breaks));
self
}
pub fn set_minor_breaks(&mut self, breaks: Vec<Value>) {
self.minor_breaks_spec = Some(MinorBreaksSpec::Explicit(breaks));
self.bump_generation();
}
pub fn with_minor_count(mut self, per_interval: usize) -> Self {
self.minor_breaks_spec = Some(MinorBreaksSpec::CountBetween(per_interval));
self
}
pub fn set_minor_count(&mut self, per_interval: usize) {
self.minor_breaks_spec = Some(MinorBreaksSpec::CountBetween(per_interval));
self.bump_generation();
}
pub fn with_minor_interval(mut self, step: f64) -> Self {
self.minor_breaks_spec = Some(MinorBreaksSpec::NumericInterval(step));
self
}
pub fn set_minor_interval(&mut self, step: f64) {
self.minor_breaks_spec = Some(MinorBreaksSpec::NumericInterval(step));
self.bump_generation();
}
pub fn with_minor_temporal_interval(mut self, interval: TemporalInterval) -> Self {
self.minor_breaks_spec = Some(MinorBreaksSpec::TemporalInterval(interval));
self
}
pub fn set_minor_temporal_interval(&mut self, interval: TemporalInterval) {
self.minor_breaks_spec = Some(MinorBreaksSpec::TemporalInterval(interval));
self.bump_generation();
}
pub fn clear_minor_breaks(&mut self) {
self.minor_breaks_spec = None;
self.bump_generation();
}
pub fn with_format<F>(mut self, f: F) -> Self
where
F: Fn(&Value, &Locale) -> String + Send + Sync + 'static,
{
self.formatter = FormatterSlot::Custom(Arc::new(f));
self
}
pub fn set_format<F>(&mut self, f: F)
where
F: Fn(&Value, &Locale) -> String + Send + Sync + 'static,
{
self.formatter = FormatterSlot::Custom(Arc::new(f));
self.bump_generation();
}
pub fn with_named_format<F>(mut self, name: impl Into<Arc<str>>, f: F) -> Self
where
F: Fn(&Value, &Locale) -> String + Send + Sync + 'static,
{
self.formatter = FormatterSlot::Named(name.into(), Arc::new(f));
self
}
pub fn set_named_format<F>(&mut self, name: impl Into<Arc<str>>, f: F)
where
F: Fn(&Value, &Locale) -> String + Send + Sync + 'static,
{
self.formatter = FormatterSlot::Named(name.into(), Arc::new(f));
self.bump_generation();
}
pub fn clear_format(&mut self) {
self.formatter = FormatterSlot::Default;
self.bump_generation();
}
pub fn format_spec(&self) -> FormatSpec {
self.formatter.spec()
}
fn bump_generation(&mut self) {
self.generation += 1;
}
pub fn map(&self, input: &Value) -> Value {
match self.scale_type {
ScaleTypeKind::Continuous | ScaleTypeKind::Temporal(_) => continuous_map(
input,
self.input_range.as_ref(),
self.output_range.as_ref(),
&self.transform,
self.color_space,
self.direction,
),
ScaleTypeKind::Discrete => discrete_map(
input,
self.input_range.as_ref(),
self.output_range.as_ref(),
self.direction,
),
ScaleTypeKind::Ordinal => ordinal_map(
input,
self.input_range.as_ref(),
self.output_range.as_ref(),
self.color_space,
self.direction,
),
ScaleTypeKind::Binned => binned_map(
input,
self.input_range.as_ref(),
self.bins.as_deref(),
self.output_range.as_ref(),
self.color_space,
self.direction,
),
ScaleTypeKind::Identity => identity_map(input),
}
}
pub fn map_break(&self, input: &Value) -> Value {
match self.scale_type {
ScaleTypeKind::Binned => {
binned_map_break(input, self.input_range.as_ref(), self.direction)
}
_ => self.map(input),
}
}
pub fn map_with_offset(&self, input: &Value, band_offset: f64) -> Value {
let base = self.map(input);
if band_offset == 0.0 {
return base;
}
let band_offset = if self.direction.is_reversed() {
-band_offset
} else {
band_offset
};
match base {
Value::Number(f) => {
let bw = self.band_width_at(input);
Value::Number(f + band_offset * bw)
}
other => other,
}
}
pub fn breaks(&self, n: usize) -> Vec<Value> {
if let Ok(cache) = self.breaks_cache.lock() {
if let Some((gen, cached_n, values)) = cache.as_ref() {
if *gen == self.generation && *cached_n == n {
return values.clone();
}
}
}
let computed = self
.breaks_spec
.as_ref()
.and_then(|spec| self.breaks_from_spec(spec))
.unwrap_or_else(|| self.breaks_auto(n));
if let Ok(mut cache) = self.breaks_cache.lock() {
*cache = Some((self.generation, n, computed.clone()));
}
computed
}
fn breaks_auto(&self, n: usize) -> Vec<Value> {
match self.scale_type {
ScaleTypeKind::Continuous => {
continuous_breaks(self.input_range.as_ref(), &self.transform, n)
}
ScaleTypeKind::Temporal(unit) => temporal_breaks(self.input_range.as_ref(), unit, n),
ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal => {
discrete_breaks(self.input_range.as_ref())
}
ScaleTypeKind::Binned => binned_breaks(self.bins.as_deref()),
ScaleTypeKind::Identity => Vec::new(),
}
}
fn breaks_from_spec(&self, spec: &BreaksSpec) -> Option<Vec<Value>> {
match spec {
BreaksSpec::Explicit(vs) => Some(vs.clone()),
BreaksSpec::Labeled { breaks, .. } => Some(breaks.clone()),
BreaksSpec::NumericInterval(step) => self.breaks_numeric_interval(*step),
BreaksSpec::TemporalInterval(interval) => match self.scale_type {
ScaleTypeKind::Temporal(unit) => Some(temporal_breaks_with_interval(
self.input_range.as_ref(),
unit,
*interval,
)),
_ => None,
},
}
}
fn breaks_numeric_interval(&self, step: f64) -> Option<Vec<Value>> {
Some(
self.numeric_interval_positions(step)?
.into_iter()
.map(Value::Number)
.collect(),
)
}
fn numeric_interval_positions(&self, step: f64) -> Option<Vec<f64>> {
if !step.is_finite() || step <= 0.0 {
return None;
}
let (min, max) = match self.input_range.as_ref()? {
InputRange::Continuous { min, max } => (*min, *max),
_ => return None,
};
if !min.is_finite() || !max.is_finite() || min > max {
return None;
}
let first_k = (min / step).ceil();
let last_k = (max / step).floor();
if last_k < first_k {
return Some(Vec::new());
}
let count = ((last_k - first_k) as usize).saturating_add(1);
let mut out = Vec::with_capacity(count);
let mut k = first_k;
while k <= last_k {
out.push(k * step);
k += 1.0;
}
Some(out)
}
pub fn minor_breaks(&self, n: usize) -> Vec<Value> {
if let Some(spec) = &self.minor_breaks_spec {
if let Some(ms) = self.minor_breaks_from_spec(spec, n) {
return ms;
}
}
self.minor_breaks_auto(n)
}
fn minor_breaks_auto(&self, n: usize) -> Vec<Value> {
match self.scale_type {
ScaleTypeKind::Continuous => {
let majors = self.breaks(n);
continuous_minor_breaks(self.input_range.as_ref(), &self.transform, &majors)
}
ScaleTypeKind::Temporal(unit) => {
if let Some(BreaksSpec::TemporalInterval(interval)) = &self.breaks_spec {
return temporal_minor_breaks_with_interval(
self.input_range.as_ref(),
unit,
*interval,
);
}
let majors = self.breaks(n);
temporal_minor_breaks(self.input_range.as_ref(), unit, &majors, n)
}
_ => Vec::new(),
}
}
fn minor_breaks_from_spec(&self, spec: &MinorBreaksSpec, n: usize) -> Option<Vec<Value>> {
match spec {
MinorBreaksSpec::Explicit(vs) => Some(vs.clone()),
MinorBreaksSpec::CountBetween(per_interval) => {
self.minor_breaks_count_between(*per_interval, n)
}
MinorBreaksSpec::NumericInterval(step) => {
let raw = self.numeric_interval_positions(*step)?;
let raw = self.drop_major_positions(raw, n, step.abs() * 1e-9);
Some(self.wrap_positions(raw))
}
MinorBreaksSpec::TemporalInterval(interval) => match self.scale_type {
ScaleTypeKind::Temporal(unit) => {
let raw: Vec<f64> =
temporal_breaks_with_interval(self.input_range.as_ref(), unit, *interval)
.iter()
.filter_map(|v| v.as_number())
.collect();
let raw = self.drop_major_positions(raw, n, 0.0);
Some(self.wrap_positions(raw))
}
_ => None,
},
}
}
fn minor_breaks_count_between(&self, per_interval: usize, n: usize) -> Option<Vec<Value>> {
if per_interval == 0 {
return Some(Vec::new());
}
let majors: Vec<f64> = self
.breaks(n)
.iter()
.filter_map(|v| v.as_number())
.collect();
if majors.len() < 2 {
return Some(Vec::new());
}
let raw = linear_minor_breaks_between(&majors, per_interval);
Some(self.wrap_positions(raw))
}
fn drop_major_positions(&self, positions: Vec<f64>, n: usize, tol: f64) -> Vec<f64> {
let majors: Vec<f64> = self
.breaks(n)
.iter()
.filter_map(|v| v.as_number())
.collect();
positions
.into_iter()
.filter(|p| !majors.iter().any(|m| (m - p).abs() <= tol))
.collect()
}
fn wrap_positions(&self, positions: Vec<f64>) -> Vec<Value> {
match self.scale_type {
ScaleTypeKind::Temporal(unit) => positions
.into_iter()
.map(|raw| wrap_temporal_value(raw, unit))
.collect(),
_ => positions.into_iter().map(Value::Number).collect(),
}
}
pub fn format(&self, v: &Value, locale: &Locale) -> String {
if let Some(BreaksSpec::Labeled { breaks, labels }) = &self.breaks_spec {
if let Some(i) = breaks.iter().position(|b| b.key_eq(v)) {
return labels[i].clone();
}
}
if let Some(f) = self.formatter.formatter() {
return f(v, locale);
}
format_value(v)
}
pub fn band_width(&self) -> f64 {
match self.scale_type {
ScaleTypeKind::Continuous | ScaleTypeKind::Temporal(_) => 0.0,
ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal => {
discrete_band_width(self.input_range.as_ref())
}
ScaleTypeKind::Binned => binned_band_width(self.bins.as_deref()),
ScaleTypeKind::Identity => 0.0,
}
}
pub fn band_width_at(&self, input: &Value) -> f64 {
match self.scale_type {
ScaleTypeKind::Binned => {
binned_band_width_at(input, self.input_range.as_ref(), self.bins.as_deref())
}
_ => self.band_width(),
}
}
pub fn scale_type_kind(&self) -> ScaleTypeKind {
self.scale_type
}
pub fn transform(&self) -> &Transform {
&self.transform
}
pub fn input_range(&self) -> Option<&InputRange> {
self.input_range.as_ref()
}
pub fn output_range(&self) -> Option<&OutputRange> {
self.output_range.as_ref()
}
pub fn direction(&self) -> Direction {
self.direction
}
pub fn color_space(&self) -> ColorSpace {
self.color_space
}
pub fn bins(&self) -> Option<&[f64]> {
self.bins.as_deref()
}
pub fn breaks_spec(&self) -> Option<&BreaksSpec> {
self.breaks_spec.as_ref()
}
pub fn minor_breaks_spec(&self) -> Option<&MinorBreaksSpec> {
self.minor_breaks_spec.as_ref()
}
pub fn default_format(v: &Value, _locale: &Locale) -> String {
format_value(v)
}
pub fn generation(&self) -> u64 {
self.generation
}
pub fn legend_equivalent_to(&self, other: &Scale, locale: &Locale) -> bool {
if self.scale_type != other.scale_type
|| self.transform != other.transform
|| self.input_range != other.input_range
|| self.bins != other.bins
{
return false;
}
let mine = self.breaks(DEFAULT_BREAK_COUNT);
let theirs = other.breaks(DEFAULT_BREAK_COUNT);
mine.len() == theirs.len()
&& mine
.iter()
.zip(theirs.iter())
.all(|(a, b)| a.key_eq(b) && self.format(a, locale) == other.format(b, locale))
}
pub fn visual_equivalent_to(&self, other: &Scale, locale: &Locale) -> bool {
self.output_range == other.output_range
&& self.color_space == other.color_space
&& self.direction == other.direction
&& self.legend_equivalent_to(other, locale)
}
}
impl std::fmt::Debug for Scale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Scale")
.field("scale_type", &self.scale_type)
.field("transform", &self.transform)
.field("input_range", &self.input_range)
.field("output_range", &self.output_range)
.field("bins", &self.bins)
.field("breaks_spec", &self.breaks_spec)
.field("minor_breaks_spec", &self.minor_breaks_spec)
.field("color_space", &self.color_space)
.field("direction", &self.direction)
.field("formatter", &self.format_spec())
.field("generation", &self.generation)
.finish()
}
}
fn format_value(v: &Value) -> String {
use crate::scales::value::Date;
match v {
Value::Number(n) => format_number(*n),
Value::String(s) => (**s).to_string(),
Value::Bool(b) => format!("{b}"),
Value::Null => "NA".to_string(),
Value::Color(c) => format!("{c:?}"),
Value::Date(d) => {
let (y, m, dd) = Date::from_days(*d).to_ymd();
format!("{y:04}-{m:02}-{dd:02}")
}
Value::DateTime(us) => {
let dt = crate::scales::value::DateTime::from_micros(*us);
let (date, time_us) = dt.split();
let (y, m, dd) = date.to_ymd();
let (h, mi, s, _us) = split_time_micros(time_us);
format!("{y:04}-{m:02}-{dd:02} {h:02}:{mi:02}:{s:02}")
}
Value::Time(ns) => {
let (h, mi, s, sub_ns) = split_time_nanos(*ns);
if sub_ns == 0 {
format!("{h:02}:{mi:02}:{s:02}")
} else {
let millis = sub_ns / 1_000_000;
format!("{h:02}:{mi:02}:{s:02}.{millis:03}")
}
}
Value::Duration(us) => {
let neg = *us < 0;
let mut abs = us.unsigned_abs();
let micros = (abs % 1_000_000) as u32;
abs /= 1_000_000;
let seconds = (abs % 60) as u32;
abs /= 60;
let minutes = (abs % 60) as u32;
abs /= 60;
let hours = abs;
let sign = if neg { "-" } else { "" };
if hours > 0 {
format!("{sign}{hours}h {minutes:02}m {seconds:02}s")
} else if minutes > 0 {
format!("{sign}{minutes}m {seconds:02}s")
} else if micros == 0 {
format!("{sign}{seconds}s")
} else {
let millis = micros / 1000;
format!("{sign}{seconds}.{millis:03}s")
}
}
Value::Linetype(p) => {
if p.is_empty() {
"solid".to_string()
} else {
let parts: Vec<String> = p
.iter()
.map(|s| match s {
LinetypeStep::Dash(f) => format!("dash({f})"),
LinetypeStep::Gap(f) => format!("gap({f})"),
LinetypeStep::Marker(name) => format!("marker({name:?})"),
})
.collect();
format!("[{}]", parts.join(", "))
}
}
Value::Geometry(g) => format!("{g:?}"),
}
}
fn format_number(n: f64) -> String {
let raw = if !n.is_finite() {
format!("{n}")
} else if n == 0.0 {
"0".to_string()
} else {
let cleaned: f64 = format!("{n:.11e}")
.parse()
.expect("formatted scientific f64 round-trips");
format!("{cleaned}")
};
raw
}
fn split_time_micros(us: i64) -> (u8, u8, u8, u32) {
let us = us.rem_euclid(86_400_000_000);
let micros_of_sec = (us % 1_000_000) as u32;
let total_secs = us / 1_000_000;
let s = (total_secs % 60) as u8;
let total_mins = total_secs / 60;
let mi = (total_mins % 60) as u8;
let h = ((total_mins / 60) % 24) as u8;
(h, mi, s, micros_of_sec)
}
fn split_time_nanos(ns: i64) -> (u8, u8, u8, u32) {
let ns = ns.rem_euclid(86_400_000_000_000);
let nanos_of_sec = (ns % 1_000_000_000) as u32;
let total_secs = ns / 1_000_000_000;
let s = (total_secs % 60) as u8;
let total_mins = total_secs / 60;
let mi = (total_mins % 60) as u8;
let h = ((total_mins / 60) % 24) as u8;
(h, mi, s, nanos_of_sec)
}
#[derive(Default, Clone, Debug)]
pub struct ScaleRegistry {
scales: HashMap<String, Scale>,
}
impl ScaleRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, name: impl Into<String>, scale: Scale) {
self.scales.insert(name.into(), scale);
}
pub fn with(mut self, name: impl Into<String>, scale: Scale) -> Self {
self.insert(name, scale);
self
}
pub fn remove(&mut self, name: &str) -> Option<Scale> {
self.scales.remove(name)
}
pub fn get(&self, name: &str) -> Option<&Scale> {
self.scales.get(name)
}
pub(crate) fn get_mut(&mut self, name: &str) -> Option<&mut Scale> {
self.scales.get_mut(name)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &Scale)> + '_ {
self.scales.iter().map(|(k, v)| (k.as_str(), v))
}
pub fn names(&self) -> impl Iterator<Item = &str> + '_ {
self.scales.keys().map(|s| s.as_str())
}
pub fn contains(&self, name: &str) -> bool {
self.scales.contains_key(name)
}
pub fn len(&self) -> usize {
self.scales.len()
}
pub fn is_empty(&self) -> bool {
self.scales.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_formatter_ignores_the_locale() {
let n = continuous(0.0..=1.0);
let d = Scale::new(ScaleTypeKind::Temporal(TemporalUnit::Date));
let date = Value::Date(crate::scales::value::Date::from_ymd(2024, 3, 7).to_days());
for loc in [Locale::EN_US, Locale::DE_DE, Locale::from("ar-EG")] {
assert_eq!(n.format(&Value::Number(0.5), &loc), "0.5");
assert_eq!(d.format(&date, &loc), "2024-03-07");
}
}
#[test]
fn a_closure_receives_the_tag_to_interpret() {
let s = continuous(0.0..=1.0).with_format(|v, locale| match locale.tag() {
"de-DE" => format!("{v:?} (de)"),
other => format!("{v:?} ({other})"),
});
assert!(s
.format(&Value::Number(0.5), &Locale::DE_DE)
.ends_with("(de)"));
assert!(s
.format(&Value::Number(0.5), &Locale::from("ar-EG"))
.ends_with("(ar-EG)"));
}
#[test]
fn breaks_memo_follows_the_generation_counter() {
let nums =
|vs: Vec<Value>| -> Vec<f64> { vs.iter().filter_map(|v| v.as_number()).collect() };
let mut s = continuous(0.0..=10.0);
let first = nums(s.breaks(5));
assert_eq!(
first,
nums(s.breaks(5)),
"a repeat call must agree with itself"
);
let coarser = nums(s.breaks(3));
assert_eq!(
coarser,
nums(s.breaks(3)),
"the tick target is part of the key"
);
s.set_domain_continuous(0.0, 1000.0);
let after = nums(s.breaks(5));
assert_ne!(
first, after,
"a domain change must not serve the stale break set"
);
assert_eq!(after, nums(s.breaks(5)));
}
#[test]
fn scale_and_registry_are_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Scale>();
assert_send_sync::<ScaleRegistry>();
}
use crate::scales::value::{Date, DateTime, Time};
fn approx(a: f64, b: f64, tol: f64, msg: &str) {
assert!((a - b).abs() < tol, "{msg}: {a} ≠ {b}");
}
#[test]
fn continuous_map_normalised() {
let s = continuous(0.0..=10.0);
approx(
s.map(&Value::Number(0.0)).as_number().unwrap(),
0.0,
1e-12,
"lo",
);
approx(
s.map(&Value::Number(5.0)).as_number().unwrap(),
0.5,
1e-12,
"mid",
);
approx(
s.map(&Value::Number(10.0)).as_number().unwrap(),
1.0,
1e-12,
"hi",
);
}
#[test]
fn continuous_extrapolates_outside_domain() {
let s = continuous(0.0..=10.0);
approx(
s.map(&Value::Number(-5.0)).as_number().unwrap(),
-0.5,
1e-12,
"below",
);
approx(
s.map(&Value::Number(15.0)).as_number().unwrap(),
1.5,
1e-12,
"above",
);
}
#[test]
fn continuous_with_numeric_range() {
let s = continuous(0.0..=1.0).range_numbers([2.0, 12.0]);
approx(
s.map(&Value::Number(0.0)).as_number().unwrap(),
2.0,
1e-12,
"lo",
);
approx(
s.map(&Value::Number(0.5)).as_number().unwrap(),
7.0,
1e-12,
"mid",
);
approx(
s.map(&Value::Number(1.0)).as_number().unwrap(),
12.0,
1e-12,
"hi",
);
}
#[test]
fn continuous_with_degenerate_domain() {
let s = continuous(5.0..=5.0);
assert_eq!(s.map(&Value::Number(5.0)).as_number(), Some(0.0));
}
#[test]
fn continuous_non_numeric_input_returns_null() {
let s = continuous(0.0..=10.0);
assert!(s.map(&Value::Null).is_null());
assert!(s.map(&Value::String("nope".into())).is_null());
}
#[test]
fn continuous_breaks_use_extended() {
let s = continuous(0.0..=10.0);
let bs = s.breaks(5);
assert!(bs.len() >= 4 && bs.len() <= 7);
assert!(bs.first().unwrap().key_eq(&Value::Number(0.0)));
assert!(bs.last().unwrap().key_eq(&Value::Number(10.0)));
}
#[test]
fn discrete_band_centres_no_range() {
let s = discrete(["a", "b", "c"].into_iter().map(Into::into));
approx(
s.map(&Value::from("a")).as_number().unwrap(),
1.0 / 6.0,
1e-12,
"a",
);
approx(
s.map(&Value::from("b")).as_number().unwrap(),
0.5,
1e-12,
"b",
);
approx(
s.map(&Value::from("c")).as_number().unwrap(),
5.0 / 6.0,
1e-12,
"c",
);
}
#[test]
fn discrete_unknown_category_is_null() {
let s = discrete(["a", "b"].into_iter().map(Into::into));
assert!(s.map(&Value::from("missing")).is_null());
}
#[test]
fn discrete_band_width() {
let s = discrete(["a", "b", "c", "d"].into_iter().map(Into::into));
approx(s.band_width(), 0.25, 1e-12, "1/4");
}
#[test]
fn ordinal_color_round_trip() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let green = Color::new([0.0, 1.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let s = ordinal(["A", "B", "C"]).range_colors([red, green, blue]);
assert_eq!(s.map(&Value::from("A")).as_color(), Some(red));
assert_eq!(s.map(&Value::from("B")).as_color(), Some(green));
assert_eq!(s.map(&Value::from("C")).as_color(), Some(blue));
assert!(s.map(&Value::from("D")).is_null());
}
#[test]
fn ordinal_with_numeric_range_returns_pt() {
let s = Scale::new(ScaleTypeKind::Ordinal)
.domain_discrete(["S", "M", "L"].into_iter().map(Into::into))
.range_numbers([4.0, 8.0, 12.0]);
assert_eq!(s.map(&Value::from("S")).as_number(), Some(4.0));
assert_eq!(s.map(&Value::from("L")).as_number(), Some(12.0));
}
fn lt_dash_gap(d: f64, g: f64) -> Arc<[LinetypeStep]> {
Arc::from(vec![LinetypeStep::Dash(d), LinetypeStep::Gap(g)])
}
fn lt_solid() -> Arc<[LinetypeStep]> {
Arc::from(Vec::<LinetypeStep>::new())
}
#[test]
fn discrete_with_linetype_range_steps_by_index() {
let solid = lt_solid();
let dashed = lt_dash_gap(8.0, 4.0);
let dotted = lt_dash_gap(2.0, 3.0);
let s = discrete(["A", "B", "C"].into_iter().map(Into::into)).range_linetypes([
solid.clone(),
dashed.clone(),
dotted.clone(),
]);
assert!(s
.map(&Value::from("A"))
.key_eq(&Value::Linetype(solid.clone())));
assert!(s
.map(&Value::from("B"))
.key_eq(&Value::Linetype(dashed.clone())));
assert!(s.map(&Value::from("C")).key_eq(&Value::Linetype(dotted)));
assert!(s.map(&Value::from("D")).is_null());
}
#[test]
fn ordinal_with_linetype_range_steps_by_nearest_index() {
let solid = lt_solid();
let dashed = lt_dash_gap(8.0, 4.0);
let s = ordinal(["L1", "L2", "L3", "L4"]).range_linetypes([solid.clone(), dashed.clone()]);
assert!(s
.map(&Value::from("L1"))
.key_eq(&Value::Linetype(solid.clone())));
assert!(s.map(&Value::from("L2")).key_eq(&Value::Linetype(solid)));
assert!(s
.map(&Value::from("L3"))
.key_eq(&Value::Linetype(dashed.clone())));
assert!(s.map(&Value::from("L4")).key_eq(&Value::Linetype(dashed)));
}
#[test]
fn continuous_with_linetype_range_steps() {
let solid = lt_solid();
let dashed = lt_dash_gap(8.0, 4.0);
let dotted = lt_dash_gap(2.0, 3.0);
let s =
continuous(0.0..=10.0).range_linetypes([solid.clone(), dashed.clone(), dotted.clone()]);
assert!(s
.map(&Value::Number(0.0))
.key_eq(&Value::Linetype(solid.clone())));
assert!(s.map(&Value::Number(5.0)).key_eq(&Value::Linetype(dashed)));
assert!(s.map(&Value::Number(10.0)).key_eq(&Value::Linetype(dotted)));
}
#[test]
fn ordinal_color_interpolates_when_stops_lt_levels() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let s = ordinal(["L1", "L2", "L3", "L4"])
.range_colors([red, blue])
.with_color_space(ColorSpace::Srgb);
assert_eq!(s.map(&Value::from("L1")).as_color(), Some(red));
assert_eq!(s.map(&Value::from("L4")).as_color(), Some(blue));
let c2 = s.map(&Value::from("L2")).as_color().unwrap();
approx(c2.components[0] as f64, 2.0 / 3.0, 1e-5, "L2.r");
approx(c2.components[2] as f64, 1.0 / 3.0, 1e-5, "L2.b");
let c3 = s.map(&Value::from("L3")).as_color().unwrap();
approx(c3.components[0] as f64, 1.0 / 3.0, 1e-5, "L3.r");
approx(c3.components[2] as f64, 2.0 / 3.0, 1e-5, "L3.b");
}
#[test]
fn ordinal_numeric_interpolates_when_stops_lt_levels() {
let s = Scale::new(ScaleTypeKind::Ordinal)
.domain_discrete(["A", "B", "C", "D", "E"].into_iter().map(Into::into))
.range_numbers([2.0, 10.0]);
approx(
s.map(&Value::from("A")).as_number().unwrap(),
2.0,
1e-12,
"A",
);
approx(
s.map(&Value::from("B")).as_number().unwrap(),
4.0,
1e-12,
"B",
);
approx(
s.map(&Value::from("C")).as_number().unwrap(),
6.0,
1e-12,
"C",
);
approx(
s.map(&Value::from("D")).as_number().unwrap(),
8.0,
1e-12,
"D",
);
approx(
s.map(&Value::from("E")).as_number().unwrap(),
10.0,
1e-12,
"E",
);
}
#[test]
fn continuous_with_color_range() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let s = continuous(0.0..=10.0)
.range_colors([red, blue])
.with_color_space(ColorSpace::Srgb);
assert_eq!(s.map(&Value::Number(0.0)).as_color(), Some(red));
assert_eq!(s.map(&Value::Number(10.0)).as_color(), Some(blue));
let mid = s.map(&Value::Number(5.0)).as_color().unwrap();
approx(mid.components[0] as f64, 0.5, 1e-5, "mid.r");
approx(mid.components[2] as f64, 0.5, 1e-5, "mid.b");
}
#[test]
fn color_range_interpolates_in_oklab_by_default() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let s = continuous(0.0..=10.0).range_colors([red, blue]);
assert_eq!(s.color_space(), ColorSpace::Oklab);
assert_eq!(s.map(&Value::Number(0.0)).as_color(), Some(red));
assert_eq!(s.map(&Value::Number(10.0)).as_color(), Some(blue));
let mid = s.map(&Value::Number(5.0)).as_color().unwrap();
assert!(
(mid.components[0] as f64 - 0.5).abs() > 0.02,
"oklab midpoint {mid:?} should not be the channel average"
);
let srgb_mid = continuous(0.0..=10.0)
.range_colors([red, blue])
.with_color_space(ColorSpace::Srgb)
.map(&Value::Number(5.0))
.as_color()
.unwrap();
assert_ne!(mid, srgb_mid);
}
#[test]
fn color_space_survives_mutation_and_bumps_generation() {
let mut s = continuous(0.0..=1.0).range_colors([
Color::new([1.0, 0.0, 0.0, 1.0]),
Color::new([0.0, 0.0, 1.0, 1.0]),
]);
let before = s.generation();
s.set_color_space(ColorSpace::Srgb);
assert_eq!(s.color_space(), ColorSpace::Srgb);
assert!(s.generation() > before);
}
#[test]
fn colorbars_over_different_spaces_are_not_visually_equivalent() {
let stops = [
Color::new([1.0, 0.0, 0.0, 1.0]),
Color::new([0.0, 0.0, 1.0, 1.0]),
];
let a = continuous(0.0..=1.0).range_colors(stops);
let b = continuous(0.0..=1.0)
.range_colors(stops)
.with_color_space(ColorSpace::Srgb);
let locale = Locale::default();
assert!(a.legend_equivalent_to(&b, &locale));
assert!(!a.visual_equivalent_to(&b, &locale));
}
#[test]
fn continuous_piecewise_three_stops() {
let s = continuous(0.0..=1.0).range_numbers([2.0, 8.0, 12.0]);
approx(
s.map(&Value::Number(0.0)).as_number().unwrap(),
2.0,
1e-12,
"0",
);
approx(
s.map(&Value::Number(0.25)).as_number().unwrap(),
5.0,
1e-12,
"0.25",
);
approx(
s.map(&Value::Number(0.5)).as_number().unwrap(),
8.0,
1e-12,
"0.5",
);
approx(
s.map(&Value::Number(0.75)).as_number().unwrap(),
10.0,
1e-12,
"0.75",
);
approx(
s.map(&Value::Number(1.0)).as_number().unwrap(),
12.0,
1e-12,
"1",
);
}
#[test]
fn ordinal_with_matched_stops_is_one_to_one() {
let s = Scale::new(ScaleTypeKind::Ordinal)
.domain_discrete(["S", "M", "L"].into_iter().map(Into::into))
.range_numbers([4.0, 8.0, 12.0]);
assert_eq!(s.map(&Value::from("S")).as_number(), Some(4.0));
assert_eq!(s.map(&Value::from("M")).as_number(), Some(8.0));
assert_eq!(s.map(&Value::from("L")).as_number(), Some(12.0));
}
#[test]
fn discrete_breaks_return_domain() {
let s = discrete(["a", "b", "c"].into_iter().map(Into::into));
let bs = s.breaks(0);
assert_eq!(bs.len(), 3);
assert!(bs[0].key_eq(&Value::from("a")));
assert!(bs[2].key_eq(&Value::from("c")));
}
#[test]
fn binned_map_proportional() {
let s = binned(0.0..=10.0, vec![0.0, 2.0, 5.0, 10.0]);
approx(
s.map(&Value::Number(1.0)).as_number().unwrap(),
0.1,
1e-12,
"bin 0",
);
approx(
s.map(&Value::Number(3.0)).as_number().unwrap(),
0.35,
1e-12,
"bin 1",
);
approx(
s.map(&Value::Number(8.0)).as_number().unwrap(),
0.75,
1e-12,
"bin 2",
);
approx(
s.map(&Value::Number(2.0)).as_number().unwrap(),
0.35,
1e-12,
"boundary",
);
approx(
s.map(&Value::Number(10.0)).as_number().unwrap(),
0.75,
1e-12,
"top",
);
}
#[test]
fn binned_map_break_lands_on_own_domain_fraction() {
let s = binned(
2500.0..=6500.0,
vec![2500.0, 3500.0, 4500.0, 5500.0, 6500.0],
);
let breaks = s.breaks(5);
let positions: Vec<f64> = breaks
.iter()
.map(|b| s.map_break(b).as_number().unwrap())
.collect();
for (b, p) in breaks.iter().zip(&positions) {
let v = b.as_number().unwrap();
approx(*p, (v - 2500.0) / 4000.0, 1e-12, "edge break position");
}
for (i, a) in positions.iter().enumerate() {
for b in &positions[i + 1..] {
assert!((a - b).abs() > 1e-9, "breaks collided at {a}");
}
}
}
#[test]
fn map_break_matches_map_off_binned() {
let c = continuous(0.0..=10.0);
for v in c.breaks(5) {
assert!(c.map_break(&v).key_eq(&c.map(&v)), "continuous {v:?}");
}
let d = discrete(vec![Value::from("a"), Value::from("b"), Value::from("c")]);
for v in d.breaks(0) {
assert!(d.map_break(&v).key_eq(&d.map(&v)), "discrete {v:?}");
}
}
#[test]
fn binned_band_width_at_per_bin() {
let s = binned(0.0..=10.0, vec![0.0, 2.0, 5.0, 10.0]);
approx(s.band_width_at(&Value::Number(1.0)), 0.2, 1e-12, "bin 0");
approx(s.band_width_at(&Value::Number(3.0)), 0.3, 1e-12, "bin 1");
approx(s.band_width_at(&Value::Number(8.0)), 0.5, 1e-12, "bin 2");
}
#[test]
fn binned_map_with_offset_uses_per_bin_width() {
let s = binned(0.0..=10.0, vec![0.0, 2.0, 5.0, 10.0]);
approx(
s.map_with_offset(&Value::Number(3.0), 0.5)
.as_number()
.unwrap(),
0.5,
1e-12,
"bin 1 right edge",
);
approx(
s.map_with_offset(&Value::Number(8.0), -0.5)
.as_number()
.unwrap(),
0.5,
1e-12,
"bin 2 left edge",
);
}
#[test]
fn binned_out_of_range_is_null() {
let s = binned(0.0..=10.0, vec![0.0, 5.0, 10.0]);
assert!(s.map(&Value::Number(-1.0)).is_null());
assert!(s.map(&Value::Number(11.0)).is_null());
}
#[test]
fn binned_without_bins_is_null() {
let s = Scale::new(ScaleTypeKind::Binned).domain_continuous(0.0, 10.0);
assert!(s.map(&Value::Number(5.0)).is_null());
assert!(s.breaks(5).is_empty());
approx(s.band_width(), 0.0, 1e-12, "no bins");
}
#[test]
fn binned_color_range_indexes_by_bin() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let green = Color::new([0.0, 1.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let s = binned(0.0..=30.0, vec![0.0, 10.0, 20.0, 30.0]).range_colors([red, green, blue]);
assert_eq!(s.map(&Value::Number(5.0)).as_color(), Some(red));
assert_eq!(s.map(&Value::Number(15.0)).as_color(), Some(green));
assert_eq!(s.map(&Value::Number(25.0)).as_color(), Some(blue));
let bs = s.breaks(5);
assert_eq!(bs.len(), 4);
assert!(bs[1].key_eq(&Value::Number(10.0)));
approx(s.band_width(), 1.0 / 3.0, 1e-12, "3 bins");
}
#[test]
fn binned_numeric_range_is_a_per_bin_palette() {
let s = binned(0.0..=30.0, vec![0.0, 10.0, 20.0, 30.0]).range_numbers([1.0, 3.0, 5.0]);
approx(
s.map(&Value::Number(5.0)).as_number().unwrap(),
1.0,
1e-12,
"bin 0",
);
approx(
s.map(&Value::Number(15.0)).as_number().unwrap(),
3.0,
1e-12,
"bin 1",
);
approx(
s.map(&Value::Number(25.0)).as_number().unwrap(),
5.0,
1e-12,
"bin 2",
);
}
#[test]
fn binned_color_range_interpolates_when_stops_lt_bins() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let s = binned(0.0..=40.0, vec![0.0, 10.0, 20.0, 30.0, 40.0])
.range_colors([red, blue])
.with_color_space(ColorSpace::Srgb);
assert_eq!(s.map(&Value::Number(5.0)).as_color(), Some(red));
assert_eq!(s.map(&Value::Number(35.0)).as_color(), Some(blue));
let c2 = s.map(&Value::Number(15.0)).as_color().unwrap();
approx(c2.components[0] as f64, 2.0 / 3.0, 1e-5, "bin 1 r");
approx(c2.components[2] as f64, 1.0 / 3.0, 1e-5, "bin 1 b");
}
#[test]
fn binned_linetype_range_indexes_by_bin() {
let solid = lt_solid();
let dashed = lt_dash_gap(8.0, 4.0);
let dotted = lt_dash_gap(2.0, 3.0);
let s = binned(0.0..=30.0, vec![0.0, 10.0, 20.0, 30.0]).range_linetypes([
solid.clone(),
dashed.clone(),
dotted.clone(),
]);
assert!(s
.map(&Value::Number(5.0))
.key_eq(&Value::Linetype(solid.clone())));
assert!(s.map(&Value::Number(15.0)).key_eq(&Value::Linetype(dashed)));
assert!(s.map(&Value::Number(25.0)).key_eq(&Value::Linetype(dotted)));
}
#[test]
fn binned_palette_keeps_positional_band_offsets() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let s = binned(0.0..=10.0, vec![0.0, 2.0, 5.0, 10.0]).range_colors([red, blue]);
approx(s.band_width_at(&Value::Number(3.0)), 0.3, 1e-12, "bin 1");
assert_eq!(
s.map_with_offset(&Value::Number(3.0), 0.5).as_color(),
s.map(&Value::Number(3.0)).as_color()
);
}
#[test]
fn binned_legend_equivalence_distinguishes_bin_edges() {
let locale = Locale::default();
let a = binned(0.0..=30.0, vec![0.0, 10.0, 20.0, 30.0]);
let b = binned(0.0..=30.0, vec![0.0, 15.0, 30.0]);
assert!(!a.legend_equivalent_to(&b, &locale));
let c = binned(0.0..=30.0, vec![0.0, 10.0, 20.0, 30.0])
.range_colors([Color::new([1.0, 0.0, 0.0, 1.0])]);
assert!(a.legend_equivalent_to(&c, &locale));
}
#[test]
fn reversed_continuous_mirrors_the_fraction() {
let s = continuous(0.0..=10.0).with_direction(Direction::Reversed);
approx(
s.map(&Value::Number(0.0)).as_number().unwrap(),
1.0,
1e-12,
"lo",
);
approx(
s.map(&Value::Number(2.5)).as_number().unwrap(),
0.75,
1e-12,
"quarter",
);
approx(
s.map(&Value::Number(10.0)).as_number().unwrap(),
0.0,
1e-12,
"hi",
);
}
#[test]
fn reversed_continuous_walks_its_palette_backwards() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let s = continuous(0.0..=10.0)
.range_colors([red, blue])
.with_direction(Direction::Reversed);
assert_eq!(s.map(&Value::Number(0.0)).as_color(), Some(blue));
assert_eq!(s.map(&Value::Number(10.0)).as_color(), Some(red));
}
#[test]
fn reversed_continuous_composes_with_a_transform() {
let s = continuous(1.0..=1000.0)
.with_transform(TransformKind::Log10)
.with_direction(Direction::Reversed);
approx(
s.map(&Value::Number(10.0)).as_number().unwrap(),
2.0 / 3.0,
1e-12,
"one decade up, from the far end",
);
}
#[test]
fn reversed_scale_keeps_its_break_values() {
let fwd = continuous(0.0..=10.0);
let rev = continuous(0.0..=10.0).with_direction(Direction::Reversed);
let (a, b) = (fwd.breaks(5), rev.breaks(5));
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(&b) {
assert!(x.key_eq(y), "break values differ: {x:?} vs {y:?}");
}
for v in &a {
approx(
rev.map_break(v).as_number().unwrap(),
1.0 - fwd.map_break(v).as_number().unwrap(),
1e-12,
"mirrored break position",
);
}
}
#[test]
fn reversed_discrete_mirrors_bands_and_palette() {
let levels = vec![Value::from("a"), Value::from("b"), Value::from("c")];
let s = discrete(levels.clone()).with_direction(Direction::Reversed);
approx(
s.map(&levels[0]).as_number().unwrap(),
5.0 / 6.0,
1e-12,
"first",
);
approx(
s.map(&levels[2]).as_number().unwrap(),
1.0 / 6.0,
1e-12,
"last",
);
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let green = Color::new([0.0, 1.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let c = discrete(levels.clone())
.range_colors([red, green, blue])
.with_direction(Direction::Reversed);
assert_eq!(c.map(&levels[0]).as_color(), Some(blue));
assert_eq!(c.map(&levels[2]).as_color(), Some(red));
}
#[test]
fn reversed_ordinal_mirrors_the_gradient() {
let s = ordinal(["a", "b", "c"])
.range_numbers([2.0, 12.0])
.with_direction(Direction::Reversed);
approx(
s.map(&Value::from("a")).as_number().unwrap(),
12.0,
1e-12,
"first",
);
approx(
s.map(&Value::from("b")).as_number().unwrap(),
7.0,
1e-12,
"middle",
);
approx(
s.map(&Value::from("c")).as_number().unwrap(),
2.0,
1e-12,
"last",
);
}
#[test]
fn reversed_binned_position_mirrors_bin_centres() {
let s = binned(0.0..=10.0, vec![0.0, 2.0, 5.0, 10.0]).with_direction(Direction::Reversed);
approx(
s.map(&Value::Number(1.0)).as_number().unwrap(),
0.9,
1e-12,
"bin 0",
);
approx(
s.map(&Value::Number(3.0)).as_number().unwrap(),
0.65,
1e-12,
"bin 1",
);
approx(
s.map(&Value::Number(8.0)).as_number().unwrap(),
0.25,
1e-12,
"bin 2",
);
approx(
s.band_width_at(&Value::Number(3.0)),
0.3,
1e-12,
"band width is unsigned",
);
}
#[test]
fn reversed_binned_palette_counts_from_the_far_end() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let green = Color::new([0.0, 1.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let s = binned(0.0..=30.0, vec![0.0, 10.0, 20.0, 30.0])
.range_colors([red, green, blue])
.with_direction(Direction::Reversed);
assert_eq!(s.map(&Value::Number(5.0)).as_color(), Some(blue));
assert_eq!(s.map(&Value::Number(15.0)).as_color(), Some(green));
assert_eq!(s.map(&Value::Number(25.0)).as_color(), Some(red));
}
#[test]
fn reversed_binned_breaks_stay_with_the_bins_they_bound() {
let s = binned(0.0..=10.0, vec![0.0, 2.0, 5.0, 10.0]).with_direction(Direction::Reversed);
let positions: Vec<f64> = s
.breaks(5)
.iter()
.map(|b| s.map_break(b).as_number().unwrap())
.collect();
assert_eq!(positions, vec![1.0, 0.8, 0.5, 0.0]);
}
#[test]
fn reversed_band_offset_points_the_other_way() {
let d = discrete(vec![Value::from("a"), Value::from("b"), Value::from("c")])
.with_direction(Direction::Reversed);
approx(
d.map_with_offset(&Value::from("a"), 0.5)
.as_number()
.unwrap(),
2.0 / 3.0,
1e-12,
"a's upper edge",
);
let b = binned(0.0..=10.0, vec![0.0, 2.0, 5.0, 10.0]).with_direction(Direction::Reversed);
approx(
b.map_with_offset(&Value::Number(3.0), 0.5)
.as_number()
.unwrap(),
0.5,
1e-12,
"bin 1's upper edge",
);
}
#[test]
fn identity_ignores_direction() {
let s = identity().with_direction(Direction::Reversed);
assert!(s.map(&Value::Number(3.0)).key_eq(&Value::Number(3.0)));
}
#[test]
fn direction_is_part_of_a_scale_visual_but_not_its_legend_layout() {
let loc = Locale::default();
let stops = [
Color::new([1.0, 0.0, 0.0, 1.0]),
Color::new([0.0, 0.0, 1.0, 1.0]),
];
let fwd = continuous(0.0..=10.0).range_colors(stops);
let rev = continuous(0.0..=10.0)
.range_colors(stops)
.with_direction(Direction::Reversed);
assert!(
fwd.legend_equivalent_to(&rev, &loc),
"same domain, same rows, same labels"
);
assert!(
!fwd.visual_equivalent_to(&rev, &loc),
"a mirrored ramp is a different bar"
);
}
#[test]
fn direction_bumps_the_generation_counter() {
let mut s = continuous(0.0..=1.0);
let before = s.generation();
s.set_direction(Direction::Reversed);
assert!(s.generation() > before);
}
#[test]
fn descending_domain_still_generates_breaks() {
assert!(!continuous(100.0..=0.0).breaks(5).is_empty(), "linear");
assert!(
!continuous(1000.0..=1.0)
.with_transform(TransformKind::Log10)
.breaks(5)
.is_empty(),
"log10"
);
assert!(
!continuous(100.0..=0.0)
.with_transform(TransformKind::Sqrt)
.breaks(5)
.is_empty(),
"sqrt"
);
assert!(
!continuous(100.0..=-100.0)
.with_transform(TransformKind::Asinh)
.breaks(5)
.is_empty(),
"asinh"
);
assert!(
!temporal(Date(20000)..=Date(19000)).breaks(5).is_empty(),
"date"
);
assert!(
!continuous(100.0..=0.0).minor_breaks(5).is_empty(),
"linear minors"
);
}
#[test]
fn identity_passes_through() {
let s = identity();
let c = Color::new([0.5, 0.5, 0.5, 1.0]);
assert_eq!(s.map(&Value::Number(42.0)).as_number(), Some(42.0));
assert_eq!(s.map(&Value::Color(c)).as_color(), Some(c));
assert!(s.map(&Value::from("hi")).key_eq(&Value::from("hi")));
}
#[test]
fn identity_passes_color_through() {
let s = identity();
let c = Color::new([0.25, 0.5, 0.75, 1.0]);
assert_eq!(s.map(&Value::Color(c)).as_color(), Some(c));
}
#[test]
fn continuous_dates_maps_via_days() {
let s = continuous(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 12, 31));
let mid = Date::from_ymd(2024, 7, 1);
let frac = s.map(&Value::Date(mid.to_days())).as_number().unwrap();
assert!(frac > 0.0 && frac < 1.0, "mid-year frac was {frac}");
}
#[test]
fn temporal_format_dates() {
let s = continuous(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 12, 31));
assert_eq!(
s.format(
&Value::Date(Date::from_ymd(2024, 1, 15).to_days()),
&Locale::EN_US
),
"2024-01-15"
);
}
#[test]
fn temporal_format_datetime() {
let s = continuous(
DateTime::from_ymd_hms_micros(2024, 1, 1, 0, 0, 0, 0)
..=DateTime::from_ymd_hms_micros(2024, 12, 31, 23, 59, 59, 0),
);
let dt = DateTime::from_ymd_hms_micros(2024, 6, 15, 12, 34, 56, 0);
assert_eq!(
s.format(&Value::DateTime(dt.to_micros()), &Locale::EN_US),
"2024-06-15 12:34:56"
);
}
#[test]
fn temporal_format_time_sub_second() {
let s = continuous(
Time::from_hms_micros(0, 0, 0, 0)..=Time::from_hms_micros(23, 59, 59, 999_999),
);
let t = Time::from_hms_micros(7, 8, 9, 123_000);
assert_eq!(
s.format(&Value::Time(t.to_nanos()), &Locale::EN_US),
"07:08:09.123"
);
let t_exact = Time::from_hms_micros(7, 8, 9, 0);
assert_eq!(
s.format(&Value::Time(t_exact.to_nanos()), &Locale::EN_US),
"07:08:09"
);
let t_ns = Time::from_hms_nanos(7, 8, 9, 456_000_000);
assert_eq!(
s.format(&Value::Time(t_ns.to_nanos()), &Locale::EN_US),
"07:08:09.456"
);
}
#[test]
fn binned_accepts_temporal_domain() {
let start = Date::from_ymd(2024, 1, 1);
let end = Date::from_ymd(2024, 12, 31);
let q1 = Date::from_ymd(2024, 4, 1).to_days() as f64;
let q2 = Date::from_ymd(2024, 7, 1).to_days() as f64;
let q3 = Date::from_ymd(2024, 10, 1).to_days() as f64;
let s = binned(
start..=end,
vec![start.to_days() as f64, q1, q2, q3, end.to_days() as f64],
);
let start_f = start.to_days() as f64;
let end_f = end.to_days() as f64;
let span = end_f - start_f;
let expected = ((start_f + q1) * 0.5 - start_f) / span;
let jan = Date::from_ymd(2024, 1, 15).to_days() as f64;
let frac = s.map(&Value::Date(jan as i32)).as_number().unwrap();
approx(frac, expected, 1e-12, "jan in bin 0 (proportional)");
}
#[test]
fn temporal_format_duration() {
let s = identity();
assert_eq!(
s.format(
&Value::Duration(3 * 3600 * 1_000_000 + 25 * 60 * 1_000_000 + 12 * 1_000_000),
&Locale::EN_US
),
"3h 25m 12s"
);
assert_eq!(
s.format(&Value::Duration(-90 * 1_000_000), &Locale::EN_US),
"-1m 30s"
);
assert_eq!(
s.format(&Value::Duration(45 * 1_000_000), &Locale::EN_US),
"45s"
);
}
#[test]
fn mutation_bumps_generation() {
let mut s = continuous(0.0..=10.0);
let g0 = s.generation();
s.set_domain_continuous(0.0, 20.0);
let g1 = s.generation();
assert!(g1 > g0);
s.set_range_numbers(vec![0.0, 1.0]);
let g2 = s.generation();
assert!(g2 > g1);
}
#[test]
fn builder_chaining_does_not_bump_generation() {
let s = continuous(0.0..=10.0)
.range_numbers([0.0, 1.0])
.with_transform(TransformKind::Identity);
assert_eq!(s.generation(), 0);
}
#[test]
fn continuous_from_data_fits_numeric_extent() {
let col: DataColumn = vec![1.0_f64, 3.5, -2.0, 7.0].into();
let s = continuous_from_data(&col);
match s.input_range() {
Some(InputRange::Continuous { min, max }) => {
approx(*min, -2.0, 1e-12, "min");
approx(*max, 7.0, 1e-12, "max");
}
_ => panic!("expected Continuous input range"),
}
}
#[test]
fn continuous_from_data_empty_unconfigured() {
let col: DataColumn = DataColumn::F64(vec![]);
let s = continuous_from_data(&col);
assert!(s.input_range().is_none());
}
#[test]
fn log10_scale_breaks_emit_decade_powers() {
let s = continuous(1.0..=1000.0).with_transform(TransformKind::Log10);
let bs = s.breaks(5);
let nums: Vec<f64> = bs.iter().filter_map(|v| v.as_number()).collect();
for v in [1.0, 10.0, 100.0, 1000.0] {
assert!(nums.contains(&v), "{nums:?} missing {v}");
}
}
#[test]
fn log10_scale_minor_breaks_emit_2_to_9() {
let s = continuous(1.0..=10.0).with_transform(TransformKind::Log10);
let m = s.minor_breaks(5);
let nums: Vec<f64> = m.iter().filter_map(|v| v.as_number()).collect();
for v in [2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] {
assert!(nums.contains(&v), "{nums:?} missing {v}");
}
}
#[test]
fn log10_scale_maps_decade_to_normalised_third() {
let s = continuous(1.0..=1000.0).with_transform(TransformKind::Log10);
approx(
s.map(&Value::Number(1.0)).as_number().unwrap(),
0.0,
1e-9,
"1",
);
approx(
s.map(&Value::Number(10.0)).as_number().unwrap(),
1.0 / 3.0,
1e-9,
"10",
);
approx(
s.map(&Value::Number(100.0)).as_number().unwrap(),
2.0 / 3.0,
1e-9,
"100",
);
approx(
s.map(&Value::Number(1000.0)).as_number().unwrap(),
1.0,
1e-9,
"1000",
);
}
#[test]
fn sqrt_scale_compresses_high_values() {
let s = continuous(0.0..=100.0).with_transform(TransformKind::Sqrt);
approx(
s.map(&Value::Number(50.0)).as_number().unwrap(),
(50f64.sqrt()) / 10.0,
1e-9,
"50",
);
}
#[test]
fn sqrt_scale_minor_breaks_are_linear_midpoints() {
let s = continuous(0.0..=100.0).with_transform(TransformKind::Sqrt);
let m = s.minor_breaks(5);
let majors = s.breaks(5);
if majors.len() >= 2 {
assert_eq!(m.len(), majors.len() - 1);
}
}
#[test]
fn identity_transform_breaks_match_extended() {
let s = continuous(0.0..=10.0);
let bs = s.breaks(5);
let nums: Vec<f64> = bs.iter().filter_map(|v| v.as_number()).collect();
assert!(nums.first() == Some(&0.0));
assert!(nums.last() == Some(&10.0));
}
#[test]
fn identity_transform_minor_breaks_are_midpoints() {
let s = continuous(0.0..=10.0);
let majors = s.breaks(5);
let minors = s.minor_breaks(5);
if majors.len() >= 2 {
assert_eq!(minors.len(), majors.len() - 1);
}
}
#[test]
fn asinh_scale_handles_negative_domain() {
let s = continuous(-10.0..=10.0).with_transform(TransformKind::Asinh);
approx(
s.map(&Value::Number(0.0)).as_number().unwrap(),
0.5,
1e-9,
"asinh midpoint",
);
assert!(s.map(&Value::Number(-1.0)).as_number().unwrap() < 0.5);
assert!(s.map(&Value::Number(1.0)).as_number().unwrap() > 0.5);
}
#[test]
fn discrete_scale_has_no_minor_breaks() {
let s = discrete(["a", "b", "c"].into_iter().map(Into::into));
assert!(s.minor_breaks(5).is_empty());
}
#[test]
fn pseudo_log10_can_be_constructed() {
let s = continuous(0.1..=1000.0).with_transform(TransformKind::PseudoLog10);
let bs = s.breaks(5);
assert!(!bs.is_empty());
}
#[test]
fn temporal_date_year_span_emits_year_starts() {
let s = temporal(Date::from_ymd(2020, 1, 1)..=Date::from_ymd(2024, 12, 31));
let bs = s.breaks(5);
let dates: Vec<(i32, u8, u8)> = bs
.iter()
.filter_map(|v| {
if let Value::Date(d) = v {
Some(Date::from_days(*d).to_ymd())
} else {
None
}
})
.collect();
for (_, m, d) in &dates {
assert_eq!(*m, 1, "month != 1: {dates:?}");
assert_eq!(*d, 1, "day != 1: {dates:?}");
}
assert!(dates.iter().any(|(y, _, _)| *y == 2021));
assert!(dates.iter().any(|(y, _, _)| *y == 2024));
}
#[test]
fn temporal_date_six_month_span_emits_month_starts() {
let s = temporal(Date::from_ymd(2024, 3, 15)..=Date::from_ymd(2024, 9, 15));
let bs = s.breaks(5);
let dates: Vec<(i32, u8, u8)> = bs
.iter()
.filter_map(|v| match v {
Value::Date(d) => Some(Date::from_days(*d).to_ymd()),
_ => None,
})
.collect();
assert!(!dates.is_empty());
for (_, _, d) in &dates {
assert_eq!(*d, 1, "month-start tick has day {d}: {dates:?}");
}
}
#[test]
fn temporal_date_ten_day_span_emits_day_ticks() {
let s = temporal(Date::from_ymd(2024, 6, 1)..=Date::from_ymd(2024, 6, 10));
let bs = s.breaks(5);
assert!(bs.len() >= 5, "expected ~10 day ticks, got {}", bs.len());
assert!(bs.iter().all(|v| matches!(v, Value::Date(_))));
}
#[test]
fn temporal_breaks_return_date_variant_not_number() {
let s = temporal(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 12, 31));
for v in s.breaks(5) {
assert!(matches!(v, Value::Date(_)), "{v:?} is not Date");
}
}
#[test]
fn temporal_minor_breaks_subdivide_majors() {
let s = temporal(Date::from_ymd(2020, 1, 1)..=Date::from_ymd(2024, 12, 31));
let minors = s.minor_breaks(5);
let minor_dates: Vec<(i32, u8, u8)> = minors
.iter()
.filter_map(|v| match v {
Value::Date(d) => Some(Date::from_days(*d).to_ymd()),
_ => None,
})
.collect();
for (_, m, d) in &minor_dates {
assert_eq!(*d, 1, "quarter minor has day {d}: {minor_dates:?}");
assert!(
[1, 4, 7, 10].contains(m),
"month {m} not a quarter start: {minor_dates:?}"
);
}
for (_, m, _) in &minor_dates {
assert_ne!(*m, 1, "minor coincides with major Jan 1");
}
}
#[test]
fn temporal_datetime_year_span_emits_year_starts() {
let start = DateTime::from_ymd_hms_micros(2020, 1, 1, 0, 0, 0, 0);
let end = DateTime::from_ymd_hms_micros(2024, 12, 31, 23, 59, 59, 0);
let s = temporal(start..=end);
let bs = s.breaks(5);
assert!(!bs.is_empty());
assert!(bs.iter().all(|v| matches!(v, Value::DateTime(_))));
for v in &bs {
if let Value::DateTime(us) = v {
let day_us = 86_400_000_000_i64;
assert_eq!(us % day_us, 0, "tick not at midnight: {us}");
}
}
}
#[test]
fn temporal_continuous_with_date_endpoints_still_works_numerically() {
let s = continuous(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 12, 31));
let bs = s.breaks(5);
assert!(
bs.iter().all(|v| matches!(v, Value::Number(_))),
"continuous-with-date-endpoints should produce numeric breaks: {bs:?}"
);
}
#[test]
fn temporal_scale_map_is_continuous_linear() {
let s = temporal(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 12, 31));
let mid = Date::from_ymd(2024, 7, 2);
let frac = s.map(&Value::Date(mid.to_days())).as_number().unwrap();
assert!((0.4..=0.6).contains(&frac), "frac was {frac}");
}
#[test]
fn temporal_panics_on_non_temporal_endpoint() {
let result = std::panic::catch_unwind(|| {
let _ = temporal(0.0_f64..=10.0_f64);
});
assert!(result.is_err(), "expected panic on non-temporal endpoint");
}
#[test]
fn default_number_formatter_scrubs_floating_point_noise() {
let s = identity();
assert_eq!(s.format(&Value::Number(0.1 + 0.2), &Locale::EN_US), "0.3");
assert_eq!(
s.format(&Value::Number(0.6000000000001), &Locale::EN_US),
"0.6"
);
assert_eq!(s.format(&Value::Number(1.0), &Locale::EN_US), "1");
assert_eq!(s.format(&Value::Number(1.5), &Locale::EN_US), "1.5");
assert_eq!(s.format(&Value::Number(0.0), &Locale::EN_US), "0");
assert_eq!(s.format(&Value::Number(-0.0), &Locale::EN_US), "0");
assert_eq!(
s.format(&Value::Number(0.123456789), &Locale::EN_US),
"0.123456789"
);
assert_eq!(
s.format(&Value::Number(f64::INFINITY), &Locale::EN_US),
"inf"
);
assert_eq!(
s.format(&Value::Number(f64::NEG_INFINITY), &Locale::EN_US),
"-inf"
);
assert_eq!(s.format(&Value::Number(f64::NAN), &Locale::EN_US), "NaN");
}
#[test]
fn custom_formatter_overrides_default() {
let s = identity().with_format(|v, locale| match v {
Value::Number(n) => format!("${n:.2}"),
other => Scale::default_format(other, locale),
});
assert_eq!(s.format(&Value::Number(12.345), &Locale::EN_US), "$12.35");
assert_eq!(s.format(&Value::from("abc"), &Locale::EN_US), "abc");
}
#[test]
fn clear_format_reverts_to_default() {
let mut s = identity().with_format(|_, _| "X".to_string());
assert_eq!(s.format(&Value::Number(1.0), &Locale::EN_US), "X");
s.clear_format();
assert_eq!(s.format(&Value::Number(1.0), &Locale::EN_US), "1");
}
#[test]
fn formatter_survives_clone() {
let s = identity().with_format(|v, locale| match v {
Value::Number(n) => format!("n={n}"),
other => Scale::default_format(other, locale),
});
let s2 = s.clone();
assert_eq!(s.format(&Value::Number(3.0), &Locale::EN_US), "n=3");
assert_eq!(s2.format(&Value::Number(3.0), &Locale::EN_US), "n=3");
}
#[test]
fn a_scale_without_a_formatter_reports_the_default_spec() {
assert_eq!(identity().format_spec(), FormatSpec::Default);
}
#[test]
fn an_anonymous_formatter_reports_custom_and_cannot_be_named() {
let s = identity().with_format(|_, _| "X".to_string());
assert_eq!(s.format_spec(), FormatSpec::Custom);
}
#[test]
fn a_named_formatter_reports_its_name_and_still_applies() {
let s = identity().with_named_format("usd", |v, locale| match v {
Value::Number(n) => format!("${n:.2}"),
other => Scale::default_format(other, locale),
});
assert_eq!(s.format_spec(), FormatSpec::Named("usd".into()));
assert_eq!(s.format(&Value::Number(12.345), &Locale::EN_US), "$12.35");
}
#[test]
fn set_named_format_replaces_an_anonymous_one() {
let mut s = identity().with_format(|_, _| "X".to_string());
s.set_named_format("tag", |_, _| "Y".to_string());
assert_eq!(s.format_spec(), FormatSpec::Named("tag".into()));
assert_eq!(s.format(&Value::Number(1.0), &Locale::EN_US), "Y");
}
#[test]
fn clearing_a_named_formatter_reverts_the_spec_too() {
let mut s = identity().with_named_format("tag", |_, _| "Y".to_string());
s.clear_format();
assert_eq!(s.format_spec(), FormatSpec::Default);
assert_eq!(s.format(&Value::Number(1.0), &Locale::EN_US), "1");
}
#[test]
fn a_formatter_name_survives_clone() {
let s = identity().with_named_format("tag", |_, _| "Y".to_string());
assert_eq!(s.clone().format_spec(), FormatSpec::Named("tag".into()));
}
#[test]
fn try_with_bins_accepts_a_well_formed_ladder() {
let s = Scale::new(ScaleTypeKind::Binned)
.try_with_bins([0.0, 1.0, 2.0])
.expect("strictly increasing finite edges");
assert_eq!(s.bins(), Some(&[0.0, 1.0, 2.0][..]));
}
#[test]
fn try_with_bins_rejects_fewer_than_two_edges() {
assert_eq!(
Scale::new(ScaleTypeKind::Binned).try_with_bins([1.0]).err(),
Some(BinEdgeError::TooFew { found: 1 })
);
}
#[test]
fn try_with_bins_rejects_a_non_finite_edge() {
assert_eq!(
Scale::new(ScaleTypeKind::Binned)
.try_with_bins([0.0, f64::NAN, 2.0])
.err()
.map(|e| matches!(e, BinEdgeError::NotFinite { index: 1, .. })),
Some(true)
);
}
#[test]
fn try_with_bins_rejects_edges_that_do_not_increase() {
let err = Scale::new(ScaleTypeKind::Binned)
.try_with_bins([0.0, 2.0, 2.0])
.expect_err("equal adjacent edges are not strictly increasing");
assert!(matches!(
err,
BinEdgeError::NotIncreasing {
index: 2,
previous_index: 1,
..
}
));
}
#[test]
fn try_set_bins_leaves_the_existing_ladder_on_error() {
let mut s = Scale::new(ScaleTypeKind::Binned).with_bins([0.0, 1.0]);
assert!(s.try_set_bins(vec![5.0]).is_err());
assert_eq!(s.bins(), Some(&[0.0, 1.0][..]));
}
#[test]
fn explicit_breaks_pin_positions() {
let s = continuous(0.0..=100.0).with_breaks(vec![
Value::Number(25.0),
Value::Number(50.0),
Value::Number(75.0),
]);
let bs = s.breaks(5);
let nums: Vec<f64> = bs.iter().filter_map(|v| v.as_number()).collect();
assert_eq!(nums, vec![25.0, 50.0, 75.0]);
}
#[test]
fn labeled_breaks_pin_labels() {
let s = continuous(0.0..=1.0).with_breaks_labeled(vec![
(Value::Number(0.0), "low".to_string()),
(Value::Number(1.0), "high".to_string()),
]);
assert_eq!(s.format(&Value::Number(0.0), &Locale::EN_US), "low");
assert_eq!(s.format(&Value::Number(1.0), &Locale::EN_US), "high");
assert_eq!(s.format(&Value::Number(0.5), &Locale::EN_US), "0.5");
}
#[test]
fn labeled_breaks_take_priority_over_formatter() {
let s = continuous(0.0..=1.0)
.with_format(|_, _| "FORMATTED".to_string())
.with_breaks_labeled(vec![(Value::Number(0.5), "MID".to_string())]);
assert_eq!(s.format(&Value::Number(0.5), &Locale::EN_US), "MID");
assert_eq!(s.format(&Value::Number(0.7), &Locale::EN_US), "FORMATTED");
}
#[test]
fn clear_breaks_reverts_to_auto() {
let mut s = continuous(0.0..=10.0).with_breaks(vec![Value::Number(5.0)]);
assert_eq!(s.breaks(5).len(), 1);
s.clear_breaks();
let bs = s.breaks(5);
assert!(bs.len() > 1, "expected automatic breaks after clear");
}
#[test]
fn numeric_interval_emits_aligned_multiples() {
let s = continuous(0.5..=12.0).with_interval(2.0);
let bs = s.breaks(0);
let nums: Vec<f64> = bs.iter().filter_map(|v| v.as_number()).collect();
assert_eq!(nums, vec![2.0, 4.0, 6.0, 8.0, 10.0, 12.0]);
}
#[test]
fn numeric_interval_handles_negative_domain() {
let s = continuous(-5.0..=5.0).with_interval(2.5);
let bs = s.breaks(0);
let nums: Vec<f64> = bs.iter().filter_map(|v| v.as_number()).collect();
assert_eq!(nums, vec![-5.0, -2.5, 0.0, 2.5, 5.0]);
}
#[test]
fn numeric_interval_falls_back_on_discrete() {
let s = discrete(["a", "b", "c"].into_iter().map(Into::into)).with_interval(1.0);
let bs = s.breaks(0);
assert_eq!(bs.len(), 3);
}
#[test]
fn temporal_interval_emits_calendar_aligned_ticks() {
let s = temporal(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 3, 31))
.with_temporal_interval(TemporalInterval::new(2, CalendarUnit::Week));
let bs = s.breaks(0);
assert!(!bs.is_empty(), "expected biweekly ticks in the span");
for v in &bs {
assert!(matches!(v, Value::Date(_)), "{v:?} is not Date");
}
}
#[test]
fn temporal_interval_falls_back_on_numeric_scale() {
let s = continuous(0.0..=100.0)
.with_temporal_interval(TemporalInterval::new(1, CalendarUnit::Week));
let bs = s.breaks(5);
assert!(!bs.is_empty(), "fallback should still produce breaks");
assert!(bs.iter().all(|v| matches!(v, Value::Number(_))));
}
#[test]
fn explicit_minor_breaks_pin_positions() {
let s = continuous(0.0..=10.0).with_minor_breaks(vec![
Value::Number(2.5),
Value::Number(5.0),
Value::Number(7.5),
]);
let ms: Vec<f64> = s
.minor_breaks(5)
.iter()
.filter_map(|v| v.as_number())
.collect();
assert_eq!(ms, vec![2.5, 5.0, 7.5]);
}
#[test]
fn empty_minor_breaks_suppress_minors() {
let s = continuous(0.0..=10.0).with_minor_breaks(Vec::new());
assert!(s.minor_breaks(5).is_empty());
}
#[test]
fn explicit_minor_breaks_apply_to_discrete_scales() {
let s = discrete(["a", "b", "c"].into_iter().map(Into::into))
.with_minor_breaks(vec![Value::Number(0.5)]);
let ms: Vec<f64> = s
.minor_breaks(0)
.iter()
.filter_map(|v| v.as_number())
.collect();
assert_eq!(ms, vec![0.5]);
}
#[test]
fn minor_count_subdivides_each_major_interval() {
let s = continuous(0.0..=10.0)
.with_breaks(vec![
Value::Number(0.0),
Value::Number(5.0),
Value::Number(10.0),
])
.with_minor_count(4);
let ms: Vec<f64> = s
.minor_breaks(5)
.iter()
.filter_map(|v| v.as_number())
.collect();
assert_eq!(ms, vec![1.0, 2.0, 3.0, 4.0, 6.0, 7.0, 8.0, 9.0]);
}
#[test]
fn minor_count_zero_suppresses_minors() {
let s = continuous(0.0..=10.0).with_minor_count(0);
assert!(s.minor_breaks(5).is_empty());
}
#[test]
fn minor_count_overrides_the_transform_default() {
let s = continuous(1.0..=100.0)
.with_transform(TransformKind::Log10)
.with_minor_count(1);
let majors: Vec<f64> = s.breaks(5).iter().filter_map(|v| v.as_number()).collect();
let ms: Vec<f64> = s
.minor_breaks(5)
.iter()
.filter_map(|v| v.as_number())
.collect();
assert_eq!(ms.len(), majors.len().saturating_sub(1));
}
#[test]
fn minor_interval_skips_positions_carrying_a_major() {
let s = continuous(0.0..=10.0)
.with_interval(2.0)
.with_minor_interval(0.5);
let ms: Vec<f64> = s
.minor_breaks(5)
.iter()
.filter_map(|v| v.as_number())
.collect();
assert!(
ms.iter().all(|m| (m / 2.0).fract() != 0.0),
"minor landed on a major: {ms:?}"
);
assert_eq!(ms.first(), Some(&0.5));
assert_eq!(ms.len(), 15, "21 half-steps minus 6 majors: {ms:?}");
}
#[test]
fn minor_interval_falls_back_on_discrete() {
let s = discrete(["a", "b", "c"].into_iter().map(Into::into)).with_minor_interval(0.5);
assert!(s.minor_breaks(0).is_empty());
}
#[test]
fn minor_temporal_interval_emits_calendar_aligned_dates() {
let s = temporal(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 6, 30))
.with_temporal_interval(TemporalInterval::new(1, CalendarUnit::Month))
.with_minor_temporal_interval(TemporalInterval::new(1, CalendarUnit::Week));
let minors = s.minor_breaks(5);
assert!(!minors.is_empty(), "expected weekly minors in the span");
assert!(
minors.iter().all(|v| matches!(v, Value::Date(_))),
"minors should carry the scale's calendar variant: {minors:?}"
);
let majors: Vec<f64> = s.breaks(5).iter().filter_map(|v| v.as_number()).collect();
for m in minors.iter().filter_map(|v| v.as_number()) {
assert!(!majors.contains(&m), "minor {m} coincides with a major");
}
}
#[test]
fn minor_temporal_interval_falls_back_on_numeric_scale() {
let s = continuous(0.0..=100.0)
.with_minor_temporal_interval(TemporalInterval::new(1, CalendarUnit::Week));
let ms = s.minor_breaks(5);
assert!(!ms.is_empty(), "fallback should still produce minors");
assert!(ms.iter().all(|v| matches!(v, Value::Number(_))));
}
#[test]
fn minor_numeric_interval_wraps_temporal_variants() {
let s = temporal(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 1, 31))
.with_minor_interval(2.0);
let ms = s.minor_breaks(5);
assert!(!ms.is_empty());
assert!(ms.iter().all(|v| matches!(v, Value::Date(_))), "{ms:?}");
}
#[test]
fn clear_minor_breaks_reverts_to_auto() {
let mut s = continuous(0.0..=10.0).with_minor_breaks(Vec::new());
assert!(s.minor_breaks(5).is_empty());
s.clear_minor_breaks();
assert!(
!s.minor_breaks(5).is_empty(),
"expected automatic minors after clear"
);
}
#[test]
fn auto_minors_subdivide_a_pinned_major_interval() {
let s = temporal(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 12, 31))
.with_temporal_interval(TemporalInterval::new(3, CalendarUnit::Month));
let months: Vec<u8> = s
.minor_breaks(5)
.iter()
.filter_map(|v| match v {
Value::Date(d) => Some(Date::from_days(*d).to_ymd().1),
_ => None,
})
.collect();
assert_eq!(months, vec![2, 3, 5, 6, 8, 9, 11, 12]);
}
#[test]
fn explicit_minors_win_over_a_pinned_major_interval() {
let mid = Date::from_ymd(2024, 6, 15);
let s = temporal(Date::from_ymd(2024, 1, 1)..=Date::from_ymd(2024, 12, 31))
.with_temporal_interval(TemporalInterval::new(3, CalendarUnit::Month))
.with_minor_breaks(vec![Value::Date(mid.to_days())]);
let ms = s.minor_breaks(5);
assert_eq!(ms.len(), 1);
assert!(ms[0].key_eq(&Value::Date(mid.to_days())), "{ms:?}");
}
#[test]
fn minor_override_leaves_majors_untouched() {
let s = continuous(0.0..=10.0).with_minor_count(3);
let auto = continuous(0.0..=10.0);
let a: Vec<f64> = s.breaks(5).iter().filter_map(|v| v.as_number()).collect();
let b: Vec<f64> = auto
.breaks(5)
.iter()
.filter_map(|v| v.as_number())
.collect();
assert_eq!(a, b);
}
#[test]
fn breaks_spec_accessor_reflects_state() {
let s = continuous(0.0..=10.0);
assert!(s.breaks_spec().is_none());
let s = s.with_interval(2.5);
assert!(matches!(
s.breaks_spec(),
Some(BreaksSpec::NumericInterval(2.5))
));
}
#[test]
fn minor_breaks_spec_accessor_reflects_state() {
let s = continuous(0.0..=10.0);
assert!(s.minor_breaks_spec().is_none());
let s = s.with_minor_count(3);
assert!(matches!(
s.minor_breaks_spec(),
Some(MinorBreaksSpec::CountBetween(3))
));
}
#[test]
fn override_mutators_bump_generation() {
let mut s = continuous(0.0..=10.0);
let g0 = s.generation();
s.set_breaks(vec![Value::Number(5.0)]);
assert!(s.generation() > g0);
let g1 = s.generation();
s.set_format(|_, _| "X".to_string());
assert!(s.generation() > g1);
let g2 = s.generation();
s.set_interval(1.0);
assert!(s.generation() > g2);
let g3 = s.generation();
s.clear_breaks();
assert!(s.generation() > g3);
}
#[test]
fn minor_override_mutators_bump_generation() {
let mut s = continuous(0.0..=10.0);
let g0 = s.generation();
s.set_minor_breaks(vec![Value::Number(5.0)]);
assert!(s.generation() > g0);
let g1 = s.generation();
s.set_minor_count(2);
assert!(s.generation() > g1);
let g2 = s.generation();
s.set_minor_interval(0.5);
assert!(s.generation() > g2);
let g3 = s.generation();
s.set_minor_temporal_interval(TemporalInterval::new(1, CalendarUnit::Week));
assert!(s.generation() > g3);
let g4 = s.generation();
s.clear_minor_breaks();
assert!(s.generation() > g4);
}
#[test]
fn override_chained_builders_do_not_bump_generation() {
let s = continuous(0.0..=10.0)
.with_breaks(vec![Value::Number(5.0)])
.with_format(|_, _| "X".to_string())
.with_interval(2.0);
assert_eq!(s.generation(), 0);
}
}