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/.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/.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 \ 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/ 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-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-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}" ))) } } 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/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/error.rs b/avenger-scales/src/error.rs index d45fabd5..6011b528 100644 --- a/avenger-scales/src/error.rs +++ b/avenger-scales/src/error.rs @@ -45,6 +45,18 @@ 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("DST transition error: {0}")] + DstTransitionError(String), + #[error("Invalid SVG transform string: {0}")] InvalidSvgTransformString(#[from] svgtypes::Error), diff --git a/avenger-scales/src/format_num/mod.rs b/avenger-scales/src/format_num/mod.rs index a1008b22..ac0987f1 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,11 +546,8 @@ 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 prefix = format!("{sign_prefix}{leading_part}"); + 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. @@ -580,8 +577,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 +587,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 49dfe18a..33bc43e2 100644 --- a/avenger-scales/src/scales/band.rs +++ b/avenger-scales/src/scales/band.rs @@ -5,20 +5,50 @@ 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. +/// +/// 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** (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** (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; 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 { @@ -49,10 +79,39 @@ impl BandScale { } impl ScaleImpl for BandScale { + fn scale_type(&self) -> &'static str { + "band" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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, @@ -148,44 +207,43 @@ 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); + + 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()?; 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" ))); } @@ -237,8 +295,12 @@ 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); + + 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) @@ -247,6 +309,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) { @@ -385,7 +452,7 @@ mod tests { .scale_to_numeric(&config, &values)? .as_vec(values.len(), None); - // With rounding, values should be integers + // 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" @@ -537,4 +604,171 @@ 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), + // 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 { + 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 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 = [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(()) + } } diff --git a/avenger-scales/src/scales/coerce.rs b/avenger-scales/src/scales/coerce.rs index fb26d204..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:?}" ))), } } @@ -175,7 +174,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) } } @@ -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/linear.rs b/avenger-scales/src/scales/linear.rs index 7fe75e93..c4e796ae 100644 --- a/avenger-scales/src/scales/linear.rs +++ b/avenger-scales/src/scales/linear.rs @@ -5,20 +5,51 @@ 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 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. +/// +/// # 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.). +/// +/// - **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; 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 { @@ -29,6 +60,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(), @@ -37,7 +69,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, @@ -55,24 +87,75 @@ impl LinearScale { } } - /// Compute nice domain - pub fn apply_nice( + /// Apply normalization (padding, zero and nice) to domain + pub fn apply_normalization( domain: (f32, f32), - count: Option<&Scalar>, + range: (f32, f32), + padding: 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 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 { + // 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 3: 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() { @@ -117,20 +200,107 @@ 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, (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)) + } } impl ScaleImpl for LinearScale { + fn scale_type(&self) -> &'static str { + "linear" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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), + OptionDefinition::optional("padding", OptionConstraint::NonNegativeFloat), + ]; + } + + &DEFINITIONS + } + + 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, values: &ArrayRef, ) -> Result { - let (domain_start, domain_end) = LinearScale::apply_nice( + 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"), )?; @@ -144,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 @@ -209,12 +377,14 @@ impl ScaleImpl for LinearScale { config: &ScaleConfig, values: &ArrayRef, ) -> Result, AvengerScaleError> { - let (domain_start, domain_end) = LinearScale::apply_nice( + 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); @@ -269,8 +439,12 @@ impl ScaleImpl for LinearScale { config: &ScaleConfig, count: Option, ) -> Result { - let (domain_start, domain_end) = LinearScale::apply_nice( + 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"), )?; @@ -382,6 +556,19 @@ 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 (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"), + )?; + + Ok(Arc::new(Float32Array::from(vec![domain_start, domain_end])) as ArrayRef) + } } #[cfg(test)] @@ -712,4 +899,334 @@ 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(()) + } + + #[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(()) + } + + #[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), + (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), + (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), + (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), + (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 e9c9b052..034e2d22 100644 --- a/avenger-scales/src/scales/log.rs +++ b/avenger-scales/src/scales/log.rs @@ -5,21 +5,54 @@ use arrow::{ compute::{kernels::cast, unary}, datatypes::{DataType, Float32Type}, }; -use avenger_common::value::ScalarOrArray; +use avenger_common::value::{ScalarOrArray, ScalarOrArrayValue}; +use lazy_static::lazy_static; -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}; +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. +/// +/// 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]. +/// +/// - **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)] 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 { @@ -31,6 +64,8 @@ impl LogScale { ("range_offset".to_string(), 0.0.into()), ("round".to_string(), false.into()), ("nice".to_string(), false.into()), + ("zero".to_string(), false.into()), + ("padding".to_string(), 0.0.into()), ] .into_iter() .collect(), @@ -115,26 +150,154 @@ impl LogScale { } Ok((domain_start, domain_end)) } + + /// 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(current_domain, base, nice) + } } impl ScaleImpl for LogScale { + fn scale_type(&self) -> &'static str { + "log" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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("padding", OptionConstraint::NonNegativeFloat), + OptionDefinition::optional("zero", OptionConstraint::Boolean), + OptionDefinition::optional("default", OptionConstraint::Float), + ]; + } + + &DEFINITIONS + } + + 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, values: &ArrayRef, ) -> Result { - let (domain_start, domain_end) = LinearScale::apply_nice( + 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"), )?; // 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() @@ -142,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![ @@ -345,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 { @@ -513,6 +672,22 @@ 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); + // 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"), + )?; + + Ok(Arc::new(Float32Array::from(vec![domain_start, domain_end])) as ArrayRef) + } } /// Handles logarithmic transformations with different bases @@ -588,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() { @@ -803,7 +978,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 @@ -889,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 0d3e9932..da7a9b5f 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}; @@ -34,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 { @@ -173,21 +523,98 @@ 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; + /// 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, _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, 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); @@ -253,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 @@ -279,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", ""); @@ -323,6 +756,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); @@ -468,6 +908,58 @@ impl ConfiguredScale { ) -> Result { self.scale_impl.adjust(&self.config, &to_scale.config) } + + /// Returns a new scale with zero and nice domain transformations applied and those options disabled. + /// + /// 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 + /// + /// # Example + /// ```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 (0.0, 11.0) and both zero and nice options disabled + /// # Ok::<(), avenger_scales::error::AvengerScaleError>(()) + /// ``` + pub fn normalize(self) -> Result { + let normalized_domain = self.scale_impl.compute_nice_domain(&self.config)?; + let mut new_options = self.config.options.clone(); + + // 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(); + + // 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, + config: ScaleConfig { + domain: normalized_domain, + range: self.config.range, + options: new_options, + context: self.config.context, + }, + }) + } } // Pass through methods @@ -478,6 +970,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) } @@ -511,6 +1005,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::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)?; + /// // 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/ordinal.rs b/avenger-scales/src/scales/ordinal.rs index 96e58267..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::{ @@ -32,12 +36,23 @@ 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; 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 { @@ -51,10 +66,26 @@ impl OrdinalScale { } impl ScaleImpl for OrdinalScale { + fn scale_type(&self) -> &'static str { + "ordinal" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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 80a2c989..52fb1875 100644 --- a/avenger-scales/src/scales/point.rs +++ b/avenger-scales/src/scales/point.rs @@ -2,20 +2,40 @@ 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. +/// +/// 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; 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 { @@ -36,10 +56,30 @@ impl PointScale { } impl ScaleImpl for PointScale { + fn scale_type(&self) -> &'static str { + "point" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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 3025775d..509d42c4 100644 --- a/avenger-scales/src/scales/pow.rs +++ b/avenger-scales/src/scales/pow.rs @@ -6,21 +6,56 @@ 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 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 +/// 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. +/// +/// - **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; 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 { @@ -32,6 +67,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(), @@ -59,13 +95,114 @@ impl PowScale { let domain_end = power_fun.pow_inv(nice_d1); Ok((domain_start, domain_end)) } + + /// 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), + range: (f32, f32), + padding: Option, + exponent: f32, + zero: Option<&Scalar>, + nice: Option<&Scalar>, + ) -> Result<(f32, f32), AvengerScaleError> { + // 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, range, None, zero, nice)?; + Ok((normalized_start, normalized_end)) + } } impl ScaleImpl for PowScale { + fn scale_type(&self) -> &'static str { + "pow" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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("padding", OptionConstraint::NonNegativeFloat), + OptionDefinition::optional("default", OptionConstraint::Float), + ]; + } + + &DEFINITIONS + } + + 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, @@ -77,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() @@ -94,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![ @@ -228,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"), )?; @@ -339,15 +495,46 @@ 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); 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); + + // 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"), + )?; + + Ok(Arc::new(Float32Array::from(vec![domain_start, domain_end])) as ArrayRef) + } } /// Handles power transformations with different exponents @@ -729,4 +916,119 @@ 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(()) + } + + #[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/quantile.rs b/avenger-scales/src/scales/quantile.rs index 6343e8ba..93e571ba 100644 --- a/avenger-scales/src/scales/quantile.rs +++ b/avenger-scales/src/scales/quantile.rs @@ -8,17 +8,31 @@ 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. +/// +/// 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; 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 { @@ -32,10 +46,26 @@ impl QuantileScale { } impl ScaleImpl for QuantileScale { + fn scale_type(&self) -> &'static str { + "quantile" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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 4dfe54ad..eac4ac0e 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::{ @@ -6,26 +6,47 @@ 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, +/// 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. +/// +/// - **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; 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 { 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(), }, } @@ -39,13 +60,40 @@ 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 + // 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) + } } impl ScaleImpl for QuantizeScale { + fn scale_type(&self) -> &'static str { + "quantize" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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, @@ -91,6 +139,16 @@ 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_normalization( + config.numeric_interval_domain()?, + config.options.get("zero"), + 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 f398489b..75889410 100644 --- a/avenger-scales/src/scales/symlog.rs +++ b/avenger-scales/src/scales/symlog.rs @@ -6,21 +6,51 @@ use arrow::{ compute::{kernels::cast, unary}, datatypes::{DataType, Float32Type}, }; -use avenger_common::value::ScalarOrArray; +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 +/// 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. +/// +/// - **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; 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 { @@ -32,6 +62,8 @@ impl SymlogScale { ("range_offset".to_string(), 0.0.into()), ("round".to_string(), false.into()), ("nice".to_string(), false.into()), + ("zero".to_string(), false.into()), + ("padding".to_string(), 0.0.into()), ] .into_iter() .collect(), @@ -58,13 +90,127 @@ impl SymlogScale { let domain_end = symlog_invert(nice_d1, constant); Ok((domain_start, domain_end)) } + + /// 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), + range: (f32, f32), + constant: f32, + padding: Option<&Scalar>, + zero: Option<&Scalar>, + nice: Option<&Scalar>, + ) -> Result<(f32, f32), AvengerScaleError> { + // 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, (0.0, 1.0), None, zero, nice)?; + Ok((normalized_start, normalized_end)) + } } impl ScaleImpl for SymlogScale { + fn scale_type(&self) -> &'static str { + "symlog" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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("padding", OptionConstraint::NonNegativeFloat), + OptionDefinition::optional("default", OptionConstraint::Float), + ]; + } + + &DEFINITIONS + } + + 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, @@ -76,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()?; @@ -208,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"), )?; @@ -313,27 +468,46 @@ 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); 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); + // 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"), + )?; + + Ok(Arc::new(Float32Array::from(vec![domain_start, domain_end])) as ArrayRef) + } } /// 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)] @@ -719,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/threshold.rs b/avenger-scales/src/scales/threshold.rs index b5ec5e0a..e591637a 100644 --- a/avenger-scales/src/scales/threshold.rs +++ b/avenger-scales/src/scales/threshold.rs @@ -5,17 +5,32 @@ 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. +/// +/// 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; 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 { @@ -29,10 +44,26 @@ impl ThresholdScale { } impl ScaleImpl for ThresholdScale { + fn scale_type(&self) -> &'static str { + "threshold" + } + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { 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 new file mode 100644 index 00000000..79b17b14 --- /dev/null +++ b/avenger-scales/src/scales/time.rs @@ -0,0 +1,2173 @@ +use std::sync::Arc; + +use arrow::array::{ + Array, ArrayRef, Date32Array, Date64Array, Float32Array, StringArray, + TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray, + TimestampSecondArray, +}; +use arrow::compute::kernels::cast; +use arrow::datatypes::{DataType, TimeUnit}; +use avenger_common::types::LinearScaleAdjustment; +use avenger_common::value::ScalarOrArray; +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, OptionConstraint, OptionDefinition, 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; + +/// 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 { + // 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, + } + } + } + } +} + +/// 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) +} + +/// 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 {hour} during DST transition" + )) + }) + } 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 {hour} during DST transition" + )) + }) + } + } + } + } + + /// 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) + } +} + +/// 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" + } + + fn infer_domain_from_data_method(&self) -> InferDomainFromDataMethod { + 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, + 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()?; + + // 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() { + 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 = 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))); + } + } + + 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 = 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))); + } + } + + Arc::new(Float32Array::from(output)) + } + DataType::Timestamp(unit, _) => scale_timestamp_values( + values, + unit, + &handler, + domain_start, + domain_end, + range_start, + range_end, + &tz, + use_actual_duration, + actual_duration, + )?, + _ => { + return Err(AvengerScaleError::InvalidDataTypeError( + values.data_type().clone(), + "temporal".to_string(), + )) + } + }; + + Ok(result) + } + + fn invert( + &self, + config: &ScaleConfig, + values: &ArrayRef, + ) -> Result { + // 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()?; + + // 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() { + 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 = 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)); + } + } + + // Convert milliseconds back to temporal array + create_temporal_array_from_optional_millis(&result_millis, domain_type) + } + + fn ticks( + &self, + config: &ScaleConfig, + count: Option, + ) -> Result { + // 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 { + // 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( + &self, + _from_config: &ScaleConfig, + _to_config: &ScaleConfig, + ) -> Result { + Err(AvengerScaleError::NotImplementedError( + "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 { + scalar.as_f32().unwrap_or(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 + +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(), + )), + } +} + +#[allow(clippy::too_many_arguments)] +fn scale_timestamp_values( + values: &ArrayRef, + unit: &TimeUnit, + handler: &TemporalHandler, + domain_start: i64, + 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()); + + 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 = 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))); + } + } + } + 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 = 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))); + } + } + } + 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 = 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))); + } + } + } + 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 = 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))); + } + } + } + } + + 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_or(dt); + let secs = base.second() as i32; + 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_or(dt) + .with_nanosecond(0) + .unwrap_or(dt); + let mins = base.minute() as i32; + 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_or(dt) + .with_second(0) + .unwrap_or(dt) + .with_nanosecond(0) + .unwrap_or(dt); + let hours = base.hour() as i32; + 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() + .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) => { + // 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) => { + // 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() + } + } + } + + /// 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 +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); + + // 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())) +} + +/// 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(); + let mut prev_millis = None; + + // Generate ticks within domain + while current <= end_dt { + 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) +} + +/// 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) +} + +/// 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::*; + use arrow::array::TimestampSecondArray; + use avenger_common::value::ScalarOrArrayValue; + + #[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(()) + } + + #[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)); + } + + #[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(()) + } + + #[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(()) + } + + #[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 = [ + 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, 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 + } + + 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(()) + } + + #[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(()) + } +} diff --git a/avenger-scales/tests/test_normalize_options.rs b/avenger-scales/tests/test_normalize_options.rs new file mode 100644 index 00000000..ca9d48d4 --- /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 scale = OrdinalScale::configured(domain) + .with_range(Arc::new(Float32Array::from(vec![0.0, 0.5, 1.0]))); + + // 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_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 + .scale_impl + .validate_options(&normalized_ordinal.config)?; + + Ok(()) +} 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"); + } +} 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/iris-pan-zoom/src/util.rs b/examples/iris-pan-zoom/src/util.rs index b9e8a27d..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); @@ -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)) } } @@ -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 5e4cc026..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:?}"); } } } @@ -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 { 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:?}"); } } }