From 4975602125a0bb701e5f75b0fa1f307e647d570f Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 6 May 2026 15:52:22 +0200 Subject: [PATCH 01/50] Add map coordinate system with projection shorthands Introduces CoordKind::Map with position aesthetics (lon, lat) and a crs property. parse_coord_system() now returns implied properties so that shorthands like `mercator` and `orthographic` pre-populate the crs without requiring an explicit SETTING clause. Co-Authored-By: Claude Opus 4.6 --- src/parser/builder.rs | 94 +++++++++++++++++++++++++-- src/plot/projection/coord/map.rs | 102 ++++++++++++++++++++++++++++++ src/plot/projection/coord/mod.rs | 10 +++ src/plot/projection/types.rs | 5 ++ src/writer/vegalite/projection.rs | 1 + 5 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 src/plot/projection/coord/map.rs diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 737b376ab..6acae9a95 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -969,7 +969,11 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { user_aesthetics = Some(source.find_texts(&child, query)); } "project_type" => { - coord = parse_coord_system(&child, source)?; + let (parsed_coord, implied_properties) = parse_coord_system(&child, source)?; + coord = parsed_coord; + for (name, value) in implied_properties { + properties.entry(name).or_insert(value); + } } "project_properties" => { // Find all project_property nodes @@ -1089,12 +1093,31 @@ fn validate_project_properties( Ok(()) } -/// Parse coord type from a project_type node -fn parse_coord_system(node: &Node, source: &SourceTree) -> Result { +/// Parse coord type from a project_type node. +/// Returns the coord and any pre-populated properties implied by the shorthand. +fn parse_coord_system( + node: &Node, + source: &SourceTree, +) -> Result<(Coord, Vec<(String, ParameterValue)>)> { let text = source.get_text(node); match text.to_lowercase().as_str() { - "cartesian" => Ok(Coord::cartesian()), - "polar" => Ok(Coord::polar()), + "cartesian" => Ok((Coord::cartesian(), Vec::new())), + "polar" => Ok((Coord::polar(), Vec::new())), + "map" => Ok((Coord::map(), Vec::new())), + "mercator" => Ok(( + Coord::map(), + vec![( + "crs".to_string(), + ParameterValue::String("+proj=merc".to_string()), + )], + )), + "orthographic" => Ok(( + Coord::map(), + vec![( + "crs".to_string(), + ParameterValue::String("+proj=ortho".to_string()), + )], + )), _ => Err(GgsqlError::ParseError(format!( "Unknown coord type: {}", text @@ -1340,6 +1363,67 @@ mod tests { .contains("conflicts with material aesthetic")); } + #[test] + fn test_project_map_bare() { + let query = r#" + VISUALISE + DRAW point MAPPING lon AS lon, lat AS lat + PROJECT TO map + "#; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Map); + assert_eq!( + project.aesthetics, + vec!["lon".to_string(), "lat".to_string()] + ); + assert!(!project.properties.contains_key("crs")); + } + + #[test] + fn test_project_mercator_shorthand() { + let query = r#" + VISUALISE + DRAW point MAPPING lon AS lon, lat AS lat + PROJECT TO mercator + "#; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Map); + assert_eq!( + project.properties.get("crs"), + Some(&ParameterValue::String("+proj=merc".to_string())) + ); + } + + #[test] + fn test_project_shorthand_crs_override() { + let query = r#" + VISUALISE + DRAW point MAPPING lon AS lon, lat AS lat + PROJECT TO mercator SETTING crs => '+proj=custom' + "#; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Map); + assert_eq!( + project.properties.get("crs"), + Some(&ParameterValue::String("+proj=custom".to_string())) + ); + } + // ======================================== // Case Insensitive Keywords Tests // ======================================== diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs new file mode 100644 index 000000000..905a0f62d --- /dev/null +++ b/src/plot/projection/coord/map.rs @@ -0,0 +1,102 @@ +//! Map coordinate system implementation + +use super::{CoordKind, CoordTrait}; +use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition}; + +/// Map coordinate system - for geographic/cartographic projections +#[derive(Debug, Clone, Copy)] +pub struct Map; + +impl CoordTrait for Map { + fn coord_kind(&self) -> CoordKind { + CoordKind::Map + } + + fn name(&self) -> &'static str { + "map" + } + + fn position_aesthetic_names(&self) -> &'static [&'static str] { + &["lon", "lat"] + } + + fn default_properties(&self) -> &'static [ParamDefinition] { + const PARAMS: &[ParamDefinition] = &[ + ParamDefinition { + name: "crs", + default: DefaultParamValue::Null, + constraint: ParamConstraint::string(), + }, + ParamDefinition { + name: "clip", + default: DefaultParamValue::Boolean(true), + constraint: ParamConstraint::boolean(), + }, + ]; + PARAMS + } +} + +impl std::fmt::Display for Map { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::ParameterValue; + use std::collections::HashMap; + + #[test] + fn test_map_properties() { + let map = Map; + assert_eq!(map.coord_kind(), CoordKind::Map); + assert_eq!(map.name(), "map"); + assert_eq!(map.position_aesthetic_names(), &["lon", "lat"]); + } + + #[test] + fn test_map_default_properties() { + let map = Map; + let defaults = map.default_properties(); + let names: Vec<&str> = defaults.iter().map(|p| p.name).collect(); + assert!(names.contains(&"crs")); + assert!(names.contains(&"clip")); + assert_eq!(defaults.len(), 2); + } + + #[test] + fn test_map_accepts_crs_string() { + let map = Map; + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=merc".to_string()), + ); + + let resolved = map.resolve_properties(&props); + assert!(resolved.is_ok()); + let resolved = resolved.unwrap(); + assert_eq!( + resolved.get("crs").unwrap(), + &ParameterValue::String("+proj=merc".to_string()) + ); + } + + #[test] + fn test_map_rejects_unknown_property() { + let map = Map; + let mut props = HashMap::new(); + props.insert( + "unknown".to_string(), + ParameterValue::String("value".to_string()), + ); + + let resolved = map.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("not 'unknown'")); + } +} diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index 05d21b831..7abf1182d 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -29,10 +29,12 @@ use crate::plot::ParameterValue; // Coord type implementations mod cartesian; +mod map; mod polar; // Re-export coord type structs pub use cartesian::Cartesian; +pub use map::Map; pub use polar::Polar; // ============================================================================= @@ -47,6 +49,8 @@ pub enum CoordKind { Cartesian, /// Polar coordinates (for pie charts, rose plots) Polar, + /// Map coordinates (for geographic/cartographic projections) + Map, } // ============================================================================= @@ -146,11 +150,17 @@ impl Coord { Self(Arc::new(Polar)) } + /// Create a Map coord type + pub fn map() -> Self { + Self(Arc::new(Map)) + } + /// Create a Coord from a CoordKind pub fn from_kind(kind: CoordKind) -> Self { match kind { CoordKind::Cartesian => Self::cartesian(), CoordKind::Polar => Self::polar(), + CoordKind::Map => Self::map(), } } diff --git a/src/plot/projection/types.rs b/src/plot/projection/types.rs index 9edfeea8c..458c4b6b0 100644 --- a/src/plot/projection/types.rs +++ b/src/plot/projection/types.rs @@ -33,6 +33,11 @@ impl Projection { Self::with_defaults(Coord::polar()) } + /// Create a default Map projection (lon, lat). + pub fn map() -> Self { + Self::with_defaults(Coord::map()) + } + fn with_defaults(coord: Coord) -> Self { let aesthetics = coord .position_aesthetic_names() diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 33671a2a3..6f4023894 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -117,6 +117,7 @@ pub(super) fn get_projection_renderer( Some(CoordKind::Polar) => Box::new(PolarProjection { panel: PolarContext::new(project, facet, scales), }), + Some(CoordKind::Map) => todo!("map projection rendering"), Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection { is_faceted }), } } From 9b6487f7e334543e5c5b9e05f6c872364b7c8265 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 7 May 2026 11:29:59 +0200 Subject: [PATCH 02/50] Add projection infrastructure to GeomTrait and CoordTrait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the trait methods for coord-orchestrated projection transforms: - GeomTrait::apply_projection() — per-geom hook (default no-op) - CoordTrait::apply_projection_transforms() — iterates layers - SqlDialect::sql_st_transform() — ST_Transform SQL generation Co-Authored-By: Claude Opus 4.6 --- src/plot/layer/geom/mod.rs | 28 +++++++++++++++++++++++++ src/plot/projection/coord/mod.rs | 36 +++++++++++++++++++++++++++++++- src/reader/mod.rs | 11 ++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 42069abb7..6549ce4d3 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -74,6 +74,7 @@ pub use text::Text; pub use tile::Tile; pub use violin::Violin; +use crate::plot::projection::Projection; use crate::plot::types::{ParameterValue, Schema}; use crate::reader::SqlDialect; @@ -229,6 +230,23 @@ pub trait GeomTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { Ok(df) } + /// Apply coord-specific projection transformations to a layer query. + /// + /// Called after stat transforms, before data fetch. Each geom decides what + /// projection means for its parameterization: + /// - Spatial: ST_AsWKB (always), plus ST_Transform when Map coord has a CRS + /// - Future geoms: rectangles transform corners, lines segmentize, etc. + /// + /// The default is a no-op (returns query unchanged). + fn apply_projection( + &self, + query: &str, + _projection: &Projection, + _dialect: &dyn SqlDialect, + ) -> Result { + Ok(query.to_string()) + } + /// Adjust layer mappings and parameters based on geom-specific logic. /// /// This method is called during layer execution to allow geoms to customize @@ -451,6 +469,16 @@ impl Geom { self.0.post_process(df, parameters) } + /// Apply coord-specific projection transformations + pub fn apply_projection( + &self, + query: &str, + projection: &Projection, + dialect: &dyn SqlDialect, + ) -> Result { + self.0.apply_projection(query, projection, dialect) + } + /// Adjust layer mappings and parameters based on geom-specific logic pub fn setup_layer( &self, diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index 7abf1182d..d20c416e0 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -25,7 +25,9 @@ use std::collections::HashMap; use std::sync::Arc; use crate::plot::types::{validate_parameter, ParamDefinition}; -use crate::plot::ParameterValue; +use crate::plot::{Layer, ParameterValue}; +use crate::reader::SqlDialect; +use crate::DataFrame; // Coord type implementations mod cartesian; @@ -126,6 +128,25 @@ pub trait CoordTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { Ok(resolved) } + + /// Orchestrate projection transforms for all layers. + /// + /// Iterates layers and calls each geom's `apply_projection()`. + /// Override to add coord-specific setup (e.g., Map loads the spatial extension). + fn apply_projection_transforms( + &self, + layers: &[Layer], + layer_queries: &mut [String], + projection: &super::Projection, + dialect: &dyn SqlDialect, + _execute_query: &dyn Fn(&str) -> crate::Result, + ) -> crate::Result<()> { + for (idx, layer) in layers.iter().enumerate() { + layer_queries[idx] = + layer.geom.apply_projection(&layer_queries[idx], projection, dialect)?; + } + Ok(()) + } } // ============================================================================= @@ -192,6 +213,19 @@ impl Coord { ) -> Result, String> { self.0.resolve_properties(properties) } + + /// Orchestrate projection transforms for all layers. + pub fn apply_projection_transforms( + &self, + layers: &[Layer], + layer_queries: &mut [String], + projection: &super::Projection, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, + ) -> crate::Result<()> { + self.0 + .apply_projection_transforms(layers, layer_queries, projection, dialect, execute_query) + } } impl std::fmt::Debug for Coord { diff --git a/src/reader/mod.rs b/src/reader/mod.rs index fc320a5dc..72fa56c34 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -122,6 +122,17 @@ pub trait SqlDialect { format!("ST_AsBinary({column})") } + /// SQL expression to transform a geometry to a target CRS. + /// + /// Default uses `ST_Transform(column, 'crs')` which works for DuckDB and PostGIS. + fn sql_st_transform(&self, column: &str, target_crs: &str) -> String { + format!( + "ST_Transform({}, '{}')", + column, + target_crs.replace('\'', "''") + ) + } + /// SQL statements to run before spatial operations. /// /// Override for backends that need an extension loaded (e.g. DuckDB spatial). From 7c192b7ef5c7650f46a16b863cf236d09388ad8a Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 7 May 2026 11:47:21 +0200 Subject: [PATCH 03/50] Move spatial WKB serialization from stat to projection phase - Spatial geom's apply_projection() does ST_AsWKB (always) and ST_Transform when Map coord has a CRS - Map coord override runs LOAD spatial before iterating layers - Pipeline calls coord.apply_projection_transforms() post-stat Co-Authored-By: Claude Opus 4.6 --- src/execute/mod.rs | 12 ++++ src/plot/layer/geom/spatial.rs | 99 +++++++++++++++++++++++--------- src/plot/projection/coord/map.rs | 22 +++++++ 3 files changed, 106 insertions(+), 27 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 41f6f1ee9..5408524e2 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -1333,6 +1333,18 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result = HashMap::new(); for (idx, q) in layer_queries.iter().enumerate() { diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index 74ae703c6..56d7ba3d3 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -1,6 +1,10 @@ -use super::{DefaultAesthetics, GeomTrait, GeomType, StatResult}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::naming; +use crate::plot::projection::coord::CoordKind; +use crate::plot::projection::Projection; use crate::plot::types::DefaultAestheticValue; -use crate::{naming, Mappings}; +use crate::plot::ParameterValue; +use crate::reader::SqlDialect; #[derive(Debug, Clone, Copy)] pub struct Spatial; @@ -23,34 +27,27 @@ impl GeomTrait for Spatial { } } - fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool { - true - } - - fn apply_stat_transform( + fn apply_projection( &self, query: &str, - _schema: &crate::plot::Schema, - _aesthetics: &Mappings, - _group_by: &[String], - _parameters: &std::collections::HashMap, - execute_query: &dyn Fn(&str) -> crate::Result, - dialect: &dyn crate::reader::SqlDialect, - ) -> crate::Result { - for stmt in dialect.sql_spatial_setup() { - execute_query(&stmt)?; - } - - // Geometry columns use database-native types that don't have an Arrow equivalent. - // Convert to standard WKB so the writer can parse them with geozero. + projection: &Projection, + dialect: &dyn SqlDialect, + ) -> crate::Result { let col = naming::quote_ident(&naming::aesthetic_column("geometry")); - let wkb_expr = dialect.sql_geometry_to_wkb(&col); - Ok(StatResult::Transformed { - query: format!("SELECT * REPLACE ({wkb_expr} AS {col}) FROM ({query})"), - stat_columns: vec![], - dummy_columns: vec![], - consumed_aesthetics: vec![], - }) + + let geom_expr = if let (CoordKind::Map, Some(ParameterValue::String(crs))) = ( + projection.coord.coord_kind(), + projection.properties.get("crs"), + ) { + dialect.sql_st_transform(&col, crs) + } else { + col.clone() + }; + + let wkb_expr = dialect.sql_geometry_to_wkb(&geom_expr); + Ok(format!( + "SELECT * REPLACE ({wkb_expr} AS {col}) FROM ({query})" + )) } } @@ -59,3 +56,51 @@ impl std::fmt::Display for Spatial { write!(f, "spatial") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::reader::AnsiDialect; + // Note: in AnsiDialect ST_AsBinary is the function to get WKB. + + #[test] + fn test_apply_projection_without_map_coord() { + let spatial = Spatial; + let projection = Projection::cartesian(); + let result = spatial + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .unwrap(); + + assert!(result.contains("ST_AsBinary")); + assert!(!result.contains("ST_Transform")); + } + + #[test] + fn test_apply_projection_map_without_crs() { + let spatial = Spatial; + let projection = Projection::map(); + let result = spatial + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .unwrap(); + + assert!(result.contains("ST_AsBinary")); + assert!(!result.contains("ST_Transform")); + } + + #[test] + fn test_apply_projection_map_with_crs() { + let spatial = Spatial; + let mut projection = Projection::map(); + projection.properties.insert( + "crs".to_string(), + ParameterValue::String("+proj=merc".to_string()), + ); + let result = spatial + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .unwrap(); + + assert!(result.contains("ST_AsBinary")); + assert!(result.contains("ST_Transform")); + assert!(result.contains("+proj=merc")); + } +} diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 905a0f62d..09064f107 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -2,6 +2,9 @@ use super::{CoordKind, CoordTrait}; use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition}; +use crate::plot::Layer; +use crate::reader::SqlDialect; +use crate::DataFrame; /// Map coordinate system - for geographic/cartographic projections #[derive(Debug, Clone, Copy)] @@ -35,6 +38,25 @@ impl CoordTrait for Map { ]; PARAMS } + + fn apply_projection_transforms( + &self, + layers: &[Layer], + layer_queries: &mut [String], + projection: &super::super::Projection, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, + ) -> crate::Result<()> { + for stmt in dialect.sql_spatial_setup() { + execute_query(&stmt)?; + } + + for (idx, layer) in layers.iter().enumerate() { + layer_queries[idx] = + layer.geom.apply_projection(&layer_queries[idx], projection, dialect)?; + } + Ok(()) + } } impl std::fmt::Display for Map { From 4aadf5d86a10e85b632a69e754ec083e17f911cd Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 7 May 2026 13:50:44 +0200 Subject: [PATCH 04/50] pre_stat_tra --- src/execute/mod.rs | 10 +++--- src/lib.rs | 29 ++++++++++++++++ src/plot/layer/geom/spatial.rs | 6 +++- src/plot/projection/coord/map.rs | 58 ++++++++++++++++++++++++++++++-- src/plot/projection/coord/mod.rs | 4 +-- src/plot/projection/types.rs | 16 ++++++++- src/reader/mod.rs | 9 ++--- src/writer/vegalite/layer.rs | 6 ++-- 8 files changed, 121 insertions(+), 17 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 5408524e2..26731d138 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -1335,15 +1335,17 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result = HashMap::new(); diff --git a/src/lib.rs b/src/lib.rs index a2ec401f6..b7bfe4bfe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1044,6 +1044,35 @@ mod integration_tests { assert_eq!(feature["geometry"]["type"], "Polygon"); } + #[test] + fn test_end_to_end_spatial_world_orthographic() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + let query = r#" + VISUALISE FROM ggsql:world + DRAW spatial PROJECT TO orthographic + "#; + + let prepared = execute::prepare_data_with_reader(query, &reader).unwrap(); + + let writer = VegaLiteWriter::new(); + let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + assert_eq!(vl_spec["layer"][0]["mark"]["type"], "geoshape"); + + let data = vl_spec["data"]["values"].as_array().unwrap(); + let layer_key = prepared.specs[0].layers[0].data_key.as_ref().unwrap(); + let spatial_rows: Vec<_> = data + .iter() + .filter(|r| r[naming::SOURCE_COLUMN] == layer_key.as_str()) + .collect(); + assert!(!spatial_rows.is_empty()); + // Orthographic clips the back hemisphere — some features have null geometry + assert!(spatial_rows.iter().any(|r| r["geometry"].is_null())); + assert!(spatial_rows.iter().any(|r| !r["geometry"].is_null())); + } + /// Belt-and-braces regression test: a representative basket of error- /// triggering queries must never produce a user-visible message that /// contains an internal aesthetic name (`__ggsql_aes_*`, `pos1`, `pos2`, diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index 56d7ba3d3..c9f525cd0 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -39,7 +39,11 @@ impl GeomTrait for Spatial { projection.coord.coord_kind(), projection.properties.get("crs"), ) { - dialect.sql_st_transform(&col, crs) + let source = match projection.properties.get("source") { + Some(ParameterValue::String(s)) => s.as_str(), + _ => "EPSG:4326", + }; + dialect.sql_st_transform(&col, source, crs) } else { col.clone() }; diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 09064f107..19eb2caed 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -1,8 +1,10 @@ //! Map coordinate system implementation use super::{CoordKind, CoordTrait}; +use crate::naming; +use crate::plot::layer::geom::GeomType; use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition}; -use crate::plot::Layer; +use crate::plot::{Layer, ParameterValue}; use crate::reader::SqlDialect; use crate::DataFrame; @@ -30,6 +32,11 @@ impl CoordTrait for Map { default: DefaultParamValue::Null, constraint: ParamConstraint::string(), }, + ParamDefinition { + name: "source", + default: DefaultParamValue::Null, + constraint: ParamConstraint::string(), + }, ParamDefinition { name: "clip", default: DefaultParamValue::Boolean(true), @@ -43,7 +50,7 @@ impl CoordTrait for Map { &self, layers: &[Layer], layer_queries: &mut [String], - projection: &super::super::Projection, + projection: &mut super::super::Projection, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<()> { @@ -51,6 +58,15 @@ impl CoordTrait for Map { execute_query(&stmt)?; } + // Detect source CRS from geometry columns if not explicitly set + if !projection.properties.contains_key("source") { + if let Some(srid) = detect_source_srid(layers, layer_queries, execute_query) { + projection + .properties + .insert("source".to_string(), ParameterValue::String(srid)); + } + } + for (idx, layer) in layers.iter().enumerate() { layer_queries[idx] = layer.geom.apply_projection(&layer_queries[idx], projection, dialect)?; @@ -65,6 +81,41 @@ impl std::fmt::Display for Map { } } +fn detect_source_srid( + layers: &[Layer], + layer_queries: &[String], + execute_query: &dyn Fn(&str) -> crate::Result, +) -> Option { + let geom_col = naming::quote_ident(&naming::aesthetic_column("geometry")); + + for (idx, layer) in layers.iter().enumerate() { + if layer.geom.geom_type() != GeomType::Spatial { + continue; + } + let sql = format!( + "SELECT ST_SRID({geom_col}) AS srid FROM ({}) WHERE {geom_col} IS NOT NULL LIMIT 1", + layer_queries[idx] + ); + if let Ok(df) = execute_query(&sql) { + let batch = df.inner(); + if batch.num_rows() == 0 { + continue; + } + if let Some(arr) = batch + .column(0) + .as_any() + .downcast_ref::() + { + let srid = arr.value(0); + if srid != 0 { + return Some(format!("EPSG:{srid}")); + } + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -85,8 +136,9 @@ mod tests { let defaults = map.default_properties(); let names: Vec<&str> = defaults.iter().map(|p| p.name).collect(); assert!(names.contains(&"crs")); + assert!(names.contains(&"source")); assert!(names.contains(&"clip")); - assert_eq!(defaults.len(), 2); + assert_eq!(defaults.len(), 3); } #[test] diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index d20c416e0..b1e55cfe4 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -137,7 +137,7 @@ pub trait CoordTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { &self, layers: &[Layer], layer_queries: &mut [String], - projection: &super::Projection, + projection: &mut super::Projection, dialect: &dyn SqlDialect, _execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<()> { @@ -219,7 +219,7 @@ impl Coord { &self, layers: &[Layer], layer_queries: &mut [String], - projection: &super::Projection, + projection: &mut super::Projection, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<()> { diff --git a/src/plot/projection/types.rs b/src/plot/projection/types.rs index 458c4b6b0..c4ebfcd12 100644 --- a/src/plot/projection/types.rs +++ b/src/plot/projection/types.rs @@ -6,7 +6,9 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use super::coord::Coord; -use crate::plot::ParameterValue; +use crate::plot::{Layer, ParameterValue}; +use crate::reader::SqlDialect; +use crate::DataFrame; /// Projection (from PROJECT clause) #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -56,4 +58,16 @@ impl Projection { pub fn position_names(&self) -> Vec<&str> { self.aesthetics.iter().map(|s| s.as_str()).collect() } + + /// Orchestrate projection transforms for all layers. + pub fn apply_projection_transforms( + &mut self, + layers: &[Layer], + layer_queries: &mut [String], + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, + ) -> crate::Result<()> { + let coord = self.coord.clone(); + coord.apply_projection_transforms(layers, layer_queries, self, dialect, execute_query) + } } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 72fa56c34..d98cef3ce 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -122,13 +122,14 @@ pub trait SqlDialect { format!("ST_AsBinary({column})") } - /// SQL expression to transform a geometry to a target CRS. + /// SQL expression to transform a geometry from one CRS to another. /// - /// Default uses `ST_Transform(column, 'crs')` which works for DuckDB and PostGIS. - fn sql_st_transform(&self, column: &str, target_crs: &str) -> String { + /// Default uses `ST_Transform(column, source_crs, target_crs)` which works for DuckDB. + fn sql_st_transform(&self, column: &str, source_crs: &str, target_crs: &str) -> String { format!( - "ST_Transform({}, '{}')", + "ST_Transform({}, '{}', '{}')", column, + source_crs.replace('\'', "''"), target_crs.replace('\'', "''") ) } diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index dce96d292..b188bcbf9 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -2151,8 +2151,10 @@ impl SpatialRenderer { GgsqlError::WriterError(format!("Failed to convert WKB to GeoJSON: {}", e)) })?; - serde_json::from_slice(&geojson_out) - .map_err(|e| GgsqlError::WriterError(format!("Invalid GeoJSON from WKB: {}", e))) + match serde_json::from_slice(&geojson_out) { + Ok(value) => Ok(value), + Err(_) => Ok(Value::Null), + } } fn parse_geometry_from_array(array: &arrow::array::ArrayRef, idx: usize) -> Result { From f9b05c16187abd9660dc6d1213f292cf4aa1fc28 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 7 May 2026 14:47:55 +0200 Subject: [PATCH 05/50] Refactor projection.rs into module directory Split the 2821-line projection.rs into projection/{mod,cartesian,polar}.rs so map projection rendering can live in its own file. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection/cartesian.rs | 20 + src/writer/vegalite/projection/mod.rs | 291 ++++++++++++++ .../{projection.rs => projection/polar.rs} | 372 +----------------- 3 files changed, 324 insertions(+), 359 deletions(-) create mode 100644 src/writer/vegalite/projection/cartesian.rs create mode 100644 src/writer/vegalite/projection/mod.rs rename src/writer/vegalite/{projection.rs => projection/polar.rs} (85%) diff --git a/src/writer/vegalite/projection/cartesian.rs b/src/writer/vegalite/projection/cartesian.rs new file mode 100644 index 000000000..5022b599f --- /dev/null +++ b/src/writer/vegalite/projection/cartesian.rs @@ -0,0 +1,20 @@ +use super::ProjectionRenderer; + +/// Cartesian projection — standard x/y coordinates. +pub(in crate::writer) struct CartesianProjection { + pub is_faceted: bool, +} + +impl ProjectionRenderer for CartesianProjection { + fn is_faceted(&self) -> bool { + self.is_faceted + } + + fn position_channels(&self) -> (&'static str, &'static str) { + ("x", "y") + } + + fn offset_channels(&self) -> (&'static str, &'static str) { + ("xOffset", "yOffset") + } +} diff --git a/src/writer/vegalite/projection/mod.rs b/src/writer/vegalite/projection/mod.rs new file mode 100644 index 000000000..8237df841 --- /dev/null +++ b/src/writer/vegalite/projection/mod.rs @@ -0,0 +1,291 @@ +//! Projection rendering for Vega-Lite writer +//! +//! This module provides a trait-based design for projection rendering. +//! Each projection type (cartesian, polar, and future map projections) +//! implements `ProjectionRenderer`, which owns both the VL channel mapping +//! and the spec-level transformations for that projection. + +mod cartesian; +mod polar; + +use crate::plot::{CoordKind, ParameterValue, Projection, Scale}; +use crate::{Plot, Result}; +use serde_json::{json, Value}; + +use cartesian::CartesianProjection; +use polar::PolarProjection; + +pub(in crate::writer) use polar::PolarContext; + +const ANGLE_TOLERANCE: f64 = 1.49011611938476e-08; // f64::EPSILON.sqrt() + +// ============================================================================= +// ProjectionRenderer trait +// ============================================================================= + +/// Trait defining how a projection type maps to Vega-Lite. +/// +/// Each implementation owns two concerns: +/// 1. **Channel mapping** — translating internal position aesthetics (pos1, pos2, …) +/// to Vega-Lite encoding channel names. +/// 2. **Spec transformation** — modifying the Vega-Lite spec after layers are built +/// (e.g., converting marks to arcs for polar). +pub(super) trait ProjectionRenderer { + /// Whether the spec uses faceting. + fn is_faceted(&self) -> bool; + + /// Primary and secondary VL channel names for this projection. + /// + /// Returns `(pos1_channel, pos2_channel)`, e.g. `("x", "y")` for cartesian, + /// `("radius", "theta")` for polar. + fn position_channels(&self) -> (&'static str, &'static str); + + /// Offset channel names for this projection. + /// + /// Returns `(pos1_offset, pos2_offset)`, e.g. `("xOffset", "yOffset")`. + fn offset_channels(&self) -> (&'static str, &'static str); + + /// Panel dimensions as VL values (`"container"` or explicit pixels). + /// + /// Returns `None` for faceted cartesian (VL handles sizing). + fn panel_size(&self) -> Option<(Value, Value)> { + if self.is_faceted() { + None + } else { + Some((json!("container"), json!("container"))) + } + } + + /// Apply projection-specific transformations to the VL spec. + /// + /// Called after layers are built but before faceting. + fn transform_layers(&self, _spec: &Plot, _vl_spec: &mut Value) -> Result<()> { + Ok(()) + } + + /// Vega-Lite layers to prepend before the data layers. + fn background_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { + Vec::new() + } + + /// Vega-Lite layers to append after the data layers. + fn foreground_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { + Vec::new() + } + + /// Apply all projection-specific work: transforms, clip, and panel decoration. + fn apply_projection(&self, spec: &Plot, theme: &mut Value, vl_spec: &mut Value) -> Result<()> { + self.transform_layers(spec, vl_spec)?; + + if let Some(ref project) = spec.project { + if let Some(ParameterValue::Boolean(clip)) = project.properties.get("clip") { + apply_clip_to_layers(vl_spec, *clip); + } + } + + let mut bg = self.background_layers(&spec.scales, theme); + let mut fg = self.foreground_layers(&spec.scales, theme); + if !(bg.is_empty() && fg.is_empty()) { + for layer in &mut bg { + layer["description"] = json!("background"); + } + for layer in &mut fg { + layer["description"] = json!("foreground"); + } + if let Some(layers) = get_layers_mut(vl_spec) { + let data_layers = std::mem::take(layers); + layers.reserve(bg.len() + data_layers.len() + fg.len()); + layers.extend(bg); + layers.extend(data_layers); + layers.extend(fg); + } + } + + Ok(()) + } +} + +// ============================================================================= +// Factory +// ============================================================================= + +/// Get the projection renderer for a projection spec. +/// +/// Returns the appropriate renderer based on the projection's coord kind, +/// or a Cartesian renderer if no projection is specified. +pub(super) fn get_projection_renderer( + project: Option<&Projection>, + facet: Option<&crate::plot::Facet>, + scales: &[Scale], +) -> Box { + let is_faceted = facet.is_some_and(|f| !f.get_variables().is_empty()); + match project.map(|p| p.coord.coord_kind()) { + Some(CoordKind::Polar) => Box::new(PolarProjection { + panel: PolarContext::new(project, facet, scales), + }), + Some(CoordKind::Map) => todo!("map projection rendering"), + Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection { is_faceted }), + } +} + +// ============================================================================= +// Channel mapping helpers (used by encoding.rs via the trait) +// ============================================================================= + +/// Map internal position aesthetic to Vega-Lite channel name using the renderer. +/// +/// Returns `Some(channel_name)` for internal position aesthetics (pos1, pos2, etc.), +/// or `None` for material aesthetics. +pub(super) fn map_position_to_vegalite( + aesthetic: &str, + renderer: &dyn ProjectionRenderer, +) -> Option { + let (primary, secondary) = renderer.position_channels(); + + match aesthetic { + "pos1" | "pos1min" => Some(primary.to_string()), + "pos2" | "pos2min" => Some(secondary.to_string()), + "pos1end" | "pos1max" => Some(format!("{}2", primary)), + "pos2end" | "pos2max" => Some(format!("{}2", secondary)), + _ => None, + } +} + +// ============================================================================= +// AxisInfo — reusable across projection types +// ============================================================================= + +pub(in crate::writer) struct AxisInfo { + pub domain: Option<(f64, f64)>, + pub breaks: Vec, + pub labels: Vec<(f64, String)>, + pub is_free: bool, +} + +impl AxisInfo { + pub fn new(aesthetic: &str, scales: &[Scale], facet: Option<&crate::plot::Facet>) -> Self { + let (domain, labels) = match scales.iter().find(|s| s.aesthetic == aesthetic) { + Some(s) => (s.numeric_domain(), s.break_labels()), + None => (None, Vec::new()), + }; + let domain = domain.filter(|(min, max)| (max - min).abs() > f64::EPSILON); + let breaks = labels.iter().map(|(v, _)| *v).collect(); + let is_free = facet.is_some_and(|f| f.is_free(aesthetic)); + Self { + domain, + breaks, + labels, + is_free, + } + } +} + +// ============================================================================= +// Shared helpers +// ============================================================================= + +/// Get mutable reference to the layers array, handling both flat and faceted specs. +/// +/// In a flat spec: `vl_spec["layer"]` +/// In a faceted spec: `vl_spec["spec"]["layer"]` +fn get_layers_mut(vl_spec: &mut Value) -> Option<&mut Vec> { + if vl_spec.get("layer").is_some() { + vl_spec.get_mut("layer").and_then(|l| l.as_array_mut()) + } else { + vl_spec + .get_mut("spec") + .and_then(|s| s.get_mut("layer")) + .and_then(|l| l.as_array_mut()) + } +} + +/// Apply clip setting to all layers +fn apply_clip_to_layers(vl_spec: &mut Value, clip: bool) { + if let Some(layers_arr) = get_layers_mut(vl_spec) { + for layer in layers_arr { + if let Some(mark) = layer.get_mut("mark") { + if mark.is_string() { + let mark_type = mark.as_str().unwrap().to_string(); + *mark = json!({"type": mark_type, "clip": clip}); + } else if let Some(obj) = mark.as_object_mut() { + obj.insert("clip".to_string(), json!(clip)); + } + } + } + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::Projection; + + #[test] + fn test_map_position_to_vegalite_cartesian() { + let renderer = CartesianProjection { is_faceted: false }; + assert_eq!( + map_position_to_vegalite("pos1", &renderer), + Some("x".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos2", &renderer), + Some("y".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos1end", &renderer), + Some("x2".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos2end", &renderer), + Some("y2".to_string()) + ); + assert_eq!(map_position_to_vegalite("color", &renderer), None); + assert_eq!(renderer.offset_channels(), ("xOffset", "yOffset")); + assert_eq!( + renderer.panel_size(), + Some((json!("container"), json!("container"))) + ); + } + + #[test] + fn test_map_position_to_vegalite_polar() { + let renderer = PolarProjection { + panel: PolarContext::new(None, None, &[]), + }; + assert_eq!( + map_position_to_vegalite("pos1", &renderer), + Some("radius".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos2", &renderer), + Some("theta".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos1end", &renderer), + Some("radius2".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos2end", &renderer), + Some("theta2".to_string()) + ); + assert_eq!(renderer.offset_channels(), ("radiusOffset", "thetaOffset")); + assert_eq!( + renderer.panel_size(), + Some((json!("container"), json!("container"))) + ); + } + + #[test] + fn test_get_projection_renderer() { + let cartesian = get_projection_renderer(None, None, &[]); + assert_eq!(cartesian.position_channels(), ("x", "y")); + + let polar_proj = Projection::polar(); + let polar = get_projection_renderer(Some(&polar_proj), None, &[]); + assert_eq!(polar.position_channels(), ("radius", "theta")); + } +} diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection/polar.rs similarity index 85% rename from src/writer/vegalite/projection.rs rename to src/writer/vegalite/projection/polar.rs index 6f4023894..5f352ad3d 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection/polar.rs @@ -1,180 +1,15 @@ -//! Projection rendering for Vega-Lite writer +//! Polar projection implementation for Vega-Lite writer //! -//! This module provides a trait-based design for projection rendering. -//! Each projection type (cartesian, polar, and future map projections) -//! implements `ProjectionRenderer`, which owns both the VL channel mapping -//! and the spec-level transformations for that projection. +//! Handles radius/theta coordinate transformations for pie charts, rose plots, +//! and other circular visualizations. -use crate::plot::{CoordKind, ParameterValue, Projection, Scale}; +use crate::plot::{ParameterValue, Projection, Scale}; use crate::{GgsqlError, Plot, Result}; use serde_json::{json, Value}; -use super::DEFAULT_POLAR_SIZE; - -const ANGLE_TOLERANCE: f64 = 1.49011611938476e-08; // f64::EPSILON.sqrt() - -// ============================================================================= -// ProjectionRenderer trait -// ============================================================================= - -/// Trait defining how a projection type maps to Vega-Lite. -/// -/// Each implementation owns two concerns: -/// 1. **Channel mapping** — translating internal position aesthetics (pos1, pos2, …) -/// to Vega-Lite encoding channel names. -/// 2. **Spec transformation** — modifying the Vega-Lite spec after layers are built -/// (e.g., converting marks to arcs for polar). -pub(super) trait ProjectionRenderer { - /// Whether the spec uses faceting. - fn is_faceted(&self) -> bool; - - /// Primary and secondary VL channel names for this projection. - /// - /// Returns `(pos1_channel, pos2_channel)`, e.g. `("x", "y")` for cartesian, - /// `("radius", "theta")` for polar. - fn position_channels(&self) -> (&'static str, &'static str); - - /// Offset channel names for this projection. - /// - /// Returns `(pos1_offset, pos2_offset)`, e.g. `("xOffset", "yOffset")`. - fn offset_channels(&self) -> (&'static str, &'static str); - - /// Panel dimensions as VL values (`"container"` or explicit pixels). - /// - /// Returns `None` for faceted cartesian (VL handles sizing). - fn panel_size(&self) -> Option<(Value, Value)> { - if self.is_faceted() { - None - } else { - Some((json!("container"), json!("container"))) - } - } - - /// Apply projection-specific transformations to the VL spec. - /// - /// Called after layers are built but before faceting. - fn transform_layers(&self, _spec: &Plot, _vl_spec: &mut Value) -> Result<()> { - Ok(()) - } - - /// Vega-Lite layers to prepend before the data layers. - fn background_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { - Vec::new() - } - - /// Vega-Lite layers to append after the data layers. - fn foreground_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { - Vec::new() - } - - /// Apply all projection-specific work: transforms, clip, and panel decoration. - fn apply_projection(&self, spec: &Plot, theme: &mut Value, vl_spec: &mut Value) -> Result<()> { - self.transform_layers(spec, vl_spec)?; - - if let Some(ref project) = spec.project { - if let Some(ParameterValue::Boolean(clip)) = project.properties.get("clip") { - apply_clip_to_layers(vl_spec, *clip); - } - } - - let mut bg = self.background_layers(&spec.scales, theme); - let mut fg = self.foreground_layers(&spec.scales, theme); - if !(bg.is_empty() && fg.is_empty()) { - for layer in &mut bg { - layer["description"] = json!("background"); - } - for layer in &mut fg { - layer["description"] = json!("foreground"); - } - if let Some(layers) = get_layers_mut(vl_spec) { - let data_layers = std::mem::take(layers); - layers.reserve(bg.len() + data_layers.len() + fg.len()); - layers.extend(bg); - layers.extend(data_layers); - layers.extend(fg); - } - } - - Ok(()) - } -} - -// ============================================================================= -// Factory -// ============================================================================= - -/// Get the projection renderer for a projection spec. -/// -/// Returns the appropriate renderer based on the projection's coord kind, -/// or a Cartesian renderer if no projection is specified. -pub(super) fn get_projection_renderer( - project: Option<&Projection>, - facet: Option<&crate::plot::Facet>, - scales: &[Scale], -) -> Box { - let is_faceted = facet.is_some_and(|f| !f.get_variables().is_empty()); - match project.map(|p| p.coord.coord_kind()) { - Some(CoordKind::Polar) => Box::new(PolarProjection { - panel: PolarContext::new(project, facet, scales), - }), - Some(CoordKind::Map) => todo!("map projection rendering"), - Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection { is_faceted }), - } -} - -// ============================================================================= -// Channel mapping helpers (used by encoding.rs via the trait) -// ============================================================================= - -/// Map internal position aesthetic to Vega-Lite channel name using the renderer. -/// -/// Returns `Some(channel_name)` for internal position aesthetics (pos1, pos2, etc.), -/// or `None` for material aesthetics. -pub(super) fn map_position_to_vegalite( - aesthetic: &str, - renderer: &dyn ProjectionRenderer, -) -> Option { - let (primary, secondary) = renderer.position_channels(); - - // Match internal position aesthetic patterns - // Convention: min → primary channel (x/y), max → secondary channel (x2/y2) - match aesthetic { - // Primary position and min variants - "pos1" | "pos1min" => Some(primary.to_string()), - "pos2" | "pos2min" => Some(secondary.to_string()), - // End and max variants (Vega-Lite uses x2/y2/theta2/radius2) - "pos1end" | "pos1max" => Some(format!("{}2", primary)), - "pos2end" | "pos2max" => Some(format!("{}2", secondary)), - _ => None, - } -} - -// ============================================================================= -// CartesianProjection -// ============================================================================= - -/// Cartesian projection — standard x/y coordinates. -struct CartesianProjection { - is_faceted: bool, -} - -impl ProjectionRenderer for CartesianProjection { - fn is_faceted(&self) -> bool { - self.is_faceted - } - - fn position_channels(&self) -> (&'static str, &'static str) { - ("x", "y") - } - - fn offset_channels(&self) -> (&'static str, &'static str) { - ("xOffset", "yOffset") - } -} - -// ============================================================================= -// PolarProjection -// ============================================================================= +use super::super::escape_vega_string; +use super::super::DEFAULT_POLAR_SIZE; +use super::{get_layers_mut, AxisInfo, ProjectionRenderer, ANGLE_TOLERANCE}; /// Normalized outer radius (proportion of `min(width, height) / 2`). const POLAR_OUTER: f64 = 1.0; @@ -183,32 +18,6 @@ const POLAR_OUTER: f64 = 1.0; /// `1 - paddingInner` for band scales, which is ~0.9). const POLAR_BAND_FRACTION: f64 = 0.9; -struct AxisInfo { - domain: Option<(f64, f64)>, - breaks: Vec, - labels: Vec<(f64, String)>, - is_free: bool, -} - -impl AxisInfo { - fn new(aesthetic: &str, scales: &[Scale], facet: Option<&crate::plot::Facet>) -> Self { - let (domain, labels) = match scales.iter().find(|s| s.aesthetic == aesthetic) { - Some(s) => (s.numeric_domain(), s.break_labels()), - None => (None, Vec::new()), - }; - // Set domain to None if zero-range - let domain = domain.filter(|(min, max)| (max - min).abs() > f64::EPSILON); - let breaks = labels.iter().map(|(v, _)| *v).collect(); - let is_free = facet.is_some_and(|f| f.is_free(aesthetic)); - Self { - domain, - breaks, - labels, - is_free, - } - } -} - /// Resolved geometry and scale context for polar specs. /// /// Holds angular range, radius bounds, VL expression strings for the panel @@ -216,7 +25,7 @@ impl AxisInfo { /// both position channels. In non-faceted specs the expression strings /// reference `width`/`height` signals; in faceted specs they are literal /// pixel values (VL signals don't resolve inside faceted inner specs). -struct PolarContext { +pub(in crate::writer) struct PolarContext { // Panel shape start: f64, end: f64, @@ -241,7 +50,7 @@ struct PolarContext { } impl PolarContext { - fn new( + pub(super) fn new( project: Option<&Projection>, facet: Option<&crate::plot::Facet>, scales: &[Scale], @@ -351,8 +160,8 @@ impl PolarContext { } /// Polar projection — radius/theta coordinates for pie charts, rose plots, etc. -struct PolarProjection { - panel: PolarContext, +pub(in crate::writer) struct PolarProjection { + pub(super) panel: PolarContext, } impl ProjectionRenderer for PolarProjection { @@ -992,43 +801,6 @@ fn polygon_ring( }) } -// ============================================================================= -// Shared helpers -// ============================================================================= - -/// Get mutable reference to the layers array, handling both flat and faceted specs. -/// -/// In a flat spec: `vl_spec["layer"]` -/// In a faceted spec: `vl_spec["spec"]["layer"]` -fn get_layers_mut(vl_spec: &mut Value) -> Option<&mut Vec> { - // Try flat structure first, then faceted - if vl_spec.get("layer").is_some() { - vl_spec.get_mut("layer").and_then(|l| l.as_array_mut()) - } else { - vl_spec - .get_mut("spec") - .and_then(|s| s.get_mut("layer")) - .and_then(|l| l.as_array_mut()) - } -} - -/// Apply clip setting to all layers -fn apply_clip_to_layers(vl_spec: &mut Value, clip: bool) { - if let Some(layers_arr) = get_layers_mut(vl_spec) { - for layer in layers_arr { - if let Some(mark) = layer.get_mut("mark") { - if mark.is_string() { - // Convert "point" to {"type": "point", "clip": ...} - let mark_type = mark.as_str().unwrap().to_string(); - *mark = json!({"type": mark_type, "clip": clip}); - } else if let Some(obj) = mark.as_object_mut() { - obj.insert("clip".to_string(), json!(clip)); - } - } - } - } -} - // ============================================================================= // Polar projection transformation // ============================================================================= @@ -1392,7 +1164,7 @@ fn extract_polar_channel( if !strings.is_empty() { let literal: String = strings .iter() - .map(|s| format!("'{}'", super::escape_vega_string(s))) + .map(|s| format!("'{}'", escape_vega_string(s))) .collect::>() .join(","); let arr_expr = format!("[{}]", literal); @@ -1536,7 +1308,7 @@ fn apply_polar_radius_range(encoding: &mut Value, panel: &PolarContext) -> Resul #[cfg(test)] mod tests { use super::*; - use crate::plot::{Facet, FacetLayout}; + use crate::plot::{Facet, FacetLayout, ParameterValue, Projection}; fn faceted() -> Facet { Facet::new(FacetLayout::Wrap { @@ -1546,7 +1318,6 @@ mod tests { #[test] fn test_polar_inner_radius_non_faceted() { - // Non-faceted donut should use dynamic min(width,height) expressions let mut encoding = json!({ "radius": { "field": "dummy", @@ -1575,7 +1346,6 @@ mod tests { #[test] fn test_polar_inner_radius_faceted() { - // Faceted donut should use explicit size calculation let mut encoding = json!({ "radius": { "field": "dummy", @@ -1601,7 +1371,6 @@ mod tests { #[test] fn test_polar_inner_radius_zero() { - // inner = 0 should still apply range (full pie, no donut hole) let mut encoding = json!({ "radius": { "field": "dummy", @@ -1617,68 +1386,12 @@ mod tests { let panel = PolarContext::new(Some(&proj), Some(&f), &[]); apply_polar_radius_range(&mut encoding, &panel).unwrap(); - // Range should be [0, 350/2] for full pie let range = encoding["radius"]["scale"]["range"].as_array().unwrap(); assert_eq!(range.len(), 2); assert_eq!(range[0]["expr"].as_str().unwrap(), "175 * (0)"); assert_eq!(range[1]["expr"].as_str().unwrap(), "175 * (1)"); } - #[test] - fn test_map_position_to_vegalite_cartesian() { - let renderer = CartesianProjection { is_faceted: false }; - assert_eq!( - map_position_to_vegalite("pos1", &renderer), - Some("x".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos2", &renderer), - Some("y".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos1end", &renderer), - Some("x2".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos2end", &renderer), - Some("y2".to_string()) - ); - assert_eq!(map_position_to_vegalite("color", &renderer), None); - assert_eq!(renderer.offset_channels(), ("xOffset", "yOffset")); - assert_eq!( - renderer.panel_size(), - Some((json!("container"), json!("container"))) - ); - } - - #[test] - fn test_map_position_to_vegalite_polar() { - let renderer = PolarProjection { - panel: PolarContext::new(None, None, &[]), - }; - assert_eq!( - map_position_to_vegalite("pos1", &renderer), - Some("radius".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos2", &renderer), - Some("theta".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos1end", &renderer), - Some("radius2".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos2end", &renderer), - Some("theta2".to_string()) - ); - assert_eq!(renderer.offset_channels(), ("radiusOffset", "thetaOffset")); - assert_eq!( - renderer.panel_size(), - Some((json!("container"), json!("container"))) - ); - } - fn continuous_panel() -> PolarContext { let mut panel = PolarContext::new(None, None, &[]); panel.radial.domain = Some((0.0, 10.0)); @@ -1719,7 +1432,6 @@ mod tests { let transforms = layer["transform"].as_array().unwrap(); - // Should contain pixel-coordinate expressions using width/height signals let x_calc = transforms .iter() .find(|t| t["as"] == "__polar_x__") @@ -1740,11 +1452,9 @@ mod tests { "y should use pixel coordinates, got: {y_expr}" ); - // Encoding should use scale:null (raw pixel positions) assert_eq!(layer["encoding"]["x"]["scale"], json!(null)); assert_eq!(layer["encoding"]["y"]["scale"], json!(null)); - // Original polar channels should be removed assert!(layer["encoding"].get("radius").is_none()); assert!(layer["encoding"].get("theta").is_none()); } @@ -1769,24 +1479,12 @@ mod tests { ); } - #[test] - fn test_get_projection_renderer() { - let cartesian = get_projection_renderer(None, None, &[]); - assert_eq!(cartesian.position_channels(), ("x", "y")); - - let polar_proj = Projection::polar(); - let polar = get_projection_renderer(Some(&polar_proj), None, &[]); - assert_eq!(polar.position_channels(), ("radius", "theta")); - } - #[test] fn test_expr_normalize_radius() { let mut p = PolarContext::new(None, None, &[]); - // domain [0, 10], inner 0.2 p.inner = 0.2; p.radial.domain = Some((0.0, 10.0)); - // scale = (1.0 - 0.2) / (10 - 0) = 0.08 let expr = p.expr_normalize_radius("datum.v"); assert!( expr.contains("0.08"), @@ -1797,7 +1495,6 @@ mod tests { "should reference value, got: {expr}" ); - // domain [5, 15], inner 0 → scale = 1.0 / 10 = 0.1 p.inner = 0.0; p.radial.domain = Some((5.0, 15.0)); let expr = p.expr_normalize_radius("datum.x"); @@ -1806,7 +1503,6 @@ mod tests { "scale factor should be 0.1, got: {expr}" ); - // None domain → fallback to midpoint p.radial.domain = None; let expr = p.expr_normalize_radius("datum.v"); assert!( @@ -1819,13 +1515,11 @@ mod tests { fn test_expr_normalize_theta() { use std::f64::consts::PI; - // domain [0, 100], partial circle 90°–270° (π/2 to 3π/2) let mut panel = PolarContext::new(None, None, &[]); panel.start = PI / 2.0; panel.end = 3.0 * PI / 2.0; panel.angle.domain = Some((0.0, 100.0)); let expr = panel.expr_normalize_theta("datum.v"); - // scale = (3π/2 - π/2) / (100 - 0) = π / 100 ≈ 0.031416 let expected_scale = PI / 100.0; assert!( expr.contains(&format!("{expected_scale}")), @@ -1862,20 +1556,17 @@ mod tests { let layer = &layers[0]; - // Data should contain the break values let values = layer["data"]["values"].as_array().unwrap(); assert_eq!(values.len(), 3); assert_eq!(values[0]["v"], json!(25.0)); assert_eq!(values[1]["v"], json!(50.0)); assert_eq!(values[2]["v"], json!(75.0)); - // Mark should be a stroke-only arc assert_eq!(layer["mark"]["type"], "arc"); assert_eq!(layer["mark"]["fill"], json!(null)); assert_eq!(layer["mark"]["stroke"], "#FFF"); assert_eq!(layer["mark"]["strokeWidth"], 2.0); - // Radius encoding should use an expression let radius_expr = layer["encoding"]["radius"]["value"]["expr"] .as_str() .unwrap(); @@ -1896,21 +1587,17 @@ mod tests { let layer = &layers[0]; - // Data should contain the break values let values = layer["data"]["values"].as_array().unwrap(); assert_eq!(values.len(), 2); - // Mark should be a rule assert_eq!(layer["mark"]["type"], "rule"); assert_eq!(layer["mark"]["stroke"], "#CCC"); - // Should have calculate transforms for x, y, x2, y2 let transforms = layer["transform"].as_array().unwrap(); assert_eq!(transforms.len(), 4); let field_names: Vec<&str> = transforms.iter().filter_map(|t| t["as"].as_str()).collect(); assert_eq!(field_names, vec!["x", "y", "x2", "y2"]); - // Encoding should use scale:null for pixel positions assert_eq!(layer["encoding"]["x"]["scale"], json!(null)); assert_eq!(layer["encoding"]["y"]["scale"], json!(null)); } @@ -1939,7 +1626,6 @@ mod tests { "should produce axis line, ticks, and labels" ); - // Layer 0: axis line (single rule from inner to outer) let line = &layers[0]; assert_eq!(line["mark"]["type"], "rule"); assert_eq!(line["data"]["values"].as_array().unwrap().len(), 1); @@ -1947,7 +1633,6 @@ mod tests { let fields: Vec<&str> = transforms.iter().filter_map(|t| t["as"].as_str()).collect(); assert_eq!(fields, vec!["x", "y", "x2", "y2"]); - // Layer 1: ticks (one per break) let ticks = &layers[1]; assert_eq!(ticks["mark"]["type"], "rule"); assert_eq!(ticks["data"]["values"].as_array().unwrap().len(), 3); @@ -1958,7 +1643,6 @@ mod tests { .collect(); assert_eq!(tick_fields, vec!["cx", "cy", "x", "y", "x2", "y2"]); - // Layer 2: labels (one per break) let labels = &layers[2]; assert_eq!(labels["mark"]["type"], "text"); assert_eq!(labels["data"]["values"].as_array().unwrap().len(), 3); @@ -2005,12 +1689,10 @@ mod tests { "should produce axis arc, ticks, and labels" ); - // Layer 0: axis arc along outer edge let arc = &layers[0]; assert_eq!(arc["mark"]["type"], "arc"); assert_eq!(arc["mark"]["fill"], json!(null)); - // Layer 1: ticks (one per break) let ticks = &layers[1]; assert_eq!(ticks["mark"]["type"], "rule"); assert_eq!(ticks["data"]["values"].as_array().unwrap().len(), 3); @@ -2021,7 +1703,6 @@ mod tests { .collect(); assert_eq!(tick_fields, vec!["theta", "cx", "cy", "x", "y", "x2", "y2"]); - // Layer 2: nested label layer with shared data/transforms/encoding let labels = &layers[2]; assert_eq!(labels["encoding"]["text"]["field"], "label"); assert_eq!(labels["data"]["values"].as_array().unwrap().len(), 3); @@ -2034,7 +1715,6 @@ mod tests { assert_eq!(sub["mark"]["type"], "text"); assert!(sub["mark"]["align"].is_string()); assert!(sub["mark"]["baseline"].is_string()); - // Each sub-layer filters by alignment tag assert!(sub["transform"] .as_array() .unwrap() @@ -2060,14 +1740,6 @@ mod tests { // ========================================================================= // Free scales suppress polar decorations - // - // Polar grid lines and axes are drawn as manual VL layers whose positions - // are computed from the global scale domain. With free scales each facet - // panel has its own domain, so the global positions would be wrong. - // Rather than rendering misleading decorations we suppress them entirely. - // Proper per-panel decorations would require computing per-group domains - // and threading them into the decoration data — a significant lift that - // is not yet implemented. // ========================================================================= fn facet_with_free(free: Vec) -> Facet { @@ -2223,7 +1895,6 @@ mod tests { convert_polar_to_cartesian(&mut layer, &panel).unwrap(); - // 3 categories → domain (0.5, 3.5), full circle → scale = 2π / 3.0 let transforms = layer["transform"].as_array().unwrap(); let theta_calc = transforms .iter() @@ -2427,8 +2098,6 @@ mod tests { convert_polar_to_cartesian(&mut layer, &panel).unwrap(); - // 3 categories → domain (0.5, 3.5), scale = 2π/3 - // With band fraction 0.9: effective scale = 2π/3 * 0.9 let expected = 2.0 * std::f64::consts::PI / 3.0 * POLAR_BAND_FRACTION; let transforms = layer["transform"].as_array().unwrap(); let x_calc = transforms @@ -2467,7 +2136,6 @@ mod tests { convert_polar_to_cartesian(&mut layer, &panel).unwrap(); - // Continuous → full scale = 2π/100, no band fraction let full_scale = 2.0 * std::f64::consts::PI / 100.0; let with_band = full_scale * POLAR_BAND_FRACTION; let transforms = layer["transform"].as_array().unwrap(); @@ -2518,7 +2186,6 @@ mod tests { "should produce axis line, ticks, and labels" ); - // Label data should carry category names, not numeric positions let labels = &layers[2]; let values = labels["data"]["values"].as_array().unwrap(); assert_eq!(values.len(), 3); @@ -2526,7 +2193,6 @@ mod tests { assert_eq!(values[1]["label"], "mid"); assert_eq!(values[2]["label"], "high"); - // Numeric positions should be 1, 2, 3 assert_eq!(values[0]["v"], 1.0); assert_eq!(values[1]["v"], 2.0); assert_eq!(values[2]["v"], 3.0); @@ -2549,7 +2215,6 @@ mod tests { "should produce axis arc, ticks, and labels" ); - // Label data should carry category names let labels = &layers[2]; let values = labels["data"]["values"].as_array().unwrap(); assert_eq!(values.len(), 3); @@ -2603,7 +2268,6 @@ mod tests { let f = faceted(); let panel = PolarContext::new(Some(&proj), Some(&f), &[]); - // Faceted panel should use literal pixel values, not width/height signals assert_eq!(panel.cx, "150"); assert_eq!(panel.cy, "150"); assert_eq!(panel.radius, "150"); @@ -2625,7 +2289,6 @@ mod tests { let layers = proj.grid_rings(&theme); assert_eq!(layers.len(), 1); - // Radius expression should use literal pixels (150), not signals let radius_expr = layers[0]["encoding"]["radius"]["value"]["expr"] .as_str() .unwrap(); @@ -2659,8 +2322,6 @@ mod tests { let panel = PolarContext::new(None, None, &scales); let thetas = &panel.angle_breaks_radians; assert_eq!(thetas.len(), 3); - // 3 categories → domain (0.5, 3.5), breaks at 1, 2, 3 - // theta = 0 + 2π/3 * (break - 0.5) let scale = 2.0 * PI / 3.0; assert!((thetas[0] - scale * 0.5).abs() < 1e-10); assert!((thetas[1] - scale * 1.5).abs() < 1e-10); @@ -2680,7 +2341,6 @@ mod tests { panel.angle_breaks_radians = vec![1.0, 2.0, 3.0]; let layer = polygon_ring(&panel, POLAR_OUTER, None, Value::Null, json!("red")); let values = layer["data"]["values"].as_array().unwrap(); - // 3 thetas + 1 closing vertex = 4 assert_eq!(values.len(), 4); assert_eq!(values[0]["theta"], values[3]["theta"]); } @@ -2696,9 +2356,7 @@ mod tests { panel.angle_breaks_radians = vec![0.5, 1.0, 1.5]; let layer = polygon_ring(&panel, POLAR_OUTER, None, Value::Null, json!("red")); let values = layer["data"]["values"].as_array().unwrap(); - // start + 3 breaks + end + centre(end) + centre(start) + close = 8 assert_eq!(values.len(), 8); - // First and last vertex should be at the same position (closed path) assert_eq!(values[0]["theta"], values[7]["theta"]); assert_eq!(values[0]["r"], values[7]["r"]); } @@ -2718,9 +2376,7 @@ mod tests { let r_start = values[0]["r"].as_f64().unwrap(); let r_break = values[1]["r"].as_f64().unwrap(); let r_end = values[2]["r"].as_f64().unwrap(); - // Break vertex at full radius assert!((r_break - POLAR_OUTER).abs() < 1e-10); - // Start/end vertices corrected inward by cos(π/2) let expected = POLAR_OUTER * (PI / 2.0).cos(); assert!((r_start - expected).abs() < 1e-10); assert!((r_end - expected).abs() < 1e-10); @@ -2732,7 +2388,6 @@ mod tests { panel.angle_breaks_radians = vec![1.0, 2.0, 3.0]; let layer = polygon_ring(&panel, POLAR_OUTER, Some(0.3), json!("white"), Value::Null); let values = layer["data"]["values"].as_array().unwrap(); - // Outer: 3 + 1 closing, Inner: 3 + 1 closing = 8 assert_eq!(values.len(), 8); assert_eq!(layer["mark"]["type"], "line"); assert_eq!(layer["mark"]["fill"], "white"); @@ -2805,7 +2460,6 @@ mod tests { let theme = json!({"axis": {"domainColor": "#333"}}); let layers = proj.angular_axis(&theme); assert!(!layers.is_empty()); - // First layer should be the polygon outline, not an arc assert_eq!(layers[0]["mark"]["type"], "line"); } From 5f1a8f86708d41ab85c2ac319a91110f3f6fe513 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 7 May 2026 14:55:19 +0200 Subject: [PATCH 06/50] Encapsulate projection constructors behind ::new() Each projection type now takes `facet` and deduces its own state, keeping internals (PolarContext, is_faceted) private to their files. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection/cartesian.rs | 10 +++++++++- src/writer/vegalite/projection/mod.rs | 15 ++++----------- src/writer/vegalite/projection/polar.rs | 18 +++++++++++++++--- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/writer/vegalite/projection/cartesian.rs b/src/writer/vegalite/projection/cartesian.rs index 5022b599f..5ff234556 100644 --- a/src/writer/vegalite/projection/cartesian.rs +++ b/src/writer/vegalite/projection/cartesian.rs @@ -2,7 +2,15 @@ use super::ProjectionRenderer; /// Cartesian projection — standard x/y coordinates. pub(in crate::writer) struct CartesianProjection { - pub is_faceted: bool, + is_faceted: bool, +} + +impl CartesianProjection { + pub(super) fn new(facet: Option<&crate::plot::Facet>) -> Self { + Self { + is_faceted: facet.is_some_and(|f| !f.get_variables().is_empty()), + } + } } impl ProjectionRenderer for CartesianProjection { diff --git a/src/writer/vegalite/projection/mod.rs b/src/writer/vegalite/projection/mod.rs index 8237df841..ca8c18311 100644 --- a/src/writer/vegalite/projection/mod.rs +++ b/src/writer/vegalite/projection/mod.rs @@ -15,8 +15,6 @@ use serde_json::{json, Value}; use cartesian::CartesianProjection; use polar::PolarProjection; -pub(in crate::writer) use polar::PolarContext; - const ANGLE_TOLERANCE: f64 = 1.49011611938476e-08; // f64::EPSILON.sqrt() // ============================================================================= @@ -118,13 +116,10 @@ pub(super) fn get_projection_renderer( facet: Option<&crate::plot::Facet>, scales: &[Scale], ) -> Box { - let is_faceted = facet.is_some_and(|f| !f.get_variables().is_empty()); match project.map(|p| p.coord.coord_kind()) { - Some(CoordKind::Polar) => Box::new(PolarProjection { - panel: PolarContext::new(project, facet, scales), - }), + Some(CoordKind::Polar) => Box::new(PolarProjection::new(project, facet, scales)), Some(CoordKind::Map) => todo!("map projection rendering"), - Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection { is_faceted }), + Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection::new(facet)), } } @@ -226,7 +221,7 @@ mod tests { #[test] fn test_map_position_to_vegalite_cartesian() { - let renderer = CartesianProjection { is_faceted: false }; + let renderer = CartesianProjection::new(None); assert_eq!( map_position_to_vegalite("pos1", &renderer), Some("x".to_string()) @@ -253,9 +248,7 @@ mod tests { #[test] fn test_map_position_to_vegalite_polar() { - let renderer = PolarProjection { - panel: PolarContext::new(None, None, &[]), - }; + let renderer = PolarProjection::new(None, None, &[]); assert_eq!( map_position_to_vegalite("pos1", &renderer), Some("radius".to_string()) diff --git a/src/writer/vegalite/projection/polar.rs b/src/writer/vegalite/projection/polar.rs index 5f352ad3d..53e7b37a1 100644 --- a/src/writer/vegalite/projection/polar.rs +++ b/src/writer/vegalite/projection/polar.rs @@ -25,7 +25,7 @@ const POLAR_BAND_FRACTION: f64 = 0.9; /// both position channels. In non-faceted specs the expression strings /// reference `width`/`height` signals; in faceted specs they are literal /// pixel values (VL signals don't resolve inside faceted inner specs). -pub(in crate::writer) struct PolarContext { +struct PolarContext { // Panel shape start: f64, end: f64, @@ -50,7 +50,7 @@ pub(in crate::writer) struct PolarContext { } impl PolarContext { - pub(super) fn new( + fn new( project: Option<&Projection>, facet: Option<&crate::plot::Facet>, scales: &[Scale], @@ -161,7 +161,19 @@ impl PolarContext { /// Polar projection — radius/theta coordinates for pie charts, rose plots, etc. pub(in crate::writer) struct PolarProjection { - pub(super) panel: PolarContext, + panel: PolarContext, +} + +impl PolarProjection { + pub(super) fn new( + project: Option<&Projection>, + facet: Option<&crate::plot::Facet>, + scales: &[Scale], + ) -> Self { + Self { + panel: PolarContext::new(project, facet, scales), + } + } } impl ProjectionRenderer for PolarProjection { From b3c5ba2df9f59bf08ea073c5ce7b1b0de9463890 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 7 May 2026 15:01:29 +0200 Subject: [PATCH 07/50] Move map_position_to_vegalite into ProjectionRenderer trait Replace the free function with a default method `map_position()` on the trait, so callers use `renderer.map_position(aesthetic)` directly. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/encoding.rs | 10 ++-- src/writer/vegalite/projection/mod.rs | 83 +++++++++------------------ 2 files changed, 31 insertions(+), 62 deletions(-) diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 92c9b4b56..34d98e92f 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -965,7 +965,7 @@ pub(super) fn map_aesthetic_name( ) -> String { // For internal position aesthetics, map directly to Vega-Lite channel names // based on coord type (ignoring user-facing names) - if let Some(vl_channel) = super::projection::map_position_to_vegalite(aesthetic, renderer) { + if let Some(vl_channel) = renderer.map_position(aesthetic) { return vl_channel; } @@ -1014,10 +1014,10 @@ impl<'a> RenderContext<'a> { renderer: &dyn super::projection::ProjectionRenderer, aesthetic_context: crate::plot::aesthetic::AestheticContext, ) -> Self { - let pos1 = super::projection::map_position_to_vegalite("pos1", renderer).unwrap(); - let pos1_end = super::projection::map_position_to_vegalite("pos1end", renderer).unwrap(); - let pos2 = super::projection::map_position_to_vegalite("pos2", renderer).unwrap(); - let pos2_end = super::projection::map_position_to_vegalite("pos2end", renderer).unwrap(); + let pos1 = renderer.map_position("pos1").unwrap(); + let pos1_end = renderer.map_position("pos1end").unwrap(); + let pos2 = renderer.map_position("pos2").unwrap(); + let pos2_end = renderer.map_position("pos2end").unwrap(); let (pos1_offset, pos2_offset) = renderer.offset_channels(); diff --git a/src/writer/vegalite/projection/mod.rs b/src/writer/vegalite/projection/mod.rs index ca8c18311..5aebaaddb 100644 --- a/src/writer/vegalite/projection/mod.rs +++ b/src/writer/vegalite/projection/mod.rs @@ -43,6 +43,21 @@ pub(super) trait ProjectionRenderer { /// Returns `(pos1_offset, pos2_offset)`, e.g. `("xOffset", "yOffset")`. fn offset_channels(&self) -> (&'static str, &'static str); + /// Map internal position aesthetic to Vega-Lite channel name. + /// + /// Returns `Some(channel_name)` for internal position aesthetics (pos1, pos2, etc.), + /// or `None` for material aesthetics. + fn map_position(&self, aesthetic: &str) -> Option { + let (primary, secondary) = self.position_channels(); + match aesthetic { + "pos1" | "pos1min" => Some(primary.to_string()), + "pos2" | "pos2min" => Some(secondary.to_string()), + "pos1end" | "pos1max" => Some(format!("{}2", primary)), + "pos2end" | "pos2max" => Some(format!("{}2", secondary)), + _ => None, + } + } + /// Panel dimensions as VL values (`"container"` or explicit pixels). /// /// Returns `None` for faceted cartesian (VL handles sizing). @@ -123,28 +138,6 @@ pub(super) fn get_projection_renderer( } } -// ============================================================================= -// Channel mapping helpers (used by encoding.rs via the trait) -// ============================================================================= - -/// Map internal position aesthetic to Vega-Lite channel name using the renderer. -/// -/// Returns `Some(channel_name)` for internal position aesthetics (pos1, pos2, etc.), -/// or `None` for material aesthetics. -pub(super) fn map_position_to_vegalite( - aesthetic: &str, - renderer: &dyn ProjectionRenderer, -) -> Option { - let (primary, secondary) = renderer.position_channels(); - - match aesthetic { - "pos1" | "pos1min" => Some(primary.to_string()), - "pos2" | "pos2min" => Some(secondary.to_string()), - "pos1end" | "pos1max" => Some(format!("{}2", primary)), - "pos2end" | "pos2max" => Some(format!("{}2", secondary)), - _ => None, - } -} // ============================================================================= // AxisInfo — reusable across projection types @@ -220,25 +213,13 @@ mod tests { use crate::plot::Projection; #[test] - fn test_map_position_to_vegalite_cartesian() { + fn test_map_position_cartesian() { let renderer = CartesianProjection::new(None); - assert_eq!( - map_position_to_vegalite("pos1", &renderer), - Some("x".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos2", &renderer), - Some("y".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos1end", &renderer), - Some("x2".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos2end", &renderer), - Some("y2".to_string()) - ); - assert_eq!(map_position_to_vegalite("color", &renderer), None); + assert_eq!(renderer.map_position("pos1"), Some("x".to_string())); + assert_eq!(renderer.map_position("pos2"), Some("y".to_string())); + assert_eq!(renderer.map_position("pos1end"), Some("x2".to_string())); + assert_eq!(renderer.map_position("pos2end"), Some("y2".to_string())); + assert_eq!(renderer.map_position("color"), None); assert_eq!(renderer.offset_channels(), ("xOffset", "yOffset")); assert_eq!( renderer.panel_size(), @@ -247,24 +228,12 @@ mod tests { } #[test] - fn test_map_position_to_vegalite_polar() { + fn test_map_position_polar() { let renderer = PolarProjection::new(None, None, &[]); - assert_eq!( - map_position_to_vegalite("pos1", &renderer), - Some("radius".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos2", &renderer), - Some("theta".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos1end", &renderer), - Some("radius2".to_string()) - ); - assert_eq!( - map_position_to_vegalite("pos2end", &renderer), - Some("theta2".to_string()) - ); + assert_eq!(renderer.map_position("pos1"), Some("radius".to_string())); + assert_eq!(renderer.map_position("pos2"), Some("theta".to_string())); + assert_eq!(renderer.map_position("pos1end"), Some("radius2".to_string())); + assert_eq!(renderer.map_position("pos2end"), Some("theta2".to_string())); assert_eq!(renderer.offset_channels(), ("radiusOffset", "thetaOffset")); assert_eq!( renderer.panel_size(), From a4a4cce23f08bc817f89a816d86bfa76ff69a35e Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 8 May 2026 09:47:01 +0200 Subject: [PATCH 08/50] Implement MapProjection renderer with identity projection Adds the Vega-Lite map projection renderer that emits an identity projection with reflectY for pre-projected spatial data. Fixes a 90-degree rotation bug by passing always_xy := true to ST_Transform, ensuring DuckDB uses [easting, northing] axis order regardless of CRS definition. Co-Authored-By: Claude Opus 4.6 --- src/reader/mod.rs | 2 +- src/writer/vegalite/projection/map.rs | 92 +++++++++++++++++++++++++++ src/writer/vegalite/projection/mod.rs | 4 +- 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/writer/vegalite/projection/map.rs diff --git a/src/reader/mod.rs b/src/reader/mod.rs index d98cef3ce..37c49ec45 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -127,7 +127,7 @@ pub trait SqlDialect { /// Default uses `ST_Transform(column, source_crs, target_crs)` which works for DuckDB. fn sql_st_transform(&self, column: &str, source_crs: &str, target_crs: &str) -> String { format!( - "ST_Transform({}, '{}', '{}')", + "ST_Transform({}, '{}', '{}', always_xy := true)", column, source_crs.replace('\'', "''"), target_crs.replace('\'', "''") diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs new file mode 100644 index 000000000..6e729ac22 --- /dev/null +++ b/src/writer/vegalite/projection/map.rs @@ -0,0 +1,92 @@ +//! Map projection implementation for Vega-Lite writer +//! +//! For data that has been pre-projected server-side (via ST_Transform), Vega-Lite +//! must use an identity projection so it passes coordinates through without +//! re-projecting via d3-geo. + +use crate::{Plot, Result}; +use serde_json::{json, Value}; + +use super::ProjectionRenderer; + +/// Map projection — pre-projected spatial coordinates. +pub(in crate::writer) struct MapProjection { + is_faceted: bool, +} + +impl MapProjection { + pub(super) fn new(facet: Option<&crate::plot::Facet>) -> Self { + Self { + is_faceted: facet.is_some_and(|f| !f.get_variables().is_empty()), + } + } +} + +impl ProjectionRenderer for MapProjection { + fn is_faceted(&self) -> bool { + self.is_faceted + } + + fn position_channels(&self) -> (&'static str, &'static str) { + ("x", "y") + } + + fn offset_channels(&self) -> (&'static str, &'static str) { + ("xOffset", "yOffset") + } + + fn transform_layers(&self, _spec: &Plot, vl_spec: &mut Value) -> Result<()> { + vl_spec["projection"] = json!({ + "type": "identity", + "reflectY": true + }); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::{Facet, FacetLayout}; + + #[test] + fn test_map_projection_identity() { + let renderer = MapProjection::new(None); + let mut vl_spec = json!({"layer": []}); + let spec = Plot::default(); + + renderer.transform_layers(&spec, &mut vl_spec).unwrap(); + + assert_eq!(vl_spec["projection"]["type"], "identity"); + assert_eq!(vl_spec["projection"]["reflectY"], true); + } + + #[test] + fn test_map_projection_channels() { + let renderer = MapProjection::new(None); + assert_eq!(renderer.position_channels(), ("x", "y")); + assert_eq!(renderer.offset_channels(), ("xOffset", "yOffset")); + assert_eq!(renderer.map_position("pos1"), Some("x".to_string())); + assert_eq!(renderer.map_position("pos2"), Some("y".to_string())); + } + + #[test] + fn test_map_projection_faceted() { + let facet = Facet::new(FacetLayout::Wrap { + variables: vec!["region".to_string()], + }); + let renderer = MapProjection::new(Some(&facet)); + assert!(renderer.is_faceted()); + assert_eq!(renderer.panel_size(), None); + } + + #[test] + fn test_map_projection_not_faceted() { + let renderer = MapProjection::new(None); + assert!(!renderer.is_faceted()); + assert_eq!( + renderer.panel_size(), + Some((json!("container"), json!("container"))) + ); + } +} diff --git a/src/writer/vegalite/projection/mod.rs b/src/writer/vegalite/projection/mod.rs index 5aebaaddb..b4006e4ac 100644 --- a/src/writer/vegalite/projection/mod.rs +++ b/src/writer/vegalite/projection/mod.rs @@ -6,6 +6,7 @@ //! and the spec-level transformations for that projection. mod cartesian; +mod map; mod polar; use crate::plot::{CoordKind, ParameterValue, Projection, Scale}; @@ -13,6 +14,7 @@ use crate::{Plot, Result}; use serde_json::{json, Value}; use cartesian::CartesianProjection; +use map::MapProjection; use polar::PolarProjection; const ANGLE_TOLERANCE: f64 = 1.49011611938476e-08; // f64::EPSILON.sqrt() @@ -133,7 +135,7 @@ pub(super) fn get_projection_renderer( ) -> Box { match project.map(|p| p.coord.coord_kind()) { Some(CoordKind::Polar) => Box::new(PolarProjection::new(project, facet, scales)), - Some(CoordKind::Map) => todo!("map projection rendering"), + Some(CoordKind::Map) => Box::new(MapProjection::new(facet)), Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection::new(facet)), } } From 66cc45882704f0bd5bbf7b687f727118c5557e3c Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 11 May 2026 10:47:04 +0200 Subject: [PATCH 09/50] Implement horizon clipping for azimuthal map projections Orthographic and gnomonic projections only show one hemisphere. Without clipping, ST_Transform produces degenerate geometry for features on the far side of the globe. This adds a haversine-based clip polygon computed at the projection center, filters features to those intersecting it, and clips before projecting via ST_Intersection + ST_MakeValid. Also adds a feature-gated integration test for Amsterdam-centered ortho and tightens the existing orthographic test to assert zero null geometry. Co-Authored-By: Claude Opus 4.6 --- src/lib.rs | 34 +++++++- src/plot/layer/geom/spatial.rs | 73 +++++++++++++++- src/plot/projection/coord/map.rs | 144 +++++++++++++++++++++++++++++++ src/plot/projection/coord/mod.rs | 2 +- 4 files changed, 248 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b7bfe4bfe..63b353746 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1044,6 +1044,7 @@ mod integration_tests { assert_eq!(feature["geometry"]["type"], "Polygon"); } + #[cfg(feature = "spatial")] #[test] fn test_end_to_end_spatial_world_orthographic() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); @@ -1068,9 +1069,11 @@ mod integration_tests { .filter(|r| r[naming::SOURCE_COLUMN] == layer_key.as_str()) .collect(); assert!(!spatial_rows.is_empty()); - // Orthographic clips the back hemisphere — some features have null geometry - assert!(spatial_rows.iter().any(|r| r["geometry"].is_null())); - assert!(spatial_rows.iter().any(|r| !r["geometry"].is_null())); + // Horizon clipping filters out back-of-globe features; all remaining have geometry + assert!( + spatial_rows.iter().all(|r| !r["geometry"].is_null()), + "all visible features should have valid geometry" + ); } /// Belt-and-braces regression test: a representative basket of error- @@ -1171,4 +1174,29 @@ mod integration_tests { } } } + + #[cfg(feature = "spatial")] + #[test] + fn test_orthographic_amsterdam_center() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + let query = r#" + VISUALISE FROM ggsql:world + DRAW spatial PROJECT TO map SETTING crs => '+proj=ortho +lon_0=4.90 +lat_0=52.36' + "#; + + let prepared = execute::prepare_data_with_reader(query, &reader).unwrap(); + let writer = VegaLiteWriter::new(); + let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + let data = vl_spec["data"]["values"].as_array().unwrap(); + let layer_key = prepared.specs[0].layers[0].data_key.as_ref().unwrap(); + let spatial_rows: Vec<_> = data + .iter() + .filter(|r| r[naming::SOURCE_COLUMN] == layer_key.as_str()) + .collect(); + assert!(!spatial_rows.is_empty()); + assert!(spatial_rows.iter().any(|r| !r["geometry"].is_null())); + } } diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index c9f525cd0..31dfe9d6e 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -1,11 +1,41 @@ use super::{DefaultAesthetics, GeomTrait, GeomType}; use crate::naming; +use crate::plot::projection::coord::map::visible_area_wkt; use crate::plot::projection::coord::CoordKind; use crate::plot::projection::Projection; use crate::plot::types::DefaultAestheticValue; use crate::plot::ParameterValue; use crate::reader::SqlDialect; +fn apply_horizon_clip( + query: &str, + col: &str, + source: &str, + crs: &str, + horizon_sql: &str, + dialect: &dyn SqlDialect, +) -> String { + let geom_expr = format!( + "ST_MakeValid(ST_Transform(\ + ST_Intersection({col}, ({horizon})),\ + '{source}', '{crs}', always_xy := true\ + ))", + col = col, + horizon = horizon_sql, + source = source.replace('\'', "''"), + crs = crs.replace('\'', "''"), + ); + let wkb_expr = dialect.sql_geometry_to_wkb(&geom_expr); + format!( + "SELECT * REPLACE ({wkb_expr} AS {col}) FROM ({query}) \ + WHERE ST_Intersects({col}, ({horizon}))", + col = col, + wkb_expr = wkb_expr, + query = query, + horizon = horizon_sql, + ) +} + #[derive(Debug, Clone, Copy)] pub struct Spatial; @@ -43,6 +73,12 @@ impl GeomTrait for Spatial { Some(ParameterValue::String(s)) => s.as_str(), _ => "EPSG:4326", }; + + if let Some(wkt) = visible_area_wkt(&projection.properties) { + let horizon_sql = format!("ST_GeomFromText('{wkt}')"); + return Ok(apply_horizon_clip(query, &col, source, crs, &horizon_sql, dialect)); + } + dialect.sql_st_transform(&col, source, crs) } else { col.clone() @@ -65,7 +101,6 @@ impl std::fmt::Display for Spatial { mod tests { use super::*; use crate::reader::AnsiDialect; - // Note: in AnsiDialect ST_AsBinary is the function to get WKB. #[test] fn test_apply_projection_without_map_coord() { @@ -106,5 +141,41 @@ mod tests { assert!(result.contains("ST_AsBinary")); assert!(result.contains("ST_Transform")); assert!(result.contains("+proj=merc")); + assert!(!result.contains("ST_Intersection"), "mercator should not clip"); + } + + #[test] + fn test_orthographic_gets_horizon_clip() { + let spatial = Spatial; + let mut projection = Projection::map(); + projection.properties.insert( + "crs".to_string(), + ParameterValue::String("+proj=ortho +lat_0=45 +lon_0=10".to_string()), + ); + let result = spatial + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .unwrap(); + + assert!(result.contains("ST_Transform")); + assert!(result.contains("ST_MakeValid")); + assert!(result.contains("ST_Intersection")); + assert!(result.contains("ST_Intersects")); + } + + #[test] + fn test_gnomonic_gets_horizon_clip() { + let spatial = Spatial; + let mut projection = Projection::map(); + projection.properties.insert( + "crs".to_string(), + ParameterValue::String("+proj=gnom +lat_0=90 +lon_0=0".to_string()), + ); + let result = spatial + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .unwrap(); + + assert!(result.contains("ST_MakeValid")); + assert!(result.contains("ST_Intersection")); + assert!(result.contains("ST_Intersects")); } } diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 19eb2caed..d733173ae 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -1,5 +1,7 @@ //! Map coordinate system implementation +use std::collections::HashMap; + use super::{CoordKind, CoordTrait}; use crate::naming; use crate::plot::layer::geom::GeomType; @@ -81,6 +83,108 @@ impl std::fmt::Display for Map { } } +/// Returns a WKT POLYGON representing the visible hemisphere for the given projection +/// properties, or `None` if the projection doesn't require horizon clipping. +/// +/// The polygon is a 72-vertex haversine boundary at 88° great-circle radius from the +/// projection center (`lon_0`, `lat_0`). Azimuthal projections (orthographic, gnomonic) +/// only display one hemisphere; geometry beyond this boundary produces degenerate output +/// after `ST_Transform` and must be clipped. +pub fn visible_area_wkt(properties: &HashMap) -> Option { + let crs = match properties.get("crs") { + Some(ParameterValue::String(s)) => s, + _ => return None, + }; + + if !needs_horizon_clip(crs) { + return None; + } + + let center = projection_center(crs); + Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)) +} + +fn needs_horizon_clip(crs: &str) -> bool { + let lower = crs.to_ascii_lowercase(); + lower.contains("+proj=ortho") || lower.contains("+proj=gnom") +} + +fn projection_center(crs: &str) -> (f64, f64) { + let lon = extract_proj_param(crs, "+lon_0=").unwrap_or(0.0); + let lat = extract_proj_param(crs, "+lat_0=").unwrap_or(0.0); + (lon, lat) +} + +fn extract_proj_param(crs: &str, key: &str) -> Option { + crs.find(key).and_then(|start| { + let after = &crs[start + key.len()..]; + let end = after.find(|c: char| c == ' ' || c == '+').unwrap_or(after.len()); + after[..end].parse().ok() + }) +} + +/// Haversine boundary polygon at `radius_deg` from `(lon0, lat0)`, as WKT. +/// Routes through ±90° latitude when the boundary includes a pole. +fn hemisphere_polygon_wkt(lon0: f64, lat0: f64, radius_deg: f64) -> String { + let d = radius_deg.to_radians(); + let lat0_r = lat0.to_radians(); + let sin_lat0 = lat0_r.sin(); + let cos_lat0 = lat0_r.cos(); + let sin_d = d.sin(); + let cos_d = d.cos(); + + let n_points = 72; + let mut points: Vec<(f64, f64)> = Vec::with_capacity(n_points); + for i in 0..n_points { + let az = (i as f64 * 5.0).to_radians(); + let lat2 = (sin_lat0 * cos_d + cos_lat0 * sin_d * az.cos()).asin(); + let lon2 = + lon0.to_radians() + (az.sin() * sin_d * cos_lat0).atan2(cos_d - sin_lat0 * lat2.sin()); + points.push((lon2.to_degrees(), lat2.to_degrees())); + } + + let includes_north_pole = lat0 + radius_deg > 90.0; + let includes_south_pole = lat0 - radius_deg < -90.0; + + let mut coords: Vec = Vec::with_capacity(n_points + 4); + + if includes_north_pole || includes_south_pole { + let mut split_idx = 0; + let mut max_jump = 0.0_f64; + for i in 0..points.len() { + let next = (i + 1) % points.len(); + let jump = (points[next].0 - points[i].0).abs(); + if jump > max_jump { + max_jump = jump; + split_idx = next; + } + } + + let mut ordered: Vec<(f64, f64)> = Vec::with_capacity(points.len()); + for i in 0..points.len() { + ordered.push(points[(split_idx + i) % points.len()]); + } + + let pole_lat = if includes_north_pole { 90.0 } else { -90.0 }; + let first = ordered.first().unwrap(); + let last = ordered.last().unwrap(); + + for (lon, lat) in &ordered { + coords.push(format!("{lon:.6} {lat:.6}")); + } + coords.push(format!("{:.6} {pole_lat:.6}", last.0)); + coords.push(format!("{:.6} {pole_lat:.6}", first.0)); + coords.push(format!("{:.6} {:.6}", first.0, first.1)); + } else { + for (lon, lat) in &points { + coords.push(format!("{lon:.6} {lat:.6}")); + } + coords.push(coords[0].clone()); + } + + format!("POLYGON(({}))", coords.join(", ")) +} + fn detect_source_srid( layers: &[Layer], layer_queries: &[String], @@ -173,4 +277,44 @@ mod tests { let err = resolved.unwrap_err(); assert!(err.contains("not 'unknown'")); } + + #[test] + fn test_visible_area_wkt_orthographic() { + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=ortho +lat_0=45 +lon_0=10".to_string()), + ); + let wkt = visible_area_wkt(&props); + assert!(wkt.is_some()); + let wkt = wkt.unwrap(); + assert!(wkt.starts_with("POLYGON((")); + assert!(wkt.ends_with("))")); + } + + #[test] + fn test_visible_area_wkt_gnomonic() { + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=gnom +lat_0=90 +lon_0=0".to_string()), + ); + assert!(visible_area_wkt(&props).is_some()); + } + + #[test] + fn test_visible_area_wkt_mercator_returns_none() { + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=merc".to_string()), + ); + assert!(visible_area_wkt(&props).is_none()); + } + + #[test] + fn test_visible_area_wkt_no_crs_returns_none() { + let props = HashMap::new(); + assert!(visible_area_wkt(&props).is_none()); + } } diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index b1e55cfe4..7f3a1ac6f 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -31,7 +31,7 @@ use crate::DataFrame; // Coord type implementations mod cartesian; -mod map; +pub mod map; mod polar; // Re-export coord type structs From 37ee88863635ce6e26588bba7478689938402d9a Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 11 May 2026 12:07:12 +0200 Subject: [PATCH 10/50] Handle antimeridian crossing in hemisphere clip polygon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the projection center is far from the prime meridian (e.g. lon_0=150), the haversine boundary ring crosses ±180°. Previously, the unnormalized longitudes produced a self-intersecting polygon that spatial engines couldn't use for clipping. Fix: normalize boundary longitudes to [-180, 180], detect antimeridian crossings (jumps > 180° between adjacent points), interpolate crossing latitudes, and emit a MULTIPOLYGON with one sub-polygon on each side. The pole-routing path (for centers where lat_0 ± 88° > 90°) is unchanged. Also moves visible_area_wkt() from the spatial geom into the map coord module, giving the coordinate system responsibility for computing its own visible area. Co-Authored-By: Claude Opus 4.6 --- src/lib.rs | 36 +++++ src/plot/projection/coord/map.rs | 220 +++++++++++++++++++++++++++---- 2 files changed, 227 insertions(+), 29 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 63b353746..ab39dd92b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1076,6 +1076,42 @@ mod integration_tests { ); } + #[cfg(feature = "spatial")] + #[test] + fn test_end_to_end_spatial_world_orthographic_antimeridian() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + let queries = &[ + "+proj=ortho +lat_0=0 +lon_0=150", + "+proj=ortho +lat_0=52.36 +lon_0=150.90", + ]; + + for crs in queries { + let query = format!( + "VISUALISE FROM ggsql:world \ + DRAW spatial PROJECT TO orthographic SETTING crs => '{crs}'" + ); + + let prepared = execute::prepare_data_with_reader(&query, &reader).unwrap(); + + let writer = VegaLiteWriter::new(); + let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + let data = vl_spec["data"]["values"].as_array().unwrap(); + let layer_key = prepared.specs[0].layers[0].data_key.as_ref().unwrap(); + let spatial_rows: Vec<_> = data + .iter() + .filter(|r| r[naming::SOURCE_COLUMN] == layer_key.as_str()) + .collect(); + assert!(!spatial_rows.is_empty(), "no rows for {crs}"); + assert!( + spatial_rows.iter().all(|r| !r["geometry"].is_null()), + "NULL geometry found for {crs}" + ); + } + } + /// Belt-and-braces regression test: a representative basket of error- /// triggering queries must never produce a user-visible message that /// contains an internal aesthetic name (`__ggsql_aes_*`, `pos1`, `pos2`, diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index d733173ae..a53ba1efc 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -124,7 +124,8 @@ fn extract_proj_param(crs: &str, key: &str) -> Option { } /// Haversine boundary polygon at `radius_deg` from `(lon0, lat0)`, as WKT. -/// Routes through ±90° latitude when the boundary includes a pole. +/// Returns a POLYGON when the ring doesn't cross the antimeridian, or a +/// MULTIPOLYGON split at ±180° when it does. fn hemisphere_polygon_wkt(lon0: f64, lat0: f64, radius_deg: f64) -> String { let d = radius_deg.to_radians(); let lat0_r = lat0.to_radians(); @@ -140,49 +141,167 @@ fn hemisphere_polygon_wkt(lon0: f64, lat0: f64, radius_deg: f64) -> String { let lat2 = (sin_lat0 * cos_d + cos_lat0 * sin_d * az.cos()).asin(); let lon2 = lon0.to_radians() + (az.sin() * sin_d * cos_lat0).atan2(cos_d - sin_lat0 * lat2.sin()); - points.push((lon2.to_degrees(), lat2.to_degrees())); + let mut lon_deg = lon2.to_degrees(); + // Normalize to [-180, 180] + lon_deg = ((lon_deg + 180.0) % 360.0 + 360.0) % 360.0 - 180.0; + points.push((lon_deg, lat2.to_degrees())); } let includes_north_pole = lat0 + radius_deg > 90.0; let includes_south_pole = lat0 - radius_deg < -90.0; - let mut coords: Vec = Vec::with_capacity(n_points + 4); - if includes_north_pole || includes_south_pole { - let mut split_idx = 0; - let mut max_jump = 0.0_f64; - for i in 0..points.len() { - let next = (i + 1) % points.len(); - let jump = (points[next].0 - points[i].0).abs(); - if jump > max_jump { - max_jump = jump; - split_idx = next; - } + build_pole_polygon(&points, includes_north_pole) + } else if find_antimeridian_crossings(&points).len() == 2 { + build_antimeridian_multipolygon(&points) + } else { + build_simple_polygon(&points) + } +} + +fn build_simple_polygon(points: &[(f64, f64)]) -> String { + let mut coords: Vec = points + .iter() + .map(|(lon, lat)| format!("{lon:.6} {lat:.6}")) + .collect(); + coords.push(coords[0].clone()); + format!("POLYGON(({}))", coords.join(", ")) +} + +/// Routes the ring through ±90° latitude to close around a pole. +fn build_pole_polygon(points: &[(f64, f64)], north: bool) -> String { + let mut split_idx = 0; + let mut max_jump = 0.0_f64; + for i in 0..points.len() { + let next = (i + 1) % points.len(); + let jump = (points[next].0 - points[i].0).abs(); + if jump > max_jump { + max_jump = jump; + split_idx = next; } + } + + let mut ordered: Vec<(f64, f64)> = Vec::with_capacity(points.len()); + for i in 0..points.len() { + ordered.push(points[(split_idx + i) % points.len()]); + } + + let pole_lat = if north { 90.0 } else { -90.0 }; + let first = ordered.first().unwrap(); + let last = ordered.last().unwrap(); + + let mut coords: Vec = Vec::with_capacity(points.len() + 4); + for (lon, lat) in &ordered { + coords.push(format!("{lon:.6} {lat:.6}")); + } + coords.push(format!("{:.6} {pole_lat:.6}", last.0)); + coords.push(format!("{:.6} {pole_lat:.6}", first.0)); + coords.push(format!("{:.6} {:.6}", first.0, first.1)); + + format!("POLYGON(({}))", coords.join(", ")) +} - let mut ordered: Vec<(f64, f64)> = Vec::with_capacity(points.len()); - for i in 0..points.len() { - ordered.push(points[(split_idx + i) % points.len()]); +fn find_antimeridian_crossings(points: &[(f64, f64)]) -> Vec { + let n = points.len(); + let mut crossings = Vec::new(); + for i in 0..n { + let next = (i + 1) % n; + if (points[next].0 - points[i].0).abs() > 180.0 { + crossings.push(i); } + } + crossings +} + +/// Splits the boundary ring into two polygons at the antimeridian (±180°). +/// Each sub-polygon closes by tracing the antimeridian between its two crossing latitudes. +fn build_antimeridian_multipolygon(points: &[(f64, f64)]) -> String { + let n = points.len(); + let crossings = find_antimeridian_crossings(points); + assert_eq!(crossings.len(), 2); + + let c1 = crossings[0]; + let c2 = crossings[1]; + + let lat_c1 = antimeridian_crossing_lat(points[c1], points[(c1 + 1) % n]); + let lat_c2 = antimeridian_crossing_lat(points[c2], points[(c2 + 1) % n]); + + let (east_arc, west_arc, east_start_lat, east_end_lat, west_start_lat, west_end_lat) = + split_arcs_at_crossings(points, c1, c2, lat_c1, lat_c2); - let pole_lat = if includes_north_pole { 90.0 } else { -90.0 }; - let first = ordered.first().unwrap(); - let last = ordered.last().unwrap(); + let east_coords = build_side_ring(&east_arc, 180.0, east_start_lat, east_end_lat); + let west_coords = build_side_ring(&west_arc, -180.0, west_start_lat, west_end_lat); - for (lon, lat) in &ordered { - coords.push(format!("{lon:.6} {lat:.6}")); + format!( + "MULTIPOLYGON((({})),(({})))", + east_coords.join(", "), + west_coords.join(", ") + ) +} + +/// Split the ring at two crossing indices into east/west arcs with their boundary latitudes. +fn split_arcs_at_crossings( + points: &[(f64, f64)], + c1: usize, + c2: usize, + lat_c1: f64, + lat_c2: f64, +) -> (Vec<(f64, f64)>, Vec<(f64, f64)>, f64, f64, f64, f64) { + let n = points.len(); + + let mut arc1: Vec<(f64, f64)> = Vec::new(); + let mut i = (c1 + 1) % n; + loop { + arc1.push(points[i]); + if i == c2 { + break; } - coords.push(format!("{:.6} {pole_lat:.6}", last.0)); - coords.push(format!("{:.6} {pole_lat:.6}", first.0)); - coords.push(format!("{:.6} {:.6}", first.0, first.1)); - } else { - for (lon, lat) in &points { - coords.push(format!("{lon:.6} {lat:.6}")); + i = (i + 1) % n; + } + + let mut arc2: Vec<(f64, f64)> = Vec::new(); + i = (c2 + 1) % n; + loop { + arc2.push(points[i]); + if i == c1 { + break; } - coords.push(coords[0].clone()); + i = (i + 1) % n; } - format!("POLYGON(({}))", coords.join(", ")) + if arc1[0].0 > 0.0 { + (arc1, arc2, lat_c1, lat_c2, lat_c2, lat_c1) + } else { + (arc2, arc1, lat_c2, lat_c1, lat_c1, lat_c2) + } +} + +fn build_side_ring( + arc: &[(f64, f64)], + meridian_lon: f64, + start_lat: f64, + end_lat: f64, +) -> Vec { + let mut coords: Vec = Vec::with_capacity(arc.len() + 3); + coords.push(format!("{meridian_lon:.6} {start_lat:.6}")); + for (lon, lat) in arc.iter() { + coords.push(format!("{lon:.6} {lat:.6}")); + } + coords.push(format!("{meridian_lon:.6} {end_lat:.6}")); + coords.push(coords[0].clone()); + coords +} + +fn antimeridian_crossing_lat(a: (f64, f64), b: (f64, f64)) -> f64 { + let (lon_a, lat_a) = a; + let (lon_b, lat_b) = b; + let (lon_a_u, lon_b_u) = if lon_a > lon_b { + (lon_a, lon_b + 360.0) + } else { + (lon_a + 360.0, lon_b) + }; + let t = (180.0 - lon_a_u) / (lon_b_u - lon_a_u); + lat_a + t * (lat_b - lat_a) } fn detect_source_srid( @@ -317,4 +436,47 @@ mod tests { let props = HashMap::new(); assert!(visible_area_wkt(&props).is_none()); } + + #[test] + fn test_visible_area_wkt_antimeridian_crossing() { + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=ortho +lat_0=0 +lon_0=150".to_string()), + ); + let wkt = visible_area_wkt(&props).unwrap(); + assert!( + wkt.starts_with("MULTIPOLYGON"), + "lon_0=150 should cross antimeridian: {wkt}" + ); + } + + #[test] + fn test_visible_area_wkt_no_antimeridian_for_centered() { + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=ortho +lat_0=0 +lon_0=0".to_string()), + ); + let wkt = visible_area_wkt(&props).unwrap(); + assert!( + wkt.starts_with("POLYGON(("), + "lon_0=0 should not cross antimeridian: {wkt}" + ); + } + + #[test] + fn test_visible_area_wkt_pole_and_antimeridian() { + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=ortho +lat_0=52.36 +lon_0=150.90".to_string()), + ); + let wkt = visible_area_wkt(&props).unwrap(); + // Includes north pole (52.36 + 88 > 90), ring has one big jump → pole-routing. + assert!( + wkt.starts_with("POLYGON(("), + "pole case should produce POLYGON: {wkt}" + ); + } } From cce8a1fc9cc56961c544d82382ef6de9bbfbcc0c Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 11 May 2026 12:24:03 +0200 Subject: [PATCH 11/50] Store clip boundary in temp table for reuse across layers The map coord now creates a temp table (__ggsql_clip_boundary__) holding the clip polygon geometry during apply_projection_transforms. Spatial layers reference this table by name instead of inlining the WKT literal twice per query. This avoids redundant polygon parsing across multiple spatial layers and makes the boundary available to the writer later (for rendering the globe outline). Co-Authored-By: Claude Opus 4.6 --- src/plot/layer/geom/spatial.rs | 38 ++++++++++++++++++++++---------- src/plot/projection/coord/map.rs | 15 +++++++++++++ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index 31dfe9d6e..e50a20149 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -1,38 +1,39 @@ use super::{DefaultAesthetics, GeomTrait, GeomType}; use crate::naming; -use crate::plot::projection::coord::map::visible_area_wkt; +use crate::plot::projection::coord::map::CLIP_BOUNDARY_KEY; use crate::plot::projection::coord::CoordKind; use crate::plot::projection::Projection; use crate::plot::types::DefaultAestheticValue; use crate::plot::ParameterValue; use crate::reader::SqlDialect; -fn apply_horizon_clip( +fn apply_clip_boundary( query: &str, col: &str, source: &str, crs: &str, - horizon_sql: &str, + clip_table: &str, dialect: &dyn SqlDialect, ) -> String { + let clip_geom = format!("(SELECT geom FROM {clip_table})"); let geom_expr = format!( "ST_MakeValid(ST_Transform(\ - ST_Intersection({col}, ({horizon})),\ + ST_Intersection({col}, {clip_geom}),\ '{source}', '{crs}', always_xy := true\ ))", col = col, - horizon = horizon_sql, + clip_geom = clip_geom, source = source.replace('\'', "''"), crs = crs.replace('\'', "''"), ); let wkb_expr = dialect.sql_geometry_to_wkb(&geom_expr); format!( "SELECT * REPLACE ({wkb_expr} AS {col}) FROM ({query}) \ - WHERE ST_Intersects({col}, ({horizon}))", + WHERE ST_Intersects({col}, {clip_geom})", col = col, wkb_expr = wkb_expr, query = query, - horizon = horizon_sql, + clip_geom = clip_geom, ) } @@ -74,9 +75,12 @@ impl GeomTrait for Spatial { _ => "EPSG:4326", }; - if let Some(wkt) = visible_area_wkt(&projection.properties) { - let horizon_sql = format!("ST_GeomFromText('{wkt}')"); - return Ok(apply_horizon_clip(query, &col, source, crs, &horizon_sql, dialect)); + if let Some(ParameterValue::String(clip_table)) = + projection.properties.get(CLIP_BOUNDARY_KEY) + { + return Ok(apply_clip_boundary( + query, &col, source, crs, clip_table, dialect, + )); } dialect.sql_st_transform(&col, source, crs) @@ -145,13 +149,17 @@ mod tests { } #[test] - fn test_orthographic_gets_horizon_clip() { + fn test_orthographic_gets_clip_boundary() { let spatial = Spatial; let mut projection = Projection::map(); projection.properties.insert( "crs".to_string(), ParameterValue::String("+proj=ortho +lat_0=45 +lon_0=10".to_string()), ); + projection.properties.insert( + CLIP_BOUNDARY_KEY.to_string(), + ParameterValue::String("__ggsql_clip_boundary__".to_string()), + ); let result = spatial .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) .unwrap(); @@ -160,16 +168,21 @@ mod tests { assert!(result.contains("ST_MakeValid")); assert!(result.contains("ST_Intersection")); assert!(result.contains("ST_Intersects")); + assert!(result.contains("__ggsql_clip_boundary__")); } #[test] - fn test_gnomonic_gets_horizon_clip() { + fn test_gnomonic_gets_clip_boundary() { let spatial = Spatial; let mut projection = Projection::map(); projection.properties.insert( "crs".to_string(), ParameterValue::String("+proj=gnom +lat_0=90 +lon_0=0".to_string()), ); + projection.properties.insert( + CLIP_BOUNDARY_KEY.to_string(), + ParameterValue::String("__ggsql_clip_boundary__".to_string()), + ); let result = spatial .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) .unwrap(); @@ -177,5 +190,6 @@ mod tests { assert!(result.contains("ST_MakeValid")); assert!(result.contains("ST_Intersection")); assert!(result.contains("ST_Intersects")); + assert!(result.contains("__ggsql_clip_boundary__")); } } diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index a53ba1efc..526d322fe 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -10,6 +10,9 @@ use crate::plot::{Layer, ParameterValue}; use crate::reader::SqlDialect; use crate::DataFrame; +pub const CLIP_BOUNDARY_TABLE: &str = "__ggsql_clip_boundary__"; +pub const CLIP_BOUNDARY_KEY: &str = "__clip_boundary_table"; + /// Map coordinate system - for geographic/cartographic projections #[derive(Debug, Clone, Copy)] pub struct Map; @@ -69,6 +72,18 @@ impl CoordTrait for Map { } } + // Create a temp table for the clip boundary if the projection needs clipping + if let Some(wkt) = visible_area_wkt(&projection.properties) { + let body = format!("SELECT ST_GeomFromText('{wkt}') AS geom"); + for stmt in dialect.create_or_replace_temp_table_sql(CLIP_BOUNDARY_TABLE, &[], &body) { + execute_query(&stmt)?; + } + projection.properties.insert( + CLIP_BOUNDARY_KEY.to_string(), + ParameterValue::String(CLIP_BOUNDARY_TABLE.to_string()), + ); + } + for (idx, layer) in layers.iter().enumerate() { layer_queries[idx] = layer.geom.apply_projection(&layer_queries[idx], projection, dialect)?; From 69c958d3129cf104c6cba1d3675d20773deb69dd Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 11 May 2026 12:51:51 +0200 Subject: [PATCH 12/50] Add computed field to Projection, remove CLIP_BOUNDARY_KEY const Store the clip boundary WKT in projection.computed instead of injecting a synthetic property. This separates execution-time metadata from user-facing properties and makes the WKT accessible to the writer for future panel background rendering. Co-Authored-By: Claude Opus 4.6 --- src/execute/scale.rs | 1 + src/parser/builder.rs | 1 + src/plot/layer/geom/spatial.rs | 20 +++++++++----------- src/plot/main.rs | 2 ++ src/plot/projection/coord/map.rs | 10 ++++------ src/plot/projection/resolve.rs | 4 ++++ src/plot/projection/types.rs | 5 +++++ src/writer/vegalite/mod.rs | 1 + 8 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/execute/scale.rs b/src/execute/scale.rs index 0abc81093..a5c00edbc 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -1665,6 +1665,7 @@ mod tests { coord, aesthetics, properties: std::collections::HashMap::new(), + computed: std::collections::HashMap::new(), }); // Create scale for pos2 (theta in polar) without explicit expand diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 9722139e2..cfc72598d 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1024,6 +1024,7 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { coord, aesthetics, properties, + computed: HashMap::new(), }) } diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index e50a20149..c4166f448 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -1,6 +1,6 @@ use super::{DefaultAesthetics, GeomTrait, GeomType}; use crate::naming; -use crate::plot::projection::coord::map::CLIP_BOUNDARY_KEY; +use crate::plot::projection::coord::map::CLIP_BOUNDARY_TABLE; use crate::plot::projection::coord::CoordKind; use crate::plot::projection::Projection; use crate::plot::types::DefaultAestheticValue; @@ -75,11 +75,9 @@ impl GeomTrait for Spatial { _ => "EPSG:4326", }; - if let Some(ParameterValue::String(clip_table)) = - projection.properties.get(CLIP_BOUNDARY_KEY) - { + if projection.computed.contains_key("clip_boundary") { return Ok(apply_clip_boundary( - query, &col, source, crs, clip_table, dialect, + query, &col, source, crs, CLIP_BOUNDARY_TABLE, dialect, )); } @@ -156,9 +154,9 @@ mod tests { "crs".to_string(), ParameterValue::String("+proj=ortho +lat_0=45 +lon_0=10".to_string()), ); - projection.properties.insert( - CLIP_BOUNDARY_KEY.to_string(), - ParameterValue::String("__ggsql_clip_boundary__".to_string()), + projection.computed.insert( + "clip_boundary".to_string(), + ParameterValue::String("POLYGON((...))".to_string()), ); let result = spatial .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) @@ -179,9 +177,9 @@ mod tests { "crs".to_string(), ParameterValue::String("+proj=gnom +lat_0=90 +lon_0=0".to_string()), ); - projection.properties.insert( - CLIP_BOUNDARY_KEY.to_string(), - ParameterValue::String("__ggsql_clip_boundary__".to_string()), + projection.computed.insert( + "clip_boundary".to_string(), + ParameterValue::String("POLYGON((...))".to_string()), ); let result = spatial .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) diff --git a/src/plot/main.rs b/src/plot/main.rs index 07018e0c8..3c7ff2c7a 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -771,6 +771,7 @@ mod tests { coord: Coord::cartesian(), aesthetics: vec!["y".to_string(), "x".to_string()], properties: HashMap::new(), + computed: HashMap::new(), }); spec.labels = Some(Labels { labels: HashMap::from([ @@ -801,6 +802,7 @@ mod tests { coord: Coord::polar(), aesthetics: vec!["angle".to_string(), "radius".to_string()], properties: HashMap::new(), + computed: HashMap::new(), }); spec.labels = Some(Labels { labels: HashMap::from([ diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 526d322fe..2474ac86f 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -11,7 +11,6 @@ use crate::reader::SqlDialect; use crate::DataFrame; pub const CLIP_BOUNDARY_TABLE: &str = "__ggsql_clip_boundary__"; -pub const CLIP_BOUNDARY_KEY: &str = "__clip_boundary_table"; /// Map coordinate system - for geographic/cartographic projections #[derive(Debug, Clone, Copy)] @@ -72,16 +71,15 @@ impl CoordTrait for Map { } } - // Create a temp table for the clip boundary if the projection needs clipping + // Compute clip boundary and create temp table for azimuthal projections if let Some(wkt) = visible_area_wkt(&projection.properties) { + projection + .computed + .insert("clip_boundary".to_string(), ParameterValue::String(wkt.clone())); let body = format!("SELECT ST_GeomFromText('{wkt}') AS geom"); for stmt in dialect.create_or_replace_temp_table_sql(CLIP_BOUNDARY_TABLE, &[], &body) { execute_query(&stmt)?; } - projection.properties.insert( - CLIP_BOUNDARY_KEY.to_string(), - ParameterValue::String(CLIP_BOUNDARY_TABLE.to_string()), - ); } for (idx, layer) in layers.iter().enumerate() { diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index e08646e73..6adff9c30 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -76,6 +76,7 @@ pub fn resolve_coord( coord, aesthetics, properties: HashMap::new(), + computed: HashMap::new(), })); } @@ -91,6 +92,7 @@ pub fn resolve_coord( coord, aesthetics, properties: HashMap::new(), + computed: HashMap::new(), })); } @@ -137,6 +139,8 @@ fn strip_position_suffix(name: &str) -> &str { /// - **`radar`** (polar only): When null (auto), sets to `true` if the theta /// (pos2) scale is discrete/ordinal. When explicitly `true`, validates that /// the theta scale is indeed discrete. +/// - **clip boundary** (map only): Computes the visible-area WKT polygon for +/// azimuthal projections and stores it in `computed`. pub fn resolve_projection_properties( project: &mut Projection, scales: &[Scale], diff --git a/src/plot/projection/types.rs b/src/plot/projection/types.rs index c4ebfcd12..8a9be522a 100644 --- a/src/plot/projection/types.rs +++ b/src/plot/projection/types.rs @@ -22,6 +22,10 @@ pub struct Projection { pub aesthetics: Vec, /// Projection-specific options pub properties: HashMap, + /// Values computed at execution time (e.g., clip boundary WKT). + /// Not user-facing; populated by apply_projection_transforms. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub computed: HashMap, } impl Projection { @@ -50,6 +54,7 @@ impl Projection { coord, aesthetics, properties: HashMap::new(), + computed: HashMap::new(), } } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 727526761..0e931a4e5 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -3018,6 +3018,7 @@ mod tests { aesthetics: vec!["angle".to_string(), "radius".to_string()], coord: Coord::polar(), properties: HashMap::new(), + computed: HashMap::new(), }); let layer = Layer::new(Geom::point()) .with_aesthetic( From 5c8bcb31eb1b9ee5ff8d0eecabd2460f34476de0 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 11 May 2026 14:17:13 +0200 Subject: [PATCH 13/50] Render map panel background from projected clip boundary During execution, project the clip boundary polygon via the temp table and store the resulting WKT in computed["panel_boundary"]. The Vega-Lite writer converts it to GeoJSON (via geozero with-wkt) and emits a geoshape background layer with the view fill/stroke. Also enables the geozero `with-wkt` feature. Co-Authored-By: Claude Opus 4.6 --- src/Cargo.toml | 2 +- src/plot/projection/coord/map.rs | 28 +++++++ src/writer/vegalite/projection/map.rs | 105 ++++++++++++++++++++++++-- src/writer/vegalite/projection/mod.rs | 2 +- 4 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/Cargo.toml b/src/Cargo.toml index 9bcd0d8d9..23f586e66 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -33,7 +33,7 @@ parquet = { workspace = true, optional = true } bytes = { workspace = true } # Spatial -geozero = { workspace = true, optional = true, features = ["with-wkb", "with-geojson"] } +geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Serialization serde.workspace = true diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 2474ac86f..9be21c240 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -80,6 +80,34 @@ impl CoordTrait for Map { for stmt in dialect.create_or_replace_temp_table_sql(CLIP_BOUNDARY_TABLE, &[], &body) { execute_query(&stmt)?; } + + // Project the boundary for use as panel background in the writer + let source = match projection.properties.get("source") { + Some(ParameterValue::String(s)) => s.as_str(), + _ => "EPSG:4326", + }; + if let Some(ParameterValue::String(crs)) = projection.properties.get("crs") { + let projected = dialect.sql_st_transform("geom", source, crs); + let sql = format!( + "SELECT ST_AsText({projected}) AS wkt FROM {CLIP_BOUNDARY_TABLE}" + ); + if let Ok(df) = execute_query(&sql) { + let batch = df.inner(); + if batch.num_rows() > 0 { + if let Some(arr) = batch + .column(0) + .as_any() + .downcast_ref::() + { + let projected_wkt = arr.value(0); + projection.computed.insert( + "panel_boundary".to_string(), + ParameterValue::String(projected_wkt.to_string()), + ); + } + } + } + } } for (idx, layer) in layers.iter().enumerate() { diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs index 6e729ac22..fd16db66d 100644 --- a/src/writer/vegalite/projection/map.rs +++ b/src/writer/vegalite/projection/map.rs @@ -4,6 +4,7 @@ //! must use an identity projection so it passes coordinates through without //! re-projecting via d3-geo. +use crate::plot::{ParameterValue, Projection, Scale}; use crate::{Plot, Result}; use serde_json::{json, Value}; @@ -12,12 +13,20 @@ use super::ProjectionRenderer; /// Map projection — pre-projected spatial coordinates. pub(in crate::writer) struct MapProjection { is_faceted: bool, + panel_boundary_wkt: Option, } impl MapProjection { - pub(super) fn new(facet: Option<&crate::plot::Facet>) -> Self { + pub(super) fn new(project: Option<&Projection>, facet: Option<&crate::plot::Facet>) -> Self { + let panel_boundary_wkt = project + .and_then(|p| p.computed.get("panel_boundary")) + .and_then(|v| match v { + ParameterValue::String(s) => Some(s.clone()), + _ => None, + }); Self { is_faceted: facet.is_some_and(|f| !f.get_variables().is_empty()), + panel_boundary_wkt, } } } @@ -42,16 +51,67 @@ impl ProjectionRenderer for MapProjection { }); Ok(()) } + + fn background_layers(&self, _scales: &[Scale], theme: &mut Value) -> Vec { + let Some(ref wkt) = self.panel_boundary_wkt else { + return Vec::new(); + }; + let Some(geojson) = wkt_to_geojson(wkt) else { + return Vec::new(); + }; + + let (fill, stroke) = if let Some(view) = + theme.get_mut("view").and_then(|v| v.as_object_mut()) + { + let fill = view.remove("fill").unwrap_or(Value::Null); + let stroke = view.remove("stroke").unwrap_or(Value::Null); + view.insert("stroke".to_string(), Value::Null); + (fill, stroke) + } else { + (Value::Null, Value::Null) + }; + + vec![json!({ + "data": { + "values": [{"type": "Feature", "geometry": geojson}] + }, + "mark": { + "type": "geoshape", + "fill": fill, + "stroke": stroke, + } + })] + } +} + +#[cfg(feature = "spatial")] +fn wkt_to_geojson(wkt: &str) -> Option { + use geozero::geojson::GeoJsonWriter; + use geozero::wkt::WktReader; + use geozero::GeozeroDatasource; + use std::io::Cursor; + + let mut reader = WktReader(wkt.as_bytes()); + let mut geojson_out = Vec::new(); + reader + .process_geom(&mut GeoJsonWriter::new(Cursor::new(&mut geojson_out))) + .ok()?; + serde_json::from_slice(&geojson_out).ok() +} + +#[cfg(not(feature = "spatial"))] +fn wkt_to_geojson(_wkt: &str) -> Option { + None } #[cfg(test)] mod tests { use super::*; - use crate::plot::{Facet, FacetLayout}; + use crate::plot::{Facet, FacetLayout, Projection}; #[test] fn test_map_projection_identity() { - let renderer = MapProjection::new(None); + let renderer = MapProjection::new(None, None); let mut vl_spec = json!({"layer": []}); let spec = Plot::default(); @@ -63,7 +123,7 @@ mod tests { #[test] fn test_map_projection_channels() { - let renderer = MapProjection::new(None); + let renderer = MapProjection::new(None, None); assert_eq!(renderer.position_channels(), ("x", "y")); assert_eq!(renderer.offset_channels(), ("xOffset", "yOffset")); assert_eq!(renderer.map_position("pos1"), Some("x".to_string())); @@ -75,18 +135,51 @@ mod tests { let facet = Facet::new(FacetLayout::Wrap { variables: vec!["region".to_string()], }); - let renderer = MapProjection::new(Some(&facet)); + let renderer = MapProjection::new(None, Some(&facet)); assert!(renderer.is_faceted()); assert_eq!(renderer.panel_size(), None); } #[test] fn test_map_projection_not_faceted() { - let renderer = MapProjection::new(None); + let renderer = MapProjection::new(None, None); assert!(!renderer.is_faceted()); assert_eq!( renderer.panel_size(), Some((json!("container"), json!("container"))) ); } + + #[test] + fn test_background_layer_without_boundary() { + let renderer = MapProjection::new(None, None); + let mut theme = json!({"view": {"fill": "white", "stroke": "gray"}}); + let layers = renderer.background_layers(&[], &mut theme); + assert!(layers.is_empty()); + } + + #[test] + fn test_background_layer_with_boundary() { + let mut proj = Projection::map(); + proj.computed.insert( + "panel_boundary".to_string(), + ParameterValue::String("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))".to_string()), + ); + let renderer = MapProjection::new(Some(&proj), None); + let mut theme = json!({"view": {"fill": "white", "stroke": "gray"}}); + let layers = renderer.background_layers(&[], &mut theme); + + assert_eq!(layers.len(), 1); + let layer = &layers[0]; + assert_eq!(layer["mark"]["type"], "geoshape"); + assert_eq!(layer["mark"]["fill"], "white"); + assert_eq!(layer["mark"]["stroke"], "gray"); + + let geom = &layer["data"]["values"][0]["geometry"]; + assert_eq!(geom["type"], "Polygon"); + assert!(!geom["coordinates"].is_null()); + + // view stroke should be nulled out + assert_eq!(theme["view"]["stroke"], Value::Null); + } } diff --git a/src/writer/vegalite/projection/mod.rs b/src/writer/vegalite/projection/mod.rs index b4006e4ac..e409a808c 100644 --- a/src/writer/vegalite/projection/mod.rs +++ b/src/writer/vegalite/projection/mod.rs @@ -135,7 +135,7 @@ pub(super) fn get_projection_renderer( ) -> Box { match project.map(|p| p.coord.coord_kind()) { Some(CoordKind::Polar) => Box::new(PolarProjection::new(project, facet, scales)), - Some(CoordKind::Map) => Box::new(MapProjection::new(facet)), + Some(CoordKind::Map) => Box::new(MapProjection::new(project, facet)), Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection::new(facet)), } } From 34636b2e4cc1f7e69c2b4291709c521cdb437fbf Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 11 May 2026 15:38:30 +0200 Subject: [PATCH 14/50] Fix clip polygon wedge for pole+antimeridian projections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert explicit vertices at ±179.999999° where the haversine ring crosses the antimeridian, so no polygon edge spans > 180° longitude. Also add a midpoint vertex in the pole-routing closure when the gap between first and last exceeds 180°. Together these prevent planar geometry engines from misinterpreting edge direction. Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/coord/map.rs | 34 +++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 9be21c240..98d095689 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -176,16 +176,34 @@ fn hemisphere_polygon_wkt(lon0: f64, lat0: f64, radius_deg: f64) -> String { let cos_d = d.cos(); let n_points = 72; - let mut points: Vec<(f64, f64)> = Vec::with_capacity(n_points); + let mut raw_points: Vec<(f64, f64)> = Vec::with_capacity(n_points); for i in 0..n_points { - let az = (i as f64 * 5.0).to_radians(); + let az = (i as f64 * (360.0 / n_points as f64)).to_radians(); let lat2 = (sin_lat0 * cos_d + cos_lat0 * sin_d * az.cos()).asin(); let lon2 = lon0.to_radians() + (az.sin() * sin_d * cos_lat0).atan2(cos_d - sin_lat0 * lat2.sin()); let mut lon_deg = lon2.to_degrees(); // Normalize to [-180, 180] lon_deg = ((lon_deg + 180.0) % 360.0 + 360.0) % 360.0 - 180.0; - points.push((lon_deg, lat2.to_degrees())); + raw_points.push((lon_deg, lat2.to_degrees())); + } + + // Insert exact antimeridian vertices where consecutive points cross ±180°. + // Uses 179.999999 to avoid ambiguity while placing vertices at the boundary. + let mut points: Vec<(f64, f64)> = Vec::with_capacity(n_points + 4); + for i in 0..raw_points.len() { + points.push(raw_points[i]); + let next = (i + 1) % raw_points.len(); + if (raw_points[next].0 - raw_points[i].0).abs() > 180.0 { + let lat = antimeridian_crossing_lat(raw_points[i], raw_points[next]); + if raw_points[i].0 > 0.0 { + points.push((179.999999, lat)); + points.push((-179.999999, lat)); + } else { + points.push((-179.999999, lat)); + points.push((179.999999, lat)); + } + } } let includes_north_pole = lat0 + radius_deg > 90.0; @@ -231,11 +249,17 @@ fn build_pole_polygon(points: &[(f64, f64)], north: bool) -> String { let first = ordered.first().unwrap(); let last = ordered.last().unwrap(); - let mut coords: Vec = Vec::with_capacity(points.len() + 4); + let mut coords: Vec = Vec::with_capacity(points.len() + 6); for (lon, lat) in &ordered { coords.push(format!("{lon:.6} {lat:.6}")); } coords.push(format!("{:.6} {pole_lat:.6}", last.0)); + // If the closure would jump > 180° in longitude, add an intermediate + // vertex so no single edge crosses the antimeridian. + if (last.0 - first.0).abs() > 180.0 { + let mid = (last.0 + first.0) / 2.0; + coords.push(format!("{mid:.6} {pole_lat:.6}")); + } coords.push(format!("{:.6} {pole_lat:.6}", first.0)); coords.push(format!("{:.6} {:.6}", first.0, first.1)); @@ -514,7 +538,7 @@ mod tests { ParameterValue::String("+proj=ortho +lat_0=52.36 +lon_0=150.90".to_string()), ); let wkt = visible_area_wkt(&props).unwrap(); - // Includes north pole (52.36 + 88 > 90), ring has one big jump → pole-routing. + // Includes north pole (52.36 + 88 > 90), pole-routing produces a POLYGON. assert!( wkt.starts_with("POLYGON(("), "pole case should produce POLYGON: {wkt}" From 81358c5ccf36ce13ae80418fa67ffbdf7ad6d391 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 12 May 2026 10:52:26 +0200 Subject: [PATCH 15/50] Frame map projection to data extent via scale/translate expressions Spatial layers now output native geometry (deferring WKB conversion) so the map coord can materialise each layer as a temp table, compute the aggregate bounding box with ST_Extent_Agg (DuckDB) / ST_Extent (PostGIS), and emit VL projection scale/translate as runtime expressions that reference the width/height signals for container-responsive sizing. Co-Authored-By: Claude Opus 4.6 --- src/lib.rs | 3 ++ src/plot/layer/geom/spatial.rs | 36 ++++++++------ src/plot/projection/coord/map.rs | 64 +++++++++++++++++++++++++ src/reader/duckdb.rs | 8 ++++ src/reader/mod.rs | 12 +++++ src/writer/vegalite/projection/map.rs | 67 ++++++++++++++++++++++++++- 6 files changed, 174 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ab39dd92b..b68cd1539 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1074,6 +1074,9 @@ mod integration_tests { spatial_rows.iter().all(|r| !r["geometry"].is_null()), "all visible features should have valid geometry" ); + + assert!(!vl_spec["projection"]["scale"].is_null(), "projection.scale should be present"); + assert!(!vl_spec["projection"]["translate"].is_null(), "projection.translate should be present"); } #[cfg(feature = "spatial")] diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index c4166f448..31b3b3540 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -13,7 +13,6 @@ fn apply_clip_boundary( source: &str, crs: &str, clip_table: &str, - dialect: &dyn SqlDialect, ) -> String { let clip_geom = format!("(SELECT geom FROM {clip_table})"); let geom_expr = format!( @@ -26,12 +25,11 @@ fn apply_clip_boundary( source = source.replace('\'', "''"), crs = crs.replace('\'', "''"), ); - let wkb_expr = dialect.sql_geometry_to_wkb(&geom_expr); format!( - "SELECT * REPLACE ({wkb_expr} AS {col}) FROM ({query}) \ + "SELECT * REPLACE ({geom_expr} AS {col}) FROM ({query}) \ WHERE ST_Intersects({col}, {clip_geom})", col = col, - wkb_expr = wkb_expr, + geom_expr = geom_expr, query = query, clip_geom = clip_geom, ) @@ -65,11 +63,11 @@ impl GeomTrait for Spatial { dialect: &dyn SqlDialect, ) -> crate::Result { let col = naming::quote_ident(&naming::aesthetic_column("geometry")); + let is_map = projection.coord.coord_kind() == CoordKind::Map; - let geom_expr = if let (CoordKind::Map, Some(ParameterValue::String(crs))) = ( - projection.coord.coord_kind(), - projection.properties.get("crs"), - ) { + let geom_expr = if let (true, Some(ParameterValue::String(crs))) = + (is_map, projection.properties.get("crs")) + { let source = match projection.properties.get("source") { Some(ParameterValue::String(s)) => s.as_str(), _ => "EPSG:4326", @@ -77,18 +75,25 @@ impl GeomTrait for Spatial { if projection.computed.contains_key("clip_boundary") { return Ok(apply_clip_boundary( - query, &col, source, crs, CLIP_BOUNDARY_TABLE, dialect, + query, &col, source, crs, CLIP_BOUNDARY_TABLE, )); } dialect.sql_st_transform(&col, source, crs) + } else if is_map { + // Map coord without CRS — keep native geometry (WKB added later by framing) + return Ok(query.to_string()); } else { - col.clone() + // Non-map coord — convert to WKB directly + let wkb_expr = dialect.sql_geometry_to_wkb(&col); + return Ok(format!( + "SELECT * REPLACE ({wkb_expr} AS {col}) FROM ({query})" + )); }; - let wkb_expr = dialect.sql_geometry_to_wkb(&geom_expr); + // Map coord with CRS — output native projected geometry (WKB added by framing) Ok(format!( - "SELECT * REPLACE ({wkb_expr} AS {col}) FROM ({query})" + "SELECT * REPLACE ({geom_expr} AS {col}) FROM ({query})" )) } } @@ -124,8 +129,8 @@ mod tests { .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) .unwrap(); - assert!(result.contains("ST_AsBinary")); - assert!(!result.contains("ST_Transform")); + // Map without CRS is passthrough — WKB added later by framing step + assert_eq!(result, "SELECT * FROM t"); } #[test] @@ -140,7 +145,8 @@ mod tests { .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) .unwrap(); - assert!(result.contains("ST_AsBinary")); + // Native projected geometry — WKB added later by framing step + assert!(!result.contains("ST_AsBinary")); assert!(result.contains("ST_Transform")); assert!(result.contains("+proj=merc")); assert!(!result.contains("ST_Intersection"), "mercator should not clip"); diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 98d095689..2edfe4c94 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -114,6 +114,70 @@ impl CoordTrait for Map { layer_queries[idx] = layer.geom.apply_projection(&layer_queries[idx], projection, dialect)?; } + + // Materialize spatial layers as temp tables, compute bbox, wrap with WKB + let geom_col = naming::aesthetic_column("geometry"); + let geom_col_quoted = naming::quote_ident(&geom_col); + let mut xmin = f64::INFINITY; + let mut ymin = f64::INFINITY; + let mut xmax = f64::NEG_INFINITY; + let mut ymax = f64::NEG_INFINITY; + + for (idx, layer) in layers.iter().enumerate() { + if layer.geom.geom_type() != GeomType::Spatial { + continue; + } + let table_name = format!("{}_proj", naming::layer_key(idx)); + for stmt in + dialect.create_or_replace_temp_table_sql(&table_name, &[], &layer_queries[idx]) + { + execute_query(&stmt)?; + } + + let table_quoted = naming::quote_ident(&table_name); + let sql = dialect.sql_geometry_extent(&geom_col_quoted, &table_quoted); + if let Ok(df) = execute_query(&sql) { + let batch = df.inner(); + if batch.num_rows() > 0 && batch.num_columns() >= 4 { + let get_f64 = |col: usize| -> Option { + use arrow::array::Array; + batch + .column(col) + .as_any() + .downcast_ref::() + .filter(|a| !a.is_null(0)) + .map(|a| a.value(0)) + }; + if let (Some(x0), Some(y0), Some(x1), Some(y1)) = + (get_f64(0), get_f64(1), get_f64(2), get_f64(3)) + { + xmin = xmin.min(x0); + ymin = ymin.min(y0); + xmax = xmax.max(x1); + ymax = ymax.max(y1); + } + } + } + + let wkb_expr = dialect.sql_geometry_to_wkb(&geom_col_quoted); + layer_queries[idx] = format!( + "SELECT * REPLACE ({wkb_expr} AS {geom_col_quoted}) FROM {table_quoted}" + ); + } + + if xmin.is_finite() && xmax.is_finite() { + use crate::plot::types::ArrayElement; + projection.computed.insert( + "frame_bbox".to_string(), + ParameterValue::Array(vec![ + ArrayElement::Number(xmin), + ArrayElement::Number(ymin), + ArrayElement::Number(xmax), + ArrayElement::Number(ymax), + ]), + ); + } + Ok(()) } } diff --git a/src/reader/duckdb.rs b/src/reader/duckdb.rs index 00b8939dd..9e8389d31 100644 --- a/src/reader/duckdb.rs +++ b/src/reader/duckdb.rs @@ -95,6 +95,14 @@ impl super::SqlDialect for DuckDbDialect { format!("ST_AsWKB({column})") } + fn sql_geometry_extent(&self, column: &str, from: &str) -> String { + format!( + "SELECT ST_XMin(ext) AS xmin, ST_YMin(ext) AS ymin, \ + ST_XMax(ext) AS xmax, ST_YMax(ext) AS ymax \ + FROM (SELECT ST_Extent_Agg({column}) AS ext FROM {from})" + ) + } + fn sql_spatial_setup(&self) -> Vec { vec!["LOAD spatial".into()] } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 37c49ec45..3d473fdba 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -134,6 +134,18 @@ pub trait SqlDialect { ) } + /// SQL query that computes the bounding box of a geometry column. + /// + /// Must return a single row with columns `xmin`, `ymin`, `xmax`, `ymax` (DOUBLE). + /// `from` is the table or subquery to aggregate over. + fn sql_geometry_extent(&self, column: &str, from: &str) -> String { + format!( + "SELECT ST_XMin(ext) AS xmin, ST_YMin(ext) AS ymin, \ + ST_XMax(ext) AS xmax, ST_YMax(ext) AS ymax \ + FROM (SELECT ST_Extent({column}) AS ext FROM {from})" + ) + } + /// SQL statements to run before spatial operations. /// /// Override for backends that need an extension loaded (e.g. DuckDB spatial). diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs index fd16db66d..329b1c71d 100644 --- a/src/writer/vegalite/projection/map.rs +++ b/src/writer/vegalite/projection/map.rs @@ -14,6 +14,7 @@ use super::ProjectionRenderer; pub(in crate::writer) struct MapProjection { is_faceted: bool, panel_boundary_wkt: Option, + frame_bbox: Option<[f64; 4]>, } impl MapProjection { @@ -24,9 +25,30 @@ impl MapProjection { ParameterValue::String(s) => Some(s.clone()), _ => None, }); + let frame_bbox = project + .and_then(|p| p.computed.get("frame_bbox")) + .and_then(|v| match v { + ParameterValue::Array(arr) if arr.len() == 4 => { + use crate::plot::types::ArrayElement; + let nums: Vec = arr + .iter() + .filter_map(|e| match e { + ArrayElement::Number(n) => Some(*n), + _ => None, + }) + .collect(); + if nums.len() == 4 { + Some([nums[0], nums[1], nums[2], nums[3]]) + } else { + None + } + } + _ => None, + }); Self { is_faceted: facet.is_some_and(|f| !f.get_variables().is_empty()), panel_boundary_wkt, + frame_bbox, } } } @@ -45,10 +67,25 @@ impl ProjectionRenderer for MapProjection { } fn transform_layers(&self, _spec: &Plot, vl_spec: &mut Value) -> Result<()> { - vl_spec["projection"] = json!({ + let mut proj = json!({ "type": "identity", "reflectY": true }); + if let Some([xmin, ymin, xmax, ymax]) = self.frame_bbox { + // 10% expansion to match the default scale expand padding + let dx = (xmax - xmin) * 1.1; + let dy = (ymax - ymin) * 1.1; + let cx = (xmin + xmax) / 2.0; + let cy = (ymin + ymax) / 2.0; + proj["scale"] = json!({"expr": format!( + "min(width / {dx}, height / {dy})" + )}); + proj["translate"] = json!({"expr": format!( + "[width / 2 - min(width / {dx}, height / {dy}) * {cx}, \ + height / 2 + min(width / {dx}, height / {dy}) * {cy}]" + )}); + } + vl_spec["projection"] = proj; Ok(()) } @@ -182,4 +219,32 @@ mod tests { // view stroke should be nulled out assert_eq!(theme["view"]["stroke"], Value::Null); } + + #[test] + fn test_frame_bbox_emits_scale_translate_exprs() { + use crate::plot::types::ArrayElement; + + let mut proj = Projection::map(); + proj.computed.insert( + "frame_bbox".to_string(), + ParameterValue::Array(vec![ + ArrayElement::Number(0.0), + ArrayElement::Number(0.0), + ArrayElement::Number(100.0), + ArrayElement::Number(200.0), + ]), + ); + let renderer = MapProjection::new(Some(&proj), None); + let mut vl_spec = json!({"layer": []}); + let spec = Plot::default(); + + renderer.transform_layers(&spec, &mut vl_spec).unwrap(); + + let scale = &vl_spec["projection"]["scale"]["expr"]; + let translate = &vl_spec["projection"]["translate"]["expr"]; + assert!(scale.is_string(), "scale should be an expr"); + assert!(translate.is_string(), "translate should be an expr"); + assert!(scale.as_str().unwrap().contains("width")); + assert!(translate.as_str().unwrap().contains("height")); + } } From 036f55d8a865ac556ce5242e21b1359ff030141d Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 12 May 2026 12:03:18 +0200 Subject: [PATCH 16/50] Add Inf/-Inf literal support to grammar and parser Allows SETTING values like `bounds => [null, 1, Inf, -Inf]` or `limit => Inf`. Case-insensitive, optional +/- prefix. Co-Authored-By: Claude Opus 4.6 --- src/parser/builder.rs | 11 ++++++++ tree-sitter-ggsql/grammar.js | 6 +++- tree-sitter-ggsql/test/corpus/basic.txt | 37 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index cfc72598d..c96e03f59 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -86,6 +86,15 @@ fn parse_boolean_node(node: &Node, source: &SourceTree) -> bool { text == "true" } +fn parse_infinity_node(node: &Node, source: &SourceTree) -> f64 { + let text = source.get_text(node); + if text.starts_with('-') { + f64::NEG_INFINITY + } else { + f64::INFINITY + } +} + /// Parse an array node into Vec fn parse_array_node(node: &Node, source: &SourceTree) -> Result> { let mut values = Vec::new(); @@ -105,6 +114,7 @@ fn parse_array_node(node: &Node, source: &SourceTree) -> Result ArrayElement::Number(parse_number_node(&elem_child, source)?), "boolean" => ArrayElement::Boolean(parse_boolean_node(&elem_child, source)), "null_literal" => ArrayElement::Null, + "infinity" => ArrayElement::Number(parse_infinity_node(&elem_child, source)), _ => { return Err(GgsqlError::ParseError(format!( "Invalid array element type: {}", @@ -138,6 +148,7 @@ fn parse_value_node(node: &Node, source: &SourceTree, context: &str) -> Result

Ok(ParameterValue::Null), + "infinity" => Ok(ParameterValue::Number(parse_infinity_node(node, source))), _ => Err(GgsqlError::ParseError(format!( "Unexpected {} value type: {}", context, diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 704956092..b3f3d5623 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -589,6 +589,7 @@ module.exports = grammar({ $.number, $.boolean, $.null_literal, + $.infinity, $.array ), @@ -905,6 +906,8 @@ module.exports = grammar({ boolean: $ => choice('true', 'false'), + infinity: $ => token(seq(optional(choice('-', '+')), caseInsensitive('Inf'))), + array: $ => choice( seq( '[', @@ -928,7 +931,8 @@ module.exports = grammar({ $.string, $.number, $.boolean, - $.null_literal + $.null_literal, + $.infinity ), null_literal: $ => caseInsensitive('NULL'), diff --git a/tree-sitter-ggsql/test/corpus/basic.txt b/tree-sitter-ggsql/test/corpus/basic.txt index 43bd28950..b988e0c0f 100644 --- a/tree-sitter-ggsql/test/corpus/basic.txt +++ b/tree-sitter-ggsql/test/corpus/basic.txt @@ -3224,3 +3224,40 @@ SELECT x, COUNT(DISTINCT y) OVER (PARTITION BY x) AS n FROM data VISUALISE n AS (viz_clause (draw_clause (geom_type))))) + +================================================================================ +Infinity literals in SETTING +================================================================================ + +VISUALISE +DRAW point SETTING bounds => [null, 1, Inf, -Inf], limit => inf + +-------------------------------------------------------------------------------- + +(query + (visualise_statement + (visualise_keyword) + (viz_clause + (draw_clause + (geom_type) + (setting_clause + (parameter_assignment + name: (parameter_name + (identifier + (bare_identifier))) + value: (parameter_value + (array + (array_element + (null_literal)) + (array_element + (number)) + (array_element + (infinity)) + (array_element + (infinity))))) + (parameter_assignment + name: (parameter_name + (identifier + (bare_identifier))) + value: (parameter_value + (infinity)))))))) From ddeda967187720eb95255170f52c3026f390eeb7 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 12 May 2026 13:16:51 +0200 Subject: [PATCH 17/50] Add user-controllable bounds with Inf/null fallback, refactor map projection - resolve_frame_bbox merges user bounds, data bbox, and world bbox: null elements fall back to data, Inf falls back to clip boundary - Extract setup_clip_boundary from apply_projection_transforms - Rename sql_geometry_extent -> sql_geometry_bbox across trait + impls - Add step comments, clippy fixes, focused tests for bbox resolution Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/coord/map.rs | 358 ++++++++++++++++++++++++------- src/reader/duckdb.rs | 2 +- src/reader/mod.rs | 2 +- 3 files changed, 279 insertions(+), 83 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 2edfe4c94..31d81d1c9 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use super::{CoordKind, CoordTrait}; use crate::naming; use crate::plot::layer::geom::GeomType; -use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition}; +use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition, TypeConstraint}; use crate::plot::{Layer, ParameterValue}; use crate::reader::SqlDialect; use crate::DataFrame; @@ -30,6 +30,7 @@ impl CoordTrait for Map { } fn default_properties(&self) -> &'static [ParamDefinition] { + use crate::plot::types::{ArrayConstraint, NumberConstraint}; const PARAMS: &[ParamDefinition] = &[ ParamDefinition { name: "crs", @@ -46,6 +47,21 @@ impl CoordTrait for Map { default: DefaultParamValue::Boolean(true), constraint: ParamConstraint::boolean(), }, + // [xmin, ymin, xmax, ymax] in projected coordinates; null uses data bbox, Inf uses world bbox + ParamDefinition { + name: "bounds", + default: DefaultParamValue::Null, + constraint: ParamConstraint { + number: TypeConstraint::Forbidden, + string: TypeConstraint::Forbidden, + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Constrained( + ArrayConstraint::of_numbers_len(NumberConstraint::unconstrained(), 4) + .with_null_elements(), + ), + allow_null: true, + }, + }, ]; PARAMS } @@ -62,7 +78,7 @@ impl CoordTrait for Map { execute_query(&stmt)?; } - // Detect source CRS from geometry columns if not explicitly set + // Step 1: Detect source CRS from geometry columns if not explicitly set if !projection.properties.contains_key("source") { if let Some(srid) = detect_source_srid(layers, layer_queries, execute_query) { projection @@ -71,57 +87,26 @@ impl CoordTrait for Map { } } - // Compute clip boundary and create temp table for azimuthal projections - if let Some(wkt) = visible_area_wkt(&projection.properties) { - projection - .computed - .insert("clip_boundary".to_string(), ParameterValue::String(wkt.clone())); - let body = format!("SELECT ST_GeomFromText('{wkt}') AS geom"); - for stmt in dialect.create_or_replace_temp_table_sql(CLIP_BOUNDARY_TABLE, &[], &body) { - execute_query(&stmt)?; - } - - // Project the boundary for use as panel background in the writer - let source = match projection.properties.get("source") { - Some(ParameterValue::String(s)) => s.as_str(), - _ => "EPSG:4326", - }; - if let Some(ParameterValue::String(crs)) = projection.properties.get("crs") { - let projected = dialect.sql_st_transform("geom", source, crs); - let sql = format!( - "SELECT ST_AsText({projected}) AS wkt FROM {CLIP_BOUNDARY_TABLE}" - ); - if let Ok(df) = execute_query(&sql) { - let batch = df.inner(); - if batch.num_rows() > 0 { - if let Some(arr) = batch - .column(0) - .as_any() - .downcast_ref::() - { - let projected_wkt = arr.value(0); - projection.computed.insert( - "panel_boundary".to_string(), - ParameterValue::String(projected_wkt.to_string()), - ); - } - } - } - } - } + // Step 2: For azimuthal projections, compute the hemisphere clip boundary. + // This produces: clip_boundary (unprojected WKT), panel_boundary (projected + // WKT for the writer's background layer), and world_bbox (bounding box of the + // full projected visible area, used to resolve Inf in user-specified bounds). + let world_bbox = setup_clip_boundary(projection, dialect, execute_query)?; + // Step 3: Apply per-layer projection (ST_Transform, clip to horizon) for (idx, layer) in layers.iter().enumerate() { layer_queries[idx] = - layer.geom.apply_projection(&layer_queries[idx], projection, dialect)?; + layer + .geom + .apply_projection(&layer_queries[idx], projection, dialect)?; } - // Materialize spatial layers as temp tables, compute bbox, wrap with WKB + // Step 4: Materialize projected spatial layers as temp tables, compute the + // data bbox for framing, then convert geometry to WKB for Arrow transport. let geom_col = naming::aesthetic_column("geometry"); let geom_col_quoted = naming::quote_ident(&geom_col); - let mut xmin = f64::INFINITY; - let mut ymin = f64::INFINITY; - let mut xmax = f64::NEG_INFINITY; - let mut ymax = f64::NEG_INFINITY; + let bounds_param = projection.properties.get("bounds"); + let mut computed_bbox: Option<[f64; 4]> = None; for (idx, layer) in layers.iter().enumerate() { if layer.geom.geom_type() != GeomType::Spatial { @@ -135,45 +120,29 @@ impl CoordTrait for Map { } let table_quoted = naming::quote_ident(&table_name); - let sql = dialect.sql_geometry_extent(&geom_col_quoted, &table_quoted); - if let Ok(df) = execute_query(&sql) { - let batch = df.inner(); - if batch.num_rows() > 0 && batch.num_columns() >= 4 { - let get_f64 = |col: usize| -> Option { - use arrow::array::Array; - batch - .column(col) - .as_any() - .downcast_ref::() - .filter(|a| !a.is_null(0)) - .map(|a| a.value(0)) - }; - if let (Some(x0), Some(y0), Some(x1), Some(y1)) = - (get_f64(0), get_f64(1), get_f64(2), get_f64(3)) - { - xmin = xmin.min(x0); - ymin = ymin.min(y0); - xmax = xmax.max(x1); - ymax = ymax.max(y1); - } + + if needs_computed_bbox(bounds_param) { + let sql = dialect.sql_geometry_bbox(&geom_col_quoted, &table_quoted); + if let Ok(df) = execute_query(&sql) { + computed_bbox = merge_bbox(computed_bbox, bbox_from_df(&df)); } } let wkb_expr = dialect.sql_geometry_to_wkb(&geom_col_quoted); - layer_queries[idx] = format!( - "SELECT * REPLACE ({wkb_expr} AS {geom_col_quoted}) FROM {table_quoted}" - ); + layer_queries[idx] = + format!("SELECT * REPLACE ({wkb_expr} AS {geom_col_quoted}) FROM {table_quoted}"); } - if xmin.is_finite() && xmax.is_finite() { + // Step 5: Resolve final frame bbox from user bounds + data bounds + world bounds + if let Some(bbox) = resolve_frame_bbox(bounds_param, computed_bbox, world_bbox) { use crate::plot::types::ArrayElement; projection.computed.insert( "frame_bbox".to_string(), ParameterValue::Array(vec![ - ArrayElement::Number(xmin), - ArrayElement::Number(ymin), - ArrayElement::Number(xmax), - ArrayElement::Number(ymax), + ArrayElement::Number(bbox[0]), + ArrayElement::Number(bbox[1]), + ArrayElement::Number(bbox[2]), + ArrayElement::Number(bbox[3]), ]), ); } @@ -188,6 +157,133 @@ impl std::fmt::Display for Map { } } +/// Set up the clip boundary for azimuthal projections. Creates the clip boundary temp table, +/// projects it into the target CRS, and returns the world bbox (projected clip boundary extent). +fn setup_clip_boundary( + projection: &mut super::super::Projection, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> crate::Result> { + let Some(wkt) = visible_area_wkt(&projection.properties) else { + return Ok(None); + }; + + projection.computed.insert( + "clip_boundary".to_string(), + ParameterValue::String(wkt.clone()), + ); + let body = format!("SELECT ST_GeomFromText('{wkt}') AS geom"); + for stmt in dialect.create_or_replace_temp_table_sql(CLIP_BOUNDARY_TABLE, &[], &body) { + execute_query(&stmt)?; + } + + let source = match projection.properties.get("source") { + Some(ParameterValue::String(s)) => s.as_str(), + _ => "EPSG:4326", + }; + let Some(ParameterValue::String(crs)) = projection.properties.get("crs") else { + return Ok(None); + }; + + let projected = dialect.sql_st_transform("geom", source, crs); + let sql = format!("SELECT ST_AsText({projected}) AS wkt FROM {CLIP_BOUNDARY_TABLE}"); + if let Ok(df) = execute_query(&sql) { + let batch = df.inner(); + if batch.num_rows() > 0 { + if let Some(arr) = batch + .column(0) + .as_any() + .downcast_ref::() + { + let projected_wkt = arr.value(0); + projection.computed.insert( + "panel_boundary".to_string(), + ParameterValue::String(projected_wkt.to_string()), + ); + } + } + } + + let world_bbox_sql = dialect.sql_geometry_bbox(&projected, CLIP_BOUNDARY_TABLE); + let world_bbox = execute_query(&world_bbox_sql) + .ok() + .and_then(|df| bbox_from_df(&df)); + Ok(world_bbox) +} + +/// Returns true if we need to compute a bbox (bounding box representing the extent of geometry) +/// from the data — i.e. when bounds is absent or has null elements that need filling in. +fn needs_computed_bbox(bounds_param: Option<&ParameterValue>) -> bool { + match bounds_param { + Some(ParameterValue::Array(arr)) => { + use crate::plot::types::ArrayElement; + arr.iter().any(|e| !matches!(e, ArrayElement::Number(_))) + } + _ => true, + } +} + +/// Extract a [xmin, ymin, xmax, ymax] bbox from a DataFrame returned by `sql_geometry_bbox`. +fn bbox_from_df(df: &DataFrame) -> Option<[f64; 4]> { + use arrow::array::Array; + let batch = df.inner(); + if batch.num_rows() == 0 || batch.num_columns() < 4 { + return None; + } + let get_f64 = |col: usize| -> Option { + batch + .column(col) + .as_any() + .downcast_ref::() + .filter(|a| !a.is_null(0)) + .map(|a| a.value(0)) + }; + match (get_f64(0), get_f64(1), get_f64(2), get_f64(3)) { + (Some(x0), Some(y0), Some(x1), Some(y1)) => Some([x0, y0, x1, y1]), + _ => None, + } +} + +/// Expand an existing bbox to include another, or return the new one. +fn merge_bbox(existing: Option<[f64; 4]>, new: Option<[f64; 4]>) -> Option<[f64; 4]> { + match (existing, new) { + (Some([x0, y0, x1, y1]), Some([nx0, ny0, nx1, ny1])) => { + Some([x0.min(nx0), y0.min(ny0), x1.max(nx1), y1.max(ny1)]) + } + (Some(b), None) | (None, Some(b)) => Some(b), + (None, None) => None, + } +} + +/// Resolve the frame bbox: merge explicit bounds with computed values. +/// - Null elements fall back to the corresponding data-computed bbox. +/// - Inf/-Inf elements fall back to the clip boundary (world) bbox. +fn resolve_frame_bbox( + bounds_param: Option<&ParameterValue>, + computed: Option<[f64; 4]>, + world: Option<[f64; 4]>, +) -> Option<[f64; 4]> { + if let Some(ParameterValue::Array(arr)) = bounds_param { + use crate::plot::types::ArrayElement; + let data_fallback = computed.unwrap_or([f64::NAN; 4]); + let world_fallback = world.unwrap_or([f64::NAN; 4]); + let resolved: Vec = arr + .iter() + .enumerate() + .map(|(i, e)| match e { + ArrayElement::Number(n) if n.is_finite() => *n, + ArrayElement::Number(_) => world_fallback[i], + _ => data_fallback[i], + }) + .collect(); + // [xmin, ymin, xmax, ymax] in projected CRS units + if resolved.len() == 4 && resolved.iter().all(|v| v.is_finite()) { + return Some([resolved[0], resolved[1], resolved[2], resolved[3]]); + } + } + computed +} + /// Returns a WKT POLYGON representing the visible hemisphere for the given projection /// properties, or `None` if the projection doesn't require horizon clipping. /// @@ -223,7 +319,7 @@ fn projection_center(crs: &str) -> (f64, f64) { fn extract_proj_param(crs: &str, key: &str) -> Option { crs.find(key).and_then(|start| { let after = &crs[start + key.len()..]; - let end = after.find(|c: char| c == ' ' || c == '+').unwrap_or(after.len()); + let end = after.find([' ', '+']).unwrap_or(after.len()); after[..end].parse().ok() }) } @@ -355,7 +451,7 @@ fn build_antimeridian_multipolygon(points: &[(f64, f64)]) -> String { let lat_c1 = antimeridian_crossing_lat(points[c1], points[(c1 + 1) % n]); let lat_c2 = antimeridian_crossing_lat(points[c2], points[(c2 + 1) % n]); - let (east_arc, west_arc, east_start_lat, east_end_lat, west_start_lat, west_end_lat) = + let (east_arc, west_arc, [east_start_lat, east_end_lat, west_start_lat, west_end_lat]) = split_arcs_at_crossings(points, c1, c2, lat_c1, lat_c2); let east_coords = build_side_ring(&east_arc, 180.0, east_start_lat, east_end_lat); @@ -369,13 +465,15 @@ fn build_antimeridian_multipolygon(points: &[(f64, f64)]) -> String { } /// Split the ring at two crossing indices into east/west arcs with their boundary latitudes. +type ArcSplit = (Vec<(f64, f64)>, Vec<(f64, f64)>, [f64; 4]); + fn split_arcs_at_crossings( points: &[(f64, f64)], c1: usize, c2: usize, lat_c1: f64, lat_c2: f64, -) -> (Vec<(f64, f64)>, Vec<(f64, f64)>, f64, f64, f64, f64) { +) -> ArcSplit { let n = points.len(); let mut arc1: Vec<(f64, f64)> = Vec::new(); @@ -399,9 +497,9 @@ fn split_arcs_at_crossings( } if arc1[0].0 > 0.0 { - (arc1, arc2, lat_c1, lat_c2, lat_c2, lat_c1) + (arc1, arc2, [lat_c1, lat_c2, lat_c2, lat_c1]) } else { - (arc2, arc1, lat_c2, lat_c1, lat_c1, lat_c2) + (arc2, arc1, [lat_c2, lat_c1, lat_c1, lat_c2]) } } @@ -490,7 +588,8 @@ mod tests { assert!(names.contains(&"crs")); assert!(names.contains(&"source")); assert!(names.contains(&"clip")); - assert_eq!(defaults.len(), 3); + assert!(names.contains(&"bounds")); + assert_eq!(defaults.len(), 4); } #[test] @@ -608,4 +707,101 @@ mod tests { "pole case should produce POLYGON: {wkt}" ); } + + #[test] + fn test_resolve_frame_bbox_no_bounds_uses_computed() { + let computed = Some([0.0, 0.0, 100.0, 200.0]); + assert_eq!(resolve_frame_bbox(None, computed, None), computed); + } + + #[test] + fn test_resolve_frame_bbox_no_bounds_no_computed() { + assert_eq!(resolve_frame_bbox(None, None, None), None); + } + + #[test] + fn test_resolve_frame_bbox_explicit_bounds_override_computed() { + use crate::plot::types::ArrayElement; + let bounds = ParameterValue::Array(vec![ + ArrayElement::Number(10.0), + ArrayElement::Number(20.0), + ArrayElement::Number(30.0), + ArrayElement::Number(40.0), + ]); + let computed = Some([0.0, 0.0, 100.0, 200.0]); + assert_eq!( + resolve_frame_bbox(Some(&bounds), computed, None), + Some([10.0, 20.0, 30.0, 40.0]) + ); + } + + #[test] + fn test_resolve_frame_bbox_null_elements_use_computed() { + use crate::plot::types::ArrayElement; + let bounds = ParameterValue::Array(vec![ + ArrayElement::Null, + ArrayElement::Number(20.0), + ArrayElement::Null, + ArrayElement::Number(40.0), + ]); + let computed = Some([5.0, 0.0, 95.0, 0.0]); + assert_eq!( + resolve_frame_bbox(Some(&bounds), computed, None), + Some([5.0, 20.0, 95.0, 40.0]) + ); + } + + #[test] + fn test_resolve_frame_bbox_inf_elements_use_world() { + use crate::plot::types::ArrayElement; + let bounds = ParameterValue::Array(vec![ + ArrayElement::Number(f64::NEG_INFINITY), + ArrayElement::Number(20.0), + ArrayElement::Number(f64::INFINITY), + ArrayElement::Number(40.0), + ]); + let computed = Some([5.0, 0.0, 95.0, 0.0]); + let world = Some([-500.0, -500.0, 500.0, 500.0]); + assert_eq!( + resolve_frame_bbox(Some(&bounds), computed, world), + Some([-500.0, 20.0, 500.0, 40.0]) + ); + } + + #[test] + fn test_resolve_frame_bbox_null_without_computed_falls_through() { + use crate::plot::types::ArrayElement; + let bounds = ParameterValue::Array(vec![ + ArrayElement::Null, + ArrayElement::Number(20.0), + ArrayElement::Number(30.0), + ArrayElement::Number(40.0), + ]); + // null can't resolve without computed, so result is NaN → falls through to computed + assert_eq!(resolve_frame_bbox(Some(&bounds), None, None), None); + } + + #[test] + fn test_resolve_frame_bbox_inf_without_world_falls_through() { + use crate::plot::types::ArrayElement; + let bounds = ParameterValue::Array(vec![ + ArrayElement::Number(f64::INFINITY), + ArrayElement::Number(20.0), + ArrayElement::Number(30.0), + ArrayElement::Number(40.0), + ]); + let computed = Some([5.0, 0.0, 95.0, 200.0]); + // Inf can't resolve without world, falls through to computed + assert_eq!(resolve_frame_bbox(Some(&bounds), computed, None), computed); + } + + #[test] + fn test_merge_bbox() { + let a = Some([0.0, 10.0, 50.0, 60.0]); + let b = Some([-5.0, 15.0, 45.0, 70.0]); + assert_eq!(merge_bbox(a, b), Some([-5.0, 10.0, 50.0, 70.0])); + assert_eq!(merge_bbox(a, None), a); + assert_eq!(merge_bbox(None, a), a); + assert_eq!(merge_bbox(None, None), None); + } } diff --git a/src/reader/duckdb.rs b/src/reader/duckdb.rs index 9e8389d31..69ff1b5e4 100644 --- a/src/reader/duckdb.rs +++ b/src/reader/duckdb.rs @@ -95,7 +95,7 @@ impl super::SqlDialect for DuckDbDialect { format!("ST_AsWKB({column})") } - fn sql_geometry_extent(&self, column: &str, from: &str) -> String { + fn sql_geometry_bbox(&self, column: &str, from: &str) -> String { format!( "SELECT ST_XMin(ext) AS xmin, ST_YMin(ext) AS ymin, \ ST_XMax(ext) AS xmax, ST_YMax(ext) AS ymax \ diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 3d473fdba..ffc16f12a 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -138,7 +138,7 @@ pub trait SqlDialect { /// /// Must return a single row with columns `xmin`, `ymin`, `xmax`, `ymax` (DOUBLE). /// `from` is the table or subquery to aggregate over. - fn sql_geometry_extent(&self, column: &str, from: &str) -> String { + fn sql_geometry_bbox(&self, column: &str, from: &str) -> String { format!( "SELECT ST_XMin(ext) AS xmin, ST_YMin(ext) AS ymin, \ ST_XMax(ext) AS xmax, ST_YMax(ext) AS ymax \ From 530b4c1ecfd550554f797178d5faf06605282850 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 12 May 2026 13:28:01 +0200 Subject: [PATCH 18/50] update the CLAUDE.mds --- src/CLAUDE.md | 12 ++++-------- src/plot/CLAUDE.md | 12 ++++++++++-- src/writer/vegalite/CLAUDE.md | 31 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/CLAUDE.md b/src/CLAUDE.md index 5b3152792..0e354b6ce 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -46,11 +46,10 @@ Grammar lives in [`/tree-sitter-ggsql/`](../tree-sitter-ggsql/) — when adding | `duckdb.rs` | DuckDB (in-memory or file) | `duckdb` (default) | | `sqlite.rs` | SQLite | `sqlite` (default) | | `odbc.rs` | ODBC | `odbc` (default) | -| `snowflake.rs` | Snowflake (very small placeholder today) | — | | `connection.rs` | Connection-string parsing for all of the above | — | | `data.rs`, `spec.rs` | `Spec` type returned by `execute()`, plus DataFrame conversion | — | -`SqlDialect` trait in `mod.rs` lets each driver supply its own type names and information-schema queries. PostgreSQL has a feature flag (`postgres`) but no driver file yet. +`SqlDialect` trait in `mod.rs` lets each driver supply its own type names, information-schema queries, and spatial helper methods (`sql_st_transform`, `sql_geometry_to_wkb`, `sql_geometry_bbox`, `sql_spatial_setup`). ### `execute/` @@ -97,15 +96,12 @@ Defined in `Cargo.toml`: | `sqlite` | ✓ | SQLite reader | | `odbc` | ✓ | ODBC reader | | `parquet` | ✓ | Parquet support in readers/data | -| `ipc` | ✓ | Arrow IPC support | +| `spatial` | ✓ | Spatial/geometry support (geozero for WKT↔GeoJSON) | | `vegalite` | ✓ | Vega-Lite writer | | `builtin-data` | ✓ | Bundled penguins/airquality datasets | -| `postgres` | — | PostgreSQL reader (declared, not yet implemented) | -| `ggplot2` | — | ggplot2 writer (declared, not yet implemented) | -| `all-readers` | — | `duckdb` + `postgres` + `sqlite` + `odbc` | -| `all-writers` | — | `vegalite` + `ggplot2` + `plotters` | +| `all-readers` | — | `duckdb` + `sqlite` + `odbc` | -`ggsql-wasm` builds with `default-features = false` plus `vegalite`, `sqlite`, `builtin-data`. `ggsql-jupyter` builds with `duckdb`, `sqlite`, `odbc`, `vegalite`. +`ggsql-wasm` builds with `default-features = false` plus `vegalite`, `sqlite`, `builtin-data`. `ggsql-jupyter` builds with `duckdb`, `vegalite`. ## Testing diff --git a/src/plot/CLAUDE.md b/src/plot/CLAUDE.md index 6dee23b26..eca8fdbd7 100644 --- a/src/plot/CLAUDE.md +++ b/src/plot/CLAUDE.md @@ -35,7 +35,7 @@ plot/ │ ├── orientation.rs Layer transposition (horizontal vs vertical) │ └── position/ identity, stack, dodge, jitter ├── projection/ PROJECT clause -│ └── coord/ cartesian, polar +│ └── coord/ cartesian, polar, map └── scale/ SCALE clause ├── scale_type/ binned, continuous, discrete, identity, ordinal └── transform/ identity, log, sqrt, asinh, exp, square, pseudo_log, @@ -64,7 +64,15 @@ Scale type / transform docs: [`/doc/syntax/scale/type/`](../../doc/syntax/scale/ ### `facet/` and `projection/` -Smaller subsystems. Each has a `types.rs` (data structure) and `resolve.rs` (logic that runs during execution). `projection/coord/` currently has `cartesian` and `polar`. Docs: [`/doc/syntax/clause/facet.qmd`](../../doc/syntax/clause/facet.qmd), [`/doc/syntax/coord/`](../../doc/syntax/coord/). +Each has a `types.rs` (data structure) and `resolve.rs` (logic that runs during execution). `projection/coord/` has three implementations: + +- **`cartesian`** — standard x/y. No special behaviour. +- **`polar`** — radius/angle (for pie/rose plots). +- **`map`** — geographic projections via PROJ strings. Implements `apply_projection_transforms` to: detect source CRS from geometry SRID, make clip boundaries, delegate per-layer spatial transforms, materialize projected layers as temp tables, and resolve frame bbox from user bounds / data extent / world extent. Properties: `crs` (PROJ string), `source` (source EPSG), `clip` (bool), `bounds` ([xmin, ymin, xmax, ymax] with null/Inf fallback semantics). + +`Projection` (in `types.rs`) wraps `Coord` + resolved aesthetics + properties + a `computed` map populated at execution time (e.g., `clip_boundary`, `panel_boundary`, `frame_bbox`). + +Docs: [`/doc/syntax/clause/facet.qmd`](../../doc/syntax/clause/facet.qmd), [`/doc/syntax/coord/`](../../doc/syntax/coord/). ## Adding a new geom / scale type / coord diff --git a/src/writer/vegalite/CLAUDE.md b/src/writer/vegalite/CLAUDE.md index 0baae99a0..508674e6e 100644 --- a/src/writer/vegalite/CLAUDE.md +++ b/src/writer/vegalite/CLAUDE.md @@ -377,10 +377,41 @@ grep '__ggsql_source__' /tmp/test.vl.json # Source values Paste the spec into [Vega-Lite Editor](https://vega.github.io/editor/#/url/vega-lite/) to visualize. +## Projection Rendering + +The `projection/` subdirectory handles coord-specific VL output. Each coord type implements `ProjectionRenderer` (defined in `projection/mod.rs`): + +``` +writer/vegalite/projection/ +├── mod.rs ProjectionRenderer trait + factory (get_projection_renderer) +├── cartesian.rs Standard x/y axes with domain/breaks from scales +├── polar.rs Arc marks, theta/radius channels, radial axes +└── map.rs Identity projection for pre-projected spatial data +``` + +### ProjectionRenderer trait + +Two concerns per implementation: + +1. **Channel mapping** — `position_channels()` returns the VL encoding names for pos1/pos2 (e.g. `("x", "y")` for cartesian, `("radius", "theta")` for polar). +2. **Spec transformation** — `transform_layers()` modifies the VL spec after layers are built (e.g. polar converts marks to arcs, map adds an identity projection with scale/translate expressions). + +Additional hooks: `background_layers()` / `foreground_layers()` inject layers before/after the data layers (e.g. map renders the projected clip boundary as a geoshape panel background), and `apply_projection()` orchestrates all of these plus clip propagation. + +### Map projection specifics + +`MapProjection` reads computed values from `Projection.computed` (populated at execution time by the `Map` coord): + +- `panel_boundary` (WKT) → converted to GeoJSON for a geoshape background layer. +- `frame_bbox` ([xmin, ymin, xmax, ymax]) → emits VL `projection.scale` and `projection.translate` expressions that frame the data to the viewport. + +The VL projection is always `{"type": "identity", "reflectY": true}` because coordinates arrive pre-projected from the SQL layer. + ## References - **Main implementation**: `src/writer/vegalite/mod.rs` - **Layer rendering**: `src/writer/vegalite/layer.rs` - **Data unification**: `src/writer/vegalite/data.rs` (`unify_datasets()`) - **Renderer trait**: `src/writer/vegalite/layer.rs` (`GeomRenderer` trait) +- **Projection rendering**: `src/writer/vegalite/projection/mod.rs` (`ProjectionRenderer` trait) - **Example renderers**: `LineRenderer`, `BoxplotRenderer`, `ViolinRenderer` in `layer.rs` From 6290d83720edeebf21e06a8474f4fdae53b8ceae Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 12 May 2026 15:12:48 +0200 Subject: [PATCH 19/50] Add graticule (lat/lon grid lines) to map projections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate meridian and parallel lines as background layers in map projections. The graticule is computed by inverse-projecting the frame bbox to determine the angular extent, selecting pretty intervals from standard geographic steps (1, 2, 5, 10, 15, 30, 45, 90°), densifying the lines, clipping to the hemisphere boundary for azimuthal projections, and projecting to the target CRS. Styling follows the theme's axis.gridColor and axis.gridWidth. Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/coord/map.rs | 404 ++++++++++++++++++++++++-- src/writer/vegalite/projection/map.rs | 151 ++++++++-- 2 files changed, 504 insertions(+), 51 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 31d81d1c9..e42884322 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -87,11 +87,20 @@ impl CoordTrait for Map { } } + let source = match projection.properties.get("source") { + Some(ParameterValue::String(s)) => s.clone(), + _ => "EPSG:4326".to_string(), + }; + let crs = match projection.properties.get("crs") { + Some(ParameterValue::String(s)) => s.clone(), + _ => return Ok(()), + }; + // Step 2: For azimuthal projections, compute the hemisphere clip boundary. // This produces: clip_boundary (unprojected WKT), panel_boundary (projected // WKT for the writer's background layer), and world_bbox (bounding box of the // full projected visible area, used to resolve Inf in user-specified bounds). - let world_bbox = setup_clip_boundary(projection, dialect, execute_query)?; + let world_bbox = setup_clip_boundary(projection, &source, &crs, dialect, execute_query)?; // Step 3: Apply per-layer projection (ST_Transform, clip to horizon) for (idx, layer) in layers.iter().enumerate() { @@ -134,17 +143,33 @@ impl CoordTrait for Map { } // Step 5: Resolve final frame bbox from user bounds + data bounds + world bounds - if let Some(bbox) = resolve_frame_bbox(bounds_param, computed_bbox, world_bbox) { - use crate::plot::types::ArrayElement; - projection.computed.insert( - "frame_bbox".to_string(), - ParameterValue::Array(vec![ - ArrayElement::Number(bbox[0]), - ArrayElement::Number(bbox[1]), - ArrayElement::Number(bbox[2]), - ArrayElement::Number(bbox[3]), - ]), - ); + let Some(bbox) = resolve_frame_bbox(bounds_param, computed_bbox, world_bbox) else { + return Ok(()); + }; + use crate::plot::types::ArrayElement; + projection.computed.insert( + "frame_bbox".to_string(), + ParameterValue::Array(vec![ + ArrayElement::Number(bbox[0]), + ArrayElement::Number(bbox[1]), + ArrayElement::Number(bbox[2]), + ArrayElement::Number(bbox[3]), + ]), + ); + + // Step 6: Generate graticule lines + let has_clip = projection.computed.contains_key("clip_boundary"); + let (lon_wkt, lat_wkt) = + build_graticule(bbox, &crs, &source, has_clip, dialect, execute_query)?; + if let Some(wkt) = lon_wkt { + projection + .computed + .insert("graticule_lon".to_string(), ParameterValue::String(wkt)); + } + if let Some(wkt) = lat_wkt { + projection + .computed + .insert("graticule_lat".to_string(), ParameterValue::String(wkt)); } Ok(()) @@ -161,6 +186,8 @@ impl std::fmt::Display for Map { /// projects it into the target CRS, and returns the world bbox (projected clip boundary extent). fn setup_clip_boundary( projection: &mut super::super::Projection, + source: &str, + crs: &str, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result> { @@ -177,14 +204,6 @@ fn setup_clip_boundary( execute_query(&stmt)?; } - let source = match projection.properties.get("source") { - Some(ParameterValue::String(s)) => s.as_str(), - _ => "EPSG:4326", - }; - let Some(ParameterValue::String(crs)) = projection.properties.get("crs") else { - return Ok(None); - }; - let projected = dialect.sql_st_transform("geom", source, crs); let sql = format!("SELECT ST_AsText({projected}) AS wkt FROM {CLIP_BOUNDARY_TABLE}"); if let Ok(df) = execute_query(&sql) { @@ -211,6 +230,302 @@ fn setup_clip_boundary( Ok(world_bbox) } +/// Build graticule lines: determine the visible lon/lat extent, generate densified +/// meridians and parallels, clip and project them, and return projected WKT. +fn build_graticule( + frame_bbox: [f64; 4], + crs: &str, + source: &str, + has_clip: bool, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> crate::Result<(Option, Option)> { + let Some([lon_min, lat_min, lon_max, lat_max]) = + graticule_bbox(frame_bbox, crs, source, has_clip, dialect, execute_query)? + else { + return Ok((None, None)); + }; + + let lon_breaks = graticule_breaks(lon_min, lon_max); + let lat_breaks = graticule_breaks(lat_min, lat_max); + + if lon_breaks.is_empty() && lat_breaks.is_empty() { + return Ok((None, None)); + } + + // Densification interval based on angular extent + let max_range = (lon_max - lon_min).max(lat_max - lat_min); + let step_deg = if max_range > 90.0 { + 2.0 + } else if max_range > 30.0 { + 1.0 + } else { + 0.5 + }; + + // Clamp meridians away from ±180 to avoid antimeridian issues, and + // deduplicate (e.g. if both -180 and 180 were present, they become the same) + let lon_breaks: Vec = { + let mut clamped: Vec = lon_breaks + .iter() + .map(|&v| { + if v <= -180.0 { + -179.999999 + } else if v >= 180.0 { + 179.999999 + } else { + v + } + }) + .collect(); + clamped.dedup_by(|a, b| (*a - *b).abs() < 0.001); + clamped + }; + + let lon_wkt = if !lon_breaks.is_empty() { + Some(meridians_multilinestring( + &lon_breaks, + lat_min, + lat_max, + step_deg, + )) + } else { + None + }; + let lat_wkt = if !lat_breaks.is_empty() { + Some(parallels_multilinestring( + &lat_breaks, + lon_min, + lon_max, + step_deg, + )) + } else { + None + }; + + Ok(( + project_wkt(lon_wkt, has_clip, source, crs, dialect, execute_query)?, + project_wkt(lat_wkt, has_clip, source, crs, dialect, execute_query)?, + )) +} + +/// Determine the lon/lat bounding box visible in the current frame by inverse-projecting +/// the bbox corners. Falls back to the clip boundary for azimuthal projections +/// where corners collapse to degenerate values. +fn graticule_bbox( + frame_bbox: [f64; 4], + crs: &str, + source: &str, + has_clip: bool, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> crate::Result> { + use arrow::array::Array; + + let [xmin, ymin, xmax, ymax] = frame_bbox; + let corners_sql = format!( + "SELECT ST_X(pt) AS lon, ST_Y(pt) AS lat FROM (\ + VALUES ({xmin}, {ymin}), ({xmax}, {ymin}), ({xmin}, {ymax}), ({xmax}, {ymax})\ + ) AS t(px, py), \ + LATERAL (SELECT {} AS pt) sub", + dialect.sql_st_transform("ST_Point(px, py)", crs, source) + ); + let df = match execute_query(&corners_sql) { + Ok(df) => df, + Err(_) => return Ok(None), + }; + + let batch = df.inner(); + if batch.num_rows() == 0 { + return Ok(None); + } + let lon_arr = batch + .column(0) + .as_any() + .downcast_ref::(); + let lat_arr = batch + .column(1) + .as_any() + .downcast_ref::(); + let (Some(lons), Some(lats)) = (lon_arr, lat_arr) else { + return Ok(None); + }; + + let mut lon_min = f64::INFINITY; + let mut lon_max = f64::NEG_INFINITY; + let mut lat_min = f64::INFINITY; + let mut lat_max = f64::NEG_INFINITY; + for i in 0..batch.num_rows() { + if lons.is_null(i) || lats.is_null(i) { + continue; + } + let lon = lons.value(i).clamp(-180.0, 180.0); + let lat = lats.value(i).clamp(-90.0, 90.0); + lon_min = lon_min.min(lon); + lon_max = lon_max.max(lon); + lat_min = lat_min.min(lat); + lat_max = lat_max.max(lat); + } + if !lon_min.is_finite() || !lat_min.is_finite() { + return Ok(None); + } + + // For azimuthal projections the bbox corners often inverse-project to + // degenerate values (all at the horizon). Fall back to the clip boundary + // extent which represents the actual visible hemisphere. + if has_clip && (lon_max - lon_min).abs() < 1.0 { + let bbox_sql = format!( + "SELECT ST_XMin(ext) AS xmin, ST_YMin(ext) AS ymin, \ + ST_XMax(ext) AS xmax, ST_YMax(ext) AS ymax \ + FROM (SELECT ST_Extent(geom) AS ext FROM {CLIP_BOUNDARY_TABLE})" + ); + if let Ok(df) = execute_query(&bbox_sql) { + if let Some([x0, y0, x1, y1]) = bbox_from_df(&df) { + lon_min = x0; + lat_min = y0; + lon_max = x1; + lat_max = y1; + } + } + } + + // For projections showing the full globe, expand to full range + if lon_max - lon_min > 300.0 { + lon_min = -180.0; + lon_max = 180.0; + } + if lat_max - lat_min > 150.0 { + lat_min = -90.0; + lat_max = 90.0; + } + + Ok(Some([lon_min, lat_min, lon_max, lat_max])) +} + +/// Clip (if needed) and project a WKT geometry string, returning the projected WKT. +fn project_wkt( + wkt: Option, + has_clip: bool, + source: &str, + crs: &str, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> crate::Result> { + use arrow::array::Array; + + let Some(wkt) = wkt else { return Ok(None) }; + let geom_expr = format!("ST_GeomFromText('{wkt}')"); + let clipped = if has_clip { + format!("ST_Intersection({geom_expr}, (SELECT geom FROM {CLIP_BOUNDARY_TABLE}))") + } else { + geom_expr + }; + let projected = dialect.sql_st_transform(&clipped, source, crs); + let sql = format!("SELECT ST_AsText({projected}) AS wkt"); + match execute_query(&sql) { + Ok(df) => { + let batch = df.inner(); + if batch.num_rows() > 0 { + if let Some(arr) = batch + .column(0) + .as_any() + .downcast_ref::() + { + if !arr.is_null(0) { + return Ok(Some(arr.value(0).to_string())); + } + } + } + Ok(None) + } + Err(_) => Ok(None), + } +} + +/// Pick pretty graticule break positions for a lon or lat range. +/// Uses standard angular intervals (multiples of 1, 2, 5, 10, 15, 30, 45, 90). +fn graticule_breaks(min: f64, max: f64) -> Vec { + let range = max - min; + if range <= 0.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]; + + // Pick the smallest step that gives at most ~7 lines + let step = STEPS + .iter() + .copied() + .find(|&s| range / s <= 8.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(); + + // Include the boundary value when range covers the full extent, + // so the antimeridian/pole gets a line + if min <= -180.0 && !breaks.iter().any(|&v| v == -180.0) { + breaks.insert(0, -180.0); + } else if max >= 180.0 && !breaks.iter().any(|&v| v == 180.0) { + breaks.push(180.0); + } + if min <= -90.0 && !breaks.iter().any(|&v| v == -90.0) { + breaks.insert(0, -90.0); + } else if max >= 90.0 && !breaks.iter().any(|&v| v == 90.0) { + breaks.push(90.0); + } + + breaks +} + +/// Generate a MULTILINESTRING WKT with meridians (vertical lines at fixed longitude), +/// densified with intermediate vertices at `step_deg` intervals. +fn meridians_multilinestring( + lon_breaks: &[f64], + lat_min: f64, + lat_max: f64, + step_deg: f64, +) -> String { + let mut lines: Vec = Vec::with_capacity(lon_breaks.len()); + for &lon in lon_breaks { + let mut coords = Vec::new(); + let mut lat = lat_min; + while lat < lat_max { + coords.push(format!("{lon:.6} {lat:.6}")); + lat += step_deg; + } + coords.push(format!("{lon:.6} {lat_max:.6}")); + lines.push(format!("({})", coords.join(", "))); + } + format!("MULTILINESTRING({})", lines.join(", ")) +} + +/// Generate a MULTILINESTRING WKT with parallels (horizontal lines at fixed latitude), +/// densified with intermediate vertices at `step_deg` intervals. +fn parallels_multilinestring( + lat_breaks: &[f64], + lon_min: f64, + lon_max: f64, + step_deg: f64, +) -> String { + let mut lines: Vec = Vec::with_capacity(lat_breaks.len()); + for &lat in lat_breaks { + let mut coords = Vec::new(); + let mut lon = lon_min; + while lon < lon_max { + coords.push(format!("{lon:.6} {lat:.6}")); + lon += step_deg; + } + coords.push(format!("{lon_max:.6} {lat:.6}")); + lines.push(format!("({})", coords.join(", "))); + } + format!("MULTILINESTRING({})", lines.join(", ")) +} + /// Returns true if we need to compute a bbox (bounding box representing the extent of geometry) /// from the data — i.e. when bounds is absent or has null elements that need filling in. fn needs_computed_bbox(bounds_param: Option<&ParameterValue>) -> bool { @@ -804,4 +1119,53 @@ mod tests { assert_eq!(merge_bbox(None, a), a); assert_eq!(merge_bbox(None, None), None); } + + #[test] + fn test_graticule_breaks_world() { + let breaks = graticule_breaks(-180.0, 180.0); + 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); + 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); + 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); + assert!(breaks.is_empty()); + } + + #[test] + fn test_meridians_multilinestring() { + let wkt = meridians_multilinestring(&[0.0, 30.0], -90.0, 90.0, 45.0); + assert!(wkt.starts_with("MULTILINESTRING("), "{wkt}"); + // Two meridians: each starts with "(" after the outer wrapper + assert!(wkt.contains("0.000000 -90.000000"), "{wkt}"); + assert!(wkt.contains("30.000000 -90.000000"), "{wkt}"); + assert!(wkt.contains("0.000000 90.000000"), "{wkt}"); + assert!(wkt.contains("30.000000 90.000000"), "{wkt}"); + } + + #[test] + fn test_parallels_multilinestring() { + let wkt = parallels_multilinestring(&[0.0, 45.0], -180.0, 180.0, 90.0); + assert!(wkt.starts_with("MULTILINESTRING(")); + assert!(wkt.contains("0.000000")); + assert!(wkt.contains("45.000000")); + } } diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs index 329b1c71d..62199b991 100644 --- a/src/writer/vegalite/projection/map.rs +++ b/src/writer/vegalite/projection/map.rs @@ -14,17 +14,25 @@ use super::ProjectionRenderer; pub(in crate::writer) struct MapProjection { is_faceted: bool, panel_boundary_wkt: Option, + graticule_lon_wkt: Option, + graticule_lat_wkt: Option, frame_bbox: Option<[f64; 4]>, } impl MapProjection { pub(super) fn new(project: Option<&Projection>, facet: Option<&crate::plot::Facet>) -> Self { - let panel_boundary_wkt = project - .and_then(|p| p.computed.get("panel_boundary")) - .and_then(|v| match v { - ParameterValue::String(s) => Some(s.clone()), - _ => None, - }); + let get_string = |key: &str| -> Option { + project + .and_then(|p| p.computed.get(key)) + .and_then(|v| match v { + ParameterValue::String(s) => Some(s.clone()), + _ => None, + }) + }; + let panel_boundary_wkt = get_string("panel_boundary"); + let graticule_lon_wkt = get_string("graticule_lon"); + let graticule_lat_wkt = get_string("graticule_lat"); + // TODO: simplify — coord side always stores exactly 4 Numbers let frame_bbox = project .and_then(|p| p.computed.get("frame_bbox")) .and_then(|v| match v { @@ -48,6 +56,8 @@ impl MapProjection { Self { is_faceted: facet.is_some_and(|f| !f.get_variables().is_empty()), panel_boundary_wkt, + graticule_lon_wkt, + graticule_lat_wkt, frame_bbox, } } @@ -90,34 +100,66 @@ impl ProjectionRenderer for MapProjection { } fn background_layers(&self, _scales: &[Scale], theme: &mut Value) -> Vec { - let Some(ref wkt) = self.panel_boundary_wkt else { - return Vec::new(); - }; - let Some(geojson) = wkt_to_geojson(wkt) else { - return Vec::new(); - }; + let mut layers = Vec::new(); - let (fill, stroke) = if let Some(view) = - theme.get_mut("view").and_then(|v| v.as_object_mut()) - { - let fill = view.remove("fill").unwrap_or(Value::Null); - let stroke = view.remove("stroke").unwrap_or(Value::Null); - view.insert("stroke".to_string(), Value::Null); - (fill, stroke) - } else { - (Value::Null, Value::Null) - }; + // Panel boundary (hemisphere fill) + if let Some(ref wkt) = self.panel_boundary_wkt { + if let Some(geojson) = wkt_to_geojson(wkt) { + let (fill, stroke) = if let Some(view) = + theme.get_mut("view").and_then(|v| v.as_object_mut()) + { + let fill = view.remove("fill").unwrap_or(Value::Null); + let stroke = view.remove("stroke").unwrap_or(Value::Null); + view.insert("stroke".to_string(), Value::Null); + (fill, stroke) + } else { + (Value::Null, Value::Null) + }; + layers.push(json!({ + "data": { + "values": [{"type": "Feature", "geometry": geojson}] + }, + "mark": { + "type": "geoshape", + "fill": fill, + "stroke": stroke, + } + })); + } + } - vec![json!({ - "data": { - "values": [{"type": "Feature", "geometry": geojson}] - }, - "mark": { - "type": "geoshape", - "fill": fill, - "stroke": stroke, + // Graticule lines (meridians and parallels) + if self.graticule_lon_wkt.is_some() || self.graticule_lat_wkt.is_some() { + let grid_color = theme + .pointer("/axis/gridColor") + .cloned() + .unwrap_or(json!("#cccccc")); + let grid_width = theme + .pointer("/axis/gridWidth") + .cloned() + .unwrap_or(json!(0.5)); + + for wkt in [&self.graticule_lon_wkt, &self.graticule_lat_wkt] + .into_iter() + .flatten() + { + if let Some(geojson) = wkt_to_geojson(wkt) { + layers.push(json!({ + "data": { + "values": [{"type": "Feature", "geometry": geojson}] + }, + "mark": { + "type": "geoshape", + "filled": false, + "stroke": grid_color, + "strokeWidth": grid_width, + } + })); + } } - })] + } + + layers } } @@ -247,4 +289,51 @@ mod tests { assert!(scale.as_str().unwrap().contains("width")); assert!(translate.as_str().unwrap().contains("height")); } + + #[test] + fn test_graticule_layers_rendered() { + let mut proj = Projection::map(); + proj.computed.insert( + "graticule_lon".to_string(), + ParameterValue::String("MULTILINESTRING ((0 -90, 0 90), (30 -90, 30 90))".to_string()), + ); + proj.computed.insert( + "graticule_lat".to_string(), + ParameterValue::String("MULTILINESTRING ((-180 0, 180 0), (-180 45, 180 45))".to_string()), + ); + let renderer = MapProjection::new(Some(&proj), None); + let mut theme = json!({"axis": {"gridColor": "#dddddd", "gridWidth": 1}}); + let layers = renderer.background_layers(&[], &mut theme); + + assert_eq!(layers.len(), 2); + for layer in &layers { + assert_eq!(layer["mark"]["type"], "geoshape"); + assert_eq!(layer["mark"]["filled"], false); + assert_eq!(layer["mark"]["stroke"], "#dddddd"); + assert_eq!(layer["mark"]["strokeWidth"], 1); + let geom = &layer["data"]["values"][0]["geometry"]; + assert_eq!(geom["type"], "MultiLineString"); + } + } + + #[test] + fn test_graticule_with_panel_boundary() { + let mut proj = Projection::map(); + proj.computed.insert( + "panel_boundary".to_string(), + ParameterValue::String("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))".to_string()), + ); + proj.computed.insert( + "graticule_lon".to_string(), + ParameterValue::String("MULTILINESTRING ((0.5 0, 0.5 1))".to_string()), + ); + let renderer = MapProjection::new(Some(&proj), None); + let mut theme = json!({"view": {"fill": "white", "stroke": "gray"}}); + let layers = renderer.background_layers(&[], &mut theme); + + // Panel boundary first, then graticule + assert_eq!(layers.len(), 2); + assert_eq!(layers[0]["mark"]["fill"], "white"); + assert_eq!(layers[1]["mark"]["filled"], false); + } } From c013a7480813ca7d42a8fdda4162ed8c3d42a250 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 12 May 2026 16:05:18 +0200 Subject: [PATCH 20/50] Introduce BBox struct in map projection for CRS-aware bounding boxes Replaces raw [f64; 4] arrays with a typed struct carrying xmin/ymin/xmax/ymax plus CRS. Adds methods: from_df, from_array, merge (errors on CRS mismatch), reproject (via ST_MakeEnvelope), clamp, xrange/yrange, as_parameter_value. Simplifies graticule_bbox by using reproject instead of manual corner extraction. Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/coord/map.rs | 383 ++++++++++++++++++------------- 1 file changed, 221 insertions(+), 162 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index e42884322..ab459f1a5 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -12,6 +12,126 @@ use crate::DataFrame; pub const CLIP_BOUNDARY_TABLE: &str = "__ggsql_clip_boundary__"; +#[derive(Debug, Clone, PartialEq)] +struct BBox { + xmin: f64, + ymin: f64, + xmax: f64, + ymax: f64, + crs: String, +} + +impl BBox { + fn from_df(df: &DataFrame, crs: &str) -> Option { + use arrow::array::Array; + let batch = df.inner(); + if batch.num_rows() == 0 || batch.num_columns() < 4 { + return None; + } + let get_f64 = |col: usize| -> Option { + batch + .column(col) + .as_any() + .downcast_ref::() + .filter(|a| !a.is_null(0)) + .map(|a| a.value(0)) + }; + match (get_f64(0), get_f64(1), get_f64(2), get_f64(3)) { + (Some(xmin), Some(ymin), Some(xmax), Some(ymax)) => Some(Self { + xmin, + ymin, + xmax, + ymax, + crs: crs.to_string(), + }), + _ => None, + } + } + + fn merge(existing: Option, new: Option) -> crate::Result> { + match (existing, new) { + (Some(a), Some(b)) => { + if a.crs != b.crs { + return Err(crate::GgsqlError::InternalError(format!( + "Cannot merge bounding boxes with different CRS: '{}' vs '{}'", + a.crs, b.crs + ))); + } + Ok(Some(Self { + xmin: a.xmin.min(b.xmin), + ymin: a.ymin.min(b.ymin), + xmax: a.xmax.max(b.xmax), + ymax: a.ymax.max(b.ymax), + crs: a.crs, + })) + } + (Some(b), None) | (None, Some(b)) => Ok(Some(b)), + (None, None) => Ok(None), + } + } + + fn from_array(arr: [f64; 4], crs: &str) -> Self { + Self { + xmin: arr[0], + ymin: arr[1], + xmax: arr[2], + ymax: arr[3], + crs: crs.to_string(), + } + } + + fn to_array(&self) -> [f64; 4] { + [self.xmin, self.ymin, self.xmax, self.ymax] + } + + fn clamp(mut self, xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> Self { + self.xmin = self.xmin.clamp(xmin, xmax); + self.ymin = self.ymin.clamp(ymin, ymax); + self.xmax = self.xmax.clamp(xmin, xmax); + self.ymax = self.ymax.clamp(ymin, ymax); + self + } + + fn xrange(&self) -> (f64, f64) { + (self.xmin, self.xmax) + } + + fn yrange(&self) -> (f64, f64) { + (self.ymin, self.ymax) + } + + fn as_parameter_value(&self) -> ParameterValue { + use crate::plot::types::ArrayElement; + ParameterValue::Array(vec![ + ArrayElement::Number(self.xmin), + ArrayElement::Number(self.ymin), + ArrayElement::Number(self.xmax), + ArrayElement::Number(self.ymax), + ]) + } + + fn reproject( + &self, + target_crs: &str, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, + ) -> Option { + let envelope = format!( + "ST_MakeEnvelope({}, {}, {}, {})", + self.xmin, self.ymin, self.xmax, self.ymax + ); + let transformed = dialect.sql_st_transform(&envelope, &self.crs, target_crs); + let sql = format!( + "SELECT ST_XMin(g) AS xmin, ST_YMin(g) AS ymin, \ + ST_XMax(g) AS xmax, ST_YMax(g) AS ymax \ + FROM (SELECT {transformed} AS g)" + ); + execute_query(&sql) + .ok() + .and_then(|df| Self::from_df(&df, target_crs)) + } +} + /// Map coordinate system - for geographic/cartographic projections #[derive(Debug, Clone, Copy)] pub struct Map; @@ -115,7 +235,7 @@ impl CoordTrait for Map { let geom_col = naming::aesthetic_column("geometry"); let geom_col_quoted = naming::quote_ident(&geom_col); let bounds_param = projection.properties.get("bounds"); - let mut computed_bbox: Option<[f64; 4]> = None; + let mut computed_bbox: Option = None; for (idx, layer) in layers.iter().enumerate() { if layer.geom.geom_type() != GeomType::Spatial { @@ -133,7 +253,7 @@ impl CoordTrait for Map { if needs_computed_bbox(bounds_param) { let sql = dialect.sql_geometry_bbox(&geom_col_quoted, &table_quoted); if let Ok(df) = execute_query(&sql) { - computed_bbox = merge_bbox(computed_bbox, bbox_from_df(&df)); + computed_bbox = BBox::merge(computed_bbox, BBox::from_df(&df, &crs))?; } } @@ -146,21 +266,13 @@ impl CoordTrait for Map { let Some(bbox) = resolve_frame_bbox(bounds_param, computed_bbox, world_bbox) else { return Ok(()); }; - use crate::plot::types::ArrayElement; - projection.computed.insert( - "frame_bbox".to_string(), - ParameterValue::Array(vec![ - ArrayElement::Number(bbox[0]), - ArrayElement::Number(bbox[1]), - ArrayElement::Number(bbox[2]), - ArrayElement::Number(bbox[3]), - ]), - ); + projection + .computed + .insert("frame_bbox".to_string(), bbox.as_parameter_value()); // Step 6: Generate graticule lines let has_clip = projection.computed.contains_key("clip_boundary"); - let (lon_wkt, lat_wkt) = - build_graticule(bbox, &crs, &source, has_clip, dialect, execute_query)?; + let (lon_wkt, lat_wkt) = build_graticule(&bbox, &source, has_clip, dialect, execute_query)?; if let Some(wkt) = lon_wkt { projection .computed @@ -190,7 +302,7 @@ fn setup_clip_boundary( crs: &str, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, -) -> crate::Result> { +) -> crate::Result> { let Some(wkt) = visible_area_wkt(&projection.properties) else { return Ok(None); }; @@ -226,35 +338,34 @@ fn setup_clip_boundary( let world_bbox_sql = dialect.sql_geometry_bbox(&projected, CLIP_BOUNDARY_TABLE); let world_bbox = execute_query(&world_bbox_sql) .ok() - .and_then(|df| bbox_from_df(&df)); + .and_then(|df| BBox::from_df(&df, crs)); Ok(world_bbox) } /// Build graticule lines: determine the visible lon/lat extent, generate densified /// meridians and parallels, clip and project them, and return projected WKT. fn build_graticule( - frame_bbox: [f64; 4], - crs: &str, + frame_bbox: &BBox, source: &str, has_clip: bool, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<(Option, Option)> { - let Some([lon_min, lat_min, lon_max, lat_max]) = - graticule_bbox(frame_bbox, crs, source, has_clip, dialect, execute_query)? + let crs = &frame_bbox.crs; + let Some(geo_bbox) = graticule_bbox(frame_bbox, source, has_clip, dialect, execute_query)? else { return Ok((None, None)); }; - let lon_breaks = graticule_breaks(lon_min, lon_max); - let lat_breaks = graticule_breaks(lat_min, lat_max); + let lon_breaks = graticule_breaks(geo_bbox.xrange()); + let lat_breaks = graticule_breaks(geo_bbox.yrange()); if lon_breaks.is_empty() && lat_breaks.is_empty() { return Ok((None, None)); } // Densification interval based on angular extent - let max_range = (lon_max - lon_min).max(lat_max - lat_min); + let max_range = (geo_bbox.xmax - geo_bbox.xmin).max(geo_bbox.ymax - geo_bbox.ymin); let step_deg = if max_range > 90.0 { 2.0 } else if max_range > 30.0 { @@ -285,8 +396,7 @@ fn build_graticule( let lon_wkt = if !lon_breaks.is_empty() { Some(meridians_multilinestring( &lon_breaks, - lat_min, - lat_max, + geo_bbox.yrange(), step_deg, )) } else { @@ -295,8 +405,7 @@ fn build_graticule( let lat_wkt = if !lat_breaks.is_empty() { Some(parallels_multilinestring( &lat_breaks, - lon_min, - lon_max, + geo_bbox.xrange(), step_deg, )) } else { @@ -304,8 +413,8 @@ fn build_graticule( }; Ok(( - project_wkt(lon_wkt, has_clip, source, crs, dialect, execute_query)?, - project_wkt(lat_wkt, has_clip, source, crs, dialect, execute_query)?, + project_wkt(lon_wkt, has_clip, source, &crs, dialect, execute_query)?, + project_wkt(lat_wkt, has_clip, source, &crs, dialect, execute_query)?, )) } @@ -313,93 +422,44 @@ fn build_graticule( /// the bbox corners. Falls back to the clip boundary for azimuthal projections /// where corners collapse to degenerate values. fn graticule_bbox( - frame_bbox: [f64; 4], - crs: &str, + frame_bbox: &BBox, source: &str, has_clip: bool, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, -) -> crate::Result> { - use arrow::array::Array; - - let [xmin, ymin, xmax, ymax] = frame_bbox; - let corners_sql = format!( - "SELECT ST_X(pt) AS lon, ST_Y(pt) AS lat FROM (\ - VALUES ({xmin}, {ymin}), ({xmax}, {ymin}), ({xmin}, {ymax}), ({xmax}, {ymax})\ - ) AS t(px, py), \ - LATERAL (SELECT {} AS pt) sub", - dialect.sql_st_transform("ST_Point(px, py)", crs, source) - ); - let df = match execute_query(&corners_sql) { - Ok(df) => df, - Err(_) => return Ok(None), - }; - - let batch = df.inner(); - if batch.num_rows() == 0 { - return Ok(None); - } - let lon_arr = batch - .column(0) - .as_any() - .downcast_ref::(); - let lat_arr = batch - .column(1) - .as_any() - .downcast_ref::(); - let (Some(lons), Some(lats)) = (lon_arr, lat_arr) else { - return Ok(None); +) -> crate::Result> { + let mut geo_bbox = match frame_bbox.reproject(source, dialect, execute_query) { + Some(b) => b.clamp(-180.0, -90.0, 180.0, 90.0), + None => return Ok(None), }; - let mut lon_min = f64::INFINITY; - let mut lon_max = f64::NEG_INFINITY; - let mut lat_min = f64::INFINITY; - let mut lat_max = f64::NEG_INFINITY; - for i in 0..batch.num_rows() { - if lons.is_null(i) || lats.is_null(i) { - continue; - } - let lon = lons.value(i).clamp(-180.0, 180.0); - let lat = lats.value(i).clamp(-90.0, 90.0); - lon_min = lon_min.min(lon); - lon_max = lon_max.max(lon); - lat_min = lat_min.min(lat); - lat_max = lat_max.max(lat); - } - if !lon_min.is_finite() || !lat_min.is_finite() { - return Ok(None); - } - // For azimuthal projections the bbox corners often inverse-project to // degenerate values (all at the horizon). Fall back to the clip boundary // extent which represents the actual visible hemisphere. - if has_clip && (lon_max - lon_min).abs() < 1.0 { + if has_clip && (geo_bbox.xmax - geo_bbox.xmin).abs() < 1.0 { let bbox_sql = format!( "SELECT ST_XMin(ext) AS xmin, ST_YMin(ext) AS ymin, \ ST_XMax(ext) AS xmax, ST_YMax(ext) AS ymax \ FROM (SELECT ST_Extent(geom) AS ext FROM {CLIP_BOUNDARY_TABLE})" ); if let Ok(df) = execute_query(&bbox_sql) { - if let Some([x0, y0, x1, y1]) = bbox_from_df(&df) { - lon_min = x0; - lat_min = y0; - lon_max = x1; - lat_max = y1; + if let Some(clip_bbox) = BBox::from_df(&df, source) { + geo_bbox = clip_bbox; } } } // For projections showing the full globe, expand to full range - if lon_max - lon_min > 300.0 { - lon_min = -180.0; - lon_max = 180.0; + if geo_bbox.xmax - geo_bbox.xmin > 300.0 { + geo_bbox.xmin = -180.0; + geo_bbox.xmax = 180.0; } - if lat_max - lat_min > 150.0 { - lat_min = -90.0; - lat_max = 90.0; + if geo_bbox.ymax - geo_bbox.ymin > 150.0 { + geo_bbox.ymin = -90.0; + geo_bbox.ymax = 90.0; } - Ok(Some([lon_min, lat_min, lon_max, lat_max])) + Ok(Some(geo_bbox)) } /// Clip (if needed) and project a WKT geometry string, returning the projected WKT. @@ -444,7 +504,7 @@ fn project_wkt( /// Pick pretty graticule break positions for a lon or lat range. /// Uses standard angular intervals (multiples of 1, 2, 5, 10, 15, 30, 45, 90). -fn graticule_breaks(min: f64, max: f64) -> Vec { +fn graticule_breaks((min, max): (f64, f64)) -> Vec { let range = max - min; if range <= 0.0 { return vec![]; @@ -486,8 +546,7 @@ fn graticule_breaks(min: f64, max: f64) -> Vec { /// densified with intermediate vertices at `step_deg` intervals. fn meridians_multilinestring( lon_breaks: &[f64], - lat_min: f64, - lat_max: f64, + (lat_min, lat_max): (f64, f64), step_deg: f64, ) -> String { let mut lines: Vec = Vec::with_capacity(lon_breaks.len()); @@ -508,8 +567,7 @@ fn meridians_multilinestring( /// densified with intermediate vertices at `step_deg` intervals. fn parallels_multilinestring( lat_breaks: &[f64], - lon_min: f64, - lon_max: f64, + (lon_min, lon_max): (f64, f64), step_deg: f64, ) -> String { let mut lines: Vec = Vec::with_capacity(lat_breaks.len()); @@ -538,50 +596,23 @@ fn needs_computed_bbox(bounds_param: Option<&ParameterValue>) -> bool { } } -/// Extract a [xmin, ymin, xmax, ymax] bbox from a DataFrame returned by `sql_geometry_bbox`. -fn bbox_from_df(df: &DataFrame) -> Option<[f64; 4]> { - use arrow::array::Array; - let batch = df.inner(); - if batch.num_rows() == 0 || batch.num_columns() < 4 { - return None; - } - let get_f64 = |col: usize| -> Option { - batch - .column(col) - .as_any() - .downcast_ref::() - .filter(|a| !a.is_null(0)) - .map(|a| a.value(0)) - }; - match (get_f64(0), get_f64(1), get_f64(2), get_f64(3)) { - (Some(x0), Some(y0), Some(x1), Some(y1)) => Some([x0, y0, x1, y1]), - _ => None, - } -} - -/// Expand an existing bbox to include another, or return the new one. -fn merge_bbox(existing: Option<[f64; 4]>, new: Option<[f64; 4]>) -> Option<[f64; 4]> { - match (existing, new) { - (Some([x0, y0, x1, y1]), Some([nx0, ny0, nx1, ny1])) => { - Some([x0.min(nx0), y0.min(ny0), x1.max(nx1), y1.max(ny1)]) - } - (Some(b), None) | (None, Some(b)) => Some(b), - (None, None) => None, - } -} - /// Resolve the frame bbox: merge explicit bounds with computed values. /// - Null elements fall back to the corresponding data-computed bbox. /// - Inf/-Inf elements fall back to the clip boundary (world) bbox. fn resolve_frame_bbox( bounds_param: Option<&ParameterValue>, - computed: Option<[f64; 4]>, - world: Option<[f64; 4]>, -) -> Option<[f64; 4]> { + computed: Option, + world: Option, +) -> Option { if let Some(ParameterValue::Array(arr)) = bounds_param { use crate::plot::types::ArrayElement; - let data_fallback = computed.unwrap_or([f64::NAN; 4]); - let world_fallback = world.unwrap_or([f64::NAN; 4]); + let data_fallback = computed.as_ref().map_or([f64::NAN; 4], |b| b.to_array()); + let world_fallback = world.as_ref().map_or([f64::NAN; 4], |b| b.to_array()); + let crs = computed + .as_ref() + .or(world.as_ref()) + .map(|b| b.crs.clone()) + .unwrap_or_default(); let resolved: Vec = arr .iter() .enumerate() @@ -591,9 +622,11 @@ fn resolve_frame_bbox( _ => data_fallback[i], }) .collect(); - // [xmin, ymin, xmax, ymax] in projected CRS units if resolved.len() == 4 && resolved.iter().all(|v| v.is_finite()) { - return Some([resolved[0], resolved[1], resolved[2], resolved[3]]); + return Some(BBox::from_array( + [resolved[0], resolved[1], resolved[2], resolved[3]], + &crs, + )); } } computed @@ -1023,10 +1056,14 @@ mod tests { ); } + fn bbox(xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> BBox { + BBox::from_array([xmin, ymin, xmax, ymax], "EPSG:4326") + } + #[test] fn test_resolve_frame_bbox_no_bounds_uses_computed() { - let computed = Some([0.0, 0.0, 100.0, 200.0]); - assert_eq!(resolve_frame_bbox(None, computed, None), computed); + let computed = Some(bbox(0.0, 0.0, 100.0, 200.0)); + assert_eq!(resolve_frame_bbox(None, computed.clone(), None), computed); } #[test] @@ -1043,10 +1080,10 @@ mod tests { ArrayElement::Number(30.0), ArrayElement::Number(40.0), ]); - let computed = Some([0.0, 0.0, 100.0, 200.0]); + let computed = Some(bbox(0.0, 0.0, 100.0, 200.0)); assert_eq!( resolve_frame_bbox(Some(&bounds), computed, None), - Some([10.0, 20.0, 30.0, 40.0]) + Some(bbox(10.0, 20.0, 30.0, 40.0)) ); } @@ -1059,10 +1096,10 @@ mod tests { ArrayElement::Null, ArrayElement::Number(40.0), ]); - let computed = Some([5.0, 0.0, 95.0, 0.0]); + let computed = Some(bbox(5.0, 0.0, 95.0, 0.0)); assert_eq!( resolve_frame_bbox(Some(&bounds), computed, None), - Some([5.0, 20.0, 95.0, 40.0]) + Some(bbox(5.0, 20.0, 95.0, 40.0)) ); } @@ -1075,11 +1112,11 @@ mod tests { ArrayElement::Number(f64::INFINITY), ArrayElement::Number(40.0), ]); - let computed = Some([5.0, 0.0, 95.0, 0.0]); - let world = Some([-500.0, -500.0, 500.0, 500.0]); + let computed = Some(bbox(5.0, 0.0, 95.0, 0.0)); + let world = Some(bbox(-500.0, -500.0, 500.0, 500.0)); assert_eq!( resolve_frame_bbox(Some(&bounds), computed, world), - Some([-500.0, 20.0, 500.0, 40.0]) + Some(bbox(-500.0, 20.0, 500.0, 40.0)) ); } @@ -1092,7 +1129,6 @@ mod tests { ArrayElement::Number(30.0), ArrayElement::Number(40.0), ]); - // null can't resolve without computed, so result is NaN → falls through to computed assert_eq!(resolve_frame_bbox(Some(&bounds), None, None), None); } @@ -1105,24 +1141,47 @@ mod tests { ArrayElement::Number(30.0), ArrayElement::Number(40.0), ]); - let computed = Some([5.0, 0.0, 95.0, 200.0]); - // Inf can't resolve without world, falls through to computed - assert_eq!(resolve_frame_bbox(Some(&bounds), computed, None), computed); + let computed = Some(bbox(5.0, 0.0, 95.0, 200.0)); + assert_eq!( + resolve_frame_bbox(Some(&bounds), computed.clone(), None), + computed + ); } #[test] fn test_merge_bbox() { - let a = Some([0.0, 10.0, 50.0, 60.0]); - let b = Some([-5.0, 15.0, 45.0, 70.0]); - assert_eq!(merge_bbox(a, b), Some([-5.0, 10.0, 50.0, 70.0])); - assert_eq!(merge_bbox(a, None), a); - assert_eq!(merge_bbox(None, a), a); - assert_eq!(merge_bbox(None, None), None); + let a = Some(bbox(0.0, 10.0, 50.0, 60.0)); + let b = Some(bbox(-5.0, 15.0, 45.0, 70.0)); + assert_eq!( + BBox::merge(a.clone(), b).unwrap(), + Some(bbox(-5.0, 10.0, 50.0, 70.0)) + ); + assert_eq!(BBox::merge(a.clone(), None).unwrap(), a); + assert_eq!(BBox::merge(None, a.clone()).unwrap(), a); + assert_eq!(BBox::merge(None, None).unwrap(), None); + } + + #[test] + fn test_merge_bbox_crs_mismatch() { + let a = Some(BBox::from_array([0.0, 0.0, 1.0, 1.0], "EPSG:4326")); + let b = Some(BBox::from_array([0.0, 0.0, 1.0, 1.0], "EPSG:3857")); + assert!(BBox::merge(a, b).is_err()); + } + + #[test] + fn test_clamp() { + // restricts values that exceed bounds + let b = BBox::from_array([-200.0, -100.0, 200.0, 100.0], "EPSG:4326"); + assert_eq!(b.clamp(-180.0, -90.0, 180.0, 90.0), bbox(-180.0, -90.0, 180.0, 90.0)); + + // no-op when already within bounds + let b = bbox(10.0, 20.0, 30.0, 40.0); + assert_eq!(b.clamp(-180.0, -90.0, 180.0, 90.0), bbox(10.0, 20.0, 30.0, 40.0)); } #[test] fn test_graticule_breaks_world() { - let breaks = graticule_breaks(-180.0, 180.0); + let breaks = graticule_breaks((-180.0, 180.0)); assert_eq!( breaks, vec![-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0] @@ -1131,13 +1190,13 @@ mod tests { #[test] fn test_graticule_breaks_hemisphere() { - let breaks = graticule_breaks(-88.0, 88.0); + let breaks = graticule_breaks((-88.0, 88.0)); 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); + let breaks = graticule_breaks((10.0, 20.0)); assert!(!breaks.is_empty()); for &b in &breaks { assert!(b > 10.0 && b < 20.0); @@ -1146,13 +1205,13 @@ mod tests { #[test] fn test_graticule_breaks_empty_for_zero_range() { - let breaks = graticule_breaks(50.0, 50.0); + let breaks = graticule_breaks((50.0, 50.0)); assert!(breaks.is_empty()); } #[test] fn test_meridians_multilinestring() { - let wkt = meridians_multilinestring(&[0.0, 30.0], -90.0, 90.0, 45.0); + let wkt = meridians_multilinestring(&[0.0, 30.0], (-90.0, 90.0), 45.0); assert!(wkt.starts_with("MULTILINESTRING("), "{wkt}"); // Two meridians: each starts with "(" after the outer wrapper assert!(wkt.contains("0.000000 -90.000000"), "{wkt}"); @@ -1163,7 +1222,7 @@ mod tests { #[test] fn test_parallels_multilinestring() { - let wkt = parallels_multilinestring(&[0.0, 45.0], -180.0, 180.0, 90.0); + let wkt = parallels_multilinestring(&[0.0, 45.0], (-180.0, 180.0), 90.0); assert!(wkt.starts_with("MULTILINESTRING(")); assert!(wkt.contains("0.000000")); assert!(wkt.contains("45.000000")); From c2ad9ed72dd22dfa218d4341bd8fcfa1792659d6 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 12 May 2026 16:30:18 +0200 Subject: [PATCH 21/50] Deduplicate map projection helpers: grid_lines_wkt, query_scalar_string Unify meridians/parallels multilinestring into a single grid_lines_wkt function. Extract query_scalar_string to eliminate repeated Arrow StringArray downcasting. Use dialect.sql_geometry_bbox for clip boundary extent. Fix clippy needless_borrow and manual_contains warnings. Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/coord/map.rs | 157 ++++++++++++++----------------- 1 file changed, 70 insertions(+), 87 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index ab459f1a5..a7cb673ff 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -132,6 +132,27 @@ impl BBox { } } +/// Execute a query and extract a single string value from the first row, first column. +fn query_scalar_string( + sql: &str, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> Option { + use arrow::array::Array; + let df = execute_query(sql).ok()?; + let batch = df.inner(); + if batch.num_rows() == 0 { + return None; + } + let arr = batch + .column(0) + .as_any() + .downcast_ref::()?; + if arr.is_null(0) { + return None; + } + Some(arr.value(0).to_string()) +} + /// Map coordinate system - for geographic/cartographic projections #[derive(Debug, Clone, Copy)] pub struct Map; @@ -318,21 +339,11 @@ fn setup_clip_boundary( let projected = dialect.sql_st_transform("geom", source, crs); let sql = format!("SELECT ST_AsText({projected}) AS wkt FROM {CLIP_BOUNDARY_TABLE}"); - if let Ok(df) = execute_query(&sql) { - let batch = df.inner(); - if batch.num_rows() > 0 { - if let Some(arr) = batch - .column(0) - .as_any() - .downcast_ref::() - { - let projected_wkt = arr.value(0); - projection.computed.insert( - "panel_boundary".to_string(), - ParameterValue::String(projected_wkt.to_string()), - ); - } - } + if let Some(projected_wkt) = query_scalar_string(&sql, execute_query) { + projection.computed.insert( + "panel_boundary".to_string(), + ParameterValue::String(projected_wkt), + ); } let world_bbox_sql = dialect.sql_geometry_bbox(&projected, CLIP_BOUNDARY_TABLE); @@ -394,27 +405,29 @@ fn build_graticule( }; let lon_wkt = if !lon_breaks.is_empty() { - Some(meridians_multilinestring( + Some(grid_lines_wkt( &lon_breaks, geo_bbox.yrange(), step_deg, + true, )) } else { None }; let lat_wkt = if !lat_breaks.is_empty() { - Some(parallels_multilinestring( + Some(grid_lines_wkt( &lat_breaks, geo_bbox.xrange(), step_deg, + false, )) } else { None }; Ok(( - project_wkt(lon_wkt, has_clip, source, &crs, dialect, execute_query)?, - project_wkt(lat_wkt, has_clip, source, &crs, dialect, execute_query)?, + project_wkt(lon_wkt, has_clip, source, crs, dialect, execute_query)?, + project_wkt(lat_wkt, has_clip, source, crs, dialect, execute_query)?, )) } @@ -437,11 +450,7 @@ fn graticule_bbox( // degenerate values (all at the horizon). Fall back to the clip boundary // extent which represents the actual visible hemisphere. if has_clip && (geo_bbox.xmax - geo_bbox.xmin).abs() < 1.0 { - let bbox_sql = format!( - "SELECT ST_XMin(ext) AS xmin, ST_YMin(ext) AS ymin, \ - ST_XMax(ext) AS xmax, ST_YMax(ext) AS ymax \ - FROM (SELECT ST_Extent(geom) AS ext FROM {CLIP_BOUNDARY_TABLE})" - ); + let bbox_sql = dialect.sql_geometry_bbox("geom", CLIP_BOUNDARY_TABLE); if let Ok(df) = execute_query(&bbox_sql) { if let Some(clip_bbox) = BBox::from_df(&df, source) { geo_bbox = clip_bbox; @@ -471,8 +480,6 @@ fn project_wkt( dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result> { - use arrow::array::Array; - let Some(wkt) = wkt else { return Ok(None) }; let geom_expr = format!("ST_GeomFromText('{wkt}')"); let clipped = if has_clip { @@ -482,24 +489,7 @@ fn project_wkt( }; let projected = dialect.sql_st_transform(&clipped, source, crs); let sql = format!("SELECT ST_AsText({projected}) AS wkt"); - match execute_query(&sql) { - Ok(df) => { - let batch = df.inner(); - if batch.num_rows() > 0 { - if let Some(arr) = batch - .column(0) - .as_any() - .downcast_ref::() - { - if !arr.is_null(0) { - return Ok(Some(arr.value(0).to_string())); - } - } - } - Ok(None) - } - Err(_) => Ok(None), - } + Ok(query_scalar_string(&sql, execute_query)) } /// Pick pretty graticule break positions for a lon or lat range. @@ -528,57 +518,45 @@ fn graticule_breaks((min, max): (f64, f64)) -> Vec { // Include the boundary value when range covers the full extent, // so the antimeridian/pole gets a line - if min <= -180.0 && !breaks.iter().any(|&v| v == -180.0) { + if min <= -180.0 && !breaks.contains(&-180.0) { breaks.insert(0, -180.0); - } else if max >= 180.0 && !breaks.iter().any(|&v| v == 180.0) { + } else if max >= 180.0 && !breaks.contains(&180.0) { breaks.push(180.0); } - if min <= -90.0 && !breaks.iter().any(|&v| v == -90.0) { + if min <= -90.0 && !breaks.contains(&-90.0) { breaks.insert(0, -90.0); - } else if max >= 90.0 && !breaks.iter().any(|&v| v == 90.0) { + } else if max >= 90.0 && !breaks.contains(&90.0) { breaks.push(90.0); } breaks } -/// Generate a MULTILINESTRING WKT with meridians (vertical lines at fixed longitude), -/// densified with intermediate vertices at `step_deg` intervals. -fn meridians_multilinestring( - lon_breaks: &[f64], - (lat_min, lat_max): (f64, f64), - step_deg: f64, -) -> String { - let mut lines: Vec = Vec::with_capacity(lon_breaks.len()); - for &lon in lon_breaks { - let mut coords = Vec::new(); - let mut lat = lat_min; - while lat < lat_max { - coords.push(format!("{lon:.6} {lat:.6}")); - lat += step_deg; - } - coords.push(format!("{lon:.6} {lat_max:.6}")); - lines.push(format!("({})", coords.join(", "))); - } - format!("MULTILINESTRING({})", lines.join(", ")) -} - -/// Generate a MULTILINESTRING WKT with parallels (horizontal lines at fixed latitude), -/// densified with intermediate vertices at `step_deg` intervals. -fn parallels_multilinestring( - lat_breaks: &[f64], - (lon_min, lon_max): (f64, f64), +/// Generate a MULTILINESTRING WKT with one line per break value, densified along +/// the varying axis at `step_deg` intervals. +/// - `lon_first = true`: fixed longitude (meridians), varying latitude. +/// - `lon_first = false`: fixed latitude (parallels), varying longitude. +fn grid_lines_wkt( + breaks: &[f64], + (vary_min, vary_max): (f64, f64), step_deg: f64, + lon_first: bool, ) -> String { - let mut lines: Vec = Vec::with_capacity(lat_breaks.len()); - for &lat in lat_breaks { + let mut lines: Vec = Vec::with_capacity(breaks.len()); + for &fixed in breaks { let mut coords = Vec::new(); - let mut lon = lon_min; - while lon < lon_max { + let mut v = vary_min; + while v < vary_max { + let (lon, lat) = if lon_first { (fixed, v) } else { (v, fixed) }; coords.push(format!("{lon:.6} {lat:.6}")); - lon += step_deg; + v += step_deg; } - coords.push(format!("{lon_max:.6} {lat:.6}")); + let (lon, lat) = if lon_first { + (fixed, vary_max) + } else { + (vary_max, fixed) + }; + coords.push(format!("{lon:.6} {lat:.6}")); lines.push(format!("({})", coords.join(", "))); } format!("MULTILINESTRING({})", lines.join(", ")) @@ -1172,11 +1150,17 @@ mod tests { fn test_clamp() { // restricts values that exceed bounds let b = BBox::from_array([-200.0, -100.0, 200.0, 100.0], "EPSG:4326"); - assert_eq!(b.clamp(-180.0, -90.0, 180.0, 90.0), bbox(-180.0, -90.0, 180.0, 90.0)); + assert_eq!( + b.clamp(-180.0, -90.0, 180.0, 90.0), + bbox(-180.0, -90.0, 180.0, 90.0) + ); // no-op when already within bounds let b = bbox(10.0, 20.0, 30.0, 40.0); - assert_eq!(b.clamp(-180.0, -90.0, 180.0, 90.0), bbox(10.0, 20.0, 30.0, 40.0)); + assert_eq!( + b.clamp(-180.0, -90.0, 180.0, 90.0), + bbox(10.0, 20.0, 30.0, 40.0) + ); } #[test] @@ -1210,10 +1194,9 @@ mod tests { } #[test] - fn test_meridians_multilinestring() { - let wkt = meridians_multilinestring(&[0.0, 30.0], (-90.0, 90.0), 45.0); + fn test_grid_lines_wkt_meridians() { + let wkt = grid_lines_wkt(&[0.0, 30.0], (-90.0, 90.0), 45.0, true); assert!(wkt.starts_with("MULTILINESTRING("), "{wkt}"); - // Two meridians: each starts with "(" after the outer wrapper assert!(wkt.contains("0.000000 -90.000000"), "{wkt}"); assert!(wkt.contains("30.000000 -90.000000"), "{wkt}"); assert!(wkt.contains("0.000000 90.000000"), "{wkt}"); @@ -1221,8 +1204,8 @@ mod tests { } #[test] - fn test_parallels_multilinestring() { - let wkt = parallels_multilinestring(&[0.0, 45.0], (-180.0, 180.0), 90.0); + fn test_grid_lines_wkt_parallels() { + let wkt = grid_lines_wkt(&[0.0, 45.0], (-180.0, 180.0), 90.0, false); assert!(wkt.starts_with("MULTILINESTRING(")); assert!(wkt.contains("0.000000")); assert!(wkt.contains("45.000000")); From 777c6e06b8b03eef10ec5ebb578178a8d9a9bda4 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 12 May 2026 16:40:13 +0200 Subject: [PATCH 22/50] Reorganize map.rs into logical sections Reorder functions without changing behavior: 1. Map struct + CoordTrait 2. BBox struct 3. Graticule helpers 4. Generic helpers 5. Visible area/horizon/hemisphere Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/coord/map.rs | 476 ++++++++++++++++--------------- 1 file changed, 250 insertions(+), 226 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index a7cb673ff..44102af83 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -12,146 +12,9 @@ use crate::DataFrame; pub const CLIP_BOUNDARY_TABLE: &str = "__ggsql_clip_boundary__"; -#[derive(Debug, Clone, PartialEq)] -struct BBox { - xmin: f64, - ymin: f64, - xmax: f64, - ymax: f64, - crs: String, -} - -impl BBox { - fn from_df(df: &DataFrame, crs: &str) -> Option { - use arrow::array::Array; - let batch = df.inner(); - if batch.num_rows() == 0 || batch.num_columns() < 4 { - return None; - } - let get_f64 = |col: usize| -> Option { - batch - .column(col) - .as_any() - .downcast_ref::() - .filter(|a| !a.is_null(0)) - .map(|a| a.value(0)) - }; - match (get_f64(0), get_f64(1), get_f64(2), get_f64(3)) { - (Some(xmin), Some(ymin), Some(xmax), Some(ymax)) => Some(Self { - xmin, - ymin, - xmax, - ymax, - crs: crs.to_string(), - }), - _ => None, - } - } - - fn merge(existing: Option, new: Option) -> crate::Result> { - match (existing, new) { - (Some(a), Some(b)) => { - if a.crs != b.crs { - return Err(crate::GgsqlError::InternalError(format!( - "Cannot merge bounding boxes with different CRS: '{}' vs '{}'", - a.crs, b.crs - ))); - } - Ok(Some(Self { - xmin: a.xmin.min(b.xmin), - ymin: a.ymin.min(b.ymin), - xmax: a.xmax.max(b.xmax), - ymax: a.ymax.max(b.ymax), - crs: a.crs, - })) - } - (Some(b), None) | (None, Some(b)) => Ok(Some(b)), - (None, None) => Ok(None), - } - } - - fn from_array(arr: [f64; 4], crs: &str) -> Self { - Self { - xmin: arr[0], - ymin: arr[1], - xmax: arr[2], - ymax: arr[3], - crs: crs.to_string(), - } - } - - fn to_array(&self) -> [f64; 4] { - [self.xmin, self.ymin, self.xmax, self.ymax] - } - - fn clamp(mut self, xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> Self { - self.xmin = self.xmin.clamp(xmin, xmax); - self.ymin = self.ymin.clamp(ymin, ymax); - self.xmax = self.xmax.clamp(xmin, xmax); - self.ymax = self.ymax.clamp(ymin, ymax); - self - } - - fn xrange(&self) -> (f64, f64) { - (self.xmin, self.xmax) - } - - fn yrange(&self) -> (f64, f64) { - (self.ymin, self.ymax) - } - - fn as_parameter_value(&self) -> ParameterValue { - use crate::plot::types::ArrayElement; - ParameterValue::Array(vec![ - ArrayElement::Number(self.xmin), - ArrayElement::Number(self.ymin), - ArrayElement::Number(self.xmax), - ArrayElement::Number(self.ymax), - ]) - } - - fn reproject( - &self, - target_crs: &str, - dialect: &dyn SqlDialect, - execute_query: &dyn Fn(&str) -> crate::Result, - ) -> Option { - let envelope = format!( - "ST_MakeEnvelope({}, {}, {}, {})", - self.xmin, self.ymin, self.xmax, self.ymax - ); - let transformed = dialect.sql_st_transform(&envelope, &self.crs, target_crs); - let sql = format!( - "SELECT ST_XMin(g) AS xmin, ST_YMin(g) AS ymin, \ - ST_XMax(g) AS xmax, ST_YMax(g) AS ymax \ - FROM (SELECT {transformed} AS g)" - ); - execute_query(&sql) - .ok() - .and_then(|df| Self::from_df(&df, target_crs)) - } -} - -/// Execute a query and extract a single string value from the first row, first column. -fn query_scalar_string( - sql: &str, - execute_query: &dyn Fn(&str) -> crate::Result, -) -> Option { - use arrow::array::Array; - let df = execute_query(sql).ok()?; - let batch = df.inner(); - if batch.num_rows() == 0 { - return None; - } - let arr = batch - .column(0) - .as_any() - .downcast_ref::()?; - if arr.is_null(0) { - return None; - } - Some(arr.value(0).to_string()) -} +// --------------------------------------------------------------------------- +// Map coord +// --------------------------------------------------------------------------- /// Map coordinate system - for geographic/cartographic projections #[derive(Debug, Clone, Copy)] @@ -315,44 +178,134 @@ impl std::fmt::Display for Map { } } -/// Set up the clip boundary for azimuthal projections. Creates the clip boundary temp table, -/// projects it into the target CRS, and returns the world bbox (projected clip boundary extent). -fn setup_clip_boundary( - projection: &mut super::super::Projection, - source: &str, - crs: &str, - dialect: &dyn SqlDialect, - execute_query: &dyn Fn(&str) -> crate::Result, -) -> crate::Result> { - let Some(wkt) = visible_area_wkt(&projection.properties) else { - return Ok(None); - }; +// --------------------------------------------------------------------------- +// BBox +// --------------------------------------------------------------------------- - projection.computed.insert( - "clip_boundary".to_string(), - ParameterValue::String(wkt.clone()), - ); - let body = format!("SELECT ST_GeomFromText('{wkt}') AS geom"); - for stmt in dialect.create_or_replace_temp_table_sql(CLIP_BOUNDARY_TABLE, &[], &body) { - execute_query(&stmt)?; +#[derive(Debug, Clone, PartialEq)] +struct BBox { + xmin: f64, + ymin: f64, + xmax: f64, + ymax: f64, + crs: String, +} + +impl BBox { + fn from_df(df: &DataFrame, crs: &str) -> Option { + use arrow::array::Array; + let batch = df.inner(); + if batch.num_rows() == 0 || batch.num_columns() < 4 { + return None; + } + let get_f64 = |col: usize| -> Option { + batch + .column(col) + .as_any() + .downcast_ref::() + .filter(|a| !a.is_null(0)) + .map(|a| a.value(0)) + }; + match (get_f64(0), get_f64(1), get_f64(2), get_f64(3)) { + (Some(xmin), Some(ymin), Some(xmax), Some(ymax)) => Some(Self { + xmin, + ymin, + xmax, + ymax, + crs: crs.to_string(), + }), + _ => None, + } } - let projected = dialect.sql_st_transform("geom", source, crs); - let sql = format!("SELECT ST_AsText({projected}) AS wkt FROM {CLIP_BOUNDARY_TABLE}"); - if let Some(projected_wkt) = query_scalar_string(&sql, execute_query) { - projection.computed.insert( - "panel_boundary".to_string(), - ParameterValue::String(projected_wkt), - ); + fn merge(existing: Option, new: Option) -> crate::Result> { + match (existing, new) { + (Some(a), Some(b)) => { + if a.crs != b.crs { + return Err(crate::GgsqlError::InternalError(format!( + "Cannot merge bounding boxes with different CRS: '{}' vs '{}'", + a.crs, b.crs + ))); + } + Ok(Some(Self { + xmin: a.xmin.min(b.xmin), + ymin: a.ymin.min(b.ymin), + xmax: a.xmax.max(b.xmax), + ymax: a.ymax.max(b.ymax), + crs: a.crs, + })) + } + (Some(b), None) | (None, Some(b)) => Ok(Some(b)), + (None, None) => Ok(None), + } } - let world_bbox_sql = dialect.sql_geometry_bbox(&projected, CLIP_BOUNDARY_TABLE); - let world_bbox = execute_query(&world_bbox_sql) - .ok() - .and_then(|df| BBox::from_df(&df, crs)); - Ok(world_bbox) + fn from_array(arr: [f64; 4], crs: &str) -> Self { + Self { + xmin: arr[0], + ymin: arr[1], + xmax: arr[2], + ymax: arr[3], + crs: crs.to_string(), + } + } + + fn to_array(&self) -> [f64; 4] { + [self.xmin, self.ymin, self.xmax, self.ymax] + } + + fn clamp(mut self, xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> Self { + self.xmin = self.xmin.clamp(xmin, xmax); + self.ymin = self.ymin.clamp(ymin, ymax); + self.xmax = self.xmax.clamp(xmin, xmax); + self.ymax = self.ymax.clamp(ymin, ymax); + self + } + + fn xrange(&self) -> (f64, f64) { + (self.xmin, self.xmax) + } + + fn yrange(&self) -> (f64, f64) { + (self.ymin, self.ymax) + } + + fn as_parameter_value(&self) -> ParameterValue { + use crate::plot::types::ArrayElement; + ParameterValue::Array(vec![ + ArrayElement::Number(self.xmin), + ArrayElement::Number(self.ymin), + ArrayElement::Number(self.xmax), + ArrayElement::Number(self.ymax), + ]) + } + + fn reproject( + &self, + target_crs: &str, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, + ) -> Option { + let envelope = format!( + "ST_MakeEnvelope({}, {}, {}, {})", + self.xmin, self.ymin, self.xmax, self.ymax + ); + let transformed = dialect.sql_st_transform(&envelope, &self.crs, target_crs); + let sql = format!( + "SELECT ST_XMin(g) AS xmin, ST_YMin(g) AS ymin, \ + ST_XMax(g) AS xmax, ST_YMax(g) AS ymax \ + FROM (SELECT {transformed} AS g)" + ); + execute_query(&sql) + .ok() + .and_then(|df| Self::from_df(&df, target_crs)) + } } +// --------------------------------------------------------------------------- +// Graticule helpers +// --------------------------------------------------------------------------- + /// Build graticule lines: determine the visible lon/lat extent, generate densified /// meridians and parallels, clip and project them, and return projected WKT. fn build_graticule( @@ -471,27 +424,6 @@ fn graticule_bbox( Ok(Some(geo_bbox)) } -/// Clip (if needed) and project a WKT geometry string, returning the projected WKT. -fn project_wkt( - wkt: Option, - has_clip: bool, - source: &str, - crs: &str, - dialect: &dyn SqlDialect, - execute_query: &dyn Fn(&str) -> crate::Result, -) -> crate::Result> { - let Some(wkt) = wkt else { return Ok(None) }; - let geom_expr = format!("ST_GeomFromText('{wkt}')"); - let clipped = if has_clip { - format!("ST_Intersection({geom_expr}, (SELECT geom FROM {CLIP_BOUNDARY_TABLE}))") - } else { - geom_expr - }; - let projected = dialect.sql_st_transform(&clipped, source, crs); - let sql = format!("SELECT ST_AsText({projected}) AS wkt"); - Ok(query_scalar_string(&sql, execute_query)) -} - /// Pick pretty graticule break positions for a lon or lat range. /// Uses standard angular intervals (multiples of 1, 2, 5, 10, 15, 30, 45, 90). fn graticule_breaks((min, max): (f64, f64)) -> Vec { @@ -562,6 +494,90 @@ fn grid_lines_wkt( format!("MULTILINESTRING({})", lines.join(", ")) } +// --------------------------------------------------------------------------- +// Generic helpers +// --------------------------------------------------------------------------- + +/// Execute a query and extract a single string value from the first row, first column. +fn query_scalar_string( + sql: &str, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> Option { + use arrow::array::Array; + let df = execute_query(sql).ok()?; + let batch = df.inner(); + if batch.num_rows() == 0 { + return None; + } + let arr = batch + .column(0) + .as_any() + .downcast_ref::()?; + if arr.is_null(0) { + return None; + } + Some(arr.value(0).to_string()) +} + +/// Set up the clip boundary for azimuthal projections. Creates the clip boundary temp table, +/// projects it into the target CRS, and returns the world bbox (projected clip boundary extent). +fn setup_clip_boundary( + projection: &mut super::super::Projection, + source: &str, + crs: &str, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> crate::Result> { + let Some(wkt) = visible_area_wkt(&projection.properties) else { + return Ok(None); + }; + + projection.computed.insert( + "clip_boundary".to_string(), + ParameterValue::String(wkt.clone()), + ); + let body = format!("SELECT ST_GeomFromText('{wkt}') AS geom"); + for stmt in dialect.create_or_replace_temp_table_sql(CLIP_BOUNDARY_TABLE, &[], &body) { + execute_query(&stmt)?; + } + + let projected = dialect.sql_st_transform("geom", source, crs); + let sql = format!("SELECT ST_AsText({projected}) AS wkt FROM {CLIP_BOUNDARY_TABLE}"); + if let Some(projected_wkt) = query_scalar_string(&sql, execute_query) { + projection.computed.insert( + "panel_boundary".to_string(), + ParameterValue::String(projected_wkt), + ); + } + + let world_bbox_sql = dialect.sql_geometry_bbox(&projected, CLIP_BOUNDARY_TABLE); + let world_bbox = execute_query(&world_bbox_sql) + .ok() + .and_then(|df| BBox::from_df(&df, crs)); + Ok(world_bbox) +} + +/// Clip (if needed) and project a WKT geometry string, returning the projected WKT. +fn project_wkt( + wkt: Option, + has_clip: bool, + source: &str, + crs: &str, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> crate::Result> { + let Some(wkt) = wkt else { return Ok(None) }; + let geom_expr = format!("ST_GeomFromText('{wkt}')"); + let clipped = if has_clip { + format!("ST_Intersection({geom_expr}, (SELECT geom FROM {CLIP_BOUNDARY_TABLE}))") + } else { + geom_expr + }; + let projected = dialect.sql_st_transform(&clipped, source, crs); + let sql = format!("SELECT ST_AsText({projected}) AS wkt"); + Ok(query_scalar_string(&sql, execute_query)) +} + /// Returns true if we need to compute a bbox (bounding box representing the extent of geometry) /// from the data — i.e. when bounds is absent or has null elements that need filling in. fn needs_computed_bbox(bounds_param: Option<&ParameterValue>) -> bool { @@ -610,6 +626,45 @@ fn resolve_frame_bbox( computed } +fn detect_source_srid( + layers: &[Layer], + layer_queries: &[String], + execute_query: &dyn Fn(&str) -> crate::Result, +) -> Option { + let geom_col = naming::quote_ident(&naming::aesthetic_column("geometry")); + + for (idx, layer) in layers.iter().enumerate() { + if layer.geom.geom_type() != GeomType::Spatial { + continue; + } + let sql = format!( + "SELECT ST_SRID({geom_col}) AS srid FROM ({}) WHERE {geom_col} IS NOT NULL LIMIT 1", + layer_queries[idx] + ); + if let Ok(df) = execute_query(&sql) { + let batch = df.inner(); + if batch.num_rows() == 0 { + continue; + } + if let Some(arr) = batch + .column(0) + .as_any() + .downcast_ref::() + { + let srid = arr.value(0); + if srid != 0 { + return Some(format!("EPSG:{srid}")); + } + } + } + } + None +} + +// --------------------------------------------------------------------------- +// Visible area / horizon clipping +// --------------------------------------------------------------------------- + /// Returns a WKT POLYGON representing the visible hemisphere for the given projection /// properties, or `None` if the projection doesn't require horizon clipping. /// @@ -857,40 +912,9 @@ fn antimeridian_crossing_lat(a: (f64, f64), b: (f64, f64)) -> f64 { lat_a + t * (lat_b - lat_a) } -fn detect_source_srid( - layers: &[Layer], - layer_queries: &[String], - execute_query: &dyn Fn(&str) -> crate::Result, -) -> Option { - let geom_col = naming::quote_ident(&naming::aesthetic_column("geometry")); - - for (idx, layer) in layers.iter().enumerate() { - if layer.geom.geom_type() != GeomType::Spatial { - continue; - } - let sql = format!( - "SELECT ST_SRID({geom_col}) AS srid FROM ({}) WHERE {geom_col} IS NOT NULL LIMIT 1", - layer_queries[idx] - ); - if let Ok(df) = execute_query(&sql) { - let batch = df.inner(); - if batch.num_rows() == 0 { - continue; - } - if let Some(arr) = batch - .column(0) - .as_any() - .downcast_ref::() - { - let srid = arr.value(0); - if srid != 0 { - return Some(format!("EPSG:{srid}")); - } - } - } - } - None -} +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- #[cfg(test)] mod tests { From cc40f2d023ddc91678dccc1cddc5cf7139959415 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 12 May 2026 16:57:54 +0200 Subject: [PATCH 23/50] Clean up writer map projection: extract helpers, merge impl blocks - Simplify frame_bbox parsing with if-let and try_into - Split background_layers into panel_boundary() and graticule() methods - Extract geoshape_layer() helper for building geoshape VL layers - Merge two impl MapProjection blocks into one - Hoist ArrayElement import to top level Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection/map.rs | 157 +++++++++++++------------- 1 file changed, 77 insertions(+), 80 deletions(-) diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs index 62199b991..e656d6f92 100644 --- a/src/writer/vegalite/projection/map.rs +++ b/src/writer/vegalite/projection/map.rs @@ -4,6 +4,7 @@ //! must use an identity projection so it passes coordinates through without //! re-projecting via d3-geo. +use crate::plot::types::ArrayElement; use crate::plot::{ParameterValue, Projection, Scale}; use crate::{Plot, Result}; use serde_json::{json, Value}; @@ -32,27 +33,20 @@ impl MapProjection { let panel_boundary_wkt = get_string("panel_boundary"); let graticule_lon_wkt = get_string("graticule_lon"); let graticule_lat_wkt = get_string("graticule_lat"); - // TODO: simplify — coord side always stores exactly 4 Numbers - let frame_bbox = project - .and_then(|p| p.computed.get("frame_bbox")) - .and_then(|v| match v { - ParameterValue::Array(arr) if arr.len() == 4 => { - use crate::plot::types::ArrayElement; - let nums: Vec = arr - .iter() - .filter_map(|e| match e { - ArrayElement::Number(n) => Some(*n), - _ => None, - }) - .collect(); - if nums.len() == 4 { - Some([nums[0], nums[1], nums[2], nums[3]]) - } else { - None - } - } - _ => None, - }); + let frame_bbox = if let Some(ParameterValue::Array(arr)) = + project.and_then(|p| p.computed.get("frame_bbox")) + { + let nums: Vec = arr + .iter() + .filter_map(|e| match e { + ArrayElement::Number(n) => Some(*n), + _ => None, + }) + .collect(); + nums.try_into().ok() + } else { + None + }; Self { is_faceted: facet.is_some_and(|f| !f.get_variables().is_empty()), panel_boundary_wkt, @@ -61,6 +55,55 @@ impl MapProjection { frame_bbox, } } + + fn panel_boundary(&self, theme: &mut Value) -> Vec { + let Some(ref wkt) = self.panel_boundary_wkt else { + return Vec::new(); + }; + let Some(geojson) = wkt_to_geojson(wkt) else { + return Vec::new(); + }; + let (fill, stroke) = + if let Some(view) = theme.get_mut("view").and_then(|v| v.as_object_mut()) { + let fill = view.remove("fill").unwrap_or(Value::Null); + let stroke = view.remove("stroke").unwrap_or(Value::Null); + view.insert("stroke".to_string(), Value::Null); + (fill, stroke) + } else { + (Value::Null, Value::Null) + }; + vec![geoshape_layer( + geojson, + json!({ + "fill": fill, + "stroke": stroke, + }), + )] + } + + fn graticule(&self, theme: &Value) -> Vec { + let grid_color = theme + .pointer("/axis/gridColor") + .cloned() + .unwrap_or(json!("#cccccc")); + let grid_width = theme + .pointer("/axis/gridWidth") + .cloned() + .unwrap_or(json!(0.5)); + + let mark = json!({ + "filled": false, + "stroke": grid_color, + "strokeWidth": grid_width, + }); + + [&self.graticule_lon_wkt, &self.graticule_lat_wkt] + .into_iter() + .flatten() + .filter_map(|wkt| wkt_to_geojson(wkt)) + .map(|geojson| geoshape_layer(geojson, mark.clone())) + .collect() + } } impl ProjectionRenderer for MapProjection { @@ -101,68 +144,20 @@ impl ProjectionRenderer for MapProjection { fn background_layers(&self, _scales: &[Scale], theme: &mut Value) -> Vec { let mut layers = Vec::new(); - - // Panel boundary (hemisphere fill) - if let Some(ref wkt) = self.panel_boundary_wkt { - if let Some(geojson) = wkt_to_geojson(wkt) { - let (fill, stroke) = if let Some(view) = - theme.get_mut("view").and_then(|v| v.as_object_mut()) - { - let fill = view.remove("fill").unwrap_or(Value::Null); - let stroke = view.remove("stroke").unwrap_or(Value::Null); - view.insert("stroke".to_string(), Value::Null); - (fill, stroke) - } else { - (Value::Null, Value::Null) - }; - layers.push(json!({ - "data": { - "values": [{"type": "Feature", "geometry": geojson}] - }, - "mark": { - "type": "geoshape", - "fill": fill, - "stroke": stroke, - } - })); - } - } - - // Graticule lines (meridians and parallels) - if self.graticule_lon_wkt.is_some() || self.graticule_lat_wkt.is_some() { - let grid_color = theme - .pointer("/axis/gridColor") - .cloned() - .unwrap_or(json!("#cccccc")); - let grid_width = theme - .pointer("/axis/gridWidth") - .cloned() - .unwrap_or(json!(0.5)); - - for wkt in [&self.graticule_lon_wkt, &self.graticule_lat_wkt] - .into_iter() - .flatten() - { - if let Some(geojson) = wkt_to_geojson(wkt) { - layers.push(json!({ - "data": { - "values": [{"type": "Feature", "geometry": geojson}] - }, - "mark": { - "type": "geoshape", - "filled": false, - "stroke": grid_color, - "strokeWidth": grid_width, - } - })); - } - } - } - + layers.extend(self.panel_boundary(theme)); + layers.extend(self.graticule(theme)); layers } } +fn geoshape_layer(geojson: Value, mut mark: Value) -> Value { + mark["type"] = json!("geoshape"); + json!({ + "data": {"values": [{"type": "Feature", "geometry": geojson}]}, + "mark": mark + }) +} + #[cfg(feature = "spatial")] fn wkt_to_geojson(wkt: &str) -> Option { use geozero::geojson::GeoJsonWriter; @@ -299,7 +294,9 @@ mod tests { ); proj.computed.insert( "graticule_lat".to_string(), - ParameterValue::String("MULTILINESTRING ((-180 0, 180 0), (-180 45, 180 45))".to_string()), + ParameterValue::String( + "MULTILINESTRING ((-180 0, 180 0), (-180 45, 180 45))".to_string(), + ), ); let renderer = MapProjection::new(Some(&proj), None); let mut theme = json!({"axis": {"gridColor": "#dddddd", "gridWidth": 1}}); From de2a632a36c4d85b66cf40481370801441452315 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 13 May 2026 11:55:55 +0200 Subject: [PATCH 24/50] Add visible area boundary for Mercator projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mercator now gets a world-extent rectangle (±180°, ±85°) as its visible area, enabling the full projection pipeline: clip boundary table, geometry clipping, projected panel background, world bbox for bounds resolution, and graticules. Refactors extract_proj_param into extract_proj_param_str and uses match on projection name for dispatching visible area shape. Co-Authored-By: Claude Opus 4.6 --- src/plot/layer/geom/spatial.rs | 30 ++++++++-- src/plot/projection/coord/map.rs | 80 +++++++++++++++------------ src/writer/vegalite/projection/map.rs | 21 +++---- 3 files changed, 82 insertions(+), 49 deletions(-) diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index f3edd71a7..05f00585e 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -138,7 +138,7 @@ mod tests { } #[test] - fn test_apply_projection_map_with_crs() { + fn test_apply_projection_map_with_crs_no_clip() { let spatial = Spatial; let mut projection = Projection::map(); projection.properties.insert( @@ -149,14 +149,34 @@ mod tests { .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) .unwrap(); - // Native projected geometry — WKB added later by framing step + // Without clip_boundary in computed, just ST_Transform assert!(!result.contains("ST_AsBinary")); assert!(result.contains("ST_Transform")); assert!(result.contains("+proj=merc")); - assert!( - !result.contains("ST_Intersection"), - "mercator should not clip" + assert!(!result.contains("ST_Intersection")); + } + + #[test] + fn test_apply_projection_mercator_with_clip_boundary() { + let spatial = Spatial; + let mut projection = Projection::map(); + projection.properties.insert( + "crs".to_string(), + ParameterValue::String("+proj=merc".to_string()), + ); + projection.computed.insert( + "clip_boundary".to_string(), + ParameterValue::String( + "POLYGON((-180 -85, 180 -85, 180 85, -180 85, -180 -85))".to_string(), + ), ); + let result = spatial + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .unwrap(); + + assert!(result.contains("ST_Intersection")); + assert!(result.contains("ST_Intersects")); + assert!(result.contains("__ggsql_clip_boundary__")); } #[test] diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 44102af83..716bdd1e5 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -100,10 +100,11 @@ impl CoordTrait for Map { _ => return Ok(()), }; - // Step 2: For azimuthal projections, compute the hemisphere clip boundary. - // This produces: clip_boundary (unprojected WKT), panel_boundary (projected - // WKT for the writer's background layer), and world_bbox (bounding box of the - // full projected visible area, used to resolve Inf in user-specified bounds). + // Step 2: Compute the visible area boundary for this projection. + // Azimuthal projections get a hemisphere polygon; cylindrical get a world + // rectangle. This produces: clip_boundary (unprojected WKT), panel_boundary + // (projected WKT for the writer's background layer), and world_bbox (bounding + // box of the full projected visible area, used to resolve Inf in user bounds). let world_bbox = setup_clip_boundary(projection, &source, &crs, dialect, execute_query)?; // Step 3: Apply per-layer projection (ST_Transform, clip to horizon) @@ -242,10 +243,10 @@ impl BBox { fn from_array(arr: [f64; 4], crs: &str) -> Self { Self { - xmin: arr[0], - ymin: arr[1], - xmax: arr[2], - ymax: arr[3], + xmin: arr[0].min(arr[2]), + ymin: arr[1].min(arr[3]), + xmax: arr[0].max(arr[2]), + ymax: arr[1].max(arr[3]), crs: crs.to_string(), } } @@ -270,6 +271,13 @@ impl BBox { (self.ymin, self.ymax) } + fn to_polygon_wkt(&self) -> String { + let (xmin, ymin, xmax, ymax) = (self.xmin, self.ymin, self.xmax, self.ymax); + format!( + "POLYGON(({xmin} {ymin}, {xmax} {ymin}, {xmax} {ymax}, {xmin} {ymax}, {xmin} {ymin}))" + ) + } + fn as_parameter_value(&self) -> ParameterValue { use crate::plot::types::ArrayElement; ParameterValue::Array(vec![ @@ -665,44 +673,43 @@ fn detect_source_srid( // Visible area / horizon clipping // --------------------------------------------------------------------------- -/// Returns a WKT POLYGON representing the visible hemisphere for the given projection -/// properties, or `None` if the projection doesn't require horizon clipping. +/// Returns a WKT POLYGON representing the valid visible area for the given projection. /// -/// The polygon is a 72-vertex haversine boundary at 88° great-circle radius from the -/// projection center (`lon_0`, `lat_0`). Azimuthal projections (orthographic, gnomonic) -/// only display one hemisphere; geometry beyond this boundary produces degenerate output -/// after `ST_Transform` and must be clipped. +/// - Azimuthal projections (orthographic, gnomonic): a 72-vertex haversine boundary at +/// 88° great-circle radius from the projection center. Geometry beyond this boundary +/// produces degenerate output after `ST_Transform`. +/// - Cylindrical projections (mercator): a rectangle at ±180° longitude, ±85° latitude +/// (the Mercator singularity is at ±85.05°). +/// - Returns `None` if no CRS is set (no projection to apply). pub fn visible_area_wkt(properties: &HashMap) -> Option { let crs = match properties.get("crs") { Some(ParameterValue::String(s)) => s, _ => return None, }; - if !needs_horizon_clip(crs) { - return None; - } - let center = projection_center(crs); - Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)) -} - -fn needs_horizon_clip(crs: &str) -> bool { - let lower = crs.to_ascii_lowercase(); - lower.contains("+proj=ortho") || lower.contains("+proj=gnom") + match extract_proj_param_str(crs, "+proj=") { + Some("ortho") | Some("gnom") => Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)), + Some("merc") => Some(BBox::from_array([-180.0, -85.0, 180.0, 85.0], "").to_polygon_wkt()), + _ => None, + } } fn projection_center(crs: &str) -> (f64, f64) { - let lon = extract_proj_param(crs, "+lon_0=").unwrap_or(0.0); - let lat = extract_proj_param(crs, "+lat_0=").unwrap_or(0.0); + let lon = extract_proj_param_str(crs, "+lon_0=") + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0); + let lat = extract_proj_param_str(crs, "+lat_0=") + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0); (lon, lat) } -fn extract_proj_param(crs: &str, key: &str) -> Option { - crs.find(key).and_then(|start| { - let after = &crs[start + key.len()..]; - let end = after.find([' ', '+']).unwrap_or(after.len()); - after[..end].parse().ok() - }) +fn extract_proj_param_str<'a>(crs: &'a str, key: &str) -> Option<&'a str> { + let start = crs.find(key)?; + let after = &crs[start + key.len()..]; + let end = after.find([' ', '+']).unwrap_or(after.len()); + Some(&after[..end]) } /// Haversine boundary polygon at `radius_deg` from `(lon0, lat0)`, as WKT. @@ -1000,13 +1007,18 @@ mod tests { } #[test] - fn test_visible_area_wkt_mercator_returns_none() { + fn test_visible_area_wkt_mercator_returns_rectangle() { let mut props = HashMap::new(); props.insert( "crs".to_string(), ParameterValue::String("+proj=merc".to_string()), ); - assert!(visible_area_wkt(&props).is_none()); + let wkt = visible_area_wkt(&props); + assert!(wkt.is_some()); + let wkt = wkt.unwrap(); + assert!(wkt.starts_with("POLYGON((")); + assert!(wkt.contains("-180") && wkt.contains("180")); + assert!(wkt.contains("-85") && wkt.contains("85")); } #[test] diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs index e656d6f92..3619d6b74 100644 --- a/src/writer/vegalite/projection/map.rs +++ b/src/writer/vegalite/projection/map.rs @@ -125,18 +125,19 @@ impl ProjectionRenderer for MapProjection { "reflectY": true }); if let Some([xmin, ymin, xmax, ymax]) = self.frame_bbox { - // 10% expansion to match the default scale expand padding let dx = (xmax - xmin) * 1.1; let dy = (ymax - ymin) * 1.1; - let cx = (xmin + xmax) / 2.0; - let cy = (ymin + ymax) / 2.0; - proj["scale"] = json!({"expr": format!( - "min(width / {dx}, height / {dy})" - )}); - proj["translate"] = json!({"expr": format!( - "[width / 2 - min(width / {dx}, height / {dy}) * {cx}, \ - height / 2 + min(width / {dx}, height / {dy}) * {cy}]" - )}); + if dx.is_finite() && dy.is_finite() && dx > 0.0 && dy > 0.0 { + let cx = (xmin + xmax) / 2.0; + let cy = (ymin + ymax) / 2.0; + proj["scale"] = json!({"expr": format!( + "min(width / {dx}, height / {dy})" + )}); + proj["translate"] = json!({"expr": format!( + "[width / 2 - min(width / {dx}, height / {dy}) * {cx}, \ + height / 2 + min(width / {dx}, height / {dy}) * {cy}]" + )}); + } } vl_spec["projection"] = proj; Ok(()) From f8553386b1848388c5e0a519be1be7cc77ac988c Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 13 May 2026 14:42:47 +0200 Subject: [PATCH 25/50] Add map projection shorthands: miller, equirectangular, stereographic, lambert, azimuthal_equidistant Extends the parser with new coord system keywords and adds visible area support for each: cylindrical projections (mill, eqc) use a world box, stereographic uses the hemisphere polygon, and full-globe azimuthal projections (laea, aeqd) are marked as todo. Also extracts a map_proj helper in the parser, fixes graticule extent for azimuthal projections, and guards against degenerate frame bbox in the writer. Co-Authored-By: Claude Opus 4.6 --- src/parser/builder.rs | 27 +++++++++++++-------------- src/plot/projection/coord/map.rs | 31 ++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index c96e03f59..594fe5af0 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1113,24 +1113,23 @@ fn parse_coord_system( source: &SourceTree, ) -> Result<(Coord, Vec<(String, ParameterValue)>)> { let text = source.get_text(node); + let map_proj = |proj: &str| -> Result<(Coord, Vec<(String, ParameterValue)>)> { + Ok(( + Coord::map(), + vec![("crs".to_string(), ParameterValue::String(format!("+proj={proj}")))], + )) + }; match text.to_lowercase().as_str() { "cartesian" => Ok((Coord::cartesian(), Vec::new())), "polar" => Ok((Coord::polar(), Vec::new())), "map" => Ok((Coord::map(), Vec::new())), - "mercator" => Ok(( - Coord::map(), - vec![( - "crs".to_string(), - ParameterValue::String("+proj=merc".to_string()), - )], - )), - "orthographic" => Ok(( - Coord::map(), - vec![( - "crs".to_string(), - ParameterValue::String("+proj=ortho".to_string()), - )], - )), + "mercator" => map_proj("merc"), + "orthographic" => map_proj("ortho"), + "miller" => map_proj("mill"), + "equirectangular" => map_proj("eqc"), + "stereographic" => map_proj("stere"), + "lambert" => map_proj("laea"), + "azimuthal_equidistant" => map_proj("aeqd"), _ => Err(GgsqlError::ParseError(format!( "Unknown coord type: {}", text diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 716bdd1e5..a0320faff 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -155,9 +155,14 @@ impl CoordTrait for Map { .computed .insert("frame_bbox".to_string(), bbox.as_parameter_value()); - // Step 6: Generate graticule lines - let has_clip = projection.computed.contains_key("clip_boundary"); - let (lon_wkt, lat_wkt) = build_graticule(&bbox, &source, has_clip, dialect, execute_query)?; + // Step 6: Generate graticule lines (azimuthal projections need clip-based + // extent and ST_Intersection; cylindrical projections don't) + let is_azimuthal = matches!( + extract_proj_param_str(&crs, "+proj="), + Some("ortho") | Some("gnom") | Some("stere") + ); + let (lon_wkt, lat_wkt) = + build_graticule(&bbox, &source, is_azimuthal, dialect, execute_query)?; if let Some(wkt) = lon_wkt { projection .computed @@ -408,9 +413,9 @@ fn graticule_bbox( }; // For azimuthal projections the bbox corners often inverse-project to - // degenerate values (all at the horizon). Fall back to the clip boundary - // extent which represents the actual visible hemisphere. - if has_clip && (geo_bbox.xmax - geo_bbox.xmin).abs() < 1.0 { + // degenerate or incomplete values. Use the clip boundary extent which + // correctly represents the visible hemisphere. + if has_clip { let bbox_sql = dialect.sql_geometry_bbox("geom", CLIP_BOUNDARY_TABLE); if let Ok(df) = execute_query(&bbox_sql) { if let Some(clip_bbox) = BBox::from_df(&df, source) { @@ -527,8 +532,10 @@ fn query_scalar_string( Some(arr.value(0).to_string()) } -/// Set up the clip boundary for azimuthal projections. Creates the clip boundary temp table, -/// projects it into the target CRS, and returns the world bbox (projected clip boundary extent). +/// Set up the clip/visible area boundary. Creates the clip boundary temp table, +/// projects it into the target CRS, and returns the world bbox (projected extent). +/// For projections where `visible_area_wkt` returns None (e.g. LAEA), generates the +/// panel boundary directly in projected space using ST_Buffer. fn setup_clip_boundary( projection: &mut super::super::Projection, source: &str, @@ -689,8 +696,14 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< let center = projection_center(crs); match extract_proj_param_str(crs, "+proj=") { - Some("ortho") | Some("gnom") => Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)), + Some("ortho") | Some("gnom") | Some("stere") => { + Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)) + } + Some("laea") | Some("aeqd") => todo!("full-globe azimuthal visible area"), Some("merc") => Some(BBox::from_array([-180.0, -85.0, 180.0, 85.0], "").to_polygon_wkt()), + Some("mill") | Some("eqc") => { + Some(BBox::from_array([-180.0, -90.0, 180.0, 90.0], "").to_polygon_wkt()) + } _ => None, } } From 0d327ae6b66d552c196bcbd933afbf379ce360f7 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 13 May 2026 16:39:55 +0200 Subject: [PATCH 26/50] Add Interrupted Goode Homolosine projection support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `igh` parser keyword for +proj=igh. The visible area is a hardcoded polygon with densified meridian slits at the interrupt longitudes (-40° north, -100°/-20°/80° south). Graticule clipping uses ST_CollectionExtract to discard stray points from intersection results, fixing GEOMETRYCOLLECTION output that Vega-Lite can't render. Co-Authored-By: Claude Opus 4.6 --- src/parser/builder.rs | 1 + src/plot/projection/coord/map.rs | 70 ++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 594fe5af0..022156db0 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1130,6 +1130,7 @@ fn parse_coord_system( "stereographic" => map_proj("stere"), "lambert" => map_proj("laea"), "azimuthal_equidistant" => map_proj("aeqd"), + "igh" => map_proj("igh"), _ => Err(GgsqlError::ParseError(format!( "Unknown coord type: {}", text diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index a0320faff..da5aaa4b8 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -155,14 +155,15 @@ impl CoordTrait for Map { .computed .insert("frame_bbox".to_string(), bbox.as_parameter_value()); - // Step 6: Generate graticule lines (azimuthal projections need clip-based - // extent and ST_Intersection; cylindrical projections don't) - let is_azimuthal = matches!( - extract_proj_param_str(&crs, "+proj="), - Some("ortho") | Some("gnom") | Some("stere") + // Step 6: Generate graticule lines (azimuthal and interrupted projections + // need clip-based extent and ST_Intersection; cylindrical projections don't) + let proj_name = extract_proj_param_str(&crs, "+proj="); + let needs_clip = matches!( + proj_name, + Some("ortho") | Some("gnom") | Some("stere") | Some("igh") ); let (lon_wkt, lat_wkt) = - build_graticule(&bbox, &source, is_azimuthal, dialect, execute_query)?; + build_graticule(&bbox, &source, needs_clip, dialect, execute_query)?; if let Some(wkt) = lon_wkt { projection .computed @@ -584,7 +585,12 @@ fn project_wkt( let Some(wkt) = wkt else { return Ok(None) }; let geom_expr = format!("ST_GeomFromText('{wkt}')"); let clipped = if has_clip { - format!("ST_Intersection({geom_expr}, (SELECT geom FROM {CLIP_BOUNDARY_TABLE}))") + // ST_CollectionExtract(..., 2) keeps only linestring components, + // discarding stray points from vertex-on-boundary intersections. + format!( + "ST_CollectionExtract(ST_Intersection({geom_expr}, \ + (SELECT geom FROM {CLIP_BOUNDARY_TABLE})), 2)" + ) } else { geom_expr }; @@ -700,6 +706,7 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)) } Some("laea") | Some("aeqd") => todo!("full-globe azimuthal visible area"), + Some("igh") => Some(igh_outline_wkt()), Some("merc") => Some(BBox::from_array([-180.0, -85.0, 180.0, 85.0], "").to_polygon_wkt()), Some("mill") | Some("eqc") => { Some(BBox::from_array([-180.0, -90.0, 180.0, 90.0], "").to_polygon_wkt()) @@ -718,6 +725,55 @@ fn projection_center(crs: &str) -> (f64, f64) { (lon, lat) } +/// Interrupted Goode Homolosine outline polygon with densified meridian edges. +/// Interrupts: -40° (north), -100°/-20°/80° (south). The outline traces +/// vertical slits at these meridians with 1° spacing for smooth projection. +fn igh_outline_wkt() -> String { + let mut coords: Vec = Vec::new(); + + // Helper: densified meridian segment from lat_start to lat_end at fixed lon + let meridian = |coords: &mut Vec, lon: f64, lat_start: f64, lat_end: f64| { + let step = if lat_end > lat_start { 5.0 } else { -5.0 }; + let n = ((lat_end - lat_start) / step).abs() as usize; + for i in 0..n { + let lat = lat_start + step * i as f64; + coords.push(format!("{lon:.2} {lat:.2}")); + } + }; + + // Counter-clockwise ring matching the R/sf approach: + // Start top-right (180,90), down east edge, trace bottom east→west with + // southern slits, up west edge, trace top west→east with northern slit. + + // East edge: (180, 90) down to (180, -90) + meridian(&mut coords, 180.0, 90.0, -90.0); + + // Bottom edge east→west with southern slits at 80°, -20°, -100° + coords.push("80.01 -90".to_string()); + meridian(&mut coords, 80.01, -90.0, 0.0); + meridian(&mut coords, 79.99, 0.0, -90.0); + coords.push("-19.99 -90".to_string()); + meridian(&mut coords, -19.99, -90.0, 0.0); + meridian(&mut coords, -20.01, 0.0, -90.0); + coords.push("-99.99 -90".to_string()); + meridian(&mut coords, -99.99, -90.0, 0.0); + meridian(&mut coords, -100.01, 0.0, -90.0); + coords.push("-180 -90".to_string()); + + // West edge: (-180, -90) up to (-180, 90) + meridian(&mut coords, -180.0, -90.0, 90.0); + + // Top edge west→east with northern slit at -40° + coords.push("-40.01 90".to_string()); + meridian(&mut coords, -40.01, 90.0, 0.0); + meridian(&mut coords, -39.99, 0.0, 90.0); + + // Close ring + coords.push("180 90".to_string()); + + format!("POLYGON(({}))", coords.join(", ")) +} + fn extract_proj_param_str<'a>(crs: &'a str, key: &str) -> Option<&'a str> { let start = crs.find(key)?; let after = &crs[start + key.len()..]; From 0e606fbfc1bfe5acf1a5b71532e1fb461dcc0416 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 13 May 2026 17:03:52 +0200 Subject: [PATCH 27/50] Add Robinson projection with densified rectangle boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `robinson` parser keyword for +proj=robin. The visible area uses a densified rectangle (36 segments on left/right meridian edges for smooth curves). Extracts `needs_graticule_clip` helper — flips logic to allowlist cylindrical projections that don't need clip, defaulting to clip for all others. Also adds `densified_rectangle_wkt` for projections needing selectively densified edges, and renames project_wkt to project_graticule_wkt for clarity. Co-Authored-By: Claude Opus 4.6 --- src/parser/builder.rs | 1 + src/plot/projection/coord/map.rs | 55 ++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 022156db0..643a67eab 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1131,6 +1131,7 @@ fn parse_coord_system( "lambert" => map_proj("laea"), "azimuthal_equidistant" => map_proj("aeqd"), "igh" => map_proj("igh"), + "robinson" => map_proj("robin"), _ => Err(GgsqlError::ParseError(format!( "Unknown coord type: {}", text diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index da5aaa4b8..13bbdc4ad 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -158,10 +158,7 @@ impl CoordTrait for Map { // Step 6: Generate graticule lines (azimuthal and interrupted projections // need clip-based extent and ST_Intersection; cylindrical projections don't) let proj_name = extract_proj_param_str(&crs, "+proj="); - let needs_clip = matches!( - proj_name, - Some("ortho") | Some("gnom") | Some("stere") | Some("igh") - ); + let needs_clip = needs_graticule_clip(proj_name); let (lon_wkt, lat_wkt) = build_graticule(&bbox, &source, needs_clip, dialect, execute_query)?; if let Some(wkt) = lon_wkt { @@ -393,8 +390,8 @@ fn build_graticule( }; Ok(( - project_wkt(lon_wkt, has_clip, source, crs, dialect, execute_query)?, - project_wkt(lat_wkt, has_clip, source, crs, dialect, execute_query)?, + project_graticule_wkt(lon_wkt, has_clip, source, crs, dialect, execute_query)?, + project_graticule_wkt(lat_wkt, has_clip, source, crs, dialect, execute_query)?, )) } @@ -574,7 +571,7 @@ fn setup_clip_boundary( } /// Clip (if needed) and project a WKT geometry string, returning the projected WKT. -fn project_wkt( +fn project_graticule_wkt( wkt: Option, has_clip: bool, source: &str, @@ -707,6 +704,10 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< } Some("laea") | Some("aeqd") => todo!("full-globe azimuthal visible area"), Some("igh") => Some(igh_outline_wkt()), + Some("robin") => Some(densified_rectangle_wkt( + -180.0, -90.0, 180.0, 90.0, + [1, 36, 1, 36], // densify left/right meridian edges only + )), Some("merc") => Some(BBox::from_array([-180.0, -85.0, 180.0, 85.0], "").to_polygon_wkt()), Some("mill") | Some("eqc") => { Some(BBox::from_array([-180.0, -90.0, 180.0, 90.0], "").to_polygon_wkt()) @@ -725,6 +726,38 @@ fn projection_center(crs: &str) -> (f64, f64) { (lon, lat) } +/// Rectangle WKT with selectively densified edges. +/// `segments` controls how many segments each edge is split into: +/// `[top, right, bottom, left]`. Use 1 for no densification on an edge. +fn densified_rectangle_wkt( + xmin: f64, + ymin: f64, + xmax: f64, + ymax: f64, + segments: [usize; 4], +) -> String { + let mut coords: Vec = Vec::new(); + let [top, right, bottom, left] = segments.map(|s| s.max(1)); + for i in 0..top { + let t = i as f64 / top as f64; + coords.push(format!("{:.6} {:.6}", xmin + t * (xmax - xmin), ymax)); + } + for i in 0..right { + let t = i as f64 / right as f64; + coords.push(format!("{:.6} {:.6}", xmax, ymax - t * (ymax - ymin))); + } + for i in 0..bottom { + let t = i as f64 / bottom as f64; + coords.push(format!("{:.6} {:.6}", xmax - t * (xmax - xmin), ymin)); + } + for i in 0..left { + let t = i as f64 / left as f64; + coords.push(format!("{:.6} {:.6}", xmin, ymin + t * (ymax - ymin))); + } + coords.push(format!("{:.6} {:.6}", xmin, ymax)); + format!("POLYGON(({}))", coords.join(", ")) +} + /// Interrupted Goode Homolosine outline polygon with densified meridian edges. /// Interrupts: -40° (north), -100°/-20°/80° (south). The outline traces /// vertical slits at these meridians with 1° spacing for smooth projection. @@ -774,6 +807,14 @@ fn igh_outline_wkt() -> String { format!("POLYGON(({}))", coords.join(", ")) } +/// Whether graticule generation should use the clip boundary extent rather than +/// inverse-projecting the frame bbox corners. Needed for projections with curved +/// edges or interruptions, where corner inverse-projection doesn't recover the +/// full visible lon/lat range. +fn needs_graticule_clip(proj_name: Option<&str>) -> bool { + !matches!(proj_name, Some("merc") | Some("mill") | Some("eqc") | None) +} + fn extract_proj_param_str<'a>(crs: &'a str, key: &str) -> Option<&'a str> { let start = crs.find(key)?; let after = &crs[start + key.len()..]; From bd931104767060f8728a1a3ef7fe00928d2a45a7 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 13 May 2026 17:15:45 +0200 Subject: [PATCH 28/50] Add fallback panel background for unsupported map projections When no projected panel boundary is available (e.g. for projections not yet fully supported), emit a rect mark spanning the full view as the background instead of leaving the panel empty. This ensures a consistent gray panel appears regardless of projection type. Also adds ST_CollectionExtract to graticule clipping to handle GEOMETRYCOLLECTION results from ST_Intersection. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection/map.rs | 49 ++++++++++++++++++--------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs index 3619d6b74..537be7b4e 100644 --- a/src/writer/vegalite/projection/map.rs +++ b/src/writer/vegalite/projection/map.rs @@ -57,12 +57,6 @@ impl MapProjection { } fn panel_boundary(&self, theme: &mut Value) -> Vec { - let Some(ref wkt) = self.panel_boundary_wkt else { - return Vec::new(); - }; - let Some(geojson) = wkt_to_geojson(wkt) else { - return Vec::new(); - }; let (fill, stroke) = if let Some(view) = theme.get_mut("view").and_then(|v| v.as_object_mut()) { let fill = view.remove("fill").unwrap_or(Value::Null); @@ -72,13 +66,30 @@ impl MapProjection { } else { (Value::Null, Value::Null) }; - vec![geoshape_layer( - geojson, - json!({ - "fill": fill, - "stroke": stroke, - }), - )] + + if let Some(ref wkt) = self.panel_boundary_wkt { + let Some(geojson) = wkt_to_geojson(wkt) else { + return Vec::new(); + }; + vec![geoshape_layer( + geojson, + json!({ "fill": fill, "stroke": stroke }), + )] + } else { + vec![json!({ + "mark": { + "type": "rect", + "fill": fill, + "stroke": stroke, + }, + "encoding": { + "x": {"value": 0}, + "y": {"value": 0}, + "x2": {"value": {"expr": "width"}}, + "y2": {"value": {"expr": "height"}}, + } + })] + } } fn graticule(&self, theme: &Value) -> Vec { @@ -230,7 +241,11 @@ mod tests { let renderer = MapProjection::new(None, None); let mut theme = json!({"view": {"fill": "white", "stroke": "gray"}}); let layers = renderer.background_layers(&[], &mut theme); - assert!(layers.is_empty()); + // Fallback: rect mark spanning the full view + assert_eq!(layers.len(), 1); + assert_eq!(layers[0]["mark"]["type"], "rect"); + assert_eq!(layers[0]["mark"]["fill"], "white"); + assert_eq!(layers[0]["mark"]["stroke"], "gray"); } #[test] @@ -303,8 +318,10 @@ mod tests { let mut theme = json!({"axis": {"gridColor": "#dddddd", "gridWidth": 1}}); let layers = renderer.background_layers(&[], &mut theme); - assert_eq!(layers.len(), 2); - for layer in &layers { + // 1 rect fallback + 2 graticule layers + assert_eq!(layers.len(), 3); + assert_eq!(layers[0]["mark"]["type"], "rect"); + for layer in &layers[1..] { assert_eq!(layer["mark"]["type"], "geoshape"); assert_eq!(layer["mark"]["filled"], false); assert_eq!(layer["mark"]["stroke"], "#dddddd"); From ccc48ab00ae3f0c59ede92f5d43171902c361150 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 14 May 2026 15:10:21 +0200 Subject: [PATCH 29/50] Add gnomonic and cylindrical equal-area projection shorthands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gnomonic uses a 60° clip radius (smaller than ortho/stere's 88° due to extreme distortion near the singularity). Cylindrical equal-area is a standard rectangle like miller/equirectangular. Co-Authored-By: Claude Opus 4.6 --- src/parser/builder.rs | 2 ++ src/plot/projection/coord/map.rs | 14 ++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 643a67eab..dbccf4f8b 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1128,6 +1128,8 @@ fn parse_coord_system( "miller" => map_proj("mill"), "equirectangular" => map_proj("eqc"), "stereographic" => map_proj("stere"), + "gnomonic" => map_proj("gnom"), + "equal_area" => map_proj("cea"), "lambert" => map_proj("laea"), "azimuthal_equidistant" => map_proj("aeqd"), "igh" => map_proj("igh"), diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 13bbdc4ad..39c9685ab 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -699,17 +699,19 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< let center = projection_center(crs); match extract_proj_param_str(crs, "+proj=") { - Some("ortho") | Some("gnom") | Some("stere") => { - Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)) - } + Some("ortho") | Some("stere") => Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)), + Some("gnom") => Some(hemisphere_polygon_wkt(center.0, center.1, 60.0)), Some("laea") | Some("aeqd") => todo!("full-globe azimuthal visible area"), Some("igh") => Some(igh_outline_wkt()), Some("robin") => Some(densified_rectangle_wkt( - -180.0, -90.0, 180.0, 90.0, + -180.0, + -90.0, + 180.0, + 90.0, [1, 36, 1, 36], // densify left/right meridian edges only )), Some("merc") => Some(BBox::from_array([-180.0, -85.0, 180.0, 85.0], "").to_polygon_wkt()), - Some("mill") | Some("eqc") => { + Some("mill") | Some("eqc") | Some("cea") => { Some(BBox::from_array([-180.0, -90.0, 180.0, 90.0], "").to_polygon_wkt()) } _ => None, @@ -812,7 +814,7 @@ fn igh_outline_wkt() -> String { /// edges or interruptions, where corner inverse-projection doesn't recover the /// full visible lon/lat range. fn needs_graticule_clip(proj_name: Option<&str>) -> bool { - !matches!(proj_name, Some("merc") | Some("mill") | Some("eqc") | None) + !matches!(proj_name, Some("merc") | Some("mill") | Some("eqc") | Some("cea") | None) } fn extract_proj_param_str<'a>(crs: &'a str, key: &str) -> Option<&'a str> { From db54b6aea38943cddfa148bbf863ad606d78f291 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 14 May 2026 15:21:40 +0200 Subject: [PATCH 30/50] Add pseudocylindrical projection shorthands: mollweide, sinusoidal, eckert4, natural, winkel_tripel All use densified rectangle boundaries. Mollweide, sinusoidal, eckert4, and natural have straight parallels (densify meridian edges only). Winkel Tripel has curved parallels (all edges densified). Co-Authored-By: Claude Opus 4.6 --- src/parser/builder.rs | 5 +++++ src/plot/projection/coord/map.rs | 13 +++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index dbccf4f8b..83200245b 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1130,6 +1130,11 @@ fn parse_coord_system( "stereographic" => map_proj("stere"), "gnomonic" => map_proj("gnom"), "equal_area" => map_proj("cea"), + "mollweide" => map_proj("moll"), + "sinusoidal" => map_proj("sinu"), + "eckert4" => map_proj("eck4"), + "natural" => map_proj("natearth"), + "winkel_tripel" => map_proj("wintri"), "lambert" => map_proj("laea"), "azimuthal_equidistant" => map_proj("aeqd"), "igh" => map_proj("igh"), diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 39c9685ab..0ece7e58f 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -703,12 +703,21 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< Some("gnom") => Some(hemisphere_polygon_wkt(center.0, center.1, 60.0)), Some("laea") | Some("aeqd") => todo!("full-globe azimuthal visible area"), Some("igh") => Some(igh_outline_wkt()), - Some("robin") => Some(densified_rectangle_wkt( + Some("robin") | Some("moll") | Some("sinu") | Some("eck4") | Some("natearth") => { + Some(densified_rectangle_wkt( + -180.0, + -90.0, + 180.0, + 90.0, + [1, 36, 1, 36], // densify left/right meridian edges only + )) + } + Some("wintri") => Some(densified_rectangle_wkt( -180.0, -90.0, 180.0, 90.0, - [1, 36, 1, 36], // densify left/right meridian edges only + [36, 36, 36, 36], // all edges curved )), Some("merc") => Some(BBox::from_array([-180.0, -85.0, 180.0, 85.0], "").to_polygon_wkt()), Some("mill") | Some("eqc") | Some("cea") => { From c17f8fd826c13e07ba3a9fe7220f5e2cd73057c3 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 14 May 2026 15:26:37 +0200 Subject: [PATCH 31/50] unify rectangle specs --- src/plot/projection/coord/map.rs | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 0ece7e58f..47f1b06e0 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -274,13 +274,6 @@ impl BBox { (self.ymin, self.ymax) } - fn to_polygon_wkt(&self) -> String { - let (xmin, ymin, xmax, ymax) = (self.xmin, self.ymin, self.xmax, self.ymax); - format!( - "POLYGON(({xmin} {ymin}, {xmax} {ymin}, {xmax} {ymax}, {xmin} {ymax}, {xmin} {ymin}))" - ) - } - fn as_parameter_value(&self) -> ParameterValue { use crate::plot::types::ArrayElement; ParameterValue::Array(vec![ @@ -704,7 +697,7 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< Some("laea") | Some("aeqd") => todo!("full-globe azimuthal visible area"), Some("igh") => Some(igh_outline_wkt()), Some("robin") | Some("moll") | Some("sinu") | Some("eck4") | Some("natearth") => { - Some(densified_rectangle_wkt( + Some(rectangle_wkt( -180.0, -90.0, 180.0, @@ -712,16 +705,16 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< [1, 36, 1, 36], // densify left/right meridian edges only )) } - Some("wintri") => Some(densified_rectangle_wkt( + Some("wintri") => Some(rectangle_wkt( -180.0, -90.0, 180.0, 90.0, [36, 36, 36, 36], // all edges curved )), - Some("merc") => Some(BBox::from_array([-180.0, -85.0, 180.0, 85.0], "").to_polygon_wkt()), + Some("merc") => Some(rectangle_wkt(-180.0, -85.0, 180.0, 85.0, [1, 1, 1, 1])), Some("mill") | Some("eqc") | Some("cea") => { - Some(BBox::from_array([-180.0, -90.0, 180.0, 90.0], "").to_polygon_wkt()) + Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [1, 1, 1, 1])) } _ => None, } @@ -737,10 +730,9 @@ fn projection_center(crs: &str) -> (f64, f64) { (lon, lat) } -/// Rectangle WKT with selectively densified edges. -/// `segments` controls how many segments each edge is split into: -/// `[top, right, bottom, left]`. Use 1 for no densification on an edge. -fn densified_rectangle_wkt( +/// Rectangle WKT with optional edge densification. +/// `segments` is `[top, right, bottom, left]`: number of segments per edge. +fn rectangle_wkt( xmin: f64, ymin: f64, xmax: f64, From ef6e35baf1ba6b078462255502084bb66b84dca8 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 15 May 2026 10:31:27 +0200 Subject: [PATCH 32/50] Add conical projection shorthands (albers, lambert_conformal) with seam handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces seam splitting for projections whose antimeridian differs from ±180°. Geometries, panel boundaries, and graticule lines are split at the seam before reprojection to avoid cross-map artifacts. Co-Authored-By: Claude Opus 4.6 --- src/parser/builder.rs | 2 + src/plot/layer/geom/spatial.rs | 29 ++++- src/plot/projection/coord/map.rs | 201 +++++++++++++++++++++++++------ 3 files changed, 189 insertions(+), 43 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 83200245b..2facb1ab3 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1135,6 +1135,8 @@ fn parse_coord_system( "eckert4" => map_proj("eck4"), "natural" => map_proj("natearth"), "winkel_tripel" => map_proj("wintri"), + "albers" => map_proj("aea +lat_1=29.5 +lat_2=45.5"), + "lambert_conformal" => map_proj("lcc +lat_1=29.5 +lat_2=45.5"), "lambert" => map_proj("laea"), "azimuthal_equidistant" => map_proj("aeqd"), "igh" => map_proj("igh"), diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index 05f00585e..0b6b110c4 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -13,17 +13,26 @@ fn apply_clip_boundary( source: &str, crs: &str, clip_table: &str, + seam_slit: Option<&str>, ) -> String { let clip_geom = format!("(SELECT geom FROM {clip_table})"); + let source_esc = source.replace('\'', "''"); + let crs_esc = crs.replace('\'', "''"); + + let clipped = if let Some(slit_wkt) = seam_slit { + format!( + "ST_Difference(ST_Intersection({col}, {clip_geom}), \ + ST_GeomFromText('{slit_wkt}'))" + ) + } else { + format!("ST_Intersection({col}, {clip_geom})") + }; + let geom_expr = format!( "ST_MakeValid(ST_Transform(\ - ST_Intersection({col}, {clip_geom}),\ - '{source}', '{crs}', always_xy := true\ + {clipped},\ + '{source_esc}', '{crs_esc}', always_xy := true\ ))", - col = col, - clip_geom = clip_geom, - source = source.replace('\'', "''"), - crs = crs.replace('\'', "''"), ); format!( "SELECT * REPLACE ({geom_expr} AS {col}) FROM ({query}) \ @@ -74,12 +83,20 @@ impl GeomTrait for Spatial { }; if projection.computed.contains_key("clip_boundary") { + let seam_slit = projection + .computed + .get("seam_slit") + .and_then(|v| match v { + ParameterValue::String(s) => Some(s.as_str()), + _ => None, + }); return Ok(apply_clip_boundary( query, &col, source, crs, CLIP_BOUNDARY_TABLE, + seam_slit, )); } diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 47f1b06e0..5fc132e82 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -100,6 +100,17 @@ impl CoordTrait for Map { _ => return Ok(()), }; + // Validate CRS by attempting a single point transform + let probe = dialect.sql_st_transform("ST_Point(0, 0)", &source, &crs); + if let Err(e) = execute_query(&format!("SELECT {probe}")) { + let msg = e.to_string(); + return Err(crate::GgsqlError::ValidationError(format!( + "Invalid CRS '{}': {}", + crs, + msg.split(':').last().unwrap_or(&msg).trim() + ))); + } + // Step 2: Compute the visible area boundary for this projection. // Azimuthal projections get a hemisphere polygon; cylindrical get a world // rectangle. This produces: clip_boundary (unprojected WKT), panel_boundary @@ -342,6 +353,16 @@ fn build_graticule( 0.5 }; + // Compute the seam meridian (lon_0 + 180°) for non-cylindrical projections. + // Graticule lines must not cross this longitude. + let seam = if has_clip { + let center = projection_center(crs); + let s = wrap_lon(center.0 + 180.0); + Some(s) + } else { + None + }; + // Clamp meridians away from ±180 to avoid antimeridian issues, and // deduplicate (e.g. if both -180 and 180 were present, they become the same) let lon_breaks: Vec = { @@ -367,6 +388,7 @@ fn build_graticule( geo_bbox.yrange(), step_deg, true, + seam, )) } else { None @@ -377,6 +399,7 @@ fn build_graticule( geo_bbox.xrange(), step_deg, false, + seam, )) } else { None @@ -477,27 +500,62 @@ fn grid_lines_wkt( (vary_min, vary_max): (f64, f64), step_deg: f64, lon_first: bool, + seam: Option, ) -> String { + let seam_epsilon = 0.01; + let mut lines: Vec = Vec::with_capacity(breaks.len()); for &fixed in breaks { - let mut coords = Vec::new(); - let mut v = vary_min; - while v < vary_max { - let (lon, lat) = if lon_first { (fixed, v) } else { (v, fixed) }; - coords.push(format!("{lon:.6} {lat:.6}")); - v += step_deg; + // For meridians (lon_first): skip if the meridian is at the seam + if lon_first { + if let Some(s) = seam { + if (fixed - s).abs() < seam_epsilon { + continue; + } + } } - let (lon, lat) = if lon_first { - (fixed, vary_max) + + // For parallels (lon varies): split the line at the seam + let segments = if !lon_first { + seam_split_ranges(vary_min, vary_max, seam, seam_epsilon) } else { - (vary_max, fixed) + vec![(vary_min, vary_max)] }; - coords.push(format!("{lon:.6} {lat:.6}")); - lines.push(format!("({})", coords.join(", "))); + + for (seg_min, seg_max) in segments { + let mut coords = Vec::new(); + let mut v = seg_min; + while v < seg_max { + let (lon, lat) = if lon_first { (fixed, v) } else { (v, fixed) }; + coords.push(format!("{lon:.6} {lat:.6}")); + v += step_deg; + } + let (lon, lat) = if lon_first { + (fixed, seg_max) + } else { + (seg_max, fixed) + }; + coords.push(format!("{lon:.6} {lat:.6}")); + if coords.len() >= 2 { + lines.push(format!("({})", coords.join(", "))); + } + } } format!("MULTILINESTRING({})", lines.join(", ")) } +/// Split a range into sub-ranges that avoid the seam. +/// Returns one range if no seam, or two ranges if the seam falls within [min, max]. +fn seam_split_ranges(min: f64, max: f64, seam: Option, epsilon: f64) -> Vec<(f64, f64)> { + let Some(s) = seam else { + return vec![(min, max)]; + }; + if s <= min + epsilon || s >= max - epsilon { + return vec![(min, max)]; + } + vec![(min, s - epsilon), (s + epsilon, max)] +} + // --------------------------------------------------------------------------- // Generic helpers // --------------------------------------------------------------------------- @@ -542,13 +600,42 @@ fn setup_clip_boundary( "clip_boundary".to_string(), ParameterValue::String(wkt.clone()), ); + + // Compute the seam slit: a thin rectangle at lon_0+180° that splits + // geometries crossing the projection's antimeridian before reprojection. + // Densify the slit's meridian edges for projections with curved meridians. + let center = projection_center(crs); + let seam = wrap_lon(center.0 + 180.0); + let half_width = 0.005; + if (seam - (-180.0)).abs() > half_width && (seam - 180.0).abs() > half_width { + let meridian_segments = match extract_proj_param_str(crs, "+proj=") { + Some("merc") | Some("mill") | Some("eqc") | Some("cea") => 1, + _ => 36, + }; + let slit_wkt = rectangle_wkt( + seam - half_width, -90.0, seam + half_width, 90.0, + [1, meridian_segments, 1, meridian_segments], + ); + projection.computed.insert( + "seam_slit".to_string(), + ParameterValue::String(slit_wkt), + ); + } + let body = format!("SELECT ST_GeomFromText('{wkt}') AS geom"); for stmt in dialect.create_or_replace_temp_table_sql(CLIP_BOUNDARY_TABLE, &[], &body) { execute_query(&stmt)?; } - let projected = dialect.sql_st_transform("geom", source, crs); - let sql = format!("SELECT ST_AsText({projected}) AS wkt FROM {CLIP_BOUNDARY_TABLE}"); + // Project the clip boundary to get the panel boundary shape. + // Apply the seam slit first so each half projects independently. + let panel_geom = if let Some(ParameterValue::String(slit)) = projection.computed.get("seam_slit") { + let split = format!("ST_Difference(geom, ST_GeomFromText('{slit}'))"); + dialect.sql_st_transform(&split, source, crs) + } else { + dialect.sql_st_transform("geom", source, crs) + }; + let sql = format!("SELECT ST_AsText({panel_geom}) AS wkt FROM {CLIP_BOUNDARY_TABLE}"); if let Some(projected_wkt) = query_scalar_string(&sql, execute_query) { projection.computed.insert( "panel_boundary".to_string(), @@ -556,6 +643,7 @@ fn setup_clip_boundary( ); } + let projected = dialect.sql_st_transform("geom", source, crs); let world_bbox_sql = dialect.sql_geometry_bbox(&projected, CLIP_BOUNDARY_TABLE); let world_bbox = execute_query(&world_bbox_sql) .ok() @@ -697,21 +785,12 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< Some("laea") | Some("aeqd") => todo!("full-globe azimuthal visible area"), Some("igh") => Some(igh_outline_wkt()), Some("robin") | Some("moll") | Some("sinu") | Some("eck4") | Some("natearth") => { - Some(rectangle_wkt( - -180.0, - -90.0, - 180.0, - 90.0, - [1, 36, 1, 36], // densify left/right meridian edges only - )) + Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [1, 36, 1, 36])) + } + Some("wintri") | Some("aea") => { + Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [36, 36, 36, 36])) } - Some("wintri") => Some(rectangle_wkt( - -180.0, - -90.0, - 180.0, - 90.0, - [36, 36, 36, 36], // all edges curved - )), + Some("lcc") => Some(rectangle_wkt(-180.0, -80.0, 180.0, 84.0, [36, 36, 36, 36])), Some("merc") => Some(rectangle_wkt(-180.0, -85.0, 180.0, 85.0, [1, 1, 1, 1])), Some("mill") | Some("eqc") | Some("cea") => { Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [1, 1, 1, 1])) @@ -720,6 +799,12 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< } } +/// Wrap a longitude value to [-180, 180]. +fn wrap_lon(lon: f64) -> f64 { + ((lon + 180.0) % 360.0 + 360.0) % 360.0 - 180.0 +} + + fn projection_center(crs: &str) -> (f64, f64) { let lon = extract_proj_param_str(crs, "+lon_0=") .and_then(|s| s.parse().ok()) @@ -732,13 +817,7 @@ fn projection_center(crs: &str) -> (f64, f64) { /// Rectangle WKT with optional edge densification. /// `segments` is `[top, right, bottom, left]`: number of segments per edge. -fn rectangle_wkt( - xmin: f64, - ymin: f64, - xmax: f64, - ymax: f64, - segments: [usize; 4], -) -> String { +fn rectangle_wkt(xmin: f64, ymin: f64, xmax: f64, ymax: f64, segments: [usize; 4]) -> String { let mut coords: Vec = Vec::new(); let [top, right, bottom, left] = segments.map(|s| s.max(1)); for i in 0..top { @@ -815,7 +894,10 @@ fn igh_outline_wkt() -> String { /// edges or interruptions, where corner inverse-projection doesn't recover the /// full visible lon/lat range. fn needs_graticule_clip(proj_name: Option<&str>) -> bool { - !matches!(proj_name, Some("merc") | Some("mill") | Some("eqc") | Some("cea") | None) + !matches!( + proj_name, + Some("merc") | Some("mill") | Some("eqc") | Some("cea") | None + ) } fn extract_proj_param_str<'a>(crs: &'a str, key: &str) -> Option<&'a str> { @@ -1183,6 +1265,28 @@ mod tests { ); } + #[test] + fn test_visible_area_wkt_rectangle_always_polygon() { + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=robin +lon_0=-90".to_string()), + ); + let wkt = visible_area_wkt(&props).unwrap(); + assert!( + wkt.starts_with("POLYGON(("), + "rectangle projections always produce POLYGON: {wkt}" + ); + } + + #[test] + fn test_seam_slit_wkt() { + let crs = "+proj=robin +lon_0=-90"; + let center = projection_center(crs); + let seam = wrap_lon(center.0 + 180.0); + assert!((seam - 90.0).abs() < 1e-6, "seam should be at 90°"); + } + fn bbox(xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> BBox { BBox::from_array([xmin, ymin, xmax, ymax], "EPSG:4326") } @@ -1344,7 +1448,7 @@ mod tests { #[test] fn test_grid_lines_wkt_meridians() { - let wkt = grid_lines_wkt(&[0.0, 30.0], (-90.0, 90.0), 45.0, true); + let wkt = grid_lines_wkt(&[0.0, 30.0], (-90.0, 90.0), 45.0, true, None); assert!(wkt.starts_with("MULTILINESTRING("), "{wkt}"); assert!(wkt.contains("0.000000 -90.000000"), "{wkt}"); assert!(wkt.contains("30.000000 -90.000000"), "{wkt}"); @@ -1354,9 +1458,32 @@ mod tests { #[test] fn test_grid_lines_wkt_parallels() { - let wkt = grid_lines_wkt(&[0.0, 45.0], (-180.0, 180.0), 90.0, false); + let wkt = grid_lines_wkt(&[0.0, 45.0], (-180.0, 180.0), 90.0, false, None); assert!(wkt.starts_with("MULTILINESTRING(")); assert!(wkt.contains("0.000000")); assert!(wkt.contains("45.000000")); } + + #[test] + fn test_grid_lines_wkt_seam_splits_parallels() { + // Seam at 90°: parallels spanning -180..180 should be split into two segments + let wkt = grid_lines_wkt(&[0.0], (-180.0, 180.0), 30.0, false, Some(90.0)); + assert!(wkt.starts_with("MULTILINESTRING(")); + // Should have two linestrings (split at seam) + let line_count = wkt.matches("(").count() - 1; // subtract outer parens + assert!(line_count >= 2, "expected split into 2+ lines, got: {wkt}"); + // First segment should end before seam, second should start after + assert!(wkt.contains("89.99"), "first segment should stop near seam: {wkt}"); + assert!(wkt.contains("90.01"), "second segment should start past seam: {wkt}"); + } + + #[test] + fn test_grid_lines_wkt_seam_skips_meridian() { + // Seam at 90°: a meridian at 90° should be skipped + let wkt = grid_lines_wkt(&[60.0, 90.0, 120.0], (-90.0, 90.0), 45.0, true, Some(90.0)); + assert!(wkt.starts_with("MULTILINESTRING(")); + assert!(wkt.contains("60.000000"), "60° meridian should be present"); + assert!(wkt.contains("120.000000"), "120° meridian should be present"); + assert!(!wkt.contains("90.000000 "), "90° meridian should be skipped: {wkt}"); + } } From 07d7077556427f92fdd6bd945d5131fd8c8e1ce7 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 18 May 2026 14:00:51 +0200 Subject: [PATCH 33/50] Support non-EPSG:4326 source CRS in map projections The clip boundary and graticule are now decoupled from the data's source CRS. The boundary is built in 4326, combined with the seam slit, then transformed to source CRS for layer clipping and to target CRS for the panel background. Graticules are always built and clipped in 4326. Co-Authored-By: Claude Opus 4.6 --- src/lib.rs | 41 +++++++++ src/plot/CLAUDE.md | 2 +- src/plot/layer/geom/mod.rs | 4 +- src/plot/layer/geom/spatial.rs | 56 +++--------- src/plot/projection/coord/map.rs | 148 ++++++++++++++++--------------- src/plot/projection/coord/mod.rs | 4 +- 6 files changed, 137 insertions(+), 118 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b68cd1539..7596f95fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1238,4 +1238,45 @@ mod integration_tests { assert!(!spatial_rows.is_empty()); assert!(spatial_rows.iter().any(|r| !r["geometry"].is_null())); } + + #[cfg(feature = "spatial")] + #[test] + fn test_non_4326_source_to_orthographic() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + // Amsterdam in EPSG:3857: (545977.96, 6867712.83) + // Project to orthographic centered on Amsterdam — the point should survive. + let query = r#" + LOAD spatial; + SELECT ST_Point(545977.96, 6867712.83) AS geometry + VISUALISE + DRAW spatial + PROJECT TO map SETTING + source => 'EPSG:3857', + crs => '+proj=ortho +lon_0=4.90 +lat_0=52.36' + "#; + + let prepared = execute::prepare_data_with_reader(query, &reader).unwrap(); + let writer = VegaLiteWriter::new(); + let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + let data = vl_spec["data"]["values"].as_array().unwrap(); + let layer_key = prepared.specs[0].layers[0].data_key.as_ref().unwrap(); + let spatial_rows: Vec<_> = data + .iter() + .filter(|r| r[naming::SOURCE_COLUMN] == layer_key.as_str()) + .collect(); + assert_eq!(spatial_rows.len(), 1); + let geom = &spatial_rows[0]["geometry"]; + assert!(!geom.is_null(), "Point in source CRS should project successfully"); + + // The projected point should be near (0, 0) in orthographic coords + // since the projection is centered on the same location. + let coords = geom["coordinates"].as_array().unwrap(); + let x = coords[0].as_f64().unwrap(); + let y = coords[1].as_f64().unwrap(); + assert!(x.abs() < 2000.0, "Expected x near 0, got {x}"); + assert!(y.abs() < 2000.0, "Expected y near 0, got {y}"); + } } diff --git a/src/plot/CLAUDE.md b/src/plot/CLAUDE.md index eca8fdbd7..21ed28bcd 100644 --- a/src/plot/CLAUDE.md +++ b/src/plot/CLAUDE.md @@ -70,7 +70,7 @@ Each has a `types.rs` (data structure) and `resolve.rs` (logic that runs during - **`polar`** — radius/angle (for pie/rose plots). - **`map`** — geographic projections via PROJ strings. Implements `apply_projection_transforms` to: detect source CRS from geometry SRID, make clip boundaries, delegate per-layer spatial transforms, materialize projected layers as temp tables, and resolve frame bbox from user bounds / data extent / world extent. Properties: `crs` (PROJ string), `source` (source EPSG), `clip` (bool), `bounds` ([xmin, ymin, xmax, ymax] with null/Inf fallback semantics). -`Projection` (in `types.rs`) wraps `Coord` + resolved aesthetics + properties + a `computed` map populated at execution time (e.g., `clip_boundary`, `panel_boundary`, `frame_bbox`). +`Projection` (in `types.rs`) wraps `Coord` + resolved aesthetics + properties + a `computed` map populated at execution time for the writer (e.g., `panel_boundary`, `frame_bbox`, `graticule_lon`, `graticule_lat`). Docs: [`/doc/syntax/clause/facet.qmd`](../../doc/syntax/clause/facet.qmd), [`/doc/syntax/coord/`](../../doc/syntax/coord/). diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index f53c8c3f7..5ae8a4a84 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -300,6 +300,7 @@ pub trait GeomTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { query: &str, _projection: &Projection, _dialect: &dyn SqlDialect, + _clip: bool, ) -> Result { Ok(query.to_string()) } @@ -569,8 +570,9 @@ impl Geom { query: &str, projection: &Projection, dialect: &dyn SqlDialect, + clip: bool, ) -> Result { - self.0.apply_projection(query, projection, dialect) + self.0.apply_projection(query, projection, dialect, clip) } /// Adjust layer mappings and parameters based on geom-specific logic diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index c835c5cc3..313fa5fe3 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -14,21 +14,12 @@ fn apply_clip_boundary( source: &str, crs: &str, clip_table: &str, - seam_slit: Option<&str>, ) -> String { let clip_geom = format!("(SELECT geom FROM {clip_table})"); let source_esc = source.replace('\'', "''"); let crs_esc = crs.replace('\'', "''"); - let clipped = if let Some(slit_wkt) = seam_slit { - format!( - "ST_Difference(ST_Intersection({col}, {clip_geom}), \ - ST_GeomFromText('{slit_wkt}'))" - ) - } else { - format!("ST_Intersection({col}, {clip_geom})") - }; - + let clipped = format!("ST_Intersection({col}, {clip_geom})"); let geom_expr = format!( "ST_MakeValid(ST_Transform(\ {clipped},\ @@ -94,6 +85,7 @@ impl GeomTrait for Spatial { query: &str, projection: &Projection, dialect: &dyn SqlDialect, + clip: bool, ) -> crate::Result { let col = naming::quote_ident(&naming::aesthetic_column("geometry")); let is_map = projection.coord.coord_kind() == CoordKind::Map; @@ -113,21 +105,13 @@ impl GeomTrait for Spatial { _ => "EPSG:4326", }; - if projection.computed.contains_key("clip_boundary") { - let seam_slit = projection - .computed - .get("seam_slit") - .and_then(|v| match v { - ParameterValue::String(s) => Some(s.as_str()), - _ => None, - }); + if clip { return Ok(apply_clip_boundary( &geom_query, &col, source, crs, CLIP_BOUNDARY_TABLE, - seam_slit, )); } @@ -166,7 +150,7 @@ mod tests { let spatial = Spatial; let projection = Projection::cartesian(); let result = spatial - .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect, false) .unwrap(); assert!(result.contains("ST_AsBinary")); @@ -178,7 +162,7 @@ mod tests { let spatial = Spatial; let projection = Projection::map(); let result = spatial - .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect, false) .unwrap(); // Map without CRS ensures GEOMETRY type for the framing step @@ -195,10 +179,10 @@ mod tests { ParameterValue::String("+proj=merc".to_string()), ); let result = spatial - .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect, false) .unwrap(); - // Without clip_boundary in computed, just ST_Transform + // Without clip=true, just ST_Transform assert!(!result.contains("ST_AsBinary")); assert!(result.contains("ST_Transform")); assert!(result.contains("+proj=merc")); @@ -206,21 +190,15 @@ mod tests { } #[test] - fn test_apply_projection_mercator_with_clip_boundary() { + fn test_apply_projection_mercator_with_clip() { let spatial = Spatial; let mut projection = Projection::map(); projection.properties.insert( "crs".to_string(), ParameterValue::String("+proj=merc".to_string()), ); - projection.computed.insert( - "clip_boundary".to_string(), - ParameterValue::String( - "POLYGON((-180 -85, 180 -85, 180 85, -180 85, -180 -85))".to_string(), - ), - ); let result = spatial - .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect, true) .unwrap(); assert!(result.contains("ST_Intersection")); @@ -229,19 +207,15 @@ mod tests { } #[test] - fn test_orthographic_gets_clip_boundary() { + fn test_orthographic_with_clip() { let spatial = Spatial; let mut projection = Projection::map(); projection.properties.insert( "crs".to_string(), ParameterValue::String("+proj=ortho +lat_0=45 +lon_0=10".to_string()), ); - projection.computed.insert( - "clip_boundary".to_string(), - ParameterValue::String("POLYGON((...))".to_string()), - ); let result = spatial - .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect, true) .unwrap(); assert!(result.contains("ST_Transform")); @@ -252,19 +226,15 @@ mod tests { } #[test] - fn test_gnomonic_gets_clip_boundary() { + fn test_gnomonic_with_clip() { let spatial = Spatial; let mut projection = Projection::map(); projection.properties.insert( "crs".to_string(), ParameterValue::String("+proj=gnom +lat_0=90 +lon_0=0".to_string()), ); - projection.computed.insert( - "clip_boundary".to_string(), - ParameterValue::String("POLYGON((...))".to_string()), - ); let result = spatial - .apply_projection("SELECT * FROM t", &projection, &AnsiDialect) + .apply_projection("SELECT * FROM t", &projection, &AnsiDialect, true) .unwrap(); assert!(result.contains("ST_MakeValid")); diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 5fc132e82..4be28fe4d 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -112,18 +112,19 @@ impl CoordTrait for Map { } // Step 2: Compute the visible area boundary for this projection. - // Azimuthal projections get a hemisphere polygon; cylindrical get a world - // rectangle. This produces: clip_boundary (unprojected WKT), panel_boundary - // (projected WKT for the writer's background layer), and world_bbox (bounding - // box of the full projected visible area, used to resolve Inf in user bounds). - let world_bbox = setup_clip_boundary(projection, &source, &crs, dialect, execute_query)?; + // Produces: clip boundary temp table (in source CRS for layer clipping), + // panel_boundary (projected WKT for the writer's background layer), and + // world_bbox (bounding box of the full projected visible area). + let (world_bbox, boundary_4326) = + setup_clip_boundary(projection, &source, &crs, dialect, execute_query)?; + let clip = boundary_4326.is_some(); // Step 3: Apply per-layer projection (ST_Transform, clip to horizon) for (idx, layer) in layers.iter().enumerate() { layer_queries[idx] = layer .geom - .apply_projection(&layer_queries[idx], projection, dialect)?; + .apply_projection(&layer_queries[idx], projection, dialect, clip)?; } // Step 4: Materialize projected spatial layers as temp tables, compute the @@ -166,12 +167,10 @@ impl CoordTrait for Map { .computed .insert("frame_bbox".to_string(), bbox.as_parameter_value()); - // Step 6: Generate graticule lines (azimuthal and interrupted projections - // need clip-based extent and ST_Intersection; cylindrical projections don't) - let proj_name = extract_proj_param_str(&crs, "+proj="); - let needs_clip = needs_graticule_clip(proj_name); + // Step 6: Generate graticule lines. The graticule is built and clipped + // in EPSG:4326 (independent of source), then projected to target. let (lon_wkt, lat_wkt) = - build_graticule(&bbox, &source, needs_clip, dialect, execute_query)?; + build_graticule(&bbox, boundary_4326.as_deref(), &crs, dialect, execute_query)?; if let Some(wkt) = lon_wkt { projection .computed @@ -325,13 +324,13 @@ impl BBox { /// meridians and parallels, clip and project them, and return projected WKT. fn build_graticule( frame_bbox: &BBox, - source: &str, - has_clip: bool, + clip_boundary_wkt: Option<&str>, + crs: &str, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<(Option, Option)> { - let crs = &frame_bbox.crs; - let Some(geo_bbox) = graticule_bbox(frame_bbox, source, has_clip, dialect, execute_query)? + let Some(geo_bbox) = + graticule_bbox(frame_bbox, clip_boundary_wkt, dialect, execute_query)? else { return Ok((None, None)); }; @@ -355,7 +354,7 @@ fn build_graticule( // Compute the seam meridian (lon_0 + 180°) for non-cylindrical projections. // Graticule lines must not cross this longitude. - let seam = if has_clip { + let seam = if clip_boundary_wkt.is_some() { let center = projection_center(crs); let s = wrap_lon(center.0 + 180.0); Some(s) @@ -406,22 +405,21 @@ fn build_graticule( }; Ok(( - project_graticule_wkt(lon_wkt, has_clip, source, crs, dialect, execute_query)?, - project_graticule_wkt(lat_wkt, has_clip, source, crs, dialect, execute_query)?, + project_graticule_wkt(lon_wkt, clip_boundary_wkt, crs, dialect, execute_query)?, + project_graticule_wkt(lat_wkt, clip_boundary_wkt, crs, dialect, execute_query)?, )) } /// Determine the lon/lat bounding box visible in the current frame by inverse-projecting -/// the bbox corners. Falls back to the clip boundary for azimuthal projections -/// where corners collapse to degenerate values. +/// the bbox corners to EPSG:4326. Falls back to the clip boundary extent for azimuthal +/// projections where corners collapse to degenerate values. fn graticule_bbox( frame_bbox: &BBox, - source: &str, - has_clip: bool, + clip_boundary_wkt: Option<&str>, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result> { - let mut geo_bbox = match frame_bbox.reproject(source, dialect, execute_query) { + let mut geo_bbox = match frame_bbox.reproject("EPSG:4326", dialect, execute_query) { Some(b) => b.clamp(-180.0, -90.0, 180.0, 90.0), None => return Ok(None), }; @@ -429,10 +427,14 @@ fn graticule_bbox( // For azimuthal projections the bbox corners often inverse-project to // degenerate or incomplete values. Use the clip boundary extent which // correctly represents the visible hemisphere. - if has_clip { - let bbox_sql = dialect.sql_geometry_bbox("geom", CLIP_BOUNDARY_TABLE); - if let Ok(df) = execute_query(&bbox_sql) { - if let Some(clip_bbox) = BBox::from_df(&df, source) { + if let Some(wkt) = clip_boundary_wkt { + let sql = format!( + "SELECT ST_XMin(g) AS xmin, ST_YMin(g) AS ymin, \ + ST_XMax(g) AS xmax, ST_YMax(g) AS ymax \ + FROM (SELECT ST_GeomFromText('{wkt}') AS g)" + ); + if let Ok(df) = execute_query(&sql) { + if let Some(clip_bbox) = BBox::from_df(&df, "EPSG:4326") { geo_bbox = clip_bbox; } } @@ -581,61 +583,73 @@ fn query_scalar_string( Some(arr.value(0).to_string()) } -/// Set up the clip/visible area boundary. Creates the clip boundary temp table, -/// projects it into the target CRS, and returns the world bbox (projected extent). -/// For projections where `visible_area_wkt` returns None (e.g. LAEA), generates the -/// panel boundary directly in projected space using ST_Buffer. +/// Set up the clip/visible area boundary. +/// +/// Returns `(world_bbox, boundary_4326_wkt)`: +/// - `world_bbox`: bounding box of the boundary projected into the target CRS +/// - `boundary_4326_wkt`: combined clip+slit boundary in EPSG:4326 (for graticule use) +/// +/// Side effects: +/// - Creates `CLIP_BOUNDARY_TABLE` temp table containing the boundary in source CRS +/// - Stores `panel_boundary` in `projection.computed` (boundary projected to target CRS) fn setup_clip_boundary( projection: &mut super::super::Projection, source: &str, crs: &str, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, -) -> crate::Result> { +) -> crate::Result<(Option, Option)> { let Some(wkt) = visible_area_wkt(&projection.properties) else { - return Ok(None); + return Ok((None, None)); }; - projection.computed.insert( - "clip_boundary".to_string(), - ParameterValue::String(wkt.clone()), - ); - // Compute the seam slit: a thin rectangle at lon_0+180° that splits // geometries crossing the projection's antimeridian before reprojection. // Densify the slit's meridian edges for projections with curved meridians. let center = projection_center(crs); let seam = wrap_lon(center.0 + 180.0); let half_width = 0.005; - if (seam - (-180.0)).abs() > half_width && (seam - 180.0).abs() > half_width { + let slit_wkt = if (seam - (-180.0)).abs() > half_width && (seam - 180.0).abs() > half_width { let meridian_segments = match extract_proj_param_str(crs, "+proj=") { Some("merc") | Some("mill") | Some("eqc") | Some("cea") => 1, _ => 36, }; - let slit_wkt = rectangle_wkt( + Some(rectangle_wkt( seam - half_width, -90.0, seam + half_width, 90.0, [1, meridian_segments, 1, meridian_segments], + )) + } else { + None + }; + + // Combine clip boundary and seam slit into a single polygon in EPSG:4326. + let boundary_4326 = if let Some(slit) = &slit_wkt { + let sql = format!( + "SELECT ST_AsText(ST_Difference(ST_GeomFromText('{wkt}'), ST_GeomFromText('{slit}'))) AS wkt" ); - projection.computed.insert( - "seam_slit".to_string(), - ParameterValue::String(slit_wkt), - ); - } + query_scalar_string(&sql, execute_query).unwrap_or(wkt) + } else { + wkt + }; - let body = format!("SELECT ST_GeomFromText('{wkt}') AS geom"); + // Store the boundary in source CRS for per-layer clipping. + let source_geom = dialect.sql_st_transform( + &format!("ST_GeomFromText('{boundary_4326}')"), + "EPSG:4326", + source, + ); + let body = format!("SELECT {source_geom} AS geom"); for stmt in dialect.create_or_replace_temp_table_sql(CLIP_BOUNDARY_TABLE, &[], &body) { execute_query(&stmt)?; } - // Project the clip boundary to get the panel boundary shape. - // Apply the seam slit first so each half projects independently. - let panel_geom = if let Some(ParameterValue::String(slit)) = projection.computed.get("seam_slit") { - let split = format!("ST_Difference(geom, ST_GeomFromText('{slit}'))"); - dialect.sql_st_transform(&split, source, crs) - } else { - dialect.sql_st_transform("geom", source, crs) - }; - let sql = format!("SELECT ST_AsText({panel_geom}) AS wkt FROM {CLIP_BOUNDARY_TABLE}"); + // Project the boundary to target CRS for the panel background shape. + let panel_geom = dialect.sql_st_transform( + &format!("ST_GeomFromText('{boundary_4326}')"), + "EPSG:4326", + crs, + ); + let sql = format!("SELECT ST_AsText({panel_geom}) AS wkt"); if let Some(projected_wkt) = query_scalar_string(&sql, execute_query) { projection.computed.insert( "panel_boundary".to_string(), @@ -643,36 +657,36 @@ fn setup_clip_boundary( ); } + // World bbox: extent of the boundary in target CRS. let projected = dialect.sql_st_transform("geom", source, crs); let world_bbox_sql = dialect.sql_geometry_bbox(&projected, CLIP_BOUNDARY_TABLE); let world_bbox = execute_query(&world_bbox_sql) .ok() .and_then(|df| BBox::from_df(&df, crs)); - Ok(world_bbox) + Ok((world_bbox, Some(boundary_4326))) } -/// Clip (if needed) and project a WKT geometry string, returning the projected WKT. +/// Clip (if needed) and project a graticule WKT from EPSG:4326 to the target CRS. fn project_graticule_wkt( wkt: Option, - has_clip: bool, - source: &str, + clip_boundary_wkt: Option<&str>, crs: &str, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result> { let Some(wkt) = wkt else { return Ok(None) }; let geom_expr = format!("ST_GeomFromText('{wkt}')"); - let clipped = if has_clip { + let clipped = if let Some(boundary) = clip_boundary_wkt { // ST_CollectionExtract(..., 2) keeps only linestring components, // discarding stray points from vertex-on-boundary intersections. format!( "ST_CollectionExtract(ST_Intersection({geom_expr}, \ - (SELECT geom FROM {CLIP_BOUNDARY_TABLE})), 2)" + ST_GeomFromText('{boundary}')), 2)" ) } else { geom_expr }; - let projected = dialect.sql_st_transform(&clipped, source, crs); + let projected = dialect.sql_st_transform(&clipped, "EPSG:4326", crs); let sql = format!("SELECT ST_AsText({projected}) AS wkt"); Ok(query_scalar_string(&sql, execute_query)) } @@ -889,16 +903,6 @@ fn igh_outline_wkt() -> String { format!("POLYGON(({}))", coords.join(", ")) } -/// Whether graticule generation should use the clip boundary extent rather than -/// inverse-projecting the frame bbox corners. Needed for projections with curved -/// edges or interruptions, where corner inverse-projection doesn't recover the -/// full visible lon/lat range. -fn needs_graticule_clip(proj_name: Option<&str>) -> bool { - !matches!( - proj_name, - Some("merc") | Some("mill") | Some("eqc") | Some("cea") | None - ) -} fn extract_proj_param_str<'a>(crs: &'a str, key: &str) -> Option<&'a str> { let start = crs.find(key)?; diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index 7f3a1ac6f..fce95b470 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -143,7 +143,9 @@ pub trait CoordTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { ) -> crate::Result<()> { for (idx, layer) in layers.iter().enumerate() { layer_queries[idx] = - layer.geom.apply_projection(&layer_queries[idx], projection, dialect)?; + layer + .geom + .apply_projection(&layer_queries[idx], projection, dialect, false)?; } Ok(()) } From e4d3c043e571580ca718bc476f859e9e727c9e02 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 18 May 2026 14:48:08 +0200 Subject: [PATCH 34/50] Infer Map coord from spatial layers and lon/lat aesthetics DRAW spatial without an explicit PROJECT clause now auto-detects the Map coordinate system. Detection works via two signals: GeomType::Spatial in any layer, or lon/lat aesthetics in mappings. Conflicts between coord systems (cartesian/polar/map) produce a clear error listing the clashing systems. Also makes detect_source_srid() validate that all spatial layers share the same SRID (errors on conflict), and supports the "source but no target" case where crs defaults to source for identity projection. Co-Authored-By: Claude Opus 4.6 --- src/execute/mod.rs | 1 - src/lib.rs | 7 +- src/parser/builder.rs | 4 +- src/plot/projection/coord/map.rs | 28 ++++- src/plot/projection/resolve.rs | 202 ++++++++++++++++++++++++++----- 5 files changed, 206 insertions(+), 36 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index efcdef3b1..af734d3a4 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -1369,7 +1369,6 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result Result { } } - // Resolve coord (infer from mappings if not explicit) + // Resolve coord (infer from mappings and layer types if not explicit) // This must happen after parsing but before initialize_aesthetic_context() let layer_mappings: Vec<&Mappings> = spec.layers.iter().map(|l| &l.mappings).collect(); + let layer_geom_types: Vec = spec.layers.iter().map(|l| l.geom.geom_type()).collect(); if let Some(inferred) = resolve_coord( spec.project.as_ref(), &spec.global_mappings, &layer_mappings, + &layer_geom_types, ) .map_err(GgsqlError::ParseError)? { diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 4be28fe4d..78d80b558 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -84,7 +84,7 @@ impl CoordTrait for Map { // Step 1: Detect source CRS from geometry columns if not explicitly set if !projection.properties.contains_key("source") { - if let Some(srid) = detect_source_srid(layers, layer_queries, execute_query) { + if let Some(srid) = detect_source_srid(layers, layer_queries, execute_query)? { projection .properties .insert("source".to_string(), ParameterValue::String(srid)); @@ -97,7 +97,13 @@ impl CoordTrait for Map { }; let crs = match projection.properties.get("crs") { Some(ParameterValue::String(s)) => s.clone(), - _ => return Ok(()), + _ => { + projection.properties.insert( + "crs".to_string(), + ParameterValue::String(source.clone()), + ); + source.clone() + } }; // Validate CRS by attempting a single point transform @@ -743,8 +749,9 @@ fn detect_source_srid( layers: &[Layer], layer_queries: &[String], execute_query: &dyn Fn(&str) -> crate::Result, -) -> Option { +) -> crate::Result> { let geom_col = naming::quote_ident(&naming::aesthetic_column("geometry")); + let mut detected: Option = None; for (idx, layer) in layers.iter().enumerate() { if layer.geom.geom_type() != GeomType::Spatial { @@ -766,12 +773,23 @@ fn detect_source_srid( { let srid = arr.value(0); if srid != 0 { - return Some(format!("EPSG:{srid}")); + let crs = format!("EPSG:{srid}"); + if let Some(ref prev) = detected { + if *prev != crs { + return Err(crate::GgsqlError::ValidationError(format!( + "Spatial layers have conflicting CRS: '{}' vs '{}'. \ + Set PROJECT source to specify which CRS the data is in.", + prev, crs + ))); + } + } else { + detected = Some(crs); + } } } } } - None + Ok(detected) } // --------------------------------------------------------------------------- diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index 6adff9c30..fcfd7896b 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use super::coord::{Coord, CoordKind}; use super::Projection; use crate::plot::aesthetic::{MATERIAL_AESTHETICS, POSITION_SUFFIXES}; +use crate::plot::layer::GeomType; use crate::plot::scale::ScaleTypeKind; use crate::plot::{Mappings, ParameterValue, Scale}; use crate::GgsqlError; @@ -18,13 +19,17 @@ const CARTESIAN_PRIMARIES: &[&str] = &["x", "y"]; /// Polar primary aesthetic names const POLAR_PRIMARIES: &[&str] = &["angle", "radius"]; +/// Map primary aesthetic names +const MAP_PRIMARIES: &[&str] = &["lon", "lat"]; + /// Resolve coordinate system for a Plot /// /// If `project` is `Some`, returns `Ok(None)` (keep existing, no changes needed). -/// If `project` is `None`, infers coord from aesthetic mappings: +/// If `project` is `None`, infers coord from aesthetic mappings and layer types: /// - x/y/xmin/xmax/ymin/ymax → Cartesian /// - angle/radius/anglemin/... → Polar -/// - Both → Error +/// - Any Spatial layer → Map +/// - Both cartesian+polar → Error /// - Neither → Ok(None) (caller should use default Cartesian) /// /// Called early in the pipeline, before AestheticContext construction. @@ -32,36 +37,68 @@ pub fn resolve_coord( project: Option<&Projection>, global_mappings: &Mappings, layer_mappings: &[&Mappings], + layer_geom_types: &[GeomType], ) -> Result, String> { // If project is explicitly specified, keep it as-is if project.is_some() { return Ok(None); } + // Check if any layer is spatial + let mut found_map = layer_geom_types.iter().any(|g| *g == GeomType::Spatial); + // Collect all explicit aesthetic keys from global and layer mappings let mut found_cartesian = false; let mut found_polar = false; // Check global mappings for aesthetic in global_mappings.aesthetics.keys() { - check_aesthetic(aesthetic, &mut found_cartesian, &mut found_polar); + check_aesthetic(aesthetic, &mut found_cartesian, &mut found_polar, &mut found_map); } // Check layer mappings for layer_map in layer_mappings { for aesthetic in layer_map.aesthetics.keys() { - check_aesthetic(aesthetic, &mut found_cartesian, &mut found_polar); + check_aesthetic(aesthetic, &mut found_cartesian, &mut found_polar, &mut found_map); } } - // Determine result - if found_cartesian && found_polar { - return Err( - "Conflicting aesthetics: cannot use both cartesian (x/y) and polar (angle/radius) \ - aesthetics in the same plot. Use PROJECT TO cartesian or PROJECT TO polar to \ - specify the coordinate system explicitly." - .to_string(), - ); + // Determine result — check for conflicts + let conflict_count = [found_cartesian, found_polar, found_map] + .iter() + .filter(|&&v| v) + .count(); + if conflict_count > 1 { + let mut systems = Vec::new(); + if found_cartesian { + systems.push("cartesian (x/y)"); + } + if found_polar { + systems.push("polar (angle/radius)"); + } + if found_map { + systems.push("map (lon/lat)"); + } + return Err(format!( + "Conflicting aesthetics: cannot mix {} aesthetics in the same plot. \ + Use PROJECT TO specify the coordinate system explicitly.", + systems.join(" and "), + )); + } + + if found_map { + let coord = Coord::from_kind(CoordKind::Map); + let aesthetics = coord + .position_aesthetic_names() + .iter() + .map(|s| s.to_string()) + .collect(); + return Ok(Some(Projection { + coord, + aesthetics, + properties: HashMap::new(), + computed: HashMap::new(), + })); } if found_polar { @@ -100,9 +137,14 @@ pub fn resolve_coord( Ok(None) } -/// Check if an aesthetic name indicates cartesian or polar coordinate system. +/// Check if an aesthetic name indicates a coordinate system. /// Updates the found flags accordingly. -fn check_aesthetic(aesthetic: &str, found_cartesian: &mut bool, found_polar: &mut bool) { +fn check_aesthetic( + aesthetic: &str, + found_cartesian: &mut bool, + found_polar: &mut bool, + found_map: &mut bool, +) { // Skip material aesthetics (color, size, etc.) if MATERIAL_AESTHETICS.contains(&aesthetic) { return; @@ -120,6 +162,11 @@ fn check_aesthetic(aesthetic: &str, found_cartesian: &mut bool, found_polar: &mu if POLAR_PRIMARIES.contains(&primary) { *found_polar = true; } + + // Check against map primaries + if MAP_PRIMARIES.contains(&primary) { + *found_map = true; + } } /// Strip position suffix from an aesthetic name. @@ -218,7 +265,7 @@ mod tests { let global = mappings_with(&["angle", "radius"]); // Would infer polar let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(Some(&project), &global, &layers); + let result = resolve_coord(Some(&project), &global, &layers, &[]); assert!(result.is_ok()); assert!(result.unwrap().is_none()); // None means keep existing } @@ -232,7 +279,7 @@ mod tests { let global = mappings_with(&["x", "y"]); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); let inferred = result.unwrap(); assert!(inferred.is_some()); @@ -246,7 +293,7 @@ mod tests { let global = mappings_with(&["xmin", "ymax"]); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); let inferred = result.unwrap(); assert!(inferred.is_some()); @@ -260,7 +307,7 @@ mod tests { let layer = mappings_with(&["x", "y"]); let layers: Vec<&Mappings> = vec![&layer]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); let inferred = result.unwrap(); assert!(inferred.is_some()); @@ -277,7 +324,7 @@ mod tests { let global = mappings_with(&["angle", "radius"]); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); let inferred = result.unwrap(); assert!(inferred.is_some()); @@ -291,7 +338,7 @@ mod tests { let global = mappings_with(&["anglemin", "radiusmax"]); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); let inferred = result.unwrap(); assert!(inferred.is_some()); @@ -305,7 +352,7 @@ mod tests { let layer = mappings_with(&["angle", "radius"]); let layers: Vec<&Mappings> = vec![&layer]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); let inferred = result.unwrap(); assert!(inferred.is_some()); @@ -322,7 +369,7 @@ mod tests { let global = mappings_with(&["color", "size", "fill", "opacity"]); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); assert!(result.unwrap().is_none()); // Neither cartesian nor polar } @@ -332,7 +379,7 @@ mod tests { let global = mappings_with(&["x", "y", "color", "size"]); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); let inferred = result.unwrap(); assert!(inferred.is_some()); @@ -349,7 +396,7 @@ mod tests { let global = mappings_with(&["x", "angle"]); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("Conflicting")); @@ -363,10 +410,49 @@ mod tests { let layer = mappings_with(&["angle"]); let layers: Vec<&Mappings> = vec![&layer]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Conflicting")); + } + + #[test] + fn test_conflict_cartesian_and_map() { + let global = mappings_with(&["x", "lon"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("Conflicting")); + assert!(err.contains("cartesian")); + assert!(err.contains("map")); + } + + #[test] + fn test_conflict_polar_and_map() { + let global = mappings_with(&["angle", "lat"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers, &[]); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Conflicting")); + assert!(err.contains("polar")); + assert!(err.contains("map")); + } + + #[test] + fn test_conflict_cartesian_and_spatial_layer() { + let global = mappings_with(&["x", "y"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers, &[GeomType::Spatial]); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Conflicting")); + assert!(err.contains("cartesian")); + assert!(err.contains("map")); } // ======================================== @@ -378,7 +464,7 @@ mod tests { let global = Mappings::new(); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); assert!(result.unwrap().is_none()); } @@ -393,7 +479,7 @@ mod tests { global.insert("angle", AestheticValue::standard_column("cat")); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); let inferred = result.unwrap(); assert!(inferred.is_some()); @@ -406,11 +492,71 @@ mod tests { let global = Mappings::with_wildcard(); let layers: Vec<&Mappings> = vec![]; - let result = resolve_coord(None, &global, &layers); + let result = resolve_coord(None, &global, &layers, &[]); assert!(result.is_ok()); assert!(result.unwrap().is_none()); // Wildcard alone doesn't infer coord } + // ======================================== + // Test: Spatial layer infers Map + // ======================================== + + #[test] + fn test_infer_map_from_spatial_layer() { + let global = Mappings::new(); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers, &[GeomType::Spatial]); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Map); + assert_eq!(proj.aesthetics, vec!["lon", "lat"]); + } + + #[test] + fn test_infer_map_from_spatial_among_other_layers() { + let global = Mappings::new(); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord( + None, + &global, + &layers, + &[GeomType::Spatial, GeomType::Point], + ); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Map); + } + + #[test] + fn test_infer_map_from_lon_lat_aesthetics() { + let global = mappings_with(&["lon", "lat"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers, &[]); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Map); + } + + #[test] + fn test_explicit_project_overrides_spatial() { + let project = Projection::cartesian(); + let global = Mappings::new(); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(Some(&project), &global, &layers, &[GeomType::Spatial]); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + // ======================================== // Test: Helper functions // ======================================== From 8d45138bfeb0763ba75483873c49cdfe797dcc77 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 18 May 2026 16:26:09 +0200 Subject: [PATCH 35/50] Shift IGH interruption slits with lon_0 The Goode Homolosine projection has interruption meridians that shift when lon_0 is set. Replace the old hardcoded outline polygon (which had narrow notches that never actually split data) with a proper MULTIPOLYGON slit that gets subtracted via ST_Difference, matching the existing seam-slit mechanism used by other projections. Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/coord/map.rs | 191 ++++++++++++++++++------------- 1 file changed, 114 insertions(+), 77 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 78d80b558..1d9cfc5a0 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -98,10 +98,9 @@ impl CoordTrait for Map { let crs = match projection.properties.get("crs") { Some(ParameterValue::String(s)) => s.clone(), _ => { - projection.properties.insert( - "crs".to_string(), - ParameterValue::String(source.clone()), - ); + projection + .properties + .insert("crs".to_string(), ParameterValue::String(source.clone())); source.clone() } }; @@ -175,8 +174,13 @@ impl CoordTrait for Map { // Step 6: Generate graticule lines. The graticule is built and clipped // in EPSG:4326 (independent of source), then projected to target. - let (lon_wkt, lat_wkt) = - build_graticule(&bbox, boundary_4326.as_deref(), &crs, dialect, execute_query)?; + let (lon_wkt, lat_wkt) = build_graticule( + &bbox, + boundary_4326.as_deref(), + &crs, + dialect, + execute_query, + )?; if let Some(wkt) = lon_wkt { projection .computed @@ -335,8 +339,7 @@ fn build_graticule( dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<(Option, Option)> { - let Some(geo_bbox) = - graticule_bbox(frame_bbox, clip_boundary_wkt, dialect, execute_query)? + let Some(geo_bbox) = graticule_bbox(frame_bbox, clip_boundary_wkt, dialect, execute_query)? else { return Ok((None, None)); }; @@ -609,26 +612,35 @@ fn setup_clip_boundary( return Ok((None, None)); }; - // Compute the seam slit: a thin rectangle at lon_0+180° that splits - // geometries crossing the projection's antimeridian before reprojection. - // Densify the slit's meridian edges for projections with curved meridians. + // Compute the seam slit that splits geometries at projection discontinuities. + // For most projections: a single rectangle at lon_0+180° (the antimeridian). + // For IGH: a MULTIPOLYGON combining all interruption slits. let center = projection_center(crs); let seam = wrap_lon(center.0 + 180.0); let half_width = 0.005; - let slit_wkt = if (seam - (-180.0)).abs() > half_width && (seam - 180.0).abs() > half_width { - let meridian_segments = match extract_proj_param_str(crs, "+proj=") { - Some("merc") | Some("mill") | Some("eqc") | Some("cea") => 1, - _ => 36, - }; - Some(rectangle_wkt( - seam - half_width, -90.0, seam + half_width, 90.0, - [1, meridian_segments, 1, meridian_segments], - )) - } else { - None + + let slit_wkt: Option = match extract_proj_param_str(crs, "+proj=") { + Some("igh") => Some(igh_slit_wkt(center.0, half_width)), + _ => { + if (seam - (-180.0)).abs() > half_width && (seam - 180.0).abs() > half_width { + let meridian_segments = match extract_proj_param_str(crs, "+proj=") { + Some("merc") | Some("mill") | Some("eqc") | Some("cea") => 1, + _ => 36, + }; + Some(rectangle_wkt( + seam - half_width, + -90.0, + seam + half_width, + 90.0, + [1, meridian_segments, 1, meridian_segments], + )) + } else { + None + } + } }; - // Combine clip boundary and seam slit into a single polygon in EPSG:4326. + // Combine clip boundary and seam slit into a single geometry in EPSG:4326. let boundary_4326 = if let Some(slit) = &slit_wkt { let sql = format!( "SELECT ST_AsText(ST_Difference(ST_GeomFromText('{wkt}'), ST_GeomFromText('{slit}'))) AS wkt" @@ -815,7 +827,7 @@ pub fn visible_area_wkt(properties: &HashMap) -> Option< Some("ortho") | Some("stere") => Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)), Some("gnom") => Some(hemisphere_polygon_wkt(center.0, center.1, 60.0)), Some("laea") | Some("aeqd") => todo!("full-globe azimuthal visible area"), - Some("igh") => Some(igh_outline_wkt()), + Some("igh") => Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [1, 36, 1, 36])), Some("robin") | Some("moll") | Some("sinu") | Some("eck4") | Some("natearth") => { Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [1, 36, 1, 36])) } @@ -836,7 +848,6 @@ fn wrap_lon(lon: f64) -> f64 { ((lon + 180.0) % 360.0 + 360.0) % 360.0 - 180.0 } - fn projection_center(crs: &str) -> (f64, f64) { let lon = extract_proj_param_str(crs, "+lon_0=") .and_then(|s| s.parse().ok()) @@ -872,56 +883,53 @@ fn rectangle_wkt(xmin: f64, ymin: f64, xmax: f64, ymax: f64, segments: [usize; 4 format!("POLYGON(({}))", coords.join(", ")) } -/// Interrupted Goode Homolosine outline polygon with densified meridian edges. -/// Interrupts: -40° (north), -100°/-20°/80° (south). The outline traces -/// vertical slits at these meridians with 1° spacing for smooth projection. -fn igh_outline_wkt() -> String { - let mut coords: Vec = Vec::new(); - - // Helper: densified meridian segment from lat_start to lat_end at fixed lon - let meridian = |coords: &mut Vec, lon: f64, lat_start: f64, lat_end: f64| { - let step = if lat_end > lat_start { 5.0 } else { -5.0 }; - let n = ((lat_end - lat_start) / step).abs() as usize; - for i in 0..n { - let lat = lat_start + step * i as f64; - coords.push(format!("{lon:.2} {lat:.2}")); - } - }; - - // Counter-clockwise ring matching the R/sf approach: - // Start top-right (180,90), down east edge, trace bottom east→west with - // southern slits, up west edge, trace top west→east with northern slit. - - // East edge: (180, 90) down to (180, -90) - meridian(&mut coords, 180.0, 90.0, -90.0); - - // Bottom edge east→west with southern slits at 80°, -20°, -100° - coords.push("80.01 -90".to_string()); - meridian(&mut coords, 80.01, -90.0, 0.0); - meridian(&mut coords, 79.99, 0.0, -90.0); - coords.push("-19.99 -90".to_string()); - meridian(&mut coords, -19.99, -90.0, 0.0); - meridian(&mut coords, -20.01, 0.0, -90.0); - coords.push("-99.99 -90".to_string()); - meridian(&mut coords, -99.99, -90.0, 0.0); - meridian(&mut coords, -100.01, 0.0, -90.0); - coords.push("-180 -90".to_string()); - - // West edge: (-180, -90) up to (-180, 90) - meridian(&mut coords, -180.0, -90.0, 90.0); - - // Top edge west→east with northern slit at -40° - coords.push("-40.01 90".to_string()); - meridian(&mut coords, -40.01, 90.0, 0.0); - meridian(&mut coords, -39.99, 0.0, 90.0); - - // Close ring - coords.push("180 90".to_string()); - - format!("POLYGON(({}))", coords.join(", ")) +/// Single MULTIPOLYGON combining all IGH interruption slits. +/// Each slit is a half-height rectangle (equator to pole) at the interruption, +/// plus a full-height slit at the antimeridian (lon_0 + 180°) if not at ±180°. +/// Single MULTIPOLYGON WKT combining all IGH interruption slits. +fn igh_slit_wkt(lon_0: f64, half_width: f64) -> String { + let segs = [1, 36, 1, 36]; + let polygon_ring = |wkt: String| -> String { wkt.strip_prefix("POLYGON").unwrap().to_string() }; + + let mut parts = Vec::new(); + + // Southern interruptions: from equator to south pole + for offset in [80.0, -20.0, -100.0] { + let lon = wrap_lon(lon_0 + offset); + parts.push(polygon_ring(rectangle_wkt( + lon - half_width, + -90.0, + lon + half_width, + 0.0, + segs, + ))); + } + + // Northern interruption: from equator to north pole + let north = wrap_lon(lon_0 - 40.0); + parts.push(polygon_ring(rectangle_wkt( + north - half_width, + 0.0, + north + half_width, + 90.0, + segs, + ))); + + // Antimeridian slit (full height) if not at ±180° + let seam = wrap_lon(lon_0 + 180.0); + if (seam - (-180.0)).abs() > half_width && (seam - 180.0).abs() > half_width { + parts.push(polygon_ring(rectangle_wkt( + seam - half_width, + -90.0, + seam + half_width, + 90.0, + segs, + ))); + } + + format!("MULTIPOLYGON({})", parts.join(", ")) } - fn extract_proj_param_str<'a>(crs: &'a str, key: &str) -> Option<&'a str> { let start = crs.find(key)?; let after = &crs[start + key.len()..]; @@ -1486,6 +1494,23 @@ mod tests { assert!(wkt.contains("45.000000")); } + #[test] + fn test_igh_slit_wkt_shift_with_lon_0() { + let wkt0 = igh_slit_wkt(0.0, 0.005); + assert!(wkt0.starts_with("MULTIPOLYGON("), "{wkt0}"); + // lon_0=0: 4 polygon parts (no antimeridian since seam at ±180°) + assert_eq!(wkt0.matches("((").count(), 4, "{wkt0}"); + + let wkt90 = igh_slit_wkt(90.0, 0.005); + // lon_0=90: 5 parts (adds antimeridian at -90°) + assert_eq!(wkt90.matches("((").count(), 5, "{wkt90}"); + // South slits shifted to 170°, 70°, -10°; north to 50° + assert!(wkt90.contains("170.005"), "south slit near 170°: {wkt90}"); + assert!(wkt90.contains("70.005"), "south slit near 70°: {wkt90}"); + assert!(wkt90.contains("-10.005"), "south slit near -10°: {wkt90}"); + assert!(wkt90.contains("50.005"), "north slit near 50°: {wkt90}"); + } + #[test] fn test_grid_lines_wkt_seam_splits_parallels() { // Seam at 90°: parallels spanning -180..180 should be split into two segments @@ -1495,8 +1520,14 @@ mod tests { let line_count = wkt.matches("(").count() - 1; // subtract outer parens assert!(line_count >= 2, "expected split into 2+ lines, got: {wkt}"); // First segment should end before seam, second should start after - assert!(wkt.contains("89.99"), "first segment should stop near seam: {wkt}"); - assert!(wkt.contains("90.01"), "second segment should start past seam: {wkt}"); + assert!( + wkt.contains("89.99"), + "first segment should stop near seam: {wkt}" + ); + assert!( + wkt.contains("90.01"), + "second segment should start past seam: {wkt}" + ); } #[test] @@ -1505,7 +1536,13 @@ mod tests { let wkt = grid_lines_wkt(&[60.0, 90.0, 120.0], (-90.0, 90.0), 45.0, true, Some(90.0)); assert!(wkt.starts_with("MULTILINESTRING(")); assert!(wkt.contains("60.000000"), "60° meridian should be present"); - assert!(wkt.contains("120.000000"), "120° meridian should be present"); - assert!(!wkt.contains("90.000000 "), "90° meridian should be skipped: {wkt}"); + assert!( + wkt.contains("120.000000"), + "120° meridian should be present" + ); + assert!( + !wkt.contains("90.000000 "), + "90° meridian should be skipped: {wkt}" + ); } } From 2a92336fa92da3d5062b4b14282f21ee444ffa44 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 19 May 2026 13:34:03 +0200 Subject: [PATCH 36/50] Project non-spatial point and text layers through the map CRS When point or text layers use lon/lat aesthetics in a Map coordinate system with a target CRS, their position columns are now projected via ST_Transform rather than passed through in degree-space. Co-Authored-By: Claude Opus 4.6 --- src/plot/layer/geom/mod.rs | 41 +++++++++++++++++++++++++++++++- src/plot/layer/geom/point.rs | 16 ++++++++++++- src/plot/layer/geom/text.rs | 16 +++++++++++-- src/plot/projection/coord/map.rs | 36 +++++++++++++++++++++------- 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 5ae8a4a84..4141027e7 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -21,7 +21,7 @@ //! ``` use crate::plot::types::DefaultAestheticValue; -use crate::{DataFrame, Mappings, Result}; +use crate::{naming, DataFrame, Mappings, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -332,6 +332,45 @@ pub trait GeomTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { } } +/// Project pos1/pos2 columns through the map CRS transform. +/// +/// When the coordinate system is Map with a CRS, wraps the position columns +/// with ST_X/ST_Y(ST_Transform(ST_Point(pos1, pos2), source, crs)). Returns +/// the query unchanged for non-map coords or when source == crs. +pub(crate) fn project_position_columns( + query: &str, + projection: &Projection, + dialect: &dyn SqlDialect, +) -> Result { + use crate::plot::projection::coord::CoordKind; + + if projection.coord.coord_kind() != CoordKind::Map { + return Ok(query.to_string()); + } + let crs = match projection.properties.get("crs") { + Some(ParameterValue::String(s)) => s.as_str(), + _ => return Ok(query.to_string()), + }; + let source = match projection.properties.get("source") { + Some(ParameterValue::String(s)) => s.as_str(), + _ => "EPSG:4326", + }; + if source == crs { + return Ok(query.to_string()); + } + + let pos1 = naming::quote_ident(&naming::aesthetic_column("pos1")); + let pos2 = naming::quote_ident(&naming::aesthetic_column("pos2")); + let point_expr = format!("ST_Point({pos1}, {pos2})"); + let transformed = dialect.sql_st_transform(&point_expr, source, crs); + let proj_col = naming::quote_ident("__ggsql_proj_pt__"); + + Ok(format!( + "SELECT * REPLACE (ST_X({proj_col}) AS {pos1}, ST_Y({proj_col}) AS {pos2}) \ + FROM (SELECT *, {transformed} AS {proj_col} FROM ({query}))" + )) +} + /// True when `parameters["aggregate"]` is set to a non-null string or array. pub(crate) fn has_aggregate_param(parameters: &HashMap) -> bool { matches!( diff --git a/src/plot/layer/geom/point.rs b/src/plot/layer/geom/point.rs index 3e9c55a6c..d812e4dc8 100644 --- a/src/plot/layer/geom/point.rs +++ b/src/plot/layer/geom/point.rs @@ -2,9 +2,13 @@ use super::types::POSITION_VALUES; use super::{ - DefaultAesthetics, DefaultParamValue, GeomTrait, GeomType, ParamConstraint, ParamDefinition, + project_position_columns, DefaultAesthetics, DefaultParamValue, GeomTrait, GeomType, + ParamConstraint, ParamDefinition, }; +use crate::plot::projection::Projection; use crate::plot::types::DefaultAestheticValue; +use crate::reader::SqlDialect; +use crate::Result; /// Point geom - scatter plots and similar #[derive(Debug, Clone, Copy)] @@ -50,6 +54,16 @@ impl GeomTrait for Point { fn aggregate_domain_aesthetics(&self) -> Option<&'static [&'static str]> { Some(&[]) } + + fn apply_projection( + &self, + query: &str, + projection: &Projection, + dialect: &dyn SqlDialect, + _clip: bool, + ) -> Result { + project_position_columns(query, projection, dialect) + } } impl std::fmt::Display for Point { diff --git a/src/plot/layer/geom/text.rs b/src/plot/layer/geom/text.rs index ae71ca55e..cd9974b41 100644 --- a/src/plot/layer/geom/text.rs +++ b/src/plot/layer/geom/text.rs @@ -2,11 +2,13 @@ use super::types::POSITION_VALUES; use super::{ - DefaultAesthetics, DefaultParamValue, GeomTrait, GeomType, ParamConstraint, ParamDefinition, - ParameterValue, + project_position_columns, DefaultAesthetics, DefaultParamValue, GeomTrait, GeomType, + ParamConstraint, ParamDefinition, ParameterValue, }; +use crate::plot::projection::Projection; use crate::plot::types::DefaultAestheticValue; use crate::plot::{ArrayConstraint, NumberConstraint}; +use crate::reader::SqlDialect; use crate::{naming, DataFrame, Result}; use std::collections::HashMap; @@ -68,6 +70,16 @@ impl GeomTrait for Text { Some(&[]) } + fn apply_projection( + &self, + query: &str, + projection: &Projection, + dialect: &dyn SqlDialect, + _clip: bool, + ) -> Result { + project_position_columns(query, projection, dialect) + } + fn post_process( &self, df: DataFrame, diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 1d9cfc5a0..131c49e6e 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -132,36 +132,54 @@ impl CoordTrait for Map { .apply_projection(&layer_queries[idx], projection, dialect, clip)?; } - // Step 4: Materialize projected spatial layers as temp tables, compute the - // data bbox for framing, then convert geometry to WKB for Arrow transport. + // Step 4: Materialize projected layers as temp tables, compute data bbox, + // then rewrite layer queries to read from those tables. let geom_col = naming::aesthetic_column("geometry"); let geom_col_quoted = naming::quote_ident(&geom_col); + let pos1_col = naming::quote_ident(&naming::aesthetic_column("pos1")); + let pos2_col = naming::quote_ident(&naming::aesthetic_column("pos2")); let bounds_param = projection.properties.get("bounds"); let mut computed_bbox: Option = None; for (idx, layer) in layers.iter().enumerate() { - if layer.geom.geom_type() != GeomType::Spatial { + let is_spatial = layer.geom.geom_type() == GeomType::Spatial; + let has_projected_positions = !is_spatial + && source != crs + && layer.mappings.contains_key("pos1") + && layer.mappings.contains_key("pos2"); + + if !is_spatial && !has_projected_positions { continue; } + let table_name = format!("{}_proj", naming::layer_key(idx)); for stmt in dialect.create_or_replace_temp_table_sql(&table_name, &[], &layer_queries[idx]) { execute_query(&stmt)?; } - let table_quoted = naming::quote_ident(&table_name); if needs_computed_bbox(bounds_param) { - let sql = dialect.sql_geometry_bbox(&geom_col_quoted, &table_quoted); - if let Ok(df) = execute_query(&sql) { + let bbox_sql = if is_spatial { + dialect.sql_geometry_bbox(&geom_col_quoted, &table_quoted) + } else { + format!( + "SELECT MIN({pos1_col}), MIN({pos2_col}), \ + MAX({pos1_col}), MAX({pos2_col}) FROM {table_quoted}" + ) + }; + if let Ok(df) = execute_query(&bbox_sql) { computed_bbox = BBox::merge(computed_bbox, BBox::from_df(&df, &crs))?; } } - let wkb_expr = dialect.sql_geometry_to_wkb(&geom_col_quoted); - layer_queries[idx] = - format!("SELECT * REPLACE ({wkb_expr} AS {geom_col_quoted}) FROM {table_quoted}"); + layer_queries[idx] = if is_spatial { + let wkb_expr = dialect.sql_geometry_to_wkb(&geom_col_quoted); + format!("SELECT * REPLACE ({wkb_expr} AS {geom_col_quoted}) FROM {table_quoted}") + } else { + format!("SELECT * FROM {table_quoted}") + }; } // Step 5: Resolve final frame bbox from user bounds + data bounds + world bounds From dd5067816b06ea5aab4808560533dc466bc1d6ad Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 19 May 2026 13:53:20 +0200 Subject: [PATCH 37/50] Use longitude/latitude encoding channels for map projection layers Non-spatial layers in a map coordinate system now emit Vega-Lite longitude/latitude channels instead of x/y. This routes their projected coordinates through VL's projection system (identity + scale/translate) rather than raw pixel space. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/mod.rs | 20 ++++++++++++++------ src/writer/vegalite/projection/map.rs | 15 +++++++++------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 0e931a4e5..5d8ec03c8 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -308,9 +308,14 @@ fn build_layer_encoding( channel_name = "fillOpacity".to_string(); } - // Secondary position channels (x2, y2, theta2, radius2) only support - // field/datum/value in Vega-Lite — not type, scale, axis, or title - if matches!(channel_name.as_str(), "x2" | "y2" | "theta2" | "radius2") { + // Secondary position channels (x2, y2, theta2, radius2) and geographic + // channels (longitude, latitude) only support field/datum/value in + // Vega-Lite — not type, scale, axis, or title. + if matches!( + channel_name.as_str(), + "x2" | "y2" | "theta2" | "radius2" | "longitude" | "latitude" + | "longitude2" | "latitude2" + ) { let secondary_encoding = match value { AestheticValue::Column { name: col, .. } => json!({"field": col}), AestheticValue::Literal(lit) => json!({"value": lit.to_json()}), @@ -383,9 +388,12 @@ fn build_layer_encoding( // This prevents Vega-Lite from applying its own stack/dodge logic on top of ours. // Only set stack: null on primary position channels (y/radius) — Vega-Lite does // not support 'stack' on secondary channels (y2/radius2) and Altair rejects it. - if let Some(y_enc) = encoding.get_mut(pos2.as_str()) { - if let Some(obj) = y_enc.as_object_mut() { - obj.insert("stack".to_string(), Value::Null); + // Geographic channels (latitude) don't support stack either. + if !matches!(pos2.as_str(), "latitude") { + if let Some(y_enc) = encoding.get_mut(pos2.as_str()) { + if let Some(obj) = y_enc.as_object_mut() { + obj.insert("stack".to_string(), Value::Null); + } } } diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs index 537be7b4e..b37f9df09 100644 --- a/src/writer/vegalite/projection/map.rs +++ b/src/writer/vegalite/projection/map.rs @@ -123,11 +123,11 @@ impl ProjectionRenderer for MapProjection { } fn position_channels(&self) -> (&'static str, &'static str) { - ("x", "y") + ("longitude", "latitude") } fn offset_channels(&self) -> (&'static str, &'static str) { - ("xOffset", "yOffset") + ("longitude", "latitude") } fn transform_layers(&self, _spec: &Plot, vl_spec: &mut Value) -> Result<()> { @@ -210,10 +210,13 @@ mod tests { #[test] fn test_map_projection_channels() { let renderer = MapProjection::new(None, None); - assert_eq!(renderer.position_channels(), ("x", "y")); - assert_eq!(renderer.offset_channels(), ("xOffset", "yOffset")); - assert_eq!(renderer.map_position("pos1"), Some("x".to_string())); - assert_eq!(renderer.map_position("pos2"), Some("y".to_string())); + assert_eq!(renderer.position_channels(), ("longitude", "latitude")); + assert_eq!(renderer.offset_channels(), ("longitude", "latitude")); + assert_eq!( + renderer.map_position("pos1"), + Some("longitude".to_string()) + ); + assert_eq!(renderer.map_position("pos2"), Some("latitude".to_string())); } #[test] From 3ba38c2040927ba2c2f056f1941dbcd0f981fe6f Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 19 May 2026 14:22:43 +0200 Subject: [PATCH 38/50] cargo fmt --- src/lib.rs | 15 ++++++++++++--- src/parser/builder.rs | 5 ++++- src/plot/layer/geom/spatial.rs | 4 +--- src/plot/projection/coord/mod.rs | 9 +++++++-- src/plot/projection/resolve.rs | 14 ++++++++++++-- src/reader/mod.rs | 1 - src/writer/vegalite/mod.rs | 9 +++++++-- src/writer/vegalite/projection/map.rs | 5 +---- src/writer/vegalite/projection/mod.rs | 6 ++++-- src/writer/vegalite/projection/polar.rs | 1 - 10 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 9b2ea17ba..38833fa56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1080,8 +1080,14 @@ mod integration_tests { "all visible features should have valid geometry" ); - assert!(!vl_spec["projection"]["scale"].is_null(), "projection.scale should be present"); - assert!(!vl_spec["projection"]["translate"].is_null(), "projection.translate should be present"); + assert!( + !vl_spec["projection"]["scale"].is_null(), + "projection.scale should be present" + ); + assert!( + !vl_spec["projection"]["translate"].is_null(), + "projection.translate should be present" + ); } #[cfg(feature = "spatial")] @@ -1274,7 +1280,10 @@ mod integration_tests { .collect(); assert_eq!(spatial_rows.len(), 1); let geom = &spatial_rows[0]["geometry"]; - assert!(!geom.is_null(), "Point in source CRS should project successfully"); + assert!( + !geom.is_null(), + "Point in source CRS should project successfully" + ); // The projected point should be near (0, 0) in orthographic coords // since the projection is centered on the same location. diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 64a2f18a6..1eb908c4f 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1118,7 +1118,10 @@ fn parse_coord_system( let map_proj = |proj: &str| -> Result<(Coord, Vec<(String, ParameterValue)>)> { Ok(( Coord::map(), - vec![("crs".to_string(), ParameterValue::String(format!("+proj={proj}")))], + vec![( + "crs".to_string(), + ParameterValue::String(format!("+proj={proj}")), + )], )) }; match text.to_lowercase().as_str() { diff --git a/src/plot/layer/geom/spatial.rs b/src/plot/layer/geom/spatial.rs index 313fa5fe3..08252efd9 100644 --- a/src/plot/layer/geom/spatial.rs +++ b/src/plot/layer/geom/spatial.rs @@ -93,9 +93,7 @@ impl GeomTrait for Spatial { // WORKAROUND(duckdb-rs#714): normalize column to GEOMETRY since it may // be WKB BLOB from the Arrow export workaround. let ensure_geom = dialect.sql_ensure_geometry(&col); - let geom_query = format!( - "SELECT * REPLACE ({ensure_geom} AS {col}) FROM ({query})" - ); + let geom_query = format!("SELECT * REPLACE ({ensure_geom} AS {col}) FROM ({query})"); let geom_expr = if let (true, Some(ParameterValue::String(crs))) = (is_map, projection.properties.get("crs")) diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index fce95b470..60811de86 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -225,8 +225,13 @@ impl Coord { dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<()> { - self.0 - .apply_projection_transforms(layers, layer_queries, projection, dialect, execute_query) + self.0.apply_projection_transforms( + layers, + layer_queries, + projection, + dialect, + execute_query, + ) } } diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index fcfd7896b..a98f3409c 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -53,13 +53,23 @@ pub fn resolve_coord( // Check global mappings for aesthetic in global_mappings.aesthetics.keys() { - check_aesthetic(aesthetic, &mut found_cartesian, &mut found_polar, &mut found_map); + check_aesthetic( + aesthetic, + &mut found_cartesian, + &mut found_polar, + &mut found_map, + ); } // Check layer mappings for layer_map in layer_mappings { for aesthetic in layer_map.aesthetics.keys() { - check_aesthetic(aesthetic, &mut found_cartesian, &mut found_polar, &mut found_map); + check_aesthetic( + aesthetic, + &mut found_cartesian, + &mut found_polar, + &mut found_map, + ); } } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index c185f585c..e8376bc6b 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -132,7 +132,6 @@ pub trait SqlDialect { format!("ST_GeomFromWKB(CAST({column} AS BLOB))") } - /// SQL expression to transform a geometry from one CRS to another. /// /// Default uses `ST_Transform(column, source_crs, target_crs)` which works for DuckDB. diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 5d8ec03c8..ceb85a0d2 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -313,8 +313,13 @@ fn build_layer_encoding( // Vega-Lite — not type, scale, axis, or title. if matches!( channel_name.as_str(), - "x2" | "y2" | "theta2" | "radius2" | "longitude" | "latitude" - | "longitude2" | "latitude2" + "x2" | "y2" + | "theta2" + | "radius2" + | "longitude" + | "latitude" + | "longitude2" + | "latitude2" ) { let secondary_encoding = match value { AestheticValue::Column { name: col, .. } => json!({"field": col}), diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs index b37f9df09..ff6c53035 100644 --- a/src/writer/vegalite/projection/map.rs +++ b/src/writer/vegalite/projection/map.rs @@ -212,10 +212,7 @@ mod tests { let renderer = MapProjection::new(None, None); assert_eq!(renderer.position_channels(), ("longitude", "latitude")); assert_eq!(renderer.offset_channels(), ("longitude", "latitude")); - assert_eq!( - renderer.map_position("pos1"), - Some("longitude".to_string()) - ); + assert_eq!(renderer.map_position("pos1"), Some("longitude".to_string())); assert_eq!(renderer.map_position("pos2"), Some("latitude".to_string())); } diff --git a/src/writer/vegalite/projection/mod.rs b/src/writer/vegalite/projection/mod.rs index 0763a5832..cad3afbcb 100644 --- a/src/writer/vegalite/projection/mod.rs +++ b/src/writer/vegalite/projection/mod.rs @@ -140,7 +140,6 @@ pub(super) fn get_projection_renderer( } } - // ============================================================================= // AxisInfo — reusable across projection types // ============================================================================= @@ -236,7 +235,10 @@ mod tests { let renderer = PolarProjection::new(None, None, &[]); assert_eq!(renderer.map_position("pos1"), Some("radius".to_string())); assert_eq!(renderer.map_position("pos2"), Some("theta".to_string())); - assert_eq!(renderer.map_position("pos1end"), Some("radius2".to_string())); + assert_eq!( + renderer.map_position("pos1end"), + Some("radius2".to_string()) + ); assert_eq!(renderer.map_position("pos2end"), Some("theta2".to_string())); assert_eq!(renderer.offset_channels(), ("radiusOffset", "thetaOffset")); assert_eq!( diff --git a/src/writer/vegalite/projection/polar.rs b/src/writer/vegalite/projection/polar.rs index a9ccb3061..328202b88 100644 --- a/src/writer/vegalite/projection/polar.rs +++ b/src/writer/vegalite/projection/polar.rs @@ -18,7 +18,6 @@ const POLAR_OUTER: f64 = 1.0; /// `1 - paddingInner` for band scales, which is ~0.9). const POLAR_BAND_FRACTION: f64 = 0.9; - /// Resolved geometry and scale context for polar specs. /// /// Holds angular range, radius bounds, VL expression strings for the panel From 17f4c414db3d81262dfc6243fc439c91317307eb Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 20 May 2026 15:34:05 +0200 Subject: [PATCH 39/50] Extract map projection dispatch into MapSpecification trait Replaces match-arm dispatch in map.rs with a trait-based system: each projection is a struct implementing MapProjectionTrait, wrapped in Arc via MapSpecification. Constructed at parse time (from coord type name or explicit crs property) and stored on Projection. Co-Authored-By: Claude Opus 4.6 --- src/execute/scale.rs | 1 + src/parser/builder.rs | 93 +- src/plot/main.rs | 2 + src/plot/projection/coord/map.rs | 510 +------- src/plot/projection/coord/map_projections.rs | 1121 ++++++++++++++++++ src/plot/projection/coord/mod.rs | 2 + src/plot/projection/resolve.rs | 3 + src/plot/projection/types.rs | 6 +- src/writer/vegalite/mod.rs | 1 + 9 files changed, 1193 insertions(+), 546 deletions(-) create mode 100644 src/plot/projection/coord/map_projections.rs diff --git a/src/execute/scale.rs b/src/execute/scale.rs index dd176026f..18bd0b1c8 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -1723,6 +1723,7 @@ mod tests { coord, aesthetics, properties: std::collections::HashMap::new(), + map_projection: None, computed: std::collections::HashMap::new(), }); diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 1eb908c4f..a780c93c3 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -4,6 +4,7 @@ //! handling all the node types defined in the grammar. use crate::plot::layer::geom::Geom; +use crate::plot::projection::coord::map_projections::MapSpecification; use crate::plot::projection::resolve_coord; use crate::plot::scale::{color_to_hex, is_color_aesthetic, is_user_facet_aesthetic, Transform}; use crate::plot::*; @@ -971,6 +972,7 @@ fn parse_facet_vars(node: &Node, source: &SourceTree) -> Result> { /// Aesthetics are optional and default to the coord's standard names. fn build_project(node: &Node, source: &SourceTree) -> Result { let mut coord = Coord::cartesian(); + let mut coord_type_name: Option = None; let mut properties = HashMap::new(); let mut user_aesthetics: Option> = None; @@ -983,11 +985,9 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { user_aesthetics = Some(source.find_texts(&child, query)); } "project_type" => { - let (parsed_coord, implied_properties) = parse_coord_system(&child, source)?; - coord = parsed_coord; - for (name, value) in implied_properties { - properties.entry(name).or_insert(value); - } + let text = source.get_text(&child).to_lowercase(); + coord = parse_coord_system(&text)?; + coord_type_name = Some(text); } "project_properties" => { // Find all project_property nodes @@ -1033,10 +1033,28 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { // Validate properties for this coord type validate_project_properties(&coord, &properties)?; + let map_projection = if let Some(ParameterValue::String(crs)) = properties.get("crs") { + Some(MapSpecification::from_proj_str(crs)) + } else { + coord_type_name + .as_deref() + .and_then(MapSpecification::from_coord_name) + }; + + if let Some(ref spec) = map_projection { + let proj_str = spec.to_proj_str(); + if !proj_str.is_empty() { + properties + .entry("crs".to_string()) + .or_insert_with(|| ParameterValue::String(proj_str)); + } + } + Ok(Projection { coord, aesthetics, properties, + map_projection, computed: HashMap::new(), }) } @@ -1108,47 +1126,32 @@ fn validate_project_properties( Ok(()) } -/// Parse coord type from a project_type node. -/// Returns the coord and any pre-populated properties implied by the shorthand. -fn parse_coord_system( - node: &Node, - source: &SourceTree, -) -> Result<(Coord, Vec<(String, ParameterValue)>)> { - let text = source.get_text(node); - let map_proj = |proj: &str| -> Result<(Coord, Vec<(String, ParameterValue)>)> { - Ok(( - Coord::map(), - vec![( - "crs".to_string(), - ParameterValue::String(format!("+proj={proj}")), - )], - )) - }; - match text.to_lowercase().as_str() { - "cartesian" => Ok((Coord::cartesian(), Vec::new())), - "polar" => Ok((Coord::polar(), Vec::new())), - "map" => Ok((Coord::map(), Vec::new())), - "mercator" => map_proj("merc"), - "orthographic" => map_proj("ortho"), - "miller" => map_proj("mill"), - "equirectangular" => map_proj("eqc"), - "stereographic" => map_proj("stere"), - "gnomonic" => map_proj("gnom"), - "equal_area" => map_proj("cea"), - "mollweide" => map_proj("moll"), - "sinusoidal" => map_proj("sinu"), - "eckert4" => map_proj("eck4"), - "natural" => map_proj("natearth"), - "winkel_tripel" => map_proj("wintri"), - "albers" => map_proj("aea +lat_1=29.5 +lat_2=45.5"), - "lambert_conformal" => map_proj("lcc +lat_1=29.5 +lat_2=45.5"), - "lambert" => map_proj("laea"), - "azimuthal_equidistant" => map_proj("aeqd"), - "igh" => map_proj("igh"), - "robinson" => map_proj("robin"), +fn parse_coord_system(name: &str) -> Result { + match name { + "cartesian" => Ok(Coord::cartesian()), + "polar" => Ok(Coord::polar()), + "map" + | "mercator" + | "orthographic" + | "miller" + | "equirectangular" + | "stereographic" + | "gnomonic" + | "equal_area" + | "mollweide" + | "sinusoidal" + | "eckert4" + | "natural" + | "winkel_tripel" + | "albers" + | "lambert_conformal" + | "lambert" + | "azimuthal_equidistant" + | "igh" + | "robinson" => Ok(Coord::map()), _ => Err(GgsqlError::ParseError(format!( "Unknown coord type: {}", - text + name ))), } } @@ -1428,7 +1431,7 @@ mod tests { assert_eq!(project.coord.coord_kind(), CoordKind::Map); assert_eq!( project.properties.get("crs"), - Some(&ParameterValue::String("+proj=merc".to_string())) + Some(&ParameterValue::String("+proj=merc +lon_0=0".to_string())) ); } diff --git a/src/plot/main.rs b/src/plot/main.rs index 3f78da465..2fd6502f6 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -774,6 +774,7 @@ mod tests { coord: Coord::cartesian(), aesthetics: vec!["y".to_string(), "x".to_string()], properties: HashMap::new(), + map_projection: None, computed: HashMap::new(), }); spec.labels = Some(Labels { @@ -805,6 +806,7 @@ mod tests { coord: Coord::polar(), aesthetics: vec!["angle".to_string(), "radius".to_string()], properties: HashMap::new(), + map_projection: None, computed: HashMap::new(), }); spec.labels = Some(Labels { diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 131c49e6e..b3eeb0cc1 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -1,8 +1,6 @@ //! Map coordinate system implementation -use std::collections::HashMap; - -use super::{CoordKind, CoordTrait}; +use super::{map_projections, CoordKind, CoordTrait}; use crate::naming; use crate::plot::layer::geom::GeomType; use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition, TypeConstraint}; @@ -382,9 +380,10 @@ fn build_graticule( // Compute the seam meridian (lon_0 + 180°) for non-cylindrical projections. // Graticule lines must not cross this longitude. let seam = if clip_boundary_wkt.is_some() { - let center = projection_center(crs); - let s = wrap_lon(center.0 + 180.0); - Some(s) + let lon_0 = map_projections::extract_proj_param_str(crs, "+lon_0=") + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0); + Some(map_projections::wrap_lon(lon_0 + 180.0)) } else { None }; @@ -626,37 +625,15 @@ fn setup_clip_boundary( dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<(Option, Option)> { - let Some(wkt) = visible_area_wkt(&projection.properties) else { + let Some(map_proj) = projection.map_projection.as_ref() else { + return Ok((None, None)); + }; + let Some(wkt) = map_proj.visible_area_wkt() else { return Ok((None, None)); }; - // Compute the seam slit that splits geometries at projection discontinuities. - // For most projections: a single rectangle at lon_0+180° (the antimeridian). - // For IGH: a MULTIPOLYGON combining all interruption slits. - let center = projection_center(crs); - let seam = wrap_lon(center.0 + 180.0); let half_width = 0.005; - - let slit_wkt: Option = match extract_proj_param_str(crs, "+proj=") { - Some("igh") => Some(igh_slit_wkt(center.0, half_width)), - _ => { - if (seam - (-180.0)).abs() > half_width && (seam - 180.0).abs() > half_width { - let meridian_segments = match extract_proj_param_str(crs, "+proj=") { - Some("merc") | Some("mill") | Some("eqc") | Some("cea") => 1, - _ => 36, - }; - Some(rectangle_wkt( - seam - half_width, - -90.0, - seam + half_width, - 90.0, - [1, meridian_segments, 1, meridian_segments], - )) - } else { - None - } - } - }; + let slit_wkt = map_proj.slit_wkt(half_width); // Combine clip boundary and seam slit into a single geometry in EPSG:4326. let boundary_4326 = if let Some(slit) = &slit_wkt { @@ -822,346 +799,6 @@ fn detect_source_srid( Ok(detected) } -// --------------------------------------------------------------------------- -// Visible area / horizon clipping -// --------------------------------------------------------------------------- - -/// Returns a WKT POLYGON representing the valid visible area for the given projection. -/// -/// - Azimuthal projections (orthographic, gnomonic): a 72-vertex haversine boundary at -/// 88° great-circle radius from the projection center. Geometry beyond this boundary -/// produces degenerate output after `ST_Transform`. -/// - Cylindrical projections (mercator): a rectangle at ±180° longitude, ±85° latitude -/// (the Mercator singularity is at ±85.05°). -/// - Returns `None` if no CRS is set (no projection to apply). -pub fn visible_area_wkt(properties: &HashMap) -> Option { - let crs = match properties.get("crs") { - Some(ParameterValue::String(s)) => s, - _ => return None, - }; - - let center = projection_center(crs); - match extract_proj_param_str(crs, "+proj=") { - Some("ortho") | Some("stere") => Some(hemisphere_polygon_wkt(center.0, center.1, 88.0)), - Some("gnom") => Some(hemisphere_polygon_wkt(center.0, center.1, 60.0)), - Some("laea") | Some("aeqd") => todo!("full-globe azimuthal visible area"), - Some("igh") => Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [1, 36, 1, 36])), - Some("robin") | Some("moll") | Some("sinu") | Some("eck4") | Some("natearth") => { - Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [1, 36, 1, 36])) - } - Some("wintri") | Some("aea") => { - Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [36, 36, 36, 36])) - } - Some("lcc") => Some(rectangle_wkt(-180.0, -80.0, 180.0, 84.0, [36, 36, 36, 36])), - Some("merc") => Some(rectangle_wkt(-180.0, -85.0, 180.0, 85.0, [1, 1, 1, 1])), - Some("mill") | Some("eqc") | Some("cea") => { - Some(rectangle_wkt(-180.0, -90.0, 180.0, 90.0, [1, 1, 1, 1])) - } - _ => None, - } -} - -/// Wrap a longitude value to [-180, 180]. -fn wrap_lon(lon: f64) -> f64 { - ((lon + 180.0) % 360.0 + 360.0) % 360.0 - 180.0 -} - -fn projection_center(crs: &str) -> (f64, f64) { - let lon = extract_proj_param_str(crs, "+lon_0=") - .and_then(|s| s.parse().ok()) - .unwrap_or(0.0); - let lat = extract_proj_param_str(crs, "+lat_0=") - .and_then(|s| s.parse().ok()) - .unwrap_or(0.0); - (lon, lat) -} - -/// Rectangle WKT with optional edge densification. -/// `segments` is `[top, right, bottom, left]`: number of segments per edge. -fn rectangle_wkt(xmin: f64, ymin: f64, xmax: f64, ymax: f64, segments: [usize; 4]) -> String { - let mut coords: Vec = Vec::new(); - let [top, right, bottom, left] = segments.map(|s| s.max(1)); - for i in 0..top { - let t = i as f64 / top as f64; - coords.push(format!("{:.6} {:.6}", xmin + t * (xmax - xmin), ymax)); - } - for i in 0..right { - let t = i as f64 / right as f64; - coords.push(format!("{:.6} {:.6}", xmax, ymax - t * (ymax - ymin))); - } - for i in 0..bottom { - let t = i as f64 / bottom as f64; - coords.push(format!("{:.6} {:.6}", xmax - t * (xmax - xmin), ymin)); - } - for i in 0..left { - let t = i as f64 / left as f64; - coords.push(format!("{:.6} {:.6}", xmin, ymin + t * (ymax - ymin))); - } - coords.push(format!("{:.6} {:.6}", xmin, ymax)); - format!("POLYGON(({}))", coords.join(", ")) -} - -/// Single MULTIPOLYGON combining all IGH interruption slits. -/// Each slit is a half-height rectangle (equator to pole) at the interruption, -/// plus a full-height slit at the antimeridian (lon_0 + 180°) if not at ±180°. -/// Single MULTIPOLYGON WKT combining all IGH interruption slits. -fn igh_slit_wkt(lon_0: f64, half_width: f64) -> String { - let segs = [1, 36, 1, 36]; - let polygon_ring = |wkt: String| -> String { wkt.strip_prefix("POLYGON").unwrap().to_string() }; - - let mut parts = Vec::new(); - - // Southern interruptions: from equator to south pole - for offset in [80.0, -20.0, -100.0] { - let lon = wrap_lon(lon_0 + offset); - parts.push(polygon_ring(rectangle_wkt( - lon - half_width, - -90.0, - lon + half_width, - 0.0, - segs, - ))); - } - - // Northern interruption: from equator to north pole - let north = wrap_lon(lon_0 - 40.0); - parts.push(polygon_ring(rectangle_wkt( - north - half_width, - 0.0, - north + half_width, - 90.0, - segs, - ))); - - // Antimeridian slit (full height) if not at ±180° - let seam = wrap_lon(lon_0 + 180.0); - if (seam - (-180.0)).abs() > half_width && (seam - 180.0).abs() > half_width { - parts.push(polygon_ring(rectangle_wkt( - seam - half_width, - -90.0, - seam + half_width, - 90.0, - segs, - ))); - } - - format!("MULTIPOLYGON({})", parts.join(", ")) -} - -fn extract_proj_param_str<'a>(crs: &'a str, key: &str) -> Option<&'a str> { - let start = crs.find(key)?; - let after = &crs[start + key.len()..]; - let end = after.find([' ', '+']).unwrap_or(after.len()); - Some(&after[..end]) -} - -/// Haversine boundary polygon at `radius_deg` from `(lon0, lat0)`, as WKT. -/// Returns a POLYGON when the ring doesn't cross the antimeridian, or a -/// MULTIPOLYGON split at ±180° when it does. -fn hemisphere_polygon_wkt(lon0: f64, lat0: f64, radius_deg: f64) -> String { - let d = radius_deg.to_radians(); - let lat0_r = lat0.to_radians(); - let sin_lat0 = lat0_r.sin(); - let cos_lat0 = lat0_r.cos(); - let sin_d = d.sin(); - let cos_d = d.cos(); - - let n_points = 72; - let mut raw_points: Vec<(f64, f64)> = Vec::with_capacity(n_points); - for i in 0..n_points { - let az = (i as f64 * (360.0 / n_points as f64)).to_radians(); - let lat2 = (sin_lat0 * cos_d + cos_lat0 * sin_d * az.cos()).asin(); - let lon2 = - lon0.to_radians() + (az.sin() * sin_d * cos_lat0).atan2(cos_d - sin_lat0 * lat2.sin()); - let mut lon_deg = lon2.to_degrees(); - // Normalize to [-180, 180] - lon_deg = ((lon_deg + 180.0) % 360.0 + 360.0) % 360.0 - 180.0; - raw_points.push((lon_deg, lat2.to_degrees())); - } - - // Insert exact antimeridian vertices where consecutive points cross ±180°. - // Uses 179.999999 to avoid ambiguity while placing vertices at the boundary. - let mut points: Vec<(f64, f64)> = Vec::with_capacity(n_points + 4); - for i in 0..raw_points.len() { - points.push(raw_points[i]); - let next = (i + 1) % raw_points.len(); - if (raw_points[next].0 - raw_points[i].0).abs() > 180.0 { - let lat = antimeridian_crossing_lat(raw_points[i], raw_points[next]); - if raw_points[i].0 > 0.0 { - points.push((179.999999, lat)); - points.push((-179.999999, lat)); - } else { - points.push((-179.999999, lat)); - points.push((179.999999, lat)); - } - } - } - - let includes_north_pole = lat0 + radius_deg > 90.0; - let includes_south_pole = lat0 - radius_deg < -90.0; - - if includes_north_pole || includes_south_pole { - build_pole_polygon(&points, includes_north_pole) - } else if find_antimeridian_crossings(&points).len() == 2 { - build_antimeridian_multipolygon(&points) - } else { - build_simple_polygon(&points) - } -} - -fn build_simple_polygon(points: &[(f64, f64)]) -> String { - let mut coords: Vec = points - .iter() - .map(|(lon, lat)| format!("{lon:.6} {lat:.6}")) - .collect(); - coords.push(coords[0].clone()); - format!("POLYGON(({}))", coords.join(", ")) -} - -/// Routes the ring through ±90° latitude to close around a pole. -fn build_pole_polygon(points: &[(f64, f64)], north: bool) -> String { - let mut split_idx = 0; - let mut max_jump = 0.0_f64; - for i in 0..points.len() { - let next = (i + 1) % points.len(); - let jump = (points[next].0 - points[i].0).abs(); - if jump > max_jump { - max_jump = jump; - split_idx = next; - } - } - - let mut ordered: Vec<(f64, f64)> = Vec::with_capacity(points.len()); - for i in 0..points.len() { - ordered.push(points[(split_idx + i) % points.len()]); - } - - let pole_lat = if north { 90.0 } else { -90.0 }; - let first = ordered.first().unwrap(); - let last = ordered.last().unwrap(); - - let mut coords: Vec = Vec::with_capacity(points.len() + 6); - for (lon, lat) in &ordered { - coords.push(format!("{lon:.6} {lat:.6}")); - } - coords.push(format!("{:.6} {pole_lat:.6}", last.0)); - // If the closure would jump > 180° in longitude, add an intermediate - // vertex so no single edge crosses the antimeridian. - if (last.0 - first.0).abs() > 180.0 { - let mid = (last.0 + first.0) / 2.0; - coords.push(format!("{mid:.6} {pole_lat:.6}")); - } - coords.push(format!("{:.6} {pole_lat:.6}", first.0)); - coords.push(format!("{:.6} {:.6}", first.0, first.1)); - - format!("POLYGON(({}))", coords.join(", ")) -} - -fn find_antimeridian_crossings(points: &[(f64, f64)]) -> Vec { - let n = points.len(); - let mut crossings = Vec::new(); - for i in 0..n { - let next = (i + 1) % n; - if (points[next].0 - points[i].0).abs() > 180.0 { - crossings.push(i); - } - } - crossings -} - -/// Splits the boundary ring into two polygons at the antimeridian (±180°). -/// Each sub-polygon closes by tracing the antimeridian between its two crossing latitudes. -fn build_antimeridian_multipolygon(points: &[(f64, f64)]) -> String { - let n = points.len(); - let crossings = find_antimeridian_crossings(points); - assert_eq!(crossings.len(), 2); - - let c1 = crossings[0]; - let c2 = crossings[1]; - - let lat_c1 = antimeridian_crossing_lat(points[c1], points[(c1 + 1) % n]); - let lat_c2 = antimeridian_crossing_lat(points[c2], points[(c2 + 1) % n]); - - let (east_arc, west_arc, [east_start_lat, east_end_lat, west_start_lat, west_end_lat]) = - split_arcs_at_crossings(points, c1, c2, lat_c1, lat_c2); - - let east_coords = build_side_ring(&east_arc, 180.0, east_start_lat, east_end_lat); - let west_coords = build_side_ring(&west_arc, -180.0, west_start_lat, west_end_lat); - - format!( - "MULTIPOLYGON((({})),(({})))", - east_coords.join(", "), - west_coords.join(", ") - ) -} - -/// Split the ring at two crossing indices into east/west arcs with their boundary latitudes. -type ArcSplit = (Vec<(f64, f64)>, Vec<(f64, f64)>, [f64; 4]); - -fn split_arcs_at_crossings( - points: &[(f64, f64)], - c1: usize, - c2: usize, - lat_c1: f64, - lat_c2: f64, -) -> ArcSplit { - let n = points.len(); - - let mut arc1: Vec<(f64, f64)> = Vec::new(); - let mut i = (c1 + 1) % n; - loop { - arc1.push(points[i]); - if i == c2 { - break; - } - i = (i + 1) % n; - } - - let mut arc2: Vec<(f64, f64)> = Vec::new(); - i = (c2 + 1) % n; - loop { - arc2.push(points[i]); - if i == c1 { - break; - } - i = (i + 1) % n; - } - - if arc1[0].0 > 0.0 { - (arc1, arc2, [lat_c1, lat_c2, lat_c2, lat_c1]) - } else { - (arc2, arc1, [lat_c2, lat_c1, lat_c1, lat_c2]) - } -} - -fn build_side_ring( - arc: &[(f64, f64)], - meridian_lon: f64, - start_lat: f64, - end_lat: f64, -) -> Vec { - let mut coords: Vec = Vec::with_capacity(arc.len() + 3); - coords.push(format!("{meridian_lon:.6} {start_lat:.6}")); - for (lon, lat) in arc.iter() { - coords.push(format!("{lon:.6} {lat:.6}")); - } - coords.push(format!("{meridian_lon:.6} {end_lat:.6}")); - coords.push(coords[0].clone()); - coords -} - -fn antimeridian_crossing_lat(a: (f64, f64), b: (f64, f64)) -> f64 { - let (lon_a, lat_a) = a; - let (lon_b, lat_b) = b; - let (lon_a_u, lon_b_u) = if lon_a > lon_b { - (lon_a, lon_b + 360.0) - } else { - (lon_a + 360.0, lon_b) - }; - let t = (180.0 - lon_a_u) / (lon_b_u - lon_a_u); - lat_a + t * (lat_b - lat_a) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1225,116 +862,6 @@ mod tests { assert!(err.contains("not 'unknown'")); } - #[test] - fn test_visible_area_wkt_orthographic() { - let mut props = HashMap::new(); - props.insert( - "crs".to_string(), - ParameterValue::String("+proj=ortho +lat_0=45 +lon_0=10".to_string()), - ); - let wkt = visible_area_wkt(&props); - assert!(wkt.is_some()); - let wkt = wkt.unwrap(); - assert!(wkt.starts_with("POLYGON((")); - assert!(wkt.ends_with("))")); - } - - #[test] - fn test_visible_area_wkt_gnomonic() { - let mut props = HashMap::new(); - props.insert( - "crs".to_string(), - ParameterValue::String("+proj=gnom +lat_0=90 +lon_0=0".to_string()), - ); - assert!(visible_area_wkt(&props).is_some()); - } - - #[test] - fn test_visible_area_wkt_mercator_returns_rectangle() { - let mut props = HashMap::new(); - props.insert( - "crs".to_string(), - ParameterValue::String("+proj=merc".to_string()), - ); - let wkt = visible_area_wkt(&props); - assert!(wkt.is_some()); - let wkt = wkt.unwrap(); - assert!(wkt.starts_with("POLYGON((")); - assert!(wkt.contains("-180") && wkt.contains("180")); - assert!(wkt.contains("-85") && wkt.contains("85")); - } - - #[test] - fn test_visible_area_wkt_no_crs_returns_none() { - let props = HashMap::new(); - assert!(visible_area_wkt(&props).is_none()); - } - - #[test] - fn test_visible_area_wkt_antimeridian_crossing() { - let mut props = HashMap::new(); - props.insert( - "crs".to_string(), - ParameterValue::String("+proj=ortho +lat_0=0 +lon_0=150".to_string()), - ); - let wkt = visible_area_wkt(&props).unwrap(); - assert!( - wkt.starts_with("MULTIPOLYGON"), - "lon_0=150 should cross antimeridian: {wkt}" - ); - } - - #[test] - fn test_visible_area_wkt_no_antimeridian_for_centered() { - let mut props = HashMap::new(); - props.insert( - "crs".to_string(), - ParameterValue::String("+proj=ortho +lat_0=0 +lon_0=0".to_string()), - ); - let wkt = visible_area_wkt(&props).unwrap(); - assert!( - wkt.starts_with("POLYGON(("), - "lon_0=0 should not cross antimeridian: {wkt}" - ); - } - - #[test] - fn test_visible_area_wkt_pole_and_antimeridian() { - let mut props = HashMap::new(); - props.insert( - "crs".to_string(), - ParameterValue::String("+proj=ortho +lat_0=52.36 +lon_0=150.90".to_string()), - ); - let wkt = visible_area_wkt(&props).unwrap(); - // Includes north pole (52.36 + 88 > 90), pole-routing produces a POLYGON. - assert!( - wkt.starts_with("POLYGON(("), - "pole case should produce POLYGON: {wkt}" - ); - } - - #[test] - fn test_visible_area_wkt_rectangle_always_polygon() { - let mut props = HashMap::new(); - props.insert( - "crs".to_string(), - ParameterValue::String("+proj=robin +lon_0=-90".to_string()), - ); - let wkt = visible_area_wkt(&props).unwrap(); - assert!( - wkt.starts_with("POLYGON(("), - "rectangle projections always produce POLYGON: {wkt}" - ); - } - - #[test] - fn test_seam_slit_wkt() { - let crs = "+proj=robin +lon_0=-90"; - let center = projection_center(crs); - let seam = wrap_lon(center.0 + 180.0); - assert!((seam - 90.0).abs() < 1e-6, "seam should be at 90°"); - } - fn bbox(xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> BBox { BBox::from_array([xmin, ymin, xmax, ymax], "EPSG:4326") } @@ -1512,23 +1039,6 @@ mod tests { assert!(wkt.contains("45.000000")); } - #[test] - fn test_igh_slit_wkt_shift_with_lon_0() { - let wkt0 = igh_slit_wkt(0.0, 0.005); - assert!(wkt0.starts_with("MULTIPOLYGON("), "{wkt0}"); - // lon_0=0: 4 polygon parts (no antimeridian since seam at ±180°) - assert_eq!(wkt0.matches("((").count(), 4, "{wkt0}"); - - let wkt90 = igh_slit_wkt(90.0, 0.005); - // lon_0=90: 5 parts (adds antimeridian at -90°) - assert_eq!(wkt90.matches("((").count(), 5, "{wkt90}"); - // South slits shifted to 170°, 70°, -10°; north to 50° - assert!(wkt90.contains("170.005"), "south slit near 170°: {wkt90}"); - assert!(wkt90.contains("70.005"), "south slit near 70°: {wkt90}"); - assert!(wkt90.contains("-10.005"), "south slit near -10°: {wkt90}"); - assert!(wkt90.contains("50.005"), "north slit near 50°: {wkt90}"); - } - #[test] fn test_grid_lines_wkt_seam_splits_parallels() { // Seam at 90°: parallels spanning -180..180 should be split into two segments diff --git a/src/plot/projection/coord/map_projections.rs b/src/plot/projection/coord/map_projections.rs new file mode 100644 index 000000000..1d031712f --- /dev/null +++ b/src/plot/projection/coord/map_projections.rs @@ -0,0 +1,1121 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::sync::Arc; + +// ============================================================================= +// Trait +// ============================================================================= + +pub trait MapProjectionTrait: fmt::Debug + Send + Sync { + fn proj_code(&self) -> &'static str; + fn display_name(&self) -> &'static str; + fn center(&self) -> (f64, f64); + fn to_proj_str(&self) -> String; + + fn visible_area_wkt(&self) -> Option { + Some(self.clip_shape_wkt()) + } + + fn clip_shape_wkt(&self) -> String { + rectangle_wkt( + -180.0, + self.lat_bounds().0, + 180.0, + self.lat_bounds().1, + self.edge_segments(), + ) + } + + fn lat_bounds(&self) -> (f64, f64) { + (-90.0, 90.0) + } + + fn edge_segments(&self) -> [usize; 4] { + [1, 36, 1, 36] + } + + fn slit_wkt(&self, epsilon: f64) -> Option { + let seam = wrap_lon(self.center().0 + 180.0); + if (seam - (-180.0)).abs() > epsilon && (seam - 180.0).abs() > epsilon { + let segs = self.edge_segments()[1]; + Some(rectangle_wkt( + seam - epsilon, + -90.0, + seam + epsilon, + 90.0, + [1, segs, 1, segs], + )) + } else { + None + } + } +} + +// ============================================================================= +// Wrapper +// ============================================================================= + +#[derive(Clone)] +pub struct MapSpecification(Arc); + +impl MapSpecification { + pub fn from_coord_name(name: &str) -> Option { + let obj: Arc = match name { + "map" => Arc::new(UnknownProj { + raw: String::new(), + lon_0: 0.0, + lat_0: 0.0, + }), + "mercator" => Arc::new(Mercator { lon_0: 0.0 }), + "orthographic" => Arc::new(Orthographic { + lon_0: 0.0, + lat_0: 0.0, + }), + "miller" => Arc::new(Miller { lon_0: 0.0 }), + "equirectangular" => Arc::new(Equirectangular { lon_0: 0.0 }), + "stereographic" => Arc::new(Stereographic { + lon_0: 0.0, + lat_0: 0.0, + }), + "gnomonic" => Arc::new(Gnomonic { + lon_0: 0.0, + lat_0: 0.0, + }), + "equal_area" => Arc::new(CylindricalEqualArea { lon_0: 0.0 }), + "mollweide" => Arc::new(Mollweide { lon_0: 0.0 }), + "sinusoidal" => Arc::new(Sinusoidal { lon_0: 0.0 }), + "eckert4" => Arc::new(Eckert4 { lon_0: 0.0 }), + "natural" => Arc::new(NaturalEarth { lon_0: 0.0 }), + "winkel_tripel" => Arc::new(WinkelTripel { lon_0: 0.0 }), + "albers" => Arc::new(AlbersEqualArea { + lon_0: 0.0, + lat_0: 0.0, + lat_1: 29.5, + lat_2: 45.5, + }), + "lambert_conformal" => Arc::new(LambertConformalConic { + lon_0: 0.0, + lat_0: 0.0, + lat_1: 29.5, + lat_2: 45.5, + }), + "lambert" => Arc::new(LambertAzimuthal { + lon_0: 0.0, + lat_0: 0.0, + }), + "azimuthal_equidistant" => Arc::new(AzimuthalEquidistant { + lon_0: 0.0, + lat_0: 0.0, + }), + "igh" => Arc::new(Igh { lon_0: 0.0 }), + "robinson" => Arc::new(Robinson { lon_0: 0.0 }), + _ => return None, + }; + Some(Self(obj)) + } + + pub fn from_proj_str(crs: &str) -> Self { + let code = extract_proj_param_str(crs, "+proj=").unwrap_or(""); + let lon_0 = extract_f64_param(crs, "+lon_0=").unwrap_or(0.0); + let lat_0 = extract_f64_param(crs, "+lat_0=").unwrap_or(0.0); + + let obj: Arc = match code { + "ortho" => Arc::new(Orthographic { lon_0, lat_0 }), + "stere" => Arc::new(Stereographic { lon_0, lat_0 }), + "gnom" => Arc::new(Gnomonic { lon_0, lat_0 }), + "laea" => Arc::new(LambertAzimuthal { lon_0, lat_0 }), + "aeqd" => Arc::new(AzimuthalEquidistant { lon_0, lat_0 }), + "merc" => Arc::new(Mercator { lon_0 }), + "mill" => Arc::new(Miller { lon_0 }), + "eqc" => Arc::new(Equirectangular { lon_0 }), + "cea" => Arc::new(CylindricalEqualArea { lon_0 }), + "robin" => Arc::new(Robinson { lon_0 }), + "moll" => Arc::new(Mollweide { lon_0 }), + "sinu" => Arc::new(Sinusoidal { lon_0 }), + "eck4" => Arc::new(Eckert4 { lon_0 }), + "natearth" => Arc::new(NaturalEarth { lon_0 }), + "igh" => Arc::new(Igh { lon_0 }), + "wintri" => Arc::new(WinkelTripel { lon_0 }), + "aea" => Arc::new(AlbersEqualArea { + lon_0, + lat_0, + lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), + lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), + }), + "lcc" => Arc::new(LambertConformalConic { + lon_0, + lat_0, + lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), + lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), + }), + _ => Arc::new(UnknownProj { + raw: crs.to_string(), + lon_0, + lat_0, + }), + }; + Self(obj) + } + + pub fn proj_code(&self) -> &'static str { + self.0.proj_code() + } + + pub fn display_name(&self) -> &'static str { + self.0.display_name() + } + + pub fn center(&self) -> (f64, f64) { + self.0.center() + } + + pub fn to_proj_str(&self) -> String { + self.0.to_proj_str() + } + + pub fn visible_area_wkt(&self) -> Option { + self.0.visible_area_wkt() + } + + pub fn slit_wkt(&self, epsilon: f64) -> Option { + self.0.slit_wkt(epsilon) + } +} + +impl fmt::Debug for MapSpecification { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "MapSpecification({})", self.0.to_proj_str()) + } +} + +impl fmt::Display for MapSpecification { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.display_name()) + } +} + +impl PartialEq for MapSpecification { + fn eq(&self, other: &Self) -> bool { + self.0.to_proj_str() == other.0.to_proj_str() + } +} + +impl Serialize for MapSpecification { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.to_proj_str().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for MapSpecification { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(MapSpecification::from_proj_str(&s)) + } +} + +// ============================================================================= +// Azimuthal projections +// ============================================================================= + +#[derive(Debug, Clone)] +pub struct Orthographic { + pub lon_0: f64, + pub lat_0: f64, +} + +impl MapProjectionTrait for Orthographic { + fn proj_code(&self) -> &'static str { + "ortho" + } + fn display_name(&self) -> &'static str { + "Orthographic" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, self.lat_0) + } + fn to_proj_str(&self) -> String { + format!("+proj=ortho +lon_0={} +lat_0={}", self.lon_0, self.lat_0) + } + fn clip_shape_wkt(&self) -> String { + hemisphere_polygon_wkt(self.lon_0, self.lat_0, 88.0) + } +} + +#[derive(Debug, Clone)] +pub struct Stereographic { + pub lon_0: f64, + pub lat_0: f64, +} + +impl MapProjectionTrait for Stereographic { + fn proj_code(&self) -> &'static str { + "stere" + } + fn display_name(&self) -> &'static str { + "Stereographic" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, self.lat_0) + } + fn to_proj_str(&self) -> String { + format!("+proj=stere +lon_0={} +lat_0={}", self.lon_0, self.lat_0) + } + fn clip_shape_wkt(&self) -> String { + hemisphere_polygon_wkt(self.lon_0, self.lat_0, 88.0) + } +} + +#[derive(Debug, Clone)] +pub struct Gnomonic { + pub lon_0: f64, + pub lat_0: f64, +} + +impl MapProjectionTrait for Gnomonic { + fn proj_code(&self) -> &'static str { + "gnom" + } + fn display_name(&self) -> &'static str { + "Gnomonic" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, self.lat_0) + } + fn to_proj_str(&self) -> String { + format!("+proj=gnom +lon_0={} +lat_0={}", self.lon_0, self.lat_0) + } + fn clip_shape_wkt(&self) -> String { + hemisphere_polygon_wkt(self.lon_0, self.lat_0, 60.0) + } +} + +#[derive(Debug, Clone)] +pub struct LambertAzimuthal { + pub lon_0: f64, + pub lat_0: f64, +} + +impl MapProjectionTrait for LambertAzimuthal { + fn proj_code(&self) -> &'static str { + "laea" + } + fn display_name(&self) -> &'static str { + "Lambert Azimuthal Equal-Area" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, self.lat_0) + } + fn to_proj_str(&self) -> String { + format!("+proj=laea +lon_0={} +lat_0={}", self.lon_0, self.lat_0) + } + fn visible_area_wkt(&self) -> Option { + todo!("full-globe azimuthal visible area") + } +} + +#[derive(Debug, Clone)] +pub struct AzimuthalEquidistant { + pub lon_0: f64, + pub lat_0: f64, +} + +impl MapProjectionTrait for AzimuthalEquidistant { + fn proj_code(&self) -> &'static str { + "aeqd" + } + fn display_name(&self) -> &'static str { + "Azimuthal Equidistant" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, self.lat_0) + } + fn to_proj_str(&self) -> String { + format!("+proj=aeqd +lon_0={} +lat_0={}", self.lon_0, self.lat_0) + } + fn visible_area_wkt(&self) -> Option { + todo!("full-globe azimuthal visible area") + } +} + +// ============================================================================= +// Cylindrical projections +// ============================================================================= + +#[derive(Debug, Clone)] +pub struct Mercator { + pub lon_0: f64, +} + +impl MapProjectionTrait for Mercator { + fn proj_code(&self) -> &'static str { + "merc" + } + fn display_name(&self) -> &'static str { + "Mercator" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=merc +lon_0={}", self.lon_0) + } + fn lat_bounds(&self) -> (f64, f64) { + (-85.0, 85.0) + } + fn edge_segments(&self) -> [usize; 4] { + [1, 1, 1, 1] + } +} + +#[derive(Debug, Clone)] +pub struct Miller { + pub lon_0: f64, +} + +impl MapProjectionTrait for Miller { + fn proj_code(&self) -> &'static str { + "mill" + } + fn display_name(&self) -> &'static str { + "Miller" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=mill +lon_0={}", self.lon_0) + } + fn edge_segments(&self) -> [usize; 4] { + [1, 1, 1, 1] + } +} + +#[derive(Debug, Clone)] +pub struct Equirectangular { + pub lon_0: f64, +} + +impl MapProjectionTrait for Equirectangular { + fn proj_code(&self) -> &'static str { + "eqc" + } + fn display_name(&self) -> &'static str { + "Equirectangular" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=eqc +lon_0={}", self.lon_0) + } + fn edge_segments(&self) -> [usize; 4] { + [1, 1, 1, 1] + } +} + +#[derive(Debug, Clone)] +pub struct CylindricalEqualArea { + pub lon_0: f64, +} + +impl MapProjectionTrait for CylindricalEqualArea { + fn proj_code(&self) -> &'static str { + "cea" + } + fn display_name(&self) -> &'static str { + "Cylindrical Equal-Area" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=cea +lon_0={}", self.lon_0) + } + fn edge_segments(&self) -> [usize; 4] { + [1, 1, 1, 1] + } +} + +// ============================================================================= +// Pseudocylindrical projections +// ============================================================================= + +#[derive(Debug, Clone)] +pub struct Robinson { + pub lon_0: f64, +} + +impl MapProjectionTrait for Robinson { + fn proj_code(&self) -> &'static str { + "robin" + } + fn display_name(&self) -> &'static str { + "Robinson" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=robin +lon_0={}", self.lon_0) + } +} + +#[derive(Debug, Clone)] +pub struct Mollweide { + pub lon_0: f64, +} + +impl MapProjectionTrait for Mollweide { + fn proj_code(&self) -> &'static str { + "moll" + } + fn display_name(&self) -> &'static str { + "Mollweide" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=moll +lon_0={}", self.lon_0) + } +} + +#[derive(Debug, Clone)] +pub struct Sinusoidal { + pub lon_0: f64, +} + +impl MapProjectionTrait for Sinusoidal { + fn proj_code(&self) -> &'static str { + "sinu" + } + fn display_name(&self) -> &'static str { + "Sinusoidal" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=sinu +lon_0={}", self.lon_0) + } +} + +#[derive(Debug, Clone)] +pub struct Eckert4 { + pub lon_0: f64, +} + +impl MapProjectionTrait for Eckert4 { + fn proj_code(&self) -> &'static str { + "eck4" + } + fn display_name(&self) -> &'static str { + "Eckert IV" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=eck4 +lon_0={}", self.lon_0) + } +} + +#[derive(Debug, Clone)] +pub struct NaturalEarth { + pub lon_0: f64, +} + +impl MapProjectionTrait for NaturalEarth { + fn proj_code(&self) -> &'static str { + "natearth" + } + fn display_name(&self) -> &'static str { + "Natural Earth" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=natearth +lon_0={}", self.lon_0) + } +} + +// ============================================================================= +// Interrupted +// ============================================================================= + +#[derive(Debug, Clone)] +pub struct Igh { + pub lon_0: f64, +} + +impl MapProjectionTrait for Igh { + fn proj_code(&self) -> &'static str { + "igh" + } + fn display_name(&self) -> &'static str { + "Interrupted Goode Homolosine" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=igh +lon_0={}", self.lon_0) + } + fn slit_wkt(&self, epsilon: f64) -> Option { + Some(igh_slit_wkt(self.lon_0, epsilon)) + } +} + +// ============================================================================= +// Conic projections +// ============================================================================= + +#[derive(Debug, Clone)] +pub struct AlbersEqualArea { + pub lon_0: f64, + pub lat_0: f64, + pub lat_1: f64, + pub lat_2: f64, +} + +impl MapProjectionTrait for AlbersEqualArea { + fn proj_code(&self) -> &'static str { + "aea" + } + fn display_name(&self) -> &'static str { + "Albers Equal-Area" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, self.lat_0) + } + fn to_proj_str(&self) -> String { + format!( + "+proj=aea +lon_0={} +lat_0={} +lat_1={} +lat_2={}", + self.lon_0, self.lat_0, self.lat_1, self.lat_2 + ) + } + fn edge_segments(&self) -> [usize; 4] { + [36, 36, 36, 36] + } +} + +#[derive(Debug, Clone)] +pub struct LambertConformalConic { + pub lon_0: f64, + pub lat_0: f64, + pub lat_1: f64, + pub lat_2: f64, +} + +impl MapProjectionTrait for LambertConformalConic { + fn proj_code(&self) -> &'static str { + "lcc" + } + fn display_name(&self) -> &'static str { + "Lambert Conformal Conic" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, self.lat_0) + } + fn to_proj_str(&self) -> String { + format!( + "+proj=lcc +lon_0={} +lat_0={} +lat_1={} +lat_2={}", + self.lon_0, self.lat_0, self.lat_1, self.lat_2 + ) + } + fn lat_bounds(&self) -> (f64, f64) { + (-80.0, 84.0) + } + fn edge_segments(&self) -> [usize; 4] { + [36, 36, 36, 36] + } +} + +// ============================================================================= +// Standalone +// ============================================================================= + +#[derive(Debug, Clone)] +pub struct WinkelTripel { + pub lon_0: f64, +} + +impl MapProjectionTrait for WinkelTripel { + fn proj_code(&self) -> &'static str { + "wintri" + } + fn display_name(&self) -> &'static str { + "Winkel Tripel" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=wintri +lon_0={}", self.lon_0) + } + fn edge_segments(&self) -> [usize; 4] { + [36, 36, 36, 36] + } +} + +// ============================================================================= +// Unknown / fallback +// ============================================================================= + +#[derive(Debug, Clone)] +struct UnknownProj { + raw: String, + lon_0: f64, + lat_0: f64, +} + +impl MapProjectionTrait for UnknownProj { + fn proj_code(&self) -> &'static str { + "unknown" + } + fn display_name(&self) -> &'static str { + "Unknown" + } + fn center(&self) -> (f64, f64) { + (self.lon_0, self.lat_0) + } + fn to_proj_str(&self) -> String { + self.raw.clone() + } + fn visible_area_wkt(&self) -> Option { + None + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +pub fn wrap_lon(lon: f64) -> f64 { + ((lon + 180.0) % 360.0 + 360.0) % 360.0 - 180.0 +} + +pub fn extract_proj_param_str<'a>(crs: &'a str, key: &str) -> Option<&'a str> { + let start = crs.find(key)?; + let after = &crs[start + key.len()..]; + let end = after.find([' ', '+']).unwrap_or(after.len()); + Some(&after[..end]) +} + +fn extract_f64_param(crs: &str, key: &str) -> Option { + extract_proj_param_str(crs, key).and_then(|s| s.parse().ok()) +} + +pub fn rectangle_wkt(xmin: f64, ymin: f64, xmax: f64, ymax: f64, segments: [usize; 4]) -> String { + let mut coords: Vec = Vec::new(); + let [top, right, bottom, left] = segments.map(|s| s.max(1)); + for i in 0..top { + let t = i as f64 / top as f64; + coords.push(format!("{:.6} {:.6}", xmin + t * (xmax - xmin), ymax)); + } + for i in 0..right { + let t = i as f64 / right as f64; + coords.push(format!("{:.6} {:.6}", xmax, ymax - t * (ymax - ymin))); + } + for i in 0..bottom { + let t = i as f64 / bottom as f64; + coords.push(format!("{:.6} {:.6}", xmax - t * (xmax - xmin), ymin)); + } + for i in 0..left { + let t = i as f64 / left as f64; + coords.push(format!("{:.6} {:.6}", xmin, ymin + t * (ymax - ymin))); + } + coords.push(format!("{:.6} {:.6}", xmin, ymax)); + format!("POLYGON(({}))", coords.join(", ")) +} + +fn igh_slit_wkt(lon_0: f64, half_width: f64) -> String { + let segs = [1, 36, 1, 36]; + let polygon_ring = |wkt: String| -> String { wkt.strip_prefix("POLYGON").unwrap().to_string() }; + + let mut parts = Vec::new(); + + for offset in [80.0, -20.0, -100.0] { + let lon = wrap_lon(lon_0 + offset); + parts.push(polygon_ring(rectangle_wkt( + lon - half_width, + -90.0, + lon + half_width, + 0.0, + segs, + ))); + } + + let north = wrap_lon(lon_0 - 40.0); + parts.push(polygon_ring(rectangle_wkt( + north - half_width, + 0.0, + north + half_width, + 90.0, + segs, + ))); + + let seam = wrap_lon(lon_0 + 180.0); + if (seam - (-180.0)).abs() > half_width && (seam - 180.0).abs() > half_width { + parts.push(polygon_ring(rectangle_wkt( + seam - half_width, + -90.0, + seam + half_width, + 90.0, + segs, + ))); + } + + format!("MULTIPOLYGON({})", parts.join(", ")) +} + +pub fn hemisphere_polygon_wkt(lon0: f64, lat0: f64, radius_deg: f64) -> String { + let d = radius_deg.to_radians(); + let lat0_r = lat0.to_radians(); + let sin_lat0 = lat0_r.sin(); + let cos_lat0 = lat0_r.cos(); + let sin_d = d.sin(); + let cos_d = d.cos(); + + let n_points = 72; + let mut raw_points: Vec<(f64, f64)> = Vec::with_capacity(n_points); + for i in 0..n_points { + let az = (i as f64 * (360.0 / n_points as f64)).to_radians(); + let lat2 = (sin_lat0 * cos_d + cos_lat0 * sin_d * az.cos()).asin(); + let lon2 = + lon0.to_radians() + (az.sin() * sin_d * cos_lat0).atan2(cos_d - sin_lat0 * lat2.sin()); + let mut lon_deg = lon2.to_degrees(); + lon_deg = ((lon_deg + 180.0) % 360.0 + 360.0) % 360.0 - 180.0; + raw_points.push((lon_deg, lat2.to_degrees())); + } + + let mut points: Vec<(f64, f64)> = Vec::with_capacity(n_points + 4); + for i in 0..raw_points.len() { + points.push(raw_points[i]); + let next = (i + 1) % raw_points.len(); + if (raw_points[next].0 - raw_points[i].0).abs() > 180.0 { + let lat = antimeridian_crossing_lat(raw_points[i], raw_points[next]); + if raw_points[i].0 > 0.0 { + points.push((179.999999, lat)); + points.push((-179.999999, lat)); + } else { + points.push((-179.999999, lat)); + points.push((179.999999, lat)); + } + } + } + + let includes_north_pole = lat0 + radius_deg > 90.0; + let includes_south_pole = lat0 - radius_deg < -90.0; + + if includes_north_pole || includes_south_pole { + build_pole_polygon(&points, includes_north_pole) + } else if find_antimeridian_crossings(&points).len() == 2 { + build_antimeridian_multipolygon(&points) + } else { + build_simple_polygon(&points) + } +} + +fn build_simple_polygon(points: &[(f64, f64)]) -> String { + let mut coords: Vec = points + .iter() + .map(|(lon, lat)| format!("{lon:.6} {lat:.6}")) + .collect(); + coords.push(coords[0].clone()); + format!("POLYGON(({}))", coords.join(", ")) +} + +fn build_pole_polygon(points: &[(f64, f64)], north: bool) -> String { + let mut split_idx = 0; + let mut max_jump = 0.0_f64; + for i in 0..points.len() { + let next = (i + 1) % points.len(); + let jump = (points[next].0 - points[i].0).abs(); + if jump > max_jump { + max_jump = jump; + split_idx = next; + } + } + + let mut ordered: Vec<(f64, f64)> = Vec::with_capacity(points.len()); + for i in 0..points.len() { + ordered.push(points[(split_idx + i) % points.len()]); + } + + let pole_lat = if north { 90.0 } else { -90.0 }; + let first = ordered.first().unwrap(); + let last = ordered.last().unwrap(); + + let mut coords: Vec = Vec::with_capacity(points.len() + 6); + for (lon, lat) in &ordered { + coords.push(format!("{lon:.6} {lat:.6}")); + } + coords.push(format!("{:.6} {pole_lat:.6}", last.0)); + if (last.0 - first.0).abs() > 180.0 { + let mid = (last.0 + first.0) / 2.0; + coords.push(format!("{mid:.6} {pole_lat:.6}")); + } + coords.push(format!("{:.6} {pole_lat:.6}", first.0)); + coords.push(format!("{:.6} {:.6}", first.0, first.1)); + + format!("POLYGON(({}))", coords.join(", ")) +} + +fn find_antimeridian_crossings(points: &[(f64, f64)]) -> Vec { + let n = points.len(); + let mut crossings = Vec::new(); + for i in 0..n { + let next = (i + 1) % n; + if (points[next].0 - points[i].0).abs() > 180.0 { + crossings.push(i); + } + } + crossings +} + +fn build_antimeridian_multipolygon(points: &[(f64, f64)]) -> String { + let n = points.len(); + let crossings = find_antimeridian_crossings(points); + assert_eq!(crossings.len(), 2); + + let c1 = crossings[0]; + let c2 = crossings[1]; + + let lat_c1 = antimeridian_crossing_lat(points[c1], points[(c1 + 1) % n]); + let lat_c2 = antimeridian_crossing_lat(points[c2], points[(c2 + 1) % n]); + + let (east_arc, west_arc, [east_start_lat, east_end_lat, west_start_lat, west_end_lat]) = + split_arcs_at_crossings(points, c1, c2, lat_c1, lat_c2); + + let east_coords = build_side_ring(&east_arc, 180.0, east_start_lat, east_end_lat); + let west_coords = build_side_ring(&west_arc, -180.0, west_start_lat, west_end_lat); + + format!( + "MULTIPOLYGON((({})),(({})))", + east_coords.join(", "), + west_coords.join(", ") + ) +} + +type ArcSplit = (Vec<(f64, f64)>, Vec<(f64, f64)>, [f64; 4]); + +fn split_arcs_at_crossings( + points: &[(f64, f64)], + c1: usize, + c2: usize, + lat_c1: f64, + lat_c2: f64, +) -> ArcSplit { + let n = points.len(); + + let mut arc1: Vec<(f64, f64)> = Vec::new(); + let mut i = (c1 + 1) % n; + loop { + arc1.push(points[i]); + if i == c2 { + break; + } + i = (i + 1) % n; + } + + let mut arc2: Vec<(f64, f64)> = Vec::new(); + i = (c2 + 1) % n; + loop { + arc2.push(points[i]); + if i == c1 { + break; + } + i = (i + 1) % n; + } + + if arc1[0].0 > 0.0 { + (arc1, arc2, [lat_c1, lat_c2, lat_c2, lat_c1]) + } else { + (arc2, arc1, [lat_c2, lat_c1, lat_c1, lat_c2]) + } +} + +fn build_side_ring( + arc: &[(f64, f64)], + meridian_lon: f64, + start_lat: f64, + end_lat: f64, +) -> Vec { + let mut coords: Vec = Vec::with_capacity(arc.len() + 3); + coords.push(format!("{meridian_lon:.6} {start_lat:.6}")); + for (lon, lat) in arc.iter() { + coords.push(format!("{lon:.6} {lat:.6}")); + } + coords.push(format!("{meridian_lon:.6} {end_lat:.6}")); + coords.push(coords[0].clone()); + coords +} + +fn antimeridian_crossing_lat(a: (f64, f64), b: (f64, f64)) -> f64 { + let (lon_a, lat_a) = a; + let (lon_b, lat_b) = b; + let (lon_a_u, lon_b_u) = if lon_a > lon_b { + (lon_a, lon_b + 360.0) + } else { + (lon_a + 360.0, lon_b) + }; + let t = (180.0 - lon_a_u) / (lon_b_u - lon_a_u); + lat_a + t * (lat_b - lat_a) +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_proj_str_known_projections() { + let proj = MapSpecification::from_proj_str("+proj=ortho +lon_0=10 +lat_0=45"); + assert_eq!(proj.proj_code(), "ortho"); + assert_eq!(proj.center(), (10.0, 45.0)); + + let proj = MapSpecification::from_proj_str("+proj=merc"); + assert_eq!(proj.proj_code(), "merc"); + assert_eq!(proj.center(), (0.0, 0.0)); + + let proj = MapSpecification::from_proj_str("+proj=aea +lon_0=5 +lat_1=30 +lat_2=50"); + assert_eq!(proj.proj_code(), "aea"); + assert_eq!( + proj.to_proj_str(), + "+proj=aea +lon_0=5 +lat_0=0 +lat_1=30 +lat_2=50" + ); + } + + #[test] + fn from_proj_str_unknown_projection() { + let proj = MapSpecification::from_proj_str("+proj=fooproj +lon_0=5"); + assert_eq!(proj.proj_code(), "unknown"); + assert_eq!(proj.center(), (5.0, 0.0)); + assert_eq!(proj.visible_area_wkt(), None); + } + + #[test] + fn round_trip_serialization() { + let proj = MapSpecification::from_proj_str("+proj=robin +lon_0=10"); + let json = serde_json::to_string(&proj).unwrap(); + let deser: MapSpecification = serde_json::from_str(&json).unwrap(); + assert_eq!(proj, deser); + } + + #[test] + fn visible_area_cylindrical() { + let proj = MapSpecification::from_proj_str("+proj=merc"); + let wkt = proj.visible_area_wkt().unwrap(); + assert!(wkt.starts_with("POLYGON((")); + assert!(wkt.contains("85.000000")); + } + + #[test] + fn visible_area_azimuthal() { + let proj = MapSpecification::from_proj_str("+proj=ortho +lon_0=0 +lat_0=0"); + let wkt = proj.visible_area_wkt().unwrap(); + assert!(wkt.starts_with("POLYGON((") || wkt.starts_with("MULTIPOLYGON(")); + } + + #[test] + fn slit_igh() { + let proj = MapSpecification::from_proj_str("+proj=igh"); + let slit = proj.slit_wkt(0.005).unwrap(); + assert!(slit.starts_with("MULTIPOLYGON(")); + } + + #[test] + fn slit_default_antimeridian() { + let proj = MapSpecification::from_proj_str("+proj=robin +lon_0=10"); + let slit = proj.slit_wkt(0.005).unwrap(); + assert!(slit.starts_with("POLYGON((")); + assert!(slit.contains("-170.")); + } + + #[test] + fn slit_at_dateline_returns_none() { + let proj = MapSpecification::from_proj_str("+proj=robin +lon_0=0"); + assert!(proj.slit_wkt(0.005).is_none()); + } + + #[test] + fn visible_area_gnomonic() { + let proj = MapSpecification::from_proj_str("+proj=gnom +lat_0=90 +lon_0=0"); + assert!(proj.visible_area_wkt().is_some()); + } + + #[test] + fn visible_area_antimeridian_crossing() { + let proj = MapSpecification::from_proj_str("+proj=ortho +lat_0=0 +lon_0=150"); + let wkt = proj.visible_area_wkt().unwrap(); + assert!( + wkt.starts_with("MULTIPOLYGON"), + "lon_0=150 should cross antimeridian: {wkt}" + ); + } + + #[test] + fn visible_area_no_antimeridian_for_centered() { + let proj = MapSpecification::from_proj_str("+proj=ortho +lat_0=0 +lon_0=0"); + let wkt = proj.visible_area_wkt().unwrap(); + assert!( + wkt.starts_with("POLYGON(("), + "lon_0=0 should not cross antimeridian: {wkt}" + ); + } + + #[test] + fn visible_area_pole_routing() { + let proj = MapSpecification::from_proj_str("+proj=ortho +lat_0=52.36 +lon_0=150.90"); + let wkt = proj.visible_area_wkt().unwrap(); + assert!( + wkt.starts_with("POLYGON(("), + "pole case should produce POLYGON: {wkt}" + ); + } + + #[test] + fn visible_area_rectangle_always_polygon() { + let proj = MapSpecification::from_proj_str("+proj=robin +lon_0=-90"); + let wkt = proj.visible_area_wkt().unwrap(); + assert!( + wkt.starts_with("POLYGON(("), + "rectangle projections always produce POLYGON: {wkt}" + ); + } + + #[test] + fn seam_position() { + let proj = MapSpecification::from_proj_str("+proj=robin +lon_0=-90"); + let (lon_0, _) = proj.center(); + let seam = wrap_lon(lon_0 + 180.0); + assert!((seam - 90.0).abs() < 1e-6, "seam should be at 90°"); + } + + #[test] + fn igh_slit_shift_with_lon_0() { + let igh0 = MapSpecification::from_proj_str("+proj=igh"); + let wkt0 = igh0.slit_wkt(0.005).unwrap(); + assert!(wkt0.starts_with("MULTIPOLYGON("), "{wkt0}"); + assert_eq!(wkt0.matches("((").count(), 4, "{wkt0}"); + + let igh90 = MapSpecification::from_proj_str("+proj=igh +lon_0=90"); + let wkt90 = igh90.slit_wkt(0.005).unwrap(); + assert_eq!(wkt90.matches("((").count(), 5, "{wkt90}"); + assert!(wkt90.contains("170.005"), "south slit near 170°: {wkt90}"); + assert!(wkt90.contains("70.005"), "south slit near 70°: {wkt90}"); + assert!(wkt90.contains("-10.005"), "south slit near -10°: {wkt90}"); + assert!(wkt90.contains("50.005"), "north slit near 50°: {wkt90}"); + } +} diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index 60811de86..0815d791d 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -32,11 +32,13 @@ use crate::DataFrame; // Coord type implementations mod cartesian; pub mod map; +pub mod map_projections; mod polar; // Re-export coord type structs pub use cartesian::Cartesian; pub use map::Map; +pub use map_projections::MapSpecification; pub use polar::Polar; // ============================================================================= diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index a98f3409c..9b95fc3a2 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -107,6 +107,7 @@ pub fn resolve_coord( coord, aesthetics, properties: HashMap::new(), + map_projection: None, computed: HashMap::new(), })); } @@ -123,6 +124,7 @@ pub fn resolve_coord( coord, aesthetics, properties: HashMap::new(), + map_projection: None, computed: HashMap::new(), })); } @@ -139,6 +141,7 @@ pub fn resolve_coord( coord, aesthetics, properties: HashMap::new(), + map_projection: None, computed: HashMap::new(), })); } diff --git a/src/plot/projection/types.rs b/src/plot/projection/types.rs index 8a9be522a..fb800803b 100644 --- a/src/plot/projection/types.rs +++ b/src/plot/projection/types.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use super::coord::Coord; +use super::coord::{Coord, MapSpecification}; use crate::plot::{Layer, ParameterValue}; use crate::reader::SqlDialect; use crate::DataFrame; @@ -22,6 +22,9 @@ pub struct Projection { pub aesthetics: Vec, /// Projection-specific options pub properties: HashMap, + /// Typed map projection struct (present when coord is Map and crs is set). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub map_projection: Option, /// Values computed at execution time (e.g., clip boundary WKT). /// Not user-facing; populated by apply_projection_transforms. #[serde(default, skip_serializing_if = "HashMap::is_empty")] @@ -54,6 +57,7 @@ impl Projection { coord, aesthetics, properties: HashMap::new(), + map_projection: None, computed: HashMap::new(), } } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index ceb85a0d2..d616fbfe2 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -3031,6 +3031,7 @@ mod tests { aesthetics: vec!["angle".to_string(), "radius".to_string()], coord: Coord::polar(), properties: HashMap::new(), + map_projection: None, computed: HashMap::new(), }); let layer = Layer::new(Geom::point()) From c80f79a4280a6590ba5dba76844f70ae3c9d2e74 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 20 May 2026 16:05:42 +0200 Subject: [PATCH 40/50] Remove graticule seam splitting (handled by clip boundary slits) The slit baked into the clip boundary at ST_Difference time already prevents graticule lines from crossing the projection seam, making the per-line seam splitting in grid_lines_wkt redundant. Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/coord/map.rs | 110 +++++-------------------------- 1 file changed, 15 insertions(+), 95 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index b3eeb0cc1..6e4b0a3b5 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -1,6 +1,6 @@ //! Map coordinate system implementation -use super::{map_projections, CoordKind, CoordTrait}; +use super::{CoordKind, CoordTrait}; use crate::naming; use crate::plot::layer::geom::GeomType; use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition, TypeConstraint}; @@ -377,17 +377,6 @@ fn build_graticule( 0.5 }; - // Compute the seam meridian (lon_0 + 180°) for non-cylindrical projections. - // Graticule lines must not cross this longitude. - let seam = if clip_boundary_wkt.is_some() { - let lon_0 = map_projections::extract_proj_param_str(crs, "+lon_0=") - .and_then(|s| s.parse().ok()) - .unwrap_or(0.0); - Some(map_projections::wrap_lon(lon_0 + 180.0)) - } else { - None - }; - // Clamp meridians away from ±180 to avoid antimeridian issues, and // deduplicate (e.g. if both -180 and 180 were present, they become the same) let lon_breaks: Vec = { @@ -413,7 +402,6 @@ fn build_graticule( geo_bbox.yrange(), step_deg, true, - seam, )) } else { None @@ -424,7 +412,6 @@ fn build_graticule( geo_bbox.xrange(), step_deg, false, - seam, )) } else { None @@ -528,62 +515,29 @@ fn grid_lines_wkt( (vary_min, vary_max): (f64, f64), step_deg: f64, lon_first: bool, - seam: Option, ) -> String { - let seam_epsilon = 0.01; - let mut lines: Vec = Vec::with_capacity(breaks.len()); for &fixed in breaks { - // For meridians (lon_first): skip if the meridian is at the seam - if lon_first { - if let Some(s) = seam { - if (fixed - s).abs() < seam_epsilon { - continue; - } - } + let mut coords = Vec::new(); + let mut v = vary_min; + while v < vary_max { + let (lon, lat) = if lon_first { (fixed, v) } else { (v, fixed) }; + coords.push(format!("{lon:.6} {lat:.6}")); + v += step_deg; } - - // For parallels (lon varies): split the line at the seam - let segments = if !lon_first { - seam_split_ranges(vary_min, vary_max, seam, seam_epsilon) + let (lon, lat) = if lon_first { + (fixed, vary_max) } else { - vec![(vary_min, vary_max)] + (vary_max, fixed) }; - - for (seg_min, seg_max) in segments { - let mut coords = Vec::new(); - let mut v = seg_min; - while v < seg_max { - let (lon, lat) = if lon_first { (fixed, v) } else { (v, fixed) }; - coords.push(format!("{lon:.6} {lat:.6}")); - v += step_deg; - } - let (lon, lat) = if lon_first { - (fixed, seg_max) - } else { - (seg_max, fixed) - }; - coords.push(format!("{lon:.6} {lat:.6}")); - if coords.len() >= 2 { - lines.push(format!("({})", coords.join(", "))); - } + coords.push(format!("{lon:.6} {lat:.6}")); + if coords.len() >= 2 { + lines.push(format!("({})", coords.join(", "))); } } format!("MULTILINESTRING({})", lines.join(", ")) } -/// Split a range into sub-ranges that avoid the seam. -/// Returns one range if no seam, or two ranges if the seam falls within [min, max]. -fn seam_split_ranges(min: f64, max: f64, seam: Option, epsilon: f64) -> Vec<(f64, f64)> { - let Some(s) = seam else { - return vec![(min, max)]; - }; - if s <= min + epsilon || s >= max - epsilon { - return vec![(min, max)]; - } - vec![(min, s - epsilon), (s + epsilon, max)] -} - // --------------------------------------------------------------------------- // Generic helpers // --------------------------------------------------------------------------- @@ -1023,7 +977,7 @@ mod tests { #[test] fn test_grid_lines_wkt_meridians() { - let wkt = grid_lines_wkt(&[0.0, 30.0], (-90.0, 90.0), 45.0, true, None); + let wkt = grid_lines_wkt(&[0.0, 30.0], (-90.0, 90.0), 45.0, true); assert!(wkt.starts_with("MULTILINESTRING("), "{wkt}"); assert!(wkt.contains("0.000000 -90.000000"), "{wkt}"); assert!(wkt.contains("30.000000 -90.000000"), "{wkt}"); @@ -1033,44 +987,10 @@ mod tests { #[test] fn test_grid_lines_wkt_parallels() { - let wkt = grid_lines_wkt(&[0.0, 45.0], (-180.0, 180.0), 90.0, false, None); + let wkt = grid_lines_wkt(&[0.0, 45.0], (-180.0, 180.0), 90.0, false); assert!(wkt.starts_with("MULTILINESTRING(")); assert!(wkt.contains("0.000000")); assert!(wkt.contains("45.000000")); } - #[test] - fn test_grid_lines_wkt_seam_splits_parallels() { - // Seam at 90°: parallels spanning -180..180 should be split into two segments - let wkt = grid_lines_wkt(&[0.0], (-180.0, 180.0), 30.0, false, Some(90.0)); - assert!(wkt.starts_with("MULTILINESTRING(")); - // Should have two linestrings (split at seam) - let line_count = wkt.matches("(").count() - 1; // subtract outer parens - assert!(line_count >= 2, "expected split into 2+ lines, got: {wkt}"); - // First segment should end before seam, second should start after - assert!( - wkt.contains("89.99"), - "first segment should stop near seam: {wkt}" - ); - assert!( - wkt.contains("90.01"), - "second segment should start past seam: {wkt}" - ); - } - - #[test] - fn test_grid_lines_wkt_seam_skips_meridian() { - // Seam at 90°: a meridian at 90° should be skipped - let wkt = grid_lines_wkt(&[60.0, 90.0, 120.0], (-90.0, 90.0), 45.0, true, Some(90.0)); - assert!(wkt.starts_with("MULTILINESTRING(")); - assert!(wkt.contains("60.000000"), "60° meridian should be present"); - assert!( - wkt.contains("120.000000"), - "120° meridian should be present" - ); - assert!( - !wkt.contains("90.000000 "), - "90° meridian should be skipped: {wkt}" - ); - } } From a7f6ffe5bc2a4d0c043047c317236338eee8ab38 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 20 May 2026 17:08:20 +0200 Subject: [PATCH 41/50] Refactor map.rs: decompose setup_clip_boundary, rename variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split setup_clip_boundary into focused helpers (materialize_clip_boundary, boundary_to_target_crs, compute_world_bbox, compute_layer_bbox, materialize_layer). Rename frame_bbox→bbox, bounds_param→user_bbox, computed_bbox→data_bbox for clarity. Co-Authored-By: Claude Opus 4.6 --- src/plot/CLAUDE.md | 2 +- src/plot/projection/coord/map.rs | 223 +++++++++++++++----------- src/writer/vegalite/CLAUDE.md | 2 +- src/writer/vegalite/projection/map.rs | 14 +- 4 files changed, 137 insertions(+), 104 deletions(-) diff --git a/src/plot/CLAUDE.md b/src/plot/CLAUDE.md index 21ed28bcd..bc4a58d10 100644 --- a/src/plot/CLAUDE.md +++ b/src/plot/CLAUDE.md @@ -70,7 +70,7 @@ Each has a `types.rs` (data structure) and `resolve.rs` (logic that runs during - **`polar`** — radius/angle (for pie/rose plots). - **`map`** — geographic projections via PROJ strings. Implements `apply_projection_transforms` to: detect source CRS from geometry SRID, make clip boundaries, delegate per-layer spatial transforms, materialize projected layers as temp tables, and resolve frame bbox from user bounds / data extent / world extent. Properties: `crs` (PROJ string), `source` (source EPSG), `clip` (bool), `bounds` ([xmin, ymin, xmax, ymax] with null/Inf fallback semantics). -`Projection` (in `types.rs`) wraps `Coord` + resolved aesthetics + properties + a `computed` map populated at execution time for the writer (e.g., `panel_boundary`, `frame_bbox`, `graticule_lon`, `graticule_lat`). +`Projection` (in `types.rs`) wraps `Coord` + resolved aesthetics + properties + a `computed` map populated at execution time for the writer (e.g., `panel_boundary`, `bbox`, `graticule_lon`, `graticule_lat`). Docs: [`/doc/syntax/clause/facet.qmd`](../../doc/syntax/clause/facet.qmd), [`/doc/syntax/coord/`](../../doc/syntax/coord/). diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 6e4b0a3b5..e8fe7dd28 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -114,13 +114,23 @@ impl CoordTrait for Map { ))); } - // Step 2: Compute the visible area boundary for this projection. - // Produces: clip boundary temp table (in source CRS for layer clipping), - // panel_boundary (projected WKT for the writer's background layer), and - // world_bbox (bounding box of the full projected visible area). - let (world_bbox, boundary_4326) = - setup_clip_boundary(projection, &source, &crs, dialect, execute_query)?; - let clip = boundary_4326.is_some(); + // Step 2: Materialize clip boundary, panel boundary, and world bbox. + let mut world_bbox: Option = None; + let mut boundary_lonlat: Option = None; + + if let Some(map_proj) = projection.map_projection.as_ref() { + if map_proj.visible_area_wkt().is_some() { + let b = materialize_clip_boundary(map_proj, &source, dialect, execute_query)?; + if let Some(wkt) = boundary_to_target_crs(&b, &crs, dialect, execute_query) { + projection + .computed + .insert("panel_boundary".to_string(), ParameterValue::String(wkt)); + } + world_bbox = compute_world_bbox(&source, &crs, dialect, execute_query); + boundary_lonlat = Some(b); + } + } + let clip = boundary_lonlat.is_some(); // Step 3: Apply per-layer projection (ST_Transform, clip to horizon) for (idx, layer) in layers.iter().enumerate() { @@ -132,12 +142,9 @@ impl CoordTrait for Map { // Step 4: Materialize projected layers as temp tables, compute data bbox, // then rewrite layer queries to read from those tables. - let geom_col = naming::aesthetic_column("geometry"); - let geom_col_quoted = naming::quote_ident(&geom_col); - let pos1_col = naming::quote_ident(&naming::aesthetic_column("pos1")); - let pos2_col = naming::quote_ident(&naming::aesthetic_column("pos2")); - let bounds_param = projection.properties.get("bounds"); - let mut computed_bbox: Option = None; + let user_bbox = projection.properties.get("bounds"); + let needs_data_bbox = needs_data_bbox(user_bbox); + let mut data_bbox: Option = None; for (idx, layer) in layers.iter().enumerate() { let is_spatial = layer.geom.geom_type() == GeomType::Spatial; @@ -150,29 +157,17 @@ impl CoordTrait for Map { continue; } - let table_name = format!("{}_proj", naming::layer_key(idx)); - for stmt in - dialect.create_or_replace_temp_table_sql(&table_name, &[], &layer_queries[idx]) - { - execute_query(&stmt)?; - } - let table_quoted = naming::quote_ident(&table_name); + let table_quoted = + materialize_layer(idx, &layer_queries[idx], dialect, execute_query)?; - if needs_computed_bbox(bounds_param) { - let bbox_sql = if is_spatial { - dialect.sql_geometry_bbox(&geom_col_quoted, &table_quoted) - } else { - format!( - "SELECT MIN({pos1_col}), MIN({pos2_col}), \ - MAX({pos1_col}), MAX({pos2_col}) FROM {table_quoted}" - ) - }; - if let Ok(df) = execute_query(&bbox_sql) { - computed_bbox = BBox::merge(computed_bbox, BBox::from_df(&df, &crs))?; - } + if needs_data_bbox { + let layer_bbox = + compute_layer_bbox(&table_quoted, is_spatial, &crs, dialect, execute_query); + data_bbox = BBox::merge(data_bbox, layer_bbox)?; } layer_queries[idx] = if is_spatial { + let geom_col_quoted = naming::quote_ident(&naming::aesthetic_column("geometry")); let wkb_expr = dialect.sql_geometry_to_wkb(&geom_col_quoted); format!("SELECT * REPLACE ({wkb_expr} AS {geom_col_quoted}) FROM {table_quoted}") } else { @@ -181,18 +176,18 @@ impl CoordTrait for Map { } // Step 5: Resolve final frame bbox from user bounds + data bounds + world bounds - let Some(bbox) = resolve_frame_bbox(bounds_param, computed_bbox, world_bbox) else { + let Some(bbox) = resolve_final_bbox(user_bbox, data_bbox, world_bbox) else { return Ok(()); }; projection .computed - .insert("frame_bbox".to_string(), bbox.as_parameter_value()); + .insert("bbox".to_string(), bbox.as_parameter_value()); // Step 6: Generate graticule lines. The graticule is built and clipped // in EPSG:4326 (independent of source), then projected to target. let (lon_wkt, lat_wkt) = build_graticule( &bbox, - boundary_4326.as_deref(), + boundary_lonlat.as_deref(), &crs, dialect, execute_query, @@ -349,13 +344,13 @@ impl BBox { /// Build graticule lines: determine the visible lon/lat extent, generate densified /// meridians and parallels, clip and project them, and return projected WKT. fn build_graticule( - frame_bbox: &BBox, + bbox: &BBox, clip_boundary_wkt: Option<&str>, crs: &str, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<(Option, Option)> { - let Some(geo_bbox) = graticule_bbox(frame_bbox, clip_boundary_wkt, dialect, execute_query)? + let Some(geo_bbox) = graticule_bbox(bbox, clip_boundary_wkt, dialect, execute_query)? else { return Ok((None, None)); }; @@ -427,12 +422,12 @@ fn build_graticule( /// the bbox corners to EPSG:4326. Falls back to the clip boundary extent for azimuthal /// projections where corners collapse to degenerate values. fn graticule_bbox( - frame_bbox: &BBox, + bbox: &BBox, clip_boundary_wkt: Option<&str>, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result> { - let mut geo_bbox = match frame_bbox.reproject("EPSG:4326", dialect, execute_query) { + let mut geo_bbox = match bbox.reproject("EPSG:4326", dialect, execute_query) { Some(b) => b.clamp(-180.0, -90.0, 180.0, 90.0), None => return Ok(None), }; @@ -563,34 +558,20 @@ fn query_scalar_string( Some(arr.value(0).to_string()) } -/// Set up the clip/visible area boundary. -/// -/// Returns `(world_bbox, boundary_4326_wkt)`: -/// - `world_bbox`: bounding box of the boundary projected into the target CRS -/// - `boundary_4326_wkt`: combined clip+slit boundary in EPSG:4326 (for graticule use) -/// -/// Side effects: -/// - Creates `CLIP_BOUNDARY_TABLE` temp table containing the boundary in source CRS -/// - Stores `panel_boundary` in `projection.computed` (boundary projected to target CRS) -fn setup_clip_boundary( - projection: &mut super::super::Projection, + +/// Compose the clip boundary (visible area minus seam slits), materialize it as a +/// temp table in source CRS for per-layer clipping, and return the WKT in EPSG:4326. +fn materialize_clip_boundary( + map_proj: &super::map_projections::MapSpecification, source: &str, - crs: &str, dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, -) -> crate::Result<(Option, Option)> { - let Some(map_proj) = projection.map_projection.as_ref() else { - return Ok((None, None)); - }; - let Some(wkt) = map_proj.visible_area_wkt() else { - return Ok((None, None)); - }; - +) -> crate::Result { + let wkt = map_proj.visible_area_wkt().unwrap(); let half_width = 0.005; let slit_wkt = map_proj.slit_wkt(half_width); - // Combine clip boundary and seam slit into a single geometry in EPSG:4326. - let boundary_4326 = if let Some(slit) = &slit_wkt { + let boundary_lonlat = if let Some(slit) = &slit_wkt { let sql = format!( "SELECT ST_AsText(ST_Difference(ST_GeomFromText('{wkt}'), ST_GeomFromText('{slit}'))) AS wkt" ); @@ -599,9 +580,8 @@ fn setup_clip_boundary( wkt }; - // Store the boundary in source CRS for per-layer clipping. let source_geom = dialect.sql_st_transform( - &format!("ST_GeomFromText('{boundary_4326}')"), + &format!("ST_GeomFromText('{boundary_lonlat}')"), "EPSG:4326", source, ); @@ -610,27 +590,80 @@ fn setup_clip_boundary( execute_query(&stmt)?; } - // Project the boundary to target CRS for the panel background shape. + Ok(boundary_lonlat) +} + +/// Project the clip boundary from EPSG:4326 to target CRS, returning the WKT. +fn boundary_to_target_crs( + boundary_lonlat: &str, + crs: &str, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> Option { let panel_geom = dialect.sql_st_transform( - &format!("ST_GeomFromText('{boundary_4326}')"), + &format!("ST_GeomFromText('{boundary_lonlat}')"), "EPSG:4326", crs, ); let sql = format!("SELECT ST_AsText({panel_geom}) AS wkt"); - if let Some(projected_wkt) = query_scalar_string(&sql, execute_query) { - projection.computed.insert( - "panel_boundary".to_string(), - ParameterValue::String(projected_wkt), - ); + query_scalar_string(&sql, execute_query) +} + +/// Materialize a layer query as a temp table, returning the quoted table name. +fn materialize_layer( + idx: usize, + query: &str, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> crate::Result { + let table_name = format!("{}_proj", naming::layer_key(idx)); + for stmt in dialect.create_or_replace_temp_table_sql(&table_name, &[], query) { + execute_query(&stmt)?; + } + Ok(naming::quote_ident(&table_name)) +} + +/// Compute the bounding box of a single materialized layer table. +fn compute_layer_bbox( + table: &str, + is_spatial: bool, + crs: &str, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> Option { + let sql = if is_spatial { + let geom_col = naming::quote_ident(&naming::aesthetic_column("geometry")); + dialect.sql_geometry_bbox(&geom_col, table) + } else { + let pos1_col = naming::quote_ident(&naming::aesthetic_column("pos1")); + let pos2_col = naming::quote_ident(&naming::aesthetic_column("pos2")); + format!( + "SELECT MIN({pos1_col}), MIN({pos2_col}), \ + MAX({pos1_col}), MAX({pos2_col}) FROM {table}" + ) + }; + if let Ok(df) = execute_query(&sql) { + BBox::from_df(&df, crs) + } else { + None } +} - // World bbox: extent of the boundary in target CRS. +/// Compute the world bounding box by reading the extent of the materialized +/// clip boundary table, projected to target CRS. +fn compute_world_bbox( + source: &str, + crs: &str, + dialect: &dyn SqlDialect, + execute_query: &dyn Fn(&str) -> crate::Result, +) -> Option { let projected = dialect.sql_st_transform("geom", source, crs); - let world_bbox_sql = dialect.sql_geometry_bbox(&projected, CLIP_BOUNDARY_TABLE); - let world_bbox = execute_query(&world_bbox_sql) - .ok() - .and_then(|df| BBox::from_df(&df, crs)); - Ok((world_bbox, Some(boundary_4326))) + let sql = dialect.sql_geometry_bbox(&projected, CLIP_BOUNDARY_TABLE); + if let Ok(df) = execute_query(&sql) { + BBox::from_df(&df, crs) + } else { + None + } } /// Clip (if needed) and project a graticule WKT from EPSG:4326 to the target CRS. @@ -660,8 +693,8 @@ fn project_graticule_wkt( /// Returns true if we need to compute a bbox (bounding box representing the extent of geometry) /// from the data — i.e. when bounds is absent or has null elements that need filling in. -fn needs_computed_bbox(bounds_param: Option<&ParameterValue>) -> bool { - match bounds_param { +fn needs_data_bbox(user_bbox: Option<&ParameterValue>) -> bool { + match user_bbox { Some(ParameterValue::Array(arr)) => { use crate::plot::types::ArrayElement; arr.iter().any(|e| !matches!(e, ArrayElement::Number(_))) @@ -673,12 +706,12 @@ fn needs_computed_bbox(bounds_param: Option<&ParameterValue>) -> bool { /// Resolve the frame bbox: merge explicit bounds with computed values. /// - Null elements fall back to the corresponding data-computed bbox. /// - Inf/-Inf elements fall back to the clip boundary (world) bbox. -fn resolve_frame_bbox( - bounds_param: Option<&ParameterValue>, +fn resolve_final_bbox( + user_bbox: Option<&ParameterValue>, computed: Option, world: Option, ) -> Option { - if let Some(ParameterValue::Array(arr)) = bounds_param { + if let Some(ParameterValue::Array(arr)) = user_bbox { use crate::plot::types::ArrayElement; let data_fallback = computed.as_ref().map_or([f64::NAN; 4], |b| b.to_array()); let world_fallback = world.as_ref().map_or([f64::NAN; 4], |b| b.to_array()); @@ -821,18 +854,18 @@ mod tests { } #[test] - fn test_resolve_frame_bbox_no_bounds_uses_computed() { + fn test_resolve_final_bbox_no_bounds_uses_computed() { let computed = Some(bbox(0.0, 0.0, 100.0, 200.0)); - assert_eq!(resolve_frame_bbox(None, computed.clone(), None), computed); + assert_eq!(resolve_final_bbox(None, computed.clone(), None), computed); } #[test] - fn test_resolve_frame_bbox_no_bounds_no_computed() { - assert_eq!(resolve_frame_bbox(None, None, None), None); + fn test_resolve_final_bbox_no_bounds_no_computed() { + assert_eq!(resolve_final_bbox(None, None, None), None); } #[test] - fn test_resolve_frame_bbox_explicit_bounds_override_computed() { + fn test_resolve_final_bbox_explicit_bounds_override_computed() { use crate::plot::types::ArrayElement; let bounds = ParameterValue::Array(vec![ ArrayElement::Number(10.0), @@ -842,13 +875,13 @@ mod tests { ]); let computed = Some(bbox(0.0, 0.0, 100.0, 200.0)); assert_eq!( - resolve_frame_bbox(Some(&bounds), computed, None), + resolve_final_bbox(Some(&bounds), computed, None), Some(bbox(10.0, 20.0, 30.0, 40.0)) ); } #[test] - fn test_resolve_frame_bbox_null_elements_use_computed() { + fn test_resolve_final_bbox_null_elements_use_computed() { use crate::plot::types::ArrayElement; let bounds = ParameterValue::Array(vec![ ArrayElement::Null, @@ -858,13 +891,13 @@ mod tests { ]); let computed = Some(bbox(5.0, 0.0, 95.0, 0.0)); assert_eq!( - resolve_frame_bbox(Some(&bounds), computed, None), + resolve_final_bbox(Some(&bounds), computed, None), Some(bbox(5.0, 20.0, 95.0, 40.0)) ); } #[test] - fn test_resolve_frame_bbox_inf_elements_use_world() { + fn test_resolve_final_bbox_inf_elements_use_world() { use crate::plot::types::ArrayElement; let bounds = ParameterValue::Array(vec![ ArrayElement::Number(f64::NEG_INFINITY), @@ -875,13 +908,13 @@ mod tests { let computed = Some(bbox(5.0, 0.0, 95.0, 0.0)); let world = Some(bbox(-500.0, -500.0, 500.0, 500.0)); assert_eq!( - resolve_frame_bbox(Some(&bounds), computed, world), + resolve_final_bbox(Some(&bounds), computed, world), Some(bbox(-500.0, 20.0, 500.0, 40.0)) ); } #[test] - fn test_resolve_frame_bbox_null_without_computed_falls_through() { + fn test_resolve_final_bbox_null_without_computed_falls_through() { use crate::plot::types::ArrayElement; let bounds = ParameterValue::Array(vec![ ArrayElement::Null, @@ -889,11 +922,11 @@ mod tests { ArrayElement::Number(30.0), ArrayElement::Number(40.0), ]); - assert_eq!(resolve_frame_bbox(Some(&bounds), None, None), None); + assert_eq!(resolve_final_bbox(Some(&bounds), None, None), None); } #[test] - fn test_resolve_frame_bbox_inf_without_world_falls_through() { + fn test_resolve_final_bbox_inf_without_world_falls_through() { use crate::plot::types::ArrayElement; let bounds = ParameterValue::Array(vec![ ArrayElement::Number(f64::INFINITY), @@ -903,7 +936,7 @@ mod tests { ]); let computed = Some(bbox(5.0, 0.0, 95.0, 200.0)); assert_eq!( - resolve_frame_bbox(Some(&bounds), computed.clone(), None), + resolve_final_bbox(Some(&bounds), computed.clone(), None), computed ); } diff --git a/src/writer/vegalite/CLAUDE.md b/src/writer/vegalite/CLAUDE.md index 508674e6e..dc1bcf05f 100644 --- a/src/writer/vegalite/CLAUDE.md +++ b/src/writer/vegalite/CLAUDE.md @@ -403,7 +403,7 @@ Additional hooks: `background_layers()` / `foreground_layers()` inject layers be `MapProjection` reads computed values from `Projection.computed` (populated at execution time by the `Map` coord): - `panel_boundary` (WKT) → converted to GeoJSON for a geoshape background layer. -- `frame_bbox` ([xmin, ymin, xmax, ymax]) → emits VL `projection.scale` and `projection.translate` expressions that frame the data to the viewport. +- `bbox` ([xmin, ymin, xmax, ymax]) → emits VL `projection.scale` and `projection.translate` expressions that frame the data to the viewport. The VL projection is always `{"type": "identity", "reflectY": true}` because coordinates arrive pre-projected from the SQL layer. diff --git a/src/writer/vegalite/projection/map.rs b/src/writer/vegalite/projection/map.rs index ff6c53035..336bae625 100644 --- a/src/writer/vegalite/projection/map.rs +++ b/src/writer/vegalite/projection/map.rs @@ -17,7 +17,7 @@ pub(in crate::writer) struct MapProjection { panel_boundary_wkt: Option, graticule_lon_wkt: Option, graticule_lat_wkt: Option, - frame_bbox: Option<[f64; 4]>, + bbox: Option<[f64; 4]>, } impl MapProjection { @@ -33,8 +33,8 @@ impl MapProjection { let panel_boundary_wkt = get_string("panel_boundary"); let graticule_lon_wkt = get_string("graticule_lon"); let graticule_lat_wkt = get_string("graticule_lat"); - let frame_bbox = if let Some(ParameterValue::Array(arr)) = - project.and_then(|p| p.computed.get("frame_bbox")) + let bbox = if let Some(ParameterValue::Array(arr)) = + project.and_then(|p| p.computed.get("bbox")) { let nums: Vec = arr .iter() @@ -52,7 +52,7 @@ impl MapProjection { panel_boundary_wkt, graticule_lon_wkt, graticule_lat_wkt, - frame_bbox, + bbox, } } @@ -135,7 +135,7 @@ impl ProjectionRenderer for MapProjection { "type": "identity", "reflectY": true }); - if let Some([xmin, ymin, xmax, ymax]) = self.frame_bbox { + if let Some([xmin, ymin, xmax, ymax]) = self.bbox { let dx = (xmax - xmin) * 1.1; let dy = (ymax - ymin) * 1.1; if dx.is_finite() && dy.is_finite() && dx > 0.0 && dy > 0.0 { @@ -274,12 +274,12 @@ mod tests { } #[test] - fn test_frame_bbox_emits_scale_translate_exprs() { + fn test_bbox_emits_scale_translate_exprs() { use crate::plot::types::ArrayElement; let mut proj = Projection::map(); proj.computed.insert( - "frame_bbox".to_string(), + "bbox".to_string(), ParameterValue::Array(vec![ ArrayElement::Number(0.0), ArrayElement::Number(0.0), From 8e5f34b9f011027a7865af8cd86899bbdabb5a58 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 21 May 2026 12:59:53 +0200 Subject: [PATCH 42/50] Add center/parallel projection settings and consolidate MapSpecification Expose user-friendly projection parameters via the SETTING clause: - center => 30 or center => (lon, lat) for the central meridian/latitude - parallel => 30 or parallel => (lat_1, lat_2) for conic standard parallels Consolidate MapSpecification construction into a single `new(name, properties)` method that handles both named projections and raw CRS strings. Extract NAMED_PROJECTIONS const as single source of truth for valid projection names. Add validation rejecting incompatible combinations (named + crs, crs + center/parallel). Make Map coord carry the specific projection name for better error messages. Co-Authored-By: Claude Opus 4.6 --- src/lib.rs | 2 +- src/parser/builder.rs | 44 +-- src/plot/projection/coord/map.rs | 127 ++++++++- src/plot/projection/coord/map_projections.rs | 268 ++++++++++++------- src/plot/projection/coord/mod.rs | 6 +- src/plot/projection/types.rs | 2 +- 6 files changed, 303 insertions(+), 146 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 38833fa56..43d2bf0b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1103,7 +1103,7 @@ mod integration_tests { for crs in queries { let query = format!( "VISUALISE FROM ggsql:world \ - DRAW spatial PROJECT TO orthographic SETTING crs => '{crs}'" + DRAW spatial PROJECT TO map SETTING crs => '{crs}'" ); let prepared = execute::prepare_data_with_reader(&query, &reader).unwrap(); diff --git a/src/parser/builder.rs b/src/parser/builder.rs index a780c93c3..28067b652 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1033,13 +1033,7 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { // Validate properties for this coord type validate_project_properties(&coord, &properties)?; - let map_projection = if let Some(ParameterValue::String(crs)) = properties.get("crs") { - Some(MapSpecification::from_proj_str(crs)) - } else { - coord_type_name - .as_deref() - .and_then(MapSpecification::from_coord_name) - }; + let map_projection = MapSpecification::new(coord_type_name.as_deref(), &properties); if let Some(ref spec) = map_projection { let proj_str = spec.to_proj_str(); @@ -1127,28 +1121,12 @@ fn validate_project_properties( } fn parse_coord_system(name: &str) -> Result { + use crate::plot::projection::coord::map_projections::NAMED_PROJECTIONS; match name { "cartesian" => Ok(Coord::cartesian()), "polar" => Ok(Coord::polar()), - "map" - | "mercator" - | "orthographic" - | "miller" - | "equirectangular" - | "stereographic" - | "gnomonic" - | "equal_area" - | "mollweide" - | "sinusoidal" - | "eckert4" - | "natural" - | "winkel_tripel" - | "albers" - | "lambert_conformal" - | "lambert" - | "azimuthal_equidistant" - | "igh" - | "robinson" => Ok(Coord::map()), + "map" => Ok(Coord::map(name)), + _ if NAMED_PROJECTIONS.contains(&name) => Ok(Coord::map(name)), _ => Err(GgsqlError::ParseError(format!( "Unknown coord type: {}", name @@ -1436,7 +1414,7 @@ mod tests { } #[test] - fn test_project_shorthand_crs_override() { + fn test_project_shorthand_crs_override_rejected() { let query = r#" VISUALISE DRAW point MAPPING lon AS lon, lat AS lat @@ -1444,15 +1422,9 @@ mod tests { "#; let result = parse_test_query(query); - assert!(result.is_ok()); - let specs = result.unwrap(); - - let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.coord.coord_kind(), CoordKind::Map); - assert_eq!( - project.properties.get("crs"), - Some(&ParameterValue::String("+proj=custom".to_string())) - ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Cannot combine a named projection")); } // ======================================== diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index e8fe7dd28..def0a83dd 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -1,9 +1,13 @@ //! Map coordinate system implementation +use std::collections::HashMap; + use super::{CoordKind, CoordTrait}; use crate::naming; use crate::plot::layer::geom::GeomType; -use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition, TypeConstraint}; +use crate::plot::types::{ + validate_parameter, DefaultParamValue, ParamConstraint, ParamDefinition, TypeConstraint, +}; use crate::plot::{Layer, ParameterValue}; use crate::reader::SqlDialect; use crate::DataFrame; @@ -16,7 +20,21 @@ pub const CLIP_BOUNDARY_TABLE: &str = "__ggsql_clip_boundary__"; /// Map coordinate system - for geographic/cartographic projections #[derive(Debug, Clone, Copy)] -pub struct Map; +pub struct Map { + coord_type_name: &'static str, +} + +impl Map { + pub fn new(name: &str) -> Self { + use super::map_projections::NAMED_PROJECTIONS; + let coord_type_name = NAMED_PROJECTIONS + .iter() + .find(|&&n| n == name) + .copied() + .unwrap_or("map"); + Self { coord_type_name } + } +} impl CoordTrait for Map { fn coord_kind(&self) -> CoordKind { @@ -24,7 +42,7 @@ impl CoordTrait for Map { } fn name(&self) -> &'static str { - "map" + self.coord_type_name } fn position_aesthetic_names(&self) -> &'static [&'static str] { @@ -33,6 +51,8 @@ impl CoordTrait for Map { fn default_properties(&self) -> &'static [ParamDefinition] { use crate::plot::types::{ArrayConstraint, NumberConstraint}; + const LON_RANGE: NumberConstraint = NumberConstraint::range(-180.0, 180.0); + const LAT_RANGE: NumberConstraint = NumberConstraint::range(-90.0, 90.0); const PARAMS: &[ParamDefinition] = &[ ParamDefinition { name: "crs", @@ -64,10 +84,75 @@ impl CoordTrait for Map { allow_null: true, }, }, + // center => 30 (lon only) or center => (30, 45) (lon, lat) + ParamDefinition { + name: "center", + default: DefaultParamValue::Null, + constraint: ParamConstraint::number_or_numeric_array( + LON_RANGE, + ArrayConstraint::of_numbers_len(LON_RANGE, 2), + ), + }, + // parallel => 30 (tangent) or parallel => (30, 50) (secant) + ParamDefinition { + name: "parallel", + default: DefaultParamValue::Null, + constraint: ParamConstraint::number_or_numeric_array( + LAT_RANGE, + ArrayConstraint::of_numbers_len(LAT_RANGE, 2), + ), + }, ]; PARAMS } + fn resolve_properties( + &self, + properties: &HashMap, + ) -> Result, String> { + if self.coord_type_name != "map" && properties.contains_key("crs") { + return Err(format!( + "Cannot combine a named projection ('{}') with a 'crs' string. \ + Use either PROJECT TO {} or PROJECT TO map SETTING crs => '...'", + self.coord_type_name, self.coord_type_name + )); + } + let has_crs = properties.contains_key("crs"); + let has_center = properties.contains_key("center"); + let has_parallel = properties.contains_key("parallel"); + if has_crs && (has_center || has_parallel) { + return Err( + "Cannot combine 'crs' setting with 'center' or 'parallel'. \ + Use either the CRS string or a named projection with 'center'/'parallel' settings." + .to_string(), + ); + } + // Delegate to default validation + let defaults = self.default_properties(); + for (key, value) in properties.iter() { + if let Some(param) = defaults.iter().find(|p| p.name == key) { + validate_parameter(key, value, ¶m.constraint)?; + } else { + let allowed: Vec<&str> = defaults.iter().map(|p| p.name).collect(); + return Err(format!( + "{} projection property should be {}, not '{}'", + self.name(), + crate::or_list_quoted(&allowed, '\''), + key + )); + } + } + let mut resolved = properties.clone(); + for param in defaults { + if !resolved.contains_key(param.name) { + if let Some(default) = param.to_parameter_value() { + resolved.insert(param.name.to_string(), default); + } + } + } + Ok(resolved) + } + fn apply_projection_transforms( &self, layers: &[Layer], @@ -157,8 +242,7 @@ impl CoordTrait for Map { continue; } - let table_quoted = - materialize_layer(idx, &layer_queries[idx], dialect, execute_query)?; + let table_quoted = materialize_layer(idx, &layer_queries[idx], dialect, execute_query)?; if needs_data_bbox { let layer_bbox = @@ -350,8 +434,7 @@ fn build_graticule( dialect: &dyn SqlDialect, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result<(Option, Option)> { - let Some(geo_bbox) = graticule_bbox(bbox, clip_boundary_wkt, dialect, execute_query)? - else { + let Some(geo_bbox) = graticule_bbox(bbox, clip_boundary_wkt, dialect, execute_query)? else { return Ok((None, None)); }; @@ -558,7 +641,6 @@ fn query_scalar_string( Some(arr.value(0).to_string()) } - /// Compose the clip boundary (visible area minus seam slits), materialize it as a /// temp table in source CRS for per-layer clipping, and return the WKT in EPSG:4326. fn materialize_clip_boundary( @@ -798,7 +880,7 @@ mod tests { #[test] fn test_map_properties() { - let map = Map; + let map = Map::new("map"); assert_eq!(map.coord_kind(), CoordKind::Map); assert_eq!(map.name(), "map"); assert_eq!(map.position_aesthetic_names(), &["lon", "lat"]); @@ -806,19 +888,21 @@ mod tests { #[test] fn test_map_default_properties() { - let map = Map; + let map = Map::new("map"); let defaults = map.default_properties(); let names: Vec<&str> = defaults.iter().map(|p| p.name).collect(); assert!(names.contains(&"crs")); assert!(names.contains(&"source")); assert!(names.contains(&"clip")); assert!(names.contains(&"bounds")); - assert_eq!(defaults.len(), 4); + assert!(names.contains(&"center")); + assert!(names.contains(&"parallel")); + assert_eq!(defaults.len(), 6); } #[test] fn test_map_accepts_crs_string() { - let map = Map; + let map = Map::new("map"); let mut props = HashMap::new(); props.insert( "crs".to_string(), @@ -836,7 +920,7 @@ mod tests { #[test] fn test_map_rejects_unknown_property() { - let map = Map; + let map = Map::new("map"); let mut props = HashMap::new(); props.insert( "unknown".to_string(), @@ -849,6 +933,22 @@ mod tests { assert!(err.contains("not 'unknown'")); } + #[test] + fn test_crs_rejects_center_and_parallel() { + let map = Map::new("map"); + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=ortho".to_string()), + ); + props.insert("center".to_string(), ParameterValue::Number(30.0)); + + let resolved = map.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("Cannot combine 'crs'")); + } + fn bbox(xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> BBox { BBox::from_array([xmin, ymin, xmax, ymax], "EPSG:4326") } @@ -1025,5 +1125,4 @@ mod tests { assert!(wkt.contains("0.000000")); assert!(wkt.contains("45.000000")); } - } diff --git a/src/plot/projection/coord/map_projections.rs b/src/plot/projection/coord/map_projections.rs index 1d031712f..69ec7c39d 100644 --- a/src/plot/projection/coord/map_projections.rs +++ b/src/plot/projection/coord/map_projections.rs @@ -1,7 +1,38 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fmt; use std::sync::Arc; +use crate::plot::types::ArrayElement; +use crate::plot::ParameterValue; + +// When adding a new named projection: +// 1. Add a struct implementing MapProjectionTrait (in this file) +// 2. Add the name to NAMED_PROJECTIONS below +// 3. Add match arms in MapSpecification::new(): +// - the `crs` branch (maps +proj= code to the new struct) +// - the `else` branch (maps coord type name to the new struct) +pub const NAMED_PROJECTIONS: &[&str] = &[ + "mercator", + "orthographic", + "miller", + "equirectangular", + "stereographic", + "gnomonic", + "equal_area", + "mollweide", + "sinusoidal", + "eckert4", + "natural", + "winkel_tripel", + "albers", + "lambert_conformal", + "lambert", + "azimuthal_equidistant", + "igh", + "robinson", +]; + // ============================================================================= // Trait // ============================================================================= @@ -59,102 +90,115 @@ pub trait MapProjectionTrait: fmt::Debug + Send + Sync { pub struct MapSpecification(Arc); impl MapSpecification { - pub fn from_coord_name(name: &str) -> Option { - let obj: Arc = match name { - "map" => Arc::new(UnknownProj { - raw: String::new(), - lon_0: 0.0, - lat_0: 0.0, - }), - "mercator" => Arc::new(Mercator { lon_0: 0.0 }), - "orthographic" => Arc::new(Orthographic { - lon_0: 0.0, - lat_0: 0.0, - }), - "miller" => Arc::new(Miller { lon_0: 0.0 }), - "equirectangular" => Arc::new(Equirectangular { lon_0: 0.0 }), - "stereographic" => Arc::new(Stereographic { - lon_0: 0.0, - lat_0: 0.0, - }), - "gnomonic" => Arc::new(Gnomonic { - lon_0: 0.0, - lat_0: 0.0, - }), - "equal_area" => Arc::new(CylindricalEqualArea { lon_0: 0.0 }), - "mollweide" => Arc::new(Mollweide { lon_0: 0.0 }), - "sinusoidal" => Arc::new(Sinusoidal { lon_0: 0.0 }), - "eckert4" => Arc::new(Eckert4 { lon_0: 0.0 }), - "natural" => Arc::new(NaturalEarth { lon_0: 0.0 }), - "winkel_tripel" => Arc::new(WinkelTripel { lon_0: 0.0 }), - "albers" => Arc::new(AlbersEqualArea { - lon_0: 0.0, - lat_0: 0.0, - lat_1: 29.5, - lat_2: 45.5, - }), - "lambert_conformal" => Arc::new(LambertConformalConic { - lon_0: 0.0, - lat_0: 0.0, - lat_1: 29.5, - lat_2: 45.5, - }), - "lambert" => Arc::new(LambertAzimuthal { - lon_0: 0.0, - lat_0: 0.0, - }), - "azimuthal_equidistant" => Arc::new(AzimuthalEquidistant { - lon_0: 0.0, - lat_0: 0.0, - }), - "igh" => Arc::new(Igh { lon_0: 0.0 }), - "robinson" => Arc::new(Robinson { lon_0: 0.0 }), - _ => return None, + pub fn new(name: Option<&str>, properties: &HashMap) -> Option { + let name = name?; + + let obj: Arc = if let Some(ParameterValue::String(crs)) = + properties.get("crs") + { + let code = extract_proj_param_str(crs, "+proj=").unwrap_or(""); + let lon_0 = extract_f64_param(crs, "+lon_0=").unwrap_or(0.0); + let lat_0 = extract_f64_param(crs, "+lat_0=").unwrap_or(0.0); + match code { + "ortho" => Arc::new(Orthographic { lon_0, lat_0 }), + "stere" => Arc::new(Stereographic { lon_0, lat_0 }), + "gnom" => Arc::new(Gnomonic { lon_0, lat_0 }), + "laea" => Arc::new(LambertAzimuthal { lon_0, lat_0 }), + "aeqd" => Arc::new(AzimuthalEquidistant { lon_0, lat_0 }), + "merc" => Arc::new(Mercator { lon_0 }), + "mill" => Arc::new(Miller { lon_0 }), + "eqc" => Arc::new(Equirectangular { lon_0 }), + "cea" => Arc::new(CylindricalEqualArea { lon_0 }), + "robin" => Arc::new(Robinson { lon_0 }), + "moll" => Arc::new(Mollweide { lon_0 }), + "sinu" => Arc::new(Sinusoidal { lon_0 }), + "eck4" => Arc::new(Eckert4 { lon_0 }), + "natearth" => Arc::new(NaturalEarth { lon_0 }), + "igh" => Arc::new(Igh { lon_0 }), + "wintri" => Arc::new(WinkelTripel { lon_0 }), + "aea" => Arc::new(AlbersEqualArea { + lon_0, + lat_0, + lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), + lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), + }), + "lcc" => Arc::new(LambertConformalConic { + lon_0, + lat_0, + lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), + lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), + }), + _ => Arc::new(UnknownProj { + raw: crs.to_string(), + lon_0, + lat_0, + }), + } + } else { + // Extract center: number (lon only) or array (lon, lat) + let (lon_0, lat_0) = match properties.get("center") { + Some(ParameterValue::Number(lon)) => (*lon, 0.0), + Some(ParameterValue::Array(arr)) => { + let lon = match arr.get(0) { + Some(ArrayElement::Number(n)) => *n, + _ => 0.0, + }; + let lat = match arr.get(1) { + Some(ArrayElement::Number(n)) => *n, + _ => 0.0, + }; + (lon, lat) + } + _ => (0.0, 0.0), + }; + + // Extract parallel: number (tangent) or array (secant) + let (lat_1, lat_2) = match properties.get("parallel") { + Some(ParameterValue::Number(lat)) => (*lat, *lat), + Some(ParameterValue::Array(arr)) => { + let l1 = match arr.get(0) { + Some(ArrayElement::Number(n)) => *n, + _ => 29.5, + }; + let l2 = match arr.get(1) { + Some(ArrayElement::Number(n)) => *n, + _ => 45.5, + }; + (l1, l2) + } + _ => (29.5, 45.5), + }; + + match name { + "mercator" => Arc::new(Mercator { lon_0 }), + "orthographic" => Arc::new(Orthographic { lon_0, lat_0 }), + "miller" => Arc::new(Miller { lon_0 }), + "equirectangular" => Arc::new(Equirectangular { lon_0 }), + "stereographic" => Arc::new(Stereographic { lon_0, lat_0 }), + "gnomonic" => Arc::new(Gnomonic { lon_0, lat_0 }), + "equal_area" => Arc::new(CylindricalEqualArea { lon_0 }), + "mollweide" => Arc::new(Mollweide { lon_0 }), + "sinusoidal" => Arc::new(Sinusoidal { lon_0 }), + "eckert4" => Arc::new(Eckert4 { lon_0 }), + "natural" => Arc::new(NaturalEarth { lon_0 }), + "winkel_tripel" => Arc::new(WinkelTripel { lon_0 }), + "albers" => Arc::new(AlbersEqualArea { lon_0, lat_0, lat_1, lat_2 }), + "lambert_conformal" => Arc::new(LambertConformalConic { lon_0, lat_0, lat_1, lat_2 }), + "lambert" => Arc::new(LambertAzimuthal { lon_0, lat_0 }), + "azimuthal_equidistant" => Arc::new(AzimuthalEquidistant { lon_0, lat_0 }), + "igh" => Arc::new(Igh { lon_0 }), + "robinson" => Arc::new(Robinson { lon_0 }), + "map" => Arc::new(UnknownProj { raw: String::new(), lon_0, lat_0 }), + _ => return None, + } }; Some(Self(obj)) } pub fn from_proj_str(crs: &str) -> Self { - let code = extract_proj_param_str(crs, "+proj=").unwrap_or(""); - let lon_0 = extract_f64_param(crs, "+lon_0=").unwrap_or(0.0); - let lat_0 = extract_f64_param(crs, "+lat_0=").unwrap_or(0.0); - - let obj: Arc = match code { - "ortho" => Arc::new(Orthographic { lon_0, lat_0 }), - "stere" => Arc::new(Stereographic { lon_0, lat_0 }), - "gnom" => Arc::new(Gnomonic { lon_0, lat_0 }), - "laea" => Arc::new(LambertAzimuthal { lon_0, lat_0 }), - "aeqd" => Arc::new(AzimuthalEquidistant { lon_0, lat_0 }), - "merc" => Arc::new(Mercator { lon_0 }), - "mill" => Arc::new(Miller { lon_0 }), - "eqc" => Arc::new(Equirectangular { lon_0 }), - "cea" => Arc::new(CylindricalEqualArea { lon_0 }), - "robin" => Arc::new(Robinson { lon_0 }), - "moll" => Arc::new(Mollweide { lon_0 }), - "sinu" => Arc::new(Sinusoidal { lon_0 }), - "eck4" => Arc::new(Eckert4 { lon_0 }), - "natearth" => Arc::new(NaturalEarth { lon_0 }), - "igh" => Arc::new(Igh { lon_0 }), - "wintri" => Arc::new(WinkelTripel { lon_0 }), - "aea" => Arc::new(AlbersEqualArea { - lon_0, - lat_0, - lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), - lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), - }), - "lcc" => Arc::new(LambertConformalConic { - lon_0, - lat_0, - lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), - lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), - }), - _ => Arc::new(UnknownProj { - raw: crs.to_string(), - lon_0, - lat_0, - }), - }; - Self(obj) + let mut properties = HashMap::new(); + properties.insert("crs".to_string(), ParameterValue::String(crs.to_string())); + Self::new(Some("map"), &properties).unwrap() } pub fn proj_code(&self) -> &'static str { @@ -997,6 +1041,48 @@ mod tests { ); } + #[test] + fn new_from_crs_albers() { + let mut props = HashMap::new(); + props.insert( + "crs".to_string(), + ParameterValue::String("+proj=aea +lon_0=5 +lat_0=10 +lat_1=30 +lat_2=50".to_string()), + ); + let proj = MapSpecification::new(Some("map"), &props).unwrap(); + assert_eq!(proj.proj_code(), "aea"); + assert_eq!(proj.center(), (5.0, 10.0)); + assert_eq!( + proj.to_proj_str(), + "+proj=aea +lon_0=5 +lat_0=10 +lat_1=30 +lat_2=50" + ); + } + + #[test] + fn new_named_albers_with_settings() { + let mut props = HashMap::new(); + props.insert( + "center".to_string(), + ParameterValue::Array(vec![ + ArrayElement::Number(-96.0), + ArrayElement::Number(37.5), + ]), + ); + props.insert( + "parallel".to_string(), + ParameterValue::Array(vec![ + ArrayElement::Number(29.5), + ArrayElement::Number(45.5), + ]), + ); + let proj = MapSpecification::new(Some("albers"), &props).unwrap(); + assert_eq!(proj.proj_code(), "aea"); + assert_eq!(proj.center(), (-96.0, 37.5)); + assert_eq!( + proj.to_proj_str(), + "+proj=aea +lon_0=-96 +lat_0=37.5 +lat_1=29.5 +lat_2=45.5" + ); + } + #[test] fn from_proj_str_unknown_projection() { let proj = MapSpecification::from_proj_str("+proj=fooproj +lon_0=5"); diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index 0815d791d..308d4193d 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -176,8 +176,8 @@ impl Coord { } /// Create a Map coord type - pub fn map() -> Self { - Self(Arc::new(Map)) + pub fn map(name: &str) -> Self { + Self(Arc::new(Map::new(name))) } /// Create a Coord from a CoordKind @@ -185,7 +185,7 @@ impl Coord { match kind { CoordKind::Cartesian => Self::cartesian(), CoordKind::Polar => Self::polar(), - CoordKind::Map => Self::map(), + CoordKind::Map => Self::map("map"), } } diff --git a/src/plot/projection/types.rs b/src/plot/projection/types.rs index fb800803b..c69401cf7 100644 --- a/src/plot/projection/types.rs +++ b/src/plot/projection/types.rs @@ -44,7 +44,7 @@ impl Projection { /// Create a default Map projection (lon, lat). pub fn map() -> Self { - Self::with_defaults(Coord::map()) + Self::with_defaults(Coord::map("map")) } fn with_defaults(coord: Coord) -> Self { From 1cdd9be56efc4bff9e4f3fb6b0acb2cd0b956cec Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 21 May 2026 13:41:12 +0200 Subject: [PATCH 43/50] Honour clip => false setting in map projection The clip property was accepted but never read. Now when clip => false, skip boundary materialization so geometries are not clipped to the projection's visible area. Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/coord/map.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index def0a83dd..ff4844cac 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -200,20 +200,27 @@ impl CoordTrait for Map { } // Step 2: Materialize clip boundary, panel boundary, and world bbox. + let clip_enabled = match projection.properties.get("clip") { + Some(ParameterValue::Boolean(b)) => *b, + _ => true, + }; let mut world_bbox: Option = None; let mut boundary_lonlat: Option = None; - if let Some(map_proj) = projection.map_projection.as_ref() { - if map_proj.visible_area_wkt().is_some() { - let b = materialize_clip_boundary(map_proj, &source, dialect, execute_query)?; - if let Some(wkt) = boundary_to_target_crs(&b, &crs, dialect, execute_query) { - projection - .computed - .insert("panel_boundary".to_string(), ParameterValue::String(wkt)); - } - world_bbox = compute_world_bbox(&source, &crs, dialect, execute_query); - boundary_lonlat = Some(b); + let clip_wkt = clip_enabled + .then(|| projection.map_projection.as_ref()) + .flatten() + .and_then(|map_proj| map_proj.visible_area_wkt().map(|_| map_proj)); + + if let Some(map_proj) = clip_wkt { + let b = materialize_clip_boundary(map_proj, &source, dialect, execute_query)?; + if let Some(wkt) = boundary_to_target_crs(&b, &crs, dialect, execute_query) { + projection + .computed + .insert("panel_boundary".to_string(), ParameterValue::String(wkt)); } + world_bbox = compute_world_bbox(&source, &crs, dialect, execute_query); + boundary_lonlat = Some(b); } let clip = boundary_lonlat.is_some(); From 95782d866d8db3f831c2068ac833030504091a47 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 21 May 2026 14:43:14 +0200 Subject: [PATCH 44/50] manual constraint on latitude center --- src/plot/projection/coord/map.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index ff4844cac..bd30cec16 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -142,6 +142,18 @@ impl CoordTrait for Map { )); } } + // ArrayConstraint applies uniform bounds to all elements, so we validate + // the latitude element (index 1) separately against [-90, 90]. + if let Some(ParameterValue::Array(arr)) = properties.get("center") { + if let Some(crate::plot::types::ArrayElement::Number(lat)) = arr.get(1) { + if *lat < -90.0 || *lat > 90.0 { + return Err(format!( + "center latitude must be between -90 and 90, got {}", + lat + )); + } + } + } let mut resolved = properties.clone(); for param in defaults { if !resolved.contains_key(param.name) { @@ -956,6 +968,24 @@ mod tests { assert!(err.contains("Cannot combine 'crs'")); } + #[test] + fn test_center_rejects_latitude_out_of_range() { + let map = Map::new("albers"); + let mut props = HashMap::new(); + props.insert( + "center".to_string(), + ParameterValue::Array(vec![ + crate::plot::types::ArrayElement::Number(0.0), + crate::plot::types::ArrayElement::Number(95.0), + ]), + ); + + let resolved = map.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("center latitude must be between -90 and 90")); + } + fn bbox(xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> BBox { BBox::from_array([xmin, ymin, xmax, ymax], "EPSG:4326") } From f13ba1dfc587713f12af512028c4bfdd1b3f3cd5 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 21 May 2026 16:05:37 +0200 Subject: [PATCH 45/50] annotations don't train bbox --- src/plot/projection/coord/map.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index bd30cec16..27f258d86 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -8,7 +8,7 @@ use crate::plot::layer::geom::GeomType; use crate::plot::types::{ validate_parameter, DefaultParamValue, ParamConstraint, ParamDefinition, TypeConstraint, }; -use crate::plot::{Layer, ParameterValue}; +use crate::plot::{DataSource, Layer, ParameterValue}; use crate::reader::SqlDialect; use crate::DataFrame; @@ -251,13 +251,14 @@ impl CoordTrait for Map { let mut data_bbox: Option = None; for (idx, layer) in layers.iter().enumerate() { + let is_annotation = matches!(layer.source, Some(DataSource::Annotation)); let is_spatial = layer.geom.geom_type() == GeomType::Spatial; let has_projected_positions = !is_spatial && source != crs && layer.mappings.contains_key("pos1") && layer.mappings.contains_key("pos2"); - if !is_spatial && !has_projected_positions { + if is_annotation || (!is_spatial && !has_projected_positions) { continue; } From 65637b4238651d90a1a9259c2caba01ae2147371 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 21 May 2026 16:27:02 +0200 Subject: [PATCH 46/50] add docs --- doc/syntax/coord/map.qmd | 180 +++++++++++++++++++++++++++++++++++++ doc/syntax/coord/polar.qmd | 2 +- 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 doc/syntax/coord/map.qmd diff --git a/doc/syntax/coord/map.qmd b/doc/syntax/coord/map.qmd new file mode 100644 index 000000000..97aee0311 --- /dev/null +++ b/doc/syntax/coord/map.qmd @@ -0,0 +1,180 @@ +--- +title: Map +--- + +The map coordinate system facilitates the display of geographical data. +It can project data to various coordinate reference systems or projections. +It is used to display choropleths and other types of maps. + +## Default aesthetics + +* **Primary**: `lon` for longitude (horizontal) +* **Secondary**: `lat` for latitude (vertical) + +Users can provide their own aesthetic names if needed. +For example, if using `x` and `y` aesthetics: + +```ggsql +PROJECT x, y TO map +``` + +This maps `x` to longitude and `y` to latitude. +This is a convenience when converting from a Cartesian coordinate system, without having to modify all the mappings. + +## Settings + +* `clip`: Should data be removed if it appears outside the bounds of the coordinate system, or has become invalid due to the projection. + Defaults to `true`. +* `crs` A string giving the target Coordinate Reference System (CRS) holding parameters of a coordinate transformation. + This is only used with unnamed proejctions and therefore mutually exclusive with the `center` and `parallel` settings. + By default, matches the Spatial Reference Identifiers (SRIDs) of geometry columns. + Typically takes one of following forms: + * A [proj string](https://proj.org/en/stable/usage/quickstart.html) like `'+proj=longlat +lon_0=90'` for 2D longitude/latitude with 90° longitude offset. + * A [EPSG code](https://en.wikipedia.org/wiki/EPSG_Geodetic_Parameter_Dataset) like `'EPSG:3395'` for World Mercator projection. +* `source` A string giving the source CRS for the data to use as fallback when not explicit in geometry. + Useful when using non-spatial layers that don't carry SRIDs. + Defaults to `'EPGS:4326'` or longitude/latitude coordinates. +* `bounds` A numeric array of length 4 giving the bounding box of the area to view. + The numbers mean ('xmin', 'ymin', 'xmax', 'ymax') in that order, in the units of the `crs` argument. + Be mindful that your data might be in longitude/latitude degrees, but the `crs` might use meters, or vice versa. + Bounds can have `Inf`/`-Inf` values to indicate the visible area's extent. + Using `null` values indicates to fall back to the data's extent, so only partial bounds need to be indicated. +* `center` Projection center location. + This is only used with named projections and therefore mutually exclusive with `crs`. + One of the following: + * A scalar number, setting the center longitude or 'central meridian' in degrees between −180 and +180. + Corresponds to the `+lon_0` proj string parameter. + * An array of two numbers, where the first number is the scalar option above. + The second number sets the latitude of origin in degrees between −90 and +90. + The latter corresponds to the `+lat_0` proj string parameter. + If unsupported by the projection, the latitude is silently ignored. +* `parallel` Setting for conic or cylindrical projections. + This is only used with named projects and therefore mutually exclusive with `crs`. + If unsupported by the projection, this setting is silently ignored. + One of the following: + * A scalar number, setting the standard parallel as degrees between −90 and +90. + Corresponds to the `+lat_1` proj string parameter. + * An array of two numbers, where the first number is the scalar option above. + The second number sets the second standard parallel as degrees between −90 and +90. + +## Supported projections + +There are two main ways to set the map projection. +One is via a named projection, which allows for the `center` and `parallel` settings. + +```ggsql +PROJECT TO orthographic SETTING center => (150, 52) +``` + +The second option is to use the generic `PROJECT TO map` together with the `crs` setting. +The code below is equivalent to the code above. + +```ggsql +PROJECT TO map SETTING crs => '+proj=ortho +lon_0=150 +lat_0=52' +``` + +In this context, 'supported' means that we are reasonably confident that we can draw a servicable map. +Below follows a table giving an overview of the supported projections. +The projections are recognised by name in the 'string' column or from the 'proj string' CRS setting. +More information about them can be found on the [PRØJ website](https://proj.org/en/stable/operations/projections/index.html). + +| name | string | center | parallel | proj string | +|---------------------------------------|-------------------------|--------|--------------|------------------| +| Mercator | `mercator` | 0 | NA | `+proj=merc` | +| Orthographic | `orthographic` | (0, 0) | NA | `+proj=ortho` | +| Miller Cylindrical | `miller` | 0 | NA | `+proj=mill` | +| Equidistant Cylindrical (Plate Carée) | `equirectangular` | 0 | NA | `+proj=eqc` | +| Stereographic | `stereographic` | (0, 0) | NA | `+proj=stere` | +| Gnomonic | `gnomonic` | (0, 0) | NA | `+proj=gnom` | +| Cylindrical Equal Area | `equal_area` | 0 | NA | `+proj=cea` | +| Mollweide | `mollweide` | 0 | NA | `+proj=moll` | +| Sanson-Flamsteed Sinusoidal | `sinusoidal` | 0 | NA | `+proj=sinu` | +| Eckert IV | `eckert4` | 0 | NA | `+proj=eck4` | +| Natural Earth | `natural` | 0 | NA | `+proj=natearth` | +| Winkel Tripel | `winkel_tripel` | 0 | NA | `+proj=wintri` | +| Albers Equal Area | `albers` | (0, 0) | (29.5, 45.5) | `+proj=aea` | +| Lambert Conformal Conic | `lambert_conformal` | (0, 0) | (29.5, 45.5) | `+proj=lcc` | +| Lambert Azimuthal | `lambert` | (0, 0) | NA | `+proj=laea` | +| Azimuthal Equidistant | `azimuthal_equidistant` | (0, 0) | NA | `+proj=aeqd` | +| Interrupted Goode Homolosine | `igh` | 0 | NA | `+proj=igh` | +| Robinson | `robinson` | 0 | NA | `+proj=robin` | + +The way to draw an unsupported map is to use the `crs` setting. +Unsupported maps are likely drawn without projection-appropriate clipping. + +## Examples + +Note that depending on your reader, you may need to activate modules for spatial analysis. + +```{ggsql} +-- For example, for DuckDB, one could use: +INSTALL spatial; +LOAD spatial; +``` + +### Via named projection + +```{ggsql} +VISUALISE continent AS fill FROM ggsql:world +DRAW spatial +PROJECT TO robinson +``` + +### Via proj string + +```{ggsql} +VISUALISE continent AS fill FROM ggsql:world +DRAW spatial +PROJECT TO map SETTING crs => '+proj=robin' +``` + +### Centering elsewhere + +```{ggsql} +VISUALISE continent AS fill FROM ggsql:world +DRAW spatial +PROJECT TO orthographic + SETTING center => (133.77, -25.27) +``` + +### Zooming in + +The default zoom behaviour is to zoom in where the data is. + +```{ggsql} +VISUALISE continent AS fill FROM ggsql:world +DRAW spatial + FILTER subregion == 'Western Africa' +PROJECT TO orthographic +``` + +An alternative is to zoom using the `bounds` setting. +Note that while `ggsql:world` is defined in degrees longitude/latitude, the `orthographic` projection uses metres as unit. + +```{ggsql} +VISUALISE continent AS fill FROM ggsql:world +DRAW spatial +PROJECT TO orthographic + SETTING bounds => (-1868152, 468481, 1638882, 2917218) +``` + +The `bounds` arguments can take infinites to take the visible area's extreme values, or `null` to take take the data's bounds. + +```{ggsql} +VISUALISE continent AS fill FROM ggsql:world +DRAW spatial + FILTER subregion == 'Western Africa' +PROJECT TO orthographic + SETTING bounds => (-1868152, -Inf, 1638882, null) +``` + +### Parallels + +Default conic projections aren't always kind to the southern hemisphere. +This can be changed by tweaking the `parallel` setting. + +```{ggsql} +VISUALISE continent AS fill FROM ggsql:world +DRAW spatial +PROJECT TO albers SETTING parallel => (-29.5, -45.5) +``` \ No newline at end of file diff --git a/doc/syntax/coord/polar.qmd b/doc/syntax/coord/polar.qmd index 24e8af75d..a17e97f30 100644 --- a/doc/syntax/coord/polar.qmd +++ b/doc/syntax/coord/polar.qmd @@ -16,7 +16,7 @@ Users can provide their own aesthetic names if needed. For example, if using `x` PROJECT y, x TO polar ``` -This maps `y` to radius and `x` to angle. This is useful when converting from a cartesian coordinate system without editing all the mappings. +This maps `y` to radius and `x` to angle. This is useful when converting from a Cartesian coordinate system without editing all the mappings. ## Settings * `clip`: Should data be removed if it appears outside the bounds of the coordinate system. Defaults to `true` From 983671e30f088403f6a96526a41470ba02d9604d Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 21 May 2026 16:33:01 +0200 Subject: [PATCH 47/50] the pacification of clippy and cargo fmt --- src/plot/projection/coord/map.rs | 4 +- src/plot/projection/coord/map_projections.rs | 210 ++++++++++--------- src/plot/projection/resolve.rs | 2 +- 3 files changed, 113 insertions(+), 103 deletions(-) diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 27f258d86..1e809d694 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -207,7 +207,7 @@ impl CoordTrait for Map { return Err(crate::GgsqlError::ValidationError(format!( "Invalid CRS '{}': {}", crs, - msg.split(':').last().unwrap_or(&msg).trim() + msg.split(':').next_back().unwrap_or(&msg).trim() ))); } @@ -220,7 +220,7 @@ impl CoordTrait for Map { let mut boundary_lonlat: Option = None; let clip_wkt = clip_enabled - .then(|| projection.map_projection.as_ref()) + .then_some(projection.map_projection.as_ref()) .flatten() .and_then(|map_proj| map_proj.visible_area_wkt().map(|_| map_proj)); diff --git a/src/plot/projection/coord/map_projections.rs b/src/plot/projection/coord/map_projections.rs index 69ec7c39d..326b8f4e2 100644 --- a/src/plot/projection/coord/map_projections.rs +++ b/src/plot/projection/coord/map_projections.rs @@ -93,105 +93,118 @@ impl MapSpecification { pub fn new(name: Option<&str>, properties: &HashMap) -> Option { let name = name?; - let obj: Arc = if let Some(ParameterValue::String(crs)) = - properties.get("crs") - { - let code = extract_proj_param_str(crs, "+proj=").unwrap_or(""); - let lon_0 = extract_f64_param(crs, "+lon_0=").unwrap_or(0.0); - let lat_0 = extract_f64_param(crs, "+lat_0=").unwrap_or(0.0); - match code { - "ortho" => Arc::new(Orthographic { lon_0, lat_0 }), - "stere" => Arc::new(Stereographic { lon_0, lat_0 }), - "gnom" => Arc::new(Gnomonic { lon_0, lat_0 }), - "laea" => Arc::new(LambertAzimuthal { lon_0, lat_0 }), - "aeqd" => Arc::new(AzimuthalEquidistant { lon_0, lat_0 }), - "merc" => Arc::new(Mercator { lon_0 }), - "mill" => Arc::new(Miller { lon_0 }), - "eqc" => Arc::new(Equirectangular { lon_0 }), - "cea" => Arc::new(CylindricalEqualArea { lon_0 }), - "robin" => Arc::new(Robinson { lon_0 }), - "moll" => Arc::new(Mollweide { lon_0 }), - "sinu" => Arc::new(Sinusoidal { lon_0 }), - "eck4" => Arc::new(Eckert4 { lon_0 }), - "natearth" => Arc::new(NaturalEarth { lon_0 }), - "igh" => Arc::new(Igh { lon_0 }), - "wintri" => Arc::new(WinkelTripel { lon_0 }), - "aea" => Arc::new(AlbersEqualArea { - lon_0, - lat_0, - lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), - lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), - }), - "lcc" => Arc::new(LambertConformalConic { - lon_0, - lat_0, - lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), - lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), - }), - _ => Arc::new(UnknownProj { - raw: crs.to_string(), - lon_0, - lat_0, - }), - } - } else { - // Extract center: number (lon only) or array (lon, lat) - let (lon_0, lat_0) = match properties.get("center") { - Some(ParameterValue::Number(lon)) => (*lon, 0.0), - Some(ParameterValue::Array(arr)) => { - let lon = match arr.get(0) { - Some(ArrayElement::Number(n)) => *n, - _ => 0.0, - }; - let lat = match arr.get(1) { - Some(ArrayElement::Number(n)) => *n, - _ => 0.0, - }; - (lon, lat) + let obj: Arc = + if let Some(ParameterValue::String(crs)) = properties.get("crs") { + let code = extract_proj_param_str(crs, "+proj=").unwrap_or(""); + let lon_0 = extract_f64_param(crs, "+lon_0=").unwrap_or(0.0); + let lat_0 = extract_f64_param(crs, "+lat_0=").unwrap_or(0.0); + match code { + "ortho" => Arc::new(Orthographic { lon_0, lat_0 }), + "stere" => Arc::new(Stereographic { lon_0, lat_0 }), + "gnom" => Arc::new(Gnomonic { lon_0, lat_0 }), + "laea" => Arc::new(LambertAzimuthal { lon_0, lat_0 }), + "aeqd" => Arc::new(AzimuthalEquidistant { lon_0, lat_0 }), + "merc" => Arc::new(Mercator { lon_0 }), + "mill" => Arc::new(Miller { lon_0 }), + "eqc" => Arc::new(Equirectangular { lon_0 }), + "cea" => Arc::new(CylindricalEqualArea { lon_0 }), + "robin" => Arc::new(Robinson { lon_0 }), + "moll" => Arc::new(Mollweide { lon_0 }), + "sinu" => Arc::new(Sinusoidal { lon_0 }), + "eck4" => Arc::new(Eckert4 { lon_0 }), + "natearth" => Arc::new(NaturalEarth { lon_0 }), + "igh" => Arc::new(Igh { lon_0 }), + "wintri" => Arc::new(WinkelTripel { lon_0 }), + "aea" => Arc::new(AlbersEqualArea { + lon_0, + lat_0, + lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), + lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), + }), + "lcc" => Arc::new(LambertConformalConic { + lon_0, + lat_0, + lat_1: extract_f64_param(crs, "+lat_1=").unwrap_or(29.5), + lat_2: extract_f64_param(crs, "+lat_2=").unwrap_or(45.5), + }), + _ => Arc::new(UnknownProj { + raw: crs.to_string(), + lon_0, + lat_0, + }), } - _ => (0.0, 0.0), - }; - - // Extract parallel: number (tangent) or array (secant) - let (lat_1, lat_2) = match properties.get("parallel") { - Some(ParameterValue::Number(lat)) => (*lat, *lat), - Some(ParameterValue::Array(arr)) => { - let l1 = match arr.get(0) { - Some(ArrayElement::Number(n)) => *n, - _ => 29.5, - }; - let l2 = match arr.get(1) { - Some(ArrayElement::Number(n)) => *n, - _ => 45.5, - }; - (l1, l2) + } else { + // Extract center: number (lon only) or array (lon, lat) + let (lon_0, lat_0) = match properties.get("center") { + Some(ParameterValue::Number(lon)) => (*lon, 0.0), + Some(ParameterValue::Array(arr)) => { + let lon = match arr.first() { + Some(ArrayElement::Number(n)) => *n, + _ => 0.0, + }; + let lat = match arr.get(1) { + Some(ArrayElement::Number(n)) => *n, + _ => 0.0, + }; + (lon, lat) + } + _ => (0.0, 0.0), + }; + + // Extract parallel: number (tangent) or array (secant) + let (lat_1, lat_2) = match properties.get("parallel") { + Some(ParameterValue::Number(lat)) => (*lat, *lat), + Some(ParameterValue::Array(arr)) => { + let l1 = match arr.first() { + Some(ArrayElement::Number(n)) => *n, + _ => 29.5, + }; + let l2 = match arr.get(1) { + Some(ArrayElement::Number(n)) => *n, + _ => 45.5, + }; + (l1, l2) + } + _ => (29.5, 45.5), + }; + + match name { + "mercator" => Arc::new(Mercator { lon_0 }), + "orthographic" => Arc::new(Orthographic { lon_0, lat_0 }), + "miller" => Arc::new(Miller { lon_0 }), + "equirectangular" => Arc::new(Equirectangular { lon_0 }), + "stereographic" => Arc::new(Stereographic { lon_0, lat_0 }), + "gnomonic" => Arc::new(Gnomonic { lon_0, lat_0 }), + "equal_area" => Arc::new(CylindricalEqualArea { lon_0 }), + "mollweide" => Arc::new(Mollweide { lon_0 }), + "sinusoidal" => Arc::new(Sinusoidal { lon_0 }), + "eckert4" => Arc::new(Eckert4 { lon_0 }), + "natural" => Arc::new(NaturalEarth { lon_0 }), + "winkel_tripel" => Arc::new(WinkelTripel { lon_0 }), + "albers" => Arc::new(AlbersEqualArea { + lon_0, + lat_0, + lat_1, + lat_2, + }), + "lambert_conformal" => Arc::new(LambertConformalConic { + lon_0, + lat_0, + lat_1, + lat_2, + }), + "lambert" => Arc::new(LambertAzimuthal { lon_0, lat_0 }), + "azimuthal_equidistant" => Arc::new(AzimuthalEquidistant { lon_0, lat_0 }), + "igh" => Arc::new(Igh { lon_0 }), + "robinson" => Arc::new(Robinson { lon_0 }), + "map" => Arc::new(UnknownProj { + raw: String::new(), + lon_0, + lat_0, + }), + _ => return None, } - _ => (29.5, 45.5), }; - - match name { - "mercator" => Arc::new(Mercator { lon_0 }), - "orthographic" => Arc::new(Orthographic { lon_0, lat_0 }), - "miller" => Arc::new(Miller { lon_0 }), - "equirectangular" => Arc::new(Equirectangular { lon_0 }), - "stereographic" => Arc::new(Stereographic { lon_0, lat_0 }), - "gnomonic" => Arc::new(Gnomonic { lon_0, lat_0 }), - "equal_area" => Arc::new(CylindricalEqualArea { lon_0 }), - "mollweide" => Arc::new(Mollweide { lon_0 }), - "sinusoidal" => Arc::new(Sinusoidal { lon_0 }), - "eckert4" => Arc::new(Eckert4 { lon_0 }), - "natural" => Arc::new(NaturalEarth { lon_0 }), - "winkel_tripel" => Arc::new(WinkelTripel { lon_0 }), - "albers" => Arc::new(AlbersEqualArea { lon_0, lat_0, lat_1, lat_2 }), - "lambert_conformal" => Arc::new(LambertConformalConic { lon_0, lat_0, lat_1, lat_2 }), - "lambert" => Arc::new(LambertAzimuthal { lon_0, lat_0 }), - "azimuthal_equidistant" => Arc::new(AzimuthalEquidistant { lon_0, lat_0 }), - "igh" => Arc::new(Igh { lon_0 }), - "robinson" => Arc::new(Robinson { lon_0 }), - "map" => Arc::new(UnknownProj { raw: String::new(), lon_0, lat_0 }), - _ => return None, - } - }; Some(Self(obj)) } @@ -1069,10 +1082,7 @@ mod tests { ); props.insert( "parallel".to_string(), - ParameterValue::Array(vec![ - ArrayElement::Number(29.5), - ArrayElement::Number(45.5), - ]), + ParameterValue::Array(vec![ArrayElement::Number(29.5), ArrayElement::Number(45.5)]), ); let proj = MapSpecification::new(Some("albers"), &props).unwrap(); assert_eq!(proj.proj_code(), "aea"); diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index 9b95fc3a2..0eabc98b5 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -45,7 +45,7 @@ pub fn resolve_coord( } // Check if any layer is spatial - let mut found_map = layer_geom_types.iter().any(|g| *g == GeomType::Spatial); + let mut found_map = layer_geom_types.contains(&GeomType::Spatial); // Collect all explicit aesthetic keys from global and layer mappings let mut found_cartesian = false; From bbd8c8c0c94f965bec55479cbf0ec72c967a1495 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 21 May 2026 16:47:39 +0200 Subject: [PATCH 48/50] rename center -> origin --- doc/syntax/coord/map.qmd | 22 +++---- src/plot/projection/coord/map.rs | 28 ++++----- src/plot/projection/coord/map_projections.rs | 64 ++++++++++---------- 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/doc/syntax/coord/map.qmd b/doc/syntax/coord/map.qmd index 97aee0311..2b737a53d 100644 --- a/doc/syntax/coord/map.qmd +++ b/doc/syntax/coord/map.qmd @@ -26,7 +26,7 @@ This is a convenience when converting from a Cartesian coordinate system, withou * `clip`: Should data be removed if it appears outside the bounds of the coordinate system, or has become invalid due to the projection. Defaults to `true`. * `crs` A string giving the target Coordinate Reference System (CRS) holding parameters of a coordinate transformation. - This is only used with unnamed proejctions and therefore mutually exclusive with the `center` and `parallel` settings. + This is only used with unnamed proejctions and therefore mutually exclusive with the `origin` and `parallel` settings. By default, matches the Spatial Reference Identifiers (SRIDs) of geometry columns. Typically takes one of following forms: * A [proj string](https://proj.org/en/stable/usage/quickstart.html) like `'+proj=longlat +lon_0=90'` for 2D longitude/latitude with 90° longitude offset. @@ -39,12 +39,12 @@ This is a convenience when converting from a Cartesian coordinate system, withou Be mindful that your data might be in longitude/latitude degrees, but the `crs` might use meters, or vice versa. Bounds can have `Inf`/`-Inf` values to indicate the visible area's extent. Using `null` values indicates to fall back to the data's extent, so only partial bounds need to be indicated. -* `center` Projection center location. - This is only used with named projections and therefore mutually exclusive with `crs`. +* `origin` Projection origin location. + This is only used with named projections and therefore mutually exclusive with `crs`. One of the following: - * A scalar number, setting the center longitude or 'central meridian' in degrees between −180 and +180. + * A scalar number, setting the origin longitude or 'central meridian' in degrees between −180 and +180. Corresponds to the `+lon_0` proj string parameter. - * An array of two numbers, where the first number is the scalar option above. + * An array of two numbers, where the first number is the scalar option above. The second number sets the latitude of origin in degrees between −90 and +90. The latter corresponds to the `+lat_0` proj string parameter. If unsupported by the projection, the latitude is silently ignored. @@ -60,10 +60,10 @@ This is a convenience when converting from a Cartesian coordinate system, withou ## Supported projections There are two main ways to set the map projection. -One is via a named projection, which allows for the `center` and `parallel` settings. +One is via a named projection, which allows for the `origin` and `parallel` settings. ```ggsql -PROJECT TO orthographic SETTING center => (150, 52) +PROJECT TO orthographic SETTING origin => (150, 52) ``` The second option is to use the generic `PROJECT TO map` together with the `crs` setting. @@ -78,7 +78,7 @@ Below follows a table giving an overview of the supported projections. The projections are recognised by name in the 'string' column or from the 'proj string' CRS setting. More information about them can be found on the [PRØJ website](https://proj.org/en/stable/operations/projections/index.html). -| name | string | center | parallel | proj string | +| name | string | origin | parallel | proj string | |---------------------------------------|-------------------------|--------|--------------|------------------| | Mercator | `mercator` | 0 | NA | `+proj=merc` | | Orthographic | `orthographic` | (0, 0) | NA | `+proj=ortho` | @@ -128,13 +128,13 @@ DRAW spatial PROJECT TO map SETTING crs => '+proj=robin' ``` -### Centering elsewhere +### Setting the origin ```{ggsql} VISUALISE continent AS fill FROM ggsql:world DRAW spatial -PROJECT TO orthographic - SETTING center => (133.77, -25.27) +PROJECT TO orthographic + SETTING origin => (133.77, -25.27) ``` ### Zooming in diff --git a/src/plot/projection/coord/map.rs b/src/plot/projection/coord/map.rs index 1e809d694..06e4c2e7a 100644 --- a/src/plot/projection/coord/map.rs +++ b/src/plot/projection/coord/map.rs @@ -84,9 +84,9 @@ impl CoordTrait for Map { allow_null: true, }, }, - // center => 30 (lon only) or center => (30, 45) (lon, lat) + // origin => 30 (lon only) or origin => (30, 45) (lon, lat) ParamDefinition { - name: "center", + name: "origin", default: DefaultParamValue::Null, constraint: ParamConstraint::number_or_numeric_array( LON_RANGE, @@ -118,12 +118,12 @@ impl CoordTrait for Map { )); } let has_crs = properties.contains_key("crs"); - let has_center = properties.contains_key("center"); + let has_origin = properties.contains_key("origin"); let has_parallel = properties.contains_key("parallel"); - if has_crs && (has_center || has_parallel) { + if has_crs && (has_origin || has_parallel) { return Err( - "Cannot combine 'crs' setting with 'center' or 'parallel'. \ - Use either the CRS string or a named projection with 'center'/'parallel' settings." + "Cannot combine 'crs' setting with 'origin' or 'parallel'. \ + Use either the CRS string or a named projection with 'origin'/'parallel' settings." .to_string(), ); } @@ -144,11 +144,11 @@ impl CoordTrait for Map { } // ArrayConstraint applies uniform bounds to all elements, so we validate // the latitude element (index 1) separately against [-90, 90]. - if let Some(ParameterValue::Array(arr)) = properties.get("center") { + if let Some(ParameterValue::Array(arr)) = properties.get("origin") { if let Some(crate::plot::types::ArrayElement::Number(lat)) = arr.get(1) { if *lat < -90.0 || *lat > 90.0 { return Err(format!( - "center latitude must be between -90 and 90, got {}", + "origin latitude must be between -90 and 90, got {}", lat )); } @@ -915,7 +915,7 @@ mod tests { assert!(names.contains(&"source")); assert!(names.contains(&"clip")); assert!(names.contains(&"bounds")); - assert!(names.contains(&"center")); + assert!(names.contains(&"origin")); assert!(names.contains(&"parallel")); assert_eq!(defaults.len(), 6); } @@ -954,14 +954,14 @@ mod tests { } #[test] - fn test_crs_rejects_center_and_parallel() { + fn test_crs_rejects_origin_and_parallel() { let map = Map::new("map"); let mut props = HashMap::new(); props.insert( "crs".to_string(), ParameterValue::String("+proj=ortho".to_string()), ); - props.insert("center".to_string(), ParameterValue::Number(30.0)); + props.insert("origin".to_string(), ParameterValue::Number(30.0)); let resolved = map.resolve_properties(&props); assert!(resolved.is_err()); @@ -970,11 +970,11 @@ mod tests { } #[test] - fn test_center_rejects_latitude_out_of_range() { + fn test_origin_rejects_latitude_out_of_range() { let map = Map::new("albers"); let mut props = HashMap::new(); props.insert( - "center".to_string(), + "origin".to_string(), ParameterValue::Array(vec![ crate::plot::types::ArrayElement::Number(0.0), crate::plot::types::ArrayElement::Number(95.0), @@ -984,7 +984,7 @@ mod tests { let resolved = map.resolve_properties(&props); assert!(resolved.is_err()); let err = resolved.unwrap_err(); - assert!(err.contains("center latitude must be between -90 and 90")); + assert!(err.contains("origin latitude must be between -90 and 90")); } fn bbox(xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> BBox { diff --git a/src/plot/projection/coord/map_projections.rs b/src/plot/projection/coord/map_projections.rs index 326b8f4e2..8fbdea51b 100644 --- a/src/plot/projection/coord/map_projections.rs +++ b/src/plot/projection/coord/map_projections.rs @@ -40,7 +40,7 @@ pub const NAMED_PROJECTIONS: &[&str] = &[ pub trait MapProjectionTrait: fmt::Debug + Send + Sync { fn proj_code(&self) -> &'static str; fn display_name(&self) -> &'static str; - fn center(&self) -> (f64, f64); + fn origin(&self) -> (f64, f64); fn to_proj_str(&self) -> String; fn visible_area_wkt(&self) -> Option { @@ -66,7 +66,7 @@ pub trait MapProjectionTrait: fmt::Debug + Send + Sync { } fn slit_wkt(&self, epsilon: f64) -> Option { - let seam = wrap_lon(self.center().0 + 180.0); + let seam = wrap_lon(self.origin().0 + 180.0); if (seam - (-180.0)).abs() > epsilon && (seam - 180.0).abs() > epsilon { let segs = self.edge_segments()[1]; Some(rectangle_wkt( @@ -134,8 +134,8 @@ impl MapSpecification { }), } } else { - // Extract center: number (lon only) or array (lon, lat) - let (lon_0, lat_0) = match properties.get("center") { + // Extract origin: number (lon only) or array (lon, lat) + let (lon_0, lat_0) = match properties.get("origin") { Some(ParameterValue::Number(lon)) => (*lon, 0.0), Some(ParameterValue::Array(arr)) => { let lon = match arr.first() { @@ -222,8 +222,8 @@ impl MapSpecification { self.0.display_name() } - pub fn center(&self) -> (f64, f64) { - self.0.center() + pub fn origin(&self) -> (f64, f64) { + self.0.origin() } pub fn to_proj_str(&self) -> String { @@ -293,7 +293,7 @@ impl MapProjectionTrait for Orthographic { fn display_name(&self) -> &'static str { "Orthographic" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, self.lat_0) } fn to_proj_str(&self) -> String { @@ -317,7 +317,7 @@ impl MapProjectionTrait for Stereographic { fn display_name(&self) -> &'static str { "Stereographic" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, self.lat_0) } fn to_proj_str(&self) -> String { @@ -341,7 +341,7 @@ impl MapProjectionTrait for Gnomonic { fn display_name(&self) -> &'static str { "Gnomonic" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, self.lat_0) } fn to_proj_str(&self) -> String { @@ -365,7 +365,7 @@ impl MapProjectionTrait for LambertAzimuthal { fn display_name(&self) -> &'static str { "Lambert Azimuthal Equal-Area" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, self.lat_0) } fn to_proj_str(&self) -> String { @@ -389,7 +389,7 @@ impl MapProjectionTrait for AzimuthalEquidistant { fn display_name(&self) -> &'static str { "Azimuthal Equidistant" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, self.lat_0) } fn to_proj_str(&self) -> String { @@ -416,7 +416,7 @@ impl MapProjectionTrait for Mercator { fn display_name(&self) -> &'static str { "Mercator" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -442,7 +442,7 @@ impl MapProjectionTrait for Miller { fn display_name(&self) -> &'static str { "Miller" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -465,7 +465,7 @@ impl MapProjectionTrait for Equirectangular { fn display_name(&self) -> &'static str { "Equirectangular" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -488,7 +488,7 @@ impl MapProjectionTrait for CylindricalEqualArea { fn display_name(&self) -> &'static str { "Cylindrical Equal-Area" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -515,7 +515,7 @@ impl MapProjectionTrait for Robinson { fn display_name(&self) -> &'static str { "Robinson" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -535,7 +535,7 @@ impl MapProjectionTrait for Mollweide { fn display_name(&self) -> &'static str { "Mollweide" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -555,7 +555,7 @@ impl MapProjectionTrait for Sinusoidal { fn display_name(&self) -> &'static str { "Sinusoidal" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -575,7 +575,7 @@ impl MapProjectionTrait for Eckert4 { fn display_name(&self) -> &'static str { "Eckert IV" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -595,7 +595,7 @@ impl MapProjectionTrait for NaturalEarth { fn display_name(&self) -> &'static str { "Natural Earth" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -619,7 +619,7 @@ impl MapProjectionTrait for Igh { fn display_name(&self) -> &'static str { "Interrupted Goode Homolosine" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -649,7 +649,7 @@ impl MapProjectionTrait for AlbersEqualArea { fn display_name(&self) -> &'static str { "Albers Equal-Area" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, self.lat_0) } fn to_proj_str(&self) -> String { @@ -678,7 +678,7 @@ impl MapProjectionTrait for LambertConformalConic { fn display_name(&self) -> &'static str { "Lambert Conformal Conic" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, self.lat_0) } fn to_proj_str(&self) -> String { @@ -711,7 +711,7 @@ impl MapProjectionTrait for WinkelTripel { fn display_name(&self) -> &'static str { "Winkel Tripel" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, 0.0) } fn to_proj_str(&self) -> String { @@ -740,7 +740,7 @@ impl MapProjectionTrait for UnknownProj { fn display_name(&self) -> &'static str { "Unknown" } - fn center(&self) -> (f64, f64) { + fn origin(&self) -> (f64, f64) { (self.lon_0, self.lat_0) } fn to_proj_str(&self) -> String { @@ -1040,11 +1040,11 @@ mod tests { fn from_proj_str_known_projections() { let proj = MapSpecification::from_proj_str("+proj=ortho +lon_0=10 +lat_0=45"); assert_eq!(proj.proj_code(), "ortho"); - assert_eq!(proj.center(), (10.0, 45.0)); + assert_eq!(proj.origin(), (10.0, 45.0)); let proj = MapSpecification::from_proj_str("+proj=merc"); assert_eq!(proj.proj_code(), "merc"); - assert_eq!(proj.center(), (0.0, 0.0)); + assert_eq!(proj.origin(), (0.0, 0.0)); let proj = MapSpecification::from_proj_str("+proj=aea +lon_0=5 +lat_1=30 +lat_2=50"); assert_eq!(proj.proj_code(), "aea"); @@ -1063,7 +1063,7 @@ mod tests { ); let proj = MapSpecification::new(Some("map"), &props).unwrap(); assert_eq!(proj.proj_code(), "aea"); - assert_eq!(proj.center(), (5.0, 10.0)); + assert_eq!(proj.origin(), (5.0, 10.0)); assert_eq!( proj.to_proj_str(), "+proj=aea +lon_0=5 +lat_0=10 +lat_1=30 +lat_2=50" @@ -1074,7 +1074,7 @@ mod tests { fn new_named_albers_with_settings() { let mut props = HashMap::new(); props.insert( - "center".to_string(), + "origin".to_string(), ParameterValue::Array(vec![ ArrayElement::Number(-96.0), ArrayElement::Number(37.5), @@ -1086,7 +1086,7 @@ mod tests { ); let proj = MapSpecification::new(Some("albers"), &props).unwrap(); assert_eq!(proj.proj_code(), "aea"); - assert_eq!(proj.center(), (-96.0, 37.5)); + assert_eq!(proj.origin(), (-96.0, 37.5)); assert_eq!( proj.to_proj_str(), "+proj=aea +lon_0=-96 +lat_0=37.5 +lat_1=29.5 +lat_2=45.5" @@ -1097,7 +1097,7 @@ mod tests { fn from_proj_str_unknown_projection() { let proj = MapSpecification::from_proj_str("+proj=fooproj +lon_0=5"); assert_eq!(proj.proj_code(), "unknown"); - assert_eq!(proj.center(), (5.0, 0.0)); + assert_eq!(proj.origin(), (5.0, 0.0)); assert_eq!(proj.visible_area_wkt(), None); } @@ -1194,7 +1194,7 @@ mod tests { #[test] fn seam_position() { let proj = MapSpecification::from_proj_str("+proj=robin +lon_0=-90"); - let (lon_0, _) = proj.center(); + let (lon_0, _) = proj.origin(); let seam = wrap_lon(lon_0 + 180.0); assert!((seam - 90.0).abs() < 1e-6, "seam should be at 90°"); } From e7505255a8a612b9e69f807b4ac4ae2eda978691 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 21 May 2026 16:49:20 +0200 Subject: [PATCH 49/50] spelling --- doc/syntax/coord/map.qmd | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/doc/syntax/coord/map.qmd b/doc/syntax/coord/map.qmd index 2b737a53d..b796eee27 100644 --- a/doc/syntax/coord/map.qmd +++ b/doc/syntax/coord/map.qmd @@ -26,14 +26,14 @@ This is a convenience when converting from a Cartesian coordinate system, withou * `clip`: Should data be removed if it appears outside the bounds of the coordinate system, or has become invalid due to the projection. Defaults to `true`. * `crs` A string giving the target Coordinate Reference System (CRS) holding parameters of a coordinate transformation. - This is only used with unnamed proejctions and therefore mutually exclusive with the `origin` and `parallel` settings. + This is only used with unnamed projections and therefore mutually exclusive with the `origin` and `parallel` settings. By default, matches the Spatial Reference Identifiers (SRIDs) of geometry columns. - Typically takes one of following forms: + Typically takes one of the following forms: * A [proj string](https://proj.org/en/stable/usage/quickstart.html) like `'+proj=longlat +lon_0=90'` for 2D longitude/latitude with 90° longitude offset. - * A [EPSG code](https://en.wikipedia.org/wiki/EPSG_Geodetic_Parameter_Dataset) like `'EPSG:3395'` for World Mercator projection. + * An [EPSG code](https://en.wikipedia.org/wiki/EPSG_Geodetic_Parameter_Dataset) like `'EPSG:3395'` for World Mercator projection. * `source` A string giving the source CRS for the data to use as fallback when not explicit in geometry. Useful when using non-spatial layers that don't carry SRIDs. - Defaults to `'EPGS:4326'` or longitude/latitude coordinates. + Defaults to `'EPSG:4326'` or longitude/latitude coordinates. * `bounds` A numeric array of length 4 giving the bounding box of the area to view. The numbers mean ('xmin', 'ymin', 'xmax', 'ymax') in that order, in the units of the `crs` argument. Be mindful that your data might be in longitude/latitude degrees, but the `crs` might use meters, or vice versa. @@ -49,7 +49,7 @@ This is a convenience when converting from a Cartesian coordinate system, withou The latter corresponds to the `+lat_0` proj string parameter. If unsupported by the projection, the latitude is silently ignored. * `parallel` Setting for conic or cylindrical projections. - This is only used with named projects and therefore mutually exclusive with `crs`. + This is only used with named projections and therefore mutually exclusive with `crs`. If unsupported by the projection, this setting is silently ignored. One of the following: * A scalar number, setting the standard parallel as degrees between −90 and +90. @@ -73,7 +73,7 @@ The code below is equivalent to the code above. PROJECT TO map SETTING crs => '+proj=ortho +lon_0=150 +lat_0=52' ``` -In this context, 'supported' means that we are reasonably confident that we can draw a servicable map. +In this context, 'supported' means that we are reasonably confident that we can draw a serviceable map. Below follows a table giving an overview of the supported projections. The projections are recognised by name in the 'string' column or from the 'proj string' CRS setting. More information about them can be found on the [PRØJ website](https://proj.org/en/stable/operations/projections/index.html). @@ -158,7 +158,7 @@ PROJECT TO orthographic SETTING bounds => (-1868152, 468481, 1638882, 2917218) ``` -The `bounds` arguments can take infinites to take the visible area's extreme values, or `null` to take take the data's bounds. +The `bounds` arguments can take infinites to take the visible area's extreme values, or `null` to take the data's bounds. ```{ggsql} VISUALISE continent AS fill FROM ggsql:world From f2991a758643ed0536b7af371543af661c3b9032 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 21 May 2026 17:02:18 +0200 Subject: [PATCH 50/50] add longlat --- doc/syntax/coord/map.qmd | 1 + src/plot/projection/coord/map_projections.rs | 30 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/doc/syntax/coord/map.qmd b/doc/syntax/coord/map.qmd index b796eee27..e800aebb3 100644 --- a/doc/syntax/coord/map.qmd +++ b/doc/syntax/coord/map.qmd @@ -80,6 +80,7 @@ More information about them can be found on the [PRØJ website](https://proj.org | name | string | origin | parallel | proj string | |---------------------------------------|-------------------------|--------|--------------|------------------| +| Geographic (unprojected) | `geographic` | 0 | NA | `+proj=longlat` | | Mercator | `mercator` | 0 | NA | `+proj=merc` | | Orthographic | `orthographic` | (0, 0) | NA | `+proj=ortho` | | Miller Cylindrical | `miller` | 0 | NA | `+proj=mill` | diff --git a/src/plot/projection/coord/map_projections.rs b/src/plot/projection/coord/map_projections.rs index 8fbdea51b..aed1b2468 100644 --- a/src/plot/projection/coord/map_projections.rs +++ b/src/plot/projection/coord/map_projections.rs @@ -13,6 +13,7 @@ use crate::plot::ParameterValue; // - the `crs` branch (maps +proj= code to the new struct) // - the `else` branch (maps coord type name to the new struct) pub const NAMED_PROJECTIONS: &[&str] = &[ + "geographic", "mercator", "orthographic", "miller", @@ -99,6 +100,7 @@ impl MapSpecification { let lon_0 = extract_f64_param(crs, "+lon_0=").unwrap_or(0.0); let lat_0 = extract_f64_param(crs, "+lat_0=").unwrap_or(0.0); match code { + "longlat" | "latlong" => Arc::new(Geographic { lon_0 }), "ortho" => Arc::new(Orthographic { lon_0, lat_0 }), "stere" => Arc::new(Stereographic { lon_0, lat_0 }), "gnom" => Arc::new(Gnomonic { lon_0, lat_0 }), @@ -169,6 +171,7 @@ impl MapSpecification { }; match name { + "geographic" => Arc::new(Geographic { lon_0 }), "mercator" => Arc::new(Mercator { lon_0 }), "orthographic" => Arc::new(Orthographic { lon_0, lat_0 }), "miller" => Arc::new(Miller { lon_0 }), @@ -400,6 +403,33 @@ impl MapProjectionTrait for AzimuthalEquidistant { } } +// ============================================================================= +// Geographic (unprojected) +// ============================================================================= + +#[derive(Debug, Clone)] +pub struct Geographic { + pub lon_0: f64, +} + +impl MapProjectionTrait for Geographic { + fn proj_code(&self) -> &'static str { + "longlat" + } + fn display_name(&self) -> &'static str { + "Geographic" + } + fn origin(&self) -> (f64, f64) { + (self.lon_0, 0.0) + } + fn to_proj_str(&self) -> String { + format!("+proj=longlat +lon_0={}", self.lon_0) + } + fn edge_segments(&self) -> [usize; 4] { + [1, 1, 1, 1] + } +} + // ============================================================================= // Cylindrical projections // =============================================================================