Skip to main content

uni_query/query/
spatial.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4use anyhow::{Result, anyhow};
5use std::collections::HashMap;
6use uni_common::Value;
7
8const EARTH_RADIUS_KM: f64 = 6371.0;
9
10pub fn eval_spatial_function(name: &str, args: &[Value]) -> Result<Value> {
11    match name {
12        "POINT" => eval_point(args),
13        "DISTANCE" => eval_distance(args),
14        "POINT.WITHINBBOX" => eval_within_bbox(args),
15        _ => Err(anyhow!("Unknown spatial function: {}", name)),
16    }
17}
18
19fn eval_point(args: &[Value]) -> Result<Value> {
20    if args.len() != 1 {
21        return Err(anyhow!("point() requires exactly 1 map argument"));
22    }
23
24    let map = args[0]
25        .as_object()
26        .ok_or_else(|| anyhow!("point() requires a map argument"))?;
27
28    // Geographic point: {latitude, longitude}
29    if map.contains_key("latitude") && map.contains_key("longitude") {
30        let lat = map["latitude"]
31            .as_f64()
32            .ok_or_else(|| anyhow!("latitude must be a number"))?;
33        let lon = map["longitude"]
34            .as_f64()
35            .ok_or_else(|| anyhow!("longitude must be a number"))?;
36
37        if !(-90.0..=90.0).contains(&lat) {
38            return Err(anyhow!("latitude must be between -90 and 90"));
39        }
40        if !(-180.0..=180.0).contains(&lon) {
41            return Err(anyhow!("longitude must be between -180 and 180"));
42        }
43
44        return Ok(Value::Map(HashMap::from([
45            ("type".to_string(), Value::String("Point".into())),
46            ("crs".to_string(), Value::String("WGS84".into())),
47            ("latitude".to_string(), Value::Float(lat)),
48            ("longitude".to_string(), Value::Float(lon)),
49        ])));
50    }
51
52    // Cartesian point: {x, y} or {x, y, z}
53    if map.contains_key("x") && map.contains_key("y") {
54        let x = map["x"]
55            .as_f64()
56            .ok_or_else(|| anyhow!("x must be a number"))?;
57        let y = map["y"]
58            .as_f64()
59            .ok_or_else(|| anyhow!("y must be a number"))?;
60        let z = map.get("z").and_then(|v| v.as_f64());
61
62        let crs = if z.is_some() {
63            "Cartesian-3D"
64        } else {
65            "Cartesian"
66        };
67
68        let z_value = z.map_or(Value::Null, Value::Float);
69
70        return Ok(Value::Map(HashMap::from([
71            ("type".to_string(), Value::String("Point".into())),
72            ("crs".to_string(), Value::String(crs.into())),
73            ("x".to_string(), Value::Float(x)),
74            ("y".to_string(), Value::Float(y)),
75            ("z".to_string(), z_value),
76        ])));
77    }
78
79    Err(anyhow!(
80        "point() requires either {{latitude, longitude}} or {{x, y}}"
81    ))
82}
83
84fn eval_distance(args: &[Value]) -> Result<Value> {
85    if args.len() != 2 {
86        return Err(anyhow!("distance() requires exactly 2 point arguments"));
87    }
88
89    let p1 = args[0]
90        .as_object()
91        .ok_or_else(|| anyhow!("First argument must be a point"))?;
92    let p2 = args[1]
93        .as_object()
94        .ok_or_else(|| anyhow!("Second argument must be a point"))?;
95
96    let crs1 = p1.get("crs").and_then(|v| v.as_str()).unwrap_or("");
97    let crs2 = p2.get("crs").and_then(|v| v.as_str()).unwrap_or("");
98
99    if crs1 != crs2 {
100        return Err(anyhow!(
101            "Cannot compute distance between points with different CRS"
102        ));
103    }
104
105    match crs1 {
106        "WGS84" => {
107            let (lat1, lon1) = get_geo_coords(p1, "First point")?;
108            let (lat2, lon2) = get_geo_coords(p2, "Second point")?;
109            let (lat1, lon1) = (lat1.to_radians(), lon1.to_radians());
110            let (lat2, lon2) = (lat2.to_radians(), lon2.to_radians());
111
112            let dlat = lat2 - lat1;
113            let dlon = lon2 - lon1;
114
115            let a =
116                (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
117            let c = 2.0 * a.sqrt().asin();
118
119            Ok(Value::Float(EARTH_RADIUS_KM * c * 1000.0)) // Return meters
120        }
121        "Cartesian" => {
122            let (x1, y1) = get_cartesian_coords(p1, "First point")?;
123            let (x2, y2) = get_cartesian_coords(p2, "Second point")?;
124
125            Ok(Value::Float(((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt()))
126        }
127        "Cartesian-3D" => {
128            let (x1, y1) = get_cartesian_coords(p1, "First point")?;
129            let (x2, y2) = get_cartesian_coords(p2, "Second point")?;
130            let z1 = p1.get("z").and_then(|v| v.as_f64()).unwrap_or(0.0);
131            let z2 = p2.get("z").and_then(|v| v.as_f64()).unwrap_or(0.0);
132
133            Ok(Value::Float(
134                ((x2 - x1).powi(2) + (y2 - y1).powi(2) + (z2 - z1).powi(2)).sqrt(),
135            ))
136        }
137        _ => Err(anyhow!("Unknown coordinate reference system: {}", crs1)),
138    }
139}
140
141/// Extract geographic coordinates (latitude, longitude) from a point object.
142fn get_geo_coords(point: &HashMap<String, Value>, name: &str) -> Result<(f64, f64)> {
143    let lat = point
144        .get("latitude")
145        .and_then(|v| v.as_f64())
146        .ok_or_else(|| anyhow!("{} latitude must be a number", name))?;
147    let lon = point
148        .get("longitude")
149        .and_then(|v| v.as_f64())
150        .ok_or_else(|| anyhow!("{} longitude must be a number", name))?;
151    Ok((lat, lon))
152}
153
154/// Extract Cartesian coordinates (x, y) from a point object.
155fn get_cartesian_coords(point: &HashMap<String, Value>, name: &str) -> Result<(f64, f64)> {
156    let x = point
157        .get("x")
158        .and_then(|v| v.as_f64())
159        .ok_or_else(|| anyhow!("{} x must be a number", name))?;
160    let y = point
161        .get("y")
162        .and_then(|v| v.as_f64())
163        .ok_or_else(|| anyhow!("{} y must be a number", name))?;
164    Ok((x, y))
165}
166
167/// Check if a point is within a bounding box defined by lower-left and upper-right corners.
168/// Usage: point.withinBBox(point, lowerLeft, upperRight)
169fn eval_within_bbox(args: &[Value]) -> Result<Value> {
170    if args.len() != 3 {
171        return Err(anyhow!(
172            "point.withinBBox() requires 3 arguments: point, lowerLeft, upperRight"
173        ));
174    }
175
176    let point = args[0]
177        .as_object()
178        .ok_or_else(|| anyhow!("First argument must be a point"))?;
179    let lower_left = args[1]
180        .as_object()
181        .ok_or_else(|| anyhow!("Second argument must be a point (lower-left corner)"))?;
182    let upper_right = args[2]
183        .as_object()
184        .ok_or_else(|| anyhow!("Third argument must be a point (upper-right corner)"))?;
185
186    // For geographic points (WGS84)
187    if point.contains_key("latitude") {
188        let (lat, lon) = get_geo_coords(point, "Point")?;
189        let (min_lat, min_lon) = get_geo_coords(lower_left, "Lower-left")?;
190        let (max_lat, max_lon) = get_geo_coords(upper_right, "Upper-right")?;
191
192        return Ok(Value::Bool(
193            (min_lat..=max_lat).contains(&lat) && (min_lon..=max_lon).contains(&lon),
194        ));
195    }
196
197    // For Cartesian points
198    if point.contains_key("x") {
199        let (x, y) = get_cartesian_coords(point, "Point")?;
200        let (min_x, min_y) = get_cartesian_coords(lower_left, "Lower-left")?;
201        let (max_x, max_y) = get_cartesian_coords(upper_right, "Upper-right")?;
202
203        return Ok(Value::Bool(
204            (min_x..=max_x).contains(&x) && (min_y..=max_y).contains(&y),
205        ));
206    }
207
208    Err(anyhow!(
209        "Point must have either latitude/longitude or x/y coordinates"
210    ))
211}