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, ENUM_NULL, MAX_PROPERTIES_LIMIT}; use crate::data::EnumFeatureData; use crate::filter::{parse_filters, row_passes_filters}; use crate::state::AppState; use super::parse::h3_cell_bounds; #[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: f64, pub lon: f64, 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()) } } fn lookup_enum_value( enum_features: &[EnumFeatureData], enum_idx: &FxHashMap, row: usize, names: &[&str], ) -> Option { for name in names { if let Some(&feature_index) = enum_idx.get(*name) { let enum_feature = &enum_features[feature_index]; let data_index = enum_feature.data[row]; if data_index != ENUM_NULL { if let Some(value) = enum_feature.values.get(data_index as usize) { return Some(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 as usize; if resolution >= state.h3_cells.len() || state.h3_cells[resolution].is_empty() { warn!( resolution, "Invalid or non-precomputed resolution for hexagon-properties" ); return Err(( StatusCode::BAD_REQUEST, "Invalid or non-precomputed resolution".to_string(), )); } 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.data.feature_names, &state.data.enum_features, ); let num_filters = parsed_filters.len() + parsed_enum_filters.len(); let result = tokio::task::spawn_blocking(move || { let t0 = std::time::Instant::now(); let h3_data = &state.h3_cells[resolution]; let num_features = state.data.num_features; let feature_data = &state.data.feature_data; let enum_features = &state.data.enum_features; let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001); 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 h3_data[row] == cell_u64 && row_passes_filters( row, &parsed_filters, &parsed_enum_filters, feature_data, num_features, enum_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 state.data.feature_names.iter().enumerate() { 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( enum_features, &state.enum_name_to_idx, row, &["Property type", "epc_property_type", "pp_property_type"], ), built_form: lookup_enum_value( enum_features, &state.enum_name_to_idx, row, &["Property type/built form", "built_form"], ), duration: lookup_enum_value( enum_features, &state.enum_name_to_idx, row, &["Leashold/Freehold", "duration"], ), current_energy_rating: lookup_enum_value( enum_features, &state.enum_name_to_idx, row, &["Current energy rating", "current_energy_rating"], ), potential_energy_rating: lookup_enum_value( enum_features, &state.enum_name_to_idx, 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" ); HexagonPropertiesResponse { properties, total, limit, offset, truncated, } }) .await .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; Ok(Json(result)) }