use std::sync::Arc; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json}; use axum::Extension; use serde::Deserialize; use tracing::{info, warn}; use crate::auth::OptionalUser; use crate::consts::POSTCODE_SEARCH_OFFSET; use crate::licensing::{check_license_point, resolve_share_code}; use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters}; use crate::state::SharedState; use crate::utils::normalize_postcode; use super::hexagon_stats::{ parse_area_stats_field_set, top_filter_exclusions, EnumFeatureStats, HexagonStatsResponse, }; use super::stats; use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters}; #[derive(Deserialize)] pub struct PostcodeStatsParams { pub postcode: String, pub filters: Option, /// `;;`-separated feature names to include in stats response. /// Only listed features are computed. If absent, area stats default to /// displayable groups; if empty, no feature stats are returned. pub fields: Option, /// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`. /// Optional min:max applies as a filter (exclude properties outside range). pub travel: Option, /// Share-link code; grants bbox-scoped access for unlicensed users. pub share: Option, } pub async fn get_postcode_stats( State(shared): State>, Extension(user): Extension, Extension(geo): Extension, Query(params): Query, ) -> Result, axum::response::Response> { let state = shared.load_state(); let normalized = normalize_postcode(¶ms.postcode); // Look up postcode centroid for spatial search let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) { Some(&idx) => idx, None => { warn!(postcode = %normalized, "Postcode not found"); return Err(( StatusCode::NOT_FOUND, format!("Postcode not found: {}", normalized), ) .into_response()); } }; let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx]; // License check using postcode centroid let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; check_license_point( &user.0, centroid_lat as f64, centroid_lon as f64, geo.free_zone, share_bounds, )?; let quant = state.data.quant_ref(); let poi_quant = state.data.poi_metrics.quant_ref(); let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, &quant, &state.data.poi_metrics.name_to_index, &poi_quant, ) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let num_filters = parsed_filters.len() + parsed_enum_filters.len() + parsed_poi_filters.len(); let filters_str = params.filters; let has_poi_filters = !parsed_poi_filters.is_empty(); let (fields_specified, field_set) = parse_area_stats_field_set(params.fields.as_deref()); let travel_entries = parse_optional_travel(params.travel.as_deref()) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let postcode_str = normalized; let response = tokio::task::spawn_blocking(move || { let start_time = std::time::Instant::now(); let num_features = state.data.num_features; let feature_data: &[u16] = &state.data.feature_data; let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?; let has_travel = !travel_entries.is_empty(); // Search around centroid (generous for a postcode) let offset: f64 = POSTCODE_SEARCH_OFFSET; let min_lat = centroid_lat as f64 - offset; let max_lat = centroid_lat as f64 + offset; let min_lon = centroid_lon as f64 - offset; let max_lon = centroid_lon as f64 + offset; let mut area_rows: Vec = Vec::new(); let mut matching_rows: Vec = Vec::new(); state .grid .for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| { let row = row_idx as usize; let row_postcode = state.data.postcode(row); if row_postcode != postcode_str { return; } area_rows.push(row); if row_passes_filters( row, &parsed_filters, &parsed_enum_filters, feature_data, num_features, ) && (!has_poi_filters || row_passes_poi_filters(row, &parsed_poi_filters, &state.data.poi_metrics)) { if has_travel && !row_passes_travel_filters(row_postcode, &travel_entries, &travel_data) { return; } matching_rows.push(row); } }); let total_count = matching_rows.len(); let filter_exclusions = if total_count == 0 { top_filter_exclusions( &area_rows, &parsed_filters, &parsed_enum_filters, &parsed_poi_filters, &travel_entries, &travel_data, &state.data, ) } else { Vec::new() }; let price_history = stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index); let crime_by_year = stats::compute_crime_by_year( &matching_rows, &state.data, &state.crime_by_year, fields_specified, &field_set, ); let (mut numeric_features, mut enum_features_out) = stats::compute_feature_stats( &matching_rows, &state.data, &state.data.feature_names, &state.data.enum_values, &state.data.feature_stats, fields_specified, &field_set, ); numeric_features.extend(stats::compute_poi_feature_stats( &matching_rows, &state.data.poi_metrics, fields_specified, &field_set, )); // The council-house count is an attribute of the postcode itself, not of // the currently filter-matching subset: recompute it over every property // in the postcode so toggling filters never changes it. (When no filters // are applied, `area_rows == matching_rows` and this is a no-op.) if let Some(&council_idx) = state .feature_name_to_index .get(crate::consts::FORMER_COUNCIL_HOUSE_FEATURE) { enum_features_out.retain(|f| f.name != crate::consts::FORMER_COUNCIL_HOUSE_FEATURE); let included = !fields_specified || field_set.contains(crate::consts::FORMER_COUNCIL_HOUSE_FEATURE); if included { if let Some(counts) = stats::compute_enum_feature_counts(&area_rows, &state.data, council_idx) { if !counts.is_empty() { enum_features_out.push(EnumFeatureStats { name: crate::consts::FORMER_COUNCIL_HOUSE_FEATURE.to_string(), counts, }); } } } } let elapsed = start_time.elapsed(); info!( postcode = %postcode_str, total_count, filters = num_filters, filters_raw = filters_str.as_deref().unwrap_or("-"), travel_entries = travel_entries.len(), ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0), "GET /api/postcode-stats" ); let crime_latest_year = if crime_by_year.is_empty() { None } else { stats::crime_latest_available_year(&state.crime_by_year) }; let (crime_outcode, crime_sector, crime_area_averages) = stats::area_crime_averages_for( Some(postcode_str.as_str()), &state.area_crime_averages, fields_specified, &field_set, ); // Usual residents (Census 2021) for this postcode. Display-only. let population = state.population.for_postcode(&postcode_str); Ok(HexagonStatsResponse { count: total_count, numeric_features, enum_features: enum_features_out, price_history, crime_by_year, crime_latest_year, crime_outcode, crime_sector, crime_area_averages, central_postcode: None, population, filter_exclusions, }) }) .await .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())? .map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?; Ok(Json(response)) }