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

@ -29,7 +29,7 @@ use tracing_subscriber::EnvFilter;
use state::AppState;
#[derive(Parser)]
#[command(name = "perfect-postcodes", about = "Perfect Postcodes property map server")]
#[command(name = "perfect-postcode", about = "Perfect Postcode property map server")]
struct Cli {
/// Path to the wide property parquet file
#[arg(long)]
@ -74,6 +74,10 @@ struct Cli {
/// Ollama model name for area summaries
#[arg(long, env = "OLLAMA_MODEL", default_value = "gemma3:12b")]
ollama_model: String,
/// R5 routing service URL for real-time travel times (e.g. http://r5:8003)
#[arg(long, env = "R5_URL", default_value = "")]
r5_url: String,
}
#[tokio::main]
@ -238,6 +242,11 @@ async fn main() -> anyhow::Result<()> {
"Ollama configured: {} (model: {})",
cli.ollama_url, cli.ollama_model
);
if !cli.r5_url.is_empty() {
info!("R5 routing service configured: {}", cli.r5_url);
} else {
info!("R5 routing service not configured (travel time queries disabled)");
}
let token_cache = Arc::new(auth::TokenCache::new());
@ -261,6 +270,7 @@ async fn main() -> anyhow::Result<()> {
pocketbase_url: cli.pocketbase_url,
ollama_url: cli.ollama_url,
ollama_model: cli.ollama_model,
r5_url: cli.r5_url,
token_cache,
});
@ -283,6 +293,8 @@ async fn main() -> anyhow::Result<()> {
let state_pb = state.clone();
let state_postcode_stats = state.clone();
let state_area_summary = state.clone();
let state_shorten = state.clone();
let state_short_url = state.clone();
let api = Router::new()
.route(
@ -335,6 +347,14 @@ async fn main() -> anyhow::Result<()> {
.route(
"/api/area-summary",
post(move |body| routes::post_area_summary(state_area_summary.clone(), body)),
)
.route(
"/api/shorten",
post(move |body| routes::post_shorten(state_shorten.clone(), body)),
)
.route(
"/s/{code}",
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
);
// Add tile routes

View file

@ -10,8 +10,10 @@ mod postcode_stats;
mod postcodes;
pub(crate) mod properties;
mod screenshot;
mod shorten;
mod stats;
mod tiles;
pub(crate) mod travel_time;
pub use area_summary::post_area_summary;
pub use export::get_export;
@ -25,4 +27,5 @@ pub use postcode_stats::get_postcode_stats;
pub use postcodes::{get_postcode_lookup, get_postcodes};
pub use properties::get_hexagon_properties;
pub use screenshot::get_screenshot;
pub use shorten::{get_short_url, post_shorten};
pub use tiles::{get_style, get_tile, init_tile_reader};

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))
}

View 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()
}
}
}

View 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)
}

View file

@ -43,6 +43,8 @@ pub struct AppState {
pub ollama_url: String,
/// Ollama model name for area summaries (e.g. gemma3:12b)
pub ollama_model: String,
/// R5 routing service URL for real-time travel times (empty = disabled)
pub r5_url: String,
/// Token validation cache (60s TTL)
pub token_cache: Arc<TokenCache>,
}