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::MAX_POIS_PER_REQUEST; use crate::data::{resolve_poi_category_filter, POICategoryGroup, SchoolMetadata}; use crate::parsing::require_bounds; use crate::state::SharedState; #[derive(Serialize)] #[allow(clippy::upper_case_acronyms)] pub struct POI { id: String, name: String, category: String, icon_category: String, group: String, lat: f32, lng: f32, emoji: String, #[serde(skip_serializing_if = "Option::is_none")] school: Option, } #[derive(Serialize)] pub struct POIsResponse { pois: Vec, } #[derive(Deserialize)] pub struct POIParams { bounds: Option, /// Comma-separated list of categories to filter by categories: Option, } pub async fn get_pois( State(shared): State>, Query(params): Query, ) -> Result, ApiError> { let state = shared.load_state(); let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?; let category_filter: Option> = params .categories .as_deref() .filter(|text| !text.is_empty()) .map(|text| resolve_poi_category_filter(&state.poi_data.category.values, text)); let categories_raw = params.categories; let num_categories = category_filter.as_ref().map(|cats| cats.len()).unwrap_or(0); let pois = tokio::task::spawn_blocking(move || { let t0 = std::time::Instant::now(); let row_indices = state.poi_grid.query(south, west, north, east); let matching_rows: Vec = row_indices .iter() .filter_map(|&row_idx| { let row = row_idx as usize; if let Some(ref categories) = category_filter { if !categories.contains(&state.poi_data.category.indices[row]) { return None; } } Some(row) }) .collect(); let mut matching_pois = matching_rows; if matching_pois.len() > MAX_POIS_PER_REQUEST { let ratio = (matching_pois.len() / MAX_POIS_PER_REQUEST) as u32; let step = ratio.next_power_of_two(); let mask = step - 1; matching_pois.retain(|&row| state.poi_data.priority[row] & mask == 0); if matching_pois.len() > MAX_POIS_PER_REQUEST { matching_pois.sort_unstable_by_key(|&row| state.poi_data.priority[row]); matching_pois.truncate(MAX_POIS_PER_REQUEST); } } let pois: Vec = matching_pois .iter() .map(|&row| POI { id: state.poi_data.id(row).to_string(), name: state.poi_data.name[row].clone(), category: state.poi_data.category.get(row).to_string(), icon_category: state.poi_data.icon_category.get(row).to_string(), group: state.poi_data.group.get(row).to_string(), lat: state.poi_data.lat[row], lng: state.poi_data.lng[row], emoji: state.poi_data.emoji.get(row).to_string(), school: state.poi_data.school(row).cloned(), }) .collect(); let elapsed = t0.elapsed(); info!( results = pois.len(), candidates = row_indices.len(), categories = num_categories, categories_raw = categories_raw.as_deref().unwrap_or("-"), ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0), "GET /api/pois" ); pois }) .await .map_err(|error| ApiError::Internal(error.to_string()))?; Ok(Json(POIsResponse { pois })) } #[derive(Serialize)] pub struct POICategoriesResponse { groups: Vec, } pub async fn get_poi_categories( State(shared): State>, ) -> Json { let state = shared.load_state(); let groups: Vec = state.poi_category_groups.to_vec(); let total: usize = groups.iter().map(|group| group.categories.len()).sum(); info!( count = total, groups = groups.len(), "GET /api/poi-categories" ); Json(POICategoriesResponse { groups }) }