server
This commit is contained in:
parent
8dc939d761
commit
d98819b569
12 changed files with 157 additions and 161 deletions
|
|
@ -23,6 +23,8 @@ pub struct PocketBaseUser {
|
|||
pub subscription: String,
|
||||
#[serde(default)]
|
||||
pub newsletter: bool,
|
||||
#[serde(default)]
|
||||
pub can_see_listings: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Per-LSOA per-crime-type per-year crime counts, loaded from a side parquet
|
||||
//! and used by the right pane to plot crime-over-time. Filtering is not
|
||||
//! Per-postcode per-crime-type per-year crime counts, loaded from a side
|
||||
//! parquet and used by the right pane to plot crime-over-time. Filtering is not
|
||||
//! supported — this data is display-only.
|
||||
|
||||
use std::path::Path;
|
||||
|
|
@ -23,8 +23,8 @@ pub struct YearPoint {
|
|||
pub count: f32,
|
||||
}
|
||||
|
||||
/// One per crime type: ordered list of (year, count) for a single LSOA.
|
||||
pub struct LsoaCrimeSeries {
|
||||
/// One per crime type: ordered list of (year, count) for a single postcode.
|
||||
pub struct PostcodeCrimeSeries {
|
||||
/// Index into `crime_types`.
|
||||
pub type_idx: u16,
|
||||
pub points: Vec<YearPoint>,
|
||||
|
|
@ -35,8 +35,8 @@ pub struct CrimeByYearData {
|
|||
pub crime_types: Vec<String>,
|
||||
/// All years available for each crime type, same order as `crime_types`.
|
||||
pub years_by_type: Vec<Vec<i32>>,
|
||||
/// LSOA code → all available per-type series for that LSOA.
|
||||
pub series_by_lsoa: FxHashMap<String, Vec<LsoaCrimeSeries>>,
|
||||
/// Postcode → all available per-type series for that postcode.
|
||||
pub series_by_postcode: FxHashMap<String, Vec<PostcodeCrimeSeries>>,
|
||||
}
|
||||
|
||||
impl CrimeByYearData {
|
||||
|
|
@ -44,7 +44,7 @@ impl CrimeByYearData {
|
|||
Self {
|
||||
crime_types: Vec::new(),
|
||||
years_by_type: Vec::new(),
|
||||
series_by_lsoa: FxHashMap::default(),
|
||||
series_by_postcode: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,20 +67,20 @@ impl CrimeByYearData {
|
|||
format!("Failed to read crime-by-year parquet at {}", path.display())
|
||||
})?;
|
||||
|
||||
let lsoa_col = df
|
||||
.column("LSOA code")
|
||||
.context("crime-by-year parquet missing 'LSOA code' column")?
|
||||
let postcode_col = df
|
||||
.column("postcode")
|
||||
.context("crime-by-year parquet missing 'postcode' column")?
|
||||
.str()
|
||||
.context("'LSOA code' column is not a string")?;
|
||||
let lsoa_values: Vec<String> = lsoa_col
|
||||
.context("'postcode' column is not a string")?;
|
||||
let postcode_values: Vec<String> = postcode_col
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(row, value)| {
|
||||
let value =
|
||||
value.with_context(|| format!("crime-by-year row {row} has null LSOA code"))?;
|
||||
value.with_context(|| format!("crime-by-year row {row} has null postcode"))?;
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
bail!("crime-by-year row {row} has blank LSOA code");
|
||||
bail!("crime-by-year row {row} has blank postcode");
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
})
|
||||
|
|
@ -106,7 +106,8 @@ impl CrimeByYearData {
|
|||
|
||||
let crime_types: Vec<String> = crime_type_cols.iter().map(|(t, _)| t.clone()).collect();
|
||||
|
||||
let mut series_by_lsoa: FxHashMap<String, Vec<LsoaCrimeSeries>> = FxHashMap::default();
|
||||
let mut series_by_postcode: FxHashMap<String, Vec<PostcodeCrimeSeries>> =
|
||||
FxHashMap::default();
|
||||
let mut years_by_type: Vec<Vec<i32>> = Vec::with_capacity(crime_type_cols.len());
|
||||
let row_count = df.height();
|
||||
|
||||
|
|
@ -161,10 +162,10 @@ impl CrimeByYearData {
|
|||
}
|
||||
points.sort_by_key(|p| p.year);
|
||||
|
||||
series_by_lsoa
|
||||
.entry(lsoa_values[row].clone())
|
||||
series_by_postcode
|
||||
.entry(postcode_values[row].clone())
|
||||
.or_default()
|
||||
.push(LsoaCrimeSeries {
|
||||
.push(PostcodeCrimeSeries {
|
||||
type_idx: type_idx as u16,
|
||||
points,
|
||||
});
|
||||
|
|
@ -173,7 +174,7 @@ impl CrimeByYearData {
|
|||
}
|
||||
|
||||
info!(
|
||||
lsoas = series_by_lsoa.len(),
|
||||
postcodes = series_by_postcode.len(),
|
||||
crime_types = crime_types.len(),
|
||||
"Crime-by-year data loaded"
|
||||
);
|
||||
|
|
@ -181,7 +182,7 @@ impl CrimeByYearData {
|
|||
Ok(Self {
|
||||
crime_types,
|
||||
years_by_type,
|
||||
series_by_lsoa,
|
||||
series_by_postcode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -831,10 +831,6 @@ pub struct PropertyData {
|
|||
/// Interned postcodes: reader is thread-safe, keys index into it.
|
||||
postcode_interner: lasso::RodeoReader,
|
||||
postcode_keys: Vec<lasso::Spur>,
|
||||
/// Interned LSOA (2021) codes per row.
|
||||
/// Used to look up per-LSOA side tables (e.g. crime time series).
|
||||
lsoa_interner: lasso::RodeoReader,
|
||||
lsoa_keys: Vec<lasso::Spur>,
|
||||
/// Rows for each postcode, keyed by the interned postcode key.
|
||||
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
|
||||
/// Inverted index from address tokens to property rows.
|
||||
|
|
@ -881,11 +877,6 @@ impl PropertyData {
|
|||
self.postcode_interner.resolve(&self.postcode_keys[row])
|
||||
}
|
||||
|
||||
/// Get the LSOA (2021) code for a given row.
|
||||
pub fn lsoa(&self, row: usize) -> &str {
|
||||
self.lsoa_interner.resolve(&self.lsoa_keys[row])
|
||||
}
|
||||
|
||||
/// Get postcode components for field-level borrowing (avoids conflicting borrows with feature_data).
|
||||
pub fn postcode_parts(&self) -> (&lasso::RodeoReader, &[lasso::Spur]) {
|
||||
(&self.postcode_interner, &self.postcode_keys)
|
||||
|
|
@ -1541,15 +1532,6 @@ impl PropertyData {
|
|||
}
|
||||
}
|
||||
|
||||
// LSOA (2021) code per row, brought in via the postcode join. Used as a
|
||||
// lookup key into per-LSOA side tables (e.g. crime time series).
|
||||
match schema.get("lsoa21") {
|
||||
Some(dtype) if matches!(dtype, DataType::String) || dtype.is_categorical() => {}
|
||||
Some(dtype) => bail!("Column 'lsoa21' has unexpected type {:?}", dtype),
|
||||
None => bail!("Required column 'lsoa21' not found in joined property data"),
|
||||
}
|
||||
select_exprs.push(col("lsoa21").cast(DataType::String));
|
||||
|
||||
// Enum features as String
|
||||
for &name in &enum_names {
|
||||
select_exprs.push(col(name).cast(DataType::String));
|
||||
|
|
@ -1704,33 +1686,8 @@ impl PropertyData {
|
|||
Ok(vec![None; row_count])
|
||||
}
|
||||
};
|
||||
let extract_required_trimmed_string_col =
|
||||
|df: &DataFrame, name: &str| -> anyhow::Result<Vec<String>> {
|
||||
let column = df
|
||||
.column(name)
|
||||
.with_context(|| format!("Required column '{name}' not found in parquet"))?;
|
||||
let string_column = column
|
||||
.str()
|
||||
.with_context(|| format!("Column '{name}' is not a string column"))?;
|
||||
string_column
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(row, value)| {
|
||||
let value = value.with_context(|| {
|
||||
format!("Required column '{name}' has null at row {row}")
|
||||
})?;
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
bail!("Required column '{name}' has blank value at row {row}");
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let property_sub_type_raw = extract_optional_string_col(&df, "Property sub-type")?;
|
||||
let price_qualifier_raw = extract_optional_string_col(&df, "Price qualifier")?;
|
||||
let lsoa_raw = extract_required_trimmed_string_col(&df, "lsoa21")?;
|
||||
|
||||
tracing::info!("Building enum features");
|
||||
// enum_col_major: Vec<(values_list, encoded_as_f32)>
|
||||
|
|
@ -2041,14 +1998,6 @@ impl PropertyData {
|
|||
}
|
||||
let postcode_interner = postcode_rodeo.into_reader();
|
||||
|
||||
// Intern LSOA codes (permuted).
|
||||
let mut lsoa_rodeo = lasso::Rodeo::default();
|
||||
let mut lsoa_keys: Vec<lasso::Spur> = Vec::with_capacity(row_count);
|
||||
for &perm_index in perm.iter() {
|
||||
lsoa_keys.push(lsoa_rodeo.get_or_intern(&lsoa_raw[perm_index as usize]));
|
||||
}
|
||||
let lsoa_interner = lsoa_rodeo.into_reader();
|
||||
|
||||
let row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
|
||||
vec![NO_POI_METRIC_ROW; row_count]
|
||||
} else {
|
||||
|
|
@ -2220,8 +2169,6 @@ impl PropertyData {
|
|||
address_lengths,
|
||||
postcode_interner,
|
||||
postcode_keys,
|
||||
lsoa_interner,
|
||||
lsoa_keys,
|
||||
postcode_row_index,
|
||||
address_token_index,
|
||||
address_prefix_index,
|
||||
|
|
|
|||
|
|
@ -526,36 +526,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
FeatureGroup {
|
||||
name: "Crime",
|
||||
features: &[
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Serious crime per 1k residents (avg/yr)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 2.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 0.1,
|
||||
description: "Serious crime rate per 1,000 residents per year",
|
||||
detail: "Violence, robbery, burglary, and weapons possession per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Minor crime per 1k residents (avg/yr)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 2.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 0.1,
|
||||
description: "Minor crime rate per 1,000 residents per year",
|
||||
detail: "Anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Serious crime (avg/yr)",
|
||||
bounds: Bounds::Percentile {
|
||||
|
|
@ -564,7 +534,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Aggregate of serious crime categories per year",
|
||||
detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data. Provides a single serious crime metric.",
|
||||
detail: "Sum of violence, robbery, burglary, and weapons possession per year within 50m of the postcode, counted from police.uk street-level crime points (anonymised, snapped to nearby map points). Provides a single serious crime metric.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -579,7 +549,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Aggregate of minor crime categories per year",
|
||||
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data. Provides a single minor crime metric.",
|
||||
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year within 50m of the postcode, counted from police.uk street-level crime points (anonymised, snapped to nearby map points). Provides a single minor crime metric.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -594,7 +564,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly violent and sexual offences in the area",
|
||||
detail: "Average number of violence and sexual offences per year in the LSOA, from police.uk street-level crime data. Includes assault, harassment, and sexual offences.",
|
||||
detail: "Average number of violence and sexual offences per year within 50m of the postcode, from police.uk street-level crime data. Includes assault, harassment, and sexual offences.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -609,7 +579,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly burglary offences in the area",
|
||||
detail: "Average number of burglary offences per year in the LSOA, from police.uk street-level crime data. Includes residential and commercial burglary.",
|
||||
detail: "Average number of burglary offences per year within 50m of the postcode, from police.uk street-level crime data. Includes residential and commercial burglary.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -624,7 +594,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly robbery offences in the area",
|
||||
detail: "Average number of robbery offences per year in the LSOA, from police.uk street-level crime data. Robbery involves theft with force or threat of force.",
|
||||
detail: "Average number of robbery offences per year within 50m of the postcode, from police.uk street-level crime data. Robbery involves theft with force or threat of force.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -639,7 +609,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly vehicle crime in the area",
|
||||
detail: "Average number of vehicle crime incidents per year in the LSOA, from police.uk street-level crime data. Includes theft of and from vehicles.",
|
||||
detail: "Average number of vehicle crime incidents per year within 50m of the postcode, from police.uk street-level crime data. Includes theft of and from vehicles.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -654,7 +624,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly anti-social behaviour incidents in the area",
|
||||
detail: "Average number of anti-social behaviour incidents per year in the LSOA, from police.uk street-level crime data. Includes nuisance, environmental, and personal anti-social behaviour.",
|
||||
detail: "Average number of anti-social behaviour incidents per year within 50m of the postcode, from police.uk street-level crime data. Includes nuisance, environmental, and personal anti-social behaviour.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -669,7 +639,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly criminal damage and arson in the area",
|
||||
detail: "Average number of criminal damage and arson incidents per year in the LSOA, from police.uk street-level crime data.",
|
||||
detail: "Average number of criminal damage and arson incidents per year within 50m of the postcode, from police.uk street-level crime data.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -684,7 +654,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly other theft offences in the area",
|
||||
detail: "Average number of 'other theft' offences per year in the LSOA, from police.uk street-level crime data. Includes theft not classified under burglary, vehicle crime, shoplifting, or bicycle theft.",
|
||||
detail: "Average number of 'other theft' offences per year within 50m of the postcode, from police.uk street-level crime data. Includes theft not classified under burglary, vehicle crime, shoplifting, or bicycle theft.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -699,7 +669,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly theft from the person in the area",
|
||||
detail: "Average number of theft from the person offences per year in the LSOA, from police.uk street-level crime data. Includes pickpocketing and bag snatching without force.",
|
||||
detail: "Average number of theft from the person offences per year within 50m of the postcode, from police.uk street-level crime data. Includes pickpocketing and bag snatching without force.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -714,7 +684,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly shoplifting offences in the area",
|
||||
detail: "Average number of shoplifting offences per year in the LSOA, from police.uk street-level crime data.",
|
||||
detail: "Average number of shoplifting offences per year within 50m of the postcode, from police.uk street-level crime data.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -729,7 +699,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly bicycle theft in the area",
|
||||
detail: "Average number of bicycle theft offences per year in the LSOA, from police.uk street-level crime data.",
|
||||
detail: "Average number of bicycle theft offences per year within 50m of the postcode, from police.uk street-level crime data.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -744,7 +714,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly drug offences in the area",
|
||||
detail: "Average number of drug offences per year in the LSOA, from police.uk street-level crime data. Includes possession and trafficking offences.",
|
||||
detail: "Average number of drug offences per year within 50m of the postcode, from police.uk street-level crime data. Includes possession and trafficking offences.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -759,7 +729,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly weapons possession offences in the area",
|
||||
detail: "Average number of possession of weapons offences per year in the LSOA, from police.uk street-level crime data.",
|
||||
detail: "Average number of possession of weapons offences per year within 50m of the postcode, from police.uk street-level crime data.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -774,7 +744,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly public order offences in the area",
|
||||
detail: "Average number of public order offences per year in the LSOA, from police.uk street-level crime data. Includes causing fear, alarm, or distress.",
|
||||
detail: "Average number of public order offences per year within 50m of the postcode, from police.uk street-level crime data. Includes causing fear, alarm, or distress.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
@ -789,7 +759,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 1.0,
|
||||
description: "Average yearly other crime in the area",
|
||||
detail: "Average number of other crime offences per year in the LSOA, from police.uk street-level crime data. A catch-all category for offences not classified elsewhere.",
|
||||
detail: "Average number of other crime offences per year within 50m of the postcode, from police.uk street-level crime data. A catch-all category for offences not classified elsewhere.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
|
|
|
|||
|
|
@ -171,6 +171,10 @@ struct Cli {
|
|||
#[arg(long, env = "SATELLITE_TILES")]
|
||||
satellite_tiles: Option<PathBuf>,
|
||||
|
||||
/// Optional PMTiles raster overlay for high-resolution EA aerial photography.
|
||||
#[arg(long, env = "SATELLITE_HIGHRES_TILES")]
|
||||
satellite_highres_tiles: Option<PathBuf>,
|
||||
|
||||
/// Optional PMTiles raster overlay for high-resolution strategic noise.
|
||||
#[arg(long, env = "NOISE_OVERLAY_TILES")]
|
||||
noise_overlay_tiles: Option<PathBuf>,
|
||||
|
|
@ -183,6 +187,10 @@ struct Cli {
|
|||
#[arg(long, env = "TREE_OVERLAY_TILES")]
|
||||
tree_overlay_tiles: Option<PathBuf>,
|
||||
|
||||
/// Optional PMTiles vector overlay for INSPIRE property-border polygons.
|
||||
#[arg(long, env = "PROPERTY_BORDER_TILES")]
|
||||
property_border_tiles: Option<PathBuf>,
|
||||
|
||||
/// Path to the frontend dist directory (optional; disables static serving and OG injection when omitted)
|
||||
#[arg(long)]
|
||||
dist: Option<PathBuf>,
|
||||
|
|
@ -300,34 +308,31 @@ async fn capture_server_error_responses(
|
|||
response
|
||||
}
|
||||
|
||||
async fn init_optional_tile_reader(
|
||||
async fn init_required_tile_reader(
|
||||
label: &'static str,
|
||||
path: Option<&PathBuf>,
|
||||
) -> anyhow::Result<Option<Arc<routes::TileReader>>> {
|
||||
let Some(path) = path else {
|
||||
info!("{label} overlay tiles not configured");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
path: &Path,
|
||||
) -> anyhow::Result<Arc<routes::TileReader>> {
|
||||
if !path.exists() {
|
||||
bail!("{label} overlay PMTiles not found: {}", path.display());
|
||||
bail!("{label} PMTiles not found: {}", path.display());
|
||||
}
|
||||
|
||||
info!("Loading {label} overlay PMTiles from {}", path.display());
|
||||
Ok(Some(Arc::new(routes::init_tile_reader(path).await?)))
|
||||
info!("Loading {label} PMTiles from {}", path.display());
|
||||
Ok(Arc::new(routes::init_tile_reader(path).await?))
|
||||
}
|
||||
|
||||
fn configured_or_default_overlay_path(
|
||||
configured: &Option<PathBuf>,
|
||||
tiles_path: &Path,
|
||||
file_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
) -> PathBuf {
|
||||
if let Some(path) = configured {
|
||||
return Some(path.clone());
|
||||
return path.clone();
|
||||
}
|
||||
|
||||
let default_path = tiles_path.parent()?.join(file_name);
|
||||
default_path.exists().then_some(default_path)
|
||||
tiles_path
|
||||
.parent()
|
||||
.map(|parent| parent.join(file_name))
|
||||
.unwrap_or_else(|| PathBuf::from(file_name))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -481,6 +486,11 @@ async fn main() -> anyhow::Result<()> {
|
|||
);
|
||||
let satellite_tiles =
|
||||
configured_or_default_overlay_path(&cli.satellite_tiles, tiles_path, "satellite.pmtiles");
|
||||
let satellite_highres_tiles = configured_or_default_overlay_path(
|
||||
&cli.satellite_highres_tiles,
|
||||
tiles_path,
|
||||
"satellite_highres.pmtiles",
|
||||
);
|
||||
let crime_hotspot_tiles = configured_or_default_overlay_path(
|
||||
&cli.crime_hotspot_tiles,
|
||||
tiles_path,
|
||||
|
|
@ -491,14 +501,23 @@ async fn main() -> anyhow::Result<()> {
|
|||
tiles_path,
|
||||
"trees_outside_woodlands.pmtiles",
|
||||
);
|
||||
let property_border_tiles = configured_or_default_overlay_path(
|
||||
&cli.property_border_tiles,
|
||||
tiles_path,
|
||||
"property_borders.pmtiles",
|
||||
);
|
||||
|
||||
let noise_overlay_reader =
|
||||
init_optional_tile_reader("Noise", noise_overlay_tiles.as_ref()).await?;
|
||||
let satellite_reader = init_optional_tile_reader("Satellite", satellite_tiles.as_ref()).await?;
|
||||
init_required_tile_reader("Noise", &noise_overlay_tiles).await?;
|
||||
let satellite_reader = init_required_tile_reader("Satellite", &satellite_tiles).await?;
|
||||
let satellite_highres_reader =
|
||||
init_required_tile_reader("Satellite high-res", &satellite_highres_tiles).await?;
|
||||
let crime_hotspot_reader =
|
||||
init_optional_tile_reader("Crime hotspots", crime_hotspot_tiles.as_ref()).await?;
|
||||
init_required_tile_reader("Crime hotspots", &crime_hotspot_tiles).await?;
|
||||
let tree_overlay_reader =
|
||||
init_optional_tile_reader("Trees outside woodland", tree_overlay_tiles.as_ref()).await?;
|
||||
init_required_tile_reader("Trees outside woodland", &tree_overlay_tiles).await?;
|
||||
let property_border_reader =
|
||||
init_required_tile_reader("Property borders", &property_border_tiles).await?;
|
||||
|
||||
let feature_name_to_index: rustc_hash::FxHashMap<String, usize> = property_data
|
||||
.feature_names
|
||||
|
|
@ -700,9 +719,11 @@ async fn main() -> anyhow::Result<()> {
|
|||
let reader_tile = tile_reader.clone();
|
||||
let reader_style = tile_reader.clone();
|
||||
let reader_satellite = satellite_reader.clone();
|
||||
let reader_satellite_highres = satellite_highres_reader.clone();
|
||||
let reader_noise_overlay = noise_overlay_reader.clone();
|
||||
let reader_crime_hotspot = crime_hotspot_reader.clone();
|
||||
let reader_tree_overlay = tree_overlay_reader.clone();
|
||||
let reader_property_border = property_border_reader.clone();
|
||||
let public_url_tiles = initial_state.public_url.clone();
|
||||
|
||||
let api = Router::new()
|
||||
|
|
@ -878,6 +899,18 @@ async fn main() -> anyhow::Result<()> {
|
|||
})
|
||||
.layer(ConcurrencyLimitLayer::new(30)),
|
||||
)
|
||||
.route(
|
||||
"/api/tiles/satellite-highres/{z}/{x}/{y}",
|
||||
get(move |path| {
|
||||
routes::get_overlay_tile(
|
||||
reader_satellite_highres.clone(),
|
||||
routes::OverlayTileFormat::RasterWebp,
|
||||
"satellite-highres",
|
||||
path,
|
||||
)
|
||||
})
|
||||
.layer(ConcurrencyLimitLayer::new(30)),
|
||||
)
|
||||
.route(
|
||||
"/api/overlays/noise/{z}/{x}/{y}",
|
||||
get(move |path| {
|
||||
|
|
@ -914,6 +947,18 @@ async fn main() -> anyhow::Result<()> {
|
|||
})
|
||||
.layer(ConcurrencyLimitLayer::new(30)),
|
||||
)
|
||||
.route(
|
||||
"/api/overlays/property-borders/{z}/{x}/{y}",
|
||||
get(move |path| {
|
||||
routes::get_overlay_tile(
|
||||
reader_property_border.clone(),
|
||||
routes::OverlayTileFormat::VectorMvtGzip,
|
||||
"property-borders",
|
||||
path,
|
||||
)
|
||||
})
|
||||
.layer(ConcurrencyLimitLayer::new(30)),
|
||||
)
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route(
|
||||
"/metrics",
|
||||
|
|
|
|||
|
|
@ -283,7 +283,9 @@ async fn find_users_collection_id(
|
|||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Ensure `is_admin` (bool) and `subscription` (text) fields exist on the `users` collection.
|
||||
/// Ensure custom fields (`is_admin`, `subscription`, `newsletter`, `ai_tokens_*`,
|
||||
/// `can_see_listings`) exist on the `users` collection. Booleans default to false,
|
||||
/// so new fields are off for everyone until a superuser write flips them.
|
||||
/// PocketBase PATCH replaces the entire `fields` array, so we must preserve existing fields.
|
||||
async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> anyhow::Result<()> {
|
||||
let url = format!("{base_url}/api/collections/users");
|
||||
|
|
@ -309,12 +311,14 @@ async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> any
|
|||
let has_newsletter = fields.iter().any(|f| f["name"] == "newsletter");
|
||||
let has_ai_tokens_used = fields.iter().any(|f| f["name"] == "ai_tokens_used");
|
||||
let has_ai_tokens_week = fields.iter().any(|f| f["name"] == "ai_tokens_week");
|
||||
let has_can_see_listings = fields.iter().any(|f| f["name"] == "can_see_listings");
|
||||
|
||||
let has_all_required_fields = has_is_admin
|
||||
&& has_subscription
|
||||
&& has_newsletter
|
||||
&& has_ai_tokens_used
|
||||
&& has_ai_tokens_week;
|
||||
&& has_ai_tokens_week
|
||||
&& has_can_see_listings;
|
||||
|
||||
if has_all_required_fields {
|
||||
info!("PocketBase users collection already has all required fields");
|
||||
|
|
@ -358,6 +362,13 @@ async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> any
|
|||
}));
|
||||
}
|
||||
|
||||
if !has_can_see_listings {
|
||||
new_fields.push(serde_json::json!({
|
||||
"name": "can_see_listings",
|
||||
"type": "bool",
|
||||
}));
|
||||
}
|
||||
|
||||
let patch_resp = client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
|
|
@ -388,13 +399,15 @@ async fn ensure_user_auth_rules(
|
|||
"@request.body.subscription:isset = false",
|
||||
" && @request.body.is_admin:isset = false",
|
||||
" && @request.body.ai_tokens_used:isset = false",
|
||||
" && @request.body.ai_tokens_week:isset = false"
|
||||
" && @request.body.ai_tokens_week:isset = false",
|
||||
" && @request.body.can_see_listings:isset = false"
|
||||
);
|
||||
let protected_fields_unchanged = concat!(
|
||||
"@request.body.subscription:changed = false",
|
||||
" && @request.body.is_admin:changed = false",
|
||||
" && @request.body.ai_tokens_used:changed = false",
|
||||
" && @request.body.ai_tokens_week:changed = false"
|
||||
" && @request.body.ai_tokens_week:changed = false",
|
||||
" && @request.body.can_see_listings:changed = false"
|
||||
);
|
||||
let update_rule = format!("{self_only} && {protected_fields_unchanged}");
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,16 @@ pub async fn get_actual_listings(
|
|||
) -> Result<Json<ActualListingsResponse>, Response> {
|
||||
let state = shared.load_state();
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
// Gate the entire feature behind the per-user `can_see_listings` flag. The
|
||||
// flag is off by default for everyone, so listings are invisible unless a
|
||||
// superuser has explicitly granted access to this account.
|
||||
if !user.0.as_ref().is_some_and(|u| u.can_see_listings) {
|
||||
return Err(
|
||||
ApiError::Forbidden("You do not have access to listings".to_string()).into_response(),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(actual_listings) = state.actual_listings.clone() else {
|
||||
return Ok(Json(ActualListingsResponse {
|
||||
listings: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -359,9 +359,9 @@ pub fn build_system_prompt(
|
|||
or \"max\" (at most this value). Never set two filters on the same feature.\n\
|
||||
- Use EXACT feature names from the list — spelling, capitalisation, and punctuation must match.\n\
|
||||
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
|
||||
- \"low crime\" / \"safe\" = low values on the Serious crime and Minor crime features. \
|
||||
Prefer the per-1k resident crime features for broad area safety; use specific crime \
|
||||
features only when the user names a crime type.\n\
|
||||
- \"low crime\" / \"safe\" = low values on the Serious crime (avg/yr) and Minor crime (avg/yr) \
|
||||
features (incidents counted within 50m of the postcode). Prefer these aggregates for broad \
|
||||
area safety; use specific crime features only when the user names a crime type.\n\
|
||||
- \"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of amenities (Park) within 2km \
|
||||
or low Distance to nearest park (km), depending on wording.\n\
|
||||
- \"good schools\" = Good+ school features. \"outstanding schools\" = Outstanding school features.\n\
|
||||
|
|
@ -505,8 +505,8 @@ pub fn build_system_prompt(
|
|||
parts.push(
|
||||
"\nUser: \"safe quiet area with good schools and parks\"\n\
|
||||
Output: {\"numeric_filters\": [\
|
||||
{\"name\": \"Serious crime per 1k residents (avg/yr)\", \"bound\": \"max\", \"value\": 20}, \
|
||||
{\"name\": \"Minor crime per 1k residents (avg/yr)\", \"bound\": \"max\", \"value\": 50}, \
|
||||
{\"name\": \"Serious crime (avg/yr)\", \"bound\": \"max\", \"value\": 5}, \
|
||||
{\"name\": \"Minor crime (avg/yr)\", \"bound\": \"max\", \"value\": 20}, \
|
||||
{\"name\": \"Noise (dB)\", \"bound\": \"max\", \"value\": 55}, \
|
||||
{\"name\": \"Good+ primary schools within 2km\", \"bound\": \"min\", \"value\": 2}, \
|
||||
{\"name\": \"Good+ secondary schools within 2km\", \"bound\": \"min\", \"value\": 1}, \
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub enum OverlayTileFormat {
|
|||
VectorMvtGzip,
|
||||
RasterPng,
|
||||
RasterJpeg,
|
||||
RasterWebp,
|
||||
}
|
||||
|
||||
impl OverlayTileFormat {
|
||||
|
|
@ -21,6 +22,7 @@ impl OverlayTileFormat {
|
|||
Self::VectorMvtGzip => "application/x-protobuf",
|
||||
Self::RasterPng => "image/png",
|
||||
Self::RasterJpeg => "image/jpeg",
|
||||
Self::RasterWebp => "image/webp",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -30,15 +32,11 @@ impl OverlayTileFormat {
|
|||
}
|
||||
|
||||
pub async fn get_overlay_tile(
|
||||
reader: Option<Arc<TileReader>>,
|
||||
reader: 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) => {
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ pub async fn get_places(
|
|||
.postcodes
|
||||
.iter()
|
||||
.filter(|postcode| postcode_starts_with_compact(postcode, &compact_query))
|
||||
.filter(|postcode| !property_data.rows_for_postcode(postcode).is_empty())
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -123,7 +123,15 @@ fn insert_feature_value(
|
|||
return;
|
||||
}
|
||||
|
||||
let value = state.data.get_feature(row, feat_idx);
|
||||
// `get_feature` decodes the lossy u16-quantized value, which turns round
|
||||
// sale prices into noise (e.g. £428,000 → £427,984). For the last sale we
|
||||
// keep an unquantized copy, so serve that instead to match the exact
|
||||
// `historical_prices` entries and the price-history chart.
|
||||
let value = if feature_names[feat_idx] == "Last known price" {
|
||||
state.data.last_known_price_raw(row)
|
||||
} else {
|
||||
state.data.get_feature(row, feat_idx)
|
||||
};
|
||||
if value.is_finite() {
|
||||
features.insert(feature_names[feat_idx].clone(), value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,10 +257,11 @@ pub fn compute_feature_stats(
|
|||
|
||||
/// 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).
|
||||
/// Each matching property contributes its postcode's per-year counts (incidents
|
||||
/// within 50m of that postcode); this is the same property-weighted-average
|
||||
/// shape used elsewhere in the right pane. Postcodes with no series for a given
|
||||
/// crime type contribute 0 for that type (matching how the `(avg/yr)` columns
|
||||
/// treat missing crime types).
|
||||
pub fn compute_crime_by_year(
|
||||
matching_rows: &[usize],
|
||||
data: &PropertyData,
|
||||
|
|
@ -273,19 +274,19 @@ pub fn compute_crime_by_year(
|
|||
}
|
||||
|
||||
// For each crime type, accumulate per-year sums and the count of rows whose
|
||||
// LSOA exists in the crime side table.
|
||||
// postcode 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 {
|
||||
let postcode = data.postcode(row);
|
||||
let Some(series_list) = crime_by_year.series_by_postcode.get(postcode) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// For every type the LSOA reports, add its per-year counts.
|
||||
// For every type the postcode 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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue