From 5d29873694d61f8cc1c1411063bcb254ed2a9feb Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Thu, 5 Nov 2026 13:29:17 +0100 Subject: [PATCH 1/5] Adopt zygos --- CHANGELOG.md | 48 + Cargo.lock | 46 +- Cargo.toml | 3 - doc/syntax/scale/type/binned.qmd | 15 +- doc/syntax/scale/type/continuous.qmd | 23 +- src/Cargo.toml | 8 +- src/execute/mod.rs | 2 +- src/execute/scale.rs | 54 +- src/golden/ansi.sql | 2 +- src/golden/bigquery.sql | 2 +- src/golden/clickhouse.sql | 2 +- src/golden/databricks.sql | 2 +- src/golden/datafusion.sql | 2 +- src/golden/drill.sql | 2 +- src/golden/druid.sql | 2 +- src/golden/duckdb.sql | 2 +- src/golden/exasol.sql | 2 +- src/golden/monetdb.sql | 2 +- src/golden/mssql.sql | 2 +- src/golden/mysql.sql | 2 +- src/golden/oracle.sql | 2 +- src/golden/postgres.sql | 2 +- src/golden/redshift.sql | 2 +- src/golden/snowflake.sql | 2 +- src/golden/sqlite.sql | 2 +- src/golden/trino.sql | 2 +- src/plot/AGENTS.md | 10 +- src/plot/scale/breaks.rs | 2121 ++------------------ src/plot/scale/colour.rs | 187 +- src/plot/scale/linetype.rs | 125 +- src/plot/scale/mod.rs | 2 - src/plot/scale/palettes.rs | 2405 +---------------------- src/plot/scale/scale_type/binned.rs | 59 +- src/plot/scale/scale_type/continuous.rs | 2 +- src/plot/scale/scale_type/mod.rs | 372 +++- src/plot/scale/shape.rs | 416 ---- src/plot/scale/transform/asinh.rs | 62 +- src/plot/scale/transform/date.rs | 365 +--- src/plot/scale/transform/datetime.rs | 402 +--- src/plot/scale/transform/exp.rs | 57 +- src/plot/scale/transform/identity.rs | 15 +- src/plot/scale/transform/log.rs | 109 +- src/plot/scale/transform/mod.rs | 205 ++ src/plot/scale/transform/pseudo_log.rs | 59 +- src/plot/scale/transform/sqrt.rs | 46 +- src/plot/scale/transform/square.rs | 32 +- src/plot/scale/transform/time.rs | 316 +-- src/plot/scale/types.rs | 44 +- src/writer/hephaestus/AGENTS.md | 5 - src/writer/hephaestus/mod.rs | 11 +- src/writer/hephaestus/scales.rs | 44 +- src/writer/vegalite/encoding.rs | 3 +- src/writer/vegalite/mod.rs | 4 +- src/writer/vegalite/shape.rs | 55 + 54 files changed, 1379 insertions(+), 6389 deletions(-) delete mode 100644 src/plot/scale/shape.rs create mode 100644 src/writer/vegalite/shape.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c42cecde..b6fb848d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,41 @@ - Scale resolution now detects and converts temporal-unit mismatches and errors on unrepresentable ranges instead of producing silently wrong scales (#569). - `Plot.labels` field is no longer `Option` but plain `Labels`. +- Scale internals now build on the `zygos` crate: numeric transform math, + break/tick selection, color ramps, the named palette tables, + shape/linetype lookups, and out-of-bounds policies live there and are + delegated to. Break and tick selection comes from zygos wholesale, so tick + positions and counts may differ from previous releases: + - the default `breaks` count is 5 (was 7), which also changes the default + number of bins on binned scales; + - pretty breaks use zygos's Wilkinson Extended weights, favouring steps of 2 + and ticks that stay within the data (e.g. 0–100 gives 0, 20, …, 100); + `square` and `exp*` scales use them too, in data space; + - log breaks follow the 1-2-5 pattern on every decade rather than being + thinned to the requested count (3 and 7 join it over narrow spans when + more breaks are asked for; very wide spans keep only powers); + `pretty => false` no longer switches log scales to powers only; + - `asinh`/`pseudo_log*` breaks reach closer to zero on each side, and + `pseudo_log2`/`pseudo_ln` place them at powers of their own base; + - log-family minor breaks are the whole-number multiples between powers + (2, 3, …, 9 for base 10) rather than even steps in log space; a + `minor_breaks` count only switches them on or off; + - temporal breaks, automatic or from an interval, start from a calendar grid + sized to the interval (e.g. "3 months" from Feb 15 → Jan, Apr, Jul), and + `TIME` interval breaks step across minute/hour boundaries; automatic + temporal minor breaks fall on the calendar sub-interval (quarters under + years, weeks under months, …) instead of three evenly spaced per interval. +- **Breaking (library API):** the scale module's public surface shrank with + the move to `zygos`. Removed: the `plot::scale::shape` module including + `shape_to_svg_path`; the ~85 named palette table constants in + `plot::scale::palettes` (`GGSQL10`, `VIRIDIS`, … — the `get_*_palette` + lookups remain); the hand-rolled break functions `wilkinson_extended`, + `pretty_breaks`, `log_breaks`, `exp_pretty_breaks`, `minor_breaks_log`, + `minor_breaks_sqrt`, `minor_breaks_symlog`, `trim_breaks`, + `trim_temporal_breaks` and `derive_minor_interval` (their zygos + counterparts are re-exported where ggsql still uses them); + `colour::ColorSpace` is now a re-export of zygos's, whose linear-RGB + variant is named `Srgb` rather than `LinearRgb`. - Replaced `CLAUDE.md` files with `AGENTS.md` files. ### Fixed @@ -41,6 +76,19 @@ - Fixed schema-less `validate()` rejecting spatial layers that omit an explicit `geometry` mapping, even though execution auto-detects the geometry column (#587) +- Log axes carry decade ticks again (#512). Position scales now expand their + domain in transformed space (via zygos) rather than in data space, so the + pad can no longer push a log or sqrt scale's lower bound below zero and + get clipped to the smallest positive float — a 1–100 `log` axis now spans + roughly 0.79–126 instead of 5e-308–105. As a consequence the `expand` + setting is measured in transformed units on transformed scales: the + multiplier is a share of the transformed span, and the additive part is in + transformed units too (`add = 0.1` on a `log` scale is a tenth of a decade). + A zero-width range pads as if it spanned one transformed unit (previously + its own absolute value). Where a pad would land outside a transform's image + (the lower end of a `sqrt`, `square` or `exp*` scale near zero), that end is + left unpadded rather than folding back inside the data range. Identity + scales are unaffected. ## 0.5.2 - 2026-09-11 diff --git a/Cargo.lock b/Cargo.lock index 3cdb9db58..d56bcdbcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,12 +619,6 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - [[package]] name = "bytecheck" version = "0.6.12" @@ -1369,12 +1363,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - [[package]] name = "fastrand" version = "2.4.1" @@ -1746,7 +1734,6 @@ dependencies = [ "hephaestus", "jsonschema", "libloading", - "palette", "parquet", "rand 0.8.6", "regex", @@ -1762,6 +1749,7 @@ dependencies = [ "ureq", "uuid", "wuff", + "zygos", ] [[package]] @@ -3392,29 +3380,6 @@ dependencies = [ "ttf-parser", ] -[[package]] -name = "palette" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" -dependencies = [ - "approx", - "fast-srgb8", - "palette_derive", -] - -[[package]] -name = "palette_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" -dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -6745,3 +6710,12 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zygos" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c52e3a9047311f9bcd2d4850c025f13726bca44df8c0c172b2967c2327c559" +dependencies = [ + "peniko", +] diff --git a/Cargo.toml b/Cargo.toml index 457247270..83477cdf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,9 +65,6 @@ clap = { version = "4.4", features = ["derive"] } anyhow = "1.0" thiserror = "1.0" -# Color interpolation -palette = { version = "0.7", default-features = false, features = ["std", "approx"] } - # Spatial geozero = { version = "0.14", default-features = false } diff --git a/doc/syntax/scale/type/binned.qmd b/doc/syntax/scale/type/binned.qmd index 43fdcd8a9..2c82083b7 100644 --- a/doc/syntax/scale/type/binned.qmd +++ b/doc/syntax/scale/type/binned.qmd @@ -82,15 +82,14 @@ Since breaks are not just presentational as it is with continuous scales the cho * `linear`: - `pretty => true`: Will use Wilkinsons Extended algorithm to attempt to find nice breaks in the given interval close to the number of breaks requested - `pretty => false`: Will produce the requested number of evenly spaced breaks within the scale range -* `log`/`log2`/`ln`: - - `pretty => true`: Will use the 1-2-5 pattern and thin down to approximately the requested number of breaks - - `pretty => false`: Breaks will be exclusively at the power of the base (e.g. 1, 10, 100, 1000 for log10) -* `exp10`/`exp2`/`exp`: Same logic as the log breaks but in the inverse direction -* `sqrt`/`square`: Like `linear` but the range is first converted to sqrt space and the breaks are then converted back -* `asinh`/`pseudo_log`/`pseudo_log2`/`pseudo_ln`: Like `log` but includes zero and negates the breaks for the negative part +* `log`/`log2`/`ln`: Uses the 1-2-5 pattern on every power of the base (e.g. 1, 2, 5, 10, 20, 50 for log10), adding 3 and 7 on narrow ranges when many breaks are requested and keeping only the powers on very wide ranges, where powers are skipped to stay near the requested number of breaks. Multipliers at or above the base are dropped, so `log2` breaks are powers of 2. The `pretty` setting has no effect +* `exp10`/`exp2`/`exp`: Like `linear`, on the exponents +* `sqrt`: Like `linear` but the range is first converted to sqrt space and the breaks are then converted back +* `square`: Like `linear` +* `asinh`/`pseudo_log`/`pseudo_log2`/`pseudo_ln`: Like `log` (in base 10 for `asinh`, in their own base for the others) but includes zero and negates the breaks for the negative part * `integer`: Like `linear` except disallowing breaks at fractional parts * `date`/`datetime`/`time`: - - `breaks => `: If breaks are given as an interval (e.g. `week`, `30 seconds` or `5 years`) then the breaks will get that spacing aligned at the interval boundary (Jan 1 for years, etc). This ignores the `pretty` setting + - `breaks => `: If breaks are given as an interval (e.g. `week`, `30 seconds` or `5 years`) then the breaks will get that spacing aligned to a calendar grid of that size (Jan 1 for years, Mondays for weeks, and e.g. January/April/July/October for `3 months` or years divisible by 5 for `5 years`). This ignores the `pretty` setting - `pretty => true`: An appropriate interval is chosen that approximates the requested number of breaks and then used as above - `pretty => false`: Linear spacing in integer space as close to the requested number of breaks @@ -125,7 +124,7 @@ SCALE BINNED x VIA date ## Settings The following settings are recognised by binned scales: -* `expand` (only for `x`/`y`): Either a scalar number or 2-element array of numbers (values must be >= 0). Sets the expansion of the scale to either side of the range. If a scalar it gives the multiplicative expansion. If an array the first element is a multiplication factor and the second element is an additive constant. Defaults to `0.05` (5 %). Expansion is only applied to values that are not explicitly given by the user, i.e. if setting the range as `SCALE x FROM (0, null)` expansion will only be applied to the upper range. +* `expand` (only for `x`/`y`): Either a scalar number or 2-element array of numbers (values must be >= 0). Sets the expansion of the scale to either side of the range. If a scalar it gives the multiplicative expansion. If an array the first element is a multiplication factor and the second element is an additive constant. Defaults to `0.05` (5 %). Expansion is only applied to values that are not explicitly given by the user, i.e. if setting the range as `SCALE x FROM (0, null)` expansion will only be applied to the upper range. Expansion is applied in the transformed space of the scale, so on a transformed scale both parts are measured in transformed units: on a `log` scale a factor of `0.05` pads by 5 % of the visible decades and an additive `1` pads by one decade. * `oob`: How should values outside of the scale input range be treated. One of `'censor'` (set to `null`), or `'squish'` (set to the nearest bin). Default is `'censor'`. When set to `'squish'` the terminal bin labels will be removed to reflect that they extend to -Inf and Inf. * `breaks`: Either a scalar (whole number >= 1) as described in [the section on breaks](#breaks), or an array of values to place breaks at. Defaults to `5`. * `pretty`: A boolean indicating which algorithm to use for automatic calculation of breaks as described in [the section on breaks](#breaks). Defaults to `true`. diff --git a/doc/syntax/scale/type/continuous.qmd b/doc/syntax/scale/type/continuous.qmd index 8a6127879..db5e82ae0 100644 --- a/doc/syntax/scale/type/continuous.qmd +++ b/doc/syntax/scale/type/continuous.qmd @@ -83,15 +83,14 @@ If not provided explicitly by the user the breaks for the scales will be calcula * `linear`: - `pretty => true`: Will use Wilkinsons Extended algorithm to attempt to find nice breaks in the given interval close to the number of breaks requested - `pretty => false`: Will produce the requested number of evenly spaced breaks within the scale range -* `log`/`log2`/`ln`: - - `pretty => true`: Will use the 1-2-5 pattern and thin down to approximately the requested number of breaks - - `pretty => false`: Breaks will be exclusively at the power of the base (e.g. 1, 10, 100, 1000 for log10) -* `exp10`/`exp2`/`exp`: Same logic as the log breaks but in the inverse direction -* `sqrt`/`square`: Like `linear` but the range is first converted to sqrt space and the breaks are then converted back -* `asinh`/`pseudo_log`/`pseudo_log2`/`pseudo_ln`: Like `log` but includes zero and negates the breaks for the negative part +* `log`/`log2`/`ln`: Uses the 1-2-5 pattern on every power of the base (e.g. 1, 2, 5, 10, 20, 50 for log10), adding 3 and 7 on narrow ranges when many breaks are requested and keeping only the powers on very wide ranges, where powers are skipped to stay near the requested number of breaks. Multipliers at or above the base are dropped, so `log2` breaks are powers of 2. The `pretty` setting has no effect +* `exp10`/`exp2`/`exp`: Like `linear`, on the exponents +* `sqrt`: Like `linear` but the range is first converted to sqrt space and the breaks are then converted back +* `square`: Like `linear` +* `asinh`/`pseudo_log`/`pseudo_log2`/`pseudo_ln`: Like `log` (in base 10 for `asinh`, in their own base for the others) but includes zero and negates the breaks for the negative part * `integer`: Like `linear` except disallowing breaks at fractional parts * `date`/`datetime`/`time`: - - `breaks => `: If breaks are given as an interval (e.g. `week`, `30 seconds` or `5 years`) then the breaks will get that spacing aligned at the interval boundary (Jan 1 for years, etc). This ignores the `pretty` setting + - `breaks => `: If breaks are given as an interval (e.g. `week`, `30 seconds` or `5 years`) then the breaks will get that spacing aligned to a calendar grid of that size (Jan 1 for years, Mondays for weeks, and e.g. January/April/July/October for `3 months` or years divisible by 5 for `5 years`). This ignores the `pretty` setting - `pretty => true`: An appropriate interval is chosen that approximates the requested number of breaks and then used as above - `pretty => false`: Linear spacing in integer space as close to the requested number of breaks @@ -102,13 +101,13 @@ Where `breaks => 5` asks for about five breaks across the whole scale, `minor_br If not given, the transformation picks the count: -* `linear`/`integer`/`sqrt`/`square`: one minor break per interval, i.e. at the midpoint -* `log`/`log2`/`ln`/`exp10`/`exp2`/`exp`/`asinh`/`pseudo_log`/`pseudo_log2`/`pseudo_ln`: eight per interval, giving the familiar 2-9 pattern between powers -* `date`/`datetime`/`time`: three per interval +* `linear`/`integer`/`sqrt`/`square`/`exp10`/`exp2`/`exp`: one minor break per interval, i.e. at the midpoint +* `log`/`log2`/`ln`/`asinh`/`pseudo_log`/`pseudo_log2`/`pseudo_ln`: the whole-number multiples between powers of the base, giving the familiar 2-9 pattern for base 10 (and the geometric midpoint for base 2) +* `date`/`datetime`/`time`: when the breaks step by a calendar interval (chosen automatically or given as an interval), the next calendar unit down — quarters under years, months under quarters, weeks under months, days under weeks, and so on — while a multiple of a unit divides within it (months under `2 months`). Otherwise three per interval Automatically derived minor breaks are always placed relative to the breaks, so changing `breaks` moves them too. Set them explicitly to break that link: -* `minor_breaks => `: that many evenly spaced minor breaks inside every interval. `0` removes them entirely +* `minor_breaks => `: that many evenly spaced minor breaks inside every interval. `0` removes them entirely. The log-family transforms keep their whole-number multiples for any other number * `minor_breaks => (…)`: an array of exact positions. Values outside the scale range are dropped * `minor_breaks => `: for `date`/`datetime`/`time` only, an interval (e.g. `week` or `6 hours`) aligned at the interval boundary, exactly as `breaks` treats an interval @@ -162,7 +161,7 @@ SCALE x ## Settings The following settings are recognised by continuous scales: -* `expand` (only for `x`/`y`): Either a scalar number or 2-element array of numbers (values must be >= 0). Sets the expansion of the scale to either side of the range. If a scalar it gives the multiplicative expansion. If an array the first element is a multiplication factor and the second element is an additive constant. Defaults to `0.05` (5 %). Expansion is only applied to values that are not explicitly given by the user, i.e. if setting the range as `SCALE x FROM (0, null)` expansion will only be applied to the upper range. +* `expand` (only for `x`/`y`): Either a scalar number or 2-element array of numbers (values must be >= 0). Sets the expansion of the scale to either side of the range. If a scalar it gives the multiplicative expansion. If an array the first element is a multiplication factor and the second element is an additive constant. Defaults to `0.05` (5 %). Expansion is only applied to values that are not explicitly given by the user, i.e. if setting the range as `SCALE x FROM (0, null)` expansion will only be applied to the upper range. Expansion is applied in the transformed space of the scale, so on a transformed scale both parts are measured in transformed units: on a `log` scale a factor of `0.05` pads by 5 % of the visible decades and an additive `1` pads by one decade. * `oob`: How should values outside of the scale input range be treated. One of `'keep'` (keep the values as-is), `'censor'` (set to `null`), or `'squish'` (set to the nearest values within the range). Default for `x`/`y` is `'keep'`, for the remaining it is `'censor'`. * `breaks`: Either a scalar (whole number >= 1) as described in [the section on breaks](#breaks), or an array of values to place breaks at. Defaults to `5`. * `minor_breaks`: Placement of the minor breaks that fall between the breaks, as described in [the section on minor breaks](#minor-breaks). Either a scalar (whole number >= 0) giving the number of minor breaks *per interval between two breaks*, an array of values to place them at, or (for temporal transformations) an interval. Use `minor_breaks => 0` to remove them. Defaults to a value chosen by the transformation. diff --git a/src/Cargo.toml b/src/Cargo.toml index 34f4209cd..e639b8bf0 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -19,9 +19,6 @@ tree-sitter.workspace = true tree-sitter-ggsql = { workspace = true } csscolorparser.workspace = true -# Color interpolation -palette.workspace = true - # Arrow (core data container) arrow = { workspace = true } @@ -47,6 +44,11 @@ geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt # the `raster` feature, so a build wanting only vector output pulls no wgpu. hephaestus = { version = "0.4.1", optional = true, default-features = false, features = ["geom-wkb", "geom-wkt"] } +# Scale primitives (transforms, breaks, palettes, OOB policies). Unlike +# hephaestus this is a core dependency: the executor resolves scales through +# it, so it must build on the MSRV toolchain. +zygos = { version = "0.2.0", default-features = false, features = ["palettes"] } + # WOFF / WOFF2 decoding for `fonts::register_font`, behind `webfonts`. Pure # Rust and no build script; `brotli` here is `brotli-decompressor`, the # decode-only half, so the gate costs a decompressor and nothing else. diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 1ef2118c7..216e67b12 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -1718,7 +1718,7 @@ mod tests { // Each panel's domain covers only its own data (with expansion), and // the two panels resolve to disjoint domains. assert!( - lo_a <= 1.0 && hi_a >= 3.0 && hi_a < 50.0, + lo_a <= 1.0 && (3.0..50.0).contains(&hi_a), "panel A: {lo_a}..{hi_a}" ); assert!( diff --git a/src/execute/scale.rs b/src/execute/scale.rs index 306009140..8e18afdcd 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -10,7 +10,6 @@ use crate::plot::projection::CoordKind; use crate::plot::scale::{ default_oob, gets_default_scale, infer_scale_target_type, infer_scale_type_from_input_range, infer_transform_from_input_range, is_facet_aesthetic, transform::Transform, TransformKind, - OOB_CENSOR, OOB_KEEP, OOB_SQUISH, }; use crate::plot::{ AestheticValue, ArrayElement, ArrayElementType, ColumnInfo, Layer, ParameterValue, Plot, Scale, @@ -1376,15 +1375,13 @@ pub fn apply_scale_oob(spec: &Plot, data_map: &mut HashMap) - // - If not set but has explicit input range, use default for aesthetic // - Otherwise skip let oob_mode = match scale.properties.get("oob") { - Some(ParameterValue::String(s)) if s != OOB_KEEP => s.as_str(), - Some(ParameterValue::String(_)) => continue, // explicit "keep" - None if scale.explicit_input_range => { - let default = default_oob(&scale.aesthetic); - if default == OOB_KEEP { - continue; - } - default - } + Some(ParameterValue::String(s)) => s.as_str(), + None if scale.explicit_input_range => default_oob(&scale.aesthetic), + _ => continue, + }; + // Validated upstream against OOB_VALUES_*; "keep" means no OOB pass. + let oob_policy = match zygos::OobPolicy::from_name(oob_mode) { + Some(p) if p != zygos::OobPolicy::Keep => p, _ => continue, }; @@ -1445,7 +1442,7 @@ pub fn apply_scale_oob(spec: &Plot, data_map: &mut HashMap) - (Some(lo), Some(hi)) => (lo, hi), _ => continue, }; - apply_oob_to_column_numeric(df, &col_name, range_min, range_max, oob_mode)? + apply_oob_to_column_numeric(df, &col_name, range_min, range_max, oob_policy)? } else { // Discrete range - collect allowed values as strings using to_key_string let allowed_values: HashSet = input_range @@ -1453,7 +1450,7 @@ pub fn apply_scale_oob(spec: &Plot, data_map: &mut HashMap) - .filter(|elem| !matches!(elem, ArrayElement::Null)) .map(|elem| elem.to_key_string()) .collect(); - apply_oob_to_column_discrete(df, &col_name, &allowed_values, oob_mode)? + apply_oob_to_column_discrete(df, &col_name, &allowed_values, oob_policy)? }; data_map.insert(data_key, transformed); } @@ -1550,7 +1547,7 @@ pub fn apply_oob_to_column_numeric( col_name: &str, range_min: f64, range_max: f64, - oob_mode: &str, + oob_policy: zygos::OobPolicy, ) -> Result { use crate::array_util::*; use arrow::array::Array; @@ -1568,17 +1565,18 @@ pub fn apply_oob_to_column_numeric( let f64_arr = as_f64(&f64_col)?; - match oob_mode { - OOB_CENSOR => { - // Filter out rows where values are outside [range_min, range_max] - // Build a boolean mask + match oob_policy { + zygos::OobPolicy::Censor => { + // Filter out rows where values are outside [range_min, range_max]. + // NaN is censored too; zygos's `apply` currently passes NaN through + // under every policy, so it is dropped explicitly here. let mask_values: Vec = (0..f64_arr.len()) .map(|i| { if f64_arr.is_null(i) { true // Keep nulls } else { let v = f64_arr.value(i); - v >= range_min && v <= range_max + !v.is_nan() && oob_policy.apply(v, range_min, range_max).is_some() } }) .collect(); @@ -1595,14 +1593,14 @@ pub fn apply_oob_to_column_numeric( } DataFrame::new(new_columns) } - OOB_SQUISH => { + zygos::OobPolicy::Squish => { // Clamp values to [range_min, range_max] let clamped: Vec> = (0..f64_arr.len()) .map(|i| { if f64_arr.is_null(i) { None } else { - Some(f64_arr.value(i).clamp(range_min, range_max)) + oob_policy.apply(f64_arr.value(i), range_min, range_max) } }) .collect(); @@ -1626,7 +1624,7 @@ pub fn apply_oob_to_column_numeric( df.with_column(col_name, restored_array) .map_err(|e| GgsqlError::InternalError(format!("Failed to replace column: {}", e))) } - _ => Ok(df.clone()), + zygos::OobPolicy::Keep => Ok(df.clone()), } } @@ -1661,13 +1659,13 @@ pub fn apply_oob_to_column_discrete( df: &DataFrame, col_name: &str, allowed_values: &HashSet, - oob_mode: &str, + oob_policy: zygos::OobPolicy, ) -> Result { use crate::array_util::*; use arrow::array::Array; // For discrete columns, only censor makes sense (squish is validated out earlier) - if oob_mode != OOB_CENSOR { + if oob_policy != zygos::OobPolicy::Censor { return Ok(df.clone()); } @@ -2067,8 +2065,9 @@ mod tests { let df = DataFrame::new(vec![("date", dates)]).unwrap(); // Censor to [2024-03-01, 2024-09-01] ≈ [19783, 19967] → keeps only row 1 - let result = apply_oob_to_column_numeric(&df, "date", 19783.0, 19967.0, OOB_CENSOR) - .expect("oob censor should handle Date32"); + let result = + apply_oob_to_column_numeric(&df, "date", 19783.0, 19967.0, zygos::OobPolicy::Censor) + .expect("oob censor should handle Date32"); assert_eq!(result.height(), 1); } @@ -2080,8 +2079,9 @@ mod tests { let dates: ArrayRef = Arc::new(Date32Array::from(vec![19000, 19875, 21000])); let df = DataFrame::new(vec![("date", dates)]).unwrap(); - let result = apply_oob_to_column_numeric(&df, "date", 19723.0, 20089.0, OOB_SQUISH) - .expect("oob squish should handle Date32"); + let result = + apply_oob_to_column_numeric(&df, "date", 19723.0, 20089.0, zygos::OobPolicy::Squish) + .expect("oob squish should handle Date32"); assert_eq!( result.column("date").unwrap().data_type(), &DataType::Date32 diff --git a/src/golden/ansi.sql b/src/golden/ansi.sql index 71e5a0a1d..eeb31fa31 100644 --- a/src/golden/ansi.sql +++ b/src/golden/ansi.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.2 THEN 19000.1 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.2 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.4 THEN 19000.300000000003 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.4 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.6 THEN 19000.5 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.6 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.8 THEN 19000.699999999997 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.8 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19001 THEN 19000.9 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19001 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.2 THEN 19000.1 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.2 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.4 THEN 19000.300000000003 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.4 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.6 THEN 19000.5 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.6 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.8 THEN 19000.699999999997 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.8 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/bigquery.sql b/src/golden/bigquery.sql index d6dad5c3c..6f239ea97 100644 --- a/src/golden/bigquery.sql +++ b/src/golden/bigquery.sql @@ -80,7 +80,7 @@ SELECT *, `id` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; WITH `__ggsql_source__` AS (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) SELECT MIN(`id`) AS `__ggsql_min_id`, MAX(`id`) AS `__ggsql_max_id`, MIN(`val`) AS `__ggsql_min_val`, MAX(`val`) AS `__ggsql_max_val`, MIN(`grp`) AS `__ggsql_min_grp`, MAX(`grp`) AS `__ggsql_max_grp`, MIN(`day`) AS `__ggsql_min_day`, MAX(`day`) AS `__ggsql_max_day`, MIN(`mixed Case`) AS `__ggsql_min_mixed Case`, MAX(`mixed Case`) AS `__ggsql_max_mixed Case` FROM `__ggsql_source__`; -SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) < 19000.2 THEN 19000.1 WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000.2 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) < 19000.4 THEN 19000.300000000003 WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000.4 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) < 19000.6 THEN 19000.5 WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000.6 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) < 19000.8 THEN 19000.699999999997 WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000.8 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) < 19001 THEN 19000.9 WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19001 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) <= 19001.2 THEN 19001.1 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; +SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) < 19000.2 THEN 19000.1 WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000.2 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) < 19000.4 THEN 19000.300000000003 WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000.4 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) < 19000.6 THEN 19000.5 WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000.6 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) < 19000.8 THEN 19000.699999999997 WHEN SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) >= 19000.8 AND SAFE_CAST(`__ggsql_aes_pos1__` AS FLOAT64) <= 19001 THEN 19000.9 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; diff --git a/src/golden/clickhouse.sql b/src/golden/clickhouse.sql index 44dba9a9b..66d67e703 100644 --- a/src/golden/clickhouse.sql +++ b/src/golden/clickhouse.sql @@ -83,7 +83,7 @@ SELECT *, `id` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; WITH `__ggsql_source__` AS (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) SELECT MIN(`id`) AS `__ggsql_min_id`, MAX(`id`) AS `__ggsql_max_id`, MIN(`val`) AS `__ggsql_min_val`, MAX(`val`) AS `__ggsql_max_val`, MIN(`grp`) AS `__ggsql_min_grp`, MAX(`grp`) AS `__ggsql_max_grp`, MIN(`day`) AS `__ggsql_min_day`, MAX(`day`) AS `__ggsql_max_day`, MIN(`mixed Case`) AS `__ggsql_min_mixed Case`, MAX(`mixed Case`) AS `__ggsql_max_mixed Case` FROM `__ggsql_source__`; -SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000 AND toInt32(`__ggsql_aes_pos1__`) < 19000.2 THEN 19000.1 WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000.2 AND toInt32(`__ggsql_aes_pos1__`) < 19000.4 THEN 19000.300000000003 WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000.4 AND toInt32(`__ggsql_aes_pos1__`) < 19000.6 THEN 19000.5 WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000.6 AND toInt32(`__ggsql_aes_pos1__`) < 19000.8 THEN 19000.699999999997 WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000.8 AND toInt32(`__ggsql_aes_pos1__`) < 19001 THEN 19000.9 WHEN toInt32(`__ggsql_aes_pos1__`) >= 19001 AND toInt32(`__ggsql_aes_pos1__`) <= 19001.2 THEN 19001.1 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; +SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000 AND toInt32(`__ggsql_aes_pos1__`) < 19000.2 THEN 19000.1 WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000.2 AND toInt32(`__ggsql_aes_pos1__`) < 19000.4 THEN 19000.300000000003 WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000.4 AND toInt32(`__ggsql_aes_pos1__`) < 19000.6 THEN 19000.5 WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000.6 AND toInt32(`__ggsql_aes_pos1__`) < 19000.8 THEN 19000.699999999997 WHEN toInt32(`__ggsql_aes_pos1__`) >= 19000.8 AND toInt32(`__ggsql_aes_pos1__`) <= 19001 THEN 19000.9 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; diff --git a/src/golden/databricks.sql b/src/golden/databricks.sql index 593486831..052897cd8 100644 --- a/src/golden/databricks.sql +++ b/src/golden/databricks.sql @@ -80,7 +80,7 @@ SELECT *, `id` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; WITH `__ggsql_source__` AS (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) SELECT MIN(`id`) AS `__ggsql_min_id`, MAX(`id`) AS `__ggsql_max_id`, MIN(`val`) AS `__ggsql_min_val`, MAX(`val`) AS `__ggsql_max_val`, MIN(`grp`) AS `__ggsql_min_grp`, MAX(`grp`) AS `__ggsql_max_grp`, MIN(`day`) AS `__ggsql_min_day`, MAX(`day`) AS `__ggsql_max_day`, MIN(`mixed Case`) AS `__ggsql_min_mixed Case`, MAX(`mixed Case`) AS `__ggsql_max_mixed Case` FROM `__ggsql_source__`; -SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000 AND UNIX_DATE(`__ggsql_aes_pos1__`) < 19000.2 THEN 19000.1 WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000.2 AND UNIX_DATE(`__ggsql_aes_pos1__`) < 19000.4 THEN 19000.300000000003 WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000.4 AND UNIX_DATE(`__ggsql_aes_pos1__`) < 19000.6 THEN 19000.5 WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000.6 AND UNIX_DATE(`__ggsql_aes_pos1__`) < 19000.8 THEN 19000.699999999997 WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000.8 AND UNIX_DATE(`__ggsql_aes_pos1__`) < 19001 THEN 19000.9 WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19001 AND UNIX_DATE(`__ggsql_aes_pos1__`) <= 19001.2 THEN 19001.1 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; +SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000 AND UNIX_DATE(`__ggsql_aes_pos1__`) < 19000.2 THEN 19000.1 WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000.2 AND UNIX_DATE(`__ggsql_aes_pos1__`) < 19000.4 THEN 19000.300000000003 WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000.4 AND UNIX_DATE(`__ggsql_aes_pos1__`) < 19000.6 THEN 19000.5 WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000.6 AND UNIX_DATE(`__ggsql_aes_pos1__`) < 19000.8 THEN 19000.699999999997 WHEN UNIX_DATE(`__ggsql_aes_pos1__`) >= 19000.8 AND UNIX_DATE(`__ggsql_aes_pos1__`) <= 19001 THEN 19000.9 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; diff --git a/src/golden/datafusion.sql b/src/golden/datafusion.sql index 950f46b34..37e12ed72 100644 --- a/src/golden/datafusion.sql +++ b/src/golden/datafusion.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.2 THEN 19000.1 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.2 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.4 THEN 19000.300000000003 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.4 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.6 THEN 19000.5 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.6 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.8 THEN 19000.699999999997 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.8 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19001 THEN 19000.9 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19001 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.2 THEN 19000.1 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.2 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.4 THEN 19000.300000000003 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.4 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.6 THEN 19000.5 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.6 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.8 THEN 19000.699999999997 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.8 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/drill.sql b/src/golden/drill.sql index 35ca7b5db..8b3b25f65 100644 --- a/src/golden/drill.sql +++ b/src/golden/drill.sql @@ -83,7 +83,7 @@ SELECT *, `id` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; WITH `__ggsql_source__` AS (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) SELECT MIN(`id`) AS `__ggsql_min_id`, MAX(`id`) AS `__ggsql_max_id`, MIN(`val`) AS `__ggsql_min_val`, MAX(`val`) AS `__ggsql_max_val`, MIN(`grp`) AS `__ggsql_min_grp`, MAX(`grp`) AS `__ggsql_max_grp`, MIN(`day`) AS `__ggsql_min_day`, MAX(`day`) AS `__ggsql_max_day`, MIN(`mixed Case`) AS `__ggsql_min_mixed Case`, MAX(`mixed Case`) AS `__ggsql_max_mixed Case` FROM `__ggsql_source__`; -SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) < 19000.2 THEN 19000.1 WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000.2 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) < 19000.4 THEN 19000.300000000003 WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000.4 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) < 19000.6 THEN 19000.5 WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000.6 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) < 19000.8 THEN 19000.699999999997 WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000.8 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) < 19001 THEN 19000.9 WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19001 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) <= 19001.2 THEN 19001.1 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; +SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) < 19000.2 THEN 19000.1 WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000.2 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) < 19000.4 THEN 19000.300000000003 WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000.4 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) < 19000.6 THEN 19000.5 WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000.6 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) < 19000.8 THEN 19000.699999999997 WHEN CAST(`__ggsql_aes_pos1__` AS DOUBLE) >= 19000.8 AND CAST(`__ggsql_aes_pos1__` AS DOUBLE) <= 19001 THEN 19000.9 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; diff --git a/src/golden/druid.sql b/src/golden/druid.sql index 23030d76f..206358430 100644 --- a/src/golden/druid.sql +++ b/src/golden/druid.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.2 THEN 19000.1 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.2 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.4 THEN 19000.300000000003 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.4 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.6 THEN 19000.5 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.6 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.8 THEN 19000.699999999997 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.8 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19001 THEN 19000.9 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19001 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.2 THEN 19000.1 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.2 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.4 THEN 19000.300000000003 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.4 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.6 THEN 19000.5 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.6 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.8 THEN 19000.699999999997 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.8 AND CAST("__ggsql_aes_pos1__" AS DOUBLE) <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/duckdb.sql b/src/golden/duckdb.sql index dfa88831c..f89bfd11d 100644 --- a/src/golden/duckdb.sql +++ b/src/golden/duckdb.sql @@ -80,7 +80,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.2 THEN 19000.1 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.2 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.4 THEN 19000.300000000003 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.4 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.6 THEN 19000.5 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.6 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.8 THEN 19000.699999999997 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.8 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19001 THEN 19000.9 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19001 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.2 THEN 19000.1 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.2 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.4 THEN 19000.300000000003 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.4 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.6 THEN 19000.5 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.6 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.8 THEN 19000.699999999997 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.8 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/exasol.sql b/src/golden/exasol.sql index 71e5a0a1d..eeb31fa31 100644 --- a/src/golden/exasol.sql +++ b/src/golden/exasol.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.2 THEN 19000.1 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.2 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.4 THEN 19000.300000000003 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.4 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.6 THEN 19000.5 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.6 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.8 THEN 19000.699999999997 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.8 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19001 THEN 19000.9 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19001 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.2 THEN 19000.1 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.2 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.4 THEN 19000.300000000003 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.4 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.6 THEN 19000.5 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.6 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.8 THEN 19000.699999999997 WHEN CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.8 AND CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/monetdb.sql b/src/golden/monetdb.sql index e5c965ee7..26f990bcc 100644 --- a/src/golden/monetdb.sql +++ b/src/golden/monetdb.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.2 THEN 19000.1 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.2 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.4 THEN 19000.300000000003 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.4 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.6 THEN 19000.5 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.6 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.8 THEN 19000.699999999997 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.8 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19001 THEN 19000.9 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19001 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.2 THEN 19000.1 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.2 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.4 THEN 19000.300000000003 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.4 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.6 THEN 19000.5 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.6 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) < 19000.8 THEN 19000.699999999997 WHEN (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) >= 19000.8 AND (EXTRACT(EPOCH FROM "__ggsql_aes_pos1__") / 86400) <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/mssql.sql b/src/golden/mssql.sql index 48dc02d67..da15cd69e 100644 --- a/src/golden/mssql.sql +++ b/src/golden/mssql.sql @@ -86,7 +86,7 @@ DROP TABLE IF EXISTS "__ggsql_global___"; -- case: binned_temporal SELECT TOP 1 * FROM (SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__") AS "__ggsql_lim__"; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case", MIN("__ggsql_lim__") AS "__ggsql_min___ggsql_lim__", MAX("__ggsql_lim__") AS "__ggsql_max___ggsql_lim__" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", "__ggsql_lim__", (CASE WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") < 19000.2 THEN 19000.1 WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000.2 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") < 19000.4 THEN 19000.300000000003 WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000.4 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") < 19000.6 THEN 19000.5 WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000.6 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") < 19000.8 THEN 19000.699999999997 WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000.8 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") < 19001 THEN 19000.9 WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19001 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", "__ggsql_lim__", (CASE WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") < 19000.2 THEN 19000.1 WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000.2 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") < 19000.4 THEN 19000.300000000003 WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000.4 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") < 19000.6 THEN 19000.5 WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000.6 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") < 19000.8 THEN 19000.699999999997 WHEN DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") >= 19000.8 AND DATEDIFF(DAY, '1970-01-01', "__ggsql_aes_pos1__") <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT TOP 1 * FROM (SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__") AS "__ggsql_lim__"; diff --git a/src/golden/mysql.sql b/src/golden/mysql.sql index 4e263132b..e85173131 100644 --- a/src/golden/mysql.sql +++ b/src/golden/mysql.sql @@ -86,7 +86,7 @@ DROP TABLE IF EXISTS `__ggsql_global___`; -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; WITH `__ggsql_source__` AS (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) SELECT MIN(`id`) AS `__ggsql_min_id`, MAX(`id`) AS `__ggsql_max_id`, MIN(`val`) AS `__ggsql_min_val`, MAX(`val`) AS `__ggsql_max_val`, MIN(`grp`) AS `__ggsql_min_grp`, MAX(`grp`) AS `__ggsql_max_grp`, MIN(`day`) AS `__ggsql_min_day`, MAX(`day`) AS `__ggsql_max_day`, MIN(`mixed Case`) AS `__ggsql_min_mixed Case`, MAX(`mixed Case`) AS `__ggsql_max_mixed Case` FROM `__ggsql_source__`; -SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) < 19000.2 THEN 19000.1 WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000.2 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) < 19000.4 THEN 19000.300000000003 WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000.4 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) < 19000.6 THEN 19000.5 WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000.6 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) < 19000.8 THEN 19000.699999999997 WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000.8 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) < 19001 THEN 19000.9 WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19001 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) <= 19001.2 THEN 19001.1 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; +SELECT `id`, `val`, `grp`, `day`, `mixed Case`, (CASE WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) < 19000.2 THEN 19000.1 WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000.2 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) < 19000.4 THEN 19000.300000000003 WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000.4 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) < 19000.6 THEN 19000.5 WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000.6 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) < 19000.8 THEN 19000.699999999997 WHEN (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) >= 19000.8 AND (TO_DAYS(`__ggsql_aes_pos1__`) - 719528) <= 19001 THEN 19000.9 ELSE NULL END) AS `__ggsql_aes_pos1__`, `__ggsql_aes_pos2__` FROM (SELECT *, `day` AS `__ggsql_aes_pos1__`, `val` AS `__ggsql_aes_pos2__` FROM (SELECT * FROM ggsql_live_test) AS `__ggsql_src__`) AS `__ggsql_pre__`; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS `__schema__` LIMIT 1; diff --git a/src/golden/oracle.sql b/src/golden/oracle.sql index 7362a07db..4101a483f 100644 --- a/src/golden/oracle.sql +++ b/src/golden/oracle.sql @@ -86,7 +86,7 @@ BEGIN EXECUTE IMMEDIATE 'DROP TABLE "__ggsql_global___"'; EXCEPTION WHE -- case: binned_temporal SELECT * FROM (SELECT * FROM (SELECT * FROM ggsql_live_test) "__schema__") "__ggsql_lim__" WHERE ROWNUM <= 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case", MIN("__ggsql_lim__") AS "__ggsql_min___ggsql_lim__", MAX("__ggsql_lim__") AS "__ggsql_max___ggsql_lim__" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", "__ggsql_lim__", (CASE WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) < 19000.2 THEN 19000.1 WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000.2 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) < 19000.4 THEN 19000.300000000003 WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000.4 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) < 19000.6 THEN 19000.5 WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000.6 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) < 19000.8 THEN 19000.699999999997 WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000.8 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) < 19001 THEN 19000.9 WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19001 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) "__ggsql_src__") "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", "__ggsql_lim__", (CASE WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) < 19000.2 THEN 19000.1 WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000.2 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) < 19000.4 THEN 19000.300000000003 WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000.4 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) < 19000.6 THEN 19000.5 WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000.6 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) < 19000.8 THEN 19000.699999999997 WHEN CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) >= 19000.8 AND CAST("__ggsql_aes_pos1__" AS BINARY_DOUBLE) <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) "__ggsql_src__") "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM (SELECT * FROM ggsql_live_test) "__schema__") "__ggsql_lim__" WHERE ROWNUM <= 1; diff --git a/src/golden/postgres.sql b/src/golden/postgres.sql index 3413ddc04..72c8f34d2 100644 --- a/src/golden/postgres.sql +++ b/src/golden/postgres.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.2 THEN 19000.1 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.2 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.4 THEN 19000.300000000003 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.4 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.6 THEN 19000.5 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.6 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.8 THEN 19000.699999999997 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.8 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19001 THEN 19000.9 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19001 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.2 THEN 19000.1 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.2 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.4 THEN 19000.300000000003 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.4 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.6 THEN 19000.5 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.6 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.8 THEN 19000.699999999997 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.8 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/redshift.sql b/src/golden/redshift.sql index aad775253..69a6ffbc1 100644 --- a/src/golden/redshift.sql +++ b/src/golden/redshift.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.2 THEN 19000.1 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.2 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.4 THEN 19000.300000000003 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.4 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.6 THEN 19000.5 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.6 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.8 THEN 19000.699999999997 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.8 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19001 THEN 19000.9 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19001 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.2 THEN 19000.1 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.2 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.4 THEN 19000.300000000003 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.4 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.6 THEN 19000.5 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.6 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') < 19000.8 THEN 19000.699999999997 WHEN ("__ggsql_aes_pos1__" - DATE '1970-01-01') >= 19000.8 AND ("__ggsql_aes_pos1__" - DATE '1970-01-01') <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/snowflake.sql b/src/golden/snowflake.sql index 66d594047..b24af01cd 100644 --- a/src/golden/snowflake.sql +++ b/src/golden/snowflake.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.2 THEN 19000.1 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.2 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.4 THEN 19000.300000000003 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.4 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.6 THEN 19000.5 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.6 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.8 THEN 19000.699999999997 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.8 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19001 THEN 19000.9 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19001 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.2 THEN 19000.1 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.2 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.4 THEN 19000.300000000003 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.4 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.6 THEN 19000.5 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.6 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) < 19000.8 THEN 19000.699999999997 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) >= 19000.8 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE) <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/sqlite.sql b/src/golden/sqlite.sql index 81c564aba..0a83c92c2 100644 --- a/src/golden/sqlite.sql +++ b/src/golden/sqlite.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) < 19000.2 THEN 19000.1 WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000.2 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) < 19000.4 THEN 19000.300000000003 WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000.4 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) < 19000.6 THEN 19000.5 WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000.6 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) < 19000.8 THEN 19000.699999999997 WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000.8 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) < 19001 THEN 19000.9 WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19001 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) < 19000.2 THEN 19000.1 WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000.2 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) < 19000.4 THEN 19000.300000000003 WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000.4 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) < 19000.6 THEN 19000.5 WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000.6 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) < 19000.8 THEN 19000.699999999997 WHEN (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) >= 19000.8 AND (JULIANDAY("__ggsql_aes_pos1__") - 2440587.5) <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/golden/trino.sql b/src/golden/trino.sql index 7e68ac294..af14fe4bf 100644 --- a/src/golden/trino.sql +++ b/src/golden/trino.sql @@ -83,7 +83,7 @@ SELECT *, "id" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELE -- case: binned_temporal SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; WITH "__ggsql_source__" AS (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") SELECT MIN("id") AS "__ggsql_min_id", MAX("id") AS "__ggsql_max_id", MIN("val") AS "__ggsql_min_val", MAX("val") AS "__ggsql_max_val", MIN("grp") AS "__ggsql_min_grp", MAX("grp") AS "__ggsql_max_grp", MIN("day") AS "__ggsql_min_day", MAX("day") AS "__ggsql_max_day", MIN("mixed Case") AS "__ggsql_min_mixed Case", MAX("mixed Case") AS "__ggsql_max_mixed Case" FROM "__ggsql_source__"; -SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.2 THEN 19000.1 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.2 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.4 THEN 19000.300000000003 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.4 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.6 THEN 19000.5 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.6 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.8 THEN 19000.699999999997 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.8 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19001 THEN 19000.9 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19001 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) <= 19001.2 THEN 19001.1 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; +SELECT "id", "val", "grp", "day", "mixed Case", (CASE WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.2 THEN 19000.1 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.2 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.4 THEN 19000.300000000003 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.4 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.6 THEN 19000.5 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.6 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) < 19000.8 THEN 19000.699999999997 WHEN TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) >= 19000.8 AND TRY_CAST("__ggsql_aes_pos1__" AS DOUBLE PRECISION) <= 19001 THEN 19000.9 ELSE NULL END) AS "__ggsql_aes_pos1__", "__ggsql_aes_pos2__" FROM (SELECT *, "day" AS "__ggsql_aes_pos1__", "val" AS "__ggsql_aes_pos2__" FROM (SELECT * FROM ggsql_live_test) AS "__ggsql_src__") AS "__ggsql_pre__"; -- case: binned_temporal_date SELECT * FROM (SELECT * FROM ggsql_live_test) AS "__schema__" LIMIT 1; diff --git a/src/plot/AGENTS.md b/src/plot/AGENTS.md index d17aa879c..45b2d9092 100644 --- a/src/plot/AGENTS.md +++ b/src/plot/AGENTS.md @@ -55,10 +55,12 @@ plot/ `Scale` in `types.rs`, the kind enum + traits in `scale_type/`, the transformation logic in `transform/`. Other top-level files in `scale/`: -- `breaks.rs` — break/tick computation for axes. -- `colour.rs` — colour aesthetic helpers, gradient interpolation. -- `linetype.rs`, `shape.rs` — categorical aesthetic mappings. -- `palettes.rs` — built-in colour palettes (large file, mostly data tables). +- `breaks.rs` — break/tick computation for axes. Every tick algorithm comes from the `zygos` crate (Wilkinson Extended, linear, log/sqrt/symlog majors and minors, calendar-aware temporal breaks and their interval picker), re-exported here and called directly from each transform's `calculate_breaks`/`calculate_minor_breaks`. ggsql keeps only what zygos lacks: `integer_breaks`, `graticule_breaks` (map projections), `minor_breaks_linear` (zygos's between-majors subdivision, continued past the outermost majors), `extent_minor_breaks` (runs zygos's extent-driven log/symlog minors and drops positions the majors hold), `filter_breaks_to_range` (typed `ArrayElement` breaks against a domain), and the temporal glue — `parse_temporal_interval` (`'2 months'` → zygos `TemporalInterval`), `interval_breaks` (dispatch to zygos's per-type calendar breaks), `auto_temporal_interval` (the interval automatic temporal majors use, so default minors in `ScaleTypeTrait::resolve` can subdivide it). +- `colour.rs` — colour aesthetic helpers; interpolation delegates to `zygos::color`. +- `linetype.rs` — stroke-dash conversion over `zygos::patterns::linetype`. +- `palettes.rs` — adapts `zygos::palettes` (the tables live there) to ggsql's string vocabulary (hex colors, hex linetype patterns), plus the `ArrayElement`-typed `lookup_palette`. Shape-name → SVG-path conversion is not in `scale/` at all: it's a Vega-Lite concern and lives in `writer/vegalite/shape.rs` over `zygos::patterns::shape`. + +OOB (out-of-bounds) policies use `zygos::OobPolicy`; the `OOB_*` string constants are the user-facing vocabulary in the SCALE clause. Scale type / transform docs: [`/doc/syntax/scale/type/`](../../doc/syntax/scale/type/) and [`/doc/syntax/scale/aesthetic/`](../../doc/syntax/scale/aesthetic/). diff --git a/src/plot/scale/breaks.rs b/src/plot/scale/breaks.rs index 0d72db090..421e7855a 100644 --- a/src/plot/scale/breaks.rs +++ b/src/plot/scale/breaks.rs @@ -1,215 +1,26 @@ -//! Break calculation algorithms for scales +//! Break calculation for scales. //! -//! Provides functions to calculate axis/legend break positions. +//! Tick selection itself lives in zygos — Wilkinson Extended +//! ([`extended_breaks`]), [`linear_breaks`], the log / sqrt / symlog +//! families and the calendar-aware temporal breaks — and transforms call it +//! directly. This module re-exports those and keeps only what zygos has no +//! counterpart for: integer-constrained breaks, graticules for map +//! projections, minor breaks that continue past the outermost majors, +//! filtering typed breaks to a domain, and parsing ggsql's interval strings +//! (`'2 months'`) into a zygos [`TemporalInterval`]. + +pub use zygos::breaks::{ + extended_breaks, linear_breaks, linear_minor_breaks_between, log_minor_breaks, + log_pretty_breaks, sqrt_breaks, symlog_breaks, symlog_minor_breaks, CalendarUnit, + TemporalInterval, DEFAULT_BREAK_COUNT, +}; +use zygos::TemporalUnit; use crate::plot::ArrayElement; -/// Default number of breaks -pub const DEFAULT_BREAK_COUNT: usize = 7; - -// ============================================================================= -// Wilkinson Extended Algorithm -// ============================================================================= - -/// "Nice" step multipliers in order of preference (most preferred first). -/// From Talbot et al. "An Extension of Wilkinson's Algorithm for Positioning Tick Labels on Axes" -const Q: &[f64] = &[1.0, 5.0, 2.0, 2.5, 4.0, 3.0]; - -/// Default scoring weights -const W_SIMPLICITY: f64 = 0.2; -const W_COVERAGE: f64 = 0.25; -const W_DENSITY: f64 = 0.5; -const W_LEGIBILITY: f64 = 0.05; - -/// Calculate breaks using Wilkinson Extended labeling algorithm. -/// -/// This algorithm searches for optimal axis labeling by scoring candidates -/// on simplicity, coverage, density, and legibility. -/// -/// Reference: Talbot, Lin, Hanrahan (2010) "An Extension of Wilkinson's Algorithm -/// for Positioning Tick Labels on Axes" -pub fn wilkinson_extended(min: f64, max: f64, target_count: usize) -> Vec { - if target_count == 0 || min >= max || !min.is_finite() || !max.is_finite() { - return vec![]; - } - - let range = max - min; - - let mut best_score = f64::NEG_INFINITY; - let mut best_breaks: Vec = vec![]; - - // Search through possible labelings - // j = skip factor (1 = every Q value, 2 = every other, etc.) - for j in 1..=target_count.max(10) { - // q_index = which Q value to use - for (q_index, &q) in Q.iter().enumerate() { - // Simplicity score for this q - let q_score = simplicity_score(q_index, Q.len(), j); - - // Early termination: if best possible score can't beat current best - if q_score + W_COVERAGE + W_DENSITY + W_LEGIBILITY < best_score { - continue; - } - - // k = actual number of ticks (varies around target) - for k in 2..=(target_count * 2).max(10) { - let density = density_score(k, target_count); - - // Early termination check - if q_score + W_COVERAGE + density + W_LEGIBILITY < best_score { - continue; - } - - // Calculate step size - let delta = (range / (k as f64 - 1.0)) * (j as f64); - let step = q * nice_step_size(delta / q); - - // Find nice min that covers data - let nice_min = (min / step).floor() * step; - let nice_max = nice_min + step * (k as f64 - 1.0); - - // Check coverage - if nice_max < max { - continue; // Doesn't cover data - } - - let coverage = coverage_score(min, max, nice_min, nice_max); - let legibility = 1.0; // Simplified: all formats equally legible - - let score = W_SIMPLICITY * q_score - + W_COVERAGE * coverage - + W_DENSITY * density - + W_LEGIBILITY * legibility; - - if score > best_score { - best_score = score; - best_breaks = generate_breaks(nice_min, step, k); - } - } - } - } - - // Fallback to simple algorithm if search failed - if best_breaks.is_empty() { - return pretty_breaks_simple(min, max, target_count); - } - - best_breaks -} - -/// Simplicity score: prefer earlier Q values and smaller skip factors -fn simplicity_score(q_index: usize, q_len: usize, j: usize) -> f64 { - 1.0 - (q_index as f64) / (q_len as f64) - (j as f64 - 1.0) / 10.0 -} - -/// Coverage score: penalize extending too far beyond data range -fn coverage_score(data_min: f64, data_max: f64, label_min: f64, label_max: f64) -> f64 { - let data_range = data_max - data_min; - let label_range = label_max - label_min; - - if label_range == 0.0 { - return 0.0; - } - - // Penalize for extending beyond data - let extension = (label_range - data_range) / data_range; - (1.0 - 0.5 * extension).max(0.0) -} - -/// Density score: prefer getting close to target count -fn density_score(actual: usize, target: usize) -> f64 { - let ratio = actual as f64 / target as f64; - // Prefer slight under-density to over-density - if ratio >= 1.0 { - 2.0 - ratio - } else { - ratio - } -} - -/// Round to nearest power of 10 -fn nice_step_size(x: f64) -> f64 { - 10f64.powf(x.log10().round()) -} - -/// Generate break positions -fn generate_breaks(start: f64, step: f64, count: usize) -> Vec { - (0..count).map(|i| start + step * i as f64).collect() -} - -// ============================================================================= -// Pretty Breaks (Public API) -// ============================================================================= - -/// Calculate pretty breaks using Wilkinson Extended labeling algorithm. -/// -/// This is the main entry point for "nice" axis break calculation. -/// Uses an optimization-based approach to find breaks that balance -/// simplicity, coverage, and density. -pub fn pretty_breaks(min: f64, max: f64, n: usize) -> Vec { - wilkinson_extended(min, max, n) -} - -/// Legacy simple "nice numbers" algorithm. -/// -/// Kept for comparison and fallback purposes. -pub fn pretty_breaks_simple(min: f64, max: f64, n: usize) -> Vec { - if n == 0 || min >= max { - return vec![]; - } - - let range = max - min; - let rough_step = range / (n as f64); - - // Find a "nice" step size (1, 2, 5, 10, 20, 25, 50, etc.) - let magnitude = 10f64.powf(rough_step.log10().floor()); - let residual = rough_step / magnitude; - - let nice_step = if residual <= 1.0 { - 1.0 * magnitude - } else if residual <= 2.0 { - 2.0 * magnitude - } else if residual <= 5.0 { - 5.0 * magnitude - } else { - 10.0 * magnitude - }; - - // Calculate nice min/max - let nice_min = (min / nice_step).floor() * nice_step; - let nice_max = (max / nice_step).ceil() * nice_step; - - // Generate breaks - let mut breaks = vec![]; - let mut value = nice_min; - while value <= nice_max + nice_step * 0.5 { - breaks.push(value); - value += nice_step; - } - breaks -} - -/// Calculate simple linear breaks (evenly spaced). -/// -/// Generates exactly n evenly-spaced breaks from min to max. -/// Use this when `pretty => false` for exact data coverage. -pub fn linear_breaks(min: f64, max: f64, n: usize) -> Vec { - if n == 0 { - return vec![]; - } - if n == 1 { - // Single break at midpoint - return vec![(min + max) / 2.0]; - } - - let step = (max - min) / (n - 1) as f64; - // Generate exactly n breaks from min to max - (0..n).map(|i| min + step * i as f64).collect() -} - /// Calculate breaks for integer scales with even spacing. /// -/// Unlike simply rounding the output of `pretty_breaks`, this function +/// Unlike simply rounding the output of [`extended_breaks`], this function /// ensures that breaks are evenly spaced integers. For small ranges where /// the natural step would be < 1, it uses step = 1 and generates consecutive /// integers. @@ -313,197 +124,6 @@ pub fn filter_breaks_to_range( .collect() } -// ============================================================================= -// Transform-Aware Break Calculations -// ============================================================================= - -/// Calculate breaks for log scales. -/// -/// For `pretty=true`: Uses 1-2-5 pattern across decades (e.g., 1, 2, 5, 10, 20, 50, 100). -/// For `pretty=false`: Returns only powers of the base (e.g., 1, 10, 100, 1000 for base 10). -/// -/// Material values are filtered out since log is undefined for them. -pub fn log_breaks(min: f64, max: f64, n: usize, base: f64, pretty: bool) -> Vec { - // Filter to positive values only - let pos_min = if min <= 0.0 { f64::MIN_POSITIVE } else { min }; - let pos_max = if max <= 0.0 { - return vec![]; - } else { - max - }; - - if pos_min >= pos_max || n == 0 { - return vec![]; - } - - let min_exp = pos_min.log(base).floor() as i32; - let max_exp = pos_max.log(base).ceil() as i32; - - if pretty { - log_breaks_extended(pos_min, pos_max, base, min_exp, max_exp, n) - } else { - // Simple: just powers of base - (min_exp..=max_exp) - .map(|e| base.powi(e)) - .filter(|&v| v >= pos_min && v <= pos_max) - .collect() - } -} - -/// Extended log breaks using 1-2-5 pattern. -/// -/// Generates breaks at each power of the base, multiplied by 1, 2, and 5, -/// then thins the result to approximately n values. -fn log_breaks_extended( - min: f64, - max: f64, - base: f64, - min_exp: i32, - max_exp: i32, - n: usize, -) -> Vec { - let multipliers = [1.0, 2.0, 5.0]; - - let mut breaks = Vec::new(); - for exp in min_exp..=max_exp { - let power = base.powi(exp); - for &mult in &multipliers { - let value = power * mult; - if value >= min && value <= max { - breaks.push(value); - } - } - } - - // Sort to ensure proper order (multipliers can cause interleaving) - breaks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - breaks.dedup_by(|a, b| (*a - *b).abs() < f64::EPSILON * a.abs().max(b.abs())); - - thin_breaks(breaks, n) -} - -/// Calculate breaks for sqrt scales. -/// -/// Calculates breaks in sqrt-transformed space, then squares them back. -/// Non-negative values only (sqrt is undefined for negative numbers). -pub fn sqrt_breaks(min: f64, max: f64, n: usize, pretty: bool) -> Vec { - let pos_min = min.max(0.0); - if pos_min >= max || n == 0 { - return vec![]; - } - - let sqrt_min = pos_min.sqrt(); - let sqrt_max = max.sqrt(); - - // Calculate breaks in sqrt space, then square - let sqrt_space_breaks = if pretty { - pretty_breaks(sqrt_min, sqrt_max, n) - } else { - linear_breaks(sqrt_min, sqrt_max, n) - }; - - sqrt_space_breaks - .into_iter() - .map(|v| v * v) - .filter(|&v| v >= pos_min && v <= max) - .collect() -} - -/// Calculate "pretty" breaks for exponential scales. -/// -/// Mirrors the log 1-2-5 pattern: for base 10, breaks at 0, log10(2), log10(5), 1, ... -/// This produces output values at 1, 2, 5, 10, 20, 50, 100... when exponentiated. -/// -/// For exponential transforms, the input space (exponents) is linear, so we want -/// breaks at values that will produce "nice" output values when exponentiated. -pub fn exp_pretty_breaks(min: f64, max: f64, n: usize, base: f64) -> Vec { - if n == 0 || min >= max { - return vec![]; - } - - // The 1-2-5 multipliers in log space - // For base 10: log10(1)=0, log10(2)≈0.301, log10(5)≈0.699 - let multipliers: [f64; 3] = [1.0, 2.0, 5.0]; - let log_mults: Vec = multipliers.iter().map(|&m| m.log(base)).collect(); - - let floor_min = min.floor(); - let ceil_max = max.ceil(); - - let mut breaks = Vec::new(); - let mut exp = floor_min; - while exp <= ceil_max { - for &log_mult in &log_mults { - let val = exp + log_mult; - if val >= min && val <= max { - breaks.push(val); - } - } - exp += 1.0; - } - - // Thin to approximately n breaks if we have too many - thin_breaks(breaks, n) -} - -/// Calculate breaks for symlog scales (handles zero and negatives). -/// -/// Symmetric log scale that can handle the full range of values including -/// zero and negative numbers. Uses log breaks for positive and negative -/// portions separately, with zero included if in range. -pub fn symlog_breaks(min: f64, max: f64, n: usize, pretty: bool) -> Vec { - if n == 0 { - return vec![]; - } - - let mut breaks = Vec::new(); - - // Handle negative portion - if min < 0.0 { - let neg_max = min.abs(); - let neg_min = if max < 0.0 { max.abs() } else { 1.0 }; - let neg_breaks = log_breaks(neg_min, neg_max, n / 2 + 1, 10.0, pretty); - breaks.extend(neg_breaks.into_iter().map(|v| -v).rev()); - } - - // Include zero if in range - if min <= 0.0 && max >= 0.0 { - breaks.push(0.0); - } - - // Handle positive portion - if max > 0.0 { - let pos_min = if min > 0.0 { min } else { 1.0 }; - breaks.extend(log_breaks(pos_min, max, n / 2 + 1, 10.0, pretty)); - } - - breaks -} - -/// Thin a break vector to approximately n values. -/// -/// Keeps the first and last values and selects evenly-spaced indices -/// from the middle to achieve the target count. -fn thin_breaks(breaks: Vec, n: usize) -> Vec { - if breaks.len() <= n || n == 0 { - return breaks; - } - - if n == 1 { - // Return middle value - return vec![breaks[breaks.len() / 2]]; - } - - // Keep first and last, thin middle - let step = (breaks.len() - 1) as f64 / (n - 1) as f64; - let mut result = Vec::with_capacity(n); - for i in 0..n { - let idx = (i as f64 * step).round() as usize; - result.push(breaks[idx.min(breaks.len() - 1)]); - } - result.dedup_by(|a, b| (*a - *b).abs() < f64::EPSILON * a.abs().max(b.abs())); - result -} - // ============================================================================= // Graticule breaks (for map projections) // ============================================================================= @@ -615,16 +235,7 @@ pub fn minor_breaks_linear(major_breaks: &[f64], n: usize, range: Option<(f64, f } // Add minor breaks between each pair of major breaks - for window in major_breaks.windows(2) { - let start = window[0]; - let end = window[1]; - let local_step = (end - start) / (n + 1) as f64; - - for i in 1..=n { - let pos = start + local_step * i as f64; - minors.push(pos); - } - } + minors.extend(linear_minor_breaks_between(major_breaks, n)); // If range extends beyond last major break, extrapolate forwards if let (true, Some((_, max))) = (evenly_spaced, range) { @@ -640,1041 +251,123 @@ pub fn minor_breaks_linear(major_breaks: &[f64], n: usize, range: Option<(f64, f minors } -/// Calculate minor breaks for log scales (equal ratios in log space) -/// -/// Transforms major breaks to log space, divides evenly, transforms back. -/// This produces minor breaks that are evenly spaced in log space (equal ratios). -/// -/// # Arguments -/// - `major_breaks`: The major break positions (must be positive and sorted) -/// - `n`: Number of minor breaks per major interval -/// - `base`: The logarithm base (e.g., 10.0, 2.0, E) -/// - `range`: Optional (min, max) scale input range to extend minor breaks beyond major breaks +/// Parse an interval string like `"month"`, `"2 months"` or `"3 days"`. /// -/// # Returns -/// Minor break positions (excluding major breaks) -pub fn minor_breaks_log( - major_breaks: &[f64], - n: usize, - base: f64, - range: Option<(f64, f64)>, -) -> Vec { - if major_breaks.len() < 2 || n == 0 { - return vec![]; - } - - // Filter to positive values only - let positive_majors: Vec = major_breaks.iter().copied().filter(|&x| x > 0.0).collect(); +/// Returns `None` for anything that isn't an optional whole-number count followed +/// by a calendar unit (`second`, `minute`, `hour`, `day`, `week`, `month`, `year`, +/// singular or plural, case-insensitive). +pub fn parse_temporal_interval(s: &str) -> Option { + let s = s.trim().to_lowercase(); + let parts: Vec<&str> = s.split_whitespace().collect(); - if positive_majors.len() < 2 { - return vec![]; + match parts.as_slice() { + // Just unit: "month", "day" + [unit] => Some(TemporalInterval::new(1, parse_calendar_unit(unit)?)), + // Count + unit: "2 months", "3 days" + [count, unit] => { + let count: u32 = count.parse().ok()?; + Some(TemporalInterval::new(count, parse_calendar_unit(unit)?)) + } + _ => None, } - - // Transform to log space - let log_majors: Vec = positive_majors.iter().map(|&x| x.log(base)).collect(); - - // Calculate minor breaks in log space - let log_range = range.map(|(min, max)| { - let log_min = if min > 0.0 { - min.log(base) - } else { - log_majors[0] - (log_majors[1] - log_majors[0]) - }; - let log_max = max.log(base); - (log_min, log_max) - }); - - let log_minors = minor_breaks_linear(&log_majors, n, log_range); - - // Transform back to data space - log_minors.into_iter().map(|x| base.powf(x)).collect() } -/// Calculate minor breaks in sqrt space -/// -/// Transforms to sqrt space, divides evenly, squares back. -/// -/// # Arguments -/// - `major_breaks`: The major break positions (must be non-negative and sorted) -/// - `n`: Number of minor breaks per major interval -/// - `range`: Optional (min, max) scale input range to extend minor breaks beyond major breaks -/// -/// # Returns -/// Minor break positions (excluding major breaks) -pub fn minor_breaks_sqrt(major_breaks: &[f64], n: usize, range: Option<(f64, f64)>) -> Vec { - if major_breaks.len() < 2 || n == 0 { - return vec![]; - } - - // Filter to non-negative values only - let nonneg_majors: Vec = major_breaks.iter().copied().filter(|&x| x >= 0.0).collect(); - - if nonneg_majors.len() < 2 { - return vec![]; +fn parse_calendar_unit(s: &str) -> Option { + match s { + "second" | "seconds" => Some(CalendarUnit::Second), + "minute" | "minutes" => Some(CalendarUnit::Minute), + "hour" | "hours" => Some(CalendarUnit::Hour), + "day" | "days" => Some(CalendarUnit::Day), + "week" | "weeks" => Some(CalendarUnit::Week), + "month" | "months" => Some(CalendarUnit::Month), + "year" | "years" => Some(CalendarUnit::Year), + _ => None, } +} - // Transform to sqrt space - let sqrt_majors: Vec = nonneg_majors.iter().map(|&x| x.sqrt()).collect(); - - // Calculate minor breaks in sqrt space - let sqrt_range = range.map(|(min, max)| (min.max(0.0).sqrt(), max.sqrt())); - - let sqrt_minors = minor_breaks_linear(&sqrt_majors, n, sqrt_range); - - // Transform back to data space (square) - sqrt_minors.into_iter().map(|x| x * x).collect() +/// Calendar breaks every `interval` from the interval's calendar grid at or +/// before `min` through `max`, plus one terminal break past `max` so the last +/// bin of a binned scale closes over the data. Positions are in `unit`'s raw +/// encoding: days for a date, microseconds for a datetime, nanoseconds since +/// midnight for a time (which stops at midnight rather than wrapping). +pub fn interval_breaks( + interval: TemporalInterval, + min: f64, + max: f64, + unit: TemporalUnit, +) -> Vec { + use zygos::breaks::{temporal_breaks_date, temporal_breaks_datetime, temporal_breaks_time}; + match unit { + TemporalUnit::Date => temporal_breaks_date(min as i32, max as i32, interval) + .into_iter() + .map(f64::from) + .collect(), + TemporalUnit::DateTime | TemporalUnit::Duration => { + temporal_breaks_datetime(min as i64, max as i64, interval) + .into_iter() + .map(|v| v as f64) + .collect() + } + TemporalUnit::Time => temporal_breaks_time(min as i64, max as i64, interval) + .into_iter() + .map(|v| v as f64) + .collect(), + } +} + +/// The calendar interval automatic temporal breaks step by over `[min, max]` +/// when about `n` breaks are asked for — the interval +/// [`zygos::breaks::temporal_breaks_from_f64`] picks, exposed so the minor +/// breaks can subdivide the same interval the majors use. +pub fn auto_temporal_interval( + min: f64, + max: f64, + n: usize, + unit: TemporalUnit, +) -> TemporalInterval { + zygos::breaks::pick_temporal_interval(max - min, n.max(2) as f64, unit) } -/// Calculate minor breaks for symlog scales -/// -/// Uses asinh transform space for even division. Handles negative values. -/// -/// # Arguments -/// - `major_breaks`: The major break positions (sorted) -/// - `n`: Number of minor breaks per major interval -/// - `range`: Optional (min, max) scale input range to extend minor breaks beyond major breaks +/// Minor breaks for a transform whose zygos minor algorithm works from the +/// scale's extent rather than by subdividing between majors — the log +/// family's whole-number multiples and the symlog branches built on them. /// -/// # Returns -/// Minor break positions (excluding major breaks) -pub fn minor_breaks_symlog(major_breaks: &[f64], n: usize, range: Option<(f64, f64)>) -> Vec { - if major_breaks.len() < 2 || n == 0 { +/// `minors_for` receives the extent: `range` when given, otherwise the span of +/// the majors. Those algorithms fix their own density, so `n` only decides +/// whether there are minors at all (`0` asks for none). Positions a major +/// already occupies are dropped. +pub fn extent_minor_breaks( + major_breaks: &[f64], + n: usize, + range: Option<(f64, f64)>, + minors_for: impl FnOnce(f64, f64) -> Vec, +) -> Vec { + if n == 0 { return vec![]; } - - // Transform to asinh space - let asinh_majors: Vec = major_breaks.iter().map(|&x| x.asinh()).collect(); - - // Calculate minor breaks in asinh space - let asinh_range = range.map(|(min, max)| (min.asinh(), max.asinh())); - - let asinh_minors = minor_breaks_linear(&asinh_majors, n, asinh_range); - - // Transform back to data space - asinh_minors.into_iter().map(|x| x.sinh()).collect() -} - -/// Trim breaks to be within the specified range (inclusive) -/// -/// # Arguments -/// - `breaks`: The break positions to filter -/// - `range`: The (min, max) range to keep -/// -/// # Returns -/// Break positions that fall within [min, max] -pub fn trim_breaks(breaks: &[f64], range: (f64, f64)) -> Vec { - breaks - .iter() - .copied() - .filter(|&b| b >= range.0 && b <= range.1) - .collect() + let (min, max) = match range { + Some(extent) => extent, + None => match (major_breaks.first(), major_breaks.last()) { + (Some(&first), Some(&last)) => (first, last), + _ => return vec![], + }, + }; + let mut minors = minors_for(min, max); + minors.retain(|minor| { + !major_breaks + .iter() + .any(|major| (minor - major).abs() <= major.abs().max(minor.abs()) * 1e-9) + }); + minors } -/// Trim temporal breaks to be within the specified range (inclusive) -/// -/// Uses string comparison for ISO-format dates (works for Date, DateTime, Time). -/// -/// # Arguments -/// - `breaks`: The break positions as ISO strings -/// - `range`: The (min, max) range as ISO strings -/// -/// # Returns -/// Break positions that fall within the range -pub fn trim_temporal_breaks(breaks: &[String], range: (&str, &str)) -> Vec { - breaks - .iter() - .filter(|b| b.as_str() >= range.0 && b.as_str() <= range.1) - .cloned() - .collect() -} +#[cfg(test)] +mod tests { + use super::*; -/// Temporal minor break specification -#[derive(Debug, Clone, PartialEq, Default)] -pub enum MinorBreakSpec { - /// Derive minor interval from major interval (default) - #[default] - Auto, - /// Explicit count per major interval - Count(usize), - /// Explicit interval string - Interval(String), -} - -/// Derive minor interval from major interval (keeps count below 10) -/// -/// Returns the recommended minor interval string for a given major interval. -/// -/// | Major Epoch | Minor Epoch | Approx Count | -/// |-------------|--------------|--------------| -/// | year | 3 months | 4 | -/// | quarter | month | 3 | -/// | month | week | ~4 | -/// | week | day | 7 | -/// | day | 6 hours | 4 | -/// | hour | 15 minutes | 4 | -/// | minute | 15 seconds | 4 | -/// | second | 100 ms | 10 | -pub fn derive_minor_interval(major_interval: &str) -> &'static str { - let interval = TemporalInterval::create_from_str(major_interval); - match interval { - Some(TemporalInterval { - unit: TemporalUnit::Year, - .. - }) => "3 months", - Some(TemporalInterval { - unit: TemporalUnit::Month, - count, - }) if count >= 3 => "month", // quarter -> month - Some(TemporalInterval { - unit: TemporalUnit::Month, - .. - }) => "week", - Some(TemporalInterval { - unit: TemporalUnit::Week, - .. - }) => "day", - Some(TemporalInterval { - unit: TemporalUnit::Day, - .. - }) => "6 hours", - Some(TemporalInterval { - unit: TemporalUnit::Hour, - .. - }) => "15 minutes", - Some(TemporalInterval { - unit: TemporalUnit::Minute, - .. - }) => "15 seconds", - Some(TemporalInterval { - unit: TemporalUnit::Second, - .. - }) => "100 ms", - None => "day", // fallback - } -} - -/// Calculate temporal minor breaks for Date scale -/// -/// # Arguments -/// - `major_breaks`: Major break positions as ISO date strings ("YYYY-MM-DD") -/// - `major_interval`: The major interval string (e.g., "month", "year") -/// - `spec`: Minor break specification (Auto, Count, or Interval) -/// - `range`: Optional (min, max) as ISO date strings to extend minor breaks -/// -/// # Returns -/// Minor break positions as ISO date strings -pub fn temporal_minor_breaks_date( - major_breaks: &[String], - major_interval: &str, - spec: MinorBreakSpec, - range: Option<(&str, &str)>, -) -> Vec { - use chrono::NaiveDate; - - if major_breaks.len() < 2 { - return vec![]; - } - - // Parse major breaks to dates - let major_dates: Vec = major_breaks - .iter() - .filter_map(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) - .collect(); - - if major_dates.len() < 2 { - return vec![]; - } - - let minor_interval = match spec { - MinorBreakSpec::Auto => derive_minor_interval(major_interval).to_string(), - MinorBreakSpec::Count(n) => { - // Calculate interval between first two majors and divide by n+1 - let days = (major_dates[1] - major_dates[0]).num_days(); - let minor_days = days / (n + 1) as i64; - format!("{} days", minor_days.max(1)) - } - MinorBreakSpec::Interval(s) => s, - }; - - let interval = match TemporalInterval::create_from_str(&minor_interval) { - Some(i) => i, - None => return vec![], - }; - - let mut minors = Vec::new(); - - // Parse range bounds - let range_dates = range.and_then(|(min, max)| { - let min_date = NaiveDate::parse_from_str(min, "%Y-%m-%d").ok()?; - let max_date = NaiveDate::parse_from_str(max, "%Y-%m-%d").ok()?; - Some((min_date, max_date)) - }); - - // If range extends before first major, extrapolate backwards - if let Some((min_date, _)) = range_dates { - let first_major = major_dates[0]; - let mut current = retreat_date_by_interval(first_major, &interval); - while current >= min_date { - minors.push(current.format("%Y-%m-%d").to_string()); - current = retreat_date_by_interval(current, &interval); - } - } - - // Add minors between each pair of major breaks - for window in major_dates.windows(2) { - let start = window[0]; - let end = window[1]; - let mut current = advance_date_by_interval(start, &interval); - while current < end { - minors.push(current.format("%Y-%m-%d").to_string()); - current = advance_date_by_interval(current, &interval); - } - } - - // If range extends beyond last major, extrapolate forwards - if let Some((_, max_date)) = range_dates { - let last_major = *major_dates.last().unwrap(); - let mut current = advance_date_by_interval(last_major, &interval); - while current <= max_date { - minors.push(current.format("%Y-%m-%d").to_string()); - current = advance_date_by_interval(current, &interval); - } - } - - minors.sort(); - minors -} - -/// Retreat a date by the given interval (go backwards) -fn retreat_date_by_interval( - date: chrono::NaiveDate, - interval: &TemporalInterval, -) -> chrono::NaiveDate { - use chrono::{Datelike, NaiveDate}; - - let count = interval.count as i64; - match interval.unit { - TemporalUnit::Day => date - chrono::Duration::days(count), - TemporalUnit::Week => date - chrono::Duration::weeks(count), - TemporalUnit::Month => { - let total_months = date.year() * 12 + date.month() as i32 - 1 - count as i32; - let year = total_months.div_euclid(12); - let month = (total_months.rem_euclid(12)) as u32 + 1; - NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date) - } - TemporalUnit::Year => { - NaiveDate::from_ymd_opt(date.year() - count as i32, 1, 1).unwrap_or(date) - } - _ => date - chrono::Duration::days(count), - } -} - -/// Calculate temporal minor breaks for DateTime scale -/// -/// # Arguments -/// - `major_breaks`: Major break positions as ISO datetime strings -/// - `major_interval`: The major interval string -/// - `spec`: Minor break specification -/// - `range`: Optional (min, max) as ISO datetime strings -/// -/// # Returns -/// Minor break positions as ISO datetime strings -pub fn temporal_minor_breaks_datetime( - major_breaks: &[String], - major_interval: &str, - spec: MinorBreakSpec, - range: Option<(&str, &str)>, -) -> Vec { - use chrono::{DateTime, Utc}; - - if major_breaks.len() < 2 { - return vec![]; - } - - // Parse major breaks to datetimes - let major_dts: Vec> = major_breaks - .iter() - .filter_map(|s| { - DateTime::parse_from_rfc3339(s) - .ok() - .map(|dt| dt.with_timezone(&Utc)) - }) - .collect(); - - if major_dts.len() < 2 { - return vec![]; - } - - let minor_interval = match spec { - MinorBreakSpec::Auto => derive_minor_interval(major_interval).to_string(), - MinorBreakSpec::Count(n) => { - let duration = major_dts[1] - major_dts[0]; - let minor_secs = duration.num_seconds() / (n + 1) as i64; - if minor_secs >= 3600 { - format!("{} hours", minor_secs / 3600) - } else if minor_secs >= 60 { - format!("{} minutes", minor_secs / 60) - } else { - format!("{} seconds", minor_secs.max(1)) - } - } - MinorBreakSpec::Interval(s) => s, - }; - - let interval = match TemporalInterval::create_from_str(&minor_interval) { - Some(i) => i, - None => return vec![], - }; - - let mut minors = Vec::new(); - - // Parse range bounds - let range_dts = range.and_then(|(min, max)| { - let min_dt = DateTime::parse_from_rfc3339(min) - .ok() - .map(|dt| dt.with_timezone(&Utc))?; - let max_dt = DateTime::parse_from_rfc3339(max) - .ok() - .map(|dt| dt.with_timezone(&Utc))?; - Some((min_dt, max_dt)) - }); - - // If range extends before first major, extrapolate backwards - if let Some((min_dt, _)) = range_dts { - let first_major = major_dts[0]; - let mut current = retreat_datetime_by_interval(first_major, &interval); - while current >= min_dt { - minors.push(current.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()); - current = retreat_datetime_by_interval(current, &interval); - } - } - - // Add minors between each pair of major breaks - for window in major_dts.windows(2) { - let start = window[0]; - let end = window[1]; - let mut current = advance_datetime_by_interval(start, &interval); - while current < end { - minors.push(current.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()); - current = advance_datetime_by_interval(current, &interval); - } - } - - // If range extends beyond last major, extrapolate forwards - if let Some((_, max_dt)) = range_dts { - let last_major = *major_dts.last().unwrap(); - let mut current = advance_datetime_by_interval(last_major, &interval); - while current <= max_dt { - minors.push(current.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()); - current = advance_datetime_by_interval(current, &interval); - } - } - - minors.sort(); - minors -} - -/// Retreat a datetime by the given interval (go backwards) -fn retreat_datetime_by_interval( - dt: chrono::DateTime, - interval: &TemporalInterval, -) -> chrono::DateTime { - use chrono::{Datelike, TimeZone, Timelike, Utc}; - - let count = interval.count as i64; - match interval.unit { - TemporalUnit::Second => dt - chrono::Duration::seconds(count), - TemporalUnit::Minute => dt - chrono::Duration::minutes(count), - TemporalUnit::Hour => dt - chrono::Duration::hours(count), - TemporalUnit::Day => dt - chrono::Duration::days(count), - TemporalUnit::Week => dt - chrono::Duration::weeks(count), - TemporalUnit::Month => { - let total_months = dt.year() * 12 + dt.month() as i32 - 1 - count as i32; - let year = total_months.div_euclid(12); - let month = (total_months.rem_euclid(12)) as u32 + 1; - Utc.with_ymd_and_hms( - year, - month, - dt.day().min(28), - dt.hour(), - dt.minute(), - dt.second(), - ) - .single() - .unwrap_or(dt) - } - TemporalUnit::Year => Utc - .with_ymd_and_hms( - dt.year() - count as i32, - dt.month(), - dt.day().min(28), - dt.hour(), - dt.minute(), - dt.second(), - ) - .single() - .unwrap_or(dt), - } -} - -/// Calculate temporal minor breaks for Time scale -/// -/// # Arguments -/// - `major_breaks`: Major break positions as time strings ("HH:MM:SS.mmm") -/// - `major_interval`: The major interval string -/// - `spec`: Minor break specification -/// - `range`: Optional (min, max) as time strings -/// -/// # Returns -/// Minor break positions as time strings -pub fn temporal_minor_breaks_time( - major_breaks: &[String], - major_interval: &str, - spec: MinorBreakSpec, - range: Option<(&str, &str)>, -) -> Vec { - use chrono::NaiveTime; - - if major_breaks.len() < 2 { - return vec![]; - } - - // Parse major breaks to times - let major_times: Vec = major_breaks - .iter() - .filter_map(|s| NaiveTime::parse_from_str(s, "%H:%M:%S%.3f").ok()) - .collect(); - - if major_times.len() < 2 { - return vec![]; - } - - let minor_interval = match spec { - MinorBreakSpec::Auto => derive_minor_interval(major_interval).to_string(), - MinorBreakSpec::Count(n) => { - let duration = major_times[1] - major_times[0]; - let minor_secs = duration.num_seconds() / (n + 1) as i64; - if minor_secs >= 60 { - format!("{} minutes", minor_secs / 60) - } else { - format!("{} seconds", minor_secs.max(1)) - } - } - MinorBreakSpec::Interval(s) => s, - }; - - let interval = match TemporalInterval::create_from_str(&minor_interval) { - Some(i) => i, - None => return vec![], - }; - - let mut minors = Vec::new(); - - // Parse range bounds - let range_times = range.and_then(|(min, max)| { - let min_time = NaiveTime::parse_from_str(min, "%H:%M:%S%.3f").ok()?; - let max_time = NaiveTime::parse_from_str(max, "%H:%M:%S%.3f").ok()?; - Some((min_time, max_time)) - }); - - // If range extends before first major, extrapolate backwards - if let Some((min_time, _)) = range_times { - let first_major = major_times[0]; - if let Some(mut current) = retreat_time_by_interval(first_major, &interval) { - while current >= min_time && current < first_major { - minors.push(current.format("%H:%M:%S%.3f").to_string()); - match retreat_time_by_interval(current, &interval) { - Some(prev) if prev < current => current = prev, - _ => break, - } - } - } - } - - // Add minors between each pair of major breaks - for window in major_times.windows(2) { - let start = window[0]; - let end = window[1]; - if let Some(mut current) = advance_time_by_interval(start, &interval) { - while current < end { - minors.push(current.format("%H:%M:%S%.3f").to_string()); - match advance_time_by_interval(current, &interval) { - Some(next) if next > current => current = next, - _ => break, - } - } - } - } - - // If range extends beyond last major, extrapolate forwards - if let Some((_, max_time)) = range_times { - let last_major = *major_times.last().unwrap(); - if let Some(mut current) = advance_time_by_interval(last_major, &interval) { - while current <= max_time && current > last_major { - minors.push(current.format("%H:%M:%S%.3f").to_string()); - match advance_time_by_interval(current, &interval) { - Some(next) if next > current => current = next, - _ => break, - } - } - } - } - - minors.sort(); - minors -} - -/// Retreat a time by the given interval (go backwards) -fn retreat_time_by_interval( - time: chrono::NaiveTime, - interval: &TemporalInterval, -) -> Option { - let count = interval.count as i64; - let duration = match interval.unit { - TemporalUnit::Second => chrono::Duration::seconds(count), - TemporalUnit::Minute => chrono::Duration::minutes(count), - TemporalUnit::Hour => chrono::Duration::hours(count), - _ => return Some(time), // Day/week/month/year not applicable - }; - time.overflowing_sub_signed(duration).0.into() -} - -/// Temporal interval unit -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TemporalUnit { - Second, - Minute, - Hour, - Day, - Week, - Month, - Year, -} - -/// Temporal interval with optional count (e.g., "2 months", "3 days") -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TemporalInterval { - pub count: u32, - pub unit: TemporalUnit, -} - -impl TemporalInterval { - /// Parse interval string like "month", "2 months", "3 days" - pub fn create_from_str(s: &str) -> Option { - let s = s.trim().to_lowercase(); - let parts: Vec<&str> = s.split_whitespace().collect(); - - match parts.as_slice() { - // Just unit: "month", "day" - [unit] => { - let unit = Self::parse_unit(unit)?; - Some(Self { count: 1, unit }) - } - // Count + unit: "2 months", "3 days" - [count, unit] => { - let count: u32 = count.parse().ok()?; - let unit = Self::parse_unit(unit)?; - Some(Self { count, unit }) - } - _ => None, - } - } - - fn parse_unit(s: &str) -> Option { - match s { - "second" | "seconds" => Some(TemporalUnit::Second), - "minute" | "minutes" => Some(TemporalUnit::Minute), - "hour" | "hours" => Some(TemporalUnit::Hour), - "day" | "days" => Some(TemporalUnit::Day), - "week" | "weeks" => Some(TemporalUnit::Week), - "month" | "months" => Some(TemporalUnit::Month), - "year" | "years" => Some(TemporalUnit::Year), - _ => None, - } - } -} - -/// Calculate temporal breaks at interval boundaries for Date scale. -/// min/max are days since epoch for Date. -/// -/// For binning, breaks must SPAN the data range. This means we need a terminal -/// break AFTER max_date to close the last bin. For example, data from Jan 15 to -/// Mar 15 with monthly breaks needs: [Jan-01, Feb-01, Mar-01, Apr-01] to create -/// bins that cover all data. -pub fn temporal_breaks_date( - min_days: i32, - max_days: i32, - interval: TemporalInterval, -) -> Vec { - use chrono::NaiveDate; - - let epoch = match NaiveDate::from_ymd_opt(1970, 1, 1) { - Some(d) => d, - None => return vec![], - }; - let min_date = epoch + chrono::Duration::days(min_days as i64); - let max_date = epoch + chrono::Duration::days(max_days as i64); - - let mut breaks = vec![]; - let mut current = align_date_to_interval(min_date, &interval); - - // Generate breaks up to and including max_date - while current <= max_date { - breaks.push(current.format("%Y-%m-%d").to_string()); - current = advance_date_by_interval(current, &interval); - } - - // Add terminal break after max_date to close the last bin - // This ensures data at max_date falls within a bin, not outside - if !breaks.is_empty() { - breaks.push(current.format("%Y-%m-%d").to_string()); - } - - breaks -} - -fn align_date_to_interval( - date: chrono::NaiveDate, - interval: &TemporalInterval, -) -> chrono::NaiveDate { - use chrono::{Datelike, NaiveDate}; - - match interval.unit { - TemporalUnit::Day => date, - TemporalUnit::Week => { - // Align to Monday - let days_from_monday = date.weekday().num_days_from_monday(); - date - chrono::Duration::days(days_from_monday as i64) - } - TemporalUnit::Month => { - NaiveDate::from_ymd_opt(date.year(), date.month(), 1).unwrap_or(date) - } - TemporalUnit::Year => NaiveDate::from_ymd_opt(date.year(), 1, 1).unwrap_or(date), - _ => date, // Second/minute/hour not applicable to Date - } -} - -fn advance_date_by_interval( - date: chrono::NaiveDate, - interval: &TemporalInterval, -) -> chrono::NaiveDate { - use chrono::{Datelike, NaiveDate}; - - let count = interval.count as i64; - match interval.unit { - TemporalUnit::Day => date + chrono::Duration::days(count), - TemporalUnit::Week => date + chrono::Duration::weeks(count), - TemporalUnit::Month => { - // Add N months - let total_months = date.year() * 12 + date.month() as i32 - 1 + count as i32; - let year = total_months / 12; - let month = (total_months % 12) as u32 + 1; - NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date) - } - TemporalUnit::Year => { - NaiveDate::from_ymd_opt(date.year() + count as i32, 1, 1).unwrap_or(date) - } - _ => date + chrono::Duration::days(count), - } -} - -/// Calculate temporal breaks at interval boundaries for DateTime scale. -/// min/max are microseconds since epoch. -/// -/// For binning, breaks must SPAN the data range. This means we need a terminal -/// break AFTER max_dt to close the last bin. -pub fn temporal_breaks_datetime( - min_us: i64, - max_us: i64, - interval: TemporalInterval, -) -> Vec { - use chrono::{DateTime, Utc}; - - let to_datetime = |us: i64| -> Option> { - let secs = us / 1_000_000; - let nsecs = ((us % 1_000_000).abs() * 1000) as u32; - DateTime::::from_timestamp(secs, nsecs) - }; - - let min_dt = match to_datetime(min_us) { - Some(dt) => dt, - None => return vec![], - }; - let max_dt = match to_datetime(max_us) { - Some(dt) => dt, - None => return vec![], - }; - - let mut breaks = vec![]; - let mut current = align_datetime_to_interval(min_dt, &interval); - - // Generate breaks up to and including max_dt - while current <= max_dt { - breaks.push(current.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()); - current = advance_datetime_by_interval(current, &interval); - } - - // Add terminal break after max_dt to close the last bin - if !breaks.is_empty() { - breaks.push(current.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()); - } - - breaks -} - -fn align_datetime_to_interval( - dt: chrono::DateTime, - interval: &TemporalInterval, -) -> chrono::DateTime { - use chrono::{Datelike, TimeZone, Timelike, Utc}; - - match interval.unit { - TemporalUnit::Second => Utc - .with_ymd_and_hms( - dt.year(), - dt.month(), - dt.day(), - dt.hour(), - dt.minute(), - dt.second(), - ) - .single() - .unwrap_or(dt), - TemporalUnit::Minute => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), dt.minute(), 0) - .single() - .unwrap_or(dt), - TemporalUnit::Hour => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), 0, 0) - .single() - .unwrap_or(dt), - TemporalUnit::Day => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 0, 0, 0) - .single() - .unwrap_or(dt), - TemporalUnit::Week => { - let days_from_monday = dt.weekday().num_days_from_monday(); - let aligned = dt - chrono::Duration::days(days_from_monday as i64); - Utc.with_ymd_and_hms(aligned.year(), aligned.month(), aligned.day(), 0, 0, 0) - .single() - .unwrap_or(dt) - } - TemporalUnit::Month => Utc - .with_ymd_and_hms(dt.year(), dt.month(), 1, 0, 0, 0) - .single() - .unwrap_or(dt), - TemporalUnit::Year => Utc - .with_ymd_and_hms(dt.year(), 1, 1, 0, 0, 0) - .single() - .unwrap_or(dt), - } -} - -fn advance_datetime_by_interval( - dt: chrono::DateTime, - interval: &TemporalInterval, -) -> chrono::DateTime { - use chrono::{Datelike, TimeZone, Timelike, Utc}; - - let count = interval.count as i64; - match interval.unit { - TemporalUnit::Second => dt + chrono::Duration::seconds(count), - TemporalUnit::Minute => dt + chrono::Duration::minutes(count), - TemporalUnit::Hour => dt + chrono::Duration::hours(count), - TemporalUnit::Day => dt + chrono::Duration::days(count), - TemporalUnit::Week => dt + chrono::Duration::weeks(count), - TemporalUnit::Month => { - let total_months = dt.year() * 12 + dt.month() as i32 - 1 + count as i32; - let year = total_months / 12; - let month = (total_months % 12) as u32 + 1; - Utc.with_ymd_and_hms( - year, - month, - dt.day().min(28), - dt.hour(), - dt.minute(), - dt.second(), - ) - .single() - .unwrap_or(dt) - } - TemporalUnit::Year => Utc - .with_ymd_and_hms( - dt.year() + count as i32, - dt.month(), - dt.day().min(28), - dt.hour(), - dt.minute(), - dt.second(), - ) - .single() - .unwrap_or(dt), - } -} - -/// Calculate temporal breaks at interval boundaries for Time scale. -/// min/max are nanoseconds since midnight. -/// -/// For binning, breaks must SPAN the data range. This means we need a terminal -/// break AFTER max_time to close the last bin (unless it would overflow past 24 hours). -pub fn temporal_breaks_time(min_ns: i64, max_ns: i64, interval: TemporalInterval) -> Vec { - use chrono::NaiveTime; - - let to_time = |ns: i64| -> Option { - let total_secs = ns / 1_000_000_000; - let nanos = (ns % 1_000_000_000).unsigned_abs() as u32; - let hours = (total_secs / 3600) as u32; - let mins = ((total_secs % 3600) / 60) as u32; - let secs = (total_secs % 60) as u32; - NaiveTime::from_hms_nano_opt(hours.min(23), mins, secs, nanos) - }; - - let min_time = match to_time(min_ns) { - Some(t) => t, - None => return vec![], - }; - let max_time = match to_time(max_ns) { - Some(t) => t, - None => return vec![], - }; - - let mut breaks = vec![]; - let mut current = align_time_to_interval(min_time, &interval); - - // Generate breaks up to and including max_time - while current <= max_time { - breaks.push(current.format("%H:%M:%S%.3f").to_string()); - current = match advance_time_by_interval(current, &interval) { - Some(t) if t > current => t, - _ => break, // Overflow past 24 hours - }; - } - - // Add terminal break after max_time to close the last bin (if no overflow) - if !breaks.is_empty() && current > max_time { - breaks.push(current.format("%H:%M:%S%.3f").to_string()); - } - - breaks -} - -fn align_time_to_interval( - time: chrono::NaiveTime, - interval: &TemporalInterval, -) -> chrono::NaiveTime { - use chrono::{NaiveTime, Timelike}; - - match interval.unit { - TemporalUnit::Second => { - NaiveTime::from_hms_opt(time.hour(), time.minute(), time.second()).unwrap_or(time) - } - TemporalUnit::Minute => { - NaiveTime::from_hms_opt(time.hour(), time.minute(), 0).unwrap_or(time) - } - TemporalUnit::Hour => NaiveTime::from_hms_opt(time.hour(), 0, 0).unwrap_or(time), - _ => time, // Day/week/month/year not applicable to Time - } -} - -fn advance_time_by_interval( - time: chrono::NaiveTime, - interval: &TemporalInterval, -) -> Option { - use chrono::Timelike; - - let count = interval.count; - match interval.unit { - TemporalUnit::Second => time.with_second((time.second() + count) % 60), - TemporalUnit::Minute => time.with_minute((time.minute() + count) % 60), - TemporalUnit::Hour => time.with_hour((time.hour() + count) % 24), - _ => Some(time), // Day/week/month/year not applicable - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ========================================================================= - // Pretty Breaks Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_pretty_breaks_variations() { - // Test various range sizes - let test_cases: Vec<(f64, f64, usize)> = vec![ - (0.0, 100.0, 5), // Basic range - (0.1, 0.9, 5), // Small range - (0.0, 10000.0, 5), // Large range - ]; - for (min, max, n) in test_cases { - let breaks = pretty_breaks(min, max, n); - assert!( - !breaks.is_empty(), - "pretty_breaks({}, {}, {}) should not be empty", - min, - max, - n - ); - assert!( - breaks[0] <= min, - "pretty_breaks({}, {}, {}): first should be <= min", - min, - max, - n - ); - assert!( - *breaks.last().unwrap() >= max, - "pretty_breaks({}, {}, {}): last should be >= max", - min, - max, - n - ); - } - } - - #[test] - fn test_pretty_breaks_edge_cases() { - assert!( - pretty_breaks(0.0, 100.0, 0).is_empty(), - "zero count should return empty" - ); - assert!( - pretty_breaks(50.0, 50.0, 5).is_empty(), - "equal min/max should return empty" - ); - } - - // ========================================================================= - // Linear Breaks Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_linear_breaks_variations() { - // Test various counts - // Format: (min, max, n, expected_result) - let test_cases: Vec<(f64, f64, usize, Vec)> = vec![ - (0.0, 100.0, 5, vec![0.0, 25.0, 50.0, 75.0, 100.0]), - (0.0, 100.0, 1, vec![50.0]), // Single break at midpoint - (0.0, 100.0, 2, vec![0.0, 100.0]), // Two breaks at endpoints - (10.0, 90.0, 5, vec![10.0, 30.0, 50.0, 70.0, 90.0]), // Non-zero start - ]; - for (min, max, n, expected) in test_cases { - let breaks = linear_breaks(min, max, n); - assert_eq!(breaks, expected, "linear_breaks({}, {}, {})", min, max, n); - } - } - - #[test] - fn test_linear_breaks_edge_cases() { - assert!( - linear_breaks(0.0, 100.0, 0).is_empty(), - "zero count should return empty" - ); - } - - // ========================================================================= - // Integer Breaks Tests (Consolidated) - // ========================================================================= + // ========================================================================= + // Integer Breaks Tests (Consolidated) + // ========================================================================= #[test] fn test_integer_breaks_variations() { @@ -1790,199 +483,34 @@ mod tests { } // ========================================================================= - // Log Break Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_log_breaks_powers() { - // pretty=false should give powers of base - let test_cases = vec![ - // (min, max, base, expected) - (1.0, 10000.0, 10.0, vec![1.0, 10.0, 100.0, 1000.0, 10000.0]), - (1.0, 16.0, 2.0, vec![1.0, 2.0, 4.0, 8.0, 16.0]), - (0.01, 100.0, 10.0, vec![0.01, 0.1, 1.0, 10.0, 100.0]), - ]; - for (min, max, base, expected) in test_cases { - let breaks = log_breaks(min, max, 10, base, false); - assert_eq!( - breaks, expected, - "log_breaks({}, {}, base={})", - min, max, base - ); - } - } - - #[test] - fn test_log_breaks_pretty_1_2_5_pattern() { - // pretty=true should give 1-2-5 pattern - let breaks = log_breaks(1.0, 100.0, 10, 10.0, true); - for &v in &[1.0, 2.0, 5.0, 10.0, 100.0] { - assert!( - breaks.contains(&v), - "log_breaks pretty should contain {}", - v - ); - } - } - - #[test] - fn test_log_breaks_filters_negative() { - // Range includes negative - should only return positive breaks - let breaks = log_breaks(-10.0, 1000.0, 10, 10.0, false); - assert!(breaks.iter().all(|&v| v > 0.0)); - for &v in &[1.0, 10.0, 100.0, 1000.0] { - assert!( - breaks.contains(&v), - "log_breaks should contain {} after filtering negative", - v - ); - } - } - - #[test] - fn test_log_breaks_edge_cases() { - assert!( - log_breaks(-100.0, -1.0, 5, 10.0, true).is_empty(), - "all negative should return empty" - ); - assert!( - log_breaks(1.0, 100.0, 0, 10.0, true).is_empty(), - "zero count should return empty" - ); - } - - // ========================================================================= - // Sqrt Break Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_sqrt_breaks_variations() { - // Basic case - let breaks = sqrt_breaks(0.0, 100.0, 5, false); - assert!(breaks.len() >= 5, "Should have at least 5 breaks"); - assert!( - breaks.first().unwrap() >= &0.0, - "First break should be >= 0" - ); - assert!( - breaks.last().unwrap() >= &100.0, - "Last break should be >= 100" - ); - - // With negative input (should filter) - let breaks_neg = sqrt_breaks(-10.0, 100.0, 5, true); - assert!( - breaks_neg.iter().all(|&v| v >= 0.0), - "Should filter negative values" - ); - - // Pretty mode - let breaks_pretty = sqrt_breaks(0.0, 100.0, 5, true); - assert!(!breaks_pretty.is_empty()); - } - - #[test] - fn test_sqrt_breaks_edge_cases() { - assert!( - sqrt_breaks(0.0, 100.0, 0, true).is_empty(), - "zero count should return empty" - ); - } - - // ========================================================================= - // Symlog Break Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_symlog_breaks_variations() { - // Symmetric range - should have negatives, zero, positives - let breaks_sym = symlog_breaks(-1000.0, 1000.0, 10, false); - assert!( - breaks_sym.contains(&0.0), - "Symmetric range should contain 0" - ); - assert!( - breaks_sym.iter().any(|&v| v < 0.0), - "Should have negative values" - ); - assert!( - breaks_sym.iter().any(|&v| v > 0.0), - "Should have positive values" - ); - - // Positive only - let breaks_pos = symlog_breaks(1.0, 1000.0, 5, false); - assert!( - breaks_pos.iter().all(|&v| v > 0.0), - "Positive-only should have only positive" - ); - - // Negative only - let breaks_neg = symlog_breaks(-1000.0, -1.0, 5, false); - assert!( - breaks_neg.iter().all(|&v| v < 0.0), - "Negative-only should have only negative" - ); - - // Crossing zero should include zero - let breaks_cross = symlog_breaks(-100.0, 100.0, 7, false); - assert!( - breaks_cross.contains(&0.0), - "Crossing zero should include 0" - ); - } - - #[test] - fn test_symlog_breaks_edge_cases() { - assert!( - symlog_breaks(-100.0, 100.0, 0, true).is_empty(), - "zero count should return empty" - ); - } - - // ========================================================================= - // Thin Breaks Tests (Consolidated) + // Temporal Interval Tests (Consolidated) // ========================================================================= - #[test] - fn test_thin_breaks_variations() { - // No thinning needed - let result_none = thin_breaks(vec![1.0, 2.0, 3.0, 4.0, 5.0], 10); - assert_eq!(result_none, vec![1.0, 2.0, 3.0, 4.0, 5.0]); - - // Thin to smaller - let result_thin = thin_breaks(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], 5); - assert_eq!(result_thin.len(), 5); - assert_eq!(result_thin[0], 1.0); - assert_eq!(result_thin[4], 10.0); - - // Thin to single - should be middle - let result_single = thin_breaks(vec![1.0, 2.0, 3.0, 4.0, 5.0], 1); - assert_eq!(result_single, vec![3.0]); + /// Days since the Unix epoch for an ISO date. + fn days(iso: &str) -> i32 { + let date = chrono::NaiveDate::parse_from_str(iso, "%Y-%m-%d").unwrap(); + let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(); + (date - epoch).num_days() as i32 } - // ========================================================================= - // Temporal Interval Tests (Consolidated) - // ========================================================================= - #[test] fn test_temporal_interval_parsing() { // Simple unit names - let simple = TemporalInterval::create_from_str("month").unwrap(); + let simple = parse_temporal_interval("month").unwrap(); assert_eq!(simple.count, 1); - assert_eq!(simple.unit, TemporalUnit::Month); + assert_eq!(simple.unit, CalendarUnit::Month); // With count prefix - let with_count = TemporalInterval::create_from_str("2 months").unwrap(); + let with_count = parse_temporal_interval("2 months").unwrap(); assert_eq!(with_count.count, 2); - assert_eq!(with_count.unit, TemporalUnit::Month); + assert_eq!(with_count.unit, CalendarUnit::Month); // All unit names should parse for unit in &[ "second", "seconds", "minute", "hour", "day", "week", "month", "year", ] { assert!( - TemporalInterval::create_from_str(unit).is_some(), + parse_temporal_interval(unit).is_some(), "{} should parse", unit ); @@ -1991,60 +519,13 @@ mod tests { // Invalid inputs for invalid in &["invalid", "foo bar baz", ""] { assert!( - TemporalInterval::create_from_str(invalid).is_none(), + parse_temporal_interval(invalid).is_none(), "{} should not parse", invalid ); } } - // ========================================================================= - // Temporal Date Breaks Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_temporal_breaks_date_various_intervals() { - // Monthly breaks: 2024-01-15 to 2024-04-15 - let monthly = TemporalInterval::create_from_str("month").unwrap(); - let breaks_monthly = temporal_breaks_date(19738, 19828, monthly); - assert_eq!(breaks_monthly[0], "2024-01-01"); - for month in &["2024-02-01", "2024-03-01", "2024-04-01"] { - assert!( - breaks_monthly.contains(&month.to_string()), - "Monthly should contain {}", - month - ); - } - - // Bimonthly: 2024-01-01 to 2024-07-01 - let bimonthly = TemporalInterval::create_from_str("2 months").unwrap(); - let breaks_bi = temporal_breaks_date(19724, 19907, bimonthly); - assert!(breaks_bi.contains(&"2024-03-01".to_string())); - assert!( - !breaks_bi.contains(&"2024-02-01".to_string()), - "Bimonthly should skip Feb" - ); - - // Yearly: 2022-01-01 to 2024-12-31 - let yearly = TemporalInterval::create_from_str("year").unwrap(); - let breaks_yearly = temporal_breaks_date(18993, 20089, yearly); - for year in &["2022-01-01", "2023-01-01", "2024-01-01"] { - assert!( - breaks_yearly.contains(&year.to_string()), - "Yearly should contain {}", - year - ); - } - - // Weekly: ~30 days - let weekly = TemporalInterval::create_from_str("week").unwrap(); - let breaks_weekly = temporal_breaks_date(19724, 19754, weekly); - assert!( - breaks_weekly.len() >= 4, - "Weekly should have at least 4 breaks" - ); - } - // ========================================================================= // Minor Breaks Linear Tests (Consolidated) // ========================================================================= @@ -2082,274 +563,21 @@ mod tests { } // ========================================================================= - // Minor Breaks Log/Sqrt/Symlog Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_minor_breaks_log_variations() { - // Basic case - let minors_basic = minor_breaks_log(&[1.0, 10.0, 100.0], 8, 10.0, None); - assert_eq!(minors_basic.len(), 16, "8 per decade × 2 decades"); - - // Single minor (geometric mean) - let minors_single = minor_breaks_log(&[1.0, 10.0, 100.0], 1, 10.0, None); - assert_eq!(minors_single.len(), 2); - assert!((minors_single[0] - (1.0_f64 * 10.0).sqrt()).abs() < 0.01); - - // With extension - let minors_ext = minor_breaks_log(&[10.0, 100.0], 8, 10.0, Some((1.0, 1000.0))); - assert_eq!(minors_ext.len(), 24, "8 per decade × 3 decades"); - - // Filters negative - let minors_neg = minor_breaks_log(&[-10.0, 1.0, 10.0, 100.0], 1, 10.0, None); - assert!(minors_neg.iter().all(|&x| x > 0.0)); - } - - #[test] - fn test_minor_breaks_sqrt_variations() { - // Basic case - midpoints in sqrt space, squared back - let minors_basic = minor_breaks_sqrt(&[0.0, 25.0, 100.0], 1, None); - assert_eq!(minors_basic.len(), 2); - assert!((minors_basic[0] - 6.25).abs() < 0.01); - assert!((minors_basic[1] - 56.25).abs() < 0.01); - - // With extension - let minors_ext = minor_breaks_sqrt(&[25.0, 100.0], 1, Some((0.0, 225.0))); - assert!(minors_ext.len() >= 2); - - // Filters negative - let minors_neg = minor_breaks_sqrt(&[-10.0, 0.0, 25.0, 100.0], 1, None); - assert!(minors_neg.iter().all(|&x| x >= 0.0)); - } - - #[test] - fn test_minor_breaks_symlog_variations() { - // Basic case - one minor per interval - let minors_basic = minor_breaks_symlog(&[-100.0, -10.0, 0.0, 10.0, 100.0], 1, None); - assert_eq!(minors_basic.len(), 4); - - // Crossing zero - midpoint should be near 0 - let minors_cross = minor_breaks_symlog(&[-10.0, 10.0], 1, None); - assert_eq!(minors_cross.len(), 1); - assert!( - minors_cross[0].abs() < 1.0, - "Midpoint crossing zero should be near 0" - ); - - // With extension - let minors_ext = minor_breaks_symlog(&[0.0, 100.0], 1, Some((-100.0, 200.0))); - assert!(minors_ext.len() >= 2); - } - - // ========================================================================= - // Trim Breaks Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_trim_breaks_variations() { - // Trim from both ends - let trimmed = trim_breaks(&[5.0, 10.0, 15.0, 20.0, 25.0, 30.0], (10.0, 25.0)); - assert_eq!(trimmed, vec![10.0, 15.0, 20.0, 25.0]); - - // All outside range - let empty = trim_breaks(&[5.0, 10.0, 15.0], (20.0, 30.0)); - assert!(empty.is_empty()); - - // All inside range - let all = trim_breaks(&[15.0, 20.0, 25.0], (10.0, 30.0)); - assert_eq!(all, vec![15.0, 20.0, 25.0]); - } - - #[test] - fn test_trim_temporal_breaks_variations() { - // Trim to middle - let breaks = vec![ - "2024-01-01".to_string(), - "2024-02-01".to_string(), - "2024-03-01".to_string(), - ]; - let trimmed = trim_temporal_breaks(&breaks, ("2024-01-15", "2024-02-15")); - assert_eq!(trimmed, vec!["2024-02-01".to_string()]); - - // All inside - let all_inside = trim_temporal_breaks( - &["2024-02-01".to_string(), "2024-02-15".to_string()], - ("2024-01-01", "2024-03-01"), - ); - assert_eq!(all_inside.len(), 2); - } - - // ========================================================================= - // Derive Minor Interval Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_derive_minor_interval_all_units() { - let expected = vec![ - ("year", "3 months"), - ("3 months", "month"), - ("month", "week"), - ("week", "day"), - ("day", "6 hours"), - ("hour", "15 minutes"), - ("minute", "15 seconds"), - ("invalid", "day"), // Falls back to day - ]; - for (input, expected_output) in expected { - assert_eq!( - derive_minor_interval(input), - expected_output, - "derive_minor_interval({}) should be {}", - input, - expected_output - ); - } - } - - // ========================================================================= - // Temporal Minor Breaks & MinorBreakSpec Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_temporal_minor_breaks_date_variations() { - let majors = vec![ - "2024-01-01".to_string(), - "2024-02-01".to_string(), - "2024-03-01".to_string(), - ]; - - // Auto mode - derives "week" from "month" - let minors_auto = temporal_minor_breaks_date(&majors, "month", MinorBreakSpec::Auto, None); - assert!(!minors_auto.is_empty()); - assert!(minors_auto.iter().any(|d| d.starts_with("2024-01"))); - assert!(minors_auto.iter().any(|d| d.starts_with("2024-02"))); - - // By count - let minors_count = - temporal_minor_breaks_date(&majors[..2], "month", MinorBreakSpec::Count(3), None); - assert!(!minors_count.is_empty()); - - // By interval - let minors_interval = temporal_minor_breaks_date( - &majors[..2], - "month", - MinorBreakSpec::Interval("week".to_string()), - None, - ); - assert!(minors_interval.len() >= 3, "January has about 4 weeks"); - - // With extension - let minors_ext = temporal_minor_breaks_date( - &["2024-02-01".to_string(), "2024-03-01".to_string()], - "month", - MinorBreakSpec::Interval("week".to_string()), - Some(("2024-01-01", "2024-04-01")), - ); - assert!( - minors_ext.iter().any(|d| d.starts_with("2024-01")), - "Should extend into January" - ); - assert!( - minors_ext.iter().any(|d| d.starts_with("2024-03")), - "Should extend into March" - ); - - // Single major returns empty - let minors_single = temporal_minor_breaks_date( - &["2024-01-01".to_string()], - "month", - MinorBreakSpec::Auto, - None, - ); - assert!(minors_single.is_empty()); - } - - #[test] - fn test_minor_break_spec_types() { - assert_eq!(MinorBreakSpec::default(), MinorBreakSpec::Auto); - assert_eq!(MinorBreakSpec::Count(4), MinorBreakSpec::Count(4)); - assert_eq!( - MinorBreakSpec::Interval("week".to_string()), - MinorBreakSpec::Interval("week".to_string()) - ); - } - - // ========================================================================= - // Wilkinson Extended Tests (Consolidated) + // Extent-driven Minor Breaks Tests // ========================================================================= #[test] - fn test_wilkinson_basic_properties() { - // Test various ranges - should produce nice round numbers - let test_cases = vec![ - (0.0, 100.0, 5), - (0.1, 0.9, 5), - (0.0, 1_000_000.0, 5), - (-50.0, 50.0, 5), - (0.0, 152.0, 5), // penguin scenario - ]; - for (min, max, n) in test_cases { - let breaks = wilkinson_extended(min, max, n); - assert!( - !breaks.is_empty(), - "wilkinson_extended({}, {}, {}) should not be empty", - min, - max, - n - ); - assert!( - breaks.len() >= 3 && breaks.len() <= 10, - "wilkinson({}, {}, {}) count should be reasonable", - min, - max, - n - ); - } - } - - #[test] - fn test_wilkinson_prefers_nice_numbers() { - let breaks = wilkinson_extended(0.0, 97.0, 5); - for b in &breaks { - let normalized = b / 10.0; - let is_nice = normalized.fract() == 0.0 - || (normalized * 2.0).fract() == 0.0 - || (normalized * 4.0).fract() == 0.0; - assert!(is_nice, "Break {} should be a nice number", b); - } - } - - #[test] - fn test_wilkinson_covers_data() { - let breaks = wilkinson_extended(7.3, 94.2, 5); - assert!(*breaks.first().unwrap() <= 7.3); - assert!(*breaks.last().unwrap() >= 94.2); - } + fn test_extent_minor_breaks_drops_majors_and_honours_zero() { + let majors = [1.0, 10.0, 100.0]; + let log10 = |min: f64, max: f64| log_minor_breaks(min, max, 10.0); + let minors = extent_minor_breaks(&majors, 8, None, log10); + assert_eq!(minors.len(), 16); + assert!(minors.iter().all(|m| !majors.contains(m))); + assert!(extent_minor_breaks(&majors, 0, None, log10).is_empty()); - #[test] - fn test_wilkinson_edge_cases() { - let edge_cases = vec![ - (0.0, 100.0, 0, "zero count"), - (100.0, 0.0, 5, "min > max"), - (50.0, 50.0, 5, "min == max"), - (f64::NAN, 100.0, 5, "NaN min"), - (0.0, f64::INFINITY, 5, "infinite max"), - ]; - for (min, max, n, desc) in edge_cases { - assert!( - wilkinson_extended(min, max, n).is_empty(), - "wilkinson_extended with {} should be empty", - desc - ); - } - } - - #[test] - fn test_pretty_breaks_simple_preserved() { - let breaks = pretty_breaks_simple(0.0, 100.0, 5); - assert!(!breaks.is_empty()); - assert!(breaks[0] <= 0.0); - assert!(*breaks.last().unwrap() >= 100.0); + // 1-2-5 majors: the 2 and 5 multiples belong to the majors. + let minors = extent_minor_breaks(&[1.0, 2.0, 5.0, 10.0], 8, None, log10); + assert_eq!(minors, vec![3.0, 4.0, 6.0, 7.0, 8.0, 9.0]); } // ========================================================================= @@ -2358,7 +586,7 @@ mod tests { #[test] fn test_temporal_breaks_date_includes_terminal_break() { - // Test that temporal_breaks_date includes a terminal break AFTER max_date + // Test that interval_breaks includes a terminal break AFTER max_date // to ensure all data falls within a bin. // // Data spanning Jan 15 to Mar 15 with monthly breaks should produce: @@ -2368,45 +596,60 @@ mod tests { // 2024-01-15 to 2024-03-15 (days since epoch) // Jan 15, 2024 = 19738 days since epoch // Mar 15, 2024 = 19798 days since epoch - let monthly = TemporalInterval::create_from_str("month").unwrap(); - let breaks = temporal_breaks_date(19738, 19798, monthly); + let monthly = parse_temporal_interval("month").unwrap(); + let breaks: Vec = interval_breaks(monthly, 19738.0, 19798.0, TemporalUnit::Date) + .into_iter() + .map(|d| d as i32) + .collect(); // Should include terminal break (Apr-01) after max_date (Mar-15) assert!( - breaks.contains(&"2024-04-01".to_string()), + breaks.contains(&days("2024-04-01")), "Should include terminal break Apr-01 to close the last bin. Got: {:?}", breaks ); // Verify all expected breaks are present - assert!(breaks.contains(&"2024-01-01".to_string())); - assert!(breaks.contains(&"2024-02-01".to_string())); - assert!(breaks.contains(&"2024-03-01".to_string())); + assert!(breaks.contains(&days("2024-01-01"))); + assert!(breaks.contains(&days("2024-02-01"))); + assert!(breaks.contains(&days("2024-03-01"))); // The terminal break ensures data from Mar 2-15 falls within [Mar-01, Apr-01] assert_eq!(breaks.len(), 4, "Should have 4 breaks for 3 bins"); } #[test] - fn test_temporal_breaks_datetime_includes_terminal_break() { - // Test that temporal_breaks_datetime includes a terminal break - // 2024-01-01T00:00:00 to 2024-01-01T02:30:00 with hourly breaks - - let hourly = TemporalInterval::create_from_str("hour").unwrap(); - // Jan 1, 2024 00:00:00 = 1704067200 seconds = 1704067200000000 microseconds - let min_us = 1704067200_i64 * 1_000_000; - // Jan 1, 2024 02:30:00 = 1704076200 seconds - let max_us = 1704076200_i64 * 1_000_000; - - let breaks = temporal_breaks_datetime(min_us, max_us, hourly); - - // Should include 00:00, 01:00, 02:00, and terminal 03:00 + fn test_interval_breaks_align_to_count_sized_grid() { + // "3 months" from Feb 15 steps along the quarter grid (Jan / Apr / Jul), + // not from February. + let quarterly = parse_temporal_interval("3 months").unwrap(); + let breaks: Vec = interval_breaks( + quarterly, + days("2024-02-15") as f64, + days("2024-06-30") as f64, + TemporalUnit::Date, + ) + .into_iter() + .map(|d| d as i32) + .collect(); assert_eq!( - breaks.len(), - 4, - "Should have 4 breaks (including terminal). Got: {:?}", - breaks + breaks, + vec![days("2024-01-01"), days("2024-04-01"), days("2024-07-01")] + ); + } + + #[test] + fn test_interval_breaks_time_stops_at_midnight() { + // A terminal break would land past 24:00, so there is none. + const HOUR_NS: f64 = 3_600_000_000_000.0; + let hourly = parse_temporal_interval("hour").unwrap(); + let breaks = interval_breaks( + hourly, + 22.0 * HOUR_NS, + 23.0 * HOUR_NS + 1.0, + TemporalUnit::Time, ); + assert_eq!(breaks, vec![22.0 * HOUR_NS, 23.0 * HOUR_NS]); } // ========================================================================= diff --git a/src/plot/scale/colour.rs b/src/plot/scale/colour.rs index 1408f061e..9c35756d2 100644 --- a/src/plot/scale/colour.rs +++ b/src/plot/scale/colour.rs @@ -1,8 +1,11 @@ //! Color utilities for ggsql visualization //! //! Provides color parsing, conversion, and interpolation functions. +//! Interpolation itself lives in `zygos::color`; this module adapts it to +//! ggsql's CSS-string interface. -use palette::{FromColor, IntoColor, LinSrgb, Mix, Oklab, Srgb}; +pub use zygos::color::ColorSpace; +use zygos::color::{ramp, Color}; // ============================================================================= // Color Utilities @@ -25,18 +28,6 @@ pub fn is_color_aesthetic(aesthetic: &str) -> bool { // Color Interpolation // ============================================================================= -/// Color space options for interpolation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ColorSpace { - /// Oklab color space - perceptually uniform (recommended for most uses). - /// Produces visually pleasing gradients that avoid muddy colors. - #[default] - Oklab, - /// Linear RGB color space - simple linear interpolation in RGB. - /// Can produce darker intermediate colors for complementary hues. - LinearRgb, -} - /// Interpolate between colors, returning `count` evenly-spaced colors. /// /// Colors can be any CSS color format supported by `csscolorparser`: @@ -70,29 +61,15 @@ pub fn interpolate_colors( return Err("At least one color is required".to_string()); } - if count == 0 { - return Ok(vec![]); - } - - // Parse all input colors to Srgb - let srgb_colors: Vec> = colors + let stops: Vec = colors .iter() - .map(|c| parse_to_srgb(c)) + .map(|c| parse_to_color(c)) .collect::, _>>()?; - // Single color: return it `count` times - if srgb_colors.len() == 1 { - let hex = srgb_to_hex(&srgb_colors[0]); - return Ok(vec![hex; count]); - } - - // Two or more colors: interpolate - let result = match space { - ColorSpace::Oklab => interpolate_in_oklab(&srgb_colors, count), - ColorSpace::LinearRgb => interpolate_in_linear_rgb(&srgb_colors, count), - }; - - Ok(result) + Ok(ramp(&stops, count, space) + .iter() + .map(color_to_css_hex) + .collect()) } /// Convenience function for creating a two-color gradient. @@ -119,75 +96,24 @@ pub fn gradient( interpolate_colors(&[start, end], count, space) } -/// Parse a CSS color string to Srgb. -fn parse_to_srgb(color: &str) -> Result, String> { +/// Parse a CSS color string to a zygos Color. +fn parse_to_color(color: &str) -> Result { let parsed = csscolorparser::parse(color).map_err(|e| format!("Invalid color '{}': {}", color, e))?; - Ok(Srgb::new(parsed.r as f32, parsed.g as f32, parsed.b as f32)) -} - -/// Convert Srgb to hex string. -fn srgb_to_hex(color: &Srgb) -> String { - let r = (color.red.clamp(0.0, 1.0) * 255.0).round() as u8; - let g = (color.green.clamp(0.0, 1.0) * 255.0).round() as u8; - let b = (color.blue.clamp(0.0, 1.0) * 255.0).round() as u8; - format!("#{:02x}{:02x}{:02x}", r, g, b) -} - -/// Interpolate colors in Oklab color space. -fn interpolate_in_oklab(colors: &[Srgb], count: usize) -> Vec { - // Convert to Oklab - let oklab_colors: Vec> = colors - .iter() - .map(|c| Oklab::from_color(LinSrgb::from(*c))) - .collect(); - - if count == 1 { - let lin: LinSrgb = oklab_colors[0].into_color(); - return vec![srgb_to_hex(&Srgb::from(lin))]; - } - - let num_segments = oklab_colors.len() - 1; - let mut result = Vec::with_capacity(count); - - for i in 0..count { - let t = i as f32 / (count - 1) as f32; - let segment_float = t * num_segments as f32; - let segment = (segment_float.floor() as usize).min(num_segments - 1); - let segment_t = segment_float - segment as f32; - - let interpolated = oklab_colors[segment].mix(oklab_colors[segment + 1], segment_t); - let lin: LinSrgb = interpolated.into_color(); - result.push(srgb_to_hex(&Srgb::from(lin))); - } - - result + Ok(Color::new([ + parsed.r as f32, + parsed.g as f32, + parsed.b as f32, + parsed.a as f32, + ])) } -/// Interpolate colors in linear RGB color space. -fn interpolate_in_linear_rgb(colors: &[Srgb], count: usize) -> Vec { - // Convert to linear RGB - let lin_colors: Vec> = colors.iter().map(|c| LinSrgb::from(*c)).collect(); - - if count == 1 { - return vec![srgb_to_hex(&Srgb::from(lin_colors[0]))]; - } - - let num_segments = lin_colors.len() - 1; - let mut result = Vec::with_capacity(count); - - for i in 0..count { - let t = i as f32 / (count - 1) as f32; - let segment_float = t * num_segments as f32; - let segment = (segment_float.floor() as usize).min(num_segments - 1); - let segment_t = segment_float - segment as f32; - - let interpolated = lin_colors[segment].mix(lin_colors[segment + 1], segment_t); - result.push(srgb_to_hex(&Srgb::from(interpolated))); - } - - result +/// Convert a zygos Color to a hex string, dropping alpha. +pub(crate) fn color_to_css_hex(color: &Color) -> String { + let [r, g, b, _] = color.components; + let to_u8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}", to_u8(r), to_u8(g), to_u8(b)) } #[cfg(test)] @@ -224,8 +150,6 @@ mod tests { assert!(is_color_aesthetic("fill")); assert!(is_color_aesthetic("stroke")); assert!(!is_color_aesthetic("x")); - assert!(!is_color_aesthetic("y")); - assert!(!is_color_aesthetic("size")); assert!(!is_color_aesthetic("shape")); } @@ -235,30 +159,24 @@ mod tests { #[test] fn test_interpolate_colors_basic() { - // Two colors, 5 output colors let colors = interpolate_colors(&["red", "blue"], 5, ColorSpace::Oklab).unwrap(); assert_eq!(colors.len(), 5); - // First and last should be close to input colors - assert_eq!(colors[0], "#ff0000"); // red - assert_eq!(colors[4], "#0000ff"); // blue + assert_eq!(colors[0], "#ff0000"); + assert_eq!(colors[4], "#0000ff"); } #[test] - fn test_interpolate_colors_linear_rgb() { - let colors = interpolate_colors(&["white", "black"], 3, ColorSpace::LinearRgb).unwrap(); + fn test_interpolate_colors_srgb() { + let colors = interpolate_colors(&["white", "black"], 3, ColorSpace::Srgb).unwrap(); assert_eq!(colors.len(), 3); - assert_eq!(colors[0], "#ffffff"); // white - assert_eq!(colors[2], "#000000"); // black + assert_eq!(colors[0], "#ffffff"); + assert_eq!(colors[2], "#000000"); } #[test] fn test_interpolate_colors_single_input() { - // Single color input should return that color repeated let colors = interpolate_colors(&["red"], 3, ColorSpace::Oklab).unwrap(); - assert_eq!(colors.len(), 3); - assert_eq!(colors[0], "#ff0000"); - assert_eq!(colors[1], "#ff0000"); - assert_eq!(colors[2], "#ff0000"); + assert_eq!(colors, vec!["#ff0000", "#ff0000", "#ff0000"]); } #[test] @@ -270,16 +188,13 @@ mod tests { #[test] fn test_interpolate_colors_count_one() { let colors = interpolate_colors(&["red", "blue"], 1, ColorSpace::Oklab).unwrap(); - assert_eq!(colors.len(), 1); - assert_eq!(colors[0], "#ff0000"); // should be first color + assert_eq!(colors, vec!["#ff0000"]); } #[test] fn test_interpolate_colors_count_two() { let colors = interpolate_colors(&["red", "blue"], 2, ColorSpace::Oklab).unwrap(); - assert_eq!(colors.len(), 2); - assert_eq!(colors[0], "#ff0000"); // red - assert_eq!(colors[1], "#0000ff"); // blue + assert_eq!(colors, vec!["#ff0000", "#0000ff"]); } #[test] @@ -298,20 +213,10 @@ mod tests { #[test] fn test_interpolate_colors_multi_stop() { - // Three colors: red -> white -> blue let colors = interpolate_colors(&["red", "white", "blue"], 5, ColorSpace::Oklab).unwrap(); - assert_eq!(colors.len(), 5); - assert_eq!(colors[0], "#ff0000"); // red - assert_eq!(colors[2], "#ffffff"); // white (middle) - assert_eq!(colors[4], "#0000ff"); // blue - } - - #[test] - fn test_interpolate_colors_hex_input() { - let colors = interpolate_colors(&["#ff0000", "#0000ff"], 3, ColorSpace::Oklab).unwrap(); - assert_eq!(colors.len(), 3); assert_eq!(colors[0], "#ff0000"); - assert_eq!(colors[2], "#0000ff"); + assert_eq!(colors[2], "#ffffff"); + assert_eq!(colors[4], "#0000ff"); } #[test] @@ -323,37 +228,31 @@ mod tests { } #[test] - fn test_oklab_vs_linear_rgb_red_cyan() { + fn test_oklab_vs_srgb_red_cyan() { // Red to cyan: Oklab should produce lighter intermediates, - // while linear RGB produces darker/muddier intermediates + // while sRGB produces darker/muddier intermediates let oklab = interpolate_colors(&["red", "cyan"], 5, ColorSpace::Oklab).unwrap(); - let linear = interpolate_colors(&["red", "cyan"], 5, ColorSpace::LinearRgb).unwrap(); + let srgb = interpolate_colors(&["red", "cyan"], 5, ColorSpace::Srgb).unwrap(); - // Both should have same start and end assert_eq!(oklab[0], "#ff0000"); assert_eq!(oklab[4], "#00ffff"); - assert_eq!(linear[0], "#ff0000"); - assert_eq!(linear[4], "#00ffff"); + assert_eq!(srgb[0], "#ff0000"); + assert_eq!(srgb[4], "#00ffff"); - // Middle colors should differ - Oklab tends to be brighter - // We just verify they're different (the specific values depend on the algorithm) - assert_ne!(oklab[2], linear[2]); + assert_ne!(oklab[2], srgb[2]); } #[test] fn test_color_space_default() { - // Default should be Oklab assert_eq!(ColorSpace::default(), ColorSpace::Oklab); } #[test] fn test_interpolate_preserves_endpoints() { - // Verify that interpolation preserves exact endpoint colors let test_cases = vec![("black", "white"), ("red", "green"), ("#123456", "#abcdef")]; for (start, end) in test_cases { let colors = interpolate_colors(&[start, end], 10, ColorSpace::Oklab).unwrap(); - // First color should match start (parsed and converted back) let start_hex = color_to_hex(start).unwrap(); let end_hex = color_to_hex(end).unwrap(); assert_eq!( @@ -367,7 +266,6 @@ mod tests { #[test] fn test_interpolate_many_stops() { - // Rainbow gradient with 6 stops let colors = interpolate_colors( &["red", "orange", "yellow", "green", "blue", "violet"], 11, @@ -375,8 +273,7 @@ mod tests { ) .unwrap(); assert_eq!(colors.len(), 11); - // First and last should match - assert_eq!(colors[0], "#ff0000"); // red - assert_eq!(colors[10], "#ee82ee"); // violet + assert_eq!(colors[0], "#ff0000"); + assert_eq!(colors[10], "#ee82ee"); } } diff --git a/src/plot/scale/linetype.rs b/src/plot/scale/linetype.rs index 2099934ca..4813a0f19 100644 --- a/src/plot/scale/linetype.rs +++ b/src/plot/scale/linetype.rs @@ -1,29 +1,7 @@ -//! Linetype definitions and conversion to Vega-Lite strokeDash arrays. - -/// Parse a ggplot2-style hex string linetype pattern. -/// -/// Format: Even number (2-8) of hex digits, each specifying on/off lengths. -/// Examples: "33" = [3,3], "1343" = [1,3,4,3], "44" = [4,4] -fn parse_hex_linetype(s: &str) -> Option> { - let len = s.len(); - - // Must be even length, 2-8 characters, all hex digits - if !(2..=8).contains(&len) || len % 2 != 0 { - return None; - } - - // Parse each character as a hex digit - let mut result = Vec::with_capacity(len); - for c in s.chars() { - let digit = c.to_digit(16)?; - if digit == 0 { - return None; // ggplot2 requires non-zero digits - } - result.push(digit); - } - - Some(result) -} +//! Linetype definitions and conversion to stroke-dash arrays. +//! +//! The patterns live in `zygos::patterns::linetype`; this module adapts +//! them to the `Vec` stroke-dash form the Vega-Lite writer emits. /// Get the strokeDash array for a linetype specification. /// @@ -31,34 +9,19 @@ fn parse_hex_linetype(s: &str) -> Option> { /// - Named linetypes: "solid", "dashed", "dotted", "dotdash", "longdash", "twodash" /// - Hex string patterns: "33", "1343", "44", etc. (2-8 hex digits) /// -/// # Named linetype patterns -/// - `solid`: continuous line (empty array) -/// - `dashed`: regular dashes `[6, 4]` -/// - `dotted`: dots `[1, 2]` -/// - `dotdash`: alternating dots and dashes `[1, 2, 6, 2]` -/// - `longdash`: longer dashes `[10, 4]` -/// - `twodash`: two-dash pattern `[6, 2, 2, 2]` -/// -/// # Hex string patterns -/// A string of 2-8 hex digits (even count), where each digit specifies -/// the length of alternating on/off segments. For example: -/// - `"33"` = 3 units on, 3 units off -/// - `"1343"` = 1 on, 3 off, 4 on, 3 off -/// - `"af"` = 10 on, 15 off (hex digits a-f supported) +/// `solid` resolves to an empty array. Lengths are multiples of the line +/// width — the convention zygos, hephaestus and ggsql share (a `6,4` +/// dashed line at 2 px width dashes 12 px on, 8 px off). pub fn linetype_to_stroke_dash(name: &str) -> Option> { - // First try named linetypes (case-insensitive) - match name.to_lowercase().as_str() { - "solid" => return Some(vec![]), - "dashed" => return Some(vec![6, 4]), - "dotted" => return Some(vec![1, 2]), - "dotdash" => return Some(vec![1, 2, 6, 2]), - "longdash" => return Some(vec![10, 4]), - "twodash" => return Some(vec![6, 2, 2, 2]), - _ => {} - } - - // Then try hex string pattern - parse_hex_linetype(name) + zygos::patterns::linetype::linetype_pattern(name).map(|steps| { + steps + .iter() + .filter_map(|step| match step { + zygos::LinetypeStep::Dash(v) | zygos::LinetypeStep::Gap(v) => Some(*v as u32), + zygos::LinetypeStep::Marker(_) => None, + }) + .collect() + }) } #[cfg(test)] @@ -79,7 +42,6 @@ mod tests { fn test_linetype_to_stroke_dash_case_insensitive() { assert!(linetype_to_stroke_dash("SOLID").is_some()); assert!(linetype_to_stroke_dash("Dashed").is_some()); - assert!(linetype_to_stroke_dash("DoTdAsH").is_some()); } #[test] @@ -89,62 +51,9 @@ mod tests { } #[test] - fn test_hex_linetype_basic() { - // Simple two-digit patterns + fn test_hex_linetype() { assert_eq!(linetype_to_stroke_dash("33"), Some(vec![3, 3])); - assert_eq!(linetype_to_stroke_dash("44"), Some(vec![4, 4])); - assert_eq!(linetype_to_stroke_dash("13"), Some(vec![1, 3])); - } - - #[test] - fn test_hex_linetype_four_digit() { assert_eq!(linetype_to_stroke_dash("1343"), Some(vec![1, 3, 4, 3])); - assert_eq!(linetype_to_stroke_dash("2262"), Some(vec![2, 2, 6, 2])); - } - - #[test] - fn test_hex_linetype_six_and_eight_digit() { - assert_eq!( - linetype_to_stroke_dash("123456"), - Some(vec![1, 2, 3, 4, 5, 6]) - ); - assert_eq!( - linetype_to_stroke_dash("12345678"), - Some(vec![1, 2, 3, 4, 5, 6, 7, 8]) - ); - } - - #[test] - fn test_hex_linetype_ggplot2_standards() { - // ggplot2's standard dash-dot patterns - assert_eq!(linetype_to_stroke_dash("44"), Some(vec![4, 4])); // dashed - assert_eq!(linetype_to_stroke_dash("13"), Some(vec![1, 3])); // dotted - assert_eq!(linetype_to_stroke_dash("1343"), Some(vec![1, 3, 4, 3])); // dotdash - assert_eq!(linetype_to_stroke_dash("73"), Some(vec![7, 3])); // longdash - assert_eq!(linetype_to_stroke_dash("2262"), Some(vec![2, 2, 6, 2])); // twodash - } - - #[test] - fn test_hex_linetype_with_letters() { - // Hex digits a-f should work assert_eq!(linetype_to_stroke_dash("af"), Some(vec![10, 15])); - assert_eq!(linetype_to_stroke_dash("AF"), Some(vec![10, 15])); - assert_eq!(linetype_to_stroke_dash("1a2b"), Some(vec![1, 10, 2, 11])); - } - - #[test] - fn test_hex_linetype_invalid() { - // Odd length - assert!(linetype_to_stroke_dash("123").is_none()); - // Too short - assert!(linetype_to_stroke_dash("1").is_none()); - // Too long (>8) - assert!(linetype_to_stroke_dash("1234567890").is_none()); - // Contains zero (invalid in ggplot2) - assert!(linetype_to_stroke_dash("10").is_none()); - assert!(linetype_to_stroke_dash("01").is_none()); - // Non-hex characters - assert!(linetype_to_stroke_dash("gg").is_none()); - assert!(linetype_to_stroke_dash("1x").is_none()); } } diff --git a/src/plot/scale/mod.rs b/src/plot/scale/mod.rs index 9065818df..91c445b87 100644 --- a/src/plot/scale/mod.rs +++ b/src/plot/scale/mod.rs @@ -7,7 +7,6 @@ pub mod colour; pub mod linetype; pub mod palettes; mod scale_type; -pub mod shape; pub mod transform; mod types; @@ -25,7 +24,6 @@ pub use scale_type::{ ScaleTypeTrait, TypeFamily, OOB_CENSOR, OOB_KEEP, OOB_SQUISH, }; -pub use shape::shape_to_svg_path; pub use transform::{Transform, TransformKind, TransformTrait, ALL_TRANSFORM_NAMES}; pub use types::{OutputRange, PanelScale, Scale}; diff --git a/src/plot/scale/palettes.rs b/src/plot/scale/palettes.rs index e5faf0d34..5d315d4db 100644 --- a/src/plot/scale/palettes.rs +++ b/src/plot/scale/palettes.rs @@ -1,2103 +1,48 @@ -//! Named palette definitions for color and shape aesthetics +//! Named palette definitions for color and shape aesthetics. //! -//! Provides lookup functions to expand palette names to explicit color/shape values. +//! The tables and lookup functions live in `zygos::palettes`; this module +//! adapts them to ggsql's string-based `ArrayElement` vocabulary (hex color +//! strings, ggplot2-style hex linetype patterns) and adds the +//! `ArrayElement`-typed lookup used during scale resolution. -use crate::plot::ArrayElement; - -// ============================================================================= -// Categorical Color Palettes -// ============================================================================= - -/// ggsql 10 - default categorical palette -pub const GGSQL10: &[&str] = &[ - "#0067A5", "#F38400", "#008856", "#F3C300", "#875692", "#A1CAF1", "#BE0032", "#8DB600", - "#654522", "#E68FAC", -]; - -/// Tableau 10 -pub const TABLEAU10: &[&str] = &[ - "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#ff9da7", - "#9c755f", "#bab0ac", -]; - -/// D3 Category 10 -pub const CATEGORY10: &[&str] = &[ - "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", - "#bcbd22", "#17becf", -]; - -/// ColorBrewer Set1 -pub const SET1: &[&str] = &[ - "#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00", "#ffff33", "#a65628", "#f781bf", - "#999999", -]; - -/// ColorBrewer Set2 -pub const SET2: &[&str] = &[ - "#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f", "#e5c494", "#b3b3b3", -]; - -/// ColorBrewer Set3 -pub const SET3: &[&str] = &[ - "#8dd3c7", "#ffffb3", "#bebada", "#fb8072", "#80b1d3", "#fdb462", "#b3de69", "#fccde5", - "#d9d9d9", "#bc80bd", "#ccebc5", "#ffed6f", -]; - -/// ColorBrewer Pastel1 -pub const PASTEL1: &[&str] = &[ - "#fbb4ae", "#b3cde3", "#ccebc5", "#decbe4", "#fed9a6", "#ffffcc", "#e5d8bd", "#fddaec", - "#f2f2f2", -]; - -/// ColorBrewer Pastel2 -pub const PASTEL2: &[&str] = &[ - "#b3e2cd", "#fdcdac", "#cbd5e8", "#f4cae4", "#e6f5c9", "#fff2ae", "#f1e2cc", "#cccccc", -]; - -/// ColorBrewer Dark2 -pub const DARK2: &[&str] = &[ - "#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#66a61e", "#e6ab02", "#a6761d", "#666666", -]; - -/// ColorBrewer Paired -pub const PAIRED: &[&str] = &[ - "#a6cee3", "#1f78b4", "#b2df8a", "#33a02c", "#fb9a99", "#e31a1c", "#fdbf6f", "#ff7f00", - "#cab2d6", "#6a3d9a", "#ffff99", "#b15928", -]; - -/// ColorBrewer Accent -pub const ACCENT: &[&str] = &[ - "#7fc97f", "#beaed4", "#fdc086", "#ffff99", "#386cb0", "#f0027f", "#bf5b17", "#666666", -]; - -/// Kelly's 22 colors of maximum contrast (excluding white and black) -/// Source: Kenneth Kelly, "Twenty-two colors of maximum contrast" (1965) -pub const KELLY22: &[&str] = &[ - "#F3C300", "#875692", "#F38400", "#A1CAF1", "#BE0032", "#C2B280", "#848482", "#008856", - "#E68FAC", "#0067A5", "#F99379", "#604E97", "#F6A600", "#B3446C", "#DCD300", "#882D17", - "#8DB600", "#654522", "#E25822", "#2B3D26", -]; - -// ============================================================================= -// Continuous Sequential Colormaps (full 256 colors) -// Source: matplotlib (van der Walt & Smith, 2015) -// ============================================================================= - -/// Viridis - perceptually uniform, colorblind-friendly (256 colors) -pub const VIRIDIS: &[&str] = &[ - "#440154", "#440255", "#440357", "#450558", "#45065a", "#45085b", "#46095c", "#460b5e", - "#460c5f", "#460e61", "#470f62", "#471163", "#471265", "#471466", "#471567", "#471669", - "#47186a", "#48196b", "#481a6c", "#481c6e", "#481d6f", "#481e70", "#482071", "#482172", - "#482273", "#482374", "#472575", "#472676", "#472777", "#472878", "#472a79", "#472b7a", - "#472c7b", "#462d7c", "#462f7c", "#46307d", "#46317e", "#45327f", "#45347f", "#453580", - "#453681", "#443781", "#443982", "#433a83", "#433b83", "#433c84", "#423d84", "#423e85", - "#424085", "#414186", "#414286", "#404387", "#404487", "#3f4587", "#3f4788", "#3e4888", - "#3e4989", "#3d4a89", "#3d4b89", "#3d4c89", "#3c4d8a", "#3c4e8a", "#3b508a", "#3b518a", - "#3a528b", "#3a538b", "#39548b", "#39558b", "#38568b", "#38578c", "#37588c", "#37598c", - "#365a8c", "#365b8c", "#355c8c", "#355d8c", "#345e8d", "#345f8d", "#33608d", "#33618d", - "#32628d", "#32638d", "#31648d", "#31658d", "#31668d", "#30678d", "#30688d", "#2f698d", - "#2f6a8d", "#2e6b8e", "#2e6c8e", "#2e6d8e", "#2d6e8e", "#2d6f8e", "#2c708e", "#2c718e", - "#2c728e", "#2b738e", "#2b748e", "#2a758e", "#2a768e", "#2a778e", "#29788e", "#29798e", - "#287a8e", "#287a8e", "#287b8e", "#277c8e", "#277d8e", "#277e8e", "#267f8e", "#26808e", - "#26818e", "#25828e", "#25838d", "#24848d", "#24858d", "#24868d", "#23878d", "#23888d", - "#23898d", "#22898d", "#228a8d", "#228b8d", "#218c8d", "#218d8c", "#218e8c", "#208f8c", - "#20908c", "#20918c", "#1f928c", "#1f938b", "#1f948b", "#1f958b", "#1f968b", "#1e978a", - "#1e988a", "#1e998a", "#1e998a", "#1e9a89", "#1e9b89", "#1e9c89", "#1e9d88", "#1e9e88", - "#1e9f88", "#1ea087", "#1fa187", "#1fa286", "#1fa386", "#20a485", "#20a585", "#21a685", - "#21a784", "#22a784", "#23a883", "#23a982", "#24aa82", "#25ab81", "#26ac81", "#27ad80", - "#28ae7f", "#29af7f", "#2ab07e", "#2bb17d", "#2cb17d", "#2eb27c", "#2fb37b", "#30b47a", - "#32b57a", "#33b679", "#35b778", "#36b877", "#38b976", "#39b976", "#3bba75", "#3dbb74", - "#3ebc73", "#40bd72", "#42be71", "#44be70", "#45bf6f", "#47c06e", "#49c16d", "#4bc26c", - "#4dc26b", "#4fc369", "#51c468", "#53c567", "#55c666", "#57c665", "#59c764", "#5bc862", - "#5ec961", "#60c960", "#62ca5f", "#64cb5d", "#67cc5c", "#69cc5b", "#6bcd59", "#6dce58", - "#70ce56", "#72cf55", "#74d054", "#77d052", "#79d151", "#7cd24f", "#7ed24e", "#81d34c", - "#83d34b", "#86d449", "#88d547", "#8bd546", "#8dd644", "#90d643", "#92d741", "#95d73f", - "#97d83e", "#9ad83c", "#9dd93a", "#9fd938", "#a2da37", "#a5da35", "#a7db33", "#aadb32", - "#addc30", "#afdc2e", "#b2dd2c", "#b5dd2b", "#b7dd29", "#bade27", "#bdde26", "#bfdf24", - "#c2df22", "#c5df21", "#c7e01f", "#cae01e", "#cde01d", "#cfe11c", "#d2e11b", "#d4e11a", - "#d7e219", "#dae218", "#dce218", "#dfe318", "#e1e318", "#e4e318", "#e7e419", "#e9e419", - "#ece41a", "#eee51b", "#f1e51c", "#f3e51e", "#f6e61f", "#f8e621", "#fae622", "#fde724", -]; - -/// Plasma - perceptually uniform, colorblind-friendly (256 colors) -pub const PLASMA: &[&str] = &[ - "#0c0786", "#100787", "#130689", "#15068a", "#18068b", "#1b068c", "#1d068d", "#1f058e", - "#21058f", "#230590", "#250591", "#270592", "#290593", "#2b0594", "#2d0494", "#2f0495", - "#310496", "#330497", "#340498", "#360498", "#380499", "#3a049a", "#3b039a", "#3d039b", - "#3f039c", "#40039c", "#42039d", "#44039e", "#45039e", "#47029f", "#49029f", "#4a02a0", - "#4c02a1", "#4e02a1", "#4f02a2", "#5101a2", "#5201a3", "#5401a3", "#5601a3", "#5701a4", - "#5901a4", "#5a00a5", "#5c00a5", "#5e00a5", "#5f00a6", "#6100a6", "#6200a6", "#6400a7", - "#6500a7", "#6700a7", "#6800a7", "#6a00a7", "#6c00a8", "#6d00a8", "#6f00a8", "#7000a8", - "#7200a8", "#7300a8", "#7500a8", "#7601a8", "#7801a8", "#7901a8", "#7b02a8", "#7c02a7", - "#7e03a7", "#7f03a7", "#8104a7", "#8204a7", "#8405a6", "#8506a6", "#8607a6", "#8807a5", - "#8908a5", "#8b09a4", "#8c0aa4", "#8e0ca4", "#8f0da3", "#900ea3", "#920fa2", "#9310a1", - "#9511a1", "#9612a0", "#9713a0", "#99149f", "#9a159e", "#9b179e", "#9d189d", "#9e199c", - "#9f1a9b", "#a01b9b", "#a21c9a", "#a31d99", "#a41e98", "#a51f97", "#a72197", "#a82296", - "#a92395", "#aa2494", "#ac2593", "#ad2692", "#ae2791", "#af2890", "#b02a8f", "#b12b8f", - "#b22c8e", "#b42d8d", "#b52e8c", "#b62f8b", "#b7308a", "#b83289", "#b93388", "#ba3487", - "#bb3586", "#bc3685", "#bd3784", "#be3883", "#bf3982", "#c03b81", "#c13c80", "#c23d80", - "#c33e7f", "#c43f7e", "#c5407d", "#c6417c", "#c7427b", "#c8447a", "#c94579", "#ca4678", - "#cb4777", "#cc4876", "#cd4975", "#ce4a75", "#cf4b74", "#d04d73", "#d14e72", "#d14f71", - "#d25070", "#d3516f", "#d4526e", "#d5536d", "#d6556d", "#d7566c", "#d7576b", "#d8586a", - "#d95969", "#da5a68", "#db5b67", "#dc5d66", "#dc5e66", "#dd5f65", "#de6064", "#df6163", - "#df6262", "#e06461", "#e16560", "#e26660", "#e3675f", "#e3685e", "#e46a5d", "#e56b5c", - "#e56c5b", "#e66d5a", "#e76e5a", "#e87059", "#e87158", "#e97257", "#ea7356", "#ea7455", - "#eb7654", "#ec7754", "#ec7853", "#ed7952", "#ed7b51", "#ee7c50", "#ef7d4f", "#ef7e4e", - "#f0804d", "#f0814d", "#f1824c", "#f2844b", "#f2854a", "#f38649", "#f38748", "#f48947", - "#f48a47", "#f58b46", "#f58d45", "#f68e44", "#f68f43", "#f69142", "#f79241", "#f79341", - "#f89540", "#f8963f", "#f8983e", "#f9993d", "#f99a3c", "#fa9c3b", "#fa9d3a", "#fa9f3a", - "#faa039", "#fba238", "#fba337", "#fba436", "#fca635", "#fca735", "#fca934", "#fcaa33", - "#fcac32", "#fcad31", "#fdaf31", "#fdb030", "#fdb22f", "#fdb32e", "#fdb52d", "#fdb62d", - "#fdb82c", "#fdb92b", "#fdbb2b", "#fdbc2a", "#fdbe29", "#fdc029", "#fdc128", "#fdc328", - "#fdc427", "#fdc626", "#fcc726", "#fcc926", "#fccb25", "#fccc25", "#fcce25", "#fbd024", - "#fbd124", "#fbd324", "#fad524", "#fad624", "#fad824", "#f9d924", "#f9db24", "#f8dd24", - "#f8df24", "#f7e024", "#f7e225", "#f6e425", "#f6e525", "#f5e726", "#f5e926", "#f4ea26", - "#f3ec26", "#f3ee26", "#f2f026", "#f2f126", "#f1f326", "#f0f525", "#f0f623", "#eff821", -]; - -/// Magma - perceptually uniform, colorblind-friendly (256 colors) -pub const MAGMA: &[&str] = &[ - "#000003", "#000004", "#000006", "#010007", "#010109", "#01010b", "#02020d", "#02020f", - "#030311", "#040313", "#040415", "#050417", "#060519", "#07051b", "#08061d", "#09071f", - "#0a0722", "#0b0824", "#0c0926", "#0d0a28", "#0e0a2a", "#0f0b2c", "#100c2f", "#110c31", - "#120d33", "#140d35", "#150e38", "#160e3a", "#170f3c", "#180f3f", "#1a1041", "#1b1044", - "#1c1046", "#1e1049", "#1f114b", "#20114d", "#221150", "#231152", "#251155", "#261157", - "#281159", "#2a115c", "#2b115e", "#2d1060", "#2f1062", "#301065", "#321067", "#341068", - "#350f6a", "#370f6c", "#390f6e", "#3b0f6f", "#3c0f71", "#3e0f72", "#400f73", "#420f74", - "#430f75", "#450f76", "#470f77", "#481078", "#4a1079", "#4b1079", "#4d117a", "#4f117b", - "#50127b", "#52127c", "#53137c", "#55137d", "#57147d", "#58157e", "#5a157e", "#5b167e", - "#5d177e", "#5e177f", "#60187f", "#61187f", "#63197f", "#651a80", "#661a80", "#681b80", - "#691c80", "#6b1c80", "#6c1d80", "#6e1e81", "#6f1e81", "#711f81", "#731f81", "#742081", - "#762181", "#772181", "#792281", "#7a2281", "#7c2381", "#7e2481", "#7f2481", "#812581", - "#822581", "#842681", "#852681", "#872781", "#892881", "#8a2881", "#8c2980", "#8d2980", - "#8f2a80", "#912a80", "#922b80", "#942b80", "#952c80", "#972c7f", "#992d7f", "#9a2d7f", - "#9c2e7f", "#9e2e7e", "#9f2f7e", "#a12f7e", "#a3307e", "#a4307d", "#a6317d", "#a7317d", - "#a9327c", "#ab337c", "#ac337b", "#ae347b", "#b0347b", "#b1357a", "#b3357a", "#b53679", - "#b63679", "#b83778", "#b93778", "#bb3877", "#bd3977", "#be3976", "#c03a75", "#c23a75", - "#c33b74", "#c53c74", "#c63c73", "#c83d72", "#ca3e72", "#cb3e71", "#cd3f70", "#ce4070", - "#d0416f", "#d1426e", "#d3426d", "#d4436d", "#d6446c", "#d7456b", "#d9466a", "#da4769", - "#dc4869", "#dd4968", "#de4a67", "#e04b66", "#e14c66", "#e24d65", "#e44e64", "#e55063", - "#e65162", "#e75262", "#e85461", "#ea5560", "#eb5660", "#ec585f", "#ed595f", "#ee5b5e", - "#ee5d5d", "#ef5e5d", "#f0605d", "#f1615c", "#f2635c", "#f3655c", "#f3675b", "#f4685b", - "#f56a5b", "#f56c5b", "#f66e5b", "#f6705b", "#f7715b", "#f7735c", "#f8755c", "#f8775c", - "#f9795c", "#f97b5d", "#f97d5d", "#fa7f5e", "#fa805e", "#fa825f", "#fb8460", "#fb8660", - "#fb8861", "#fb8a62", "#fc8c63", "#fc8e63", "#fc9064", "#fc9265", "#fc9366", "#fd9567", - "#fd9768", "#fd9969", "#fd9b6a", "#fd9d6b", "#fd9f6c", "#fda16e", "#fda26f", "#fda470", - "#fea671", "#fea873", "#feaa74", "#feac75", "#feae76", "#feaf78", "#feb179", "#feb37b", - "#feb57c", "#feb77d", "#feb97f", "#febb80", "#febc82", "#febe83", "#fec085", "#fec286", - "#fec488", "#fec689", "#fec78b", "#fec98d", "#fecb8e", "#fdcd90", "#fdcf92", "#fdd193", - "#fdd295", "#fdd497", "#fdd698", "#fdd89a", "#fdda9c", "#fddc9d", "#fddd9f", "#fddfa1", - "#fde1a3", "#fce3a5", "#fce5a6", "#fce6a8", "#fce8aa", "#fceaac", "#fcecae", "#fceeb0", - "#fcf0b1", "#fcf1b3", "#fcf3b5", "#fcf5b7", "#fbf7b9", "#fbf9bb", "#fbfabd", "#fbfcbf", -]; - -/// Inferno - perceptually uniform, colorblind-friendly (256 colors) -pub const INFERNO: &[&str] = &[ - "#000003", "#000004", "#000006", "#010007", "#010109", "#01010b", "#02010e", "#020210", - "#030212", "#040314", "#040316", "#050418", "#06041b", "#07051d", "#08061f", "#090621", - "#0a0723", "#0b0726", "#0d0828", "#0e082a", "#0f092d", "#10092f", "#120a32", "#130a34", - "#140b36", "#160b39", "#170b3b", "#190b3e", "#1a0b40", "#1c0c43", "#1d0c45", "#1f0c47", - "#200c4a", "#220b4c", "#240b4e", "#260b50", "#270b52", "#290b54", "#2b0a56", "#2d0a58", - "#2e0a5a", "#300a5c", "#32095d", "#34095f", "#350960", "#370961", "#390962", "#3b0964", - "#3c0965", "#3e0966", "#400966", "#410967", "#430a68", "#450a69", "#460a69", "#480b6a", - "#4a0b6a", "#4b0c6b", "#4d0c6b", "#4f0d6c", "#500d6c", "#520e6c", "#530e6d", "#550f6d", - "#570f6d", "#58106d", "#5a116d", "#5b116e", "#5d126e", "#5f126e", "#60136e", "#62146e", - "#63146e", "#65156e", "#66156e", "#68166e", "#6a176e", "#6b176e", "#6d186e", "#6e186e", - "#70196e", "#72196d", "#731a6d", "#751b6d", "#761b6d", "#781c6d", "#7a1c6d", "#7b1d6c", - "#7d1d6c", "#7e1e6c", "#801f6b", "#811f6b", "#83206b", "#85206a", "#86216a", "#88216a", - "#892269", "#8b2269", "#8d2369", "#8e2468", "#902468", "#912567", "#932567", "#952666", - "#962666", "#982765", "#992864", "#9b2864", "#9c2963", "#9e2963", "#a02a62", "#a12b61", - "#a32b61", "#a42c60", "#a62c5f", "#a72d5f", "#a92e5e", "#ab2e5d", "#ac2f5c", "#ae305b", - "#af315b", "#b1315a", "#b23259", "#b43358", "#b53357", "#b73456", "#b83556", "#ba3655", - "#bb3754", "#bd3753", "#be3852", "#bf3951", "#c13a50", "#c23b4f", "#c43c4e", "#c53d4d", - "#c73e4c", "#c83e4b", "#c93f4a", "#cb4049", "#cc4148", "#cd4247", "#cf4446", "#d04544", - "#d14643", "#d24742", "#d44841", "#d54940", "#d64a3f", "#d74b3e", "#d94d3d", "#da4e3b", - "#db4f3a", "#dc5039", "#dd5238", "#de5337", "#df5436", "#e05634", "#e25733", "#e35832", - "#e45a31", "#e55b30", "#e65c2e", "#e65e2d", "#e75f2c", "#e8612b", "#e9622a", "#ea6428", - "#eb6527", "#ec6726", "#ed6825", "#ed6a23", "#ee6c22", "#ef6d21", "#f06f1f", "#f0701e", - "#f1721d", "#f2741c", "#f2751a", "#f37719", "#f37918", "#f47a16", "#f57c15", "#f57e14", - "#f68012", "#f68111", "#f78310", "#f7850e", "#f8870d", "#f8880c", "#f88a0b", "#f98c09", - "#f98e08", "#f99008", "#fa9107", "#fa9306", "#fa9506", "#fa9706", "#fb9906", "#fb9b06", - "#fb9d06", "#fb9e07", "#fba007", "#fba208", "#fba40a", "#fba60b", "#fba80d", "#fbaa0e", - "#fbac10", "#fbae12", "#fbb014", "#fbb116", "#fbb318", "#fbb51a", "#fbb71c", "#fbb91e", - "#fabb21", "#fabd23", "#fabf25", "#fac128", "#f9c32a", "#f9c52c", "#f9c72f", "#f8c931", - "#f8cb34", "#f8cd37", "#f7cf3a", "#f7d13c", "#f6d33f", "#f6d542", "#f5d745", "#f5d948", - "#f4db4b", "#f4dc4f", "#f3de52", "#f3e056", "#f3e259", "#f2e45d", "#f2e660", "#f1e864", - "#f1e968", "#f1eb6c", "#f1ed70", "#f1ee74", "#f1f079", "#f1f27d", "#f2f381", "#f2f485", - "#f3f689", "#f4f78d", "#f5f891", "#f6fa95", "#f7fb99", "#f9fc9d", "#fafda0", "#fcfea4", -]; - -/// Cividis - perceptually uniform, optimized for color vision deficiency (256 colors) -/// Source: Nuñez, Anderton, Renslow (2018) -pub const CIVIDIS: &[&str] = &[ - "#00224d", "#00234f", "#002350", "#002452", "#002554", "#002655", "#002657", "#002759", - "#00285b", "#00285c", "#00295e", "#002a60", "#002a62", "#002b64", "#002c66", "#002c67", - "#002d69", "#002e6b", "#002f6d", "#002f6f", "#003070", "#003070", "#003170", "#003170", - "#043270", "#083370", "#0b3370", "#0e3470", "#11356f", "#14366f", "#16366f", "#18376f", - "#1a386f", "#1c386e", "#1d396e", "#1f3a6e", "#213b6e", "#223b6e", "#243c6e", "#253d6d", - "#273d6d", "#283e6d", "#2a3f6d", "#2b3f6d", "#2c406d", "#2e416c", "#2f426c", "#30426c", - "#31436c", "#32446c", "#34446c", "#35456c", "#36466c", "#37466c", "#38476c", "#39486c", - "#3a486b", "#3b496b", "#3d4a6b", "#3e4b6b", "#3f4b6b", "#404c6b", "#414d6b", "#424d6b", - "#434e6b", "#444f6b", "#454f6b", "#46506b", "#47516b", "#48516b", "#49526b", "#4a536b", - "#4b546c", "#4c546c", "#4d556c", "#4e566c", "#4e566c", "#4f576c", "#50586c", "#51586c", - "#52596c", "#535a6c", "#545a6c", "#555b6d", "#565c6d", "#575d6d", "#585d6d", "#595e6d", - "#595f6d", "#5a5f6d", "#5b606e", "#5c616e", "#5d616e", "#5e626e", "#5f636e", "#60646e", - "#61646f", "#61656f", "#62666f", "#63666f", "#64676f", "#656870", "#666970", "#676970", - "#686a70", "#686b71", "#696b71", "#6a6c71", "#6b6d71", "#6c6d72", "#6d6e72", "#6e6f72", - "#6e7073", "#6f7073", "#707173", "#717273", "#727374", "#737374", "#747475", "#747575", - "#757575", "#767676", "#777776", "#787876", "#797877", "#797977", "#7a7a77", "#7b7b77", - "#7c7b78", "#7d7c78", "#7e7d78", "#7f7d78", "#807e78", "#817f78", "#828078", "#838078", - "#848178", "#858278", "#858378", "#868378", "#878478", "#888578", "#898678", "#8a8678", - "#8b8778", "#8c8878", "#8d8978", "#8e8978", "#8f8a77", "#908b77", "#918c77", "#928c77", - "#938d77", "#948e77", "#958f77", "#968f77", "#979076", "#989176", "#999276", "#9a9376", - "#9b9376", "#9c9476", "#9d9575", "#9e9675", "#9f9675", "#a09775", "#a19874", "#a29974", - "#a39a74", "#a49a74", "#a59b73", "#a69c73", "#a79d73", "#a89e73", "#a99e72", "#aa9f72", - "#aba072", "#aca171", "#ada271", "#aea271", "#afa370", "#b0a470", "#b1a570", "#b2a66f", - "#b3a66f", "#b4a76f", "#b5a86e", "#b6a96e", "#b7aa6d", "#b8ab6d", "#b9ab6d", "#baac6c", - "#bbad6c", "#bcae6b", "#bdaf6b", "#beb06a", "#bfb06a", "#c1b169", "#c2b269", "#c3b368", - "#c4b468", "#c5b567", "#c6b567", "#c7b666", "#c8b765", "#c9b865", "#cab964", "#cbba64", - "#ccbb63", "#cdbc62", "#cebc62", "#cfbd61", "#d0be60", "#d2bf60", "#d3c05f", "#d4c15e", - "#d5c25e", "#d6c35d", "#d7c35c", "#d8c45b", "#d9c55a", "#dac65a", "#dbc759", "#dcc858", - "#dec957", "#dfca56", "#e0cb55", "#e1cc54", "#e2cc53", "#e3cd52", "#e4ce51", "#e5cf50", - "#e6d04f", "#e8d14e", "#e9d24d", "#ead34c", "#ebd44b", "#ecd54a", "#edd648", "#eed747", - "#efd846", "#f1d944", "#f2da43", "#f3da42", "#f4db40", "#f5dc3f", "#f6dd3d", "#f8de3b", - "#f9df3a", "#fae038", "#fbe136", "#fde234", "#fde333", "#fde534", "#fde636", "#fde737", -]; - -// ============================================================================= -// ColorBrewer Sequential Palettes (original 9-class) -// ============================================================================= - -/// ColorBrewer Blues (9-class) -pub const BLUES: &[&str] = &[ - "#f7fbff", "#deebf7", "#c6dbef", "#9ecae1", "#6baed6", "#4292c6", "#2171b5", "#08519c", - "#08306b", -]; - -/// ColorBrewer Greens (9-class) -pub const GREENS: &[&str] = &[ - "#f7fcf5", "#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476", "#41ab5d", "#238b45", "#006d2c", - "#00441b", -]; - -/// ColorBrewer Oranges (9-class) -pub const ORANGES: &[&str] = &[ - "#fff5eb", "#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c", "#f16913", "#d94801", "#a63603", - "#7f2704", -]; - -/// ColorBrewer Reds (9-class) -pub const REDS: &[&str] = &[ - "#fff5f0", "#fee0d2", "#fcbba1", "#fc9272", "#fb6a4a", "#ef3b2c", "#cb181d", "#a50f15", - "#67000d", -]; - -/// ColorBrewer Purples (9-class) -pub const PURPLES: &[&str] = &[ - "#fcfbfd", "#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8", "#807dba", "#6a51a3", "#54278f", - "#3f007d", -]; - -/// ColorBrewer Greys (9-class) -pub const GREYS: &[&str] = &[ - "#ffffff", "#f0f0f0", "#d9d9d9", "#bdbdbd", "#969696", "#737373", "#525252", "#252525", - "#000000", -]; - -/// ColorBrewer YlOrRd - Yellow-Orange-Red (9-class) -pub const YLORRD: &[&str] = &[ - "#ffffcc", "#ffeda0", "#fed976", "#feb24c", "#fd8d3c", "#fc4e2a", "#e31a1c", "#bd0026", - "#800026", -]; - -/// ColorBrewer YlOrBr - Yellow-Orange-Brown (9-class) -pub const YLORBR: &[&str] = &[ - "#ffffe5", "#fff7bc", "#fee391", "#fec44f", "#fe9929", "#ec7014", "#cc4c02", "#993404", - "#662506", -]; - -/// ColorBrewer YlGnBu - Yellow-Green-Blue (9-class) -pub const YLGNBU: &[&str] = &[ - "#ffffd9", "#edf8b1", "#c7e9b4", "#7fcdbb", "#41b6c4", "#1d91c0", "#225ea8", "#253494", - "#081d58", -]; - -/// ColorBrewer YlGn - Yellow-Green (9-class) -pub const YLGN: &[&str] = &[ - "#ffffe5", "#f7fcb9", "#d9f0a3", "#addd8e", "#78c679", "#41ab5d", "#238443", "#006837", - "#004529", -]; - -/// ColorBrewer PuRd - Purple-Red (9-class) -pub const PURD: &[&str] = &[ - "#f7f4f9", "#e7e1ef", "#d4b9da", "#c994c7", "#df65b0", "#e7298a", "#ce1256", "#980043", - "#67001f", -]; - -/// ColorBrewer PuBuGn - Purple-Blue-Green (9-class) -pub const PUBUGN: &[&str] = &[ - "#fff7fb", "#ece2f0", "#d0d1e6", "#a6bddb", "#67a9cf", "#3690c0", "#02818a", "#016c59", - "#014636", -]; - -/// ColorBrewer PuBu - Purple-Blue (9-class) -pub const PUBU: &[&str] = &[ - "#fff7fb", "#ece7f2", "#d0d1e6", "#a6bddb", "#74a9cf", "#3690c0", "#0570b0", "#045a8d", - "#023858", -]; - -/// ColorBrewer OrRd - Orange-Red (9-class) -pub const ORRD: &[&str] = &[ - "#fff7ec", "#fee8c8", "#fdd49e", "#fdbb84", "#fc8d59", "#ef6548", "#d7301f", "#b30000", - "#7f0000", -]; - -/// ColorBrewer GnBu - Green-Blue (9-class) -pub const GNBU: &[&str] = &[ - "#f7fcf0", "#e0f3db", "#ccebc5", "#a8ddb5", "#7bccc4", "#4eb3d3", "#2b8cbe", "#0868ac", - "#084081", -]; - -/// ColorBrewer BuPu - Blue-Purple (9-class) -pub const BUPU: &[&str] = &[ - "#f7fcfd", "#e0ecf4", "#bfd3e6", "#9ebcda", "#8c96c6", "#8c6bb1", "#88419d", "#810f7c", - "#4d004b", -]; - -/// ColorBrewer BuGn - Blue-Green (9-class) -pub const BUGN: &[&str] = &[ - "#f7fcfd", "#e5f5f9", "#ccece6", "#99d8c9", "#66c2a4", "#41ae76", "#238b45", "#006d2c", - "#00441b", -]; - -/// ColorBrewer RdPu - Red-Purple (9-class) -pub const RDPU: &[&str] = &[ - "#fff7f3", "#fde0dd", "#fcc5c0", "#fa9fb5", "#f768a1", "#dd3497", "#ae017e", "#7a0177", - "#49006a", -]; - -// ============================================================================= -// ColorBrewer Diverging Palettes (original 11-class) -// ============================================================================= - -/// ColorBrewer RdBu - Red-Blue diverging (11-class) -pub const RDBU: &[&str] = &[ - "#67001f", "#b2182b", "#d6604d", "#f4a582", "#fddbc7", "#f7f7f7", "#d1e5f0", "#92c5de", - "#4393c3", "#2166ac", "#053061", -]; - -/// ColorBrewer RdYlBu - Red-Yellow-Blue diverging (11-class) -pub const RDYLBU: &[&str] = &[ - "#a50026", "#d73027", "#f46d43", "#fdae61", "#fee090", "#ffffbf", "#e0f3f8", "#abd9e9", - "#74add1", "#4575b4", "#313695", -]; - -/// ColorBrewer RdYlGn - Red-Yellow-Green diverging (11-class) -pub const RDYLGN: &[&str] = &[ - "#a50026", "#d73027", "#f46d43", "#fdae61", "#fee08b", "#ffffbf", "#d9ef8b", "#a6d96a", - "#66bd63", "#1a9850", "#006837", -]; - -/// ColorBrewer Spectral - diverging (11-class) -pub const SPECTRAL: &[&str] = &[ - "#9e0142", "#d53e4f", "#f46d43", "#fdae61", "#fee08b", "#ffffbf", "#e6f598", "#abdda4", - "#66c2a5", "#3288bd", "#5e4fa2", -]; - -/// ColorBrewer BrBG - Brown-Blue-Green diverging (11-class) -pub const BRBG: &[&str] = &[ - "#543005", "#8c510a", "#bf812d", "#dfc27d", "#f6e8c3", "#f5f5f5", "#c7eae5", "#80cdc1", - "#35978f", "#01665e", "#003c30", -]; - -/// ColorBrewer PRGn - Purple-Green diverging (11-class) -pub const PRGN: &[&str] = &[ - "#40004b", "#762a83", "#9970ab", "#c2a5cf", "#e7d4e8", "#f7f7f7", "#d9f0d3", "#a6dba0", - "#5aae61", "#1b7837", "#00441b", -]; - -/// ColorBrewer PiYG - Pink-Yellow-Green diverging (11-class) -pub const PIYG: &[&str] = &[ - "#8e0152", "#c51b7d", "#de77ae", "#f1b6da", "#fde0ef", "#f7f7f7", "#e6f5d0", "#b8e186", - "#7fbc41", "#4d9221", "#276419", -]; - -/// ColorBrewer RdGy - Red-Grey diverging (11-class) -pub const RDGY: &[&str] = &[ - "#67001f", "#b2182b", "#d6604d", "#f4a582", "#fddbc7", "#ffffff", "#e0e0e0", "#bababa", - "#878787", "#4d4d4d", "#1a1a1a", -]; - -/// ColorBrewer PuOr - Purple-Orange diverging (11-class) -pub const PUOR: &[&str] = &[ - "#7f3b08", "#b35806", "#e08214", "#fdb863", "#fee0b6", "#f7f7f7", "#d8daeb", "#b2abd2", - "#8073ac", "#542788", "#2d004b", -]; - -// ============================================================================= -// Crameri Scientific Colour Maps (full 256 colors) -// Source: Fabio Crameri (https://www.fabiocrameri.ch/colourmaps/) -// ============================================================================= - -// NOTE: Crameri palettes are added below via include -// The palettes are: acton, bam, bamako, bamO, batlow, batlowK, batlowW, -// berlin, bilbao, broc, brocO, buda, bukavu, cork, corkO, davos, devon, -// fes, glasgow, grayC, hawaii, imola, lajolla, lapaz, lipari, lisbon, -// managua, navia, nuuk, oleron, oslo, roma, romaO, tofino, tokyo, turku, -// vanimo, vik, vikO - -/// Crameri acton (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const ACTON: &[&str] = &[ - "#260C3F", "#260D40", "#270E41", "#281143", "#2A1244", "#2B1345", "#2B1346", "#2C1547", - "#2C1748", "#2D184A", "#2E194B", "#301A4C", "#311B4C", "#321D4D", "#321E4E", "#331F50", - "#332052", "#342152", "#352253", "#352454", "#372555", "#382657", "#392658", "#392859", - "#3A2A59", "#3A2B5A", "#3B2C5B", "#3D2C5D", "#3E2D5E", "#3E2E5F", "#3F315F", "#3F3260", - "#403361", "#413363", "#413464", "#433565", "#443766", "#453866", "#453967", "#463968", - "#463B6A", "#473D6B", "#473E6C", "#483F6C", "#4A3F6D", "#4B406E", "#4B4170", "#4C4371", - "#4C4472", "#4D4572", "#4D4673", "#4E4674", "#504776", "#514877", "#514A78", "#524B79", - "#524C79", "#534C7A", "#534D7A", "#544E7B", "#55507D", "#57517E", "#57527F", "#58527F", - "#595380", "#595481", "#5A5583", "#5B5783", "#5D5884", "#5E5985", "#5F5985", "#5F5A85", - "#605A86", "#615B87", "#635D87", "#655D88", "#665E8A", "#665F8A", "#675F8A", "#685F8B", - "#6B5F8B", "#6C5F8C", "#6C608C", "#6D608C", "#6E608C", "#71608C", "#72618C", "#72618C", - "#73618C", "#74618C", "#77618C", "#78618C", "#79638C", "#79638C", "#7A638C", "#7D638D", - "#7E638D", "#7F638D", "#7F638D", "#80638D", "#83638D", "#84638D", "#85648D", "#85648D", - "#87648D", "#88648D", "#8A648D", "#8B648D", "#8C648D", "#8D648D", "#8E648D", "#91648E", - "#92648E", "#92658E", "#94658E", "#96658E", "#97658E", "#99658E", "#99658E", "#9A658E", - "#9D658E", "#9E658E", "#9F658E", "#A0668E", "#A1668E", "#A4668E", "#A56690", "#A66690", - "#A76690", "#AA6690", "#AB6690", "#AC6690", "#AD6690", "#AE6690", "#B16690", "#B26690", - "#B36691", "#B46791", "#B76791", "#B86791", "#B96791", "#BA6892", "#BD6892", "#BE6A92", - "#BF6A92", "#C06B92", "#C16B93", "#C36C93", "#C56C94", "#C56C96", "#C66D96", "#C76E97", - "#C97098", "#CA7199", "#CB7299", "#CC7299", "#CC739A", "#CD749B", "#CD779D", "#CE789E", - "#D0799F", "#D0799F", "#D17BA0", "#D27DA3", "#D27EA4", "#D27FA5", "#D280A5", "#D281A6", - "#D383A7", "#D385A9", "#D485AA", "#D486AB", "#D687AC", "#D68AAC", "#D68BAE", "#D78CB0", - "#D78DB1", "#D88EB2", "#D890B2", "#D892B3", "#D892B4", "#D893B6", "#D894B8", "#D997B8", - "#D998B9", "#DA99BA", "#DA9ABB", "#DA9BBD", "#DC9DBE", "#DC9FBF", "#DD9FBF", "#DDA0C1", - "#DDA3C3", "#DEA4C4", "#DEA5C5", "#DEA6C5", "#DFA7C6", "#DFA9C7", "#DFABC9", "#DFACCA", - "#DFACCB", "#E0ADCC", "#E0B0CC", "#E0B1CD", "#E1B2CE", "#E1B3D0", "#E1B4D1", "#E3B6D2", - "#E3B7D2", "#E3B8D3", "#E4B9D4", "#E4BAD6", "#E4BBD7", "#E5BED8", "#E5BFD8", "#E5BFD9", - "#E5C0DA", "#E5C1DC", "#E5C4DD", "#E5C5DE", "#E6C5DE", "#E6C6DF", "#E6C7DF", "#E7CAE0", - "#E7CBE1", "#E7CCE3", "#E7CCE4", "#E9CDE4", "#E9CEE5", "#E9D0E5", "#E9D1E6", "#EAD2E7", - "#EAD3E9", "#EAD4E9", "#EBD6EA", "#EBD7EB", "#EBD8EB", "#EBD8EC", "#EBD9EC", "#EBDAED", - "#EBDCEE", "#EBDDF0", "#ECDEF0", "#ECDFF1", "#ECDFF2", "#ECE0F2", "#EDE1F2", "#EDE3F3", - "#EDE4F4", "#EDE5F6", "#EEE5F6", "#EEE6F7", "#EEE7F8", "#EEE9F8", "#EEE9F8", "#F0EAF9", -]; - -/// Crameri bam (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BAM: &[&str] = &[ - "#65014B", "#66054D", "#6A0650", "#6C0A52", "#6D0C53", "#701057", "#721259", "#74135A", - "#77175D", "#79195F", "#7A1A60", "#7D1D63", "#7F1F66", "#802067", "#84226A", "#85256C", - "#87266D", "#8A2770", "#8C2A72", "#8D2C73", "#902D76", "#923078", "#923179", "#94337B", - "#97347E", "#99377F", "#9A3880", "#9D3983", "#9E3B85", "#9F3E86", "#A13F87", "#A4408A", - "#A5438C", "#A6448D", "#A9468E", "#AA4791", "#AC4892", "#AC4B93", "#AE4C96", "#B04D97", - "#B25099", "#B25299", "#B4539B", "#B6549D", "#B8579F", "#B8599F", "#B959A1", "#BB5BA3", - "#BD5EA5", "#BF5FA5", "#BF61A7", "#C064A9", "#C166AB", "#C467AC", "#C56AAD", "#C56CAE", - "#C76DB0", "#C970B2", "#CA72B2", "#CB73B4", "#CC76B6", "#CD78B7", "#CE79B8", "#D07BB9", - "#D17FBB", "#D280BD", "#D283BE", "#D385BF", "#D486C0", "#D68AC1", "#D78CC4", "#D88DC5", - "#D891C5", "#D992C7", "#DA94C9", "#DC97CA", "#DD99CB", "#DE9BCC", "#DF9ECD", "#DFA0CE", - "#DFA3D0", "#E0A5D1", "#E1A6D2", "#E3AAD3", "#E4ACD4", "#E4ADD6", "#E5B0D7", "#E5B2D8", - "#E6B4D9", "#E6B7DA", "#E7B8DC", "#E9BADD", "#EABDDE", "#EABFDF", "#EBC1DF", "#EBC4E0", - "#EBC5E1", "#ECC7E3", "#EDCAE4", "#EDCCE5", "#EECDE5", "#EED0E6", "#F0D1E7", "#F1D2E9", - "#F1D4E9", "#F2D7EA", "#F2D8EB", "#F2D9EB", "#F2DCEB", "#F2DDEC", "#F3DFED", "#F3DFED", - "#F4E1EE", "#F4E3EE", "#F4E4F0", "#F4E5F0", "#F6E6F0", "#F6E7F1", "#F6E9F1", "#F6EAF1", - "#F6EBF1", "#F6EBF1", "#F6ECF1", "#F6EDF1", "#F6EEF1", "#F6EEF1", "#F6F0F1", "#F6F1F1", - "#F6F1F0", "#F6F2F0", "#F4F2F0", "#F4F2EE", "#F4F2EE", "#F3F2ED", "#F3F2EC", "#F2F2EC", - "#F2F2EB", "#F2F2EB", "#F2F2EA", "#F1F2E9", "#F0F2E7", "#EEF2E5", "#EEF2E5", "#EDF2E4", - "#ECF2E1", "#EBF2DF", "#EBF1DF", "#EAF1DD", "#E7F0DA", "#E6F0D8", "#E5EED7", "#E5EED4", - "#E3EDD2", "#E1ECD1", "#DFEBCE", "#DFEBCC", "#DDEBCA", "#DCEAC6", "#D9E9C5", "#D8E7C1", - "#D6E6BF", "#D3E5BD", "#D2E4B9", "#D0E3B7", "#CDE1B3", "#CCE0B1", "#CADFAD", "#C7DEAB", - "#C5DDA7", "#C3DAA5", "#C0D9A1", "#BFD89F", "#BBD79A", "#B9D498", "#B7D294", "#B4D292", - "#B2D08E", "#B0CD8C", "#ACCC88", "#ABCB85", "#A7C983", "#A5C67F", "#A3C57D", "#A0C479", - "#9EC177", "#9BBF73", "#99BE72", "#97BB6E", "#93BA6C", "#92B86A", "#8EB767", "#8CB465", - "#8AB263", "#87B160", "#85B05E", "#83AD5B", "#80AC59", "#7FAA58", "#7BA755", "#79A653", - "#78A552", "#76A350", "#73A14E", "#729F4C", "#709E4B", "#6C9B48", "#6B9A47", "#689946", - "#669845", "#659643", "#649340", "#61923F", "#5F913E", "#5E903D", "#5B8D3B", "#598C39", - "#588B39", "#578A37", "#548735", "#528634", "#518533", "#508432", "#4D8131", "#4C802E", - "#4B7F2D", "#487E2C", "#467B2B", "#457A2A", "#447928", "#417826", "#3F7626", "#3E7425", - "#3D7222", "#3A7121", "#397020", "#376D1F", "#346C1E", "#336B1B", "#32681A", "#306719", - "#2D6618", "#2C6415", "#2A6314", "#276013", "#265F12", "#245D10", "#215B0D", "#1F590C", - "#1E580B", "#1A5508", "#195406", "#175206", "#145104", "#124E02", "#0E4C00", "#0C4C00", -]; - -/// Crameri bamako (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BAMAKO: &[&str] = &[ - "#003A46", "#003A46", "#003A46", "#003B46", "#013B46", "#013B46", "#023B46", "#023D45", - "#043D45", "#043D45", "#053E45", "#053E44", "#063E44", "#063F44", "#063F43", "#063F43", - "#073F43", "#073F41", "#083F41", "#083F41", "#0A4041", "#0A4040", "#0B4040", "#0B4140", - "#0C413F", "#0C413F", "#0C433F", "#0C433F", "#0D433F", "#0D443F", "#0E443E", "#0E443E", - "#10453E", "#10453D", "#11453D", "#12463D", "#12463B", "#13463B", "#13463B", "#13463A", - "#13473A", "#144739", "#154739", "#154839", "#174839", "#174A39", "#184A39", "#194A38", - "#194B38", "#194B37", "#1A4C37", "#1A4C37", "#1B4C35", "#1D4C35", "#1D4C34", "#1E4D34", - "#1F4D33", "#1F4E33", "#1F4E33", "#205033", "#215033", "#215132", "#225132", "#245231", - "#255231", "#265230", "#265230", "#26532E", "#27532E", "#28542D", "#28542D", "#2A552C", - "#2B552C", "#2C572C", "#2C572C", "#2D582B", "#2D582B", "#2E592A", "#30592A", "#315928", - "#325A28", "#335A27", "#335B26", "#345B26", "#345D26", "#355E26", "#375E25", "#385F25", - "#395F24", "#395F22", "#3A6022", "#3B6021", "#3D6121", "#3E6120", "#3F6320", "#3F641F", - "#40641F", "#41651F", "#43661E", "#44661E", "#45661D", "#46671B", "#46671B", "#47681A", - "#486819", "#4A6A19", "#4B6B19", "#4C6B18", "#4C6C18", "#4D6C17", "#4E6D17", "#506D15", - "#516E14", "#527014", "#527013", "#537113", "#547213", "#557212", "#587211", "#597311", - "#597310", "#5A740E", "#5B760E", "#5D770D", "#5E770C", "#5F780C", "#5F790C", "#60790B", - "#63790A", "#647A0A", "#657B08", "#667B07", "#667D06", "#677E06", "#687E06", "#6A7F05", - "#6B7F05", "#6C7F04", "#6D8004", "#6E8102", "#708102", "#718301", "#728301", "#738400", - "#748400", "#778500", "#788500", "#798500", "#7A8500", "#7B8500", "#7D8600", "#7F8600", - "#7F8600", "#808700", "#838700", "#848700", "#858700", "#868800", "#878800", "#888800", - "#8B8800", "#8C8A00", "#8C8A00", "#8E8A00", "#908B00", "#918B00", "#928B00", "#938C00", - "#948C00", "#978C01", "#988C02", "#998D02", "#9A8D04", "#9B8E05", "#9D9006", "#9E9107", - "#9F9208", "#A0920B", "#A1920C", "#A4930D", "#A59410", "#A59611", "#A79713", "#A99814", - "#AA9915", "#AC9918", "#AC9A19", "#AD9B1A", "#B09D1D", "#B19E1F", "#B29F1F", "#B39F21", - "#B4A024", "#B7A125", "#B8A326", "#B8A528", "#BAA52B", "#BBA62C", "#BEA72D", "#BFA930", - "#BFAA32", "#C1AB33", "#C3AC34", "#C5AC37", "#C5AD39", "#C6AE3A", "#C9B03D", "#CAB23F", - "#CBB240", "#CCB344", "#CDB446", "#CEB647", "#D0B74A", "#D2B84C", "#D2B84E", "#D3BA51", - "#D4BB53", "#D6BD55", "#D7BE59", "#D8BF5A", "#D9BF5D", "#DAC05F", "#DCC161", "#DDC464", - "#DEC566", "#DFC568", "#DFC66C", "#E1C76D", "#E3C970", "#E4CA72", "#E5CB74", "#E5CC77", - "#E6CC79", "#E7CE7B", "#E9D07E", "#EAD180", "#EBD283", "#EBD285", "#ECD387", "#EED48A", - "#F0D68C", "#F1D78D", "#F2D891", "#F2D892", "#F3D994", "#F4DA97", "#F6DC99", "#F7DD9B", - "#F8DE9E", "#F8DF9F", "#F9DFA3", "#FAE0A5", "#FCE1A6", "#FDE3A9", "#FEE4AB", "#FFE5AC", -]; - -/// Crameri bamO (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BAMO: &[&str] = &[ - "#4E3043", "#502E45", "#512E46", "#523046", "#533048", "#55304B", "#58304C", "#59314D", - "#5B3250", "#5E3252", "#5F3353", "#613357", "#643459", "#66355A", "#68375D", "#6B385F", - "#6C3960", "#703963", "#723A65", "#733D66", "#763E68", "#793F6B", "#7A406C", "#7D416E", - "#7F4471", "#804572", "#834674", "#854777", "#864879", "#884B7A", "#8B4C7D", "#8C4D7E", - "#8E4E7F", "#915181", "#925284", "#935385", "#965486", "#985788", "#99588A", "#9A598C", - "#9D5A8C", "#9F5D8E", "#9F5E91", "#A15F92", "#A36093", "#A56394", "#A66597", "#A76698", - "#AA6799", "#AB689A", "#AC6B9D", "#AD6C9E", "#B06D9F", "#B170A0", "#B272A3", "#B473A4", - "#B676A5", "#B877A6", "#B879A9", "#BA7AAA", "#BB7DAC", "#BE7FAC", "#BF80AE", "#BF83B0", - "#C185B2", "#C386B2", "#C588B4", "#C58BB6", "#C68CB8", "#C98EB8", "#CA92B9", "#CB93BB", - "#CC96BD", "#CC98BE", "#CE99BF", "#D09BC0", "#D19EC1", "#D29FC3", "#D2A3C4", "#D2A5C5", - "#D3A6C5", "#D4A9C6", "#D6ABC6", "#D6ACC7", "#D7ADC9", "#D7B0C9", "#D8B2CA", "#D8B3CA", - "#D8B4CB", "#D8B7CB", "#D8B8CB", "#D8B9CC", "#D9BACC", "#D9BBCC", "#D9BECC", "#D9BFCC", - "#D9BFCC", "#D9C0CC", "#D9C1CC", "#D9C3CC", "#D9C4CB", "#D8C5CB", "#D8C5CB", "#D8C5CB", - "#D8C6CA", "#D8C6CA", "#D8C7CA", "#D8C9C9", "#D8C9C9", "#D8CAC7", "#D7CAC7", "#D7CAC6", - "#D7CBC6", "#D6CBC6", "#D6CBC5", "#D6CCC5", "#D4CCC5", "#D4CCC4", "#D3CCC4", "#D3CCC3", - "#D3CCC1", "#D2CCC1", "#D2CCC0", "#D2CCBF", "#D1CCBF", "#D1CCBE", "#D0CCBD", "#CECCBB", - "#CDCCBA", "#CCCCB9", "#CCCCB8", "#CBCCB7", "#CACCB6", "#C9CCB3", "#C7CBB2", "#C5CBB1", - "#C5CAAE", "#C3C9AC", "#C1C7AA", "#BFC6A7", "#BEC5A5", "#BBC5A4", "#B9C4A0", "#B8C19E", - "#B6C09B", "#B3BF99", "#B2BE97", "#B0BB93", "#ACB991", "#ABB88E", "#A9B68C", "#A6B48A", - "#A4B286", "#A1B185", "#9FAE83", "#9EAC7F", "#9BAB7E", "#99A97B", "#98A679", "#96A578", - "#93A376", "#92A073", "#919F72", "#8E9E70", "#8C9B6D", "#8B996C", "#8A986B", "#879668", - "#859367", "#859266", "#839165", "#818E63", "#7F8C61", "#7F8C60", "#7D8A5F", "#7B875E", - "#7A865D", "#79855B", "#78835A", "#778159", "#747F58", "#737F57", "#727D55", "#727B54", - "#717953", "#6E7952", "#6D7752", "#6C7452", "#6C7351", "#6B7250", "#6A714E", "#686E4D", - "#666D4C", "#666C4C", "#656B4C", "#64684B", "#63674A", "#616648", "#606547", "#5F6347", - "#5F6146", "#5E5F46", "#5D5F46", "#5B5D45", "#5A5B44", "#595A44", "#595943", "#585841", - "#575541", "#575440", "#555340", "#54523F", "#53513F", "#53503F", "#524E3F", "#524D3F", - "#514C3E", "#514C3E", "#504B3D", "#50483D", "#4E473D", "#4E473B", "#4D463B", "#4D463B", - "#4C453B", "#4C443A", "#4C433A", "#4C413A", "#4B403A", "#4B3F3A", "#4B3F3A", "#4A3F39", - "#4A3E39", "#4A3D39", "#4A3B39", "#483B39", "#483A39", "#483939", "#483939", "#483939", - "#483839", "#473739", "#47353A", "#47353A", "#47343A", "#48333A", "#48333B", "#48333B", - "#48323D", "#4A323D", "#4A313E", "#4B313E", "#4B313F", "#4C303F", "#4C3040", "#4D3041", -]; - -/// Crameri batlow (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BATLOW: &[&str] = &[ - "#001959", "#011A59", "#021B59", "#041E59", "#051F59", "#06205A", "#06215A", "#06245A", - "#07255A", "#08265B", "#0A275B", "#0A2A5B", "#0B2B5B", "#0B2C5D", "#0C2D5D", "#0C2E5D", - "#0C315D", "#0C325E", "#0C335E", "#0D345E", "#0D355E", "#0D375E", "#0E385F", "#0E395F", - "#0E3A5F", "#0E3B5F", "#103D5F", "#103E5F", "#103F5F", "#103F5F", "#11405F", "#11415F", - "#11435F", "#11445F", "#124560", "#124660", "#124660", "#124760", "#134860", "#134A60", - "#134B60", "#134C61", "#134C61", "#134D61", "#144E61", "#144E61", "#155061", "#155161", - "#175261", "#175261", "#185361", "#185461", "#195561", "#195761", "#195761", "#1A5861", - "#1A5961", "#1B5961", "#1D5A61", "#1E5B61", "#1E5D61", "#1F5D60", "#1F5E60", "#205F60", - "#215F60", "#225F5F", "#24605F", "#25615F", "#26635F", "#26635F", "#27645F", "#2A655E", - "#2B655E", "#2C665D", "#2C665D", "#2E665B", "#30675B", "#31685A", "#336859", "#336A59", - "#346A59", "#376B58", "#386C58", "#396C57", "#3A6C55", "#3B6C55", "#3E6D54", "#3F6D53", - "#406E52", "#416E52", "#447052", "#457051", "#467150", "#47714E", "#4A724D", "#4C724C", - "#4C724C", "#4E724C", "#50734B", "#52734A", "#527448", "#547447", "#577646", "#587646", - "#597745", "#5A7745", "#5D7844", "#5F7843", "#5F7941", "#617940", "#63793F", "#65793F", - "#667A3E", "#677A3E", "#6A7A3D", "#6C7B3B", "#6C7B3A", "#6E7D39", "#717D39", "#727E38", - "#737E38", "#767F37", "#787F35", "#797F34", "#7A7F33", "#7D8033", "#7F8033", "#808132", - "#818131", "#848331", "#858330", "#87842E", "#8A842E", "#8C852D", "#8D852D", "#8E852C", - "#91852C", "#92862C", "#94862C", "#97872C", "#99872C", "#9A882B", "#9D882B", "#9F882B", - "#A08A2B", "#A38A2C", "#A58B2C", "#A68B2C", "#A98C2C", "#AB8C2C", "#AC8C2C", "#AE8C2D", - "#B18C2E", "#B28D2E", "#B48D30", "#B78D31", "#B88E32", "#BA8E33", "#BD8E33", "#BE9034", - "#BF9035", "#C19037", "#C49138", "#C59139", "#C7913A", "#CA923B", "#CB923E", "#CC923F", - "#CE923F", "#D19241", "#D29243", "#D39345", "#D69346", "#D89347", "#D8944A", "#DA944B", - "#DD944C", "#DE964E", "#DF9651", "#E09752", "#E39753", "#E49755", "#E59858", "#E69859", - "#E9995B", "#EA995E", "#EB995F", "#EC9961", "#ED9A64", "#EE9A66", "#F09B68", "#F19D6B", - "#F29D6C", "#F29E70", "#F39F72", "#F49F73", "#F69F77", "#F7A079", "#F8A07A", "#F8A17E", - "#F8A37F", "#F8A381", "#F9A485", "#F9A586", "#FAA588", "#FAA58C", "#FCA68D", "#FCA790", - "#FCA992", "#FCA994", "#FDAA97", "#FDAB99", "#FDAC9B", "#FDAC9E", "#FDAC9F", "#FDADA1", - "#FDAEA5", "#FDAEA6", "#FDB0A9", "#FDB1AB", "#FDB2AC", "#FDB2AE", "#FDB2B1", "#FDB3B3", - "#FDB3B6", "#FDB4B8", "#FDB6B9", "#FDB7BB", "#FDB7BE", "#FDB8BF", "#FDB8C1", "#FDB9C4", - "#FDB9C6", "#FDBAC9", "#FDBBCB", "#FDBBCC", "#FCBDCE", "#FCBED1", "#FCBFD2", "#FCBFD6", - "#FCBFD8", "#FCC0D9", "#FCC1DC", "#FCC3DF", "#FCC3E0", "#FCC4E3", "#FCC5E5", "#FAC5E7", - "#FAC5EA", "#FAC6EB", "#FAC7EE", "#FAC9F1", "#FACAF2", "#FACAF6", "#F9CBF8", "#F9CCF9", -]; - -/// Crameri batlowK (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BATLOWK: &[&str] = &[ - "#04050A", "#05060C", "#06070E", "#060A11", "#080C13", "#0A0C14", "#0B0E17", "#0C1019", - "#0C1119", "#0D121B", "#0E131E", "#0E141F", "#101520", "#101722", "#111825", "#111926", - "#121A28", "#121B2B", "#131E2C", "#131F2E", "#131F31", "#142133", "#142234", "#152537", - "#172639", "#17263A", "#18283D", "#192B3F", "#192C40", "#192D43", "#1A2E45", "#1B3146", - "#1D3247", "#1E334A", "#1F344B", "#1F374C", "#20384E", "#213950", "#223A51", "#243D52", - "#253E53", "#263F54", "#264055", "#274357", "#284458", "#2A4559", "#2B4659", "#2C475A", - "#2C485A", "#2D4A5B", "#2E4B5D", "#304C5D", "#314D5E", "#324E5E", "#33505E", "#33515F", - "#34525F", "#35525F", "#37535F", "#38545F", "#39545F", "#39555F", "#3A575F", "#3B585F", - "#3D595F", "#3E595F", "#3E595F", "#3F5A5E", "#3F5B5E", "#405B5E", "#415D5E", "#435D5D", - "#435E5D", "#445F5D", "#455F5B", "#465F5B", "#465F5B", "#47605A", "#48615A", "#486159", - "#4A6359", "#4B6359", "#4C6459", "#4C6558", "#4D6558", "#4E6657", "#506657", "#516655", - "#526655", "#526754", "#536854", "#546853", "#556A52", "#576A52", "#586B52", "#596C51", - "#596C51", "#5A6C50", "#5B6D4E", "#5E6D4E", "#5F6E4D", "#5F704C", "#60704C", "#61714C", - "#63724B", "#65724B", "#66724A", "#667348", "#687348", "#6A7447", "#6B7646", "#6C7646", - "#6D7746", "#6E7845", "#717845", "#727944", "#727943", "#747A43", "#767A41", "#787B40", - "#797D40", "#7A7E3F", "#7D7E3F", "#7E7F3F", "#7F7F3E", "#80803E", "#83803D", "#85813D", - "#85833B", "#87843B", "#8A853A", "#8C853A", "#8D8539", "#8E8639", "#918739", "#928739", - "#948839", "#978A39", "#998B38", "#9A8B38", "#9D8C38", "#9F8C38", "#A08D38", "#A38D38", - "#A58E38", "#A69038", "#A99139", "#AB9139", "#AC9239", "#AE9239", "#B19239", "#B2933A", - "#B4943A", "#B7943B", "#B9963D", "#BB963D", "#BE973E", "#BF983F", "#C1983F", "#C49940", - "#C59941", "#C79943", "#C99945", "#CB9A46", "#CC9A46", "#CE9B48", "#D19B4A", "#D29D4C", - "#D39D4C", "#D69D4E", "#D89E50", "#D89E52", "#DA9F53", "#DC9F54", "#DE9F57", "#DF9F59", - "#E09F5A", "#E1A05D", "#E4A05F", "#E5A05F", "#E5A161", "#E6A164", "#E7A366", "#EAA367", - "#EBA36A", "#EBA46C", "#ECA46D", "#EDA570", "#EEA572", "#F0A573", "#F0A576", "#F1A578", - "#F2A679", "#F2A67B", "#F2A67E", "#F3A77F", "#F4A781", "#F4A984", "#F6A985", "#F6AA87", - "#F7AA8B", "#F7AB8C", "#F8AB8E", "#F8AC91", "#F8AC92", "#F8AC93", "#F9AC96", "#F9AD98", - "#F9AD99", "#FAAE9B", "#FAAE9E", "#FAB09F", "#FAB0A1", "#FCB1A4", "#FCB1A5", "#FCB2A7", - "#FCB2AA", "#FCB2AC", "#FCB2AD", "#FDB3B0", "#FDB4B2", "#FDB4B3", "#FDB6B6", "#FDB6B7", - "#FDB7B8", "#FDB7BA", "#FDB8BD", "#FDB8BF", "#FDB8C0", "#FDB9C3", "#FDBAC5", "#FDBAC6", - "#FDBBC9", "#FDBBCB", "#FDBDCC", "#FDBECE", "#FDBED1", "#FDBFD2", "#FDBFD6", "#FDC0D8", - "#FDC0D9", "#FDC1DC", "#FDC3DE", "#FDC3DF", "#FCC4E1", "#FCC5E4", "#FCC5E6", "#FCC5E9", - "#FCC6EB", "#FCC7EC", "#FAC7EE", "#FAC9F1", "#FACAF3", "#FACBF6", "#FACBF8", "#F9CCF9", -]; - -/// Crameri batlowW (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BATLOWW: &[&str] = &[ - "#001959", "#011A59", "#021B59", "#041E59", "#051F59", "#06205A", "#06215A", "#06245A", - "#07255A", "#08265B", "#0A275B", "#0A2A5B", "#0B2B5B", "#0B2C5D", "#0C2D5D", "#0C2E5D", - "#0C315D", "#0C325E", "#0C335E", "#0D345E", "#0D355E", "#0D375E", "#0E385F", "#0E395F", - "#0E3A5F", "#0E3B5F", "#103D5F", "#103E5F", "#103F5F", "#103F5F", "#10405F", "#11415F", - "#11435F", "#11445F", "#114560", "#124660", "#124660", "#124660", "#124760", "#134860", - "#134A60", "#134B60", "#134C61", "#134C61", "#134D61", "#144E61", "#145061", "#155061", - "#155161", "#155261", "#175261", "#175361", "#185461", "#185561", "#195761", "#195861", - "#195861", "#1A5961", "#1B5961", "#1B5A61", "#1D5B61", "#1E5D61", "#1F5D61", "#1F5E61", - "#1F5F60", "#205F60", "#216060", "#226060", "#246160", "#25635F", "#26645F", "#26645F", - "#27655F", "#2A665F", "#2B665E", "#2C665E", "#2C675E", "#2D685D", "#30685D", "#316A5B", - "#326B5B", "#336B5A", "#346C59", "#356C59", "#386C59", "#396D59", "#396D58", "#3B6E57", - "#3D7055", "#3F7055", "#3F7154", "#417153", "#437253", "#457252", "#467252", "#477351", - "#487351", "#4B7450", "#4C744E", "#4D764D", "#4E774C", "#51774C", "#52784C", "#53784B", - "#55794A", "#577948", "#597948", "#5A7A47", "#5B7A46", "#5E7B46", "#5F7B45", "#607D45", - "#637E44", "#647E43", "#667F41", "#677F40", "#687F3F", "#6B803F", "#6C803F", "#6D813E", - "#70813D", "#72833D", "#73843B", "#74843A", "#778539", "#798539", "#7A8539", "#7B8638", - "#7E8637", "#7F8737", "#818835", "#848834", "#858A34", "#878B33", "#8A8B33", "#8C8C33", - "#8D8C33", "#908C33", "#928D32", "#938E32", "#968E32", "#989032", "#999132", "#9B9232", - "#9E9233", "#9F9233", "#A19333", "#A49333", "#A69434", "#A99635", "#AB9737", "#AC9738", - "#AE9839", "#B19939", "#B2993A", "#B4993D", "#B79A3F", "#B99A3F", "#BA9B41", "#BD9D44", - "#BF9D46", "#C09E47", "#C39F4A", "#C59F4C", "#C59F4D", "#C79F50", "#CAA052", "#CBA053", - "#CCA155", "#CDA158", "#D0A359", "#D1A35B", "#D2A45E", "#D3A45F", "#D4A561", "#D7A564", - "#D8A566", "#D8A568", "#D9A56B", "#DAA66C", "#DCA66E", "#DDA670", "#DFA772", "#DFA773", - "#E0A976", "#E1A978", "#E3A979", "#E4AA7B", "#E5AA7E", "#E5AB7F", "#E5AB81", "#E6AB84", - "#E7AC85", "#E9AC87", "#EAAC88", "#EBAC8B", "#EBAD8C", "#ECAE8E", "#EDAE91", "#EDB093", - "#EEB196", "#F0B198", "#F1B299", "#F2B29B", "#F2B39E", "#F2B4A0", "#F3B6A3", "#F4B7A5", - "#F6B8A7", "#F7B9AA", "#F7BAAC", "#F8BDAE", "#F8BEB2", "#F8BFB4", "#F9C0B7", "#F9C3B9", - "#FAC5BB", "#FCC5BF", "#FCC7C0", "#FCCAC4", "#FDCCC5", "#FDCCC9", "#FDCECB", "#FED1CC", - "#FED2CE", "#FED4D2", "#FED6D3", "#FED8D6", "#FFD9D8", "#FFDAD9", "#FFDDDA", "#FFDFDD", - "#FFDFDF", "#FFE1E0", "#FFE3E1", "#FFE5E4", "#FFE5E5", "#FFE7E6", "#FFE9E9", "#FFEAEA", - "#FFEBEB", "#FFECEC", "#FFEEED", "#FFF0F0", "#FFF1F1", "#FFF2F2", "#FFF3F2", "#FFF4F4", - "#FFF6F6", "#FFF7F7", "#FFF8F8", "#FFF9F8", "#FFFAFA", "#FFFCFC", "#FFFDFD", "#FFFEFE", -]; - -/// Crameri berlin (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BERLIN: &[&str] = &[ - "#9EB0FF", "#9BB0FE", "#99B0FD", "#98AEFC", "#94AEFA", "#92AEF9", "#91ADF8", "#8DADF7", - "#8CADF6", "#8AADF4", "#86ACF3", "#85ACF2", "#81ACF2", "#7FACF1", "#7EACF0", "#7AACED", - "#79ABEC", "#76ABEB", "#73AAEB", "#71AAE9", "#6EA9E7", "#6CA9E5", "#6AA7E5", "#66A7E3", - "#65A6E1", "#61A5DF", "#5FA5DF", "#5DA5DD", "#5AA4DA", "#58A3D8", "#55A1D7", "#539FD4", - "#519FD2", "#4E9ED1", "#4C9DCE", "#4B9ACC", "#4799CA", "#4698C7", "#4497C5", "#4394C3", - "#4093C0", "#3F92BE", "#3E90BB", "#3B8DB8", "#3A8CB7", "#398BB3", "#3888B2", "#3786AE", - "#3585AC", "#3484AA", "#3381A7", "#327FA5", "#327EA3", "#317BA0", "#30799E", "#2E789B", - "#2D7699", "#2C7497", "#2C7293", "#2C7192", "#2B6E8E", "#2A6C8C", "#286B8B", "#286887", - "#276785", "#266683", "#266480", "#26617F", "#255F7B", "#245E79", "#245D78", "#225A74", - "#215972", "#215771", "#20546D", "#1F536C", "#1F526A", "#1F5067", "#1E4D65", "#1E4C63", - "#1D4B60", "#1B485F", "#1B465B", "#1A4659", "#194458", "#194155", "#194052", "#193F51", - "#183D4E", "#173B4C", "#17394B", "#153848", "#153746", "#143444", "#143341", "#13323F", - "#13303E", "#132E3B", "#132C39", "#122C38", "#122A35", "#122833", "#112632", "#112630", - "#11242D", "#11222C", "#11202A", "#111F27", "#101F26", "#101D25", "#101B22", "#111A20", - "#11191F", "#11191E", "#11181B", "#11151A", "#111419", "#111318", "#111317", "#121214", - "#121213", "#131112", "#131011", "#130E10", "#140D0D", "#150D0C", "#170C0B", "#180C0A", - "#190C08", "#190C07", "#1A0B06", "#1B0B06", "#1D0B05", "#1E0B04", "#1F0B04", "#200B02", - "#210C01", "#220C01", "#240C01", "#250C00", "#260C00", "#260C00", "#270C00", "#2A0D00", - "#2B0D00", "#2C0D00", "#2C0D00", "#2E0D00", "#300E00", "#310E00", "#330E00", "#330E00", - "#341000", "#371000", "#381000", "#391100", "#3A1100", "#3B1100", "#3E1200", "#3F1200", - "#401200", "#411300", "#441300", "#451300", "#461300", "#471401", "#4A1401", "#4B1501", - "#4C1501", "#4E1702", "#501802", "#521804", "#531905", "#551905", "#571A06", "#591B06", - "#5A1D07", "#5D1E08", "#5F1F0A", "#601F0B", "#63200C", "#65220D", "#67240E", "#6A2510", - "#6C2611", "#6D2713", "#702A13", "#722B15", "#742C17", "#772E19", "#79301A", "#7A321B", - "#7D331E", "#7F351F", "#813721", "#843924", "#853A26", "#873D27", "#8A3F2A", "#8C3F2C", - "#8D412D", "#904430", "#924632", "#934733", "#964A35", "#984C39", "#994C3A", "#9B4E3D", - "#9E513F", "#9F5240", "#A15444", "#A45746", "#A55947", "#A7594A", "#AA5B4C", "#AC5E4E", - "#AD5F51", "#B06152", "#B26454", "#B36658", "#B66759", "#B86A5B", "#B96B5F", "#BB6C60", - "#BE6E63", "#BF7165", "#C17267", "#C4746A", "#C5776C", "#C7796E", "#CA7A71", "#CC7D72", - "#CD7F76", "#D08078", "#D28379", "#D4857D", "#D7867F", "#D88881", "#DA8B84", "#DD8C85", - "#DF8E88", "#E0918B", "#E3928D", "#E59490", "#E69792", "#EA9994", "#EB9A97", "#ED9D99", - "#F09F9B", "#F2A09F", "#F3A3A0", "#F6A5A3", "#F8A6A5", "#FAA9A7", "#FDABAB", "#FFACAC", -]; - -/// Crameri bilbao (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BILBAO: &[&str] = &[ - "#4C0000", "#4D0002", "#4E0105", "#510406", "#520508", "#53060B", "#54070C", "#570A0D", - "#580B10", "#590C11", "#5A0D13", "#5D1013", "#5E1114", "#5F1315", "#601318", "#611419", - "#641519", "#65181A", "#66191D", "#67191E", "#681A1F", "#6B1D1F", "#6C1E21", "#6C1F22", - "#6D1F24", "#702026", "#712126", "#722227", "#722528", "#74262B", "#76262C", "#77272C", - "#78282E", "#792A30", "#7A2B31", "#7B2C32", "#7D2D33", "#7E2E34", "#7F3035", "#803137", - "#813239", "#833339", "#84333A", "#85343B", "#86373E", "#87383F", "#88393F", "#8A3940", - "#8B3A41", "#8C3B43", "#8C3D45", "#8D3F46", "#8E3F46", "#904047", "#914148", "#92434A", - "#92454B", "#93464B", "#94464C", "#94474C", "#96484D", "#974A4E", "#984C4E", "#984C50", - "#994D51", "#994E51", "#995052", "#995152", "#9A5252", "#9A5252", "#9B5352", "#9B5453", - "#9D5553", "#9D5753", "#9D5854", "#9E5954", "#9E5954", "#9E5A55", "#9E5B55", "#9F5D55", - "#9F5E55", "#9F5E55", "#9F5F57", "#9F5F57", "#9F6057", "#9F6157", "#9F6357", "#A06358", - "#A06458", "#A06558", "#A06658", "#A06658", "#A16659", "#A16759", "#A16859", "#A16A59", - "#A36B59", "#A36B59", "#A36C59", "#A36C59", "#A36D59", "#A46D59", "#A46E59", "#A47059", - "#A4715A", "#A4715A", "#A5725A", "#A5725A", "#A5735A", "#A5735A", "#A5745A", "#A5765B", - "#A5765B", "#A5775B", "#A5785B", "#A5795B", "#A6795B", "#A6795D", "#A67A5D", "#A67B5D", - "#A67B5D", "#A77D5D", "#A77E5D", "#A77E5E", "#A77F5E", "#A77F5E", "#A9805E", "#A9805E", - "#A9815E", "#A9835E", "#A9845F", "#AA845F", "#AA855F", "#AA855F", "#AA855F", "#AA865F", - "#AB875F", "#AB885F", "#AB885F", "#AB8A5F", "#AB8B5F", "#AC8C5F", "#AC8C60", "#AC8C60", - "#AC8D60", "#AC8E60", "#AC9060", "#AC9161", "#AC9161", "#AC9261", "#AD9261", "#AD9363", - "#AD9463", "#AD9663", "#AE9664", "#AE9764", "#AE9864", "#AE9965", "#B09966", "#B09A66", - "#B19B66", "#B19D67", "#B19E67", "#B29F68", "#B29F6A", "#B2A06B", "#B2A16C", "#B2A36D", - "#B3A46E", "#B3A571", "#B4A672", "#B4A773", "#B6A974", "#B6AA77", "#B7AB79", "#B7AC79", - "#B8AC7B", "#B8AC7E", "#B8AD7F", "#B8AE81", "#B9B083", "#B9B185", "#BAB286", "#BAB288", - "#BBB28B", "#BBB38C", "#BDB48D", "#BDB490", "#BEB692", "#BEB793", "#BFB794", "#BFB897", - "#BFB899", "#BFB899", "#BFB99B", "#C0BA9E", "#C0BA9F", "#C0BBA0", "#C1BBA3", "#C1BDA4", - "#C3BEA5", "#C3BEA6", "#C3BFA9", "#C4BFAA", "#C4BFAC", "#C5C0AC", "#C5C0AE", "#C5C1B0", - "#C5C1B2", "#C5C3B2", "#C6C3B3", "#C6C4B6", "#C6C5B7", "#C7C5B8", "#C7C5B9", "#C9C6BB", - "#CAC6BD", "#CAC7BE", "#CBC9BF", "#CBCAC0", "#CCCAC3", "#CCCBC4", "#CDCCC5", "#CDCCC6", - "#CECDC9", "#D0CECB", "#D1D0CC", "#D2D2CD", "#D3D2CE", "#D4D3D1", "#D6D4D2", "#D7D7D3", - "#D8D8D6", "#D9D9D8", "#DADAD9", "#DDDDDA", "#DEDEDD", "#DFDFDF", "#E0E0E0", "#E3E3E1", - "#E5E5E4", "#E5E5E5", "#E7E7E7", "#EAEAE9", "#EBEBEB", "#ECECEC", "#EEEEEE", "#F1F1F0", - "#F2F2F2", "#F3F3F3", "#F6F6F6", "#F8F8F8", "#F9F9F9", "#FAFAFA", "#FDFDFD", "#FFFFFF", -]; - -/// Crameri broc (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BROC: &[&str] = &[ - "#2C194C", "#2C1A4D", "#2C1D4E", "#2B1E51", "#2B1F52", "#2B2053", "#2B2254", "#2B2457", - "#2B2658", "#2B2659", "#2B285A", "#2B2A5D", "#2A2C5E", "#2A2C5F", "#2A2D60", "#2A3063", - "#2A3164", "#2A3366", "#283466", "#283568", "#28386A", "#28396C", "#283A6C", "#283B6E", - "#283E71", "#273F72", "#274073", "#274374", "#274477", "#274678", "#274679", "#28487B", - "#284B7D", "#284C7F", "#284D7F", "#2A5081", "#2A5183", "#2B5285", "#2C5485", "#2C5787", - "#2D5888", "#2E598B", "#305B8C", "#315E8D", "#335F8E", "#336091", "#356392", "#376592", - "#396694", "#3A6796", "#3D6A97", "#3F6B99", "#406C99", "#436E9A", "#45719B", "#46729E", - "#48739F", "#4B769F", "#4C78A0", "#5079A3", "#527AA4", "#537DA5", "#557FA5", "#587FA6", - "#5A81A9", "#5D84AA", "#5F85AB", "#6086AC", "#6488AC", "#668BAE", "#678CB0", "#6B8DB1", - "#6C90B2", "#6E92B2", "#7192B4", "#7394B6", "#7697B7", "#7899B8", "#7A9AB9", "#7D9BBA", - "#7F9EBB", "#819FBD", "#84A1BE", "#85A3BF", "#88A5C0", "#8BA6C1", "#8CA9C3", "#90AAC5", - "#92ACC5", "#93ADC6", "#97B0C7", "#99B2CA", "#9AB2CB", "#9EB4CC", "#9FB7CC", "#A1B8CD", - "#A5BAD0", "#A6BBD1", "#AABED2", "#ACBFD2", "#ADC1D4", "#B1C4D6", "#B2C5D7", "#B4C6D8", - "#B8C9D9", "#B9CBDA", "#BDCCDC", "#BFCEDD", "#C1D0DF", "#C4D2DF", "#C5D3E0", "#C9D6E1", - "#CBD8E4", "#CDD8E5", "#D0DAE5", "#D2DDE6", "#D4DFE7", "#D7DFE9", "#D8E1EA", "#DCE4EB", - "#DEE5EB", "#DFE6EB", "#E1E7EC", "#E4EAEC", "#E5EBEC", "#E6EBEC", "#E9ECEC", "#EAEDEB", - "#EBEDEB", "#EBEEEB", "#ECEEE9", "#ECEEE7", "#EDEEE6", "#EDEEE5", "#EDEEE3", "#ECEDE0", - "#ECEDDF", "#EBECDD", "#EBEBDA", "#EBEBD8", "#EAEAD6", "#E9E9D3", "#E7E7D2", "#E6E6CE", - "#E5E5CC", "#E5E5CB", "#E4E4C7", "#E3E1C5", "#E0E0C4", "#DFDFC0", "#DFDFBF", "#DEDEBD", - "#DDDDB9", "#DCDAB8", "#D9D9B4", "#D8D8B2", "#D8D8B1", "#D7D7AD", "#D4D4AC", "#D3D3AA", - "#D2D2A6", "#D2D2A5", "#D0D0A1", "#CECE9F", "#CDCD9E", "#CCCC9A", "#CBCB99", "#C9C996", - "#C7C793", "#C5C591", "#C5C58E", "#C3C38C", "#C0C08A", "#BFBF86", "#BEBE85", "#BBBB83", - "#B9B97F", "#B8B87E", "#B6B67B", "#B3B379", "#B2B277", "#B0B074", "#ADAD72", "#ACAC71", - "#AAAA6E", "#A7A76C", "#A5A56B", "#A3A368", "#A0A066", "#9F9F65", "#9D9D63", "#9A9A61", - "#99995F", "#97975E", "#94945B", "#92925A", "#919159", "#8E8E57", "#8C8C55", "#8B8B53", - "#888852", "#868651", "#85854E", "#83834C", "#80804C", "#7F7F4A", "#7D7D47", "#7A7A46", - "#797945", "#777744", "#747441", "#73733F", "#72723F", "#70703D", "#6D6D3B", "#6C6C39", - "#6A6A38", "#676737", "#666634", "#646433", "#616132", "#605F31", "#5F5F2E", "#5D5D2C", - "#5A5A2C", "#59592A", "#575728", "#545426", "#535326", "#525224", "#505022", "#4D4D20", - "#4C4C1F", "#4B4B1F", "#48481D", "#46461B", "#454519", "#434319", "#414118", "#3F3F15", - "#3E3E14", "#3B3D13", "#3A3A12", "#393911", "#373810", "#35350E", "#33330D", "#32330C", - "#31310B", "#2E300A", "#2D2D07", "#2C2C06", "#2B2B05", "#282804", "#272701", "#262600", -]; - -/// Crameri brocO (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BROCO: &[&str] = &[ - "#372E38", "#352E39", "#352E39", "#352E3A", "#352E3B", "#35303D", "#35303F", "#35303F", - "#353040", "#353143", "#353144", "#353246", "#353246", "#353348", "#35334A", "#35334C", - "#35344D", "#353450", "#353551", "#353752", "#353854", "#353957", "#373959", "#373A5A", - "#373B5B", "#373D5E", "#373F5F", "#383F61", "#384064", "#384366", "#394467", "#39456A", - "#39466C", "#39476E", "#3A4A71", "#3A4C72", "#3B4C74", "#3D4E77", "#3E5179", "#3E527A", - "#3F537B", "#3F557E", "#40577F", "#415981", "#435A84", "#445D85", "#455E87", "#465F8A", - "#47618B", "#48648C", "#4A668E", "#4C6690", "#4C6892", "#4E6B93", "#506C94", "#526E97", - "#527099", "#547299", "#55739B", "#58769D", "#59789F", "#5A799F", "#5D7AA1", "#5F7DA3", - "#607FA5", "#6380A5", "#6481A6", "#6684A9", "#6785AA", "#6A87AB", "#6C88AC", "#6D8BAD", - "#708CAE", "#728EB1", "#7390B2", "#7692B2", "#7893B3", "#7996B6", "#7B97B7", "#7E99B8", - "#7F9AB8", "#819DB9", "#849EBA", "#859FBD", "#87A1BE", "#8AA3BF", "#8CA5BF", "#8DA6C0", - "#91A7C1", "#92AAC3", "#94ACC4", "#97ACC5", "#99AEC5", "#9AB1C6", "#9DB2C7", "#9FB3C7", - "#A0B4C9", "#A3B7CA", "#A5B8CB", "#A6B9CC", "#A9BACC", "#ABBDCC", "#ACBECC", "#AEBFCD", - "#B1C0CD", "#B2C1CE", "#B4C4CE", "#B7C5D0", "#B8C5D0", "#B9C7D0", "#BBC9D0", "#BDCAD0", - "#BFCBD0", "#C0CCD0", "#C1CCD0", "#C4CDD0", "#C5CECE", "#C5CECE", "#C6D0CD", "#C9D1CD", - "#CAD1CC", "#CBD2CC", "#CBD2CB", "#CCD2CB", "#CCD2CA", "#CDD2C7", "#CDD2C6", "#CED2C5", - "#CED2C5", "#CED2C3", "#CED2C1", "#CED2BF", "#CED2BE", "#CED2BD", "#CED2BA", "#CED1B8", - "#CDD0B7", "#CDD0B4", "#CCCEB2", "#CCCDB1", "#CCCCAE", "#CBCCAC", "#CACBAB", "#C9CAA9", - "#C7C9A5", "#C6C7A4", "#C5C5A1", "#C5C59F", "#C4C49D", "#C1C19A", "#C0C099", "#BFBF96", - "#BEBE93", "#BBBB92", "#BABA90", "#B8B88C", "#B8B78B", "#B6B688", "#B3B385", "#B2B284", - "#B1B081", "#AEAE7F", "#ACAC7D", "#ABAB7A", "#AAA979", "#A7A677", "#A5A573", "#A4A472", - "#A1A170", "#9F9F6D", "#9E9E6C", "#9D9B6A", "#9A9967", "#999866", "#979664", "#949361", - "#92925F", "#91915E", "#8E8E5B", "#8C8C59", "#8B8B59", "#888857", "#878654", "#858552", - "#848351", "#818050", "#7F7F4D", "#7E7E4C", "#7B7B4B", "#7A7948", "#797847", "#777646", - "#747345", "#727243", "#727141", "#706E40", "#6D6C3F", "#6C6B3E", "#6B683D", "#68673A", - "#666639", "#666439", "#646138", "#616037", "#605F35", "#5F5D34", "#5E5B33", "#5B5933", - "#5A5932", "#595731", "#585430", "#55532E", "#54522D", "#53512D", "#524E2C", "#514D2C", - "#504C2C", "#4E4B2B", "#4C4A2B", "#4C472B", "#4B462A", "#4A462A", "#48452A", "#474328", - "#464128", "#464028", "#453F28", "#443F28", "#433E28", "#413D28", "#413B28", "#403A28", - "#3F3928", "#3F3928", "#3F3828", "#3E382A", "#3E372A", "#3D352A", "#3B342B", "#3B342B", - "#3A332B", "#3A332C", "#3A332C", "#39332C", "#39322D", "#39322D", "#39312E", "#393130", - "#383030", "#383031", "#383032", "#383033", "#372E33", "#372E34", "#372E35", "#372E37", -]; - -/// Crameri buda (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BUDA: &[&str] = &[ - "#B200B2", "#B202B2", "#B205B1", "#B206B0", "#B208AE", "#B20BAD", "#B20CAD", "#B20EAC", - "#B211AC", "#B212AB", "#B213AA", "#B215A9", "#B217A9", "#B218A7", "#B219A6", "#B21AA5", - "#B21BA5", "#B21EA5", "#B21FA4", "#B21FA4", "#B220A3", "#B222A1", "#B224A1", "#B225A0", - "#B2269F", "#B2269F", "#B2279F", "#B2289F", "#B22B9E", "#B22C9E", "#B22C9D", "#B22D9D", - "#B22E9B", "#B2309B", "#B3319A", "#B3329A", "#B33399", "#B33399", "#B33499", "#B33599", - "#B43798", "#B43898", "#B43998", "#B43997", "#B63A97", "#B63B96", "#B63D96", "#B63E96", - "#B73F94", "#B73F94", "#B74094", "#B84193", "#B84393", "#B84493", "#B84592", "#B84692", - "#B84692", "#B84792", "#B94892", "#B94A92", "#B94B91", "#BA4B91", "#BA4C91", "#BA4C90", - "#BB4D90", "#BB4E90", "#BB508E", "#BB518E", "#BD528E", "#BD528E", "#BD538D", "#BE548D", - "#BE548D", "#BE558C", "#BF578C", "#BF588C", "#BF598C", "#BF598C", "#BF5A8C", "#BF5B8C", - "#BF5B8B", "#C05D8B", "#C05E8B", "#C05F8A", "#C15F8A", "#C1608A", "#C1618A", "#C16388", - "#C36388", "#C36488", "#C36587", "#C46687", "#C46687", "#C46787", "#C46886", "#C56886", - "#C56A86", "#C56B85", "#C56C85", "#C56C85", "#C56D85", "#C56D85", "#C66E85", "#C67085", - "#C67184", "#C67284", "#C77284", "#C77284", "#C77383", "#C97483", "#C97683", "#C97783", - "#C97881", "#CA7981", "#CA7981", "#CA7980", "#CA7A80", "#CB7B80", "#CB7D80", "#CB7E7F", - "#CB7E7F", "#CC7F7F", "#CC7F7F", "#CC807F", "#CC817F", "#CC837F", "#CC847F", "#CC847E", - "#CC857E", "#CD857E", "#CD867E", "#CD877E", "#CD887D", "#CE8A7D", "#CE8A7D", "#CE8B7D", - "#CE8C7B", "#CE8C7B", "#D08D7B", "#D08E7B", "#D0907B", "#D0917A", "#D1917A", "#D1927A", - "#D1927A", "#D19379", "#D19479", "#D29679", "#D29779", "#D29779", "#D29879", "#D29979", - "#D29979", "#D29A79", "#D29B78", "#D29D78", "#D39E78", "#D39E78", "#D39F78", "#D39F77", - "#D3A077", "#D4A177", "#D4A377", "#D4A477", "#D4A576", "#D6A576", "#D6A576", "#D6A676", - "#D6A774", "#D6A974", "#D7AA74", "#D7AB74", "#D7AC74", "#D7AC73", "#D8AC73", "#D8AD73", - "#D8AE73", "#D8B072", "#D8B172", "#D8B272", "#D8B272", "#D8B372", "#D8B472", "#D9B472", - "#D9B672", "#D9B772", "#D9B871", "#D9B871", "#DAB971", "#DABA71", "#DABB71", "#DABD70", - "#DCBE70", "#DCBF70", "#DCBF70", "#DCBF6E", "#DDC06E", "#DDC16E", "#DDC36E", "#DDC46D", - "#DDC56D", "#DEC56D", "#DEC66D", "#DEC76D", "#DEC96C", "#DFCA6C", "#DFCA6C", "#DFCB6C", - "#DFCC6C", "#DFCC6C", "#DFCD6C", "#DFCE6C", "#DFD06B", "#DFD16B", "#E0D26B", "#E0D26B", - "#E0D36B", "#E1D46A", "#E1D66A", "#E1D76A", "#E1D86A", "#E3D868", "#E3D968", "#E3DA68", - "#E4DC68", "#E4DD68", "#E5DE67", "#E5DF67", "#E5DF67", "#E5E067", "#E6E167", "#E6E367", - "#E7E466", "#E7E566", "#E9E566", "#EAE666", "#EBE766", "#EBE966", "#EBEB66", "#ECEB66", - "#EDEC66", "#EEED66", "#F0EE66", "#F1F066", "#F2F266", "#F2F266", "#F3F366", "#F6F466", - "#F7F666", "#F8F866", "#F8F866", "#F9F966", "#FAFA66", "#FDFD66", "#FEFE66", "#FFFF66", -]; - -/// Crameri bukavu (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const BUKAVU: &[&str] = &[ - "#193333", "#193334", "#193437", "#193439", "#1A353A", "#1A373D", "#1A383F", "#1A3940", - "#1A3943", "#1B3A45", "#1B3B46", "#1B3D48", "#1B3E4C", "#1D3E4D", "#1D3F50", "#1D3F52", - "#1E4154", "#1E4357", "#1E4459", "#1E455B", "#1F465F", "#1F4661", "#1F4765", "#1F4A67", - "#1F4B6B", "#1F4C6D", "#204D71", "#204E74", "#215178", "#21527A", "#21537F", "#225481", - "#225785", "#245988", "#245A8C", "#255B8E", "#255E92", "#265F94", "#266197", "#266499", - "#27669B", "#28679F", "#2868A0", "#2A6BA3", "#2A6CA5", "#2B6EA7", "#2C71AA", "#2C72AC", - "#2C74AE", "#2C76B1", "#2D78B3", "#2E79B6", "#2E7BB8", "#307EB9", "#317FBD", "#3280BF", - "#3283BF", "#3385C1", "#3486C4", "#3588C5", "#388AC5", "#398CC5", "#3A8DC6", "#3D90C6", - "#3F92C7", "#4092C7", "#4394C7", "#4597C7", "#4699C7", "#4899C7", "#4B9BC7", "#4C9EC9", - "#4D9FC9", "#50A0C9", "#52A3C9", "#53A5C9", "#55A5C9", "#58A7C9", "#59AAC9", "#5AABCA", - "#5DACCA", "#5FADCA", "#60B0CA", "#61B2CA", "#64B2CA", "#66B4CA", "#67B7CA", "#6AB8CB", - "#6BB9CB", "#6CBACB", "#6EBDCB", "#71BFCB", "#72BFCB", "#73C1CB", "#76C3CB", "#78C5CC", - "#79C6CC", "#7BC7CC", "#7FCACC", "#80CCCC", "#84CCCC", "#86CECD", "#8AD1CE", "#8DD2D0", - "#92D4D1", "#94D7D1", "#99D8D2", "#9DD9D2", "#9FDCD3", "#A4DED4", "#A7DFD6", "#ACE1D7", - "#AEE4D8", "#B2E5D8", "#B6E6D9", "#B9E9DA", "#BEEBDC", "#C0ECDC", "#C5EEDD", "#C7F0DE", - "#CCF2DF", "#CEF3DF", "#D2F6E0", "#D6F7E1", "#D9F8E3", "#DDFAE3", "#DFFCE4", "#E4FEE5", - "#003F26", "#014025", "#024124", "#044422", "#054521", "#064620", "#06461F", "#07481F", - "#084A1E", "#0B4B1D", "#0C4C1B", "#0D4D1A", "#0E5019", "#115119", "#135218", "#145317", - "#175515", "#195814", "#1B5913", "#1F5A13", "#215D13", "#255E12", "#275F12", "#2B6112", - "#2E6312", "#326513", "#356613", "#396613", "#3D6814", "#3F6A15", "#446B17", "#466C19", - "#4B6C19", "#4D6D1A", "#526E1D", "#546E1E", "#58701F", "#5A7120", "#5E7122", "#607224", - "#647225", "#667226", "#687227", "#6C7328", "#6E732B", "#71742C", "#73742C", "#76762D", - "#78762E", "#7A7631", "#7D7732", "#7F7733", "#817833", "#847834", "#857835", "#877938", - "#8B7939", "#8C7939", "#8E793A", "#917A3B", "#927A3E", "#967B3F", "#987B3F", "#997D41", - "#9B7E43", "#9E7E45", "#A07F46", "#A37F48", "#A5814B", "#A6834C", "#AA844E", "#AC8552", - "#AD8653", "#B08857", "#B28B59", "#B38C5D", "#B68E5F", "#B79163", "#B89266", "#BA9468", - "#BB976C", "#BE996E", "#BF9B72", "#BF9E76", "#C19F79", "#C3A17B", "#C4A57F", "#C5A681", - "#C5A985", "#C7AC88", "#C9AD8C", "#CAB08E", "#CBB292", "#CCB496", "#CCB799", "#CDB89B", - "#CEBA9F", "#D0BDA3", "#D2BFA5", "#D2C1AA", "#D3C4AC", "#D4C5B0", "#D6C7B3", "#D7CAB7", - "#D8CCB9", "#D8CCBE", "#D9CEC0", "#DAD1C4", "#DCD2C6", "#DDD3CA", "#DED6CC", "#DFD7D0", - "#DFD8D2", "#E0D9D4", "#E1DAD8", "#E3DDDA", "#E3DEDD", "#E4DFDF", "#E5E0E3", "#E5E1E5", - "#E6E3E7", "#E7E5EB", "#E9E5EC", "#E9E6F0", "#EAE9F2", "#EBEAF6", "#EBEBF8", "#ECECFC", -]; - -/// Crameri cork (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const CORK: &[&str] = &[ - "#2C194C", "#2C1A4D", "#2C1D4E", "#2B1E51", "#2B1F52", "#2B2153", "#2B2255", "#2B2557", - "#2B2659", "#2B275A", "#2B285B", "#2A2B5E", "#2A2C5F", "#2A2D60", "#2A2E61", "#2A3164", - "#2A3265", "#283366", "#283567", "#28376A", "#28396B", "#28396C", "#273B6D", "#273D70", - "#273F72", "#273F72", "#274174", "#274376", "#274578", "#274679", "#27477A", "#274A7B", - "#274B7E", "#274C7F", "#284E80", "#285081", "#2A5284", "#2A5285", "#2B5486", "#2C5787", - "#2C598A", "#2D598B", "#2E5B8C", "#305E8D", "#325F8E", "#336091", "#346392", "#356492", - "#386694", "#396796", "#3B6897", "#3D6B98", "#3F6C99", "#406D9A", "#43709B", "#45719D", - "#46729E", "#48739F", "#4B769F", "#4C77A0", "#4D79A1", "#5079A3", "#527BA4", "#537DA5", - "#557FA5", "#587FA6", "#5981A7", "#5B83A9", "#5E85AA", "#5F85AC", "#6187AC", "#6488AD", - "#668BAE", "#678CB0", "#6A8DB1", "#6C90B2", "#6E92B2", "#7192B3", "#7294B6", "#7497B7", - "#7798B8", "#7999B8", "#7B9BB9", "#7E9DBA", "#809FBD", "#83A0BE", "#85A3BF", "#87A4BF", - "#8AA5C1", "#8CA7C3", "#8EAAC4", "#91ACC5", "#93ADC6", "#96AEC7", "#99B1C9", "#9AB2CB", - "#9EB4CC", "#9FB7CC", "#A3B8CE", "#A5BAD0", "#A7BDD1", "#AABFD2", "#ACC0D3", "#B0C3D4", - "#B2C5D7", "#B4C6D8", "#B8C9D9", "#B9CBDA", "#BDCCDC", "#BFCEDE", "#C1D1DF", "#C5D2E0", - "#C7D4E1", "#CAD7E3", "#CCD8E5", "#D0DAE5", "#D2DDE6", "#D4DFE7", "#D7E0E9", "#D9E3EA", - "#DCE5EB", "#DEE5EB", "#DFE7EC", "#E1E9EC", "#E3EBEC", "#E5EBEC", "#E5ECEC", "#E5ECEC", - "#E5ECEB", "#E5EDEB", "#E5EDEA", "#E5ECE7", "#E4ECE6", "#E3EBE5", "#E1EBE3", "#DFEAE0", - "#DEE9DF", "#DCE7DD", "#D9E6DA", "#D8E5D8", "#D6E4D6", "#D3E1D3", "#D2E0D2", "#CEDFCE", - "#CCDECC", "#CBDCCB", "#C7DAC7", "#C5D8C5", "#C4D7C3", "#C0D6C0", "#BFD3BF", "#BBD2BB", - "#B9D1B9", "#B7CEB7", "#B4CDB4", "#B2CCB2", "#B0CAB0", "#ADC9AC", "#ABC6AB", "#A9C5A7", - "#A5C4A5", "#A4C1A3", "#A0BFA0", "#9FBF9F", "#9BBD9B", "#99BA99", "#97B997", "#94B894", - "#92B792", "#90B490", "#8DB28C", "#8BB28B", "#88B087", "#85AD85", "#84AC84", "#81AB80", - "#7FAA7F", "#7DA77D", "#7AA679", "#78A578", "#76A376", "#73A172", "#719F71", "#6E9F6E", - "#6C9D6C", "#6B9B6A", "#679967", "#669966", "#649764", "#619660", "#5F945F", "#5D925D", - "#5A925A", "#599059", "#578E57", "#548C54", "#528C52", "#518B50", "#4E884D", "#4C874C", - "#4B854A", "#488547", "#468446", "#458144", "#438041", "#407F3F", "#3F7E3E", "#3D7D3B", - "#3A7A39", "#397938", "#377835", "#347733", "#337432", "#317330", "#2E722D", "#2C702C", - "#2B6E2A", "#2A6C27", "#276B26", "#266A25", "#256722", "#226620", "#21641F", "#1F631E", - "#1F601D", "#1E5F1A", "#1D5D19", "#1B5A19", "#1A5917", "#195815", "#195514", "#185313", - "#185213", "#175012", "#174D12", "#154C11", "#154B10", "#14480E", "#14460E", "#13450D", - "#13430C", "#13400C", "#133F0C", "#133E0C", "#123B0B", "#12390A", "#12380A", "#113708", - "#113407", "#113307", "#103106", "#103006", "#102D05", "#102C05", "#0E2B04", "#0E2802", -]; - -/// Crameri corkO (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const CORKO: &[&str] = &[ - "#3F3E39", "#3F3E3A", "#3F3E3B", "#3F3D3D", "#3E3D3E", "#3E3D3F", "#3E3D3F", "#3E3D40", - "#3E3D41", "#3E3D43", "#3E3D44", "#3E3D45", "#3E3D46", "#3E3D46", "#3E3D47", "#3E3D48", - "#3E3D4B", "#3E3D4C", "#3E3E4C", "#3E3E4E", "#3E3F50", "#3E3F52", "#3E3F52", "#3E3F54", - "#3E4055", "#3E4058", "#3E4159", "#3E435A", "#3E445D", "#3E455F", "#3F4660", "#3F4661", - "#3F4764", "#3F4866", "#3F4A67", "#3F4B6A", "#404C6C", "#404D6D", "#414E70", "#415072", - "#435273", "#445276", "#445478", "#455579", "#46587B", "#46597E", "#465A7F", "#475D81", - "#485E84", "#4A5F85", "#4B6186", "#4C6388", "#4D658B", "#4E668C", "#50688E", "#516A91", - "#526C92", "#536D93", "#557096", "#577197", "#587299", "#59749A", "#5B779B", "#5D789E", - "#5F799F", "#5F7BA0", "#617EA1", "#647FA4", "#6580A5", "#6683A5", "#6885A7", "#6A85A9", - "#6C87AB", "#6D8AAC", "#708CAC", "#728CAD", "#728EB0", "#7491B1", "#7792B2", "#7993B2", - "#7A96B3", "#7B97B6", "#7E99B7", "#7F9AB8", "#819BB8", "#849EB9", "#859FBA", "#86A0BB", - "#88A3BD", "#8BA4BE", "#8CA5BF", "#8EA6BF", "#90A9BF", "#92ABC0", "#93ACC1", "#96ADC3", - "#97AEC3", "#99B1C4", "#99B2C5", "#9BB2C5", "#9EB4C5", "#9FB6C5", "#A0B8C6", "#A1B8C6", - "#A3B9C6", "#A5BAC7", "#A5BDC7", "#A6BEC7", "#A7BFC7", "#A9BFC7", "#AAC0C7", "#ABC1C7", - "#ACC3C7", "#ACC4C7", "#ADC5C6", "#ADC5C6", "#AEC5C6", "#AEC6C5", "#B0C7C5", "#B0C7C5", - "#B0C9C4", "#B0C9C3", "#B0CAC1", "#B0CAC0", "#B0CABF", "#B0CABF", "#B0CABE", "#AECBBD", - "#AECBBB", "#ADCABA", "#ACCAB8", "#ACCAB8", "#ACCAB6", "#ABCAB4", "#AAC9B2", "#A9C9B2", - "#A7C7B0", "#A6C7AE", "#A5C6AC", "#A5C6AB", "#A3C5AA", "#A1C5A7", "#A0C4A5", "#9FC4A4", - "#9EC3A3", "#9BC1A0", "#9AC09F", "#99BF9D", "#98BF9A", "#96BE99", "#94BD97", "#92BB94", - "#91BA92", "#90B991", "#8DB88E", "#8CB78C", "#8BB68B", "#88B488", "#86B386", "#85B285", - "#84B183", "#81B080", "#7FAD7F", "#7FAC7D", "#7DAC7A", "#7AAA79", "#79A977", "#78A674", - "#76A572", "#74A471", "#72A36E", "#71A06C", "#709F6A", "#6D9E67", "#6C9B66", "#6B9964", - "#689961", "#67975F", "#66945E", "#65935B", "#639259", "#619058", "#5F8D55", "#5F8C53", - "#5E8B52", "#5B8850", "#5A864D", "#59854C", "#59844A", "#578148", "#557F46", "#547F45", - "#537D44", "#527A41", "#527940", "#51783F", "#50763E", "#4E733B", "#4E723A", "#4D7139", - "#4C6E38", "#4C6D37", "#4C6C35", "#4B6B34", "#4A6833", "#4A6733", "#486632", "#486532", - "#476431", "#476130", "#466030", "#465F2E", "#465E2E", "#465D2D", "#465B2D", "#455A2C", - "#45592C", "#45582C", "#45572C", "#44552C", "#44542C", "#44532C", "#44522C", "#43522C", - "#43512C", "#43502C", "#434E2C", "#434E2C", "#414D2C", "#414C2C", "#414C2C", "#414B2C", - "#414B2D", "#414A2D", "#41482D", "#40472E", "#40472E", "#40462E", "#404630", "#404630", - "#404531", "#404531", "#404432", "#3F4432", "#3F4333", "#3F4333", "#3F4133", "#3F4133", - "#3F4034", "#3F4035", "#3F3F35", "#3F3F37", "#3F3F38", "#3F3F38", "#3F3F39", "#3F3E39", -]; - -/// Crameri davos (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const DAVOS: &[&str] = &[ - "#00054A", "#00064C", "#00084C", "#000B4E", "#000C50", "#000D51", "#010E52", "#011053", - "#021255", "#041357", "#041359", "#051559", "#05175A", "#06185D", "#06195E", "#061A5F", - "#071B60", "#081E61", "#0A1F64", "#0B1F65", "#0B2166", "#0C2267", "#0C2468", "#0D266B", - "#0D266C", "#0E276C", "#102A6E", "#112B70", "#112C71", "#122D72", "#132E73", "#133074", - "#133277", "#143378", "#153379", "#173579", "#17377B", "#18387D", "#19397E", "#193A7F", - "#193B7F", "#1A3E81", "#1B3F83", "#1D3F84", "#1E4085", "#1E4385", "#1F4486", "#1F4587", - "#204688", "#21478A", "#22488B", "#224A8C", "#244C8C", "#254C8D", "#264D8E", "#264E90", - "#275190", "#285291", "#2A5292", "#2A5392", "#2B5592", "#2C5793", "#2C5894", "#2D5994", - "#2E5996", "#305A97", "#315B97", "#325E98", "#335F98", "#335F99", "#346099", "#356199", - "#376399", "#386499", "#39659A", "#39669A", "#39669A", "#3A679B", "#3B689B", "#3D6A9B", - "#3E6B9B", "#3F6C9D", "#3F6C9D", "#406D9D", "#416E9D", "#43709D", "#44709D", "#45719D", - "#46729D", "#46729D", "#47739D", "#48749D", "#4A769D", "#4B769D", "#4C779D", "#4C789D", - "#4D799D", "#4E799D", "#50799B", "#517A9B", "#527B9B", "#527B9B", "#537D9B", "#547E9A", - "#557F9A", "#557F9A", "#577F9A", "#588099", "#598099", "#598199", "#5A8399", "#5B8399", - "#5D8499", "#5E8598", "#5F8598", "#5F8598", "#608597", "#618697", "#638797", "#648796", - "#658896", "#658A94", "#668A94", "#668B94", "#678B93", "#688C93", "#6A8C92", "#6B8C92", - "#6C8D92", "#6C8D92", "#6D8E92", "#6E9091", "#709091", "#719191", "#729190", "#729290", - "#73928E", "#73928E", "#74938E", "#76948D", "#77948D", "#78968C", "#79968C", "#79978C", - "#7A988C", "#7B988C", "#7D998C", "#7E998B", "#7F998B", "#7F9A8B", "#809B8A", "#819D8A", - "#839D8A", "#849E88", "#859F88", "#869F88", "#879F88", "#88A087", "#8AA187", "#8BA387", - "#8CA387", "#8CA487", "#8EA587", "#90A586", "#91A686", "#92A786", "#92A986", "#94AA86", - "#96AB86", "#97AC86", "#99AC87", "#99AD87", "#9AAE87", "#9DB087", "#9EB187", "#9FB288", - "#A0B288", "#A3B388", "#A4B68A", "#A5B78A", "#A7B88B", "#A9B88B", "#ABBA8C", "#ACBB8C", - "#ADBD8C", "#B0BF8D", "#B2BF8E", "#B3C090", "#B6C391", "#B8C492", "#B8C592", "#BAC693", - "#BDC994", "#BFCA96", "#C0CC98", "#C3CC99", "#C5CE9A", "#C6D09B", "#C9D29E", "#CBD29F", - "#CCD4A0", "#CED6A1", "#D0D8A4", "#D2D8A5", "#D3DAA7", "#D6DCA9", "#D8DEAB", "#D8DFAC", - "#DADFAE", "#DDE1B1", "#DEE3B2", "#DFE4B4", "#E0E5B6", "#E3E6B8", "#E4E7B9", "#E5E9BB", - "#E6EABE", "#E7EBBF", "#EAEBC1", "#EBECC4", "#EBEDC5", "#ECEEC6", "#EDEEC9", "#EEF0CB", - "#F0F1CC", "#F1F2CE", "#F2F2D0", "#F2F2D2", "#F2F3D3", "#F3F3D6", "#F4F4D7", "#F4F6D8", - "#F6F6DA", "#F7F7DD", "#F7F7DE", "#F8F8DF", "#F8F8E0", "#F8F8E3", "#F8F8E5", "#F9F8E5", - "#F9F9E7", "#FAF9E9", "#FAFAEB", "#FAFAEB", "#FCFAED", "#FCFCF0", "#FCFCF1", "#FCFCF2", - "#FDFDF3", "#FDFDF6", "#FDFDF7", "#FEFDF8", "#FEFEF9", "#FEFEFC", "#FEFEFD", "#FEFEFE", -]; - -/// Crameri devon (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const DEVON: &[&str] = &[ - "#2C194C", "#2C1A4C", "#2C1B4D", "#2B1B4E", "#2B1D50", "#2B1E51", "#2B1F52", "#2B1F52", - "#2B2053", "#2B2154", "#2B2255", "#2B2457", "#2B2558", "#2B2658", "#2B2659", "#2A2759", - "#2A285A", "#2A285B", "#2A2A5D", "#2A2B5E", "#2A2C5F", "#2A2C5F", "#2A2D60", "#2A2E61", - "#283063", "#283164", "#283265", "#283366", "#283366", "#283467", "#283568", "#28376A", - "#28386A", "#27396B", "#27396C", "#27396C", "#273A6D", "#273B6E", "#273D70", "#273E71", - "#273F72", "#273F72", "#264073", "#264174", "#264376", "#264478", "#264579", "#264679", - "#26467A", "#26477B", "#26487D", "#264A7E", "#264C7F", "#264C80", "#264D81", "#264E83", - "#265084", "#265185", "#265286", "#265287", "#27538A", "#27548B", "#27558C", "#27578D", - "#28588E", "#285990", "#285992", "#2A5A92", "#2A5B94", "#2B5D96", "#2B5D98", "#2C5E99", - "#2C5F99", "#2C5F9B", "#2D609D", "#2D619F", "#2E619F", "#3063A1", "#3064A3", "#3165A4", - "#3265A5", "#3266A6", "#3366A9", "#3367AA", "#3467AB", "#3568AC", "#376AAD", "#386AB0", - "#396BB1", "#396CB2", "#3A6CB3", "#3B6CB4", "#3D6DB7", "#3E6EB8", "#3F70B8", "#4071BA", - "#4172BB", "#4472BD", "#4572BF", "#4673BF", "#4774C0", "#4A76C3", "#4C77C4", "#4C78C5", - "#4E79C5", "#5179C7", "#527AC9", "#547BCA", "#577BCB", "#597DCC", "#5A7ECC", "#5D7FCD", - "#5F7FCE", "#6080D0", "#6381D1", "#6583D2", "#6684D2", "#6885D3", "#6B85D4", "#6C86D6", - "#6E87D7", "#7187D8", "#7288D8", "#748AD8", "#778BD9", "#798CDA", "#7A8CDC", "#7D8DDC", - "#7E8EDD", "#7F8EDE", "#8190DF", "#8491DF", "#8592DF", "#8792E0", "#8A93E0", "#8C94E1", - "#8C94E3", "#8E96E3", "#9197E4", "#9298E5", "#9499E5", "#9699E5", "#989AE6", "#999AE6", - "#9A9BE7", "#9D9DE7", "#9E9EE9", "#9F9FE9", "#A19FEA", "#A3A0EA", "#A4A1EB", "#A5A3EB", - "#A6A4EB", "#A7A5EB", "#AAA5EC", "#ABA5EC", "#ACA6EC", "#ACA7ED", "#ADA9ED", "#AEAAED", - "#B0ABED", "#B1ACEE", "#B2ACEE", "#B2ACEE", "#B3ADEE", "#B4AEF0", "#B6B0F0", "#B7B1F0", - "#B8B2F0", "#B8B2F0", "#B9B2F1", "#B9B3F1", "#BAB4F1", "#BBB6F1", "#BDB7F1", "#BEB8F2", - "#BEB8F2", "#BFB8F2", "#BFB9F2", "#C0BAF2", "#C1BBF2", "#C1BDF2", "#C3BEF2", "#C4BFF2", - "#C5BFF2", "#C5BFF2", "#C5C0F2", "#C6C1F3", "#C7C3F3", "#C9C4F3", "#CAC5F3", "#CAC5F3", - "#CBC5F3", "#CCC6F3", "#CCC7F4", "#CDC9F4", "#CDCAF4", "#CECBF4", "#D0CCF4", "#D1CCF4", - "#D2CCF6", "#D2CDF6", "#D2CEF6", "#D3D0F6", "#D4D1F6", "#D6D2F6", "#D6D2F7", "#D7D3F7", - "#D8D3F7", "#D8D4F7", "#D9D6F7", "#D9D7F7", "#DAD8F8", "#DCD8F8", "#DDD9F8", "#DEDAF8", - "#DEDCF8", "#DFDCF8", "#DFDDF8", "#E0DEF8", "#E1DFF8", "#E3DFF8", "#E3E0F8", "#E4E1F9", - "#E5E3F9", "#E5E4F9", "#E6E4F9", "#E7E5F9", "#E7E5F9", "#E9E6FA", "#EAE7FA", "#EBE9FA", - "#EBEAFA", "#ECEBFA", "#ECEBFA", "#EDECFC", "#EEEDFC", "#F0EDFC", "#F1EEFC", "#F2F0FC", - "#F2F1FC", "#F2F2FD", "#F3F2FD", "#F4F3FD", "#F6F4FD", "#F7F6FD", "#F7F7FD", "#F8F8FE", - "#F8F8FE", "#F9F8FE", "#FAF9FE", "#FCFAFE", "#FCFCFE", "#FDFDFF", "#FEFEFF", "#FFFFFF", -]; - -/// Crameri fes (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const FES: &[&str] = &[ - "#0C0C0C", "#0E0E0E", "#121212", "#131313", "#151515", "#181818", "#191919", "#1A1A1A", - "#1D1D1D", "#1F1F1F", "#202020", "#212121", "#242424", "#262626", "#272727", "#282828", - "#2B2B2B", "#2C2C2C", "#2D2D2D", "#303030", "#323232", "#333333", "#343434", "#373737", - "#393939", "#3A3A3A", "#3B3B3B", "#3E3E3E", "#3F3F3F", "#404040", "#434343", "#454545", - "#464646", "#474747", "#484848", "#4B4B4B", "#4C4C4C", "#4D4D4D", "#505050", "#525252", - "#525252", "#545454", "#555555", "#585858", "#595959", "#5A5A5A", "#5B5B5B", "#5E5E5E", - "#5F5F5F", "#606060", "#636363", "#646464", "#656565", "#666666", "#676767", "#6A6A6A", - "#6B6B6B", "#6C6C6C", "#6D6D6D", "#707070", "#717171", "#727272", "#737373", "#747474", - "#777777", "#787878", "#797979", "#7A7A7A", "#7B7B7B", "#7E7E7E", "#7F7F7F", "#808080", - "#818181", "#848484", "#858585", "#868686", "#878787", "#8A8A8A", "#8B8B8B", "#8C8C8C", - "#8D8D8D", "#909090", "#929292", "#929292", "#949494", "#979797", "#999999", "#999999", - "#9B9B9B", "#9E9E9E", "#9F9F9F", "#A0A0A0", "#A3A3A3", "#A5A5A5", "#A6A6A6", "#A9A9A9", - "#ABABAB", "#ACACAC", "#AEAEAE", "#B1B1B1", "#B2B2B2", "#B4B4B4", "#B7B7B7", "#B8B8B8", - "#BABABA", "#BDBDBD", "#BFBFBF", "#C0C0C0", "#C3C3C3", "#C5C5C5", "#C7C7C7", "#CACACA", - "#CCCCCC", "#CDCDCD", "#D1D1D1", "#D2D2D2", "#D4D4D4", "#D8D8D8", "#D9D9D9", "#DDDDDD", - "#DFDFDF", "#E1E1E1", "#E4E4E4", "#E6E6E6", "#E9E9E9", "#EBEBEB", "#EDEDED", "#F1F1F1", - "#013F26", "#054026", "#084125", "#0C4324", "#0E4422", "#124622", "#144621", "#184721", - "#1A4820", "#1E4A20", "#204B1F", "#244C1F", "#264D1F", "#2B4E1F", "#2D501F", "#31511F", - "#33521F", "#37521F", "#39531F", "#3D541F", "#3F5420", "#415520", "#455721", "#475821", - "#4A5921", "#4C5922", "#505922", "#525A24", "#545A24", "#575B25", "#595D25", "#5B5D26", - "#5E5E26", "#605E26", "#635F26", "#655F26", "#675F27", "#6A6027", "#6C6028", "#6E6128", - "#716128", "#72632A", "#74642A", "#78642B", "#79652B", "#7B652C", "#7F662C", "#80662C", - "#83662C", "#85672D", "#87672D", "#8B682E", "#8C6A2E", "#906B30", "#926B30", "#946C31", - "#976C32", "#996D33", "#9B6E33", "#9F7034", "#A07137", "#A47238", "#A57339", "#A7763B", - "#AB773E", "#AC793F", "#AE7A41", "#B17D45", "#B27F46", "#B4804A", "#B6834C", "#B88550", - "#B88652", "#B98855", "#BB8B59", "#BD8D5B", "#BE905F", "#BF9261", "#BF9365", "#C09768", - "#C1996C", "#C39A6E", "#C49D72", "#C59F74", "#C5A178", "#C5A47B", "#C6A57F", "#C7A781", - "#C9AB85", "#CAAC88", "#CBAE8C", "#CCB18E", "#CCB292", "#CDB696", "#CEB899", "#D0B99B", - "#D1BB9F", "#D2BEA3", "#D2BFA5", "#D2C1AA", "#D3C4AC", "#D4C5B0", "#D6C7B3", "#D7CAB7", - "#D8CCB9", "#D8CDBD", "#D9CEC0", "#DAD1C4", "#DCD2C6", "#DDD3CA", "#DED6CC", "#DFD7CE", - "#DFD8D2", "#E0D9D4", "#E1DAD8", "#E1DDD9", "#E3DEDD", "#E4DFDF", "#E5DFE1", "#E5E1E5", - "#E6E3E7", "#E7E4EA", "#E7E5EC", "#E9E6F0", "#EAE9F2", "#EBEAF6", "#EBEBF8", "#ECECFC", -]; - -/// Crameri glasgow (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const GLASGOW: &[&str] = &[ - "#351338", "#371337", "#381337", "#391335", "#391334", "#3A1333", "#3B1333", "#3D1433", - "#3E1432", "#3F1431", "#3F1430", "#3F152E", "#40152E", "#41152D", "#43152C", "#44172C", - "#45172C", "#46172B", "#46172A", "#461828", "#471828", "#481827", "#4A1826", "#4B1926", - "#4B1926", "#4C1925", "#4C1924", "#4D1924", "#4E1922", "#501921", "#501920", "#511A20", - "#521A1F", "#521A1F", "#531A1E", "#541B1E", "#551B1D", "#571B1B", "#571B1A", "#581D1A", - "#591D19", "#591D19", "#5A1E18", "#5B1E18", "#5D1E17", "#5E1F15", "#5F1F14", "#5F1F13", - "#601F13", "#612013", "#632012", "#652111", "#662110", "#66220E", "#67240D", "#68250C", - "#6A250C", "#6B260B", "#6C260B", "#6C270A", "#6C2808", "#6D2A07", "#6E2B06", "#702C06", - "#702C06", "#712D05", "#712E04", "#723104", "#723202", "#723302", "#723302", "#723401", - "#733701", "#733801", "#733900", "#733900", "#733A00", "#733B00", "#733E00", "#733F00", - "#743F00", "#744000", "#744100", "#744300", "#744400", "#734500", "#734600", "#734600", - "#734800", "#734A00", "#734B00", "#734C00", "#734C00", "#734D00", "#734E00", "#735000", - "#735100", "#735200", "#735201", "#735301", "#735402", "#735502", "#725704", "#725804", - "#725905", "#725906", "#725A06", "#725B07", "#725E0A", "#725F0B", "#725F0C", "#72600D", - "#726110", "#716311", "#716413", "#716514", "#716617", "#706618", "#706619", "#70671B", - "#70681E", "#6E6A1F", "#6E6B20", "#6E6C22", "#6D6C25", "#6D6D26", "#6D6E28", "#6C702B", - "#6C702C", "#6C712E", "#6C7231", "#6C7232", "#6C7333", "#6B7435", "#6B7438", "#6B7639", - "#6A773B", "#6A783E", "#6A793F", "#687941", "#687944", "#687A46", "#677B47", "#677D4A", - "#677D4C", "#667E4C", "#667F4E", "#667F51", "#668052", "#668054", "#668157", "#658359", - "#65845A", "#65845D", "#65855F", "#648560", "#648663", "#648764", "#638766", "#638867", - "#638A6A", "#618B6C", "#618B6D", "#618C70", "#608C72", "#608D73", "#608D76", "#608E78", - "#5F9079", "#5F917A", "#5F927D", "#5F927F", "#5F9280", "#5F9383", "#5F9485", "#5F9486", - "#5F9688", "#5F978A", "#5F988C", "#5F998D", "#5F9990", "#609992", "#609A93", "#619B96", - "#619D98", "#639E99", "#649F9A", "#659F9D", "#669F9F", "#66A0A0", "#67A1A3", "#68A3A5", - "#6BA4A5", "#6CA5A7", "#6DA5AA", "#6EA5AB", "#71A6AC", "#72A7AE", "#73A9B0", "#76AAB2", - "#77AAB2", "#79ABB4", "#7AACB7", "#7DACB8", "#7EADB8", "#7FADBA", "#81AEBB", "#84B0BE", - "#85B1BF", "#86B1C0", "#88B2C1", "#8BB2C3", "#8CB3C5", "#8DB3C5", "#90B4C7", "#92B6C9", - "#93B6CA", "#94B7CC", "#97B8CC", "#99B8CD", "#9AB8CE", "#9BB9D1", "#9EBAD2", "#9FBAD2", - "#A0BBD4", "#A3BDD6", "#A5BDD7", "#A5BED8", "#A7BFD9", "#AABFDA", "#ABBFDC", "#ACC0DD", - "#AEC0DF", "#B0C1DF", "#B2C3E0", "#B3C3E1", "#B4C4E4", "#B7C5E5", "#B8C5E5", "#BAC5E6", - "#BBC6E9", "#BEC7EA", "#BFC7EB", "#C1C9EC", "#C4CAED", "#C5CBF0", "#C7CCF1", "#CACCF2", - "#CCCCF3", "#CDCDF6", "#D0CEF7", "#D2D0F8", "#D3D1F9", "#D7D1FC", "#D8D2FD", "#DAD2FF", -]; - -/// Crameri grayC (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const GRAYC: &[&str] = &[ - "#000000", "#010101", "#040404", "#050505", "#060606", "#080808", "#0B0B0B", "#0C0C0C", - "#0D0D0D", "#0E0E0E", "#111111", "#121212", "#131313", "#131313", "#141414", "#151515", - "#171717", "#181818", "#191919", "#191919", "#1A1A1A", "#1B1B1B", "#1D1D1D", "#1E1E1E", - "#1F1F1F", "#1F1F1F", "#202020", "#212121", "#222222", "#242424", "#242424", "#252525", - "#262626", "#262626", "#272727", "#282828", "#2A2A2A", "#2B2B2B", "#2C2C2C", "#2C2C2C", - "#2D2D2D", "#2E2E2E", "#303030", "#313131", "#313131", "#323232", "#333333", "#333333", - "#343434", "#353535", "#373737", "#383838", "#393939", "#393939", "#3A3A3A", "#3A3A3A", - "#3B3B3B", "#3D3D3D", "#3E3E3E", "#3F3F3F", "#3F3F3F", "#404040", "#414141", "#434343", - "#444444", "#444444", "#454545", "#464646", "#464646", "#474747", "#484848", "#4A4A4A", - "#4B4B4B", "#4B4B4B", "#4C4C4C", "#4C4C4C", "#4D4D4D", "#4E4E4E", "#505050", "#515151", - "#515151", "#525252", "#525252", "#535353", "#545454", "#555555", "#555555", "#575757", - "#585858", "#595959", "#595959", "#5A5A5A", "#5A5A5A", "#5B5B5B", "#5D5D5D", "#5E5E5E", - "#5F5F5F", "#5F5F5F", "#5F5F5F", "#606060", "#616161", "#636363", "#636363", "#646464", - "#656565", "#666666", "#666666", "#666666", "#676767", "#686868", "#6A6A6A", "#6A6A6A", - "#6B6B6B", "#6C6C6C", "#6C6C6C", "#6D6D6D", "#6D6D6D", "#6E6E6E", "#707070", "#717171", - "#717171", "#727272", "#727272", "#737373", "#737373", "#747474", "#767676", "#777777", - "#777777", "#787878", "#797979", "#797979", "#797979", "#7A7A7A", "#7B7B7B", "#7D7D7D", - "#7D7D7D", "#7E7E7E", "#7F7F7F", "#7F7F7F", "#7F7F7F", "#808080", "#818181", "#838383", - "#848484", "#848484", "#858585", "#858585", "#868686", "#878787", "#878787", "#888888", - "#8A8A8A", "#8B8B8B", "#8C8C8C", "#8C8C8C", "#8D8D8D", "#8D8D8D", "#8E8E8E", "#909090", - "#919191", "#929292", "#929292", "#939393", "#949494", "#949494", "#969696", "#979797", - "#989898", "#999999", "#999999", "#9A9A9A", "#9B9B9B", "#9D9D9D", "#9E9E9E", "#9F9F9F", - "#9F9F9F", "#A0A0A0", "#A1A1A1", "#A3A3A3", "#A4A4A4", "#A5A5A5", "#A5A5A5", "#A6A6A6", - "#A7A7A7", "#A9A9A9", "#AAAAAA", "#ABABAB", "#ACACAC", "#ACACAC", "#ADADAD", "#AEAEAE", - "#B0B0B0", "#B1B1B1", "#B2B2B2", "#B2B2B2", "#B3B3B3", "#B4B4B4", "#B6B6B6", "#B7B7B7", - "#B8B8B8", "#B8B8B8", "#BABABA", "#BBBBBB", "#BDBDBD", "#BEBEBE", "#BFBFBF", "#BFBFBF", - "#C0C0C0", "#C3C3C3", "#C4C4C4", "#C5C5C5", "#C5C5C5", "#C6C6C6", "#C7C7C7", "#CACACA", - "#CBCBCB", "#CCCCCC", "#CCCCCC", "#CDCDCD", "#D0D0D0", "#D1D1D1", "#D2D2D2", "#D2D2D2", - "#D4D4D4", "#D6D6D6", "#D7D7D7", "#D8D8D8", "#D9D9D9", "#DADADA", "#DCDCDC", "#DEDEDE", - "#DFDFDF", "#DFDFDF", "#E1E1E1", "#E3E3E3", "#E4E4E4", "#E5E5E5", "#E6E6E6", "#E7E7E7", - "#EAEAEA", "#EBEBEB", "#EBEBEB", "#EDEDED", "#EEEEEE", "#F1F1F1", "#F2F2F2", "#F2F2F2", - "#F4F4F4", "#F6F6F6", "#F8F8F8", "#F8F8F8", "#FAFAFA", "#FCFCFC", "#FEFEFE", "#FFFFFF", -]; - -/// Crameri hawaii (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const HAWAII: &[&str] = &[ - "#8C0172", "#8C0472", "#8C0671", "#8C0770", "#8C0A6E", "#8D0C6D", "#8D0D6C", "#8D106C", - "#8D126B", "#8D136A", "#8E1468", "#8E1567", "#8E1866", "#8E1966", "#8E1965", "#901B64", - "#901D63", "#901E63", "#901F61", "#901F60", "#90205F", "#91225F", "#91245E", "#91255D", - "#91265B", "#91265A", "#912759", "#922859", "#922A59", "#922B58", "#922C57", "#922C55", - "#922D54", "#922E53", "#923053", "#923152", "#923252", "#923351", "#923350", "#923450", - "#92354E", "#93374D", "#93384C", "#93394C", "#93394C", "#933A4B", "#933B4A", "#933D4A", - "#943E48", "#943F47", "#943F46", "#944046", "#944146", "#944345", "#944445", "#944544", - "#964643", "#964641", "#964741", "#964840", "#964A3F", "#964B3F", "#964C3F", "#964C3E", - "#974D3E", "#974E3D", "#97503B", "#97513B", "#97523A", "#975239", "#975239", "#975339", - "#985438", "#985538", "#985737", "#985835", "#985935", "#985934", "#985B33", "#995D33", - "#995E33", "#995F32", "#995F32", "#996031", "#996130", "#996330", "#99642E", "#99652D", - "#99662D", "#99662C", "#99672C", "#99682C", "#996A2B", "#996B2A", "#9A6C2A", "#9A6C28", - "#9A6E27", "#9A7027", "#9A7126", "#9A7226", "#9A7226", "#9A7325", "#9B7424", "#9B7724", - "#9B7822", "#9B7922", "#9B7921", "#9B7A20", "#9B7D20", "#9B7E1F", "#9B7F1F", "#9B7F1F", - "#9D801F", "#9D831E", "#9D841E", "#9D851D", "#9D861D", "#9D871D", "#9D881B", "#9D8A1B", - "#9D8C1B", "#9D8C1B", "#9D8D1B", "#9D901B", "#9B911B", "#9B921B", "#9B931B", "#9B941B", - "#9B961B", "#9B981D", "#9A991D", "#9A991E", "#9A9B1F", "#9A9D1F", "#999F1F", "#999F20", - "#99A021", "#99A322", "#99A424", "#98A526", "#98A526", "#97A727", "#97A92A", "#96AA2B", - "#94AB2C", "#94AC2D", "#93AD30", "#92AE32", "#92B033", "#92B134", "#91B237", "#91B339", - "#90B43A", "#8EB63B", "#8DB73E", "#8CB83F", "#8CB841", "#8CB944", "#8BBA46", "#8ABB47", - "#88BD4A", "#87BE4C", "#86BE4D", "#85BF50", "#85BF52", "#84C053", "#83C157", "#81C359", - "#81C35A", "#80C45D", "#7FC55F", "#7FC560", "#7EC563", "#7DC665", "#7BC766", "#7AC968", - "#79C96C", "#79CA6D", "#78CB70", "#77CC72", "#76CC73", "#74CC76", "#73CD79", "#72CD7A", - "#72CE7D", "#71D07F", "#70D080", "#70D183", "#6ED285", "#6DD287", "#6CD28A", "#6CD38C", - "#6BD38D", "#6AD491", "#68D692", "#67D694", "#67D797", "#66D899", "#66D89B", "#65D89E", - "#64D99F", "#64D9A1", "#63DAA5", "#61DCA6", "#61DCA9", "#60DDAB", "#60DEAD", "#5FDEB0", - "#5FDFB2", "#5FDFB3", "#5FDFB7", "#5FE0B8", "#5FE1BA", "#5FE1BD", "#5FE3BF", "#5FE4C1", - "#5FE4C4", "#5FE5C5", "#60E5C7", "#60E5CA", "#61E6CC", "#63E6CE", "#64E7D1", "#66E7D2", - "#66E9D4", "#67E9D7", "#6AEAD8", "#6CEBDA", "#6CEBDC", "#6EEBDE", "#71EBDF", "#72EBE1", - "#76ECE3", "#78ECE5", "#79EDE6", "#7DEDE7", "#7FEDEA", "#81EEEB", "#84EEEB", "#86EEED", - "#8AF0EE", "#8CF0F0", "#8EF0F1", "#92F0F2", "#94F1F3", "#98F1F4", "#99F1F6", "#9DF1F7", - "#9FF1F8", "#A3F1F8", "#A5F1F8", "#A7F2F9", "#ABF2FA", "#ADF2FC", "#B1F2FD", "#B2F2FD", -]; - -/// Crameri imola (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const IMOLA: &[&str] = &[ - "#1933B2", "#1933B2", "#1A34B2", "#1A34B1", "#1B35B1", "#1B37B1", "#1B37B0", "#1D38B0", - "#1D39B0", "#1E39AE", "#1E39AE", "#1E3AAE", "#1F3BAD", "#1F3BAD", "#1F3DAD", "#1F3EAC", - "#1F3EAC", "#1F3FAC", "#203FAC", "#203FAC", "#2040AC", "#2141AB", "#2141AB", "#2143AB", - "#2244AA", "#2244AA", "#2245AA", "#2446A9", "#2446A9", "#2446A9", "#2547A7", "#2547A7", - "#2548A7", "#264AA6", "#264AA6", "#264BA6", "#264CA5", "#264CA5", "#264CA5", "#274DA5", - "#274DA5", "#274EA5", "#2850A4", "#2850A4", "#2851A4", "#2A52A3", "#2A52A3", "#2A52A3", - "#2B52A1", "#2B53A1", "#2B54A1", "#2C54A0", "#2C55A0", "#2C57A0", "#2C579F", "#2C589F", - "#2C599F", "#2D599F", "#2D599F", "#2D5A9F", "#2E5A9E", "#2E5B9E", "#2E5D9E", "#305D9D", - "#305E9D", "#305F9B", "#315F9B", "#315F9B", "#325F9A", "#32609A", "#32619A", "#336199", - "#336399", "#336499", "#336499", "#336598", "#346598", "#346698", "#356697", "#356697", - "#376796", "#376796", "#386894", "#386A94", "#396A93", "#396B93", "#396B92", "#396C92", - "#3A6C92", "#3A6C92", "#3B6D91", "#3B6D91", "#3D6E90", "#3D6E90", "#3E708E", "#3E708E", - "#3F718D", "#3F728C", "#3F728C", "#40728C", "#40728C", "#41738B", "#41738B", "#43748A", - "#44768A", "#447688", "#457788", "#457787", "#467887", "#467986", "#467986", "#477985", - "#477A85", "#487A85", "#4A7B85", "#4A7D84", "#4B7D84", "#4C7E83", "#4C7F83", "#4C7F81", - "#4D7F81", "#4D8080", "#4E8080", "#508180", "#51837F", "#51847F", "#52857F", "#52857F", - "#53857F", "#54867F", "#55877E", "#55887E", "#578A7E", "#588A7D", "#598B7D", "#598C7D", - "#5A8C7D", "#5B8D7B", "#5B8E7B", "#5D907B", "#5E917A", "#5F927A", "#5F927A", "#60927A", - "#619379", "#639479", "#649679", "#659779", "#669879", "#669979", "#669979", "#679A79", - "#689B79", "#6A9D78", "#6B9E78", "#6C9F78", "#6C9F78", "#6DA077", "#6EA177", "#70A377", - "#71A477", "#72A576", "#72A576", "#73A676", "#74A776", "#76A974", "#77AA74", "#78AB74", - "#79AC74", "#79AC73", "#7AAD73", "#7BAE73", "#7DB073", "#7EB172", "#7FB272", "#7FB272", - "#80B372", "#81B472", "#83B672", "#84B772", "#85B872", "#85B871", "#86B971", "#87BA71", - "#88BB71", "#8ABD70", "#8BBE70", "#8CBF70", "#8CBF70", "#8DC06E", "#90C16E", "#91C36E", - "#92C46D", "#92C56D", "#93C66D", "#94C76D", "#96C96C", "#97CA6C", "#98CB6C", "#99CC6C", - "#9ACC6C", "#9BCD6C", "#9DCE6C", "#9ED06C", "#9FD26B", "#A0D26B", "#A1D36B", "#A3D46B", - "#A4D66A", "#A5D76A", "#A6D86A", "#A9D86A", "#AAD968", "#ACDA68", "#ACDD68", "#AEDE68", - "#B0DF67", "#B2DF67", "#B3E067", "#B4E167", "#B7E367", "#B8E467", "#BAE566", "#BDE566", - "#BFE666", "#BFE766", "#C1E966", "#C4EA66", "#C5EB66", "#C7EB66", "#CAEB66", "#CCEC66", - "#CEED66", "#D1EE66", "#D2F066", "#D4F066", "#D7F166", "#D8F266", "#DAF266", "#DDF366", - "#DFF366", "#E1F466", "#E4F666", "#E5F666", "#E7F766", "#EAF866", "#EBF866", "#EDF866", - "#F0F966", "#F2FA66", "#F4FA66", "#F7FC66", "#F8FD66", "#FAFE66", "#FDFE66", "#FFFF66", -]; - -/// Crameri lajolla (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const LAJOLLA: &[&str] = &[ - "#191900", "#191900", "#1A1900", "#1B1900", "#1D1A01", "#1E1A01", "#1E1A02", "#1F1B02", - "#1F1B04", "#201B04", "#211B05", "#221D05", "#241D06", "#241D06", "#251E06", "#261E07", - "#261E07", "#271F08", "#281F08", "#2A1F0A", "#2B1F0A", "#2C1F0B", "#2C1F0C", "#2D200C", - "#2E200C", "#30200C", "#31210D", "#32210E", "#33210E", "#332210", "#342210", "#352411", - "#372411", "#392412", "#392513", "#3A2513", "#3B2613", "#3D2613", "#3F2614", "#3F2615", - "#402615", "#432717", "#442717", "#452818", "#462819", "#472A19", "#4A2A19", "#4B2B1A", - "#4C2B1B", "#4D2C1D", "#502C1D", "#512C1E", "#522C1F", "#532D1F", "#552D20", "#572E20", - "#592E21", "#5A3022", "#5B3024", "#5E3125", "#5F3226", "#603226", "#633327", "#653328", - "#66332A", "#68342B", "#6A342C", "#6C352C", "#6D352C", "#70372D", "#72382E", "#733830", - "#763931", "#783932", "#793933", "#7A3A33", "#7D3A34", "#7F3B35", "#803B37", "#833D38", - "#853E39", "#863E39", "#883F39", "#8B3F3A", "#8C3F3B", "#8E3F3D", "#92403E", "#93403E", - "#96413F", "#98413F", "#99433F", "#9B4340", "#9E4441", "#9F4441", "#A14543", "#A44544", - "#A54644", "#A74645", "#AB4645", "#AC4646", "#AE4746", "#B14746", "#B24846", "#B44846", - "#B74A47", "#B84A47", "#BA4B47", "#BB4C48", "#BE4C48", "#BF4C48", "#C14C4A", "#C44D4A", - "#C54E4A", "#C6504B", "#C9514B", "#CA514B", "#CC524B", "#CC524C", "#CE534C", "#D0544C", - "#D1554C", "#D2584C", "#D3594C", "#D4594C", "#D65A4C", "#D75B4C", "#D75E4C", "#D85F4C", - "#D85F4D", "#D9604D", "#DA634D", "#DA644D", "#DC654D", "#DC664D", "#DD674D", "#DD684E", - "#DE6B4E", "#DE6C4E", "#DF6C4E", "#DF6D4E", "#DF704E", "#DF714E", "#DF724E", "#E0734E", - "#E07450", "#E07650", "#E07750", "#E17950", "#E17950", "#E17A50", "#E37B50", "#E37D50", - "#E37F50", "#E37F50", "#E48050", "#E48151", "#E48351", "#E48551", "#E58551", "#E58651", - "#E58751", "#E58851", "#E58B51", "#E58C51", "#E58C51", "#E58D51", "#E58E52", "#E69052", - "#E69252", "#E69252", "#E69352", "#E79452", "#E79652", "#E79752", "#E79852", "#E99952", - "#E99A52", "#E99B52", "#E99D52", "#EA9E52", "#EA9F52", "#EAA052", "#EAA152", "#EBA352", - "#EBA452", "#EBA552", "#EBA552", "#EBA752", "#EBA952", "#EBAA53", "#EBAB53", "#EBAC53", - "#ECAD53", "#ECAE53", "#ECB053", "#ECB153", "#EDB254", "#EDB354", "#EDB454", "#EEB654", - "#EEB855", "#EEB855", "#EEB955", "#F0BB57", "#F0BD57", "#F0BE58", "#F1BF58", "#F1C059", - "#F1C159", "#F1C459", "#F2C55A", "#F2C65B", "#F2C75D", "#F2CA5F", "#F2CB5F", "#F3CC60", - "#F3CD63", "#F3D065", "#F4D166", "#F4D268", "#F4D36B", "#F6D66C", "#F6D76E", "#F7D871", - "#F7D973", "#F7DC77", "#F8DD79", "#F8DF7B", "#F8DF7F", "#F8E181", "#F8E384", "#F9E586", - "#F9E58A", "#F9E68C", "#FAE790", "#FAEA92", "#FAEB96", "#FAEB99", "#FCEC9B", "#FCEE9F", - "#FCF0A1", "#FCF1A5", "#FDF2A7", "#FDF2AB", "#FDF3AD", "#FDF4B0", "#FEF6B2", "#FEF7B6", - "#FEF8B8", "#FEF8BA", "#FEF9BE", "#FEFABF", "#FFFCC3", "#FFFDC5", "#FFFEC7", "#FFFECB", -]; - -/// Crameri lapaz (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const LAPAZ: &[&str] = &[ - "#190C64", "#1A0D65", "#1A0E66", "#1A1066", "#1B1267", "#1B1367", "#1B1368", "#1D156A", - "#1D176B", "#1D186C", "#1E196C", "#1E196C", "#1E1B6D", "#1F1D6E", "#1F1E70", "#1F1F71", - "#1F1F71", "#1F2072", "#1F2272", "#1F2473", "#202574", "#202674", "#202676", "#202777", - "#212878", "#212A79", "#212B79", "#212C79", "#222C7A", "#222E7B", "#22307B", "#22317D", - "#24327E", "#24337F", "#24337F", "#24347F", "#253580", "#253781", "#253881", "#253983", - "#263984", "#263A84", "#263B85", "#263D85", "#263E86", "#263F86", "#263F87", "#264088", - "#274188", "#27438A", "#27458B", "#27468B", "#28468C", "#28478C", "#28488C", "#2A4A8D", - "#2A4B8D", "#2A4C8E", "#2B4C90", "#2B4D90", "#2B4E91", "#2C5091", "#2C5192", "#2C5292", - "#2C5292", "#2C5393", "#2C5493", "#2D5594", "#2D5794", "#2D5896", "#2E5996", "#2E5997", - "#305A97", "#305B98", "#305D98", "#315E99", "#315E99", "#325F99", "#325F99", "#336099", - "#33619A", "#33639A", "#33649B", "#34659B", "#34669B", "#35669D", "#37679D", "#37689D", - "#386A9E", "#386B9E", "#396C9E", "#396C9F", "#396D9F", "#3A6E9F", "#3B709F", "#3B709F", - "#3D719F", "#3E72A0", "#3E72A0", "#3F73A0", "#3F74A0", "#4076A1", "#4177A1", "#4178A1", - "#4379A1", "#4479A1", "#4579A1", "#467AA3", "#467BA3", "#477DA3", "#487EA3", "#4A7FA3", - "#4B7FA3", "#4C7FA3", "#4C80A3", "#4D81A3", "#4E83A4", "#5084A4", "#5185A4", "#5285A4", - "#5285A4", "#5386A4", "#5487A4", "#5587A4", "#5788A4", "#588AA3", "#598BA3", "#5A8BA3", - "#5B8CA3", "#5D8CA3", "#5E8DA3", "#5F8DA3", "#608EA3", "#6190A3", "#6390A1", "#6591A1", - "#6692A1", "#6692A1", "#6792A1", "#6A93A0", "#6B93A0", "#6C94A0", "#6D94A0", "#6E96A0", - "#70979F", "#72979F", "#72989F", "#73989F", "#76999F", "#77999F", "#78999F", "#79999E", - "#7A9A9E", "#7B9A9E", "#7E9B9D", "#7F9B9D", "#7F9D9D", "#819D9B", "#839E9B", "#859E9B", - "#859E9A", "#869F9A", "#889F99", "#8A9F99", "#8B9F99", "#8C9F99", "#8DA099", "#90A099", - "#91A198", "#92A198", "#93A198", "#94A398", "#97A397", "#98A497", "#99A497", "#9AA496", - "#9BA596", "#9EA596", "#9FA596", "#A0A596", "#A1A596", "#A4A694", "#A5A694", "#A6A794", - "#A7A794", "#AAA994", "#ABA994", "#ACAA94", "#ADAA94", "#B0AB94", "#B2AB96", "#B2AC96", - "#B4AC96", "#B7AC96", "#B8AD97", "#B9AE97", "#BBB098", "#BEB098", "#BFB199", "#C0B299", - "#C3B299", "#C5B39A", "#C6B49B", "#C7B69B", "#CAB79D", "#CCB89E", "#CDB89F", "#D0B99F", - "#D2BAA1", "#D2BBA3", "#D4BDA4", "#D7BEA5", "#D8BFA6", "#D9C0A7", "#DCC1AA", "#DEC3AB", - "#DFC5AC", "#E0C5AE", "#E3C6B0", "#E4C9B2", "#E5CAB3", "#E6CBB4", "#E7CCB7", "#EACDB8", - "#EBCEBA", "#EBD0BD", "#ECD2BE", "#EED2BF", "#F0D3C1", "#F1D4C4", "#F2D7C5", "#F2D8C7", - "#F3D8C9", "#F3D9CB", "#F4DACC", "#F6DDCE", "#F7DED1", "#F7DFD2", "#F8DFD3", "#F8E0D6", - "#F8E1D8", "#F9E3D9", "#F9E5DC", "#FAE5DD", "#FAE6DF", "#FAE7E0", "#FCE9E3", "#FCEAE5", - "#FCEBE5", "#FDEBE7", "#FDECEA", "#FDEDEB", "#FEEEEC", "#FEF0EE", "#FEF1F1", "#FEF2F2", -]; - -/// Crameri lipari (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const LIPARI: &[&str] = &[ - "#021326", "#041327", "#041528", "#04172B", "#05182C", "#05192E", "#051A31", "#051B33", - "#061D34", "#061F37", "#061F38", "#062039", "#06213B", "#06243E", "#07253F", "#072641", - "#082744", "#082846", "#0A2A47", "#0A2C4A", "#0B2C4B", "#0C2D4C", "#0C304E", "#0C3151", - "#0D3352", "#0E3354", "#103457", "#113758", "#133859", "#13395B", "#143A5E", "#153B5F", - "#183E60", "#193F63", "#1A3F64", "#1D4166", "#1E4367", "#1F4468", "#21466B", "#24466C", - "#25476C", "#26486E", "#284B70", "#2B4C71", "#2C4C72", "#2E4D72", "#314E73", "#335074", - "#355176", "#385277", "#395277", "#3B5378", "#3E5478", "#3F5579", "#405579", "#435779", - "#455879", "#465879", "#475979", "#4A5979", "#4C5979", "#4C597A", "#4E5A7A", "#505A79", - "#525A79", "#525B79", "#535B79", "#555B79", "#575B79", "#585D79", "#595D79", "#5A5D79", - "#5B5D79", "#5D5D79", "#5E5D79", "#5F5E78", "#605E78", "#615E78", "#635E78", "#645E77", - "#655E77", "#665E77", "#675E77", "#685E76", "#6A5E76", "#6B5F76", "#6C5F74", "#6C5F74", - "#6D5F74", "#705F73", "#715F73", "#725F73", "#725F73", "#735F72", "#765F72", "#775F72", - "#785F72", "#795F72", "#795F72", "#7B5F71", "#7D5F71", "#7E5F71", "#7F5F70", "#805F70", - "#815F70", "#835F6E", "#855F6E", "#855F6E", "#865F6D", "#885F6D", "#8A5F6D", "#8C5F6C", - "#8C606C", "#8D606C", "#90606C", "#91606C", "#92606B", "#93606B", "#96606B", "#97606A", - "#98606A", "#996068", "#9A6068", "#9D6168", "#9F6167", "#9F6167", "#A16166", "#A36166", - "#A56166", "#A56166", "#A76166", "#AA6165", "#AB6365", "#AC6365", "#AD6364", "#B06364", - "#B26364", "#B26363", "#B46363", "#B76461", "#B86461", "#B96461", "#BB6460", "#BE6560", - "#BF6560", "#C0655F", "#C3665F", "#C5665F", "#C5665F", "#C7665F", "#CA665F", "#CB675F", - "#CC675F", "#CE685E", "#D06A5E", "#D26A5E", "#D36B5E", "#D46C5E", "#D76C5E", "#D86D5E", - "#D96E5E", "#DA705F", "#DD715F", "#DE725F", "#DF725F", "#DF735F", "#E0765F", "#E1775F", - "#E37860", "#E47960", "#E57A61", "#E57B63", "#E67E63", "#E67F64", "#E78065", "#E78165", - "#E98466", "#E98566", "#E98667", "#E98767", "#EA8A68", "#EA8B6A", "#EA8C6B", "#EA8D6C", - "#EA906C", "#EA916D", "#EA926D", "#EA936E", "#E99470", "#E99771", "#E99872", "#E99972", - "#E99A73", "#E79B74", "#E79D76", "#E79E77", "#E79F77", "#E6A078", "#E6A179", "#E6A379", - "#E6A57A", "#E5A57B", "#E5A67D", "#E5A77E", "#E5AA7F", "#E5AB7F", "#E5AC80", "#E5AC81", - "#E5AE84", "#E5B085", "#E5B185", "#E5B286", "#E5B387", "#E5B48A", "#E5B68B", "#E5B88C", - "#E5B88D", "#E5B98E", "#E5BB90", "#E5BD92", "#E5BE92", "#E5BF94", "#E6C097", "#E6C398", - "#E6C499", "#E7C59B", "#E7C69D", "#E9C99F", "#E9CAA0", "#EACCA3", "#EACCA5", "#EBCEA6", - "#EBD0A9", "#EBD2AB", "#ECD2AC", "#ECD4AE", "#EDD6B1", "#EED8B2", "#EED9B4", "#F0DAB7", - "#F1DDB8", "#F2DEBA", "#F2DFBE", "#F2E0BF", "#F3E3C1", "#F4E5C4", "#F6E5C5", "#F6E7C9", - "#F7E9CB", "#F8EBCC", "#F8ECCE", "#F9EDD1", "#FAF0D3", "#FAF1D6", "#FCF2D8", "#FDF4D9", -]; - -/// Crameri lisbon (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const LISBON: &[&str] = &[ - "#E5E5FF", "#E3E3FD", "#DFE1FC", "#DDDFF9", "#DADEF8", "#D8DCF7", "#D4D9F4", "#D2D8F3", - "#D0D6F2", "#CCD3F1", "#CBD2EE", "#C7D0EC", "#C5CDEB", "#C3CCEA", "#BFCAE9", "#BDC7E6", - "#BAC5E5", "#B8C4E4", "#B4C1E1", "#B2BFE0", "#B0BEDF", "#ACBBDE", "#ABB9DC", "#A7B8D9", - "#A5B7D8", "#A3B4D7", "#9FB2D6", "#9EB1D3", "#9AAED2", "#98ACD1", "#96ABCE", "#92A9CD", - "#90A6CC", "#8DA5CB", "#8BA3C9", "#88A1C7", "#859FC5", "#849EC4", "#809BC3", "#7E99C0", - "#7B98BF", "#7996BE", "#7793BD", "#7392BA", "#7290B8", "#6E8DB8", "#6C8CB6", "#6A8BB4", - "#6788B2", "#6586B1", "#6185B0", "#5F83AD", "#5E80AC", "#5A7FAB", "#597DA9", "#557AA6", - "#5379A5", "#5177A4", "#4E74A1", "#4C729F", "#4A729E", "#47709D", "#456D9A", "#436C99", - "#406A97", "#3F6794", "#3D6692", "#396491", "#38618E", "#355F8C", "#335E8A", "#325B87", - "#305985", "#2E5984", "#2C5781", "#2B547F", "#2A527D", "#27517A", "#264E78", "#254D76", - "#244C73", "#214A71", "#20476E", "#1F466C", "#1F456A", "#1E4367", "#1D4166", "#1B3F64", - "#1A3F60", "#193D5F", "#193B5D", "#193959", "#183958", "#173755", "#173552", "#153351", - "#15334E", "#14314C", "#14304B", "#132D47", "#132C46", "#132C44", "#132A41", "#13283F", - "#12273E", "#12263B", "#122539", "#122437", "#112234", "#112133", "#112032", "#111F30", - "#111F2D", "#111E2C", "#111D2A", "#111B27", "#111A26", "#121A25", "#121924", "#121921", - "#121920", "#13191F", "#13191E", "#13181D", "#13181B", "#14181A", "#141819", "#151919", - "#171919", "#181919", "#181918", "#191918", "#191A18", "#1A1B17", "#1B1B17", "#1E1D17", - "#1F1E18", "#1F1F18", "#201F18", "#222018", "#242219", "#252419", "#262519", "#272619", - "#2A271A", "#2B281B", "#2C2B1B", "#2E2C1D", "#302C1E", "#322E1F", "#33301F", "#34321F", - "#373320", "#383421", "#393722", "#3B3824", "#3E3925", "#3F3B26", "#403D26", "#433F27", - "#453F28", "#46412A", "#47442B", "#4A452C", "#4C462C", "#4C482D", "#4E4B2E", "#514C31", - "#524D32", "#545033", "#575133", "#585234", "#595435", "#5B5737", "#5E5838", "#5F5939", - "#615B39", "#645E3B", "#665F3D", "#67603E", "#68633F", "#6B653F", "#6C6640", "#6E6843", - "#716B44", "#726C45", "#746D46", "#777046", "#797248", "#7A734A", "#7D764B", "#7F784C", - "#80794D", "#837A4E", "#857D50", "#867F52", "#888052", "#8B8354", "#8C8555", "#8E8658", - "#918859", "#938B5A", "#968C5B", "#988E5E", "#99915F", "#9B9260", "#9E9663", "#9F9865", - "#A19966", "#A49B68", "#A59E6A", "#A79F6C", "#ABA16D", "#ACA470", "#AEA672", "#B1A974", - "#B2AB77", "#B4AC79", "#B7AE7A", "#B8B17D", "#BAB27F", "#BDB681", "#BFB884", "#C0B986", - "#C3BB88", "#C5BE8B", "#C6BF8D", "#C9C390", "#CBC592", "#CCC694", "#CEC998", "#D1CB99", - "#D2CC9D", "#D4CE9F", "#D7D2A1", "#D8D3A4", "#D9D6A6", "#DCD8A9", "#DED9AC", "#DFDCAE", - "#E1DEB1", "#E4E0B3", "#E5E3B6", "#E7E5B8", "#EAE6BB", "#EBE9BE", "#EDEBC0", "#F0EDC3", - "#F1F0C5", "#F2F2C9", "#F4F3CB", "#F7F6CD", "#F8F8D1", "#FAFAD2", "#FDFDD6", "#FFFFD8", -]; - -/// Crameri managua (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const MANAGUA: &[&str] = &[ - "#FFCE66", "#FECC66", "#FDCB66", "#FAC965", "#F9C664", "#F8C564", "#F8C363", "#F6C061", - "#F4BF61", "#F3BD60", "#F2BA5F", "#F1B85F", "#F0B75F", "#EEB45E", "#EDB25E", "#EBB15D", - "#EBB05D", "#EAAD5B", "#E9AC5A", "#E7AA5A", "#E5A759", "#E5A559", "#E4A459", "#E3A358", - "#E0A058", "#DF9F57", "#DF9D55", "#DE9B55", "#DD9954", "#DA9854", "#D99653", "#D89453", - "#D89252", "#D79152", "#D49052", "#D38D51", "#D28C51", "#D28B50", "#D18850", "#CE864E", - "#CD854E", "#CC844D", "#CC814C", "#CB804C", "#C97F4C", "#C77E4C", "#C67B4B", "#C57A4B", - "#C5794A", "#C3774A", "#C17648", "#C07348", "#BF7247", "#BE7147", "#BD7046", "#BB6E46", - "#BA6C46", "#B86C46", "#B86A45", "#B76845", "#B66645", "#B36644", "#B26444", "#B26343", - "#B16143", "#AE5F41", "#AD5F41", "#AC5E41", "#AB5B40", "#AA5A40", "#A7593F", "#A6583F", - "#A5573F", "#A4543F", "#A3533F", "#A0523E", "#9F513E", "#9E503E", "#9D4E3D", "#9B4C3D", - "#994C3D", "#984B3B", "#97483B", "#94473B", "#93463A", "#92463A", "#91443A", "#8E433A", - "#8D4139", "#8C4039", "#8A3F39", "#883E39", "#863D39", "#853B39", "#843A39", "#813939", - "#803939", "#7F3839", "#7E3739", "#7B3539", "#793439", "#793339", "#773339", "#763239", - "#733139", "#723039", "#712E39", "#702E39", "#6D2D39", "#6C2C39", "#6B2C3A", "#6A2C3A", - "#682B3B", "#662B3B", "#662A3D", "#652A3D", "#632A3E", "#61283F", "#60283F", "#5F283F", - "#5F2840", "#5E2841", "#5D2743", "#5B2743", "#5A2744", "#592746", "#592846", "#582847", - "#572848", "#55284A", "#542A4C", "#532A4C", "#532A4D", "#522B50", "#522B51", "#522C52", - "#512C53", "#512C55", "#502D58", "#502E59", "#4E305A", "#4E315D", "#4D315E", "#4D325F", - "#4D3361", "#4C3364", "#4C3466", "#4C3567", "#4C376A", "#4C396B", "#4C396C", "#4C3A6E", - "#4C3B71", "#4C3D72", "#4C3F74", "#4C3F77", "#4C4079", "#4C437A", "#4C447D", "#4C457F", - "#4C4680", "#4C4783", "#4C4A85", "#4C4B85", "#4C4C87", "#4C4D8A", "#4C4E8C", "#4C518D", - "#4C5290", "#4D5391", "#4D5492", "#4D5794", "#4E5897", "#4E5998", "#4E5A99", "#505D9B", - "#505E9D", "#515F9F", "#5160A0", "#5163A1", "#5264A4", "#5266A5", "#5266A6", "#5268A7", - "#536AAA", "#536CAB", "#546DAC", "#546EAD", "#5571AE", "#5572B1", "#5773B2", "#5774B2", - "#5877B4", "#5878B6", "#5979B7", "#597AB8", "#597DB9", "#5A7EBA", "#5A7FBB", "#5B80BD", - "#5B83BF", "#5D85BF", "#5D85C0", "#5E87C1", "#5F88C3", "#5F8BC5", "#5F8CC5", "#5F8DC6", - "#6090C7", "#6192C9", "#6192CB", "#6394CC", "#6497CC", "#6498CD", "#6599CE", "#669BD0", - "#669ED2", "#669FD2", "#67A0D3", "#67A3D4", "#68A5D6", "#68A5D7", "#6AA7D8", "#6BAAD9", - "#6BACDA", "#6CACDC", "#6CAEDD", "#6CB1DE", "#6DB2DF", "#6EB4E0", "#6EB6E1", "#70B8E3", - "#71B9E4", "#72BBE5", "#72BEE5", "#72BFE6", "#73C1E9", "#73C4EA", "#74C5EB", "#76C6EB", - "#76C9EC", "#77CBED", "#78CCF0", "#78CEF1", "#79D1F2", "#79D2F2", "#79D4F3", "#7AD7F4", - "#7BD8F7", "#7BDAF8", "#7DDDF8", "#7EDFF9", "#7EE0FA", "#7FE3FD", "#7FE5FE", "#80E6FF", -]; - -/// Crameri navia (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const NAVIA: &[&str] = &[ - "#021326", "#041428", "#04152B", "#04172C", "#04182D", "#041930", "#051932", "#051A33", - "#051B35", "#051D38", "#051E39", "#051F3B", "#05203E", "#05213F", "#052241", "#052444", - "#052546", "#052647", "#06274A", "#06284C", "#062A4D", "#062B50", "#062C52", "#062D53", - "#062E55", "#063058", "#063159", "#06325B", "#06335E", "#06345F", "#063561", "#063764", - "#063966", "#073967", "#073A6A", "#073B6B", "#073E6C", "#083F6E", "#083F71", "#0A4072", - "#0A4373", "#0B4476", "#0B4578", "#0B4679", "#0C477A", "#0C487D", "#0C4A7E", "#0D4C7F", - "#0D4C80", "#0E4D81", "#104E84", "#105185", "#115285", "#125286", "#135387", "#135588", - "#13578A", "#14588B", "#15598C", "#17598C", "#175B8C", "#185D8D", "#195E8D", "#195F8E", - "#1A5F8E", "#1A6090", "#1B6190", "#1D6390", "#1E6491", "#1E6591", "#1F6691", "#1F6691", - "#1F6791", "#206891", "#216A91", "#216B91", "#226C91", "#246C91", "#246C91", "#256D90", - "#266E90", "#266E90", "#267090", "#26718E", "#27718E", "#27728E", "#28728E", "#2A728D", - "#2A738D", "#2B748D", "#2B748C", "#2C768C", "#2C768C", "#2C778C", "#2C788C", "#2D788C", - "#2E798B", "#2E798B", "#30798B", "#30798A", "#317A8A", "#317B8A", "#327B88", "#327D88", - "#337D88", "#337E87", "#337E87", "#347F87", "#347F86", "#357F86", "#357F85", "#378085", - "#378085", "#388185", "#398385", "#398385", "#398484", "#398484", "#3A8584", "#3B8583", - "#3B8583", "#3D8581", "#3D8681", "#3E8781", "#3F8780", "#3F8880", "#3F8880", "#3F8A7F", - "#408A7F", "#418B7F", "#418C7F", "#438C7F", "#448C7E", "#448C7E", "#458D7D", "#468E7D", - "#468E7D", "#46907B", "#47917B", "#48917A", "#48927A", "#4A9279", "#4B9279", "#4C9379", - "#4C9479", "#4C9479", "#4D9678", "#4E9778", "#509877", "#509877", "#519976", "#529976", - "#529A74", "#539A74", "#549B73", "#559D73", "#579E72", "#579F72", "#589F72", "#599F72", - "#59A071", "#5AA171", "#5BA370", "#5DA470", "#5EA56E", "#5FA56E", "#5FA66D", "#61A76D", - "#63A96C", "#64AA6C", "#65AB6C", "#66AC6C", "#66AC6B", "#68AD6B", "#6AAE6A", "#6BB06A", - "#6CB168", "#6DB268", "#70B268", "#71B368", "#72B467", "#73B667", "#76B767", "#78B867", - "#79B967", "#7ABA67", "#7DBB67", "#7FBD67", "#80BF67", "#83BF67", "#85C068", "#86C168", - "#88C36A", "#8CC56A", "#8DC56B", "#90C66C", "#92C76C", "#94CA6D", "#98CB6E", "#99CC70", - "#9BCC72", "#9FCD72", "#A1CE73", "#A4D176", "#A6D278", "#A9D279", "#ACD37A", "#ADD47D", - "#B1D67F", "#B2D780", "#B4D883", "#B8D885", "#B9D986", "#BBDA88", "#BFDC8B", "#C0DC8C", - "#C3DD90", "#C5DE92", "#C6DF93", "#C9DF96", "#CBDF98", "#CCE09A", "#CEE19D", "#D1E39F", - "#D2E3A0", "#D4E4A3", "#D7E5A5", "#D8E5A7", "#D9E5AA", "#DCE6AC", "#DDE6AD", "#DFE7B0", - "#DFE7B2", "#E1E9B3", "#E3EAB6", "#E5EAB8", "#E5EBB9", "#E7EBBB", "#E9EBBE", "#EAECBF", - "#EBECC0", "#ECEDC3", "#EDEDC5", "#EEEEC5", "#F0EEC7", "#F1F0CA", "#F2F0CB", "#F3F1CC", - "#F4F1CE", "#F6F2D0", "#F7F2D2", "#F8F2D2", "#F8F2D4", "#F9F2D6", "#FAF3D7", "#FCF3D8", -]; - -/// Sequential palette (204 colors) -/// Derived from Crameri navia with 20% of lightest colors removed -pub const SEQUENTIAL: &[&str] = &[ - "#021326", "#041428", "#04152B", "#04172C", "#04182D", "#041930", "#051932", "#051A33", - "#051B35", "#051D38", "#051E39", "#051F3B", "#05203E", "#05213F", "#052241", "#052444", - "#052546", "#052647", "#06274A", "#06284C", "#062A4D", "#062B50", "#062C52", "#062D53", - "#062E55", "#063058", "#063159", "#06325B", "#06335E", "#06345F", "#063561", "#063764", - "#063966", "#073967", "#073A6A", "#073B6B", "#073E6C", "#083F6E", "#083F71", "#0A4072", - "#0A4373", "#0B4476", "#0B4578", "#0B4679", "#0C477A", "#0C487D", "#0C4A7E", "#0D4C7F", - "#0D4C80", "#0E4D81", "#104E84", "#105185", "#115285", "#125286", "#135387", "#135588", - "#13578A", "#14588B", "#15598C", "#17598C", "#175B8C", "#185D8D", "#195E8D", "#195F8E", - "#1A5F8E", "#1A6090", "#1B6190", "#1D6390", "#1E6491", "#1E6591", "#1F6691", "#1F6691", - "#1F6791", "#206891", "#216A91", "#216B91", "#226C91", "#246C91", "#246C91", "#256D90", - "#266E90", "#266E90", "#267090", "#26718E", "#27718E", "#27728E", "#28728E", "#2A728D", - "#2A738D", "#2B748D", "#2B748C", "#2C768C", "#2C768C", "#2C778C", "#2C788C", "#2D788C", - "#2E798B", "#2E798B", "#30798B", "#30798A", "#317A8A", "#317B8A", "#327B88", "#327D88", - "#337D88", "#337E87", "#337E87", "#347F87", "#347F86", "#357F86", "#357F85", "#378085", - "#378085", "#388185", "#398385", "#398385", "#398484", "#398484", "#3A8584", "#3B8583", - "#3B8583", "#3D8581", "#3D8681", "#3E8781", "#3F8780", "#3F8880", "#3F8880", "#3F8A7F", - "#408A7F", "#418B7F", "#418C7F", "#438C7F", "#448C7E", "#448C7E", "#458D7D", "#468E7D", - "#468E7D", "#46907B", "#47917B", "#48917A", "#48927A", "#4A9279", "#4B9279", "#4C9379", - "#4C9479", "#4C9479", "#4D9678", "#4E9778", "#509877", "#509877", "#519976", "#529976", - "#529A74", "#539A74", "#549B73", "#559D73", "#579E72", "#579F72", "#589F72", "#599F72", - "#59A071", "#5AA171", "#5BA370", "#5DA470", "#5EA56E", "#5FA56E", "#5FA66D", "#61A76D", - "#63A96C", "#64AA6C", "#65AB6C", "#66AC6C", "#66AC6B", "#68AD6B", "#6AAE6A", "#6BB06A", - "#6CB168", "#6DB268", "#70B268", "#71B368", "#72B467", "#73B667", "#76B767", "#78B867", - "#79B967", "#7ABA67", "#7DBB67", "#7FBD67", "#80BF67", "#83BF67", "#85C068", "#86C168", - "#88C36A", "#8CC56A", "#8DC56B", "#90C66C", "#92C76C", "#94CA6D", "#98CB6E", "#99CC70", - "#9BCC72", "#9FCD72", "#A1CE73", "#A4D176", -]; - -/// Crameri nuuk (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const NUUK: &[&str] = &[ - "#05598C", "#06598C", "#08598C", "#0B598B", "#0C5A8B", "#0D5A8B", "#105A8A", "#115A8A", - "#135B88", "#135B88", "#145B88", "#175B87", "#185D87", "#195D87", "#195D86", "#1A5E86", - "#1B5E86", "#1E5E85", "#1F5F85", "#1F5F85", "#205F85", "#215F85", "#225F85", "#245F85", - "#256085", "#266084", "#266084", "#276184", "#286184", "#2A6383", "#2B6383", "#2C6383", - "#2C6483", "#2D6483", "#2E6583", "#306581", "#326681", "#336681", "#336681", "#346681", - "#356781", "#376781", "#386881", "#396881", "#3A6A81", "#3B6A81", "#3D6B81", "#3E6C81", - "#3F6C81", "#3F6C81", "#416C81", "#436D81", "#446E81", "#456E81", "#467083", "#477183", - "#487183", "#4A7283", "#4B7283", "#4C7284", "#4D7384", "#4E7484", "#517484", "#527685", - "#527785", "#547885", "#557885", "#577985", "#587985", "#597986", "#5A7A86", "#5B7B86", - "#5E7D87", "#5F7D87", "#5F7E87", "#617F88", "#637F88", "#64808A", "#66808A", "#66818A", - "#67838B", "#6A848B", "#6B848C", "#6C858C", "#6D858C", "#6E868C", "#70868C", "#72878D", - "#72888D", "#738A8E", "#768B8E", "#778B8E", "#788C90", "#798C90", "#7A8D91", "#7B8D91", - "#7D8E91", "#7F9092", "#7F9192", "#809192", "#819292", "#849292", "#859392", "#859393", - "#869493", "#889693", "#8A9794", "#8B9794", "#8C9894", "#8C9996", "#8D9996", "#909996", - "#919A96", "#929B96", "#929B97", "#939D97", "#949E97", "#969E97", "#979F97", "#989F97", - "#999F97", "#99A098", "#9AA198", "#9BA198", "#9DA398", "#9EA498", "#9FA498", "#9FA598", - "#A0A598", "#A1A598", "#A1A698", "#A3A698", "#A4A797", "#A5A797", "#A5A997", "#A5AA97", - "#A6AA97", "#A7AB97", "#A9AB97", "#A9AC97", "#AAAC96", "#ABAC96", "#ABAC96", "#ACAD96", - "#ACAD94", "#ACAE94", "#ADAE94", "#ADB094", "#AEB093", "#B0B193", "#B0B193", "#B1B193", - "#B1B292", "#B2B292", "#B2B292", "#B2B292", "#B2B392", "#B3B392", "#B3B491", "#B4B491", - "#B4B491", "#B6B690", "#B6B690", "#B7B790", "#B7B78E", "#B7B78E", "#B8B88D", "#B8B88D", - "#B8B88D", "#B8B88C", "#B9B88C", "#B9B98C", "#B9B98C", "#BABA8C", "#BABA8B", "#BBBA8B", - "#BBBB8B", "#BDBB8A", "#BDBD8A", "#BDBD8A", "#BEBD88", "#BEBE88", "#BFBE87", "#BFBF87", - "#BFBF87", "#BFBF86", "#BFBF86", "#C0C086", "#C0C085", "#C1C085", "#C1C185", "#C3C185", - "#C3C385", "#C4C385", "#C4C485", "#C5C484", "#C5C584", "#C5C584", "#C6C584", "#C6C684", - "#C7C683", "#C7C783", "#C9C983", "#CAC983", "#CACA83", "#CBCB83", "#CCCB83", "#CCCC83", - "#CCCC83", "#CDCD83", "#CECE84", "#D0CE84", "#D1D084", "#D2D184", "#D2D285", "#D2D285", - "#D3D385", "#D4D485", "#D6D686", "#D7D786", "#D8D887", "#D8D887", "#DAD988", "#DCDA8A", - "#DDDD8B", "#DEDE8C", "#DFDF8C", "#DFDF8D", "#E0E08E", "#E3E190", "#E4E391", "#E5E592", - "#E5E592", "#E6E693", "#E7E794", "#E9E997", "#EBEA98", "#EBEB99", "#ECEC99", "#EDED9B", - "#EEEE9D", "#F0F09E", "#F1F19F", "#F2F2A0", "#F2F2A3", "#F3F3A4", "#F4F4A5", "#F6F6A6", - "#F7F7A7", "#F8F8AA", "#F8F8AB", "#F9F9AC", "#FAFAAD", "#FCFCAE", "#FDFDB1", "#FEFEB2", -]; - -/// Crameri oleron (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const OLERON: &[&str] = &[ - "#192659", "#1A275A", "#1D285B", "#1E2B5E", "#1F2C5F", "#202D60", "#222E61", "#243164", - "#263265", "#273366", "#283467", "#2B376A", "#2C386B", "#2D396C", "#2E3A6E", "#313D70", - "#323F72", "#333F72", "#344174", "#374376", "#394578", "#394679", "#3B477A", "#3D4A7D", - "#3F4B7E", "#404C7F", "#414E81", "#445083", "#455285", "#465386", "#485487", "#4A578A", - "#4C598C", "#4D598C", "#4E5B8E", "#515E91", "#525F92", "#536093", "#556396", "#586497", - "#596699", "#5A679A", "#5D689B", "#5E6B9E", "#5F6C9F", "#616EA1", "#6470A3", "#6572A5", - "#6673A6", "#6874A9", "#6B77AA", "#6C79AC", "#6D7AAD", "#707DB0", "#727EB1", "#727FB2", - "#7481B4", "#7784B7", "#7985B8", "#7986B9", "#7B88BB", "#7E8BBE", "#7F8CBF", "#818DC0", - "#8390C3", "#8592C5", "#8693C6", "#8896C9", "#8B97CA", "#8C99CC", "#8D9ACD", "#909DD0", - "#929FD2", "#93A0D2", "#96A1D4", "#98A4D7", "#99A5D8", "#9AA7DA", "#9DAADC", "#9FACDE", - "#A0ACDF", "#A3AEE0", "#A4B1E3", "#A5B2E5", "#A7B4E5", "#AAB7E7", "#ACB8E9", "#ACB9EA", - "#AEBBEB", "#B1BDEC", "#B2BFED", "#B3C0EE", "#B6C1F0", "#B7C4F1", "#B8C5F2", "#B9C6F2", - "#BBC9F2", "#BDCAF3", "#BFCBF3", "#BFCCF4", "#C1CDF4", "#C3D0F6", "#C4D1F6", "#C5D2F7", - "#C6D3F7", "#C7D4F8", "#CAD6F8", "#CBD8F8", "#CCD8F8", "#CDD9F8", "#CEDCF8", "#D0DDF9", - "#D2DEF9", "#D2DFF9", "#D3E0FA", "#D6E1FA", "#D7E4FA", "#D8E5FC", "#D9E5FC", "#DAE7FC", - "#DCE9FD", "#DEEAFD", "#DFEBFD", "#DFECFE", "#E1EDFE", "#E3F0FE", "#E4F1FF", "#E5F2FF", - "#194C00", "#1D4C00", "#1F4D00", "#214E00", "#254E00", "#265000", "#2A5100", "#2C5100", - "#2E5200", "#315200", "#335300", "#345300", "#385400", "#395500", "#3B5500", "#3E5700", - "#3F5700", "#415800", "#445900", "#465900", "#485900", "#4B5A00", "#4C5B00", "#4E5B01", - "#515D01", "#525E01", "#545F02", "#575F04", "#595F05", "#5B6006", "#5E6106", "#5F6308", - "#63640A", "#65660C", "#66660D", "#6A6710", "#6C6812", "#6D6A13", "#716C15", "#726C18", - "#746D19", "#78701D", "#79711F", "#7B7220", "#7E7322", "#807425", "#837627", "#85782A", - "#86792C", "#8A7A2D", "#8C7B31", "#8D7D33", "#907F34", "#927F37", "#938139", "#97833B", - "#99843E", "#9A853F", "#9D8643", "#9F8845", "#A08A46", "#A38C48", "#A58C4C", "#A78E4D", - "#AA9050", "#AC9252", "#AD9254", "#B19457", "#B29759", "#B4985B", "#B7995E", "#B99B5F", - "#BB9D63", "#BE9F65", "#C0A066", "#C3A36A", "#C5A46C", "#C6A56D", "#CAA771", "#CCAA72", - "#CDAC74", "#D1AC78", "#D2AE79", "#D4B17B", "#D7B27F", "#D8B480", "#DCB684", "#DEB885", - "#DFB988", "#E1BB8B", "#E4BE8D", "#E5BF90", "#E6C192", "#E9C494", "#EAC598", "#EBC699", - "#ECC99D", "#EDCB9F", "#F0CCA1", "#F1CDA4", "#F2D0A6", "#F2D2A9", "#F2D3AB", "#F3D4AD", - "#F4D7B0", "#F4D8B2", "#F6D9B4", "#F6DCB7", "#F7DEB8", "#F7DFBB", "#F7E0BE", "#F8E1BF", - "#F8E4C3", "#F8E5C5", "#F8E6C6", "#F8E9C9", "#F8EACC", "#F9EBCD", "#F9EDD0", "#F9EED2", - "#FAF1D4", "#FAF2D7", "#FAF3D9", "#FAF6DC", "#FCF8DE", "#FCF8E0", "#FCFAE3", "#FDFDE5", -]; - -/// Crameri oslo (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const OSLO: &[&str] = &[ - "#000000", "#000102", "#010405", "#010506", "#020608", "#04060B", "#04070C", "#05080E", - "#050A10", "#060B12", "#060C13", "#060C14", "#070D15", "#070E17", "#081018", "#081119", - "#0A121A", "#0B131B", "#0B131D", "#0B131E", "#0C141F", "#0C151F", "#0C1521", "#0C1722", - "#0C1824", "#0C1925", "#0C1926", "#0C1927", "#0C1A28", "#0D1A2A", "#0D1B2C", "#0D1D2C", - "#0D1E2D", "#0D1E2E", "#0D1F31", "#0D1F32", "#0E2033", "#0E2034", "#0E2135", "#0E2237", - "#0E2439", "#0E2539", "#10263B", "#10263D", "#10263E", "#10273F", "#102840", "#112A43", - "#112B44", "#112B45", "#112C46", "#122C47", "#122D4A", "#122E4B", "#12304C", "#13314D", - "#13324E", "#133251", "#133352", "#133353", "#133454", "#133557", "#143758", "#143859", - "#14395A", "#15395D", "#153A5E", "#173A5F", "#173B60", "#173D63", "#183E64", "#183F66", - "#193F66", "#194068", "#19416A", "#19436C", "#19446C", "#1A456E", "#1A4670", "#1B4672", - "#1B4772", "#1D4774", "#1D4876", "#1E4A78", "#1E4B79", "#1F4C7A", "#1F4C7D", "#1F4D7E", - "#204E7F", "#205080", "#215183", "#225285", "#225285", "#245387", "#255488", "#26558B", - "#26578C", "#26588D", "#275990", "#285991", "#2A5A92", "#2B5B93", "#2C5D96", "#2C5E98", - "#2D5F99", "#2E5F9A", "#30609B", "#31619E", "#32639F", "#3364A0", "#3365A3", "#3566A4", - "#3766A5", "#3868A7", "#396AA9", "#3A6BAB", "#3D6CAC", "#3E6CAD", "#3F6DAE", "#4070B1", - "#4371B2", "#4472B2", "#4672B4", "#4674B6", "#4876B8", "#4B77B8", "#4C78B9", "#4D79BA", - "#507ABB", "#517BBD", "#527DBE", "#537EBF", "#557FBF", "#5880C0", "#5981C1", "#5A83C3", - "#5B84C3", "#5E85C4", "#5F85C5", "#6086C5", "#6387C5", "#6488C5", "#658AC6", "#668BC6", - "#678CC6", "#6A8CC7", "#6B8DC7", "#6C8EC7", "#6D90C7", "#6E90C9", "#7091C9", "#7292C9", - "#7292C9", "#7393C9", "#7493C9", "#7794C9", "#7896C9", "#7997CA", "#7998CA", "#7A98CA", - "#7D99CA", "#7E99CA", "#7F9ACA", "#7F9ACA", "#809BCA", "#839DCA", "#849ECA", "#859ECA", - "#859FCA", "#869FCA", "#889FCA", "#8AA0CA", "#8BA1C9", "#8CA3C9", "#8CA3C9", "#8DA4C9", - "#90A5C9", "#91A5C9", "#92A5C9", "#92A6C9", "#93A7C9", "#96A9C9", "#97A9C9", "#98AAC9", - "#99ABC9", "#99ACC9", "#9BACC9", "#9DACC9", "#9EADC9", "#9FAEC9", "#9FAEC9", "#A1B0CA", - "#A3B1CA", "#A4B2CA", "#A5B2CA", "#A6B3CA", "#A7B3CA", "#A9B4CA", "#AAB6CA", "#ACB7CA", - "#ACB8CB", "#ADB8CB", "#B0B9CB", "#B1BACB", "#B2BBCC", "#B3BDCC", "#B4BDCC", "#B6BECC", - "#B8BFCC", "#B8C0CC", "#B9C1CD", "#BBC3CD", "#BDC4CE", "#BFC5CE", "#BFC5D0", "#C1C6D1", - "#C3C7D1", "#C5C9D2", "#C5CBD2", "#C6CCD2", "#C9CCD3", "#CACDD4", "#CCD0D6", "#CDD1D7", - "#CED2D8", "#D1D2D8", "#D2D4D9", "#D3D6DA", "#D4D8DC", "#D7D8DD", "#D8D9DE", "#D9DCDF", - "#DADDDF", "#DDDEE0", "#DEDFE1", "#DFE0E4", "#E1E3E5", "#E3E4E5", "#E5E5E6", "#E5E6E9", - "#E7E9EA", "#E9EAEB", "#EBEBEC", "#EBECED", "#EDEDEE", "#EEF0F1", "#F1F1F2", "#F2F2F3", - "#F3F3F4", "#F6F6F6", "#F7F7F8", "#F8F8F8", "#F9F9FA", "#FCFCFC", "#FDFDFE", "#FFFFFF", -]; - -/// Crameri roma (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const ROMA: &[&str] = &[ - "#7E1700", "#7F1900", "#7F1D01", "#801F02", "#812104", "#832504", "#842605", "#852A06", - "#852C06", "#862D06", "#873007", "#8A3207", "#8B3308", "#8C350A", "#8C380B", "#8D390B", - "#8E3B0C", "#903E0C", "#903F0D", "#91410E", "#92440E", "#924610", "#934611", "#944812", - "#964B12", "#974C13", "#984D13", "#995013", "#995214", "#995215", "#9A5417", "#9B5717", - "#9D5818", "#9E5919", "#9E5B19", "#9F5D19", "#9F5F1A", "#A0601B", "#A1611B", "#A1641D", - "#A3651E", "#A4661E", "#A5671F", "#A56A1F", "#A56C1F", "#A66C20", "#A76E21", "#A97022", - "#A97222", "#AA7224", "#AB7425", "#AC7726", "#AC7826", "#AC7926", "#AD7B27", "#AE7D28", - "#B07F2A", "#B07F2B", "#B1812C", "#B2842C", "#B2852D", "#B3862E", "#B48830", "#B48B31", - "#B68C32", "#B78D33", "#B89033", "#B89234", "#B99337", "#B99438", "#BA9739", "#BB993A", - "#BD9A3B", "#BE9D3E", "#BF9F3F", "#BFA040", "#C0A343", "#C0A545", "#C1A546", "#C3A747", - "#C4AA4A", "#C5AC4C", "#C5AD4E", "#C6B051", "#C6B252", "#C7B354", "#C9B658", "#CAB859", - "#CBB95D", "#CBBB5F", "#CCBE61", "#CCBF64", "#CDC166", "#CDC46A", "#CEC56C", "#CEC76E", - "#D0CA72", "#D0CC74", "#D1CC78", "#D1CE7A", "#D2D17E", "#D2D280", "#D2D384", "#D2D686", - "#D2D78A", "#D2D88C", "#D2D990", "#D2DC92", "#D2DD96", "#D2DE98", "#D1DF9A", "#D1E09E", - "#D1E1A0", "#D0E3A3", "#D0E4A5", "#CEE5A7", "#CDE5AB", "#CCE5AC", "#CCE6B0", "#CCE6B2", - "#CBE7B3", "#C9E9B6", "#C7E9B8", "#C6E9B9", "#C5EABB", "#C4EABE", "#C3EABF", "#C0EAC1", - "#BFEAC3", "#BEEAC5", "#BDEAC5", "#BAEAC7", "#B8EAC9", "#B7EACA", "#B4EACC", "#B2E9CC", - "#B1E9CD", "#AEE7CE", "#ACE7D0", "#ABE6D1", "#A9E6D2", "#A5E5D2", "#A4E5D2", "#A1E5D3", - "#9FE4D3", "#9DE3D4", "#9AE1D4", "#98E0D6", "#94DFD6", "#92DFD6", "#90DED7", "#8DDDD7", - "#8BDCD7", "#88D9D7", "#85D8D7", "#83D8D7", "#80D7D7", "#7ED4D7", "#7AD3D7", "#79D2D7", - "#76D1D7", "#73CED6", "#71CDD6", "#6DCCD6", "#6CCBD6", "#68C9D4", "#66C6D4", "#64C5D4", - "#61C4D3", "#5FC1D3", "#5DC0D2", "#5ABFD2", "#59BDD2", "#57BBD2", "#54B9D1", "#52B8D1", - "#51B7D0", "#4EB4D0", "#4CB2CE", "#4BB2CD", "#48B0CD", "#46ADCC", "#46ACCC", "#44ABCC", - "#43A9CB", "#40A6CB", "#3FA5CA", "#3EA4C9", "#3DA1C9", "#3BA0C7", "#399FC6", "#399DC6", - "#389BC5", "#3799C5", "#3598C5", "#3497C4", "#3394C3", "#3393C3", "#3292C1", "#3190C0", - "#308EC0", "#308CBF", "#2E8CBF", "#2D8ABF", "#2C87BE", "#2C86BD", "#2C85BD", "#2B84BB", - "#2B81BA", "#2A80BA", "#287FB9", "#287EB8", "#277BB8", "#2779B8", "#2679B7", "#2677B7", - "#2676B6", "#2573B4", "#2572B4", "#2471B3", "#246EB2", "#226DB2", "#226CB2", "#216AB1", - "#2168B0", "#2066B0", "#2066AE", "#1F64AD", "#1F61AD", "#1F5FAC", "#1E5FAC", "#1E5DAB", - "#1D5AAB", "#1D59AA", "#1B58A9", "#1A55A7", "#1A53A7", "#1952A6", "#1951A5", "#194EA5", - "#184CA4", "#174CA4", "#154AA3", "#1447A1", "#1346A0", "#13449F", "#12419F", "#113F9F", - "#0E3F9E", "#0D3D9D", "#0C3A9B", "#0B399B", "#08379A", "#063499", "#053399", "#023198", -]; - -/// Crameri romaO (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const ROMAO: &[&str] = &[ - "#723957", "#733955", "#743953", "#743852", "#763851", "#773850", "#77384E", "#78384C", - "#79384C", "#79384B", "#793848", "#7A3847", "#7B3846", "#7B3946", "#7D3945", "#7E3943", - "#7E3941", "#7F3940", "#7F393F", "#803A3F", "#803A3E", "#813B3D", "#833B3B", "#843D3A", - "#843D39", "#853E39", "#853F38", "#863F37", "#863F37", "#874035", "#884134", "#8A4333", - "#8B4433", "#8C4533", "#8C4632", "#8C4631", "#8D4731", "#8E4830", "#904A30", "#914C2E", - "#924C2E", "#924D2D", "#93502D", "#93512C", "#94522C", "#96532C", "#97542C", "#98572C", - "#99582C", "#99592C", "#9A5A2C", "#9B5D2B", "#9D5F2B", "#9E5F2B", "#9F612B", "#9F642C", - "#A1662C", "#A3662C", "#A4682C", "#A56B2C", "#A56C2C", "#A66E2C", "#A7712D", "#A9722D", - "#AA742E", "#AB7730", "#AC7930", "#AD7A31", "#AE7D32", "#B07F33", "#B18033", "#B28334", - "#B38535", "#B48737", "#B68A39", "#B78C39", "#B88D3A", "#B8913D", "#BA923F", "#BB943F", - "#BD9841", "#BE9944", "#BF9B46", "#C09F46", "#C1A048", "#C3A34B", "#C4A54D", "#C5A750", - "#C5AA52", "#C7AC53", "#C9AE57", "#CAB159", "#CBB25B", "#CCB45E", "#CCB860", "#CDB963", - "#CEBB66", "#CEBE67", "#D0BF6B", "#D1C16D", "#D2C470", "#D2C572", "#D2C776", "#D3C978", - "#D3CB7A", "#D3CC7E", "#D4CD80", "#D4D083", "#D4D185", "#D6D287", "#D6D38B", "#D6D48D", - "#D6D790", "#D6D892", "#D4D894", "#D4D998", "#D4DA99", "#D3DC9B", "#D3DD9F", "#D2DDA0", - "#D2DEA3", "#D2DFA5", "#D1DFA6", "#D0DFA9", "#CEDFAB", "#CDDFAC", "#CCE0AE", "#CCE0B1", - "#CBE0B2", "#CAE0B4", "#C7E0B6", "#C6E0B8", "#C5E0B8", "#C4E0BA", "#C1E0BB", "#C0E0BE", - "#BFE0BF", "#BDDFBF", "#BADFC1", "#B8DFC3", "#B8DFC4", "#B6DEC5", "#B3DEC5", "#B1DDC6", - "#AEDCC7", "#ACDCC7", "#ABDAC9", "#A9D9CA", "#A6D8CB", "#A4D8CB", "#A1D7CC", "#9FD6CC", - "#9DD4CC", "#9AD3CC", "#99D2CD", "#96D1CD", "#93D0CD", "#92CECD", "#8ECDCE", "#8CCCCE", - "#8BCBCE", "#87C9CE", "#85C7CE", "#84C5CE", "#80C5CE", "#7FC3CE", "#7DC1CD", "#7ABFCD", - "#78BECD", "#76BDCD", "#73BACC", "#72B8CC", "#70B8CC", "#6DB6CC", "#6CB3CC", "#6AB2CB", - "#67B1CB", "#66AECA", "#65ACCA", "#63ABC9", "#61A9C9", "#5FA7C7", "#5FA5C6", "#5DA4C6", - "#5BA1C5", "#599FC5", "#599EC4", "#589BC4", "#579AC3", "#5599C1", "#5497C0", "#5394BF", - "#5292BF", "#5291BE", "#528EBD", "#518CBB", "#508BBA", "#508AB9", "#4E87B8", "#4E85B8", - "#4E84B7", "#4E81B6", "#4D7FB3", "#4D7EB2", "#4D7BB2", "#4D79B0", "#4E78AE", "#4E76AD", - "#4E74AC", "#4E72AB", "#5071A9", "#506EA7", "#516CA5", "#516BA4", "#5268A3", "#5266A0", - "#52669F", "#53649E", "#53619B", "#545F99", "#555F98", "#575D96", "#575A93", "#585992", - "#595891", "#59558E", "#5A548C", "#5B528B", "#5B5288", "#5D5086", "#5E4E85", "#5F4C83", - "#5F4C80", "#604B7F", "#61487D", "#63477A", "#634679", "#644677", "#654576", "#664473", - "#664372", "#674170", "#67406D", "#683F6C", "#6A3F6B", "#6B3F68", "#6C3E66", "#6C3D65", - "#6C3D64", "#6D3B61", "#6E3A5F", "#6E3A5F", "#70395D", "#71395B", "#723959", "#723959", -]; - -/// Crameri tofino (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const TOFINO: &[&str] = &[ - "#DED8FF", "#DAD7FE", "#D8D4FD", "#D6D2FA", "#D2D1F9", "#D0CEF8", "#CDCCF8", "#CBCBF7", - "#C7CAF6", "#C5C7F3", "#C3C5F2", "#BFC4F2", "#BEC1F1", "#BABFF0", "#B8BEEE", "#B6BBEC", - "#B2BAEB", "#B0B8EB", "#ADB7EA", "#ABB4E9", "#A7B2E7", "#A5B1E5", "#A3AEE5", "#9FACE4", - "#9EACE3", "#9AAAE1", "#98A7E0", "#96A5DF", "#92A4DE", "#90A1DD", "#8D9FDC", "#8B9FDA", - "#879DD8", "#859AD8", "#8399D7", "#7F97D4", "#7E94D3", "#7A92D2", "#7991D1", "#768ED0", - "#728CCD", "#718BCC", "#6D88CB", "#6B86CA", "#6885C7", "#6683C5", "#6480C5", "#607FC3", - "#5F7DC0", "#5B7ABF", "#5979BD", "#5777B9", "#5474B8", "#5272B6", "#5171B3", "#4E6EB1", - "#4C6CAE", "#4A6BAC", "#4868AA", "#4666A6", "#4565A5", "#4363A1", "#41619F", "#3F5F9D", - "#3E5E99", "#3D5B98", "#3B5994", "#395992", "#395790", "#38548C", "#37538B", "#345287", - "#335085", "#334E83", "#324C7F", "#314C7E", "#304A7A", "#2E4879", "#2D4676", "#2C4573", - "#2C4471", "#2B416E", "#2A406C", "#283F6A", "#273E66", "#263B65", "#263A61", "#25395F", - "#24385D", "#22375A", "#213459", "#213355", "#203253", "#1F3151", "#1F304E", "#1E2D4C", - "#1D2C4A", "#1B2B47", "#1B2A46", "#1A2843", "#192740", "#19263F", "#19253D", "#18243A", - "#172238", "#152035", "#151F33", "#141F32", "#141E30", "#131D2D", "#131B2C", "#131A2A", - "#121927", "#121926", "#111824", "#111822", "#111720", "#10151F", "#10151E", "#0E141D", - "#0E141A", "#0D1419", "#0D1419", "#0C1418", "#0C1417", "#0C1415", "#0C1414", "#0C1413", - "#0C1513", "#0C1513", "#0C1712", "#0C1712", "#0C1812", "#0D1912", "#0D1911", "#0E1A12", - "#0E1B12", "#101B12", "#101E12", "#111F12", "#111F13", "#112013", "#122113", "#122213", - "#132514", "#132614", "#132615", "#132815", "#142A17", "#142C18", "#152C18", "#152E19", - "#173019", "#183219", "#18331A", "#19341B", "#19351B", "#19381D", "#1A391E", "#1B3A1F", - "#1D3D1F", "#1D3E1F", "#1E3F20", "#1F4121", "#1F4322", "#1F4524", "#204625", "#214725", - "#224A26", "#224C26", "#244C27", "#254E28", "#26512A", "#26522B", "#26532C", "#27552C", - "#28582C", "#2A592D", "#2B5A2E", "#2C5D30", "#2C5F31", "#2C6032", "#2D6133", "#2E6433", - "#306634", "#316735", "#326A37", "#336C38", "#336C39", "#346E39", "#35713A", "#37723B", - "#38743D", "#39773E", "#39793F", "#3A7A3F", "#3D7D40", "#3E7F41", "#3F8044", "#408345", - "#418546", "#448646", "#468848", "#478B4A", "#4A8C4B", "#4C8E4C", "#4D914D", "#509250", - "#529451", "#549752", "#579953", "#599B55", "#5B9E57", "#5F9F59", "#61A159", "#64A45B", - "#66A55E", "#6AA75F", "#6CA960", "#70AB63", "#72AC64", "#76AE66", "#79B166", "#7BB268", - "#7FB36B", "#81B66C", "#85B86D", "#87B870", "#8BBA71", "#8DBD72", "#91BE73", "#93BF76", - "#97C178", "#99C379", "#9DC57A", "#9FC57B", "#A3C77E", "#A5CA7F", "#A9CB80", "#ACCC83", - "#AECD84", "#B2D085", "#B4D186", "#B7D288", "#B9D38A", "#BDD68C", "#BFD88C", "#C3D88E", - "#C5DA91", "#C9DC92", "#CCDE93", "#CEDF94", "#D2E097", "#D4E198", "#D8E499", "#DAE59A", -]; +pub use zygos::palettes::linetype::get_linetype_palette; +pub use zygos::palettes::shape::get_shape_palette; -/// Crameri tokyo (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const TOKYO: &[&str] = &[ - "#1B0D33", "#1D0E33", "#1F0E34", "#201034", "#221035", "#241035", "#261137", "#271138", - "#281238", "#2B1239", "#2C1339", "#2E1339", "#301339", "#32143A", "#33143B", "#34153B", - "#37153D", "#39173D", "#39183E", "#3B193E", "#3E193F", "#3F193F", "#401A3F", "#431B40", - "#441D40", "#461E41", "#471E43", "#481F43", "#4B1F44", "#4C2044", "#4D2145", "#4E2245", - "#512446", "#522646", "#522646", "#542746", "#552847", "#572A47", "#592B48", "#592C48", - "#5A2C48", "#5B2E4A", "#5D304A", "#5E314B", "#5F324B", "#5F334B", "#60334C", "#61344C", - "#63374C", "#64384C", "#64394C", "#65394C", "#663A4C", "#663B4D", "#663D4D", "#673E4D", - "#673F4D", "#683F4D", "#68404E", "#6A414E", "#6A434E", "#6A444E", "#6B454E", "#6B464E", - "#6C4650", "#6C4750", "#6C4850", "#6C4A50", "#6C4B50", "#6C4B50", "#6C4C50", "#6C4C50", - "#6D4D50", "#6D4E50", "#6D4E51", "#6D5051", "#6D5151", "#6E5251", "#6E5251", "#6E5251", - "#6E5351", "#6E5351", "#6E5451", "#6E5551", "#705551", "#705751", "#705851", "#705851", - "#705951", "#705951", "#705952", "#705A52", "#705A52", "#705B52", "#715B52", "#715D52", - "#715D52", "#715E52", "#715E52", "#715F52", "#715F52", "#715F52", "#716052", "#716052", - "#716152", "#716152", "#726352", "#726352", "#726452", "#726452", "#726552", "#726552", - "#726652", "#726652", "#726652", "#726752", "#726752", "#726852", "#726852", "#726A52", - "#726B52", "#726B52", "#726C52", "#726C52", "#726C52", "#726D52", "#736D52", "#736E52", - "#737052", "#737152", "#737152", "#737253", "#737253", "#737253", "#737353", "#747453", - "#747653", "#747753", "#747753", "#747853", "#747953", "#747953", "#767A53", "#767B53", - "#767B53", "#767D54", "#767E54", "#767F54", "#777F54", "#778054", "#778154", "#778354", - "#778454", "#788554", "#788555", "#788655", "#788755", "#788855", "#798A55", "#798B55", - "#798C57", "#798D57", "#798E57", "#799057", "#799157", "#799258", "#7A9358", "#7A9458", - "#7A9658", "#7A9759", "#7B9859", "#7B9959", "#7B9A59", "#7D9B59", "#7D9E59", "#7D9F5A", - "#7E9F5A", "#7EA15B", "#7FA35B", "#7FA45D", "#7FA55D", "#7FA65E", "#80A95E", "#80AA5F", - "#81AC5F", "#81AC5F", "#83AE60", "#84B061", "#84B263", "#85B264", "#85B465", "#86B666", - "#86B866", "#87B867", "#88BA68", "#8ABD6A", "#8BBE6B", "#8CBF6C", "#8CC06D", "#8DC370", - "#8EC571", "#91C572", "#92C773", "#92CA76", "#94CB78", "#96CC79", "#98CE7A", "#99D07D", - "#9AD27F", "#9BD380", "#9ED483", "#9FD785", "#A1D886", "#A4D98A", "#A5DC8C", "#A6DD8D", - "#A9DF90", "#ABDF92", "#ACE194", "#AEE398", "#B2E499", "#B3E59B", "#B6E69F", "#B8E7A0", - "#B9EAA4", "#BBEBA5", "#BEEBA9", "#C0ECAB", "#C3EDAC", "#C5EEB0", "#C6F0B2", "#C9F1B3", - "#CBF2B7", "#CCF2B8", "#CEF3BA", "#D1F3BD", "#D2F4BF", "#D4F6C0", "#D6F6C3", "#D8F7C5", - "#D9F7C6", "#DCF8C9", "#DDF8CA", "#DFF8CC", "#DFF8CD", "#E1F8CE", "#E3F9D1", "#E5F9D2", - "#E5F9D3", "#E7FAD4", "#E9FAD7", "#EAFAD8", "#EBFAD8", "#ECFCD9", "#EDFCDC", "#EEFCDD", -]; +use zygos::LinetypeStep; -/// Crameri turku (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const TURKU: &[&str] = &[ - "#000000", "#010101", "#040402", "#060605", "#060606", "#080807", "#0B0B0A", "#0C0C0C", - "#0D0D0C", "#10100D", "#111110", "#121211", "#131312", "#141413", "#151513", "#171714", - "#181815", "#191917", "#191918", "#1A1A19", "#1B1B19", "#1D1D19", "#1E1E1A", "#1F1F1B", - "#1F1F1D", "#20201E", "#21211F", "#22221F", "#24241F", "#252520", "#262621", "#262622", - "#272722", "#282824", "#2A2A25", "#2B2B26", "#2C2C26", "#2C2C26", "#2D2D27", "#2E2E28", - "#30302A", "#32312A", "#33322B", "#33332C", "#34332C", "#35352C", "#37372D", "#38382E", - "#393930", "#393930", "#3A3A31", "#3B3B32", "#3D3D32", "#3E3E33", "#3F3F33", "#3F3F33", - "#404034", "#414134", "#434335", "#444437", "#454537", "#464638", "#464638", "#474739", - "#484839", "#4B4A39", "#4C4B3A", "#4C4C3A", "#4D4C3B", "#4E4D3B", "#504E3D", "#51503D", - "#52513E", "#52523E", "#53523F", "#54533F", "#55553F", "#57573F", "#585840", "#595940", - "#595941", "#5A5A41", "#5B5B43", "#5D5D43", "#5E5E44", "#5F5F44", "#5F5F45", "#606045", - "#616146", "#636346", "#646446", "#656546", "#666646", "#666647", "#676747", "#6A6848", - "#6B6A48", "#6C6B4A", "#6C6C4A", "#6D6C4B", "#6E6D4B", "#706E4C", "#71704C", "#72714C", - "#72724C", "#74724D", "#76734D", "#77744E", "#78764E", "#797750", "#7A7950", "#7B7951", - "#7D7A51", "#7E7B52", "#7F7D52", "#807E52", "#817F52", "#837F53", "#858053", "#858154", - "#868355", "#888455", "#8A8557", "#8B8558", "#8C8658", "#8D8759", "#908A59", "#918B59", - "#928C5A", "#938C5A", "#968D5B", "#978E5D", "#99905E", "#99915E", "#9B925F", "#9D925F", - "#9F9360", "#A09360", "#A19461", "#A49663", "#A59764", "#A69864", "#A99965", "#AA9966", - "#AC9966", "#AC9A67", "#AE9B67", "#B09D68", "#B29D6A", "#B29E6B", "#B49F6C", "#B69F6C", - "#B89F6C", "#B89F6D", "#BAA06E", "#BBA070", "#BEA170", "#BFA171", "#BFA172", "#C1A372", - "#C3A373", "#C4A473", "#C5A474", "#C6A476", "#C7A477", "#C9A578", "#CBA578", "#CCA579", - "#CCA579", "#CDA57A", "#CEA57B", "#D0A57B", "#D1A57D", "#D2A57E", "#D2A57F", "#D3A67F", - "#D6A67F", "#D7A680", "#D8A681", "#D8A683", "#D9A684", "#DAA785", "#DAA785", "#DCA786", - "#DDA787", "#DEA788", "#DFA98A", "#DFA98B", "#E0A98C", "#E1A98C", "#E3AA8D", "#E4AA8E", - "#E5AA90", "#E5AB92", "#E6AB92", "#E7AC93", "#E9AC94", "#E9AC97", "#EAAC98", "#EBAD99", - "#EBAD9A", "#ECAE9B", "#EDB09E", "#EEB09F", "#EEB1A0", "#F0B2A1", "#F1B2A3", "#F2B2A5", - "#F2B3A6", "#F2B4A7", "#F3B6AA", "#F3B7AB", "#F4B8AC", "#F6B8AD", "#F6B9B0", "#F7BAB1", - "#F7BBB2", "#F8BDB3", "#F8BEB6", "#F8BFB7", "#F8BFB8", "#F9C0B9", "#F9C1BB", "#FAC3BD", - "#FAC4BF", "#FAC5BF", "#FCC5C0", "#FCC6C3", "#FCC7C4", "#FDC9C5", "#FDCBC6", "#FDCCC7", - "#FDCCCA", "#FDCDCB", "#FECECC", "#FED0CD", "#FED1CE", "#FED2D0", "#FED2D1", "#FED3D2", - "#FED6D3", "#FED7D4", "#FFD8D6", "#FFD8D7", "#FFD9D8", "#FFDAD9", "#FFDCDA", "#FFDDDC", - "#FFDEDD", "#FFDFDF", "#FFDFDF", "#FFE1E0", "#FFE3E1", "#FFE4E3", "#FFE5E5", "#FFE5E5", -]; - -/// Crameri vanimo (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const VANIMO: &[&str] = &[ - "#FFCCFD", "#FDCAFA", "#FCC6F8", "#F9C4F7", "#F8C0F3", "#F7BEF2", "#F6BAF0", "#F3B8ED", - "#F2B6EB", "#F1B2EA", "#F0B0E7", "#EDACE5", "#EBABE4", "#EBA7E1", "#E9A5DF", "#E7A1DE", - "#E59FDC", "#E59DD9", "#E399D8", "#E198D6", "#DF94D3", "#DE92D2", "#DD90D0", "#DA8DCD", - "#D98BCC", "#D888CA", "#D785C7", "#D484C5", "#D280C4", "#D27FC1", "#D07DBF", "#CE79BF", - "#CC78BD", "#CB76BA", "#CA72B8", "#C771B7", "#C66EB4", "#C56CB2", "#C36BB2", "#C168B0", - "#BF66AD", "#BE65AC", "#BD63AA", "#BA60A7", "#B85FA6", "#B85DA5", "#B65AA3", "#B359A0", - "#B2589F", "#B1559E", "#AE549B", "#AC5299", "#AB5198", "#A95096", "#A64D93", "#A54C92", - "#A34B90", "#A0488D", "#9F478C", "#9D468A", "#9A4487", "#994385", "#964084", "#933F81", - "#923E7F", "#903D7E", "#8C3B7A", "#8B3979", "#873977", "#853774", "#833572", "#803470", - "#7E336D", "#7B326B", "#793168", "#763066", "#732D64", "#712C61", "#6D2C5F", "#6C2B5D", - "#682A5A", "#662859", "#642655", "#602653", "#5E2551", "#5B244E", "#59224C", "#55214A", - "#532047", "#512046", "#4E1F44", "#4C1F40", "#481E3F", "#461D3D", "#451B3A", "#411B39", - "#3F1A37", "#3E1934", "#3A1933", "#391931", "#37192E", "#34182C", "#33172C", "#31172A", - "#2E1727", "#2D1526", "#2C1525", "#2A1424", "#281422", "#271420", "#26141F", "#25131F", - "#24131E", "#22131D", "#21131B", "#20131A", "#1F1319", "#1F1319", "#1E1318", "#1D1318", - "#1D1317", "#1B1315", "#1B1315", "#1A1314", "#1A1314", "#191313", "#191413", "#191413", - "#191413", "#191513", "#191512", "#191512", "#191712", "#191712", "#191811", "#191811", - "#191911", "#191911", "#191911", "#191A11", "#1A1B11", "#1A1D11", "#1A1E11", "#1B1F11", - "#1B1F11", "#1D2012", "#1D2112", "#1E2412", "#1F2512", "#1F2612", "#1F2613", "#202813", - "#212A13", "#222C13", "#222C13", "#242E13", "#253013", "#263214", "#263314", "#283415", - "#2A3715", "#2B3915", "#2C3A17", "#2C3B17", "#2D3E18", "#2E3F18", "#314118", "#324419", - "#334619", "#334619", "#354819", "#374B1A", "#384C1A", "#394E1B", "#3A511B", "#3B521D", - "#3D541D", "#3F551E", "#3F581F", "#40591F", "#435B1F", "#445E1F", "#455F20", "#466120", - "#476321", "#486522", "#4B6622", "#4C6824", "#4C6B24", "#4D6C25", "#506D25", "#517026", - "#527226", "#537326", "#547427", "#557728", "#577928", "#597A2A", "#597B2A", "#5A7E2B", - "#5B7F2C", "#5E802C", "#5F832C", "#5F852D", "#61862E", "#63872E", "#648A30", "#658C31", - "#668D32", "#679033", "#6A9233", "#6B9234", "#6C9435", "#6D9737", "#6E9938", "#719A39", - "#729D3A", "#739F3B", "#74A03D", "#77A33F", "#79A53F", "#79A741", "#7BAA43", "#7EAC45", - "#7FAD46", "#80B048", "#83B24B", "#85B44C", "#86B74E", "#87B951", "#8ABB52", "#8CBE54", - "#8DC058", "#90C359", "#92C55D", "#93C75F", "#96CB61", "#98CC65", "#99D067", "#9BD26B", - "#9ED46D", "#9FD772", "#A1D974", "#A4DD78", "#A5DF7B", "#A9E17F", "#ABE483", "#ACE685", - "#AEEA8A", "#B1EB8D", "#B2EE92", "#B4F294", "#B7F499", "#B9F79D", "#BBF9A0", "#BEFDA5", -]; - -/// Crameri vik (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const VIK: &[&str] = &[ - "#001260", "#001361", "#001463", "#001764", "#001865", "#001966", "#011B66", "#011D67", - "#011F68", "#011F6A", "#01216B", "#01226C", "#01256C", "#01266D", "#01276E", "#012A70", - "#012B71", "#012C72", "#012D72", "#013073", "#013174", "#013376", "#013377", "#013578", - "#013779", "#013979", "#01397A", "#023B7B", "#023E7D", "#023F7E", "#02407F", "#02417F", - "#024480", "#024581", "#024683", "#024884", "#024A85", "#044C85", "#044C86", "#044E87", - "#055188", "#05528A", "#06538B", "#06558C", "#06578C", "#07598E", "#085A90", "#0B5D91", - "#0C5E92", "#0D5F92", "#106193", "#116496", "#136697", "#146698", "#176899", "#196B99", - "#1B6C9B", "#1E6E9D", "#1F719E", "#22729F", "#2574A0", "#2777A1", "#2B79A4", "#2C7AA5", - "#307DA5", "#337FA7", "#3580A9", "#3983AB", "#3B85AC", "#3F86AC", "#4188AE", "#458BB0", - "#478CB2", "#4B90B2", "#4D92B3", "#5193B6", "#5396B7", "#5798B8", "#5999B9", "#5D9BBA", - "#609EBD", "#649FBE", "#66A1BF", "#6AA4C0", "#6CA5C1", "#71A7C4", "#73AAC5", "#77ACC5", - "#79ADC7", "#7DB0C9", "#7FB2CA", "#84B3CC", "#86B6CC", "#8AB8CD", "#8CB9D0", "#90BBD1", - "#93BED2", "#97BFD3", "#99C1D4", "#9DC4D6", "#9FC5D8", "#A3C6D8", "#A6C9D9", "#AACBDC", - "#ACCCDD", "#B0CEDE", "#B2D1DF", "#B6D2E0", "#B9D4E1", "#BDD6E3", "#BFD8E4", "#C3D9E5", - "#C5DAE5", "#C9DDE6", "#CCDFE7", "#CEDFE7", "#D2E0E9", "#D4E3E9", "#D8E4E9", "#DAE5E9", - "#DEE5E9", "#DFE5E9", "#E1E6E7", "#E5E6E7", "#E6E6E6", "#E7E6E5", "#EAE5E4", "#EBE5E1", - "#EBE5DF", "#ECE4DE", "#EDE3DC", "#EDE0D9", "#EDDFD8", "#EDDED4", "#EDDDD2", "#EDDAD0", - "#EDD8CC", "#ECD7CB", "#ECD4C7", "#EBD2C5", "#EBD1C3", "#EBD0BF", "#EACDBD", "#E9CCB9", - "#E9CAB8", "#E7C7B4", "#E6C5B2", "#E5C4B0", "#E5C0AC", "#E4BFAA", "#E4BEA7", "#E3BBA5", - "#E1B9A1", "#E0B89F", "#DFB69D", "#DFB399", "#DFB298", "#DEB094", "#DDAD92", "#DCAC90", - "#DAAA8C", "#DAA78B", "#D9A587", "#D8A485", "#D8A183", "#D79F80", "#D69F7E", "#D69D7B", - "#D49A79", "#D39977", "#D29773", "#D29472", "#D29370", "#D1926C", "#D0906B", "#CE8D67", - "#CE8C66", "#CD8B64", "#CC8860", "#CC865F", "#CC855D", "#CB8359", "#CA8158", "#C97F55", - "#C97E52", "#C77B51", "#C67A4E", "#C5794C", "#C5774A", "#C57447", "#C47345", "#C37243", - "#C17040", "#C16D3F", "#C06C3B", "#BF6B39", "#BF6838", "#BE6635", "#BE6533", "#BD6431", - "#BB612E", "#BA5F2C", "#B95E2A", "#B85B27", "#B75926", "#B65824", "#B45420", "#B2521F", - "#B2511D", "#B04E1A", "#AE4C18", "#AC4A15", "#AB4713", "#A94512", "#A64310", "#A53F0E", - "#A33E0C", "#A03B0B", "#9F390A", "#9B3708", "#993407", "#983306", "#963106", "#932E06", - "#912C06", "#8E2B06", "#8C2806", "#8B2606", "#882606", "#862406", "#852106", "#832006", - "#801F06", "#7F1E06", "#7E1D06", "#7B1A06", "#791906", "#781806", "#761706", "#731406", - "#721306", "#711306", "#6E1106", "#6C1006", "#6C0D06", "#6A0C06", "#670C06", "#660A06", - "#650806", "#630606", "#610606", "#5F0407", "#5E0207", "#5D0107", "#5A0007", "#590007", -]; - -/// Crameri vikO (256 colors) -/// Source: Fabio Crameri scientific colour maps -pub const VIKO: &[&str] = &[ - "#4E193D", "#4D193E", "#4D1A3F", "#4C1A40", "#4C1B41", "#4B1D43", "#4B1D44", "#4A1E46", - "#481F46", "#471F48", "#47204A", "#46204B", "#46214C", "#45224D", "#452450", "#442551", - "#432652", "#432653", "#412755", "#402858", "#3F2A59", "#3F2C5A", "#3F2C5D", "#3E2D5E", - "#3D2E5F", "#3D3161", "#3B3263", "#3A3365", "#393466", "#393567", "#39386A", "#38396C", - "#383A6D", "#373B6E", "#353E71", "#353F72", "#344073", "#344376", "#334478", "#334679", - "#33477A", "#33487D", "#334B7F", "#334C7F", "#334E81", "#335184", "#335285", "#335386", - "#335588", "#33588A", "#34598C", "#345B8C", "#355E8E", "#375F90", "#386092", "#396393", - "#396594", "#3A6697", "#3B6898", "#3D6B99", "#3F6C9A", "#3F6E9B", "#41719E", "#43729F", - "#4574A0", "#4677A1", "#4879A4", "#4B7AA5", "#4C7DA5", "#4E7FA7", "#5180A9", "#5283AB", - "#5485AC", "#5786AC", "#5988AE", "#5B8BB0", "#5E8CB1", "#608EB2", "#6391B3", "#6692B4", - "#6794B6", "#6B97B7", "#6C98B8", "#7099B9", "#729BBA", "#749EBB", "#789FBD", "#79A1BE", - "#7DA4BF", "#7FA5BF", "#83A6C0", "#85A9C1", "#87AAC1", "#8BACC3", "#8DADC4", "#90AEC5", - "#92B1C5", "#96B2C5", "#99B3C5", "#9AB4C6", "#9EB6C6", "#A0B8C7", "#A4B8C7", "#A5B9C7", - "#A9BAC7", "#ABBBC7", "#ADBDC7", "#B0BEC7", "#B2BFC7", "#B4BFC7", "#B8BFC6", "#B9C0C6", - "#BBC0C5", "#BEC1C5", "#BFC1C5", "#C1C1C4", "#C4C1C3", "#C5C3C1", "#C7C3C0", "#C9C1BF", - "#CBC1BF", "#CCC1BE", "#CDC1BB", "#CEC0BA", "#D1C0B8", "#D2BFB8", "#D2BFB6", "#D3BFB4", - "#D4BEB2", "#D4BDB1", "#D6BDAE", "#D7BBAC", "#D7BAAC", "#D8B9AA", "#D8B8A7", "#D8B7A5", - "#D8B6A4", "#D8B4A0", "#D8B39F", "#D8B29D", "#D8B19A", "#D8AE99", "#D8AD97", "#D8AC93", - "#D8AB92", "#D8A990", "#D8A78C", "#D8A58B", "#D8A588", "#D7A386", "#D7A084", "#D69F81", - "#D49E7F", "#D49B7D", "#D3997A", "#D39879", "#D29776", "#D29473", "#D19272", "#D0916E", - "#D08E6C", "#CE8C6B", "#CD8B67", "#CC8A66", "#CC8764", "#CB8560", "#CA845F", "#C7815D", - "#C67F5A", "#C57E58", "#C57B55", "#C47953", "#C17752", "#C0744E", "#BF724C", "#BE714B", - "#BD6E48", "#BA6C46", "#B96A45", "#B86743", "#B76640", "#B4643F", "#B2613D", "#B25F3A", - "#B05D39", "#AD5A37", "#AC5934", "#AA5533", "#A95332", "#A65230", "#A5502E", "#A34C2C", - "#A04B2C", "#9F482A", "#9D4628", "#9A4527", "#994326", "#974026", "#943E25", "#923B24", - "#913A22", "#8E3921", "#8C3720", "#8C3420", "#8A331F", "#87311F", "#85301F", "#842D1F", - "#812C1F", "#802B1E", "#7F281E", "#7D271E", "#7B261E", "#79251E", "#79241E", "#77221E", - "#76211F", "#73201F", "#721F1F", "#721F1F", "#701E1F", "#6E1D1F", "#6D1B1F", "#6C1A20", - "#6B1920", "#6A1921", "#681921", "#671922", "#661822", "#661824", "#651725", "#641725", - "#631526", "#611526", "#601526", "#5F1527", "#5F1528", "#5E142A", "#5D142B", "#5B142B", - "#5A142C", "#59142C", "#59142D", "#59142E", "#581530", "#571531", "#551532", "#551533", - "#541533", "#531734", "#521735", "#521737", "#521838", "#511839", "#501939", "#50193B", -]; - -// ============================================================================= -// Shape Palettes -// ============================================================================= - -/// Closed shapes - shapes with enclosed area/fill -pub const SHAPES_CLOSED: &[&str] = &[ - "circle", - "square", - "diamond", - "triangle-up", - "triangle-down", - "star", - "square-cross", - "circle-plus", - "square-plus", -]; - -/// Stroke shapes - shapes made of strokes/lines (no enclosed area) -pub const SHAPES_OPEN: &[&str] = &[ - "cross", // X shape - "plus", // + shape - "asterisk", // * shape (6-pointed) - "bowtie", // two triangles pointing inward - "hline", // horizontal line - "vline", // vertical line -]; - -/// Default point shapes - combined palette ordered by distinguishability -pub const SHAPES: &[&str] = &[ - // closed shapes first - "circle", - "square", - "diamond", - "triangle-up", - "triangle-down", - "star", - "square-cross", - "circle-plus", - "square-plus", - // Open shapes - "cross", - "plus", - "asterisk", - "bowtie", - "hline", - "vline", -]; - -// ============================================================================= -// Linetype Palettes -// ============================================================================= - -/// Default linetypes ordered by distinguishability. -/// These map to ggplot2 linetype names and Vega-Lite strokeDash arrays. -pub const LINETYPES: &[&str] = &[ - "solid", "dashed", "dotted", "dotdash", "longdash", "twodash", -]; - -// ============================================================================= -// Lookup Functions -// ============================================================================= +use super::colour::color_to_css_hex; +use crate::plot::ArrayElement; -/// Look up a color palette by name. -/// Returns the palette colors as a static slice, or None if not found. -pub fn get_color_palette(name: &str) -> Option<&'static [&'static str]> { - match name.to_lowercase().as_str() { - // Categorical - "ggsql10" | "ggsql" | "categorical" => Some(GGSQL10), - "tableau10" | "tableau" => Some(TABLEAU10), - "category10" | "d3" => Some(CATEGORY10), - "set1" => Some(SET1), - "set2" => Some(SET2), - "set3" => Some(SET3), - "pastel1" => Some(PASTEL1), - "pastel2" => Some(PASTEL2), - "dark2" => Some(DARK2), - "paired" => Some(PAIRED), - "accent" => Some(ACCENT), - "kelly22" | "kelly" => Some(KELLY22), - // Continuous sequential colormaps - "viridis" => Some(VIRIDIS), - "plasma" => Some(PLASMA), - "magma" => Some(MAGMA), - "inferno" => Some(INFERNO), - "cividis" => Some(CIVIDIS), - // ColorBrewer sequential - "blues" => Some(BLUES), - "greens" => Some(GREENS), - "oranges" => Some(ORANGES), - "reds" => Some(REDS), - "purples" => Some(PURPLES), - "greys" | "grays" => Some(GREYS), - "ylorrd" => Some(YLORRD), - "ylorbr" => Some(YLORBR), - "ylgnbu" => Some(YLGNBU), - "ylgn" => Some(YLGN), - "purd" => Some(PURD), - "pubugn" => Some(PUBUGN), - "pubu" => Some(PUBU), - "orrd" => Some(ORRD), - "gnbu" => Some(GNBU), - "bupu" => Some(BUPU), - "bugn" => Some(BUGN), - "rdpu" => Some(RDPU), - // ColorBrewer diverging - "rdbu" => Some(RDBU), - "rdylbu" => Some(RDYLBU), - "rdylgn" => Some(RDYLGN), - "spectral" => Some(SPECTRAL), - "brbg" => Some(BRBG), - "prgn" => Some(PRGN), - "piyg" => Some(PIYG), - "rdgy" => Some(RDGY), - "puor" => Some(PUOR), - // Crameri scientific colour maps sequential - "acton" => Some(ACTON), - "bamako" => Some(BAMAKO), - "batlow" => Some(BATLOW), - "batlowk" => Some(BATLOWK), - "batloww" => Some(BATLOWW), - "bilbao" => Some(BILBAO), - "buda" => Some(BUDA), - "davos" => Some(DAVOS), - "devon" => Some(DEVON), - "glasgow" => Some(GLASGOW), - "grayc" => Some(GRAYC), - "hawaii" => Some(HAWAII), - "imola" => Some(IMOLA), - "lajolla" => Some(LAJOLLA), - "lapaz" => Some(LAPAZ), - "lipari" => Some(LIPARI), - "navia" => Some(NAVIA), - "sequential" => Some(SEQUENTIAL), - "nuuk" => Some(NUUK), - "oslo" => Some(OSLO), - "tokyo" => Some(TOKYO), - "turku" => Some(TURKU), - // Crameri scientific colour maps multi-sequential - "bukavu" => Some(BUKAVU), - "fes" => Some(FES), - "oleron" => Some(OLERON), - // Crameri scientific colour maps diverging - "bam" => Some(BAM), - "berlin" => Some(BERLIN), - "broc" => Some(BROC), - "cork" => Some(CORK), - "lisbon" => Some(LISBON), - "managua" => Some(MANAGUA), - "roma" => Some(ROMA), - "tofino" => Some(TOFINO), - "vanimo" => Some(VANIMO), - "vik" | "diverging" => Some(VIK), - // Crameri scientific colour maps cyclical - "bamo" => Some(BAMO), - "broco" => Some(BROCO), - "corko" => Some(CORKO), - "romao" | "cyclic" => Some(ROMAO), - "viko" => Some(VIKO), - _ => None, - } +/// Look up a color palette by name, as hex color strings ("#rrggbb"). +pub fn get_color_palette(name: &str) -> Option> { + zygos::palettes::color::get_color_palette(name) + .map(|colors| colors.iter().map(color_to_css_hex).collect()) } -/// Look up a shape palette by name. -pub fn get_shape_palette(name: &str) -> Option<&'static [&'static str]> { - match name.to_lowercase().as_str() { - "shapes" => Some(SHAPES), - "shapes_closed" | "closed" | "default" => Some(SHAPES_CLOSED), - "shapes_open" | "open" => Some(SHAPES_OPEN), - _ => None, - } +/// Generate linetypes with evenly-spaced ink densities, as ggplot2-style +/// hex patterns ("1f", "88") with "solid" last — the string vocabulary the +/// linetype aesthetic's output range uses. +pub fn generate_linetype_sequential(count: usize) -> Vec { + zygos::palettes::linetype::generate_linetype_sequential(count) + .iter() + .map(|steps| steps_to_hex_pattern(steps)) + .collect() } -/// Look up a linetype palette by name. -pub fn get_linetype_palette(name: &str) -> Option<&'static [&'static str]> { - match name.to_lowercase().as_str() { - "categorical" | "default" => Some(LINETYPES), - _ => None, +/// Convert a generated Dash/Gap pattern back to its hex string form. The +/// sequential generator only emits whole-number Dash/Gap steps (an empty +/// pattern is "solid"), so the conversion is lossless. +fn steps_to_hex_pattern(steps: &[LinetypeStep]) -> String { + if steps.is_empty() { + return "solid".to_string(); } + steps + .iter() + .filter_map(|step| match step { + LinetypeStep::Dash(v) | LinetypeStep::Gap(v) => char::from_digit(*v as u32, 16), + LinetypeStep::Marker(_) => None, + }) + .collect() } /// Look up a palette by aesthetic and name, returning as ArrayElements. @@ -2115,17 +60,10 @@ pub fn get_linetype_palette(name: &str) -> Option<&'static [&'static str]> { /// Returns `Ok(Vec)` with the palette values, or an error if: /// - The aesthetic doesn't support palettes /// - The palette name is unknown -/// -/// # Example -/// -/// ```ignore -/// let colors = lookup_palette("fill", "viridis")?; -/// let shapes = lookup_palette("shape", "default")?; -/// ``` pub fn lookup_palette(aesthetic: &str, name: &str) -> Result, String> { - let palette = match aesthetic { - "shape" => get_shape_palette(name), - "linetype" => get_linetype_palette(name), + let strings: Vec = match aesthetic { + "shape" => get_shape_palette(name).map(|p| p.iter().map(|s| s.to_string()).collect()), + "linetype" => get_linetype_palette(name).map(|p| p.iter().map(|s| s.to_string()).collect()), "color" | "fill" | "stroke" => get_color_palette(name), _ => { return Err( @@ -2136,84 +74,7 @@ pub fn lookup_palette(aesthetic: &str, name: &str) -> Result, } .ok_or_else(|| format!("Unknown {} palette: '{}'", aesthetic, name))?; - Ok(palette - .iter() - .map(|s| ArrayElement::String(s.to_string())) - .collect()) -} - -/// Generate linetypes with evenly-spaced ink densities. -/// -/// Creates `count` linetypes ranging from sparse (low ink) to solid (100% ink). -/// Each linetype is represented as a hex string pattern (e.g., "1f" for ~6% ink, -/// "88" for 50% ink) or "solid" for 100% ink. -/// -/// Hex patterns use digits 1-9 and a-f (values 1-15) for on/off lengths. -/// With a cycle of 16 units, ink densities range from 6.25% to 93.75%, -/// plus solid for 100%. -/// -/// # Examples -/// -/// ``` -/// use ggsql::plot::scale::palettes::generate_linetype_sequential; -/// -/// // Generate 3 linetypes -/// let types = generate_linetype_sequential(3); -/// assert_eq!(types, vec!["1f", "88", "solid"]); // 6.25%, 50%, 100% -/// -/// // Generate 5 linetypes with evenly spaced densities -/// let types = generate_linetype_sequential(5); -/// assert_eq!(types.len(), 5); -/// assert_eq!(types[4], "solid"); // Last is always solid -/// ``` -/// -/// # Arguments -/// -/// * `count` - Number of linetypes to generate (minimum 1) -/// -/// # Returns -/// -/// Vector of linetype strings, ordered from least to most ink. -pub fn generate_linetype_sequential(count: usize) -> Vec { - /// Minimum ink density: 1/16 ≈ 6.25% - const MIN_INK_RATIO: f64 = 1.0 / 16.0; - /// Maximum ink density for non-solid: 15/16 ≈ 93.75% - const MAX_INK_RATIO: f64 = 15.0 / 16.0; - - if count == 0 { - return vec![]; - } - if count == 1 { - return vec!["solid".to_string()]; - } - - let mut result = Vec::with_capacity(count); - - // Generate evenly spaced ink percentages from MIN_INK_RATIO to 100% - // The last one is always solid (100%) - for i in 0..count { - let t = i as f64 / (count - 1) as f64; - - if i == count - 1 { - // Last one is always solid (100% ink) - result.push("solid".to_string()); - } else { - // Interpolate between MIN_INK_RATIO and MAX_INK_RATIO - let ink_pct = MIN_INK_RATIO + t * (MAX_INK_RATIO - MIN_INK_RATIO); - - // Create hex pattern with cycle of 16 units - // on / 16 = ink_pct, so on = ink_pct * 16 - let on = (ink_pct * 16.0).round() as u32; - let on = on.clamp(1, 15); - let off = 16 - on; - let off = off.clamp(1, 15); - - // Format as two hex digits (1-9, a-f) - result.push(format!("{:x}{:x}", on, off)); - } - } - - result + Ok(strings.into_iter().map(ArrayElement::String).collect()) } #[cfg(test)] @@ -2222,213 +83,51 @@ mod tests { #[test] fn test_get_color_palette() { - assert!(get_color_palette("viridis").is_some()); + let viridis = get_color_palette("viridis").unwrap(); + assert_eq!(viridis.len(), 256); + assert_eq!(viridis[0], "#440154"); assert!(get_color_palette("VIRIDIS").is_some()); // case insensitive - assert!(get_color_palette("tableau10").is_some()); - assert!(get_color_palette("ggsql10").is_some()); - assert!(get_color_palette("ggsql").is_some()); // alias - assert!(get_color_palette("kelly22").is_some()); - assert!(get_color_palette("kelly").is_some()); // alias - // ColorBrewer sequential - assert!(get_color_palette("greys").is_some()); - assert!(get_color_palette("grays").is_some()); // alias - assert!(get_color_palette("ylgnbu").is_some()); - assert!(get_color_palette("bugn").is_some()); - assert!(get_color_palette("rdpu").is_some()); - // Crameri scientific colour maps - assert!(get_color_palette("batlow").is_some()); - assert!(get_color_palette("BATLOW").is_some()); // case insensitive - assert!(get_color_palette("hawaii").is_some()); - assert!(get_color_palette("roma").is_some()); - assert!(get_color_palette("vik").is_some()); - assert!(get_color_palette("turku").is_some()); assert!(get_color_palette("unknown").is_none()); } - #[test] - fn test_crameri_palette_size() { - // All Crameri palettes should have 256 colors - assert_eq!(get_color_palette("batlow").unwrap().len(), 256); - assert_eq!(get_color_palette("hawaii").unwrap().len(), 256); - assert_eq!(get_color_palette("roma").unwrap().len(), 256); - assert_eq!(get_color_palette("vik").unwrap().len(), 256); - assert_eq!(get_color_palette("acton").unwrap().len(), 256); - } - - #[test] - fn test_sequential_palette() { - // Sequential is derived from navia with 20% lightest colors removed - assert_eq!(get_color_palette("sequential").unwrap().len(), 204); - assert_eq!(get_color_palette("navia").unwrap().len(), 256); - // First colors should match - assert_eq!( - get_color_palette("sequential").unwrap()[0], - get_color_palette("navia").unwrap()[0] - ); - } - - #[test] - fn test_get_shape_palette() { - // Combined palette (all shapes) - assert!(get_shape_palette("shapes").is_some()); - assert_eq!(get_shape_palette("shapes").unwrap().len(), 15); - - // Closed shapes palette (default) - assert!(get_shape_palette("shapes_closed").is_some()); - assert!(get_shape_palette("closed").is_some()); - assert!(get_shape_palette("default").is_some()); - assert_eq!(get_shape_palette("closed").unwrap().len(), 9); - assert_eq!(get_shape_palette("default").unwrap().len(), 9); - - // Open shapes palette - assert!(get_shape_palette("shapes_open").is_some()); - assert!(get_shape_palette("open").is_some()); - assert_eq!(get_shape_palette("open").unwrap().len(), 6); - - // Case insensitivity - assert!(get_shape_palette("SHAPES").is_some()); - assert!(get_shape_palette("Closed").is_some()); - - // Unknown palette - assert!(get_shape_palette("unknown").is_none()); - } - - #[test] - fn test_get_linetype_palette() { - // Default palette - assert!(get_linetype_palette("categorical").is_some()); - assert!(get_linetype_palette("default").is_some()); - assert_eq!(get_linetype_palette("categorical").unwrap().len(), 6); - - // Case insensitivity - assert!(get_linetype_palette("CATEGORICAL").is_some()); - assert!(get_linetype_palette("Categorical").is_some()); - - // Unknown palette - assert!(get_linetype_palette("unknown_lt").is_none()); - } - #[test] fn test_generate_linetype_sequential() { - // Empty assert_eq!(generate_linetype_sequential(0), Vec::::new()); - - // Single = solid assert_eq!(generate_linetype_sequential(1), vec!["solid"]); - - // Two = min ink + solid - let two = generate_linetype_sequential(2); - assert_eq!(two.len(), 2); - assert_eq!(two[0], "1f"); // 1/16 ≈ 6.25% ink - assert_eq!(two[1], "solid"); - - // Three = min, mid, solid - let three = generate_linetype_sequential(3); - assert_eq!(three.len(), 3); - assert_eq!(three[0], "1f"); // ~6.25% ink - assert_eq!(three[1], "88"); // 50% ink - assert_eq!(three[2], "solid"); - - // Five linetypes - let five = generate_linetype_sequential(5); - assert_eq!(five.len(), 5); - assert_eq!(five[4], "solid"); // Last is always solid - - // Verify ink density increases (on value should increase) - // Parse first hex digit (on length) from each pattern - for i in 0..4 { - let on_i = u32::from_str_radix(&five[i][0..1], 16).unwrap(); - let on_next = if five[i + 1] == "solid" { - 16 // solid = 100% ink - } else { - u32::from_str_radix(&five[i + 1][0..1], 16).unwrap() - }; - assert!( - on_next >= on_i, - "Ink density should increase: {} vs {}", - five[i], - five[i + 1] - ); - } - } - - #[test] - fn test_generate_linetype_sequential_valid_hex() { - // Verify all generated patterns are valid hex linetypes - use crate::plot::scale::linetype_to_stroke_dash; - - for count in 2..=10 { - let linetypes = generate_linetype_sequential(count); - for lt in &linetypes { - assert!( - linetype_to_stroke_dash(lt).is_some(), - "Generated linetype '{}' should be valid", - lt - ); - } - } + assert_eq!(generate_linetype_sequential(3), vec!["1f", "88", "solid"]); } #[test] fn test_lookup_palette_color() { - // Color aesthetic should look up color palettes let result = lookup_palette("fill", "viridis"); assert!(result.is_ok()); - let arr = result.unwrap(); - assert_eq!(arr.len(), 256); // viridis has 256 colors + assert_eq!(result.unwrap().len(), 256); - // stroke should also work - let result = lookup_palette("stroke", "tableau10"); - assert!(result.is_ok()); - assert_eq!(result.unwrap().len(), 10); - - // color should also work - let result = lookup_palette("color", "ggsql"); - assert!(result.is_ok()); - assert_eq!(result.unwrap().len(), 10); + assert_eq!(lookup_palette("stroke", "tableau10").unwrap().len(), 10); + assert_eq!(lookup_palette("color", "ggsql").unwrap().len(), 10); } #[test] fn test_lookup_palette_shape() { - let result = lookup_palette("shape", "default"); - assert!(result.is_ok()); - let arr = result.unwrap(); - assert_eq!(arr.len(), 9); // default shapes palette - } - - #[test] - fn test_lookup_palette_linetype() { - let result = lookup_palette("linetype", "default"); - assert!(result.is_ok()); - let arr = result.unwrap(); - assert_eq!(arr.len(), 6); // default linetypes palette + assert_eq!(lookup_palette("shape", "default").unwrap().len(), 9); + assert_eq!(lookup_palette("linetype", "default").unwrap().len(), 6); } #[test] fn test_lookup_palette_unknown_palette() { - let result = lookup_palette("fill", "nonexistent_palette"); - assert!(result.is_err()); - let err = result.unwrap_err(); + let err = lookup_palette("fill", "nonexistent_palette").unwrap_err(); assert!(err.contains("Unknown")); assert!(err.contains("nonexistent_palette")); } #[test] fn test_lookup_palette_invalid_aesthetic() { - // Palettes don't apply to position aesthetics. The error must NOT - // mention the internal aesthetic name (e.g. `pos1`); it should be - // coord-agnostic and self-explanatory. It must also not name the - // requested palette, since palette names belong to a specific - // aesthetic family (e.g. 'viridis' is not valid for shape or linetype). - let result = lookup_palette("pos1", "viridis"); - assert!(result.is_err()); - let err = result.unwrap_err(); + // The error must not leak the internal aesthetic name (e.g. `pos1`) + // or the palette name. + let err = lookup_palette("pos1", "viridis").unwrap_err(); assert_eq!( err, "Palettes are only valid for color, fill, stroke, shape, and linetype aesthetics" ); - // Must not leak the internal aesthetic name or the palette name. - assert!(!err.contains("pos1")); - assert!(!err.contains("viridis")); } } diff --git a/src/plot/scale/scale_type/binned.rs b/src/plot/scale/scale_type/binned.rs index d183a817e..d5289997f 100644 --- a/src/plot/scale/scale_type/binned.rs +++ b/src/plot/scale/scale_type/binned.rs @@ -404,12 +404,13 @@ impl ScaleTypeTrait for Binned { Some(ParameterValue::String(interval_str)) => { // Temporal interval string like "2 months", "week" // Only valid for temporal transforms (Date, DateTime, Time) - use super::super::super::breaks::{ - temporal_breaks_date, temporal_breaks_datetime, temporal_breaks_time, - TemporalInterval, - }; + use super::super::super::breaks::{interval_breaks, parse_temporal_interval}; - if let Some(interval) = TemporalInterval::create_from_str(interval_str) { + // Non-temporal transforms don't support interval strings. + if let (Some(interval), Some(unit)) = ( + parse_temporal_interval(interval_str), + resolved_transform.temporal_unit(), + ) { // Use raw data range (not expanded input_range) so breaks align // to actual data extent; expansion happens later in step 5b. let break_range: Option<&[ArrayElement]> = match &context_range_continuous { @@ -417,51 +418,23 @@ impl ScaleTypeTrait for Binned { None => scale.input_range.as_deref(), }; if let Some(range) = break_range { - let breaks: Vec = match resolved_transform.transform_kind() { - TransformKind::Date => { - let min = range[0].to_f64().unwrap_or(0.0) as i32; - let max = range[range.len() - 1].to_f64().unwrap_or(0.0) as i32; - temporal_breaks_date(min, max, interval) - .into_iter() - .map(ArrayElement::String) - .collect() - } - TransformKind::DateTime => { - let min = range[0].to_f64().unwrap_or(0.0) as i64; - let max = range[range.len() - 1].to_f64().unwrap_or(0.0) as i64; - temporal_breaks_datetime(min, max, interval) - .into_iter() - .map(ArrayElement::String) - .collect() - } - TransformKind::Time => { - let min = range[0].to_f64().unwrap_or(0.0) as i64; - let max = range[range.len() - 1].to_f64().unwrap_or(0.0) as i64; - temporal_breaks_time(min, max, interval) - .into_iter() - .map(ArrayElement::String) - .collect() - } - _ => vec![], // Non-temporal transforms don't support interval strings - }; + let min = range[0].to_f64().unwrap_or(0.0); + let max = range[range.len() - 1].to_f64().unwrap_or(0.0); + let breaks: Vec = interval_breaks(interval, min, max, unit) + .into_iter() + .map(|v| resolved_transform.wrap_numeric(v)) + .collect(); if !breaks.is_empty() { - // Convert string breaks to appropriate temporal ArrayElement types - let converted: Vec = breaks - .iter() - .map(|elem| resolved_transform.parse_value(elem)) - .collect(); // Only filter to input range when user provided explicit FROM clause let filtered = if scale.explicit_input_range { if let Some(ref ir) = scale.input_range { - super::super::super::breaks::filter_breaks_to_range( - &converted, ir, - ) + super::super::super::breaks::filter_breaks_to_range(&breaks, ir) } else { - converted + breaks } } else { - converted + breaks }; scale .properties @@ -500,7 +473,7 @@ impl ScaleTypeTrait for Binned { // Only expand for position aesthetics (x, y, etc.) // Material aesthetics (color, fill, size) don't get expansion let final_range = if super::is_position_aesthetic(aesthetic) { - expand_numeric_range(&terminal_range, mult, add) + expand_numeric_range(&terminal_range, mult, add, &resolved_transform) } else { terminal_range }; diff --git a/src/plot/scale/scale_type/continuous.rs b/src/plot/scale/scale_type/continuous.rs index 13e4bf322..76493331f 100644 --- a/src/plot/scale/scale_type/continuous.rs +++ b/src/plot/scale/scale_type/continuous.rs @@ -165,7 +165,7 @@ impl ScaleTypeTrait for Continuous { Ok(Some( palette .iter() - .map(|col: &&str| ArrayElement::String(col.to_string())) + .map(|col: &String| ArrayElement::String(col.clone())) .collect(), )) } diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index facbf9509..e0a62a3a6 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -790,7 +790,7 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { _ => { // Identity transform or no transform - use default pretty/linear breaks let raw_breaks = if pretty { - super::breaks::pretty_breaks(min, max, count) + super::breaks::extended_breaks(min, max, count) } else { super::breaks::linear_breaks(min, max, count) }; @@ -895,7 +895,17 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { // If breaks is a scalar Number (count), calculate actual break positions and store as Array // If breaks is already an Array, user provided explicit breaks - convert using transform // Then filter breaks to the input range (break algorithms may produce "nice" values outside range) + // + // The calendar interval temporal majors step by, kept for step 5b so + // the automatic minors subdivide that same interval. + let mut major_interval = None; if self.supports_breaks() { + major_interval = calendar_major_interval( + scale.properties.get("breaks"), + scale.input_range.as_deref(), + &scale.properties, + &resolved_transform, + ); match scale.properties.get("breaks") { Some(ParameterValue::Number(_)) => { // Scalar count → calculate actual breaks and store as Array @@ -956,7 +966,9 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { // interval string — except that its count is *per major interval* rather than a // target for the whole axis, because that is the unit a minor break is defined // in. Absent, the transform's own density applies (one midpoint for linear, the - // 2-9 ladder for the log family, three per interval for temporal). + // whole-number multiples between powers for the log family), and temporal majors + // stepping by a calendar interval take minors on that interval's sub-interval + // grid (quarters under years, weeks under months, …). // // `Some(vec![])` and `None` mean different things downstream: the first is // "resolved to no minors" (`minor_breaks => 0`, which a writer must honour by @@ -997,7 +1009,17 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { temporal_interval_breaks(interval, range, &resolved_transform) .unwrap_or_default() } - _ => derive(resolved_transform.default_minor_break_count()), + _ => match (major_interval, resolved_transform.temporal_unit(), extent) { + (Some(interval), Some(unit), Some((min, max))) => { + zygos::breaks::temporal_minor_breaks_from_f64_with_interval( + min, max, unit, interval, + ) + .into_iter() + .map(|v| resolved_transform.wrap_numeric(v)) + .collect() + } + _ => derive(resolved_transform.default_minor_break_count()), + }, }; // Drop anything outside the domain, the same way the majors are // filtered. @@ -1683,19 +1705,22 @@ pub(super) fn parse_expand_value(expand: &ParameterValue) -> Option<(f64, f64)> } } -/// Apply expansion to a numeric [min, max] range. +/// Apply expansion to a numeric [min, max] range, in the transform's space. /// Returns expanded [min, max] as ArrayElements. /// -/// Formula: [min - range*mult - add, max + range*mult + add] +/// Each end moves outward by `span × mult + add`, with the span and `add` +/// measured in transformed units (see [`Transform::expand_range`]). pub(crate) fn expand_numeric_range( range: &[ArrayElement], mult: f64, add: f64, + transform: &Transform, ) -> Vec { - expand_numeric_range_selective(range, mult, add, None) + expand_numeric_range_selective(range, mult, add, None, transform) } -/// Apply expansion selectively to a numeric [min, max] range. +/// Apply expansion selectively to a numeric [min, max] range, in the +/// transform's space. /// /// If `original_user_range` is provided, only expand values that were originally Null /// in the user range. This preserves explicit user limits while expanding inferred values. @@ -1704,12 +1729,14 @@ pub(crate) fn expand_numeric_range( /// - min=0 is explicit, so it's preserved as 0 /// - max was null (inferred from data), so it gets expanded /// -/// Formula for expanded values: [min - range*mult - add, max + range*mult + add] +/// The span is that of the whole range in transformed space, so an inferred +/// end is padded by the same amount whether or not the other end is explicit. pub(crate) fn expand_numeric_range_selective( range: &[ArrayElement], mult: f64, add: f64, original_user_range: Option<&[ArrayElement]>, + transform: &Transform, ) -> Vec { if range.len() < 2 { return range.to_vec(); @@ -1725,44 +1752,29 @@ pub(crate) fn expand_numeric_range_selective( None => return range.to_vec(), }; - let span = max - min; - - // For singular ranges (min == max), use the absolute value to compute expansion - // This prevents zero expansion when all data values are identical - // If the value itself is 0, use a small default expansion (1.0) - let effective_span = if span.abs() < 1e-10 { - if min.abs() < 1e-10 { - 1.0 // If the value is 0, expand by ±1 + // An end the user set explicitly (non-null in the original range) stays put. + let is_explicit = |i: usize| { + original_user_range + .and_then(|ur| ur.get(i)) + .is_some_and(|e| !matches!(e, ArrayElement::Null)) + }; + let factors = |i: usize| { + if is_explicit(i) { + (0.0, 0.0) } else { - min.abs() // Use the absolute value for expansion + (mult, add) } - } else { - span }; - let expansion = effective_span * mult + add; - - // Check if min was explicitly set by user (non-null in original range) - let min_is_explicit = original_user_range - .and_then(|ur| ur.first()) - .map(|e| !matches!(e, ArrayElement::Null)) - .unwrap_or(false); - - // Check if max was explicitly set by user (non-null in original range) - let max_is_explicit = original_user_range - .and_then(|ur| ur.get(1)) - .map(|e| !matches!(e, ArrayElement::Null)) - .unwrap_or(false); - - // Only expand values that were inferred (originally null) - let expanded_min = if min_is_explicit { - min - } else { - min - expansion - }; - let expanded_max = if max_is_explicit { - max + let (min_mult, min_add) = factors(0); + let (max_mult, max_add) = factors(1); + + // The transform expands an ascending range; a descending one is expanded + // with its ends swapped and handed back in its original order. + let (expanded_min, expanded_max) = if min <= max { + transform.expand_range(min, max, (min_mult, max_mult), (min_add, max_add)) } else { - max + expansion + let (lo, hi) = transform.expand_range(max, min, (max_mult, min_mult), (max_add, min_add)); + (hi, lo) }; vec![ @@ -1823,46 +1835,69 @@ pub(crate) fn temporal_interval_breaks( range: &[ArrayElement], transform: &Transform, ) -> Option> { - use super::breaks::{ - filter_breaks_to_range, temporal_breaks_date, temporal_breaks_datetime, - temporal_breaks_time, TemporalInterval, - }; + use super::breaks::parse_temporal_interval; - let interval = TemporalInterval::create_from_str(interval_str)?; + // Non-temporal transforms have no calendar to step through. + let unit = transform.temporal_unit()?; + let interval = parse_temporal_interval(interval_str)?; let min = range.first()?.to_f64()?; let max = range.last()?.to_f64()?; - let breaks: Vec = match transform.transform_kind() { - TransformKind::Date => temporal_breaks_date(min as i32, max as i32, interval) - .into_iter() - .map(ArrayElement::String) - .collect(), - TransformKind::DateTime => temporal_breaks_datetime(min as i64, max as i64, interval) - .into_iter() - .map(ArrayElement::String) - .collect(), - TransformKind::Time => temporal_breaks_time(min as i64, max as i64, interval) - .into_iter() - .map(ArrayElement::String) - .collect(), - // Non-temporal transforms have no calendar to step through. - _ => return None, - }; + // zygos steps the interval's calendar grid and keeps only what falls + // inside the domain. (Binned scales can't use this — they need the + // terminal break past `max`, so they call `interval_breaks` directly.) + let breaks: Vec = zygos::temporal_breaks_with_interval( + Some(&zygos::InputRange::Continuous { min, max }), + unit, + interval, + ) + .iter() + .filter_map(|v| v.as_number()) + .map(|v| transform.wrap_numeric(v)) + .collect(); if breaks.is_empty() { return None; } - // Convert the ISO strings to the transform's temporal variant, then drop any - // that fell outside the domain. - let converted: Vec = breaks - .iter() - .map(|elem| transform.parse_value(elem)) - .collect(); - Some(filter_breaks_to_range(&converted, range)) + Some(breaks) +} + +/// The calendar interval a temporal scale's majors step by: the interval a +/// `breaks` string names, or the one automatic breaks pick for a `breaks` +/// count (with `pretty`, the default). `None` for non-temporal transforms and +/// for majors that don't follow a calendar interval (explicit positions, +/// `pretty => false`). +fn calendar_major_interval( + breaks: Option<&ParameterValue>, + input_range: Option<&[ArrayElement]>, + properties: &Parameters, + transform: &Transform, +) -> Option { + let unit = transform.temporal_unit()?; + match breaks? { + ParameterValue::String(interval) => super::breaks::parse_temporal_interval(interval), + ParameterValue::Number(count) => { + if let Some(ParameterValue::Boolean(false)) = properties.get("pretty") { + return None; + } + let range = input_range?; + let min = range.first()?.to_f64()?; + let max = range.last()?.to_f64()?; + Some(super::breaks::auto_temporal_interval( + min, + max, + *count as usize, + unit, + )) + } + _ => None, + } } /// Clip an input range to a transform's valid domain. /// -/// This prevents expansion from producing invalid values for transforms -/// with restricted domains (e.g., log scales which exclude 0 and negatives). +/// Expansion happens in transformed space and cannot leave the domain on its +/// own; this catches the limits it leaves untouched — a user-explicit limit +/// outside the domain (`FROM (-5, 100)` on a log scale), or data that reaches +/// a value the transform has no finite image for (a zero on a log scale). pub(crate) fn clip_to_transform_domain( range: &[ArrayElement], transform: &Transform, @@ -2110,8 +2145,10 @@ pub(crate) fn resolve_common_steps( // 2. Aesthetic is position (x, y, xmin, xmax, etc.) // 3. Input range was at least partially deduced (not fully explicit) // - // Then clip to the transform's valid domain to prevent invalid values - // (e.g., expansion producing negative values for log scales) + // Expansion runs in transformed space, so padding never leaves the + // transform's domain. Clipping then catches what expansion leaves alone: an + // explicit limit, or a data extreme, outside that domain (e.g. a + // non-positive limit on a log scale). let mut applied_expand = (0.0, 0.0); if let Some(range) = base_range { let is_position = is_position_aesthetic(aesthetic); @@ -2120,8 +2157,13 @@ pub(crate) fn resolve_common_steps( if !is_discrete_range && is_position && is_deduced { applied_expand = (mult, add); - let expanded = - expand_numeric_range_selective(&range, mult, add, original_user_range.as_deref()); + let expanded = expand_numeric_range_selective( + &range, + mult, + add, + original_user_range.as_deref(), + &resolved_transform, + ); scale.input_range = Some(clip_to_transform_domain(&expanded, &resolved_transform)); } else { // No expansion for discrete scales, material aesthetics, or fully explicit ranges @@ -2558,7 +2600,7 @@ mod tests { #[test] fn test_expand_numeric_range_mult_only() { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range(&range, 0.05, 0.0); + let expanded = expand_numeric_range(&range, 0.05, 0.0, &Transform::identity()); // span = 100, expanded = [-5, 105] assert_eq!(expanded[0], ArrayElement::Number(-5.0)); assert_eq!(expanded[1], ArrayElement::Number(105.0)); @@ -2567,7 +2609,7 @@ mod tests { #[test] fn test_expand_numeric_range_with_add() { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range(&range, 0.05, 10.0); + let expanded = expand_numeric_range(&range, 0.05, 10.0, &Transform::identity()); // span = 100, mult_expansion = 5, add_expansion = 10 // expanded = [0 - 5 - 10, 100 + 5 + 10] = [-15, 115] assert_eq!(expanded[0], ArrayElement::Number(-15.0)); @@ -2577,7 +2619,7 @@ mod tests { #[test] fn test_expand_numeric_range_zero_disables() { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range(&range, 0.0, 0.0); + let expanded = expand_numeric_range(&range, 0.0, 0.0, &Transform::identity()); // No expansion assert_eq!(expanded[0], ArrayElement::Number(0.0)); assert_eq!(expanded[1], ArrayElement::Number(100.0)); @@ -2589,7 +2631,13 @@ mod tests { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; let user_range = vec![ArrayElement::Number(0.0), ArrayElement::Null]; - let expanded = expand_numeric_range_selective(&range, 0.05, 0.0, Some(&user_range)); + let expanded = expand_numeric_range_selective( + &range, + 0.05, + 0.0, + Some(&user_range), + &Transform::identity(), + ); // Min should stay at 0 (explicit), max should be expanded // span = 100, expansion = 5 @@ -2603,7 +2651,13 @@ mod tests { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; let user_range = vec![ArrayElement::Null, ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range_selective(&range, 0.05, 0.0, Some(&user_range)); + let expanded = expand_numeric_range_selective( + &range, + 0.05, + 0.0, + Some(&user_range), + &Transform::identity(), + ); // Min should be expanded, max should stay at 100 (explicit) // span = 100, expansion = 5 @@ -2617,7 +2671,13 @@ mod tests { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; let user_range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range_selective(&range, 0.05, 0.0, Some(&user_range)); + let expanded = expand_numeric_range_selective( + &range, + 0.05, + 0.0, + Some(&user_range), + &Transform::identity(), + ); // Both should stay as-is (no expansion) assert_eq!(expanded[0], ArrayElement::Number(0.0)); @@ -2629,7 +2689,8 @@ mod tests { // No user range (all inferred) → expand both let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range_selective(&range, 0.05, 0.0, None); + let expanded = + expand_numeric_range_selective(&range, 0.05, 0.0, None, &Transform::identity()); // Both should be expanded assert_eq!(expanded[0], ArrayElement::Number(-5.0)); @@ -2639,29 +2700,87 @@ mod tests { #[test] fn test_expand_singular_range_nonzero() { // Singular range: all values are the same (e.g., count=12 for all bars) - // Should expand based on the value itself to create visible range + // A zero-width range pads as if it spanned one transformed unit. let range = vec![ArrayElement::Number(12.0), ArrayElement::Number(12.0)]; - let expanded = expand_numeric_range(&range, 0.05, 0.0); + let expanded = expand_numeric_range(&range, 0.05, 0.0, &Transform::identity()); - // span = 0, effective_span = |12| = 12, expansion = 12 * 0.05 = 0.6 - // expanded = [12 - 0.6, 12 + 0.6] = [11.4, 12.6] - assert_eq!(expanded[0], ArrayElement::Number(11.4)); - assert_eq!(expanded[1], ArrayElement::Number(12.6)); + // span = 0 → unit span, expansion = 1 * 0.05 = 0.05 + assert_eq!(expanded[0], ArrayElement::Number(12.0 - 0.05)); + assert_eq!(expanded[1], ArrayElement::Number(12.0 + 0.05)); } #[test] fn test_expand_singular_range_zero() { // Singular range at zero (e.g., all counts are 0) - // Should use default expansion of 1.0 let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(0.0)]; - let expanded = expand_numeric_range(&range, 0.05, 0.0); + let expanded = expand_numeric_range(&range, 0.05, 0.0, &Transform::identity()); - // span = 0, value = 0, effective_span = 1.0, expansion = 1.0 * 0.05 = 0.05 - // expanded = [0 - 0.05, 0 + 0.05] = [-0.05, 0.05] + // span = 0 → unit span, expansion = 1 * 0.05 = 0.05 assert_eq!(expanded[0], ArrayElement::Number(-0.05)); assert_eq!(expanded[1], ArrayElement::Number(0.05)); } + #[test] + fn test_expand_descending_range_keeps_its_order() { + let range = vec![ArrayElement::Number(100.0), ArrayElement::Number(0.0)]; + let user_range = vec![ArrayElement::Number(100.0), ArrayElement::Null]; + let expanded = expand_numeric_range_selective( + &range, + 0.05, + 0.0, + Some(&user_range), + &Transform::identity(), + ); + // The explicit first end stays; the inferred second end moves outward. + assert_eq!(expanded[0], ArrayElement::Number(100.0)); + assert_eq!(expanded[1], ArrayElement::Number(-5.0)); + } + + fn as_f64(elem: &ArrayElement) -> f64 { + elem.to_f64().unwrap() + } + + #[test] + fn test_expand_log10_pads_in_decades() { + // 1..100 is two decades; a 5% pad is a tenth of a decade per end, so + // the lower bound stays positive rather than collapsing to the domain + // minimum. + let range = vec![ArrayElement::Number(1.0), ArrayElement::Number(100.0)]; + let expanded = expand_numeric_range(&range, 0.05, 0.0, &Transform::log()); + assert!((as_f64(&expanded[0]) - 10f64.powf(-0.1)).abs() < 1e-12); + assert!((as_f64(&expanded[1]) - 10f64.powf(2.1)).abs() < 1e-9); + } + + #[test] + fn test_expand_log10_add_is_in_decades() { + let range = vec![ArrayElement::Number(1.0), ArrayElement::Number(100.0)]; + let expanded = expand_numeric_range(&range, 0.0, 1.0, &Transform::log()); + assert!((as_f64(&expanded[0]) - 0.1).abs() < 1e-12); + assert!((as_f64(&expanded[1]) - 1000.0).abs() < 1e-9); + } + + #[test] + fn test_expand_selective_log10_keeps_explicit_end() { + let range = vec![ArrayElement::Number(1.0), ArrayElement::Number(100.0)]; + let user_range = vec![ArrayElement::Number(1.0), ArrayElement::Null]; + let expanded = + expand_numeric_range_selective(&range, 0.05, 0.0, Some(&user_range), &Transform::log()); + assert_eq!(expanded[0], ArrayElement::Number(1.0)); + assert!((as_f64(&expanded[1]) - 10f64.powf(2.1)).abs() < 1e-9); + } + + #[test] + fn test_expand_sqrt_from_zero_does_not_fold() { + // sqrt of 0..100 spans 0..10; the lower pad would land at -0.5, which + // squares back to 0.25 and cuts the data at zero off. That end is left + // unpadded; the upper end pads in transformed space as usual. + let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; + let sqrt = Transform::sqrt(); + let expanded = expand_numeric_range(&range, 0.05, 0.0, &sqrt); + assert_eq!(expanded[0], ArrayElement::Number(0.0)); + assert!((as_f64(&expanded[1]) - 110.25).abs() < 1e-9); + } + // ========================================================================= // resolve_properties Tests (Consolidated) // ========================================================================= @@ -3188,12 +3307,12 @@ mod tests { // ========================================================================= #[test] - fn test_breaks_property_default_is_7() { + fn test_breaks_property_default_is_5() { let props = Parameters::new(); let resolved = ScaleType::continuous() .resolve_properties("x", &props) .unwrap(); - assert_eq!(resolved.get("breaks"), Some(&ParameterValue::Number(7.0))); + assert_eq!(resolved.get("breaks"), Some(&ParameterValue::Number(5.0))); } #[test] @@ -3585,6 +3704,45 @@ mod tests { } } + #[test] + fn test_resolve_temporal_minor_breaks_follow_the_calendar() { + use crate::plot::scale::Scale; + use chrono::Datelike; + + let mut scale = Scale::new("x"); + scale.scale_type = Some(ScaleType::continuous()); + scale.transform = Some(Transform::date()); + scale.input_range = Some(vec![ + ArrayElement::Date(19738), // 2024-01-15 + ArrayElement::Date(19889), // 2024-06-15 + ]); + scale.properties.insert( + "breaks".to_string(), + ParameterValue::String("2 months".to_string()), + ); + + let context = ScaleDataContext::new(); + ScaleType::continuous() + .resolve(&mut scale, &context, "x") + .unwrap(); + + // Two-month majors divide into single months: every minor is the first + // of a month the majors skip, rather than a fixed fraction of a gap. + let majors = scale.numeric_breaks(); + let minors = scale.numeric_minor_breaks().expect("minors resolved"); + let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(); + let month_starts: Vec = minors + .iter() + .map(|m| { + let date = epoch + chrono::Duration::days(*m as i64); + assert_eq!(date.day(), 1, "minor {date} is not a month start"); + date.month() + }) + .collect(); + assert_eq!(month_starts, vec![2, 4, 6]); + assert!(minors.iter().all(|m| !majors.contains(m))); + } + #[test] fn test_resolve_discrete_has_no_minor_breaks() { use crate::plot::scale::Scale; @@ -4070,22 +4228,18 @@ mod tests { } #[test] - fn test_expansion_clipped_to_log_domain() { - // Simulate what happens when expansion produces invalid values for log scale - // Data: [0.001, 0.01], with 50% expansion produces negative min + fn test_log_expansion_never_needs_clipping() { + // Data [0.001, 0.01] with 50% expansion: in data space this would pad + // the lower end below zero. In log10 space the range is one decade, so + // each end moves half a decade and stays positive; clipping is a no-op. let range = vec![ArrayElement::Number(0.001), ArrayElement::Number(0.01)]; - // span = 0.009, expansion = 0.0045 - // expanded_min = 0.001 - 0.0045 = -0.0035 - // expanded_max = 0.01 + 0.0045 = 0.0145 - let expanded = expand_numeric_range(&range, 0.5, 0.0); - assert!(expanded[0].to_f64().unwrap() < 0.0); - - // Now clip to log domain let transform = Transform::from_kind(TransformKind::Log10); - let clipped = clip_to_transform_domain(&expanded, &transform); - // Min should be clipped to f64::MIN_POSITIVE - assert_eq!(clipped[0], ArrayElement::Number(f64::MIN_POSITIVE)); - assert_eq!(clipped[1].to_f64().unwrap(), 0.0145); + let expanded = expand_numeric_range(&range, 0.5, 0.0, &transform); + let lo = expanded[0].to_f64().unwrap(); + let hi = expanded[1].to_f64().unwrap(); + assert!((lo - 10f64.powf(-3.5)).abs() < 1e-15, "lower end: {lo}"); + assert!((hi - 10f64.powf(-1.5)).abs() < 1e-12, "upper end: {hi}"); + assert_eq!(clip_to_transform_domain(&expanded, &transform), expanded); } // ========================================================================= diff --git a/src/plot/scale/shape.rs b/src/plot/scale/shape.rs deleted file mode 100644 index c3ee3edc5..000000000 --- a/src/plot/scale/shape.rs +++ /dev/null @@ -1,416 +0,0 @@ -/// Get normalized coordinates for a shape. -/// Returns `Vec>` where each inner Vec is a path/polygon. -/// Coordinates are normalized to [-1, 1] range centered at origin (0, 0). -/// This is the format expected by Vega-Lite SVG paths. -/// -/// # Examples -/// - Simple shapes (circle, square): Single path `vec![vec![(x1,y1), (x2,y2), ...]]` -/// - Composite shapes (square-cross): Multiple paths `vec![vec![square coords], vec![cross coords]]` -pub fn get_shape_coordinates(name: &str) -> Option>> { - match name.to_lowercase().as_str() { - "circle" => Some(circle_coords()), - "square" => Some(square_coords()), - "diamond" => Some(diamond_coords()), - "triangle-up" => Some(triangle_up_coords()), - "triangle-down" => Some(triangle_down_coords()), - "star" => Some(star_coords()), - "cross" => Some(cross_coords()), - "plus" => Some(plus_coords()), - "hline" => Some(hline_coords()), - "vline" => Some(vline_coords()), - "asterisk" => Some(asterisk_coords()), - "bowtie" => Some(bowtie_coords()), - // Composite shapes with cutouts (use evenodd fill-rule) - "square-cross" => Some(square_cross_coords()), - "circle-plus" => Some(circle_plus_coords()), - "square-plus" => Some(square_plus_coords()), - _ => None, - } -} - -/// Convert shape coordinates to SVG path string for Vega-Lite. -/// Coordinates are in [-1, 1] range centered at origin. -/// -/// Returns None for unknown shapes. -pub fn shape_to_svg_path(name: &str) -> Option { - let paths = get_shape_coordinates(name)?; - - let svg_paths: Vec = paths - .iter() - .map(|path| { - let mut svg = String::new(); - for (i, &(x, y)) in path.iter().enumerate() { - let cmd = if i == 0 { "M" } else { "L" }; - svg.push_str(&format!("{}{:.3},{:.3} ", cmd, x, y)); - } - // Close path for polygons (3+ points) - if path.len() >= 3 { - svg.push('Z'); - } - svg.trim().to_string() - }) - .collect(); - - Some(svg_paths.join(" ")) -} - -// ============================================================================= -// Area-equalized shape coordinates -// ============================================================================= -// All closed shapes are scaled to have approximately equal visual area. -// Reference: circle with radius 0.8 has area π×0.8² ≈ 2.01 -// -// Scale factors derived from: -// - Square: area = 4s² → s = √(π×0.8²/4) ≈ 0.71 -// - Diamond: area = 2d² → d = √(π×0.8²/2) ≈ 0.89 -// - Triangle: area = √3×r² (equilateral) → needs ~15% larger radius -// - Star: area ≈ 0.5 × circle → needs ~40% larger radius - -/// Circle approximated with 32-point polygon. -/// Radius 0.8 centered at origin. Area ≈ 2.01 (reference shape). -fn circle_coords() -> Vec> { - let n = 32; - let radius = 0.8; - let points: Vec<(f64, f64)> = (0..n) - .map(|i| { - let angle = 2.0 * std::f64::consts::PI * (i as f64) / (n as f64); - (radius * angle.cos(), radius * angle.sin()) - }) - .collect(); - vec![points] -} - -/// Square scaled for equal area with circle. -/// Half-side 0.71 gives area ≈ 2.01. -fn square_coords() -> Vec> { - let s = 0.71; // √(π×0.8²/4) ≈ 0.709 - vec![vec![(-s, -s), (s, -s), (s, s), (-s, s)]] -} - -/// Diamond (square rotated 45 degrees) scaled for equal area. -/// Half-diagonal 0.89 gives area ≈ 2.01. -fn diamond_coords() -> Vec> { - let d = 0.89; // √(π×0.8²/2) ≈ 0.892 - vec![vec![(0.0, -d), (d, 0.0), (0.0, d), (-d, 0.0)]] -} - -/// Triangle pointing up, scaled for equal area. -/// Base and height adjusted for area ≈ 2.01. -fn triangle_up_coords() -> Vec> { - // Equilateral-ish triangle: area = 0.5 × base × height - // For area 2.01 with base = 2r and height = 1.5r: r ≈ 0.92 - let r = 0.92; - let h = r * 0.75; // height offset from center - vec![vec![(0.0, -r), (r, h), (-r, h)]] -} - -/// Triangle pointing down, scaled for equal area. -fn triangle_down_coords() -> Vec> { - let r = 0.92; - let h = r * 0.75; - vec![vec![(-r, -h), (r, -h), (0.0, r)]] -} - -/// 5-pointed star scaled for equal area. -/// Outer radius increased to compensate for inner concavity. -fn star_coords() -> Vec> { - // Star area ≈ 0.5 × circle area for same outer radius - // Scale up by √2 ≈ 1.41 to compensate - let outer_radius = 0.95; // 0.8 × ~1.19, capped to stay in bounds - let inner_radius = outer_radius * 0.4; // maintain proportions - let points: Vec<(f64, f64)> = (0..10) - .map(|i| { - // Start from top (-PI/2) and go clockwise - let angle = -std::f64::consts::PI / 2.0 + std::f64::consts::PI * (i as f64) / 5.0; - let radius = if i % 2 == 0 { - outer_radius - } else { - inner_radius - }; - (radius * angle.cos(), radius * angle.sin()) - }) - .collect(); - vec![points] -} - -/// X shape (diagonal cross) - two line segments. -/// Scaled by 1/√2 so diagonal length matches the plus's axis-aligned length. -fn cross_coords() -> Vec> { - // 0.8 / √2 ≈ 0.566 gives same line length as plus (1.6 units) - let c = 0.8 / std::f64::consts::SQRT_2; - vec![ - vec![(-c, -c), (c, c)], // diagonal from bottom-left to top-right - vec![(-c, c), (c, -c)], // diagonal from top-left to bottom-right - ] -} - -/// + shape (axis-aligned cross) - two line segments. -fn plus_coords() -> Vec> { - vec![ - vec![(-0.8, 0.0), (0.8, 0.0)], // horizontal line - vec![(0.0, -0.8), (0.0, 0.8)], // vertical line - ] -} - -/// Horizontal line at y=0. -fn hline_coords() -> Vec> { - vec![vec![(-0.8, 0.0), (0.8, 0.0)]] -} - -/// Vertical line at x=0. -fn vline_coords() -> Vec> { - vec![vec![(0.0, -0.8), (0.0, 0.8)]] -} - -/// Asterisk (*) - three lines through center evenly spaced at 60° (six wedges). -fn asterisk_coords() -> Vec> { - let r: f64 = 0.8; - (0..3) - .map(|i| { - let angle = (i as f64) * std::f64::consts::PI / 3.0; - let (sin, cos) = angle.sin_cos(); - vec![(-r * cos, -r * sin), (r * cos, r * sin)] - }) - .collect() -} - -/// Bowtie - two triangles meeting at center. -fn bowtie_coords() -> Vec> { - vec![ - vec![(-0.8, -0.8), (0.0, 0.0), (-0.8, 0.8)], // left triangle - vec![(0.8, -0.8), (0.0, 0.0), (0.8, 0.8)], // right triangle - ] -} - -/// Square divided into 4 triangles by X-shaped gap. -/// Creates 4 separate triangular pieces (top, right, bottom, left). -fn square_cross_coords() -> Vec> { - let s = 0.71; // area-equalized square size - let g = 0.12; // half-gap width (perpendicular to diagonal) - - // 4 triangles pointing inward from each edge - vec![ - // Top triangle - vec![(-s + g, -s), (s - g, -s), (0.0, -g)], - // Right triangle - vec![(s, -s + g), (s, s - g), (g, 0.0)], - // Bottom triangle - vec![(s - g, s), (-s + g, s), (0.0, g)], - // Left triangle - vec![(-s, s - g), (-s, -s + g), (-g, 0.0)], - ] -} - -/// Circle divided into 4 quarters by +-shaped gap with constant width. -/// Creates 4 separate quarter-circle pieces. -fn circle_plus_coords() -> Vec> { - let r: f64 = 0.8; // circle radius - let g: f64 = 0.12 / std::f64::consts::SQRT_2; // half-gap width, scaled to match X visually - let n = 8; // points per quarter arc - - // Where the circle intersects the gap edge: sqrt(r² - g²) - let edge = (r * r - g * g).sqrt(); - - // Start and end angles for the arc (where circle intersects gap) - let start_angle = (g / r).asin(); // angle where y = g on circle - let end_angle = std::f64::consts::FRAC_PI_2 - start_angle; // angle where x = g - - let mut quarters = Vec::new(); - - for q in 0..4 { - let base_angle = (q as f64) * std::f64::consts::FRAC_PI_2; - let mut points = Vec::new(); - - // Inner corner point - let (cx, cy) = match q { - 0 => (g, g), // top-right - 1 => (-g, g), // top-left - 2 => (-g, -g), // bottom-left - _ => (g, -g), // bottom-right - }; - points.push((cx, cy)); - - // Point where gap meets circle (start of arc) - let (sx, sy) = match q { - 0 => (edge, g), // right edge of horizontal gap - 1 => (-g, edge), // top edge of vertical gap - 2 => (-edge, -g), // left edge of horizontal gap - _ => (g, -edge), // bottom edge of vertical gap - }; - points.push((sx, sy)); - - // Arc points - let arc_start = base_angle + start_angle; - let arc_span = end_angle - start_angle; - for i in 0..=n { - let t = (i as f64) / (n as f64); - let angle = arc_start + t * arc_span; - points.push((r * angle.cos(), r * angle.sin())); - } - - // Point where arc meets gap (end of arc) - let (ex, ey) = match q { - 0 => (g, edge), // top edge of vertical gap - 1 => (-edge, g), // left edge of horizontal gap - 2 => (-g, -edge), // bottom edge of vertical gap - _ => (edge, -g), // right edge of horizontal gap - }; - points.push((ex, ey)); - - quarters.push(points); - } - - quarters -} - -/// Square divided into 4 smaller squares by +-shaped gap. -/// Creates 4 separate square pieces in each corner. -fn square_plus_coords() -> Vec> { - let s = 0.71; // area-equalized square size - let g = 0.12 / std::f64::consts::SQRT_2; // half-gap width, scaled to match X visually - - // 4 smaller squares in each corner - vec![ - // Top-left square - vec![(-s, -s), (-g, -s), (-g, -g), (-s, -g)], - // Top-right square - vec![(g, -s), (s, -s), (s, -g), (g, -g)], - // Bottom-right square - vec![(g, g), (s, g), (s, s), (g, s)], - // Bottom-left square - vec![(-s, g), (-g, g), (-g, s), (-s, s)], - ] -} - -#[cfg(test)] -mod tests { - use super::{get_shape_coordinates, shape_to_svg_path}; - use crate::plot::palettes::SHAPES; - - #[test] - fn test_get_shape_coordinates_simple_shapes() { - // Simple closed shapes return single path - assert_eq!(get_shape_coordinates("circle").unwrap().len(), 1); - assert_eq!(get_shape_coordinates("square").unwrap().len(), 1); - assert_eq!(get_shape_coordinates("diamond").unwrap().len(), 1); - assert_eq!(get_shape_coordinates("triangle-up").unwrap().len(), 1); - assert_eq!(get_shape_coordinates("triangle-down").unwrap().len(), 1); - assert_eq!(get_shape_coordinates("star").unwrap().len(), 1); - } - - #[test] - fn test_get_shape_coordinates_open_shapes() { - // Open/stroke shapes may have multiple line segments - assert!(get_shape_coordinates("cross").is_some()); - assert!(get_shape_coordinates("plus").is_some()); - assert!(get_shape_coordinates("hline").is_some()); - assert!(get_shape_coordinates("vline").is_some()); - assert!(get_shape_coordinates("asterisk").is_some()); - assert!(get_shape_coordinates("bowtie").is_some()); - } - - #[test] - fn test_get_shape_coordinates_composite_shapes() { - // Composite shapes return multiple paths (base + overlay) - let sq_cross = get_shape_coordinates("square-cross").unwrap(); - assert!( - sq_cross.len() > 1, - "square-cross should have multiple paths" - ); - - let circ_plus = get_shape_coordinates("circle-plus").unwrap(); - assert!( - circ_plus.len() > 1, - "circle-plus should have multiple paths" - ); - - let sq_plus = get_shape_coordinates("square-plus").unwrap(); - assert!(sq_plus.len() > 1, "square-plus should have multiple paths"); - } - - #[test] - fn test_get_shape_coordinates_all_shapes_supported() { - // All shapes in the SHAPES palette should have coordinates - for shape in SHAPES.iter() { - assert!( - get_shape_coordinates(shape).is_some(), - "Shape '{}' should have coordinates", - shape - ); - } - } - - #[test] - fn test_get_shape_coordinates_normalized() { - // All coordinates should be in [-1, 1] range - for shape in SHAPES.iter() { - if let Some(paths) = get_shape_coordinates(shape) { - for path in &paths { - for &(x, y) in path { - assert!((-1.0..=1.0).contains(&x), "{} x={} out of range", shape, x); - assert!((-1.0..=1.0).contains(&y), "{} y={} out of range", shape, y); - } - } - } - } - } - - #[test] - fn test_get_shape_coordinates_unknown() { - assert!(get_shape_coordinates("unknown_shape").is_none()); - } - - #[test] - fn test_get_shape_coordinates_case_insensitive() { - assert!(get_shape_coordinates("CIRCLE").is_some()); - assert!(get_shape_coordinates("Square").is_some()); - assert!(get_shape_coordinates("TRIANGLE-UP").is_some()); - } - - #[test] - fn test_shape_to_svg_path_square() { - let path = shape_to_svg_path("square").unwrap(); - assert!(path.starts_with('M')); - assert!(path.contains('L')); - assert!(path.ends_with('Z')); - } - - #[test] - fn test_shape_to_svg_path_all_shapes() { - for shape in SHAPES.iter() { - assert!( - shape_to_svg_path(shape).is_some(), - "Shape '{}' should produce SVG path", - shape - ); - } - } - - #[test] - fn test_shape_to_svg_path_unknown() { - assert!(shape_to_svg_path("unknown").is_none()); - } - - #[test] - fn test_shape_to_svg_path_composite() { - let path = shape_to_svg_path("square-cross").unwrap(); - // Should contain multiple M commands (one per sub-path) - assert!(path.matches('M').count() > 1); - } - - #[test] - fn test_shape_to_svg_path_open_shapes_not_closed() { - // Open shapes (lines) should NOT end with Z - let hline = shape_to_svg_path("hline").unwrap(); - assert!(!hline.ends_with('Z')); - - let vline = shape_to_svg_path("vline").unwrap(); - assert!(!vline.ends_with('Z')); - - let cross = shape_to_svg_path("cross").unwrap(); - assert!(!cross.ends_with('Z')); - - let plus = shape_to_svg_path("plus").unwrap(); - assert!(!plus.ends_with('Z')); - } -} diff --git a/src/plot/scale/transform/asinh.rs b/src/plot/scale/transform/asinh.rs index bc58bb30f..9e7e08e61 100644 --- a/src/plot/scale/transform/asinh.rs +++ b/src/plot/scale/transform/asinh.rs @@ -1,7 +1,11 @@ //! Asinh transform implementation (inverse hyperbolic sine) -use super::{TransformKind, TransformTrait}; -use crate::plot::scale::breaks::{minor_breaks_symlog, symlog_breaks}; +use super::{TransformKind, TransformTrait, ZygosMath}; +use crate::plot::scale::breaks::{extent_minor_breaks, symlog_breaks, symlog_minor_breaks}; + +// Branches either side of zero follow the base-10 1-2-5 pattern: asinh +// grows like a natural log, but powers of e would label as 2.718…. +const BREAK_BASE: f64 = 10.0; /// Asinh transform - inverse hyperbolic sine /// @@ -25,11 +29,11 @@ impl TransformTrait for Asinh { } fn allowed_domain(&self) -> (f64, f64) { - (f64::NEG_INFINITY, f64::INFINITY) + ZygosMath::of(TransformKind::Asinh).allowed_domain() } - fn calculate_breaks(&self, min: f64, max: f64, n: usize, pretty: bool) -> Vec { - symlog_breaks(min, max, n, pretty) + fn calculate_breaks(&self, min: f64, max: f64, n: usize, _pretty: bool) -> Vec { + symlog_breaks(min, max, n, BREAK_BASE) } fn calculate_minor_breaks( @@ -38,19 +42,23 @@ impl TransformTrait for Asinh { n: usize, range: Option<(f64, f64)>, ) -> Vec { - minor_breaks_symlog(major_breaks, n, range) + extent_minor_breaks(major_breaks, n, range, |min, max| { + symlog_minor_breaks(min, max, BREAK_BASE) + }) } fn default_minor_break_count(&self) -> usize { - 8 // Similar density to traditional 2-9 pattern on log axes + // The whole-number multiples between powers fix the density; any + // non-zero count asks for them. + 8 } fn transform(&self, value: f64) -> f64 { - value.asinh() + ZygosMath::of(TransformKind::Asinh).forward(value) } fn inverse(&self, value: f64) -> f64 { - value.sinh() + ZygosMath::of(TransformKind::Asinh).inverse(value) } } @@ -115,18 +123,40 @@ mod tests { #[test] fn test_asinh_breaks_symmetric() { let t = Asinh; - let breaks = t.calculate_breaks(-1000.0, 1000.0, 10, false); - // Should have negative, zero, and positive values - assert!(breaks.contains(&0.0)); - assert!(breaks.iter().any(|&v| v < 0.0)); - assert!(breaks.iter().any(|&v| v > 0.0)); + let breaks = t.calculate_breaks(-1000.0, 1000.0, 10, true); + // Decade 1-2-5 breaks on each branch, mirrored around zero. + let positive = [ + 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0, + ]; + let mut expected: Vec = positive.iter().rev().map(|v| -v).collect(); + expected.push(0.0); + expected.extend(positive); + assert_eq!(breaks, expected); } #[test] fn test_asinh_works_with_zero() { let t = Asinh; // Unlike log, asinh works with zero - let breaks = t.calculate_breaks(0.0, 100.0, 5, false); - assert!(!breaks.is_empty()); + let breaks = t.calculate_breaks(0.0, 100.0, 5, true); + assert_eq!( + breaks, + vec![0.0, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0] + ); + } + + #[test] + fn test_asinh_minor_breaks() { + let t = Asinh; + let minors = t.calculate_minor_breaks(&[-10.0, 0.0, 10.0], 8, Some((-20.0, 20.0))); + // Whole-number multiples on each branch; the majors' own positions + // are left to the majors. + assert!(minors.contains(&-20.0) && minors.contains(&20.0)); + assert!(minors.contains(&2.0) && minors.contains(&-9.0)); + assert!(!minors.contains(&10.0) && !minors.contains(&-10.0)); + assert!(minors.windows(2).all(|w| w[0] < w[1])); + assert!(t + .calculate_minor_breaks(&[-10.0, 0.0, 10.0], 0, None) + .is_empty()); } } diff --git a/src/plot/scale/transform/date.rs b/src/plot/scale/transform/date.rs index 622f3453a..6412ee972 100644 --- a/src/plot/scale/transform/date.rs +++ b/src/plot/scale/transform/date.rs @@ -4,11 +4,10 @@ //! The transform itself is identity (no numerical transformation), but the //! break calculation produces nice temporal intervals (years, months, weeks, days). -use chrono::Datelike; - use super::{TransformKind, TransformTrait}; use crate::plot::scale::breaks::{integer_breaks, minor_breaks_linear}; use crate::plot::ArrayElement; +use zygos::breaks::temporal_breaks_from_f64; /// Date transform - for date data (days since epoch) /// @@ -18,91 +17,6 @@ use crate::plot::ArrayElement; #[derive(Debug, Clone, Copy)] pub struct Date; -// Date interval types for break calculation -#[derive(Debug, Clone, Copy, PartialEq)] -enum DateInterval { - Year, - Quarter, - Month, - Week, - Day, -} - -impl DateInterval { - /// Approximate number of days in each interval - fn days(&self) -> f64 { - match self { - DateInterval::Year => 365.25, - DateInterval::Quarter => 91.3125, // 365.25 / 4 - DateInterval::Month => 30.4375, // 365.25 / 12 - DateInterval::Week => 7.0, - DateInterval::Day => 1.0, - } - } - - /// Calculate expected number of breaks for this interval over the given span - fn expected_breaks(&self, span_days: f64) -> f64 { - span_days / self.days() - } - - /// Select appropriate interval and step based on span in days and desired break count. - /// Uses tolerance-based search: tries each interval from largest to smallest, - /// stops when within ~20% of requested n, then calculates a nice step multiplier. - fn select(span_days: f64, n: usize) -> (Self, usize) { - let n_f64 = n as f64; - let tolerance = 0.2; // 20% tolerance - let min_breaks = n_f64 * (1.0 - tolerance); - let max_breaks = n_f64 * (1.0 + tolerance); - - // Intervals from largest to smallest - let intervals = [ - DateInterval::Year, - DateInterval::Quarter, - DateInterval::Month, - DateInterval::Week, - DateInterval::Day, - ]; - - for &interval in &intervals { - let expected = interval.expected_breaks(span_days); - - // Skip if this interval produces too few breaks - if expected < 1.0 { - continue; - } - - // If within tolerance, use step=1 - if expected >= min_breaks && expected <= max_breaks { - return (interval, 1); - } - - // If too many breaks, calculate a nice step - if expected > max_breaks { - let raw_step = expected / n_f64; - let nice = match interval { - DateInterval::Year => nice_step(raw_step) as usize, - DateInterval::Quarter => nice_quarter_step(raw_step), - DateInterval::Month => nice_month_step(raw_step), - DateInterval::Week => nice_week_step(raw_step), - DateInterval::Day => nice_step(raw_step) as usize, - }; - let step = nice.max(1); - - // Verify the stepped interval is reasonable - let stepped_breaks = expected / step as f64; - if stepped_breaks >= 1.0 { - return (interval, step); - } - } - } - - // Fallback: use Day with step calculation - let expected = DateInterval::Day.expected_breaks(span_days); - let step = (nice_step(expected / n_f64) as usize).max(1); - (DateInterval::Day, step) - } -} - impl TransformTrait for Date { fn transform_kind(&self) -> TransformKind { TransformKind::Date @@ -131,11 +45,10 @@ impl TransformTrait for Date { return vec![]; } - let span = max - min; - let (interval, step) = DateInterval::select(span, n); - if pretty { - calculate_pretty_date_breaks(min, max, interval, step) + // A calendar interval (years, quarters, months, weeks, days) sized + // to about `n` breaks, stepped from its calendar grid. + temporal_breaks_from_f64(min, max, zygos::TemporalUnit::Date, n) } else { // For non-pretty, use integer breaks since dates are whole days integer_breaks(min, max, n, false) @@ -148,12 +61,13 @@ impl TransformTrait for Date { n: usize, range: Option<(f64, f64)>, ) -> Vec { - // Use linear minor breaks in day-space + // Evenly spaced minors for an explicit count, or for majors that don't + // step by a calendar interval; calendar-stepped majors get calendar + // minors during scale resolution instead. minor_breaks_linear(major_breaks, n, range) } fn default_minor_break_count(&self) -> usize { - // 3 minor ticks per major interval works well for dates 3 } @@ -174,206 +88,6 @@ impl TransformTrait for Date { } } -/// Calculate pretty date breaks aligned to interval boundaries -fn calculate_pretty_date_breaks( - min: f64, - max: f64, - interval: DateInterval, - step: usize, -) -> Vec { - let unix_epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(); - - // Convert min/max to dates - let min_date = unix_epoch + chrono::Duration::days(min.floor() as i64); - let max_date = unix_epoch + chrono::Duration::days(max.ceil() as i64); - - let mut breaks = Vec::new(); - - match interval { - DateInterval::Year => { - // Start at the beginning of the year containing min_date - let start_year = min_date.year(); - let end_year = max_date.year(); - - // Use the step from interval selection - let step = step as i32; - let aligned_start = (start_year / step) * step; - - let mut year = aligned_start; - while year <= end_year + step { - if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, 1, 1) { - let days = (date - unix_epoch).num_days() as f64; - if days >= min && days <= max { - breaks.push(days); - } - } - year += step; - } - } - DateInterval::Quarter => { - // Start at the beginning of the quarter containing min_date - let start_year = min_date.year(); - let start_quarter = (min_date.month() - 1) / 3; - - let end_year = max_date.year(); - let end_quarter = (max_date.month() - 1) / 3; - - // Align to step boundary - let aligned_start_quarter = (start_quarter / step as u32) * step as u32; - - let mut year = start_year; - let mut quarter = aligned_start_quarter; - - while year < end_year || (year == end_year && quarter <= end_quarter) { - let month = quarter * 3 + 1; - if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, 1) { - let days = (date - unix_epoch).num_days() as f64; - if days >= min && days <= max { - breaks.push(days); - } - } - quarter += step as u32; - if quarter > 3 { - // Calculate how many years to advance and remaining quarters - let years_advance = quarter / 4; - quarter %= 4; - year += years_advance as i32; - } - } - } - DateInterval::Month => { - // Start at the beginning of the month containing min_date - let start_year = min_date.year(); - let start_month = min_date.month(); - - let end_year = max_date.year(); - let end_month = max_date.month(); - - // Use the step from interval selection - let mut year = start_year; - let mut month = ((start_month - 1) / step as u32) * step as u32 + 1; - - while year < end_year || (year == end_year && month <= end_month) { - if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, 1) { - let days = (date - unix_epoch).num_days() as f64; - if days >= min && days <= max { - breaks.push(days); - } - } - month += step as u32; - if month > 12 { - month -= 12; - year += 1; - } - } - } - DateInterval::Week => { - // Start at the Monday on or before min_date - let start_days = min.floor() as i64; - // weekday() returns 0 for Monday, 6 for Sunday - let weekday = (start_days.rem_euclid(7) + 3) % 7; // Convert to Mon=0 - let first_monday = start_days - weekday; - - let end_days = max.ceil() as i64; - let step_days = (step * 7) as i64; // step weeks in days - - let mut day = first_monday; - while day <= end_days { - let days = day as f64; - if days >= min && days <= max { - breaks.push(days); - } - day += step_days; - } - } - DateInterval::Day => { - // Use the step from interval selection - let step = step as i64; - - let start_day = (min / step as f64).floor() as i64 * step; - let end_day = max.ceil() as i64; - - let mut day = start_day; - while day <= end_day { - let days = day as f64; - if days >= min && days <= max { - breaks.push(days); - } - day += step; - } - } - } - - // Ensure we have at least min and max if the algorithm produced nothing - if breaks.is_empty() { - breaks.push(min); - if max > min { - breaks.push(max); - } - } - - breaks -} - -/// Round to a "nice" step value (1, 2, 5, 10, 20, 50, etc.) -fn nice_step(step: f64) -> f64 { - if step <= 0.0 { - return 1.0; - } - - let magnitude = 10_f64.powf(step.log10().floor()); - let residual = step / magnitude; - - let nice = if residual <= 1.5 { - 1.0 - } else if residual <= 3.0 { - 2.0 - } else if residual <= 7.0 { - 5.0 - } else { - 10.0 - }; - - nice * magnitude -} - -/// Nice step values for weeks (1, 2, 4) -fn nice_week_step(step: f64) -> usize { - if step <= 1.5 { - 1 - } else if step <= 3.0 { - 2 - } else { - 4 - } -} - -/// Nice step values for quarters (1, 2, 4) -fn nice_quarter_step(step: f64) -> usize { - if step <= 1.5 { - 1 - } else if step <= 3.0 { - 2 - } else { - 4 - } -} - -/// Nice step values for months (1, 2, 3, 6, 12) -fn nice_month_step(step: f64) -> usize { - if step <= 1.0 { - 1 - } else if step <= 2.0 { - 2 - } else if step <= 4.0 { - 3 - } else if step <= 8.0 { - 6 - } else { - 12 - } -} - impl std::fmt::Display for Date { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.name()) @@ -484,42 +198,22 @@ mod tests { } #[test] - fn test_date_interval_selection() { - // Large span (10 years, n=5) -> year with step - let (interval, step) = DateInterval::select(3650.0, 5); - assert_eq!(interval, DateInterval::Year); - assert!(step >= 1); - - // Medium span (6 months, n=6) -> month - let (interval, step) = DateInterval::select(180.0, 6); - assert_eq!(interval, DateInterval::Month); - assert!(step >= 1); - - // Small span (4 weeks, n=4) -> week - let (interval, step) = DateInterval::select(28.0, 4); - assert_eq!(interval, DateInterval::Week); - assert!(step >= 1); - - // Very small span (7 days, n=7) -> day - let (interval, step) = DateInterval::select(7.0, 7); - assert_eq!(interval, DateInterval::Day); - assert!(step >= 1); - } - - #[test] - fn test_date_interval_selection_airquality() { - // airquality data: ~150 days, n=7 - // Should select Month (150/30 ≈ 5 breaks, within 20% of 7) - let (interval, step) = DateInterval::select(150.0, 7); - // Month gives ~5 breaks (within tolerance of 7), or - // Week with step would give ~5 breaks - let expected_breaks = interval.expected_breaks(150.0) / step as f64; - assert!( - (3.0..=10.0).contains(&expected_breaks), - "Expected 3-10 breaks for 150 days, n=7, got {} ({:?} with step {})", - expected_breaks, - interval, - step + fn test_date_breaks_follow_the_calendar() { + let t = Date; + // Five years → year starts. + assert_eq!( + t.calculate_breaks(0.0, 1826.0, 5, true), + vec![0.0, 365.0, 730.0, 1096.0, 1461.0, 1826.0] + ); + // Six months → month starts. + assert_eq!( + t.calculate_breaks(0.0, 180.0, 6, true), + vec![0.0, 31.0, 59.0, 90.0, 120.0, 151.0] + ); + // Four weeks → Mondays (1970-01-05 is the first). + assert_eq!( + t.calculate_breaks(0.0, 28.0, 5, true), + vec![4.0, 11.0, 18.0, 25.0] ); } @@ -538,17 +232,4 @@ mod tests { breaks.len() ); } - - #[test] - fn test_nice_step() { - assert_eq!(nice_step(1.0), 1.0); - assert_eq!(nice_step(1.5), 1.0); // 1.5 rounds down to 1.0 - assert_eq!(nice_step(1.6), 2.0); // 1.6 rounds up to 2.0 - assert_eq!(nice_step(3.0), 2.0); // 3.0 rounds to 2.0 - assert_eq!(nice_step(3.5), 5.0); // 3.5 rounds up to 5.0 - assert_eq!(nice_step(7.0), 5.0); - assert_eq!(nice_step(8.0), 10.0); - assert_eq!(nice_step(15.0), 10.0); // 15 = 1.5 * 10, rounds to 1.0 * 10 - assert_eq!(nice_step(16.0), 20.0); // 16 = 1.6 * 10, rounds to 2.0 * 10 - } } diff --git a/src/plot/scale/transform/datetime.rs b/src/plot/scale/transform/datetime.rs index 81f68d91e..a61b74b54 100644 --- a/src/plot/scale/transform/datetime.rs +++ b/src/plot/scale/transform/datetime.rs @@ -4,11 +4,10 @@ //! The transform itself is identity (no numerical transformation), but the //! break calculation produces nice temporal intervals. -use chrono::Datelike; - use super::{TransformKind, TransformTrait}; -use crate::plot::scale::breaks::minor_breaks_linear; +use crate::plot::scale::breaks::{linear_breaks, minor_breaks_linear}; use crate::plot::ArrayElement; +use zygos::breaks::temporal_breaks_from_f64; /// DateTime transform - for datetime data (microseconds since epoch) /// @@ -18,116 +17,6 @@ use crate::plot::ArrayElement; #[derive(Debug, Clone, Copy)] pub struct DateTime; -// Microseconds per time unit -const MICROS_PER_SECOND: f64 = 1_000_000.0; -const MICROS_PER_MINUTE: f64 = 60.0 * MICROS_PER_SECOND; -const MICROS_PER_HOUR: f64 = 60.0 * MICROS_PER_MINUTE; -const MICROS_PER_DAY: f64 = 24.0 * MICROS_PER_HOUR; - -// DateTime interval types for break calculation -#[derive(Debug, Clone, Copy, PartialEq)] -enum DateTimeInterval { - Year, - Month, - Day, - Hour, - Minute, - Second, -} - -impl DateTimeInterval { - /// Approximate microseconds in each interval - fn micros(&self) -> f64 { - match self { - DateTimeInterval::Year => 365.25 * MICROS_PER_DAY, - DateTimeInterval::Month => 30.4375 * MICROS_PER_DAY, - DateTimeInterval::Day => MICROS_PER_DAY, - DateTimeInterval::Hour => MICROS_PER_HOUR, - DateTimeInterval::Minute => MICROS_PER_MINUTE, - DateTimeInterval::Second => MICROS_PER_SECOND, - } - } - - /// Calculate expected number of breaks for this interval over the given span - fn expected_breaks(&self, span_micros: f64) -> f64 { - span_micros / self.micros() - } - - /// Select appropriate interval and step based on span and desired break count. - /// Uses tolerance-based search: tries each interval from largest to smallest, - /// stops when within ~20% of requested n, then calculates a nice step multiplier. - fn select(span_micros: f64, n: usize) -> (Self, usize) { - let n_f64 = n as f64; - let tolerance = 0.2; // 20% tolerance - let min_breaks = n_f64 * (1.0 - tolerance); - let max_breaks = n_f64 * (1.0 + tolerance); - - // Intervals from largest to smallest - let intervals = [ - DateTimeInterval::Year, - DateTimeInterval::Month, - DateTimeInterval::Day, - DateTimeInterval::Hour, - DateTimeInterval::Minute, - DateTimeInterval::Second, - ]; - - for &interval in &intervals { - let expected = interval.expected_breaks(span_micros); - - // Skip if this interval produces too few breaks - if expected < 1.0 { - continue; - } - - // If within tolerance, use step=1 - if expected >= min_breaks && expected <= max_breaks { - return (interval, 1); - } - - // If too many breaks, calculate a nice step - if expected > max_breaks { - let raw_step = expected / n_f64; - let nice = match interval { - DateTimeInterval::Year => nice_step(raw_step) as usize, - DateTimeInterval::Month => nice_month_step(raw_step), - DateTimeInterval::Day => nice_step(raw_step) as usize, - DateTimeInterval::Hour => nice_hour_step(raw_step) as usize, - DateTimeInterval::Minute => nice_minute_step(raw_step) as usize, - DateTimeInterval::Second => nice_step(raw_step) as usize, - }; - let step = nice.max(1); - - // Verify the stepped interval is reasonable - let stepped_breaks = expected / step as f64; - if stepped_breaks >= 1.0 { - return (interval, step); - } - } - } - - // Fallback: use Second with step calculation - let expected = DateTimeInterval::Second.expected_breaks(span_micros); - let step = (nice_step(expected / n_f64) as usize).max(1); - (DateTimeInterval::Second, step) - } -} - -/// Nice step values for months (1, 2, 3, 6, 12) -fn nice_month_step(step: f64) -> usize { - if step <= 1.0 { - 1 - } else if step <= 2.0 { - 2 - } else if step <= 4.0 { - 3 - } else if step <= 8.0 { - 6 - } else { - 12 - } -} - impl TransformTrait for DateTime { fn transform_kind(&self) -> TransformKind { TransformKind::DateTime @@ -158,13 +47,12 @@ impl TransformTrait for DateTime { return vec![]; } - let span = max - min; - let (interval, step) = DateTimeInterval::select(span, n); - if pretty { - calculate_pretty_datetime_breaks(min, max, interval, step) + // A calendar interval (years down to seconds) sized to about `n` + // breaks, stepped from its calendar grid. + temporal_breaks_from_f64(min, max, zygos::TemporalUnit::DateTime, n) } else { - calculate_linear_datetime_breaks(min, max, n) + linear_breaks(min, max, n) } } @@ -174,7 +62,9 @@ impl TransformTrait for DateTime { n: usize, range: Option<(f64, f64)>, ) -> Vec { - // Use linear minor breaks in microsecond-space + // Evenly spaced minors for an explicit count, or for majors that don't + // step by a calendar interval; calendar-stepped majors get calendar + // minors during scale resolution instead. minor_breaks_linear(major_breaks, n, range) } @@ -199,215 +89,6 @@ impl TransformTrait for DateTime { } } -/// Calculate pretty datetime breaks aligned to interval boundaries -fn calculate_pretty_datetime_breaks( - min: f64, - max: f64, - interval: DateTimeInterval, - step: usize, -) -> Vec { - let mut breaks = Vec::new(); - - match interval { - DateTimeInterval::Year => { - let min_dt = micros_to_datetime(min as i64); - let max_dt = micros_to_datetime(max as i64); - - let start_year = min_dt.year(); - let end_year = max_dt.year(); - - let step = step as i32; - let aligned_start = (start_year / step) * step; - - let mut year = aligned_start; - while year <= end_year + step { - if let Some(dt) = chrono::NaiveDate::from_ymd_opt(year, 1, 1) { - let micros = datetime_to_micros(dt.and_hms_opt(0, 0, 0).unwrap()); - if micros >= min && micros <= max { - breaks.push(micros); - } - } - year += step; - } - } - DateTimeInterval::Month => { - let min_dt = micros_to_datetime(min as i64); - let max_dt = micros_to_datetime(max as i64); - - let start_year = min_dt.year(); - let start_month = min_dt.month(); - let end_year = max_dt.year(); - let end_month = max_dt.month(); - - let mut year = start_year; - let mut month = ((start_month - 1) / step as u32) * step as u32 + 1; - - while year < end_year || (year == end_year && month <= end_month) { - if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, 1) { - let micros = datetime_to_micros(date.and_hms_opt(0, 0, 0).unwrap()); - if micros >= min && micros <= max { - breaks.push(micros); - } - } - month += step as u32; - if month > 12 { - month -= 12; - year += 1; - } - } - } - DateTimeInterval::Day => { - let step_micros = (step as i64) * MICROS_PER_DAY as i64; - - let start_micros = (min / MICROS_PER_DAY).floor() as i64 * MICROS_PER_DAY as i64; - - let mut micros = start_micros; - while (micros as f64) <= max { - let m = micros as f64; - if m >= min && m <= max { - breaks.push(m); - } - micros += step_micros; - } - } - DateTimeInterval::Hour => { - let step_micros = (step as i64) * MICROS_PER_HOUR as i64; - - let start_micros = (min / MICROS_PER_HOUR).floor() as i64 * MICROS_PER_HOUR as i64; - - let mut micros = start_micros; - while (micros as f64) <= max { - let m = micros as f64; - if m >= min && m <= max { - breaks.push(m); - } - micros += step_micros; - } - } - DateTimeInterval::Minute => { - let step_micros = (step as i64) * MICROS_PER_MINUTE as i64; - - let start_micros = (min / MICROS_PER_MINUTE).floor() as i64 * MICROS_PER_MINUTE as i64; - - let mut micros = start_micros; - while (micros as f64) <= max { - let m = micros as f64; - if m >= min && m <= max { - breaks.push(m); - } - micros += step_micros; - } - } - DateTimeInterval::Second => { - let step_micros = (step as i64) * MICROS_PER_SECOND as i64; - - let start_micros = (min / MICROS_PER_SECOND).floor() as i64 * MICROS_PER_SECOND as i64; - - let mut micros = start_micros; - while (micros as f64) <= max { - let m = micros as f64; - if m >= min && m <= max { - breaks.push(m); - } - micros += step_micros; - } - } - } - - if breaks.is_empty() { - breaks.push(min); - if max > min { - breaks.push(max); - } - } - - breaks -} - -/// Calculate linear breaks in microsecond-space -fn calculate_linear_datetime_breaks(min: f64, max: f64, n: usize) -> Vec { - if n <= 1 { - return vec![min]; - } - - let step = (max - min) / (n - 1) as f64; - (0..n).map(|i| min + i as f64 * step).collect() -} - -/// Convert microseconds since epoch to NaiveDateTime -fn micros_to_datetime(micros: i64) -> chrono::NaiveDateTime { - let secs = micros / 1_000_000; - let nsecs = ((micros % 1_000_000).abs() * 1000) as u32; - chrono::DateTime::from_timestamp(secs, nsecs) - .map(|dt| dt.naive_utc()) - .unwrap_or_default() -} - -/// Convert NaiveDateTime to microseconds since epoch -fn datetime_to_micros(dt: chrono::NaiveDateTime) -> f64 { - dt.and_utc().timestamp_micros() as f64 -} - -/// Round to a "nice" step value -fn nice_step(step: f64) -> f64 { - if step <= 0.0 { - return 1.0; - } - - let magnitude = 10_f64.powf(step.log10().floor()); - let residual = step / magnitude; - - let nice = if residual <= 1.5 { - 1.0 - } else if residual <= 3.0 { - 2.0 - } else if residual <= 7.0 { - 5.0 - } else { - 10.0 - }; - - nice * magnitude -} - -/// Nice step values for hours (1, 2, 3, 4, 6, 12, 24) -fn nice_hour_step(step: f64) -> f64 { - if step <= 1.0 { - 1.0 - } else if step <= 2.0 { - 2.0 - } else if step <= 3.0 { - 3.0 - } else if step <= 4.0 { - 4.0 - } else if step <= 6.0 { - 6.0 - } else if step <= 12.0 { - 12.0 - } else { - 24.0 - } -} - -/// Nice step values for minutes (1, 2, 5, 10, 15, 30, 60) -fn nice_minute_step(step: f64) -> f64 { - if step <= 1.0 { - 1.0 - } else if step <= 2.0 { - 2.0 - } else if step <= 5.0 { - 5.0 - } else if step <= 10.0 { - 10.0 - } else if step <= 15.0 { - 15.0 - } else if step <= 30.0 { - 30.0 - } else { - 60.0 - } -} - impl std::fmt::Display for DateTime { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.name()) @@ -418,6 +99,12 @@ impl std::fmt::Display for DateTime { mod tests { use super::*; + // Microseconds per time unit + const MICROS_PER_SECOND: f64 = 1_000_000.0; + const MICROS_PER_MINUTE: f64 = 60.0 * MICROS_PER_SECOND; + const MICROS_PER_HOUR: f64 = 60.0 * MICROS_PER_MINUTE; + const MICROS_PER_DAY: f64 = 24.0 * MICROS_PER_HOUR; + #[test] fn test_datetime_transform_kind() { let t = DateTime; @@ -480,6 +167,22 @@ mod tests { assert!(!breaks.is_empty()); } + #[test] + fn test_datetime_breaks_follow_the_clock() { + let t = DateTime; + // A day with about eight breaks → every three hours from midnight. + let breaks = t.calculate_breaks(0.0, 24.0 * MICROS_PER_HOUR, 8, true); + let hours: Vec = breaks.iter().map(|b| b / MICROS_PER_HOUR).collect(); + assert_eq!( + hours, + vec![0.0, 3.0, 6.0, 9.0, 12.0, 15.0, 18.0, 21.0, 24.0] + ); + // An hour with about six breaks → every ten minutes. + let breaks = t.calculate_breaks(0.0, 60.0 * MICROS_PER_MINUTE, 6, true); + let minutes: Vec = breaks.iter().map(|b| b / MICROS_PER_MINUTE).collect(); + assert_eq!(minutes, vec![0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0]); + } + #[test] fn test_datetime_breaks_linear() { let t = DateTime; @@ -488,47 +191,4 @@ mod tests { assert_eq!(breaks[0], 0.0); assert_eq!(breaks[4], 1_000_000.0); } - - #[test] - fn test_datetime_interval_selection() { - // Large span (5 years, n=5) -> year with step - let (interval, step) = DateTimeInterval::select(365.0 * MICROS_PER_DAY * 5.0, 5); - assert_eq!(interval, DateTimeInterval::Year); - assert!(step >= 1); - - // Day span (30 days, n=5) -> day with step - let (interval, step) = DateTimeInterval::select(30.0 * MICROS_PER_DAY, 5); - assert_eq!(interval, DateTimeInterval::Day); - assert!(step >= 1); - - // Hour span (24 hours, n=8) -> hour with step - let (interval, step) = DateTimeInterval::select(24.0 * MICROS_PER_HOUR, 8); - assert_eq!(interval, DateTimeInterval::Hour); - assert!(step >= 1); - - // Minute span (60 minutes, n=6) -> minute with step - let (interval, step) = DateTimeInterval::select(60.0 * MICROS_PER_MINUTE, 6); - assert_eq!(interval, DateTimeInterval::Minute); - assert!(step >= 1); - } - - #[test] - fn test_nice_hour_step() { - assert_eq!(nice_hour_step(1.0), 1.0); - assert_eq!(nice_hour_step(1.5), 2.0); - assert_eq!(nice_hour_step(2.5), 3.0); - assert_eq!(nice_hour_step(5.0), 6.0); - assert_eq!(nice_hour_step(10.0), 12.0); - assert_eq!(nice_hour_step(20.0), 24.0); - } - - #[test] - fn test_nice_minute_step() { - assert_eq!(nice_minute_step(1.0), 1.0); - assert_eq!(nice_minute_step(3.0), 5.0); - assert_eq!(nice_minute_step(7.0), 10.0); - assert_eq!(nice_minute_step(12.0), 15.0); - assert_eq!(nice_minute_step(20.0), 30.0); - assert_eq!(nice_minute_step(45.0), 60.0); - } } diff --git a/src/plot/scale/transform/exp.rs b/src/plot/scale/transform/exp.rs index 08938fe85..ba4302821 100644 --- a/src/plot/scale/transform/exp.rs +++ b/src/plot/scale/transform/exp.rs @@ -1,7 +1,7 @@ //! Exponential transform implementation (base^x) - inverse of log -use super::{TransformKind, TransformTrait}; -use crate::plot::scale::breaks::{exp_pretty_breaks, linear_breaks, minor_breaks_linear}; +use super::{TransformKind, TransformTrait, ZygosMath}; +use crate::plot::scale::breaks::{extended_breaks, linear_breaks, minor_breaks_linear}; /// Exponential transform (base^x) - inverse of log /// @@ -53,6 +53,17 @@ impl Exp { fn is_base2(&self) -> bool { (self.base - 2.0).abs() < 1e-10 } + + /// zygos's exponential for this base: its own member for bases 10, 2 and e, + /// and the natural exponential of `x · ln(base)` for any other. + fn math(&self) -> ZygosMath { + match self.transform_kind() { + TransformKind::Exp if (self.base - std::f64::consts::E).abs() >= 1e-10 => { + ZygosMath::with_scale(TransformKind::Exp, self.base.ln()) + } + kind => ZygosMath::of(kind), + } + } } impl TransformTrait for Exp { @@ -78,15 +89,15 @@ impl TransformTrait for Exp { } fn allowed_domain(&self) -> (f64, f64) { - (f64::NEG_INFINITY, f64::INFINITY) + self.math().allowed_domain() } fn calculate_breaks(&self, min: f64, max: f64, n: usize, pretty: bool) -> Vec { - // Breaks are in data space (exponents), not output space - if pretty && (self.is_base10() || self.is_base2()) { - exp_pretty_breaks(min, max, n, self.base) + // Breaks in data space (the exponents), so the labels stay round + // numbers; the axis spaces them out exponentially. + if pretty { + extended_breaks(min, max, n) } else { - // Default: nice linear breaks (0, 1, 2, 3, ...) linear_breaks(min, max, n) } } @@ -101,11 +112,15 @@ impl TransformTrait for Exp { } fn transform(&self, value: f64) -> f64 { - self.base.powf(value) + self.math().forward_exp(value) } fn inverse(&self, value: f64) -> f64 { - value.log(self.base) + self.math().inverse_exp(value) + } + + fn expand_range(&self, min: f64, max: f64, mult: (f64, f64), add: (f64, f64)) -> (f64, f64) { + self.math().expand_range_exp(min, max, mult, add) } } @@ -268,6 +283,19 @@ mod tests { assert!((Exp::natural().base() - E).abs() < 1e-10); } + #[test] + fn test_expand_range_pads_in_own_transformed_units() { + // Whatever zygos member carries the math, each end moves outward by + // `mult × span + add` measured in this transform's own output. + for t in [Exp::base10(), Exp::base2(), Exp::natural(), Exp::new(5.0)] { + let (min, max) = (1.5, 2.0); + let (lo, hi) = t.expand_range(min, max, (0.05, 0.1), (0.5, 0.25)); + let span = t.transform(max) - t.transform(min); + assert!((t.transform(min) - t.transform(lo) - (0.05 * span + 0.5)).abs() < 1e-9); + assert!((t.transform(hi) - t.transform(max) - (0.1 * span + 0.25)).abs() < 1e-9); + } + } + #[test] fn test_custom_base() { let t = Exp::new(5.0); @@ -297,7 +325,14 @@ mod tests { #[test] fn test_exp_breaks() { let t = Exp::base10(); - let breaks = t.calculate_breaks(0.0, 3.0, 5, false); - assert!(!breaks.is_empty()); + // Round exponents; the axis spaces them out exponentially. + assert_eq!( + t.calculate_breaks(0.0, 3.0, 5, true), + vec![0.0, 1.0, 2.0, 3.0] + ); + assert_eq!( + t.calculate_breaks(0.0, 3.0, 5, false), + vec![0.0, 0.75, 1.5, 2.25, 3.0] + ); } } diff --git a/src/plot/scale/transform/identity.rs b/src/plot/scale/transform/identity.rs index aa490585b..3fbc50c41 100644 --- a/src/plot/scale/transform/identity.rs +++ b/src/plot/scale/transform/identity.rs @@ -1,7 +1,7 @@ //! Identity transform implementation (no transformation) -use super::{TransformKind, TransformTrait}; -use crate::plot::scale::breaks::{linear_breaks, minor_breaks_linear, pretty_breaks}; +use super::{TransformKind, TransformTrait, ZygosMath}; +use crate::plot::scale::breaks::{extended_breaks, linear_breaks, minor_breaks_linear}; /// Identity transform - no transformation (linear scale) #[derive(Debug, Clone, Copy)] @@ -17,12 +17,12 @@ impl TransformTrait for Identity { } fn allowed_domain(&self) -> (f64, f64) { - (f64::NEG_INFINITY, f64::INFINITY) + ZygosMath::of(TransformKind::Identity).allowed_domain() } fn calculate_breaks(&self, min: f64, max: f64, n: usize, pretty: bool) -> Vec { if pretty { - pretty_breaks(min, max, n) + extended_breaks(min, max, n) } else { linear_breaks(min, max, n) } @@ -38,11 +38,11 @@ impl TransformTrait for Identity { } fn transform(&self, value: f64) -> f64 { - value + ZygosMath::of(TransformKind::Identity).forward(value) } fn inverse(&self, value: f64) -> f64 { - value + ZygosMath::of(TransformKind::Identity).inverse(value) } } @@ -94,8 +94,7 @@ mod tests { fn test_identity_breaks_pretty() { let t = Identity; let breaks = t.calculate_breaks(0.0, 100.0, 5, true); - assert!(!breaks.is_empty()); - // Pretty breaks should produce nice numbers + assert_eq!(breaks, vec![0.0, 20.0, 40.0, 60.0, 80.0, 100.0]); } #[test] diff --git a/src/plot/scale/transform/log.rs b/src/plot/scale/transform/log.rs index 17ac8a4fd..6b0882380 100644 --- a/src/plot/scale/transform/log.rs +++ b/src/plot/scale/transform/log.rs @@ -3,8 +3,8 @@ //! This module provides a unified logarithm transform that supports any base. //! Common bases (10, 2, e) have named constructors for convenience. -use super::{TransformKind, TransformTrait}; -use crate::plot::scale::breaks::{log_breaks, minor_breaks_log}; +use super::{TransformKind, TransformTrait, ZygosMath}; +use crate::plot::scale::breaks::{extent_minor_breaks, log_minor_breaks, log_pretty_breaks}; /// Log transform - logarithm with configurable base /// @@ -60,6 +60,17 @@ impl Log { fn is_base2(&self) -> bool { (self.base - 2.0).abs() < 1e-10 } + + /// zygos's log for this base: its own member for bases 10, 2 and e, and the + /// natural log divided by `ln(base)` for any other. + fn math(&self) -> ZygosMath { + match self.transform_kind() { + TransformKind::Log if (self.base - std::f64::consts::E).abs() >= 1e-10 => { + ZygosMath::with_scale(TransformKind::Log, self.base.ln()) + } + kind => ZygosMath::of(kind), + } + } } impl TransformTrait for Log { @@ -85,11 +96,13 @@ impl TransformTrait for Log { } fn allowed_domain(&self) -> (f64, f64) { + // Strictly positive, not zygos's `(0, +∞)` bound: scale resolution clips + // an out-of-domain limit to this minimum, and zero has no logarithm. (f64::MIN_POSITIVE, f64::INFINITY) } - fn calculate_breaks(&self, min: f64, max: f64, n: usize, pretty: bool) -> Vec { - log_breaks(min, max, n, self.base, pretty) + fn calculate_breaks(&self, min: f64, max: f64, n: usize, _pretty: bool) -> Vec { + log_pretty_breaks(min, max, n, self.base) } fn calculate_minor_breaks( @@ -98,19 +111,27 @@ impl TransformTrait for Log { n: usize, range: Option<(f64, f64)>, ) -> Vec { - minor_breaks_log(major_breaks, n, self.base, range) + extent_minor_breaks(major_breaks, n, range, |min, max| { + log_minor_breaks(min, max, self.base) + }) } fn default_minor_break_count(&self) -> usize { - 8 // Similar density to traditional 2-9 pattern on log axes + // The whole-number multiples between powers fix the density (2-9 for + // base 10); any non-zero count asks for them. + 8 } fn transform(&self, value: f64) -> f64 { - value.log(self.base) + self.math().forward(value) } fn inverse(&self, value: f64) -> f64 { - self.base.powf(value) + self.math().inverse(value) + } + + fn expand_range(&self, min: f64, max: f64, mult: (f64, f64), add: (f64, f64)) -> (f64, f64) { + self.math().expand_range(min, max, mult, add) } } @@ -217,28 +238,34 @@ mod tests { } #[test] - fn test_all_bases_breaks_contain_powers() { - // Log10: 1, 10, 100, 1000 + fn test_all_bases_breaks() { + // Log10: the 1-2-5 pattern across every decade. let t10 = Log::base10(); - let breaks10 = t10.calculate_breaks(1.0, 1000.0, 10, false); - for &v in &[1.0, 10.0, 100.0, 1000.0] { - assert!(breaks10.contains(&v), "log10 breaks should contain {}", v); - } + assert_eq!( + t10.calculate_breaks(1.0, 1000.0, 5, true), + vec![1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0] + ); + // `pretty` has no powers-only alternative. + assert_eq!( + t10.calculate_breaks(1.0, 1000.0, 5, false), + t10.calculate_breaks(1.0, 1000.0, 5, true) + ); - // Log2: 1, 2, 4, 8, 16 + // Log2: only multipliers below the base survive, leaving the powers. let t2 = Log::base2(); - let breaks2 = t2.calculate_breaks(1.0, 16.0, 10, false); - for &v in &[1.0, 2.0, 4.0, 8.0, 16.0] { - assert!(breaks2.contains(&v), "log2 breaks should contain {}", v); - } + assert_eq!( + t2.calculate_breaks(1.0, 16.0, 10, true), + vec![1.0, 2.0, 4.0, 8.0, 16.0] + ); - // Natural log - just verify non-empty + // Natural log over more than four e-folds: the powers of e alone. let tn = Log::natural(); - let breaksn = tn.calculate_breaks(1.0, 100.0, 10, false); - assert!( - !breaksn.is_empty(), - "natural log breaks should not be empty" - ); + let expected = [0, 1, 2, 3, 4].map(|d| std::f64::consts::E.powi(d)); + let breaksn = tn.calculate_breaks(1.0, 100.0, 10, true); + assert_eq!(breaksn.len(), expected.len(), "{breaksn:?}"); + for (b, x) in breaksn.iter().zip(expected) { + assert!((b - x).abs() < 1e-9, "{breaksn:?}"); + } } #[test] @@ -257,6 +284,19 @@ mod tests { assert!((Log::natural().base() - E).abs() < 1e-10); } + #[test] + fn test_expand_range_pads_in_own_transformed_units() { + // Whatever zygos member carries the math, each end moves outward by + // `mult × span + add` measured in this transform's own output. + for t in [Log::base10(), Log::base2(), Log::natural(), Log::new(5.0)] { + let (min, max) = (1.0, 1000.0); + let (lo, hi) = t.expand_range(min, max, (0.05, 0.1), (0.5, 0.25)); + let span = t.transform(max) - t.transform(min); + assert!((t.transform(min) - t.transform(lo) - (0.05 * span + 0.5)).abs() < 1e-9); + assert!((t.transform(hi) - t.transform(max) - (0.1 * span + 0.25)).abs() < 1e-9); + } + } + #[test] fn test_custom_base() { let t = Log::new(5.0); @@ -290,7 +330,7 @@ mod tests { // Test minor breaks work for all bases let test_cases = vec![ (Log::base10(), vec![1.0, 10.0, 100.0], 8, 16), // 8 per decade × 2 decades - (Log::base2(), vec![1.0, 2.0, 4.0, 8.0], 1, 3), // 1 per interval × 3 intervals + (Log::base2(), vec![1.0, 2.0, 4.0, 8.0], 1, 3), // geometric midpoint per octave ]; for (t, majors, n, expected_len) in test_cases { @@ -312,13 +352,20 @@ mod tests { } #[test] - fn test_minor_breaks_geometric_mean() { + fn test_minor_breaks_whole_number_multiples() { let t = Log::base10(); let majors = vec![1.0, 10.0]; - let minors = t.calculate_minor_breaks(&majors, 1, None); - // Single minor break should be at geometric mean: sqrt(1 * 10) ≈ 3.16 - assert_eq!(minors.len(), 1); - assert!((minors[0] - (1.0_f64 * 10.0).sqrt()).abs() < 0.01); + // The multiples 2-9 of each power, whatever non-zero count is asked for. + let expected = vec![2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + assert_eq!(t.calculate_minor_breaks(&majors, 1, None), expected); + assert_eq!(t.calculate_minor_breaks(&majors, 8, None), expected); + assert!(t.calculate_minor_breaks(&majors, 0, None).is_empty()); + + // Base 2 has no whole number strictly inside an octave: the geometric + // midpoint stands in. + let minors = Log::base2().calculate_minor_breaks(&[1.0, 2.0, 4.0, 8.0], 1, None); + let sqrt2 = std::f64::consts::SQRT_2; + assert_eq!(minors, vec![sqrt2, 2.0 * sqrt2, 4.0 * sqrt2]); } #[test] diff --git a/src/plot/scale/transform/mod.rs b/src/plot/scale/transform/mod.rs index a8b4ffc72..c860fe838 100644 --- a/src/plot/scale/transform/mod.rs +++ b/src/plot/scale/transform/mod.rs @@ -118,6 +118,151 @@ impl TransformKind { TransformKind::Date | TransformKind::DateTime | TransformKind::Time ) } + + /// The zygos transform that computes this kind's value math, for the numeric + /// kinds zygos implements. `None` for the temporal, discrete, integer and + /// geographic kinds, whose math stays in ggsql. + /// + /// The log, exp and pseudo-log families carry a base that the kind alone + /// doesn't pin down — `Log` and `Exp` also cover custom bases, and `PseudoLog` + /// covers every base — so `Log`, `Exp` and `PseudoLog` map to zygos's + /// natural-base member, which [`ZygosMath`] rescales by `ln(base)`. + pub(crate) fn zygos_kind(self) -> Option { + use zygos::transform::TransformKind as Z; + Some(match self { + TransformKind::Identity => Z::Identity, + TransformKind::Log10 => Z::Log10, + TransformKind::Log2 => Z::Log2, + TransformKind::Log => Z::Log, + TransformKind::Sqrt => Z::Sqrt, + TransformKind::Square => Z::Square, + TransformKind::Exp10 => Z::Exp10, + TransformKind::Exp2 => Z::Exp2, + TransformKind::Exp => Z::Exp, + TransformKind::Asinh => Z::Asinh, + TransformKind::PseudoLog => Z::PseudoLog, + TransformKind::Date + | TransformKind::DateTime + | TransformKind::Time + | TransformKind::String + | TransformKind::Bool + | TransformKind::Integer + | TransformKind::Geographic => return None, + }) + } + + /// The zygos calendar type a temporal kind's values encode — days for + /// `Date`, microseconds for `DateTime`, nanoseconds since midnight for + /// `Time` — which is what zygos's calendar break algorithms step through. + /// `None` for the non-temporal kinds. + pub(crate) fn temporal_unit(self) -> Option { + match self { + TransformKind::Date => Some(zygos::TemporalUnit::Date), + TransformKind::DateTime => Some(zygos::TemporalUnit::DateTime), + TransformKind::Time => Some(zygos::TemporalUnit::Time), + _ => None, + } + } +} + +/// Value math for a numeric transform, delegated to zygos. +/// +/// `scale` stretches zygos's transform to a base it has no member for: a log of +/// base `b` is `ln(x) / ln(b)`, so a `Log` transform of a custom base runs +/// zygos's natural log and divides by `ln(b)`. It is `1` whenever zygos computes +/// the base directly. +#[derive(Debug, Clone, Copy)] +pub(crate) struct ZygosMath { + transform: zygos::transform::Transform, + scale: f64, +} + +impl ZygosMath { + /// zygos's math for `kind` at its own base. + /// + /// # Panics + /// If `kind` has no zygos counterpart (see [`TransformKind::zygos_kind`]). + pub(crate) fn of(kind: TransformKind) -> Self { + Self::with_scale(kind, 1.0) + } + + /// zygos's math for `kind`, rescaled by `scale` (see the type docs). + pub(crate) fn with_scale(kind: TransformKind, scale: f64) -> Self { + let zygos_kind = kind + .zygos_kind() + .unwrap_or_else(|| panic!("{kind} has no zygos transform")); + Self { + transform: zygos::transform::Transform::of(zygos_kind), + scale, + } + } + + /// The domain zygos defines the transform on. + pub(crate) fn allowed_domain(&self) -> (f64, f64) { + self.transform.allowed_domain() + } + + /// Forward transform. The base divides the output, as it does for the log + /// and pseudo-log families: `f(x) / scale`. + pub(crate) fn forward(&self, value: f64) -> f64 { + self.transform.forward(value) / self.scale + } + + /// Inverse of [`forward`](Self::forward): `f⁻¹(y · scale)`. + pub(crate) fn inverse(&self, value: f64) -> f64 { + self.transform.inverse(value * self.scale) + } + + /// Forward transform for the exponential family, where the base multiplies + /// the input instead: `f(x · scale)`. + pub(crate) fn forward_exp(&self, value: f64) -> f64 { + self.transform.forward(value * self.scale) + } + + /// Inverse of [`forward_exp`](Self::forward_exp): `f⁻¹(y) / scale`. + pub(crate) fn inverse_exp(&self, value: f64) -> f64 { + self.transform.inverse(value) / self.scale + } + + /// Expand `[min, max]` in the space of [`forward`](Self::forward), via + /// zygos's transformed-space expansion. `mult` and `add` are `(lower, upper)` + /// pairs, and `add` is in units of `forward`'s output — which is zygos's + /// output divided by `scale`, so it is multiplied back before zygos sees it. + pub(crate) fn expand_range( + &self, + min: f64, + max: f64, + mult: (f64, f64), + add: (f64, f64), + ) -> (f64, f64) { + zygos::transform::expand_range_transformed_selective( + min, + max, + mult, + (add.0 * self.scale, add.1 * self.scale), + self.transform.kind(), + ) + } + + /// [`expand_range`](Self::expand_range) in the space of + /// [`forward_exp`](Self::forward_exp): zygos expands the scaled input + /// `x · scale`, and the result is scaled back. + pub(crate) fn expand_range_exp( + &self, + min: f64, + max: f64, + mult: (f64, f64), + add: (f64, f64), + ) -> (f64, f64) { + let (lo, hi) = zygos::transform::expand_range_transformed_selective( + min * self.scale, + max * self.scale, + mult, + add, + self.transform.kind(), + ); + (lo / self.scale, hi / self.scale) + } } impl std::fmt::Display for TransformKind { @@ -218,6 +363,28 @@ pub trait TransformTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { /// Maps a value from transformed space back to data space. fn inverse(&self, value: f64) -> f64; + /// Expand a `[min, max]` domain in transformed space and map it back. + /// + /// `mult` and `add` are `(lower, upper)` pairs: an end is padded by + /// `mult × span + add`, where the span and `add` are measured in transformed + /// units, so a padded domain never leaves the transform's domain (a log axis + /// is padded in decades rather than below zero). A zero-width range pads as + /// if it spanned one transformed unit, and a range whose endpoints have no + /// finite transform comes back unchanged. See + /// [`zygos::transform::expand_range_transformed_selective`]. + /// + /// The default runs zygos's expansion for this kind, or the identity + /// expansion for kinds zygos has no transform for (temporal, discrete, + /// integer, geographic). Transforms whose math rescales a zygos member to + /// another base override it. + fn expand_range(&self, min: f64, max: f64, mult: (f64, f64), add: (f64, f64)) -> (f64, f64) { + let kind = self + .transform_kind() + .zygos_kind() + .unwrap_or(zygos::transform::TransformKind::Identity); + zygos::transform::expand_range_transformed_selective(min, max, mult, add, kind) + } + /// Wrap a numeric value in the appropriate ArrayElement type. /// /// Temporal transforms override to return Date/DateTime/Time variants. @@ -455,6 +622,21 @@ impl Transform { self.0.inverse(value) } + /// Expand a `[min, max]` domain in transformed space, per end (see + /// [`TransformTrait::expand_range`]). Ends whose pad would leave the + /// transform's image are left unpadded by zygos (a sqrt domain padded + /// below 0 keeps its 0 rather than folding back inside the range). + pub fn expand_range( + &self, + min: f64, + max: f64, + mult: (f64, f64), + add: (f64, f64), + ) -> (f64, f64) { + let (min, max) = if min <= max { (min, max) } else { (max, min) }; + self.0.expand_range(min, max, mult, add) + } + /// Wrap a numeric value in the appropriate ArrayElement type pub fn wrap_numeric(&self, value: f64) -> ArrayElement { self.0.wrap_numeric(value) @@ -470,6 +652,12 @@ impl Transform { self.transform_kind() == TransformKind::Identity } + /// The zygos calendar type of a temporal transform (see + /// [`TransformKind::temporal_unit`]); `None` for non-temporal transforms. + pub(crate) fn temporal_unit(&self) -> Option { + self.transform_kind().temporal_unit() + } + /// Returns true if this is a temporal transform (Date, DateTime, or Time) pub fn is_temporal(&self) -> bool { self.transform_kind().is_temporal() @@ -622,6 +810,23 @@ pub const ALL_TRANSFORM_NAMES: &[&str] = &[ mod tests { use super::*; + #[test] + fn test_expand_range_leaves_ends_past_the_image_unpadded() { + // exp10 of 0..3 spans 1..1000; a 5% pad below 1 lands at -49, which + // has no logarithm, so the lower end stays put rather than padding. + let t = Transform::exp10(); + let (lo, hi) = t.expand_range(0.0, 3.0, (0.05, 0.05), (0.0, 0.0)); + assert_eq!(lo, 0.0); + assert!((t.transform(hi) - (1000.0 + 0.05 * 999.0)).abs() < 1e-9); + + // Square of 0..10 spans 0..100; the lower pad overshoots zero too, + // where the inverse would fold back inside the range. + let t = Transform::square(); + let (lo, hi) = t.expand_range(0.0, 10.0, (0.05, 0.05), (0.0, 0.0)); + assert_eq!(lo, 0.0); + assert!((t.transform(hi) - 105.0).abs() < 1e-9); + } + #[test] fn test_transform_creation() { let identity = Transform::identity(); diff --git a/src/plot/scale/transform/pseudo_log.rs b/src/plot/scale/transform/pseudo_log.rs index f7461cb3c..3767f99e5 100644 --- a/src/plot/scale/transform/pseudo_log.rs +++ b/src/plot/scale/transform/pseudo_log.rs @@ -4,8 +4,8 @@ //! negative values. The base parameter controls which logarithm is approximated //! for large values. -use super::{TransformKind, TransformTrait}; -use crate::plot::scale::breaks::{minor_breaks_symlog, symlog_breaks}; +use super::{TransformKind, TransformTrait, ZygosMath}; +use crate::plot::scale::breaks::{extent_minor_breaks, symlog_breaks, symlog_minor_breaks}; /// PseudoLog transform - symmetric logarithm with configurable base /// @@ -84,6 +84,11 @@ impl PseudoLog { fn is_base2(&self) -> bool { (self.base - 2.0).abs() < 1e-10 } + + /// zygos's natural pseudo-log, `asinh(x / 2)`, divided by `ln(base)`. + fn math(&self) -> ZygosMath { + ZygosMath::with_scale(TransformKind::PseudoLog, self.ln_base) + } } impl TransformTrait for PseudoLog { @@ -102,11 +107,11 @@ impl TransformTrait for PseudoLog { } fn allowed_domain(&self) -> (f64, f64) { - (f64::NEG_INFINITY, f64::INFINITY) + self.math().allowed_domain() } - fn calculate_breaks(&self, min: f64, max: f64, n: usize, pretty: bool) -> Vec { - symlog_breaks(min, max, n, pretty) + fn calculate_breaks(&self, min: f64, max: f64, n: usize, _pretty: bool) -> Vec { + symlog_breaks(min, max, n, self.base) } fn calculate_minor_breaks( @@ -115,19 +120,27 @@ impl TransformTrait for PseudoLog { n: usize, range: Option<(f64, f64)>, ) -> Vec { - minor_breaks_symlog(major_breaks, n, range) + extent_minor_breaks(major_breaks, n, range, |min, max| { + symlog_minor_breaks(min, max, self.base) + }) } fn default_minor_break_count(&self) -> usize { - 8 // Similar density to traditional 2-9 pattern on log axes + // The whole-number multiples between powers fix the density; any + // non-zero count asks for them. + 8 } fn transform(&self, value: f64) -> f64 { - (value * 0.5).asinh() / self.ln_base + self.math().forward(value) } fn inverse(&self, value: f64) -> f64 { - (value * self.ln_base).sinh() * 2.0 + self.math().inverse(value) + } + + fn expand_range(&self, min: f64, max: f64, mult: (f64, f64), add: (f64, f64)) -> (f64, f64) { + self.math().expand_range(min, max, mult, add) } } @@ -280,6 +293,24 @@ mod tests { assert!((PseudoLog::natural().base() - E).abs() < 1e-10); } + #[test] + fn test_expand_range_pads_in_own_transformed_units() { + // Whatever zygos member carries the math, each end moves outward by + // `mult × span + add` measured in this transform's own output. + for t in [ + PseudoLog::base10(), + PseudoLog::base2(), + PseudoLog::natural(), + PseudoLog::new(5.0), + ] { + let (min, max) = (-50.0, 1000.0); + let (lo, hi) = t.expand_range(min, max, (0.05, 0.1), (0.5, 0.25)); + let span = t.transform(max) - t.transform(min); + assert!((t.transform(min) - t.transform(lo) - (0.05 * span + 0.5)).abs() < 1e-9); + assert!((t.transform(hi) - t.transform(max) - (0.1 * span + 0.25)).abs() < 1e-9); + } + } + #[test] fn test_custom_base() { let t = PseudoLog::new(5.0); @@ -333,8 +364,14 @@ mod tests { #[test] fn test_pseudo_log_breaks() { let t = PseudoLog::base10(); - let breaks = t.calculate_breaks(-100.0, 100.0, 7, false); - assert!(breaks.contains(&0.0)); + let breaks = t.calculate_breaks(-100.0, 100.0, 7, true); + assert_eq!( + breaks, + vec![ + -100.0, -50.0, -20.0, -10.0, -5.0, -2.0, -1.0, 0.0, 1.0, 2.0, 5.0, 10.0, 20.0, + 50.0, 100.0 + ] + ); } #[test] diff --git a/src/plot/scale/transform/sqrt.rs b/src/plot/scale/transform/sqrt.rs index 9b3d9c7c4..c0927542b 100644 --- a/src/plot/scale/transform/sqrt.rs +++ b/src/plot/scale/transform/sqrt.rs @@ -1,7 +1,7 @@ //! Sqrt transform implementation (square root) -use super::{TransformKind, TransformTrait}; -use crate::plot::scale::breaks::{minor_breaks_sqrt, sqrt_breaks}; +use super::{TransformKind, TransformTrait, ZygosMath}; +use crate::plot::scale::breaks::{linear_breaks, minor_breaks_linear, sqrt_breaks}; /// Sqrt transform - square root /// @@ -19,11 +19,23 @@ impl TransformTrait for Sqrt { } fn allowed_domain(&self) -> (f64, f64) { - (0.0, f64::INFINITY) + ZygosMath::of(TransformKind::Sqrt).allowed_domain() } fn calculate_breaks(&self, min: f64, max: f64, n: usize, pretty: bool) -> Vec { - sqrt_breaks(min, max, n, pretty) + if pretty { + sqrt_breaks(min, max, n) + } else { + // Evenly spaced in sqrt space, squared back. + let min = min.max(0.0); + if min >= max { + return vec![]; + } + linear_breaks(min.sqrt(), max.sqrt(), n) + .into_iter() + .map(|v| v * v) + .collect() + } } fn calculate_minor_breaks( @@ -32,15 +44,15 @@ impl TransformTrait for Sqrt { n: usize, range: Option<(f64, f64)>, ) -> Vec { - minor_breaks_sqrt(major_breaks, n, range) + minor_breaks_linear(major_breaks, n, range) } fn transform(&self, value: f64) -> f64 { - value.sqrt() + ZygosMath::of(TransformKind::Sqrt).forward(value) } fn inverse(&self, value: f64) -> f64 { - value * value + ZygosMath::of(TransformKind::Sqrt).inverse(value) } } @@ -103,17 +115,15 @@ mod tests { #[test] fn test_sqrt_breaks() { let t = Sqrt; - let breaks = t.calculate_breaks(0.0, 100.0, 5, false); - // linear_breaks now extends one step before and after - // Negative values in sqrt space get clipped - assert!( - breaks.len() >= 5, - "Should have at least 5 breaks, got {}", - breaks.len() + // Nice numbers in sqrt space, squared back. + assert_eq!( + t.calculate_breaks(0.0, 100.0, 5, true), + vec![0.0, 4.0, 16.0, 36.0, 64.0, 100.0] + ); + // Evenly spaced in sqrt space, squared back. + assert_eq!( + t.calculate_breaks(0.0, 100.0, 5, false), + vec![0.0, 6.25, 25.0, 56.25, 100.0] ); - // First break should be >= 0 (sqrt clips negatives) - assert!(breaks.first().unwrap() >= &0.0); - // Last break should be >= 100 - assert!(breaks.last().unwrap() >= &100.0); } } diff --git a/src/plot/scale/transform/square.rs b/src/plot/scale/transform/square.rs index 6f82299f4..2228b8733 100644 --- a/src/plot/scale/transform/square.rs +++ b/src/plot/scale/transform/square.rs @@ -1,7 +1,7 @@ //! Square transform implementation (x²) - inverse of sqrt -use super::{TransformKind, TransformTrait}; -use crate::plot::scale::breaks::{linear_breaks, minor_breaks_linear}; +use super::{TransformKind, TransformTrait, ZygosMath}; +use crate::plot::scale::breaks::{extended_breaks, linear_breaks, minor_breaks_linear}; /// Square transform (x²) - inverse of sqrt /// @@ -20,13 +20,17 @@ impl TransformTrait for Square { } fn allowed_domain(&self) -> (f64, f64) { - (f64::NEG_INFINITY, f64::INFINITY) + ZygosMath::of(TransformKind::Square).allowed_domain() } - fn calculate_breaks(&self, min: f64, max: f64, n: usize, _pretty: bool) -> Vec { - // Data-space even breaks (simple linear breaks in input space) - // These won't be visually evenly spaced after squaring, but that's expected - linear_breaks(min, max, n) + fn calculate_breaks(&self, min: f64, max: f64, n: usize, pretty: bool) -> Vec { + // Breaks in data space: they won't be visually evenly spaced after + // squaring, but their labels stay round. + if pretty { + extended_breaks(min, max, n) + } else { + linear_breaks(min, max, n) + } } fn calculate_minor_breaks( @@ -39,10 +43,13 @@ impl TransformTrait for Square { } fn transform(&self, value: f64) -> f64 { - value * value + ZygosMath::of(TransformKind::Square).forward(value) } fn inverse(&self, value: f64) -> f64 { + // Sign-preserving, unlike zygos's non-negative branch, so a domain padded + // below zero in transformed space maps back below zero rather than + // folding onto its mirror image. if value >= 0.0 { value.sqrt() } else { @@ -142,6 +149,15 @@ mod tests { } } + #[test] + fn test_square_breaks_pretty() { + let t = Square; + assert_eq!( + t.calculate_breaks(0.0, 10.0, 5, true), + vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0] + ); + } + #[test] fn test_square_breaks() { let t = Square; diff --git a/src/plot/scale/transform/time.rs b/src/plot/scale/transform/time.rs index 71f3ce399..e65549f4d 100644 --- a/src/plot/scale/transform/time.rs +++ b/src/plot/scale/transform/time.rs @@ -5,8 +5,9 @@ //! break calculation produces nice time intervals. use super::{TransformKind, TransformTrait}; -use crate::plot::scale::breaks::minor_breaks_linear; +use crate::plot::scale::breaks::{linear_breaks, minor_breaks_linear}; use crate::plot::ArrayElement; +use zygos::breaks::temporal_breaks_from_f64; /// Time transform - for time data (nanoseconds since midnight) /// @@ -24,87 +25,6 @@ const NANOS_PER_HOUR: f64 = 60.0 * NANOS_PER_MINUTE; // Maximum time value (24 hours in nanoseconds) const MAX_TIME_NANOS: f64 = 24.0 * NANOS_PER_HOUR; -// Time interval types for break calculation -#[derive(Debug, Clone, Copy, PartialEq)] -enum TimeInterval { - Hour, - Minute, - Second, - Millisecond, -} - -impl TimeInterval { - /// Nanoseconds in each interval - fn nanos(&self) -> f64 { - match self { - TimeInterval::Hour => NANOS_PER_HOUR, - TimeInterval::Minute => NANOS_PER_MINUTE, - TimeInterval::Second => NANOS_PER_SECOND, - TimeInterval::Millisecond => 1_000_000.0, - } - } - - /// Calculate expected number of breaks for this interval over the given span - fn expected_breaks(&self, span_nanos: f64) -> f64 { - span_nanos / self.nanos() - } - - /// Select appropriate interval and step based on span and desired break count. - /// Uses tolerance-based search: tries each interval from largest to smallest, - /// stops when within ~20% of requested n, then calculates a nice step multiplier. - fn select(span_nanos: f64, n: usize) -> (Self, usize) { - let n_f64 = n as f64; - let tolerance = 0.2; // 20% tolerance - let min_breaks = n_f64 * (1.0 - tolerance); - let max_breaks = n_f64 * (1.0 + tolerance); - - // Intervals from largest to smallest - let intervals = [ - TimeInterval::Hour, - TimeInterval::Minute, - TimeInterval::Second, - TimeInterval::Millisecond, - ]; - - for &interval in &intervals { - let expected = interval.expected_breaks(span_nanos); - - // Skip if this interval produces too few breaks - if expected < 1.0 { - continue; - } - - // If within tolerance, use step=1 - if expected >= min_breaks && expected <= max_breaks { - return (interval, 1); - } - - // If too many breaks, calculate a nice step - if expected > max_breaks { - let raw_step = expected / n_f64; - let nice = match interval { - TimeInterval::Hour => nice_hour_step(raw_step) as usize, - TimeInterval::Minute => nice_minute_step(raw_step) as usize, - TimeInterval::Second => nice_second_step(raw_step) as usize, - TimeInterval::Millisecond => nice_step(raw_step) as usize, - }; - let step = nice.max(1); - - // Verify the stepped interval is reasonable - let stepped_breaks = expected / step as f64; - if stepped_breaks >= 1.0 { - return (interval, step); - } - } - } - - // Fallback: use Millisecond with step calculation - let expected = TimeInterval::Millisecond.expected_breaks(span_nanos); - let step = (nice_step(expected / n_f64) as usize).max(1); - (TimeInterval::Millisecond, step) - } -} - impl TransformTrait for Time { fn transform_kind(&self) -> TransformKind { TransformKind::Time @@ -138,13 +58,12 @@ impl TransformTrait for Time { let min = min.max(0.0); let max = max.min(MAX_TIME_NANOS); - let span = max - min; - let (interval, step) = TimeInterval::select(span, n); - if pretty { - calculate_pretty_time_breaks(min, max, interval, step) + // A clock interval (hours, minutes, seconds) sized to about `n` + // breaks, stepped from midnight. + temporal_breaks_from_f64(min, max, zygos::TemporalUnit::Time, n) } else { - calculate_linear_time_breaks(min, max, n) + linear_breaks(min, max, n) } } @@ -154,6 +73,9 @@ impl TransformTrait for Time { n: usize, range: Option<(f64, f64)>, ) -> Vec { + // Evenly spaced minors for an explicit count, or for majors that don't + // step by a calendar interval; calendar-stepped majors get calendar + // minors during scale resolution instead. minor_breaks_linear(major_breaks, n, range) } @@ -178,167 +100,6 @@ impl TransformTrait for Time { } } -/// Calculate pretty time breaks aligned to interval boundaries -fn calculate_pretty_time_breaks( - min: f64, - max: f64, - interval: TimeInterval, - step: usize, -) -> Vec { - let mut breaks = Vec::new(); - - match interval { - TimeInterval::Hour => { - let step_nanos = (step as i64) * NANOS_PER_HOUR as i64; - - let start_nanos = (min / NANOS_PER_HOUR).floor() as i64 * NANOS_PER_HOUR as i64; - - let mut nanos = start_nanos; - while (nanos as f64) <= max { - let ns = nanos as f64; - if ns >= min && ns <= max { - breaks.push(ns); - } - nanos += step_nanos; - } - } - TimeInterval::Minute => { - let step_nanos = (step as i64) * NANOS_PER_MINUTE as i64; - - let start_nanos = (min / NANOS_PER_MINUTE).floor() as i64 * NANOS_PER_MINUTE as i64; - - let mut nanos = start_nanos; - while (nanos as f64) <= max { - let ns = nanos as f64; - if ns >= min && ns <= max { - breaks.push(ns); - } - nanos += step_nanos; - } - } - TimeInterval::Second => { - let step_nanos = (step as i64) * NANOS_PER_SECOND as i64; - - let start_nanos = (min / NANOS_PER_SECOND).floor() as i64 * NANOS_PER_SECOND as i64; - - let mut nanos = start_nanos; - while (nanos as f64) <= max { - let ns = nanos as f64; - if ns >= min && ns <= max { - breaks.push(ns); - } - nanos += step_nanos; - } - } - TimeInterval::Millisecond => { - let step_nanos = (step as i64) * 1_000_000; - - let start_nanos = (min / 1_000_000.0).floor() as i64 * 1_000_000; - - let mut nanos = start_nanos; - while (nanos as f64) <= max { - let ns = nanos as f64; - if ns >= min && ns <= max { - breaks.push(ns); - } - nanos += step_nanos; - } - } - } - - if breaks.is_empty() { - breaks.push(min); - if max > min { - breaks.push(max); - } - } - - breaks -} - -/// Calculate linear breaks in nanosecond-space -fn calculate_linear_time_breaks(min: f64, max: f64, n: usize) -> Vec { - if n <= 1 { - return vec![min]; - } - - let step = (max - min) / (n - 1) as f64; - (0..n).map(|i| min + i as f64 * step).collect() -} - -/// Round to a "nice" step value -fn nice_step(step: f64) -> f64 { - if step <= 0.0 { - return 1.0; - } - - let magnitude = 10_f64.powf(step.log10().floor()); - let residual = step / magnitude; - - let nice = if residual <= 1.5 { - 1.0 - } else if residual <= 3.0 { - 2.0 - } else if residual <= 7.0 { - 5.0 - } else { - 10.0 - }; - - nice * magnitude -} - -/// Nice step values for hours (1, 2, 3, 4, 6, 12) -fn nice_hour_step(step: f64) -> f64 { - if step <= 1.0 { - 1.0 - } else if step <= 2.0 { - 2.0 - } else if step <= 3.0 { - 3.0 - } else if step <= 4.0 { - 4.0 - } else if step <= 6.0 { - 6.0 - } else { - 12.0 - } -} - -/// Nice step values for minutes (1, 2, 5, 10, 15, 30) -fn nice_minute_step(step: f64) -> f64 { - if step <= 1.0 { - 1.0 - } else if step <= 2.0 { - 2.0 - } else if step <= 5.0 { - 5.0 - } else if step <= 10.0 { - 10.0 - } else if step <= 15.0 { - 15.0 - } else { - 30.0 - } -} - -/// Nice step values for seconds (1, 2, 5, 10, 15, 30) -fn nice_second_step(step: f64) -> f64 { - if step <= 1.0 { - 1.0 - } else if step <= 2.0 { - 2.0 - } else if step <= 5.0 { - 5.0 - } else if step <= 10.0 { - 10.0 - } else if step <= 15.0 { - 15.0 - } else { - 30.0 - } -} - impl std::fmt::Display for Time { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.name()) @@ -410,6 +171,18 @@ mod tests { assert!(!breaks.is_empty()); } + #[test] + fn test_time_breaks_follow_the_clock() { + let t = Time; + // Two hours with about eight breaks → every fifteen minutes. + let breaks = t.calculate_breaks(0.0, 2.0 * NANOS_PER_HOUR, 8, true); + let minutes: Vec = breaks.iter().map(|b| b / NANOS_PER_MINUTE).collect(); + assert_eq!( + minutes, + vec![0.0, 15.0, 30.0, 45.0, 60.0, 75.0, 90.0, 105.0, 120.0] + ); + } + #[test] fn test_time_breaks_linear() { let t = Time; @@ -418,51 +191,4 @@ mod tests { assert_eq!(breaks[0], 0.0); assert_eq!(breaks[4], NANOS_PER_HOUR); } - - #[test] - fn test_time_interval_selection() { - // Full day (24 hours, n=8) -> hour with step - let (interval, step) = TimeInterval::select(24.0 * NANOS_PER_HOUR, 8); - assert_eq!(interval, TimeInterval::Hour); - assert!(step >= 1); - - // Hour span (1 hour, n=6) -> minute with step - let (interval, step) = TimeInterval::select(NANOS_PER_HOUR, 6); - assert_eq!(interval, TimeInterval::Minute); - assert!(step >= 1); - - // Minute span (1 minute, n=6) -> second with step - let (interval, step) = TimeInterval::select(NANOS_PER_MINUTE, 6); - assert_eq!(interval, TimeInterval::Second); - assert!(step >= 1); - - // Second span (1 second, n=10) -> millisecond with step - let (interval, step) = TimeInterval::select(NANOS_PER_SECOND, 10); - assert_eq!(interval, TimeInterval::Millisecond); - assert!(step >= 1); - } - - #[test] - fn test_nice_hour_step() { - assert_eq!(nice_hour_step(1.0), 1.0); - assert_eq!(nice_hour_step(2.5), 3.0); - assert_eq!(nice_hour_step(5.0), 6.0); - assert_eq!(nice_hour_step(10.0), 12.0); - } - - #[test] - fn test_nice_minute_step() { - assert_eq!(nice_minute_step(1.0), 1.0); - assert_eq!(nice_minute_step(3.0), 5.0); - assert_eq!(nice_minute_step(12.0), 15.0); - assert_eq!(nice_minute_step(25.0), 30.0); - } - - #[test] - fn test_nice_second_step() { - assert_eq!(nice_second_step(1.0), 1.0); - assert_eq!(nice_second_step(3.0), 5.0); - assert_eq!(nice_second_step(12.0), 15.0); - assert_eq!(nice_second_step(25.0), 30.0); - } } diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index 51350503e..8cb1d20d2 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -318,19 +318,27 @@ impl Scale { /// `SETTING expand`, and picks up context-dependent factors the caller can't /// see (a polar full-circle theta resolves to zero expansion). /// - /// Mirrors resolution: expand, then clip to the transform's allowed domain. - /// On an unresolved scale the factors fall back to the continuous defaults. + /// Mirrors resolution: expand in the transform's space, then clip to the + /// transform's allowed domain. On an unresolved scale the factors fall back + /// to the continuous defaults, and a scale without a transform expands as + /// identity. pub fn expand_range(&self, min: f64, max: f64) -> (f64, f64) { let (mult, add) = super::scale_type::get_expand_factors(&self.properties); + let identity; + let transform = match &self.transform { + Some(t) => t, + None => { + identity = Transform::identity(); + &identity + } + }; let expanded = super::scale_type::expand_numeric_range( &[ArrayElement::Number(min), ArrayElement::Number(max)], mult, add, + transform, ); - let clipped = match &self.transform { - Some(t) => super::scale_type::clip_to_transform_domain(&expanded, t), - None => expanded, - }; + let clipped = super::scale_type::clip_to_transform_domain(&expanded, transform); match ( clipped.first().and_then(|e| e.to_f64()), clipped.last().and_then(|e| e.to_f64()), @@ -659,16 +667,24 @@ mod tests { } #[test] - fn test_expand_range_clips_to_the_transform_domain() { - // Mirrors resolution: expanding below zero on a log scale clips to the - // transform's allowed minimum rather than producing an invalid domain. + fn test_expand_range_pads_in_transformed_space() { + // Mirrors resolution: a log scale pads in decades, so the lower end of + // a 1..1000 domain stays positive instead of collapsing to the domain + // minimum. Three decades, 5% each end = 0.15 of a decade. let mut s = continuous_scale((1.0, 1000.0), vec![]); s.transform = Some(Transform::log()); let (lo, hi) = s.expand_range(1.0, 1000.0); - assert_eq!(lo, f64::MIN_POSITIVE); - assert!( - (hi - 1049.95).abs() < 1e-9, - "upper end expands normally: {hi}" - ); + assert!((lo - 10f64.powf(-0.15)).abs() < 1e-12, "lower end: {lo}"); + assert!((hi - 10f64.powf(3.15)).abs() < 1e-9, "upper end: {hi}"); + } + + #[test] + fn test_expand_range_clips_out_of_domain_input() { + // A data extreme with no logarithm is left alone by expansion and then + // clipped to the transform's allowed minimum. + let mut s = continuous_scale((0.0, 1000.0), vec![]); + s.transform = Some(Transform::log()); + let (lo, hi) = s.expand_range(0.0, 1000.0); + assert_eq!((lo, hi), (f64::MIN_POSITIVE, 1000.0)); } } diff --git a/src/writer/hephaestus/AGENTS.md b/src/writer/hephaestus/AGENTS.md index 6f63d30c1..9d71297f8 100644 --- a/src/writer/hephaestus/AGENTS.md +++ b/src/writer/hephaestus/AGENTS.md @@ -722,11 +722,6 @@ Deliberately not done, in rough order of how likely they are to bite: doing it at scale. The SVG corpus is the natural fixture surface, being deterministic text where a 2 px panel shift reads as a hunk rather than as a changed hash; whether to commit it is still open. -- **Log-scale tick labels are wrong, and not because of this writer.** ggsql - resolves a 1–100 `log10` domain to breaks of - `[5e-308, 2e-256, …, 100]`, and both writers faithfully print those. The fix - is in scale resolution; nothing changes here. Recorded as an ignored test - (`svg_text::log_tick_labels_should_be_decades`). - **No axis label thinning.** ggsql's resolved breaks are drawn as-is, so a narrow facet panel can crowd or overlap long labels — which is why `free_continuous_scale` narrows the *global* breaks to a panel rather than diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 2925c320c..ccef3abf8 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -1365,8 +1365,8 @@ mod svg_text { // ggsql's own break spacing and number formatting: the trailing `.0` // shows these are pass-throughs, not the renderer's idea of a nice tick. let linear = svg("SELECT x, y FROM (VALUES (1,2),(2,3),(3,1)) t(x,y) \ - VISUALISE x AS x, y AS y DRAW point"); - for label in ["1.0", "1.5", "2.0", "2.5", "3.0"] { + VISUALISE x AS x, y AS y DRAW point SCALE x SETTING breaks => 9"); + for label in ["1.0", "1.2", "1.4", "2.0", "2.6", "3.0"] { assert!(contains(&linear, label), "missing tick '{label}'"); } @@ -1378,12 +1378,9 @@ mod svg_text { assert!(contains(&renamed, "b")); } - /// A log axis should carry decade ticks, but `Scale::numeric_breaks()` - /// resolves `[5e-308, 2e-256, …, 100]` for a 1–100 log10 domain and both - /// writers pass that through. Kept as a failing expectation so it turns - /// green on its own once the scale is fixed. + /// A log axis carries decade ticks: the 1–100 log10 domain is padded in + /// decades, so its breaks resolve to powers of ten that reach the rail. #[test] - #[ignore = "ggsql resolves log-scale breaks to denormals; not a writer bug"] fn log_tick_labels_should_be_decades() { let log = svg("SELECT x, y FROM (VALUES (1,2),(10,3),(100,1)) t(x,y) \ VISUALISE x AS x, y AS y DRAW point SCALE x VIA log"); diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 559512fab..0ee820f5b 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -369,8 +369,8 @@ fn apply_output_range(hs: HScale, kind: RangeKind, values: &[ArrayElement]) -> H /// hephaestus's own builtins would silently render every hex pattern solid and /// alias `longdash`/`twodash` onto the wrong ones. /// -/// The resulting on/off lengths are points, which is what hephaestus's linetype -/// steps take. +/// The resulting on/off lengths are multiples of the line width, the +/// convention ggsql, zygos and hephaestus share. pub fn map_linetype(name: &str) -> Arc<[LinetypeStep]> { let Some(lengths) = linetype_to_stroke_dash(name) else { return solid(); @@ -574,26 +574,26 @@ fn temporal_value(transform: Option, pos: f64) -> HValue { /// the breaks it picks, which arrive resolved through `apply_breaks`. All of them /// map to identity (`None` — hephaestus defaults to identity). fn map_transform(kind: GTransform) -> Option { - match kind { - GTransform::Log10 => Some(HTransform::Log10), - GTransform::Log2 => Some(HTransform::Log2), - GTransform::Log => Some(HTransform::Log), - GTransform::Sqrt => Some(HTransform::Sqrt), - GTransform::Square => Some(HTransform::Square), - GTransform::Exp10 => Some(HTransform::Exp10), - GTransform::Exp2 => Some(HTransform::Exp2), - GTransform::Exp => Some(HTransform::Exp), - GTransform::Asinh => Some(HTransform::Asinh), - GTransform::PseudoLog => Some(HTransform::PseudoLog), - GTransform::Identity - | GTransform::Date - | GTransform::DateTime - | GTransform::Time - | GTransform::String - | GTransform::Bool - | GTransform::Integer - | GTransform::Geographic => None, - } + // Match on the zygos kind so this stays in sync with the executor's + // delegation; convert by name until hephaestus's published scale types + // are the zygos ones (then this becomes `kind.zygos_kind()`). + let zygos_kind = kind.zygos_kind()?; + Some(match zygos_kind { + // None — hephaestus defaults to identity + zygos::transform::TransformKind::Identity => return None, + zygos::transform::TransformKind::Log10 => HTransform::Log10, + zygos::transform::TransformKind::Log2 => HTransform::Log2, + zygos::transform::TransformKind::Log => HTransform::Log, + zygos::transform::TransformKind::Sqrt => HTransform::Sqrt, + zygos::transform::TransformKind::Square => HTransform::Square, + zygos::transform::TransformKind::Exp10 => HTransform::Exp10, + zygos::transform::TransformKind::Exp2 => HTransform::Exp2, + zygos::transform::TransformKind::Exp => HTransform::Exp, + zygos::transform::TransformKind::Asinh => HTransform::Asinh, + zygos::transform::TransformKind::PseudoLog + | zygos::transform::TransformKind::PseudoLog2 + | zygos::transform::TransformKind::PseudoLog10 => HTransform::PseudoLog, + }) } /// Convert a ggsql array element to a hephaestus domain value. diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 77710d3a7..9d190a1a0 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -3,9 +3,10 @@ //! This module handles building Vega-Lite encoding channels from ggsql aesthetic mappings, //! including type inference, scale properties, and title handling. +use super::shape::shape_to_svg_path; use crate::array_util::as_str; use crate::plot::aesthetic::{is_position_aesthetic, AestheticContext}; -use crate::plot::scale::{linetype_to_stroke_dash, shape_to_svg_path, ScaleTypeKind}; +use crate::plot::scale::{linetype_to_stroke_dash, ScaleTypeKind}; use crate::plot::{ParameterValue, Scale}; use crate::{AestheticValue, DataFrame, GgsqlError, Plot, Result}; use arrow::array::Array; diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 1419a6644..846210561 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -24,6 +24,7 @@ mod data; mod encoding; mod layer; mod projection; +mod shape; use crate::plot::ArrayElement; use crate::plot::{ParameterValue, Parameters, Scale, ScaleTypeKind}; @@ -1782,7 +1783,8 @@ mod tests { /// symbol name it cannot parse, and `'dashed'` as a dash array it cannot read. #[test] fn test_identity_names_convert_like_literals() { - use crate::plot::scale::{linetype_to_stroke_dash, shape_to_svg_path}; + use super::shape::shape_to_svg_path; + use crate::plot::scale::linetype_to_stroke_dash; use crate::plot::{ArrayElement, Scale, ScaleType}; let writer = VegaLiteWriter::new(); diff --git a/src/writer/vegalite/shape.rs b/src/writer/vegalite/shape.rs new file mode 100644 index 000000000..da90a217e --- /dev/null +++ b/src/writer/vegalite/shape.rs @@ -0,0 +1,55 @@ +//! Shape-name → SVG path conversion for Vega-Lite symbol marks. +//! +//! The shape geometry lives in `zygos::patterns::shape` as kurbo paths; +//! serializing them to SVG path strings is a Vega-Lite concern, so it +//! lives here in the writer. + +/// Convert a named shape to an SVG path string for Vega-Lite. +/// +/// Composite shapes (e.g. "square-cross") produce multiple subpaths joined +/// by spaces; Vega-Lite fills them with the evenodd fill-rule. Returns +/// `None` for unknown shapes. +pub fn shape_to_svg_path(name: &str) -> Option { + let shape = zygos::patterns::shape::builtin_shape(name)?; + Some( + shape + .paths() + .iter() + .map(|p| p.to_svg()) + .collect::>() + .join(" "), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_shapes_have_paths() { + for name in [ + "circle", + "square", + "diamond", + "triangle-up", + "star", + "cross", + ] { + assert!( + shape_to_svg_path(name).is_some(), + "shape '{name}' should have an SVG path" + ); + } + } + + #[test] + fn unknown_shape_is_none() { + assert!(shape_to_svg_path("not-a-shape").is_none()); + } + + #[test] + fn composite_shape_has_multiple_subpaths() { + let path = shape_to_svg_path("square-cross").unwrap(); + assert!(path.matches('M').count() > 1); + } +} From b78132852570af376b900d851608f97e66cd819a Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Thu, 5 Nov 2026 15:27:27 +0100 Subject: [PATCH 2/5] update to zygos 0.3.0 --- Cargo.lock | 4 +- src/Cargo.toml | 2 +- src/execute/scale.rs | 7 +- src/plot/AGENTS.md | 2 +- src/plot/scale/breaks.rs | 305 +------------------------------ src/plot/scale/scale_type/mod.rs | 2 +- 6 files changed, 17 insertions(+), 305 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d56bcdbcb..682750e54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6713,9 +6713,9 @@ dependencies = [ [[package]] name = "zygos" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c52e3a9047311f9bcd2d4850c025f13726bca44df8c0c172b2967c2327c559" +checksum = "ca3ede22c9a8d1cbc904b185ab02abe5ba24044c66c34906b1d7c9f8300af9b0" dependencies = [ "peniko", ] diff --git a/src/Cargo.toml b/src/Cargo.toml index e639b8bf0..00e05235b 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -47,7 +47,7 @@ hephaestus = { version = "0.4.1", optional = true, default-features = false, fea # Scale primitives (transforms, breaks, palettes, OOB policies). Unlike # hephaestus this is a core dependency: the executor resolves scales through # it, so it must build on the MSRV toolchain. -zygos = { version = "0.2.0", default-features = false, features = ["palettes"] } +zygos = { version = "0.3.0", default-features = false, features = ["palettes"] } # WOFF / WOFF2 decoding for `fonts::register_font`, behind `webfonts`. Pure # Rust and no build script; `brotli` here is `brotli-decompressor`, the diff --git a/src/execute/scale.rs b/src/execute/scale.rs index 8e18afdcd..3f526b527 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -1568,15 +1568,14 @@ pub fn apply_oob_to_column_numeric( match oob_policy { zygos::OobPolicy::Censor => { // Filter out rows where values are outside [range_min, range_max]. - // NaN is censored too; zygos's `apply` currently passes NaN through - // under every policy, so it is dropped explicitly here. let mask_values: Vec = (0..f64_arr.len()) .map(|i| { if f64_arr.is_null(i) { true // Keep nulls } else { - let v = f64_arr.value(i); - !v.is_nan() && oob_policy.apply(v, range_min, range_max).is_some() + oob_policy + .apply(f64_arr.value(i), range_min, range_max) + .is_some() } }) .collect(); diff --git a/src/plot/AGENTS.md b/src/plot/AGENTS.md index 45b2d9092..13e68df22 100644 --- a/src/plot/AGENTS.md +++ b/src/plot/AGENTS.md @@ -55,7 +55,7 @@ plot/ `Scale` in `types.rs`, the kind enum + traits in `scale_type/`, the transformation logic in `transform/`. Other top-level files in `scale/`: -- `breaks.rs` — break/tick computation for axes. Every tick algorithm comes from the `zygos` crate (Wilkinson Extended, linear, log/sqrt/symlog majors and minors, calendar-aware temporal breaks and their interval picker), re-exported here and called directly from each transform's `calculate_breaks`/`calculate_minor_breaks`. ggsql keeps only what zygos lacks: `integer_breaks`, `graticule_breaks` (map projections), `minor_breaks_linear` (zygos's between-majors subdivision, continued past the outermost majors), `extent_minor_breaks` (runs zygos's extent-driven log/symlog minors and drops positions the majors hold), `filter_breaks_to_range` (typed `ArrayElement` breaks against a domain), and the temporal glue — `parse_temporal_interval` (`'2 months'` → zygos `TemporalInterval`), `interval_breaks` (dispatch to zygos's per-type calendar breaks), `auto_temporal_interval` (the interval automatic temporal majors use, so default minors in `ScaleTypeTrait::resolve` can subdivide it). +- `breaks.rs` — break/tick computation for axes. Every tick algorithm comes from the `zygos` crate (Wilkinson Extended, linear, integer, graticule, log/sqrt/symlog majors and minors, calendar-aware temporal breaks and their interval picker), re-exported here and called directly from each transform's `calculate_breaks`/`calculate_minor_breaks`. ggsql keeps only what zygos lacks: `minor_breaks_linear` (zygos's between-majors subdivision, continued past the outermost majors), `extent_minor_breaks` (runs zygos's extent-driven log/symlog minors and drops positions the majors hold), `filter_breaks_to_range` (typed `ArrayElement` breaks against a domain), and the temporal glue — `parse_temporal_interval` (`'2 months'` → zygos `TemporalInterval`) and `interval_breaks` (dispatch to zygos's per-type calendar breaks, keeping the terminal break binned scales need). - `colour.rs` — colour aesthetic helpers; interpolation delegates to `zygos::color`. - `linetype.rs` — stroke-dash conversion over `zygos::patterns::linetype`. - `palettes.rs` — adapts `zygos::palettes` (the tables live there) to ggsql's string vocabulary (hex colors, hex linetype patterns), plus the `ArrayElement`-typed `lookup_palette`. Shape-name → SVG-path conversion is not in `scale/` at all: it's a Vega-Lite concern and lives in `writer/vegalite/shape.rs` over `zygos::patterns::shape`. diff --git a/src/plot/scale/breaks.rs b/src/plot/scale/breaks.rs index 421e7855a..997378a6b 100644 --- a/src/plot/scale/breaks.rs +++ b/src/plot/scale/breaks.rs @@ -1,104 +1,23 @@ //! Break calculation for scales. //! //! Tick selection itself lives in zygos — Wilkinson Extended -//! ([`extended_breaks`]), [`linear_breaks`], the log / sqrt / symlog -//! families and the calendar-aware temporal breaks — and transforms call it -//! directly. This module re-exports those and keeps only what zygos has no -//! counterpart for: integer-constrained breaks, graticules for map -//! projections, minor breaks that continue past the outermost majors, -//! filtering typed breaks to a domain, and parsing ggsql's interval strings +//! ([`extended_breaks`]), [`linear_breaks`], [`integer_breaks`], +//! [`graticule_breaks`], the log / sqrt / symlog families and the +//! calendar-aware temporal breaks — and transforms call it directly. This +//! module re-exports those and keeps only what zygos has no counterpart +//! for: minor breaks that continue past the outermost majors, filtering +//! typed breaks to a domain, and parsing ggsql's interval strings //! (`'2 months'`) into a zygos [`TemporalInterval`]. pub use zygos::breaks::{ - extended_breaks, linear_breaks, linear_minor_breaks_between, log_minor_breaks, - log_pretty_breaks, sqrt_breaks, symlog_breaks, symlog_minor_breaks, CalendarUnit, - TemporalInterval, DEFAULT_BREAK_COUNT, + auto_temporal_interval, extended_breaks, graticule_breaks, integer_breaks, linear_breaks, + linear_minor_breaks_between, log_minor_breaks, log_pretty_breaks, sqrt_breaks, symlog_breaks, + symlog_minor_breaks, CalendarUnit, TemporalInterval, DEFAULT_BREAK_COUNT, }; use zygos::TemporalUnit; use crate::plot::ArrayElement; -/// Calculate breaks for integer scales with even spacing. -/// -/// Unlike simply rounding the output of [`extended_breaks`], this function -/// ensures that breaks are evenly spaced integers. For small ranges where -/// the natural step would be < 1, it uses step = 1 and generates consecutive -/// integers. -/// -/// # Arguments -/// - `min`: Minimum data value -/// - `max`: Maximum data value -/// - `n`: Target number of breaks -/// - `pretty`: If true, use "nice" integer step sizes (1, 2, 5, 10, 20, ...). -/// If false, use exact linear spacing rounded to integers. -pub fn integer_breaks(min: f64, max: f64, n: usize, pretty: bool) -> Vec { - if n == 0 || min >= max || !min.is_finite() || !max.is_finite() { - return vec![]; - } - - let range = max - min; - let int_min = min.floor() as i64; - let int_max = max.ceil() as i64; - let int_range = int_max - int_min; - - // For very small ranges, just return consecutive integers - if int_range <= n as i64 { - return (int_min..=int_max).map(|i| i as f64).collect(); - } - - if pretty { - // Use "nice" integer step sizes: 1, 2, 5, 10, 20, 25, 50, 100, ... - let rough_step = range / (n as f64); - - // Find nice integer step (must be >= 1) - let nice_step = if rough_step < 1.0 { - 1 - } else { - let magnitude = 10f64.powf(rough_step.log10().floor()) as i64; - let residual = rough_step / magnitude as f64; - - let multiplier = if residual <= 1.0 { - 1 - } else if residual <= 2.0 { - 2 - } else if residual <= 5.0 { - 5 - } else { - 10 - }; - - (magnitude * multiplier).max(1) - }; - - // Find starting point (nice_min <= min, aligned to step) - let nice_min = (int_min / nice_step) * nice_step; - - // Generate breaks - let mut breaks = vec![]; - let mut value = nice_min; - while value <= int_max { - breaks.push(value as f64); - value += nice_step; - } - breaks - } else { - // Linear spacing with integer step (at least 1) - // Extend one step before min and one step after max for binned scales - let step = ((int_range as f64) / (n as f64 - 1.0)).ceil() as i64; - let step = step.max(1); - - let mut breaks = vec![]; - // Start one step before int_min - let mut value = int_min - step; - // Generate until one step past int_max - while value <= int_max + step { - breaks.push(value as f64); - value += step; - } - breaks - } -} - /// Filter breaks to only those within the given range. /// /// Both sides are compared through [`ArrayElement::to_f64`], which is the space a @@ -124,58 +43,6 @@ pub fn filter_breaks_to_range( .collect() } -// ============================================================================= -// Graticule breaks (for map projections) -// ============================================================================= - -/// Calculate graticule break positions for a geographic range. -/// -/// Picks the smallest step from a predefined set that produces at most `n` lines, -/// then returns values within (min, max) at that interval. Includes boundary -/// values (-180/180 for longitude, -90/90 for latitude) when the range spans -/// the full extent. -pub fn graticule_breaks(min: f64, max: f64, n: usize) -> Vec { - // Graticules never span more than the globe. max/min also tame non-finite - // inputs (e.g. infinite coordinates from degenerate projections), which - // otherwise make the step loop below iterate ~i64::MAX times. - let min = min.max(-180.0); - let max = max.min(180.0); - - let range = max - min; - if range <= 0.0 || n == 0 { - return vec![]; - } - - const STEPS: &[f64] = &[1.0, 2.0, 5.0, 10.0, 15.0, 20.0, 30.0, 45.0, 60.0, 90.0]; - - let step = STEPS - .iter() - .copied() - .find(|&s| range / s <= n as f64 + 1.0) - .unwrap_or(90.0); - - let start = (min / step).ceil() as i64; - let end = (max / step).floor() as i64; - let mut breaks: Vec = (start..=end) - .map(|i| i as f64 * step) - .filter(|&v| v > min && v < max) - .collect(); - - // min/max are clamped to ±180 above, so these boundaries are equality checks - if min == -180.0 && !breaks.contains(&-180.0) { - breaks.insert(0, -180.0); - } else if max == 180.0 && !breaks.contains(&180.0) { - breaks.push(180.0); - } - if min <= -90.0 && !breaks.contains(&-90.0) { - breaks.insert(0, -90.0); - } else if max >= 90.0 && !breaks.contains(&90.0) { - breaks.push(90.0); - } - - breaks -} - // ============================================================================= // Minor Break Calculations // ============================================================================= @@ -315,19 +182,6 @@ pub fn interval_breaks( } } -/// The calendar interval automatic temporal breaks step by over `[min, max]` -/// when about `n` breaks are asked for — the interval -/// [`zygos::breaks::temporal_breaks_from_f64`] picks, exposed so the minor -/// breaks can subdivide the same interval the majors use. -pub fn auto_temporal_interval( - min: f64, - max: f64, - n: usize, - unit: TemporalUnit, -) -> TemporalInterval { - zygos::breaks::pick_temporal_interval(max - min, n.max(2) as f64, unit) -} - /// Minor breaks for a transform whose zygos minor algorithm works from the /// scale's extent rather than by subdividing between majors — the log /// family's whole-number multiples and the symlog branches built on them. @@ -366,85 +220,6 @@ mod tests { use super::*; // ========================================================================= - // Integer Breaks Tests (Consolidated) - // ========================================================================= - - #[test] - fn test_integer_breaks_variations() { - // Test various ranges - all should produce integer, evenly-spaced breaks - let test_cases = vec![ - (0.0, 100.0, 5, true), - (0.0, 1_000_000.0, 5, true), - (-50.0, 50.0, 5, true), - (0.0, 5.0, 5, false), - ]; - for (min, max, n, pretty) in test_cases { - let breaks = integer_breaks(min, max, n, pretty); - assert!( - !breaks.is_empty(), - "integer_breaks({}, {}, {}, {}) should not be empty", - min, - max, - n, - pretty - ); - // All breaks should be integers - for b in &breaks { - assert_eq!( - *b, - b.round(), - "Break {} should be integer for ({}, {}, {}, {})", - b, - min, - max, - n, - pretty - ); - } - // All gaps should be equal (evenly spaced) - if breaks.len() >= 2 { - let step = breaks[1] - breaks[0]; - for i in 1..breaks.len() { - let gap = breaks[i] - breaks[i - 1]; - assert!( - (gap - step).abs() < 0.01, - "Uneven spacing for ({}, {}, {}, {}): {:?}", - min, - max, - n, - pretty, - breaks - ); - } - } - } - } - - #[test] - fn test_integer_breaks_small_range() { - // For range 0-5, should get consecutive integers - let breaks = integer_breaks(0.0, 5.0, 10, true); - assert_eq!(breaks, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); - } - - #[test] - fn test_integer_breaks_edge_cases() { - let edge_cases = vec![ - (0.0, 100.0, 0, "zero count"), - (100.0, 0.0, 5, "min > max"), - (50.0, 50.0, 5, "min == max"), - (f64::NAN, 100.0, 5, "NaN min"), - (0.0, f64::INFINITY, 5, "infinite max"), - ]; - for (min, max, n, desc) in edge_cases { - assert!( - integer_breaks(min, max, n, true).is_empty(), - "integer_breaks with {} should be empty", - desc - ); - } - } - // ========================================================================= // Filter Breaks Tests // ========================================================================= @@ -651,66 +426,4 @@ mod tests { ); assert_eq!(breaks, vec![22.0 * HOUR_NS, 23.0 * HOUR_NS]); } - - // ========================================================================= - // Graticule Breaks Tests - // ========================================================================= - - #[test] - fn test_graticule_breaks_world() { - let breaks = graticule_breaks(-180.0, 180.0, 7); - assert_eq!( - breaks, - vec![-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0] - ); - } - - #[test] - fn test_graticule_breaks_hemisphere() { - let breaks = graticule_breaks(-88.0, 88.0, 7); - assert_eq!(breaks, vec![-60.0, -30.0, 0.0, 30.0, 60.0]); - } - - #[test] - fn test_graticule_breaks_small_range() { - let breaks = graticule_breaks(10.0, 20.0, 7); - assert!(!breaks.is_empty()); - for &b in &breaks { - assert!(b > 10.0 && b < 20.0); - } - } - - #[test] - fn test_graticule_breaks_empty_for_zero_range() { - let breaks = graticule_breaks(50.0, 50.0, 7); - assert!(breaks.is_empty()); - } - - #[test] - fn test_graticule_breaks_infinite_range_falls_back_to_globe() { - let breaks = graticule_breaks(f64::NEG_INFINITY, f64::INFINITY, 7); - assert_eq!( - breaks, - vec![-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0] - ); - } - - #[test] - fn test_graticule_breaks_nan_range_falls_back_to_globe() { - let breaks = graticule_breaks(f64::NAN, f64::NAN, 7); - assert_eq!(breaks, graticule_breaks(-180.0, 180.0, 7)); - } - - #[test] - fn test_graticule_breaks_one_sided_infinity() { - let breaks = graticule_breaks(0.0, f64::INFINITY, 7); - assert!(breaks.iter().all(|&b| b > 0.0 && b <= 180.0)); - assert!(!breaks.is_empty()); - } - - #[test] - fn test_graticule_breaks_huge_finite_range_is_clamped() { - let breaks = graticule_breaks(-1e15, 1e15, 7); - assert!(breaks.iter().all(|&b| (-180.0..=180.0).contains(&b))); - } } diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index e0a62a3a6..2c9802393 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -1884,8 +1884,8 @@ fn calendar_major_interval( Some(super::breaks::auto_temporal_interval( min, max, - *count as usize, unit, + *count as usize, )) } _ => None, From 00c9c2381e72548c6367891e696d289492959c04 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Sat, 7 Nov 2026 09:09:02 +0100 Subject: [PATCH 3/5] adopt to 0.4.0 --- CHANGELOG.md | 23 + Cargo.lock | 14 +- doc/syntax/clause/scale.qmd | 14 +- src/Cargo.toml | 3 +- src/format.rs | 753 +++++++++++--------------- src/parser/builder.rs | 142 ++--- src/plot/scale/mod.rs | 1 - src/plot/scale/scale_type/binned.rs | 40 +- src/plot/scale/scale_type/discrete.rs | 2 +- src/plot/scale/scale_type/mod.rs | 76 +-- src/plot/scale/scale_type/ordinal.rs | 2 +- src/plot/scale/types.rs | 192 +++++-- src/writer/hephaestus/facet.rs | 6 +- src/writer/hephaestus/scales.rs | 25 +- src/writer/vegalite/encoding.rs | 39 +- src/writer/vegalite/layer.rs | 9 +- src/writer/vegalite/mod.rs | 26 +- 17 files changed, 652 insertions(+), 715 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6fb848d7..e6d705787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,29 @@ `TIME` interval breaks step across minute/hour boundaries; automatic temporal minor breaks fall on the calendar sub-interval (quarters under years, weeks under months, …) instead of three evenly spaced per interval. +- Break labels are rendered by `zygos`'s label engine: the `RENAMING` clause + (explicit renames, `NULL` suppressions, and the `* => '...'` template) is + kept as a value-keyed `zygos::LabelSpec` on `Scale` and re-rendered against + the current breaks on every read instead of being folded into a static + string map at resolution time. Consequences: + - shared `{}` decimal precision is derived from the un-renamed breaks only; + - negative numbers render with U+2212 MINUS SIGN everywhere; + - `{:time}` and `{:num}` use zygos's hand-rolled strftime/printf subsets + (the `sprintf` dependency is gone); `%c %x %X %v` and the timezone tokens + `%z %Z` are unsupported, as before; + - new template extensions: `{:num %S}` for SI-prefixed notation + (`{:num %.2S}` → `1.2M`), the `!` flag for Unicode scientific notation + (`{:num %!.2e}` → `1.23 × 10³`), and further placeholders (`{:abbr N}`, + `{:bytes}`, `{:duration}`, `{:ordinal}`); placeholder keywords are now + ASCII-case-insensitive; + - unquoted numeric RENAMING keys are typed (`RENAMING 10 => 'ten'` matches + the string category `"10"`); quoted keys keep matching breaks by their + text form as before. +- **Breaking (library API):** `Scale::label_mapping` and `Scale::label_template` + are replaced by a single `label_spec: zygos::LabelSpec` field; the folded + string map is available on demand via the `Scale::label_mapping()` method, + and `format::apply_label_template` is gone (`format::label_elements` takes + its place). - **Breaking (library API):** the scale module's public surface shrank with the move to `zygos`. Removed: the `plot::scale::shape` module including `shape_to_svg_path`; the ~85 named palette table constants in diff --git a/Cargo.lock b/Cargo.lock index 682750e54..bcce1ab9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1740,7 +1740,6 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sprintf", "tempfile", "thiserror 1.0.69", "toml_edit 0.22.27", @@ -4606,15 +4605,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "sprintf" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e59842c3aac5c7901ea11bbb02e60e5d67263b43d4361dec9303cca8e764eb" -dependencies = [ - "thiserror 2.0.18", -] - [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -6713,9 +6703,9 @@ dependencies = [ [[package]] name = "zygos" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3ede22c9a8d1cbc904b185ab02abe5ba24044c66c34906b1d7c9f8300af9b0" +checksum = "3a80aeb3167e942468b1b74c62c67572380f4057ce8861e1b3a01308a0655ad1" dependencies = [ "peniko", ] diff --git a/doc/syntax/clause/scale.qmd b/doc/syntax/clause/scale.qmd index 3464029e8..b5b1450b3 100644 --- a/doc/syntax/clause/scale.qmd +++ b/doc/syntax/clause/scale.qmd @@ -65,14 +65,16 @@ Apart from the direct renaming described above it is also possible to provide a * `{:Title}` will title-case the value (make the first letter in each work upper case) before insertion, e.g. `* => '{:Title} species'` will become "Adelie species" for the "adelie" break. * `{:UPPER}` will make the value upper-case, e.g. `* => '{:UPPER} species'` will become "ADELIE species" for the "adelie" break. * `{:lower}` works much like `{:UPPER}` but changes the value to lower-case instead. -* `{:time ...}` will format a date/datetime/time value according to the format defined afterwards. The formatting follows strftime format using the Rust chrono library. You can see an overview of the supported syntax at the [chrono docs](https://docs.rs/chrono/latest/chrono/format/strftime/index.html). The basic usage is `* => '{:time %B %Y}` which would format a break at 2025-07-04 as "July 2025". -* `{:num ...}` will format a numeric according to the format defined afterwards. The format follows the printf format using the Rust sprintf library. The syntax is `%[flags][width][.precision]type` with the following meaning: +* `{:time ...}` will format a date/datetime/time value according to the format defined afterwards. The formatting follows a strftime subset: `%Y %y %m %d %e %H %I %M %S %p %P %b %B %h %a %A %j %s %t %n %u %w %U %W %V %G %%`, the composites `%F %T %R %D %r`, fractional seconds (`%f`, `%.f`, `%.3f`, `%.6f`, `%.9f`), and the `%-` / `%_` / `%0` padding modifiers (`%V`/`%G` are ISO 8601 week and week-year). Locale composites (`%c %x %X %v`) and timezone tokens (`%z %Z`) are not supported. The basic usage is `* => '{:time %B %Y}` which would format a break at 2025-07-04 as "July 2025". +* `{:num ...}` will format a numeric according to the format defined afterwards. The format follows a printf subset. The syntax is `%[flags][width][.precision]type` with the following meaning: - `flags`: One or more modifiers: * `-`: left-justify * `+`: Force sign for positive numbers * ` `: (space) Space before positive numbers * `0`: Zero-pad * `#`: Alternate form (`0x` prefix for hex, etc) + * `'`: Thousands separator (`,` is accepted as an alias), e.g. `{:num %',.2f}` → `1,234.50` + * `!`: Unicode scientific notation on `e`/`E`/`g`/`G`, e.g. `{:num %!.2e}` → `1.23 × 10³` - `width`: The minimum width of characters to render. Depending on the `flags` the string will be padded to be at least this width - `precision`: The maximum precision of the number. For `%g`/`%G` it is the total number of digits whereas for the rest it is the number of digits to the right of the decimal point - `type`: How to present the number. One of: @@ -83,3 +85,11 @@ Apart from the direct renaming described above it is also possible to provide a * `g`/`G`: Shortest form of `e` and `f` * `o`: Unsigned octal * `x`/`X`: Unsigned hexadecimal + * `s`: Stringifies the number + * `S`: SI-prefixed notation with precision as significant digits, e.g. `{:num %.2S}` → `1.2M` +* `{:abbr N}` will truncate the value to `N` characters with an ellipsis. +* `{:bytes}` will format a number as a base-1024 size (e.g. `1.5 KiB`), with an optional significant-digit count (`{:bytes 2}`). +* `{:duration}` will format a number as a composite span (e.g. `1h 30m`), read in seconds unless a unit is given (`{:duration ms}`; one of `ns`, `us`, `ms`, `s`, `m`, `h`, `d`). +* `{:ordinal}` will format a number with an ordinal suffix (`1st`, `2nd`, `3rd`, …). + +Formatter keywords are case-insensitive (`{:NUM}`, `{:upper}`, `{:TITLE}` all work); the `{:time}`/`{:num}` format payloads stay case-sensitive. Negative numbers render with a proper minus sign (−) throughout. diff --git a/src/Cargo.toml b/src/Cargo.toml index 00e05235b..0197a3588 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -47,7 +47,7 @@ hephaestus = { version = "0.4.1", optional = true, default-features = false, fea # Scale primitives (transforms, breaks, palettes, OOB policies). Unlike # hephaestus this is a core dependency: the executor resolves scales through # it, so it must build on the MSRV toolchain. -zygos = { version = "0.3.0", default-features = false, features = ["palettes"] } +zygos = { version = "0.4.0", default-features = false, features = ["palettes"] } # WOFF / WOFF2 decoding for `fonts::register_font`, behind `webfonts`. Pure # Rust and no build script; `brotli` here is `brotli-decompressor`, the @@ -65,7 +65,6 @@ thiserror.workspace = true regex.workspace = true chrono.workspace = true rand.workspace = true -sprintf = "0.4" const_format.workspace = true uuid.workspace = true diff --git a/src/format.rs b/src/format.rs index d0d9df1ef..38ea23848 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,269 +1,142 @@ -//! Template-based label generation for scale RENAMING wildcards +//! Break-label generation, delegated to [`zygos::LabelSpec`] //! -//! Supports placeholder syntax in templates: -//! - `{}` - Insert value as-is -//! - `{:UPPER}` - Convert to UPPERCASE -//! - `{:lower}` - Convert to lowercase -//! - `{:Title}` - Convert to Title Case -//! - `{:time %fmt}` - DateTime strftime format (e.g., `{:time %b %Y}` -> "Jan 2024") -//! - `{:num %fmt}` - Number printf format (e.g., `{:num %.2f}` -> "25.50") - -use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; -use regex::Regex; -use std::collections::HashMap; -use std::sync::OnceLock; - -use crate::plot::ArrayElement; - -/// Placeholder types supported in label templates -#[derive(Debug, Clone)] -enum Placeholder { - /// `{}` - Insert value as-is - Plain, - /// `{:UPPER}` - Convert to UPPERCASE - Upper, - /// `{:lower}` - Convert to lowercase - Lower, - /// `{:Title}` - Convert to Title Case - Title, - /// `{:time %Y-%m-%d}` or similar strftime format - DateTime(String), - /// `{:num %.2f}` or similar printf format - Number(String), -} - -/// Parsed placeholder with its full match text -#[derive(Debug, Clone)] -struct ParsedPlaceholder { - placeholder: Placeholder, - match_text: String, -} +//! ggsql's RENAMING clause maps to zygos's label spec: explicit +//! `'value' => 'label'` entries become renames/suppressions and the +//! wildcard `* => '...'` becomes the fallback template. Labels are +//! rendered against the *current* break set on every call, so +//! re-breaking re-expands templates and re-derives shared numeric +//! precision. +//! +//! Rename matching reproduces ggsql's historical string-key behaviour +//! on top of zygos's typed [`Value::key_eq`]: user rename keys come +//! from SQL literals (`'25'`, `'2024-01-01'`) while breaks are typed, +//! so a break matches a key either by `key_eq` or by comparing the +//! break's `to_key_string()` against the key's own string form. -/// Regex for matching placeholders in templates -fn placeholder_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"\{([^}]*)\}").expect("Invalid placeholder regex")) -} +use zygos::{LabelSpec, Value}; -/// Parse all placeholders from a template string -fn parse_placeholders(template: &str) -> Vec { - placeholder_regex() - .find_iter(template) - .map(|cap| { - let inner = &template[cap.start() + 1..cap.end() - 1]; - let placeholder = match inner { - "" => Placeholder::Plain, - ":UPPER" => Placeholder::Upper, - ":lower" => Placeholder::Lower, - ":Title" => Placeholder::Title, - s if s.starts_with(":time ") => { - Placeholder::DateTime(s.strip_prefix(":time ").unwrap().to_string()) - } - s if s.starts_with(":num ") => { - Placeholder::Number(s.strip_prefix(":num ").unwrap().to_string()) - } - _ => Placeholder::Plain, // Unknown, treat as plain - }; - ParsedPlaceholder { - placeholder, - match_text: cap.as_str().to_string(), - } - }) - .collect() -} +use crate::plot::{format_number, ArrayElement}; -/// Apply transformation based on placeholder type. -/// `precision` controls decimal places for `Plain` placeholders on numeric values. -fn apply_transformation( - value: &str, - placeholder: &Placeholder, - precision: Option, -) -> String { - match placeholder { - Placeholder::Plain => match precision { - Some(prec) => match value.parse::() { - Ok(n) => format!("{n:.prec$}"), - Err(_) => value.to_string(), - }, - None => value.to_string(), - }, - Placeholder::Upper => value.to_uppercase(), - Placeholder::Lower => value.to_lowercase(), - Placeholder::Title => to_title_case(value), - Placeholder::DateTime(fmt) => format_datetime(value, fmt), - Placeholder::Number(fmt) => format_number_with_spec(value, fmt), +/// Convert a break element to the [`Value`] it is labelled as. +/// +/// Numeric breaks stay numeric so `{}` shared precision and `{:num}` +/// work; temporal breaks go as their ISO key string, which zygos's +/// `{:time}` parses (and which rename keys written as ISO string +/// literals match by `key_eq`). +pub(crate) fn break_value(elem: &ArrayElement) -> Value { + match elem { + ArrayElement::String(s) => Value::String(s.as_str().into()), + ArrayElement::Number(n) => Value::Number(*n), + ArrayElement::Boolean(b) => Value::Bool(*b), + ArrayElement::Null => Value::Null, + ArrayElement::Date(_) | ArrayElement::DateTime(_) | ArrayElement::Time(_) => { + Value::String(elem.to_key_string().into()) + } } } -/// Convert string to Title Case -fn to_title_case(s: &str) -> String { - s.split_whitespace() - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(first) => first - .to_uppercase() - .chain(chars.flat_map(|c| c.to_lowercase())) - .collect(), - } - }) - .collect::>() - .join(" ") -} - -/// Format datetime string using strftime format -fn format_datetime(value: &str, fmt: &str) -> String { - // Try parsing as NaiveDateTime first (various formats) - if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") { - return dt.format(fmt).to_string(); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") { - return dt.format(fmt).to_string(); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S") { - return dt.format(fmt).to_string(); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f") { - return dt.format(fmt).to_string(); +/// Convert a rename *key* element to a [`Value`], keeping the +/// literal's type: `'A'` stays a string, `25` a number, `NULL` null. +/// The typed rule lets an unquoted `10` match the string category +/// `"10"`; quoted keys still match through the text rule in +/// [`label_elements`]. +pub(crate) fn key_value(elem: &ArrayElement) -> Value { + match elem { + ArrayElement::String(s) => Value::String(s.as_str().into()), + ArrayElement::Number(n) => Value::Number(*n), + ArrayElement::Boolean(b) => Value::Bool(*b), + ArrayElement::Null => Value::Null, + ArrayElement::Date(d) => Value::Date(*d), + ArrayElement::DateTime(dt) => Value::DateTime(*dt), + ArrayElement::Time(t) => Value::Time(*t), } - // Try parsing as NaiveDate - if let Ok(d) = NaiveDate::parse_from_str(value, "%Y-%m-%d") { - return d.format(fmt).to_string(); - } - // Try parsing as NaiveTime - if let Ok(d) = NaiveTime::parse_from_str(value, "%H:%M:%S") { - return d.format(fmt).to_string(); - } - if let Ok(d) = NaiveTime::parse_from_str(value, "%H:%M:%S%.f") { - return d.format(fmt).to_string(); - } - // Fallback: return original value if parsing fails - value.to_string() } -/// Format number using printf-style format specifier (e.g., "%.2f", "%d", "%e") -fn format_number_with_spec(value: &str, fmt: &str) -> String { - // Try to parse as f64 - if let Ok(n) = value.parse::() { - // Use sprintf crate for full printf compatibility - // Supports: %d, %i, %f, %e, %E, %g, %G, %o, %x, %X, width, precision, flags - return sprintf::sprintf!(fmt, n).unwrap_or_else(|_| value.to_string()); +/// The string form of a rename key, for the text matching rule: +/// `'25'` renames numeric break 25 because the break's +/// `to_key_string()` equals the key's string. +fn key_string(value: &Value) -> String { + match value { + Value::String(s) => s.to_string(), + Value::Number(n) => format_number(*n), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + // Temporal and other keys never arise from RENAMING literals; + // give them their default rendering so the rule stays total. + other => zygos::default_label(other, None).unwrap_or_default(), } - // Fallback: return original value if parsing fails - value.to_string() } -/// Apply a label template to an array of break values. -/// -/// Each break value is formatted using the template string. -/// Explicit mappings (from existing label_mapping) take priority over template-generated ones. +/// Render the labels for a break set against a [`LabelSpec`], one +/// entry per break, aligned by index. `None` means the label is +/// suppressed. /// -/// # Arguments -/// * `breaks` - Array elements to apply template to -/// * `template` - Template string with placeholders (e.g., "{} units", "{:UPPER}") -/// * `existing` - Optional existing label mappings (explicit mappings take priority) +/// Resolution order per break (ggplot2's `labels` semantics, extended +/// with ggsql's historical string-key matching): /// -/// # Returns -/// HashMap of original value -> formatted label +/// 1. **Typed rename** — the break's [`break_value`] `key_eq`-matches +/// a rename key. +/// 2. **Text rename** — the break's `to_key_string()` equals a rename +/// key's string form. +/// 3. **Template** — the spec's template (default `{}`), with shared +/// numeric precision derived from the un-renamed breaks. /// -/// # Example -/// ```ignore -/// let breaks = vec![ArrayElement::Number(0.0), ArrayElement::Number(25.0)]; -/// let result = apply_label_template(&breaks, "{} units", &None); -/// // result: {"0" => Some("0 units"), "25" => Some("25 units")} -/// ``` -pub fn apply_label_template( - breaks: &[ArrayElement], - template: &str, - existing: &Option>>, -) -> HashMap> { - let mut result = existing.clone().unwrap_or_default(); - - // Parse all placeholders once - let placeholders = parse_placeholders(template); - - // For numeric breaks with a plain placeholder, determine consistent decimal - // precision so that e.g. [54.5, 55, 55.5] formats as ["54.5", "55.0", "55.5"] - // rather than stripping the ".0" from the integer-valued break. - let has_plain = placeholders +/// A bare null break with no rename renders `"null"`, as it always +/// has (zygos suppresses [`Value::Null`] by default). +pub fn label_elements(spec: &LabelSpec, breaks: &[ArrayElement]) -> Vec> { + let values: Vec = breaks.iter().map(break_value).collect(); + + // Resolve renames first: typed match, then the historical text rule. + let renames: Vec<(&Value, Option<&str>)> = spec.renames().collect(); + let matched: Vec>> = values .iter() - .any(|p| matches!(p.placeholder, Placeholder::Plain)) - || placeholders.is_empty(); - let numeric_precision = if has_plain { - compute_numeric_precision(breaks) + .zip(breaks) + .map(|(value, elem)| { + if let Some(entry) = spec.lookup(value) { + return Some(entry.map(str::to_string)); + } + let key = elem.to_key_string(); + renames + .iter() + .find(|(k, _)| key_string(k) == key) + .map(|(_, r)| r.map(str::to_string)) + }) + .collect(); + + // Shared precision over the un-renamed breaks, for plain `{}`. + let has_plain = spec.template().is_none_or(zygos::Template::has_plain); + let precision = if has_plain { + let unrenamed: Vec = values + .iter() + .zip(&matched) + .filter(|(_, m)| m.is_none()) + .map(|(v, _)| v.clone()) + .collect(); + zygos::shared_precision(&unrenamed) } else { None }; - for elem in breaks { - // Skip null values - if matches!(elem, ArrayElement::Null) { - continue; - } - let key = elem.to_key_string(); - - // Only apply template if no explicit mapping exists - result.entry(key.clone()).or_insert_with(|| { - Some(format_value( - &key, - template, - &placeholders, - numeric_precision, - )) - }); - } - - result -} - -/// Determine the number of decimal places needed to display numeric breaks -/// consistently. Based on the algorithm from R's `scales::precision()`: -/// uses the smallest inter-break difference to derive display precision. -/// Returns `None` if there are fewer than 2 numeric breaks or all are integers. -fn compute_numeric_precision(breaks: &[ArrayElement]) -> Option { - let tol = f64::EPSILON.sqrt(); - - let mut numbers: Vec = breaks + values .iter() - .filter_map(|e| match e { - ArrayElement::Number(n) => Some(*n), - _ => None, + .zip(breaks) + .zip(&matched) + .map(|((value, elem), m)| match m { + Some(entry) => entry.clone(), + None => { + let rendered = match spec.template() { + Some(t) => t.format(value, precision), + None => zygos::default_label(value, precision), + }; + match (rendered, elem) { + // zygos suppresses nulls; ggsql's bare null break + // has always rendered the text "null". + (None, ArrayElement::Null) => Some("null".to_string()), + (rendered, _) => rendered, + } + } }) - .collect(); - - if numbers.len() < 2 { - return None; - } - - numbers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - - let smallest_diff = numbers - .windows(2) - .map(|w| (w[1] - w[0]).abs()) - .filter(|d| *d > 0.0) - .fold(f64::INFINITY, f64::min); - - if smallest_diff == f64::INFINITY || smallest_diff < tol { - return None; - } - - let mut precision = 10f64.powf(smallest_diff.log10().floor() - 1.0); - - // If all values are multiples of 10*precision, coarsen by one level - if numbers - .iter() - .all(|&x| ((x / precision).round() % 10.0).abs() < tol) - { - precision *= 10.0; - } - - let n_decimals = (-precision.log10().floor()) as isize; - if n_decimals > 0 { - Some(n_decimals.min(20) as usize) - } else { - None - } + .collect() } /// Apply label formatting template to a DataFrame column. @@ -296,22 +169,20 @@ pub fn format_dataframe_column( .column(column_name) .map_err(|e| format!("Column '{}' not found: {}", column_name, e))?; - // Step 1: Convert entire column to strings - let string_values: Vec> = if let Ok(str_col) = as_str(column) { + // Convert the column to label values: strings stay strings, + // numerics stay numeric so `{:num}` and `{}` work. + let values: Vec> = if let Ok(str_col) = as_str(column) { // String column (includes temporal data auto-converted to ISO format) (0..str_col.len()) .map(|i| { if str_col.is_null(i) { None } else { - Some(str_col.value(i).to_string()) + Some(Value::String(str_col.value(i).into())) } }) .collect() } else if let Ok(cast) = cast_array(column, &DataType::Float64) { - // Numeric column - use shared format_number helper for clean integer formatting - use crate::plot::format_number; - let f64_col = as_f64(&cast).map_err(|e| format!("Failed to cast column to f64: {}", e))?; (0..f64_col.len()) @@ -319,7 +190,7 @@ pub fn format_dataframe_column( if f64_col.is_null(i) { None } else { - Some(format_number(f64_col.value(i))) + Some(Value::Number(f64_col.value(i))) } }) .collect() @@ -331,11 +202,10 @@ pub fn format_dataframe_column( )); }; - // Step 2: Apply formatting template to all string values - let placeholders = parse_placeholders(template); - let formatted_owned: Vec> = string_values - .into_iter() - .map(|opt| opt.map(|s| format_value(&s, template, &placeholders, None))) + let template = zygos::Template::parse(template); + let formatted_owned: Vec> = values + .iter() + .map(|opt| opt.as_ref().and_then(|v| template.format(v, None))) .collect(); let formatted_refs: Vec> = @@ -347,286 +217,281 @@ pub fn format_dataframe_column( .map_err(|e| format!("Failed to replace column: {}", e)) } -/// Format a single value using template and parsed placeholders -fn format_value( - value: &str, - template: &str, - placeholders: &[ParsedPlaceholder], - precision: Option, -) -> String { - if placeholders.is_empty() { - // No placeholders - use template as literal string - template.to_string() - } else { - // Replace each placeholder with its transformed value - let mut result = template.to_string(); - for parsed in placeholders.iter().rev() { - let transformed = apply_transformation(value, &parsed.placeholder, precision); - result = result.replace(&parsed.match_text, &transformed); - } - result - } -} - #[cfg(test)] mod tests { use super::*; + fn spec_with_template(template: &str) -> LabelSpec { + let mut spec = LabelSpec::new(); + spec.set_template(template); + spec + } + + fn numbers(breaks: &[f64]) -> Vec { + breaks.iter().map(|n| ArrayElement::Number(*n)).collect() + } + + fn strings(breaks: &[&str]) -> Vec { + breaks + .iter() + .map(|s| ArrayElement::String(s.to_string())) + .collect() + } + + fn labels(spec: &LabelSpec, breaks: &[ArrayElement]) -> Vec> { + label_elements(spec, breaks) + } + #[test] fn test_plain_placeholder() { - let breaks = vec![ - ArrayElement::Number(0.0), - ArrayElement::Number(25.0), - ArrayElement::Number(50.0), - ]; - let result = apply_label_template(&breaks, "{} units", &None); - - assert_eq!(result.get("0"), Some(&Some("0 units".to_string()))); - assert_eq!(result.get("25"), Some(&Some("25 units".to_string()))); - assert_eq!(result.get("50"), Some(&Some("50 units".to_string()))); + let result = labels(&LabelSpec::new(), &numbers(&[0.0, 25.0, 50.0])); + assert_eq!( + result, + vec![ + Some("0".to_string()), + Some("25".to_string()), + Some("50".to_string()) + ] + ); } #[test] fn test_upper_placeholder() { - let breaks = vec![ - ArrayElement::String("north".to_string()), - ArrayElement::String("south".to_string()), - ]; - let result = apply_label_template(&breaks, "{:UPPER}", &None); - - assert_eq!(result.get("north"), Some(&Some("NORTH".to_string()))); - assert_eq!(result.get("south"), Some(&Some("SOUTH".to_string()))); + let result = labels( + &spec_with_template("{:UPPER}"), + &strings(&["north", "south"]), + ); + assert_eq!( + result, + vec![Some("NORTH".to_string()), Some("SOUTH".to_string())] + ); } #[test] fn test_lower_placeholder() { - let breaks = vec![ - ArrayElement::String("HELLO".to_string()), - ArrayElement::String("WORLD".to_string()), - ]; - let result = apply_label_template(&breaks, "{:lower}", &None); - - assert_eq!(result.get("HELLO"), Some(&Some("hello".to_string()))); - assert_eq!(result.get("WORLD"), Some(&Some("world".to_string()))); + let result = labels( + &spec_with_template("{:lower}"), + &strings(&["HELLO", "WORLD"]), + ); + assert_eq!( + result, + vec![Some("hello".to_string()), Some("world".to_string())] + ); } #[test] fn test_title_placeholder() { - let breaks = vec![ - ArrayElement::String("us east".to_string()), - ArrayElement::String("eu west".to_string()), - ]; - let result = apply_label_template(&breaks, "Region: {:Title}", &None); - - assert_eq!( - result.get("us east"), - Some(&Some("Region: Us East".to_string())) + let result = labels( + &spec_with_template("Region: {:Title}"), + &strings(&["us east", "eu west"]), ); assert_eq!( - result.get("eu west"), - Some(&Some("Region: Eu West".to_string())) + result, + vec![ + Some("Region: Us East".to_string()), + Some("Region: Eu West".to_string()) + ] ); } #[test] fn test_datetime_placeholder() { - let breaks = vec![ - ArrayElement::String("2024-01-15".to_string()), - ArrayElement::String("2024-02-15".to_string()), - ]; - let result = apply_label_template(&breaks, "{:time %b %Y}", &None); - - assert_eq!( - result.get("2024-01-15"), - Some(&Some("Jan 2024".to_string())) + let result = labels( + &spec_with_template("{:time %b %Y}"), + &strings(&["2024-01-15", "2024-02-15"]), ); assert_eq!( - result.get("2024-02-15"), - Some(&Some("Feb 2024".to_string())) + result, + vec![Some("Jan 2024".to_string()), Some("Feb 2024".to_string())] ); } #[test] - fn test_explicit_takes_priority() { - let breaks = vec![ - ArrayElement::String("A".to_string()), - ArrayElement::String("B".to_string()), - ArrayElement::String("C".to_string()), - ]; - let mut existing = HashMap::new(); - existing.insert("A".to_string(), Some("Alpha".to_string())); - - let result = apply_label_template(&breaks, "Category {}", &Some(existing)); - - // A should keep explicit mapping - assert_eq!(result.get("A"), Some(&Some("Alpha".to_string()))); - // B and C should get template - assert_eq!(result.get("B"), Some(&Some("Category B".to_string()))); - assert_eq!(result.get("C"), Some(&Some("Category C".to_string()))); - } - - #[test] - fn test_multiple_placeholders() { - let breaks = vec![ArrayElement::String("hello".to_string())]; - let result = apply_label_template(&breaks, "{} - {:UPPER}", &None); - + fn test_rename_takes_priority() { + let mut spec = spec_with_template("Category {}"); + spec.rename(Value::String("A".into()), "Alpha"); + let result = labels(&spec, &strings(&["A", "B", "C"])); assert_eq!( - result.get("hello"), - Some(&Some("hello - HELLO".to_string())) + result, + vec![ + Some("Alpha".to_string()), + Some("Category B".to_string()), + Some("Category C".to_string()) + ] ); } #[test] - fn test_no_placeholder_literal() { - let breaks = vec![ - ArrayElement::String("A".to_string()), - ArrayElement::String("B".to_string()), - ]; - let result = apply_label_template(&breaks, "Constant Label", &None); - - assert_eq!(result.get("A"), Some(&Some("Constant Label".to_string()))); - assert_eq!(result.get("B"), Some(&Some("Constant Label".to_string()))); + fn test_quoted_number_key_matches_numeric_break() { + // The historical text rule: '25' renames numeric break 25. + let mut spec = LabelSpec::new(); + spec.rename(Value::String("25".into()), "twenty-five"); + let result = labels(&spec, &numbers(&[25.0, 50.0])); + assert_eq!( + result, + vec![Some("twenty-five".to_string()), Some("50".to_string())] + ); } #[test] - fn test_to_key_string_number_integer() { - assert_eq!(ArrayElement::Number(0.0).to_key_string(), "0"); - assert_eq!(ArrayElement::Number(25.0).to_key_string(), "25"); - assert_eq!(ArrayElement::Number(-100.0).to_key_string(), "-100"); + fn test_unquoted_number_key_matches_string_category() { + // The typed rule: 10 renames the string category "10". + let mut spec = LabelSpec::new(); + spec.rename(Value::Number(10.0), "ten"); + let result = labels(&spec, &strings(&["10", "20"])); + assert_eq!( + result, + vec![Some("ten".to_string()), Some("20".to_string())] + ); } #[test] - fn test_to_key_string_number_decimal() { - assert_eq!(ArrayElement::Number(25.5).to_key_string(), "25.5"); - assert_eq!(ArrayElement::Number(0.123).to_key_string(), "0.123"); + fn test_multiple_placeholders() { + let result = labels(&spec_with_template("{} - {:UPPER}"), &strings(&["hello"])); + assert_eq!(result, vec![Some("hello - HELLO".to_string())]); } #[test] - fn test_to_title_case() { - assert_eq!(to_title_case("hello world"), "Hello World"); - assert_eq!(to_title_case("HELLO WORLD"), "Hello World"); - assert_eq!(to_title_case("hello"), "Hello"); - assert_eq!(to_title_case(""), ""); + fn test_no_placeholder_literal() { + let result = labels(&spec_with_template("Constant Label"), &strings(&["A", "B"])); + assert_eq!( + result, + vec![ + Some("Constant Label".to_string()), + Some("Constant Label".to_string()) + ] + ); } #[test] fn test_datetime_with_time() { - let breaks = vec![ArrayElement::String("2024-01-15T10:30:00".to_string())]; - let result = apply_label_template(&breaks, "{:time %Y-%m-%d %H:%M}", &None); - - assert_eq!( - result.get("2024-01-15T10:30:00"), - Some(&Some("2024-01-15 10:30".to_string())) + let result = labels( + &spec_with_template("{:time %Y-%m-%d %H:%M}"), + &strings(&["2024-01-15T10:30:00"]), ); + assert_eq!(result, vec![Some("2024-01-15 10:30".to_string())]); } #[test] fn test_invalid_datetime_fallback() { - let breaks = vec![ArrayElement::String("not-a-date".to_string())]; - let result = apply_label_template(&breaks, "{:time %Y-%m-%d}", &None); + let result = labels( + &spec_with_template("{:time %Y-%m-%d}"), + &strings(&["not-a-date"]), + ); + assert_eq!(result, vec![Some("not-a-date".to_string())]); + } - // Should fall back to original value + #[test] + fn test_null_renders_null_text() { + let result = labels( + &LabelSpec::new(), + &[ + ArrayElement::String("A".to_string()), + ArrayElement::Null, + ArrayElement::String("B".to_string()), + ], + ); assert_eq!( - result.get("not-a-date"), - Some(&Some("not-a-date".to_string())) + result, + vec![ + Some("A".to_string()), + Some("null".to_string()), + Some("B".to_string()) + ] ); } #[test] - fn test_null_skipped() { - let breaks = vec![ - ArrayElement::String("A".to_string()), - ArrayElement::Null, - ArrayElement::String("B".to_string()), - ]; - let result = apply_label_template(&breaks, "{}", &None); - - assert_eq!(result.len(), 2); - assert!(result.contains_key("A")); - assert!(result.contains_key("B")); + fn test_null_rename_by_null_literal_key() { + let mut spec = LabelSpec::new(); + spec.rename(Value::Null, "The rest"); + let result = labels(&spec, &[ArrayElement::Null]); + assert_eq!(result, vec![Some("The rest".to_string())]); } #[test] fn test_number_format_decimal_places() { - let breaks = vec![ArrayElement::Number(25.5), ArrayElement::Number(100.0)]; - let result = apply_label_template(&breaks, "${:num %.2f}", &None); - - assert_eq!(result.get("25.5"), Some(&Some("$25.50".to_string()))); - assert_eq!(result.get("100"), Some(&Some("$100.00".to_string()))); + let result = labels( + &spec_with_template("${:num %.2f}"), + &numbers(&[25.5, 100.0]), + ); + assert_eq!( + result, + vec![Some("$25.50".to_string()), Some("$100.00".to_string())] + ); } #[test] fn test_number_format_no_decimals() { - let breaks = vec![ArrayElement::Number(25.7)]; - let result = apply_label_template(&breaks, "{:num %.0f} items", &None); - - assert_eq!(result.get("25.7"), Some(&Some("26 items".to_string()))); + let result = labels(&spec_with_template("{:num %.0f} items"), &numbers(&[25.7])); + assert_eq!(result, vec![Some("26 items".to_string())]); } #[test] fn test_number_format_scientific() { - let breaks = vec![ArrayElement::Number(1234.5)]; - let result = apply_label_template(&breaks, "{:num %.2e}", &None); - - assert_eq!(result.get("1234.5"), Some(&Some("1.23e+03".to_string()))); + let result = labels(&spec_with_template("{:num %.2e}"), &numbers(&[1234.5])); + assert_eq!(result, vec![Some("1.23e+03".to_string())]); } #[test] fn test_number_format_non_numeric_fallback() { - let breaks = vec![ArrayElement::String("hello".to_string())]; - let result = apply_label_template(&breaks, "{:num %.2f}", &None); - - // Non-numeric should fall back to original value - assert_eq!(result.get("hello"), Some(&Some("hello".to_string()))); + let result = labels(&spec_with_template("{:num %.2f}"), &strings(&["hello"])); + assert_eq!(result, vec![Some("hello".to_string())]); } #[test] fn test_number_format_integer() { - let breaks = vec![ArrayElement::Number(42.0)]; - let result = apply_label_template(&breaks, "{:num %d}", &None); - - assert_eq!(result.get("42"), Some(&Some("42".to_string()))); + let result = labels(&spec_with_template("{:num %d}"), &numbers(&[42.0])); + assert_eq!(result, vec![Some("42".to_string())]); } #[test] fn test_plain_placeholder_consistent_decimal_precision() { - let breaks = vec![ - ArrayElement::Number(54.5), - ArrayElement::Number(55.0), - ArrayElement::Number(55.5), - ]; - let result = apply_label_template(&breaks, "{}", &None); - - assert_eq!(result.get("54.5"), Some(&Some("54.5".to_string()))); - assert_eq!(result.get("55"), Some(&Some("55.0".to_string()))); - assert_eq!(result.get("55.5"), Some(&Some("55.5".to_string()))); + let result = labels(&LabelSpec::new(), &numbers(&[54.5, 55.0, 55.5])); + assert_eq!( + result, + vec![ + Some("54.5".to_string()), + Some("55.0".to_string()), + Some("55.5".to_string()) + ] + ); } #[test] fn test_plain_placeholder_all_integers_no_decimals() { - let breaks = vec![ - ArrayElement::Number(10.0), - ArrayElement::Number(20.0), - ArrayElement::Number(30.0), - ]; - let result = apply_label_template(&breaks, "{}", &None); - - assert_eq!(result.get("10"), Some(&Some("10".to_string()))); - assert_eq!(result.get("20"), Some(&Some("20".to_string()))); - assert_eq!(result.get("30"), Some(&Some("30".to_string()))); + let result = labels(&LabelSpec::new(), &numbers(&[10.0, 20.0, 30.0])); + assert_eq!( + result, + vec![ + Some("10".to_string()), + Some("20".to_string()), + Some("30".to_string()) + ] + ); } #[test] fn test_plain_placeholder_small_fractional_steps() { - let breaks = vec![ - ArrayElement::Number(0.0), - ArrayElement::Number(0.05), - ArrayElement::Number(0.1), - ]; - let result = apply_label_template(&breaks, "{}", &None); - - assert_eq!(result.get("0"), Some(&Some("0.00".to_string()))); - assert_eq!(result.get("0.05"), Some(&Some("0.05".to_string()))); - assert_eq!(result.get("0.1"), Some(&Some("0.10".to_string()))); + let result = labels(&LabelSpec::new(), &numbers(&[0.0, 0.05, 0.1])); + assert_eq!( + result, + vec![ + Some("0.00".to_string()), + Some("0.05".to_string()), + Some("0.10".to_string()) + ] + ); + } + + #[test] + fn test_suppression() { + let mut spec = LabelSpec::new(); + spec.suppress(Value::String("B".into())); + let result = labels(&spec, &strings(&["A", "B", "C"])); + assert_eq!( + result, + vec![Some("A".to_string()), None, Some("C".to_string())] + ); } } diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 59f7e2aaf..fec1c8adf 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -669,8 +669,7 @@ fn build_scale(node: &Node, source: &SourceTree) -> Result { let mut transform: Option = None; let mut explicit_transform = false; let mut properties = Parameters::new(); - let mut label_mapping: Option>> = None; - let mut label_template = "{}".to_string(); + let mut label_spec = zygos::LabelSpec::new(); let mut cursor = node.walk(); for child in node.children(&mut cursor) { @@ -706,11 +705,7 @@ fn build_scale(node: &Node, source: &SourceTree) -> Result { } "scale_renaming_clause" => { // Parse RENAMING 'A' => 'Alpha', 'B' => 'Beta', * => '{} units' - let (mappings, template) = parse_scale_renaming_clause(&child, source)?; - if !mappings.is_empty() { - label_mapping = Some(mappings); - } - label_template = template; + label_spec = parse_scale_renaming_clause(&child, source)?; } _ => {} } @@ -759,8 +754,7 @@ fn build_scale(node: &Node, source: &SourceTree) -> Result { explicit_transform, properties, resolved: false, - label_mapping, - label_template, + label_spec, panels: None, }) } @@ -829,15 +823,12 @@ fn parse_scale_via_clause(node: &Node, source: &SourceTree) -> Result /// Parse RENAMING clause: RENAMING 'A' => 'Alpha', 'B' => 'Beta', 'internal' => NULL, * => '{} units' /// -/// Returns a tuple of: -/// - HashMap where: Key = original value, Value = Some(label) or None for suppressed labels -/// - Template string for wildcard mappings (* => '...'), defaults to "{}" -fn parse_scale_renaming_clause( - node: &Node, - source: &SourceTree, -) -> Result<(HashMap>, String)> { - let mut mappings = HashMap::new(); - let mut template = "{}".to_string(); +/// Builds a [`zygos::LabelSpec`]: explicit entries become renames (`Some`) +/// or suppressions (`NULL`), the wildcard `* => '...'` becomes the fallback +/// template. Rename keys keep the literal's type — `'A'` a string, `25` a +/// number, `NULL` null — see [`crate::format::key_value`]. +fn parse_scale_renaming_clause(node: &Node, source: &SourceTree) -> Result { + let mut spec = zygos::LabelSpec::new(); // Find all renaming_assignment nodes let query = "(renaming_assignment) @assign"; @@ -852,10 +843,16 @@ fn parse_scale_renaming_clause( // Parse 'name' (from) value - wildcards, strings need unquoting, numbers are raw let from_value = match name_node.kind() { - "*" => "*".to_string(), - "string" => parse_string_node(&name_node, source), - "number" => source.get_text(&name_node), - "null_literal" => "null".to_string(), // null key for renaming null values + "*" => zygos::Value::Null, // unused; wildcards set the template + "string" => zygos::Value::String(parse_string_node(&name_node, source).into()), + "number" => { + let text = source.get_text(&name_node); + let number = text.parse::().map_err(|_| { + GgsqlError::ParseError(format!("Invalid number in scale renaming: {}", text)) + })?; + zygos::Value::Number(number) + } + "null_literal" => zygos::Value::Null, // null key for renaming null values _ => { return Err(GgsqlError::ParseError(format!( "Invalid 'from' type in scale renaming: {}", @@ -879,15 +876,18 @@ fn parse_scale_renaming_clause( if is_wildcard { // Wildcard: * => 'template' if let Some(tmpl) = to_value { - template = tmpl; + spec.set_template(&tmpl); } } else { - // Explicit mapping: 'A' => 'Alpha' or '10' => 'Ten' - mappings.insert(from_value, to_value); + // Explicit mapping: 'A' => 'Alpha' or 'internal' => NULL + match to_value { + Some(label) => spec.rename(from_value, label), + None => spec.suppress(from_value), + }; } } - Ok((mappings, template)) + Ok(spec) } // ============================================================================ @@ -3175,10 +3175,11 @@ mod tests { // Scale aesthetic is transformed to internal name assert_eq!(scales[0].aesthetic, "pos1"); - let label_mapping = scales[0].label_mapping.as_ref().unwrap(); - assert_eq!(label_mapping.len(), 2); - assert_eq!(label_mapping.get("A"), Some(&Some("Alpha".to_string()))); - assert_eq!(label_mapping.get("B"), Some(&Some("Beta".to_string()))); + let spec = &scales[0].label_spec; + assert_eq!(spec.rename_len(), 2); + let key = |s: &str| zygos::Value::String(s.into()); + assert_eq!(spec.lookup(&key("A")), Some(Some("Alpha"))); + assert_eq!(spec.lookup(&key("B")), Some(Some("Beta"))); } #[test] @@ -3192,13 +3193,10 @@ mod tests { let specs = parse_test_query(query).unwrap(); let scales = &specs[0].scales; - let label_mapping = scales[0].label_mapping.as_ref().unwrap(); - - assert_eq!(label_mapping.get("internal"), Some(&None)); // NULL -> None - assert_eq!( - label_mapping.get("visible"), - Some(&Some("Shown".to_string())) - ); + let spec = &scales[0].label_spec; + let key = |s: &str| zygos::Value::String(s.into()); + assert_eq!(spec.lookup(&key("internal")), Some(None)); // NULL suppresses + assert_eq!(spec.lookup(&key("visible")), Some(Some("Shown"))); } #[test] @@ -3212,12 +3210,18 @@ mod tests { let specs = parse_test_query(query).unwrap(); let scales = &specs[0].scales; - let label_mapping = scales[0].label_mapping.as_ref().unwrap(); - - assert_eq!(label_mapping.len(), 3); - assert_eq!(label_mapping.get("0"), Some(&Some("0-10".to_string()))); - assert_eq!(label_mapping.get("10"), Some(&Some("10-20".to_string()))); - assert_eq!(label_mapping.get("20"), Some(&Some("20-30".to_string()))); + let spec = &scales[0].label_spec; + assert_eq!(spec.rename_len(), 3); + // Unquoted numeric literals become typed number keys. + assert_eq!(spec.lookup(&zygos::Value::Number(0.0)), Some(Some("0-10"))); + assert_eq!( + spec.lookup(&zygos::Value::Number(10.0)), + Some(Some("10-20")) + ); + assert_eq!( + spec.lookup(&zygos::Value::Number(20.0)), + Some(Some("20-30")) + ); } #[test] @@ -3234,15 +3238,10 @@ mod tests { assert_eq!(scales.len(), 1); assert_eq!(scales[0].aesthetic, "color"); - let label_mapping = scales[0].label_mapping.as_ref().unwrap(); - assert_eq!( - label_mapping.get("cat_a"), - Some(&Some("Category A".to_string())) - ); - assert_eq!( - label_mapping.get("cat_b"), - Some(&Some("Category B".to_string())) - ); + let spec = &scales[0].label_spec; + let key = |s: &str| zygos::Value::String(s.into()); + assert_eq!(spec.lookup(&key("cat_a")), Some(Some("Category A"))); + assert_eq!(spec.lookup(&key("cat_b")), Some(Some("Category B"))); } #[test] @@ -3264,9 +3263,10 @@ mod tests { ); // Check RENAMING was parsed - let label_mapping = scales[0].label_mapping.as_ref().unwrap(); - assert_eq!(label_mapping.get("A"), Some(&Some("First".to_string()))); - assert_eq!(label_mapping.get("B"), Some(&Some("Second".to_string()))); + let spec = &scales[0].label_spec; + let key = |s: &str| zygos::Value::String(s.into()); + assert_eq!(spec.lookup(&key("A")), Some(Some("First"))); + assert_eq!(spec.lookup(&key("B")), Some(Some("Second"))); } #[test] @@ -3290,8 +3290,8 @@ mod tests { assert!(scales[0].output_range.is_some()); // Check RENAMING was parsed - let label_mapping = scales[0].label_mapping.as_ref().unwrap(); - assert_eq!(label_mapping.get("A"), Some(&Some("Option A".to_string()))); + let key = zygos::Value::String("A".into()); + assert_eq!(scales[0].label_spec.lookup(&key), Some(Some("Option A"))); } #[test] @@ -3306,9 +3306,12 @@ mod tests { let specs = parse_test_query(query).unwrap(); let scales = &specs[0].scales; - // Check label_template was parsed - assert!(scales[0].label_mapping.is_none()); // No explicit mappings - assert_eq!(scales[0].label_template, "{} units"); + // Check the template was parsed, with no explicit renames + assert!(scales[0].label_spec.rename_is_empty()); + assert_eq!( + scales[0].label_spec.template().map(|t| t.source()), + Some("{} units") + ); } #[test] @@ -3324,11 +3327,14 @@ mod tests { let scales = &specs[0].scales; // Check explicit mapping was parsed - let label_mapping = scales[0].label_mapping.as_ref().unwrap(); - assert_eq!(label_mapping.get("A"), Some(&Some("Alpha".to_string()))); + let key = zygos::Value::String("A".into()); + assert_eq!(scales[0].label_spec.lookup(&key), Some(Some("Alpha"))); // Check template was also parsed - assert_eq!(scales[0].label_template, "Category {}"); + assert_eq!( + scales[0].label_spec.template().map(|t| t.source()), + Some("Category {}") + ); } #[test] @@ -3343,7 +3349,10 @@ mod tests { let specs = parse_test_query(query).unwrap(); let scales = &specs[0].scales; - assert_eq!(scales[0].label_template, "{:UPPER}"); + assert_eq!( + scales[0].label_spec.template().map(|t| t.source()), + Some("{:UPPER}") + ); } #[test] @@ -3358,7 +3367,10 @@ mod tests { let specs = parse_test_query(query).unwrap(); let scales = &specs[0].scales; - assert_eq!(scales[0].label_template, "{:time %b %Y}"); + assert_eq!( + scales[0].label_spec.template().map(|t| t.source()), + Some("{:time %b %Y}") + ); } // ======================================== diff --git a/src/plot/scale/mod.rs b/src/plot/scale/mod.rs index 91c445b87..3dc386332 100644 --- a/src/plot/scale/mod.rs +++ b/src/plot/scale/mod.rs @@ -10,7 +10,6 @@ mod scale_type; pub mod transform; mod types; -pub use crate::format::apply_label_template; pub use crate::plot::aesthetic::{ is_facet_aesthetic, is_position_aesthetic, is_user_facet_aesthetic, }; diff --git a/src/plot/scale/scale_type/binned.rs b/src/plot/scale/scale_type/binned.rs index d5289997f..97b98f2cc 100644 --- a/src/plot/scale/scale_type/binned.rs +++ b/src/plot/scale/scale_type/binned.rs @@ -1,7 +1,5 @@ //! Binned scale type implementation -use std::collections::HashMap; - use arrow::datatypes::DataType; use super::{ @@ -496,39 +494,11 @@ impl ScaleTypeTrait for Binned { } } - // 6. Apply label template (RENAMING * => '...') - // Default is '{}' to ensure we control formatting instead of Vega-Lite - // For binned scales, apply to breaks array - let template = &scale.label_template; - - let values_to_label = match scale.properties.get("breaks") { - Some(ParameterValue::Array(breaks)) => Some(breaks.clone()), - _ => None, - }; - - if let Some(values) = values_to_label { - let generated_labels = - crate::format::apply_label_template(&values, template, &scale.label_mapping); - scale.label_mapping = Some(generated_labels); - } - - // 6b. Binned-specific: suppress terminal break labels for oob='squish' - // since those bins extend to infinity (-∞ to first internal break, last internal break to +∞) - if let Some(ParameterValue::String(oob)) = scale.properties.get("oob") { - if oob == OOB_SQUISH { - if let Some(ParameterValue::Array(breaks)) = scale.properties.get("breaks") { - if breaks.len() > 2 { - // Suppress first and last break labels - let first_key = breaks[0].to_key_string(); - let last_key = breaks[breaks.len() - 1].to_key_string(); - - let label_mapping = scale.label_mapping.get_or_insert_with(HashMap::new); - label_mapping.insert(first_key, None); - label_mapping.insert(last_key, None); - } - } - } - } + // 6. Labelling needs no resolution step: `Scale::labelled_breaks` + // renders `label_spec` against the current breaks on every read, + // including the terminal-edge suppression for oob='squish' (the + // outermost bins extend to infinity, so those edges are not real + // boundaries). // 7. Resolve output range (TO clause) self.resolve_output_range(scale, aesthetic)?; diff --git a/src/plot/scale/scale_type/discrete.rs b/src/plot/scale/scale_type/discrete.rs index 14d3968af..7bee9893c 100644 --- a/src/plot/scale/scale_type/discrete.rs +++ b/src/plot/scale/scale_type/discrete.rs @@ -65,7 +65,7 @@ impl ScaleTypeTrait for Discrete { super::categorical_numeric_domain(scale) } - fn break_labels(&self, scale: &super::super::Scale) -> Vec<(f64, String)> { + fn break_labels(&self, scale: &super::super::Scale) -> Vec<(f64, crate::plot::ArrayElement)> { super::categorical_break_labels(scale) } diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index 2c9802393..aa13ea8db 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -27,9 +27,7 @@ use std::sync::Arc; use super::transform::{Transform, TransformKind}; use crate::plot::aesthetic::{is_facet_aesthetic, is_position_aesthetic}; -use crate::plot::types::{ - format_number, validate_parameter, DefaultParamValue, ParamDefinition, Parameters, -}; +use crate::plot::types::{validate_parameter, DefaultParamValue, ParamDefinition, Parameters}; use crate::plot::{ArrayElement, ColumnInfo, ParameterValue}; // Scale type implementations @@ -829,25 +827,25 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { } } - /// Labelled breaks: `(numeric_position, display_label)` pairs. + /// Labelled breaks: `(numeric_position, break_element)` pairs. /// - /// Default: labels each break from its own typed `ArrayElement`, so a - /// temporal break reads as its ISO string rather than as the epoch number - /// its position projects to. The label is the element's `to_key_string()`, - /// which is also how `label_mapping` is keyed — so the `RENAMING` and - /// label-template overrides the caller applies actually match. - /// Discrete/ordinal override to pair position indices with input-range - /// category names. - fn break_labels(&self, scale: &super::Scale) -> Vec<(f64, String)> { + /// Default: each break is its own typed `ArrayElement`, so a temporal + /// break labels as its ISO string rather than as the epoch number its + /// position projects to. Labelling (RENAMING, templates) happens in + /// [`Scale::labelled_breaks`](crate::plot::Scale), which matches rename + /// keys against the element — so the element must be in the same form + /// `RENAMING` keys are written in. Discrete/ordinal override to pair + /// position indices with input-range category elements. + fn break_labels(&self, scale: &super::Scale) -> Vec<(f64, ArrayElement)> { if let Some(ParameterValue::Array(breaks)) = scale.properties.get("breaks") { return breaks .iter() - .filter_map(|b| b.to_f64().map(|v| (v, b.to_key_string()))) + .filter_map(|b| b.to_f64().map(|v| (v, b.clone()))) .collect(); } self.numeric_breaks(scale) .into_iter() - .map(|v| (v, format_number(v))) + .map(|v| (v, ArrayElement::Number(v))) .collect() } @@ -876,11 +874,11 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { /// 3. Resolves input_range from context (or merges with existing partial range) /// 4. Converts input_range values using transform (e.g., ISO strings → Date/DateTime/Time) /// 5. If breaks is a scalar Number, calculates break positions and stores as Array - /// 6. Applies label template + /// 6. (Labelling is not a resolution step — see `Scale::labelled_breaks`) /// 7. Resolves output range /// /// Note: Binned scale overrides this method to add Binned-specific logic - /// (implicit break handling, break/range alignment, terminal label suppression). + /// (implicit break handling, break/range alignment). fn resolve( &self, scale: &mut super::Scale, @@ -974,8 +972,8 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { // "resolved to no minors" (`minor_breaks => 0`, which a writer must honour by // drawing none) and the second is "not resolved", which leaves a writer free to // fall back on its own. Binned overrides `resolve` and so never reaches this: a - // binned axis's ticks are its bin edges, with nothing to subdivide. Runs before - // the label template because minor breaks carry no labels. + // binned axis's ticks are its bin edges, with nothing to subdivide. Minor breaks carry no + // labels, so labelling never looks at them. if self.supports_breaks() { if let (Some(ParameterValue::Array(majors)), Some(range)) = (scale.properties.get("breaks"), scale.input_range.as_deref()) @@ -1033,28 +1031,9 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { } } - // 6. Apply label template (RENAMING * => '...') - // Default is '{}' to ensure we control formatting instead of Vega-Lite - // For continuous scales, apply to breaks array - // For discrete scales, apply to input_range (domain values) - let template = &scale.label_template; - - let values_to_label = if self.supports_breaks() { - // Continuous: use breaks - match scale.properties.get("breaks") { - Some(ParameterValue::Array(breaks)) => Some(breaks.clone()), - _ => None, - } - } else { - // Discrete: use input_range - scale.input_range.clone() - }; - - if let Some(values) = values_to_label { - let generated_labels = - crate::format::apply_label_template(&values, template, &scale.label_mapping); - scale.label_mapping = Some(generated_labels); - } + // 6. Labelling needs no resolution step: `Scale::labelled_breaks` + // renders `label_spec` (RENAMING renames plus the wildcard template) + // against the current breaks on every read. // 7. Resolve output range (TO clause) self.resolve_output_range(scale, aesthetic)?; @@ -1190,19 +1169,20 @@ pub(super) fn categorical_numeric_domain(scale: &super::Scale) -> Option<(f64, f } } -/// Labelled breaks for categorical scales: pairs position indices with category names. +/// Break elements for categorical scales: pairs position indices with the +/// category elements themselves. /// -/// The label doubles as the lookup key into `label_mapping`, so it must be the -/// element's `to_key_string()` — the same form `RENAMING` writes its keys in. -/// Formatting a number through `to_json()` instead yields `"6.0"` against a key -/// of `"6"`, which silently drops every rename on a non-string domain. -pub(super) fn categorical_break_labels(scale: &super::Scale) -> Vec<(f64, String)> { +/// The element doubles as the rename-match key, so it must stay the typed +/// `ArrayElement` — the same form `RENAMING` keys are written in. Formatting +/// a number through `to_json()` instead would yield `"6.0"` against a key of +/// `"6"`, silently dropping every rename on a non-string domain. +pub(super) fn categorical_break_labels(scale: &super::Scale) -> Vec<(f64, ArrayElement)> { let Some(range) = scale.input_range.as_ref() else { return Vec::new(); }; let mut out = Vec::with_capacity(range.len()); for (i, elem) in range.iter().enumerate() { - out.push(((i + 1) as f64, elem.to_key_string())); + out.push(((i + 1) as f64, elem.clone())); } out } @@ -1400,7 +1380,7 @@ impl ScaleType { } /// Labelled breaks: `(numeric_position, display_label)` pairs. - pub fn break_labels(&self, scale: &super::Scale) -> Vec<(f64, String)> { + pub fn break_labels(&self, scale: &super::Scale) -> Vec<(f64, ArrayElement)> { self.0.break_labels(scale) } diff --git a/src/plot/scale/scale_type/ordinal.rs b/src/plot/scale/scale_type/ordinal.rs index b573b8379..9b923406e 100644 --- a/src/plot/scale/scale_type/ordinal.rs +++ b/src/plot/scale/scale_type/ordinal.rs @@ -72,7 +72,7 @@ impl ScaleTypeTrait for Ordinal { super::categorical_numeric_domain(scale) } - fn break_labels(&self, scale: &super::super::Scale) -> Vec<(f64, String)> { + fn break_labels(&self, scale: &super::super::Scale) -> Vec<(f64, crate::plot::ArrayElement)> { super::categorical_break_labels(scale) } diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index 8cb1d20d2..555f4342b 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -9,9 +9,61 @@ use super::super::types::{ArrayElement, ParameterValue, Parameters}; use super::scale_type::ScaleType; use super::transform::Transform; -/// Default label template - passes through values unchanged -fn default_label_template() -> String { - "{}".to_string() +/// Serde mirror for [`zygos::LabelSpec`], which is dependency-free and has no +/// serde impls of its own. Rename keys round-trip as [`ArrayElement`]s under +/// the same key-typing rule the parser uses (`'A'` → string, `25` → number, +/// `NULL` → null); the template round-trips as its source text. +mod label_spec_serde { + use super::super::super::types::ArrayElement; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use zygos::{LabelSpec, Value}; + + #[derive(Serialize, Deserialize, Default)] + struct LabelSpecSerde { + #[serde(default)] + renames: Vec<(ArrayElement, Option)>, + #[serde(default)] + template: Option, + } + + fn value_to_element(value: &Value) -> ArrayElement { + match value { + Value::String(s) => ArrayElement::String(s.to_string()), + Value::Number(n) => ArrayElement::Number(*n), + Value::Bool(b) => ArrayElement::Boolean(*b), + Value::Null => ArrayElement::Null, + // Only RENAMING literals reach a LabelSpec — string, number, + // boolean, null. Anything else stringifies. + other => ArrayElement::String(zygos::default_label(other, None).unwrap_or_default()), + } + } + + pub fn serialize(spec: &LabelSpec, serializer: S) -> Result { + let mirror = LabelSpecSerde { + renames: spec + .renames() + .map(|(k, r)| (value_to_element(k), r.map(str::to_string))) + .collect(), + template: spec.template().map(|t| t.source().to_string()), + }; + mirror.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let mirror = LabelSpecSerde::deserialize(deserializer)?; + let mut spec = LabelSpec::new(); + for (key, rename) in mirror.renames { + let value = crate::format::key_value(&key); + match rename { + Some(label) => spec.rename(value, label), + None => spec.suppress(value), + }; + } + if let Some(template) = mirror.template { + spec.set_template(&template); + } + Ok(spec) + } } /// One bin of a resolved binned scale: the edges bounding it and the text that @@ -69,17 +121,12 @@ pub struct Scale { /// Used to skip re-resolution of pre-resolved scales (e.g., Binned scales) #[serde(default)] pub resolved: bool, - /// Label mappings for custom axis/legend labels (RENAMING clause) - /// Maps raw data values to display labels. `None` value suppresses the label. - /// Example: `RENAMING 'A' => 'Alpha', 'internal' => NULL` - #[serde(default)] - pub label_mapping: Option>>, - /// Template for generating labels from scale values (e.g., "{} units") - /// Default is "{}" which passes through the value unchanged. - /// The `{}` placeholder is replaced with each value at resolution time. - /// Example: "{} units" -> {"0": "0 units", "25": "25 units", ...} - #[serde(default = "default_label_template")] - pub label_template: String, + /// Break labelling (RENAMING clause): value-keyed renames and + /// suppressions plus the wildcard template, rendered against the + /// current break set on every read. Example: + /// `RENAMING 'A' => 'Alpha', 'internal' => NULL, * => '{} units'` + #[serde(default, with = "label_spec_serde")] + pub label_spec: zygos::LabelSpec, /// Per-panel resolution for a positional scale whose facet dimension is /// `free`, indexed by panel index in the canonical order (see /// [`facet::panels`](crate::plot::facet::panels)). `None` when the scale is @@ -123,8 +170,7 @@ impl Scale { explicit_transform: false, properties: Parameters::new(), resolved: false, - label_mapping: None, - label_template: "{}".to_string(), + label_spec: zygos::LabelSpec::new(), panels: None, } } @@ -182,7 +228,7 @@ impl Scale { /// Labelled breaks: `(numeric_position, display_label)` pairs. /// - /// Delegates to the scale type, then applies `label_mapping` overrides. + /// Delegates to the scale type, then applies `label_spec` overrides. /// Suppressed labels (`None` in the mapping) become empty strings — the /// break is kept, but goes unlabelled. Use [`Self::visible_break_labels`] /// when a suppressed break should disappear entirely. @@ -210,25 +256,68 @@ impl Scale { /// Breaks paired with their resolved label, where `None` means the label was /// explicitly suppressed (as opposed to merely empty). The two public break /// accessors differ only in what they do with that `None`. + /// + /// Labels render from [`Self::label_spec`] against the *current* breaks on + /// every call, so re-breaking re-expands templates and re-derives shared + /// numeric precision. pub(crate) fn labelled_breaks(&self) -> Vec<(f64, Option)> { - let raw = match &self.scale_type { + self.labelled_break_elements() + .into_iter() + .map(|(pos, _, label)| (pos, label)) + .collect() + } + + /// The breaks behind [`Self::labelled_breaks`], keeping each break's + /// typed element alongside its position and label. + fn labelled_break_elements(&self) -> Vec<(f64, ArrayElement, Option)> { + let raw: Vec<(f64, ArrayElement)> = match &self.scale_type { Some(st) => st.break_labels(self), None => self .numeric_breaks() .into_iter() - .map(|v| (v, format!("{v}"))) + .map(|v| (v, ArrayElement::Number(v))) .collect(), }; - let mappings = self.label_mapping.as_ref(); - let mut out = Vec::with_capacity(raw.len()); - for (pos, label) in raw { - match mappings.and_then(|m| m.get(&label)) { - Some(Some(renamed)) => out.push((pos, Some(renamed.clone()))), - Some(None) => out.push((pos, None)), - None => out.push((pos, Some(label))), - } + let elements: Vec = raw.iter().map(|(_, e)| e.clone()).collect(); + let mut labels = crate::format::label_elements(&self.label_spec, &elements); + self.suppress_binned_terminals(&mut labels); + raw.into_iter() + .zip(labels) + .map(|((pos, elem), label)| (pos, elem, label)) + .collect() + } + + /// A binned scale under `oob => 'squish'` suppresses its two terminal + /// break labels: the outermost bins reach past the edge values, so those + /// edges are not real boundaries. Applied at label time, overriding even + /// an explicit rename of the edge. + fn suppress_binned_terminals(&self, labels: &mut [Option]) { + if self.scale_type.as_ref().map(|st| st.scale_type_kind()) + != Some(super::ScaleTypeKind::Binned) + { + return; + } + let squish = matches!( + self.properties.get("oob"), + Some(ParameterValue::String(s)) if s == super::scale_type::OOB_SQUISH + ); + if squish && labels.len() > 2 { + let last = labels.len() - 1; + labels[0] = None; + labels[last] = None; } - out + } + + /// The break labels as a lookup map keyed by each break's + /// `to_key_string()`: `Some(label)` renames, `None` suppresses. This is + /// the same map resolution used to fold eagerly into the scale, now + /// materialized on demand — keys are the breaks for continuous/binned + /// scales and the input range (domain values) otherwise. + pub fn label_mapping(&self) -> HashMap> { + self.labelled_break_elements() + .into_iter() + .map(|(_, elem, label)| (elem.to_key_string(), label)) + .collect() } /// The bins of a binned scale, each with its edges and display label. @@ -258,29 +347,29 @@ impl Scale { self.properties.get("closed"), Some(ParameterValue::String(s)) if s == "right" ); - let mapping = self.label_mapping.as_ref(); + let mut edge_labels = crate::format::label_elements(&self.label_spec, breaks); + self.suppress_binned_terminals(&mut edge_labels); let last = breaks.len() - 2; (0..=last) .map(|i| { let (lower, upper) = (breaks[i].clone(), breaks[i + 1].clone()); - let (lo_key, hi_key) = (lower.to_key_string(), upper.to_key_string()); - let suppressed = |key: &str| matches!(mapping.and_then(|m| m.get(key)), Some(None)); - let label_of = |key: &str| { - mapping - .and_then(|m| m.get(key)) - .cloned() - .flatten() - .unwrap_or_else(|| key.to_string()) + let suppressed = |i: usize| edge_labels[i].is_none(); + // A suppressed non-terminal edge (RENAMING => NULL) falls + // back to its key text, as it always has in bin ranges. + let label_of = |i: usize| { + edge_labels[i] + .clone() + .unwrap_or_else(|| breaks[i].to_key_string()) }; - let label = if i == 0 && suppressed(&lo_key) { + let label = if i == 0 && suppressed(i) { let symbol = if closed_right { "≤" } else { "<" }; - format!("{symbol} {}", label_of(&hi_key)) - } else if i == last && suppressed(&hi_key) { + format!("{symbol} {}", label_of(i + 1)) + } else if i == last && suppressed(i + 1) { let symbol = if closed_right { ">" } else { "≥" }; - format!("{symbol} {}", label_of(&lo_key)) + format!("{symbol} {}", label_of(i)) } else { - format!("{} – {}", label_of(&lo_key), label_of(&hi_key)) + format!("{} – {}", label_of(i), label_of(i + 1)) }; BinLabel { lower, @@ -565,17 +654,17 @@ mod tests { #[test] fn test_temporal_break_labels_honour_mapping() { - // `label_mapping` is keyed by `to_key_string()`, so a RENAMING override on - // a temporal break has to be found under the ISO key. + // A RENAMING override on a temporal break is found under the ISO + // string key, matching how the literal would be written in a query. let mut s = continuous_scale((1208.0, 1236.0), vec![]); s.properties.insert( "breaks".to_string(), ParameterValue::Array(vec![ArrayElement::Date(1208), ArrayElement::Date(1236)]), ); - let mut mapping = HashMap::new(); - mapping.insert("1973-04-23".to_string(), Some("Apr 23".to_string())); - mapping.insert("1973-05-21".to_string(), None); - s.label_mapping = Some(mapping); + s.label_spec + .rename(zygos::Value::String("1973-04-23".into()), "Apr 23"); + s.label_spec + .suppress(zygos::Value::String("1973-05-21".into())); assert_eq!( s.break_labels(), vec![(1208.0, "Apr 23".to_string()), (1236.0, String::new())] @@ -585,10 +674,9 @@ mod tests { #[test] fn test_break_labels_with_mapping() { let mut s = discrete_scale(&["A", "B", "C"]); - let mut mapping = HashMap::new(); - mapping.insert("A".to_string(), Some("Alpha".to_string())); - mapping.insert("C".to_string(), None); - s.label_mapping = Some(mapping); + s.label_spec + .rename(zygos::Value::String("A".into()), "Alpha"); + s.label_spec.suppress(zygos::Value::String("C".into())); assert_eq!( s.break_labels(), vec![ diff --git a/src/writer/hephaestus/facet.rs b/src/writer/hephaestus/facet.rs index f15aae467..b27bb08b9 100644 --- a/src/writer/hephaestus/facet.rs +++ b/src/writer/hephaestus/facet.rs @@ -203,7 +203,7 @@ fn facet_label(scale: Option<&Scale>, level: &FacetLevel) -> String { // NULL keys as the literal string "null", matching ggsql's RENAMING key for // a null level (`RENAMING null => 'The rest'`). if level.key.is_null { - return match scale.and_then(|s| s.label_mapping.as_ref()) { + return match scale.map(|s| s.label_mapping()) { Some(mapping) => match mapping.get("null") { Some(Some(label)) => label.clone(), Some(None) => String::new(), @@ -229,9 +229,7 @@ fn discrete_label(scale: Option<&Scale>, level: &FacetLevel) -> String { let Some(scale) = scale else { return level.key.text.clone(); }; - let Some(mapping) = scale.label_mapping.as_ref() else { - return level.key.text.clone(); - }; + let mapping = scale.label_mapping(); // `label_mapping` is keyed on the domain element's `to_key_string()`, which // can differ from the column's arrow-cast text (e.g. "5" vs "5.0"), so find // the matching domain element first. diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 0ee820f5b..1c9a84bd8 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -640,7 +640,6 @@ fn pad_degenerate(min: f64, max: f64) -> (f64, f64) { mod tests { use super::*; use crate::writer::hephaestus::channels::{column_to_channel, ChannelData}; - use std::collections::HashMap; /// A binned scale with the given edges, plus optional per-edge label overrides /// and properties, as ggsql's resolution would leave it. @@ -654,14 +653,6 @@ mod tests { for (key, value) in props { scale.properties.insert(key.to_string(), value.clone()); } - // ggsql always populates `label_mapping` for a resolved scale (the default - // `{}` template applied to every edge). - let mut mapping: HashMap> = HashMap::new(); - for edge in edges { - let key = ArrayElement::Number(*edge).to_key_string(); - mapping.insert(key.clone(), Some(key)); - } - scale.label_mapping = Some(mapping); scale } @@ -681,22 +672,22 @@ mod tests { fn binned_bins_honors_edge_renaming() { let mut scale = binned_scale(&[0.0, 10.0, 20.0], &[]); scale - .label_mapping - .as_mut() - .unwrap() - .insert("10".to_string(), Some("ten".to_string())); + .label_spec + .rename(zygos::Value::String("10".into()), "ten"); assert_eq!(labels(&binned_bins(&scale)), vec!["0 – ten", "ten – 20"]); } #[test] fn binned_bins_opens_suppressed_terminals() { // `oob => 'squish'` suppresses the terminal edge labels. - let mut scale = binned_scale(&[0.0, 10.0, 20.0], &[]); - let mapping = scale.label_mapping.as_mut().unwrap(); - mapping.insert("0".to_string(), None); - mapping.insert("20".to_string(), None); + let scale = binned_scale( + &[0.0, 10.0, 20.0], + &[("oob", ParameterValue::String("squish".to_string()))], + ); assert_eq!(labels(&binned_bins(&scale)), vec!["< 10", "≥ 10"]); + let mut scale = scale; + scale.properties.insert( "closed".to_string(), ParameterValue::String("right".to_string()), diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 9d190a1a0..d36044cd3 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -676,18 +676,15 @@ fn apply_breaks_to_encoding( if is_position_aesthetic(aesthetic) { // For position aesthetics (axes), filter out suppressed terminal breaks - let axis_values: Vec = if let Some(ref label_mapping) = scale.label_mapping { - breaks - .iter() - .filter(|e| { - let key = e.to_key_string(); - !matches!(label_mapping.get(&key), Some(None)) - }) - .map(|e| e.to_json()) - .collect() - } else { - all_values - }; + let label_mapping = scale.label_mapping(); + let axis_values: Vec = breaks + .iter() + .filter(|e| { + let key = e.to_key_string(); + !matches!(label_mapping.get(&key), Some(None)) + }) + .map(|e| e.to_json()) + .collect(); insert_axis_property(encoding, "values", json!(axis_values)); } else { @@ -720,9 +717,7 @@ fn apply_label_mapping_to_encoding( use crate::plot::scale::TransformKind; use crate::plot::ParameterValue; - let Some(ref label_mapping) = scale.label_mapping else { - return; - }; + let label_mapping = scale.label_mapping(); if label_mapping.is_empty() { return; } @@ -751,7 +746,7 @@ fn apply_label_mapping_to_encoding( let filtered_mapping = if is_symbol { build_symbol_legend_label_mapping(scale) } else { - label_mapping.clone() + label_mapping }; // Gradient legends use null for the first terminal's label @@ -1320,7 +1315,14 @@ mod tests { scale .properties .insert("breaks".to_string(), ParameterValue::Array(breaks)); - scale.label_mapping = Some(label_mapping); + let mut spec = zygos::LabelSpec::new(); + for (k, v) in label_mapping { + match v { + Some(label) => spec.rename(zygos::Value::String(k.into()), label), + None => spec.suppress(zygos::Value::String(k.into())), + }; + } + scale.label_spec = spec; let symbol_mapping = build_symbol_legend_label_mapping(&scale); // The resulting mapping uses VL's range-style label strings as keys @@ -1378,8 +1380,7 @@ mod tests { explicit_transform: false, properties: Parameters::new(), resolved: false, - label_mapping: None, - label_template: "{}".to_string(), + label_spec: zygos::LabelSpec::new(), panels: None, } } diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 0a19fb837..08ab93ad8 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -4309,8 +4309,7 @@ mod tests { explicit_transform: false, properties: Parameters::new(), resolved: false, - label_mapping: None, - label_template: "{}".to_string(), + label_spec: zygos::LabelSpec::new(), panels: None, }]; let context = RenderContext::new( @@ -4343,8 +4342,7 @@ mod tests { explicit_transform: false, properties: Parameters::new(), resolved: false, - label_mapping: None, - label_template: "{}".to_string(), + label_spec: zygos::LabelSpec::new(), panels: None, }]; let context = RenderContext::new( @@ -4373,8 +4371,7 @@ mod tests { explicit_transform: false, properties: Parameters::new(), resolved: false, - label_mapping: None, - label_template: "{}".to_string(), + label_spec: zygos::LabelSpec::new(), panels: None, }]; let context = RenderContext::new( diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 846210561..25bc032e9 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -493,8 +493,8 @@ fn apply_facet_aesthetic( let index_map = resolve_facet_ordering(scale); apply_facet_ordering(vl_spec, &aes_col, &index_map); - let label_mapping = scale.and_then(|s| s.label_mapping.as_ref()); - apply_facet_label_renaming(&mut facet_def, label_mapping, scale, &index_map); + let label_mapping = scale.map(|s| s.label_mapping()); + apply_facet_label_renaming(&mut facet_def, label_mapping.as_ref(), scale, &index_map); apply_binned_facet_reverse(&mut facet_def, scale); @@ -1254,10 +1254,24 @@ mod tests { "closed".to_string(), ParameterValue::String(closed.to_string()), ); - scale.label_mapping = Some(label_mapping); + scale.label_spec = label_spec_from(label_mapping); scale } + /// Build a `LabelSpec` from the map form these tests were written + /// against: keys as string literals (they still match numeric breaks by + /// the text rule), `Some` renames, `None` suppresses. + fn label_spec_from(mapping: HashMap>) -> zygos::LabelSpec { + let mut spec = zygos::LabelSpec::new(); + for (k, v) in mapping { + match v { + Some(label) => spec.rename(zygos::Value::String(k.into()), label), + None => spec.suppress(zygos::Value::String(k.into())), + }; + } + spec + } + fn rewrite_refs(val: &mut Value) { match val { Value::Object(obj) => { @@ -2155,7 +2169,7 @@ mod tests { labels.insert("25".to_string(), Some("Medium".to_string())); labels.insert("50".to_string(), Some("High".to_string())); labels.insert("75".to_string(), Some("Very High".to_string())); - scale.label_mapping = Some(labels); + scale.label_spec = label_spec_from(labels); spec.scales.push(scale); let df = df! { @@ -2673,7 +2687,7 @@ mod tests { label_mapping.insert("40".to_string(), Some("High".to_string())); label_mapping.insert("60".to_string(), Some("Very High".to_string())); - scale.label_mapping = Some(label_mapping); + scale.label_spec = label_spec_from(label_mapping); let expr = build_binned_facet_label_expr(Some(&scale)); // Should contain midpoint comparisons: @@ -2737,7 +2751,7 @@ mod tests { label_mapping.insert("50".to_string(), Some("High".to_string())); label_mapping.insert("100".to_string(), Some("Max".to_string())); - scale.label_mapping = Some(label_mapping); + scale.label_spec = label_spec_from(label_mapping); let expr = build_binned_facet_label_expr(Some(&scale)); // First bin with suppressed lower terminal → open format "< 50" or "< High" From 5d5cdd12f7757d5169334894caae2bbcdb40632c Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Sat, 7 Nov 2026 11:33:50 +0100 Subject: [PATCH 4/5] address review comments --- CHANGELOG.md | 4 + doc/syntax/scale/type/binned.qmd | 2 +- doc/syntax/scale/type/continuous.qmd | 2 +- src/execute/scale.rs | 11 ++ src/plot/AGENTS.md | 2 +- src/plot/scale/breaks.rs | 21 +-- src/plot/scale/colour.rs | 2 + src/plot/scale/scale_type/binned.rs | 15 ++- src/plot/scale/scale_type/mod.rs | 187 +++++++++++++++++++-------- src/plot/scale/types.rs | 1 + src/writer/hephaestus/scales.rs | 25 ++-- 11 files changed, 186 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6d705787..942b5cf00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,10 @@ ### Fixed +- A scale whose transform has a restricted domain (e.g. `log`, `sqrt`) now + infers its data range from in-domain values only, so non-positive data on a + log scale no longer drags the lower limit to the transform's minimum bound + and produces an unreadable axis. - Free facet dimensions now resolve their domain, breaks, labels, and minor breaks per panel in core (`Scale::panels`, indexed by the canonical panel order shared by both writers). The Vega-Lite writer no longer pins the diff --git a/doc/syntax/scale/type/binned.qmd b/doc/syntax/scale/type/binned.qmd index 2c82083b7..1b35ce686 100644 --- a/doc/syntax/scale/type/binned.qmd +++ b/doc/syntax/scale/type/binned.qmd @@ -82,7 +82,7 @@ Since breaks are not just presentational as it is with continuous scales the cho * `linear`: - `pretty => true`: Will use Wilkinsons Extended algorithm to attempt to find nice breaks in the given interval close to the number of breaks requested - `pretty => false`: Will produce the requested number of evenly spaced breaks within the scale range -* `log`/`log2`/`ln`: Uses the 1-2-5 pattern on every power of the base (e.g. 1, 2, 5, 10, 20, 50 for log10), adding 3 and 7 on narrow ranges when many breaks are requested and keeping only the powers on very wide ranges, where powers are skipped to stay near the requested number of breaks. Multipliers at or above the base are dropped, so `log2` breaks are powers of 2. The `pretty` setting has no effect +* `log`/`log2`/`ln`: Uses the 1-2-5 pattern on every power of the base (e.g. 1, 2, 5, 10, 20, 50 for log10), adding 3 and 7 on narrow ranges when many breaks are requested and keeping only the powers on very wide ranges, where powers are skipped to stay near the requested number of breaks. Multipliers above the base are dropped, so `log2` breaks are powers of 2. The `pretty` setting has no effect * `exp10`/`exp2`/`exp`: Like `linear`, on the exponents * `sqrt`: Like `linear` but the range is first converted to sqrt space and the breaks are then converted back * `square`: Like `linear` diff --git a/doc/syntax/scale/type/continuous.qmd b/doc/syntax/scale/type/continuous.qmd index db5e82ae0..19cc1fdb6 100644 --- a/doc/syntax/scale/type/continuous.qmd +++ b/doc/syntax/scale/type/continuous.qmd @@ -83,7 +83,7 @@ If not provided explicitly by the user the breaks for the scales will be calcula * `linear`: - `pretty => true`: Will use Wilkinsons Extended algorithm to attempt to find nice breaks in the given interval close to the number of breaks requested - `pretty => false`: Will produce the requested number of evenly spaced breaks within the scale range -* `log`/`log2`/`ln`: Uses the 1-2-5 pattern on every power of the base (e.g. 1, 2, 5, 10, 20, 50 for log10), adding 3 and 7 on narrow ranges when many breaks are requested and keeping only the powers on very wide ranges, where powers are skipped to stay near the requested number of breaks. Multipliers at or above the base are dropped, so `log2` breaks are powers of 2. The `pretty` setting has no effect +* `log`/`log2`/`ln`: Uses the 1-2-5 pattern on every power of the base (e.g. 1, 2, 5, 10, 20, 50 for log10), adding 3 and 7 on narrow ranges when many breaks are requested and keeping only the powers on very wide ranges, where powers are skipped to stay near the requested number of breaks. Multipliers above the base are dropped, so `log2` breaks are powers of 2. The `pretty` setting has no effect * `exp10`/`exp2`/`exp`: Like `linear`, on the exponents * `sqrt`: Like `linear` but the range is first converted to sqrt space and the breaks are then converted back * `square`: Like `linear` diff --git a/src/execute/scale.rs b/src/execute/scale.rs index 3f526b527..bf560e73a 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -1046,6 +1046,13 @@ pub fn resolve_scales(spec: &mut Plot, data_map: &mut HashMap // Build context from actual data columns let mut context = ScaleDataContext::from_columns(&column_refs, use_discrete_range); + // A user-requested transform with a restricted domain (e.g. log + // excludes non-positive values) can't map out-of-domain data, so + // infer the range from in-domain values only. + if let Some(transform) = &spec.scales[idx].transform { + context.restrict_to_domain(&column_refs, transform.allowed_domain()); + } + // For polar full-circle theta (pos2), use zero expansion if is_polar && polar_is_full_circle && aesthetic == "pos2" { context.default_expand = Some((0.0, 0.0)); @@ -1175,6 +1182,10 @@ fn resolve_panel_scales( let column_refs: Vec<&ArrayRef> = owned.iter().collect(); let mut context = ScaleDataContext::from_columns(&column_refs, st.uses_discrete_input_range()); + // Same in-domain restriction as the global resolution above. + if let Some(transform) = &spec.scales[idx].transform { + context.restrict_to_domain(&column_refs, transform.allowed_domain()); + } if polar_zero_expand && aesthetic == "pos2" { context.default_expand = Some((0.0, 0.0)); } diff --git a/src/plot/AGENTS.md b/src/plot/AGENTS.md index 13e68df22..a9d3e2b3b 100644 --- a/src/plot/AGENTS.md +++ b/src/plot/AGENTS.md @@ -55,7 +55,7 @@ plot/ `Scale` in `types.rs`, the kind enum + traits in `scale_type/`, the transformation logic in `transform/`. Other top-level files in `scale/`: -- `breaks.rs` — break/tick computation for axes. Every tick algorithm comes from the `zygos` crate (Wilkinson Extended, linear, integer, graticule, log/sqrt/symlog majors and minors, calendar-aware temporal breaks and their interval picker), re-exported here and called directly from each transform's `calculate_breaks`/`calculate_minor_breaks`. ggsql keeps only what zygos lacks: `minor_breaks_linear` (zygos's between-majors subdivision, continued past the outermost majors), `extent_minor_breaks` (runs zygos's extent-driven log/symlog minors and drops positions the majors hold), `filter_breaks_to_range` (typed `ArrayElement` breaks against a domain), and the temporal glue — `parse_temporal_interval` (`'2 months'` → zygos `TemporalInterval`) and `interval_breaks` (dispatch to zygos's per-type calendar breaks, keeping the terminal break binned scales need). +- `breaks.rs` — break/tick computation for axes. The algorithms live in the `zygos` crate; ggsql keeps only what zygos lacks: a few minor-break helpers, typed range filtering, and the temporal glue (`parse_temporal_interval`, `calendar_interval_breaks`). Details sit with the functions themselves. - `colour.rs` — colour aesthetic helpers; interpolation delegates to `zygos::color`. - `linetype.rs` — stroke-dash conversion over `zygos::patterns::linetype`. - `palettes.rs` — adapts `zygos::palettes` (the tables live there) to ggsql's string vocabulary (hex colors, hex linetype patterns), plus the `ArrayElement`-typed `lookup_palette`. Shape-name → SVG-path conversion is not in `scale/` at all: it's a Vega-Lite concern and lives in `writer/vegalite/shape.rs` over `zygos::patterns::shape`. diff --git a/src/plot/scale/breaks.rs b/src/plot/scale/breaks.rs index 997378a6b..6a0306613 100644 --- a/src/plot/scale/breaks.rs +++ b/src/plot/scale/breaks.rs @@ -157,7 +157,7 @@ fn parse_calendar_unit(s: &str) -> Option { /// bin of a binned scale closes over the data. Positions are in `unit`'s raw /// encoding: days for a date, microseconds for a datetime, nanoseconds since /// midnight for a time (which stops at midnight rather than wrapping). -pub fn interval_breaks( +pub fn calendar_interval_breaks( interval: TemporalInterval, min: f64, max: f64, @@ -361,7 +361,7 @@ mod tests { #[test] fn test_temporal_breaks_date_includes_terminal_break() { - // Test that interval_breaks includes a terminal break AFTER max_date + // Test that calendar_interval_breaks includes a terminal break AFTER max_date // to ensure all data falls within a bin. // // Data spanning Jan 15 to Mar 15 with monthly breaks should produce: @@ -372,10 +372,11 @@ mod tests { // Jan 15, 2024 = 19738 days since epoch // Mar 15, 2024 = 19798 days since epoch let monthly = parse_temporal_interval("month").unwrap(); - let breaks: Vec = interval_breaks(monthly, 19738.0, 19798.0, TemporalUnit::Date) - .into_iter() - .map(|d| d as i32) - .collect(); + let breaks: Vec = + calendar_interval_breaks(monthly, 19738.0, 19798.0, TemporalUnit::Date) + .into_iter() + .map(|d| d as i32) + .collect(); // Should include terminal break (Apr-01) after max_date (Mar-15) assert!( @@ -394,11 +395,11 @@ mod tests { } #[test] - fn test_interval_breaks_align_to_count_sized_grid() { + fn test_calendar_interval_breaks_align_to_count_sized_grid() { // "3 months" from Feb 15 steps along the quarter grid (Jan / Apr / Jul), // not from February. let quarterly = parse_temporal_interval("3 months").unwrap(); - let breaks: Vec = interval_breaks( + let breaks: Vec = calendar_interval_breaks( quarterly, days("2024-02-15") as f64, days("2024-06-30") as f64, @@ -414,11 +415,11 @@ mod tests { } #[test] - fn test_interval_breaks_time_stops_at_midnight() { + fn test_calendar_interval_breaks_time_stops_at_midnight() { // A terminal break would land past 24:00, so there is none. const HOUR_NS: f64 = 3_600_000_000_000.0; let hourly = parse_temporal_interval("hour").unwrap(); - let breaks = interval_breaks( + let breaks = calendar_interval_breaks( hourly, 22.0 * HOUR_NS, 23.0 * HOUR_NS + 1.0, diff --git a/src/plot/scale/colour.rs b/src/plot/scale/colour.rs index 9c35756d2..e67e3dc59 100644 --- a/src/plot/scale/colour.rs +++ b/src/plot/scale/colour.rs @@ -150,6 +150,8 @@ mod tests { assert!(is_color_aesthetic("fill")); assert!(is_color_aesthetic("stroke")); assert!(!is_color_aesthetic("x")); + assert!(!is_color_aesthetic("y")); + assert!(!is_color_aesthetic("size")); assert!(!is_color_aesthetic("shape")); } diff --git a/src/plot/scale/scale_type/binned.rs b/src/plot/scale/scale_type/binned.rs index 97b98f2cc..17e01b9b4 100644 --- a/src/plot/scale/scale_type/binned.rs +++ b/src/plot/scale/scale_type/binned.rs @@ -402,7 +402,9 @@ impl ScaleTypeTrait for Binned { Some(ParameterValue::String(interval_str)) => { // Temporal interval string like "2 months", "week" // Only valid for temporal transforms (Date, DateTime, Time) - use super::super::super::breaks::{interval_breaks, parse_temporal_interval}; + use super::super::super::breaks::{ + calendar_interval_breaks, parse_temporal_interval, + }; // Non-temporal transforms don't support interval strings. if let (Some(interval), Some(unit)) = ( @@ -418,10 +420,11 @@ impl ScaleTypeTrait for Binned { if let Some(range) = break_range { let min = range[0].to_f64().unwrap_or(0.0); let max = range[range.len() - 1].to_f64().unwrap_or(0.0); - let breaks: Vec = interval_breaks(interval, min, max, unit) - .into_iter() - .map(|v| resolved_transform.wrap_numeric(v)) - .collect(); + let breaks: Vec = + calendar_interval_breaks(interval, min, max, unit) + .into_iter() + .map(|v| resolved_transform.wrap_numeric(v)) + .collect(); if !breaks.is_empty() { // Only filter to input range when user provided explicit FROM clause @@ -471,7 +474,7 @@ impl ScaleTypeTrait for Binned { // Only expand for position aesthetics (x, y, etc.) // Material aesthetics (color, fill, size) don't get expansion let final_range = if super::is_position_aesthetic(aesthetic) { - expand_numeric_range(&terminal_range, mult, add, &resolved_transform) + expand_numeric_range(&terminal_range, mult, add, None, &resolved_transform) } else { terminal_range }; diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index aa13ea8db..75124083e 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -177,6 +177,21 @@ impl ScaleDataContext { } } + /// Recompute a continuous range over in-domain values only. + /// + /// No-op for discrete ranges or an unrestricted domain. When every value + /// is out of domain the range becomes `None`, and resolution proceeds as + /// if no data range were available. + pub fn restrict_to_domain(&mut self, columns: &[&ArrayRef], domain: (f64, f64)) { + if domain == (f64::NEG_INFINITY, f64::INFINITY) { + return; + } + if matches!(self.range, Some(InputRange::Continuous(_))) { + self.range = + compute_column_range_multi_in_domain(columns, domain).map(InputRange::Continuous); + } + } + /// Get the discrete range as unique values if available. pub fn discrete_range(&self) -> Option<&[ArrayElement]> { match &self.range { @@ -218,6 +233,42 @@ fn compute_column_range_multi(columns: &[&ArrayRef]) -> Option } } +/// Min/max across all columns over values inside `domain` only. +/// +/// Used when a scale's transform has a restricted domain (e.g. log excludes +/// non-positive values): an out-of-domain data extreme would otherwise become +/// an end of the inferred range that the transform cannot map, which domain +/// clipping then replaces with a meaningless bound (f64::MIN_POSITIVE for +/// log). NaN never satisfies the comparisons and is excluded too. +fn compute_column_range_multi_in_domain( + columns: &[&ArrayRef], + domain: (f64, f64), +) -> Option> { + use crate::array_util::cast_array; + + let (lo, hi) = domain; + let mut global_min: Option = None; + let mut global_max: Option = None; + + for column in columns { + if let Ok(cast) = cast_array(column, &DataType::Float64) { + if let Ok(f64_arr) = crate::array_util::as_f64(&cast) { + for value in f64_arr.iter().flatten() { + if value >= lo && value <= hi { + global_min = Some(global_min.map_or(value, |m| m.min(value))); + global_max = Some(global_max.map_or(value, |m| m.max(value))); + } + } + } + } + } + + match (global_min, global_max) { + (Some(min), Some(max)) => Some(vec![ArrayElement::Number(min), ArrayElement::Number(max)]), + _ => None, + } +} + /// Merge user-provided range with context-computed range. /// /// Replaces Null values in user_range with corresponding values from context_range. @@ -1690,17 +1741,6 @@ pub(super) fn parse_expand_value(expand: &ParameterValue) -> Option<(f64, f64)> /// /// Each end moves outward by `span × mult + add`, with the span and `add` /// measured in transformed units (see [`Transform::expand_range`]). -pub(crate) fn expand_numeric_range( - range: &[ArrayElement], - mult: f64, - add: f64, - transform: &Transform, -) -> Vec { - expand_numeric_range_selective(range, mult, add, None, transform) -} - -/// Apply expansion selectively to a numeric [min, max] range, in the -/// transform's space. /// /// If `original_user_range` is provided, only expand values that were originally Null /// in the user range. This preserves explicit user limits while expanding inferred values. @@ -1711,7 +1751,7 @@ pub(crate) fn expand_numeric_range( /// /// The span is that of the whole range in transformed space, so an inferred /// end is padded by the same amount whether or not the other end is explicit. -pub(crate) fn expand_numeric_range_selective( +pub(crate) fn expand_numeric_range( range: &[ArrayElement], mult: f64, add: f64, @@ -1824,7 +1864,7 @@ pub(crate) fn temporal_interval_breaks( let max = range.last()?.to_f64()?; // zygos steps the interval's calendar grid and keeps only what falls // inside the domain. (Binned scales can't use this — they need the - // terminal break past `max`, so they call `interval_breaks` directly.) + // terminal break past `max`, so they call `calendar_interval_breaks` directly.) let breaks: Vec = zygos::temporal_breaks_with_interval( Some(&zygos::InputRange::Continuous { min, max }), unit, @@ -2137,7 +2177,7 @@ pub(crate) fn resolve_common_steps( if !is_discrete_range && is_position && is_deduced { applied_expand = (mult, add); - let expanded = expand_numeric_range_selective( + let expanded = expand_numeric_range( &range, mult, add, @@ -2580,7 +2620,7 @@ mod tests { #[test] fn test_expand_numeric_range_mult_only() { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range(&range, 0.05, 0.0, &Transform::identity()); + let expanded = expand_numeric_range(&range, 0.05, 0.0, None, &Transform::identity()); // span = 100, expanded = [-5, 105] assert_eq!(expanded[0], ArrayElement::Number(-5.0)); assert_eq!(expanded[1], ArrayElement::Number(105.0)); @@ -2589,7 +2629,7 @@ mod tests { #[test] fn test_expand_numeric_range_with_add() { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range(&range, 0.05, 10.0, &Transform::identity()); + let expanded = expand_numeric_range(&range, 0.05, 10.0, None, &Transform::identity()); // span = 100, mult_expansion = 5, add_expansion = 10 // expanded = [0 - 5 - 10, 100 + 5 + 10] = [-15, 115] assert_eq!(expanded[0], ArrayElement::Number(-15.0)); @@ -2599,7 +2639,7 @@ mod tests { #[test] fn test_expand_numeric_range_zero_disables() { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range(&range, 0.0, 0.0, &Transform::identity()); + let expanded = expand_numeric_range(&range, 0.0, 0.0, None, &Transform::identity()); // No expansion assert_eq!(expanded[0], ArrayElement::Number(0.0)); assert_eq!(expanded[1], ArrayElement::Number(100.0)); @@ -2611,13 +2651,8 @@ mod tests { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; let user_range = vec![ArrayElement::Number(0.0), ArrayElement::Null]; - let expanded = expand_numeric_range_selective( - &range, - 0.05, - 0.0, - Some(&user_range), - &Transform::identity(), - ); + let expanded = + expand_numeric_range(&range, 0.05, 0.0, Some(&user_range), &Transform::identity()); // Min should stay at 0 (explicit), max should be expanded // span = 100, expansion = 5 @@ -2631,13 +2666,8 @@ mod tests { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; let user_range = vec![ArrayElement::Null, ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range_selective( - &range, - 0.05, - 0.0, - Some(&user_range), - &Transform::identity(), - ); + let expanded = + expand_numeric_range(&range, 0.05, 0.0, Some(&user_range), &Transform::identity()); // Min should be expanded, max should stay at 100 (explicit) // span = 100, expansion = 5 @@ -2651,13 +2681,8 @@ mod tests { let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; let user_range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range_selective( - &range, - 0.05, - 0.0, - Some(&user_range), - &Transform::identity(), - ); + let expanded = + expand_numeric_range(&range, 0.05, 0.0, Some(&user_range), &Transform::identity()); // Both should stay as-is (no expansion) assert_eq!(expanded[0], ArrayElement::Number(0.0)); @@ -2669,8 +2694,7 @@ mod tests { // No user range (all inferred) → expand both let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; - let expanded = - expand_numeric_range_selective(&range, 0.05, 0.0, None, &Transform::identity()); + let expanded = expand_numeric_range(&range, 0.05, 0.0, None, &Transform::identity()); // Both should be expanded assert_eq!(expanded[0], ArrayElement::Number(-5.0)); @@ -2682,7 +2706,7 @@ mod tests { // Singular range: all values are the same (e.g., count=12 for all bars) // A zero-width range pads as if it spanned one transformed unit. let range = vec![ArrayElement::Number(12.0), ArrayElement::Number(12.0)]; - let expanded = expand_numeric_range(&range, 0.05, 0.0, &Transform::identity()); + let expanded = expand_numeric_range(&range, 0.05, 0.0, None, &Transform::identity()); // span = 0 → unit span, expansion = 1 * 0.05 = 0.05 assert_eq!(expanded[0], ArrayElement::Number(12.0 - 0.05)); @@ -2693,7 +2717,7 @@ mod tests { fn test_expand_singular_range_zero() { // Singular range at zero (e.g., all counts are 0) let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(0.0)]; - let expanded = expand_numeric_range(&range, 0.05, 0.0, &Transform::identity()); + let expanded = expand_numeric_range(&range, 0.05, 0.0, None, &Transform::identity()); // span = 0 → unit span, expansion = 1 * 0.05 = 0.05 assert_eq!(expanded[0], ArrayElement::Number(-0.05)); @@ -2704,13 +2728,8 @@ mod tests { fn test_expand_descending_range_keeps_its_order() { let range = vec![ArrayElement::Number(100.0), ArrayElement::Number(0.0)]; let user_range = vec![ArrayElement::Number(100.0), ArrayElement::Null]; - let expanded = expand_numeric_range_selective( - &range, - 0.05, - 0.0, - Some(&user_range), - &Transform::identity(), - ); + let expanded = + expand_numeric_range(&range, 0.05, 0.0, Some(&user_range), &Transform::identity()); // The explicit first end stays; the inferred second end moves outward. assert_eq!(expanded[0], ArrayElement::Number(100.0)); assert_eq!(expanded[1], ArrayElement::Number(-5.0)); @@ -2726,7 +2745,7 @@ mod tests { // the lower bound stays positive rather than collapsing to the domain // minimum. let range = vec![ArrayElement::Number(1.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range(&range, 0.05, 0.0, &Transform::log()); + let expanded = expand_numeric_range(&range, 0.05, 0.0, None, &Transform::log()); assert!((as_f64(&expanded[0]) - 10f64.powf(-0.1)).abs() < 1e-12); assert!((as_f64(&expanded[1]) - 10f64.powf(2.1)).abs() < 1e-9); } @@ -2734,7 +2753,7 @@ mod tests { #[test] fn test_expand_log10_add_is_in_decades() { let range = vec![ArrayElement::Number(1.0), ArrayElement::Number(100.0)]; - let expanded = expand_numeric_range(&range, 0.0, 1.0, &Transform::log()); + let expanded = expand_numeric_range(&range, 0.0, 1.0, None, &Transform::log()); assert!((as_f64(&expanded[0]) - 0.1).abs() < 1e-12); assert!((as_f64(&expanded[1]) - 1000.0).abs() < 1e-9); } @@ -2744,7 +2763,7 @@ mod tests { let range = vec![ArrayElement::Number(1.0), ArrayElement::Number(100.0)]; let user_range = vec![ArrayElement::Number(1.0), ArrayElement::Null]; let expanded = - expand_numeric_range_selective(&range, 0.05, 0.0, Some(&user_range), &Transform::log()); + expand_numeric_range(&range, 0.05, 0.0, Some(&user_range), &Transform::log()); assert_eq!(expanded[0], ArrayElement::Number(1.0)); assert!((as_f64(&expanded[1]) - 10f64.powf(2.1)).abs() < 1e-9); } @@ -2756,7 +2775,7 @@ mod tests { // unpadded; the upper end pads in transformed space as usual. let range = vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]; let sqrt = Transform::sqrt(); - let expanded = expand_numeric_range(&range, 0.05, 0.0, &sqrt); + let expanded = expand_numeric_range(&range, 0.05, 0.0, None, &sqrt); assert_eq!(expanded[0], ArrayElement::Number(0.0)); assert!((as_f64(&expanded[1]) - 110.25).abs() < 1e-9); } @@ -4147,6 +4166,66 @@ mod tests { ); } + // ========================================================================= + // Domain-restricted range computation Tests + // ========================================================================= + + #[test] + fn test_restrict_to_domain_log() { + use arrow::array::Float64Array; + use std::sync::Arc; + + let array: ArrayRef = Arc::new(Float64Array::from(vec![ + Some(-100.0), + Some(0.0), + Some(10.0), + Some(100.0), + None, + ])); + let columns: Vec<&ArrayRef> = vec![&array]; + + let mut context = ScaleDataContext::from_columns(&columns, false); + assert_eq!( + context.continuous_range().unwrap(), + &[ArrayElement::Number(-100.0), ArrayElement::Number(100.0)] + ); + + context.restrict_to_domain(&columns, Transform::log().allowed_domain()); + assert_eq!( + context.continuous_range().unwrap(), + &[ArrayElement::Number(10.0), ArrayElement::Number(100.0)] + ); + } + + #[test] + fn test_restrict_to_domain_all_out_of_domain() { + use arrow::array::Float64Array; + use std::sync::Arc; + + let array: ArrayRef = Arc::new(Float64Array::from(vec![Some(-5.0), Some(-1.0)])); + let columns: Vec<&ArrayRef> = vec![&array]; + + let mut context = ScaleDataContext::from_columns(&columns, false); + context.restrict_to_domain(&columns, Transform::log().allowed_domain()); + assert!(context.continuous_range().is_none()); + } + + #[test] + fn test_restrict_to_domain_unrestricted_is_noop() { + use arrow::array::Float64Array; + use std::sync::Arc; + + let array: ArrayRef = Arc::new(Float64Array::from(vec![Some(-5.0), Some(1.0)])); + let columns: Vec<&ArrayRef> = vec![&array]; + + let mut context = ScaleDataContext::from_columns(&columns, false); + context.restrict_to_domain(&columns, Transform::identity().allowed_domain()); + assert_eq!( + context.continuous_range().unwrap(), + &[ArrayElement::Number(-5.0), ArrayElement::Number(1.0)] + ); + } + // ========================================================================= // clip_to_transform_domain Tests // ========================================================================= @@ -4214,7 +4293,7 @@ mod tests { // each end moves half a decade and stays positive; clipping is a no-op. let range = vec![ArrayElement::Number(0.001), ArrayElement::Number(0.01)]; let transform = Transform::from_kind(TransformKind::Log10); - let expanded = expand_numeric_range(&range, 0.5, 0.0, &transform); + let expanded = expand_numeric_range(&range, 0.5, 0.0, None, &transform); let lo = expanded[0].to_f64().unwrap(); let hi = expanded[1].to_f64().unwrap(); assert!((lo - 10f64.powf(-3.5)).abs() < 1e-15, "lower end: {lo}"); diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index 555f4342b..5d7c39768 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -425,6 +425,7 @@ impl Scale { &[ArrayElement::Number(min), ArrayElement::Number(max)], mult, add, + None, transform, ); let clipped = super::scale_type::clip_to_transform_domain(&expanded, transform); diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 1c9a84bd8..6c029dab4 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -578,21 +578,20 @@ fn map_transform(kind: GTransform) -> Option { // delegation; convert by name until hephaestus's published scale types // are the zygos ones (then this becomes `kind.zygos_kind()`). let zygos_kind = kind.zygos_kind()?; + use zygos::transform::TransformKind as Z; Some(match zygos_kind { // None — hephaestus defaults to identity - zygos::transform::TransformKind::Identity => return None, - zygos::transform::TransformKind::Log10 => HTransform::Log10, - zygos::transform::TransformKind::Log2 => HTransform::Log2, - zygos::transform::TransformKind::Log => HTransform::Log, - zygos::transform::TransformKind::Sqrt => HTransform::Sqrt, - zygos::transform::TransformKind::Square => HTransform::Square, - zygos::transform::TransformKind::Exp10 => HTransform::Exp10, - zygos::transform::TransformKind::Exp2 => HTransform::Exp2, - zygos::transform::TransformKind::Exp => HTransform::Exp, - zygos::transform::TransformKind::Asinh => HTransform::Asinh, - zygos::transform::TransformKind::PseudoLog - | zygos::transform::TransformKind::PseudoLog2 - | zygos::transform::TransformKind::PseudoLog10 => HTransform::PseudoLog, + Z::Identity => return None, + Z::Log10 => HTransform::Log10, + Z::Log2 => HTransform::Log2, + Z::Log => HTransform::Log, + Z::Sqrt => HTransform::Sqrt, + Z::Square => HTransform::Square, + Z::Exp10 => HTransform::Exp10, + Z::Exp2 => HTransform::Exp2, + Z::Exp => HTransform::Exp, + Z::Asinh => HTransform::Asinh, + Z::PseudoLog | Z::PseudoLog2 | Z::PseudoLog10 => HTransform::PseudoLog, }) } From 555258a7b02ec6d3dedde52b58cefcf8456d5f36 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Sat, 7 Nov 2026 12:09:39 +0100 Subject: [PATCH 5/5] prune unit test to remove tests belonging in zygos --- src/plot/scale/colour.rs | 85 ---------------- src/plot/scale/transform/asinh.rs | 74 -------------- src/plot/scale/transform/date.rs | 85 ---------------- src/plot/scale/transform/datetime.rs | 42 -------- src/plot/scale/transform/exp.rs | 121 ----------------------- src/plot/scale/transform/geographic.rs | 25 ----- src/plot/scale/transform/identity.rs | 43 -------- src/plot/scale/transform/integer.rs | 98 ------------------ src/plot/scale/transform/log.rs | 123 ----------------------- src/plot/scale/transform/pseudo_log.rs | 132 ------------------------- src/plot/scale/transform/sqrt.rs | 46 --------- src/plot/scale/transform/square.rs | 99 ------------------- src/plot/scale/transform/time.rs | 42 -------- 13 files changed, 1015 deletions(-) diff --git a/src/plot/scale/colour.rs b/src/plot/scale/colour.rs index e67e3dc59..be4e8ef18 100644 --- a/src/plot/scale/colour.rs +++ b/src/plot/scale/colour.rs @@ -167,38 +167,6 @@ mod tests { assert_eq!(colors[4], "#0000ff"); } - #[test] - fn test_interpolate_colors_srgb() { - let colors = interpolate_colors(&["white", "black"], 3, ColorSpace::Srgb).unwrap(); - assert_eq!(colors.len(), 3); - assert_eq!(colors[0], "#ffffff"); - assert_eq!(colors[2], "#000000"); - } - - #[test] - fn test_interpolate_colors_single_input() { - let colors = interpolate_colors(&["red"], 3, ColorSpace::Oklab).unwrap(); - assert_eq!(colors, vec!["#ff0000", "#ff0000", "#ff0000"]); - } - - #[test] - fn test_interpolate_colors_count_zero() { - let colors = interpolate_colors(&["red", "blue"], 0, ColorSpace::Oklab).unwrap(); - assert!(colors.is_empty()); - } - - #[test] - fn test_interpolate_colors_count_one() { - let colors = interpolate_colors(&["red", "blue"], 1, ColorSpace::Oklab).unwrap(); - assert_eq!(colors, vec!["#ff0000"]); - } - - #[test] - fn test_interpolate_colors_count_two() { - let colors = interpolate_colors(&["red", "blue"], 2, ColorSpace::Oklab).unwrap(); - assert_eq!(colors, vec!["#ff0000", "#0000ff"]); - } - #[test] fn test_interpolate_colors_empty_input() { let result = interpolate_colors(&[], 5, ColorSpace::Oklab); @@ -213,14 +181,6 @@ mod tests { assert!(result.unwrap_err().contains("Invalid color")); } - #[test] - fn test_interpolate_colors_multi_stop() { - let colors = interpolate_colors(&["red", "white", "blue"], 5, ColorSpace::Oklab).unwrap(); - assert_eq!(colors[0], "#ff0000"); - assert_eq!(colors[2], "#ffffff"); - assert_eq!(colors[4], "#0000ff"); - } - #[test] fn test_gradient_convenience() { let colors = gradient("red", "blue", 5, ColorSpace::Oklab).unwrap(); @@ -229,53 +189,8 @@ mod tests { assert_eq!(colors[4], "#0000ff"); } - #[test] - fn test_oklab_vs_srgb_red_cyan() { - // Red to cyan: Oklab should produce lighter intermediates, - // while sRGB produces darker/muddier intermediates - let oklab = interpolate_colors(&["red", "cyan"], 5, ColorSpace::Oklab).unwrap(); - let srgb = interpolate_colors(&["red", "cyan"], 5, ColorSpace::Srgb).unwrap(); - - assert_eq!(oklab[0], "#ff0000"); - assert_eq!(oklab[4], "#00ffff"); - assert_eq!(srgb[0], "#ff0000"); - assert_eq!(srgb[4], "#00ffff"); - - assert_ne!(oklab[2], srgb[2]); - } - #[test] fn test_color_space_default() { assert_eq!(ColorSpace::default(), ColorSpace::Oklab); } - - #[test] - fn test_interpolate_preserves_endpoints() { - let test_cases = vec![("black", "white"), ("red", "green"), ("#123456", "#abcdef")]; - - for (start, end) in test_cases { - let colors = interpolate_colors(&[start, end], 10, ColorSpace::Oklab).unwrap(); - let start_hex = color_to_hex(start).unwrap(); - let end_hex = color_to_hex(end).unwrap(); - assert_eq!( - colors[0], start_hex, - "Start mismatch for {}->{}", - start, end - ); - assert_eq!(colors[9], end_hex, "End mismatch for {}->{}", start, end); - } - } - - #[test] - fn test_interpolate_many_stops() { - let colors = interpolate_colors( - &["red", "orange", "yellow", "green", "blue", "violet"], - 11, - ColorSpace::Oklab, - ) - .unwrap(); - assert_eq!(colors.len(), 11); - assert_eq!(colors[0], "#ff0000"); - assert_eq!(colors[10], "#ee82ee"); - } } diff --git a/src/plot/scale/transform/asinh.rs b/src/plot/scale/transform/asinh.rs index 9e7e08e61..f29a31502 100644 --- a/src/plot/scale/transform/asinh.rs +++ b/src/plot/scale/transform/asinh.rs @@ -72,54 +72,6 @@ impl std::fmt::Display for Asinh { mod tests { use super::*; - #[test] - fn test_asinh_domain() { - let t = Asinh; - let (min, max) = t.allowed_domain(); - assert!(min.is_infinite() && min.is_sign_negative()); - assert!(max.is_infinite() && max.is_sign_positive()); - } - - #[test] - fn test_asinh_transform() { - let t = Asinh; - // asinh(0) = 0 - assert!((t.transform(0.0) - 0.0).abs() < 1e-10); - // asinh is odd function - assert!((t.transform(-1.0) + t.transform(1.0)).abs() < 1e-10); - // For large values, asinh(x) ≈ ln(2x) - let large: f64 = 1000.0; - let expected = (2.0 * large).ln(); - assert!((t.transform(large) - expected).abs() < 0.01); - } - - #[test] - fn test_asinh_inverse() { - let t = Asinh; - // sinh(0) = 0 - assert!((t.inverse(0.0) - 0.0).abs() < 1e-10); - // sinh is odd function - assert!((t.inverse(-1.0) + t.inverse(1.0)).abs() < 1e-10); - } - - #[test] - fn test_asinh_roundtrip() { - let t = Asinh; - for &val in &[-100.0, -10.0, -1.0, 0.0, 1.0, 10.0, 100.0] { - let transformed = t.transform(val); - let back = t.inverse(transformed); - if val == 0.0 { - assert!((back - val).abs() < 1e-10, "Roundtrip failed for {}", val); - } else { - assert!( - (back - val).abs() / val.abs() < 1e-10, - "Roundtrip failed for {}", - val - ); - } - } - } - #[test] fn test_asinh_breaks_symmetric() { let t = Asinh; @@ -133,30 +85,4 @@ mod tests { expected.extend(positive); assert_eq!(breaks, expected); } - - #[test] - fn test_asinh_works_with_zero() { - let t = Asinh; - // Unlike log, asinh works with zero - let breaks = t.calculate_breaks(0.0, 100.0, 5, true); - assert_eq!( - breaks, - vec![0.0, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0] - ); - } - - #[test] - fn test_asinh_minor_breaks() { - let t = Asinh; - let minors = t.calculate_minor_breaks(&[-10.0, 0.0, 10.0], 8, Some((-20.0, 20.0))); - // Whole-number multiples on each branch; the majors' own positions - // are left to the majors. - assert!(minors.contains(&-20.0) && minors.contains(&20.0)); - assert!(minors.contains(&2.0) && minors.contains(&-9.0)); - assert!(!minors.contains(&10.0) && !minors.contains(&-10.0)); - assert!(minors.windows(2).all(|w| w[0] < w[1])); - assert!(t - .calculate_minor_breaks(&[-10.0, 0.0, 10.0], 0, None) - .is_empty()); - } } diff --git a/src/plot/scale/transform/date.rs b/src/plot/scale/transform/date.rs index 6412ee972..2b5be3693 100644 --- a/src/plot/scale/transform/date.rs +++ b/src/plot/scale/transform/date.rs @@ -128,75 +128,6 @@ mod tests { assert_eq!(t.inverse(-50.0), -50.0); } - #[test] - fn test_date_breaks_year_span() { - let t = Date; - // ~5 years span (in days) - let min = 0.0; // 1970-01-01 - let max = 365.0 * 5.0; // ~1975 - let breaks = t.calculate_breaks(min, max, 5, true); - assert!(!breaks.is_empty()); - // All breaks should be within range - for &b in &breaks { - assert!(b >= min && b <= max); - } - } - - #[test] - fn test_date_breaks_month_span() { - let t = Date; - // ~6 months span - let min = 0.0; - let max = 180.0; - let breaks = t.calculate_breaks(min, max, 6, true); - assert!(!breaks.is_empty()); - } - - #[test] - fn test_date_breaks_week_span() { - let t = Date; - // ~4 weeks span - let min = 0.0; - let max = 28.0; - let breaks = t.calculate_breaks(min, max, 5, true); - assert!(!breaks.is_empty()); - } - - #[test] - fn test_date_breaks_day_span() { - let t = Date; - // ~7 days span - let min = 0.0; - let max = 7.0; - let breaks = t.calculate_breaks(min, max, 7, true); - assert!(!breaks.is_empty()); - } - - #[test] - fn test_date_breaks_linear() { - let t = Date; - let breaks = t.calculate_breaks(0.0, 100.0, 5, false); - // Should have integer day breaks - assert!(!breaks.is_empty()); - assert!(breaks[0] <= 0.0); - assert!(*breaks.last().unwrap() >= 100.0); - // All breaks should be integers (whole days) - for b in &breaks { - assert_eq!(*b, b.round(), "Break {} should be a whole day", b); - } - // Breaks should be evenly spaced - if breaks.len() >= 2 { - let step = breaks[1] - breaks[0]; - for i in 1..breaks.len() { - let gap = breaks[i] - breaks[i - 1]; - assert!( - (gap - step).abs() < 0.01, - "Date breaks should be evenly spaced" - ); - } - } - } - #[test] fn test_date_breaks_follow_the_calendar() { let t = Date; @@ -216,20 +147,4 @@ mod tests { vec![4.0, 11.0, 18.0, 25.0] ); } - - #[test] - fn test_date_breaks_airquality_count() { - let t = Date; - // ~150 days (May-September), n=7 - let min = 0.0; - let max = 150.0; - let breaks = t.calculate_breaks(min, max, 7, true); - - // Should have roughly 5-10 breaks, not 22 - assert!( - breaks.len() >= 3 && breaks.len() <= 12, - "Expected 3-12 breaks for 150 days, n=7, got {}", - breaks.len() - ); - } } diff --git a/src/plot/scale/transform/datetime.rs b/src/plot/scale/transform/datetime.rs index a61b74b54..adb7ea38c 100644 --- a/src/plot/scale/transform/datetime.rs +++ b/src/plot/scale/transform/datetime.rs @@ -134,39 +134,6 @@ mod tests { assert_eq!(t.inverse(100.0), 100.0); } - #[test] - fn test_datetime_breaks_year_span() { - let t = DateTime; - // ~5 years span (in microseconds) - let min = 0.0; - let max = 5.0 * 365.25 * MICROS_PER_DAY; - let breaks = t.calculate_breaks(min, max, 5, true); - assert!(!breaks.is_empty()); - for &b in &breaks { - assert!(b >= min && b <= max); - } - } - - #[test] - fn test_datetime_breaks_hour_span() { - let t = DateTime; - // ~24 hours span - let min = 0.0; - let max = 24.0 * MICROS_PER_HOUR; - let breaks = t.calculate_breaks(min, max, 8, true); - assert!(!breaks.is_empty()); - } - - #[test] - fn test_datetime_breaks_minute_span() { - let t = DateTime; - // ~60 minutes span - let min = 0.0; - let max = 60.0 * MICROS_PER_MINUTE; - let breaks = t.calculate_breaks(min, max, 6, true); - assert!(!breaks.is_empty()); - } - #[test] fn test_datetime_breaks_follow_the_clock() { let t = DateTime; @@ -182,13 +149,4 @@ mod tests { let minutes: Vec = breaks.iter().map(|b| b / MICROS_PER_MINUTE).collect(); assert_eq!(minutes, vec![0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0]); } - - #[test] - fn test_datetime_breaks_linear() { - let t = DateTime; - let breaks = t.calculate_breaks(0.0, 1_000_000.0, 5, false); - assert_eq!(breaks.len(), 5); - assert_eq!(breaks[0], 0.0); - assert_eq!(breaks[4], 1_000_000.0); - } } diff --git a/src/plot/scale/transform/exp.rs b/src/plot/scale/transform/exp.rs index ba4302821..d77f19bb2 100644 --- a/src/plot/scale/transform/exp.rs +++ b/src/plot/scale/transform/exp.rs @@ -146,90 +146,6 @@ mod tests { ] } - #[test] - fn test_all_bases_domain() { - for (t, _, name) in get_transforms() { - let (min, max) = t.allowed_domain(); - assert!( - min.is_infinite() && min < 0.0, - "{}: domain min should be -∞", - name - ); - assert!( - max.is_infinite() && max > 0.0, - "{}: domain max should be +∞", - name - ); - } - } - - #[test] - fn test_all_bases_transform_and_inverse() { - // Test cases: (transform, input, expected_transform) - let test_cases = vec![ - // Exp10: 10^0=1, 10^1=10, 10^2=100, 10^-1=0.1 - ( - Exp::base10(), - vec![(0.0, 1.0), (1.0, 10.0), (2.0, 100.0), (-1.0, 0.1)], - ), - // Exp2: 2^0=1, 2^1=2, 2^2=4, 2^-1=0.5 - ( - Exp::base2(), - vec![(0.0, 1.0), (1.0, 2.0), (2.0, 4.0), (-1.0, 0.5)], - ), - // Natural: e^0=1, e^1=e, e^2=e² - (Exp::natural(), vec![(0.0, 1.0), (1.0, E), (2.0, E * E)]), - ]; - - for (t, cases) in test_cases { - for (input, expected) in cases { - assert!( - (t.transform(input) - expected).abs() < 1e-10, - "{}: transform({}) should be {}, got {}", - t.name(), - input, - expected, - t.transform(input) - ); - // Test inverse too - assert!( - (t.inverse(expected) - input).abs() < 1e-9, - "{}: inverse({}) should be {}, got {}", - t.name(), - expected, - input, - t.inverse(expected) - ); - } - } - } - - #[test] - fn test_all_bases_roundtrip() { - let test_values = [-2.0, -1.0, 0.0, 1.0, 2.0, 3.0]; - for (t, _, name) in get_transforms() { - for &val in &test_values { - let transformed = t.transform(val); - let back = t.inverse(transformed); - if val == 0.0 { - assert!( - (back - val).abs() < 1e-10, - "{}: Roundtrip failed for {}", - name, - val - ); - } else { - assert!( - (back - val).abs() / val.abs() < 1e-10, - "{}: Roundtrip failed for {}", - name, - val - ); - } - } - } - } - #[test] fn test_all_bases_kind_and_name() { for (t, expected_kind, expected_name) in get_transforms() { @@ -243,30 +159,6 @@ mod tests { } } - #[test] - fn test_all_bases_is_inverse_of_log() { - use super::super::Log; - - let pairs = vec![ - (Exp::base10(), Log::base10()), - (Exp::base2(), Log::base2()), - (Exp::natural(), Log::natural()), - ]; - - let test_values = [-1.0, 0.0, 1.0, 2.0]; - for (exp, log) in pairs { - for &val in &test_values { - assert!( - (exp.transform(val) - log.inverse(val)).abs() < 1e-10, - "{}::transform != {}::inverse for {}", - exp.name(), - log.name(), - val - ); - } - } - } - #[test] fn test_all_bases_display() { assert_eq!(format!("{}", Exp::base10()), "exp10"); @@ -283,19 +175,6 @@ mod tests { assert!((Exp::natural().base() - E).abs() < 1e-10); } - #[test] - fn test_expand_range_pads_in_own_transformed_units() { - // Whatever zygos member carries the math, each end moves outward by - // `mult × span + add` measured in this transform's own output. - for t in [Exp::base10(), Exp::base2(), Exp::natural(), Exp::new(5.0)] { - let (min, max) = (1.5, 2.0); - let (lo, hi) = t.expand_range(min, max, (0.05, 0.1), (0.5, 0.25)); - let span = t.transform(max) - t.transform(min); - assert!((t.transform(min) - t.transform(lo) - (0.05 * span + 0.5)).abs() < 1e-9); - assert!((t.transform(hi) - t.transform(max) - (0.1 * span + 0.25)).abs() < 1e-9); - } - } - #[test] fn test_custom_base() { let t = Exp::new(5.0); diff --git a/src/plot/scale/transform/geographic.rs b/src/plot/scale/transform/geographic.rs index 7d29a7c52..003d2ebef 100644 --- a/src/plot/scale/transform/geographic.rs +++ b/src/plot/scale/transform/geographic.rs @@ -60,29 +60,4 @@ mod tests { // Should pick nice degree intervals (multiples of 30° or 45°) assert!(breaks.iter().all(|b| b % 30.0 == 0.0 || b % 45.0 == 0.0)); } - - #[test] - fn test_breaks_small_extent() { - let t = Geographic; - let breaks = t.calculate_breaks(5.0, 15.0, 5, true); - assert!(!breaks.is_empty()); - for &b in &breaks { - assert!(b > 5.0 && b < 15.0); - } - } - - #[test] - fn test_breaks_respects_count() { - let t = Geographic; - let breaks_few = t.calculate_breaks(-90.0, 90.0, 3, true); - let breaks_many = t.calculate_breaks(-90.0, 90.0, 10, true); - assert!(breaks_many.len() >= breaks_few.len()); - } - - #[test] - fn test_identity_transform() { - let t = Geographic; - assert_eq!(t.transform(42.0), 42.0); - assert_eq!(t.inverse(42.0), 42.0); - } } diff --git a/src/plot/scale/transform/identity.rs b/src/plot/scale/transform/identity.rs index 3fbc50c41..ab8960e17 100644 --- a/src/plot/scale/transform/identity.rs +++ b/src/plot/scale/transform/identity.rs @@ -56,40 +56,6 @@ impl std::fmt::Display for Identity { mod tests { use super::*; - #[test] - fn test_identity_domain() { - let t = Identity; - let (min, max) = t.allowed_domain(); - assert!(min.is_infinite() && min.is_sign_negative()); - assert!(max.is_infinite() && max.is_sign_positive()); - } - - #[test] - fn test_identity_transform() { - let t = Identity; - assert_eq!(t.transform(1.0), 1.0); - assert_eq!(t.transform(-5.0), -5.0); - assert_eq!(t.transform(0.0), 0.0); - assert_eq!(t.transform(100.0), 100.0); - } - - #[test] - fn test_identity_inverse() { - let t = Identity; - assert_eq!(t.inverse(1.0), 1.0); - assert_eq!(t.inverse(-5.0), -5.0); - } - - #[test] - fn test_identity_roundtrip() { - let t = Identity; - for &val in &[0.0, 1.0, -1.0, 100.0, -100.0, 0.001] { - let transformed = t.transform(val); - let back = t.inverse(transformed); - assert!((back - val).abs() < 1e-10, "Roundtrip failed for {}", val); - } - } - #[test] fn test_identity_breaks_pretty() { let t = Identity; @@ -97,15 +63,6 @@ mod tests { assert_eq!(breaks, vec![0.0, 20.0, 40.0, 60.0, 80.0, 100.0]); } - #[test] - fn test_identity_breaks_linear() { - let t = Identity; - let breaks = t.calculate_breaks(0.0, 100.0, 5, false); - // linear_breaks gives exact coverage from min to max - // step = 25, so: 0, 25, 50, 75, 100 - assert_eq!(breaks, vec![0.0, 25.0, 50.0, 75.0, 100.0]); - } - #[test] fn test_identity_minor_breaks() { let t = Identity; diff --git a/src/plot/scale/transform/integer.rs b/src/plot/scale/transform/integer.rs index e84ab0e06..e6580e2a0 100644 --- a/src/plot/scale/transform/integer.rs +++ b/src/plot/scale/transform/integer.rs @@ -76,40 +76,6 @@ impl std::fmt::Display for Integer { mod tests { use super::*; - #[test] - fn test_integer_domain() { - let t = Integer; - let (min, max) = t.allowed_domain(); - assert!(min.is_infinite() && min.is_sign_negative()); - assert!(max.is_infinite() && max.is_sign_positive()); - } - - #[test] - fn test_integer_transform() { - let t = Integer; - assert_eq!(t.transform(1.0), 1.0); - assert_eq!(t.transform(-5.0), -5.0); - assert_eq!(t.transform(0.0), 0.0); - assert_eq!(t.transform(100.5), 100.5); - } - - #[test] - fn test_integer_inverse() { - let t = Integer; - assert_eq!(t.inverse(1.0), 1.0); - assert_eq!(t.inverse(-5.0), -5.0); - } - - #[test] - fn test_integer_roundtrip() { - let t = Integer; - for &val in &[0.0, 1.0, -1.0, 100.0, -100.0, 0.001] { - let transformed = t.transform(val); - let back = t.inverse(transformed); - assert!((back - val).abs() < 1e-10, "Roundtrip failed for {}", val); - } - } - #[test] fn test_integer_breaks_rounded() { let t = Integer; @@ -120,70 +86,6 @@ mod tests { } } - #[test] - fn test_integer_breaks_evenly_spaced() { - let t = Integer; - // Breaks should be evenly spaced (all gaps equal) - let breaks = t.calculate_breaks(0.0, 100.0, 5, true); - if breaks.len() >= 2 { - let step = breaks[1] - breaks[0]; - for i in 1..breaks.len() { - let gap = breaks[i] - breaks[i - 1]; - assert!( - (gap - step).abs() < 0.01, - "Uneven spacing: gap {} != step {} at index {}", - gap, - step, - i - ); - } - } - } - - #[test] - fn test_integer_breaks_small_range() { - let t = Integer; - // Small range should give consecutive integers - let breaks = t.calculate_breaks(0.0, 5.0, 5, true); - // Should be [0, 1, 2, 3, 4, 5] or similar consecutive sequence - for b in &breaks { - assert_eq!(*b, b.round(), "Break {} should be integer", b); - } - // Verify even spacing - if breaks.len() >= 2 { - let step = breaks[1] - breaks[0]; - for i in 1..breaks.len() { - let gap = breaks[i] - breaks[i - 1]; - assert!( - (gap - step).abs() < 0.01, - "Uneven spacing in small range: gap {} != step {}", - gap, - step - ); - } - } - } - - #[test] - fn test_integer_breaks_small_range_linear() { - let t = Integer; - // Test the problematic case: range 0-5 with n=5 - // Should give evenly spaced integers, not [0, 1, 3, 4, 5] (missing 2) - let breaks = t.calculate_breaks(0.0, 5.0, 5, false); - for b in &breaks { - assert_eq!(*b, b.round(), "Break {} should be integer", b); - } - // The breaks should not have the "2.5 rounds to 3" problem - // i.e., should not skip 2 and have both 1 and 3 - if breaks.contains(&1.0) && breaks.contains(&3.0) { - assert!( - breaks.contains(&2.0), - "Should include 2.0, got {:?}", - breaks - ); - } - } - #[test] fn test_integer_wrap_numeric() { let t = Integer; diff --git a/src/plot/scale/transform/log.rs b/src/plot/scale/transform/log.rs index 6b0882380..25f1d6813 100644 --- a/src/plot/scale/transform/log.rs +++ b/src/plot/scale/transform/log.rs @@ -166,64 +166,6 @@ mod tests { } } - #[test] - fn test_all_bases_transform_and_inverse() { - // Test cases: (transform, input, expected_transform, inverse_test_val, expected_inverse) - let test_cases = vec![ - // Log10: log10(1)=0, log10(10)=1, log10(100)=2, log10(0.1)=-1 - ( - Log::base10(), - vec![(1.0, 0.0), (10.0, 1.0), (100.0, 2.0), (0.1, -1.0)], - ), - // Log2: log2(1)=0, log2(2)=1, log2(4)=2, log2(0.5)=-1 - ( - Log::base2(), - vec![(1.0, 0.0), (2.0, 1.0), (4.0, 2.0), (0.5, -1.0)], - ), - // Natural: ln(1)=0, ln(e)=1, ln(e²)=2 - (Log::natural(), vec![(1.0, 0.0), (E, 1.0), (E * E, 2.0)]), - ]; - - for (t, cases) in test_cases { - for (input, expected) in cases { - assert!( - (t.transform(input) - expected).abs() < 1e-10, - "{}: transform({}) should be {}, got {}", - t.name(), - input, - expected, - t.transform(input) - ); - // Test inverse too - assert!( - (t.inverse(expected) - input).abs() < 1e-9, - "{}: inverse({}) should be {}, got {}", - t.name(), - expected, - input, - t.inverse(expected) - ); - } - } - } - - #[test] - fn test_all_bases_roundtrip() { - let test_values = [0.001, 0.1, 1.0, 2.0, 10.0, 100.0, 1000.0]; - for (t, _, name) in get_transforms() { - for &val in &test_values { - let transformed = t.transform(val); - let back = t.inverse(transformed); - assert!( - (back - val).abs() / val < 1e-10, - "{}: Roundtrip failed for {}", - name, - val - ); - } - } - } - #[test] fn test_all_bases_kind_and_name() { for (t, expected_kind, expected_name) in get_transforms() { @@ -284,19 +226,6 @@ mod tests { assert!((Log::natural().base() - E).abs() < 1e-10); } - #[test] - fn test_expand_range_pads_in_own_transformed_units() { - // Whatever zygos member carries the math, each end moves outward by - // `mult × span + add` measured in this transform's own output. - for t in [Log::base10(), Log::base2(), Log::natural(), Log::new(5.0)] { - let (min, max) = (1.0, 1000.0); - let (lo, hi) = t.expand_range(min, max, (0.05, 0.1), (0.5, 0.25)); - let span = t.transform(max) - t.transform(min); - assert!((t.transform(min) - t.transform(lo) - (0.05 * span + 0.5)).abs() < 1e-9); - assert!((t.transform(hi) - t.transform(max) - (0.1 * span + 0.25)).abs() < 1e-9); - } - } - #[test] fn test_custom_base() { let t = Log::new(5.0); @@ -325,58 +254,6 @@ mod tests { // ==================== Minor Breaks Tests ==================== - #[test] - fn test_minor_breaks_all_bases() { - // Test minor breaks work for all bases - let test_cases = vec![ - (Log::base10(), vec![1.0, 10.0, 100.0], 8, 16), // 8 per decade × 2 decades - (Log::base2(), vec![1.0, 2.0, 4.0, 8.0], 1, 3), // geometric midpoint per octave - ]; - - for (t, majors, n, expected_len) in test_cases { - let minors = t.calculate_minor_breaks(&majors, n, None); - assert_eq!( - minors.len(), - expected_len, - "{}: expected {} minor breaks, got {}", - t.name(), - expected_len, - minors.len() - ); - assert!( - minors.iter().all(|&x| x > 0.0), - "{}: all minor breaks should be positive", - t.name() - ); - } - } - - #[test] - fn test_minor_breaks_whole_number_multiples() { - let t = Log::base10(); - let majors = vec![1.0, 10.0]; - // The multiples 2-9 of each power, whatever non-zero count is asked for. - let expected = vec![2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; - assert_eq!(t.calculate_minor_breaks(&majors, 1, None), expected); - assert_eq!(t.calculate_minor_breaks(&majors, 8, None), expected); - assert!(t.calculate_minor_breaks(&majors, 0, None).is_empty()); - - // Base 2 has no whole number strictly inside an octave: the geometric - // midpoint stands in. - let minors = Log::base2().calculate_minor_breaks(&[1.0, 2.0, 4.0, 8.0], 1, None); - let sqrt2 = std::f64::consts::SQRT_2; - assert_eq!(minors, vec![sqrt2, 2.0 * sqrt2, 4.0 * sqrt2]); - } - - #[test] - fn test_minor_breaks_with_extension() { - let t = Log::base10(); - let majors = vec![10.0, 100.0]; - let minors = t.calculate_minor_breaks(&majors, 8, Some((1.0, 1000.0))); - // Should extend into [1, 10) and (100, 1000] - assert_eq!(minors.len(), 24); // 8 per decade × 3 decades - } - #[test] fn test_default_minor_break_count() { // All log transforms should have the same default diff --git a/src/plot/scale/transform/pseudo_log.rs b/src/plot/scale/transform/pseudo_log.rs index 3767f99e5..ed51fbe66 100644 --- a/src/plot/scale/transform/pseudo_log.rs +++ b/src/plot/scale/transform/pseudo_log.rs @@ -166,78 +166,6 @@ mod tests { ] } - #[test] - fn test_all_bases_domain() { - for (t, name) in get_transforms() { - let (min, max) = t.allowed_domain(); - assert!( - min.is_infinite() && min.is_sign_negative(), - "{}: domain min should be -∞", - name - ); - assert!( - max.is_infinite() && max.is_sign_positive(), - "{}: domain max should be +∞", - name - ); - } - } - - #[test] - fn test_all_bases_zero_at_origin() { - for (t, name) in get_transforms() { - assert!(t.transform(0.0).abs() < 1e-10, "{}: f(0) should be 0", name); - } - } - - #[test] - fn test_all_bases_symmetric_around_zero() { - let test_values = [0.1, 1.0, 10.0, 100.0, 1000.0]; - for (t, name) in get_transforms() { - for &val in &test_values { - let pos = t.transform(val); - let neg = t.transform(-val); - assert!( - (pos + neg).abs() < 1e-10, - "{}: Not symmetric for {} (f({})={}, f({})={})", - name, - val, - val, - pos, - -val, - neg - ); - } - } - } - - #[test] - fn test_all_bases_roundtrip() { - let test_values = [-1000.0, -100.0, -10.0, -1.0, 0.0, 1.0, 10.0, 100.0, 1000.0]; - for (t, name) in get_transforms() { - for &val in &test_values { - let transformed = t.transform(val); - let back = t.inverse(transformed); - if val == 0.0 { - assert!( - (back - val).abs() < 1e-10, - "{}: Roundtrip failed for {}", - name, - val - ); - } else { - assert!( - (back - val).abs() / val.abs() < 1e-10, - "{}: Roundtrip failed for {} (got {})", - name, - val, - back - ); - } - } - } - } - #[test] fn test_all_bases_kind_and_name() { for (t, expected_name) in get_transforms() { @@ -250,33 +178,6 @@ mod tests { } } - #[test] - fn test_approximates_log_for_large_values() { - // Test that each pseudo-log approximates its corresponding log for large values - let test_cases = vec![ - (PseudoLog::base10(), vec![1000.0, 10000.0, 100000.0], 0.01), // log10 - (PseudoLog::base2(), vec![64.0, 1024.0, 65536.0], 0.05), // log2 - (PseudoLog::natural(), vec![100.0, 1000.0, 10000.0], 0.02), // ln - ]; - - for (t, values, tolerance) in test_cases { - for x in values { - let pseudo = t.transform(x); - let actual_log = x.log(t.base()); - let error = (pseudo - actual_log).abs(); - assert!( - error < tolerance, - "{}: For x={}, pseudo={}, log={}, error={}", - t.name(), - x, - pseudo, - actual_log, - error - ); - } - } - } - #[test] fn test_all_bases_display() { assert_eq!(format!("{}", PseudoLog::base10()), "pseudo_log"); @@ -293,24 +194,6 @@ mod tests { assert!((PseudoLog::natural().base() - E).abs() < 1e-10); } - #[test] - fn test_expand_range_pads_in_own_transformed_units() { - // Whatever zygos member carries the math, each end moves outward by - // `mult × span + add` measured in this transform's own output. - for t in [ - PseudoLog::base10(), - PseudoLog::base2(), - PseudoLog::natural(), - PseudoLog::new(5.0), - ] { - let (min, max) = (-50.0, 1000.0); - let (lo, hi) = t.expand_range(min, max, (0.05, 0.1), (0.5, 0.25)); - let span = t.transform(max) - t.transform(min); - assert!((t.transform(min) - t.transform(lo) - (0.05 * span + 0.5)).abs() < 1e-9); - assert!((t.transform(hi) - t.transform(max) - (0.1 * span + 0.25)).abs() < 1e-9); - } - } - #[test] fn test_custom_base() { let t = PseudoLog::new(5.0); @@ -346,21 +229,6 @@ mod tests { } } - #[test] - fn test_pseudo_log_different_from_asinh() { - let pseudo = PseudoLog::base10(); - // pseudo_log and asinh are NOT the same - // asinh(10) ≈ 2.998, pseudo_log(10) ≈ 1.004 - let asinh_10 = 10.0_f64.asinh(); - let pseudo_10 = pseudo.transform(10.0); - assert!( - (asinh_10 - pseudo_10).abs() > 1.0, - "pseudo_log should differ from asinh: asinh(10)={}, pseudo_log(10)={}", - asinh_10, - pseudo_10 - ); - } - #[test] fn test_pseudo_log_breaks() { let t = PseudoLog::base10(); diff --git a/src/plot/scale/transform/sqrt.rs b/src/plot/scale/transform/sqrt.rs index c0927542b..11e652e30 100644 --- a/src/plot/scale/transform/sqrt.rs +++ b/src/plot/scale/transform/sqrt.rs @@ -66,52 +66,6 @@ impl std::fmt::Display for Sqrt { mod tests { use super::*; - #[test] - fn test_sqrt_domain() { - let t = Sqrt; - let (min, max) = t.allowed_domain(); - assert_eq!(min, 0.0); - assert!(max.is_infinite()); - } - - #[test] - fn test_sqrt_transform() { - let t = Sqrt; - assert!((t.transform(0.0) - 0.0).abs() < 1e-10); - assert!((t.transform(1.0) - 1.0).abs() < 1e-10); - assert!((t.transform(4.0) - 2.0).abs() < 1e-10); - assert!((t.transform(9.0) - 3.0).abs() < 1e-10); - assert!((t.transform(100.0) - 10.0).abs() < 1e-10); - } - - #[test] - fn test_sqrt_inverse() { - let t = Sqrt; - assert!((t.inverse(0.0) - 0.0).abs() < 1e-10); - assert!((t.inverse(1.0) - 1.0).abs() < 1e-10); - assert!((t.inverse(2.0) - 4.0).abs() < 1e-10); - assert!((t.inverse(3.0) - 9.0).abs() < 1e-10); - assert!((t.inverse(10.0) - 100.0).abs() < 1e-10); - } - - #[test] - fn test_sqrt_roundtrip() { - let t = Sqrt; - for &val in &[0.0, 1.0, 4.0, 9.0, 25.0, 100.0] { - let transformed = t.transform(val); - let back = t.inverse(transformed); - if val == 0.0 { - assert!((back - val).abs() < 1e-10, "Roundtrip failed for {}", val); - } else { - assert!( - (back - val).abs() / val < 1e-10, - "Roundtrip failed for {}", - val - ); - } - } - } - #[test] fn test_sqrt_breaks() { let t = Sqrt; diff --git a/src/plot/scale/transform/square.rs b/src/plot/scale/transform/square.rs index 2228b8733..7b86cde0b 100644 --- a/src/plot/scale/transform/square.rs +++ b/src/plot/scale/transform/square.rs @@ -68,87 +68,6 @@ impl std::fmt::Display for Square { mod tests { use super::*; - #[test] - fn test_square_domain() { - let t = Square; - let (min, max) = t.allowed_domain(); - assert!(min.is_infinite() && min < 0.0); - assert!(max.is_infinite() && max > 0.0); - } - - #[test] - fn test_square_transform() { - let t = Square; - assert!((t.transform(0.0) - 0.0).abs() < 1e-10); - assert!((t.transform(1.0) - 1.0).abs() < 1e-10); - assert!((t.transform(2.0) - 4.0).abs() < 1e-10); - assert!((t.transform(3.0) - 9.0).abs() < 1e-10); - assert!((t.transform(10.0) - 100.0).abs() < 1e-10); - // Negative values also square to positive - assert!((t.transform(-2.0) - 4.0).abs() < 1e-10); - assert!((t.transform(-3.0) - 9.0).abs() < 1e-10); - } - - #[test] - fn test_square_inverse() { - let t = Square; - assert!((t.inverse(0.0) - 0.0).abs() < 1e-10); - assert!((t.inverse(1.0) - 1.0).abs() < 1e-10); - assert!((t.inverse(4.0) - 2.0).abs() < 1e-10); - assert!((t.inverse(9.0) - 3.0).abs() < 1e-10); - assert!((t.inverse(100.0) - 10.0).abs() < 1e-10); - } - - #[test] - fn test_square_roundtrip_positive() { - let t = Square; - for &val in &[0.0, 1.0, 2.0, 3.0, 5.0, 10.0] { - let transformed = t.transform(val); - let back = t.inverse(transformed); - if val == 0.0 { - assert!((back - val).abs() < 1e-10, "Roundtrip failed for {}", val); - } else { - assert!( - (back - val).abs() / val < 1e-10, - "Roundtrip failed for {}", - val - ); - } - } - } - - #[test] - fn test_square_is_inverse_of_sqrt() { - // Verify that Square::transform is the same as Sqrt::inverse - use super::super::Sqrt; - let square = Square; - let sqrt = Sqrt; - - for &val in &[0.0_f64, 1.0, 4.0, 9.0, 25.0, 100.0] { - assert!( - (square.transform(val.sqrt()) - sqrt.inverse(val.sqrt())).abs() < 1e-10, - "Square::transform != Sqrt::inverse for {}", - val.sqrt() - ); - } - } - - #[test] - fn test_square_inverse_is_sqrt_transform() { - // Verify that Square::inverse is the same as Sqrt::transform - use super::super::Sqrt; - let square = Square; - let sqrt = Sqrt; - - for &val in &[0.0, 1.0, 4.0, 9.0, 25.0, 100.0] { - assert!( - (square.inverse(val) - sqrt.transform(val)).abs() < 1e-10, - "Square::inverse != Sqrt::transform for {}", - val - ); - } - } - #[test] fn test_square_breaks_pretty() { let t = Square; @@ -158,24 +77,6 @@ mod tests { ); } - #[test] - fn test_square_breaks() { - let t = Square; - let breaks = t.calculate_breaks(0.0, 10.0, 5, false); - // linear_breaks gives exact coverage from min to max - assert_eq!(breaks.len(), 5, "Should have exactly 5 breaks"); - // First break should be at 0 - assert!( - (breaks.first().unwrap() - 0.0).abs() < 1e-10, - "First break should be at 0" - ); - // Last break should be at 10 - assert!( - (breaks.last().unwrap() - 10.0).abs() < 1e-10, - "Last break should be at 10" - ); - } - #[test] fn test_square_kind_and_name() { let t = Square; diff --git a/src/plot/scale/transform/time.rs b/src/plot/scale/transform/time.rs index e65549f4d..7088b1797 100644 --- a/src/plot/scale/transform/time.rs +++ b/src/plot/scale/transform/time.rs @@ -138,39 +138,6 @@ mod tests { assert_eq!(t.inverse(100.0), 100.0); } - #[test] - fn test_time_breaks_hour_span() { - let t = Time; - // Full day - let min = 0.0; - let max = 24.0 * NANOS_PER_HOUR; - let breaks = t.calculate_breaks(min, max, 8, true); - assert!(!breaks.is_empty()); - for &b in &breaks { - assert!(b >= min && b <= max); - } - } - - #[test] - fn test_time_breaks_minute_span() { - let t = Time; - // 2 hours - let min = 0.0; - let max = 2.0 * NANOS_PER_HOUR; - let breaks = t.calculate_breaks(min, max, 8, true); - assert!(!breaks.is_empty()); - } - - #[test] - fn test_time_breaks_second_span() { - let t = Time; - // 5 minutes - let min = 0.0; - let max = 5.0 * NANOS_PER_MINUTE; - let breaks = t.calculate_breaks(min, max, 5, true); - assert!(!breaks.is_empty()); - } - #[test] fn test_time_breaks_follow_the_clock() { let t = Time; @@ -182,13 +149,4 @@ mod tests { vec![0.0, 15.0, 30.0, 45.0, 60.0, 75.0, 90.0, 105.0, 120.0] ); } - - #[test] - fn test_time_breaks_linear() { - let t = Time; - let breaks = t.calculate_breaks(0.0, NANOS_PER_HOUR, 5, false); - assert_eq!(breaks.len(), 5); - assert_eq!(breaks[0], 0.0); - assert_eq!(breaks[4], NANOS_PER_HOUR); - } }