Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,8 @@
jittered and half-sided layers too, where Vega-Lite reserved a further 20% of
every step: their marks were narrower, their displacements smaller, and their
category ticks pulled toward the middle of the panel.
- Position scales like `SCALE lon` and `SCALE lat` transfer their limits to
map projections, and transfer their `breaks` setting to the graticule (#492).

### Fixed
- Positron no longer offers a ggsql runtime on a machine that has no kernel.
Expand Down
3 changes: 3 additions & 0 deletions src/execute/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1420,12 +1420,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
87 changes: 45 additions & 42 deletions src/execute/scale.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,11 @@

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_scale_type_from_input_range,
infer_transform_from_input_range, is_facet_aesthetic, transform::Transform, OOB_CENSOR,
OOB_KEEP, OOB_SQUISH,
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,
Expand DownExpand Up@@ -329,6 +330,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 DownExpand Up@@ -363,26 +379,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 DownExpand Up@@ -427,7 +424,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@@ -445,7 +441,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@@ -463,30 +461,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 DownExpand Up@@ -951,7 +955,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 numberDiff line numberDiff 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