This commit is contained in:
Andras Schmelczer 2026-02-14 12:53:29 +00:00
parent 3a3f899ea2
commit 128b3191e7
68 changed files with 28060 additions and 1152 deletions

View file

@ -6,7 +6,7 @@ use axum::response::Json;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::{info, warn};
use tracing::info;
use crate::aggregation::Aggregator;
use crate::consts::MAX_CELLS_PER_REQUEST;
@ -33,10 +33,55 @@ pub struct HexagonParams {
/// When present (even if empty), only listed features are aggregated and written.
/// When absent, all features are included (backward compatible).
fields: Option<String>,
/// Destination point as "lat,lon" for real-time travel time calculation via R5.
destination: Option<String>,
/// Transport mode for travel time: "transit" (default), "car", or "bicycle".
mode: Option<String>,
/// Pipe-separated travel time entries: `lat,lon,mode|lat,lon,mode`
/// Each entry requests travel time from hex centroids to that destination via the given mode.
travel: Option<String>,
}
struct TravelEntry {
lat: f64,
lon: f64,
mode: String,
}
const VALID_MODES: &[&str] = &["car", "bicycle", "walking", "transit"];
/// Parse `travel` param into a list of travel entries.
/// Format: `lat,lon,mode|lat,lon,mode`
fn parse_travel_entries(s: &str) -> Result<Vec<TravelEntry>, String> {
let mut entries = Vec::new();
let mut seen_modes = Vec::new();
for segment in s.split('|') {
let parts: Vec<&str> = segment.split(',').collect();
if parts.len() != 3 {
return Err(format!(
"each travel entry must be 'lat,lon,mode', got '{}'",
segment
));
}
let lat: f64 = parts[0]
.trim()
.parse()
.map_err(|_| format!("invalid travel latitude in '{}'", segment))?;
let lon: f64 = parts[1]
.trim()
.parse()
.map_err(|_| format!("invalid travel longitude in '{}'", segment))?;
let mode = parts[2].trim().to_string();
if !VALID_MODES.contains(&mode.as_str()) {
return Err(format!(
"invalid travel mode '{}', must be one of: {}",
mode,
VALID_MODES.join(", ")
));
}
if seen_modes.contains(&mode) {
return Err(format!("duplicate travel mode '{}'", mode));
}
seen_modes.push(mode.clone());
entries.push(TravelEntry { lat, lon, mode });
}
Ok(entries)
}
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
@ -104,23 +149,6 @@ fn build_feature_maps(
features
}
/// Parse "lat,lon" string into (lat, lon) tuple.
fn parse_destination(s: &str) -> Result<[f64; 2], String> {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 2 {
return Err("destination must be 'lat,lon'".into());
}
let lat: f64 = parts[0]
.trim()
.parse()
.map_err(|_| "invalid destination latitude")?;
let lon: f64 = parts[1]
.trim()
.parse()
.map_err(|_| "invalid destination longitude")?;
Ok([lat, lon])
}
pub async fn get_hexagons(
state: Arc<AppState>,
Query(params): Query<HexagonParams>,
@ -141,16 +169,17 @@ pub async fn get_hexagons(
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
// Parse destination for travel time (before moving into blocking closure)
let destination = params
.destination
// Parse travel entries
let travel_entries = params
.travel
.as_deref()
.map(parse_destination)
.filter(|s| !s.is_empty())
.map(parse_travel_entries)
.transpose()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let mode = params.mode.clone().unwrap_or_else(|| "car".into());
.map_err(|e| (StatusCode::BAD_REQUEST, e))?
.unwrap_or_default();
// Capture what we need for the R5 call before moving state into spawn_blocking
// Capture what we need for the R5 calls before moving state into spawn_blocking
let r5_url = state.r5_url.clone();
let http_client = state.http_client.clone();
@ -250,14 +279,12 @@ pub async fn get_hexagons(
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
// If a destination was requested and R5 is configured, fetch travel times.
if let Some(dest) = destination {
if r5_url.is_empty() {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"Travel time queries require routing service (R5_URL not configured)".into(),
));
}
// If travel entries were requested and R5 is configured, fetch travel times concurrently.
if !travel_entries.is_empty() {
let url = r5_url.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Travel time queries require routing service (R5_URL not configured)".into(),
))?;
// Collect hex centroids
let origins: Vec<[f64; 2]> = response
@ -267,39 +294,56 @@ pub async fn get_hexagons(
let lat = f
.get("lat")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
.expect("lat must be present in feature map");
let lon = f
.get("lon")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
.expect("lon must be present in feature map");
[lat, lon]
})
.collect();
match fetch_travel_times(&http_client, &r5_url, origins, dest, &mode).await {
Ok(travel_times) => {
for (feature, tt) in response.features.iter_mut().zip(travel_times) {
match tt {
Some(minutes) => {
if let Some(num) = serde_json::Number::from_f64(minutes) {
feature.insert("travel_time".into(), Value::Number(num));
}
}
None => {
feature.insert("travel_time".into(), Value::Null);
// Fire concurrent R5 calls for each travel entry
let mut handles = Vec::with_capacity(travel_entries.len());
for entry in &travel_entries {
let client = http_client.clone();
let url = url.to_string();
let origins = origins.clone();
let dest = [entry.lat, entry.lon];
let mode = entry.mode.clone();
handles.push(tokio::spawn(async move {
fetch_travel_times(&client, &url, origins, dest, &mode).await
}));
}
let mut results = Vec::with_capacity(handles.len());
for handle in handles {
results.push(handle.await);
}
for (entry, result) in travel_entries.iter().zip(results) {
let travel_times = result
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|err| (StatusCode::BAD_GATEWAY, err))?;
let field_name = format!("travel_time_{}", entry.mode);
for (feature, tt) in response.features.iter_mut().zip(&travel_times) {
match tt {
Some(minutes) => {
if let Some(num) = serde_json::Number::from_f64(*minutes) {
feature.insert(field_name.clone(), Value::Number(num));
}
}
None => {
feature.insert(field_name.clone(), Value::Null);
}
}
info!(
hexagons = response.features.len(),
destination = format_args!("{},{}", dest[0], dest[1]),
mode = mode,
"Travel times merged"
);
}
Err(err) => {
warn!("Travel time query failed, returning hexagons without travel_time: {}", err);
}
info!(
hexagons = response.features.len(),
destination = format_args!("{},{}", entry.lat, entry.lon),
mode = entry.mode,
"Travel times merged"
);
}
}