extern crate alloc;
use alloc::vec::Vec;
use nami::{Binding, impl_constant, signal::IntoComputed};
use suiteki::Str;
use waterui_core::{Computed, SignalExt, configurable, layout::StretchAxis};
pub use waterkit_location as location;
pub use waterkit_location::{Latitude, Location, Longitude, OutOfRange, Timestamp};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Coordinate {
pub latitude: Latitude,
pub longitude: Longitude,
}
impl Coordinate {
#[must_use]
pub const fn new(latitude: Latitude, longitude: Longitude) -> Self {
Self {
latitude,
longitude,
}
}
pub fn from_degrees(latitude: f64, longitude: f64) -> Result<Self, OutOfRange> {
Ok(Self::new(
Latitude::new(latitude)?,
Longitude::new(longitude)?,
))
}
#[must_use]
pub const fn from_location(location: &Location) -> Self {
Self {
latitude: location.latitude(),
longitude: location.longitude(),
}
}
}
impl From<Location> for Coordinate {
fn from(value: Location) -> Self {
Self::from_location(&value)
}
}
impl From<&Location> for Coordinate {
fn from(value: &Location) -> Self {
Self::from_location(value)
}
}
impl Default for Coordinate {
fn default() -> Self {
Self::new(Latitude::new_unchecked(0.0), Longitude::new_unchecked(0.0))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Region {
pub center: Coordinate,
pub latitude_delta: f64,
pub longitude_delta: f64,
}
impl Region {
#[must_use]
pub const fn new(center: Coordinate, latitude_delta: f64, longitude_delta: f64) -> Self {
Self {
center,
latitude_delta,
longitude_delta,
}
}
#[must_use]
pub const fn from_coordinate(coordinate: Coordinate) -> Self {
Self::new(coordinate, 0.05, 0.05)
}
}
impl Default for Region {
fn default() -> Self {
Self::new(Coordinate::default(), 0.1, 0.1)
}
}
impl From<Coordinate> for Region {
fn from(coordinate: Coordinate) -> Self {
Self::from_coordinate(coordinate)
}
}
impl_constant!(Coordinate, Region, Annotation, MapStyle, MapStatus);
#[derive(Debug, Clone, PartialEq)]
pub struct Annotation {
pub coordinate: Coordinate,
pub title: Str,
pub subtitle: Option<Str>,
}
impl Annotation {
pub fn new(coordinate: Coordinate, title: impl Into<Str>) -> Self {
Self {
coordinate,
title: title.into(),
subtitle: None,
}
}
#[must_use]
pub fn subtitle(mut self, subtitle: impl Into<Str>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MapStyle {
#[default]
Standard,
Satellite,
Hybrid,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum MapStatus {
#[default]
Loading,
Ready,
Failed(Str),
}
impl MapStatus {
#[must_use]
pub const fn is_loading(&self) -> bool {
matches!(self, Self::Loading)
}
#[must_use]
pub const fn failure(&self) -> Option<&Str> {
match self {
Self::Failed(reason) => Some(reason),
Self::Loading | Self::Ready => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MapVisibility {
Hidden,
Visible,
}
impl MapVisibility {
const fn from_bool(value: bool) -> Self {
if value { Self::Visible } else { Self::Hidden }
}
#[must_use]
pub const fn is_visible(self) -> bool {
matches!(self, Self::Visible)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MapInteractivity {
ReadOnly,
Interactive,
}
impl MapInteractivity {
const fn from_bool(value: bool) -> Self {
if value {
Self::Interactive
} else {
Self::ReadOnly
}
}
#[must_use]
pub const fn is_interactive(self) -> bool {
matches!(self, Self::Interactive)
}
}
#[derive(Debug)]
pub struct MapConfig {
pub region: Computed<Region>,
pub annotations: Computed<Vec<Annotation>>,
pub style: MapStyle,
pub user_location_visibility: MapVisibility,
pub user_location: Option<Computed<Option<Location>>>,
pub interactivity: MapInteractivity,
pub compass_visibility: MapVisibility,
pub scale_visibility: MapVisibility,
pub status: Option<Binding<MapStatus>>,
}
configurable!(
#[doc = "A map view that displays a geographic region with optional annotations."]
Map,
MapConfig,
StretchAxis::Both
);
impl Map {
pub fn new(region: impl IntoComputed<Region>) -> Self {
let empty_annotations: Vec<Annotation> = Vec::new();
Self(MapConfig {
region: region.into_computed(),
annotations: empty_annotations.into_computed(),
style: MapStyle::default(),
user_location_visibility: MapVisibility::Hidden,
user_location: None,
interactivity: MapInteractivity::Interactive,
compass_visibility: MapVisibility::Visible,
scale_visibility: MapVisibility::Visible,
status: None,
})
}
pub fn centered_on(coordinate: impl IntoComputed<Coordinate>) -> Self {
let coord_signal = coordinate.into_computed();
let region_signal: Computed<Region> = coord_signal.map(Region::from_coordinate).computed();
Self::new(region_signal)
}
pub fn centered_on_location(location: impl IntoComputed<Location>) -> Self {
let location_signal = location.into_computed();
let region_signal: Computed<Region> = location_signal
.map(|location| Region::from_coordinate(Coordinate::from(location)))
.computed();
Self::new(region_signal)
}
#[must_use]
pub fn annotations(mut self, annotations: impl IntoComputed<Vec<Annotation>>) -> Self {
self.0.annotations = annotations.into_computed();
self
}
#[must_use]
pub const fn style(mut self, style: MapStyle) -> Self {
self.0.style = style;
self
}
#[must_use]
pub const fn shows_user_location(mut self, show: bool) -> Self {
self.0.user_location_visibility = MapVisibility::from_bool(show);
self
}
#[must_use]
pub fn user_location(mut self, location: impl IntoComputed<Location>) -> Self {
self.0.user_location = Some(location.into_computed().map(Some).computed());
self.0.user_location_visibility = MapVisibility::Visible;
self
}
#[must_use]
pub fn optional_user_location(mut self, location: impl IntoComputed<Option<Location>>) -> Self {
self.0.user_location = Some(location.into_computed());
self.0.user_location_visibility = MapVisibility::Visible;
self
}
#[must_use]
pub fn follows_location(mut self, location: impl IntoComputed<Location>) -> Self {
let location_signal = location.into_computed();
self.0.region = location_signal
.map(|location| Region::from_coordinate(Coordinate::from(location)))
.into_computed();
self.0.user_location = Some(location_signal.map(Some).computed());
self.0.user_location_visibility = MapVisibility::Visible;
self
}
#[must_use]
pub const fn is_interactive(mut self, interactive: bool) -> Self {
self.0.interactivity = MapInteractivity::from_bool(interactive);
self
}
#[must_use]
pub const fn shows_compass(mut self, show: bool) -> Self {
self.0.compass_visibility = MapVisibility::from_bool(show);
self
}
#[must_use]
pub const fn shows_scale(mut self, show: bool) -> Self {
self.0.scale_visibility = MapVisibility::from_bool(show);
self
}
#[must_use]
pub fn status(mut self, status: &Binding<MapStatus>) -> Self {
self.0.status = Some(status.clone());
self
}
}
pub fn map(region: impl IntoComputed<Region>) -> Map {
Map::new(region)
}
pub fn map_centered_on(coordinate: impl IntoComputed<Coordinate>) -> Map {
Map::centered_on(coordinate)
}
pub fn map_centered_on_location(location: impl IntoComputed<Location>) -> Map {
Map::centered_on_location(location)
}
#[cfg(test)]
mod tests {
use super::{Coordinate, Map, MapStatus, Region};
use nami::Binding;
#[test]
fn a_map_reports_status_into_the_caller_owned_binding() {
let status = Binding::container(MapStatus::Loading);
let map = Map::new(Region::from_coordinate(
Coordinate::from_degrees(37.7749, -122.4194).expect("valid coordinate"),
))
.status(&status);
let sink = map.0.status.expect("status sink must be retained");
sink.set(MapStatus::Failed("style unreachable".into()));
assert_eq!(
status.get().failure().map(ToString::to_string),
Some(String::from("style unreachable"))
);
assert!(!status.get().is_loading());
}
#[test]
fn a_map_without_an_observer_allocates_no_status_sink() {
let map = Map::new(Region::default());
assert!(map.0.status.is_none());
}
}