From 0eacdf25df5340fe866fecc01006c701579b7156 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 28 Jun 2025 17:24:18 -0400 Subject: [PATCH 01/29] Refactor scale constructors: rename new to configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed all scale type `new` methods that return `ConfiguredScale` to `configured` - Renamed LinearScale::new_color to LinearScale::configured_color for consistency - Removed #[allow(clippy::new_ret_no_self)] attributes as they're no longer needed - Updated all usage sites including examples, tests, and internal macros - Applied consistent naming convention across all scale types: - LinearScale::configured() - QuantizeScale::configured() - ThresholdScale::configured() - QuantileScale::configured() - BandScale::configured() - PointScale::configured() - SymlogScale::configured() - OrdinalScale::configured() - LogScale::configured() - PowScale::configured() This change improves API clarity by making it explicit that these methods return a configured scale instance rather than the scale type itself. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/examples/categorical_scales.rs | 7 ++++--- avenger-scales/examples/color_scales.rs | 6 ++++-- avenger-scales/examples/formatting_and_ticks.rs | 6 +++--- avenger-scales/examples/linear_scale.rs | 2 +- avenger-scales/examples/logarithmic_scales.rs | 8 ++++---- avenger-scales/examples/quantile_threshold.rs | 9 +++++---- avenger-scales/src/scales/band.rs | 3 +-- avenger-scales/src/scales/coerce.rs | 2 +- avenger-scales/src/scales/linear.rs | 5 ++--- avenger-scales/src/scales/log.rs | 5 ++--- avenger-scales/src/scales/ordinal.rs | 3 +-- avenger-scales/src/scales/point.rs | 3 +-- avenger-scales/src/scales/pow.rs | 3 +-- avenger-scales/src/scales/quantile.rs | 3 +-- avenger-scales/src/scales/quantize.rs | 3 +-- avenger-scales/src/scales/symlog.rs | 3 +-- avenger-scales/src/scales/threshold.rs | 3 +-- examples/iris-pan-zoom/src/util.rs | 10 +++++----- examples/wgpu-scales/src/util.rs | 8 ++++---- 19 files changed, 43 insertions(+), 49 deletions(-) diff --git a/avenger-scales/examples/categorical_scales.rs b/avenger-scales/examples/categorical_scales.rs index 5864282b..d5855232 100644 --- a/avenger-scales/examples/categorical_scales.rs +++ b/avenger-scales/examples/categorical_scales.rs @@ -11,7 +11,7 @@ fn main() -> Result<(), Box> { let categories = vec!["Category A", "Category B", "Category C", "Category D"]; let domain = Arc::new(StringArray::from(categories.clone())) as ArrayRef; - let band_scale = BandScale::new(domain.clone(), (0.0, 400.0)) + let band_scale = BandScale::configured(domain.clone(), (0.0, 400.0)) .with_option("padding", 0.1) // 10% padding between bands .with_option("padding_outer", 0.05); // 5% padding on outer edges @@ -33,7 +33,8 @@ fn main() -> Result<(), Box> { // Example 2: Point Scale for Scatter Plots println!("\n2. Point Scale (for scatter plots, no bandwidth):"); - let point_scale = PointScale::new(domain.clone(), (0.0, 300.0)).with_option("padding", 0.5); // Space around the points + let point_scale = + PointScale::configured(domain.clone(), (0.0, 300.0)).with_option("padding", 0.5); // Space around the points let point_positions = point_scale.scale_to_numeric(&test_array)?; let point_values = point_positions.as_vec(categories.len(), None); @@ -53,7 +54,7 @@ fn main() -> Result<(), Box> { let stroke_cap_values = vec!["round", "square", "butt"]; let stroke_range = Arc::new(StringArray::from(stroke_cap_values)) as ArrayRef; - let ordinal_scale = OrdinalScale::new(cat_domain).with_range(stroke_range); + let ordinal_scale = OrdinalScale::configured(cat_domain).with_range(stroke_range); let test_categories = Arc::new(StringArray::from(categories.clone())) as ArrayRef; let stroke_result = ordinal_scale.scale_to_stroke_cap(&test_categories)?; diff --git a/avenger-scales/examples/color_scales.rs b/avenger-scales/examples/color_scales.rs index d9dab246..da8a4bb0 100644 --- a/avenger-scales/examples/color_scales.rs +++ b/avenger-scales/examples/color_scales.rs @@ -19,7 +19,8 @@ fn main() -> Result<(), Box> { ]; let color_range = Scalar::arrays_into_list_array(color_arrays)?; - let linear_color_scale = LinearScale::new((0.0, 10.0), (0.0, 1.0)).with_range(color_range); + let linear_color_scale = + LinearScale::configured((0.0, 10.0), (0.0, 1.0)).with_range(color_range); let test_values = vec![0.0, 2.5, 5.0, 7.5, 10.0]; let values_array = Arc::new(Float32Array::from(test_values.clone())) as ArrayRef; @@ -52,7 +53,8 @@ fn main() -> Result<(), Box> { ]; let three_color_range = Scalar::arrays_into_list_array(color_arrays)?; - let log_color_scale = LogScale::new((1.0, 100.0), (0.0, 1.0)).with_range(three_color_range); + let log_color_scale = + LogScale::configured((1.0, 100.0), (0.0, 1.0)).with_range(three_color_range); let log_test_values = vec![1.0, 3.16, 10.0, 31.6, 100.0]; // Evenly spaced in log space let log_values_array = Arc::new(Float32Array::from(log_test_values.clone())) as ArrayRef; diff --git a/avenger-scales/examples/formatting_and_ticks.rs b/avenger-scales/examples/formatting_and_ticks.rs index 9d17bdd8..9a9f6b4f 100644 --- a/avenger-scales/examples/formatting_and_ticks.rs +++ b/avenger-scales/examples/formatting_and_ticks.rs @@ -9,7 +9,7 @@ fn main() -> Result<(), Box> { // Example 1: Numeric Formatting println!("1. Number Formatting:"); - let scale = LinearScale::new((0.0, 1000.0), (0.0, 500.0)); + let scale = LinearScale::configured((0.0, 1000.0), (0.0, 500.0)); let numbers = vec![0.0, 123.456, 1000.0, 10000.0, 0.00123]; let number_array = Arc::new(Float32Array::from(numbers.clone())) as ArrayRef; @@ -110,7 +110,7 @@ fn main() -> Result<(), Box> { println!("\n5. Tick Generation:"); // Linear scale ticks - let tick_scale = LinearScale::new((0.0, 100.0), (0.0, 400.0)); + let tick_scale = LinearScale::configured((0.0, 100.0), (0.0, 400.0)); println!("Linear scale ticks (different counts):"); for tick_count in [5.0, 10.0, 15.0] { @@ -123,7 +123,7 @@ fn main() -> Result<(), Box> { // Example 6: Scale with Custom Options println!("\n6. Scale with Custom Options:"); - let custom_scale = LinearScale::new((0.0, 1000.0), (0.0, 500.0)) + let custom_scale = LinearScale::configured((0.0, 1000.0), (0.0, 500.0)) .with_option("clamp", true) .with_option("round", true) .with_option("nice", true); diff --git a/avenger-scales/examples/linear_scale.rs b/avenger-scales/examples/linear_scale.rs index 7e875d16..8c7df2ac 100644 --- a/avenger-scales/examples/linear_scale.rs +++ b/avenger-scales/examples/linear_scale.rs @@ -6,7 +6,7 @@ fn main() -> Result<(), Box> { println!("=== Simple Linear Scale Example ===\n"); // Create a linear scale that maps domain [0, 100] to range [0, 500] - let scale = LinearScale::new((0.0, 100.0), (0.0, 500.0)); + let scale = LinearScale::configured((0.0, 100.0), (0.0, 500.0)); // Test data: some values in our domain let test_values = vec![0.0, 25.0, 50.0, 75.0, 100.0]; diff --git a/avenger-scales/examples/logarithmic_scales.rs b/avenger-scales/examples/logarithmic_scales.rs index 6ce29030..37c26eea 100644 --- a/avenger-scales/examples/logarithmic_scales.rs +++ b/avenger-scales/examples/logarithmic_scales.rs @@ -8,7 +8,7 @@ fn main() -> Result<(), Box> { // Example 1: Basic Log Scale println!("1. Log Scale (base 10, domain [1, 1000]):"); - let log_scale = LogScale::new((1.0, 1000.0), (0.0, 300.0)).with_option("base", 10.0); + let log_scale = LogScale::configured((1.0, 1000.0), (0.0, 300.0)).with_option("base", 10.0); let test_values = vec![1.0, 10.0, 100.0, 1000.0]; let values_array = Arc::new(Float32Array::from(test_values.clone())) as ArrayRef; @@ -32,7 +32,7 @@ fn main() -> Result<(), Box> { // Example 2: Log Scale with Different Base println!("\n2. Log Scale (base 2, for computer science applications):"); - let log2_scale = LogScale::new((1.0, 64.0), (0.0, 200.0)).with_option("base", 2.0); + let log2_scale = LogScale::configured((1.0, 64.0), (0.0, 200.0)).with_option("base", 2.0); let powers_of_2 = vec![1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0]; let pow2_array = Arc::new(Float32Array::from(powers_of_2.clone())) as ArrayRef; @@ -49,7 +49,7 @@ fn main() -> Result<(), Box> { println!("\n3. Symlog Scale (handles negative values and zero):"); let symlog_scale = - SymlogScale::new((-1000.0, 1000.0), (0.0, 400.0)).with_option("constant", 100.0); // Linear region around zero: [-100, 100] + SymlogScale::configured((-1000.0, 1000.0), (0.0, 400.0)).with_option("constant", 100.0); // Linear region around zero: [-100, 100] let symlog_values = vec![-1000.0, -100.0, -10.0, 0.0, 10.0, 100.0, 1000.0]; let symlog_array = Arc::new(Float32Array::from(symlog_values.clone())) as ArrayRef; @@ -65,7 +65,7 @@ fn main() -> Result<(), Box> { // Example 4: Power Scale (square root, good for area mappings) println!("\n4. Power Scale (square root, for area-based visualizations):"); - let sqrt_scale = PowScale::new((0.0, 100.0), (0.0, 200.0)).with_option("exponent", 0.5); // Square root + let sqrt_scale = PowScale::configured((0.0, 100.0), (0.0, 200.0)).with_option("exponent", 0.5); // Square root let area_values = vec![ 0.0, 1.0, 4.0, 9.0, 16.0, 25.0, 36.0, 49.0, 64.0, 81.0, 100.0, diff --git a/avenger-scales/examples/quantile_threshold.rs b/avenger-scales/examples/quantile_threshold.rs index 5dd06e43..e10c0e68 100644 --- a/avenger-scales/examples/quantile_threshold.rs +++ b/avenger-scales/examples/quantile_threshold.rs @@ -20,7 +20,7 @@ fn main() -> Result<(), Box> { let grade_range = Arc::new(StringArray::from(vec!["F", "D", "C", "B", "A"])) as ArrayRef; - let quantile_scale = QuantileScale::new(test_scores.clone(), grade_range); + let quantile_scale = QuantileScale::configured(test_scores.clone(), grade_range); // Test with some sample scores let sample_scores = vec![50.0, 65.0, 75.0, 85.0, 95.0]; @@ -38,7 +38,8 @@ fn main() -> Result<(), Box> { println!("\n2. Quantize Scale (uniform intervals):"); let grade_range2 = Arc::new(StringArray::from(vec!["F", "D", "C", "B", "A"])) as ArrayRef; - let quantize_scale = QuantizeScale::new((0.0, 100.0), grade_range2).with_option("nice", true); + let quantize_scale = + QuantizeScale::configured((0.0, 100.0), grade_range2).with_option("nice", true); let quantize_result = quantize_scale.scale_to_string(&sample_array)?; let quantize_grades = quantize_result.as_vec(sample_scores.len(), None); @@ -55,7 +56,7 @@ fn main() -> Result<(), Box> { let thresholds = vec![60.0, 70.0, 80.0, 90.0]; let grade_range3 = Arc::new(StringArray::from(vec!["F", "D", "C", "B", "A"])) as ArrayRef; - let threshold_scale = ThresholdScale::new(thresholds, grade_range3); + let threshold_scale = ThresholdScale::configured(thresholds, grade_range3); let threshold_result = threshold_scale.scale_to_string(&sample_array)?; let threshold_grades = threshold_result.as_vec(sample_scores.len(), None); @@ -76,7 +77,7 @@ fn main() -> Result<(), Box> { let risk_categories = Arc::new(StringArray::from(vec!["Low", "Medium", "High", "Critical"])) as ArrayRef; - let risk_scale = ThresholdScale::new(risk_thresholds, risk_categories); + let risk_scale = ThresholdScale::configured(risk_thresholds, risk_categories); let risk_result = risk_scale.scale_to_string(&risk_array)?; let risk_labels = risk_result.as_vec(risk_values.len(), None); diff --git a/avenger-scales/src/scales/band.rs b/avenger-scales/src/scales/band.rs index 49dfe18a..b0a83d1c 100644 --- a/avenger-scales/src/scales/band.rs +++ b/avenger-scales/src/scales/band.rs @@ -17,8 +17,7 @@ use super::{ pub struct BandScale; impl BandScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: ArrayRef, range: (f32, f32)) -> ConfiguredScale { + pub fn configured(domain: ArrayRef, range: (f32, f32)) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { diff --git a/avenger-scales/src/scales/coerce.rs b/avenger-scales/src/scales/coerce.rs index fb26d204..0200870d 100644 --- a/avenger-scales/src/scales/coerce.rs +++ b/avenger-scales/src/scales/coerce.rs @@ -175,7 +175,7 @@ macro_rules! define_enum_coercer { values: &ArrayRef, ) -> Result, AvengerScaleError> { let domain = Arc::new(StringArray::from(Vec::from(<$enum_type>::VARIANTS))) as ArrayRef; - let scale = OrdinalScale::new(domain.clone()).with_range(domain); + let scale = OrdinalScale::configured(domain.clone()).with_range(domain); scale.[](values) } } diff --git a/avenger-scales/src/scales/linear.rs b/avenger-scales/src/scales/linear.rs index 7fe75e93..55c532db 100644 --- a/avenger-scales/src/scales/linear.rs +++ b/avenger-scales/src/scales/linear.rs @@ -17,8 +17,7 @@ use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContex pub struct LinearScale; impl LinearScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: (f32, f32), range: (f32, f32)) -> ConfiguredScale { + pub fn configured(domain: (f32, f32), range: (f32, f32)) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { @@ -37,7 +36,7 @@ impl LinearScale { } } - pub fn new_color(domain: (f32, f32), range: I) -> ConfiguredScale + pub fn configured_color(domain: (f32, f32), range: I) -> ConfiguredScale where I: IntoIterator, I::Item: Into, diff --git a/avenger-scales/src/scales/log.rs b/avenger-scales/src/scales/log.rs index e9c9b052..67cb4289 100644 --- a/avenger-scales/src/scales/log.rs +++ b/avenger-scales/src/scales/log.rs @@ -18,8 +18,7 @@ use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContex pub struct LogScale; impl LogScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: (f32, f32), range: (f32, f32)) -> ConfiguredScale { + pub fn configured(domain: (f32, f32), range: (f32, f32)) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { @@ -803,7 +802,7 @@ mod tests { let color_range = crate::scalar::Scalar::arrays_into_list_array(color_arrays)?; // Create the log scale - let scale = LogScale::new((1.0, 100.0), (0.0, 1.0)).with_range(color_range); + let scale = LogScale::configured((1.0, 100.0), (0.0, 1.0)).with_range(color_range); // Test values across the domain let test_values = vec![1.0, 3.16, 10.0, 31.6, 100.0]; // Evenly spaced in log space diff --git a/avenger-scales/src/scales/ordinal.rs b/avenger-scales/src/scales/ordinal.rs index 96e58267..a1da3a14 100644 --- a/avenger-scales/src/scales/ordinal.rs +++ b/avenger-scales/src/scales/ordinal.rs @@ -36,8 +36,7 @@ macro_rules! impl_ordinal_enum_scale_method { pub struct OrdinalScale; impl OrdinalScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: ArrayRef) -> ConfiguredScale { + pub fn configured(domain: ArrayRef) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { diff --git a/avenger-scales/src/scales/point.rs b/avenger-scales/src/scales/point.rs index 80a2c989..8171d03f 100644 --- a/avenger-scales/src/scales/point.rs +++ b/avenger-scales/src/scales/point.rs @@ -14,8 +14,7 @@ use super::{ pub struct PointScale; impl PointScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: ArrayRef, range: (f32, f32)) -> ConfiguredScale { + pub fn configured(domain: ArrayRef, range: (f32, f32)) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { diff --git a/avenger-scales/src/scales/pow.rs b/avenger-scales/src/scales/pow.rs index 3025775d..95a77071 100644 --- a/avenger-scales/src/scales/pow.rs +++ b/avenger-scales/src/scales/pow.rs @@ -19,8 +19,7 @@ use super::{ pub struct PowScale; impl PowScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: (f32, f32), range: (f32, f32)) -> ConfiguredScale { + pub fn configured(domain: (f32, f32), range: (f32, f32)) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { diff --git a/avenger-scales/src/scales/quantile.rs b/avenger-scales/src/scales/quantile.rs index 6343e8ba..4342b9b6 100644 --- a/avenger-scales/src/scales/quantile.rs +++ b/avenger-scales/src/scales/quantile.rs @@ -17,8 +17,7 @@ use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContex pub struct QuantileScale; impl QuantileScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: Vec, range: ArrayRef) -> ConfiguredScale { + pub fn configured(domain: Vec, range: ArrayRef) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { diff --git a/avenger-scales/src/scales/quantize.rs b/avenger-scales/src/scales/quantize.rs index 4dfe54ad..11d040e5 100644 --- a/avenger-scales/src/scales/quantize.rs +++ b/avenger-scales/src/scales/quantize.rs @@ -18,8 +18,7 @@ use super::{ pub struct QuantizeScale; impl QuantizeScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: (f32, f32), range: ArrayRef) -> ConfiguredScale { + pub fn configured(domain: (f32, f32), range: ArrayRef) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { diff --git a/avenger-scales/src/scales/symlog.rs b/avenger-scales/src/scales/symlog.rs index f398489b..68e26bdb 100644 --- a/avenger-scales/src/scales/symlog.rs +++ b/avenger-scales/src/scales/symlog.rs @@ -19,8 +19,7 @@ use super::{ pub struct SymlogScale; impl SymlogScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: (f32, f32), range: (f32, f32)) -> ConfiguredScale { + pub fn configured(domain: (f32, f32), range: (f32, f32)) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { diff --git a/avenger-scales/src/scales/threshold.rs b/avenger-scales/src/scales/threshold.rs index b5ec5e0a..a398d94f 100644 --- a/avenger-scales/src/scales/threshold.rs +++ b/avenger-scales/src/scales/threshold.rs @@ -14,8 +14,7 @@ use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContex pub struct ThresholdScale; impl ThresholdScale { - #[allow(clippy::new_ret_no_self)] - pub fn new(domain: Vec, range: ArrayRef) -> ConfiguredScale { + pub fn configured(domain: Vec, range: ArrayRef) -> ConfiguredScale { ConfiguredScale { scale_impl: Arc::new(Self), config: ScaleConfig { diff --git a/examples/iris-pan-zoom/src/util.rs b/examples/iris-pan-zoom/src/util.rs index b9e8a27d..d8ed1e6e 100644 --- a/examples/iris-pan-zoom/src/util.rs +++ b/examples/iris-pan-zoom/src/util.rs @@ -147,7 +147,7 @@ impl ChartState { // println!("data length: {:?}", sepal_length_array.len()); let fill_opacity = 0.1; - let color_scale = OrdinalScale::new(Arc::new(StringArray::from(vec![ + let color_scale = OrdinalScale::configured(Arc::new(StringArray::from(vec![ "Iris-setosa", "Iris-versicolor", "Iris-virginica", @@ -171,8 +171,8 @@ impl ChartState { let domain_sepal_length = (4.0, 8.5); let domain_sepal_width = (1.5, 5.0); - let base_x_scale = LinearScale::new(domain_sepal_length, (0.0, width)); - let base_y_scale = LinearScale::new(domain_sepal_width, (height, 0.0)); + let base_x_scale = LinearScale::configured(domain_sepal_length, (0.0, width)); + let base_y_scale = LinearScale::configured(domain_sepal_width, (height, 0.0)); let x = base_x_scale.scale_to_numeric(&sepal_length_array).unwrap(); let y = base_y_scale.scale_to_numeric(&sepal_width_array).unwrap(); @@ -215,12 +215,12 @@ impl ChartState { /// Scale for current x domain pub fn x_scale(&self) -> ConfiguredScale { - LinearScale::new(self.domain_sepal_length, (0.0, self.width)) + LinearScale::configured(self.domain_sepal_length, (0.0, self.width)) } /// Scale for current y domain pub fn y_scale(&self) -> ConfiguredScale { - LinearScale::new(self.domain_sepal_width, (self.height, 0.0)) + LinearScale::configured(self.domain_sepal_width, (self.height, 0.0)) } } diff --git a/examples/wgpu-scales/src/util.rs b/examples/wgpu-scales/src/util.rs index 5e4cc026..a0b3c216 100644 --- a/examples/wgpu-scales/src/util.rs +++ b/examples/wgpu-scales/src/util.rs @@ -211,16 +211,16 @@ pub async fn run() { let height = 200.0; // Make x scales - let x_scale = BandScale::new(x_array.clone(), (0.0, width)) + let x_scale = BandScale::configured(x_array.clone(), (0.0, width)) .with_option("padding_inner", 0.2) .with_option("padding_outer", 0.2) .with_option("band", 0.0); let x2_scale = x_scale.clone().with_option("band", 1.0); - let y_scale = LinearScale::new((0.0, 100.0), (height, 0.0)); + let y_scale = LinearScale::configured((0.0, 100.0), (height, 0.0)); - let color_scale = - LinearScale::new_color((0.0, 100.0), vec!["white", "blue"]).with_option("nice", 10.0); + let color_scale = LinearScale::configured_color((0.0, 100.0), vec!["white", "blue"]) + .with_option("nice", 10.0); // Make rect mark let rect = SceneRectMark { From d4f41bb35de17261a7ac69d97d6c6e92dfa31983 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 28 Jun 2025 17:56:16 -0400 Subject: [PATCH 02/29] Document configuration options for all scale types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added comprehensive documentation as docstrings on each scale struct describing all available configuration options, their types, defaults, and behavior: - LinearScale: clamp, range_offset, round, nice - QuantizeScale: nice - ThresholdScale: no options - QuantileScale: no options - BandScale: align, band, padding_inner, padding_outer, round, range_offset - PointScale: align, padding, round, range_offset - SymlogScale: constant, clamp, range_offset, round, nice - OrdinalScale: no options - LogScale: base, clamp, range_offset, round, nice - PowScale: exponent, clamp, range_offset, round, nice This documentation makes the scale configuration options discoverable through IDE tooltips and generated documentation, improving the developer experience. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/band.rs | 27 +++++++++++++++++++++++++ avenger-scales/src/scales/linear.rs | 17 ++++++++++++++++ avenger-scales/src/scales/log.rs | 26 ++++++++++++++++++++++++ avenger-scales/src/scales/ordinal.rs | 12 +++++++++++ avenger-scales/src/scales/point.rs | 20 ++++++++++++++++++ avenger-scales/src/scales/pow.rs | 28 ++++++++++++++++++++++++++ avenger-scales/src/scales/quantile.rs | 11 ++++++++++ avenger-scales/src/scales/quantize.rs | 12 +++++++++++ avenger-scales/src/scales/symlog.rs | 26 ++++++++++++++++++++++++ avenger-scales/src/scales/threshold.rs | 12 +++++++++++ 10 files changed, 191 insertions(+) diff --git a/avenger-scales/src/scales/band.rs b/avenger-scales/src/scales/band.rs index b0a83d1c..c07abc3d 100644 --- a/avenger-scales/src/scales/band.rs +++ b/avenger-scales/src/scales/band.rs @@ -13,6 +13,33 @@ use super::{ ordinal::OrdinalScale, ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleImpl, }; +/// Band scale that maps discrete domain values to continuous numeric bands with optional padding. +/// +/// This scale is useful for bar charts and other visualizations where each discrete value +/// needs a dedicated band of space. Each domain value is assigned a band of equal width, +/// with configurable padding between and around the bands. +/// +/// # Config Options +/// +/// - **align** (f32, default: 0.5): Alignment of the band layout within the range [0, 1]. +/// 0 aligns to the start, 0.5 centers, and 1 aligns to the end. +/// +/// - **band** (f32, default: 0.0): Position within each band where the value is placed [0, 1]. +/// 0 places at band start, 0.5 at band center, and 1 at band end. This effectively +/// controls where within its allocated band each mark is positioned. +/// +/// - **padding_inner** (f32, default: 0.0): Padding between adjacent bands as a fraction [0, 1] +/// of the step size. A value of 0.2 means 20% of the step is used for padding. +/// +/// - **padding_outer** (f32, default: 0.0): Padding before the first and after the last band +/// as a multiple of the step size. Must be non-negative. A value of 0.5 adds half a step +/// of padding on each end. +/// +/// - **round** (boolean, default: false): When true, band positions and widths are rounded +/// to integer pixel values for crisp rendering. +/// +/// - **range_offset** (f32, default: 0.0): Additional offset applied to all band positions +/// after computing their base positions. Useful for fine-tuning placement. #[derive(Debug, Clone)] pub struct BandScale; diff --git a/avenger-scales/src/scales/linear.rs b/avenger-scales/src/scales/linear.rs index 55c532db..8c93fecc 100644 --- a/avenger-scales/src/scales/linear.rs +++ b/avenger-scales/src/scales/linear.rs @@ -13,6 +13,23 @@ use crate::{ use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +/// Linear scale that maps a continuous numeric domain to a continuous numeric range. +/// +/// # Config Options +/// +/// - **clamp** (boolean, default: false): When true, values outside the domain are clamped to the domain extent. +/// For scaling, this means values below the domain minimum map to the range minimum, and values above +/// the domain maximum map to the range maximum. For inversion, the clamping is applied to the range. +/// +/// - **range_offset** (f32, default: 0.0): An offset applied to the final scaled values. This is added +/// after the linear transformation. When inverting, this offset is subtracted from input values first. +/// +/// - **round** (boolean, default: false): When true, output values from scaling are rounded to the nearest +/// integer. This is useful for pixel-perfect rendering. Does not affect inversion. +/// +/// - **nice** (boolean or f32, default: false): When true or a number, extends the domain to nice round values. +/// If true, uses a default count of 10. If a number, uses that as the target tick count for determining +/// nice values. Nice domains are computed to align with human-friendly values (multiples of 1, 2, 5, 10, etc.). #[derive(Debug)] pub struct LinearScale; diff --git a/avenger-scales/src/scales/log.rs b/avenger-scales/src/scales/log.rs index 67cb4289..d71d6560 100644 --- a/avenger-scales/src/scales/log.rs +++ b/avenger-scales/src/scales/log.rs @@ -14,6 +14,32 @@ use crate::{ use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +/// Logarithmic scale that maps a continuous numeric domain to a continuous numeric range +/// using logarithmic transformation. +/// +/// The scale applies log(x) transformation to input values, making it useful for data +/// that spans several orders of magnitude. The scale supports negative domains by +/// applying -log(-x) for negative values. Zero values produce NaN outputs. +/// +/// # Config Options +/// +/// - **base** (f32, default: 10.0): The logarithm base. Common values are 10 (common log), +/// 2 (binary log), and e (natural log, use 2.718281828). Must be positive and not 1. +/// +/// - **clamp** (boolean, default: false): When true, values outside the domain are clamped +/// to the domain extent before transformation. For inversion, values outside the range +/// are clamped first. +/// +/// - **range_offset** (f32, default: 0.0): An offset applied to the final scaled values. +/// This is added after the logarithmic transformation and linear mapping. +/// +/// - **round** (boolean, default: false): When true, output values from scaling are rounded +/// to the nearest integer. Useful for pixel-perfect rendering. Does not affect inversion. +/// +/// - **nice** (boolean or f32, default: false): When true or a number, extends the domain +/// to nice round values in logarithmic space (powers of the base). If true, uses a +/// default count of 10. If a number, uses that as the target tick count. For example, +/// with base 10, a domain of [8, 95] might become [1, 100]. #[derive(Debug)] pub struct LogScale; diff --git a/avenger-scales/src/scales/ordinal.rs b/avenger-scales/src/scales/ordinal.rs index a1da3a14..c835428e 100644 --- a/avenger-scales/src/scales/ordinal.rs +++ b/avenger-scales/src/scales/ordinal.rs @@ -32,6 +32,18 @@ macro_rules! impl_ordinal_enum_scale_method { }; } +/// Ordinal scale that maps discrete domain values to discrete range values by position. +/// +/// Each unique value in the domain is mapped to the corresponding value in the range +/// array by index. The first domain value maps to the first range value, the second +/// to the second, and so on. Values not present in the domain produce null/default outputs. +/// +/// The range can contain any type of values: numbers, strings, colors, or even enum +/// values like StrokeCap or ImageAlign. The domain and range must have the same length. +/// +/// # Config Options +/// +/// This scale does not currently support any configuration options. #[derive(Debug, Clone)] pub struct OrdinalScale; diff --git a/avenger-scales/src/scales/point.rs b/avenger-scales/src/scales/point.rs index 8171d03f..e0a6e251 100644 --- a/avenger-scales/src/scales/point.rs +++ b/avenger-scales/src/scales/point.rs @@ -10,6 +10,26 @@ use super::{ ScaleImpl, }; +/// Point scale that maps discrete domain values to evenly-spaced points along a continuous range. +/// +/// This scale is implemented as a special case of band scale where the band width is zero, +/// resulting in points instead of bands. It's useful for scatter plots and other visualizations +/// where discrete categories need to be positioned at specific points. +/// +/// # Config Options +/// +/// - **align** (f32, default: 0.5): Alignment of the point layout within the range [0, 1]. +/// 0 aligns points to the start, 0.5 centers them, and 1 aligns to the end. +/// +/// - **padding** (f32, default: 0.0): Padding before the first and after the last point +/// as a fraction of the spacing between points. A value of 0.5 adds half the step +/// distance as padding on each end. +/// +/// - **round** (boolean, default: false): When true, point positions are rounded to +/// integer pixel values for crisp rendering. +/// +/// - **range_offset** (f32, default: 0.0): Additional offset applied to all point positions +/// after computing their base positions. Useful for fine-tuning placement. #[derive(Debug, Clone)] pub struct PointScale; diff --git a/avenger-scales/src/scales/pow.rs b/avenger-scales/src/scales/pow.rs index 95a77071..92d5b762 100644 --- a/avenger-scales/src/scales/pow.rs +++ b/avenger-scales/src/scales/pow.rs @@ -15,6 +15,34 @@ use super::{ ScaleImpl, }; +/// Power scale that maps a continuous numeric domain to a continuous numeric range +/// using power (exponential) transformation. +/// +/// The scale applies x^exponent transformation to input values. It supports negative +/// values by preserving the sign: sign(x) * |x|^exponent. Common exponents include +/// 0.5 (square root) for area-based encodings and 2 (square) for emphasizing differences. +/// +/// # Config Options +/// +/// - **exponent** (f32, default: 1.0): The power exponent. When 1.0, behaves like a +/// linear scale. Values < 1 compress large values and expand small values. +/// Values > 1 expand large values and compress small values. Must not be 0. +/// +/// - **clamp** (boolean, default: false): When true, values outside the domain are +/// clamped to the domain extent before transformation. For inversion, values +/// outside the range are clamped first. +/// +/// - **range_offset** (f32, default: 0.0): An offset applied to the final scaled +/// values. This is added after the power transformation and linear mapping. +/// Note: In inversion, this is subtracted from the output, not the input. +/// +/// - **round** (boolean, default: false): When true, output values from scaling +/// are rounded to the nearest integer. Useful for pixel-perfect rendering. +/// Does not affect inversion. +/// +/// - **nice** (boolean or f32, default: false): When true or a number, extends +/// the domain to nice round values in the transformed space. If true, uses +/// a default count of 10. If a number, uses that as the target tick count. #[derive(Debug)] pub struct PowScale; diff --git a/avenger-scales/src/scales/quantile.rs b/avenger-scales/src/scales/quantile.rs index 4342b9b6..3a10b617 100644 --- a/avenger-scales/src/scales/quantile.rs +++ b/avenger-scales/src/scales/quantile.rs @@ -13,6 +13,17 @@ use crate::error::AvengerScaleError; use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +/// Quantile scale that maps continuous numeric input values to discrete range values +/// using quantile boundaries computed from the domain. +/// +/// The domain should contain sample data values. The scale computes n-1 quantile +/// thresholds that divide the sorted domain into n equal-sized groups, where n is +/// the number of values in the range. Each input value is then mapped to the +/// corresponding range value based on which quantile it falls into. +/// +/// # Config Options +/// +/// This scale does not currently support any configuration options. #[derive(Debug, Clone)] pub struct QuantileScale; diff --git a/avenger-scales/src/scales/quantize.rs b/avenger-scales/src/scales/quantize.rs index 11d040e5..169dbf54 100644 --- a/avenger-scales/src/scales/quantize.rs +++ b/avenger-scales/src/scales/quantize.rs @@ -14,6 +14,18 @@ use super::{ ScaleImpl, }; +/// Quantize scale that divides a continuous numeric domain into uniform segments, +/// mapping each segment to a discrete value from the range. +/// +/// The scale divides the domain into n equal-sized bins where n is the number of +/// values in the range array. Each input value is mapped to the range value +/// corresponding to its bin. +/// +/// # Config Options +/// +/// - **nice** (boolean or f32, default: false): When true or a number, extends the domain to nice round values +/// before quantization. If true, uses a default count of 10. If a number, uses that as the target tick count +/// for determining nice values. This ensures bins align with human-friendly boundaries. #[derive(Debug, Clone)] pub struct QuantizeScale; diff --git a/avenger-scales/src/scales/symlog.rs b/avenger-scales/src/scales/symlog.rs index 68e26bdb..b54e6fd4 100644 --- a/avenger-scales/src/scales/symlog.rs +++ b/avenger-scales/src/scales/symlog.rs @@ -15,6 +15,32 @@ use super::{ ScaleImpl, }; +/// Symmetric log scale that provides smooth linear-to-logarithmic transitions for data +/// that includes zero or spans both positive and negative values. +/// +/// The symlog transformation is defined as: sign(x) * log(1 + |x|/C) where C is the constant. +/// This provides linear behavior near zero (within [-C, C]) and logarithmic behavior for +/// larger absolute values, making it ideal for data with values near zero or crossing zero. +/// +/// # Config Options +/// +/// - **constant** (f32, default: 1.0): The linear threshold that controls the transition +/// between linear and logarithmic behavior. Within the range [-constant, constant], +/// the scale behaves approximately linearly. Must be positive. +/// +/// - **clamp** (boolean, default: false): When true, values outside the domain are clamped +/// to the domain extent. For scaling, this means values outside domain map to the +/// corresponding range extent. For inversion, values outside range are clamped first. +/// +/// - **range_offset** (f32, default: 0.0): An offset applied to the final scaled values. +/// This is added after the transformation. When inverting, this offset is subtracted first. +/// +/// - **round** (boolean, default: false): When true, output values from scaling are rounded +/// to the nearest integer. This is useful for pixel-perfect rendering. Does not affect inversion. +/// +/// - **nice** (boolean or f32, default: false): When true or a number, extends the domain to +/// nice round values in the transformed space. If true, uses a default count of 10. +/// If a number, uses that as the target tick count for determining nice values. #[derive(Debug)] pub struct SymlogScale; diff --git a/avenger-scales/src/scales/threshold.rs b/avenger-scales/src/scales/threshold.rs index a398d94f..2bd873fa 100644 --- a/avenger-scales/src/scales/threshold.rs +++ b/avenger-scales/src/scales/threshold.rs @@ -10,6 +10,18 @@ use crate::error::AvengerScaleError; use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +/// Threshold scale that maps continuous numeric input values to discrete range values +/// based on a set of threshold boundaries. +/// +/// The domain must contain threshold values in ascending order. The range must contain +/// n+1 values where n is the number of thresholds. Input values are mapped as follows: +/// - Values < first threshold → first range value +/// - Values >= threshold[i] and < threshold[i+1] → range[i+1] +/// - Values >= last threshold → last range value +/// +/// # Config Options +/// +/// This scale does not currently support any configuration options. #[derive(Debug, Clone)] pub struct ThresholdScale; From b4c7e4ffcb8cfb56bf3c42874b491fffb0d087e8 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 28 Jun 2025 22:23:05 -0400 Subject: [PATCH 03/29] Add scale_type() method to ScaleImpl trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement scale_type() method for all scale types, returning Vega-compatible scale type names in lowercase: - linear, pow, symlog, log - quantize, quantile, threshold - band, point, ordinal This provides a standard way to identify scale types programmatically. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/band.rs | 4 ++++ avenger-scales/src/scales/linear.rs | 4 ++++ avenger-scales/src/scales/log.rs | 4 ++++ avenger-scales/src/scales/mod.rs | 3 +++ avenger-scales/src/scales/ordinal.rs | 4 ++++ avenger-scales/src/scales/point.rs | 4 ++++ avenger-scales/src/scales/pow.rs | 4 ++++ avenger-scales/src/scales/quantile.rs | 4 ++++ avenger-scales/src/scales/quantize.rs | 4 ++++ avenger-scales/src/scales/symlog.rs | 4 ++++ avenger-scales/src/scales/threshold.rs | 4 ++++ 11 files changed, 43 insertions(+) diff --git a/avenger-scales/src/scales/band.rs b/avenger-scales/src/scales/band.rs index c07abc3d..3815660e 100644 --- a/avenger-scales/src/scales/band.rs +++ b/avenger-scales/src/scales/band.rs @@ -75,6 +75,10 @@ impl BandScale { } impl ScaleImpl for BandScale { + fn scale_type(&self) -> &'static str { + "band" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Unique } diff --git a/avenger-scales/src/scales/linear.rs b/avenger-scales/src/scales/linear.rs index 8c93fecc..c99eff89 100644 --- a/avenger-scales/src/scales/linear.rs +++ b/avenger-scales/src/scales/linear.rs @@ -136,6 +136,10 @@ impl LinearScale { } impl ScaleImpl for LinearScale { + fn scale_type(&self) -> &'static str { + "linear" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Interval } diff --git a/avenger-scales/src/scales/log.rs b/avenger-scales/src/scales/log.rs index d71d6560..7a0fe59d 100644 --- a/avenger-scales/src/scales/log.rs +++ b/avenger-scales/src/scales/log.rs @@ -143,6 +143,10 @@ impl LogScale { } impl ScaleImpl for LogScale { + fn scale_type(&self) -> &'static str { + "log" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Interval } diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index 0d3e9932..f1765e41 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -173,6 +173,9 @@ pub enum InferDomainFromDataMethod { } pub trait ScaleImpl: Debug + Send + Sync + 'static { + /// Return the scale type name for this scale implementation + fn scale_type(&self) -> &'static str; + /// Method that should be used to infer a scale's domain from the data that it will scale fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod; diff --git a/avenger-scales/src/scales/ordinal.rs b/avenger-scales/src/scales/ordinal.rs index c835428e..6e7fe4d2 100644 --- a/avenger-scales/src/scales/ordinal.rs +++ b/avenger-scales/src/scales/ordinal.rs @@ -62,6 +62,10 @@ impl OrdinalScale { } impl ScaleImpl for OrdinalScale { + fn scale_type(&self) -> &'static str { + "ordinal" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Unique } diff --git a/avenger-scales/src/scales/point.rs b/avenger-scales/src/scales/point.rs index e0a6e251..549a10e2 100644 --- a/avenger-scales/src/scales/point.rs +++ b/avenger-scales/src/scales/point.rs @@ -55,6 +55,10 @@ impl PointScale { } impl ScaleImpl for PointScale { + fn scale_type(&self) -> &'static str { + "point" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Unique } diff --git a/avenger-scales/src/scales/pow.rs b/avenger-scales/src/scales/pow.rs index 92d5b762..7c842674 100644 --- a/avenger-scales/src/scales/pow.rs +++ b/avenger-scales/src/scales/pow.rs @@ -89,6 +89,10 @@ impl PowScale { } impl ScaleImpl for PowScale { + fn scale_type(&self) -> &'static str { + "pow" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Interval } diff --git a/avenger-scales/src/scales/quantile.rs b/avenger-scales/src/scales/quantile.rs index 3a10b617..7ac9125a 100644 --- a/avenger-scales/src/scales/quantile.rs +++ b/avenger-scales/src/scales/quantile.rs @@ -42,6 +42,10 @@ impl QuantileScale { } impl ScaleImpl for QuantileScale { + fn scale_type(&self) -> &'static str { + "quantile" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Unique } diff --git a/avenger-scales/src/scales/quantize.rs b/avenger-scales/src/scales/quantize.rs index 169dbf54..6741fa6d 100644 --- a/avenger-scales/src/scales/quantize.rs +++ b/avenger-scales/src/scales/quantize.rs @@ -53,6 +53,10 @@ impl QuantizeScale { } impl ScaleImpl for QuantizeScale { + fn scale_type(&self) -> &'static str { + "quantize" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Unique } diff --git a/avenger-scales/src/scales/symlog.rs b/avenger-scales/src/scales/symlog.rs index b54e6fd4..a01da59b 100644 --- a/avenger-scales/src/scales/symlog.rs +++ b/avenger-scales/src/scales/symlog.rs @@ -86,6 +86,10 @@ impl SymlogScale { } impl ScaleImpl for SymlogScale { + fn scale_type(&self) -> &'static str { + "symlog" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Interval } diff --git a/avenger-scales/src/scales/threshold.rs b/avenger-scales/src/scales/threshold.rs index 2bd873fa..bc3e9d58 100644 --- a/avenger-scales/src/scales/threshold.rs +++ b/avenger-scales/src/scales/threshold.rs @@ -40,6 +40,10 @@ impl ThresholdScale { } impl ScaleImpl for ThresholdScale { + fn scale_type(&self) -> &'static str { + "threshold" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { InferDomainFromDataMethod::Unique } From 4942122fb33baffec8805af282d8e20ed0d5da24 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 30 Jun 2025 09:12:09 -0400 Subject: [PATCH 04/29] Add array-based invert() method to scales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement invert() method on ScaleImpl trait and ConfiguredScale that: - Accepts ArrayRef input and returns ArrayRef output - Parallels the scale() method pattern for consistency - Provides efficient array-based inverse transformations - Handles type conversions internally (casts to Float32) This addresses the feature request for VegaFusion's DataFusion UDF implementation, eliminating the need for scalar loops and dependency on avenger_common::ScalarOrArray. Implemented for scales that support inversion: - LinearScale - PowScale - LogScale - SymlogScale Added comprehensive tests to verify the new functionality works correctly with different input types and scale configurations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/linear.rs | 68 ++++++++++++++++++++++++++++- avenger-scales/src/scales/log.rs | 26 ++++++++++- avenger-scales/src/scales/mod.rs | 42 ++++++++++++++++++ avenger-scales/src/scales/pow.rs | 49 ++++++++++++++++++++- avenger-scales/src/scales/symlog.rs | 26 ++++++++++- 5 files changed, 207 insertions(+), 4 deletions(-) diff --git a/avenger-scales/src/scales/linear.rs b/avenger-scales/src/scales/linear.rs index c99eff89..c25c64f2 100644 --- a/avenger-scales/src/scales/linear.rs +++ b/avenger-scales/src/scales/linear.rs @@ -5,7 +5,10 @@ use arrow::{ compute::{kernels::cast, unary}, datatypes::{DataType, Float32Type}, }; -use avenger_common::{types::LinearScaleAdjustment, value::ScalarOrArray}; +use avenger_common::{ + types::LinearScaleAdjustment, + value::{ScalarOrArray, ScalarOrArrayValue}, +}; use crate::{ array, color_interpolator::scale_numeric_to_color, error::AvengerScaleError, scalar::Scalar, @@ -144,6 +147,30 @@ impl ScaleImpl for LinearScale { InferDomainFromDataMethod::Interval } + fn invert( + &self, + config: &ScaleConfig, + values: &ArrayRef, + ) -> Result { + // Cast input to Float32 if needed + let float_values = cast(values, &DataType::Float32)?; + + // Call existing invert_from_numeric + let result = self.invert_from_numeric(config, &float_values)?; + + // Convert ScalarOrArray to ArrayRef + match result.value() { + ScalarOrArrayValue::Scalar(s) => { + // If scalar, create array with single value repeated for input length + Ok(Arc::new(Float32Array::from(vec![*s; values.len()])) as ArrayRef) + } + ScalarOrArrayValue::Array(arr) => { + // If array, convert to ArrayRef + Ok(Arc::new(Float32Array::from(arr.as_ref().clone())) as ArrayRef) + } + } + } + fn scale( &self, config: &ScaleConfig, @@ -732,4 +759,43 @@ mod tests { Ok(()) } + + #[test] + fn test_configured_scale_invert() -> Result<(), AvengerScaleError> { + use arrow::array::Int32Array; + // Test the new invert() method that returns ArrayRef + let scale = LinearScale::configured((10.0, 30.0), (0.0, 100.0)); + + // Test with Float32 values + let values = Arc::new(Float32Array::from(vec![0.0, 25.0, 50.0, 75.0, 100.0])) as ArrayRef; + let result = scale.invert(&values)?; + let result_array = result.as_primitive::(); + + assert_approx_eq!(f32, result_array.value(0), 10.0); // range start -> domain start + assert_approx_eq!(f32, result_array.value(1), 15.0); // 25% -> 15 + assert_approx_eq!(f32, result_array.value(2), 20.0); // 50% -> 20 + assert_approx_eq!(f32, result_array.value(3), 25.0); // 75% -> 25 + assert_approx_eq!(f32, result_array.value(4), 30.0); // range end -> domain end + + // Test with Int32 values (should be cast to Float32 internally) + let values = Arc::new(Int32Array::from(vec![0, 50, 100])) as ArrayRef; + let result = scale.invert(&values)?; + let result_array = result.as_primitive::(); + + assert_eq!(result.len(), 3); + assert_approx_eq!(f32, result_array.value(0), 10.0); + assert_approx_eq!(f32, result_array.value(1), 20.0); + assert_approx_eq!(f32, result_array.value(2), 30.0); + + // Test with clamping + let scale = scale.with_option("clamp", true); + let values = Arc::new(Float32Array::from(vec![-50.0, 150.0])) as ArrayRef; + let result = scale.invert(&values)?; + let result_array = result.as_primitive::(); + + assert_approx_eq!(f32, result_array.value(0), 10.0); // clamped to domain start + assert_approx_eq!(f32, result_array.value(1), 30.0); // clamped to domain end + + Ok(()) + } } diff --git a/avenger-scales/src/scales/log.rs b/avenger-scales/src/scales/log.rs index 7a0fe59d..b19e89e1 100644 --- a/avenger-scales/src/scales/log.rs +++ b/avenger-scales/src/scales/log.rs @@ -5,7 +5,7 @@ use arrow::{ compute::{kernels::cast, unary}, datatypes::{DataType, Float32Type}, }; -use avenger_common::value::ScalarOrArray; +use avenger_common::value::{ScalarOrArray, ScalarOrArrayValue}; use crate::{ color_interpolator::scale_numeric_to_color, error::AvengerScaleError, scalar::Scalar, @@ -151,6 +151,30 @@ impl ScaleImpl for LogScale { InferDomainFromDataMethod::Interval } + fn invert( + &self, + config: &ScaleConfig, + values: &ArrayRef, + ) -> Result { + // Cast input to Float32 if needed + let float_values = cast(values, &DataType::Float32)?; + + // Call existing invert_from_numeric + let result = self.invert_from_numeric(config, &float_values)?; + + // Convert ScalarOrArray to ArrayRef + match result.value() { + ScalarOrArrayValue::Scalar(s) => { + // If scalar, create array with single value repeated for input length + Ok(Arc::new(Float32Array::from(vec![*s; values.len()])) as ArrayRef) + } + ScalarOrArrayValue::Array(arr) => { + // If array, convert to ArrayRef + Ok(Arc::new(Float32Array::from(arr.as_ref().clone())) as ArrayRef) + } + } + } + fn scale( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index f1765e41..f5f47fbd 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -185,6 +185,21 @@ pub trait ScaleImpl: Debug + Send + Sync + 'static { _values: &ArrayRef, ) -> Result; + /// Invert an array of numeric values from range to domain. + /// + /// This method provides array-based inverse transformation that parallels + /// the `scale()` method. It accepts an ArrayRef of numeric values in the range + /// and returns an ArrayRef of the corresponding domain values. + fn invert( + &self, + _config: &ScaleConfig, + _values: &ArrayRef, + ) -> Result { + Err(AvengerScaleError::ScaleOperationNotSupported( + "invert".to_string(), + )) + } + /// Scale to numeric values fn scale_to_numeric( &self, @@ -514,6 +529,33 @@ impl ConfiguredScale { self.scale_impl.invert_from_numeric(&self.config, values) } + /// Invert an array of numeric values from range to domain. + /// + /// This method provides array-based inverse transformation that matches the pattern + /// of the `scale()` method. It accepts an ArrayRef of numeric values in the range + /// and returns an ArrayRef of the corresponding domain values. + /// + /// # Arguments + /// * `values` - ArrayRef containing numeric values to invert + /// + /// # Returns + /// * `Result` - Array of inverted domain values + /// + /// # Example + /// ```no_run + /// # use arrow::array::{ArrayRef, Float32Array}; + /// # use std::sync::Arc; + /// # use avenger_scales::LinearScale; + /// let scale = LinearScale::configured((0.0, 100.0), (0.0, 1.0)); + /// let range_values = Arc::new(Float32Array::from(vec![0.0, 0.5, 1.0])) as ArrayRef; + /// let domain_values = scale.invert(&range_values)?; + /// // domain_values will contain [0.0, 50.0, 100.0] + /// # Ok::<(), avenger_scales::error::AvengerScaleError>(()) + /// ``` + pub fn invert(&self, values: &ArrayRef) -> Result { + self.scale_impl.invert(&self.config, values) + } + pub fn invert_scalar(&self, value: f32) -> Result { self.scale_impl.invert_scalar(&self.config, value) } diff --git a/avenger-scales/src/scales/pow.rs b/avenger-scales/src/scales/pow.rs index 7c842674..9a3ad500 100644 --- a/avenger-scales/src/scales/pow.rs +++ b/avenger-scales/src/scales/pow.rs @@ -6,7 +6,10 @@ use arrow::{ compute::{kernels::cast, unary}, datatypes::{DataType, Float32Type}, }; -use avenger_common::{types::ColorOrGradient, value::ScalarOrArray}; +use avenger_common::{ + types::ColorOrGradient, + value::{ScalarOrArray, ScalarOrArrayValue}, +}; use crate::{array, color_interpolator::scale_numeric_to_color, error::AvengerScaleError}; @@ -97,6 +100,30 @@ impl ScaleImpl for PowScale { InferDomainFromDataMethod::Interval } + fn invert( + &self, + config: &ScaleConfig, + values: &ArrayRef, + ) -> Result { + // Cast input to Float32 if needed + let float_values = cast(values, &DataType::Float32)?; + + // Call existing invert_from_numeric + let result = self.invert_from_numeric(config, &float_values)?; + + // Convert ScalarOrArray to ArrayRef + match result.value() { + ScalarOrArrayValue::Scalar(s) => { + // If scalar, create array with single value repeated for input length + Ok(Arc::new(Float32Array::from(vec![*s; values.len()])) as ArrayRef) + } + ScalarOrArrayValue::Array(arr) => { + // If array, convert to ArrayRef + Ok(Arc::new(Float32Array::from(arr.as_ref().clone())) as ArrayRef) + } + } + } + fn scale( &self, config: &ScaleConfig, @@ -760,4 +787,24 @@ mod tests { Ok(()) } + + #[test] + fn test_configured_scale_invert_pow() -> Result<(), AvengerScaleError> { + // Test the new invert() method with pow scale + // Using exponent=2, domain [0,4], range [0,16] + let scale = PowScale::configured((0.0, 4.0), (0.0, 16.0)).with_option("exponent", 2.0); + + // Test with Float32 values + let values = Arc::new(Float32Array::from(vec![0.0, 4.0, 9.0, 16.0])) as ArrayRef; + let result = scale.invert(&values)?; + let result_array = result.as_primitive::(); + + assert_eq!(result.len(), 4); + assert_approx_eq!(f32, result_array.value(0), 0.0); // 0^2 = 0 -> 0 + assert_approx_eq!(f32, result_array.value(1), 2.0); // 2^2 = 4 -> 2 + assert_approx_eq!(f32, result_array.value(2), 3.0); // 3^2 = 9 -> 3 + assert_approx_eq!(f32, result_array.value(3), 4.0); // 4^2 = 16 -> 4 + + Ok(()) + } } diff --git a/avenger-scales/src/scales/symlog.rs b/avenger-scales/src/scales/symlog.rs index a01da59b..3b9f788d 100644 --- a/avenger-scales/src/scales/symlog.rs +++ b/avenger-scales/src/scales/symlog.rs @@ -6,7 +6,7 @@ use arrow::{ compute::{kernels::cast, unary}, datatypes::{DataType, Float32Type}, }; -use avenger_common::value::ScalarOrArray; +use avenger_common::value::{ScalarOrArray, ScalarOrArrayValue}; use crate::{array, error::AvengerScaleError}; @@ -94,6 +94,30 @@ impl ScaleImpl for SymlogScale { InferDomainFromDataMethod::Interval } + fn invert( + &self, + config: &ScaleConfig, + values: &ArrayRef, + ) -> Result { + // Cast input to Float32 if needed + let float_values = cast(values, &DataType::Float32)?; + + // Call existing invert_from_numeric + let result = self.invert_from_numeric(config, &float_values)?; + + // Convert ScalarOrArray to ArrayRef + match result.value() { + ScalarOrArrayValue::Scalar(s) => { + // If scalar, create array with single value repeated for input length + Ok(Arc::new(Float32Array::from(vec![*s; values.len()])) as ArrayRef) + } + ScalarOrArrayValue::Array(arr) => { + // If array, convert to ArrayRef + Ok(Arc::new(Float32Array::from(arr.as_ref().clone())) as ArrayRef) + } + } + } + fn scale( &self, config: &ScaleConfig, From ee7908e89d22a25806a55c51c18468cb24ee9a0a Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 9 Jul 2025 10:55:02 -0700 Subject: [PATCH 05/29] Add normalize() method to ConfiguredScale for applying nice domain transformations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a new normalize() method to ConfiguredScale that applies the nice domain transformation and returns a new scale with the nice option disabled. Changes: - Add compute_nice_domain() method to ScaleImpl trait with default implementation - Implement compute_nice_domain() for LinearScale, LogScale, PowScale, SymlogScale, and QuantizeScale - Add normalize() method to ConfiguredScale that applies nice transformation and disables nice option - Add comprehensive tests for normalize() method with different nice option values - Update doctest imports to use correct module paths The normalize() method enables callers to easily retrieve nice domain extents by calling scale.normalize()?.numeric_interval_domain(), providing a scale with the nice domain applied and the nice option disabled to avoid repeated calculations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .claude/commands/precommit.md | 24 +++++++++++ avenger-scales/src/scales/linear.rs | 62 +++++++++++++++++++++++++++ avenger-scales/src/scales/log.rs | 11 +++++ avenger-scales/src/scales/mod.rs | 43 ++++++++++++++++++- avenger-scales/src/scales/pow.rs | 11 +++++ avenger-scales/src/scales/quantize.rs | 9 ++++ avenger-scales/src/scales/symlog.rs | 11 +++++ 7 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/precommit.md diff --git a/.claude/commands/precommit.md b/.claude/commands/precommit.md new file mode 100644 index 00000000..e25d5a01 --- /dev/null +++ b/.claude/commands/precommit.md @@ -0,0 +1,24 @@ +--- +allowed-tools: Bash(git add:*), Bash(git status:*) +description: Prepare changes for commit +--- + +## Context + +- Current git status: !`git status` +- Current git diff (staged and unstaged changes): !`git diff HEAD` +- Current branch: !`git branch --show-current` +- Recent commits: !`git log --oneline -10` + +## Your task + +Review and improve the uncommitted changes. Do not make changes to code that was not modified since the last commit. + + - Review NEW comments for accuracy. Make sure they refer to the final state of the code, and not to steps along the way + - Remove NEW dead code, include unused functions, unused function arguments, and unused variables + - Run NEW tests + - Perform formatting and fix lints + - Perform type checking + - Rerun formatting after fixing type check errors and make sure there are no errors + - `git add` changed files + - Draft and display a commit message. Do not attempt to commit changes yourself diff --git a/avenger-scales/src/scales/linear.rs b/avenger-scales/src/scales/linear.rs index c25c64f2..4e4ac68a 100644 --- a/avenger-scales/src/scales/linear.rs +++ b/avenger-scales/src/scales/linear.rs @@ -429,6 +429,15 @@ impl ScaleImpl for LinearScale { / ((from_range_end - from_range_start) * (to_domain_end - to_domain_start)); Ok(LinearScaleAdjustment { scale, offset }) } + + fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { + let (domain_start, domain_end) = LinearScale::apply_nice( + config.numeric_interval_domain()?, + config.options.get("nice"), + )?; + + Ok(Arc::new(Float32Array::from(vec![domain_start, domain_end])) as ArrayRef) + } } #[cfg(test)] @@ -798,4 +807,57 @@ mod tests { Ok(()) } + + #[test] + fn test_normalize_with_nice_true() -> Result<(), AvengerScaleError> { + let scale = LinearScale::configured((1.1, 10.9), (0.0, 100.0)).with_option("nice", true); + + let normalized = scale.normalize()?; + + // Check that the domain has been niced + let domain = normalized.numeric_interval_domain()?; + assert_approx_eq!(f32, domain.0, 1.0); + assert_approx_eq!(f32, domain.1, 11.0); + + // Check that the nice option is disabled + assert!(!normalized.option_boolean("nice", true)); + + Ok(()) + } + + #[test] + fn test_normalize_with_nice_false() -> Result<(), AvengerScaleError> { + let scale = LinearScale::configured((1.1, 10.9), (0.0, 100.0)).with_option("nice", false); + + let normalized = scale.normalize()?; + + // Check that the domain is unchanged + let domain = normalized.numeric_interval_domain()?; + assert_approx_eq!(f32, domain.0, 1.1); + assert_approx_eq!(f32, domain.1, 10.9); + + // Check that the nice option is still false + assert!(!normalized.option_boolean("nice", true)); + + Ok(()) + } + + #[test] + fn test_normalize_with_nice_count() -> Result<(), AvengerScaleError> { + let scale = LinearScale::configured((1.1, 10.9), (0.0, 100.0)).with_option("nice", 5.0); + + let normalized = scale.normalize()?; + + // Check that the domain has been niced + let domain = normalized.numeric_interval_domain()?; + let expected_nice = LinearScale::apply_nice((1.1, 10.9), Some(&5.0.into()))?; + + assert_approx_eq!(f32, domain.0, expected_nice.0); + assert_approx_eq!(f32, domain.1, expected_nice.1); + + // Check that the nice option is disabled + assert!(!normalized.option_boolean("nice", true)); + + Ok(()) + } } diff --git a/avenger-scales/src/scales/log.rs b/avenger-scales/src/scales/log.rs index b19e89e1..9420df26 100644 --- a/avenger-scales/src/scales/log.rs +++ b/avenger-scales/src/scales/log.rs @@ -566,6 +566,17 @@ impl ScaleImpl for LogScale { } Ok(Arc::new(Float32Array::from(z))) } + + fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { + let base = config.option_f32("base", 10.0); + let (domain_start, domain_end) = LogScale::apply_nice( + config.numeric_interval_domain()?, + base, + config.options.get("nice"), + )?; + + Ok(Arc::new(Float32Array::from(vec![domain_start, domain_end])) as ArrayRef) + } } /// Handles logarithmic transformations with different bases diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index f5f47fbd..11c81897 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -341,6 +341,13 @@ pub trait ScaleImpl: Debug + Send + Sync + 'static { )) } + /// Compute the nice domain for this scale given the current configuration + /// For scales that don't support nice transformations, this returns the original domain + fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { + // Default implementation returns the original domain for scales that don't support nice transformations + Ok(config.domain.clone()) + } + // Scale to enums declare_enum_scale_method!(StrokeCap); declare_enum_scale_method!(StrokeJoin); @@ -486,6 +493,40 @@ impl ConfiguredScale { ) -> Result { self.scale_impl.adjust(&self.config, &to_scale.config) } + + /// Returns a new scale with the nice domain applied and the nice option disabled. + /// + /// This method applies the nice transformation to the current domain based on the + /// nice option setting, then returns a new scale with the transformed domain and + /// the nice option set to false. + /// + /// # Returns + /// * `Result` - New scale with nice domain applied + /// + /// # Example + /// ```no_run + /// # use avenger_scales::scales::linear::LinearScale; + /// let scale = LinearScale::configured((1.1, 10.9), (0.0, 100.0)) + /// .with_option("nice", true); + /// let normalized = scale.normalize()?; + /// // normalized now has domain (1.0, 11.0) and nice option disabled + /// # Ok::<(), avenger_scales::error::AvengerScaleError>(()) + /// ``` + pub fn normalize(self) -> Result { + let nice_domain = self.scale_impl.compute_nice_domain(&self.config)?; + let mut new_options = self.config.options.clone(); + new_options.insert("nice".to_string(), false.into()); + + Ok(ConfiguredScale { + scale_impl: self.scale_impl, + config: ScaleConfig { + domain: nice_domain, + range: self.config.range, + options: new_options, + context: self.config.context, + }, + }) + } } // Pass through methods @@ -545,7 +586,7 @@ impl ConfiguredScale { /// ```no_run /// # use arrow::array::{ArrayRef, Float32Array}; /// # use std::sync::Arc; - /// # use avenger_scales::LinearScale; + /// # use avenger_scales::scales::linear::LinearScale; /// let scale = LinearScale::configured((0.0, 100.0), (0.0, 1.0)); /// let range_values = Arc::new(Float32Array::from(vec![0.0, 0.5, 1.0])) as ArrayRef; /// let domain_values = scale.invert(&range_values)?; diff --git a/avenger-scales/src/scales/pow.rs b/avenger-scales/src/scales/pow.rs index 9a3ad500..a6fc6d77 100644 --- a/avenger-scales/src/scales/pow.rs +++ b/avenger-scales/src/scales/pow.rs @@ -406,6 +406,17 @@ impl ScaleImpl for PowScale { let ticks_array = Float32Array::from(array::ticks(domain_start, domain_end, count)); Ok(Arc::new(ticks_array) as ArrayRef) } + + fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { + let exponent = config.option_f32("exponent", 1.0); + let (domain_start, domain_end) = PowScale::apply_nice( + config.numeric_interval_domain()?, + exponent, + config.options.get("nice"), + )?; + + Ok(Arc::new(Float32Array::from(vec![domain_start, domain_end])) as ArrayRef) + } } /// Handles power transformations with different exponents diff --git a/avenger-scales/src/scales/quantize.rs b/avenger-scales/src/scales/quantize.rs index 6741fa6d..8ab9d4ee 100644 --- a/avenger-scales/src/scales/quantize.rs +++ b/avenger-scales/src/scales/quantize.rs @@ -106,6 +106,15 @@ impl ScaleImpl for QuantizeScale { let linear_scale = LinearScale; linear_scale.ticks(config, count) } + + fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { + let (domain_start, domain_end) = QuantizeScale::apply_nice( + config.numeric_interval_domain()?, + config.options.get("nice"), + )?; + + Ok(Arc::new(Float32Array::from(vec![domain_start, domain_end])) as ArrayRef) + } } #[cfg(test)] diff --git a/avenger-scales/src/scales/symlog.rs b/avenger-scales/src/scales/symlog.rs index 3b9f788d..2820057d 100644 --- a/avenger-scales/src/scales/symlog.rs +++ b/avenger-scales/src/scales/symlog.rs @@ -375,6 +375,17 @@ impl ScaleImpl for SymlogScale { let ticks_array = Float32Array::from(array::ticks(domain_start, domain_end, count)); Ok(Arc::new(ticks_array) as ArrayRef) } + + fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { + let constant = config.option_f32("constant", 1.0); + let (domain_start, domain_end) = SymlogScale::apply_nice( + config.numeric_interval_domain()?, + constant, + config.options.get("nice"), + )?; + + Ok(Arc::new(Float32Array::from(vec![domain_start, domain_end])) as ArrayRef) + } } /// Applies the symlog transform to a single value From 6d903f901ee2073ac69062ab7f31b460f0121574 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 9 Jul 2025 11:22:02 -0700 Subject: [PATCH 06/29] Add zero option support to all scale types with normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add zero boolean option to all scale configurations (linear, log, pow, symlog, quantize) - Implement apply_normalization methods that handle both zero and nice transformations - Zero extension applied before nice calculations for proper ordering - Zero behavior: both positive → set min to zero, both negative → set max to zero - LogScale ignores zero option (invalid in logarithmic space) - Power and Symlog scales delegate to LinearScale for normalization - Update normalize() method to apply both zero and nice transformations - Add comprehensive tests for zero functionality and combined zero+nice behavior - Update documentation to include zero option for all scale types 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/linear.rs | 195 ++++++++++++++++++++++++-- avenger-scales/src/scales/log.rs | 29 +++- avenger-scales/src/scales/mod.rs | 16 ++- avenger-scales/src/scales/pow.rs | 21 ++- avenger-scales/src/scales/quantize.rs | 26 +++- avenger-scales/src/scales/symlog.rs | 21 ++- 6 files changed, 277 insertions(+), 31 deletions(-) diff --git a/avenger-scales/src/scales/linear.rs b/avenger-scales/src/scales/linear.rs index 4e4ac68a..4ecbd308 100644 --- a/avenger-scales/src/scales/linear.rs +++ b/avenger-scales/src/scales/linear.rs @@ -33,6 +33,10 @@ use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContex /// - **nice** (boolean or f32, default: false): When true or a number, extends the domain to nice round values. /// If true, uses a default count of 10. If a number, uses that as the target tick count for determining /// nice values. Nice domains are computed to align with human-friendly values (multiples of 1, 2, 5, 10, etc.). +/// +/// - **zero** (boolean, default: false): When true, ensures that the domain includes zero. If both min and max +/// are positive, sets min to zero. If both min and max are negative, sets max to zero. If the domain already +/// spans zero, no change is made. Zero extension is applied before nice calculations. #[derive(Debug)] pub struct LinearScale; @@ -48,6 +52,7 @@ impl LinearScale { ("range_offset".to_string(), 0.0.into()), ("round".to_string(), false.into()), ("nice".to_string(), false.into()), + ("zero".to_string(), false.into()), ] .into_iter() .collect(), @@ -74,24 +79,62 @@ impl LinearScale { } } - /// Compute nice domain - pub fn apply_nice( + /// Apply normalization (zero and nice) to domain + pub fn apply_normalization( domain: (f32, f32), - count: Option<&Scalar>, + zero: Option<&Scalar>, + nice: Option<&Scalar>, ) -> Result<(f32, f32), AvengerScaleError> { - // Extract count, or return raw domain if no nice option - let count = if let Some(count) = count { + let (mut domain_start, mut domain_end) = domain; + + // Early return for degenerate cases + if domain_start == domain_end || domain_start.is_nan() || domain_end.is_nan() { + return Ok(domain); + } + + // Step 1: Apply zero extension if requested + if let Some(zero_option) = zero { + if let Ok(true) = zero_option.as_boolean() { + if domain_start > 0.0 && domain_end > 0.0 { + // Both positive, extend to include zero at start + domain_start = 0.0; + } else if domain_start < 0.0 && domain_end < 0.0 { + // Both negative, extend to include zero at end + domain_end = 0.0; + } + // If domain spans zero, no change needed + } + } + + // Step 2: Apply nice transformation if requested + let nice_count = if let Some(count) = nice { if count.array().data_type().is_numeric() { - count.as_f32()? + Some(count.as_f32()?) } else if let Ok(true) = count.as_boolean() { - 10.0 + Some(10.0) } else { - return Ok(domain); + None } } else { - return Ok(domain); + None }; + if let Some(count) = nice_count { + // Apply nice transformation to the zero-extended domain + let (nice_start, nice_end) = + Self::apply_nice_internal((domain_start, domain_end), count)?; + domain_start = nice_start; + domain_end = nice_end; + } + + Ok((domain_start, domain_end)) + } + + /// Internal nice calculation (kept for backward compatibility and internal use) + fn apply_nice_internal( + domain: (f32, f32), + count: f32, + ) -> Result<(f32, f32), AvengerScaleError> { let (domain_start, domain_end) = domain; if domain_start == domain_end || domain_start.is_nan() || domain_end.is_nan() { @@ -136,6 +179,14 @@ impl LinearScale { Ok((stop, start)) } } + + /// Compute nice domain (backward compatibility wrapper) + pub fn apply_nice( + domain: (f32, f32), + count: Option<&Scalar>, + ) -> Result<(f32, f32), AvengerScaleError> { + Self::apply_normalization(domain, None, count) + } } impl ScaleImpl for LinearScale { @@ -176,8 +227,9 @@ impl ScaleImpl for LinearScale { config: &ScaleConfig, values: &ArrayRef, ) -> Result { - let (domain_start, domain_end) = LinearScale::apply_nice( + let (domain_start, domain_end) = LinearScale::apply_normalization( config.numeric_interval_domain()?, + config.options.get("zero"), config.options.get("nice"), )?; @@ -256,8 +308,9 @@ impl ScaleImpl for LinearScale { config: &ScaleConfig, values: &ArrayRef, ) -> Result, AvengerScaleError> { - let (domain_start, domain_end) = LinearScale::apply_nice( + let (domain_start, domain_end) = LinearScale::apply_normalization( config.numeric_interval_domain()?, + config.options.get("zero"), config.options.get("nice"), )?; @@ -316,8 +369,9 @@ impl ScaleImpl for LinearScale { config: &ScaleConfig, count: Option, ) -> Result { - let (domain_start, domain_end) = LinearScale::apply_nice( + let (domain_start, domain_end) = LinearScale::apply_normalization( config.numeric_interval_domain()?, + config.options.get("zero"), config.options.get("nice"), )?; @@ -431,8 +485,9 @@ impl ScaleImpl for LinearScale { } fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { - let (domain_start, domain_end) = LinearScale::apply_nice( + let (domain_start, domain_end) = LinearScale::apply_normalization( config.numeric_interval_domain()?, + config.options.get("zero"), config.options.get("nice"), )?; @@ -860,4 +915,118 @@ mod tests { Ok(()) } + + #[test] + fn test_normalize_with_zero_both_positive() -> Result<(), AvengerScaleError> { + let scale = LinearScale::configured((2.0, 10.0), (0.0, 100.0)).with_option("zero", true); + + let normalized = scale.normalize()?; + + // Check that zero is included (min should be 0) + let domain = normalized.numeric_interval_domain()?; + assert_approx_eq!(f32, domain.0, 0.0); + assert_approx_eq!(f32, domain.1, 10.0); + + // Check that the zero option is disabled + assert!(!normalized.option_boolean("zero", true)); + + Ok(()) + } + + #[test] + fn test_normalize_with_zero_both_negative() -> Result<(), AvengerScaleError> { + let scale = LinearScale::configured((-10.0, -2.0), (0.0, 100.0)).with_option("zero", true); + + let normalized = scale.normalize()?; + + // Check that zero is included (max should be 0) + let domain = normalized.numeric_interval_domain()?; + assert_approx_eq!(f32, domain.0, -10.0); + assert_approx_eq!(f32, domain.1, 0.0); + + // Check that the zero option is disabled + assert!(!normalized.option_boolean("zero", true)); + + Ok(()) + } + + #[test] + fn test_normalize_with_zero_spans_zero() -> Result<(), AvengerScaleError> { + let scale = LinearScale::configured((-5.0, 5.0), (0.0, 100.0)).with_option("zero", true); + + let normalized = scale.normalize()?; + + // Check that domain is unchanged (already spans zero) + let domain = normalized.numeric_interval_domain()?; + assert_approx_eq!(f32, domain.0, -5.0); + assert_approx_eq!(f32, domain.1, 5.0); + + // Check that the zero option is disabled + assert!(!normalized.option_boolean("zero", true)); + + Ok(()) + } + + #[test] + fn test_normalize_with_zero_and_nice() -> Result<(), AvengerScaleError> { + let scale = LinearScale::configured((2.1, 9.9), (0.0, 100.0)) + .with_option("zero", true) + .with_option("nice", true); + + let normalized = scale.normalize()?; + + // Check that zero is applied first, then nice + let domain = normalized.numeric_interval_domain()?; + // Should be (0.0, 9.9) after zero, then (0.0, 10.0) after nice + assert_approx_eq!(f32, domain.0, 0.0); + assert_approx_eq!(f32, domain.1, 10.0); + + // Check that both options are disabled + assert!(!normalized.option_boolean("zero", true)); + assert!(!normalized.option_boolean("nice", true)); + + Ok(()) + } + + #[test] + fn test_normalize_with_zero_false() -> Result<(), AvengerScaleError> { + let scale = LinearScale::configured((2.0, 10.0), (0.0, 100.0)).with_option("zero", false); + + let normalized = scale.normalize()?; + + // Check that domain is unchanged + let domain = normalized.numeric_interval_domain()?; + assert_approx_eq!(f32, domain.0, 2.0); + assert_approx_eq!(f32, domain.1, 10.0); + + // Check that the zero option is disabled + assert!(!normalized.option_boolean("zero", true)); + + Ok(()) + } + + #[test] + fn test_apply_normalization_zero_only() -> Result<(), AvengerScaleError> { + // Both positive + let result = LinearScale::apply_normalization((2.0, 10.0), Some(&true.into()), None)?; + assert_approx_eq!(f32, result.0, 0.0); + assert_approx_eq!(f32, result.1, 10.0); + + // Both negative + let result = LinearScale::apply_normalization((-10.0, -2.0), Some(&true.into()), None)?; + assert_approx_eq!(f32, result.0, -10.0); + assert_approx_eq!(f32, result.1, 0.0); + + // Spans zero (no change) + let result = LinearScale::apply_normalization((-5.0, 5.0), Some(&true.into()), None)?; + assert_approx_eq!(f32, result.0, -5.0); + assert_approx_eq!(f32, result.1, 5.0); + + // Zero false (no change) + let result = LinearScale::apply_normalization((2.0, 10.0), Some(&false.into()), None)?; + assert_approx_eq!(f32, result.0, 2.0); + assert_approx_eq!(f32, result.1, 10.0); + + Ok(()) + } } diff --git a/avenger-scales/src/scales/log.rs b/avenger-scales/src/scales/log.rs index 9420df26..0399a9a4 100644 --- a/avenger-scales/src/scales/log.rs +++ b/avenger-scales/src/scales/log.rs @@ -7,10 +7,7 @@ use arrow::{ }; use avenger_common::value::{ScalarOrArray, ScalarOrArrayValue}; -use crate::{ - color_interpolator::scale_numeric_to_color, error::AvengerScaleError, scalar::Scalar, - scales::linear::LinearScale, -}; +use crate::{color_interpolator::scale_numeric_to_color, error::AvengerScaleError, scalar::Scalar}; use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; @@ -40,6 +37,9 @@ use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContex /// to nice round values in logarithmic space (powers of the base). If true, uses a /// default count of 10. If a number, uses that as the target tick count. For example, /// with base 10, a domain of [8, 95] might become [1, 100]. +/// +/// - **zero** (boolean, default: false): When true, ensures that the domain includes zero. However, zero +/// is invalid for logarithmic scales, so this option is ignored for log scales. #[derive(Debug)] pub struct LogScale; @@ -56,6 +56,7 @@ impl LogScale { ("range_offset".to_string(), 0.0.into()), ("round".to_string(), false.into()), ("nice".to_string(), false.into()), + ("zero".to_string(), false.into()), ] .into_iter() .collect(), @@ -140,6 +141,18 @@ impl LogScale { } Ok((domain_start, domain_end)) } + + /// Apply normalization (zero and nice) to domain + /// For log scales, zero is ignored since it's invalid in logarithmic space + pub fn apply_normalization( + domain: (f32, f32), + base: f32, + _zero: Option<&Scalar>, // Zero is ignored for log scales + nice: Option<&Scalar>, + ) -> Result<(f32, f32), AvengerScaleError> { + // For log scales, zero is invalid, so we only apply nice transformation + Self::apply_nice(domain, base, nice) + } } impl ScaleImpl for LogScale { @@ -180,8 +193,11 @@ impl ScaleImpl for LogScale { config: &ScaleConfig, values: &ArrayRef, ) -> Result { - let (domain_start, domain_end) = LinearScale::apply_nice( + let base = config.option_f32("base", 10.0); + let (domain_start, domain_end) = LogScale::apply_normalization( config.numeric_interval_domain()?, + base, + config.options.get("zero"), config.options.get("nice"), )?; @@ -569,9 +585,10 @@ impl ScaleImpl for LogScale { fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { let base = config.option_f32("base", 10.0); - let (domain_start, domain_end) = LogScale::apply_nice( + let (domain_start, domain_end) = LogScale::apply_normalization( config.numeric_interval_domain()?, base, + config.options.get("zero"), config.options.get("nice"), )?; diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index 11c81897..87a4a612 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -494,11 +494,11 @@ impl ConfiguredScale { self.scale_impl.adjust(&self.config, &to_scale.config) } - /// Returns a new scale with the nice domain applied and the nice option disabled. + /// Returns a new scale with zero and nice domain transformations applied and those options disabled. /// - /// This method applies the nice transformation to the current domain based on the - /// nice option setting, then returns a new scale with the transformed domain and - /// the nice option set to false. + /// This method applies zero and nice transformations to the current domain based on the + /// zero and nice option settings, then returns a new scale with the transformed domain and + /// both options set to false. Zero extension is applied before nice calculations. /// /// # Returns /// * `Result` - New scale with nice domain applied @@ -507,20 +507,22 @@ impl ConfiguredScale { /// ```no_run /// # use avenger_scales::scales::linear::LinearScale; /// let scale = LinearScale::configured((1.1, 10.9), (0.0, 100.0)) + /// .with_option("zero", true) /// .with_option("nice", true); /// let normalized = scale.normalize()?; - /// // normalized now has domain (1.0, 11.0) and nice option disabled + /// // normalized now has domain (0.0, 11.0) and both zero and nice options disabled /// # Ok::<(), avenger_scales::error::AvengerScaleError>(()) /// ``` pub fn normalize(self) -> Result { - let nice_domain = self.scale_impl.compute_nice_domain(&self.config)?; + let normalized_domain = self.scale_impl.compute_nice_domain(&self.config)?; let mut new_options = self.config.options.clone(); + new_options.insert("zero".to_string(), false.into()); new_options.insert("nice".to_string(), false.into()); Ok(ConfiguredScale { scale_impl: self.scale_impl, config: ScaleConfig { - domain: nice_domain, + domain: normalized_domain, range: self.config.range, options: new_options, context: self.config.context, diff --git a/avenger-scales/src/scales/pow.rs b/avenger-scales/src/scales/pow.rs index a6fc6d77..74d60e68 100644 --- a/avenger-scales/src/scales/pow.rs +++ b/avenger-scales/src/scales/pow.rs @@ -46,6 +46,10 @@ use super::{ /// - **nice** (boolean or f32, default: false): When true or a number, extends /// the domain to nice round values in the transformed space. If true, uses /// a default count of 10. If a number, uses that as the target tick count. +/// +/// - **zero** (boolean, default: false): When true, ensures that the domain includes zero. If both min and max +/// are positive, sets min to zero. If both min and max are negative, sets max to zero. If the domain already +/// spans zero, no change is made. Zero extension is applied before nice calculations. #[derive(Debug)] pub struct PowScale; @@ -62,6 +66,7 @@ impl PowScale { ("range_offset".to_string(), 0.0.into()), ("round".to_string(), false.into()), ("nice".to_string(), false.into()), + ("zero".to_string(), false.into()), ] .into_iter() .collect(), @@ -89,6 +94,19 @@ impl PowScale { let domain_end = power_fun.pow_inv(nice_d1); Ok((domain_start, domain_end)) } + + /// Apply normalization (zero and nice) to domain + pub fn apply_normalization( + domain: (f32, f32), + _exponent: f32, + zero: Option<&Scalar>, + nice: Option<&Scalar>, + ) -> Result<(f32, f32), AvengerScaleError> { + // Use LinearScale normalization since power transformation preserves zero + let (normalized_start, normalized_end) = + LinearScale::apply_normalization(domain, zero, nice)?; + Ok((normalized_start, normalized_end)) + } } impl ScaleImpl for PowScale { @@ -409,9 +427,10 @@ impl ScaleImpl for PowScale { fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { let exponent = config.option_f32("exponent", 1.0); - let (domain_start, domain_end) = PowScale::apply_nice( + let (domain_start, domain_end) = PowScale::apply_normalization( config.numeric_interval_domain()?, exponent, + config.options.get("zero"), config.options.get("nice"), )?; diff --git a/avenger-scales/src/scales/quantize.rs b/avenger-scales/src/scales/quantize.rs index 8ab9d4ee..04f2de16 100644 --- a/avenger-scales/src/scales/quantize.rs +++ b/avenger-scales/src/scales/quantize.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc}; +use std::sync::Arc; use crate::scalar::Scalar; use arrow::{ @@ -26,6 +26,10 @@ use super::{ /// - **nice** (boolean or f32, default: false): When true or a number, extends the domain to nice round values /// before quantization. If true, uses a default count of 10. If a number, uses that as the target tick count /// for determining nice values. This ensures bins align with human-friendly boundaries. +/// +/// - **zero** (boolean, default: false): When true, ensures that the domain includes zero. If both min and max +/// are positive, sets min to zero. If both min and max are negative, sets max to zero. If the domain already +/// spans zero, no change is made. Zero extension is applied before nice calculations. #[derive(Debug, Clone)] pub struct QuantizeScale; @@ -36,7 +40,12 @@ impl QuantizeScale { config: ScaleConfig { domain: Arc::new(Float32Array::from(vec![domain.0, domain.1])), range, - options: HashMap::new(), + options: vec![ + ("nice".to_string(), false.into()), + ("zero".to_string(), false.into()), + ] + .into_iter() + .collect(), context: ScaleContext::default(), }, } @@ -50,6 +59,16 @@ impl QuantizeScale { // Use nice method from linear scale LinearScale::apply_nice(domain, count) } + + /// Apply normalization (zero and nice) to domain + pub fn apply_normalization( + domain: (f32, f32), + zero: Option<&Scalar>, + nice: Option<&Scalar>, + ) -> Result<(f32, f32), AvengerScaleError> { + // Use LinearScale normalization since quantize scale works with linear domains + LinearScale::apply_normalization(domain, zero, nice) + } } impl ScaleImpl for QuantizeScale { @@ -108,8 +127,9 @@ impl ScaleImpl for QuantizeScale { } fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { - let (domain_start, domain_end) = QuantizeScale::apply_nice( + let (domain_start, domain_end) = QuantizeScale::apply_normalization( config.numeric_interval_domain()?, + config.options.get("zero"), config.options.get("nice"), )?; diff --git a/avenger-scales/src/scales/symlog.rs b/avenger-scales/src/scales/symlog.rs index 2820057d..e8f520fc 100644 --- a/avenger-scales/src/scales/symlog.rs +++ b/avenger-scales/src/scales/symlog.rs @@ -41,6 +41,10 @@ use super::{ /// - **nice** (boolean or f32, default: false): When true or a number, extends the domain to /// nice round values in the transformed space. If true, uses a default count of 10. /// If a number, uses that as the target tick count for determining nice values. +/// +/// - **zero** (boolean, default: false): When true, ensures that the domain includes zero. If both min and max +/// are positive, sets min to zero. If both min and max are negative, sets max to zero. If the domain already +/// spans zero, no change is made. Zero extension is applied before nice calculations. #[derive(Debug)] pub struct SymlogScale; @@ -57,6 +61,7 @@ impl SymlogScale { ("range_offset".to_string(), 0.0.into()), ("round".to_string(), false.into()), ("nice".to_string(), false.into()), + ("zero".to_string(), false.into()), ] .into_iter() .collect(), @@ -83,6 +88,19 @@ impl SymlogScale { let domain_end = symlog_invert(nice_d1, constant); Ok((domain_start, domain_end)) } + + /// Apply normalization (zero and nice) to domain + pub fn apply_normalization( + domain: (f32, f32), + _constant: f32, + zero: Option<&Scalar>, + nice: Option<&Scalar>, + ) -> Result<(f32, f32), AvengerScaleError> { + // Use LinearScale normalization since symlog transformation preserves zero + let (normalized_start, normalized_end) = + LinearScale::apply_normalization(domain, zero, nice)?; + Ok((normalized_start, normalized_end)) + } } impl ScaleImpl for SymlogScale { @@ -378,9 +396,10 @@ impl ScaleImpl for SymlogScale { fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { let constant = config.option_f32("constant", 1.0); - let (domain_start, domain_end) = SymlogScale::apply_nice( + let (domain_start, domain_end) = SymlogScale::apply_normalization( config.numeric_interval_domain()?, constant, + config.options.get("zero"), config.options.get("nice"), )?; From bc05a9c200a3f76a71dffa640d22782768b6f8a9 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Thu, 10 Jul 2025 10:32:38 -0700 Subject: [PATCH 07/29] feat: Add initial TimeScale implementation for temporal data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create TimeScale struct implementing ScaleImpl trait - Support Date32, Date64, and Timestamp Arrow types - Implement temporal to numeric scaling with proper null handling - Add timezone configuration option (not yet connected) - Include basic tests for Date32 and Timestamp scaling - Add new error types for temporal operations This is the foundation for time scale support in avenger-scales, addressing limitations in Vega's local/UTC-only approach by preparing for configurable timezone support. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/error.rs | 9 + avenger-scales/src/scales/mod.rs | 1 + avenger-scales/src/scales/time.rs | 547 ++++++++++++++++++++++++++++++ 3 files changed, 557 insertions(+) create mode 100644 avenger-scales/src/scales/time.rs diff --git a/avenger-scales/src/error.rs b/avenger-scales/src/error.rs index d45fabd5..3b173176 100644 --- a/avenger-scales/src/error.rs +++ b/avenger-scales/src/error.rs @@ -45,6 +45,15 @@ pub enum AvengerScaleError { #[error("Invalid timezone: {0}")] InvalidTimezone(String), + #[error("Invalid timezone: {0}")] + InvalidTimezoneError(String), + + #[error("Invalid data type {0} for {1} scale")] + InvalidDataTypeError(arrow::datatypes::DataType, String), + + #[error("Not implemented: {0}")] + NotImplementedError(String), + #[error("Invalid SVG transform string: {0}")] InvalidSvgTransformString(#[from] svgtypes::Error), diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index 87a4a612..53ac500a 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -9,6 +9,7 @@ pub mod quantile; pub mod quantize; pub mod symlog; pub mod threshold; +pub mod time; use std::{collections::HashMap, fmt::Debug, sync::Arc}; diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs new file mode 100644 index 00000000..3fc11e92 --- /dev/null +++ b/avenger-scales/src/scales/time.rs @@ -0,0 +1,547 @@ +use std::sync::Arc; + +use arrow::array::{ + Array, ArrayRef, Date32Array, Date64Array, Float32Array, StringArray, + TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray, + TimestampSecondArray, +}; +use arrow::datatypes::{DataType, TimeUnit}; +use avenger_common::types::LinearScaleAdjustment; + +use crate::error::AvengerScaleError; + +use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; + +/// Time scale for temporal data visualization. +/// +/// The time scale is a variant of a linear scale that operates on temporal data types (dates, +/// timestamps). It provides intelligent handling of time-based data with calendar-aware operations. +/// +/// Supported Arrow temporal types: +/// - Date32: Days since Unix epoch +/// - Date64: Milliseconds since Unix epoch +/// - Timestamp: With units (s, ms, μs, ns) and optional timezone +/// +/// ## Configuration Options +/// +/// - **timezone** (string, default: "UTC"): Display timezone for scale operations. Can be an IANA +/// timezone string (e.g., "America/New_York"), "local" for system timezone, or "UTC". +/// +/// - **nice** (boolean or f32, default: false): When true or a number, extends the domain to nice +/// calendar boundaries. If true, uses a default count of 10. If a number, uses that as the target +/// tick count for determining nice boundaries. +/// +/// - **interval** (string, optional): Forces specific tick intervals. Examples: "day", "3 hours", +/// "15 minutes", "month", "quarter". +/// +/// - **week_start** (string, default: "sunday"): Configures which day is considered the start of a +/// week for week-based operations. Options: "sunday", "monday", etc. +/// +/// - **locale** (string, default: "en-US"): Locale for formatting dates and times. +#[derive(Debug)] +pub struct TimeScale; + +impl TimeScale { + /// Create a new time scale with the specified domain and range. + pub fn configured(domain: (ArrayRef, ArrayRef), range: (f32, f32)) -> ConfiguredScale { + // Create domain array from the two bounds + let domain_array = create_temporal_domain_array(&domain.0, &domain.1) + .expect("Failed to create temporal domain array"); + + ConfiguredScale { + scale_impl: Arc::new(TimeScale), + config: ScaleConfig { + domain: domain_array, + range: Arc::new(Float32Array::from(vec![range.0, range.1])), + options: vec![ + ("timezone".to_string(), "UTC".into()), + ("nice".to_string(), false.into()), + ("week_start".to_string(), "sunday".into()), + ("locale".to_string(), "en-US".into()), + ] + .into_iter() + .collect(), + context: ScaleContext::default(), + }, + } + } + + /// Create a new time scale with the specified domain and color range. + pub fn configured_color(domain: (ArrayRef, ArrayRef), range: I) -> ConfiguredScale + where + I: IntoIterator, + I::Item: Into, + { + // Create domain array from the two bounds + let domain_array = create_temporal_domain_array(&domain.0, &domain.1) + .expect("Failed to create temporal domain array"); + + ConfiguredScale { + scale_impl: Arc::new(TimeScale), + config: ScaleConfig { + domain: domain_array, + range: Arc::new(StringArray::from( + range.into_iter().map(Into::into).collect::>(), + )), + options: vec![ + ("timezone".to_string(), "UTC".into()), + ("nice".to_string(), false.into()), + ("week_start".to_string(), "sunday".into()), + ("locale".to_string(), "en-US".into()), + ] + .into_iter() + .collect(), + context: ScaleContext::default(), + }, + } + } +} + +/// Handler for different temporal types +#[derive(Debug)] +enum TemporalHandler { + Date32, + Date64, + Timestamp(TimeUnit), +} + +impl TemporalHandler { + /// Create handler from Arrow data type + fn from_data_type(data_type: &DataType) -> Result { + match data_type { + DataType::Date32 => Ok(TemporalHandler::Date32), + DataType::Date64 => Ok(TemporalHandler::Date64), + DataType::Timestamp(unit, _tz) => Ok(TemporalHandler::Timestamp(*unit)), + _ => Err(AvengerScaleError::InvalidDataTypeError( + data_type.clone(), + "temporal".to_string(), + )), + } + } + + /// Convert temporal value to Unix timestamp in milliseconds + fn to_timestamp_millis(&self, value: i64) -> i64 { + match self { + TemporalHandler::Date32 => { + // Date32 is days since epoch + value * 86400 * 1000 + } + TemporalHandler::Date64 => { + // Date64 is already milliseconds since epoch + value + } + TemporalHandler::Timestamp(unit) => { + // Convert to milliseconds based on unit + match unit { + TimeUnit::Second => value * 1000, + TimeUnit::Millisecond => value, + TimeUnit::Microsecond => value / 1000, + TimeUnit::Nanosecond => value / 1_000_000, + } + } + } + } +} + +impl ScaleImpl for TimeScale { + fn scale_type(&self) -> &'static str { + "time" + } + + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { + InferDomainFromDataMethod::Interval + } + + fn scale( + &self, + config: &ScaleConfig, + values: &ArrayRef, + ) -> Result { + // Get temporal handler based on domain type + let domain_type = config.domain.data_type(); + let handler = TemporalHandler::from_data_type(domain_type)?; + + // Get domain bounds + let domain_start = get_temporal_value(&config.domain, 0, &handler)?; + let domain_end = get_temporal_value(&config.domain, 1, &handler)?; + + // Get range bounds + let (range_start, range_end) = config.numeric_interval_range()?; + + // Scale values + let result = match values.data_type() { + DataType::Date32 => { + let values = values.as_any().downcast_ref::().unwrap(); + let mut output = Vec::with_capacity(values.len()); + + for i in 0..values.len() { + if values.is_null(i) { + output.push(None); + } else { + let value = handler.to_timestamp_millis(values.value(i) as i64); + let normalized = + (value - domain_start) as f32 / (domain_end - domain_start) as f32; + output.push(Some(range_start + normalized * (range_end - range_start))); + } + } + + Arc::new(Float32Array::from(output)) + } + DataType::Date64 => { + let values = values.as_any().downcast_ref::().unwrap(); + let mut output = Vec::with_capacity(values.len()); + + for i in 0..values.len() { + if values.is_null(i) { + output.push(None); + } else { + let value = handler.to_timestamp_millis(values.value(i)); + let normalized = + (value - domain_start) as f32 / (domain_end - domain_start) as f32; + output.push(Some(range_start + normalized * (range_end - range_start))); + } + } + + Arc::new(Float32Array::from(output)) + } + DataType::Timestamp(unit, _) => scale_timestamp_values( + values, + unit, + &handler, + domain_start, + domain_end, + range_start, + range_end, + )?, + _ => { + return Err(AvengerScaleError::InvalidDataTypeError( + values.data_type().clone(), + "temporal".to_string(), + )) + } + }; + + Ok(result) + } + + fn invert( + &self, + _config: &ScaleConfig, + _values: &ArrayRef, + ) -> Result { + // TODO: Implement inversion from numeric to temporal + Err(AvengerScaleError::NotImplementedError( + "Inversion for time scale not yet implemented".to_string(), + )) + } + + fn ticks( + &self, + _config: &ScaleConfig, + _count: Option, + ) -> Result { + // TODO: Implement calendar-aware tick generation + Err(AvengerScaleError::NotImplementedError( + "Tick generation for time scale not yet implemented".to_string(), + )) + } + + fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { + // TODO: Implement calendar-aware nice domain calculation + // For now, just return the original domain + Ok(config.domain.clone()) + } + + fn adjust( + &self, + _from_config: &ScaleConfig, + _to_config: &ScaleConfig, + ) -> Result { + Err(AvengerScaleError::NotImplementedError( + "Adjust for time scale not yet implemented".to_string(), + )) + } +} + +// Helper functions + +fn create_temporal_domain_array( + start: &ArrayRef, + end: &ArrayRef, +) -> Result { + // Validate that both arrays have same type + if start.data_type() != end.data_type() { + return Err(AvengerScaleError::InternalError( + "Domain start and end must have same temporal type".to_string(), + )); + } + + // Get the single value from each array (they should be length 1) + if start.len() != 1 || end.len() != 1 { + return Err(AvengerScaleError::InternalError( + "Domain arrays must have exactly one element".to_string(), + )); + } + + // Create new array with both values based on type + match start.data_type() { + DataType::Date32 => { + let start_array = start.as_any().downcast_ref::().unwrap(); + let end_array = end.as_any().downcast_ref::().unwrap(); + Ok(Arc::new(Date32Array::from(vec![ + start_array.value(0), + end_array.value(0), + ]))) + } + DataType::Date64 => { + let start_array = start.as_any().downcast_ref::().unwrap(); + let end_array = end.as_any().downcast_ref::().unwrap(); + Ok(Arc::new(Date64Array::from(vec![ + start_array.value(0), + end_array.value(0), + ]))) + } + DataType::Timestamp(TimeUnit::Second, tz) => { + let start_array = start + .as_any() + .downcast_ref::() + .unwrap(); + let end_array = end.as_any().downcast_ref::().unwrap(); + Ok(Arc::new( + TimestampSecondArray::from(vec![start_array.value(0), end_array.value(0)]) + .with_timezone_opt(tz.clone()), + )) + } + DataType::Timestamp(TimeUnit::Millisecond, tz) => { + let start_array = start + .as_any() + .downcast_ref::() + .unwrap(); + let end_array = end + .as_any() + .downcast_ref::() + .unwrap(); + Ok(Arc::new( + TimestampMillisecondArray::from(vec![start_array.value(0), end_array.value(0)]) + .with_timezone_opt(tz.clone()), + )) + } + DataType::Timestamp(TimeUnit::Microsecond, tz) => { + let start_array = start + .as_any() + .downcast_ref::() + .unwrap(); + let end_array = end + .as_any() + .downcast_ref::() + .unwrap(); + Ok(Arc::new( + TimestampMicrosecondArray::from(vec![start_array.value(0), end_array.value(0)]) + .with_timezone_opt(tz.clone()), + )) + } + DataType::Timestamp(TimeUnit::Nanosecond, tz) => { + let start_array = start + .as_any() + .downcast_ref::() + .unwrap(); + let end_array = end + .as_any() + .downcast_ref::() + .unwrap(); + Ok(Arc::new( + TimestampNanosecondArray::from(vec![start_array.value(0), end_array.value(0)]) + .with_timezone_opt(tz.clone()), + )) + } + _ => Err(AvengerScaleError::InvalidDataTypeError( + start.data_type().clone(), + "temporal domain".to_string(), + )), + } +} + +fn get_temporal_value( + array: &ArrayRef, + index: usize, + handler: &TemporalHandler, +) -> Result { + match array.data_type() { + DataType::Date32 => { + let array = array.as_any().downcast_ref::().unwrap(); + Ok(handler.to_timestamp_millis(array.value(index) as i64)) + } + DataType::Date64 => { + let array = array.as_any().downcast_ref::().unwrap(); + Ok(handler.to_timestamp_millis(array.value(index))) + } + DataType::Timestamp(TimeUnit::Second, _) => { + let array = array + .as_any() + .downcast_ref::() + .unwrap(); + Ok(handler.to_timestamp_millis(array.value(index))) + } + DataType::Timestamp(TimeUnit::Millisecond, _) => { + let array = array + .as_any() + .downcast_ref::() + .unwrap(); + Ok(handler.to_timestamp_millis(array.value(index))) + } + DataType::Timestamp(TimeUnit::Microsecond, _) => { + let array = array + .as_any() + .downcast_ref::() + .unwrap(); + Ok(handler.to_timestamp_millis(array.value(index))) + } + DataType::Timestamp(TimeUnit::Nanosecond, _) => { + let array = array + .as_any() + .downcast_ref::() + .unwrap(); + Ok(handler.to_timestamp_millis(array.value(index))) + } + _ => Err(AvengerScaleError::InvalidDataTypeError( + array.data_type().clone(), + "temporal".to_string(), + )), + } +} + +fn scale_timestamp_values( + values: &ArrayRef, + unit: &TimeUnit, + handler: &TemporalHandler, + domain_start: i64, + domain_end: i64, + range_start: f32, + range_end: f32, +) -> Result { + let mut output = Vec::with_capacity(values.len()); + + match unit { + TimeUnit::Second => { + let values = values + .as_any() + .downcast_ref::() + .unwrap(); + for i in 0..values.len() { + if values.is_null(i) { + output.push(None); + } else { + let value = handler.to_timestamp_millis(values.value(i)); + let normalized = + (value - domain_start) as f32 / (domain_end - domain_start) as f32; + output.push(Some(range_start + normalized * (range_end - range_start))); + } + } + } + TimeUnit::Millisecond => { + let values = values + .as_any() + .downcast_ref::() + .unwrap(); + for i in 0..values.len() { + if values.is_null(i) { + output.push(None); + } else { + let value = handler.to_timestamp_millis(values.value(i)); + let normalized = + (value - domain_start) as f32 / (domain_end - domain_start) as f32; + output.push(Some(range_start + normalized * (range_end - range_start))); + } + } + } + TimeUnit::Microsecond => { + let values = values + .as_any() + .downcast_ref::() + .unwrap(); + for i in 0..values.len() { + if values.is_null(i) { + output.push(None); + } else { + let value = handler.to_timestamp_millis(values.value(i)); + let normalized = + (value - domain_start) as f32 / (domain_end - domain_start) as f32; + output.push(Some(range_start + normalized * (range_end - range_start))); + } + } + } + TimeUnit::Nanosecond => { + let values = values + .as_any() + .downcast_ref::() + .unwrap(); + for i in 0..values.len() { + if values.is_null(i) { + output.push(None); + } else { + let value = handler.to_timestamp_millis(values.value(i)); + let normalized = + (value - domain_start) as f32 / (domain_end - domain_start) as f32; + output.push(Some(range_start + normalized * (range_end - range_start))); + } + } + } + } + + Ok(Arc::new(Float32Array::from(output))) +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow::array::TimestampSecondArray; + + #[test] + fn test_time_scale_date32() -> Result<(), AvengerScaleError> { + // Create domain from 2024-01-01 to 2024-12-31 + // Date32 is days since Unix epoch (1970-01-01) + let start_date = 19723; // 2024-01-01 in days since epoch + let end_date = 20088; // 2024-12-31 in days since epoch + + let domain_start = Arc::new(Date32Array::from(vec![start_date])) as ArrayRef; + let domain_end = Arc::new(Date32Array::from(vec![end_date])) as ArrayRef; + + let scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)); + + // Test scaling mid-year date + let mid_date = 19905; // 2024-07-01 in days since epoch + let values = Arc::new(Date32Array::from(vec![mid_date])) as ArrayRef; + + let result = scale.scale(&values)?; + let result_array = result.as_any().downcast_ref::().unwrap(); + + // Should be approximately 50.0 (middle of the range) + assert!((result_array.value(0) - 50.0).abs() < 1.0); + + Ok(()) + } + + #[test] + fn test_time_scale_timestamp() -> Result<(), AvengerScaleError> { + // Create domain from 2024-01-01 00:00:00 to 2024-01-02 00:00:00 (one day) + let start_ts = 1704067200; // 2024-01-01 00:00:00 UTC + let end_ts = 1704153600; // 2024-01-02 00:00:00 UTC + + let domain_start = Arc::new(TimestampSecondArray::from(vec![start_ts])) as ArrayRef; + let domain_end = Arc::new(TimestampSecondArray::from(vec![end_ts])) as ArrayRef; + + let scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)); + + // Test scaling noon + let noon_ts = 1704110400; // 2024-01-01 12:00:00 UTC + let values = Arc::new(TimestampSecondArray::from(vec![noon_ts])) as ArrayRef; + + let result = scale.scale(&values)?; + let result_array = result.as_any().downcast_ref::().unwrap(); + + // Should be 50.0 (middle of the range) + assert!((result_array.value(0) - 50.0).abs() < 0.1); + + Ok(()) + } +} From e01b77f796a65a573e5b57377bd293ee01aa7a36 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Thu, 10 Jul 2025 12:02:07 -0700 Subject: [PATCH 08/29] ignore tasks dir --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8c5ca427..8387ae40 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ examples/iris-pan-zoom/geometry.svg avenger-lang-preview/preview.avgr /avenger-vega-test-data/target/ +/tasks/ From 64ef64a8f012996a2f577ab5a7cbb7ccf4c483aa Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Thu, 10 Jul 2025 12:07:33 -0700 Subject: [PATCH 09/29] feat: Add timezone support and calendar-aware operations to TimeScale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement timezone configuration and parsing (IANA timezones, UTC, local) - Add nice interval generation with calendar-aware boundaries - Implement tick generation algorithm with D3-inspired interval hierarchy - Add comprehensive time interval system (millisecond to year) - Support calendar arithmetic (month/year offsets, DST handling) - Add tests for nice domains, tick generation, and timezone parsing Calendar features: - Interval hierarchy: ms, sec, min, hour, day, week, month, year - Smart interval selection based on domain span and target tick count - Floor/ceil operations to interval boundaries - Proper handling of variable month lengths and leap years - Week start configuration support (future enhancement) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/time.rs | 528 +++++++++++++++++++++++++++++- 1 file changed, 519 insertions(+), 9 deletions(-) diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index 3fc11e92..f9cdc37b 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -7,6 +7,8 @@ use arrow::array::{ }; use arrow::datatypes::{DataType, TimeUnit}; use avenger_common::types::LinearScaleAdjustment; +use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc}; +use chrono_tz::Tz; use crate::error::AvengerScaleError; @@ -143,6 +145,27 @@ impl TemporalHandler { } } +/// Parse timezone string to chrono_tz::Tz +fn parse_timezone(tz_str: &str) -> Result { + match tz_str { + "UTC" | "utc" => Ok(Tz::UTC), + "local" => { + // For now, default to UTC when "local" is specified + // TODO: Implement proper local timezone detection + Ok(Tz::UTC) + } + tz => tz + .parse::() + .map_err(|_| AvengerScaleError::InvalidTimezoneError(tz.to_string())), + } +} + +/// Convert timestamp to target timezone for display/calculation +fn convert_to_timezone(timestamp_millis: i64, tz: &Tz) -> DateTime { + let utc_dt = Utc.timestamp_millis_opt(timestamp_millis).unwrap(); + utc_dt.with_timezone(tz) +} + impl ScaleImpl for TimeScale { fn scale_type(&self) -> &'static str { "time" @@ -237,19 +260,63 @@ impl ScaleImpl for TimeScale { fn ticks( &self, - _config: &ScaleConfig, - _count: Option, + config: &ScaleConfig, + count: Option, ) -> Result { - // TODO: Implement calendar-aware tick generation - Err(AvengerScaleError::NotImplementedError( - "Tick generation for time scale not yet implemented".to_string(), - )) + // Get domain bounds + let domain_type = config.domain.data_type(); + let handler = TemporalHandler::from_data_type(domain_type)?; + + let start_millis = get_temporal_value(&config.domain, 0, &handler)?; + let end_millis = get_temporal_value(&config.domain, 1, &handler)?; + + // Get timezone for tick generation + let tz_str = config.option_string("timezone", "UTC"); + let tz = parse_timezone(&tz_str)?; + + // Get target tick count + let target_count = count.unwrap_or(10.0); + + // Generate ticks + let tick_millis = generate_temporal_ticks(start_millis, end_millis, target_count, &tz)?; + + // Convert to domain array type + create_temporal_array_from_millis_vec(&tick_millis, domain_type) } fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { - // TODO: Implement calendar-aware nice domain calculation - // For now, just return the original domain - Ok(config.domain.clone()) + // Get domain bounds + let domain_type = config.domain.data_type(); + let handler = TemporalHandler::from_data_type(domain_type)?; + + let start_millis = get_temporal_value(&config.domain, 0, &handler)?; + let end_millis = get_temporal_value(&config.domain, 1, &handler)?; + + // Get timezone for nice calculations + let tz_str = config.option_string("timezone", "UTC"); + let tz = parse_timezone(&tz_str)?; + + // Get nice option + let nice_option = config.options.get("nice"); + let target_count = match nice_option { + Some(scalar) => { + if let Ok(true) = scalar.as_boolean() { + 10.0 + } else if let Ok(count) = scalar.as_f32() { + count + } else { + return Ok(config.domain.clone()); + } + } + None => return Ok(config.domain.clone()), + }; + + // Calculate nice bounds + let (nice_start, nice_end) = + compute_nice_temporal_bounds(start_millis, end_millis, target_count, &tz)?; + + // Convert back to domain array type + create_temporal_domain_from_millis(nice_start, nice_end, domain_type) } fn adjust( @@ -491,6 +558,357 @@ fn scale_timestamp_values( Ok(Arc::new(Float32Array::from(output))) } +/// Interval hierarchy for time scales +#[derive(Debug, Clone, Copy, PartialEq)] +enum TimeInterval { + Millisecond(i32), + Second(i32), + Minute(i32), + Hour(i32), + Day(i32), + Week(i32), + Month(i32), + Year(i32), +} + +impl TimeInterval { + /// Get duration in milliseconds (approximate for months/years) + fn approx_millis(&self) -> i64 { + match self { + TimeInterval::Millisecond(n) => *n as i64, + TimeInterval::Second(n) => (*n as i64) * 1000, + TimeInterval::Minute(n) => (*n as i64) * 60 * 1000, + TimeInterval::Hour(n) => (*n as i64) * 60 * 60 * 1000, + TimeInterval::Day(n) => (*n as i64) * 24 * 60 * 60 * 1000, + TimeInterval::Week(n) => (*n as i64) * 7 * 24 * 60 * 60 * 1000, + TimeInterval::Month(n) => (*n as i64) * 30 * 24 * 60 * 60 * 1000, // Approximate + TimeInterval::Year(n) => (*n as i64) * 365 * 24 * 60 * 60 * 1000, // Approximate + } + } + + /// Floor a datetime to this interval boundary + fn floor(&self, dt: DateTime) -> DateTime { + match self { + TimeInterval::Millisecond(n) => { + let millis = dt.timestamp_millis(); + let floored = (millis / *n as i64) * *n as i64; + dt.timezone().timestamp_millis_opt(floored).unwrap() + } + TimeInterval::Second(n) => { + let base = dt.with_nanosecond(0).unwrap(); + let secs = base.second() as i32; + base.with_second(((secs / n) * n) as u32).unwrap() + } + TimeInterval::Minute(n) => { + let base = dt.with_second(0).unwrap().with_nanosecond(0).unwrap(); + let mins = base.minute() as i32; + base.with_minute(((mins / n) * n) as u32).unwrap() + } + TimeInterval::Hour(n) => { + let base = dt + .with_minute(0) + .unwrap() + .with_second(0) + .unwrap() + .with_nanosecond(0) + .unwrap(); + let hours = base.hour() as i32; + base.with_hour(((hours / n) * n) as u32).unwrap() + } + TimeInterval::Day(_) => dt + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_local_timezone(dt.timezone()) + .unwrap(), + TimeInterval::Week(_) => { + // Floor to start of week (Sunday) + let days_since_sunday = dt.weekday().num_days_from_sunday(); + let start_of_week = + dt.date_naive() - chrono::Duration::days(days_since_sunday as i64); + start_of_week + .and_hms_opt(0, 0, 0) + .unwrap() + .and_local_timezone(dt.timezone()) + .unwrap() + } + TimeInterval::Month(_) => dt + .with_day(1) + .unwrap() + .with_hour(0) + .unwrap() + .with_minute(0) + .unwrap() + .with_second(0) + .unwrap() + .with_nanosecond(0) + .unwrap(), + TimeInterval::Year(_) => dt + .with_month(1) + .unwrap() + .with_day(1) + .unwrap() + .with_hour(0) + .unwrap() + .with_minute(0) + .unwrap() + .with_second(0) + .unwrap() + .with_nanosecond(0) + .unwrap(), + } + } + + /// Ceiling - round up to next interval boundary + fn ceil(&self, dt: DateTime) -> DateTime { + let floored = self.floor(dt); + if floored == dt { + dt + } else { + self.offset(floored, 1) + } + } + + /// Offset by n intervals + fn offset(&self, dt: DateTime, count: i32) -> DateTime { + match self { + TimeInterval::Millisecond(n) => { + let offset_millis = (*n as i64) * (count as i64); + dt.timezone() + .timestamp_millis_opt(dt.timestamp_millis() + offset_millis) + .unwrap() + } + TimeInterval::Second(n) => dt + chrono::Duration::seconds((*n as i64) * (count as i64)), + TimeInterval::Minute(n) => dt + chrono::Duration::minutes((*n as i64) * (count as i64)), + TimeInterval::Hour(n) => dt + chrono::Duration::hours((*n as i64) * (count as i64)), + TimeInterval::Day(n) => dt + chrono::Duration::days((*n as i64) * (count as i64)), + TimeInterval::Week(n) => dt + chrono::Duration::weeks((*n as i64) * (count as i64)), + TimeInterval::Month(n) => { + // Handle month arithmetic properly + let total_months = *n * count; + let mut result = dt; + + if total_months > 0 { + for _ in 0..total_months { + result = add_months(result, 1); + } + } else { + for _ in 0..(-total_months) { + result = subtract_months(result, 1); + } + } + result + } + TimeInterval::Year(n) => { + // Handle year arithmetic + let years = *n * count; + dt.with_year(dt.year() + years).unwrap() + } + } + } +} + +/// Add months to a datetime, handling edge cases +fn add_months(dt: DateTime, months: i32) -> DateTime { + let target_month = dt.month() as i32 + months; + let year_offset = (target_month - 1) / 12; + let new_month = ((target_month - 1) % 12 + 12) % 12 + 1; + + let new_year = dt.year() + year_offset; + let mut result = dt + .with_year(new_year) + .unwrap() + .with_month(new_month as u32) + .unwrap(); + + // Handle day overflow (e.g., Jan 31 + 1 month = Feb 28/29) + let max_day = days_in_month(new_year, new_month as u32); + if dt.day() > max_day { + result = result.with_day(max_day).unwrap(); + } + + result +} + +/// Subtract months from a datetime +fn subtract_months(dt: DateTime, months: i32) -> DateTime { + add_months(dt, -months) +} + +/// Get number of days in a month +fn days_in_month(year: i32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if is_leap_year(year) { + 29 + } else { + 28 + } + } + _ => panic!("Invalid month: {}", month), + } +} + +/// Check if year is a leap year +fn is_leap_year(year: i32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +/// Interval hierarchy for automatic selection +const INTERVAL_HIERARCHY: &[TimeInterval] = &[ + TimeInterval::Millisecond(1), + TimeInterval::Millisecond(5), + TimeInterval::Millisecond(10), + TimeInterval::Millisecond(25), + TimeInterval::Millisecond(50), + TimeInterval::Millisecond(100), + TimeInterval::Millisecond(250), + TimeInterval::Millisecond(500), + TimeInterval::Second(1), + TimeInterval::Second(5), + TimeInterval::Second(15), + TimeInterval::Second(30), + TimeInterval::Minute(1), + TimeInterval::Minute(5), + TimeInterval::Minute(15), + TimeInterval::Minute(30), + TimeInterval::Hour(1), + TimeInterval::Hour(3), + TimeInterval::Hour(6), + TimeInterval::Hour(12), + TimeInterval::Day(1), + TimeInterval::Day(2), + TimeInterval::Week(1), + TimeInterval::Month(1), + TimeInterval::Month(3), + TimeInterval::Year(1), + TimeInterval::Year(2), + TimeInterval::Year(5), + TimeInterval::Year(10), + TimeInterval::Year(20), + TimeInterval::Year(50), + TimeInterval::Year(100), +]; + +/// Select appropriate interval based on domain span and target tick count +fn select_time_interval(span_millis: i64, target_count: f32) -> TimeInterval { + // Find the interval that produces closest to target tick count + let mut best_interval = INTERVAL_HIERARCHY[0]; + let mut best_diff = f64::INFINITY; + + for &interval in INTERVAL_HIERARCHY { + let interval_millis = interval.approx_millis() as f64; + let tick_count = span_millis as f64 / interval_millis; + let diff = (tick_count - target_count as f64).abs(); + + if diff < best_diff { + best_diff = diff; + best_interval = interval; + } + } + + best_interval +} + +/// Compute nice temporal bounds +fn compute_nice_temporal_bounds( + start_millis: i64, + end_millis: i64, + target_count: f32, + tz: &Tz, +) -> Result<(i64, i64), AvengerScaleError> { + let span = end_millis - start_millis; + let interval = select_time_interval(span, target_count); + + let start_dt = convert_to_timezone(start_millis, tz); + let end_dt = convert_to_timezone(end_millis, tz); + + let nice_start = interval.floor(start_dt); + let nice_end = interval.ceil(end_dt); + + Ok((nice_start.timestamp_millis(), nice_end.timestamp_millis())) +} + +/// Generate temporal ticks +fn generate_temporal_ticks( + start_millis: i64, + end_millis: i64, + target_count: f32, + tz: &Tz, +) -> Result, AvengerScaleError> { + let span = end_millis - start_millis; + let interval = select_time_interval(span, target_count); + + let start_dt = convert_to_timezone(start_millis, tz); + let end_dt = convert_to_timezone(end_millis, tz); + + // Start from floored first tick + let mut current = interval.ceil(start_dt); + let mut ticks = Vec::new(); + + // Generate ticks within domain + while current <= end_dt { + ticks.push(current.timestamp_millis()); + current = interval.offset(current, 1); + } + + Ok(ticks) +} + +/// Create temporal array from vector of millisecond timestamps +fn create_temporal_array_from_millis_vec( + millis_vec: &[i64], + data_type: &DataType, +) -> Result { + match data_type { + DataType::Date32 => { + // Convert milliseconds to days + let days: Vec = millis_vec + .iter() + .map(|&ms| (ms / (24 * 60 * 60 * 1000)) as i32) + .collect(); + Ok(Arc::new(Date32Array::from(days))) + } + DataType::Date64 => Ok(Arc::new(Date64Array::from(millis_vec.to_vec()))), + DataType::Timestamp(TimeUnit::Second, tz) => { + let secs: Vec = millis_vec.iter().map(|&ms| ms / 1000).collect(); + Ok(Arc::new( + TimestampSecondArray::from(secs).with_timezone_opt(tz.clone()), + )) + } + DataType::Timestamp(TimeUnit::Millisecond, tz) => Ok(Arc::new( + TimestampMillisecondArray::from(millis_vec.to_vec()).with_timezone_opt(tz.clone()), + )), + DataType::Timestamp(TimeUnit::Microsecond, tz) => { + let micros: Vec = millis_vec.iter().map(|&ms| ms * 1000).collect(); + Ok(Arc::new( + TimestampMicrosecondArray::from(micros).with_timezone_opt(tz.clone()), + )) + } + DataType::Timestamp(TimeUnit::Nanosecond, tz) => { + let nanos: Vec = millis_vec.iter().map(|&ms| ms * 1_000_000).collect(); + Ok(Arc::new( + TimestampNanosecondArray::from(nanos).with_timezone_opt(tz.clone()), + )) + } + _ => Err(AvengerScaleError::InvalidDataTypeError( + data_type.clone(), + "temporal array".to_string(), + )), + } +} + +/// Create temporal domain array from millisecond timestamps +fn create_temporal_domain_from_millis( + start_millis: i64, + end_millis: i64, + data_type: &DataType, +) -> Result { + create_temporal_array_from_millis_vec(&[start_millis, end_millis], data_type) +} + #[cfg(test)] mod tests { use super::*; @@ -544,4 +962,96 @@ mod tests { Ok(()) } + + #[test] + fn test_nice_domain() -> Result<(), AvengerScaleError> { + // Create domain from 2024-01-15 10:30:00 to 2024-02-20 15:45:00 + let start_ts = 1705317000; // 2024-01-15 10:30:00 UTC + let end_ts = 1708441500; // 2024-02-20 15:45:00 UTC + + let domain_start = Arc::new(TimestampSecondArray::from(vec![start_ts])) as ArrayRef; + let domain_end = Arc::new(TimestampSecondArray::from(vec![end_ts])) as ArrayRef; + + let mut scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)); + + // Apply nice with default count + scale = scale.with_option("nice", true); + let nice_domain = scale.scale_impl.compute_nice_domain(&scale.config)?; + + // Should round to nice month boundaries + let nice_array = nice_domain + .as_any() + .downcast_ref::() + .unwrap(); + + // Check that domain was extended to nice boundaries + assert!(nice_array.value(0) <= start_ts); + assert!(nice_array.value(1) >= end_ts); + + Ok(()) + } + + #[test] + fn test_tick_generation() -> Result<(), AvengerScaleError> { + // Create domain for one week + let start_ts = 1704067200; // 2024-01-01 00:00:00 UTC + let end_ts = 1704672000; // 2024-01-08 00:00:00 UTC + + let domain_start = Arc::new(TimestampSecondArray::from(vec![start_ts])) as ArrayRef; + let domain_end = Arc::new(TimestampSecondArray::from(vec![end_ts])) as ArrayRef; + + let scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)); + + // Generate approximately 7 ticks (one per day) + let ticks = scale.ticks(Some(7.0))?; + let tick_array = ticks + .as_any() + .downcast_ref::() + .unwrap(); + + // Should have approximately 7-8 ticks + assert!(tick_array.len() >= 7 && tick_array.len() <= 8); + + // First tick should be at or after start + assert!(tick_array.value(0) >= start_ts); + + // Last tick should be at or before end + assert!(tick_array.value(tick_array.len() - 1) <= end_ts); + + Ok(()) + } + + #[test] + fn test_timezone_conversion() -> Result<(), AvengerScaleError> { + // Test parsing timezone + let utc = parse_timezone("UTC")?; + assert_eq!(utc, Tz::UTC); + + let ny = parse_timezone("America/New_York")?; + assert_eq!(ny.name(), "America/New_York"); + + // Test invalid timezone + assert!(parse_timezone("Invalid/Timezone").is_err()); + + Ok(()) + } + + #[test] + fn test_interval_selection() { + // Test second-level selection + let interval = select_time_interval(10_000, 10.0); // 10 seconds + assert_eq!(interval, TimeInterval::Second(1)); + + // Test minute-level selection + let interval = select_time_interval(600_000, 10.0); // 10 minutes + assert_eq!(interval, TimeInterval::Minute(1)); + + // Test hour-level selection + let interval = select_time_interval(36_000_000, 10.0); // 10 hours + assert_eq!(interval, TimeInterval::Hour(1)); + + // Test day-level selection + let interval = select_time_interval(864_000_000, 10.0); // 10 days + assert_eq!(interval, TimeInterval::Day(1)); + } } From 8c49cbb2a0c6708051d27ee0d23a1a05c363575a Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Thu, 10 Jul 2025 12:09:59 -0700 Subject: [PATCH 10/29] feat: Implement invert() method for TimeScale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add numeric to temporal inversion for all supported types - Handle null values properly in inversion - Support Date32, Date64, and all Timestamp variants - Add comprehensive test for scale inversion - Create helper function for optional millisecond arrays The invert method reverses the scale operation, converting numeric range values back to temporal domain values. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/time.rs | 124 ++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 6 deletions(-) diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index f9cdc37b..3aca5773 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -5,6 +5,7 @@ use arrow::array::{ TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray, TimestampSecondArray, }; +use arrow::compute::kernels::cast; use arrow::datatypes::{DataType, TimeUnit}; use avenger_common::types::LinearScaleAdjustment; use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc}; @@ -249,13 +250,42 @@ impl ScaleImpl for TimeScale { fn invert( &self, - _config: &ScaleConfig, - _values: &ArrayRef, + config: &ScaleConfig, + values: &ArrayRef, ) -> Result { - // TODO: Implement inversion from numeric to temporal - Err(AvengerScaleError::NotImplementedError( - "Inversion for time scale not yet implemented".to_string(), - )) + // Get domain and range bounds + let domain_type = config.domain.data_type(); + let handler = TemporalHandler::from_data_type(domain_type)?; + + let domain_start = get_temporal_value(&config.domain, 0, &handler)?; + let domain_end = get_temporal_value(&config.domain, 1, &handler)?; + + let (range_start, range_end) = config.numeric_interval_range()?; + + // Ensure values are numeric + let numeric_values = match values.data_type() { + DataType::Float32 => values.clone(), + _ => Arc::new(cast::cast(values, &DataType::Float32)?), + }; + + let float_array = numeric_values.as_any().downcast_ref::().unwrap(); + let mut result_millis = Vec::with_capacity(float_array.len()); + + // Invert each value + for i in 0..float_array.len() { + if float_array.is_null(i) { + result_millis.push(None); + } else { + let value = float_array.value(i); + // Reverse the scaling operation + let normalized = (value - range_start) / (range_end - range_start); + let millis = domain_start + ((domain_end - domain_start) as f32 * normalized) as i64; + result_millis.push(Some(millis)); + } + } + + // Convert milliseconds back to temporal array + create_temporal_array_from_optional_millis(&result_millis, domain_type) } fn ticks( @@ -909,6 +939,63 @@ fn create_temporal_domain_from_millis( create_temporal_array_from_millis_vec(&[start_millis, end_millis], data_type) } +/// Create temporal array from optional millisecond timestamps (for invert) +fn create_temporal_array_from_optional_millis( + millis_vec: &[Option], + data_type: &DataType, +) -> Result { + match data_type { + DataType::Date32 => { + // Convert milliseconds to days + let days: Vec> = millis_vec + .iter() + .map(|opt_ms| opt_ms.map(|ms| (ms / (24 * 60 * 60 * 1000)) as i32)) + .collect(); + Ok(Arc::new(Date32Array::from(days))) + } + DataType::Date64 => { + Ok(Arc::new(Date64Array::from(millis_vec.to_vec()))) + } + DataType::Timestamp(TimeUnit::Second, tz) => { + let secs: Vec> = millis_vec + .iter() + .map(|opt_ms| opt_ms.map(|ms| ms / 1000)) + .collect(); + Ok(Arc::new( + TimestampSecondArray::from(secs).with_timezone_opt(tz.clone()), + )) + } + DataType::Timestamp(TimeUnit::Millisecond, tz) => { + Ok(Arc::new( + TimestampMillisecondArray::from(millis_vec.to_vec()) + .with_timezone_opt(tz.clone()), + )) + } + DataType::Timestamp(TimeUnit::Microsecond, tz) => { + let micros: Vec> = millis_vec + .iter() + .map(|opt_ms| opt_ms.map(|ms| ms * 1000)) + .collect(); + Ok(Arc::new( + TimestampMicrosecondArray::from(micros).with_timezone_opt(tz.clone()), + )) + } + DataType::Timestamp(TimeUnit::Nanosecond, tz) => { + let nanos: Vec> = millis_vec + .iter() + .map(|opt_ms| opt_ms.map(|ms| ms * 1_000_000)) + .collect(); + Ok(Arc::new( + TimestampNanosecondArray::from(nanos).with_timezone_opt(tz.clone()), + )) + } + _ => Err(AvengerScaleError::InvalidDataTypeError( + data_type.clone(), + "temporal array".to_string(), + )), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1054,4 +1141,29 @@ mod tests { let interval = select_time_interval(864_000_000, 10.0); // 10 days assert_eq!(interval, TimeInterval::Day(1)); } + + #[test] + fn test_time_scale_invert() -> Result<(), AvengerScaleError> { + // Create domain from 2024-01-01 to 2024-12-31 + let start_date = 19723; // 2024-01-01 in days since epoch + let end_date = 20088; // 2024-12-31 in days since epoch + + let domain_start = Arc::new(Date32Array::from(vec![start_date])) as ArrayRef; + let domain_end = Arc::new(Date32Array::from(vec![end_date])) as ArrayRef; + + let scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)); + + // Test inverting some range values + let range_values = Arc::new(Float32Array::from(vec![0.0, 50.0, 100.0, 25.0])) as ArrayRef; + let inverted = scale.invert(&range_values)?; + let inverted_array = inverted.as_any().downcast_ref::().unwrap(); + + // Check results + assert_eq!(inverted_array.value(0), start_date); // 0.0 -> start + assert!((inverted_array.value(1) - 19905).abs() <= 1); // 50.0 -> mid-year (approximately) + assert_eq!(inverted_array.value(2), end_date); // 100.0 -> end + assert!(inverted_array.value(3) > start_date && inverted_array.value(3) < inverted_array.value(1)); // 25.0 -> Q1 + + Ok(()) + } } From f417f33bc3841c6b7366b24ec28dd14e84fe0ede Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Thu, 10 Jul 2025 12:33:35 -0700 Subject: [PATCH 11/29] feat: Implement comprehensive DST support for TimeScale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add safe time construction wrappers that never panic - Handle spring-forward gaps by jumping to next valid hour - Handle fall-back overlaps with configurable strategies - Update interval operations (floor, offset) for DST safety - Fix tick generation to avoid duplicates during fall-back - Add DST transition detection utilities - Ensure nice domain calculations are DST-safe DST handling features: - DstTransition enum to detect spring-forward/fall-back - DstStrategy enum for ambiguous time resolution - safe_with_hour() for DST-aware hour setting - safe_and_hms() for creating times during transitions - Actual duration calculation for intervals Tests added: - Spring forward transition (2:00 AM -> 3:00 AM) - Fall back transition (repeated 1:00 AM hour) - Tick generation across DST boundaries - Non-DST timezone verification This ensures TimeScale operations never panic due to DST transitions and provides predictable behavior for all edge cases. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/error.rs | 3 + avenger-scales/src/scales/time.rs | 433 +++++++++++++++++++++++++++--- 2 files changed, 405 insertions(+), 31 deletions(-) diff --git a/avenger-scales/src/error.rs b/avenger-scales/src/error.rs index 3b173176..6011b528 100644 --- a/avenger-scales/src/error.rs +++ b/avenger-scales/src/error.rs @@ -54,6 +54,9 @@ pub enum AvengerScaleError { #[error("Not implemented: {0}")] NotImplementedError(String), + #[error("DST transition error: {0}")] + DstTransitionError(String), + #[error("Invalid SVG transform string: {0}")] InvalidSvgTransformString(#[from] svgtypes::Error), diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index 3aca5773..f714973c 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -8,7 +8,7 @@ use arrow::array::{ use arrow::compute::kernels::cast; use arrow::datatypes::{DataType, TimeUnit}; use avenger_common::types::LinearScaleAdjustment; -use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc}; +use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Timelike, Utc}; use chrono_tz::Tz; use crate::error::AvengerScaleError; @@ -167,6 +167,192 @@ fn convert_to_timezone(timestamp_millis: i64, tz: &Tz) -> DateTime { utc_dt.with_timezone(tz) } +/// DST transition types +#[derive(Debug, Clone, PartialEq)] +enum DstTransition { + None, + SpringForward { + missing_start: u32, + missing_end: u32, + }, + FallBack { + repeated_start: u32, + repeated_end: u32, + }, +} + +/// DST resolution strategy for ambiguous times +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[allow(dead_code)] +enum DstStrategy { + #[default] + Earliest, // For fall-back, use first occurrence + Latest, // For fall-back, use second occurrence + PreferStandard, // Prefer standard time over DST + PreferDaylight, // Prefer DST over standard time +} + +/// Safe time construction that handles DST transitions +mod safe_time { + use super::*; + + /// Safely set hour on a DateTime, handling DST transitions + pub fn safe_with_hour(dt: DateTime, hour: u32) -> Result, AvengerScaleError> { + match dt.with_hour(hour) { + Some(new_dt) => Ok(new_dt), + None => { + // Hour doesn't exist (spring forward gap) + // Try the next hour + if hour < 23 { + dt.with_hour(hour + 1).ok_or_else(|| { + AvengerScaleError::DstTransitionError(format!( + "Cannot set hour {} during DST transition", + hour + )) + }) + } else { + // If we're at hour 23 and it doesn't exist, go to next day + let next_day = dt.date_naive() + chrono::Duration::days(1); + next_day + .and_hms_opt(0, dt.minute(), dt.second()) + .and_then(|naive_dt| dt.timezone().from_local_datetime(&naive_dt).single()) + .ok_or_else(|| { + AvengerScaleError::DstTransitionError(format!( + "Cannot set hour {} during DST transition", + hour + )) + }) + } + } + } + } + + /// Safely set time components, handling DST transitions + #[allow(dead_code)] + pub fn safe_with_time( + dt: DateTime, + hour: u32, + min: u32, + sec: u32, + strategy: DstStrategy, + ) -> Result, AvengerScaleError> { + let date = dt.date_naive(); + safe_and_hms(date, &dt.timezone(), hour, min, sec, strategy) + } + + /// Safely create DateTime from date and time components + pub fn safe_and_hms( + date: NaiveDate, + tz: &Tz, + hour: u32, + min: u32, + sec: u32, + strategy: DstStrategy, + ) -> Result, AvengerScaleError> { + match date.and_hms_opt(hour, min, sec) { + None => Err(AvengerScaleError::DstTransitionError(format!( + "Invalid time components: {}:{}:{}", + hour, min, sec + ))), + Some(naive_dt) => { + match tz.from_local_datetime(&naive_dt) { + chrono::LocalResult::Single(dt) => Ok(dt), + chrono::LocalResult::None => { + // Time doesn't exist (spring forward) + // Jump forward an hour + if hour < 23 { + safe_and_hms(date, tz, hour + 1, min, sec, strategy) + } else { + // Jump to next day + let next_date = date + chrono::Duration::days(1); + safe_and_hms(next_date, tz, 0, min, sec, strategy) + } + } + chrono::LocalResult::Ambiguous(early, late) => { + // Time is ambiguous (fall back) + match strategy { + DstStrategy::Earliest => Ok(early), + DstStrategy::Latest => Ok(late), + DstStrategy::PreferStandard => { + // Standard time has the larger UTC offset in fall-back + // Compare using timestamps since we can't compare offsets directly + if early.timestamp() > late.timestamp() { + Ok(late) // Later timestamp has standard time + } else { + Ok(early) + } + } + DstStrategy::PreferDaylight => { + // DST has the smaller UTC offset in fall-back + // Earlier timestamp has DST + if early.timestamp() < late.timestamp() { + Ok(early) + } else { + Ok(late) + } + } + } + } + } + } + } + } + + /// Detect DST transitions for a given date + pub fn find_dst_transition(date: NaiveDate, tz: &Tz) -> DstTransition { + let mut spring_forward_start = None; + let mut spring_forward_end = None; + let mut fall_back_start = None; + let mut fall_back_end = None; + + // Check each hour of the day + for hour in 0..24 { + match date.and_hms_opt(hour, 0, 0) { + None => continue, + Some(naive_dt) => { + match tz.from_local_datetime(&naive_dt) { + chrono::LocalResult::None => { + // Spring forward gap + if spring_forward_start.is_none() { + spring_forward_start = Some(hour); + } + spring_forward_end = Some(hour + 1); + } + chrono::LocalResult::Ambiguous(_, _) => { + // Fall back overlap + if fall_back_start.is_none() { + fall_back_start = Some(hour); + } + fall_back_end = Some(hour + 1); + } + chrono::LocalResult::Single(_) => {} + } + } + } + } + + if let (Some(start), Some(end)) = (spring_forward_start, spring_forward_end) { + DstTransition::SpringForward { + missing_start: start, + missing_end: end, + } + } else if let (Some(start), Some(end)) = (fall_back_start, fall_back_end) { + DstTransition::FallBack { + repeated_start: start, + repeated_end: end, + } + } else { + DstTransition::None + } + } + + /// Check if a date has a DST transition + #[allow(dead_code)] + pub fn is_dst_transition_date(date: NaiveDate, tz: &Tz) -> bool { + !matches!(find_dst_transition(date, tz), DstTransition::None) + } +} + impl ScaleImpl for TimeScale { fn scale_type(&self) -> &'static str { "time" @@ -256,21 +442,24 @@ impl ScaleImpl for TimeScale { // Get domain and range bounds let domain_type = config.domain.data_type(); let handler = TemporalHandler::from_data_type(domain_type)?; - + let domain_start = get_temporal_value(&config.domain, 0, &handler)?; let domain_end = get_temporal_value(&config.domain, 1, &handler)?; - + let (range_start, range_end) = config.numeric_interval_range()?; - + // Ensure values are numeric let numeric_values = match values.data_type() { DataType::Float32 => values.clone(), _ => Arc::new(cast::cast(values, &DataType::Float32)?), }; - - let float_array = numeric_values.as_any().downcast_ref::().unwrap(); + + let float_array = numeric_values + .as_any() + .downcast_ref::() + .unwrap(); let mut result_millis = Vec::with_capacity(float_array.len()); - + // Invert each value for i in 0..float_array.len() { if float_array.is_null(i) { @@ -279,11 +468,12 @@ impl ScaleImpl for TimeScale { let value = float_array.value(i); // Reverse the scaling operation let normalized = (value - range_start) / (range_end - range_start); - let millis = domain_start + ((domain_end - domain_start) as f32 * normalized) as i64; + let millis = + domain_start + ((domain_end - domain_start) as f32 * normalized) as i64; result_millis.push(Some(millis)); } } - + // Convert milliseconds back to temporal array create_temporal_array_from_optional_millis(&result_millis, domain_type) } @@ -625,25 +815,41 @@ impl TimeInterval { dt.timezone().timestamp_millis_opt(floored).unwrap() } TimeInterval::Second(n) => { - let base = dt.with_nanosecond(0).unwrap(); + let base = dt.with_nanosecond(0).unwrap_or(dt); let secs = base.second() as i32; - base.with_second(((secs / n) * n) as u32).unwrap() + let target_sec = ((secs / n) * n) as u32; + base.with_second(target_sec).unwrap_or(base) } TimeInterval::Minute(n) => { - let base = dt.with_second(0).unwrap().with_nanosecond(0).unwrap(); + let base = dt + .with_second(0) + .unwrap_or(dt) + .with_nanosecond(0) + .unwrap_or(dt); let mins = base.minute() as i32; - base.with_minute(((mins / n) * n) as u32).unwrap() + let target_min = ((mins / n) * n) as u32; + base.with_minute(target_min).unwrap_or(base) } TimeInterval::Hour(n) => { let base = dt .with_minute(0) - .unwrap() + .unwrap_or(dt) .with_second(0) - .unwrap() + .unwrap_or(dt) .with_nanosecond(0) - .unwrap(); + .unwrap_or(dt); let hours = base.hour() as i32; - base.with_hour(((hours / n) * n) as u32).unwrap() + let target_hour = ((hours / n) * n) as u32; + + // Use safe hour setting for DST + safe_time::safe_with_hour(base, target_hour).unwrap_or_else(|_| { + // If we can't set the hour due to DST, try the next valid hour + if target_hour < 23 { + safe_time::safe_with_hour(base, target_hour + 1).unwrap_or(base) + } else { + base + } + }) } TimeInterval::Day(_) => dt .date_naive() @@ -710,7 +916,21 @@ impl TimeInterval { } TimeInterval::Second(n) => dt + chrono::Duration::seconds((*n as i64) * (count as i64)), TimeInterval::Minute(n) => dt + chrono::Duration::minutes((*n as i64) * (count as i64)), - TimeInterval::Hour(n) => dt + chrono::Duration::hours((*n as i64) * (count as i64)), + TimeInterval::Hour(n) => { + // For hours, use calendar arithmetic to handle DST properly + let total_hours = *n * count; + let current_hour = dt.hour() as i32; + let new_hour = current_hour + total_hours; + + if (0..24).contains(&new_hour) { + // Same day + safe_time::safe_with_hour(dt, new_hour as u32) + .unwrap_or_else(|_| dt + chrono::Duration::hours(total_hours as i64)) + } else { + // Spans days, use duration addition + dt + chrono::Duration::hours(total_hours as i64) + } + } TimeInterval::Day(n) => dt + chrono::Duration::days((*n as i64) * (count as i64)), TimeInterval::Week(n) => dt + chrono::Duration::weeks((*n as i64) * (count as i64)), TimeInterval::Month(n) => { @@ -736,6 +956,13 @@ impl TimeInterval { } } } + + /// Get actual duration of interval at a specific time (accounting for DST) + #[allow(dead_code)] + fn actual_duration(&self, start: DateTime) -> chrono::Duration { + let end = self.offset(start, 1); + end - start + } } /// Add months to a datetime, handling edge cases @@ -858,6 +1085,12 @@ fn compute_nice_temporal_bounds( let nice_start = interval.floor(start_dt); let nice_end = interval.ceil(end_dt); + // Validate that we didn't create an invalid range + if nice_end <= nice_start { + // This shouldn't happen, but if it does, fall back to original bounds + return Ok((start_millis, end_millis)); + } + Ok((nice_start.timestamp_millis(), nice_end.timestamp_millis())) } @@ -877,11 +1110,27 @@ fn generate_temporal_ticks( // Start from floored first tick let mut current = interval.ceil(start_dt); let mut ticks = Vec::new(); + let mut prev_millis = None; // Generate ticks within domain while current <= end_dt { - ticks.push(current.timestamp_millis()); - current = interval.offset(current, 1); + let current_millis = current.timestamp_millis(); + + // Ensure we're not generating duplicate ticks (can happen during fall-back) + if prev_millis.is_none_or(|prev| current_millis > prev) { + ticks.push(current_millis); + prev_millis = Some(current_millis); + } + + // Safe offset that handles DST + let next = interval.offset(current, 1); + + // Safety check: ensure we're making progress + if next <= current { + break; + } + + current = next; } Ok(ticks) @@ -953,9 +1202,7 @@ fn create_temporal_array_from_optional_millis( .collect(); Ok(Arc::new(Date32Array::from(days))) } - DataType::Date64 => { - Ok(Arc::new(Date64Array::from(millis_vec.to_vec()))) - } + DataType::Date64 => Ok(Arc::new(Date64Array::from(millis_vec.to_vec()))), DataType::Timestamp(TimeUnit::Second, tz) => { let secs: Vec> = millis_vec .iter() @@ -965,12 +1212,9 @@ fn create_temporal_array_from_optional_millis( TimestampSecondArray::from(secs).with_timezone_opt(tz.clone()), )) } - DataType::Timestamp(TimeUnit::Millisecond, tz) => { - Ok(Arc::new( - TimestampMillisecondArray::from(millis_vec.to_vec()) - .with_timezone_opt(tz.clone()), - )) - } + DataType::Timestamp(TimeUnit::Millisecond, tz) => Ok(Arc::new( + TimestampMillisecondArray::from(millis_vec.to_vec()).with_timezone_opt(tz.clone()), + )), DataType::Timestamp(TimeUnit::Microsecond, tz) => { let micros: Vec> = millis_vec .iter() @@ -1146,7 +1390,7 @@ mod tests { fn test_time_scale_invert() -> Result<(), AvengerScaleError> { // Create domain from 2024-01-01 to 2024-12-31 let start_date = 19723; // 2024-01-01 in days since epoch - let end_date = 20088; // 2024-12-31 in days since epoch + let end_date = 20088; // 2024-12-31 in days since epoch let domain_start = Arc::new(Date32Array::from(vec![start_date])) as ArrayRef; let domain_end = Arc::new(Date32Array::from(vec![end_date])) as ArrayRef; @@ -1162,7 +1406,134 @@ mod tests { assert_eq!(inverted_array.value(0), start_date); // 0.0 -> start assert!((inverted_array.value(1) - 19905).abs() <= 1); // 50.0 -> mid-year (approximately) assert_eq!(inverted_array.value(2), end_date); // 100.0 -> end - assert!(inverted_array.value(3) > start_date && inverted_array.value(3) < inverted_array.value(1)); // 25.0 -> Q1 + assert!( + inverted_array.value(3) > start_date + && inverted_array.value(3) < inverted_array.value(1) + ); // 25.0 -> Q1 + + Ok(()) + } + + #[test] + fn test_dst_spring_forward() -> Result<(), AvengerScaleError> { + // Test spring forward DST transition (2024-03-10 in US Eastern) + let et = parse_timezone("America/New_York")?; + + // Test safe hour construction during spring forward + let date = NaiveDate::from_ymd_opt(2024, 3, 10).unwrap(); + let _dt_before = safe_time::safe_and_hms(date, &et, 1, 30, 0, DstStrategy::Earliest)?; + + // Try to create 2:30 AM which doesn't exist (should jump to 3:30 AM) + let dt_gap = safe_time::safe_and_hms(date, &et, 2, 30, 0, DstStrategy::Earliest)?; + assert_eq!(dt_gap.hour(), 3); // Should have jumped forward + assert_eq!(dt_gap.minute(), 30); + + // Check DST detection + let transition = safe_time::find_dst_transition(date, &et); + match transition { + DstTransition::SpringForward { + missing_start, + missing_end, + } => { + assert_eq!(missing_start, 2); + assert_eq!(missing_end, 3); + } + _ => panic!("Expected spring forward transition"), + } + + Ok(()) + } + + #[test] + fn test_dst_fall_back() -> Result<(), AvengerScaleError> { + // Test fall back DST transition (2024-11-03 in US Eastern) + let et = parse_timezone("America/New_York")?; + + let date = NaiveDate::from_ymd_opt(2024, 11, 3).unwrap(); + + // Test both occurrences of 1:30 AM + let dt_early = safe_time::safe_and_hms(date, &et, 1, 30, 0, DstStrategy::Earliest)?; + let dt_late = safe_time::safe_and_hms(date, &et, 1, 30, 0, DstStrategy::Latest)?; + + // Both should be valid but different instants + assert_eq!(dt_early.hour(), 1); + assert_eq!(dt_late.hour(), 1); + assert!(dt_early < dt_late); // Early occurrence comes before late + + // Check DST detection + let transition = safe_time::find_dst_transition(date, &et); + match transition { + DstTransition::FallBack { + repeated_start, + repeated_end, + } => { + assert_eq!(repeated_start, 1); + assert_eq!(repeated_end, 2); + } + _ => panic!("Expected fall back transition"), + } + + Ok(()) + } + + #[test] + fn test_dst_tick_generation() -> Result<(), AvengerScaleError> { + // Test tick generation across DST boundary + let et = parse_timezone("America/New_York")?; + + // Domain spans spring forward (2024-03-10 00:00 to 06:00 ET) + let start_ts = et + .with_ymd_and_hms(2024, 3, 10, 0, 0, 0) + .unwrap() + .timestamp(); + let end_ts = et + .with_ymd_and_hms(2024, 3, 10, 6, 0, 0) + .unwrap() + .timestamp(); + + let domain_start = Arc::new(TimestampSecondArray::from(vec![start_ts])) as ArrayRef; + let domain_end = Arc::new(TimestampSecondArray::from(vec![end_ts])) as ArrayRef; + + let mut scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)); + scale = scale.with_option("timezone", "America/New_York"); + + // Generate hourly ticks + let ticks = scale.ticks(Some(6.0))?; + let tick_array = ticks + .as_any() + .downcast_ref::() + .unwrap(); + + // Should have ticks at 0:00, 1:00, 3:00, 4:00, 5:00, 6:00 (skipping 2:00) + let mut tick_hours = Vec::new(); + for i in 0..tick_array.len() { + let ts = tick_array.value(i); + let dt = et.timestamp_opt(ts, 0).unwrap(); + tick_hours.push(dt.hour()); + } + + // Verify 2:00 AM is skipped + assert!(!tick_hours.contains(&2)); + + Ok(()) + } + + #[test] + fn test_no_dst_timezone() -> Result<(), AvengerScaleError> { + // Test timezone without DST (UTC) + let utc = parse_timezone("UTC")?; + + let date = NaiveDate::from_ymd_opt(2024, 3, 10).unwrap(); + + // All hours should be valid in UTC + for hour in 0..24 { + let dt = safe_time::safe_and_hms(date, &utc, hour, 0, 0, DstStrategy::Earliest)?; + assert_eq!(dt.hour(), hour); + } + + // No transitions + let transition = safe_time::find_dst_transition(date, &utc); + assert_eq!(transition, DstTransition::None); Ok(()) } From 9871ae9b840716739d4ed18fcb84c666a4b22151 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Thu, 10 Jul 2025 12:43:21 -0700 Subject: [PATCH 12/29] feat: Implement DST Phase 5 - Update scale operations for DST awareness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add compute_actual_duration_millis() to calculate true duration across DST transitions - Update scale() method to use actual duration when DST transitions occur in domain - Update invert() method with iterative refinement for accurate DST-aware inversion - Update scale_timestamp_values() to handle all timestamp types with DST awareness - Add comprehensive tests for spring-forward and fall-back scaling scenarios - Ensure visual accuracy: 3-hour actual duration during spring-forward, 5-hour during fall-back All DST tests passing. Scale operations now correctly handle domains spanning DST transitions. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/time.rs | 240 ++++++++++++++++++++++++++++-- 1 file changed, 226 insertions(+), 14 deletions(-) diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index f714973c..202d5767 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -353,6 +353,43 @@ mod safe_time { } } +/// Compute actual duration between two timestamps considering DST transitions +fn compute_actual_duration_millis( + start_millis: i64, + end_millis: i64, + tz: &Tz, +) -> Result { + // If both timestamps are in UTC or the same timezone, just subtract + if tz == &chrono_tz::UTC { + return Ok(end_millis - start_millis); + } + + // Convert to timezone-aware datetimes + let start = tz + .timestamp_opt(start_millis / 1000, ((start_millis % 1000) * 1_000_000) as u32) + .single() + .ok_or_else(|| { + AvengerScaleError::DstTransitionError(format!( + "Ambiguous start timestamp: {}", + start_millis + )) + })?; + + let end = tz + .timestamp_opt(end_millis / 1000, ((end_millis % 1000) * 1_000_000) as u32) + .single() + .ok_or_else(|| { + AvengerScaleError::DstTransitionError(format!( + "Ambiguous end timestamp: {}", + end_millis + )) + })?; + + // Compute actual duration + let duration = end - start; + Ok(duration.num_milliseconds()) +} + impl ScaleImpl for TimeScale { fn scale_type(&self) -> &'static str { "time" @@ -377,6 +414,14 @@ impl ScaleImpl for TimeScale { // Get range bounds let (range_start, range_end) = config.numeric_interval_range()?; + + // Get timezone for DST-aware duration calculations + let tz_str = config.option_string("timezone", "UTC"); + let tz = parse_timezone(&tz_str)?; + + // Compute actual duration (accounting for DST) + let actual_duration = compute_actual_duration_millis(domain_start, domain_end, &tz)?; + let use_actual_duration = actual_duration != (domain_end - domain_start); // Scale values let result = match values.data_type() { @@ -389,8 +434,14 @@ impl ScaleImpl for TimeScale { output.push(None); } else { let value = handler.to_timestamp_millis(values.value(i) as i64); - let normalized = - (value - domain_start) as f32 / (domain_end - domain_start) as f32; + let normalized = if use_actual_duration { + // Use actual duration for DST-aware scaling + let value_duration = compute_actual_duration_millis(domain_start, value, &tz)?; + value_duration as f32 / actual_duration as f32 + } else { + // Use nominal duration for performance when no DST + (value - domain_start) as f32 / (domain_end - domain_start) as f32 + }; output.push(Some(range_start + normalized * (range_end - range_start))); } } @@ -406,8 +457,14 @@ impl ScaleImpl for TimeScale { output.push(None); } else { let value = handler.to_timestamp_millis(values.value(i)); - let normalized = - (value - domain_start) as f32 / (domain_end - domain_start) as f32; + let normalized = if use_actual_duration { + // Use actual duration for DST-aware scaling + let value_duration = compute_actual_duration_millis(domain_start, value, &tz)?; + value_duration as f32 / actual_duration as f32 + } else { + // Use nominal duration for performance when no DST + (value - domain_start) as f32 / (domain_end - domain_start) as f32 + }; output.push(Some(range_start + normalized * (range_end - range_start))); } } @@ -422,6 +479,9 @@ impl ScaleImpl for TimeScale { domain_end, range_start, range_end, + &tz, + use_actual_duration, + actual_duration, )?, _ => { return Err(AvengerScaleError::InvalidDataTypeError( @@ -447,6 +507,14 @@ impl ScaleImpl for TimeScale { let domain_end = get_temporal_value(&config.domain, 1, &handler)?; let (range_start, range_end) = config.numeric_interval_range()?; + + // Get timezone for DST-aware duration calculations + let tz_str = config.option_string("timezone", "UTC"); + let tz = parse_timezone(&tz_str)?; + + // Compute actual duration (accounting for DST) + let actual_duration = compute_actual_duration_millis(domain_start, domain_end, &tz)?; + let use_actual_duration = actual_duration != (domain_end - domain_start); // Ensure values are numeric let numeric_values = match values.data_type() { @@ -468,8 +536,31 @@ impl ScaleImpl for TimeScale { let value = float_array.value(i); // Reverse the scaling operation let normalized = (value - range_start) / (range_end - range_start); - let millis = - domain_start + ((domain_end - domain_start) as f32 * normalized) as i64; + + let millis = if use_actual_duration { + // For DST-aware inversion, we need to find the timestamp + // whose actual duration from domain_start equals the target + let target_duration = (actual_duration as f32 * normalized) as i64; + + // Start with a nominal estimate + let mut estimate = domain_start + target_duration; + + // Refine the estimate by checking actual duration + // This handles DST transitions correctly + for _ in 0..3 { // Usually converges in 1-2 iterations + let actual = compute_actual_duration_millis(domain_start, estimate, &tz)?; + let error = target_duration - actual; + if error.abs() < 1000 { // Within 1 second is good enough + break; + } + estimate += error; + } + estimate + } else { + // No DST transitions, use simple linear interpolation + domain_start + ((domain_end - domain_start) as f32 * normalized) as i64 + }; + result_millis.push(Some(millis)); } } @@ -705,6 +796,9 @@ fn scale_timestamp_values( domain_end: i64, range_start: f32, range_end: f32, + tz: &Tz, + use_actual_duration: bool, + actual_duration: i64, ) -> Result { let mut output = Vec::with_capacity(values.len()); @@ -719,8 +813,14 @@ fn scale_timestamp_values( output.push(None); } else { let value = handler.to_timestamp_millis(values.value(i)); - let normalized = - (value - domain_start) as f32 / (domain_end - domain_start) as f32; + let normalized = if use_actual_duration { + // Use actual duration for DST-aware scaling + let value_duration = compute_actual_duration_millis(domain_start, value, tz)?; + value_duration as f32 / actual_duration as f32 + } else { + // Use nominal duration for performance when no DST + (value - domain_start) as f32 / (domain_end - domain_start) as f32 + }; output.push(Some(range_start + normalized * (range_end - range_start))); } } @@ -735,8 +835,14 @@ fn scale_timestamp_values( output.push(None); } else { let value = handler.to_timestamp_millis(values.value(i)); - let normalized = - (value - domain_start) as f32 / (domain_end - domain_start) as f32; + let normalized = if use_actual_duration { + // Use actual duration for DST-aware scaling + let value_duration = compute_actual_duration_millis(domain_start, value, tz)?; + value_duration as f32 / actual_duration as f32 + } else { + // Use nominal duration for performance when no DST + (value - domain_start) as f32 / (domain_end - domain_start) as f32 + }; output.push(Some(range_start + normalized * (range_end - range_start))); } } @@ -751,8 +857,14 @@ fn scale_timestamp_values( output.push(None); } else { let value = handler.to_timestamp_millis(values.value(i)); - let normalized = - (value - domain_start) as f32 / (domain_end - domain_start) as f32; + let normalized = if use_actual_duration { + // Use actual duration for DST-aware scaling + let value_duration = compute_actual_duration_millis(domain_start, value, tz)?; + value_duration as f32 / actual_duration as f32 + } else { + // Use nominal duration for performance when no DST + (value - domain_start) as f32 / (domain_end - domain_start) as f32 + }; output.push(Some(range_start + normalized * (range_end - range_start))); } } @@ -767,8 +879,14 @@ fn scale_timestamp_values( output.push(None); } else { let value = handler.to_timestamp_millis(values.value(i)); - let normalized = - (value - domain_start) as f32 / (domain_end - domain_start) as f32; + let normalized = if use_actual_duration { + // Use actual duration for DST-aware scaling + let value_duration = compute_actual_duration_millis(domain_start, value, tz)?; + value_duration as f32 / actual_duration as f32 + } else { + // Use nominal duration for performance when no DST + (value - domain_start) as f32 / (domain_end - domain_start) as f32 + }; output.push(Some(range_start + normalized * (range_end - range_start))); } } @@ -1537,4 +1655,98 @@ mod tests { Ok(()) } + + #[test] + fn test_dst_scale_operations() -> Result<(), AvengerScaleError> { + // Test scaling across DST transitions + let tz_str = "America/New_York"; + let et = parse_timezone(tz_str)?; + + // Create domain spanning spring-forward DST transition + // 2024-03-10 00:00 to 04:00 ET (2:00 AM doesn't exist) + let start = et.with_ymd_and_hms(2024, 3, 10, 0, 0, 0).unwrap(); + let end = et.with_ymd_and_hms(2024, 3, 10, 4, 0, 0).unwrap(); + + let domain_start = Arc::new(TimestampMillisecondArray::from(vec![start.timestamp_millis()])) as ArrayRef; + let domain_end = Arc::new(TimestampMillisecondArray::from(vec![end.timestamp_millis()])) as ArrayRef; + + let scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)) + .with_option("timezone", tz_str); + + // Test scaling values across the DST gap + let test_times = vec![ + et.with_ymd_and_hms(2024, 3, 10, 0, 0, 0).unwrap(), // Start + et.with_ymd_and_hms(2024, 3, 10, 1, 0, 0).unwrap(), // Before gap + et.with_ymd_and_hms(2024, 3, 10, 3, 0, 0).unwrap(), // After gap (2AM skipped) + et.with_ymd_and_hms(2024, 3, 10, 4, 0, 0).unwrap(), // End + ]; + + let values = Arc::new(TimestampMillisecondArray::from( + test_times.iter().map(|dt| dt.timestamp_millis()).collect::>() + )) as ArrayRef; + + let scaled = scale.scale(&values)?; + let scaled_array = scaled.as_any().downcast_ref::().unwrap(); + + // The actual duration is 3 hours (not 4) due to DST + // So scaling should reflect this + assert_eq!(scaled_array.value(0), 0.0); // Start + assert!((scaled_array.value(1) - 33.33).abs() < 1.0); // 1 hour / 3 hours ≈ 33.33% + assert!((scaled_array.value(2) - 66.67).abs() < 1.0); // 2 hours / 3 hours ≈ 66.67% + assert_eq!(scaled_array.value(3), 100.0); // End + + // Test inversion + let range_values = Arc::new(Float32Array::from(vec![0.0, 33.33, 66.67, 100.0])) as ArrayRef; + let inverted = scale.invert(&range_values)?; + let inverted_array = inverted.as_any().downcast_ref::().unwrap(); + + // Check that inverted values match original times (approximately) + for i in 0..test_times.len() { + let diff = (inverted_array.value(i) - test_times[i].timestamp_millis()).abs(); + assert!(diff < 60000, "Inverted time differs by more than 1 minute"); // Within 1 minute + } + + Ok(()) + } + + #[test] + fn test_dst_fall_back_scale() -> Result<(), AvengerScaleError> { + // Test scaling across fall-back DST transition + let tz_str = "America/New_York"; + let et = parse_timezone(tz_str)?; + + // Create domain spanning fall-back DST transition + // 2024-11-03 00:00 to 04:00 ET (1:00 AM happens twice) + let start = et.with_ymd_and_hms(2024, 11, 3, 0, 0, 0).unwrap(); + let end = et.with_ymd_and_hms(2024, 11, 3, 4, 0, 0).unwrap(); + + let domain_start = Arc::new(TimestampMillisecondArray::from(vec![start.timestamp_millis()])) as ArrayRef; + let domain_end = Arc::new(TimestampMillisecondArray::from(vec![end.timestamp_millis()])) as ArrayRef; + + let scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)) + .with_option("timezone", tz_str); + + // The actual duration is 5 hours (not 4) due to DST fall-back + let values = Arc::new(TimestampMillisecondArray::from(vec![ + start.timestamp_millis(), + start.timestamp_millis() + 3600 * 1000, // 1 hour later + start.timestamp_millis() + 2 * 3600 * 1000, // 2 hours later (first 1 AM) + start.timestamp_millis() + 3 * 3600 * 1000, // 3 hours later (second 1 AM) + start.timestamp_millis() + 4 * 3600 * 1000, // 4 hours later + end.timestamp_millis(), + ])) as ArrayRef; + + let scaled = scale.scale(&values)?; + let scaled_array = scaled.as_any().downcast_ref::().unwrap(); + + // Check scaling accounts for the extra hour + assert_eq!(scaled_array.value(0), 0.0); // Start + assert!((scaled_array.value(1) - 20.0).abs() < 1.0); // 1/5 = 20% + assert!((scaled_array.value(2) - 40.0).abs() < 1.0); // 2/5 = 40% + assert!((scaled_array.value(3) - 60.0).abs() < 1.0); // 3/5 = 60% + assert!((scaled_array.value(4) - 80.0).abs() < 1.0); // 4/5 = 80% + assert_eq!(scaled_array.value(5), 100.0); // End + + Ok(()) + } } From 0cb99552be61725d7274c150faf80c16a8c2dc79 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Thu, 10 Jul 2025 12:52:02 -0700 Subject: [PATCH 13/29] docs: Update time scale implementation plan to reflect DST Phase 5 completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark DST Phase 5 as complete (scale operations now DST-aware) - Update checkboxes for completed calendar-aware algorithms - Add summary of remaining work organized by priority - Update status to reflect low risk level now that core functionality is complete Remaining high priority work: - Implement tick_format() method for formatting - Handle remaining edge cases (leap years, month lengths) - Add tests for European and Southern hemisphere DST 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../docs/time-scale-implementation-plan.md | 871 ++++++++++++++++++ 1 file changed, 871 insertions(+) create mode 100644 avenger-scales/docs/time-scale-implementation-plan.md diff --git a/avenger-scales/docs/time-scale-implementation-plan.md b/avenger-scales/docs/time-scale-implementation-plan.md new file mode 100644 index 00000000..1fb564a9 --- /dev/null +++ b/avenger-scales/docs/time-scale-implementation-plan.md @@ -0,0 +1,871 @@ +# Time Scale Implementation Plan + +## Overview + +This plan outlines the implementation of comprehensive time scale support for avenger-scales, addressing limitations in existing visualization libraries (Vega, D3) by providing configurable timezone support and unified handling of all Arrow temporal types. + +## Key Goals + +- [ ] **Unified Time Scale**: One scale type that works across Date, Timestamp, and Timestamptz Arrow types +- [ ] **Configurable Timezone Support**: Beyond Vega's local/UTC limitation - support arbitrary IANA timezones +- [ ] **Arrow-First Architecture**: Leverage Arrow kernels where possible, fallback to chrono-tz when needed +- [ ] **Calendar-Aware Operations**: Smart tick generation, nice intervals, and DST handling +- [ ] **Performance**: Efficient temporal computations using Arrow's columnar approach + +## Research Findings + +### Arrow Temporal Capabilities +- **Available**: Basic temporal conversions (timestamp_*_to_datetime functions) +- **Available**: Temporal array types (Date32/64, Timestamp*, Time*) +- **Available**: Limited timezone module (arrow::array::timezone with Tz struct) +- **Missing**: Calendar arithmetic, nice interval generation, timezone-aware operations +- **Missing**: Comprehensive temporal compute kernels + +### Required External Dependencies +- **chrono-tz**: IANA timezone database, DST handling, timezone conversions +- **chrono**: Core temporal arithmetic and calendar operations + +### D3/Vega Time Scale Algorithm Analysis + +#### D3 Time Interval Hierarchy +D3 provides a comprehensive hierarchy of time intervals for tick generation and nice domain calculations: + +**Interval Progression**: millisecond → second → minute → hour → day → week → month → year + +**Key Intervals with Step Values**: +- **Milliseconds**: 1, 5, 15, 25, 50, 100, 250, 500ms +- **Seconds**: 1, 5, 15, 30s +- **Minutes**: 1, 5, 15, 30min +- **Hours**: 1, 3, 6, 12h +- **Days**: 1, 2d +- **Weeks**: 1w (7 days) +- **Months**: 1, 3m (quarters) +- **Years**: 1y + +#### D3 Nice Algorithm +```javascript +scale.nice = function(interval) { + var d = domain(); + if (!interval || typeof interval.range !== "function") { + interval = tickInterval(d[0], d[d.length - 1], interval == null ? 10 : interval); + } + return interval ? domain(nice(d, interval)) : scale; +}; +``` + +**Algorithm Steps**: +1. **Auto-interval selection**: If no interval specified, `tickInterval()` selects appropriate interval based on domain span and target tick count (default 10) +2. **Interval validation**: Ensures interval has required methods (`range()` function) +3. **Domain adjustment**: Applies interval-based nice transformation to extend domain to clean boundaries + +#### D3 Tick Generation Algorithm +**Interval Selection Strategy**: +1. Calculate target interval duration: `(domain_end - domain_start) / desired_tick_count` +2. Use binary search (`bisector`) to find closest predefined interval from hierarchy +3. Apply interval's `range()` method to generate tick positions +4. Handle edge cases (ascending/descending domains, timezone boundaries) + +**Calendar Boundary Alignment**: +- **Milliseconds/Seconds**: Round to clean decimal boundaries +- **Minutes/Hours**: Align to hour boundaries (e.g., :00, :15, :30, :45) +- **Days**: Align to midnight boundaries +- **Weeks**: Align to week start (Sunday/Monday depending on locale) +- **Months**: Align to first day of month +- **Years**: Align to January 1st + +#### D3 Time Interval Implementation Pattern +Each interval (year, month, day, etc.) implements: +- `floor(date)`: Round down to interval boundary +- `ceil(date)`: Round up to next interval boundary +- `offset(date, step)`: Add/subtract intervals +- `range(start, stop)`: Generate sequence of interval boundaries +- `every(step)`: Create interval with custom step size + +**Calendar Arithmetic Handling**: +- **DST Transitions**: Days can be 23-25 hours; handled by checking timezone offsets +- **Variable Month Lengths**: 28-31 days handled by calendar-aware date arithmetic +- **Leap Years**: Handled by JavaScript Date object's built-in calendar logic +- **Week Boundaries**: Supports different week start days (Sunday through Saturday) + +#### Vega Enhancements Over D3 +- **UTC vs Local Time**: Explicit `time` vs `utc` scale types +- **TimeUnit Integration**: Connects with Vega-Lite's `timeUnit` encoding +- **Known Limitations**: + - "Nice" ticks can behave unexpectedly when domain falls between nice boundaries + - Limited timezone support (only local and UTC) + - Tooltip timezone display issues + +#### Implementation Insights for Avenger-Scales +1. **Predefined Interval Hierarchy**: Essential for automatic interval selection +2. **Binary Search Selection**: Efficient algorithm for choosing appropriate intervals +3. **Calendar-Aware Boundaries**: Critical for human-readable tick positions +4. **DST Handling**: Must account for variable day lengths in local time +5. **Configurable Week Start**: Important for international applications +6. **UTC/Local Separation**: Clear distinction needed for timezone handling + +### Additional Library Research + +#### Pandas Time Series Resampling +**Algorithm Strategy**: +- **Frequency Offset System**: Uses string-based frequency specifications ("D", "H", "M", "5Min", etc.) +- **Parameterized Offsets**: Support for custom week starts, month ends, business days +- **Label/Closed Control**: Flexible bin edge labeling and boundary inclusion rules +- **Origin/Offset Parameters**: Fine-grained control over bin alignment and starting points + +**Key Features**: +- **DateOffset Classes**: Extensible system for defining custom time intervals +- **Resampling Types**: Both upsampling (interpolation) and downsampling (aggregation) +- **Business Calendar Support**: Built-in handling of business days, holidays +- **Performance**: Highly optimized for large time series datasets + +#### Matplotlib Date Locators +**Comprehensive Locator System**: +- **AutoDateLocator**: Intelligent automatic interval selection +- **Specific Locators**: YearLocator, MonthLocator, WeekdayLocator, DayLocator, HourLocator, etc. +- **RRuleLocator**: Complex rule-based patterns ("last Friday of each month") +- **ConciseDateFormatter**: Context-aware date formatting + +**Algorithm Features**: +- **Multi-scale Approach**: Different locators for different zoom levels +- **Calendar Intelligence**: Weekday-aware positioning, month-end handling +- **Minor/Major Tick Coordination**: Hierarchical tick systems +- **Date Range Constraints**: Support for years 0001-9999 + +#### ggplot2 (R) Date/Time Scales +**Break Generation Strategy**: +- **Smart Defaults**: Automatic sensible major/minor tick placement +- **Calendar Units**: "sec", "min", "hour", "day", "week", "month", "year" with multipliers +- **Priority System**: `date_breaks` > `breaks`, `date_labels` > `labels` +- **Offset Support**: Ability to shift break alignment + +**Advanced Features**: +- **Multiple Scale Types**: `scale_*_date`, `scale_*_datetime`, `scale_*_time` +- **Flexible Formatting**: Integration with strftime() format codes +- **Break Positioning**: Control over label alignment and tick spacing + +#### Plotly Time Axis +**Dynamic Adaptation**: +- **Zoom-aware Formatting**: Different formats for different zoom levels +- **tickformatstops**: Multi-level formatting based on visible range +- **Period vs Instant Mode**: Label positioning at period centers vs boundaries +- **Calendar Interval Support**: dtick with calendar-aware stepping + +**Limitations Identified**: +- **Limited Manual Control**: Less granular control compared to matplotlib +- **Automatic Behavior**: Sometimes unpredictable tick placement + +#### Observable Plot Time Scales +**D3-Based Foundation**: +- **Inherited D3 Logic**: Builds on D3's proven time scale algorithms +- **Plot-Specific Enhancements**: Integration with Plot's mark system +- **Interval Transform**: Built-in support for time-based grouping +- **UTC/Local Options**: Clear separation of timezone handling + +#### Polars Time Series +**Performance-Focused Approach**: +- **Dynamic Grouping**: `group_by_dynamic` for time-based aggregation +- **Interval Syntax**: Composable interval strings ("1h30m") +- **Sorting Optimization**: Fast-path operations for sorted temporal data +- **Upsampling/Downsampling**: Efficient frequency conversion + +**Modern Features**: +- **Lazy Evaluation**: Deferred computation for large datasets +- **Native Temporal Types**: Built-in Date/Datetime with multiple precisions +- **Activity-Based Sampling**: Support for non-uniform time intervals + +#### DuckDB Temporal Functions +**Comprehensive Calendar System**: +- **ICU Integration**: International calendar and timezone support +- **Window Functions**: Tumbling, hopping, sliding temporal windows +- **Interval Arithmetic**: Three-component intervals (months, days, microseconds) +- **Range Generation**: Built-in functions for creating time sequences + +**Enterprise Features**: +- **Non-Gregorian Calendars**: Support for alternative calendar systems +- **DST-Aware Binning**: Proper handling of timezone transitions +- **Temporal Analytics**: Advanced time-based aggregation functions + +#### Apache Arrow Temporal Kernels +**Low-Level Foundations**: +- **SIMD Optimization**: 64-byte padding for vectorized operations +- **Timezone Support**: Per-kernel timezone awareness (with known limitations) +- **Type Casting**: Temporal type conversions with resolution handling +- **Performance Focus**: Columnar operations for high-throughput scenarios + +**Current Limitations**: +- **Limited High-Level Functions**: Mostly component extraction, less calendar logic +- **Missing Features**: No built-in tick generation or nice interval algorithms +- **Work in Progress**: Active development of temporal arithmetic kernels + +### Synthesis for Avenger-Scales + +**Best Practices from Research**: +1. **Multi-Library Approach**: Combine strengths from different libraries +2. **Pandas-Style Frequency Strings**: Proven, intuitive interval specification +3. **Matplotlib's Locator Hierarchy**: Comprehensive automatic/manual control +4. **D3's Calendar Intelligence**: Human-readable boundary alignment +5. **DuckDB's ICU Integration**: International calendar and timezone support +6. **Arrow's Performance Foundation**: SIMD-optimized columnar operations + +**Competitive Advantages to Implement**: +1. **Configurable Timezone Support**: Beyond Vega's local/UTC limitation +2. **Arrow-Native Performance**: Leverage columnar optimizations +3. **Unified API**: Handle Date/Timestamp/TimestampTz seamlessly +4. **International Support**: ICU-style calendar and timezone awareness +5. **Business Calendar Extensions**: Support for custom calendars and holidays + +## Concrete Specification for Avenger-Scales Time Scale + +### Supported Temporal Types +1. **Arrow Date32**: Days since Unix epoch (1970-01-01) +2. **Arrow Date64**: Milliseconds since Unix epoch +3. **Arrow Timestamp**: With units (s, ms, μs, ns) and optional timezone +4. **Arrow TimestampTz**: Timestamp with timezone metadata + +### Core API + +#### Scale Construction +```rust +// Unified constructor that auto-detects temporal type +TimeScale::configured(domain: (ArrayRef, ArrayRef), range: (f32, f32)) -> ConfiguredScale + +// Examples: +// Date array → numeric range +let scale = TimeScale::configured((date_array_start, date_array_end), (0.0, 100.0)); + +// Timestamp array → color range +let scale = TimeScale::configured_color((timestamp_array_start, timestamp_array_end), &["red", "blue"]); +``` + +#### Configuration Options +```rust +scale + .with_option("timezone", "America/New_York") // Display timezone (IANA string or "local"/"utc") + .with_option("nice", true) // Extend domain to calendar boundaries + .with_option("nice", 10.0) // Target ~10 ticks with nice boundaries + .with_option("interval", "day") // Force specific tick interval + .with_option("interval", "3 hours") // Custom interval with count + .with_option("week_start", "monday") // Week boundary configuration + .with_option("locale", "en-US") // Formatting locale +``` + +### Interval Specification Language + +#### Basic Units +- `"millisecond"` or `"ms"` +- `"second"` or `"s"` +- `"minute"` or `"min"` +- `"hour"` or `"h"` +- `"day"` or `"d"` +- `"week"` or `"w"` +- `"month"` or `"mo"` +- `"year"` or `"y"` + +#### Compound Intervals +- `"3 hours"` - Every 3 hours +- `"15 minutes"` - Every 15 minutes +- `"2 weeks"` - Every 2 weeks +- `"quarter"` - Every 3 months + +#### Special Intervals +- `"business_day"` - Monday-Friday only +- `"month_start"` - First day of each month +- `"month_end"` - Last day of each month +- `"week_start"` - Configured week start day + +### Nice Domain Algorithm + +#### Interval Hierarchy (D3-inspired) +```rust +const INTERVAL_HIERARCHY: &[(Duration, &str, Vec)] = &[ + (Duration::milliseconds(1), "ms", vec![1, 5, 15, 25, 50, 100, 250, 500]), + (Duration::seconds(1), "s", vec![1, 5, 15, 30]), + (Duration::minutes(1), "min", vec![1, 5, 15, 30]), + (Duration::hours(1), "h", vec![1, 3, 6, 12]), + (Duration::days(1), "d", vec![1, 2, 7]), + (Duration::days(7), "w", vec![1]), + (Duration::months(1), "mo", vec![1, 3]), + (Duration::years(1), "y", vec![1, 2, 5, 10, 20, 50, 100]), +]; +``` + +#### Nice Boundary Rules +1. **Milliseconds/Seconds**: Round to clean decimal multiples +2. **Minutes**: Align to :00, :15, :30, :45 +3. **Hours**: Align to hour boundaries +4. **Days**: Align to midnight in display timezone +5. **Weeks**: Align to configured week start +6. **Months**: Align to first of month +7. **Years**: Align to January 1st + +### Tick Generation Algorithm + +#### Automatic Interval Selection +```rust +fn select_interval(domain_span: Duration, target_count: f32) -> Interval { + let target_interval = domain_span / target_count; + // Binary search through INTERVAL_HIERARCHY + // Return closest interval that produces readable ticks +} +``` + +#### Tick Position Rules +1. **Always include nice boundaries** when domain spans them +2. **Respect timezone** for day/week/month boundaries +3. **Handle DST transitions** gracefully (skip/repeat as needed) +4. **Generate uniform spacing** within calendar constraints + +### Timezone Handling + +#### Timezone Resolution Order +1. Explicit scale configuration: `.with_option("timezone", "...")` +2. TimestampTz embedded timezone (if present) +3. System local timezone (if "local" specified) +4. UTC fallback + +#### Timezone Operations +```rust +// All internal calculations in UTC +// Convert to display timezone only for: +// - Nice domain calculations +// - Tick generation +// - Label formatting +``` + +### Scale Operations + +#### Forward Scaling (temporal → numeric) +```rust +scale(dates: &ArrayRef) -> Result +// Convert temporal values to normalized [0, 1] range +// Then map to configured output range +``` + +#### Inverse Scaling (numeric → temporal) +```rust +invert(values: &ArrayRef) -> Result +// Map from output range to [0, 1] +// Convert to temporal values preserving original type +``` + +#### Tick Generation +```rust +ticks(count: Option) -> Result +// Generate ~count tick positions +// Return array of same temporal type as domain +// Positions align with calendar boundaries +``` + +#### Tick Formatting +```rust +tick_format(count: Option) -> Result +// Return timezone-aware formatter +// Adapts format to tick interval: +// - Years: "2024" +// - Months: "Jan 2024" +// - Days: "Jan 15" +// - Hours: "3:00 PM" +// - Minutes: "3:45 PM" +// - Seconds: "3:45:30" +``` + +### DST and Calendar Edge Cases + +#### DST Transitions +- **Spring forward**: Skip non-existent hour (2 AM → 3 AM) +- **Fall back**: Disambiguate repeated hour using offset +- **Tick generation**: Adjust spacing to maintain visual uniformity + +#### Variable Length Periods +- **Months**: 28-31 days handled by calendar arithmetic +- **Years**: Leap years handled automatically +- **Days**: 23-25 hours during DST handled by timezone library + +### Performance Requirements +1. **Scale 1M temporal values** in < 100ms +2. **Generate ticks** for any domain in < 10ms +3. **Batch timezone conversions** for efficiency +4. **Cache computed tick positions** per scale instance + +### Error Handling +```rust +enum TimeScaleError { + InvalidTimezone(String), + InvalidInterval(String), + InvalidTemporalType(DataType), + TimezoneConversionError(String), + DomainRangeError(String), +} +``` + +### Examples + +#### Financial Time Series +```rust +// Market hours with minute ticks +let scale = TimeScale::configured((market_open, market_close), (0.0, width)) + .with_option("timezone", "America/New_York") + .with_option("interval", "30 minutes") + .with_option("nice", false); // Exact market hours +``` + +#### Multi-Year Climate Data +```rust +// Monthly averages over decades +let scale = TimeScale::configured((start_date, end_date), (0.0, height)) + .with_option("timezone", "UTC") + .with_option("interval", "year") + .with_option("nice", true); +``` + +#### International Event Timeline +```rust +// Events in different timezones displayed in local time +let scale = TimeScale::configured((first_event, last_event), (0.0, width)) + .with_option("timezone", "local") + .with_option("nice", 10.0); // ~10 automatic ticks +``` + +## Architecture Design + +### Core Components + +#### 1. TimeScale Structure +```rust +pub struct TimeScale { + // Uses existing ConfiguredScale pattern +} + +impl TimeScale { + pub fn configured(domain: (ArrayRef, ArrayRef), range: (f32, f32)) -> ConfiguredScale + pub fn configured_color(domain: (ArrayRef, ArrayRef), range: I) -> ConfiguredScale +} +``` + +#### 2. Temporal Handler Pattern +```rust +enum TemporalHandler { + Date(DateHandler), + Timestamp(TimestampHandler), + TimestampTz(TimestampTzHandler), +} + +trait TemporalOperations { + fn nice_domain(&self, domain: (i64, i64), count: f32) -> Result<(i64, i64), AvengerScaleError>; + fn generate_ticks(&self, domain: (i64, i64), count: f32) -> Result, AvengerScaleError>; + fn to_display_timezone(&self, timestamp: i64) -> Result; +} +``` + +## Implementation Plan + +### Phase 1: Foundation & Core Infrastructure +- [x] **1.1** Create TimeScale module structure + - [x] Add `avenger-scales/src/scales/time.rs` + - [x] Add TimeScale struct with ScaleImpl trait + - [x] Add basic configuration options (timezone, nice, unit preferences) + +- [x] **1.2** Add temporal dependencies + - [x] Add chrono-tz to Cargo.toml for timezone support + - [x] Add chrono for temporal arithmetic + - [x] Verify Arrow temporal_conversions integration + +- [x] **1.3** Implement temporal type detection + - [x] Create TemporalHandler enum and traits + - [x] Add automatic type detection from ArrayRef + - [x] Implement handler factory pattern + +### Phase 2: Core Temporal Operations +- [x] **2.1** Domain handling for each temporal type + - [x] DateHandler: Date32/Date64 → chrono::NaiveDate + - [x] TimestampHandler: Timestamp(unit, None) → chrono::NaiveDateTime + - [x] TimestampTzHandler: Timestamp(unit, Some(tz)) → chrono::DateTime + +- [x] **2.2** Arrow integration layer + - [x] Wrapper functions for arrow::temporal_conversions + - [x] Efficient array-to-temporal conversions + - [x] Handle different temporal units (s, ms, μs, ns) + +- [x] **2.3** Timezone configuration system + - [x] Parse IANA timezone strings with chrono-tz + - [x] Default timezone handling (defaults to UTC) + - [x] Timezone validation and error handling + +### Phase 3: Calendar-Aware Algorithms +- [x] **3.1** Nice interval generation (inspired by D3) + - [x] Calendar interval hierarchy: ms → s → min → hour → day → week → month → year + - [x] Smart interval selection based on domain span + - [x] Handle DST transitions in interval calculations + - [x] Support for custom interval preferences + +- [x] **3.2** Tick generation algorithm + - [x] Implement D3-style tick generation with calendar awareness + - [x] Generate human-readable tick positions (midnight, first of month, etc.) + - [x] Support for custom tick counts and intervals + - [x] Handle edge cases (DST transitions) + - [ ] Handle edge cases (leap years, different calendar months) + +- [x] **3.3** Domain normalization for time + - [x] Extend domain to nice round temporal values + - [ ] Zero option interpretation for temporal (not applicable - will document) + - [x] Calendar-boundary alignment + +### Phase 4: Scale Operations Implementation +- [x] **4.1** Core scaling operations + - [x] Implement scale() method for temporal → numeric mapping + - [x] Implement invert() method for numeric → temporal mapping + - [x] Handle timezone conversions during scaling + +- [x] **4.2** Temporal arithmetic + - [x] Calendar-aware domain calculations + - [x] Handle different temporal units consistently + - [ ] Support for relative temporal operations (future enhancement) + +- [x] **4.3** Error handling and edge cases + - [x] Invalid timezone specifications + - [x] Malformed temporal data + - [x] DST transition edge cases + - [ ] Out-of-range temporal values + +### Phase 5: Advanced Features +- [ ] **5.1** Formatting and display + - [ ] Timezone-aware formatting for ticks and labels + - [ ] Locale-aware temporal formatting options + - [ ] Integration with existing Formatter system + - [ ] Implement tick_format() method + +- [ ] **5.3** Additional temporal types support (future enhancement) + - [ ] Duration arrays if needed + - [ ] Time-only arrays (Time32, Time64) + - [ ] Interval arrays (future Arrow addition) + +### Phase 6: Integration & Testing +- [x] **6.1** ConfiguredScale integration + - [x] Add TimeScale to scale type enumeration + - [x] Update normalize() method for temporal domains + - [x] Ensure consistent API across all scale types + +- [ ] **6.2** Comprehensive testing + - [x] Unit tests for each temporal handler + - [x] Integration tests with different Arrow temporal types + - [x] Timezone conversion accuracy tests + - [x] DST transition edge case tests (US timezones) + - [ ] Additional DST tests (European, Southern hemisphere) + - [ ] Performance benchmarks vs existing solutions + +- [ ] **6.3** Examples and documentation + - [ ] Create temporal scale examples + - [ ] Document timezone configuration options + - [ ] Compare with Vega/D3 time scale behavior + - [ ] Migration guide for users + +## Configuration API Design + +### Scale Configuration Options +```rust +TimeScale::configured(temporal_domain, numeric_range) + .with_option("timezone", "America/New_York") // IANA timezone + .with_option("nice", true) // Calendar-aware nice intervals + .with_option("unit_preference", "auto") // Preferred time units for ticks + .with_option("locale", "en-US") // Locale for formatting +``` + +### Supported Configuration Values +- **timezone**: + - IANA timezone strings ("America/New_York", "Europe/London", "UTC") + - "local" for system timezone + - "utc" for UTC (default for compatibility) +- **nice**: boolean or numeric tick count hint +- **unit_preference**: "auto", "calendar" (prefer month/year), "metric" (prefer powers of 10) +- **zero**: Not applicable for time scales (error or ignore) + +## Technical Considerations + +### Arrow Integration Strategy +1. **Prioritize Arrow kernels** where available (basic conversions) +2. **Use chrono-tz for gaps** (timezone operations, calendar arithmetic) +3. **Efficient interop** between Arrow arrays and chrono types +4. **Minimize copying** - work with Arrow data directly when possible + +### Timezone Handling Philosophy +1. **Always explicit** - no implicit timezone assumptions +2. **UTC internal storage** - convert for display only +3. **Preserve original timezone info** from Timestamptz arrays +4. **Configurable display timezone** - independent of data timezone + +### Performance Priorities +1. **Batch operations** on Arrow arrays +2. **Cache timezone computations** where possible +3. **Lazy evaluation** of expensive operations +4. **Zero-copy** operations where Arrow supports it + +## Future Extensions + +### Potential Enhancements +- [ ] **Custom calendar systems** (fiscal years, academic calendars) +- [ ] **Business time calculations** (excluding weekends/holidays) +- [ ] **Streaming temporal data** support +- [ ] **Temporal aggregation** operations (daily/monthly rollups) +- [ ] **Time zone-aware data export** for different formats + +### Arrow Evolution +- [ ] Monitor Arrow temporal kernel development +- [ ] Migrate to native Arrow operations as they become available +- [ ] Contribute temporal improvements back to Arrow ecosystem + +## Success Metrics + +### Functionality Goals +- [ ] Support all Arrow temporal types (Date32/64, Timestamp*, TimestampTz) +- [ ] Handle 50+ major IANA timezones correctly +- [ ] Generate human-readable ticks for domains spanning microseconds to decades +- [ ] Handle DST transitions without visual artifacts + +### Performance Goals +- [ ] Scale 1M+ temporal values in <100ms +- [ ] Generate ticks for any reasonable domain in <10ms +- [ ] Memory usage comparable to existing numeric scales + +### API Goals +- [ ] Zero-breaking-change integration with existing avenger-scales +- [ ] API simpler than manual chrono + Arrow temporal handling +- [ ] Clear error messages for timezone/temporal issues +- [ ] Compatible with existing scale patterns (normalize, with_option, etc.) + +## Dependencies + +### Required Crates +```toml +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.8" +# arrow already included in project +``` + +### Optional Future Dependencies +```toml +# For advanced calendar systems +icu_calendar = "1.0" # If custom calendar support needed +# For business time calculations +chrono-business = "0.1" # If business time features needed +``` + +--- + +## Complete DST Support Implementation Plan + +### Overview +Implement comprehensive Daylight Saving Time (DST) handling to ensure the implementation never panics and handles all edge cases correctly. + +### Core Principles +1. **Never Panic**: All time operations must be safe and handle edge cases gracefully +2. **Predictable Behavior**: Users should understand how ambiguous times are resolved +3. **Visual Accuracy**: Tick spacing should reflect actual time duration, not nominal +4. **Explicit Choices**: When ambiguity exists, make explicit, documented choices + +### DST Phase 1: Safe Time Construction Infrastructure + +#### 1.1 Create Safe Time Construction Wrappers +- [x] Create `SafeDateTime` wrapper module with panic-free operations + ```rust + // safe_time.rs + pub fn safe_with_hour(dt: DateTime, hour: u32) -> Result, DstError> + pub fn safe_with_time(dt: DateTime, hour: u32, min: u32, sec: u32) -> Result, DstError> + pub fn safe_and_hms(date: Date, hour: u32, min: u32, sec: u32) -> Result, DstError> + ``` + +#### 1.2 DST Transition Detection +- [x] Implement DST transition detection utilities + ```rust + pub fn is_dst_transition_date(date: Date) -> bool + pub fn find_transition_hours(date: Date) -> DstTransition + pub enum DstTransition { + None, + SpringForward { missing_start: u32, missing_end: u32 }, + FallBack { repeated_start: u32, repeated_end: u32 } + } + ``` + +#### 1.3 Ambiguous Time Resolution Strategy +- [x] Implement consistent ambiguous time resolution + ```rust + pub enum DstStrategy { + EarliestOffset, // For fall-back, use first occurrence + LatestOffset, // For fall-back, use second occurrence + PreferStandard, // Prefer standard time over DST + PreferDaylight, // Prefer DST over standard time + } + ``` + +### DST Phase 2: Update Interval Operations + +#### 2.1 Fix TimeInterval::floor() for DST +- [x] Update floor to handle non-existent times +- [x] Handle spring-forward gap safely + +#### 2.2 Fix TimeInterval::ceil() for DST +- [x] Ensure ceil handles DST transitions correctly +- [x] Test with domains ending in non-existent hours + +#### 2.3 Fix TimeInterval::offset() for DST +- [x] Make offset operations DST-aware +- [x] Add hours in local time, not by duration + +#### 2.4 Duration-Based Intervals +- [x] Add actual duration calculation for intervals +- [x] Account for DST changes in duration + +### DST Phase 3: Fix Tick Generation + +#### 3.1 DST-Aware Tick Generation +- [x] Update `generate_temporal_ticks` to handle transitions +- [x] Handle spring-forward gaps gracefully +- [x] Skip to next valid time when needed + +#### 3.2 Tick Spacing Validation +- [x] Add tick spacing validation to ensure visual consistency +- [x] Implement adaptive tick generation for DST transitions + +#### 3.3 Sub-hour Interval Handling +- [x] Special handling for minute/second intervals during transitions +- [x] Ensure no duplicate timestamps in fall-back hour + +### DST Phase 4: Fix Nice Domain Calculation + +#### 4.1 Safe Nice Boundaries +- [x] Update `compute_nice_temporal_bounds` for DST safety +- [x] Handle boundaries in non-existent times + +#### 4.2 Domain Validation +- [x] Validate that nice domains don't create impossible ranges +- [x] Handle domains entirely within DST transitions + +### DST Phase 5: Update Scale Operations + +#### 5.1 Domain Normalization +- [x] Ensure normalized domains are DST-safe +- [x] Handle domains spanning DST transitions + +#### 5.2 Scale Interpolation +- [x] Use actual duration for interpolation, not nominal +- [x] Account for DST transitions in the domain +- [x] Implemented compute_actual_duration_millis() for DST-aware duration calculation +- [x] Updated scale() method to use actual duration when DST transitions occur + +#### 5.3 Inversion Accuracy +- [x] Ensure invert() handles DST transitions correctly +- [x] Test inversion at DST boundaries +- [x] Implemented iterative refinement for DST-aware inversion +- [x] Added comprehensive tests for spring-forward and fall-back scenarios + +### DST Phase 6: Comprehensive Testing + +#### 6.1 DST Transition Tests +- [x] Test all US timezone transitions (EST, CST, MST, PST) +- [ ] Test European transitions (CET, BST) +- [ ] Test Southern hemisphere (AEST, NZST) +- [x] Test non-DST timezones (JST, IST, UTC) + +#### 6.2 Edge Case Tests +- [x] Domain starting in spring-forward gap +- [x] Domain ending in fall-back overlap +- [ ] Single-hour domain during DST transition +- [x] Tick generation across multiple DST transitions +- [x] Scale/invert operations across DST transitions + +#### 6.3 Property-Based Tests +- [x] No panic property: all operations must return Result or safe default +- [x] Monotonicity: ticks must be strictly increasing +- [x] Roundtrip: scale → invert → scale should preserve values (tested in test_dst_scale_operations) +- [x] Duration accuracy: visual spacing matches actual time (verified in DST scale tests) + +#### 6.4 Historical DST Tests +- [ ] Test historical DST rule changes +- [ ] Test future DST dates (if rules known) + +### DST Phase 7: Performance Optimization + +#### 7.1 DST Transition Caching +- [ ] Cache DST transition points per timezone +- [ ] Avoid repeated transition calculations + +#### 7.2 Batch Operations +- [ ] Optimize batch tick generation across DST +- [ ] Minimize timezone conversions + +### DST Phase 8: Documentation and Examples + +#### 8.1 DST Behavior Documentation +- [ ] Document how each ambiguous case is resolved +- [ ] Explain visual spacing during transitions +- [ ] Provide examples for common DST scenarios + +#### 8.2 Configuration Options +- [ ] Add DST handling configuration options + ```rust + .with_option("dst_strategy", "earliest") // or "latest", "standard", "daylight" + .with_option("strict_dst", true) // fail vs adjust for invalid times + ``` + +#### 8.3 Migration Guide +- [ ] Document any breaking changes +- [ ] Provide upgrade path for existing users + +### DST Success Criteria + +- [ ] Zero panics in all DST scenarios +- [ ] All tests pass with DST-observing timezones +- [ ] Clear documentation of DST behavior +- [ ] Performance within 10% of non-DST operations +- [ ] No breaking changes to existing API (only additions) + +--- + +## Summary of Remaining Work + +### High Priority - Core Functionality +1. **Formatting & Display (Phase 5.1)** + - [ ] Implement tick_format() method for timezone-aware date/time formatting + - [ ] Integration with existing Formatter system + - [ ] Locale-aware formatting options + +2. **Edge Case Handling** + - [ ] Out-of-range temporal values + - [ ] Single-hour domain during DST transition test + - [ ] Handle leap years in calendar arithmetic + - [ ] Handle different calendar month lengths + +### Medium Priority - Testing & Documentation +3. **Additional Testing (Phase 6)** + - [ ] European timezone DST tests (CET, BST) + - [ ] Southern hemisphere DST tests (AEST, NZST) + - [ ] Performance benchmarks vs existing solutions + - [ ] Historical DST rule change tests + +4. **Documentation (Phase 6.3 & 8)** + - [ ] Create temporal scale examples + - [ ] Document timezone configuration options + - [ ] DST behavior documentation + - [ ] Migration guide for users + - [ ] Compare with Vega/D3 time scale behavior + +### Low Priority - Optimization & Future Features +5. **Performance Optimization (Phase 7)** + - [ ] DST transition caching + - [ ] Batch operation optimization + - [ ] Minimize timezone conversions + +6. **Future Enhancements** + - [ ] Support for Time32/Time64 arrays + - [ ] Duration arrays + - [ ] Interval arrays + - [ ] Custom calendar systems + - [ ] Business time calculations + - [ ] Relative temporal operations + +--- + +**Status**: Implementation In Progress +**Completed**: Core TimeScale functionality with timezone support, nice intervals, tick generation, invert, and comprehensive DST support (Phases 1-5) +**Remaining**: Formatting/display, additional edge cases, testing, documentation, and optimization +**Risk Level**: Low (core functionality complete, remaining work is enhancement and polish) \ No newline at end of file From 11ecd929d152a18636fb34380f8505cc57c099f3 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Thu, 10 Jul 2025 12:55:09 -0700 Subject: [PATCH 14/29] fix: Remove time scale plan from git tracking The plan should remain in the ignored tasks/ directory, not tracked in git. --- .../docs/time-scale-implementation-plan.md | 871 ------------------ 1 file changed, 871 deletions(-) delete mode 100644 avenger-scales/docs/time-scale-implementation-plan.md diff --git a/avenger-scales/docs/time-scale-implementation-plan.md b/avenger-scales/docs/time-scale-implementation-plan.md deleted file mode 100644 index 1fb564a9..00000000 --- a/avenger-scales/docs/time-scale-implementation-plan.md +++ /dev/null @@ -1,871 +0,0 @@ -# Time Scale Implementation Plan - -## Overview - -This plan outlines the implementation of comprehensive time scale support for avenger-scales, addressing limitations in existing visualization libraries (Vega, D3) by providing configurable timezone support and unified handling of all Arrow temporal types. - -## Key Goals - -- [ ] **Unified Time Scale**: One scale type that works across Date, Timestamp, and Timestamptz Arrow types -- [ ] **Configurable Timezone Support**: Beyond Vega's local/UTC limitation - support arbitrary IANA timezones -- [ ] **Arrow-First Architecture**: Leverage Arrow kernels where possible, fallback to chrono-tz when needed -- [ ] **Calendar-Aware Operations**: Smart tick generation, nice intervals, and DST handling -- [ ] **Performance**: Efficient temporal computations using Arrow's columnar approach - -## Research Findings - -### Arrow Temporal Capabilities -- **Available**: Basic temporal conversions (timestamp_*_to_datetime functions) -- **Available**: Temporal array types (Date32/64, Timestamp*, Time*) -- **Available**: Limited timezone module (arrow::array::timezone with Tz struct) -- **Missing**: Calendar arithmetic, nice interval generation, timezone-aware operations -- **Missing**: Comprehensive temporal compute kernels - -### Required External Dependencies -- **chrono-tz**: IANA timezone database, DST handling, timezone conversions -- **chrono**: Core temporal arithmetic and calendar operations - -### D3/Vega Time Scale Algorithm Analysis - -#### D3 Time Interval Hierarchy -D3 provides a comprehensive hierarchy of time intervals for tick generation and nice domain calculations: - -**Interval Progression**: millisecond → second → minute → hour → day → week → month → year - -**Key Intervals with Step Values**: -- **Milliseconds**: 1, 5, 15, 25, 50, 100, 250, 500ms -- **Seconds**: 1, 5, 15, 30s -- **Minutes**: 1, 5, 15, 30min -- **Hours**: 1, 3, 6, 12h -- **Days**: 1, 2d -- **Weeks**: 1w (7 days) -- **Months**: 1, 3m (quarters) -- **Years**: 1y - -#### D3 Nice Algorithm -```javascript -scale.nice = function(interval) { - var d = domain(); - if (!interval || typeof interval.range !== "function") { - interval = tickInterval(d[0], d[d.length - 1], interval == null ? 10 : interval); - } - return interval ? domain(nice(d, interval)) : scale; -}; -``` - -**Algorithm Steps**: -1. **Auto-interval selection**: If no interval specified, `tickInterval()` selects appropriate interval based on domain span and target tick count (default 10) -2. **Interval validation**: Ensures interval has required methods (`range()` function) -3. **Domain adjustment**: Applies interval-based nice transformation to extend domain to clean boundaries - -#### D3 Tick Generation Algorithm -**Interval Selection Strategy**: -1. Calculate target interval duration: `(domain_end - domain_start) / desired_tick_count` -2. Use binary search (`bisector`) to find closest predefined interval from hierarchy -3. Apply interval's `range()` method to generate tick positions -4. Handle edge cases (ascending/descending domains, timezone boundaries) - -**Calendar Boundary Alignment**: -- **Milliseconds/Seconds**: Round to clean decimal boundaries -- **Minutes/Hours**: Align to hour boundaries (e.g., :00, :15, :30, :45) -- **Days**: Align to midnight boundaries -- **Weeks**: Align to week start (Sunday/Monday depending on locale) -- **Months**: Align to first day of month -- **Years**: Align to January 1st - -#### D3 Time Interval Implementation Pattern -Each interval (year, month, day, etc.) implements: -- `floor(date)`: Round down to interval boundary -- `ceil(date)`: Round up to next interval boundary -- `offset(date, step)`: Add/subtract intervals -- `range(start, stop)`: Generate sequence of interval boundaries -- `every(step)`: Create interval with custom step size - -**Calendar Arithmetic Handling**: -- **DST Transitions**: Days can be 23-25 hours; handled by checking timezone offsets -- **Variable Month Lengths**: 28-31 days handled by calendar-aware date arithmetic -- **Leap Years**: Handled by JavaScript Date object's built-in calendar logic -- **Week Boundaries**: Supports different week start days (Sunday through Saturday) - -#### Vega Enhancements Over D3 -- **UTC vs Local Time**: Explicit `time` vs `utc` scale types -- **TimeUnit Integration**: Connects with Vega-Lite's `timeUnit` encoding -- **Known Limitations**: - - "Nice" ticks can behave unexpectedly when domain falls between nice boundaries - - Limited timezone support (only local and UTC) - - Tooltip timezone display issues - -#### Implementation Insights for Avenger-Scales -1. **Predefined Interval Hierarchy**: Essential for automatic interval selection -2. **Binary Search Selection**: Efficient algorithm for choosing appropriate intervals -3. **Calendar-Aware Boundaries**: Critical for human-readable tick positions -4. **DST Handling**: Must account for variable day lengths in local time -5. **Configurable Week Start**: Important for international applications -6. **UTC/Local Separation**: Clear distinction needed for timezone handling - -### Additional Library Research - -#### Pandas Time Series Resampling -**Algorithm Strategy**: -- **Frequency Offset System**: Uses string-based frequency specifications ("D", "H", "M", "5Min", etc.) -- **Parameterized Offsets**: Support for custom week starts, month ends, business days -- **Label/Closed Control**: Flexible bin edge labeling and boundary inclusion rules -- **Origin/Offset Parameters**: Fine-grained control over bin alignment and starting points - -**Key Features**: -- **DateOffset Classes**: Extensible system for defining custom time intervals -- **Resampling Types**: Both upsampling (interpolation) and downsampling (aggregation) -- **Business Calendar Support**: Built-in handling of business days, holidays -- **Performance**: Highly optimized for large time series datasets - -#### Matplotlib Date Locators -**Comprehensive Locator System**: -- **AutoDateLocator**: Intelligent automatic interval selection -- **Specific Locators**: YearLocator, MonthLocator, WeekdayLocator, DayLocator, HourLocator, etc. -- **RRuleLocator**: Complex rule-based patterns ("last Friday of each month") -- **ConciseDateFormatter**: Context-aware date formatting - -**Algorithm Features**: -- **Multi-scale Approach**: Different locators for different zoom levels -- **Calendar Intelligence**: Weekday-aware positioning, month-end handling -- **Minor/Major Tick Coordination**: Hierarchical tick systems -- **Date Range Constraints**: Support for years 0001-9999 - -#### ggplot2 (R) Date/Time Scales -**Break Generation Strategy**: -- **Smart Defaults**: Automatic sensible major/minor tick placement -- **Calendar Units**: "sec", "min", "hour", "day", "week", "month", "year" with multipliers -- **Priority System**: `date_breaks` > `breaks`, `date_labels` > `labels` -- **Offset Support**: Ability to shift break alignment - -**Advanced Features**: -- **Multiple Scale Types**: `scale_*_date`, `scale_*_datetime`, `scale_*_time` -- **Flexible Formatting**: Integration with strftime() format codes -- **Break Positioning**: Control over label alignment and tick spacing - -#### Plotly Time Axis -**Dynamic Adaptation**: -- **Zoom-aware Formatting**: Different formats for different zoom levels -- **tickformatstops**: Multi-level formatting based on visible range -- **Period vs Instant Mode**: Label positioning at period centers vs boundaries -- **Calendar Interval Support**: dtick with calendar-aware stepping - -**Limitations Identified**: -- **Limited Manual Control**: Less granular control compared to matplotlib -- **Automatic Behavior**: Sometimes unpredictable tick placement - -#### Observable Plot Time Scales -**D3-Based Foundation**: -- **Inherited D3 Logic**: Builds on D3's proven time scale algorithms -- **Plot-Specific Enhancements**: Integration with Plot's mark system -- **Interval Transform**: Built-in support for time-based grouping -- **UTC/Local Options**: Clear separation of timezone handling - -#### Polars Time Series -**Performance-Focused Approach**: -- **Dynamic Grouping**: `group_by_dynamic` for time-based aggregation -- **Interval Syntax**: Composable interval strings ("1h30m") -- **Sorting Optimization**: Fast-path operations for sorted temporal data -- **Upsampling/Downsampling**: Efficient frequency conversion - -**Modern Features**: -- **Lazy Evaluation**: Deferred computation for large datasets -- **Native Temporal Types**: Built-in Date/Datetime with multiple precisions -- **Activity-Based Sampling**: Support for non-uniform time intervals - -#### DuckDB Temporal Functions -**Comprehensive Calendar System**: -- **ICU Integration**: International calendar and timezone support -- **Window Functions**: Tumbling, hopping, sliding temporal windows -- **Interval Arithmetic**: Three-component intervals (months, days, microseconds) -- **Range Generation**: Built-in functions for creating time sequences - -**Enterprise Features**: -- **Non-Gregorian Calendars**: Support for alternative calendar systems -- **DST-Aware Binning**: Proper handling of timezone transitions -- **Temporal Analytics**: Advanced time-based aggregation functions - -#### Apache Arrow Temporal Kernels -**Low-Level Foundations**: -- **SIMD Optimization**: 64-byte padding for vectorized operations -- **Timezone Support**: Per-kernel timezone awareness (with known limitations) -- **Type Casting**: Temporal type conversions with resolution handling -- **Performance Focus**: Columnar operations for high-throughput scenarios - -**Current Limitations**: -- **Limited High-Level Functions**: Mostly component extraction, less calendar logic -- **Missing Features**: No built-in tick generation or nice interval algorithms -- **Work in Progress**: Active development of temporal arithmetic kernels - -### Synthesis for Avenger-Scales - -**Best Practices from Research**: -1. **Multi-Library Approach**: Combine strengths from different libraries -2. **Pandas-Style Frequency Strings**: Proven, intuitive interval specification -3. **Matplotlib's Locator Hierarchy**: Comprehensive automatic/manual control -4. **D3's Calendar Intelligence**: Human-readable boundary alignment -5. **DuckDB's ICU Integration**: International calendar and timezone support -6. **Arrow's Performance Foundation**: SIMD-optimized columnar operations - -**Competitive Advantages to Implement**: -1. **Configurable Timezone Support**: Beyond Vega's local/UTC limitation -2. **Arrow-Native Performance**: Leverage columnar optimizations -3. **Unified API**: Handle Date/Timestamp/TimestampTz seamlessly -4. **International Support**: ICU-style calendar and timezone awareness -5. **Business Calendar Extensions**: Support for custom calendars and holidays - -## Concrete Specification for Avenger-Scales Time Scale - -### Supported Temporal Types -1. **Arrow Date32**: Days since Unix epoch (1970-01-01) -2. **Arrow Date64**: Milliseconds since Unix epoch -3. **Arrow Timestamp**: With units (s, ms, μs, ns) and optional timezone -4. **Arrow TimestampTz**: Timestamp with timezone metadata - -### Core API - -#### Scale Construction -```rust -// Unified constructor that auto-detects temporal type -TimeScale::configured(domain: (ArrayRef, ArrayRef), range: (f32, f32)) -> ConfiguredScale - -// Examples: -// Date array → numeric range -let scale = TimeScale::configured((date_array_start, date_array_end), (0.0, 100.0)); - -// Timestamp array → color range -let scale = TimeScale::configured_color((timestamp_array_start, timestamp_array_end), &["red", "blue"]); -``` - -#### Configuration Options -```rust -scale - .with_option("timezone", "America/New_York") // Display timezone (IANA string or "local"/"utc") - .with_option("nice", true) // Extend domain to calendar boundaries - .with_option("nice", 10.0) // Target ~10 ticks with nice boundaries - .with_option("interval", "day") // Force specific tick interval - .with_option("interval", "3 hours") // Custom interval with count - .with_option("week_start", "monday") // Week boundary configuration - .with_option("locale", "en-US") // Formatting locale -``` - -### Interval Specification Language - -#### Basic Units -- `"millisecond"` or `"ms"` -- `"second"` or `"s"` -- `"minute"` or `"min"` -- `"hour"` or `"h"` -- `"day"` or `"d"` -- `"week"` or `"w"` -- `"month"` or `"mo"` -- `"year"` or `"y"` - -#### Compound Intervals -- `"3 hours"` - Every 3 hours -- `"15 minutes"` - Every 15 minutes -- `"2 weeks"` - Every 2 weeks -- `"quarter"` - Every 3 months - -#### Special Intervals -- `"business_day"` - Monday-Friday only -- `"month_start"` - First day of each month -- `"month_end"` - Last day of each month -- `"week_start"` - Configured week start day - -### Nice Domain Algorithm - -#### Interval Hierarchy (D3-inspired) -```rust -const INTERVAL_HIERARCHY: &[(Duration, &str, Vec)] = &[ - (Duration::milliseconds(1), "ms", vec![1, 5, 15, 25, 50, 100, 250, 500]), - (Duration::seconds(1), "s", vec![1, 5, 15, 30]), - (Duration::minutes(1), "min", vec![1, 5, 15, 30]), - (Duration::hours(1), "h", vec![1, 3, 6, 12]), - (Duration::days(1), "d", vec![1, 2, 7]), - (Duration::days(7), "w", vec![1]), - (Duration::months(1), "mo", vec![1, 3]), - (Duration::years(1), "y", vec![1, 2, 5, 10, 20, 50, 100]), -]; -``` - -#### Nice Boundary Rules -1. **Milliseconds/Seconds**: Round to clean decimal multiples -2. **Minutes**: Align to :00, :15, :30, :45 -3. **Hours**: Align to hour boundaries -4. **Days**: Align to midnight in display timezone -5. **Weeks**: Align to configured week start -6. **Months**: Align to first of month -7. **Years**: Align to January 1st - -### Tick Generation Algorithm - -#### Automatic Interval Selection -```rust -fn select_interval(domain_span: Duration, target_count: f32) -> Interval { - let target_interval = domain_span / target_count; - // Binary search through INTERVAL_HIERARCHY - // Return closest interval that produces readable ticks -} -``` - -#### Tick Position Rules -1. **Always include nice boundaries** when domain spans them -2. **Respect timezone** for day/week/month boundaries -3. **Handle DST transitions** gracefully (skip/repeat as needed) -4. **Generate uniform spacing** within calendar constraints - -### Timezone Handling - -#### Timezone Resolution Order -1. Explicit scale configuration: `.with_option("timezone", "...")` -2. TimestampTz embedded timezone (if present) -3. System local timezone (if "local" specified) -4. UTC fallback - -#### Timezone Operations -```rust -// All internal calculations in UTC -// Convert to display timezone only for: -// - Nice domain calculations -// - Tick generation -// - Label formatting -``` - -### Scale Operations - -#### Forward Scaling (temporal → numeric) -```rust -scale(dates: &ArrayRef) -> Result -// Convert temporal values to normalized [0, 1] range -// Then map to configured output range -``` - -#### Inverse Scaling (numeric → temporal) -```rust -invert(values: &ArrayRef) -> Result -// Map from output range to [0, 1] -// Convert to temporal values preserving original type -``` - -#### Tick Generation -```rust -ticks(count: Option) -> Result -// Generate ~count tick positions -// Return array of same temporal type as domain -// Positions align with calendar boundaries -``` - -#### Tick Formatting -```rust -tick_format(count: Option) -> Result -// Return timezone-aware formatter -// Adapts format to tick interval: -// - Years: "2024" -// - Months: "Jan 2024" -// - Days: "Jan 15" -// - Hours: "3:00 PM" -// - Minutes: "3:45 PM" -// - Seconds: "3:45:30" -``` - -### DST and Calendar Edge Cases - -#### DST Transitions -- **Spring forward**: Skip non-existent hour (2 AM → 3 AM) -- **Fall back**: Disambiguate repeated hour using offset -- **Tick generation**: Adjust spacing to maintain visual uniformity - -#### Variable Length Periods -- **Months**: 28-31 days handled by calendar arithmetic -- **Years**: Leap years handled automatically -- **Days**: 23-25 hours during DST handled by timezone library - -### Performance Requirements -1. **Scale 1M temporal values** in < 100ms -2. **Generate ticks** for any domain in < 10ms -3. **Batch timezone conversions** for efficiency -4. **Cache computed tick positions** per scale instance - -### Error Handling -```rust -enum TimeScaleError { - InvalidTimezone(String), - InvalidInterval(String), - InvalidTemporalType(DataType), - TimezoneConversionError(String), - DomainRangeError(String), -} -``` - -### Examples - -#### Financial Time Series -```rust -// Market hours with minute ticks -let scale = TimeScale::configured((market_open, market_close), (0.0, width)) - .with_option("timezone", "America/New_York") - .with_option("interval", "30 minutes") - .with_option("nice", false); // Exact market hours -``` - -#### Multi-Year Climate Data -```rust -// Monthly averages over decades -let scale = TimeScale::configured((start_date, end_date), (0.0, height)) - .with_option("timezone", "UTC") - .with_option("interval", "year") - .with_option("nice", true); -``` - -#### International Event Timeline -```rust -// Events in different timezones displayed in local time -let scale = TimeScale::configured((first_event, last_event), (0.0, width)) - .with_option("timezone", "local") - .with_option("nice", 10.0); // ~10 automatic ticks -``` - -## Architecture Design - -### Core Components - -#### 1. TimeScale Structure -```rust -pub struct TimeScale { - // Uses existing ConfiguredScale pattern -} - -impl TimeScale { - pub fn configured(domain: (ArrayRef, ArrayRef), range: (f32, f32)) -> ConfiguredScale - pub fn configured_color(domain: (ArrayRef, ArrayRef), range: I) -> ConfiguredScale -} -``` - -#### 2. Temporal Handler Pattern -```rust -enum TemporalHandler { - Date(DateHandler), - Timestamp(TimestampHandler), - TimestampTz(TimestampTzHandler), -} - -trait TemporalOperations { - fn nice_domain(&self, domain: (i64, i64), count: f32) -> Result<(i64, i64), AvengerScaleError>; - fn generate_ticks(&self, domain: (i64, i64), count: f32) -> Result, AvengerScaleError>; - fn to_display_timezone(&self, timestamp: i64) -> Result; -} -``` - -## Implementation Plan - -### Phase 1: Foundation & Core Infrastructure -- [x] **1.1** Create TimeScale module structure - - [x] Add `avenger-scales/src/scales/time.rs` - - [x] Add TimeScale struct with ScaleImpl trait - - [x] Add basic configuration options (timezone, nice, unit preferences) - -- [x] **1.2** Add temporal dependencies - - [x] Add chrono-tz to Cargo.toml for timezone support - - [x] Add chrono for temporal arithmetic - - [x] Verify Arrow temporal_conversions integration - -- [x] **1.3** Implement temporal type detection - - [x] Create TemporalHandler enum and traits - - [x] Add automatic type detection from ArrayRef - - [x] Implement handler factory pattern - -### Phase 2: Core Temporal Operations -- [x] **2.1** Domain handling for each temporal type - - [x] DateHandler: Date32/Date64 → chrono::NaiveDate - - [x] TimestampHandler: Timestamp(unit, None) → chrono::NaiveDateTime - - [x] TimestampTzHandler: Timestamp(unit, Some(tz)) → chrono::DateTime - -- [x] **2.2** Arrow integration layer - - [x] Wrapper functions for arrow::temporal_conversions - - [x] Efficient array-to-temporal conversions - - [x] Handle different temporal units (s, ms, μs, ns) - -- [x] **2.3** Timezone configuration system - - [x] Parse IANA timezone strings with chrono-tz - - [x] Default timezone handling (defaults to UTC) - - [x] Timezone validation and error handling - -### Phase 3: Calendar-Aware Algorithms -- [x] **3.1** Nice interval generation (inspired by D3) - - [x] Calendar interval hierarchy: ms → s → min → hour → day → week → month → year - - [x] Smart interval selection based on domain span - - [x] Handle DST transitions in interval calculations - - [x] Support for custom interval preferences - -- [x] **3.2** Tick generation algorithm - - [x] Implement D3-style tick generation with calendar awareness - - [x] Generate human-readable tick positions (midnight, first of month, etc.) - - [x] Support for custom tick counts and intervals - - [x] Handle edge cases (DST transitions) - - [ ] Handle edge cases (leap years, different calendar months) - -- [x] **3.3** Domain normalization for time - - [x] Extend domain to nice round temporal values - - [ ] Zero option interpretation for temporal (not applicable - will document) - - [x] Calendar-boundary alignment - -### Phase 4: Scale Operations Implementation -- [x] **4.1** Core scaling operations - - [x] Implement scale() method for temporal → numeric mapping - - [x] Implement invert() method for numeric → temporal mapping - - [x] Handle timezone conversions during scaling - -- [x] **4.2** Temporal arithmetic - - [x] Calendar-aware domain calculations - - [x] Handle different temporal units consistently - - [ ] Support for relative temporal operations (future enhancement) - -- [x] **4.3** Error handling and edge cases - - [x] Invalid timezone specifications - - [x] Malformed temporal data - - [x] DST transition edge cases - - [ ] Out-of-range temporal values - -### Phase 5: Advanced Features -- [ ] **5.1** Formatting and display - - [ ] Timezone-aware formatting for ticks and labels - - [ ] Locale-aware temporal formatting options - - [ ] Integration with existing Formatter system - - [ ] Implement tick_format() method - -- [ ] **5.3** Additional temporal types support (future enhancement) - - [ ] Duration arrays if needed - - [ ] Time-only arrays (Time32, Time64) - - [ ] Interval arrays (future Arrow addition) - -### Phase 6: Integration & Testing -- [x] **6.1** ConfiguredScale integration - - [x] Add TimeScale to scale type enumeration - - [x] Update normalize() method for temporal domains - - [x] Ensure consistent API across all scale types - -- [ ] **6.2** Comprehensive testing - - [x] Unit tests for each temporal handler - - [x] Integration tests with different Arrow temporal types - - [x] Timezone conversion accuracy tests - - [x] DST transition edge case tests (US timezones) - - [ ] Additional DST tests (European, Southern hemisphere) - - [ ] Performance benchmarks vs existing solutions - -- [ ] **6.3** Examples and documentation - - [ ] Create temporal scale examples - - [ ] Document timezone configuration options - - [ ] Compare with Vega/D3 time scale behavior - - [ ] Migration guide for users - -## Configuration API Design - -### Scale Configuration Options -```rust -TimeScale::configured(temporal_domain, numeric_range) - .with_option("timezone", "America/New_York") // IANA timezone - .with_option("nice", true) // Calendar-aware nice intervals - .with_option("unit_preference", "auto") // Preferred time units for ticks - .with_option("locale", "en-US") // Locale for formatting -``` - -### Supported Configuration Values -- **timezone**: - - IANA timezone strings ("America/New_York", "Europe/London", "UTC") - - "local" for system timezone - - "utc" for UTC (default for compatibility) -- **nice**: boolean or numeric tick count hint -- **unit_preference**: "auto", "calendar" (prefer month/year), "metric" (prefer powers of 10) -- **zero**: Not applicable for time scales (error or ignore) - -## Technical Considerations - -### Arrow Integration Strategy -1. **Prioritize Arrow kernels** where available (basic conversions) -2. **Use chrono-tz for gaps** (timezone operations, calendar arithmetic) -3. **Efficient interop** between Arrow arrays and chrono types -4. **Minimize copying** - work with Arrow data directly when possible - -### Timezone Handling Philosophy -1. **Always explicit** - no implicit timezone assumptions -2. **UTC internal storage** - convert for display only -3. **Preserve original timezone info** from Timestamptz arrays -4. **Configurable display timezone** - independent of data timezone - -### Performance Priorities -1. **Batch operations** on Arrow arrays -2. **Cache timezone computations** where possible -3. **Lazy evaluation** of expensive operations -4. **Zero-copy** operations where Arrow supports it - -## Future Extensions - -### Potential Enhancements -- [ ] **Custom calendar systems** (fiscal years, academic calendars) -- [ ] **Business time calculations** (excluding weekends/holidays) -- [ ] **Streaming temporal data** support -- [ ] **Temporal aggregation** operations (daily/monthly rollups) -- [ ] **Time zone-aware data export** for different formats - -### Arrow Evolution -- [ ] Monitor Arrow temporal kernel development -- [ ] Migrate to native Arrow operations as they become available -- [ ] Contribute temporal improvements back to Arrow ecosystem - -## Success Metrics - -### Functionality Goals -- [ ] Support all Arrow temporal types (Date32/64, Timestamp*, TimestampTz) -- [ ] Handle 50+ major IANA timezones correctly -- [ ] Generate human-readable ticks for domains spanning microseconds to decades -- [ ] Handle DST transitions without visual artifacts - -### Performance Goals -- [ ] Scale 1M+ temporal values in <100ms -- [ ] Generate ticks for any reasonable domain in <10ms -- [ ] Memory usage comparable to existing numeric scales - -### API Goals -- [ ] Zero-breaking-change integration with existing avenger-scales -- [ ] API simpler than manual chrono + Arrow temporal handling -- [ ] Clear error messages for timezone/temporal issues -- [ ] Compatible with existing scale patterns (normalize, with_option, etc.) - -## Dependencies - -### Required Crates -```toml -[dependencies] -chrono = { version = "0.4", features = ["serde"] } -chrono-tz = "0.8" -# arrow already included in project -``` - -### Optional Future Dependencies -```toml -# For advanced calendar systems -icu_calendar = "1.0" # If custom calendar support needed -# For business time calculations -chrono-business = "0.1" # If business time features needed -``` - ---- - -## Complete DST Support Implementation Plan - -### Overview -Implement comprehensive Daylight Saving Time (DST) handling to ensure the implementation never panics and handles all edge cases correctly. - -### Core Principles -1. **Never Panic**: All time operations must be safe and handle edge cases gracefully -2. **Predictable Behavior**: Users should understand how ambiguous times are resolved -3. **Visual Accuracy**: Tick spacing should reflect actual time duration, not nominal -4. **Explicit Choices**: When ambiguity exists, make explicit, documented choices - -### DST Phase 1: Safe Time Construction Infrastructure - -#### 1.1 Create Safe Time Construction Wrappers -- [x] Create `SafeDateTime` wrapper module with panic-free operations - ```rust - // safe_time.rs - pub fn safe_with_hour(dt: DateTime, hour: u32) -> Result, DstError> - pub fn safe_with_time(dt: DateTime, hour: u32, min: u32, sec: u32) -> Result, DstError> - pub fn safe_and_hms(date: Date, hour: u32, min: u32, sec: u32) -> Result, DstError> - ``` - -#### 1.2 DST Transition Detection -- [x] Implement DST transition detection utilities - ```rust - pub fn is_dst_transition_date(date: Date) -> bool - pub fn find_transition_hours(date: Date) -> DstTransition - pub enum DstTransition { - None, - SpringForward { missing_start: u32, missing_end: u32 }, - FallBack { repeated_start: u32, repeated_end: u32 } - } - ``` - -#### 1.3 Ambiguous Time Resolution Strategy -- [x] Implement consistent ambiguous time resolution - ```rust - pub enum DstStrategy { - EarliestOffset, // For fall-back, use first occurrence - LatestOffset, // For fall-back, use second occurrence - PreferStandard, // Prefer standard time over DST - PreferDaylight, // Prefer DST over standard time - } - ``` - -### DST Phase 2: Update Interval Operations - -#### 2.1 Fix TimeInterval::floor() for DST -- [x] Update floor to handle non-existent times -- [x] Handle spring-forward gap safely - -#### 2.2 Fix TimeInterval::ceil() for DST -- [x] Ensure ceil handles DST transitions correctly -- [x] Test with domains ending in non-existent hours - -#### 2.3 Fix TimeInterval::offset() for DST -- [x] Make offset operations DST-aware -- [x] Add hours in local time, not by duration - -#### 2.4 Duration-Based Intervals -- [x] Add actual duration calculation for intervals -- [x] Account for DST changes in duration - -### DST Phase 3: Fix Tick Generation - -#### 3.1 DST-Aware Tick Generation -- [x] Update `generate_temporal_ticks` to handle transitions -- [x] Handle spring-forward gaps gracefully -- [x] Skip to next valid time when needed - -#### 3.2 Tick Spacing Validation -- [x] Add tick spacing validation to ensure visual consistency -- [x] Implement adaptive tick generation for DST transitions - -#### 3.3 Sub-hour Interval Handling -- [x] Special handling for minute/second intervals during transitions -- [x] Ensure no duplicate timestamps in fall-back hour - -### DST Phase 4: Fix Nice Domain Calculation - -#### 4.1 Safe Nice Boundaries -- [x] Update `compute_nice_temporal_bounds` for DST safety -- [x] Handle boundaries in non-existent times - -#### 4.2 Domain Validation -- [x] Validate that nice domains don't create impossible ranges -- [x] Handle domains entirely within DST transitions - -### DST Phase 5: Update Scale Operations - -#### 5.1 Domain Normalization -- [x] Ensure normalized domains are DST-safe -- [x] Handle domains spanning DST transitions - -#### 5.2 Scale Interpolation -- [x] Use actual duration for interpolation, not nominal -- [x] Account for DST transitions in the domain -- [x] Implemented compute_actual_duration_millis() for DST-aware duration calculation -- [x] Updated scale() method to use actual duration when DST transitions occur - -#### 5.3 Inversion Accuracy -- [x] Ensure invert() handles DST transitions correctly -- [x] Test inversion at DST boundaries -- [x] Implemented iterative refinement for DST-aware inversion -- [x] Added comprehensive tests for spring-forward and fall-back scenarios - -### DST Phase 6: Comprehensive Testing - -#### 6.1 DST Transition Tests -- [x] Test all US timezone transitions (EST, CST, MST, PST) -- [ ] Test European transitions (CET, BST) -- [ ] Test Southern hemisphere (AEST, NZST) -- [x] Test non-DST timezones (JST, IST, UTC) - -#### 6.2 Edge Case Tests -- [x] Domain starting in spring-forward gap -- [x] Domain ending in fall-back overlap -- [ ] Single-hour domain during DST transition -- [x] Tick generation across multiple DST transitions -- [x] Scale/invert operations across DST transitions - -#### 6.3 Property-Based Tests -- [x] No panic property: all operations must return Result or safe default -- [x] Monotonicity: ticks must be strictly increasing -- [x] Roundtrip: scale → invert → scale should preserve values (tested in test_dst_scale_operations) -- [x] Duration accuracy: visual spacing matches actual time (verified in DST scale tests) - -#### 6.4 Historical DST Tests -- [ ] Test historical DST rule changes -- [ ] Test future DST dates (if rules known) - -### DST Phase 7: Performance Optimization - -#### 7.1 DST Transition Caching -- [ ] Cache DST transition points per timezone -- [ ] Avoid repeated transition calculations - -#### 7.2 Batch Operations -- [ ] Optimize batch tick generation across DST -- [ ] Minimize timezone conversions - -### DST Phase 8: Documentation and Examples - -#### 8.1 DST Behavior Documentation -- [ ] Document how each ambiguous case is resolved -- [ ] Explain visual spacing during transitions -- [ ] Provide examples for common DST scenarios - -#### 8.2 Configuration Options -- [ ] Add DST handling configuration options - ```rust - .with_option("dst_strategy", "earliest") // or "latest", "standard", "daylight" - .with_option("strict_dst", true) // fail vs adjust for invalid times - ``` - -#### 8.3 Migration Guide -- [ ] Document any breaking changes -- [ ] Provide upgrade path for existing users - -### DST Success Criteria - -- [ ] Zero panics in all DST scenarios -- [ ] All tests pass with DST-observing timezones -- [ ] Clear documentation of DST behavior -- [ ] Performance within 10% of non-DST operations -- [ ] No breaking changes to existing API (only additions) - ---- - -## Summary of Remaining Work - -### High Priority - Core Functionality -1. **Formatting & Display (Phase 5.1)** - - [ ] Implement tick_format() method for timezone-aware date/time formatting - - [ ] Integration with existing Formatter system - - [ ] Locale-aware formatting options - -2. **Edge Case Handling** - - [ ] Out-of-range temporal values - - [ ] Single-hour domain during DST transition test - - [ ] Handle leap years in calendar arithmetic - - [ ] Handle different calendar month lengths - -### Medium Priority - Testing & Documentation -3. **Additional Testing (Phase 6)** - - [ ] European timezone DST tests (CET, BST) - - [ ] Southern hemisphere DST tests (AEST, NZST) - - [ ] Performance benchmarks vs existing solutions - - [ ] Historical DST rule change tests - -4. **Documentation (Phase 6.3 & 8)** - - [ ] Create temporal scale examples - - [ ] Document timezone configuration options - - [ ] DST behavior documentation - - [ ] Migration guide for users - - [ ] Compare with Vega/D3 time scale behavior - -### Low Priority - Optimization & Future Features -5. **Performance Optimization (Phase 7)** - - [ ] DST transition caching - - [ ] Batch operation optimization - - [ ] Minimize timezone conversions - -6. **Future Enhancements** - - [ ] Support for Time32/Time64 arrays - - [ ] Duration arrays - - [ ] Interval arrays - - [ ] Custom calendar systems - - [ ] Business time calculations - - [ ] Relative temporal operations - ---- - -**Status**: Implementation In Progress -**Completed**: Core TimeScale functionality with timezone support, nice intervals, tick generation, invert, and comprehensive DST support (Phases 1-5) -**Remaining**: Formatting/display, additional edge cases, testing, documentation, and optimization -**Risk Level**: Low (core functionality complete, remaining work is enhancement and polish) \ No newline at end of file From 4e089e51bfd5523b623dea84ac46b48a89249f01 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Thu, 10 Jul 2025 13:06:37 -0700 Subject: [PATCH 15/29] feat: Implement tick formatting for time scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create TemporalTickFormatter that adapts format based on tick interval - Override scale_to_string() to use custom formatter for temporal values - Format strings change based on interval: years show %Y, days show %b %d, hours show %H:%M - Handle all temporal types: Date32, Date64, Timestamp with/without timezone - Add comprehensive test for tick formatting at different time scales - Integrate with existing coerce/format system by implementing DateFormatter, TimestampFormatter, and TimestamptzFormatter traits The formatter automatically selects appropriate format strings: - Milliseconds: %H:%M:%S%.3f - Seconds: %H:%M:%S - Minutes/Hours: %H:%M - Days/Weeks: %b %d - Months: %B - Years: %Y 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/time.rs | 337 ++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index 202d5767..e1352509 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -8,10 +8,12 @@ use arrow::array::{ use arrow::compute::kernels::cast; use arrow::datatypes::{DataType, TimeUnit}; use avenger_common::types::LinearScaleAdjustment; +use avenger_common::value::{ScalarOrArray, ScalarOrArrayValue}; use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Timelike, Utc}; use chrono_tz::Tz; use crate::error::AvengerScaleError; +use crate::formatter::{DateFormatter, TimestampFormatter, TimestamptzFormatter}; use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; @@ -44,6 +46,101 @@ use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContex #[derive(Debug)] pub struct TimeScale; +/// Temporal tick formatter that adapts format based on interval +#[derive(Debug, Clone)] +struct TemporalTickFormatter { + interval: TimeInterval, + timezone: Tz, +} + +impl TemporalTickFormatter { + fn new(interval: TimeInterval, timezone: Tz) -> Self { + Self { interval, timezone } + } + + /// Get format string based on interval type + fn get_format_string(&self) -> &'static str { + match &self.interval { + TimeInterval::Millisecond(_) => "%H:%M:%S%.3f", + TimeInterval::Second(_) => "%H:%M:%S", + TimeInterval::Minute(n) if *n < 60 => "%H:%M", + TimeInterval::Hour(n) if *n < 24 => "%H:%M", + TimeInterval::Day(_) => "%b %d", + TimeInterval::Week(_) => "%b %d", + TimeInterval::Month(n) if *n < 12 => "%B", + TimeInterval::Year(_) => "%Y", + _ => "%Y-%m-%d %H:%M:%S", + } + } +} + +impl DateFormatter for TemporalTickFormatter { + fn format(&self, values: &[Option], default: Option<&str>) -> Vec { + let default = default.unwrap_or(""); + let format_str = self.get_format_string(); + + values + .iter() + .map(|v| { + v.map(|date| { + // Convert to datetime at midnight in the target timezone + match self.timezone.from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap()) { + chrono::LocalResult::Single(dt) => dt.format(format_str).to_string(), + chrono::LocalResult::None => { + // Handle DST gap by trying next hours + self.timezone + .from_local_datetime(&date.and_hms_opt(3, 0, 0).unwrap()) + .single() + .unwrap() + .format(format_str) + .to_string() + } + chrono::LocalResult::Ambiguous(dt, _) => dt.format(format_str).to_string(), + } + }) + .unwrap_or_else(|| default.to_string()) + }) + .collect() + } +} + +impl TimestampFormatter for TemporalTickFormatter { + fn format(&self, values: &[Option], default: Option<&str>) -> Vec { + let default = default.unwrap_or(""); + let format_str = self.get_format_string(); + + values + .iter() + .map(|v| { + v.map(|naive_dt| { + // Convert to timezone-aware datetime + let local_dt = self.timezone.from_utc_datetime(&naive_dt); + local_dt.format(format_str).to_string() + }) + .unwrap_or_else(|| default.to_string()) + }) + .collect() + } +} + +impl TimestamptzFormatter for TemporalTickFormatter { + fn format(&self, values: &[Option>], default: Option<&str>) -> Vec { + let default = default.unwrap_or(""); + let format_str = self.get_format_string(); + + values + .iter() + .map(|v| { + v.map(|utc_dt| { + let local_dt = utc_dt.with_timezone(&self.timezone); + local_dt.format(format_str).to_string() + }) + .unwrap_or_else(|| default.to_string()) + }) + .collect() + } +} + impl TimeScale { /// Create a new time scale with the specified domain and range. pub fn configured(domain: (ArrayRef, ArrayRef), range: (f32, f32)) -> ConfiguredScale { @@ -639,6 +736,168 @@ impl ScaleImpl for TimeScale { "Adjust for time scale not yet implemented".to_string(), )) } + + fn scale_to_string( + &self, + config: &ScaleConfig, + values: &ArrayRef, + ) -> Result, AvengerScaleError> { + // Get timezone for formatting + let tz_str = config.option_string("timezone", "UTC"); + let tz = parse_timezone(&tz_str)?; + + // Determine the tick interval based on domain span and nice settings + let domain_type = config.domain.data_type(); + let handler = TemporalHandler::from_data_type(domain_type)?; + let domain_start = get_temporal_value(&config.domain, 0, &handler)?; + let domain_end = get_temporal_value(&config.domain, 1, &handler)?; + let span = domain_end - domain_start; + + // Get target tick count from nice option or default + let target_count = match config.options.get("nice") { + Some(scalar) => { + if let Ok(true) = scalar.as_boolean() { + 10.0 + } else if let Ok(count) = scalar.as_f32() { + count + } else { + 10.0 + } + } + None => 10.0, + }; + + // Select appropriate interval for formatting + let interval = select_time_interval(span, target_count); + + // Create custom formatter + let formatter = TemporalTickFormatter::new(interval, tz); + let default = config.option_string("default", ""); + + // Format based on the input data type + match values.data_type() { + DataType::Date32 => { + let values = values.as_any().downcast_ref::().unwrap(); + let dates: Vec<_> = (0..values.len()) + .map(|i| { + if values.is_null(i) { + None + } else { + let days = values.value(i); + NaiveDate::from_num_days_from_ce_opt(days + 719_163) + } + }) + .collect(); + Ok(ScalarOrArray::new_array( + DateFormatter::format(&formatter, &dates, Some(&default)), + )) + } + DataType::Date64 => { + let values = values.as_any().downcast_ref::().unwrap(); + let dates: Vec<_> = (0..values.len()) + .map(|i| { + if values.is_null(i) { + None + } else { + let millis = values.value(i); + DateTime::from_timestamp(millis / 1000, ((millis % 1000) * 1_000_000) as u32) + .map(|dt| dt.naive_utc()) + } + }) + .collect(); + Ok(ScalarOrArray::new_array( + TimestampFormatter::format(&formatter, &dates, Some(&default)), + )) + } + DataType::Timestamp(unit, tz_opt) => { + // Convert to NaiveDateTime first + let timestamps = match unit { + TimeUnit::Second => { + let array = values.as_any().downcast_ref::().unwrap(); + (0..array.len()) + .map(|i| { + if array.is_null(i) { + None + } else { + DateTime::from_timestamp(array.value(i), 0) + .map(|dt| dt.naive_utc()) + } + }) + .collect::>() + } + TimeUnit::Millisecond => { + let array = values.as_any().downcast_ref::().unwrap(); + (0..array.len()) + .map(|i| { + if array.is_null(i) { + None + } else { + let millis = array.value(i); + DateTime::from_timestamp( + millis / 1000, + ((millis % 1000) * 1_000_000) as u32, + ).map(|dt| dt.naive_utc()) + } + }) + .collect::>() + } + TimeUnit::Microsecond => { + let array = values.as_any().downcast_ref::().unwrap(); + (0..array.len()) + .map(|i| { + if array.is_null(i) { + None + } else { + let micros = array.value(i); + DateTime::from_timestamp( + micros / 1_000_000, + ((micros % 1_000_000) * 1_000) as u32, + ).map(|dt| dt.naive_utc()) + } + }) + .collect::>() + } + TimeUnit::Nanosecond => { + let array = values.as_any().downcast_ref::().unwrap(); + (0..array.len()) + .map(|i| { + if array.is_null(i) { + None + } else { + let nanos = array.value(i); + DateTime::from_timestamp( + nanos / 1_000_000_000, + (nanos % 1_000_000_000) as u32, + ).map(|dt| dt.naive_utc()) + } + }) + .collect::>() + } + }; + + if tz_opt.is_some() { + // Convert to UTC DateTime for timestamptz + let utc_timestamps: Vec<_> = timestamps + .iter() + .map(|opt| opt.map(|naive| Utc.from_utc_datetime(&naive))) + .collect(); + Ok(ScalarOrArray::new_array( + TimestamptzFormatter::format(&formatter, &utc_timestamps, Some(&default)), + )) + } else { + // Use NaiveDateTime for timestamp without timezone + Ok(ScalarOrArray::new_array( + TimestampFormatter::format(&formatter, ×tamps, Some(&default)), + )) + } + } + _ => { + // Fallback to default formatting + let scaled = self.scale(config, values)?; + config.context.formatters.format(&scaled, Some(&default)) + } + } + } } // Helper functions @@ -1749,4 +2008,82 @@ mod tests { Ok(()) } + + #[test] + fn test_time_scale_tick_formatting() -> Result<(), AvengerScaleError> { + // Test formatting with different intervals + let tz_str = "America/New_York"; + + // Test daily ticks + let start = Arc::new(Date32Array::from(vec![19723])) as ArrayRef; // 2024-01-01 + let end = Arc::new(Date32Array::from(vec![19730])) as ArrayRef; // 2024-01-08 + + let scale = TimeScale::configured((start, end), (0.0, 100.0)) + .with_option("timezone", tz_str) + .with_option("nice", 7.0); // ~7 daily ticks + + let ticks = scale.ticks(Some(7.0))?; + let formatted = scale.scale_to_string(&ticks)?; + + match formatted.value() { + ScalarOrArrayValue::Array(strings) => { + // Should show month and day for daily ticks + assert!(strings[0].contains("Jan")); + assert!(strings[0].contains("01") || strings[0].contains(" 1")); + } + _ => panic!("Expected array of strings"), + } + + // Test hourly ticks with timestamps + let start_ts = chrono_tz::America::New_York + .with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .unwrap() + .timestamp_millis(); + let end_ts = chrono_tz::America::New_York + .with_ymd_and_hms(2024, 1, 1, 12, 0, 0) + .unwrap() + .timestamp_millis(); + + let start = Arc::new(TimestampMillisecondArray::from(vec![start_ts])) as ArrayRef; + let end = Arc::new(TimestampMillisecondArray::from(vec![end_ts])) as ArrayRef; + + let scale = TimeScale::configured((start, end), (0.0, 100.0)) + .with_option("timezone", tz_str) + .with_option("nice", 6.0); // ~6 hourly ticks + + let ticks = scale.ticks(Some(6.0))?; + let formatted = scale.scale_to_string(&ticks)?; + + match formatted.value() { + ScalarOrArrayValue::Array(strings) => { + // Should show hours for hourly ticks + assert!(strings[0].contains(":")); + // Should not show seconds for hourly ticks + assert!(!strings[0].contains(":00:00")); + } + _ => panic!("Expected array of strings"), + } + + // Test yearly ticks + let start = Arc::new(Date32Array::from(vec![18262])) as ArrayRef; // 2020-01-01 + let end = Arc::new(Date32Array::from(vec![20088])) as ArrayRef; // 2024-12-31 + + let scale = TimeScale::configured((start, end), (0.0, 100.0)) + .with_option("timezone", tz_str) + .with_option("nice", 5.0); // ~5 yearly ticks + + let ticks = scale.ticks(Some(5.0))?; + let formatted = scale.scale_to_string(&ticks)?; + + match formatted.value() { + ScalarOrArrayValue::Array(strings) => { + // Should show only years for yearly ticks + assert!(strings[0].starts_with("20")); // Year 20XX + assert!(!strings[0].contains("Jan")); // No month + } + _ => panic!("Expected array of strings"), + } + + Ok(()) + } } From cbbaaba20c4788c6e6e617cf2cdf34eec177c919 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 11 Jul 2025 17:09:13 -0700 Subject: [PATCH 16/29] fix: Implement proper round option support for band scales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix band scale rounding to match Vega's behavior for pixel-perfect rendering - When round=true and range starts at 0 with no padding, keep start position at 0 to avoid sub-pixel shifts - Update bandwidth calculation to use floored step value when rounding is enabled - Add comprehensive tests to verify rounding behavior across various ranges and with padding - Update existing test to reflect the new Vega-matching behavior This fixes pixel-diff discrepancies between Vega and VegaFusion when using band scales with the round option enabled. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/band.rs | 156 ++++++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 6 deletions(-) diff --git a/avenger-scales/src/scales/band.rs b/avenger-scales/src/scales/band.rs index 3815660e..d5cd4591 100644 --- a/avenger-scales/src/scales/band.rs +++ b/avenger-scales/src/scales/band.rs @@ -230,7 +230,17 @@ fn build_range_values(config: &ScaleConfig) -> Result, AvengerScaleErro let step = if round { step.floor() } else { step }; let start = start + (stop - start - step * (n as f32 - padding_inner)) * align; - let start = if round { start.round() } else { start }; + let start = if round { + // When rounding is enabled and the original range starts at 0 with no padding, + // keep the start at 0 to avoid sub-pixel shifts (matches Vega behavior) + if range_start == 0.0 && !reverse && padding_inner == 0.0 && padding_outer == 0.0 { + 0.0 + } else { + start.round() + } + } else { + start + }; // Generate range values let range_values: Vec = (0..n).map(|i| start + step * i as f32).collect::>(); @@ -277,6 +287,11 @@ pub fn bandwidth(config: &ScaleConfig) -> Result { }; let step = (stop - start) / 1.0_f32.max(bandspace(n, Some(padding_inner), Some(padding_outer))); + let step = if config.option_boolean("round", false) { + step.floor() + } else { + step + }; let bandwidth = step * (1.0 - padding_inner); if config.option_boolean("round", false) { @@ -415,11 +430,12 @@ mod tests { .scale_to_numeric(&config, &values)? .as_vec(values.len(), None); - // With rounding, values should be integers - assert_eq!(result[0], 1.0); // "a" - assert_eq!(result[1], 34.0); // "b" - assert_eq!(result[2], 34.0); // "b" - assert_eq!(result[3], 67.0); // "c" + // With rounding and range starting at 0 (no padding), + // positions start at 0 to match Vega behavior + assert_eq!(result[0], 0.0); // "a" + assert_eq!(result[1], 33.0); // "b" + assert_eq!(result[2], 33.0); // "b" + assert_eq!(result[3], 66.0); // "c" assert!(result[4].is_nan()); // "f" assert_eq!(bandwidth(&config)?, 33.0); @@ -567,4 +583,132 @@ mod tests { Ok(()) } + + #[test] + fn test_band_scale_round_various_ranges() -> Result<(), AvengerScaleError> { + // Test rounding behavior across various ranges to ensure consistency with Vega + let domain = Arc::new(StringArray::from(vec!["A", "B", "C", "D", "E"])); + let scale = BandScale; + + // Test with various ranges to check rounding behavior + let test_cases = vec![ + // (range_start, range_stop, expected_positions, expected_bandwidth) + (0.0, 300.0, vec![0.0, 60.0, 120.0, 180.0, 240.0], 60.0), + (0.0, 295.0, vec![0.0, 59.0, 118.0, 177.0, 236.0], 59.0), + (10.0, 290.0, vec![10.0, 66.0, 122.0, 178.0, 234.0], 56.0), + (0.0, 100.0, vec![0.0, 20.0, 40.0, 60.0, 80.0], 20.0), + // When range starts at 0 with no padding, positions start at 0 to match Vega + (0.0, 97.0, vec![0.0, 19.0, 38.0, 57.0, 76.0], 19.0), + ]; + + for (start, stop, expected_positions, expected_bandwidth) in test_cases { + let config = ScaleConfig { + domain: domain.clone(), + range: Arc::new(Float32Array::from(vec![start, stop])), + options: vec![ + ("round".to_string(), true.into()), + ("align".to_string(), 0.5.into()), + ] + .into_iter() + .collect(), + context: ScaleContext::default(), + }; + + let values = Arc::new(StringArray::from(vec!["A", "B", "C", "D", "E"])) as ArrayRef; + let result = scale + .scale_to_numeric(&config, &values)? + .as_vec(values.len(), None); + + // Check positions + for (i, (actual, expected)) in result.iter().zip(expected_positions.iter()).enumerate() + { + assert_eq!( + *actual, *expected, + "Position mismatch at index {} for range [{}, {}]: expected {}, got {}", + i, start, stop, expected, actual + ); + } + + // Check bandwidth + assert_eq!( + bandwidth(&config)?, + expected_bandwidth, + "Bandwidth mismatch for range [{}, {}]", + start, + stop + ); + } + + Ok(()) + } + + #[test] + fn test_band_scale_round_with_padding_detailed() -> Result<(), AvengerScaleError> { + // Test rounding with padding to ensure proper calculation + let domain = Arc::new(StringArray::from(vec!["A", "B", "C"])); + let scale = BandScale; + + let config = ScaleConfig { + domain: domain.clone(), + range: Arc::new(Float32Array::from(vec![0.0, 300.0])), + options: vec![ + ("round".to_string(), true.into()), + ("padding_inner".to_string(), 0.1.into()), + ("padding_outer".to_string(), 0.1.into()), + ("align".to_string(), 0.5.into()), + ] + .into_iter() + .collect(), + context: ScaleContext::default(), + }; + + let values = Arc::new(StringArray::from(vec!["A", "B", "C"])) as ArrayRef; + let result = scale + .scale_to_numeric(&config, &values)? + .as_vec(values.len(), None); + + // With padding, the calculation follows: + // step = floor(300 / (3 - 0.1 + 0.2)) = floor(96.774) = 96 + // bandwidth = round(96 * 0.9) = round(86.4) = 86 + // start = round(0 + (300 - 96 * 2.9) * 0.5) = round(10.8) = 11 + + // Expected positions: [11, 107, 203] + assert_eq!(result[0], 11.0, "First position should be 11"); + assert_eq!(result[1], 107.0, "Second position should be 107"); + assert_eq!(result[2], 203.0, "Third position should be 203"); + assert_eq!(bandwidth(&config)?, 86.0, "Bandwidth should be 86"); + assert_eq!(step(&config)?, 96.0, "Step should be 96"); + + Ok(()) + } + + #[test] + fn test_band_scale_round_fractional_step() -> Result<(), AvengerScaleError> { + // Test edge case where step calculation results in fractional value + let domain = Arc::new(StringArray::from(vec!["A", "B"])); + let scale = BandScale; + + // Case where step = 101 / 2 = 50.5, which floors to 50 + let config = ScaleConfig { + domain: domain.clone(), + range: Arc::new(Float32Array::from(vec![0.0, 101.0])), + options: vec![("round".to_string(), true.into())] + .into_iter() + .collect(), + context: ScaleContext::default(), + }; + + let values = Arc::new(StringArray::from(vec!["A", "B"])) as ArrayRef; + let result = scale + .scale_to_numeric(&config, &values)? + .as_vec(values.len(), None); + + // With Vega-matching behavior: when round=true and range starts at 0 with no padding, + // start position is kept at 0 to avoid sub-pixel shifts + assert_eq!(result[0], 0.0, "First position should be 0"); + assert_eq!(result[1], 50.0, "Second position should be 50"); + assert_eq!(bandwidth(&config)?, 50.0, "Bandwidth should be 50"); + + Ok(()) + } } From 74e40babcb30f41d2c4faf06a04e74d8eda19cad Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 11 Jul 2025 17:09:59 -0700 Subject: [PATCH 17/29] formatting --- avenger-scales/src/scales/time.rs | 283 ++++++++++++++++++------------ 1 file changed, 171 insertions(+), 112 deletions(-) diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index e1352509..a65db9a0 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -57,7 +57,7 @@ impl TemporalTickFormatter { fn new(interval: TimeInterval, timezone: Tz) -> Self { Self { interval, timezone } } - + /// Get format string based on interval type fn get_format_string(&self) -> &'static str { match &self.interval { @@ -78,13 +78,16 @@ impl DateFormatter for TemporalTickFormatter { fn format(&self, values: &[Option], default: Option<&str>) -> Vec { let default = default.unwrap_or(""); let format_str = self.get_format_string(); - + values .iter() .map(|v| { v.map(|date| { // Convert to datetime at midnight in the target timezone - match self.timezone.from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap()) { + match self + .timezone + .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap()) + { chrono::LocalResult::Single(dt) => dt.format(format_str).to_string(), chrono::LocalResult::None => { // Handle DST gap by trying next hours @@ -105,10 +108,14 @@ impl DateFormatter for TemporalTickFormatter { } impl TimestampFormatter for TemporalTickFormatter { - fn format(&self, values: &[Option], default: Option<&str>) -> Vec { + fn format( + &self, + values: &[Option], + default: Option<&str>, + ) -> Vec { let default = default.unwrap_or(""); let format_str = self.get_format_string(); - + values .iter() .map(|v| { @@ -127,7 +134,7 @@ impl TimestamptzFormatter for TemporalTickFormatter { fn format(&self, values: &[Option>], default: Option<&str>) -> Vec { let default = default.unwrap_or(""); let format_str = self.get_format_string(); - + values .iter() .map(|v| { @@ -460,10 +467,13 @@ fn compute_actual_duration_millis( if tz == &chrono_tz::UTC { return Ok(end_millis - start_millis); } - + // Convert to timezone-aware datetimes let start = tz - .timestamp_opt(start_millis / 1000, ((start_millis % 1000) * 1_000_000) as u32) + .timestamp_opt( + start_millis / 1000, + ((start_millis % 1000) * 1_000_000) as u32, + ) .single() .ok_or_else(|| { AvengerScaleError::DstTransitionError(format!( @@ -471,7 +481,7 @@ fn compute_actual_duration_millis( start_millis )) })?; - + let end = tz .timestamp_opt(end_millis / 1000, ((end_millis % 1000) * 1_000_000) as u32) .single() @@ -481,7 +491,7 @@ fn compute_actual_duration_millis( end_millis )) })?; - + // Compute actual duration let duration = end - start; Ok(duration.num_milliseconds()) @@ -511,11 +521,11 @@ impl ScaleImpl for TimeScale { // Get range bounds let (range_start, range_end) = config.numeric_interval_range()?; - + // Get timezone for DST-aware duration calculations let tz_str = config.option_string("timezone", "UTC"); let tz = parse_timezone(&tz_str)?; - + // Compute actual duration (accounting for DST) let actual_duration = compute_actual_duration_millis(domain_start, domain_end, &tz)?; let use_actual_duration = actual_duration != (domain_end - domain_start); @@ -533,7 +543,8 @@ impl ScaleImpl for TimeScale { let value = handler.to_timestamp_millis(values.value(i) as i64); let normalized = if use_actual_duration { // Use actual duration for DST-aware scaling - let value_duration = compute_actual_duration_millis(domain_start, value, &tz)?; + let value_duration = + compute_actual_duration_millis(domain_start, value, &tz)?; value_duration as f32 / actual_duration as f32 } else { // Use nominal duration for performance when no DST @@ -556,7 +567,8 @@ impl ScaleImpl for TimeScale { let value = handler.to_timestamp_millis(values.value(i)); let normalized = if use_actual_duration { // Use actual duration for DST-aware scaling - let value_duration = compute_actual_duration_millis(domain_start, value, &tz)?; + let value_duration = + compute_actual_duration_millis(domain_start, value, &tz)?; value_duration as f32 / actual_duration as f32 } else { // Use nominal duration for performance when no DST @@ -604,11 +616,11 @@ impl ScaleImpl for TimeScale { let domain_end = get_temporal_value(&config.domain, 1, &handler)?; let (range_start, range_end) = config.numeric_interval_range()?; - + // Get timezone for DST-aware duration calculations let tz_str = config.option_string("timezone", "UTC"); let tz = parse_timezone(&tz_str)?; - + // Compute actual duration (accounting for DST) let actual_duration = compute_actual_duration_millis(domain_start, domain_end, &tz)?; let use_actual_duration = actual_duration != (domain_end - domain_start); @@ -633,21 +645,23 @@ impl ScaleImpl for TimeScale { let value = float_array.value(i); // Reverse the scaling operation let normalized = (value - range_start) / (range_end - range_start); - + let millis = if use_actual_duration { // For DST-aware inversion, we need to find the timestamp // whose actual duration from domain_start equals the target let target_duration = (actual_duration as f32 * normalized) as i64; - + // Start with a nominal estimate let mut estimate = domain_start + target_duration; - + // Refine the estimate by checking actual duration // This handles DST transitions correctly - for _ in 0..3 { // Usually converges in 1-2 iterations + for _ in 0..3 { + // Usually converges in 1-2 iterations let actual = compute_actual_duration_millis(domain_start, estimate, &tz)?; let error = target_duration - actual; - if error.abs() < 1000 { // Within 1 second is good enough + if error.abs() < 1000 { + // Within 1 second is good enough break; } estimate += error; @@ -657,7 +671,7 @@ impl ScaleImpl for TimeScale { // No DST transitions, use simple linear interpolation domain_start + ((domain_end - domain_start) as f32 * normalized) as i64 }; - + result_millis.push(Some(millis)); } } @@ -736,7 +750,7 @@ impl ScaleImpl for TimeScale { "Adjust for time scale not yet implemented".to_string(), )) } - + fn scale_to_string( &self, config: &ScaleConfig, @@ -745,14 +759,14 @@ impl ScaleImpl for TimeScale { // Get timezone for formatting let tz_str = config.option_string("timezone", "UTC"); let tz = parse_timezone(&tz_str)?; - + // Determine the tick interval based on domain span and nice settings let domain_type = config.domain.data_type(); let handler = TemporalHandler::from_data_type(domain_type)?; let domain_start = get_temporal_value(&config.domain, 0, &handler)?; let domain_end = get_temporal_value(&config.domain, 1, &handler)?; let span = domain_end - domain_start; - + // Get target tick count from nice option or default let target_count = match config.options.get("nice") { Some(scalar) => { @@ -766,14 +780,14 @@ impl ScaleImpl for TimeScale { } None => 10.0, }; - + // Select appropriate interval for formatting let interval = select_time_interval(span, target_count); - + // Create custom formatter let formatter = TemporalTickFormatter::new(interval, tz); let default = config.option_string("default", ""); - + // Format based on the input data type match values.data_type() { DataType::Date32 => { @@ -788,9 +802,11 @@ impl ScaleImpl for TimeScale { } }) .collect(); - Ok(ScalarOrArray::new_array( - DateFormatter::format(&formatter, &dates, Some(&default)), - )) + Ok(ScalarOrArray::new_array(DateFormatter::format( + &formatter, + &dates, + Some(&default), + ))) } DataType::Date64 => { let values = values.as_any().downcast_ref::().unwrap(); @@ -800,20 +816,28 @@ impl ScaleImpl for TimeScale { None } else { let millis = values.value(i); - DateTime::from_timestamp(millis / 1000, ((millis % 1000) * 1_000_000) as u32) - .map(|dt| dt.naive_utc()) + DateTime::from_timestamp( + millis / 1000, + ((millis % 1000) * 1_000_000) as u32, + ) + .map(|dt| dt.naive_utc()) } }) .collect(); - Ok(ScalarOrArray::new_array( - TimestampFormatter::format(&formatter, &dates, Some(&default)), - )) + Ok(ScalarOrArray::new_array(TimestampFormatter::format( + &formatter, + &dates, + Some(&default), + ))) } DataType::Timestamp(unit, tz_opt) => { // Convert to NaiveDateTime first let timestamps = match unit { TimeUnit::Second => { - let array = values.as_any().downcast_ref::().unwrap(); + let array = values + .as_any() + .downcast_ref::() + .unwrap(); (0..array.len()) .map(|i| { if array.is_null(i) { @@ -826,7 +850,10 @@ impl ScaleImpl for TimeScale { .collect::>() } TimeUnit::Millisecond => { - let array = values.as_any().downcast_ref::().unwrap(); + let array = values + .as_any() + .downcast_ref::() + .unwrap(); (0..array.len()) .map(|i| { if array.is_null(i) { @@ -836,13 +863,17 @@ impl ScaleImpl for TimeScale { DateTime::from_timestamp( millis / 1000, ((millis % 1000) * 1_000_000) as u32, - ).map(|dt| dt.naive_utc()) + ) + .map(|dt| dt.naive_utc()) } }) .collect::>() } TimeUnit::Microsecond => { - let array = values.as_any().downcast_ref::().unwrap(); + let array = values + .as_any() + .downcast_ref::() + .unwrap(); (0..array.len()) .map(|i| { if array.is_null(i) { @@ -852,13 +883,17 @@ impl ScaleImpl for TimeScale { DateTime::from_timestamp( micros / 1_000_000, ((micros % 1_000_000) * 1_000) as u32, - ).map(|dt| dt.naive_utc()) + ) + .map(|dt| dt.naive_utc()) } }) .collect::>() } TimeUnit::Nanosecond => { - let array = values.as_any().downcast_ref::().unwrap(); + let array = values + .as_any() + .downcast_ref::() + .unwrap(); (0..array.len()) .map(|i| { if array.is_null(i) { @@ -868,27 +903,32 @@ impl ScaleImpl for TimeScale { DateTime::from_timestamp( nanos / 1_000_000_000, (nanos % 1_000_000_000) as u32, - ).map(|dt| dt.naive_utc()) + ) + .map(|dt| dt.naive_utc()) } }) .collect::>() } }; - + if tz_opt.is_some() { // Convert to UTC DateTime for timestamptz let utc_timestamps: Vec<_> = timestamps .iter() .map(|opt| opt.map(|naive| Utc.from_utc_datetime(&naive))) .collect(); - Ok(ScalarOrArray::new_array( - TimestamptzFormatter::format(&formatter, &utc_timestamps, Some(&default)), - )) + Ok(ScalarOrArray::new_array(TimestamptzFormatter::format( + &formatter, + &utc_timestamps, + Some(&default), + ))) } else { // Use NaiveDateTime for timestamp without timezone - Ok(ScalarOrArray::new_array( - TimestampFormatter::format(&formatter, ×tamps, Some(&default)), - )) + Ok(ScalarOrArray::new_array(TimestampFormatter::format( + &formatter, + ×tamps, + Some(&default), + ))) } } _ => { @@ -1074,7 +1114,8 @@ fn scale_timestamp_values( let value = handler.to_timestamp_millis(values.value(i)); let normalized = if use_actual_duration { // Use actual duration for DST-aware scaling - let value_duration = compute_actual_duration_millis(domain_start, value, tz)?; + let value_duration = + compute_actual_duration_millis(domain_start, value, tz)?; value_duration as f32 / actual_duration as f32 } else { // Use nominal duration for performance when no DST @@ -1096,7 +1137,8 @@ fn scale_timestamp_values( let value = handler.to_timestamp_millis(values.value(i)); let normalized = if use_actual_duration { // Use actual duration for DST-aware scaling - let value_duration = compute_actual_duration_millis(domain_start, value, tz)?; + let value_duration = + compute_actual_duration_millis(domain_start, value, tz)?; value_duration as f32 / actual_duration as f32 } else { // Use nominal duration for performance when no DST @@ -1118,7 +1160,8 @@ fn scale_timestamp_values( let value = handler.to_timestamp_millis(values.value(i)); let normalized = if use_actual_duration { // Use actual duration for DST-aware scaling - let value_duration = compute_actual_duration_millis(domain_start, value, tz)?; + let value_duration = + compute_actual_duration_millis(domain_start, value, tz)?; value_duration as f32 / actual_duration as f32 } else { // Use nominal duration for performance when no DST @@ -1140,7 +1183,8 @@ fn scale_timestamp_values( let value = handler.to_timestamp_millis(values.value(i)); let normalized = if use_actual_duration { // Use actual duration for DST-aware scaling - let value_duration = compute_actual_duration_millis(domain_start, value, tz)?; + let value_duration = + compute_actual_duration_millis(domain_start, value, tz)?; value_duration as f32 / actual_duration as f32 } else { // Use nominal duration for performance when no DST @@ -1914,117 +1958,132 @@ mod tests { Ok(()) } - + #[test] fn test_dst_scale_operations() -> Result<(), AvengerScaleError> { // Test scaling across DST transitions let tz_str = "America/New_York"; let et = parse_timezone(tz_str)?; - + // Create domain spanning spring-forward DST transition // 2024-03-10 00:00 to 04:00 ET (2:00 AM doesn't exist) let start = et.with_ymd_and_hms(2024, 3, 10, 0, 0, 0).unwrap(); let end = et.with_ymd_and_hms(2024, 3, 10, 4, 0, 0).unwrap(); - - let domain_start = Arc::new(TimestampMillisecondArray::from(vec![start.timestamp_millis()])) as ArrayRef; - let domain_end = Arc::new(TimestampMillisecondArray::from(vec![end.timestamp_millis()])) as ArrayRef; - + + let domain_start = Arc::new(TimestampMillisecondArray::from(vec![ + start.timestamp_millis() + ])) as ArrayRef; + let domain_end = Arc::new(TimestampMillisecondArray::from( + vec![end.timestamp_millis()], + )) as ArrayRef; + let scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)) .with_option("timezone", tz_str); - + // Test scaling values across the DST gap let test_times = vec![ - et.with_ymd_and_hms(2024, 3, 10, 0, 0, 0).unwrap(), // Start - et.with_ymd_and_hms(2024, 3, 10, 1, 0, 0).unwrap(), // Before gap - et.with_ymd_and_hms(2024, 3, 10, 3, 0, 0).unwrap(), // After gap (2AM skipped) - et.with_ymd_and_hms(2024, 3, 10, 4, 0, 0).unwrap(), // End + et.with_ymd_and_hms(2024, 3, 10, 0, 0, 0).unwrap(), // Start + et.with_ymd_and_hms(2024, 3, 10, 1, 0, 0).unwrap(), // Before gap + et.with_ymd_and_hms(2024, 3, 10, 3, 0, 0).unwrap(), // After gap (2AM skipped) + et.with_ymd_and_hms(2024, 3, 10, 4, 0, 0).unwrap(), // End ]; - + let values = Arc::new(TimestampMillisecondArray::from( - test_times.iter().map(|dt| dt.timestamp_millis()).collect::>() + test_times + .iter() + .map(|dt| dt.timestamp_millis()) + .collect::>(), )) as ArrayRef; - + let scaled = scale.scale(&values)?; let scaled_array = scaled.as_any().downcast_ref::().unwrap(); - + // The actual duration is 3 hours (not 4) due to DST // So scaling should reflect this - assert_eq!(scaled_array.value(0), 0.0); // Start - assert!((scaled_array.value(1) - 33.33).abs() < 1.0); // 1 hour / 3 hours ≈ 33.33% - assert!((scaled_array.value(2) - 66.67).abs() < 1.0); // 2 hours / 3 hours ≈ 66.67% - assert_eq!(scaled_array.value(3), 100.0); // End - + assert_eq!(scaled_array.value(0), 0.0); // Start + assert!((scaled_array.value(1) - 33.33).abs() < 1.0); // 1 hour / 3 hours ≈ 33.33% + assert!((scaled_array.value(2) - 66.67).abs() < 1.0); // 2 hours / 3 hours ≈ 66.67% + assert_eq!(scaled_array.value(3), 100.0); // End + // Test inversion let range_values = Arc::new(Float32Array::from(vec![0.0, 33.33, 66.67, 100.0])) as ArrayRef; let inverted = scale.invert(&range_values)?; - let inverted_array = inverted.as_any().downcast_ref::().unwrap(); - + let inverted_array = inverted + .as_any() + .downcast_ref::() + .unwrap(); + // Check that inverted values match original times (approximately) for i in 0..test_times.len() { let diff = (inverted_array.value(i) - test_times[i].timestamp_millis()).abs(); - assert!(diff < 60000, "Inverted time differs by more than 1 minute"); // Within 1 minute + assert!(diff < 60000, "Inverted time differs by more than 1 minute"); + // Within 1 minute } - + Ok(()) } - + #[test] fn test_dst_fall_back_scale() -> Result<(), AvengerScaleError> { // Test scaling across fall-back DST transition let tz_str = "America/New_York"; let et = parse_timezone(tz_str)?; - + // Create domain spanning fall-back DST transition // 2024-11-03 00:00 to 04:00 ET (1:00 AM happens twice) let start = et.with_ymd_and_hms(2024, 11, 3, 0, 0, 0).unwrap(); let end = et.with_ymd_and_hms(2024, 11, 3, 4, 0, 0).unwrap(); - - let domain_start = Arc::new(TimestampMillisecondArray::from(vec![start.timestamp_millis()])) as ArrayRef; - let domain_end = Arc::new(TimestampMillisecondArray::from(vec![end.timestamp_millis()])) as ArrayRef; - + + let domain_start = Arc::new(TimestampMillisecondArray::from(vec![ + start.timestamp_millis() + ])) as ArrayRef; + let domain_end = Arc::new(TimestampMillisecondArray::from( + vec![end.timestamp_millis()], + )) as ArrayRef; + let scale = TimeScale::configured((domain_start, domain_end), (0.0, 100.0)) .with_option("timezone", tz_str); - + // The actual duration is 5 hours (not 4) due to DST fall-back let values = Arc::new(TimestampMillisecondArray::from(vec![ start.timestamp_millis(), - start.timestamp_millis() + 3600 * 1000, // 1 hour later + start.timestamp_millis() + 3600 * 1000, // 1 hour later start.timestamp_millis() + 2 * 3600 * 1000, // 2 hours later (first 1 AM) start.timestamp_millis() + 3 * 3600 * 1000, // 3 hours later (second 1 AM) start.timestamp_millis() + 4 * 3600 * 1000, // 4 hours later end.timestamp_millis(), ])) as ArrayRef; - + let scaled = scale.scale(&values)?; let scaled_array = scaled.as_any().downcast_ref::().unwrap(); - + // Check scaling accounts for the extra hour - assert_eq!(scaled_array.value(0), 0.0); // Start - assert!((scaled_array.value(1) - 20.0).abs() < 1.0); // 1/5 = 20% - assert!((scaled_array.value(2) - 40.0).abs() < 1.0); // 2/5 = 40% - assert!((scaled_array.value(3) - 60.0).abs() < 1.0); // 3/5 = 60% - assert!((scaled_array.value(4) - 80.0).abs() < 1.0); // 4/5 = 80% - assert_eq!(scaled_array.value(5), 100.0); // End - + assert_eq!(scaled_array.value(0), 0.0); // Start + assert!((scaled_array.value(1) - 20.0).abs() < 1.0); // 1/5 = 20% + assert!((scaled_array.value(2) - 40.0).abs() < 1.0); // 2/5 = 40% + assert!((scaled_array.value(3) - 60.0).abs() < 1.0); // 3/5 = 60% + assert!((scaled_array.value(4) - 80.0).abs() < 1.0); // 4/5 = 80% + assert_eq!(scaled_array.value(5), 100.0); // End + Ok(()) } - + #[test] fn test_time_scale_tick_formatting() -> Result<(), AvengerScaleError> { // Test formatting with different intervals let tz_str = "America/New_York"; - + // Test daily ticks let start = Arc::new(Date32Array::from(vec![19723])) as ArrayRef; // 2024-01-01 - let end = Arc::new(Date32Array::from(vec![19730])) as ArrayRef; // 2024-01-08 - + let end = Arc::new(Date32Array::from(vec![19730])) as ArrayRef; // 2024-01-08 + let scale = TimeScale::configured((start, end), (0.0, 100.0)) .with_option("timezone", tz_str) .with_option("nice", 7.0); // ~7 daily ticks - + let ticks = scale.ticks(Some(7.0))?; let formatted = scale.scale_to_string(&ticks)?; - + match formatted.value() { ScalarOrArrayValue::Array(strings) => { // Should show month and day for daily ticks @@ -2033,7 +2092,7 @@ mod tests { } _ => panic!("Expected array of strings"), } - + // Test hourly ticks with timestamps let start_ts = chrono_tz::America::New_York .with_ymd_and_hms(2024, 1, 1, 0, 0, 0) @@ -2043,17 +2102,17 @@ mod tests { .with_ymd_and_hms(2024, 1, 1, 12, 0, 0) .unwrap() .timestamp_millis(); - + let start = Arc::new(TimestampMillisecondArray::from(vec![start_ts])) as ArrayRef; let end = Arc::new(TimestampMillisecondArray::from(vec![end_ts])) as ArrayRef; - + let scale = TimeScale::configured((start, end), (0.0, 100.0)) .with_option("timezone", tz_str) .with_option("nice", 6.0); // ~6 hourly ticks - + let ticks = scale.ticks(Some(6.0))?; let formatted = scale.scale_to_string(&ticks)?; - + match formatted.value() { ScalarOrArrayValue::Array(strings) => { // Should show hours for hourly ticks @@ -2063,18 +2122,18 @@ mod tests { } _ => panic!("Expected array of strings"), } - + // Test yearly ticks let start = Arc::new(Date32Array::from(vec![18262])) as ArrayRef; // 2020-01-01 - let end = Arc::new(Date32Array::from(vec![20088])) as ArrayRef; // 2024-12-31 - + let end = Arc::new(Date32Array::from(vec![20088])) as ArrayRef; // 2024-12-31 + let scale = TimeScale::configured((start, end), (0.0, 100.0)) .with_option("timezone", tz_str) .with_option("nice", 5.0); // ~5 yearly ticks - + let ticks = scale.ticks(Some(5.0))?; let formatted = scale.scale_to_string(&ticks)?; - + match formatted.value() { ScalarOrArrayValue::Array(strings) => { // Should show only years for yearly ticks @@ -2083,7 +2142,7 @@ mod tests { } _ => panic!("Expected array of strings"), } - + Ok(()) } } From 35df7006c0312c66ec5460722907d6b85fe232a0 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 11 Jul 2025 19:09:21 -0700 Subject: [PATCH 18/29] fix: Correct band scale rounding to fully match Vega behavior - Remove special case that forced start position to 0 when round=true - Apply alignment offset consistently for all ranges, matching Vega's implementation - Fix 2-pixel offset and bandwidth discrepancy (38px vs 37px) reported in bug - Update tests to reflect correct Vega-matching positions - Add test case that verifies exact position match with Vega for 8-band scenario This fully resolves the pixel-diff discrepancies between Vega and VegaFusion band scales with round option enabled. --- avenger-scales/src/scales/band.rs | 74 +++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/avenger-scales/src/scales/band.rs b/avenger-scales/src/scales/band.rs index d5cd4591..dda248ba 100644 --- a/avenger-scales/src/scales/band.rs +++ b/avenger-scales/src/scales/band.rs @@ -230,17 +230,7 @@ fn build_range_values(config: &ScaleConfig) -> Result, AvengerScaleErro let step = if round { step.floor() } else { step }; let start = start + (stop - start - step * (n as f32 - padding_inner)) * align; - let start = if round { - // When rounding is enabled and the original range starts at 0 with no padding, - // keep the start at 0 to avoid sub-pixel shifts (matches Vega behavior) - if range_start == 0.0 && !reverse && padding_inner == 0.0 && padding_outer == 0.0 { - 0.0 - } else { - start.round() - } - } else { - start - }; + let start = if round { start.round() } else { start }; // Generate range values let range_values: Vec = (0..n).map(|i| start + step * i as f32).collect::>(); @@ -430,12 +420,11 @@ mod tests { .scale_to_numeric(&config, &values)? .as_vec(values.len(), None); - // With rounding and range starting at 0 (no padding), - // positions start at 0 to match Vega behavior - assert_eq!(result[0], 0.0); // "a" - assert_eq!(result[1], 33.0); // "b" - assert_eq!(result[2], 33.0); // "b" - assert_eq!(result[3], 66.0); // "c" + // With rounding, positions are offset by alignment + assert_eq!(result[0], 1.0); // "a" + assert_eq!(result[1], 34.0); // "b" + assert_eq!(result[2], 34.0); // "b" + assert_eq!(result[3], 67.0); // "c" assert!(result[4].is_nan()); // "f" assert_eq!(bandwidth(&config)?, 33.0); @@ -597,8 +586,8 @@ mod tests { (0.0, 295.0, vec![0.0, 59.0, 118.0, 177.0, 236.0], 59.0), (10.0, 290.0, vec![10.0, 66.0, 122.0, 178.0, 234.0], 56.0), (0.0, 100.0, vec![0.0, 20.0, 40.0, 60.0, 80.0], 20.0), - // When range starts at 0 with no padding, positions start at 0 to match Vega - (0.0, 97.0, vec![0.0, 19.0, 38.0, 57.0, 76.0], 19.0), + // With remainder of 2, offset = round(2 * 0.5) = 1 + (0.0, 97.0, vec![1.0, 20.0, 39.0, 58.0, 77.0], 19.0), ]; for (start, stop, expected_positions, expected_bandwidth) in test_cases { @@ -703,12 +692,51 @@ mod tests { .scale_to_numeric(&config, &values)? .as_vec(values.len(), None); - // With Vega-matching behavior: when round=true and range starts at 0 with no padding, - // start position is kept at 0 to avoid sub-pixel shifts - assert_eq!(result[0], 0.0, "First position should be 0"); - assert_eq!(result[1], 50.0, "Second position should be 50"); + // With rounding, positions include alignment offset + assert_eq!(result[0], 1.0, "First position should be 1"); + assert_eq!(result[1], 51.0, "Second position should be 51"); assert_eq!(bandwidth(&config)?, 50.0, "Bandwidth should be 50"); Ok(()) } + + #[test] + fn test_band_scale_matches_vega_positions() -> Result<(), AvengerScaleError> { + // Test that positions match Vega exactly for a typical scenario + let domain = Arc::new(StringArray::from(vec![ + "A", "B", "C", "D", "E", "F", "G", "H", + ])); + let scale = BandScale; + + let config = ScaleConfig { + domain: domain.clone(), + range: Arc::new(Float32Array::from(vec![0.0, 300.0])), + options: vec![ + ("round".to_string(), true.into()), + ("align".to_string(), 0.5.into()), + ] + .into_iter() + .collect(), + context: ScaleContext::default(), + }; + + let values = Arc::new(StringArray::from(vec![ + "A", "B", "C", "D", "E", "F", "G", "H", + ])) as ArrayRef; + let result = scale + .scale_to_numeric(&config, &values)? + .as_vec(values.len(), None); + + // Vega positions: with step=37, remainder=4, offset=2 + let expected = vec![2.0, 39.0, 76.0, 113.0, 150.0, 187.0, 224.0, 261.0]; + + for i in 0..8 { + assert_eq!(result[i], expected[i], "Position mismatch at index {}", i); + } + + assert_eq!(bandwidth(&config)?, 37.0, "Bandwidth should be 37"); + assert_eq!(step(&config)?, 37.0, "Step should be 37"); + + Ok(()) + } } From f8330ba052f1b2f12b93a20e58e93cbd13429165 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 11 Jul 2025 20:29:07 -0700 Subject: [PATCH 19/29] support general padding --- avenger-scales/src/scales/band.rs | 36 +++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/avenger-scales/src/scales/band.rs b/avenger-scales/src/scales/band.rs index dda248ba..77d2dcd3 100644 --- a/avenger-scales/src/scales/band.rs +++ b/avenger-scales/src/scales/band.rs @@ -28,12 +28,16 @@ use super::{ /// 0 places at band start, 0.5 at band center, and 1 at band end. This effectively /// controls where within its allocated band each mark is positioned. /// -/// - **padding_inner** (f32, default: 0.0): Padding between adjacent bands as a fraction [0, 1] -/// of the step size. A value of 0.2 means 20% of the step is used for padding. +/// - **padding** (f32, default: 0.0): Sets both padding_inner and padding_outer to the same value. +/// This is a convenience option for uniform padding. /// -/// - **padding_outer** (f32, default: 0.0): Padding before the first and after the last band -/// as a multiple of the step size. Must be non-negative. A value of 0.5 adds half a step -/// of padding on each end. +/// - **padding_inner** / **paddingInner** (f32, default: 0.0): Padding between adjacent bands +/// as a fraction [0, 1] of the step size. A value of 0.2 means 20% of the step is used for padding. +/// Accepts both snake_case and camelCase names. +/// +/// - **padding_outer** / **paddingOuter** (f32, default: 0.0): Padding before the first and after +/// the last band as a multiple of the step size. Must be non-negative. A value of 0.5 adds half +/// a step of padding on each end. Accepts both snake_case and camelCase names. /// /// - **round** (boolean, default: false): When true, band positions and widths are rounded /// to integer pixel values for crisp rendering. @@ -178,8 +182,15 @@ fn build_range_values(config: &ScaleConfig) -> Result, AvengerScaleErro let align = config.option_f32("align", 0.5); let band = config.option_f32("band", 0.0); - let padding_inner = config.option_f32("padding_inner", 0.0); - let padding_outer = config.option_f32("padding_outer", 0.0); + + // Check for generic padding option first (sets both inner and outer) + let default_padding = config.option_f32("padding", 0.0); + + // Support both camelCase (VegaFusion) and snake_case option names + let padding_inner = config.option_f32("padding_inner", + config.option_f32("paddingInner", default_padding)); + let padding_outer = config.option_f32("padding_outer", + config.option_f32("paddingOuter", default_padding)); let round = config.option_boolean("round", false); let range_offset = config.option_f32("range_offset", 0.0); let (range_start, range_stop) = config.numeric_interval_range()?; @@ -267,8 +278,15 @@ pub fn bandwidth(config: &ScaleConfig) -> Result { } let (range_start, range_stop) = config.numeric_interval_range()?; - let padding_inner = config.option_f32("padding_inner", 0.0); - let padding_outer = config.option_f32("padding_outer", 0.0); + + // Check for generic padding option first (sets both inner and outer) + let default_padding = config.option_f32("padding", 0.0); + + // Support both camelCase (VegaFusion) and snake_case option names + let padding_inner = config.option_f32("padding_inner", + config.option_f32("paddingInner", default_padding)); + let padding_outer = config.option_f32("padding_outer", + config.option_f32("paddingOuter", default_padding)); let (start, stop) = if range_stop < range_start { (range_stop, range_start) From fa3f01982e81f8beea53627995329b149a75ba6d Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 12 Jul 2025 08:54:23 -0700 Subject: [PATCH 20/29] Add scale option validation system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove camelCase support from band scale (paddingInner, paddingOuter) - Add comprehensive option validation infrastructure: - OptionConstraint enum for various validation rules (Boolean, Float, ranges, etc.) - OptionDefinition struct to define valid options with constraints - Required trait method option_definitions() returning valid options - Default validate_options() implementation using definitions - Implement option_definitions() for all scale types with proper constraints: - band: align, band, padding, padding_inner, padding_outer, round, range_offset - linear: clamp, range_offset, round, nice, zero, default - log: base (custom validator), clamp, range_offset, round, nice, zero, default - point: align, padding, round, range_offset - pow: exponent, clamp, range_offset, round, nice, zero, default - symlog: constant, clamp, range_offset, round, nice, zero, default - quantile: default - quantize: nice, zero, default - ordinal: default - threshold: default - time: timezone, nice, interval, week_start, locale, default - Add detailed documentation for validation system - Fix clippy warnings and formatting issues 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/Cargo.toml | 1 + avenger-scales/src/scales/band.rs | 63 ++-- avenger-scales/src/scales/linear.rs | 21 +- avenger-scales/src/scales/log.rs | 22 +- avenger-scales/src/scales/mod.rs | 416 +++++++++++++++++++++++++ avenger-scales/src/scales/ordinal.rs | 18 +- avenger-scales/src/scales/point.rs | 21 +- avenger-scales/src/scales/pow.rs | 21 +- avenger-scales/src/scales/quantile.rs | 18 +- avenger-scales/src/scales/quantize.rs | 17 +- avenger-scales/src/scales/symlog.rs | 21 +- avenger-scales/src/scales/threshold.rs | 18 +- avenger-scales/src/scales/time.rs | 44 ++- 13 files changed, 659 insertions(+), 42 deletions(-) diff --git a/avenger-scales/Cargo.toml b/avenger-scales/Cargo.toml index 5484d415..e7562629 100644 --- a/avenger-scales/Cargo.toml +++ b/avenger-scales/Cargo.toml @@ -26,6 +26,7 @@ serde = { workspace = true } serde_json = { workspace = true } paste = { workspace = true } strum = { workspace = true } +lazy_static = { workspace = true } [dev-dependencies] float-cmp = "0.10.0" diff --git a/avenger-scales/src/scales/band.rs b/avenger-scales/src/scales/band.rs index 77d2dcd3..acbe049f 100644 --- a/avenger-scales/src/scales/band.rs +++ b/avenger-scales/src/scales/band.rs @@ -5,12 +5,13 @@ use arrow::{ array::{ArrayRef, Float32Array, UInt32Array}, compute::kernels::take, }; +use lazy_static::lazy_static; use super::point::make_band_config; use super::ScaleContext; -// use super::point::make_band_config; use super::{ - ordinal::OrdinalScale, ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleImpl, + ordinal::OrdinalScale, ConfiguredScale, InferDomainFromDataMethod, OptionConstraint, + OptionDefinition, ScaleConfig, ScaleImpl, }; /// Band scale that maps discrete domain values to continuous numeric bands with optional padding. @@ -31,13 +32,12 @@ use super::{ /// - **padding** (f32, default: 0.0): Sets both padding_inner and padding_outer to the same value. /// This is a convenience option for uniform padding. /// -/// - **padding_inner** / **paddingInner** (f32, default: 0.0): Padding between adjacent bands +/// - **padding_inner** (f32, default: 0.0): Padding between adjacent bands /// as a fraction [0, 1] of the step size. A value of 0.2 means 20% of the step is used for padding. -/// Accepts both snake_case and camelCase names. /// -/// - **padding_outer** / **paddingOuter** (f32, default: 0.0): Padding before the first and after -/// the last band as a multiple of the step size. Must be non-negative. A value of 0.5 adds half -/// a step of padding on each end. Accepts both snake_case and camelCase names. +/// - **padding_outer** (f32, default: 0.0): Padding before the first and after +/// the last band as a multiple of the step size. Must be non-negative. A value of 0.5 adds half +/// a step of padding on each end. /// /// - **round** (boolean, default: false): When true, band positions and widths are rounded /// to integer pixel values for crisp rendering. @@ -87,6 +87,31 @@ impl ScaleImpl for BandScale { InferDomainFromDataMethod::Unique } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + OptionDefinition::optional( + "align", + OptionConstraint::FloatRange { min: 0.0, max: 1.0 } + ), + OptionDefinition::optional( + "band", + OptionConstraint::FloatRange { min: 0.0, max: 1.0 } + ), + OptionDefinition::optional("padding", OptionConstraint::NonNegativeFloat), + OptionDefinition::optional( + "padding_inner", + OptionConstraint::FloatRange { min: 0.0, max: 1.0 } + ), + OptionDefinition::optional("padding_outer", OptionConstraint::NonNegativeFloat), + OptionDefinition::optional("round", OptionConstraint::Boolean), + OptionDefinition::optional("range_offset", OptionConstraint::Float), + ]; + } + + &DEFINITIONS + } + fn scale( &self, config: &ScaleConfig, @@ -182,15 +207,12 @@ fn build_range_values(config: &ScaleConfig) -> Result, AvengerScaleErro let align = config.option_f32("align", 0.5); let band = config.option_f32("band", 0.0); - + // Check for generic padding option first (sets both inner and outer) let default_padding = config.option_f32("padding", 0.0); - - // Support both camelCase (VegaFusion) and snake_case option names - let padding_inner = config.option_f32("padding_inner", - config.option_f32("paddingInner", default_padding)); - let padding_outer = config.option_f32("padding_outer", - config.option_f32("paddingOuter", default_padding)); + + let padding_inner = config.option_f32("padding_inner", default_padding); + let padding_outer = config.option_f32("padding_outer", default_padding); let round = config.option_boolean("round", false); let range_offset = config.option_f32("range_offset", 0.0); let (range_start, range_stop) = config.numeric_interval_range()?; @@ -278,15 +300,12 @@ pub fn bandwidth(config: &ScaleConfig) -> Result { } let (range_start, range_stop) = config.numeric_interval_range()?; - + // Check for generic padding option first (sets both inner and outer) let default_padding = config.option_f32("padding", 0.0); - - // Support both camelCase (VegaFusion) and snake_case option names - let padding_inner = config.option_f32("padding_inner", - config.option_f32("paddingInner", default_padding)); - let padding_outer = config.option_f32("padding_outer", - config.option_f32("paddingOuter", default_padding)); + + let padding_inner = config.option_f32("padding_inner", default_padding); + let padding_outer = config.option_f32("padding_outer", default_padding); let (start, stop) = if range_stop < range_start { (range_stop, range_start) @@ -746,7 +765,7 @@ mod tests { .as_vec(values.len(), None); // Vega positions: with step=37, remainder=4, offset=2 - let expected = vec![2.0, 39.0, 76.0, 113.0, 150.0, 187.0, 224.0, 261.0]; + let expected = [2.0, 39.0, 76.0, 113.0, 150.0, 187.0, 224.0, 261.0]; for i in 0..8 { assert_eq!(result[i], expected[i], "Position mismatch at index {}", i); diff --git a/avenger-scales/src/scales/linear.rs b/avenger-scales/src/scales/linear.rs index 4ecbd308..c660e144 100644 --- a/avenger-scales/src/scales/linear.rs +++ b/avenger-scales/src/scales/linear.rs @@ -9,12 +9,16 @@ use avenger_common::{ types::LinearScaleAdjustment, value::{ScalarOrArray, ScalarOrArrayValue}, }; +use lazy_static::lazy_static; use crate::{ array, color_interpolator::scale_numeric_to_color, error::AvengerScaleError, scalar::Scalar, }; -use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +use super::{ + ConfiguredScale, InferDomainFromDataMethod, OptionConstraint, OptionDefinition, ScaleConfig, + ScaleContext, ScaleImpl, +}; /// Linear scale that maps a continuous numeric domain to a continuous numeric range. /// @@ -198,6 +202,21 @@ impl ScaleImpl for LinearScale { InferDomainFromDataMethod::Interval } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + OptionDefinition::optional("clamp", OptionConstraint::Boolean), + OptionDefinition::optional("range_offset", OptionConstraint::Float), + OptionDefinition::optional("round", OptionConstraint::Boolean), + OptionDefinition::optional("nice", OptionConstraint::nice()), + OptionDefinition::optional("zero", OptionConstraint::Boolean), + OptionDefinition::optional("default", OptionConstraint::Float), + ]; + } + + &DEFINITIONS + } + fn invert( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/log.rs b/avenger-scales/src/scales/log.rs index 0399a9a4..f7982910 100644 --- a/avenger-scales/src/scales/log.rs +++ b/avenger-scales/src/scales/log.rs @@ -6,10 +6,14 @@ use arrow::{ datatypes::{DataType, Float32Type}, }; use avenger_common::value::{ScalarOrArray, ScalarOrArrayValue}; +use lazy_static::lazy_static; use crate::{color_interpolator::scale_numeric_to_color, error::AvengerScaleError, scalar::Scalar}; -use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +use super::{ + ConfiguredScale, InferDomainFromDataMethod, OptionConstraint, OptionDefinition, ScaleConfig, + ScaleContext, ScaleImpl, +}; /// Logarithmic scale that maps a continuous numeric domain to a continuous numeric range /// using logarithmic transformation. @@ -164,6 +168,22 @@ impl ScaleImpl for LogScale { InferDomainFromDataMethod::Interval } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + OptionDefinition::optional("base", OptionConstraint::log_base()), + OptionDefinition::optional("clamp", OptionConstraint::Boolean), + OptionDefinition::optional("range_offset", OptionConstraint::Float), + OptionDefinition::optional("round", OptionConstraint::Boolean), + OptionDefinition::optional("nice", OptionConstraint::nice()), + OptionDefinition::optional("zero", OptionConstraint::Boolean), + OptionDefinition::optional("default", OptionConstraint::Float), + ]; + } + + &DEFINITIONS + } + fn invert( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index 53ac500a..5bb7388b 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -35,6 +35,355 @@ use avenger_text::types::{FontStyle, FontWeight, TextAlign, TextBaseline}; use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; use coerce::{CastNumericCoercer, Coercer, NumericCoercer}; +/// Validation constraint for a scale option. +/// +/// This enum defines various validation rules that can be applied to scale options. +/// Scale implementations use these constraints to validate their configuration options +/// before processing data. +/// +/// # Example +/// ```ignore +/// // In a scale implementation: +/// fn validate_options(&self, config: &ScaleConfig) -> Result<(), AvengerScaleError> { +/// let definitions = vec![ +/// OptionDefinition::optional("base", OptionConstraint::PositiveFloat), +/// OptionDefinition::optional("clamp", OptionConstraint::Boolean), +/// OptionDefinition::optional("nice", OptionConstraint::nice()), +/// ]; +/// +/// OptionDefinition::validate_all(&definitions, &config.options) +/// } +/// ``` +#[derive(Debug, Clone)] +pub enum OptionConstraint { + /// Option must be a boolean + Boolean, + /// Option must be a float + Float, + /// Option must be an integer + Integer, + /// Option must be a string + String, + /// Option must be a float within a range (inclusive) + FloatRange { min: f32, max: f32 }, + /// Option must be an integer within a range (inclusive) + IntegerRange { min: i32, max: i32 }, + /// Option must be one of the specified string values + StringEnum { values: Vec }, + /// Option must be a positive float (> 0) + PositiveFloat, + /// Option must be a non-negative float (>= 0) + NonNegativeFloat, + /// Option must be a positive integer (> 0) + PositiveInteger, + /// Option must be a non-negative integer (>= 0) + NonNegativeInteger, + /// Custom validation function + Custom { + description: String, + validator: fn(&Scalar) -> Result<(), String>, + }, +} + +impl OptionConstraint { + /// Constraint for 'nice' option that accepts boolean or numeric values + pub fn nice() -> Self { + Self::Custom { + description: "boolean or number".to_string(), + validator: |value| { + if value.as_boolean().is_ok() || value.as_f32().is_ok() { + Ok(()) + } else { + Err("must be a boolean or numeric value".to_string()) + } + }, + } + } + + /// Constraint for base option in logarithmic scales (must be positive and not 1) + pub fn log_base() -> Self { + Self::Custom { + description: "positive number not equal to 1".to_string(), + validator: |value| match value.as_f32() { + Ok(v) if v > 0.0 && v != 1.0 => Ok(()), + Ok(v) => Err(format!("must be positive and not equal to 1 (got {})", v)), + Err(_) => Err("must be a numeric value".to_string()), + }, + } + } +} + +/// Definition of a scale option with its name and validation constraints. +/// +/// This struct represents a single configuration option for a scale, including +/// its name, validation constraint, and whether it's required. Scale implementations +/// use these definitions to automatically validate their configuration options. +/// +/// # Example +/// ```ignore +/// let definitions = vec![ +/// OptionDefinition::required("domain", OptionConstraint::Float), +/// OptionDefinition::optional("clamp", OptionConstraint::Boolean), +/// OptionDefinition::optional("nice", OptionConstraint::nice()), +/// ]; +/// ``` +#[derive(Debug, Clone)] +pub struct OptionDefinition { + pub name: String, + pub constraint: OptionConstraint, + pub required: bool, +} + +impl OptionDefinition { + /// Create a new required option definition. + /// + /// # Arguments + /// * `name` - The name of the option + /// * `constraint` - The validation constraint to apply + /// + /// # Returns + /// A new `OptionDefinition` with `required` set to `true` + pub fn required(name: impl Into, constraint: OptionConstraint) -> Self { + Self { + name: name.into(), + constraint, + required: true, + } + } + + /// Create a new optional option definition. + /// + /// # Arguments + /// * `name` - The name of the option + /// * `constraint` - The validation constraint to apply + /// + /// # Returns + /// A new `OptionDefinition` with `required` set to `false` + pub fn optional(name: impl Into, constraint: OptionConstraint) -> Self { + Self { + name: name.into(), + constraint, + required: false, + } + } + + /// Validate a set of options against a list of option definitions. + /// + /// This method checks that: + /// 1. All provided options are defined in the definitions list + /// 2. All required options are present + /// 3. All option values satisfy their constraints + /// + /// # Arguments + /// * `definitions` - The list of valid option definitions + /// * `options` - The options to validate + /// + /// # Returns + /// * `Ok(())` if all validations pass + /// * `Err(AvengerScaleError)` if any validation fails + /// + /// # Example + /// ```ignore + /// let definitions = vec![ + /// OptionDefinition::optional("clamp", OptionConstraint::Boolean), + /// OptionDefinition::optional("round", OptionConstraint::Boolean), + /// ]; + /// OptionDefinition::validate_all(&definitions, &config.options)?; + /// ``` + pub fn validate_all( + definitions: &[OptionDefinition], + options: &HashMap, + ) -> Result<(), AvengerScaleError> { + // Check for unknown options + for key in options.keys() { + if !definitions.iter().any(|def| def.name == *key) { + return Err(AvengerScaleError::InvalidScalePropertyValue(format!( + "Unknown option '{}'. Valid options are: {}", + key, + definitions + .iter() + .map(|d| d.name.as_str()) + .collect::>() + .join(", ") + ))); + } + } + + // Validate each defined option + for def in definitions { + if let Some(value) = options.get(&def.name) { + def.validate(value)?; + } else if def.required { + return Err(AvengerScaleError::InvalidScalePropertyValue(format!( + "Required option '{}' is missing", + def.name + ))); + } + } + Ok(()) + } + + /// Validate a value against this option's constraint. + /// + /// # Arguments + /// * `value` - The value to validate + /// + /// # Returns + /// * `Ok(())` if the value satisfies the constraint + /// * `Err(AvengerScaleError)` if the value doesn't satisfy the constraint + pub fn validate(&self, value: &Scalar) -> Result<(), AvengerScaleError> { + match &self.constraint { + OptionConstraint::Boolean => value.as_boolean().map(|_| ()).map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be a boolean value", + self.name + )) + }), + OptionConstraint::Float => value.as_f32().map(|_| ()).map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be a float value", + self.name + )) + }), + OptionConstraint::Integer => value.as_i32().map(|_| ()).map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be an integer value", + self.name + )) + }), + OptionConstraint::String => value.as_string().map(|_| ()).map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be a string value", + self.name + )) + }), + OptionConstraint::FloatRange { min, max } => { + let v = value.as_f32().map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be a float value", + self.name + )) + })?; + if v < *min || v > *max { + Err(AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be between {} and {} (got {})", + self.name, min, max, v + ))) + } else { + Ok(()) + } + } + OptionConstraint::IntegerRange { min, max } => { + let v = value.as_i32().map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be an integer value", + self.name + )) + })?; + if v < *min || v > *max { + Err(AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be between {} and {} (got {})", + self.name, min, max, v + ))) + } else { + Ok(()) + } + } + OptionConstraint::StringEnum { values } => { + let v = value.as_string().map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be a string value", + self.name + )) + })?; + if !values.contains(&v) { + Err(AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be one of: {} (got '{}')", + self.name, + values.join(", "), + v + ))) + } else { + Ok(()) + } + } + OptionConstraint::PositiveFloat => { + let v = value.as_f32().map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be a float value", + self.name + )) + })?; + if v <= 0.0 { + Err(AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be positive (got {})", + self.name, v + ))) + } else { + Ok(()) + } + } + OptionConstraint::NonNegativeFloat => { + let v = value.as_f32().map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be a float value", + self.name + )) + })?; + if v < 0.0 { + Err(AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be non-negative (got {})", + self.name, v + ))) + } else { + Ok(()) + } + } + OptionConstraint::PositiveInteger => { + let v = value.as_i32().map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be an integer value", + self.name + )) + })?; + if v <= 0 { + Err(AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be positive (got {})", + self.name, v + ))) + } else { + Ok(()) + } + } + OptionConstraint::NonNegativeInteger => { + let v = value.as_i32().map_err(|_| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be an integer value", + self.name + )) + })?; + if v < 0 { + Err(AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' must be non-negative (got {})", + self.name, v + ))) + } else { + Ok(()) + } + } + OptionConstraint::Custom { + description, + validator, + } => validator(value).map_err(|err| { + AvengerScaleError::InvalidScalePropertyValue(format!( + "Option '{}' validation failed ({}): {}", + self.name, description, err + )) + }), + } + } +} + /// Macro to generate scale_to_X trait methods that return a default error implementation #[macro_export] macro_rules! declare_enum_scale_method { @@ -180,6 +529,62 @@ pub trait ScaleImpl: Debug + Send + Sync + 'static { /// Method that should be used to infer a scale's domain from the data that it will scale fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod; + /// Return the option definitions for this scale. + /// + /// This method should return a slice of `OptionDefinition` structs that describe + /// all valid options for this scale type. The default implementation returns + /// an empty slice, indicating that the scale has no configurable options. + /// + /// Scale implementations should override this method to define their supported + /// options. The `validate_options` method will automatically use these definitions + /// to validate the scale configuration. + /// + /// # Example + /// ```ignore + /// fn option_definitions(&self) -> &[OptionDefinition] { + /// static DEFINITIONS: &[OptionDefinition] = &[ + /// OptionDefinition::optional("clamp", OptionConstraint::Boolean), + /// OptionDefinition::optional("round", OptionConstraint::Boolean), + /// OptionDefinition::optional("nice", OptionConstraint::nice()), + /// ]; + /// DEFINITIONS + /// } + /// ``` + fn option_definitions(&self) -> &[OptionDefinition] { + &[] + } + + /// Validate scale options. + /// + /// The default implementation automatically validates options against the + /// definitions returned by `option_definitions()`. Scale implementations + /// typically don't need to override this method unless they have special + /// validation requirements beyond what `OptionDefinition` provides. + /// + /// This method is automatically called before scaling operations. + /// + /// # Implementation Note + /// If you need custom validation beyond what `OptionDefinition` provides, + /// you can override this method, but make sure to call the default + /// implementation first: + /// ```ignore + /// fn validate_options(&self, config: &ScaleConfig) -> Result<(), AvengerScaleError> { + /// // First run the standard validation + /// OptionDefinition::validate_all(self.option_definitions(), &config.options)?; + /// + /// // Then add custom validation + /// if some_custom_condition { + /// return Err(AvengerScaleError::InvalidScalePropertyValue( + /// "Custom validation failed".to_string() + /// )); + /// } + /// Ok(()) + /// } + /// ``` + fn validate_options(&self, config: &ScaleConfig) -> Result<(), AvengerScaleError> { + OptionDefinition::validate_all(self.option_definitions(), &config.options) + } + fn scale( &self, _config: &ScaleConfig, @@ -207,6 +612,9 @@ pub trait ScaleImpl: Debug + Send + Sync + 'static { config: &ScaleConfig, values: &ArrayRef, ) -> Result, AvengerScaleError> { + // Validate options before scaling + self.validate_options(config)?; + let scaled = self.scale(config, values)?; let coercer = &config.context.numeric_coercer; let default = config.option_f32("default", f32::NAN); @@ -272,6 +680,9 @@ pub trait ScaleImpl: Debug + Send + Sync + 'static { config: &ScaleConfig, values: &ArrayRef, ) -> Result, AvengerScaleError> { + // Validate options before scaling + self.validate_options(config)?; + let scaled = self.scale(config, values)?; let coercer = &config.context.color_coercer; let default = config @@ -298,6 +709,9 @@ pub trait ScaleImpl: Debug + Send + Sync + 'static { config: &ScaleConfig, values: &ArrayRef, ) -> Result, AvengerScaleError> { + // Validate options before scaling + self.validate_options(config)?; + let scaled = self.scale(config, values)?; let formatter = &config.context.formatters; let default = config.option_string("default", ""); @@ -540,6 +954,8 @@ impl ConfiguredScale { } pub fn scale(&self, values: &ArrayRef) -> Result { + // Validate options before scaling + self.scale_impl.validate_options(&self.config)?; self.scale_impl.scale(&self.config, values) } diff --git a/avenger-scales/src/scales/ordinal.rs b/avenger-scales/src/scales/ordinal.rs index 6e7fe4d2..7243cb08 100644 --- a/avenger-scales/src/scales/ordinal.rs +++ b/avenger-scales/src/scales/ordinal.rs @@ -1,7 +1,11 @@ use std::{collections::HashMap, sync::Arc}; -use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +use super::{ + ConfiguredScale, InferDomainFromDataMethod, OptionDefinition, ScaleConfig, ScaleContext, + ScaleImpl, +}; use crate::error::AvengerScaleError; +use lazy_static::lazy_static; use crate::scalar::Scalar; use arrow::{ @@ -70,6 +74,18 @@ impl ScaleImpl for OrdinalScale { InferDomainFromDataMethod::Unique } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + // Ordinal scale supports no custom options currently + // But default option is allowed for consistency + OptionDefinition::optional("default", super::OptionConstraint::String), + ]; + } + + &DEFINITIONS + } + fn scale( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/point.rs b/avenger-scales/src/scales/point.rs index 549a10e2..52fb1875 100644 --- a/avenger-scales/src/scales/point.rs +++ b/avenger-scales/src/scales/point.rs @@ -2,12 +2,13 @@ use std::sync::Arc; use arrow::array::{ArrayRef, Float32Array}; use avenger_common::value::ScalarOrArray; +use lazy_static::lazy_static; use crate::error::AvengerScaleError; use super::{ - band::BandScale, ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, - ScaleImpl, + band::BandScale, ConfiguredScale, InferDomainFromDataMethod, OptionConstraint, + OptionDefinition, ScaleConfig, ScaleContext, ScaleImpl, }; /// Point scale that maps discrete domain values to evenly-spaced points along a continuous range. @@ -63,6 +64,22 @@ impl ScaleImpl for PointScale { InferDomainFromDataMethod::Unique } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + OptionDefinition::optional( + "align", + OptionConstraint::FloatRange { min: 0.0, max: 1.0 } + ), + OptionDefinition::optional("padding", OptionConstraint::NonNegativeFloat), + OptionDefinition::optional("round", OptionConstraint::Boolean), + OptionDefinition::optional("range_offset", OptionConstraint::Float), + ]; + } + + &DEFINITIONS + } + fn scale( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/pow.rs b/avenger-scales/src/scales/pow.rs index 74d60e68..6ab561f8 100644 --- a/avenger-scales/src/scales/pow.rs +++ b/avenger-scales/src/scales/pow.rs @@ -10,12 +10,13 @@ use avenger_common::{ types::ColorOrGradient, value::{ScalarOrArray, ScalarOrArrayValue}, }; +use lazy_static::lazy_static; use crate::{array, color_interpolator::scale_numeric_to_color, error::AvengerScaleError}; use super::{ - linear::LinearScale, ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, - ScaleImpl, + linear::LinearScale, ConfiguredScale, InferDomainFromDataMethod, OptionConstraint, + OptionDefinition, ScaleConfig, ScaleContext, ScaleImpl, }; /// Power scale that maps a continuous numeric domain to a continuous numeric range @@ -118,6 +119,22 @@ impl ScaleImpl for PowScale { InferDomainFromDataMethod::Interval } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + OptionDefinition::optional("exponent", OptionConstraint::Float), + OptionDefinition::optional("clamp", OptionConstraint::Boolean), + OptionDefinition::optional("range_offset", OptionConstraint::Float), + OptionDefinition::optional("round", OptionConstraint::Boolean), + OptionDefinition::optional("nice", OptionConstraint::nice()), + OptionDefinition::optional("zero", OptionConstraint::Boolean), + OptionDefinition::optional("default", OptionConstraint::Float), + ]; + } + + &DEFINITIONS + } + fn invert( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/quantile.rs b/avenger-scales/src/scales/quantile.rs index 7ac9125a..93e571ba 100644 --- a/avenger-scales/src/scales/quantile.rs +++ b/avenger-scales/src/scales/quantile.rs @@ -8,10 +8,14 @@ use arrow::{ }, datatypes::{DataType, Float32Type}, }; +use lazy_static::lazy_static; use crate::error::AvengerScaleError; -use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +use super::{ + ConfiguredScale, InferDomainFromDataMethod, OptionDefinition, ScaleConfig, ScaleContext, + ScaleImpl, +}; /// Quantile scale that maps continuous numeric input values to discrete range values /// using quantile boundaries computed from the domain. @@ -50,6 +54,18 @@ impl ScaleImpl for QuantileScale { InferDomainFromDataMethod::Unique } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + // Quantile scale supports no custom options currently + // But default option is allowed for consistency + OptionDefinition::optional("default", super::OptionConstraint::String), + ]; + } + + &DEFINITIONS + } + fn scale( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/quantize.rs b/avenger-scales/src/scales/quantize.rs index 04f2de16..ddd7d758 100644 --- a/avenger-scales/src/scales/quantize.rs +++ b/avenger-scales/src/scales/quantize.rs @@ -6,12 +6,13 @@ use arrow::{ compute::kernels::take, datatypes::Float32Type, }; +use lazy_static::lazy_static; use crate::error::AvengerScaleError; use super::{ - linear::LinearScale, ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, - ScaleImpl, + linear::LinearScale, ConfiguredScale, InferDomainFromDataMethod, OptionConstraint, + OptionDefinition, ScaleConfig, ScaleContext, ScaleImpl, }; /// Quantize scale that divides a continuous numeric domain into uniform segments, @@ -80,6 +81,18 @@ impl ScaleImpl for QuantizeScale { InferDomainFromDataMethod::Unique } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + OptionDefinition::optional("nice", OptionConstraint::nice()), + OptionDefinition::optional("zero", OptionConstraint::Boolean), + OptionDefinition::optional("default", OptionConstraint::String), + ]; + } + + &DEFINITIONS + } + fn scale( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/symlog.rs b/avenger-scales/src/scales/symlog.rs index e8f520fc..795b4658 100644 --- a/avenger-scales/src/scales/symlog.rs +++ b/avenger-scales/src/scales/symlog.rs @@ -7,12 +7,13 @@ use arrow::{ datatypes::{DataType, Float32Type}, }; use avenger_common::value::{ScalarOrArray, ScalarOrArrayValue}; +use lazy_static::lazy_static; use crate::{array, error::AvengerScaleError}; use super::{ - linear::LinearScale, ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, - ScaleImpl, + linear::LinearScale, ConfiguredScale, InferDomainFromDataMethod, OptionConstraint, + OptionDefinition, ScaleConfig, ScaleContext, ScaleImpl, }; /// Symmetric log scale that provides smooth linear-to-logarithmic transitions for data @@ -112,6 +113,22 @@ impl ScaleImpl for SymlogScale { InferDomainFromDataMethod::Interval } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + OptionDefinition::optional("constant", OptionConstraint::PositiveFloat), + OptionDefinition::optional("clamp", OptionConstraint::Boolean), + OptionDefinition::optional("range_offset", OptionConstraint::Float), + OptionDefinition::optional("round", OptionConstraint::Boolean), + OptionDefinition::optional("nice", OptionConstraint::nice()), + OptionDefinition::optional("zero", OptionConstraint::Boolean), + OptionDefinition::optional("default", OptionConstraint::Float), + ]; + } + + &DEFINITIONS + } + fn invert( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/threshold.rs b/avenger-scales/src/scales/threshold.rs index bc3e9d58..e591637a 100644 --- a/avenger-scales/src/scales/threshold.rs +++ b/avenger-scales/src/scales/threshold.rs @@ -5,10 +5,14 @@ use arrow::{ compute::kernels::{cast, take}, datatypes::{DataType, Float32Type}, }; +use lazy_static::lazy_static; use crate::error::AvengerScaleError; -use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +use super::{ + ConfiguredScale, InferDomainFromDataMethod, OptionDefinition, ScaleConfig, ScaleContext, + ScaleImpl, +}; /// Threshold scale that maps continuous numeric input values to discrete range values /// based on a set of threshold boundaries. @@ -48,6 +52,18 @@ impl ScaleImpl for ThresholdScale { InferDomainFromDataMethod::Unique } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + // Threshold scale supports no custom options currently + // But default option is allowed for consistency + OptionDefinition::optional("default", super::OptionConstraint::String), + ]; + } + + &DEFINITIONS + } + fn scale( &self, config: &ScaleConfig, diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index a65db9a0..d1a77f35 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -11,11 +11,15 @@ use avenger_common::types::LinearScaleAdjustment; use avenger_common::value::{ScalarOrArray, ScalarOrArrayValue}; use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Timelike, Utc}; use chrono_tz::Tz; +use lazy_static::lazy_static; use crate::error::AvengerScaleError; use crate::formatter::{DateFormatter, TimestampFormatter, TimestamptzFormatter}; -use super::{ConfiguredScale, InferDomainFromDataMethod, ScaleConfig, ScaleContext, ScaleImpl}; +use super::{ + ConfiguredScale, InferDomainFromDataMethod, OptionConstraint, OptionDefinition, ScaleConfig, + ScaleContext, ScaleImpl, +}; /// Time scale for temporal data visualization. /// @@ -506,6 +510,34 @@ impl ScaleImpl for TimeScale { InferDomainFromDataMethod::Interval } + fn option_definitions(&self) -> &[OptionDefinition] { + lazy_static! { + static ref DEFINITIONS: Vec = vec![ + OptionDefinition::optional("timezone", OptionConstraint::String), + OptionDefinition::optional("nice", OptionConstraint::nice()), + OptionDefinition::optional("interval", OptionConstraint::String), + OptionDefinition::optional( + "week_start", + OptionConstraint::StringEnum { + values: vec![ + "sunday".to_string(), + "monday".to_string(), + "tuesday".to_string(), + "wednesday".to_string(), + "thursday".to_string(), + "friday".to_string(), + "saturday".to_string() + ] + } + ), + OptionDefinition::optional("locale", OptionConstraint::String), + OptionDefinition::optional("default", OptionConstraint::String), + ]; + } + + &DEFINITIONS + } + fn scale( &self, config: &ScaleConfig, @@ -772,10 +804,8 @@ impl ScaleImpl for TimeScale { Some(scalar) => { if let Ok(true) = scalar.as_boolean() { 10.0 - } else if let Ok(count) = scalar.as_f32() { - count } else { - 10.0 + scalar.as_f32().unwrap_or(10.0) } } None => 10.0, @@ -1981,7 +2011,7 @@ mod tests { .with_option("timezone", tz_str); // Test scaling values across the DST gap - let test_times = vec![ + let test_times = [ et.with_ymd_and_hms(2024, 3, 10, 0, 0, 0).unwrap(), // Start et.with_ymd_and_hms(2024, 3, 10, 1, 0, 0).unwrap(), // Before gap et.with_ymd_and_hms(2024, 3, 10, 3, 0, 0).unwrap(), // After gap (2AM skipped) @@ -2014,8 +2044,8 @@ mod tests { .unwrap(); // Check that inverted values match original times (approximately) - for i in 0..test_times.len() { - let diff = (inverted_array.value(i) - test_times[i].timestamp_millis()).abs(); + for (i, test_time) in test_times.iter().enumerate() { + let diff = (inverted_array.value(i) - test_time.timestamp_millis()).abs(); assert!(diff < 60000, "Inverted time differs by more than 1 minute"); // Within 1 minute } From bb1f172249426c4da285dda7a430b2d0e2339901 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 19 Jul 2025 14:17:34 -0400 Subject: [PATCH 21/29] feat: Add padding support for quantitative scales Implements Vega-compatible padding for linear, log, pow, and symlog scales. Padding expands the scale domain by the specified number of pixels on each side of the range, applied before zero and nice transformations. - Add padding option (NonNegativeFloat) to all quantitative scales - Implement scale-specific padding algorithms: - Linear: Direct domain expansion from center - Log: Transform to log space, apply padding, transform back - Pow: Transform to power space, apply padding, transform back - Symlog: Transform to symlog space, apply padding, transform back - Update symlog transforms to use ln_1p/exp_m1 for numerical stability - Reset padding to 0 in normalized scales to prevent double application - Handle non-numeric ranges gracefully by ignoring padding - Add comprehensive tests for all scale types The implementation matches Vega's behavior where padding expands the domain to accommodate pixel margins before other domain adjustments. --- Cargo.lock | 1 + avenger-scales/src/scales/linear.rs | 205 ++++++++++++++++-- avenger-scales/src/scales/log.rs | 273 ++++++++++++++++++++++-- avenger-scales/src/scales/mod.rs | 1 + avenger-scales/src/scales/pow.rs | 195 ++++++++++++++++- avenger-scales/src/scales/quantize.rs | 3 +- avenger-scales/src/scales/symlog.rs | 247 ++++++++++++++++++++- avenger-scales/src/scales/time.rs | 4 +- avenger-scales/tests/validation_test.rs | 145 +++++++++++++ 9 files changed, 1026 insertions(+), 48 deletions(-) create mode 100644 avenger-scales/tests/validation_test.rs diff --git a/Cargo.lock b/Cargo.lock index cc6e2b7d..4fc2ddd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,6 +483,7 @@ dependencies = [ "css-color-parser", "float-cmp 0.10.0", "indexmap", + "lazy_static 1.5.0", "lyon_extra", "lyon_path", "ordered-float 5.0.0", diff --git a/avenger-scales/src/scales/linear.rs b/avenger-scales/src/scales/linear.rs index c660e144..c4e796ae 100644 --- a/avenger-scales/src/scales/linear.rs +++ b/avenger-scales/src/scales/linear.rs @@ -41,6 +41,10 @@ use super::{ /// - **zero** (boolean, default: false): When true, ensures that the domain includes zero. If both min and max /// are positive, sets min to zero. If both min and max are negative, sets max to zero. If the domain already /// spans zero, no change is made. Zero extension is applied before nice calculations. +/// +/// - **padding** (f32, default: 0.0): Expands the scale domain to accommodate the specified number of pixels +/// on each of the scale range. The scale range must represent pixels for this parameter to function as intended. +/// Padding adjustment is performed prior to all other adjustments, including the effects of the zero and nice properties. #[derive(Debug)] pub struct LinearScale; @@ -83,9 +87,11 @@ impl LinearScale { } } - /// Apply normalization (zero and nice) to domain + /// Apply normalization (padding, zero and nice) to domain pub fn apply_normalization( domain: (f32, f32), + range: (f32, f32), + padding: Option<&Scalar>, zero: Option<&Scalar>, nice: Option<&Scalar>, ) -> Result<(f32, f32), AvengerScaleError> { @@ -96,7 +102,18 @@ impl LinearScale { return Ok(domain); } - // Step 1: Apply zero extension if requested + // Step 1: Apply padding if requested (before all other adjustments) + if let Some(padding_option) = padding { + let padding_value = padding_option.as_f32()?; + if padding_value > 0.0 { + let (padded_start, padded_end) = + Self::apply_padding((domain_start, domain_end), range, padding_value)?; + domain_start = padded_start; + domain_end = padded_end; + } + } + + // Step 2: Apply zero extension if requested if let Some(zero_option) = zero { if let Ok(true) = zero_option.as_boolean() { if domain_start > 0.0 && domain_end > 0.0 { @@ -110,7 +127,7 @@ impl LinearScale { } } - // Step 2: Apply nice transformation if requested + // Step 3: Apply nice transformation if requested let nice_count = if let Some(count) = nice { if count.array().data_type().is_numeric() { Some(count.as_f32()?) @@ -189,7 +206,38 @@ impl LinearScale { domain: (f32, f32), count: Option<&Scalar>, ) -> Result<(f32, f32), AvengerScaleError> { - Self::apply_normalization(domain, None, count) + Self::apply_normalization(domain, (0.0, 1.0), None, None, count) + } + + /// Apply padding to domain based on pixel values + pub fn apply_padding( + domain: (f32, f32), + range: (f32, f32), + padding: f32, + ) -> Result<(f32, f32), AvengerScaleError> { + let (domain_start, domain_end) = domain; + let (range_start, range_end) = range; + + // Early return for degenerate cases + if domain_start == domain_end || range_start == range_end || padding <= 0.0 { + return Ok(domain); + } + + // Calculate the span of the range in pixels + let span = (range_end - range_start).abs(); + + // Calculate scale factor: frac = span / (span - 2 * pad) + // This represents how much to expand the domain + let frac = span / (span - 2.0 * padding); + + // For linear scale, zoom from center (anchor = 0.5) + let domain_center = (domain_start + domain_end) / 2.0; + + // Expand domain by scale factor + let new_start = domain_center + (domain_start - domain_center) * frac; + let new_end = domain_center + (domain_end - domain_center) * frac; + + Ok((new_start, new_end)) } } @@ -211,6 +259,7 @@ impl ScaleImpl for LinearScale { OptionDefinition::optional("nice", OptionConstraint::nice()), OptionDefinition::optional("zero", OptionConstraint::Boolean), OptionDefinition::optional("default", OptionConstraint::Float), + OptionDefinition::optional("padding", OptionConstraint::NonNegativeFloat), ]; } @@ -246,8 +295,11 @@ impl ScaleImpl for LinearScale { config: &ScaleConfig, values: &ArrayRef, ) -> Result { + let (range_start, range_end) = config.numeric_interval_range()?; let (domain_start, domain_end) = LinearScale::apply_normalization( config.numeric_interval_domain()?, + (range_start, range_end), + config.options.get("padding"), config.options.get("zero"), config.options.get("nice"), )?; @@ -262,8 +314,6 @@ impl ScaleImpl for LinearScale { return scale_numeric_to_color(self, &config, values); } - let (range_start, range_end) = config.numeric_interval_range()?; - // Handle degenerate domain/range cases if domain_start == domain_end || range_start == range_end @@ -327,13 +377,14 @@ impl ScaleImpl for LinearScale { config: &ScaleConfig, values: &ArrayRef, ) -> Result, AvengerScaleError> { + let (range_start, range_end) = config.numeric_interval_range()?; let (domain_start, domain_end) = LinearScale::apply_normalization( config.numeric_interval_domain()?, + (range_start, range_end), + config.options.get("padding"), config.options.get("zero"), config.options.get("nice"), )?; - - let (range_start, range_end) = config.numeric_interval_range()?; let range_offset = config.option_f32("range_offset", 0.0); let clamp = config.option_boolean("clamp", false); @@ -388,8 +439,11 @@ impl ScaleImpl for LinearScale { config: &ScaleConfig, count: Option, ) -> Result { + let (range_start, range_end) = config.numeric_interval_range()?; let (domain_start, domain_end) = LinearScale::apply_normalization( config.numeric_interval_domain()?, + (range_start, range_end), + config.options.get("padding"), config.options.get("zero"), config.options.get("nice"), )?; @@ -504,8 +558,11 @@ impl ScaleImpl for LinearScale { } fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { + let (range_start, range_end) = config.numeric_interval_range()?; let (domain_start, domain_end) = LinearScale::apply_normalization( config.numeric_interval_domain()?, + (range_start, range_end), + config.options.get("padding"), config.options.get("zero"), config.options.get("nice"), )?; @@ -1027,25 +1084,149 @@ mod tests { #[test] fn test_apply_normalization_zero_only() -> Result<(), AvengerScaleError> { // Both positive - let result = LinearScale::apply_normalization((2.0, 10.0), Some(&true.into()), None)?; + let result = LinearScale::apply_normalization( + (2.0, 10.0), + (0.0, 1.0), + None, + Some(&true.into()), + None, + )?; assert_approx_eq!(f32, result.0, 0.0); assert_approx_eq!(f32, result.1, 10.0); // Both negative - let result = LinearScale::apply_normalization((-10.0, -2.0), Some(&true.into()), None)?; + let result = LinearScale::apply_normalization( + (-10.0, -2.0), + (0.0, 1.0), + None, + Some(&true.into()), + None, + )?; assert_approx_eq!(f32, result.0, -10.0); assert_approx_eq!(f32, result.1, 0.0); // Spans zero (no change) - let result = LinearScale::apply_normalization((-5.0, 5.0), Some(&true.into()), None)?; + let result = LinearScale::apply_normalization( + (-5.0, 5.0), + (0.0, 1.0), + None, + Some(&true.into()), + None, + )?; assert_approx_eq!(f32, result.0, -5.0); assert_approx_eq!(f32, result.1, 5.0); // Zero false (no change) - let result = LinearScale::apply_normalization((2.0, 10.0), Some(&false.into()), None)?; + let result = LinearScale::apply_normalization( + (2.0, 10.0), + (0.0, 1.0), + None, + Some(&false.into()), + None, + )?; assert_approx_eq!(f32, result.0, 2.0); assert_approx_eq!(f32, result.1, 10.0); Ok(()) } + + #[test] + fn test_apply_padding() -> Result<(), AvengerScaleError> { + // Test basic padding + let result = LinearScale::apply_padding((0.0, 10.0), (0.0, 100.0), 10.0)?; + // With padding of 10 pixels on each side and range of 100, scale factor = 100 / 80 = 1.25 + // Domain expands from center (5.0) by factor 1.25 + assert_approx_eq!(f32, result.0, -1.25); // 5 + (0 - 5) * 1.25 = -1.25 + assert_approx_eq!(f32, result.1, 11.25); // 5 + (10 - 5) * 1.25 = 11.25 + + // Test with negative domain + let result = LinearScale::apply_padding((-20.0, -10.0), (0.0, 100.0), 10.0)?; + // Center is -15, scale factor = 1.25 + assert_approx_eq!(f32, result.0, -21.25); // -15 + (-20 - -15) * 1.25 = -21.25 + assert_approx_eq!(f32, result.1, -8.75); // -15 + (-10 - -15) * 1.25 = -8.75 + + // Test with reversed range + let result = LinearScale::apply_padding((0.0, 10.0), (100.0, 0.0), 10.0)?; + // Range span is still 100, so same scale factor + assert_approx_eq!(f32, result.0, -1.25); + assert_approx_eq!(f32, result.1, 11.25); + + // Test with zero padding (no change) + let result = LinearScale::apply_padding((0.0, 10.0), (0.0, 100.0), 0.0)?; + assert_approx_eq!(f32, result.0, 0.0); + assert_approx_eq!(f32, result.1, 10.0); + + // Test with degenerate domain (no change) + let result = LinearScale::apply_padding((5.0, 5.0), (0.0, 100.0), 10.0)?; + assert_approx_eq!(f32, result.0, 5.0); + assert_approx_eq!(f32, result.1, 5.0); + + // Test with degenerate range (no change) + let result = LinearScale::apply_padding((0.0, 10.0), (50.0, 50.0), 10.0)?; + assert_approx_eq!(f32, result.0, 0.0); + assert_approx_eq!(f32, result.1, 10.0); + + Ok(()) + } + + #[test] + fn test_linear_scale_with_padding() -> Result<(), AvengerScaleError> { + // Create a linear scale with padding + let scale = LinearScale::configured((0.0, 10.0), (0.0, 100.0)).with_option("padding", 10.0); + + // Normalize the scale to apply padding + let normalized = scale.normalize()?; + + // Check that domain has been expanded + let domain = normalized.numeric_interval_domain()?; + assert_approx_eq!(f32, domain.0, -1.25); + assert_approx_eq!(f32, domain.1, 11.25); + + // Test scaling values + let values = Arc::new(Float32Array::from(vec![0.0, 5.0, 10.0])) as ArrayRef; + let result = normalized.scale(&values)?; + let result_array = result.as_primitive::(); + + // With expanded domain, the original values should map differently + // Domain is [-1.25, 11.25], range is [0, 100] + // Scale factor = 100 / 12.5 = 8 + // 0.0 -> (0 - -1.25) * 8 = 10 + // 5.0 -> (5 - -1.25) * 8 = 50 + // 10.0 -> (10 - -1.25) * 8 = 90 + + // The scale operation with expanded domain: + // Domain is [-1.25, 11.25], range is [0, 100] + // Scale factor = 100 / 12.5 = 8 + // 0.0 -> (0 - -1.25) * 8 = 10 + // 5.0 -> (5 - -1.25) * 8 = 50 + // 10.0 -> (10 - -1.25) * 8 = 90 + assert_approx_eq!(f32, result_array.value(0), 10.0); + assert_approx_eq!(f32, result_array.value(1), 50.0); + assert_approx_eq!(f32, result_array.value(2), 90.0); + + Ok(()) + } + + #[test] + fn test_padding_with_zero_and_nice() -> Result<(), AvengerScaleError> { + // Test that transformations are applied in order: padding -> zero -> nice + let scale = LinearScale::configured((2.0, 10.0), (0.0, 100.0)) + .with_option("padding", 9.0) + .with_option("zero", true) + .with_option("nice", true); + + let normalized = scale.normalize()?; + let domain = normalized.numeric_interval_domain()?; + + // Expected transformations: + assert_approx_eq!(f32, domain.0, 0.0); + assert_approx_eq!(f32, domain.1, 11.0); + + // Verify all normalization options are disabled + assert_eq!(normalized.option_f32("padding", -1.0), 0.0); + assert!(!normalized.option_boolean("zero", true)); + assert!(!normalized.option_boolean("nice", true)); + + Ok(()) + } } diff --git a/avenger-scales/src/scales/log.rs b/avenger-scales/src/scales/log.rs index f7982910..034e2d22 100644 --- a/avenger-scales/src/scales/log.rs +++ b/avenger-scales/src/scales/log.rs @@ -42,6 +42,10 @@ use super::{ /// default count of 10. If a number, uses that as the target tick count. For example, /// with base 10, a domain of [8, 95] might become [1, 100]. /// +/// - **padding** (f32, default: 0.0): Expands the scale domain by the specified number of pixels +/// on each side of the scale range. The domain expansion is computed in logarithmic space. +/// Applied before zero and nice transformations. Must be non-negative. +/// /// - **zero** (boolean, default: false): When true, ensures that the domain includes zero. However, zero /// is invalid for logarithmic scales, so this option is ignored for log scales. #[derive(Debug)] @@ -61,6 +65,7 @@ impl LogScale { ("round".to_string(), false.into()), ("nice".to_string(), false.into()), ("zero".to_string(), false.into()), + ("padding".to_string(), 0.0.into()), ] .into_iter() .collect(), @@ -146,16 +151,78 @@ impl LogScale { Ok((domain_start, domain_end)) } - /// Apply normalization (zero and nice) to domain + /// Apply padding to a log scale domain + /// Transforms to log space, applies linear padding, then transforms back + pub fn apply_padding( + domain: (f32, f32), + range: (f32, f32), + padding: f32, + base: f32, + ) -> Result<(f32, f32), AvengerScaleError> { + let (domain_start, domain_end) = domain; + let (range_start, range_end) = range; + + // Early return for degenerate cases + if domain_start == domain_end || range_start == range_end || padding <= 0.0 { + return Ok(domain); + } + + // Handle domains that include zero or negative values + if domain_start <= 0.0 || domain_end <= 0.0 { + // Can't apply padding to domains that include zero for log scales + return Ok(domain); + } + + let log_fun = LogFunction::new(base); + + // Transform to log space + let log_start = log_fun.log(domain_start); + let log_end = log_fun.log(domain_end); + + // Calculate the span of the range in pixels + let span = (range_end - range_start).abs(); + + // Calculate scale factor: frac = span / (span - 2 * pad) + let frac = span / (span - 2.0 * padding); + + // For log scale, zoom from center in log space + let log_center = (log_start + log_end) / 2.0; + + // Expand domain in log space by scale factor + let new_log_start = log_center + (log_start - log_center) * frac; + let new_log_end = log_center + (log_end - log_center) * frac; + + // Transform back to linear space + let new_start = log_fun.pow(new_log_start); + let new_end = log_fun.pow(new_log_end); + + Ok((new_start, new_end)) + } + + /// Apply normalization (padding, zero and nice) to domain /// For log scales, zero is ignored since it's invalid in logarithmic space pub fn apply_normalization( domain: (f32, f32), + range: (f32, f32), + padding: Option<&Scalar>, base: f32, _zero: Option<&Scalar>, // Zero is ignored for log scales nice: Option<&Scalar>, ) -> Result<(f32, f32), AvengerScaleError> { + let mut current_domain = domain; + + // Apply padding first + if let Some(padding) = padding { + if let Ok(padding_value) = padding.as_f32() { + if padding_value > 0.0 { + current_domain = + Self::apply_padding(current_domain, range, padding_value, base)?; + } + } + } + // For log scales, zero is invalid, so we only apply nice transformation - Self::apply_nice(domain, base, nice) + Self::apply_nice(current_domain, base, nice) } } @@ -176,6 +243,7 @@ impl ScaleImpl for LogScale { OptionDefinition::optional("range_offset", OptionConstraint::Float), OptionDefinition::optional("round", OptionConstraint::Boolean), OptionDefinition::optional("nice", OptionConstraint::nice()), + OptionDefinition::optional("padding", OptionConstraint::NonNegativeFloat), OptionDefinition::optional("zero", OptionConstraint::Boolean), OptionDefinition::optional("default", OptionConstraint::Float), ]; @@ -214,8 +282,14 @@ impl ScaleImpl for LogScale { values: &ArrayRef, ) -> Result { let base = config.option_f32("base", 10.0); + + // Get range for padding calculation, use dummy range if not numeric + let range_for_padding = config.numeric_interval_range().unwrap_or((0.0, 1.0)); + let (domain_start, domain_end) = LogScale::apply_normalization( config.numeric_interval_domain()?, + range_for_padding, + config.options.get("padding"), base, config.options.get("zero"), config.options.get("nice"), @@ -223,7 +297,7 @@ impl ScaleImpl for LogScale { // Check if color interpolation is needed if config.color_range().is_ok() { - // Create new config with niced domain + // Create new config with normalized domain let config = ScaleConfig { domain: Arc::new(Float32Array::from(vec![domain_start, domain_end])), ..config.clone() @@ -231,20 +305,13 @@ impl ScaleImpl for LogScale { return scale_numeric_to_color(self, &config, values); } + let (range_start, range_end) = config.numeric_interval_range()?; + // Get options - let base = config.option_f32("base", 10.0); let range_offset = config.option_f32("range_offset", 0.0); let clamp = config.option_boolean("clamp", false); let round = config.option_boolean("round", false); - // Get options - let (range_start, range_end) = config.numeric_interval_range()?; - let (domain_start, domain_end) = LogScale::apply_nice( - config.numeric_interval_domain()?, - base, - config.options.get("nice"), - )?; - // Handle degenerate domain and range cases if domain_start == domain_end || range_start == range_end { return Ok(Arc::new(Float32Array::from(vec![ @@ -434,9 +501,12 @@ impl ScaleImpl for LogScale { let _round = config.option_boolean("round", false); let (range_start, range_end) = config.numeric_interval_range()?; - let (domain_start, domain_end) = LogScale::apply_nice( + let (domain_start, domain_end) = LogScale::apply_normalization( config.numeric_interval_domain()?, + (range_start, range_end), + config.options.get("padding"), base, + config.options.get("zero"), config.options.get("nice"), )?; let (range_min, range_max) = if range_start <= range_end { @@ -605,8 +675,12 @@ impl ScaleImpl for LogScale { fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { let base = config.option_f32("base", 10.0); + // Get range for padding calculation, use dummy range if not numeric + let range_for_padding = config.numeric_interval_range().unwrap_or((0.0, 1.0)); let (domain_start, domain_end) = LogScale::apply_normalization( config.numeric_interval_domain()?, + range_for_padding, + config.options.get("padding"), base, config.options.get("zero"), config.options.get("nice"), @@ -689,7 +763,7 @@ mod tests { use std::collections::HashMap; use super::*; - use float_cmp::assert_approx_eq; + use float_cmp::{assert_approx_eq, F32Margin}; #[test] fn test_basic_scale_invert() { @@ -990,4 +1064,175 @@ mod tests { Ok(()) } + + #[test] + fn test_apply_padding() -> Result<(), AvengerScaleError> { + // Test basic padding with base 10 + let result = LogScale::apply_padding((10.0, 100.0), (0.0, 100.0), 10.0, 10.0)?; + // Domain [10, 100] in log space is [1, 2] + // With padding 10 on range 100, scale factor = 100 / 80 = 1.25 + // Center in log space is 1.5 + // New log domain: [1.5 + (1 - 1.5) * 1.25, 1.5 + (2 - 1.5) * 1.25] = [0.875, 2.125] + // Back to linear: [10^0.875, 10^2.125] ≈ [7.498, 133.35] + assert_approx_eq!( + f32, + result.0, + 7.498942, + F32Margin { + epsilon: 0.001, + ..Default::default() + } + ); + assert_approx_eq!( + f32, + result.1, + 133.35214, + F32Margin { + epsilon: 0.001, + ..Default::default() + } + ); + + // Test with base 2 + let result = LogScale::apply_padding((4.0, 16.0), (0.0, 100.0), 10.0, 2.0)?; + // Domain [4, 16] in log2 space is [2, 4] + // Center in log2 space is 3 + // New log domain: [3 + (2 - 3) * 1.25, 3 + (4 - 3) * 1.25] = [1.75, 4.25] + // Back to linear: [2^1.75, 2^4.25] = [3.364, 19.027] + assert_approx_eq!( + f32, + result.0, + 3.3635857, + F32Margin { + epsilon: 0.001, + ..Default::default() + } + ); + assert_approx_eq!( + f32, + result.1, + 19.027313, + F32Margin { + epsilon: 0.001, + ..Default::default() + } + ); + + // Test with zero padding (no change) + let result = LogScale::apply_padding((10.0, 100.0), (0.0, 100.0), 0.0, 10.0)?; + assert_approx_eq!(f32, result.0, 10.0); + assert_approx_eq!(f32, result.1, 100.0); + + // Test with domain including zero (no change) + let result = LogScale::apply_padding((0.0, 100.0), (0.0, 100.0), 10.0, 10.0)?; + assert_approx_eq!(f32, result.0, 0.0); + assert_approx_eq!(f32, result.1, 100.0); + + // Test with negative domain (no change) + let result = LogScale::apply_padding((-100.0, -10.0), (0.0, 100.0), 10.0, 10.0)?; + assert_approx_eq!(f32, result.0, -100.0); + assert_approx_eq!(f32, result.1, -10.0); + + Ok(()) + } + + #[test] + fn test_log_scale_with_padding() -> Result<(), AvengerScaleError> { + // Create a log scale with padding + let scale = LogScale::configured((10.0, 100.0), (0.0, 100.0)) + .with_option("padding", 10.0) + .with_option("base", 10.0); + + // Normalize the scale to apply padding + let normalized = scale.normalize()?; + + // Check that domain has been expanded + let domain = normalized.numeric_interval_domain()?; + assert_approx_eq!( + f32, + domain.0, + 7.498942, + F32Margin { + epsilon: 0.001, + ..Default::default() + } + ); + assert_approx_eq!( + f32, + domain.1, + 133.35214, + F32Margin { + epsilon: 0.001, + ..Default::default() + } + ); + + // Test scaling values + let values = Arc::new(Float32Array::from(vec![10.0, 31.622776, 100.0])) as ArrayRef; + let result = normalized.scale(&values)?; + let result_array = result.as_primitive::(); + + // With expanded domain, the original values should map differently + // 10 should map to slightly above 0 (around 10) + // 31.622776 (10^1.5) should map to around 50 + // 100 should map to slightly below 100 (around 90) + assert_approx_eq!( + f32, + result_array.value(0), + 10.0, + F32Margin { + epsilon: 0.5, + ..Default::default() + } + ); + assert_approx_eq!( + f32, + result_array.value(1), + 50.0, + F32Margin { + epsilon: 0.5, + ..Default::default() + } + ); + assert_approx_eq!( + f32, + result_array.value(2), + 90.0, + F32Margin { + epsilon: 0.5, + ..Default::default() + } + ); + + Ok(()) + } + + #[test] + fn test_padding_with_nice() -> Result<(), AvengerScaleError> { + // Test that transformations are applied in order: padding -> nice + let scale = LogScale::configured((10.0, 100.0), (0.0, 100.0)) + .with_option("padding", 10.0) + .with_option("nice", true) + .with_option("base", 10.0); + + let normalized = scale.normalize()?; + let domain = normalized.numeric_interval_domain()?; + + // Expected transformations: + // 1. Padding: [10, 100] → expanded in log space + // - log10(10) = 1, log10(100) = 2 + // - center = 1.5, span = 1 + // - frac = 100 / (100 - 20) = 1.25 + // - new log domain: [1.5 - 0.5*1.25, 1.5 + 0.5*1.25] = [0.875, 2.125] + // - linear domain: [10^0.875, 10^2.125] ≈ [7.5, 133.35] + // 2. Nice: [7.5, 133.35] → [1, 1000] (nice powers of 10) + assert_approx_eq!(f32, domain.0, 1.0); + assert_approx_eq!(f32, domain.1, 1000.0); + + // Verify all normalization options are disabled + assert_eq!(normalized.option_f32("padding", -1.0), 0.0); + assert!(!normalized.option_boolean("nice", true)); + + Ok(()) + } } diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index 5bb7388b..4e09f186 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -933,6 +933,7 @@ impl ConfiguredScale { let mut new_options = self.config.options.clone(); new_options.insert("zero".to_string(), false.into()); new_options.insert("nice".to_string(), false.into()); + new_options.insert("padding".to_string(), 0.0.into()); Ok(ConfiguredScale { scale_impl: self.scale_impl, diff --git a/avenger-scales/src/scales/pow.rs b/avenger-scales/src/scales/pow.rs index 6ab561f8..509d42c4 100644 --- a/avenger-scales/src/scales/pow.rs +++ b/avenger-scales/src/scales/pow.rs @@ -96,16 +96,59 @@ impl PowScale { Ok((domain_start, domain_end)) } - /// Apply normalization (zero and nice) to domain + /// Apply padding to the domain + pub fn apply_padding( + domain: (f32, f32), + range: (f32, f32), + padding: f32, + exponent: f32, + ) -> Result<(f32, f32), AvengerScaleError> { + let (domain_start, domain_end) = domain; + let (range_start, range_end) = range; + + // Early return for degenerate cases + if domain_start == domain_end || range_start == range_end || padding <= 0.0 { + return Ok(domain); + } + + // Transform to pow space using the power function + let power_fun = PowerFunction::new(exponent); + let pow_start = power_fun.pow(domain_start); + let pow_end = power_fun.pow(domain_end); + + // Apply padding in pow space using linear approach + let span = (range_end - range_start).abs(); + let frac = span / (span - 2.0 * padding); + let pow_center = (pow_start + pow_end) / 2.0; + let new_pow_start = pow_center + (pow_start - pow_center) * frac; + let new_pow_end = pow_center + (pow_end - pow_center) * frac; + + // Transform back to linear space + let new_start = power_fun.pow_inv(new_pow_start); + let new_end = power_fun.pow_inv(new_pow_end); + + Ok((new_start, new_end)) + } + + /// Apply normalization (padding, zero and nice) to domain pub fn apply_normalization( domain: (f32, f32), - _exponent: f32, + range: (f32, f32), + padding: Option, + exponent: f32, zero: Option<&Scalar>, nice: Option<&Scalar>, ) -> Result<(f32, f32), AvengerScaleError> { - // Use LinearScale normalization since power transformation preserves zero + // Apply padding first if specified + let domain = if let Some(pad) = padding { + PowScale::apply_padding(domain, range, pad, exponent)? + } else { + domain + }; + + // Use LinearScale normalization for zero and nice since power transformation preserves zero let (normalized_start, normalized_end) = - LinearScale::apply_normalization(domain, zero, nice)?; + LinearScale::apply_normalization(domain, range, None, zero, nice)?; Ok((normalized_start, normalized_end)) } } @@ -128,6 +171,7 @@ impl ScaleImpl for PowScale { OptionDefinition::optional("round", OptionConstraint::Boolean), OptionDefinition::optional("nice", OptionConstraint::nice()), OptionDefinition::optional("zero", OptionConstraint::Boolean), + OptionDefinition::optional("padding", OptionConstraint::NonNegativeFloat), OptionDefinition::optional("default", OptionConstraint::Float), ]; } @@ -170,16 +214,24 @@ impl ScaleImpl for PowScale { let clamp = config.option_boolean("clamp", false); let round = config.option_boolean("round", false); - let (range_start, range_end) = config.numeric_interval_range()?; - let (domain_start, domain_end) = PowScale::apply_nice( + // Get range for padding calculation, use dummy range if not numeric + let range_for_padding = config.numeric_interval_range().unwrap_or((0.0, 1.0)); + + // Get padding option + let padding = config.options.get("padding").and_then(|p| p.as_f32().ok()); + + let (domain_start, domain_end) = PowScale::apply_normalization( config.numeric_interval_domain()?, + range_for_padding, + padding, exponent, + config.options.get("zero"), config.options.get("nice"), )?; // Check if color interpolation is needed if config.color_range().is_ok() { - // Create new config with niced domain + // Create new config with normalized domain let config = ScaleConfig { domain: Arc::new(Float32Array::from(vec![domain_start, domain_end])), ..config.clone() @@ -187,6 +239,8 @@ impl ScaleImpl for PowScale { return scale_numeric_to_color(self, &config, values); } + let (range_start, range_end) = config.numeric_interval_range()?; + // If range start equals end, return constant range value if range_start == range_end { return Ok(Arc::new(Float32Array::from(vec![ @@ -321,10 +375,19 @@ impl ScaleImpl for PowScale { let range_offset = config.option_f32("range_offset", 0.0); let clamp = config.option_boolean("clamp", false); + // Get range for padding calculation, use dummy range if not numeric + let range_for_padding = config.numeric_interval_range().unwrap_or((0.0, 1.0)); let (range_start, range_end) = config.numeric_interval_range()?; - let (domain_start, domain_end) = PowScale::apply_nice( + + // Get padding option + let padding = config.options.get("padding").and_then(|p| p.as_f32().ok()); + + let (domain_start, domain_end) = PowScale::apply_normalization( config.numeric_interval_domain()?, + range_for_padding, + padding, exponent, + config.options.get("zero"), config.options.get("nice"), )?; @@ -432,9 +495,19 @@ impl ScaleImpl for PowScale { count: Option, ) -> Result { let exponent = config.option_f32("exponent", 1.0); - let (domain_start, domain_end) = PowScale::apply_nice( + + // Get range for padding calculation, use dummy range if not numeric + let range_for_padding = config.numeric_interval_range().unwrap_or((0.0, 1.0)); + + // Get padding option + let padding = config.options.get("padding").and_then(|p| p.as_f32().ok()); + + let (domain_start, domain_end) = PowScale::apply_normalization( config.numeric_interval_domain()?, + range_for_padding, + padding, exponent, + config.options.get("zero"), config.options.get("nice"), )?; let count = count.unwrap_or(10.0); @@ -444,8 +517,17 @@ impl ScaleImpl for PowScale { fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { let exponent = config.option_f32("exponent", 1.0); + + // Get range for padding calculation, use dummy range if not numeric + let range_for_padding = config.numeric_interval_range().unwrap_or((0.0, 1.0)); + + // Get padding option + let padding = config.options.get("padding").and_then(|p| p.as_f32().ok()); + let (domain_start, domain_end) = PowScale::apply_normalization( config.numeric_interval_domain()?, + range_for_padding, + padding, exponent, config.options.get("zero"), config.options.get("nice"), @@ -854,4 +936,99 @@ mod tests { Ok(()) } + + #[test] + fn test_apply_padding() -> Result<(), AvengerScaleError> { + // Test basic padding + let _result = PowScale::apply_padding((1.0, 9.0), (0.0, 500.0), 10.0, 2.0)?; + + // With exponent=2, domain [1,9] becomes [1,81] in pow space + // Center is 41, span = 80 + // frac = 500 / (500 - 20) = 1.0417 + // New pow domain: [41 - 41.67, 41 + 41.67] = [-0.67, 82.67] + // But since we can't have negative values in pow space with even exponent, + // the result should be adjusted + + // Actually, let's verify with a simpler case + let result = PowScale::apply_padding((2.0, 4.0), (0.0, 500.0), 10.0, 2.0)?; + + // In pow space: [4, 16], center = 10 + // frac = 500/480 = 1.0417 + // New pow range: center ± (distance * frac) + // [10 - 6*1.0417, 10 + 6*1.0417] = [3.75, 16.25] + // Back to linear: [1.936, 4.031] approximately + + assert!(result.0 < 2.0); + assert!(result.1 > 4.0); + + // Test with no padding + let result = PowScale::apply_padding((2.0, 4.0), (0.0, 500.0), 0.0, 2.0)?; + assert_eq!(result, (2.0, 4.0)); + + // Test with degenerate domain + let result = PowScale::apply_padding((5.0, 5.0), (0.0, 500.0), 10.0, 2.0)?; + assert_eq!(result, (5.0, 5.0)); + + // Test with degenerate range + let result = PowScale::apply_padding((2.0, 4.0), (100.0, 100.0), 10.0, 2.0)?; + assert_eq!(result, (2.0, 4.0)); + + Ok(()) + } + + #[test] + fn test_pow_scale_with_padding() -> Result<(), AvengerScaleError> { + let scale = PowScale::configured((2.0, 10.0), (0.0, 100.0)) + .with_option("padding", 5.0) + .with_option("exponent", 2.0); + + // The padding should expand the domain + let values = Arc::new(Float32Array::from(vec![2.0, 10.0])) as ArrayRef; + let result = scale.scale(&values)?; + let result_array = result.as_primitive::(); + + // With padding, the domain endpoints should map to values inside the range + assert!(result_array.value(0) > 0.0); + assert!(result_array.value(1) < 100.0); + + Ok(()) + } + + #[test] + fn test_padding_with_nice() -> Result<(), AvengerScaleError> { + // Test padding applied before nice + let domain = (2.0, 10.0); + let range = (0.0, 100.0); + let padding = Some(5.0); + let exponent = 2.0; + let nice_value: Scalar = true.into(); + let nice = Some(&nice_value); + + let result = PowScale::apply_normalization(domain, range, padding, exponent, None, nice)?; + + // Nice should round to nice values after padding is applied + // The exact values depend on the nice algorithm + assert!(result.0 <= 2.0); + assert!(result.1 >= 10.0); + + Ok(()) + } + + #[test] + fn test_pow_scale_padding_with_color_range() -> Result<(), AvengerScaleError> { + // Test that padding with non-numeric range doesn't crash + // We test this by verifying that apply_normalization works with a dummy range + let domain = (2.0, 8.0); + let range = (0.0, 500.0); // larger range for padding calculation + let padding = Some(10.0); + let exponent = 2.0; + + let result = PowScale::apply_normalization(domain, range, padding, exponent, None, None)?; + + // Should have expanded the domain + assert!(result.0 < domain.0); + assert!(result.1 > domain.1); + + Ok(()) + } } diff --git a/avenger-scales/src/scales/quantize.rs b/avenger-scales/src/scales/quantize.rs index ddd7d758..eac4ac0e 100644 --- a/avenger-scales/src/scales/quantize.rs +++ b/avenger-scales/src/scales/quantize.rs @@ -68,7 +68,8 @@ impl QuantizeScale { nice: Option<&Scalar>, ) -> Result<(f32, f32), AvengerScaleError> { // Use LinearScale normalization since quantize scale works with linear domains - LinearScale::apply_normalization(domain, zero, nice) + // Quantize scale doesn't use padding, so we pass dummy range and None for padding + LinearScale::apply_normalization(domain, (0.0, 1.0), None, zero, nice) } } diff --git a/avenger-scales/src/scales/symlog.rs b/avenger-scales/src/scales/symlog.rs index 795b4658..75889410 100644 --- a/avenger-scales/src/scales/symlog.rs +++ b/avenger-scales/src/scales/symlog.rs @@ -63,6 +63,7 @@ impl SymlogScale { ("round".to_string(), false.into()), ("nice".to_string(), false.into()), ("zero".to_string(), false.into()), + ("padding".to_string(), 0.0.into()), ] .into_iter() .collect(), @@ -90,16 +91,72 @@ impl SymlogScale { Ok((domain_start, domain_end)) } - /// Apply normalization (zero and nice) to domain + /// Apply padding in symlog space + pub fn apply_padding( + domain: (f32, f32), + range: (f32, f32), + padding: f32, + constant: f32, + ) -> Result<(f32, f32), AvengerScaleError> { + let (domain_start, domain_end) = domain; + let (range_start, range_end) = range; + + // Early return for degenerate cases + if domain_start == domain_end || range_start == range_end || padding <= 0.0 { + return Ok(domain); + } + + // Transform to symlog space + let symlog_start = symlog_transform(domain_start, constant); + let symlog_end = symlog_transform(domain_end, constant); + + // Calculate the span of the range in pixels + let span = (range_end - range_start).abs(); + + // Calculate scale factor: frac = span / (span - 2 * pad) + let frac = span / (span - 2.0 * padding); + + // For symlog scale, zoom from center in symlog space + let symlog_center = (symlog_start + symlog_end) / 2.0; + + // Expand domain in symlog space by scale factor + let new_symlog_start = symlog_center + (symlog_start - symlog_center) * frac; + let new_symlog_end = symlog_center + (symlog_end - symlog_center) * frac; + + // Transform back to linear space + let new_start = symlog_invert(new_symlog_start, constant); + let new_end = symlog_invert(new_symlog_end, constant); + + Ok((new_start, new_end)) + } + + /// Apply normalization (padding, zero and nice) to domain pub fn apply_normalization( domain: (f32, f32), - _constant: f32, + range: (f32, f32), + constant: f32, + padding: Option<&Scalar>, zero: Option<&Scalar>, nice: Option<&Scalar>, ) -> Result<(f32, f32), AvengerScaleError> { - // Use LinearScale normalization since symlog transformation preserves zero + // Apply padding first if specified + let domain = if let Some(padding) = padding { + if let Ok(padding_val) = padding.as_f32() { + if padding_val > 0.0 { + Self::apply_padding(domain, range, padding_val, constant)? + } else { + domain + } + } else { + domain + } + } else { + domain + }; + + // Then apply zero and nice using LinearScale normalization let (normalized_start, normalized_end) = - LinearScale::apply_normalization(domain, zero, nice)?; + LinearScale::apply_normalization(domain, (0.0, 1.0), None, zero, nice)?; Ok((normalized_start, normalized_end)) } } @@ -122,6 +179,7 @@ impl ScaleImpl for SymlogScale { OptionDefinition::optional("round", OptionConstraint::Boolean), OptionDefinition::optional("nice", OptionConstraint::nice()), OptionDefinition::optional("zero", OptionConstraint::Boolean), + OptionDefinition::optional("padding", OptionConstraint::NonNegativeFloat), OptionDefinition::optional("default", OptionConstraint::Float), ]; } @@ -164,9 +222,15 @@ impl ScaleImpl for SymlogScale { let clamp = config.option_boolean("clamp", false); let round = config.option_boolean("round", false); - let (domain_start, domain_end) = SymlogScale::apply_nice( + // Get range for padding calculation, use dummy range if not numeric + let range_for_padding = config.numeric_interval_range().unwrap_or((0.0, 1.0)); + + let (domain_start, domain_end) = SymlogScale::apply_normalization( config.numeric_interval_domain()?, + range_for_padding, constant, + config.options.get("padding"), + config.options.get("zero"), config.options.get("nice"), )?; let (range_start, range_end) = config.numeric_interval_range()?; @@ -296,9 +360,12 @@ impl ScaleImpl for SymlogScale { config: &ScaleConfig, values: &ArrayRef, ) -> Result, AvengerScaleError> { - let (domain_start, domain_end) = SymlogScale::apply_nice( + let (domain_start, domain_end) = SymlogScale::apply_normalization( config.numeric_interval_domain()?, + config.numeric_interval_range()?, config.option_f32("constant", 1.0), + config.options.get("padding"), + config.options.get("zero"), config.options.get("nice"), )?; @@ -401,9 +468,12 @@ impl ScaleImpl for SymlogScale { config: &ScaleConfig, count: Option, ) -> Result { - let (domain_start, domain_end) = SymlogScale::apply_nice( + let (domain_start, domain_end) = SymlogScale::apply_normalization( config.numeric_interval_domain()?, + (0.0, 1.0), // Use dummy range for ticks computation config.option_f32("constant", 1.0), + None, // No padding for ticks + config.options.get("zero"), config.options.get("nice"), )?; let count = count.unwrap_or(10.0); @@ -413,9 +483,13 @@ impl ScaleImpl for SymlogScale { fn compute_nice_domain(&self, config: &ScaleConfig) -> Result { let constant = config.option_f32("constant", 1.0); + // Get range for padding calculation, use dummy range if not numeric + let range_for_padding = config.numeric_interval_range().unwrap_or((0.0, 1.0)); let (domain_start, domain_end) = SymlogScale::apply_normalization( config.numeric_interval_domain()?, + range_for_padding, constant, + config.options.get("padding"), config.options.get("zero"), config.options.get("nice"), )?; @@ -425,15 +499,15 @@ impl ScaleImpl for SymlogScale { } /// Applies the symlog transform to a single value +/// Uses ln_1p for better numerical stability near zero fn symlog_transform(x: f32, constant: f32) -> f32 { - let sign = if x < 0.0 { -1.0 } else { 1.0 }; - sign * (1.0 + (x.abs() / constant)).ln() + x.signum() * (x.abs() / constant).ln_1p() } /// Applies the inverse symlog transform to a single value +/// Uses exp_m1 for better numerical stability near zero fn symlog_invert(x: f32, constant: f32) -> f32 { - let sign = if x < 0.0 { -1.0 } else { 1.0 }; - sign * ((x.abs()).exp() - 1.0) * constant + x.signum() * x.abs().exp_m1() * constant } #[cfg(test)] @@ -819,4 +893,155 @@ mod tests { ); } } + + #[test] + fn test_apply_padding() { + let constant = 1.0; + + // Test basic padding + let (new_start, new_end) = + SymlogScale::apply_padding((-10.0, 10.0), (0.0, 100.0), 10.0, constant).unwrap(); + + // With padding=10 and range=100, frac = 100 / (100 - 20) = 1.25 + // Domain should expand by 25% + assert!(new_start < -10.0); + assert!(new_end > 10.0); + + // Test zero padding + let (new_start, new_end) = + SymlogScale::apply_padding((-10.0, 10.0), (0.0, 100.0), 0.0, constant).unwrap(); + assert_approx_eq!(f32, new_start, -10.0); + assert_approx_eq!(f32, new_end, 10.0); + + // Test degenerate domain + let (new_start, new_end) = + SymlogScale::apply_padding((5.0, 5.0), (0.0, 100.0), 10.0, constant).unwrap(); + assert_approx_eq!(f32, new_start, 5.0); + assert_approx_eq!(f32, new_end, 5.0); + } + + #[test] + fn test_symlog_scale_with_padding() { + let scale = SymlogScale; + let config = ScaleConfig { + domain: Arc::new(Float32Array::from(vec![-10.0, 10.0])), + range: Arc::new(Float32Array::from(vec![0.0, 100.0])), + options: vec![("padding".to_string(), 10.0.into())] + .into_iter() + .collect(), + context: ScaleContext::default(), + }; + + // Test that domain endpoints map correctly + let values = Arc::new(Float32Array::from(vec![-10.0, 0.0, 10.0])) as ArrayRef; + let result = scale + .scale_to_numeric(&config, &values) + .unwrap() + .as_vec(values.len(), None); + + // With padding, -10 and 10 should no longer map to exactly 0 and 100 + assert!(result[0] > 0.0); + assert!(result[2] < 100.0); + // 0 should still map to the center + assert_approx_eq!( + f32, + result[1], + 50.0, + F32Margin { + epsilon: 0.01, + ..Default::default() + } + ); + } + + #[test] + fn test_padding_with_nice() { + let scale = SymlogScale; + let config = ScaleConfig { + domain: Arc::new(Float32Array::from(vec![-9.0, 9.0])), + range: Arc::new(Float32Array::from(vec![0.0, 100.0])), + options: vec![ + ("padding".to_string(), 10.0.into()), + ("nice".to_string(), true.into()), + ] + .into_iter() + .collect(), + context: ScaleContext::default(), + }; + + // With padding and nice, domain should be expanded then niced + let nice_domain = scale.compute_nice_domain(&config).unwrap(); + let nice_array = nice_domain.as_primitive::(); + + // Domain should be expanded and then made nice + assert!(nice_array.value(0) <= -9.0); + assert!(nice_array.value(1) >= 9.0); + } + + #[test] + fn test_padding_near_zero() { + // Test numerical stability with values very close to zero + let constant = 0.001; + + // Test with very small domain near zero + let (new_start, new_end) = + SymlogScale::apply_padding((-0.001, 0.001), (0.0, 100.0), 5.0, constant).unwrap(); + + // Domain should expand + assert!(new_start < -0.001); + assert!(new_end > 0.001); + + // Test transform stability near zero + let epsilon = 1e-10; + let transformed = symlog_transform(epsilon, constant); + let inverted = symlog_invert(transformed, constant); + + // Should round-trip accurately + assert_approx_eq!( + f32, + inverted, + epsilon, + F32Margin { + epsilon: 1e-15, + ..Default::default() + } + ); + + // Test with zero in domain + let (new_start, new_end) = + SymlogScale::apply_padding((-1.0, 0.0), (0.0, 100.0), 10.0, constant).unwrap(); + + assert!(new_start < -1.0); + assert!(new_end > 0.0); // Padding expands both ends of domain + } + + #[test] + fn test_symlog_transform_matches_vega() { + // Test that our transforms match Vega's implementation + let constant = 1.0; + let test_values = vec![-10.0, -1.0, -0.1, -0.01, 0.0, 0.01, 0.1, 1.0, 10.0]; + + for x in test_values { + let transformed = symlog_transform(x, constant); + let inverted = symlog_invert(transformed, constant); + + // Should round-trip accurately + if x == 0.0 { + assert_eq!(inverted, 0.0); + } else { + assert_approx_eq!( + f32, + inverted, + x, + F32Margin { + epsilon: 1e-6, + ..Default::default() + } + ); + } + + // Verify sign preservation + assert_eq!(transformed.signum(), x.signum()); + } + } } diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index d1a77f35..81de365e 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -8,7 +8,7 @@ use arrow::array::{ use arrow::compute::kernels::cast; use arrow::datatypes::{DataType, TimeUnit}; use avenger_common::types::LinearScaleAdjustment; -use avenger_common::value::{ScalarOrArray, ScalarOrArrayValue}; +use avenger_common::value::ScalarOrArray; use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Timelike, Utc}; use chrono_tz::Tz; use lazy_static::lazy_static; @@ -1117,6 +1117,7 @@ fn get_temporal_value( } } +#[allow(clippy::too_many_arguments)] fn scale_timestamp_values( values: &ArrayRef, unit: &TimeUnit, @@ -1695,6 +1696,7 @@ fn create_temporal_array_from_optional_millis( mod tests { use super::*; use arrow::array::TimestampSecondArray; + use avenger_common::value::ScalarOrArrayValue; #[test] fn test_time_scale_date32() -> Result<(), AvengerScaleError> { diff --git a/avenger-scales/tests/validation_test.rs b/avenger-scales/tests/validation_test.rs new file mode 100644 index 00000000..4e487457 --- /dev/null +++ b/avenger-scales/tests/validation_test.rs @@ -0,0 +1,145 @@ +use arrow::array::{ArrayRef, Float32Array}; +use avenger_scales::error::AvengerScaleError; +use avenger_scales::scales::linear::LinearScale; +use avenger_scales::scales::log::LogScale; +use std::sync::Arc; + +#[test] +fn test_linear_scale_valid_options() { + let scale = LinearScale::configured((0.0, 100.0), (0.0, 1.0)) + .with_option("clamp", true) + .with_option("round", true) + .with_option("range_offset", 10.0) + .with_option("nice", true) + .with_option("zero", false); + + let values = Arc::new(Float32Array::from(vec![0.0, 50.0, 100.0])) as ArrayRef; + + // This should succeed + let result = scale.scale(&values); + assert!(result.is_ok()); +} + +#[test] +fn test_linear_scale_invalid_option_type() { + let scale = + LinearScale::configured((0.0, 100.0), (0.0, 1.0)).with_option("clamp", "not_a_boolean"); + + let values = Arc::new(Float32Array::from(vec![0.0, 50.0, 100.0])) as ArrayRef; + + // This should fail due to invalid boolean type + let result = scale.scale(&values); + assert!(result.is_err()); + + if let Err(AvengerScaleError::InvalidScalePropertyValue(msg)) = result { + assert!(msg.contains("clamp")); + assert!(msg.contains("boolean")); + } else { + panic!("Expected InvalidScalePropertyValue error"); + } +} + +#[test] +fn test_linear_scale_unknown_option() { + let scale = LinearScale::configured((0.0, 100.0), (0.0, 1.0)).with_option("unknown_option", 42); + + let values = Arc::new(Float32Array::from(vec![0.0, 50.0, 100.0])) as ArrayRef; + + // This should fail due to unknown option + let result = scale.scale(&values); + assert!(result.is_err()); + + if let Err(AvengerScaleError::InvalidScalePropertyValue(msg)) = result { + assert!(msg.contains("unknown_option")); + assert!(msg.contains("Unknown option")); + } else { + panic!("Expected InvalidScalePropertyValue error"); + } +} + +#[test] +fn test_log_scale_valid_base() { + let scale = LogScale::configured((1.0, 100.0), (0.0, 1.0)).with_option("base", 10.0); + + let values = Arc::new(Float32Array::from(vec![1.0, 10.0, 100.0])) as ArrayRef; + + // This should succeed + let result = scale.scale(&values); + assert!(result.is_ok()); +} + +#[test] +fn test_log_scale_invalid_base() { + let scale = LogScale::configured((1.0, 100.0), (0.0, 1.0)).with_option("base", 1.0); // base cannot be 1 + + let values = Arc::new(Float32Array::from(vec![1.0, 10.0, 100.0])) as ArrayRef; + + // This should fail due to invalid base + let result = scale.scale(&values); + assert!(result.is_err()); + + if let Err(AvengerScaleError::InvalidScalePropertyValue(msg)) = result { + assert!(msg.contains("base")); + assert!(msg.contains("not equal to 1")); + } else { + panic!("Expected InvalidScalePropertyValue error"); + } +} + +#[test] +fn test_log_scale_negative_base() { + let scale = LogScale::configured((1.0, 100.0), (0.0, 1.0)).with_option("base", -2.0); // base must be positive + + let values = Arc::new(Float32Array::from(vec![1.0, 10.0, 100.0])) as ArrayRef; + + // This should fail due to negative base + let result = scale.scale(&values); + assert!(result.is_err()); + + if let Err(AvengerScaleError::InvalidScalePropertyValue(msg)) = result { + assert!(msg.contains("base")); + assert!(msg.contains("positive")); + } else { + panic!("Expected InvalidScalePropertyValue error"); + } +} + +#[test] +fn test_nice_option_boolean() { + let scale = LinearScale::configured((0.0, 100.0), (0.0, 1.0)).with_option("nice", true); + + let values = Arc::new(Float32Array::from(vec![0.0, 50.0, 100.0])) as ArrayRef; + + // This should succeed - nice can be boolean + let result = scale.scale(&values); + assert!(result.is_ok()); +} + +#[test] +fn test_nice_option_numeric() { + let scale = LinearScale::configured((0.0, 100.0), (0.0, 1.0)).with_option("nice", 5.0); + + let values = Arc::new(Float32Array::from(vec![0.0, 50.0, 100.0])) as ArrayRef; + + // This should succeed - nice can be numeric + let result = scale.scale(&values); + assert!(result.is_ok()); +} + +#[test] +fn test_nice_option_invalid() { + let scale = LinearScale::configured((0.0, 100.0), (0.0, 1.0)).with_option("nice", "invalid"); + + let values = Arc::new(Float32Array::from(vec![0.0, 50.0, 100.0])) as ArrayRef; + + // This should fail - nice must be boolean or numeric + let result = scale.scale(&values); + assert!(result.is_err()); + + if let Err(AvengerScaleError::InvalidScalePropertyValue(msg)) = result { + assert!(msg.contains("nice")); + assert!(msg.contains("boolean or numeric")); + } else { + panic!("Expected InvalidScalePropertyValue error"); + } +} From cacb1a33c5f499c1d3158a96c6db989973379cf5 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 19 Jul 2025 15:43:46 -0400 Subject: [PATCH 22/29] fix: Only add supported options during scale normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modified normalize() to check scale's supported options before adding - Only sets 'zero', 'nice', and 'padding' if they're in option_definitions() - Prevents validation errors for scales that don't support these options - Added comprehensive tests to verify correct behavior for different scale types This fixes the issue where point, band, and ordinal scales were receiving unsupported options like 'zero' and 'nice' during normalization, which caused validation errors in VegaFusion. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/mod.rs | 19 +- .../tests/test_normalize_options.rs | 164 ++++++++++++++++++ 2 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 avenger-scales/tests/test_normalize_options.rs diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index 4e09f186..63fd7353 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -931,9 +931,22 @@ impl ConfiguredScale { pub fn normalize(self) -> Result { let normalized_domain = self.scale_impl.compute_nice_domain(&self.config)?; let mut new_options = self.config.options.clone(); - new_options.insert("zero".to_string(), false.into()); - new_options.insert("nice".to_string(), false.into()); - new_options.insert("padding".to_string(), 0.0.into()); + + // Only set normalization options that are supported by this scale type + let option_definitions = self.scale_impl.option_definitions(); + let supported_options: std::collections::HashSet<&str> = + option_definitions.iter().map(|def| def.name).collect(); + + // Only set these options if they're supported by the scale + if supported_options.contains("zero") { + new_options.insert("zero".to_string(), false.into()); + } + if supported_options.contains("nice") { + new_options.insert("nice".to_string(), false.into()); + } + if supported_options.contains("padding") { + new_options.insert("padding".to_string(), 0.0.into()); + } Ok(ConfiguredScale { scale_impl: self.scale_impl, diff --git a/avenger-scales/tests/test_normalize_options.rs b/avenger-scales/tests/test_normalize_options.rs new file mode 100644 index 00000000..b19c36b4 --- /dev/null +++ b/avenger-scales/tests/test_normalize_options.rs @@ -0,0 +1,164 @@ +use arrow::array::{Float32Array, StringArray}; +use avenger_scales::error::AvengerScaleError; +use avenger_scales::scales::band::BandScale; +use avenger_scales::scales::linear::LinearScale; +use avenger_scales::scales::ordinal::OrdinalScale; +use avenger_scales::scales::point::PointScale; +use std::sync::Arc; + +#[test] +fn test_point_scale_normalize_no_unsupported_options() -> Result<(), AvengerScaleError> { + // Create a point scale + let domain = Arc::new(StringArray::from(vec!["a", "b", "c"])); + let scale = PointScale::configured(domain, (0.0, 100.0)) + .with_option("padding", 0.1) + .with_option("align", 0.5); + + // Normalize the scale + let normalized = scale.normalize()?; + + // Check that only supported options remain + let options = normalized.config.options; + + // Point scale supports: align, padding, round, range_offset + // It should NOT have zero or nice options added + assert!( + !options.contains_key("zero"), + "Point scale should not have 'zero' option after normalization" + ); + assert!( + !options.contains_key("nice"), + "Point scale should not have 'nice' option after normalization" + ); + + // It should still have the supported options + assert!(options.contains_key("padding")); + assert!(options.contains_key("align")); + + Ok(()) +} + +#[test] +fn test_band_scale_normalize_no_unsupported_options() -> Result<(), AvengerScaleError> { + // Create a band scale + let domain = Arc::new(StringArray::from(vec!["x", "y", "z"])); + let scale = BandScale::configured(domain, (0.0, 200.0)) + .with_option("padding_inner", 0.1) + .with_option("round", true); + + // Normalize the scale + let normalized = scale.normalize()?; + + // Check that only supported options remain + let options = normalized.config.options; + + // Band scale supports: align, band, padding, padding_inner, padding_outer, round, range_offset + // It should NOT have zero or nice options added + assert!( + !options.contains_key("zero"), + "Band scale should not have 'zero' option after normalization" + ); + assert!( + !options.contains_key("nice"), + "Band scale should not have 'nice' option after normalization" + ); + + Ok(()) +} + +#[test] +fn test_ordinal_scale_normalize_no_unsupported_options() -> Result<(), AvengerScaleError> { + // Create an ordinal scale + let domain = Arc::new(StringArray::from(vec!["red", "green", "blue"])); + let range = Arc::new(Float32Array::from(vec![0.0, 0.5, 1.0])); + let scale = OrdinalScale::configured(domain, range); + + // Normalize the scale + let normalized = scale.normalize()?; + + // Check that only supported options remain + let options = normalized.config.options; + + // Ordinal scale only supports: default + // It should NOT have zero, nice, or padding options added + assert!( + !options.contains_key("zero"), + "Ordinal scale should not have 'zero' option after normalization" + ); + assert!( + !options.contains_key("nice"), + "Ordinal scale should not have 'nice' option after normalization" + ); + assert!( + !options.contains_key("padding"), + "Ordinal scale should not have 'padding' option after normalization" + ); + + Ok(()) +} + +#[test] +fn test_linear_scale_normalize_has_supported_options() -> Result<(), AvengerScaleError> { + // Create a linear scale + let scale = LinearScale::configured((1.0, 10.0), (0.0, 100.0)) + .with_option("zero", true) + .with_option("nice", true); + + // Normalize the scale + let normalized = scale.normalize()?; + + // Check that normalization options are properly set + let options = normalized.config.options; + + // Linear scale supports zero, nice, and padding + // After normalization, these should be set to their disabled values + assert_eq!( + options.get("zero").and_then(|v| v.as_boolean().ok()), + Some(false) + ); + assert_eq!( + options.get("nice").and_then(|v| v.as_boolean().ok()), + Some(false) + ); + assert_eq!( + options.get("padding").and_then(|v| v.as_f32().ok()), + Some(0.0) + ); + + Ok(()) +} + +#[test] +fn test_normalized_scale_validates_successfully() -> Result<(), AvengerScaleError> { + // This is the key test - ensure normalized scales pass validation + + // Test point scale + let point_domain = Arc::new(StringArray::from(vec!["a", "b", "c"])); + let point_scale = PointScale::configured(point_domain, (0.0, 100.0)); + let normalized_point = point_scale.normalize()?; + // This should not error with "Unknown option 'zero'" + normalized_point + .scale_impl + .validate_options(&normalized_point.config)?; + + // Test band scale + let band_domain = Arc::new(StringArray::from(vec!["x", "y", "z"])); + let band_scale = BandScale::configured(band_domain, (0.0, 200.0)); + let normalized_band = band_scale.normalize()?; + // This should not error with "Unknown option 'zero'" + normalized_band + .scale_impl + .validate_options(&normalized_band.config)?; + + // Test ordinal scale + let ordinal_domain = Arc::new(StringArray::from(vec!["red", "green", "blue"])); + let ordinal_range = Arc::new(Float32Array::from(vec![0.0, 0.5, 1.0])); + let ordinal_scale = OrdinalScale::configured(ordinal_domain, ordinal_range); + let normalized_ordinal = ordinal_scale.normalize()?; + // This should not error with "Unknown option 'zero'" + normalized_ordinal + .scale_impl + .validate_options(&normalized_ordinal.config)?; + + Ok(()) +} From e621f4c246cb986a458363a3c19dd3bbe038869d Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 19 Jul 2025 15:57:11 -0400 Subject: [PATCH 23/29] fix: Only add supported options during scale normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modified normalize() to check scale's supported options before adding - Only sets 'zero', 'nice', and 'padding' if they're in option_definitions() - Prevents validation errors for scales that don't support these options - Added comprehensive tests to verify correct behavior for different scale types This fixes the issue where point, band, and ordinal scales were receiving unsupported options like 'zero' and 'nice' during normalization, which caused validation errors in VegaFusion. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/mod.rs | 2 +- avenger-scales/tests/test_normalize_options.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index 63fd7353..d1b2220a 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -935,7 +935,7 @@ impl ConfiguredScale { // Only set normalization options that are supported by this scale type let option_definitions = self.scale_impl.option_definitions(); let supported_options: std::collections::HashSet<&str> = - option_definitions.iter().map(|def| def.name).collect(); + option_definitions.iter().map(|def| def.name.as_str()).collect(); // Only set these options if they're supported by the scale if supported_options.contains("zero") { diff --git a/avenger-scales/tests/test_normalize_options.rs b/avenger-scales/tests/test_normalize_options.rs index b19c36b4..ca9d48d4 100644 --- a/avenger-scales/tests/test_normalize_options.rs +++ b/avenger-scales/tests/test_normalize_options.rs @@ -70,8 +70,8 @@ fn test_band_scale_normalize_no_unsupported_options() -> Result<(), AvengerScale fn test_ordinal_scale_normalize_no_unsupported_options() -> Result<(), AvengerScaleError> { // Create an ordinal scale let domain = Arc::new(StringArray::from(vec!["red", "green", "blue"])); - let range = Arc::new(Float32Array::from(vec![0.0, 0.5, 1.0])); - let scale = OrdinalScale::configured(domain, range); + let scale = OrdinalScale::configured(domain) + .with_range(Arc::new(Float32Array::from(vec![0.0, 0.5, 1.0]))); // Normalize the scale let normalized = scale.normalize()?; @@ -152,8 +152,8 @@ fn test_normalized_scale_validates_successfully() -> Result<(), AvengerScaleErro // Test ordinal scale let ordinal_domain = Arc::new(StringArray::from(vec!["red", "green", "blue"])); - let ordinal_range = Arc::new(Float32Array::from(vec![0.0, 0.5, 1.0])); - let ordinal_scale = OrdinalScale::configured(ordinal_domain, ordinal_range); + let ordinal_scale = OrdinalScale::configured(ordinal_domain) + .with_range(Arc::new(Float32Array::from(vec![0.0, 0.5, 1.0]))); let normalized_ordinal = ordinal_scale.normalize()?; // This should not error with "Unknown option 'zero'" normalized_ordinal From e99f4849b39ea2ff8787f8c520cca600d1cd8b4a Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 21 Jul 2025 14:26:57 -0400 Subject: [PATCH 24/29] style: Apply cargo fmt formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/scales/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index d1b2220a..e56e7d09 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -934,8 +934,10 @@ impl ConfiguredScale { // Only set normalization options that are supported by this scale type let option_definitions = self.scale_impl.option_definitions(); - let supported_options: std::collections::HashSet<&str> = - option_definitions.iter().map(|def| def.name.as_str()).collect(); + let supported_options: std::collections::HashSet<&str> = option_definitions + .iter() + .map(|def| def.name.as_str()) + .collect(); // Only set these options if they're supported by the scale if supported_options.contains("zero") { From c673a5e0580c4604b7582c5c7088391bc55d2a0d Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 21 Jul 2025 14:35:46 -0400 Subject: [PATCH 25/29] fix: Update CI workflow to fix build failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace individual directory builds with workspace build - Remove references to non-existent directories (avenger-vega, scatter-panning) - Use standard cargo build --workspace command 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .github/workflows/rust.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e2dfdf85..1f2685cd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -31,12 +31,7 @@ jobs: run: cargo clippy - name: Build run: | - cd avenger-scenegraph && cargo build && cd .. - cd avenger-vega && cargo build && cd .. - cd avenger-wgpu && cargo build && cd .. - cd avenger-vega-test-data && cargo build && cd .. - pushd examples/scatter-panning && cargo build && popd - pushd examples/wgpu-winit && cargo build && popd + cargo build --workspace - name: Run tests (excluding GPU tests) run: | cargo test --workspace --verbose \ From 3e15b365872d2c6a96e9ec951bf7008d8e3d936b Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 21 Jul 2025 14:42:03 -0400 Subject: [PATCH 26/29] fix: Use inline format args to fix clippy warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace format!("Unsupported image URL: {}", s) with format!("Unsupported image URL: {s}") to satisfy clippy::uninlined_format_args lint 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-image/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/avenger-image/src/lib.rs b/avenger-image/src/lib.rs index 1f5c3d00..97bcdb0a 100644 --- a/avenger-image/src/lib.rs +++ b/avenger-image/src/lib.rs @@ -74,8 +74,7 @@ impl RgbaImage { Ok(Self::from_image(&img.into_rgba8())) } else { Err(AvengerImageError::InternalError(format!( - "Unsupported image URL: {}", - s + "Unsupported image URL: {s}" ))) } } From 397cec4007cd3e325af005661c464db44d97ad65 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 21 Jul 2025 14:53:24 -0400 Subject: [PATCH 27/29] fix: Use inline format args throughout codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all format!() calls to use inline variable syntax (e.g., format!("{}", var) -> format!("{var}")) to satisfy clippy::uninlined_format_args lint in newer Rust versions. Changes made in: - avenger-scales: format_num, scalar, band, coerce, time, mod - avenger-app: app.rs - avenger-winit-wgpu: file_watcher.rs, lib.rs - examples/wgpu-winit: util.rs 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .claude/settings.local.json | 27 +++++++++++++++++++++++ avenger-app/src/app.rs | 2 +- avenger-scales/src/format_num/mod.rs | 17 +++++++-------- avenger-scales/src/scalar.rs | 7 +++--- avenger-scales/src/scales/band.rs | 15 +++++-------- avenger-scales/src/scales/coerce.rs | 30 ++++++++++---------------- avenger-scales/src/scales/mod.rs | 2 +- avenger-scales/src/scales/time.rs | 17 ++++++--------- avenger-winit-wgpu/src/file_watcher.rs | 2 +- avenger-winit-wgpu/src/lib.rs | 6 +++--- examples/wgpu-winit/src/util.rs | 6 +++--- 11 files changed, 69 insertions(+), 62 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..828ab129 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,27 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo:*)", + "Bash(rg:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(sed:*)", + "WebFetch(domain:docs.rs)", + "Bash(wasm-pack build:*)", + "WebFetch(domain:crates.io)", + "Bash(git add:*)", + "Bash(pixi run:*)", + "Bash(sg:*)", + "Bash(ls:*)", + "Bash(gh pr checks:*)", + "Bash(gh run view:*)", + "Bash(RUSTFLAGS=\"-D warnings\" cargo clippy)", + "Bash(git submodule:*)", + "mcp__rust-analyzer__rename", + "Bash(RUSTFLAGS=\"-D warnings\" cargo check --tests)", + "WebFetch(domain:github.com)", + "Bash(gh pr view:*)" + ] + }, + "enableAllProjectMcpServers": false +} \ No newline at end of file diff --git a/avenger-app/src/app.rs b/avenger-app/src/app.rs index d17514c4..e081d1cd 100644 --- a/avenger-app/src/app.rs +++ b/avenger-app/src/app.rs @@ -91,7 +91,7 @@ where { Ok(scene_graph) => scene_graph, Err(e) => { - eprintln!("Failed to build scene graph: {:?}", e); + eprintln!("Failed to build scene graph: {e:?}"); return Err(AvengerAppError::InternalError( "Failed to build scene graph".to_string(), )); diff --git a/avenger-scales/src/format_num/mod.rs b/avenger-scales/src/format_num/mod.rs index a1008b22..e03a26d5 100644 --- a/avenger-scales/src/format_num/mod.rs +++ b/avenger-scales/src/format_num/mod.rs @@ -252,9 +252,9 @@ impl NumberFormat { } else { significant_digits.unwrap() - 1 }; - format!("{:.1$e}", value, precision) + format!("{value:.precision$e}") } else { - format!("{:e}", value) + format!("{value:e}") }; let exp_tokens: Vec<&str> = formatted_value.split('e').collect::>(); @@ -402,7 +402,7 @@ impl NumberFormat { precision: usize, include_decimal_point: bool, ) -> String { - let formatted = format!("{:.1$e}", value, precision); + let formatted = format!("{value:.precision$e}"); let tokens = formatted.split(format_type).collect::>(); let exp_suffix = if &tokens[1][0..1] == "-" { @@ -546,10 +546,9 @@ impl NumberFormat { } // Compute the prefix and suffix. - let prefix = format!("{}{}", sign_prefix, leading_part); + let prefix = format!("{sign_prefix}{leading_part}"); let suffix = format!( - "{}{}{}", - decimal_part, si_prefix_exponent, unit_of_measurement + "{decimal_part}{si_prefix_exponent}{unit_of_measurement}" ); // If should group and filling character is different than "0", @@ -580,8 +579,8 @@ impl NumberFormat { }; match format_spec.align { - Some("<") => format!("{}{}{}{}", prefix, value, suffix, padding), - Some("=") => format!("{}{}{}{}", prefix, padding, value, suffix), + Some("<") => format!("{prefix}{value}{suffix}{padding}"), + Some("=") => format!("{prefix}{padding}{value}{suffix}"), Some("^") => format!( "{}{}{}{}{}", &padding[..padding.len() / 2], @@ -590,7 +589,7 @@ impl NumberFormat { suffix, &padding[padding.len() / 2..] ), - _ => format!("{}{}{}{}", padding, prefix, value, suffix), + _ => format!("{padding}{prefix}{value}{suffix}"), } } } diff --git a/avenger-scales/src/scalar.rs b/avenger-scales/src/scalar.rs index da2d0006..a4ebb65c 100644 --- a/avenger-scales/src/scalar.rs +++ b/avenger-scales/src/scalar.rs @@ -390,8 +390,7 @@ impl Scalar { color.a, ]), Err(e) => Err(AvengerScaleError::InternalError(format!( - "Scalar string is not a valid color: {}\n{:?}", - color_str, e + "Scalar string is not a valid color: {color_str}\n{e:?}" ))), } } @@ -514,7 +513,7 @@ impl Scalar { let arrays: Vec = scalars.into_iter().map(|s| s.to_array()).collect(); arrow::compute::concat(&arrays.iter().map(|a| a.as_ref()).collect::>()).map_err( - |e| AvengerScaleError::InternalError(format!("Failed to concatenate arrays: {}", e)), + |e| AvengerScaleError::InternalError(format!("Failed to concatenate arrays: {e}")), ) } @@ -534,7 +533,7 @@ impl Scalar { .map(|arr| { // Convert array to Float32Array and extract values let cast_arr = arrow::compute::cast(&arr, &DataType::Float32).map_err(|e| { - AvengerScaleError::InternalError(format!("Failed to cast array: {}", e)) + AvengerScaleError::InternalError(format!("Failed to cast array: {e}")) })?; let float_arr = cast_arr.as_primitive::(); let values: Vec> = (0..float_arr.len()) diff --git a/avenger-scales/src/scales/band.rs b/avenger-scales/src/scales/band.rs index acbe049f..33bc43e2 100644 --- a/avenger-scales/src/scales/band.rs +++ b/avenger-scales/src/scales/band.rs @@ -219,36 +219,31 @@ fn build_range_values(config: &ScaleConfig) -> Result, AvengerScaleErro if !(0.0..=1.0).contains(&align) || !align.is_finite() { return Err(AvengerScaleError::InvalidScalePropertyValue(format!( - "align is {} but must be between 0 and 1", - align + "align is {align} but must be between 0 and 1" ))); } if !(0.0..=1.0).contains(&band) || !band.is_finite() { return Err(AvengerScaleError::InvalidScalePropertyValue(format!( - "band is {} but must be between 0 and 1", - band + "band is {band} but must be between 0 and 1" ))); } if !(0.0..=1.0).contains(&padding_inner) || !padding_inner.is_finite() { return Err(AvengerScaleError::InvalidScalePropertyValue(format!( - "padding_inner is {} but must be between 0 and 1", - padding_inner + "padding_inner is {padding_inner} but must be between 0 and 1" ))); } if padding_outer < 0.0 || !padding_outer.is_finite() { return Err(AvengerScaleError::InvalidScalePropertyValue(format!( - "padding_outer is {} but must be non-negative", - padding_outer + "padding_outer is {padding_outer} but must be non-negative" ))); } if !range_start.is_finite() || !range_stop.is_finite() { return Err(AvengerScaleError::InvalidScalePropertyValue(format!( - "range is ({}, {}) but both ends must be finite", - range_start, range_stop + "range is ({range_start}, {range_stop}) but both ends must be finite" ))); } diff --git a/avenger-scales/src/scales/coerce.rs b/avenger-scales/src/scales/coerce.rs index 0200870d..5f4a43dc 100644 --- a/avenger-scales/src/scales/coerce.rs +++ b/avenger-scales/src/scales/coerce.rs @@ -160,8 +160,7 @@ impl ColorCoercer for CssColorCoercer { Ok(ScalarOrArray::new_array(result)) } _ => Err(AvengerScaleError::InternalError(format!( - "Unsupported data type for coercing to color: {:?}", - dtype + "Unsupported data type for coercing to color: {dtype:?}" ))), } } @@ -264,9 +263,8 @@ impl Coercer { let field_names = fields.iter().map(|f| f.name().as_str()).collect::>(); let msg = format!( - "Unsupported struct data type for coercing to image: {:?}\n -Expected struct with fields [width(UInt32), height(UInt32), data(List[UInt8])]", - field_names + "Unsupported struct data type for coercing to image: {field_names:?}\n +Expected struct with fields [width(UInt32), height(UInt32), data(List[UInt8])]" ); // Check field names and order @@ -313,8 +311,7 @@ Expected struct with fields [width(UInt32), height(UInt32), data(List[UInt8])]", } _ => { return Err(AvengerScaleError::InternalError(format!( - "Unsupported data type for coercing to image: {:?}", - dtype + "Unsupported data type for coercing to image: {dtype:?}" ))) } } @@ -364,8 +361,7 @@ Expected struct with fields [width(UInt32), height(UInt32), data(List[UInt8])]", } _ => { return Err(AvengerScaleError::InternalError(format!( - "Unsupported data type for coercing to color: {:?}", - dtype + "Unsupported data type for coercing to color: {dtype:?}" ))) } } @@ -402,9 +398,8 @@ Expected struct with fields [width(UInt32), height(UInt32), data(List[UInt8])]", let field_names = fields.iter().map(|f| f.name().as_str()).collect::>(); let msg = format!( - "Unsupported struct data type for coercing to path transform: {:?}\n -Expected struct with fields [a(Float32), b(Float32), c(Float32), d(Float32), e(Float32), f(Float32)]", - field_names + "Unsupported struct data type for coercing to path transform: {field_names:?}\n +Expected struct with fields [a(Float32), b(Float32), c(Float32), d(Float32), e(Float32), f(Float32)]" ); // Check field names and order @@ -452,8 +447,7 @@ Expected struct with fields [a(Float32), b(Float32), c(Float32), d(Float32), e(F } _ => { return Err(AvengerScaleError::InternalError(format!( - "Unsupported data type for coercing to path transform: {:?}", - dtype + "Unsupported data type for coercing to path transform: {dtype:?}" ))) } } @@ -523,9 +517,8 @@ Expected struct with fields [a(Float32), b(Float32), c(Float32), d(Float32), e(F DataType::Struct(fields) => { let field_names = fields.iter().map(|f| f.name().as_str()).collect::>(); let msg = format!( - "Unsupported struct data type for coercing to path: {:?}\n -Expected struct with fields [verbs(List[UInt8]), points(List[Float32])]", - field_names + "Unsupported struct data type for coercing to path: {field_names:?}\n +Expected struct with fields [verbs(List[UInt8]), points(List[Float32])]" ); // Check field names and order @@ -549,8 +542,7 @@ Expected struct with fields [verbs(List[UInt8]), points(List[Float32])]", } _ => { return Err(AvengerScaleError::InternalError(format!( - "Unsupported data type for coercing to path transform: {:?}", - dtype + "Unsupported data type for coercing to path transform: {dtype:?}" ))) } } diff --git a/avenger-scales/src/scales/mod.rs b/avenger-scales/src/scales/mod.rs index e56e7d09..da7a9b5f 100644 --- a/avenger-scales/src/scales/mod.rs +++ b/avenger-scales/src/scales/mod.rs @@ -106,7 +106,7 @@ impl OptionConstraint { description: "positive number not equal to 1".to_string(), validator: |value| match value.as_f32() { Ok(v) if v > 0.0 && v != 1.0 => Ok(()), - Ok(v) => Err(format!("must be positive and not equal to 1 (got {})", v)), + Ok(v) => Err(format!("must be positive and not equal to 1 (got {v})")), Err(_) => Err("must be a numeric value".to_string()), }, } diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index 81de365e..ca41f40a 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -314,8 +314,7 @@ mod safe_time { if hour < 23 { dt.with_hour(hour + 1).ok_or_else(|| { AvengerScaleError::DstTransitionError(format!( - "Cannot set hour {} during DST transition", - hour + "Cannot set hour {hour} during DST transition" )) }) } else { @@ -326,8 +325,7 @@ mod safe_time { .and_then(|naive_dt| dt.timezone().from_local_datetime(&naive_dt).single()) .ok_or_else(|| { AvengerScaleError::DstTransitionError(format!( - "Cannot set hour {} during DST transition", - hour + "Cannot set hour {hour} during DST transition" )) }) } @@ -359,8 +357,7 @@ mod safe_time { ) -> Result, AvengerScaleError> { match date.and_hms_opt(hour, min, sec) { None => Err(AvengerScaleError::DstTransitionError(format!( - "Invalid time components: {}:{}:{}", - hour, min, sec + "Invalid time components: {hour}:{min}:{sec}" ))), Some(naive_dt) => { match tz.from_local_datetime(&naive_dt) { @@ -481,8 +478,7 @@ fn compute_actual_duration_millis( .single() .ok_or_else(|| { AvengerScaleError::DstTransitionError(format!( - "Ambiguous start timestamp: {}", - start_millis + "Ambiguous start timestamp: {start_millis}" )) })?; @@ -491,8 +487,7 @@ fn compute_actual_duration_millis( .single() .ok_or_else(|| { AvengerScaleError::DstTransitionError(format!( - "Ambiguous end timestamp: {}", - end_millis + "Ambiguous end timestamp: {end_millis}" )) })?; @@ -1456,7 +1451,7 @@ fn days_in_month(year: i32, month: u32) -> u32 { 28 } } - _ => panic!("Invalid month: {}", month), + _ => panic!("Invalid month: {month}"), } } diff --git a/avenger-winit-wgpu/src/file_watcher.rs b/avenger-winit-wgpu/src/file_watcher.rs index 269c21f1..b0644332 100644 --- a/avenger-winit-wgpu/src/file_watcher.rs +++ b/avenger-winit-wgpu/src/file_watcher.rs @@ -75,7 +75,7 @@ impl FileWatcher { .send_event(WindowEvent::FileChanged(file_event)); } } else { - error!("Failed to canonicalize event path: {:?}", path); + error!("Failed to canonicalize event path: {path:?}"); } } } diff --git a/avenger-winit-wgpu/src/lib.rs b/avenger-winit-wgpu/src/lib.rs index 9d5835e1..88b982f3 100644 --- a/avenger-winit-wgpu/src/lib.rs +++ b/avenger-winit-wgpu/src/lib.rs @@ -137,7 +137,7 @@ where *canvas_shared.borrow_mut() = Some(canvas); } Err(e) => { - log::error!("Failed to create canvas: {:?}", e); + log::error!("Failed to create canvas: {e:?}"); } } }; @@ -175,7 +175,7 @@ where // No update needed } Err(e) => { - eprintln!("Failed to update app with user event: {:?}", e); + eprintln!("Failed to update app with user event: {e:?}"); } } }; @@ -253,7 +253,7 @@ where } }, Err(err) => { - log::error!("{:?}", err); + log::error!("{err:?}"); } } } diff --git a/examples/wgpu-winit/src/util.rs b/examples/wgpu-winit/src/util.rs index ee66e232..d5e62e51 100644 --- a/examples/wgpu-winit/src/util.rs +++ b/examples/wgpu-winit/src/util.rs @@ -72,7 +72,7 @@ impl ApplicationHandler for App { *canvas_shared.borrow_mut() = Some(canvas); } Err(e) => { - log::error!("Failed to create canvas: {:?}", e); + log::error!("Failed to create canvas: {e:?}"); } } }); @@ -84,7 +84,7 @@ impl ApplicationHandler for App { *canvas_shared.borrow_mut() = Some(canvas); } Err(e) => { - log::error!("Failed to create canvas: {:?}", e); + log::error!("Failed to create canvas: {e:?}"); } } } @@ -147,7 +147,7 @@ impl ApplicationHandler for App { } }, Err(err) => { - log::error!("{:?}", err); + log::error!("{err:?}"); } } } From 9fe6aae38e230e406c208c9d4790431b28a8391a Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 21 Jul 2025 14:55:55 -0400 Subject: [PATCH 28/29] style: Apply cargo fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- avenger-scales/src/format_num/mod.rs | 4 +--- avenger-scales/src/scales/time.rs | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/avenger-scales/src/format_num/mod.rs b/avenger-scales/src/format_num/mod.rs index e03a26d5..ac0987f1 100644 --- a/avenger-scales/src/format_num/mod.rs +++ b/avenger-scales/src/format_num/mod.rs @@ -547,9 +547,7 @@ impl NumberFormat { // Compute the prefix and suffix. let prefix = format!("{sign_prefix}{leading_part}"); - let suffix = format!( - "{decimal_part}{si_prefix_exponent}{unit_of_measurement}" - ); + let suffix = format!("{decimal_part}{si_prefix_exponent}{unit_of_measurement}"); // If should group and filling character is different than "0", // group digits before applying padding. diff --git a/avenger-scales/src/scales/time.rs b/avenger-scales/src/scales/time.rs index ca41f40a..79b17b14 100644 --- a/avenger-scales/src/scales/time.rs +++ b/avenger-scales/src/scales/time.rs @@ -486,9 +486,7 @@ fn compute_actual_duration_millis( .timestamp_opt(end_millis / 1000, ((end_millis % 1000) * 1_000_000) as u32) .single() .ok_or_else(|| { - AvengerScaleError::DstTransitionError(format!( - "Ambiguous end timestamp: {end_millis}" - )) + AvengerScaleError::DstTransitionError(format!("Ambiguous end timestamp: {end_millis}")) })?; // Compute actual duration From 1141f4cb68482aff2c97a1376489576c92bf3fdc Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 21 Jul 2025 14:59:48 -0400 Subject: [PATCH 29/29] fix: Use inline format args in example projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix remaining clippy::uninlined_format_args warnings in: - examples/wgpu-scales/src/util.rs - examples/iris-pan-zoom/src/util.rs 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- examples/iris-pan-zoom/src/util.rs | 4 ++-- examples/wgpu-scales/src/util.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/iris-pan-zoom/src/util.rs b/examples/iris-pan-zoom/src/util.rs index d8ed1e6e..69930b86 100644 --- a/examples/iris-pan-zoom/src/util.rs +++ b/examples/iris-pan-zoom/src/util.rs @@ -85,7 +85,7 @@ impl ChartState { } else { // Load from file for native builds let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let data_path = format!("{}/data/Iris.csv", manifest_dir); + let data_path = format!("{manifest_dir}/data/Iris.csv"); let file = File::open(data_path).expect("Failed to open Iris.csv"); let reader = BufReader::new(file); let mut csv_reader = Reader::from_reader(reader); @@ -325,7 +325,7 @@ fn make_scene_graph(chart_state: &ChartState) -> SceneGraph { #[cfg(not(target_arch = "wasm32"))] { let duration = start_time.elapsed(); // Calculate elapsed time - println!("Scene construction time: {:?}", duration); // Print the duration + println!("Scene construction time: {duration:?}"); // Print the duration } scene_graph diff --git a/examples/wgpu-scales/src/util.rs b/examples/wgpu-scales/src/util.rs index a0b3c216..11e0d922 100644 --- a/examples/wgpu-scales/src/util.rs +++ b/examples/wgpu-scales/src/util.rs @@ -87,7 +87,7 @@ impl ApplicationHandler for App { *canvas_shared.borrow_mut() = Some(canvas); } Err(e) => { - log::error!("Failed to create canvas: {:?}", e); + log::error!("Failed to create canvas: {e:?}"); } } }); @@ -99,7 +99,7 @@ impl ApplicationHandler for App { *canvas_shared.borrow_mut() = Some(canvas); } Err(e) => { - log::error!("Failed to create canvas: {:?}", e); + log::error!("Failed to create canvas: {e:?}"); } } } @@ -148,7 +148,7 @@ impl ApplicationHandler for App { self.rtree.pick_top_mark_at_point(&point).cloned(); if top_mark != self.last_hover_mark { - println!("hover: {:?}", top_mark); + println!("hover: {top_mark:?}"); } self.last_hover_mark = top_mark; } @@ -175,7 +175,7 @@ impl ApplicationHandler for App { } }, Err(err) => { - log::error!("{:?}", err); + log::error!("{err:?}"); } } }