1extern crate alloc;
27
28use alloc::vec::Vec;
29use nami::{Binding, impl_constant, signal::IntoComputed};
30use suiteki::Str;
31use waterui_core::{Computed, SignalExt, configurable, layout::StretchAxis};
32
33pub use waterkit_location as location;
35pub use waterkit_location::{Latitude, Location, Longitude, OutOfRange, Timestamp};
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct Coordinate {
41 pub latitude: Latitude,
43 pub longitude: Longitude,
45}
46
47impl Coordinate {
48 #[must_use]
50 pub const fn new(latitude: Latitude, longitude: Longitude) -> Self {
51 Self {
52 latitude,
53 longitude,
54 }
55 }
56
57 pub fn from_degrees(latitude: f64, longitude: f64) -> Result<Self, OutOfRange> {
64 Ok(Self::new(
65 Latitude::new(latitude)?,
66 Longitude::new(longitude)?,
67 ))
68 }
69
70 #[must_use]
72 pub const fn from_location(location: &Location) -> Self {
73 Self {
74 latitude: location.latitude(),
75 longitude: location.longitude(),
76 }
77 }
78}
79
80impl From<Location> for Coordinate {
81 fn from(value: Location) -> Self {
82 Self::from_location(&value)
83 }
84}
85
86impl From<&Location> for Coordinate {
87 fn from(value: &Location) -> Self {
88 Self::from_location(value)
89 }
90}
91
92impl Default for Coordinate {
93 fn default() -> Self {
94 Self::new(Latitude::new_unchecked(0.0), Longitude::new_unchecked(0.0))
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct Region {
102 pub center: Coordinate,
104 pub latitude_delta: f64,
106 pub longitude_delta: f64,
108}
109
110impl Region {
111 #[must_use]
113 pub const fn new(center: Coordinate, latitude_delta: f64, longitude_delta: f64) -> Self {
114 Self {
115 center,
116 latitude_delta,
117 longitude_delta,
118 }
119 }
120
121 #[must_use]
123 pub const fn from_coordinate(coordinate: Coordinate) -> Self {
124 Self::new(coordinate, 0.05, 0.05)
125 }
126}
127
128impl Default for Region {
129 fn default() -> Self {
130 Self::new(Coordinate::default(), 0.1, 0.1)
131 }
132}
133
134impl From<Coordinate> for Region {
135 fn from(coordinate: Coordinate) -> Self {
136 Self::from_coordinate(coordinate)
137 }
138}
139
140impl_constant!(Coordinate, Region, Annotation, MapStyle, MapStatus);
141
142#[derive(Debug, Clone, PartialEq)]
144pub struct Annotation {
145 pub coordinate: Coordinate,
147 pub title: Str,
149 pub subtitle: Option<Str>,
151}
152
153impl Annotation {
154 pub fn new(coordinate: Coordinate, title: impl Into<Str>) -> Self {
156 Self {
157 coordinate,
158 title: title.into(),
159 subtitle: None,
160 }
161 }
162
163 #[must_use]
165 pub fn subtitle(mut self, subtitle: impl Into<Str>) -> Self {
166 self.subtitle = Some(subtitle.into());
167 self
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
180pub enum MapStyle {
181 #[default]
183 Standard,
184 Satellite,
186 Hybrid,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Default)]
197pub enum MapStatus {
198 #[default]
200 Loading,
201 Ready,
203 Failed(Str),
208}
209
210impl MapStatus {
211 #[must_use]
213 pub const fn is_loading(&self) -> bool {
214 matches!(self, Self::Loading)
215 }
216
217 #[must_use]
219 pub const fn failure(&self) -> Option<&Str> {
220 match self {
221 Self::Failed(reason) => Some(reason),
222 Self::Loading | Self::Ready => None,
223 }
224 }
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum MapVisibility {
230 Hidden,
232 Visible,
234}
235
236impl MapVisibility {
237 const fn from_bool(value: bool) -> Self {
238 if value { Self::Visible } else { Self::Hidden }
239 }
240
241 #[must_use]
243 pub const fn is_visible(self) -> bool {
244 matches!(self, Self::Visible)
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum MapInteractivity {
251 ReadOnly,
253 Interactive,
255}
256
257impl MapInteractivity {
258 const fn from_bool(value: bool) -> Self {
259 if value {
260 Self::Interactive
261 } else {
262 Self::ReadOnly
263 }
264 }
265
266 #[must_use]
268 pub const fn is_interactive(self) -> bool {
269 matches!(self, Self::Interactive)
270 }
271}
272
273#[derive(Debug)]
275pub struct MapConfig {
276 pub region: Computed<Region>,
278 pub annotations: Computed<Vec<Annotation>>,
280 pub style: MapStyle,
282 pub user_location_visibility: MapVisibility,
284 pub user_location: Option<Computed<Option<Location>>>,
290 pub interactivity: MapInteractivity,
292 pub compass_visibility: MapVisibility,
294 pub scale_visibility: MapVisibility,
296 pub status: Option<Binding<MapStatus>>,
301}
302
303configurable!(
305 #[doc = "A map view that displays a geographic region with optional annotations."]
306 Map,
307 MapConfig,
308 StretchAxis::Both
309);
310
311impl Map {
312 pub fn new(region: impl IntoComputed<Region>) -> Self {
318 let empty_annotations: Vec<Annotation> = Vec::new();
319 Self(MapConfig {
320 region: region.into_computed(),
321 annotations: empty_annotations.into_computed(),
322 style: MapStyle::default(),
323 user_location_visibility: MapVisibility::Hidden,
324 user_location: None,
325 interactivity: MapInteractivity::Interactive,
326 compass_visibility: MapVisibility::Visible,
327 scale_visibility: MapVisibility::Visible,
328 status: None,
329 })
330 }
331
332 pub fn centered_on(coordinate: impl IntoComputed<Coordinate>) -> Self {
334 let coord_signal = coordinate.into_computed();
335 let region_signal: Computed<Region> = coord_signal.map(Region::from_coordinate).computed();
336 Self::new(region_signal)
337 }
338
339 pub fn centered_on_location(location: impl IntoComputed<Location>) -> Self {
341 let location_signal = location.into_computed();
342 let region_signal: Computed<Region> = location_signal
343 .map(|location| Region::from_coordinate(Coordinate::from(location)))
344 .computed();
345 Self::new(region_signal)
346 }
347
348 #[must_use]
350 pub fn annotations(mut self, annotations: impl IntoComputed<Vec<Annotation>>) -> Self {
351 self.0.annotations = annotations.into_computed();
352 self
353 }
354
355 #[must_use]
357 pub const fn style(mut self, style: MapStyle) -> Self {
358 self.0.style = style;
359 self
360 }
361
362 #[must_use]
364 pub const fn shows_user_location(mut self, show: bool) -> Self {
365 self.0.user_location_visibility = MapVisibility::from_bool(show);
366 self
367 }
368
369 #[must_use]
371 pub fn user_location(mut self, location: impl IntoComputed<Location>) -> Self {
372 self.0.user_location = Some(location.into_computed().map(Some).computed());
373 self.0.user_location_visibility = MapVisibility::Visible;
374 self
375 }
376
377 #[must_use]
383 pub fn optional_user_location(mut self, location: impl IntoComputed<Option<Location>>) -> Self {
384 self.0.user_location = Some(location.into_computed());
385 self.0.user_location_visibility = MapVisibility::Visible;
386 self
387 }
388
389 #[must_use]
391 pub fn follows_location(mut self, location: impl IntoComputed<Location>) -> Self {
392 let location_signal = location.into_computed();
393 self.0.region = location_signal
394 .map(|location| Region::from_coordinate(Coordinate::from(location)))
395 .into_computed();
396 self.0.user_location = Some(location_signal.map(Some).computed());
397 self.0.user_location_visibility = MapVisibility::Visible;
398 self
399 }
400
401 #[must_use]
403 pub const fn is_interactive(mut self, interactive: bool) -> Self {
404 self.0.interactivity = MapInteractivity::from_bool(interactive);
405 self
406 }
407
408 #[must_use]
410 pub const fn shows_compass(mut self, show: bool) -> Self {
411 self.0.compass_visibility = MapVisibility::from_bool(show);
412 self
413 }
414
415 #[must_use]
417 pub const fn shows_scale(mut self, show: bool) -> Self {
418 self.0.scale_visibility = MapVisibility::from_bool(show);
419 self
420 }
421
422 #[must_use]
428 pub fn status(mut self, status: &Binding<MapStatus>) -> Self {
429 self.0.status = Some(status.clone());
430 self
431 }
432}
433
434pub fn map(region: impl IntoComputed<Region>) -> Map {
436 Map::new(region)
437}
438
439pub fn map_centered_on(coordinate: impl IntoComputed<Coordinate>) -> Map {
441 Map::centered_on(coordinate)
442}
443
444pub fn map_centered_on_location(location: impl IntoComputed<Location>) -> Map {
446 Map::centered_on_location(location)
447}
448
449#[cfg(test)]
450mod tests {
451 use super::{Coordinate, Map, MapStatus, Region};
452 use nami::Binding;
453
454 #[test]
455 fn a_map_reports_status_into_the_caller_owned_binding() {
456 let status = Binding::container(MapStatus::Loading);
457 let map = Map::new(Region::from_coordinate(
458 Coordinate::from_degrees(37.7749, -122.4194).expect("valid coordinate"),
459 ))
460 .status(&status);
461
462 let sink = map.0.status.expect("status sink must be retained");
463 sink.set(MapStatus::Failed("style unreachable".into()));
464
465 assert_eq!(
466 status.get().failure().map(ToString::to_string),
467 Some(String::from("style unreachable"))
468 );
469 assert!(!status.get().is_loading());
470 }
471
472 #[test]
473 fn a_map_without_an_observer_allocates_no_status_sink() {
474 let map = Map::new(Region::default());
475
476 assert!(map.0.status.is_none());
477 }
478}