use std::sync::Arc; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::Json; use serde::{Deserialize, Serialize}; use tracing::info; use crate::data::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, } #[derive(Serialize)] pub struct PlacesResponse { places: Vec, } #[derive(Deserialize)] #[allow(clippy::min_ident_chars)] pub struct PlacesParams { q: String, limit: Option, /// If set, only return places that have travel time data for this mode. mode: Option, } pub async fn get_places( State(shared): State>, Query(params): Query, ) -> Result, (StatusCode, String)> { let state = shared.load_state(); let query = if params.q.is_empty() { return Err((StatusCode::BAD_REQUEST, "'q' must not be empty".into())); } else { params.q }; let limit = params.limit.unwrap_or(7).min(20); 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 pd = &state.place_data; let tt_store = &state.travel_time_store; // 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 .iter() .enumerate() .filter_map(|(idx, name)| { if !name.contains(&query_lower) { return None; } let slug = slugify(&pd.name[idx]); // If mode filter is set, only include places with travel data if let Some(ref mode) = mode_filter { if !tt_store.has_destination(mode, &slug) { return None; } } let is_exact = name.len() == query_lower.len(); let is_prefix = name.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 results: Vec = 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(); let elapsed = t0.elapsed(); info!( query = query.as_str(), results = results.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 }) .await .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; Ok(Json(PlacesResponse { places })) }