Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## [Unreleased]

### Changed

- Position scales like `SCALE lon` and `SCALE lat` transfer their limits to
map projections, and transfer their `breaks` setting to the graticule (#492).

## 0.4.1 - 2026-06-22

### Changed
Expand Down
3 changes: 3 additions & 0 deletions src/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1383,12 +1383,15 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result<Prep
&execute_query,
)?;
}
let mut scales = std::mem::take(&mut specs[0].scales);
project.apply_projection_transforms(
&mut specs[0].layers,
&mut layer_queries,
&mut scales,
dialect,
&execute_query,
)?;
specs[0].scales = scales;
specs[0].project = Some(project);

// Phase 2: Deduplicate and execute unique queries
Expand Down
85 changes: 44 additions & 41 deletions src/execute/scale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

use crate::naming;
use crate::plot::aesthetic::AestheticContext;
use crate::plot::projection::CoordKind;
use crate::plot::scale::{
default_oob, gets_default_scale, infer_scale_target_type, infer_transform_from_input_range,
is_facet_aesthetic, transform::Transform, OOB_CENSOR, OOB_KEEP, OOB_SQUISH,
is_facet_aesthetic, transform::Transform, TransformKind, OOB_CENSOR, OOB_KEEP, OOB_SQUISH,
};
use crate::plot::{
AestheticValue, ArrayElement, ArrayElementType, ColumnInfo, Layer, ParameterValue, Plot, Scale,
Expand Down Expand Up @@ -328,6 +329,21 @@ pub fn resolve_scale_types_and_transforms(
use crate::plot::scale::coerce_dtypes;

let aesthetic_ctx = spec.get_aesthetic_context();
let is_map = spec
.project
.as_ref()
.is_some_and(|p| p.coord.coord_kind() == CoordKind::Map);

if is_map {
for aes in ["pos1", "pos2"] {
if !spec.scales.iter().any(|s| s.aesthetic == aes) {
let mut scale = Scale::new(aes);
scale.scale_type = Some(ScaleType::continuous());
scale.transform = Some(Transform::geographic());
spec.scales.push(scale);
}
}
}

for scale in &mut spec.scales {
// Skip scales that already have explicit types (user specified)
Expand Down Expand Up @@ -362,26 +378,7 @@ pub fn resolve_scale_types_and_transforms(

// Resolve transform if not set
if scale.transform.is_none() && !scale.explicit_transform {
// For Discrete/Ordinal scales, check input range first for transform inference
// This allows SCALE DISCRETE x FROM [true, false] to infer Bool transform
// even when the column is String
let transform_kind = if matches!(
scale_type.scale_type_kind(),
ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal
) {
if let Some(ref input_range) = scale.input_range {
if let Some(kind) = infer_transform_from_input_range(input_range) {
kind
} else {
scale_type
.default_transform(&scale.aesthetic, Some(&common_dtype))
}
} else {
scale_type.default_transform(&scale.aesthetic, Some(&common_dtype))
}
} else {
scale_type.default_transform(&scale.aesthetic, Some(&common_dtype))
};
let transform_kind = infer_transform(scale, &common_dtype, is_map);
scale.transform = Some(Transform::from_kind(transform_kind));
}
}
Expand Down Expand Up @@ -416,7 +413,6 @@ pub fn resolve_scale_types_and_transforms(
// If user specified VIA date/datetime/time/log/sqrt/etc., use Continuous scale
let inferred_scale_type = if scale.explicit_transform {
if let Some(ref transform) = scale.transform {
use crate::plot::scale::TransformKind;
match transform.transform_kind() {
// Temporal transforms require Continuous scale
TransformKind::Date
Expand All @@ -434,7 +430,9 @@ pub fn resolve_scale_types_and_transforms(
| TransformKind::Asinh
| TransformKind::PseudoLog
// Integer transform uses Continuous scale
| TransformKind::Integer => ScaleType::continuous(),
| TransformKind::Integer
// Geographic transform uses Continuous scale
| TransformKind::Geographic => ScaleType::continuous(),
// Discrete transforms (String, Bool) use Discrete scale
TransformKind::String | TransformKind::Bool => ScaleType::discrete(),
// Identity: fall back to dtype inference (considers aesthetic)
Expand All @@ -452,30 +450,36 @@ pub fn resolve_scale_types_and_transforms(

// Infer transform if not explicit
if scale.transform.is_none() && !scale.explicit_transform {
// For Discrete scales, check input range first for transform inference
// This allows SCALE DISCRETE x FROM [true, false] to infer Bool transform
// even when the column is String
let transform_kind = if inferred_scale_type.scale_type_kind() == ScaleTypeKind::Discrete
{
if let Some(ref input_range) = scale.input_range {
if let Some(kind) = infer_transform_from_input_range(input_range) {
kind
} else {
inferred_scale_type.default_transform(&scale.aesthetic, Some(&common_dtype))
}
} else {
inferred_scale_type.default_transform(&scale.aesthetic, Some(&common_dtype))
}
} else {
inferred_scale_type.default_transform(&scale.aesthetic, Some(&common_dtype))
};
let transform_kind = infer_transform(scale, &common_dtype, is_map);
scale.transform = Some(Transform::from_kind(transform_kind));
}
}

Ok(())
}

fn infer_transform(
scale: &Scale,
common_dtype: &arrow::datatypes::DataType,
is_map: bool,
) -> TransformKind {
if is_map && (scale.aesthetic == "pos1" || scale.aesthetic == "pos2") {
return TransformKind::Geographic;
}
let scale_type = scale.scale_type.as_ref().unwrap();
if matches!(
scale_type.scale_type_kind(),
ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal
) {
if let Some(ref input_range) = scale.input_range {
if let Some(kind) = infer_transform_from_input_range(input_range) {
return kind;
}
}
}
scale_type.default_transform(&scale.aesthetic, Some(common_dtype))
}

/// Collect all dtypes for an aesthetic across layers.
pub fn collect_dtypes_for_aesthetic(
layers: &[Layer],
Expand Down Expand Up @@ -940,7 +944,6 @@ pub fn coerce_aesthetic_columns(
///
/// Scales that were already resolved pre-stat (Binned scales) are skipped.
pub fn resolve_scales(spec: &mut Plot, data_map: &mut HashMap<String, DataFrame>) -> Result<()> {
use crate::plot::projection::CoordKind;
use crate::plot::scale::ScaleDataContext;

let aesthetic_ctx = spec.get_aesthetic_context();
Expand Down
157 changes: 157 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1293,4 +1293,161 @@ mod integration_tests {
assert!(x.abs() < 2000.0, "Expected x near 0, got {x}");
assert!(y.abs() < 2000.0, "Expected y near 0, got {y}");
}

#[cfg(feature = "spatial")]
#[test]
fn test_scale_limits_override_map_bbox() {
use crate::plot::types::ParameterValue;

let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();

// Query with explicit lon/lat scale limits on a mercator map
let query = r#"
VISUALISE FROM ggsql:world
DRAW spatial PROJECT TO mercator
SCALE lon FROM [5, 15]
SCALE lat FROM [45, 55]
"#;

let prepared = execute::prepare_data_with_reader(query, &reader).unwrap();

let project = prepared.specs[0].project.as_ref().unwrap();
let bbox_param = project
.computed
.get("bbox")
.expect("bbox should be computed");

// The bbox should reflect the projected [5,15] x [45,55] extent,
// not the full world data extent.
let ParameterValue::Array(bbox_arr) = bbox_param else {
panic!("bbox should be an Array");
};
let xmin = bbox_arr[0].to_f64().unwrap();
let ymin = bbox_arr[1].to_f64().unwrap();
let xmax = bbox_arr[2].to_f64().unwrap();
let ymax = bbox_arr[3].to_f64().unwrap();

// In Web Mercator (EPSG:3857), lon 5° ≈ 556597, lon 15° ≈ 1669792
// lat 45° ≈ 5621521, lat 55° ≈ 7361866
// The bbox should be in that ballpark, not world-sized (~±20M).
assert!(
xmin > 400_000.0 && xmin < 700_000.0,
"xmin out of range: {xmin}"
);
assert!(
xmax > 1_500_000.0 && xmax < 1_800_000.0,
"xmax out of range: {xmax}"
);
assert!(
ymin > 5_000_000.0 && ymin < 6_000_000.0,
"ymin out of range: {ymin}"
);
assert!(
ymax > 7_000_000.0 && ymax < 7_500_000.0,
"ymax out of range: {ymax}"
);
}

#[cfg(feature = "spatial")]
#[test]
fn test_scale_breaks_control_graticule() {
use crate::plot::types::ParameterValue;

let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();

let query = r#"
VISUALISE FROM ggsql:world
DRAW spatial PROJECT TO mercator
SCALE lon SETTING breaks => [0, 30, 60, 90]
"#;

let prepared = execute::prepare_data_with_reader(query, &reader).unwrap();

let project = prepared.specs[0].project.as_ref().unwrap();
let grat_lon = project
.computed
.get("graticule_lon")
.expect("graticule_lon should be set");

let ParameterValue::String(wkt) = grat_lon else {
panic!("graticule_lon should be a String (WKT)");
};

// Each meridian in the MULTILINESTRING is a (...) group.
// Count by splitting on "), (" which separates individual lines.
let line_count = wkt.split("), (").count();
assert_eq!(
line_count, 4,
"Expected 4 graticule meridians for breaks [0, 30, 60, 90], got {line_count}\nWKT: {wkt:.200}"
);
}

#[cfg(feature = "spatial")]
#[test]
fn test_scale_break_count_controls_graticule() {
use crate::plot::types::ParameterValue;

let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();

let query = r#"
VISUALISE FROM ggsql:world
DRAW spatial PROJECT TO mercator
SCALE lon SETTING breaks => 4
"#;

let prepared = execute::prepare_data_with_reader(query, &reader).unwrap();

let project = prepared.specs[0].project.as_ref().unwrap();
let grat_lon = project
.computed
.get("graticule_lon")
.expect("graticule_lon should be set");

let ParameterValue::String(wkt) = grat_lon else {
panic!("graticule_lon should be a String (WKT)");
};

let line_count = wkt.split("), (").count();
assert_eq!(
line_count, 4,
"Expected 4 graticule meridians for breaks => 4, got {line_count}\nWKT: {wkt:.200}"
);
}

#[cfg(feature = "spatial")]
#[test]
fn test_map_position_scales_resolved_after_projection() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();

let query = r#"
VISUALISE FROM ggsql:world
DRAW spatial PROJECT TO mercator
"#;

let prepared = execute::prepare_data_with_reader(query, &reader).unwrap();

let spec = &prepared.specs[0];
let pos_scales: Vec<_> = spec
.scales
.iter()
.filter(|s| s.aesthetic == "pos1" || s.aesthetic == "pos2")
.collect();
assert_eq!(
pos_scales.len(),
2,
"Map projection should create pos1 and pos2 scales even without explicit SCALE clauses"
);
for scale in pos_scales {
assert!(
scale.resolved,
"Map position scale '{}' should be marked resolved",
scale.aesthetic
);
assert!(
!scale.numeric_breaks().is_empty(),
"Map position scale '{}' should have breaks",
scale.aesthetic
);
}
}
}
Loading
Loading