More
This commit is contained in:
parent
1f68ca0512
commit
3599803589
43 changed files with 3578 additions and 262 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue