This commit is contained in:
Andras Schmelczer 2026-02-10 22:21:15 +00:00
parent 1f68ca0512
commit 3599803589
43 changed files with 3578 additions and 262 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;
use tracing::{info, warn};
use crate::aggregation::Aggregator;
use crate::consts::MAX_CELLS_PER_REQUEST;
@ -14,6 +14,7 @@ use crate::parsing::{
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
};
use crate::routes::travel_time::fetch_travel_times;
use crate::state::AppState;
#[derive(Serialize)]
@ -32,6 +33,10 @@ 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>,
}
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
@ -99,6 +104,23 @@ 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>,
@ -118,7 +140,20 @@ pub async fn get_hexagons(
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
let response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
// Parse destination for travel time (before moving into blocking closure)
let destination = params
.destination
.as_deref()
.map(parse_destination)
.transpose()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let mode = params.mode.clone().unwrap_or_else(|| "transit".into());
// Capture what we need for the R5 call before moving state into spawn_blocking
let r5_url = state.r5_url.clone();
let http_client = state.http_client.clone();
let mut response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
let t0 = std::time::Instant::now();
let num_features = state.data.num_features;
@ -214,5 +249,59 @@ 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 R5 service (R5_URL not configured)".into(),
));
}
// Collect hex centroids from the response
let origins: Vec<[f64; 2]> = response
.features
.iter()
.map(|f| {
let lat = f
.get("lat")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let lon = f
.get("lon")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
[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);
}
}
}
info!(
hexagons = response.features.len(),
destination = format_args!("{},{}", dest[0], dest[1]),
mode = mode,
"Travel times merged"
);
}
Err(err) => {
warn!("R5 travel time query failed, returning hexagons without travel_time: {}", err);
// Don't fail the whole request — just omit travel_time
}
}
}
Ok(Json(response))
}