Support outcode & gps search
This commit is contained in:
parent
23d128ff63
commit
3853b5dce7
6 changed files with 188 additions and 3 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
#[derive(Deserialize)]
|
||||
struct FeatureCollection {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<PlaceResult> = matches
|
||||
let mut results: Vec<PlaceResult> = 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<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();
|
||||
info!(
|
||||
query = query.as_str(),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ pub struct PostcodesResponse {
|
|||
features: Vec<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NearestPostcodeParams {
|
||||
lat: f64,
|
||||
lng: f64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PostcodeParams {
|
||||
bounds: Option<String>,
|
||||
|
|
@ -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<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.
|
||||
pub async fn get_postcode_lookup(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
|
|
|
|||
|
|
@ -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<PlaceData>,
|
||||
/// Postcode boundary data for high-zoom rendering
|
||||
pub postcode_data: Arc<PostcodeData>,
|
||||
/// Precomputed outcode centroids for search
|
||||
pub outcode_data: Arc<OutcodeData>,
|
||||
/// Precomputed POI category groups (sorted)
|
||||
pub poi_category_groups: Arc<Vec<POICategoryGroup>>,
|
||||
/// Precomputed travel time data store
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue