1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
//! This module contains the latitude longitude positional representation, re-exported as
//! [`crate::gui_system::LatitudeLongitude`].
use crate::gui_system::internal_math::{TileCoordinates, get_scaling_factor};
use std::f64::consts::PI;
/// The boundary latitude we do not overshoot. Needed
/// because of distortion artifacts in the mercator projection.
pub(crate) const BOUNDARY_LATITUDE: f64 = 85.05112878;
/// The absolute minimum and maximum representation of the longitude.
pub(crate) const BOUNDARY_LONGITUDE: f64 = 180.0;
/// The radius of the sphere Web Mercator is defined on, in metres.
/// Defined consistently with the geo package as earth mean radius.
const EARTH_RADIUS: f64 = 6_371_008.8;
/// The circumference of the earth in meters for mercator calculations..
pub(crate) const EARTH_CIRCUMFERENCE: f64 = 2.0 * PI * EARTH_RADIUS;
/// The latitude longitude pair. Both are given in degrees.
/// (latitude: -85 .. 85, longitude: -180 .. 180)
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LatitudeLongitude {
/// Latitude in degrees
pub latitude: f64,
/// Longitude in degrees
pub longitude: f64,
}
impl LatitudeLongitude {
/// Constructs the object and makes sure, that both coordinates are in the valid range
/// (latitude: -85 .. 85, longitude: -180 .. 180)
///
/// # Example
/// ```
/// use map_iced::gui_system::LatitudeLongitude;
/// let trier = LatitudeLongitude::new(49.7499, 6.6371);
/// assert_eq!(trier.latitude, 49.7499);
/// // Web Mercator ends short of the poles, the latitude gets clamped.
/// let pole = LatitudeLongitude::new(90.0, 0.0);
/// assert!(pole.latitude < 86.0);
/// ```
pub const fn new(latitude: f64, longitude: f64) -> Self {
Self {
latitude: latitude.clamp(-BOUNDARY_LATITUDE, BOUNDARY_LATITUDE),
longitude: longitude.clamp(-BOUNDARY_LONGITUDE, BOUNDARY_LONGITUDE),
}
}
/// Gets the tile coordinates in the indicated zoom level
pub(crate) fn get_tile_coordinates(&self, zoom: u8) -> TileCoordinates {
let scaling = get_scaling_factor(zoom);
let x = (self.longitude + 180.0) / 360.0 * scaling;
let angle = self.latitude * PI / 180.0;
let y = (1.0 - f64::ln(f64::tan(angle) + 1.0 / f64::cos(angle)) / PI) * scaling * 0.5;
TileCoordinates { x, y, zoom }
}
/// Builds the position points of a circle around the position with the radius indicated in meters.
/// The ring is open: it has `num_points` entries, the closing edge from the last
/// point back to the first is implied. Fewer than three points make no ring.
///
/// The points run counter-clockwise with longitude as x and latitude as y, north,
/// west, south, east, the usual orientation of an outer ring (GeoJSON, `geo`). In
/// screen coordinates, where y points down, the same ring has a positive area.
///
/// Every point is the destination of walking `radius_meter` along a great circle in its
/// bearing, see [`Self::destinations`].
pub(crate) fn create_circle_around(
&self,
radius_meter: f64,
num_points: usize,
) -> Vec<LatitudeLongitude> {
assert!(num_points >= 3, "Needed at least 3 points for a circle.");
// A growing bearing turns clockwise, north to east. It is negated so that the
// ring runs counter-clockwise, north to west.
self.destinations(
radius_meter,
(0..num_points).map(|i| -(i as f64) / num_points as f64 * 2.0 * PI),
)
.collect()
}
/// Builds the position points of a circle sector with this position as its apex.
/// `heading_degree` is the compass bearing the sector points to, clockwise from
/// north, and `opening_degree` the angle of the fan, centred on the heading.
///
/// The ring is open and runs counter-clockwise like [`Self::create_circle_around`]:
/// the apex first, then `num_arc_points` points on the arc from the clockwise edge
/// of the fan to the counter-clockwise one. Every arc point is `radius_meter` away
/// from the apex along a great circle.
pub(crate) fn create_sector_around(
&self,
radius_meter: f64,
heading_degree: f64,
opening_degree: f64,
num_arc_points: usize,
) -> Vec<LatitudeLongitude> {
assert!(num_arc_points >= 2, "Needed at least 2 points for an arc.");
let first = (heading_degree + opening_degree * 0.5).to_radians();
let step = opening_degree.to_radians() / (num_arc_points - 1) as f64;
std::iter::once(*self)
.chain(self.destinations(
radius_meter,
(0..num_arc_points).map(|i| first - i as f64 * step),
))
.collect()
}
/// The points reached by walking `radius_meter` from here along a great circle in
/// each of the `bearings`, given in radians clockwise from north. That stays correct
/// near the poles, where a squeezed circle breaks down.
///
/// The map does not wrap around at the date line, so a point beyond it is clamped
/// to the map edge by [`Self::new`], the same as it gets clipped there on screen.
fn destinations(
&self,
radius_meter: f64,
bearings: impl Iterator<Item = f64>,
) -> impl Iterator<Item = LatitudeLongitude> {
let angular_distance = radius_meter / EARTH_RADIUS;
let (sin_distance, cos_distance) = angular_distance.sin_cos();
let (sin_latitude, cos_latitude) = self.latitude.to_radians().sin_cos();
// On the unit sphere, with δ = angular_distance, θ = bearing (0 north, π/2 east)
// and the centre at latitude φ and longitude λ, every point is
//
// q = R_z(λ) · R_y(φ) · (cos δ, sin δ · sin θ, sin δ · cos θ)ᵀ
//
// The vector on the right is the circle around (0°, 0°), where east is +y and
// north is +z. R_y(φ) tilts it north to latitude φ:
//
// ⎡ cos φ 0 −sin φ ⎤
// R_y(φ) = ⎢ 0 1 0 ⎥
// ⎣ sin φ 0 cos φ ⎦
//
// R_z(λ) turns about the earth's axis, so it only adds λ to the longitude.
// Multiplied out, before R_z:
//
// q_x = cos φ · cos δ − sin φ · sin δ · cos θ
// q_y = sin δ · sin θ
// q_z = sin φ · cos δ + cos φ · sin δ · cos θ
//
// The latitude is asin(q_z), the longitude offset atan2(q_y, q_x). Both atan2
// arguments below are scaled by cos φ, which reuses sin(lat) = q_z:
// cos δ − sin φ · q_z = cos φ · q_x. atan2 ignores a common positive factor,
// and cos φ > 0 because the latitude stays within BOUNDARY_LATITUDE.
let longitude = self.longitude;
bearings.map(move |bearing| {
let lat =
(sin_latitude * cos_distance + cos_latitude * sin_distance * bearing.cos()).asin();
let long_diff = (bearing.sin() * sin_distance * cos_latitude)
.atan2(cos_distance - sin_latitude * lat.sin());
LatitudeLongitude::new(lat.to_degrees(), longitude + long_diff.to_degrees())
})
}
/// Uses the harvesine formula to get the distance to another point in meters.
///
/// # Example
/// ```
/// use map_iced::gui_system::LatitudeLongitude;
/// let first_point = LatitudeLongitude::new(49.7554, 6.6436);
/// let second_point = LatitudeLongitude::new( 49.7540, 6.6100);
/// let dist = first_point.get_distance_to(second_point);
/// ```
pub fn get_distance_to(&self, other_point: Self) -> f64 {
let (lat_a, lat_b) = (
self.latitude.to_radians(),
other_point.latitude.to_radians(),
);
let d_lat = lat_b - lat_a;
let d_long = (other_point.longitude - self.longitude).to_radians();
let h =
(d_lat * 0.5).sin().powi(2) + lat_a.cos() * lat_b.cos() * (d_long * 0.5).sin().powi(2);
// Rounding can push h just above 1 for near-antipodal points, where asin gives NaN.
2.0 * EARTH_RADIUS * h.sqrt().min(1.0).asin()
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::{prop_assert, proptest};
proptest! {
#[test]
fn circle_points_keep_their_distance(latitude in -80.0f64..80.0, longitude in -177.0f64..177.0, radius in 0.0 .. 50_000.0) {
// Keeps the ring off the map edges, where it gets clamped: 50 km is at most
// ~0.45 degrees of latitude, and ~2.6 degrees of longitude at 80 degrees.
let centre = LatitudeLongitude::new(latitude, longitude);
let ring = centre.create_circle_around(radius, 32);
for point in &ring {
prop_assert!(point.latitude.is_finite() && point.longitude.is_finite());
let distance = centre.get_distance_to(*point);
// The absolute micrometre covers rounding noise for a zero radius.
prop_assert!((distance - radius).abs() < radius * 1e-6 + 1e-6, "{latitude}: {distance} vs {radius}");
}
}
#[test]
fn sector_is_apex_plus_arc_counter_clockwise(latitude in -80.0f64..80.0, longitude in -177.0f64..177.0, radius in 100.0 .. 50_000.0, heading in -360.0f64..720.0, opening in 1.0f64..=180.0) {
let apex = LatitudeLongitude::new(latitude, longitude);
let ring = apex.create_sector_around(radius, heading, opening, 16);
prop_assert!(ring.len() == 17);
prop_assert!(ring[0].latitude == apex.latitude && ring[0].longitude == apex.longitude);
for point in &ring[1..] {
let distance = apex.get_distance_to(*point);
prop_assert!((distance - radius).abs() < radius * 1e-6, "{distance} vs {radius}");
}
let doubled_area: f64 = ring
.iter()
.zip(ring.iter().cycle().skip(1))
.map(|(a, b)| a.longitude * b.latitude - b.longitude * a.latitude)
.sum();
prop_assert!(doubled_area > 0.0, "{latitude}, {longitude}, {heading}, {opening}: {doubled_area}");
}
#[test]
fn circle_runs_counter_clockwise(latitude in -80.0f64..80.0, longitude in -177.0f64..177.0, radius in 100.0 .. 50_000.0) {
let ring = LatitudeLongitude::new(latitude, longitude).create_circle_around(radius, 32);
// Twice the signed area by the shoelace formula, longitude as x, latitude as y.
let doubled_area: f64 = ring
.iter()
.zip(ring.iter().cycle().skip(1))
.map(|(a, b)| a.longitude * b.latitude - b.longitude * a.latitude)
.sum();
prop_assert!(doubled_area > 0.0, "{latitude}, {longitude}: {doubled_area}");
// The second point lies west of the first, which is due north.
prop_assert!(ring[1].longitude < ring[0].longitude);
}
#[test]
fn antipodal_distance_is_half_the_circumference(latitude in -BOUNDARY_LATITUDE..BOUNDARY_LATITUDE, longitude in -BOUNDARY_LONGITUDE..0.0) {
// The longitude range keeps the antipode inside -180 .. 180, where new would clamp it.
// Near h = 1 the haversine is ill-conditioned: asin(1 - e) is about pi/2 - sqrt(2e),
// so rounding noise in h costs tenths of a metre here.
let point = LatitudeLongitude::new(latitude, longitude);
let antipode = LatitudeLongitude::new(-latitude, longitude + 180.0);
let distance = point.get_distance_to(antipode);
prop_assert!((distance - PI * EARTH_RADIUS).abs() < 1.0, "{latitude}, {longitude}: {distance}");
}
}
#[test]
fn east_west_distance_shrinks_with_latitude() {
// One degree along the 60th parallel is R * cos(60°) * 1° long; the great circle
// between its ends is shorter only by about 2e-5 of that. Squaring the cosine
// factor in the haversine would halve the result here, while the equator hides it.
let distance =
LatitudeLongitude::new(60.0, 0.0).get_distance_to(LatitudeLongitude::new(60.0, 1.0));
let parallel_arc = EARTH_RADIUS * 60f64.to_radians().cos() * 1f64.to_radians();
assert!(distance <= parallel_arc, "{distance} vs {parallel_arc}");
assert!(
distance > parallel_arc * (1.0 - 1e-4),
"{distance} vs {parallel_arc}"
);
}
#[test]
fn near_antipodal_rounding_does_not_give_nan() {
// Found by random search, about one pair in 15 million: h rounds two ULP above 1,
// so its square root lands above 1 as well and asin would give NaN without the clamp.
let point = LatitudeLongitude::new(57.36396000882365, -64.65731757479259);
let antipode = LatitudeLongitude::new(-57.36396001600441, 115.34268263197147);
let distance = point.get_distance_to(antipode);
assert!((distance - PI * EARTH_RADIUS).abs() < 1.0, "{distance}");
}
}