Skip to main content

uni_query_functions/
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        // The 2D "Cartesian" case is the 3D formula with z defaulting to 0 in both
122        // points, so the two CRS variants share a single Euclidean computation.
123        "Cartesian" | "Cartesian-3D" => {
124            let (x1, y1) = get_cartesian_coords(p1, "First point")?;
125            let (x2, y2) = get_cartesian_coords(p2, "Second point")?;
126            let z1 = p1.get("z").and_then(|v| v.as_f64()).unwrap_or(0.0);
127            let z2 = p2.get("z").and_then(|v| v.as_f64()).unwrap_or(0.0);
128
129            Ok(Value::Float(
130                ((x2 - x1).powi(2) + (y2 - y1).powi(2) + (z2 - z1).powi(2)).sqrt(),
131            ))
132        }
133        _ => Err(anyhow!("Unknown coordinate reference system: {}", crs1)),
134    }
135}
136
137/// Extract geographic coordinates (latitude, longitude) from a point object.
138fn get_geo_coords(point: &HashMap<String, Value>, name: &str) -> Result<(f64, f64)> {
139    let lat = point
140        .get("latitude")
141        .and_then(|v| v.as_f64())
142        .ok_or_else(|| anyhow!("{} latitude must be a number", name))?;
143    let lon = point
144        .get("longitude")
145        .and_then(|v| v.as_f64())
146        .ok_or_else(|| anyhow!("{} longitude must be a number", name))?;
147    Ok((lat, lon))
148}
149
150/// Extract Cartesian coordinates (x, y) from a point object.
151fn get_cartesian_coords(point: &HashMap<String, Value>, name: &str) -> Result<(f64, f64)> {
152    let x = point
153        .get("x")
154        .and_then(|v| v.as_f64())
155        .ok_or_else(|| anyhow!("{} x must be a number", name))?;
156    let y = point
157        .get("y")
158        .and_then(|v| v.as_f64())
159        .ok_or_else(|| anyhow!("{} y must be a number", name))?;
160    Ok((x, y))
161}
162
163/// Check if a point is within a bounding box defined by lower-left and upper-right corners.
164/// Usage: point.withinBBox(point, lowerLeft, upperRight)
165fn eval_within_bbox(args: &[Value]) -> Result<Value> {
166    if args.len() != 3 {
167        return Err(anyhow!(
168            "point.withinBBox() requires 3 arguments: point, lowerLeft, upperRight"
169        ));
170    }
171
172    let point = args[0]
173        .as_object()
174        .ok_or_else(|| anyhow!("First argument must be a point"))?;
175    let lower_left = args[1]
176        .as_object()
177        .ok_or_else(|| anyhow!("Second argument must be a point (lower-left corner)"))?;
178    let upper_right = args[2]
179        .as_object()
180        .ok_or_else(|| anyhow!("Third argument must be a point (upper-right corner)"))?;
181
182    // For geographic points (WGS84)
183    if point.contains_key("latitude") {
184        let (lat, lon) = get_geo_coords(point, "Point")?;
185        let (min_lat, min_lon) = get_geo_coords(lower_left, "Lower-left")?;
186        let (max_lat, max_lon) = get_geo_coords(upper_right, "Upper-right")?;
187
188        return Ok(Value::Bool(
189            (min_lat..=max_lat).contains(&lat) && (min_lon..=max_lon).contains(&lon),
190        ));
191    }
192
193    // For Cartesian points
194    if point.contains_key("x") {
195        let (x, y) = get_cartesian_coords(point, "Point")?;
196        let (min_x, min_y) = get_cartesian_coords(lower_left, "Lower-left")?;
197        let (max_x, max_y) = get_cartesian_coords(upper_right, "Upper-right")?;
198
199        return Ok(Value::Bool(
200            (min_x..=max_x).contains(&x) && (min_y..=max_y).contains(&y),
201        ));
202    }
203
204    Err(anyhow!(
205        "Point must have either latitude/longitude or x/y coordinates"
206    ))
207}