Skip to main content

waterui_map/
lib.rs

1//! # `WaterUI` Map Component
2//!
3//! This crate provides a declarative map component for the `WaterUI` framework.
4//! It displays native maps where a platform has a suitable primitive and a
5//! shared GPU-drawn vector map elsewhere, with annotations and `WaterKit`
6//! location integration.
7//!
8//! ## Example
9//!
10//! ```ignore
11//! use waterui_map::{Annotation, Coordinate, Map, Region};
12//!
13//! // Display a map centered on San Francisco
14//! # fn example() -> Result<(), waterui_map::OutOfRange> {
15//! let san_francisco = Coordinate::from_degrees(37.7749, -122.4194)?;
16//! let region = Region::new(san_francisco, 0.1, 0.1);
17//!
18//! let map = Map::new(region)
19//!     .annotations(vec![Annotation::new(san_francisco, "San Francisco")])
20//!     .shows_user_location(true);
21//! # let _ = map;
22//! # Ok(())
23//! # }
24//! ```
25
26extern 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
33// Re-export waterkit-location for downstream convenience.
34pub use waterkit_location as location;
35// Commonly used location types re-export.
36pub use waterkit_location::{Latitude, Location, Longitude, OutOfRange, Timestamp};
37
38/// A geographic coordinate with latitude and longitude.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct Coordinate {
41    /// Latitude in degrees (-90 to 90).
42    pub latitude: Latitude,
43    /// Longitude in degrees (-180 to 180).
44    pub longitude: Longitude,
45}
46
47impl Coordinate {
48    /// Creates a new coordinate.
49    #[must_use]
50    pub const fn new(latitude: Latitude, longitude: Longitude) -> Self {
51        Self {
52            latitude,
53            longitude,
54        }
55    }
56
57    /// Creates a coordinate from raw degree values.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`OutOfRange`] if either coordinate is `NaN` or outside its
62    /// valid range.
63    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    /// Creates a coordinate from a `waterkit_location::Location`.
71    #[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        // Default to null island (0, 0)
95        Self::new(Latitude::new_unchecked(0.0), Longitude::new_unchecked(0.0))
96    }
97}
98
99/// A map region defined by a center coordinate and span.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct Region {
102    /// The center coordinate of the region.
103    pub center: Coordinate,
104    /// The north-to-south span in degrees.
105    pub latitude_delta: f64,
106    /// The east-to-west span in degrees.
107    pub longitude_delta: f64,
108}
109
110impl Region {
111    /// Creates a new region.
112    #[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    /// Creates a region from a coordinate with default zoom.
122    #[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/// A map annotation (pin marker).
143#[derive(Debug, Clone, PartialEq)]
144pub struct Annotation {
145    /// The coordinate where the annotation is placed.
146    pub coordinate: Coordinate,
147    /// The title text shown on the annotation.
148    pub title: Str,
149    /// Optional subtitle text.
150    pub subtitle: Option<Str>,
151}
152
153impl Annotation {
154    /// Creates a new annotation with a title.
155    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    /// Sets the subtitle for this annotation.
164    #[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/// Map display style.
172///
173/// Native realizations resolve these against the platform map's own imagery.
174/// The portable GPU realization has no imagery of its own, so an application
175/// asking for [`Self::Satellite`] or [`Self::Hybrid`] must also supply the
176/// matching provider style (`MapGpuOptions::satellite_style_url` and
177/// `hybrid_style_url`). The map reports a load failure when it did not, rather
178/// than silently drawing the standard style.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
180pub enum MapStyle {
181    /// Standard road map.
182    #[default]
183    Standard,
184    /// Satellite imagery.
185    Satellite,
186    /// Hybrid of satellite and roads.
187    Hybrid,
188}
189
190/// Lifecycle of the imagery backing a map.
191///
192/// A realization that fetches its own tiles reports progress here so the
193/// application can show a spinner or an error instead of an empty rectangle.
194/// Realizations that draw from a platform map report the platform's own
195/// load callbacks.
196#[derive(Debug, Clone, PartialEq, Eq, Default)]
197pub enum MapStatus {
198    /// The map is fetching the data it needs to draw.
199    #[default]
200    Loading,
201    /// The map has everything it needs for the current camera.
202    Ready,
203    /// The map could not load, carrying a human-readable reason.
204    ///
205    /// The map keeps drawing whatever it last had, so a failure that arrives
206    /// after a successful load leaves the previous imagery on screen.
207    Failed(Str),
208}
209
210impl MapStatus {
211    /// Returns whether the map is still loading.
212    #[must_use]
213    pub const fn is_loading(&self) -> bool {
214        matches!(self, Self::Loading)
215    }
216
217    /// Returns the failure reason, if the map failed to load.
218    #[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/// Generic visibility toggle for optional map chrome.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum MapVisibility {
230    /// Hide the feature.
231    Hidden,
232    /// Show the feature.
233    Visible,
234}
235
236impl MapVisibility {
237    const fn from_bool(value: bool) -> Self {
238        if value { Self::Visible } else { Self::Hidden }
239    }
240
241    /// Returns whether the feature should be visible.
242    #[must_use]
243    pub const fn is_visible(self) -> bool {
244        matches!(self, Self::Visible)
245    }
246}
247
248/// Controls whether the user can pan and zoom the map.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum MapInteractivity {
251    /// Disable direct map interaction.
252    ReadOnly,
253    /// Enable direct map interaction.
254    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    /// Returns whether gestures are enabled.
267    #[must_use]
268    pub const fn is_interactive(self) -> bool {
269        matches!(self, Self::Interactive)
270    }
271}
272
273/// Configuration for the Map component.
274#[derive(Debug)]
275pub struct MapConfig {
276    /// The region to display.
277    pub region: Computed<Region>,
278    /// Annotations (pins) to display on the map.
279    pub annotations: Computed<Vec<Annotation>>,
280    /// The map display style.
281    pub style: MapStyle,
282    /// Whether to show the user's current location.
283    pub user_location_visibility: MapVisibility,
284    /// `WaterKit` location values rendered by portable map realizations.
285    ///
286    /// Native realizations may use the platform location service when this is
287    /// absent. Supplying it keeps camera following, the location marker, and
288    /// horizontal-accuracy visualization driven by one reactive source.
289    pub user_location: Option<Computed<Option<Location>>>,
290    /// Whether the map is interactive (pan/zoom enabled).
291    pub interactivity: MapInteractivity,
292    /// Whether to show the compass.
293    pub compass_visibility: MapVisibility,
294    /// Whether to show the scale.
295    pub scale_visibility: MapVisibility,
296    /// Caller-owned sink the realization reports its load lifecycle into.
297    ///
298    /// `None` means the application is not observing status, and a realization
299    /// must not allocate one on its behalf.
300    pub status: Option<Binding<MapStatus>>,
301}
302
303// Use configurable! with StretchAxis::Both - this provides both NativeView and View impls
304configurable!(
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    /// Creates a new Map displaying the specified region.
313    ///
314    /// # Arguments
315    ///
316    /// * `region` - The map region to display (can be reactive).
317    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    /// Creates a new Map centered on the specified coordinate with default zoom.
333    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    /// Creates a new Map centered on reactive `Location` values.
340    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    /// Sets the annotations (pins) to display on the map.
349    #[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    /// Sets the map display style.
356    #[must_use]
357    pub const fn style(mut self, style: MapStyle) -> Self {
358        self.0.style = style;
359        self
360    }
361
362    /// Sets whether to show the user's current location on the map.
363    #[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    /// Displays reactive `WaterKit` location values and their horizontal accuracy.
370    #[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    /// Displays an optional reactive `WaterKit` location.
378    ///
379    /// This is useful while an asynchronous location permission/request flow is
380    /// pending: `None` draws no location marker and the first `Some` value
381    /// updates the existing map without replacing its view identity.
382    #[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    /// Binds the map center to reactive `Location` updates and enables user-location display.
390    #[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    /// Sets whether the map is interactive (pan/zoom enabled).
402    #[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    /// Sets whether to show the compass.
409    #[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    /// Sets whether to show the scale.
416    #[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    /// Observes the map's load lifecycle through a caller-owned binding.
423    ///
424    /// The map writes [`MapStatus`] into `status` as it loads, fails, or
425    /// becomes ready, so an application can show its own loading and error
426    /// affordances over the map.
427    #[must_use]
428    pub fn status(mut self, status: &Binding<MapStatus>) -> Self {
429        self.0.status = Some(status.clone());
430        self
431    }
432}
433
434/// Convenience function to create a Map view.
435pub fn map(region: impl IntoComputed<Region>) -> Map {
436    Map::new(region)
437}
438
439/// Convenience constructor for [`Map::centered_on`].
440pub fn map_centered_on(coordinate: impl IntoComputed<Coordinate>) -> Map {
441    Map::centered_on(coordinate)
442}
443
444/// Convenience constructor for [`Map::centered_on_location`].
445pub 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}