296 lines
9.6 KiB
Rust
296 lines
9.6 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::extract::{Query, State};
|
|
use axum::response::Json;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::info;
|
|
|
|
use crate::api_error::ApiError;
|
|
use crate::consts::PLACES_LIMIT;
|
|
use crate::data::{normalize_search_text, slugify};
|
|
use crate::state::SharedState;
|
|
|
|
#[derive(Serialize)]
|
|
pub struct PlaceResult {
|
|
name: String,
|
|
slug: String,
|
|
place_type: String,
|
|
lat: f32,
|
|
lon: f32,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
city: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AddressResult {
|
|
address: String,
|
|
postcode: String,
|
|
lat: f32,
|
|
lon: f32,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct PlacesResponse {
|
|
places: Vec<PlaceResult>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
postcodes: Vec<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
addresses: Vec<AddressResult>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[allow(clippy::min_ident_chars)]
|
|
pub struct PlacesParams {
|
|
q: String,
|
|
/// If set, only return places that have travel time data for this mode.
|
|
mode: Option<String>,
|
|
}
|
|
|
|
fn compact_postcode_query(query: &str) -> String {
|
|
query
|
|
.chars()
|
|
.filter(|ch| !ch.is_whitespace())
|
|
.map(|ch| ch.to_ascii_uppercase())
|
|
.collect()
|
|
}
|
|
|
|
fn looks_like_postcode_prefix(query: &str) -> bool {
|
|
let compact = compact_postcode_query(query);
|
|
if compact.len() < 2 || compact.len() > 7 {
|
|
return false;
|
|
}
|
|
compact
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|ch| ch.is_ascii_alphabetic())
|
|
&& compact.chars().all(|ch| ch.is_ascii_alphanumeric())
|
|
&& compact.chars().any(|ch| ch.is_ascii_digit())
|
|
}
|
|
|
|
fn postcode_starts_with_compact(postcode: &str, compact_query: &str) -> bool {
|
|
let mut query_chars = compact_query.chars();
|
|
let mut current = query_chars.next();
|
|
if current.is_none() {
|
|
return false;
|
|
}
|
|
|
|
for postcode_char in postcode.chars() {
|
|
if postcode_char.is_whitespace() {
|
|
continue;
|
|
}
|
|
|
|
match current {
|
|
Some(query_char) if postcode_char.to_ascii_uppercase() == query_char => {
|
|
current = query_chars.next();
|
|
if current.is_none() {
|
|
return true;
|
|
}
|
|
}
|
|
_ => return false,
|
|
}
|
|
}
|
|
|
|
current.is_none()
|
|
}
|
|
|
|
pub async fn get_places(
|
|
State(shared): State<Arc<SharedState>>,
|
|
Query(params): Query<PlacesParams>,
|
|
) -> Result<Json<PlacesResponse>, ApiError> {
|
|
let state = shared.load_state();
|
|
let query = if params.q.is_empty() {
|
|
return Err(ApiError::BadRequest("'q' must not be empty".into()));
|
|
} else {
|
|
params.q
|
|
};
|
|
|
|
let limit = PLACES_LIMIT;
|
|
let mode_filter = params.mode;
|
|
|
|
let places = tokio::task::spawn_blocking(move || {
|
|
let t0 = std::time::Instant::now();
|
|
let query_lower = query.to_lowercase();
|
|
let query_search = normalize_search_text(&query);
|
|
let pd = &state.place_data;
|
|
let od = &state.outcode_data;
|
|
let postcode_data = &state.postcode_data;
|
|
let tt_store = &state.travel_time_store;
|
|
let property_data = &state.data;
|
|
|
|
// Linear scan — ~50-100k rows, <1ms
|
|
// Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len, slug)
|
|
let mut matches: Vec<(usize, bool, bool, u8, u32, usize, String)> = pd
|
|
.name_search
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, search_text)| {
|
|
if query_search.is_empty() || !search_text.contains(&query_search) {
|
|
return None;
|
|
}
|
|
let slug = slugify(&pd.name[idx]);
|
|
|
|
// If mode filter is set, keep the historical travel destination set only.
|
|
if let Some(ref mode) = mode_filter {
|
|
if !pd.travel_destination[idx] || !tt_store.has_destination(mode, &slug) {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
let is_exact = search_text
|
|
.split(" | ")
|
|
.any(|alias| alias == query_search || pd.name_lower[idx] == query_lower);
|
|
let is_prefix = search_text
|
|
.split(" | ")
|
|
.any(|alias| alias.starts_with(&query_search))
|
|
|| pd.name_lower[idx].starts_with(&query_lower);
|
|
Some((
|
|
idx,
|
|
is_exact,
|
|
is_prefix,
|
|
pd.type_rank[idx],
|
|
pd.population[idx],
|
|
pd.name[idx].len(),
|
|
slug,
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
// Sort: exact first, then prefix, then type rank asc, then population desc, then name length asc
|
|
matches.sort_unstable_by(|lhs, rhs| {
|
|
rhs.1
|
|
.cmp(&lhs.1)
|
|
.then(rhs.2.cmp(&lhs.2))
|
|
.then(lhs.3.cmp(&rhs.3))
|
|
.then(rhs.4.cmp(&lhs.4))
|
|
.then(lhs.5.cmp(&rhs.5))
|
|
});
|
|
|
|
matches.truncate(limit);
|
|
|
|
let mut results: Vec<PlaceResult> = matches
|
|
.iter()
|
|
.map(|(idx, .., slug)| PlaceResult {
|
|
name: pd.name[*idx].clone(),
|
|
slug: slug.clone(),
|
|
place_type: pd.place_type.get(*idx).to_string(),
|
|
lat: pd.lat[*idx],
|
|
lon: pd.lon[*idx],
|
|
city: pd.city[*idx].clone(),
|
|
})
|
|
.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 postcodes: Vec<String> = if mode_filter.is_none() && looks_like_postcode_prefix(&query)
|
|
{
|
|
let compact_query = compact_postcode_query(&query);
|
|
postcode_data
|
|
.postcodes
|
|
.iter()
|
|
.filter(|postcode| postcode_starts_with_compact(postcode, &compact_query))
|
|
.filter(|postcode| !property_data.rows_for_postcode(postcode).is_empty())
|
|
.take(limit)
|
|
.cloned()
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let addresses: Vec<AddressResult> = if mode_filter.is_none() {
|
|
property_data
|
|
.search_addresses(&query, limit)
|
|
.into_iter()
|
|
.map(|row| AddressResult {
|
|
address: property_data.address(row).trim().to_string(),
|
|
postcode: property_data.postcode(row).to_string(),
|
|
lat: property_data.lat[row],
|
|
lon: property_data.lon[row],
|
|
})
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let elapsed = t0.elapsed();
|
|
info!(
|
|
query = query.as_str(),
|
|
results = results.len(),
|
|
postcodes = postcodes.len(),
|
|
addresses = addresses.len(),
|
|
scanned = pd.name_lower.len(),
|
|
mode = mode_filter.as_deref().unwrap_or("-"),
|
|
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
|
"GET /api/places"
|
|
);
|
|
|
|
(results, postcodes, addresses)
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::Internal(error.to_string()))?;
|
|
|
|
Ok(Json(PlacesResponse {
|
|
places: places.0,
|
|
postcodes: places.1,
|
|
addresses: places.2,
|
|
}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn detects_postcode_prefixes() {
|
|
assert!(looks_like_postcode_prefix("EC2R"));
|
|
assert!(looks_like_postcode_prefix("sw1a 1"));
|
|
assert!(looks_like_postcode_prefix("M4"));
|
|
assert!(!looks_like_postcode_prefix("London"));
|
|
assert!(!looks_like_postcode_prefix("E"));
|
|
}
|
|
|
|
#[test]
|
|
fn postcode_prefix_match_ignores_spaces() {
|
|
assert!(postcode_starts_with_compact("EC2R 8AH", "EC2R8"));
|
|
assert!(postcode_starts_with_compact("SW1A 1AA", "SW1A1"));
|
|
assert!(!postcode_starts_with_compact("SW1A 1AA", "SW1A2"));
|
|
}
|
|
}
|