322 lines
11 KiB
Rust
322 lines
11 KiB
Rust
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<String>,
|
|
/// 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<String>,
|
|
pub limit: Option<usize>,
|
|
pub offset: Option<usize>,
|
|
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
|
pub share: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct Property {
|
|
// String fields
|
|
pub address: Option<String>,
|
|
pub postcode: Option<String>,
|
|
pub property_type: Option<String>,
|
|
pub built_form: Option<String>,
|
|
pub duration: Option<String>,
|
|
pub current_energy_rating: Option<String>,
|
|
pub potential_energy_rating: Option<String>,
|
|
pub property_sub_type: Option<String>,
|
|
pub price_qualifier: Option<String>,
|
|
pub former_council_house: Option<String>,
|
|
|
|
// Numeric fields
|
|
pub lat: f32,
|
|
pub lon: f32,
|
|
|
|
pub is_construction_date_approximate: Option<bool>,
|
|
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub renovation_history: Vec<RenovationEvent>,
|
|
|
|
#[serde(flatten)]
|
|
pub features: FxHashMap<String, f32>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct HexagonPropertiesResponse {
|
|
pub properties: Vec<Property>,
|
|
pub total: usize,
|
|
pub limit: usize,
|
|
pub offset: usize,
|
|
pub truncated: bool,
|
|
}
|
|
|
|
fn non_empty_string(text: &str) -> Option<String> {
|
|
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<String, usize>,
|
|
data: &crate::data::PropertyData,
|
|
enum_values: &FxHashMap<usize, Vec<String>>,
|
|
row: usize,
|
|
name: &str,
|
|
) -> Option<String> {
|
|
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<String, usize>,
|
|
enum_values: &FxHashMap<usize, Vec<String>>,
|
|
) -> 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<Arc<SharedState>>,
|
|
Extension(user): Extension<OptionalUser>,
|
|
Query(params): Query<HexagonPropertiesParams>,
|
|
) -> Result<Json<HexagonPropertiesResponse>, 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<u64, u64> = FxHashMap::default();
|
|
let mut matching_rows: Vec<usize> = 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<Property> = 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))
|
|
}
|