130 lines
3.9 KiB
Rust
130 lines
3.9 KiB
Rust
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<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct PlacesResponse {
|
|
places: Vec<PlaceResult>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[allow(clippy::min_ident_chars)]
|
|
pub struct PlacesParams {
|
|
q: String,
|
|
limit: Option<usize>,
|
|
/// If set, only return places that have travel time data for this mode.
|
|
mode: Option<String>,
|
|
}
|
|
|
|
pub async fn get_places(
|
|
State(shared): State<Arc<SharedState>>,
|
|
Query(params): Query<PlacesParams>,
|
|
) -> Result<Json<PlacesResponse>, (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<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();
|
|
|
|
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 }))
|
|
}
|