use std::str::FromStr; use std::sync::Arc; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json}; use axum::Extension; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::auth::OptionalUser; use crate::consts::DEFAULT_PROPERTIES_LIMIT; use crate::data::RenovationEvent; use crate::licensing::{check_license_bounds, resolve_share_code}; use crate::parsing::{ cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters_with_poi, row_passes_filters, row_passes_poi_filters, validate_h3_resolution, }; use crate::state::{AppState, SharedState}; use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters}; #[derive(Deserialize)] pub struct HexagonPropertiesParams { pub h3: String, pub resolution: u8, pub filters: 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, pub limit: Option, pub offset: Option, /// Share-link code; grants bbox-scoped access for unlicensed users. pub share: 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, pub property_sub_type: Option, pub price_qualifier: Option, pub former_council_house: Option, // Numeric fields pub lat: f32, pub lon: f32, pub is_construction_date_approximate: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub renovation_history: Vec, #[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 column name. /// Uses the unified feature model: enum values stored as u16 indices in feature_data. fn lookup_enum_value( feature_name_to_index: &FxHashMap, data: &crate::data::PropertyData, enum_values: &FxHashMap>, row: usize, name: &str, ) -> Option { let &feat_idx = feature_name_to_index.get(name)?; let values = enum_values.get(&feat_idx)?; let value = data.get_feature(row, feat_idx); if value.is_finite() { let idx = value as usize; values.get(idx).cloned() } else { None } } pub fn build_property( row: usize, state: &AppState, feature_names: &[String], feature_name_to_index: &FxHashMap, enum_values: &FxHashMap>, ) -> Property { let mut features = FxHashMap::default(); for (feat_idx, feat_name) in feature_names.iter().enumerate() { if enum_values.contains_key(&feat_idx) { continue; } let value = state.data.get_feature(row, feat_idx); if value.is_finite() { features.insert(feat_name.clone(), value); } } for (metric_idx, metric_name) in state.data.poi_metrics.feature_names.iter().enumerate() { let value = state.data.poi_metrics.get_for_property_row(row, metric_idx); if value.is_finite() { features.insert(metric_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, &state.data, enum_values, row, "Property type", ), built_form: lookup_enum_value( feature_name_to_index, &state.data, enum_values, row, "Property type/built form", ), duration: lookup_enum_value( feature_name_to_index, &state.data, enum_values, row, "Leasehold/Freehold", ), current_energy_rating: lookup_enum_value( feature_name_to_index, &state.data, enum_values, row, "Current energy rating", ), potential_energy_rating: lookup_enum_value( feature_name_to_index, &state.data, enum_values, row, "Potential energy rating", ), lat: state.data.lat[row], lon: state.data.lon[row], renovation_history: state.data.renovation_history(row).to_vec(), property_sub_type: state.data.property_sub_type(row).map(String::from), price_qualifier: state.data.price_qualifier(row).map(String::from), former_council_house: lookup_enum_value( feature_name_to_index, &state.data, enum_values, row, "Former council house", ), features, } } pub async fn get_hexagon_properties( State(shared): State>, Extension(user): Extension, Query(params): Query, ) -> Result, axum::response::Response> { let state = shared.load_state(); 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), ) .into_response() })?; let cell_u64: u64 = cell.into(); let resolution = params.resolution; validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?; // License check using H3 cell bounds let h3_bounds = h3_cell_bounds(cell, 0.0); let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; check_license_bounds(&user.0, h3_bounds, share_bounds)?; let h3_str = params.h3; 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 travel_entries = parse_optional_travel(params.travel.as_deref()) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; 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 = needs_parent(resolution); 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 travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?; let has_travel = !travel_entries.is_empty(); let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001); let mut h3_cache: FxHashMap = FxHashMap::default(); 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_cached(row, precomputed, h3_res, need_parent, &mut h3_cache) == cell_u64 && 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 { let postcode = state.data.postcode(row); if !row_passes_travel_filters(postcode, &travel_entries, &travel_data) { return; } } matching_rows.push(row); } }); // Sort so properties with addresses come first, unknown addresses last matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty()); let total = matching_rows.len(); let limit = params.limit.unwrap_or(DEFAULT_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| { build_property( row, &state, feature_names, feature_name_to_index, enum_values, ) }) .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("-"), travel_entries = travel_entries.len(), 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()).into_response())? .map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?; Ok(Json(result)) }