seems fine
This commit is contained in:
parent
48983e3b4b
commit
7a1696541f
37 changed files with 4999 additions and 1242 deletions
|
|
@ -143,6 +143,9 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
|
|||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name_lower)| {
|
||||
if !pd.travel_destination[idx] {
|
||||
return None;
|
||||
}
|
||||
let words_match = query_words.iter().all(|word| name_lower.contains(word));
|
||||
let slug = slugify(&pd.name[idx]);
|
||||
let slug_match = slug.contains(&query_slug) || query_slug.contains(&slug);
|
||||
|
|
@ -169,6 +172,9 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
|
|||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(idx, name_lower)| {
|
||||
if !pd.travel_destination[idx] {
|
||||
return None;
|
||||
}
|
||||
let words_match = query_words.iter().all(|word| name_lower.contains(word));
|
||||
let slug = slugify(&pd.name[idx]);
|
||||
let slug_match = slug.contains(&query_slug) || query_slug.contains(&slug);
|
||||
|
|
@ -186,6 +192,9 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
|
|||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, city_opt)| {
|
||||
if !pd.travel_destination[idx] {
|
||||
return None;
|
||||
}
|
||||
let city = city_opt.as_deref()?;
|
||||
if city.to_lowercase() != city_lower {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use axum::response::Json;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::data::slugify;
|
||||
use crate::data::{normalize_search_text, slugify};
|
||||
use crate::state::SharedState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -20,9 +20,21 @@ pub struct PlaceResult {
|
|||
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)]
|
||||
|
|
@ -34,6 +46,53 @@ pub struct PlacesParams {
|
|||
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>,
|
||||
|
|
@ -51,31 +110,39 @@ pub async fn get_places(
|
|||
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_lower
|
||||
.name_search
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name)| {
|
||||
if !name.contains(&query_lower) {
|
||||
.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, only include places with travel data
|
||||
// If mode filter is set, keep the historical travel destination set only.
|
||||
if let Some(ref mode) = mode_filter {
|
||||
if !tt_store.has_destination(mode, &slug) {
|
||||
if !pd.travel_destination[idx] || !tt_store.has_destination(mode, &slug) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let is_exact = name.len() == query_lower.len();
|
||||
let is_prefix = name.starts_with(&query_lower);
|
||||
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,
|
||||
|
|
@ -153,20 +220,76 @@ pub async fn get_places(
|
|||
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))
|
||||
.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
|
||||
(results, postcodes, addresses)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
||||
|
||||
Ok(Json(PlacesResponse { places }))
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ pub struct PostcodePropertiesParams {
|
|||
pub filters: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
/// Exact address to rank first when opening properties from address search.
|
||||
pub focus_address: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
|
@ -67,6 +69,12 @@ pub async fn get_postcode_properties(
|
|||
let filters_str = params.filters;
|
||||
|
||||
let postcode_str = normalized;
|
||||
let focus_address = params
|
||||
.focus_address
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|address| !address.is_empty())
|
||||
.map(str::to_ascii_lowercase);
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
|
@ -100,7 +108,20 @@ pub async fn get_postcode_properties(
|
|||
}
|
||||
});
|
||||
|
||||
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty());
|
||||
matching_rows.sort_unstable_by(|&left, &right| {
|
||||
let left_address = state.data.address(left).trim();
|
||||
let right_address = state.data.address(right).trim();
|
||||
let left_focused = focus_address
|
||||
.as_ref()
|
||||
.is_some_and(|address| left_address.eq_ignore_ascii_case(address));
|
||||
let right_focused = focus_address
|
||||
.as_ref()
|
||||
.is_some_and(|address| right_address.eq_ignore_ascii_case(address));
|
||||
|
||||
right_focused
|
||||
.cmp(&left_focused)
|
||||
.then(left_address.is_empty().cmp(&right_address.is_empty()))
|
||||
});
|
||||
|
||||
let total = matching_rows.len();
|
||||
let limit = params
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ pub async fn get_travel_destinations(
|
|||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name)| {
|
||||
if !pd.travel_destination[idx] {
|
||||
return None;
|
||||
}
|
||||
let slug = slugify(name);
|
||||
if slug_set.contains(&slug) {
|
||||
Some((idx, slug, pd.type_rank[idx], pd.population[idx], name.len()))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue