open-standards/map/map-rs/crates/map-route/src/lib.rs
Transaction Science ba1212361f Add aac, map, sandbox standards; catalog the standards family
Commit the previously-untracked standard directories and catalog them in the
root README alongside proof:

- aac/    — communication cargo class: portable boards + a ten-level
            offline-first cascade (Apache-2.0 code / CC-BY-4.0 spec).
- map/    — geospatial cargo class: range-requestable signed tilesets +
            an F0–F4 fidelity cascade (Apache-2.0 code / CC-BY-4.0 spec).
- sandbox/— isolated, metered, attested execution across the heterogeneous
            compute fabric (Apache-2.0 code / CC-BY-4.0 spec).
- jouleclaw-otel-authz, jouleclaw-trust — new jouleclaw reference crates.

Root README now lists aac, map, sandbox, and proof in the catalog, the tree,
and the contribution guide.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 17:04:36 -04:00

843 lines
26 KiB
Rust

//! Map route — local geospatial capabilities over a tileset (SPEC §9): WGS84
//! geometry, POI geocoding, weighted-graph routing, and polygon geofencing.
//!
//! These are *local capabilities a reader runs over the bytes it has* — not a
//! hosted service; the standard carries no routing API. The [`feed`] module is
//! the feed-agnostic adapter: any live source — flights, vessels, satellites,
//! CVEs, incidents, supply-chain nodes, enterprise signals — implements one
//! trait to become an [`feed::Entity`] that resolves to a `map-schema`
//! `CellKey`, so "swap the feeds and it visualizes anything with a location or a
//! time" is a first-class property, not a per-feed rewrite. Ported from
//! `joule-maps` (energy metering removed — that is `map-receipt`'s job).
//!
//! - **Core geometry** — coordinates, bounding boxes, polygons, haversine distance
//! - **Places / POI** — typed point-of-interest catalog with addresses and categories
//! - **Geocoding** — fuzzy forward geocoding and reverse geocoding by radius
//! - **Routing** — Dijkstra and A* shortest-path on a weighted road graph
//! - **Geofencing** — polygon-based enter/exit event detection
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
pub mod bridge;
pub mod cost;
pub mod directions;
pub mod feed;
pub mod isochrone;
pub mod transit;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Errors produced by map operations.
#[derive(Debug, Error, Serialize)]
pub enum MapsError {
#[error("place not found: {0}")]
PlaceNotFound(Uuid),
#[error("geofence not found: {0}")]
GeofenceNotFound(Uuid),
#[error("route node not found: {0}")]
RouteNodeNotFound(u64),
#[error("no route between {from} and {to}")]
NoRoute { from: u64, to: u64 },
#[error("invalid coordinate: lat={lat}, lng={lng}")]
InvalidCoordinate { lat: f64, lng: f64 },
#[error("invalid zoom level: {0} (must be 0..=22)")]
InvalidZoom(u8),
#[error("duplicate place id: {0}")]
DuplicatePlace(Uuid),
#[error("duplicate geofence id: {0}")]
DuplicateGeofence(Uuid),
#[error("energy budget exceeded: {used_uj} uJ used, {budget_uj} uJ allowed")]
EnergyBudgetExceeded { used_uj: u64, budget_uj: u64 },
#[error("serialization error: {0}")]
Serialization(String),
}
// ---------------------------------------------------------------------------
// Core Geometry
// ---------------------------------------------------------------------------
/// A geographic coordinate (WGS 84).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Coordinate {
pub lat: f64,
pub lng: f64,
pub altitude: Option<f64>,
}
impl Coordinate {
/// Create a new coordinate without altitude.
pub fn new(lat: f64, lng: f64) -> Self {
Self { lat, lng, altitude: None }
}
/// Create a new coordinate with altitude in meters.
pub fn with_altitude(lat: f64, lng: f64, altitude: f64) -> Self {
Self { lat, lng, altitude: Some(altitude) }
}
}
/// Axis-aligned bounding box in geographic coordinates.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct BoundingBox {
pub min: Coordinate,
pub max: Coordinate,
}
impl BoundingBox {
/// Does the bounding box contain the given coordinate?
pub fn contains(&self, coord: &Coordinate) -> bool {
coord.lat >= self.min.lat
&& coord.lat <= self.max.lat
&& coord.lng >= self.min.lng
&& coord.lng <= self.max.lng
}
/// Does this bounding box intersect another?
pub fn intersects(&self, other: &BoundingBox) -> bool {
self.min.lat <= other.max.lat
&& self.max.lat >= other.min.lat
&& self.min.lng <= other.max.lng
&& self.max.lng >= other.min.lng
}
/// Expand the bounding box by approximately `meters` in all directions.
/// Uses a rough approximation: 1 degree latitude ≈ 111_320 m.
pub fn expand_by(&self, meters: f64) -> BoundingBox {
let dlat = meters / 111_320.0;
let mid_lat = (self.min.lat + self.max.lat) / 2.0;
let dlng = meters / (111_320.0 * mid_lat.to_radians().cos());
BoundingBox {
min: Coordinate::new(self.min.lat - dlat, self.min.lng - dlng),
max: Coordinate::new(self.max.lat + dlat, self.max.lng + dlng),
}
}
}
/// A geographic point with associated properties.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoPoint {
pub coord: Coordinate,
pub properties: BTreeMap<String, serde_json::Value>,
}
/// A line (polyline) in geographic space.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoLine {
pub points: Vec<Coordinate>,
}
/// A polygon with an exterior ring and optional holes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoPolygon {
pub exterior: Vec<Coordinate>,
pub holes: Vec<Vec<Coordinate>>,
}
/// A geographic feature.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GeoFeature {
Point(GeoPoint),
Line(GeoLine),
Polygon(GeoPolygon),
MultiPoint(Vec<GeoPoint>),
MultiLine(Vec<GeoLine>),
MultiPolygon(Vec<GeoPolygon>),
}
// ---------------------------------------------------------------------------
// Geometry functions
// ---------------------------------------------------------------------------
const EARTH_RADIUS_M: f64 = 6_371_000.0;
/// Great-circle distance between two coordinates in meters (Haversine formula).
pub fn haversine_distance(a: &Coordinate, b: &Coordinate) -> f64 {
let lat1 = a.lat.to_radians();
let lat2 = b.lat.to_radians();
let dlat = (b.lat - a.lat).to_radians();
let dlng = (b.lng - a.lng).to_radians();
let h = (dlat / 2.0).sin().powi(2)
+ lat1.cos() * lat2.cos() * (dlng / 2.0).sin().powi(2);
let c = 2.0 * h.sqrt().asin();
EARTH_RADIUS_M * c
}
/// Initial compass bearing from `from` to `to`, in degrees [0, 360).
pub fn bearing(from: &Coordinate, to: &Coordinate) -> f64 {
let lat1 = from.lat.to_radians();
let lat2 = to.lat.to_radians();
let dlng = (to.lng - from.lng).to_radians();
let x = dlng.sin() * lat2.cos();
let y = lat1.cos() * lat2.sin() - lat1.sin() * lat2.cos() * dlng.cos();
let theta = x.atan2(y).to_degrees();
(theta + 360.0) % 360.0
}
/// Geographic midpoint of two coordinates.
pub fn midpoint(a: &Coordinate, b: &Coordinate) -> Coordinate {
let lat1 = a.lat.to_radians();
let lat2 = b.lat.to_radians();
let lng1 = a.lng.to_radians();
let dlng = (b.lng - a.lng).to_radians();
let bx = lat2.cos() * dlng.cos();
let by = lat2.cos() * dlng.sin();
let lat3 = (lat1.sin() + lat2.sin()).atan2(((lat1.cos() + bx).powi(2) + by.powi(2)).sqrt());
let lng3 = lng1 + by.atan2(lat1.cos() + bx);
Coordinate::new(lat3.to_degrees(), lng3.to_degrees())
}
/// Test whether a point lies inside a polygon using the ray-casting algorithm.
pub fn point_in_polygon(point: &Coordinate, polygon: &GeoPolygon) -> bool {
// Check exterior ring
if !ray_cast(point, &polygon.exterior) {
return false;
}
// Check holes — if inside any hole, outside the polygon
for hole in &polygon.holes {
if ray_cast(point, hole) {
return false;
}
}
true
}
/// Ray-casting algorithm for a single ring.
fn ray_cast(point: &Coordinate, ring: &[Coordinate]) -> bool {
let n = ring.len();
if n < 3 {
return false;
}
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let yi = ring[i].lat;
let xi = ring[i].lng;
let yj = ring[j].lat;
let xj = ring[j].lng;
if ((yi > point.lat) != (yj > point.lat))
&& (point.lng < (xj - xi) * (point.lat - yi) / (yj - yi) + xi)
{
inside = !inside;
}
j = i;
}
inside
}
/// Compute the minimum bounding box for a set of coordinates.
pub fn bounding_box_for(coords: &[Coordinate]) -> BoundingBox {
let mut min_lat = f64::MAX;
let mut max_lat = f64::MIN;
let mut min_lng = f64::MAX;
let mut max_lng = f64::MIN;
for c in coords {
if c.lat < min_lat { min_lat = c.lat; }
if c.lat > max_lat { max_lat = c.lat; }
if c.lng < min_lng { min_lng = c.lng; }
if c.lng > max_lng { max_lng = c.lng; }
}
BoundingBox {
min: Coordinate::new(min_lat, min_lng),
max: Coordinate::new(max_lat, max_lng),
}
}
// ---------------------------------------------------------------------------
// Places / POI
// ---------------------------------------------------------------------------
/// Category of a point of interest.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PlaceCategory {
Restaurant,
Cafe,
Hotel,
Hospital,
School,
Park,
Museum,
Airport,
TrainStation,
BusStop,
GasStation,
Pharmacy,
Bank,
Supermarket,
Gym,
Library,
Theater,
Stadium,
Beach,
Mountain,
Other(String),
}
/// A postal/physical address.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Address {
pub street: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub country: Option<String>,
pub postal_code: Option<String>,
pub formatted: String,
}
/// A point of interest.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Place {
pub id: Uuid,
pub name: String,
pub category: PlaceCategory,
pub coordinate: Coordinate,
pub address: Address,
pub rating: Option<f64>,
pub tags: Vec<String>,
pub created_at: DateTime<Utc>,
}
// ---------------------------------------------------------------------------
// Geocoding
// ---------------------------------------------------------------------------
/// Forward geocode: fuzzy match `query` against place names and addresses.
/// Returns matching places with a relevance score in [0.0, 1.0], sorted descending.
pub fn geocode(query: &str, places: &[Place]) -> Vec<(Place, f64)> {
let query_lower = query.to_lowercase();
let mut results: Vec<(Place, f64)> = Vec::new();
for place in places {
let mut score = 0.0_f64;
// Name matching
let name_lower = place.name.to_lowercase();
if name_lower == query_lower {
score = 1.0;
} else if name_lower.contains(&query_lower) {
score = 0.7;
} else if query_lower.contains(&name_lower) {
score = 0.5;
} else {
// Token overlap
let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
let name_tokens: Vec<&str> = name_lower.split_whitespace().collect();
let matching = query_tokens.iter()
.filter(|qt| name_tokens.iter().any(|nt| nt.contains(*qt) || qt.contains(nt)))
.count();
if matching > 0 {
score = 0.3 * (matching as f64 / query_tokens.len().max(1) as f64);
}
}
// Address matching boost
let addr_lower = place.address.formatted.to_lowercase();
if addr_lower.contains(&query_lower) {
score = score.max(0.6);
} else {
let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
let addr_matching = query_tokens.iter()
.filter(|qt| addr_lower.contains(*qt))
.count();
if addr_matching > 0 {
let addr_score = 0.3 * (addr_matching as f64 / query_tokens.len().max(1) as f64);
score = score.max(addr_score);
}
}
if score > 0.0 {
results.push((place.clone(), score));
}
}
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results
}
/// Reverse geocode: find places within `radius_m` of `coord`.
/// Returns places with distance in meters, sorted by proximity (nearest first).
pub fn reverse_geocode(coord: &Coordinate, places: &[Place], radius_m: f64) -> Vec<(Place, f64)> {
let mut results: Vec<(Place, f64)> = Vec::new();
for place in places {
let dist = haversine_distance(coord, &place.coordinate);
if dist <= radius_m {
results.push((place.clone(), dist));
}
}
results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
results
}
// ---------------------------------------------------------------------------
// Routing
// ---------------------------------------------------------------------------
/// Type of road segment.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RoadType {
Highway,
Primary,
Secondary,
Residential,
Footpath,
Cycleway,
}
/// A node in the routing graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteNode {
pub id: u64,
pub coord: Coordinate,
}
/// A directed edge in the routing graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteEdge {
pub from: u64,
pub to: u64,
pub distance_m: f64,
pub duration_secs: f64,
pub road_type: RoadType,
}
/// A weighted, directed routing graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteGraph {
pub nodes: BTreeMap<u64, RouteNode>,
pub edges: Vec<RouteEdge>,
pub adjacency: BTreeMap<u64, Vec<usize>>,
}
impl RouteGraph {
/// Create an empty routing graph.
pub fn new() -> Self {
Self {
nodes: BTreeMap::new(),
edges: Vec::new(),
adjacency: BTreeMap::new(),
}
}
/// Add a node.
pub fn add_node(&mut self, node: RouteNode) {
self.nodes.insert(node.id, node);
}
/// Add a directed edge. Returns its index.
pub fn add_edge(&mut self, edge: RouteEdge) -> usize {
let idx = self.edges.len();
self.adjacency.entry(edge.from).or_default().push(idx);
self.edges.push(edge);
idx
}
}
impl Default for RouteGraph {
fn default() -> Self {
Self::new()
}
}
/// A segment of a computed route.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteSegment {
pub from: Coordinate,
pub to: Coordinate,
pub distance_m: f64,
pub duration_secs: f64,
pub instruction: String,
}
/// Result of a routing query.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteResult {
pub waypoints: Vec<Coordinate>,
pub total_distance_m: f64,
pub total_duration_secs: f64,
pub segments: Vec<RouteSegment>,
}
/// Find the shortest route using Dijkstra's algorithm (by distance).
pub fn find_route(graph: &RouteGraph, start: u64, end: u64) -> Option<RouteResult> {
use std::collections::BinaryHeap;
use std::cmp::Reverse;
if !graph.nodes.contains_key(&start) || !graph.nodes.contains_key(&end) {
return None;
}
if start == end {
let coord = graph.nodes[&start].coord;
return Some(RouteResult {
waypoints: vec![coord],
total_distance_m: 0.0,
total_duration_secs: 0.0,
segments: Vec::new(),
});
}
let mut dist: BTreeMap<u64, f64> = BTreeMap::new();
let mut prev: BTreeMap<u64, (u64, usize)> = BTreeMap::new(); // node -> (prev_node, edge_idx)
dist.insert(start, 0.0);
// (distance * 1000 as u64 for ordering, node_id)
let mut heap: BinaryHeap<Reverse<(u64, u64)>> = BinaryHeap::new();
heap.push(Reverse((0, start)));
while let Some(Reverse((cost_key, u))) = heap.pop() {
let cost_u = cost_key as f64 / 1000.0;
if let Some(&best) = dist.get(&u) {
if cost_u > best + 0.001 {
continue;
}
}
if u == end {
break;
}
if let Some(adj) = graph.adjacency.get(&u) {
for &edge_idx in adj {
let edge = &graph.edges[edge_idx];
let new_dist = cost_u + edge.distance_m;
let current = dist.get(&edge.to).copied().unwrap_or(f64::INFINITY);
if new_dist < current {
dist.insert(edge.to, new_dist);
prev.insert(edge.to, (u, edge_idx));
heap.push(Reverse(((new_dist * 1000.0) as u64, edge.to)));
}
}
}
}
if !prev.contains_key(&end) {
return None;
}
// Reconstruct path
let mut path_edges: Vec<usize> = Vec::new();
let mut current = end;
while let Some(&(p, edge_idx)) = prev.get(&current) {
path_edges.push(edge_idx);
current = p;
}
path_edges.reverse();
build_route_result(graph, &path_edges)
}
/// Find the shortest route using A* with haversine heuristic.
pub fn find_route_a_star(graph: &RouteGraph, start: u64, end: u64) -> Option<RouteResult> {
use std::collections::BinaryHeap;
use std::cmp::Reverse;
if !graph.nodes.contains_key(&start) || !graph.nodes.contains_key(&end) {
return None;
}
if start == end {
let coord = graph.nodes[&start].coord;
return Some(RouteResult {
waypoints: vec![coord],
total_distance_m: 0.0,
total_duration_secs: 0.0,
segments: Vec::new(),
});
}
let goal_coord = &graph.nodes[&end].coord;
let mut g_score: BTreeMap<u64, f64> = BTreeMap::new();
let mut prev: BTreeMap<u64, (u64, usize)> = BTreeMap::new();
g_score.insert(start, 0.0);
// (f_score * 1000 as u64, node_id)
let h_start = haversine_distance(&graph.nodes[&start].coord, goal_coord);
let mut heap: BinaryHeap<Reverse<(u64, u64)>> = BinaryHeap::new();
heap.push(Reverse(((h_start * 1000.0) as u64, start)));
while let Some(Reverse((_f_key, u))) = heap.pop() {
if u == end {
break;
}
let g_u = g_score.get(&u).copied().unwrap_or(f64::INFINITY);
if let Some(adj) = graph.adjacency.get(&u) {
for &edge_idx in adj {
let edge = &graph.edges[edge_idx];
let tentative_g = g_u + edge.distance_m;
let current_g = g_score.get(&edge.to).copied().unwrap_or(f64::INFINITY);
if tentative_g < current_g {
g_score.insert(edge.to, tentative_g);
prev.insert(edge.to, (u, edge_idx));
let h = haversine_distance(&graph.nodes[&edge.to].coord, goal_coord);
let f = tentative_g + h;
heap.push(Reverse(((f * 1000.0) as u64, edge.to)));
}
}
}
}
if !prev.contains_key(&end) {
return None;
}
let mut path_edges: Vec<usize> = Vec::new();
let mut current = end;
while let Some(&(p, edge_idx)) = prev.get(&current) {
path_edges.push(edge_idx);
current = p;
}
path_edges.reverse();
build_route_result(graph, &path_edges)
}
/// Build a `RouteResult` from a sequence of edge indices.
fn build_route_result(graph: &RouteGraph, path_edges: &[usize]) -> Option<RouteResult> {
let mut waypoints: Vec<Coordinate> = Vec::new();
let mut segments: Vec<RouteSegment> = Vec::new();
let mut total_distance_m = 0.0;
let mut total_duration_secs = 0.0;
for (i, &edge_idx) in path_edges.iter().enumerate() {
let edge = &graph.edges[edge_idx];
let from_coord = graph.nodes[&edge.from].coord;
let to_coord = graph.nodes[&edge.to].coord;
if i == 0 {
waypoints.push(from_coord);
}
waypoints.push(to_coord);
total_distance_m += edge.distance_m;
total_duration_secs += edge.duration_secs;
let instruction = format!(
"Continue on {:?} for {:.0} m",
edge.road_type, edge.distance_m
);
segments.push(RouteSegment {
from: from_coord,
to: to_coord,
distance_m: edge.distance_m,
duration_secs: edge.duration_secs,
instruction,
});
}
Some(RouteResult {
waypoints,
total_distance_m,
total_duration_secs,
segments,
})
}
// ---------------------------------------------------------------------------
// Geofencing
// ---------------------------------------------------------------------------
/// Trigger mode for a geofence.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GeofenceTrigger {
Enter,
Exit,
Both,
}
/// A named geofence region.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Geofence {
pub id: Uuid,
pub name: String,
pub polygon: GeoPolygon,
pub trigger: GeofenceTrigger,
pub active: bool,
}
/// Kind of geofence transition.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GeofenceEventKind {
Entered,
Exited,
}
/// A geofence transition event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeofenceEvent {
pub fence_id: Uuid,
pub entity_id: Uuid,
pub kind: GeofenceEventKind,
pub coord: Coordinate,
pub timestamp: DateTime<Utc>,
}
/// Check an entity's position against a set of geofences.
///
/// `was_inside` maps fence_id → whether the entity was previously inside.
/// Returns events for fences whose state changed.
pub fn check_geofences(
coord: &Coordinate,
entity_id: Uuid,
fences: &[Geofence],
was_inside: &BTreeMap<Uuid, bool>,
) -> Vec<GeofenceEvent> {
let now = Utc::now();
let mut events = Vec::new();
for fence in fences {
if !fence.active {
continue;
}
let inside_now = point_in_polygon(coord, &fence.polygon);
let inside_before = was_inside.get(&fence.id).copied().unwrap_or(false);
if inside_now && !inside_before {
if fence.trigger == GeofenceTrigger::Enter || fence.trigger == GeofenceTrigger::Both {
events.push(GeofenceEvent {
fence_id: fence.id,
entity_id,
kind: GeofenceEventKind::Entered,
coord: *coord,
timestamp: now,
});
}
} else if !inside_now
&& inside_before
&& (fence.trigger == GeofenceTrigger::Exit || fence.trigger == GeofenceTrigger::Both)
{
events.push(GeofenceEvent {
fence_id: fence.id,
entity_id,
kind: GeofenceEventKind::Exited,
coord: *coord,
timestamp: now,
});
}
}
events
}
#[cfg(test)]
mod tests {
use super::*;
fn place(name: &str, lat: f64, lng: f64, category: PlaceCategory) -> Place {
Place {
id: Uuid::new_v4(),
name: name.to_string(),
category,
coordinate: Coordinate::new(lat, lng),
address: Address {
street: None,
city: None,
state: None,
country: None,
postal_code: None,
formatted: String::new(),
},
rating: None,
tags: vec![],
created_at: Utc::now(),
}
}
#[test]
fn haversine_one_degree_latitude_is_about_111km() {
let d = haversine_distance(&Coordinate::new(0.0, 0.0), &Coordinate::new(1.0, 0.0));
assert!((d - 111_195.0).abs() < 500.0, "1° lat ≈ 111 km, got {d}");
}
#[test]
fn point_in_polygon_inside_and_outside() {
let poly = GeoPolygon {
exterior: vec![
Coordinate::new(0.0, 0.0),
Coordinate::new(1.0, 0.0),
Coordinate::new(1.0, 1.0),
Coordinate::new(0.0, 1.0),
],
holes: vec![],
};
assert!(point_in_polygon(&Coordinate::new(0.5, 0.5), &poly));
assert!(!point_in_polygon(&Coordinate::new(2.0, 2.0), &poly));
}
#[test]
fn geocode_ranks_name_match_first() {
let places = vec![
place("Blue Bottle Coffee", 37.77, -122.42, PlaceCategory::Cafe),
place("City Hospital", 37.78, -122.41, PlaceCategory::Hospital),
];
let results = geocode("blue bottle", &places);
assert!(!results.is_empty());
assert_eq!(results[0].0.name, "Blue Bottle Coffee");
}
#[test]
fn find_route_traverses_intermediate_node() {
let mut g = RouteGraph::new();
g.add_node(RouteNode { id: 1, coord: Coordinate::new(0.0, 0.0) });
g.add_node(RouteNode { id: 2, coord: Coordinate::new(0.0, 1.0) });
g.add_node(RouteNode { id: 3, coord: Coordinate::new(0.0, 2.0) });
let edge = |from, to| RouteEdge {
from,
to,
distance_m: 100.0,
duration_secs: 10.0,
road_type: RoadType::Residential,
};
g.add_edge(edge(1, 2));
g.add_edge(edge(2, 3));
let route = find_route(&g, 1, 3).expect("a route from 1 to 3 exists");
assert!(route.total_distance_m > 0.0);
assert!(route.waypoints.len() >= 2);
}
#[test]
fn geofence_enter_event_fires() {
let fence = Geofence {
id: Uuid::new_v4(),
name: "zone".to_string(),
polygon: GeoPolygon {
exterior: vec![
Coordinate::new(0.0, 0.0),
Coordinate::new(1.0, 0.0),
Coordinate::new(1.0, 1.0),
Coordinate::new(0.0, 1.0),
],
holes: vec![],
},
trigger: GeofenceTrigger::Both,
active: true,
};
let was_inside = BTreeMap::new(); // nothing seen yet → entering
let events =
check_geofences(&Coordinate::new(0.5, 0.5), Uuid::new_v4(), &[fence], &was_inside);
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, GeofenceEventKind::Entered);
}
}