From 3853b5dce76fc2c194392a4c8a6f0ace60e6357b Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 4 Apr 2026 09:58:58 +0100 Subject: [PATCH] Support outcode & gps search --- server-rs/src/data.rs | 2 +- server-rs/src/data/postcodes.rs | 88 +++++++++++++++++++++++++++++++ server-rs/src/main.rs | 6 +++ server-rs/src/routes/places.rs | 46 +++++++++++++++- server-rs/src/routes/postcodes.rs | 45 ++++++++++++++++ server-rs/src/state.rs | 4 +- 6 files changed, 188 insertions(+), 3 deletions(-) diff --git a/server-rs/src/data.rs b/server-rs/src/data.rs index 5d0ea2f..6087166 100644 --- a/server-rs/src/data.rs +++ b/server-rs/src/data.rs @@ -6,7 +6,7 @@ pub mod travel_time; pub use places::PlaceData; pub use poi::{POICategoryGroup, POIData}; -pub use postcodes::PostcodeData; +pub use postcodes::{OutcodeData, PostcodeData}; pub use property::{ precompute_h3, FeatureStats, Histogram, PropertyData, QuantRef, RenovationEvent, }; diff --git a/server-rs/src/data/postcodes.rs b/server-rs/src/data/postcodes.rs index ba0a14e..75aed04 100644 --- a/server-rs/src/data/postcodes.rs +++ b/server-rs/src/data/postcodes.rs @@ -6,6 +6,94 @@ use std::fs; use std::path::Path; use tracing::{debug, info}; +use super::PlaceData; + +/// Precomputed outcode data derived from postcode boundaries. +/// An outcode is the first part of a UK postcode (e.g. "E14" from "E14 2DG"). +pub struct OutcodeData { + pub names: Vec, + pub name_lower: Vec, + pub centroids: Vec<(f32, f32)>, + pub cities: Vec>, +} + +impl OutcodeData { + /// Derive outcode data by grouping postcodes by their outcode prefix and averaging centroids. + pub fn from_postcode_and_place_data( + postcode_data: &PostcodeData, + place_data: &PlaceData, + ) -> Self { + // Group postcode centroids by outcode + let mut outcode_centroids: FxHashMap> = FxHashMap::default(); + for (idx, postcode) in postcode_data.postcodes.iter().enumerate() { + if let Some(space_idx) = postcode.find(' ') { + let outcode = &postcode[..space_idx]; + outcode_centroids + .entry(outcode.to_string()) + .or_default() + .push(postcode_data.centroids[idx]); + } + } + + // Build sorted vecs + let mut entries: Vec<(String, (f32, f32))> = outcode_centroids + .into_iter() + .map(|(outcode, pts)| { + let count = pts.len() as f32; + let avg_lat = pts.iter().map(|(lat, _)| lat).sum::() / count; + let avg_lon = pts.iter().map(|(_, lon)| lon).sum::() / count; + (outcode, (avg_lat, avg_lon)) + }) + .collect(); + entries.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + + let names: Vec = entries.iter().map(|(n, _)| n.clone()).collect(); + let name_lower: Vec = names.iter().map(|n| n.to_lowercase()).collect(); + let centroids: Vec<(f32, f32)> = entries.iter().map(|(_, c)| *c).collect(); + + // Compute nearest city for each outcode (same algorithm as PlaceData) + let city_indices: Vec = place_data + .type_rank + .iter() + .enumerate() + .filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None }) + .collect(); + + let cities: Vec> = centroids + .iter() + .map(|&(lat, lon)| { + let cos_lat = lat.to_radians().cos(); + let mut best_dist_sq = f32::MAX; + let mut best_city: Option<&str> = None; + for &ci in &city_indices { + let dlat = place_data.lat[ci] - lat; + let dlon = (place_data.lon[ci] - lon) * cos_lat; + let dist_sq = dlat * dlat + dlon * dlon; + if dist_sq < best_dist_sq { + best_dist_sq = dist_sq; + best_city = Some(&place_data.name[ci]); + } + } + // ~100km threshold + if best_dist_sq < 0.81 { + best_city.map(|s| s.to_string()) + } else { + None + } + }) + .collect(); + + info!(outcodes = names.len(), "Outcode data derived from postcodes"); + + OutcodeData { + names, + name_lower, + centroids, + cities, + } + } +} + /// GeoJSON structures for parsing postcode boundary files #[derive(Deserialize)] struct FeatureCollection { diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index b0aff6b..86ce445 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -243,6 +243,9 @@ async fn main() -> anyhow::Result<()> { "Postcode boundaries loaded" ); + let outcode_data = + data::OutcodeData::from_postcode_and_place_data(&postcode_data, &place_data); + // Initialize tile reader let tiles_path = &cli.tiles; if !tiles_path.exists() { @@ -375,6 +378,7 @@ async fn main() -> anyhow::Result<()> { poi_grid: Arc::new(poi_grid), place_data: Arc::new(place_data), postcode_data: Arc::new(postcode_data), + outcode_data: Arc::new(outcode_data), feature_name_to_index, min_keys, max_keys, @@ -444,6 +448,7 @@ async fn main() -> anyhow::Result<()> { get(routes::get_postcodes).layer(ConcurrencyLimitLayer::new(20)), ) .route("/api/postcode/{postcode}", get(routes::get_postcode_lookup)) + .route("/api/nearest-postcode", get(routes::get_nearest_postcode)) .route( "/api/pois", get(routes::get_pois).layer(ConcurrencyLimitLayer::new(20)), @@ -460,6 +465,7 @@ async fn main() -> anyhow::Result<()> { "/api/hexagon-properties", get(routes::get_hexagon_properties), ) + .route("/api/filter-counts", get(routes::get_filter_counts)) .route("/api/hexagon-stats", get(routes::get_hexagon_stats)) .route("/api/postcode-stats", get(routes::get_postcode_stats)) .route( diff --git a/server-rs/src/routes/places.rs b/server-rs/src/routes/places.rs index 9de9a7a..2d4f4c1 100644 --- a/server-rs/src/routes/places.rs +++ b/server-rs/src/routes/places.rs @@ -52,6 +52,7 @@ pub async fn get_places( let t0 = std::time::Instant::now(); let query_lower = query.to_lowercase(); let pd = &state.place_data; + let od = &state.outcode_data; let tt_store = &state.travel_time_store; // Linear scan — ~50-100k rows, <1ms @@ -99,7 +100,7 @@ pub async fn get_places( matches.truncate(limit); - let results: Vec = matches + let mut results: Vec = matches .iter() .map(|(idx, .., slug)| PlaceResult { name: pd.name[*idx].clone(), @@ -111,6 +112,49 @@ pub async fn get_places( }) .collect(); + // Also search outcodes (skip when mode filter is set — outcodes aren't travel destinations) + if mode_filter.is_none() { + let query_upper = query_lower.to_uppercase(); + let mut outcode_results: Vec = od + .name_lower + .iter() + .enumerate() + .filter_map(|(idx, name)| { + if !name.starts_with(&query_lower) { + return None; + } + let is_exact = name.len() == query_lower.len(); + Some((idx, is_exact)) + }) + .collect::>() + .into_iter() + .map(|(idx, _is_exact)| PlaceResult { + name: od.names[idx].clone(), + slug: od.names[idx].to_lowercase(), + place_type: "outcode".to_string(), + lat: od.centroids[idx].0, + lon: od.centroids[idx].1, + city: od.cities[idx].clone(), + }) + .collect(); + + // Sort outcodes: exact first, then by name length (shorter = broader area) + outcode_results.sort_unstable_by(|a, b| { + let a_exact = a.name.eq_ignore_ascii_case(&query_upper); + let b_exact = b.name.eq_ignore_ascii_case(&query_upper); + b_exact + .cmp(&a_exact) + .then(a.name.len().cmp(&b.name.len())) + }); + + // Prepend outcode results (up to 3) before place results, keeping total ≤ limit + outcode_results.truncate(3); + let place_slots = limit.saturating_sub(outcode_results.len()); + results.truncate(place_slots); + outcode_results.append(&mut results); + results = outcode_results; + } + let elapsed = t0.elapsed(); info!( query = query.as_str(), diff --git a/server-rs/src/routes/postcodes.rs b/server-rs/src/routes/postcodes.rs index 9332a10..20ca1e1 100644 --- a/server-rs/src/routes/postcodes.rs +++ b/server-rs/src/routes/postcodes.rs @@ -28,6 +28,12 @@ pub struct PostcodesResponse { features: Vec>, } +#[derive(Deserialize)] +pub struct NearestPostcodeParams { + lat: f64, + lng: f64, +} + #[derive(Deserialize)] pub struct PostcodeParams { bounds: Option, @@ -311,6 +317,45 @@ pub async fn get_postcodes( Ok(Json(response)) } +/// Find the nearest postcode to a given lat/lng coordinate. +pub async fn get_nearest_postcode( + State(shared): State>, + Query(params): Query, +) -> Result, StatusCode> { + let state = shared.load_state(); + let postcode_data = &state.postcode_data; + + let query_lat = params.lat as f32; + let query_lng = params.lng as f32; + let cos_lat = (query_lat as f64).to_radians().cos() as f32; + + let mut best_idx: Option = None; + let mut best_dist_sq = f32::MAX; + + for (idx, &(pc_lat, pc_lon)) in postcode_data.centroids.iter().enumerate() { + let dlat = pc_lat - query_lat; + let dlon = (pc_lon - query_lng) * cos_lat; + let dist_sq = dlat * dlat + dlon * dlon; + if dist_sq < best_dist_sq { + best_dist_sq = dist_sq; + best_idx = Some(idx); + } + } + + let idx = best_idx.ok_or(StatusCode::NOT_FOUND)?; + let (lat, lon) = postcode_data.centroids[idx]; + let geometry = postcode_data.geometries[idx].clone(); + let postcode = &postcode_data.postcodes[idx]; + + info!(postcode = %postcode, "GET /api/nearest-postcode"); + Ok(Json(serde_json::json!({ + "postcode": postcode, + "latitude": lat as f64, + "longitude": lon as f64, + "geometry": geometry, + }))) +} + /// Look up a single postcode and return its centroid coordinates and geometry. pub async fn get_postcode_lookup( State(shared): State>, diff --git a/server-rs/src/state.rs b/server-rs/src/state.rs index 45a383d..5718803 100644 --- a/server-rs/src/state.rs +++ b/server-rs/src/state.rs @@ -7,7 +7,7 @@ use rustc_hash::FxHashMap; use crate::auth::TokenCache; use crate::data::{ - POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore, + OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore, }; use crate::pocketbase::SuperuserTokenCache; use crate::routes::FeaturesResponse; @@ -39,6 +39,8 @@ pub struct AppState { pub place_data: Arc, /// Postcode boundary data for high-zoom rendering pub postcode_data: Arc, + /// Precomputed outcode centroids for search + pub outcode_data: Arc, /// Precomputed POI category groups (sorted) pub poi_category_groups: Arc>, /// Precomputed travel time data store