More
This commit is contained in:
parent
1f68ca0512
commit
3599803589
43 changed files with 3578 additions and 262 deletions
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
122
server-rs/src/routes/shorten.rs
Normal file
122
server-rs/src/routes/shorten.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Path;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::Json;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const CODE_LEN: usize = 8;
|
||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
fn generate_code() -> String {
|
||||
let mut rng = rand::rng();
|
||||
(0..CODE_LEN)
|
||||
.map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ShortenRequest {
|
||||
params: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ShortenResponse {
|
||||
code: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PbRecord {
|
||||
code: String,
|
||||
params: String,
|
||||
}
|
||||
|
||||
pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>) -> Response {
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let code = generate_code();
|
||||
|
||||
let record = PbRecord {
|
||||
code: code.clone(),
|
||||
params: req.params,
|
||||
};
|
||||
|
||||
let res = state
|
||||
.http_client
|
||||
.post(format!(
|
||||
"{pb_url}/api/collections/short_urls/records"
|
||||
))
|
||||
.json(&record)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body = ShortenResponse {
|
||||
url: format!("/s/{code}"),
|
||||
code,
|
||||
};
|
||||
Json(body).into_response()
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("PocketBase create failed ({status}): {text}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_short_url(state: Arc<AppState>, Path(code): Path<String>) -> Response {
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let filter = format!("code=\"{code}\"");
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/short_urls/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = state.http_client.get(&url).send().await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let json: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse PocketBase response: {err}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let params = json["items"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item["params"].as_str());
|
||||
|
||||
match params {
|
||||
Some(params) => {
|
||||
Redirect::temporary(&format!("/dashboard?{params}")).into_response()
|
||||
}
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
warn!("PocketBase lookup failed ({status})");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
58
server-rs/src/routes/travel_time.rs
Normal file
58
server-rs/src/routes/travel_time.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TravelTimeRequest {
|
||||
origins: Vec<[f64; 2]>,
|
||||
destination: [f64; 2],
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TravelTimeResponse {
|
||||
travel_times: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
/// Call the R5 service to compute many-to-one travel times.
|
||||
///
|
||||
/// Returns a Vec of travel times in minutes (one per origin), with None for unreachable origins.
|
||||
pub async fn fetch_travel_times(
|
||||
client: &reqwest::Client,
|
||||
r5_url: &str,
|
||||
origins: Vec<[f64; 2]>,
|
||||
destination: [f64; 2],
|
||||
mode: &str,
|
||||
) -> Result<Vec<Option<f64>>, String> {
|
||||
let url = format!("{}/travel-times", r5_url);
|
||||
|
||||
let request_body = TravelTimeRequest {
|
||||
origins,
|
||||
destination,
|
||||
mode: mode.to_string(),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&request_body)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("R5 request failed: {}", e);
|
||||
format!("R5 service error: {}", e)
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
warn!("R5 returned {}: {}", status, body);
|
||||
return Err(format!("R5 service returned {}: {}", status, body));
|
||||
}
|
||||
|
||||
let body: TravelTimeResponse = resp.json().await.map_err(|e| {
|
||||
warn!("Failed to parse R5 response: {}", e);
|
||||
format!("Failed to parse R5 response: {}", e)
|
||||
})?;
|
||||
|
||||
Ok(body.travel_times)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue