use std::str::FromStr; use std::sync::Arc; use axum::extract::Query; use axum::http::StatusCode; use axum::response::Json; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::consts::{ DEFAULT_PROPERTIES_LIMIT, H3_PRECOMPUTE_MAX, H3_REQUEST_MAX, H3_REQUEST_MIN, MAX_PROPERTIES_LIMIT, }; use crate::parsing::{h3_cell_bounds, parse_filters, row_passes_filters}; use crate::state::AppState; #[derive(Deserialize)] pub struct HexagonPropertiesParams { pub h3: String, pub resolution: u8, pub filters: Option, pub limit: Option, pub offset: Option, } #[derive(Serialize)] pub struct Property { // String fields pub address: Option, pub postcode: Option, pub property_type: Option, pub built_form: Option, pub duration: Option, pub current_energy_rating: Option, pub potential_energy_rating: Option, // Numeric fields pub lat: f32, pub lon: f32, pub is_construction_date_approximate: Option, #[serde(flatten)] pub features: FxHashMap, } #[derive(Serialize)] pub struct HexagonPropertiesResponse { pub properties: Vec, pub total: usize, pub limit: usize, pub offset: usize, pub truncated: bool, } fn non_empty_string(text: &str) -> Option { let trimmed = text.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } /// Look up an enum feature value by trying multiple possible column names. /// Uses the unified feature model: enum values stored as f32 indices in feature_data. fn lookup_enum_value( feature_name_to_index: &FxHashMap, feature_data: &[f32], num_features: usize, enum_values: &FxHashMap>, row: usize, names: &[&str], ) -> Option { for name in names { if let Some(&feat_idx) = feature_name_to_index.get(*name) { if let Some(values) = enum_values.get(&feat_idx) { let value = feature_data[row * num_features + feat_idx]; if value.is_finite() { let idx = value as usize; if let Some(str_value) = values.get(idx) { return Some(str_value.clone()); } } } } } None } pub async fn get_hexagon_properties( state: Arc, Query(params): Query, ) -> Result, (StatusCode, String)> { let cell = h3o::CellIndex::from_str(¶ms.h3).map_err(|error| { warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index"); ( StatusCode::BAD_REQUEST, format!("Invalid H3 cell: {}", error), ) })?; let cell_u64: u64 = cell.into(); let resolution = params.resolution; if !(H3_REQUEST_MIN..=H3_REQUEST_MAX).contains(&resolution) { warn!( resolution, "Resolution out of range [{}, {}]", H3_REQUEST_MIN, H3_REQUEST_MAX ); return Err(( StatusCode::BAD_REQUEST, format!( "resolution must be between {} and {}", H3_REQUEST_MIN, H3_REQUEST_MAX ), )); } let h3_str = params.h3.clone(); let filters_str = params.filters.clone(); let (parsed_filters, parsed_enum_filters) = parse_filters( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, ); let num_filters = parsed_filters.len() + parsed_enum_filters.len(); let result = tokio::task::spawn_blocking(move || { let t0 = std::time::Instant::now(); let precomputed = &state.h3_cells; let h3_res = h3o::Resolution::try_from(resolution) .map_err(|err| format!("Invalid H3 resolution {}: {}", resolution, err))?; let need_parent = resolution < H3_PRECOMPUTE_MAX; let num_features = state.data.num_features; let feature_data = &state.data.feature_data; let feature_names = &state.data.feature_names; let feature_name_to_index = &state.feature_name_to_index; let enum_values = &state.data.enum_values; let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001); let cell_for_row = |row: usize| -> u64 { let max_cell = precomputed[row]; if !need_parent || max_cell == 0 { return max_cell; } h3o::CellIndex::try_from(max_cell) .ok() .and_then(|ci| ci.parent(h3_res)) .map(u64::from) .unwrap_or(0) }; 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; if cell_for_row(row) == cell_u64 && row_passes_filters( row, &parsed_filters, &parsed_enum_filters, feature_data, num_features, ) { matching_rows.push(row); } }); let total = matching_rows.len(); let limit = params .limit .unwrap_or(DEFAULT_PROPERTIES_LIMIT) .min(MAX_PROPERTIES_LIMIT); let offset = params.offset.unwrap_or(0); let truncated = total > offset + limit; let properties: Vec = matching_rows .iter() .skip(offset) .take(limit) .map(|&row| { let mut features = FxHashMap::default(); let base = row * num_features; for (feat_idx, feat_name) in feature_names.iter().enumerate() { // Skip enum features in the generic features map if enum_values.contains_key(&feat_idx) { continue; } let value = feature_data[base + feat_idx]; if value.is_finite() { features.insert(feat_name.clone(), value); } } Property { address: non_empty_string(state.data.address(row)), postcode: non_empty_string(state.data.postcode(row)), is_construction_date_approximate: Some(state.data.is_approx_build_date(row)), property_type: lookup_enum_value( feature_name_to_index, feature_data, num_features, enum_values, row, &["Property type", "epc_property_type", "pp_property_type"], ), built_form: lookup_enum_value( feature_name_to_index, feature_data, num_features, enum_values, row, &["Property type/built form", "built_form"], ), duration: lookup_enum_value( feature_name_to_index, feature_data, num_features, enum_values, row, &["Leashold/Freehold", "duration"], ), current_energy_rating: lookup_enum_value( feature_name_to_index, feature_data, num_features, enum_values, row, &["Current energy rating", "current_energy_rating"], ), potential_energy_rating: lookup_enum_value( feature_name_to_index, feature_data, num_features, enum_values, row, &["Potential energy rating", "potential_energy_rating"], ), lat: state.data.lat[row], lon: state.data.lon[row], features, } }) .collect(); let elapsed = t0.elapsed(); info!( h3 = %h3_str, resolution, total, returned = properties.len(), offset, filters = num_filters, filters_raw = filters_str.as_deref().unwrap_or("-"), ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0), "GET /api/hexagon-properties" ); Ok(HexagonPropertiesResponse { properties, total, limit, offset, truncated, }) }) .await .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))? .map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?; Ok(Json(result)) }