has issues
This commit is contained in:
parent
2e112d7398
commit
c645b0f1d4
96 changed files with 2147083 additions and 5787 deletions
|
|
@ -408,6 +408,9 @@ pub fn build_system_prompt(
|
|||
- \"cycle\" / \"bike\" / \"cycling\" = bicycle mode\n\
|
||||
- \"walk\" / \"walking\" / \"on foot\" = walking mode\n\
|
||||
- \"train\" / \"tube\" / \"bus\" / \"public transport\" / \"commute\" = transit mode\n\
|
||||
- \"without buses\" / \"no bus\" / \"rail only\" = transit-no-bus mode\n\
|
||||
- \"no change\" / \"no transfer\" / \"direct\" / \"single bus/train\" = transit-no-change mode\n\
|
||||
- \"no change and no bus\" / \"direct rail/tube\" = transit-no-change-no-bus mode\n\
|
||||
- If a mode appears in the available mode list but is not named above, you may still \
|
||||
use the exact mode string from the list.\n\
|
||||
\n\
|
||||
|
|
@ -417,7 +420,7 @@ pub fn build_system_prompt(
|
|||
mention it in \"notes\" (e.g. \"No travel data for: Gatwick Airport\") and do NOT \
|
||||
include a travel_time_filter for it.\n\
|
||||
\n\
|
||||
Travel time values are in MINUTES (0-120 range).\n\
|
||||
Travel time values are in MINUTES (0-90 range; data is capped at 90 min).\n\
|
||||
- \"within 30 minutes\" = set \"max\": 30\n\
|
||||
- \"at least 10 minutes\" = set \"min\": 10\n\
|
||||
- \"30-45 minute commute\" = set \"min\": 30 and \"max\": 45 on the same travel_time_filter\n\
|
||||
|
|
@ -1256,11 +1259,15 @@ pub async fn post_ai_filters(
|
|||
))
|
||||
}
|
||||
|
||||
/// Maximum travel-time minutes the data can contain. Matches the Java pipeline's
|
||||
/// MAX_TRIP_DURATION_MINUTES and the frontend's MAX_TRAVEL_MINUTES.
|
||||
const TRAVEL_TIME_MAX_MINUTES: f64 = 90.0;
|
||||
|
||||
fn travel_time_minute_field(item: &Value, key: &str) -> Option<f32> {
|
||||
item.get(key)
|
||||
.and_then(|val| val.as_f64())
|
||||
.filter(|val| val.is_finite())
|
||||
.map(|val| val.clamp(0.0, 120.0) as f32)
|
||||
.map(|val| val.clamp(0.0, TRAVEL_TIME_MAX_MINUTES) as f32)
|
||||
}
|
||||
|
||||
fn parse_travel_time_bounds(item: &Value) -> (Option<f32>, Option<f32>) {
|
||||
|
|
@ -1527,7 +1534,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn travel_time_bounds_clamp_and_order_range() {
|
||||
// Data ceiling is 90 (matches Java MAX_TRIP_DURATION_MINUTES).
|
||||
// Inputs outside [0, 90] clamp; min/max ordering is preserved as-given here.
|
||||
let item = json!({ "min": 150, "max": -10 });
|
||||
assert_eq!(parse_travel_time_bounds(&item), (Some(0.0), Some(120.0)));
|
||||
assert_eq!(parse_travel_time_bounds(&item), (Some(0.0), Some(90.0)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ fn build_frontend_params(
|
|||
zoom: f64,
|
||||
filters_str: Option<&str>,
|
||||
travel_params: &[String],
|
||||
overlay_params: &[String],
|
||||
share: Option<&str>,
|
||||
) -> String {
|
||||
let mut parts = vec![
|
||||
|
|
@ -159,18 +160,23 @@ fn build_frontend_params(
|
|||
parts.push(format!("tt={}", urlencoding::encode(entry.trim())));
|
||||
}
|
||||
}
|
||||
for entry in overlay_params {
|
||||
if !entry.is_empty() {
|
||||
parts.push(format!("overlay={}", urlencoding::encode(entry.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(share) = share.filter(|value| !value.is_empty()) {
|
||||
parts.push(format!("share={}", urlencoding::encode(share)));
|
||||
}
|
||||
parts.join("&")
|
||||
}
|
||||
|
||||
fn collect_travel_state_params(query: Option<&str>) -> Vec<String> {
|
||||
fn collect_repeated_state_params(query: Option<&str>, target_key: &str) -> Vec<String> {
|
||||
query
|
||||
.into_iter()
|
||||
.flat_map(|qs| url::form_urlencoded::parse(qs.as_bytes()))
|
||||
.filter_map(|(key, value)| {
|
||||
if key == "tt" && !value.is_empty() {
|
||||
if key == target_key && !value.is_empty() {
|
||||
Some(value.into_owned())
|
||||
} else {
|
||||
None
|
||||
|
|
@ -179,6 +185,14 @@ fn collect_travel_state_params(query: Option<&str>) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn collect_travel_state_params(query: Option<&str>) -> Vec<String> {
|
||||
collect_repeated_state_params(query, "tt")
|
||||
}
|
||||
|
||||
fn collect_overlay_state_params(query: Option<&str>) -> Vec<String> {
|
||||
collect_repeated_state_params(query, "overlay")
|
||||
}
|
||||
|
||||
pub async fn get_export(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
headers: HeaderMap,
|
||||
|
|
@ -221,6 +235,7 @@ pub async fn get_export(
|
|||
.iter()
|
||||
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
|
||||
let travel_state_params = collect_travel_state_params(uri.query());
|
||||
let overlay_state_params = collect_overlay_state_params(uri.query());
|
||||
let fields_str = params.fields;
|
||||
let share_code = params.share;
|
||||
|
||||
|
|
@ -241,6 +256,7 @@ pub async fn get_export(
|
|||
zoom,
|
||||
filters_str.as_deref(),
|
||||
&travel_state_params,
|
||||
&overlay_state_params,
|
||||
share_code.as_deref(),
|
||||
);
|
||||
|
||||
|
|
@ -776,6 +792,16 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_overlay_state_params_preserves_repeated_overlay_params() {
|
||||
let query = "bounds=1,2,3,4&overlay=noise&overlay=crime-hotspots";
|
||||
|
||||
assert_eq!(
|
||||
collect_overlay_state_params(Some(query)),
|
||||
vec!["noise", "crime-hotspots"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_query_deserializes_when_tt_is_a_single_string() {
|
||||
let uri: Uri = "/api/export?bounds=1,2,3,4&tt=transit%3Abank%3ABank%2520station%3A0%3A52"
|
||||
|
|
|
|||
|
|
@ -66,6 +66,20 @@ pub struct PricePoint {
|
|||
pub price: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CrimeYearPoint {
|
||||
pub year: i32,
|
||||
pub count: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CrimeYearStats {
|
||||
/// Underlying crime type (e.g. "Burglary"). Matches existing crime feature
|
||||
/// names with the `" (avg/yr)"` suffix stripped.
|
||||
pub name: String,
|
||||
pub points: Vec<CrimeYearPoint>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FilterExclusion {
|
||||
pub name: String,
|
||||
|
|
@ -114,6 +128,8 @@ pub struct HexagonStatsResponse {
|
|||
pub enum_features: Vec<EnumFeatureStats>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub price_history: Vec<PricePoint>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub crime_by_year: Vec<CrimeYearStats>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub central_postcode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
|
|
@ -593,6 +609,14 @@ pub async fn get_hexagon_stats(
|
|||
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, enum_features_out) = stats::compute_feature_stats(
|
||||
&matching_rows,
|
||||
&state.data,
|
||||
|
|
@ -626,6 +650,7 @@ pub async fn get_hexagon_stats(
|
|||
numeric_features,
|
||||
enum_features: enum_features_out,
|
||||
price_history,
|
||||
crime_by_year,
|
||||
central_postcode,
|
||||
filter_exclusions,
|
||||
})
|
||||
|
|
|
|||
85
server-rs/src/routes/overlays.rs
Normal file
85
server-rs/src/routes/overlays.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Path;
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use pmtiles::TileCoord;
|
||||
use tracing::warn;
|
||||
|
||||
use super::TileReader;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum OverlayTileFormat {
|
||||
VectorMvtGzip,
|
||||
RasterPng,
|
||||
}
|
||||
|
||||
impl OverlayTileFormat {
|
||||
fn content_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::VectorMvtGzip => "application/x-protobuf",
|
||||
Self::RasterPng => "image/png",
|
||||
}
|
||||
}
|
||||
|
||||
fn is_gzip_encoded(self) -> bool {
|
||||
matches!(self, Self::VectorMvtGzip)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_overlay_tile(
|
||||
reader: Option<Arc<TileReader>>,
|
||||
format: OverlayTileFormat,
|
||||
overlay_name: &'static str,
|
||||
Path((zoom, col, row)): Path<(u8, u32, u32)>,
|
||||
) -> Response {
|
||||
let Some(reader) = reader else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
let tile_coord = match TileCoord::new(zoom, col, row) {
|
||||
Ok(tile_coord) => tile_coord,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
overlay = overlay_name,
|
||||
zoom,
|
||||
col,
|
||||
row,
|
||||
error = %err,
|
||||
"Invalid overlay tile coordinate"
|
||||
);
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match reader.get_tile(tile_coord).await {
|
||||
Ok(Some(tile_bytes)) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(format.content_type()),
|
||||
);
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=86400"),
|
||||
);
|
||||
if format.is_gzip_encoded() {
|
||||
headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
|
||||
}
|
||||
|
||||
(StatusCode::OK, headers, tile_bytes.to_vec()).into_response()
|
||||
}
|
||||
Ok(None) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
overlay = overlay_name,
|
||||
zoom,
|
||||
col,
|
||||
row,
|
||||
error = %err,
|
||||
"Failed to get overlay tile"
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ use tracing::info;
|
|||
|
||||
use crate::api_error::ApiError;
|
||||
use crate::consts::MAX_POIS_PER_REQUEST;
|
||||
use crate::data::{resolve_poi_category_filter, POICategoryGroup};
|
||||
use crate::data::{resolve_poi_category_filter, POICategoryGroup, SchoolMetadata};
|
||||
use crate::parsing::require_bounds;
|
||||
use crate::state::SharedState;
|
||||
|
||||
|
|
@ -22,6 +22,8 @@ pub struct POI {
|
|||
lat: f32,
|
||||
lng: f32,
|
||||
emoji: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
school: Option<SchoolMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -93,6 +95,7 @@ pub async fn get_pois(
|
|||
lat: state.poi_data.lat[row],
|
||||
lng: state.poi_data.lng[row],
|
||||
emoji: state.poi_data.emoji.get(row).to_string(),
|
||||
school: state.poi_data.school(row).cloned(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,14 @@ pub async fn get_postcode_stats(
|
|||
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, enum_features_out) = stats::compute_feature_stats(
|
||||
&matching_rows,
|
||||
&state.data,
|
||||
|
|
@ -181,6 +189,7 @@ pub async fn get_postcode_stats(
|
|||
numeric_features,
|
||||
enum_features: enum_features_out,
|
||||
price_history,
|
||||
crime_by_year,
|
||||
central_postcode: None,
|
||||
filter_exclusions,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::PROPERTIES_LIMIT;
|
||||
use crate::data::RenovationEvent;
|
||||
use crate::data::{HistoricalPrice, 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,
|
||||
|
|
@ -47,6 +47,8 @@ pub struct Property {
|
|||
pub property_sub_type: Option<String>,
|
||||
pub price_qualifier: Option<String>,
|
||||
pub former_council_house: Option<String>,
|
||||
pub within_conservation_area: Option<String>,
|
||||
pub listed_building: Option<String>,
|
||||
|
||||
// Numeric fields
|
||||
pub lat: f32,
|
||||
|
|
@ -57,6 +59,9 @@ pub struct Property {
|
|||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub renovation_history: Vec<RenovationEvent>,
|
||||
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub historical_prices: Vec<HistoricalPrice>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub features: FxHashMap<String, f32>,
|
||||
}
|
||||
|
|
@ -167,6 +172,7 @@ pub fn build_property(
|
|||
lat: state.data.lat[row],
|
||||
lon: state.data.lon[row],
|
||||
renovation_history: state.data.renovation_history(row).to_vec(),
|
||||
historical_prices: state.data.historical_prices(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(
|
||||
|
|
@ -176,6 +182,20 @@ pub fn build_property(
|
|||
row,
|
||||
"Former council house",
|
||||
),
|
||||
within_conservation_area: lookup_enum_value(
|
||||
feature_name_to_index,
|
||||
&state.data,
|
||||
enum_values,
|
||||
row,
|
||||
"Within conservation area",
|
||||
),
|
||||
listed_building: lookup_enum_value(
|
||||
feature_name_to_index,
|
||||
&state.data,
|
||||
enum_values,
|
||||
row,
|
||||
"Listed building",
|
||||
),
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ fn is_allowed_param_key(key: &str) -> bool {
|
|||
| "amenityCount2km"
|
||||
| "amenityCount5km"
|
||||
| "poi"
|
||||
| "overlay"
|
||||
| "tab"
|
||||
| "pc"
|
||||
| "tt"
|
||||
|
|
@ -570,6 +571,20 @@ mod tests {
|
|||
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&share=oldcode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_overlay_params_for_share_links() {
|
||||
let params = sanitized_query_params(
|
||||
"lat=51.5&lon=-0.1&zoom=12&overlay=noise&overlay=crime-hotspots",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
params,
|
||||
"lat=51.5&lon=-0.1&zoom=12&overlay=noise&overlay=crime-hotspots"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_html_attributes() {
|
||||
assert_eq!(escape_attr(r#""'><&"#), ""'><&");
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ use rustc_hash::FxHashMap;
|
|||
use tracing::error;
|
||||
|
||||
use crate::consts::PRICE_HISTORY_POINTS_LIMIT;
|
||||
use crate::data::crime_by_year::CrimeByYearData;
|
||||
use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
|
||||
|
||||
use super::hexagon_stats::{EnumFeatureStats, HistogramStats, NumericFeatureStats, PricePoint};
|
||||
use super::hexagon_stats::{
|
||||
CrimeYearPoint, CrimeYearStats, EnumFeatureStats, HistogramStats, NumericFeatureStats,
|
||||
PricePoint,
|
||||
};
|
||||
|
||||
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
|
||||
pub fn extract_price_history(
|
||||
|
|
@ -251,6 +255,91 @@ pub fn compute_feature_stats(
|
|||
(numeric_features, enum_features_out)
|
||||
}
|
||||
|
||||
/// Compute property-weighted per-year crime means across the selection.
|
||||
///
|
||||
/// Each matching property contributes its LSOA's per-year counts; this is the
|
||||
/// same property-weighted-LSOA-average shape used elsewhere in the right pane.
|
||||
/// LSOAs with no series for a given crime type contribute 0 for that type
|
||||
/// (matching how the existing `(avg/yr)` columns treat missing crime types).
|
||||
pub fn compute_crime_by_year(
|
||||
matching_rows: &[usize],
|
||||
data: &PropertyData,
|
||||
crime_by_year: &CrimeByYearData,
|
||||
fields_specified: bool,
|
||||
field_set: &HashSet<String>,
|
||||
) -> Vec<CrimeYearStats> {
|
||||
if crime_by_year.crime_types.is_empty() || matching_rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// For each crime type, accumulate per-year sums and the count of rows whose
|
||||
// LSOA exists in the crime side table.
|
||||
let num_types = crime_by_year.crime_types.len();
|
||||
let mut per_type_year_sums: Vec<FxHashMap<i32, f64>> =
|
||||
(0..num_types).map(|_| FxHashMap::default()).collect();
|
||||
let mut per_type_row_counts: Vec<u32> = vec![0; num_types];
|
||||
|
||||
for &row in matching_rows {
|
||||
let lsoa = data.lsoa(row);
|
||||
let Some(series_list) = crime_by_year.series_by_lsoa.get(lsoa) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// For every type the LSOA reports, add its per-year counts.
|
||||
// For types it doesn't report, treat the row as contributing 0 — so we
|
||||
// bump the row count for *every* known type below.
|
||||
for series in series_list {
|
||||
let acc = &mut per_type_year_sums[series.type_idx as usize];
|
||||
for point in &series.points {
|
||||
*acc.entry(point.year).or_insert(0.0) += point.count as f64;
|
||||
}
|
||||
}
|
||||
for c in per_type_row_counts.iter_mut() {
|
||||
*c += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (type_idx, name) in crime_by_year.crime_types.iter().enumerate() {
|
||||
// Crime types in the by-year side table are bare (e.g. "Burglary"), while
|
||||
// the configured feature names carry an " (avg/yr)" suffix. Match either
|
||||
// form so callers can pass the feature names they already know.
|
||||
if fields_specified {
|
||||
let with_suffix = format!("{name} (avg/yr)");
|
||||
if !field_set.contains(name.as_str()) && !field_set.contains(with_suffix.as_str()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let row_count = per_type_row_counts[type_idx];
|
||||
if row_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let years = crime_by_year
|
||||
.years_by_type
|
||||
.get(type_idx)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
if years.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let denom = row_count as f64;
|
||||
let sums = &per_type_year_sums[type_idx];
|
||||
let points: Vec<CrimeYearPoint> = years
|
||||
.iter()
|
||||
.map(|&year| CrimeYearPoint {
|
||||
year,
|
||||
count: (sums.get(&year).copied().unwrap_or(0.0) / denom) as f32,
|
||||
})
|
||||
.collect();
|
||||
out.push(CrimeYearStats {
|
||||
name: name.clone(),
|
||||
points,
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub fn compute_poi_feature_stats(
|
||||
matching_rows: &[usize],
|
||||
poi_metrics: &PostcodePoiMetrics,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue