Support outcode & gps search

This commit is contained in:
Andras Schmelczer 2026-04-04 09:58:58 +01:00
parent 23d128ff63
commit 3853b5dce7
6 changed files with 188 additions and 3 deletions

View file

@ -6,7 +6,7 @@ pub mod travel_time;
pub use places::PlaceData; pub use places::PlaceData;
pub use poi::{POICategoryGroup, POIData}; pub use poi::{POICategoryGroup, POIData};
pub use postcodes::PostcodeData; pub use postcodes::{OutcodeData, PostcodeData};
pub use property::{ pub use property::{
precompute_h3, FeatureStats, Histogram, PropertyData, QuantRef, RenovationEvent, precompute_h3, FeatureStats, Histogram, PropertyData, QuantRef, RenovationEvent,
}; };

View file

@ -6,6 +6,94 @@ use std::fs;
use std::path::Path; use std::path::Path;
use tracing::{debug, info}; 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<String>,
pub name_lower: Vec<String>,
pub centroids: Vec<(f32, f32)>,
pub cities: Vec<Option<String>>,
}
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<String, Vec<(f32, f32)>> = 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::<f32>() / count;
let avg_lon = pts.iter().map(|(_, lon)| lon).sum::<f32>() / count;
(outcode, (avg_lat, avg_lon))
})
.collect();
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
let names: Vec<String> = entries.iter().map(|(n, _)| n.clone()).collect();
let name_lower: Vec<String> = 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<usize> = place_data
.type_rank
.iter()
.enumerate()
.filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None })
.collect();
let cities: Vec<Option<String>> = 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 /// GeoJSON structures for parsing postcode boundary files
#[derive(Deserialize)] #[derive(Deserialize)]
struct FeatureCollection { struct FeatureCollection {

View file

@ -243,6 +243,9 @@ async fn main() -> anyhow::Result<()> {
"Postcode boundaries loaded" "Postcode boundaries loaded"
); );
let outcode_data =
data::OutcodeData::from_postcode_and_place_data(&postcode_data, &place_data);
// Initialize tile reader // Initialize tile reader
let tiles_path = &cli.tiles; let tiles_path = &cli.tiles;
if !tiles_path.exists() { if !tiles_path.exists() {
@ -375,6 +378,7 @@ async fn main() -> anyhow::Result<()> {
poi_grid: Arc::new(poi_grid), poi_grid: Arc::new(poi_grid),
place_data: Arc::new(place_data), place_data: Arc::new(place_data),
postcode_data: Arc::new(postcode_data), postcode_data: Arc::new(postcode_data),
outcode_data: Arc::new(outcode_data),
feature_name_to_index, feature_name_to_index,
min_keys, min_keys,
max_keys, max_keys,
@ -444,6 +448,7 @@ async fn main() -> anyhow::Result<()> {
get(routes::get_postcodes).layer(ConcurrencyLimitLayer::new(20)), get(routes::get_postcodes).layer(ConcurrencyLimitLayer::new(20)),
) )
.route("/api/postcode/{postcode}", get(routes::get_postcode_lookup)) .route("/api/postcode/{postcode}", get(routes::get_postcode_lookup))
.route("/api/nearest-postcode", get(routes::get_nearest_postcode))
.route( .route(
"/api/pois", "/api/pois",
get(routes::get_pois).layer(ConcurrencyLimitLayer::new(20)), get(routes::get_pois).layer(ConcurrencyLimitLayer::new(20)),
@ -460,6 +465,7 @@ async fn main() -> anyhow::Result<()> {
"/api/hexagon-properties", "/api/hexagon-properties",
get(routes::get_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/hexagon-stats", get(routes::get_hexagon_stats))
.route("/api/postcode-stats", get(routes::get_postcode_stats)) .route("/api/postcode-stats", get(routes::get_postcode_stats))
.route( .route(

View file

@ -52,6 +52,7 @@ pub async fn get_places(
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let query_lower = query.to_lowercase(); let query_lower = query.to_lowercase();
let pd = &state.place_data; let pd = &state.place_data;
let od = &state.outcode_data;
let tt_store = &state.travel_time_store; let tt_store = &state.travel_time_store;
// Linear scan — ~50-100k rows, <1ms // Linear scan — ~50-100k rows, <1ms
@ -99,7 +100,7 @@ pub async fn get_places(
matches.truncate(limit); matches.truncate(limit);
let results: Vec<PlaceResult> = matches let mut results: Vec<PlaceResult> = matches
.iter() .iter()
.map(|(idx, .., slug)| PlaceResult { .map(|(idx, .., slug)| PlaceResult {
name: pd.name[*idx].clone(), name: pd.name[*idx].clone(),
@ -111,6 +112,49 @@ pub async fn get_places(
}) })
.collect(); .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<PlaceResult> = 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::<Vec<_>>()
.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(); let elapsed = t0.elapsed();
info!( info!(
query = query.as_str(), query = query.as_str(),

View file

@ -28,6 +28,12 @@ pub struct PostcodesResponse {
features: Vec<Map<String, Value>>, features: Vec<Map<String, Value>>,
} }
#[derive(Deserialize)]
pub struct NearestPostcodeParams {
lat: f64,
lng: f64,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PostcodeParams { pub struct PostcodeParams {
bounds: Option<String>, bounds: Option<String>,
@ -311,6 +317,45 @@ pub async fn get_postcodes(
Ok(Json(response)) Ok(Json(response))
} }
/// Find the nearest postcode to a given lat/lng coordinate.
pub async fn get_nearest_postcode(
State(shared): State<Arc<SharedState>>,
Query(params): Query<NearestPostcodeParams>,
) -> Result<Json<Value>, 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<usize> = 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. /// Look up a single postcode and return its centroid coordinates and geometry.
pub async fn get_postcode_lookup( pub async fn get_postcode_lookup(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,

View file

@ -7,7 +7,7 @@ use rustc_hash::FxHashMap;
use crate::auth::TokenCache; use crate::auth::TokenCache;
use crate::data::{ use crate::data::{
POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore, OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
}; };
use crate::pocketbase::SuperuserTokenCache; use crate::pocketbase::SuperuserTokenCache;
use crate::routes::FeaturesResponse; use crate::routes::FeaturesResponse;
@ -39,6 +39,8 @@ pub struct AppState {
pub place_data: Arc<PlaceData>, pub place_data: Arc<PlaceData>,
/// Postcode boundary data for high-zoom rendering /// Postcode boundary data for high-zoom rendering
pub postcode_data: Arc<PostcodeData>, pub postcode_data: Arc<PostcodeData>,
/// Precomputed outcode centroids for search
pub outcode_data: Arc<OutcodeData>,
/// Precomputed POI category groups (sorted) /// Precomputed POI category groups (sorted)
pub poi_category_groups: Arc<Vec<POICategoryGroup>>, pub poi_category_groups: Arc<Vec<POICategoryGroup>>,
/// Precomputed travel time data store /// Precomputed travel time data store