236 lines
8.7 KiB
Rust
236 lines
8.7 KiB
Rust
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<String>,
|
|
/// `;;`-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<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>,
|
|
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
|
pub share: Option<String>,
|
|
}
|
|
|
|
pub async fn get_postcode_stats(
|
|
State(shared): State<Arc<SharedState>>,
|
|
Extension(user): Extension<OptionalUser>,
|
|
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
|
Query(params): Query<PostcodeStatsParams>,
|
|
) -> Result<Json<HexagonStatsResponse>, 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 = &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<usize> = Vec::new();
|
|
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;
|
|
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)
|
|
};
|
|
|
|
Ok(HexagonStatsResponse {
|
|
count: total_count,
|
|
numeric_features,
|
|
enum_features: enum_features_out,
|
|
price_history,
|
|
crime_by_year,
|
|
crime_latest_year,
|
|
central_postcode: None,
|
|
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))
|
|
}
|