trailgen-core 0.3.0

Typed trail graph, route scoring, loop search, and geospatial route I/O for adequate-trailgen.
Documentation
use crate::geo::{Coord, LineString};
use crate::io::route_file::{RouteFile, RouteFileMetadata};
use crate::{Result, TrailgenError};
use serde_json::{Map, Value};

pub fn route_line_from_str(s: &str) -> Result<LineString> {
    route_file_from_str(s).map(|route| route.line)
}

pub fn route_file_from_str(s: &str) -> Result<RouteFile> {
    let root: Value = serde_json::from_str(s)?;
    let points = route_points(&root).ok_or_else(|| {
        TrailgenError::InvalidData(
            "route JSON must contain coordinates, points, track, or route arrays".to_owned(),
        )
    })?;
    Ok(RouteFile::new(
        LineString::new(points)?,
        metadata_from_value(&root),
    ))
}

fn route_points(value: &Value) -> Option<Vec<Coord>> {
    match value {
        Value::Array(xs) => points_from_array(xs),
        Value::Object(obj) => points_from_object_container(obj),
        _ => None,
    }
}

fn points_from_object_container(obj: &Map<String, Value>) -> Option<Vec<Coord>> {
    for key in [
        "coordinates",
        "points",
        "track",
        "tracks",
        "route",
        "routes",
        "locations",
        "path",
        "data",
        "geometry",
    ] {
        if let Some(points) = obj.get(key).and_then(route_points) {
            return Some(points);
        }
    }
    None
}

fn points_from_array(xs: &[Value]) -> Option<Vec<Coord>> {
    if xs.iter().all(point_like) {
        return xs.iter().map(point_from_value).collect();
    }
    xs.iter().find_map(route_points)
}

fn point_like(value: &Value) -> bool {
    match value {
        Value::Array(xs) => {
            xs.len() >= 2 && value_f64(&xs[0]).is_some() && value_f64(&xs[1]).is_some()
        }
        Value::Object(obj) => {
            keyed_f64(obj, LON_KEYS).is_some() && keyed_f64(obj, LAT_KEYS).is_some()
        }
        _ => false,
    }
}

fn point_from_value(value: &Value) -> Option<Coord> {
    match value {
        Value::Array(xs) => coord(
            value_f64(xs.first()?)?,
            value_f64(xs.get(1)?)?,
            xs.get(2).and_then(value_f64),
        ),
        Value::Object(obj) => coord(
            keyed_f64(obj, LON_KEYS)?,
            keyed_f64(obj, LAT_KEYS)?,
            keyed_f64(obj, ELE_KEYS),
        ),
        _ => None,
    }
}

fn coord(lon: f64, lat: f64, ele: Option<f64>) -> Option<Coord> {
    if !lon.is_finite() || !lat.is_finite() || lon.abs() > 180.0 || lat.abs() > 90.0 {
        return None;
    }
    Some(Coord { lon, lat, ele })
}

const LON_KEYS: &[&str] = &["lon", "lng", "longitude", "x"];
const LAT_KEYS: &[&str] = &["lat", "latitude", "y"];
const ELE_KEYS: &[&str] = &[
    "ele",
    "elevation",
    "elevation_m",
    "alt",
    "altitude",
    "altitude_m",
    "z",
];

fn keyed_f64(obj: &Map<String, Value>, keys: &[&str]) -> Option<f64> {
    keys.iter()
        .find_map(|key| obj.get(*key).and_then(value_f64))
}

fn value_f64(value: &Value) -> Option<f64> {
    match value {
        Value::Number(n) => n.as_f64(),
        Value::String(s) => s.parse().ok(),
        _ => None,
    }
}

fn metadata_from_value(value: &Value) -> RouteFileMetadata {
    let Value::Object(obj) = value else {
        return RouteFileMetadata::default();
    };
    RouteFileMetadata {
        title: keyed_str(
            obj,
            &[
                "name",
                "title",
                "route_name",
                "activity_name",
                "display_name",
            ],
        ),
        description: keyed_str(obj, &["description", "desc", "comment", "notes"]),
        recorded_at: keyed_str(
            obj,
            &[
                "recorded_at",
                "time",
                "timestamp",
                "start_time",
                "created_at",
                "date",
            ],
        ),
        activity_type: keyed_str(obj, &["activity_type", "activity", "sport", "type"]),
    }
}

fn keyed_str(obj: &Map<String, Value>, keys: &[&str]) -> Option<String> {
    keys.iter()
        .find_map(|key| obj.get(*key).and_then(Value::as_str))
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_owned)
}