Add postcodes
This commit is contained in:
parent
004948385d
commit
ce4c0cc08c
5 changed files with 325 additions and 1 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
mod poi;
|
mod poi;
|
||||||
|
mod postcodes;
|
||||||
mod property;
|
mod property;
|
||||||
|
|
||||||
pub use poi::{POICategoryGroup, POIData};
|
pub use poi::{POICategoryGroup, POIData};
|
||||||
|
pub use postcodes::PostcodeData;
|
||||||
pub use property::{precompute_h3, Histogram, PropertyData};
|
pub use property::{precompute_h3, Histogram, PropertyData};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
mod consts;
|
mod consts;
|
||||||
mod data;
|
mod data;
|
||||||
mod features;
|
mod features;
|
||||||
|
mod metrics;
|
||||||
mod og_middleware;
|
mod og_middleware;
|
||||||
pub mod parsing;
|
pub mod parsing;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
@ -35,6 +36,14 @@ struct Cli {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pois: PathBuf,
|
pois: PathBuf,
|
||||||
|
|
||||||
|
/// Path to the postcode boundaries directory
|
||||||
|
#[arg(long)]
|
||||||
|
postcodes: PathBuf,
|
||||||
|
|
||||||
|
/// Path to the PMTiles file for map tiles
|
||||||
|
#[arg(long)]
|
||||||
|
tiles: PathBuf,
|
||||||
|
|
||||||
/// Path to the frontend dist directory
|
/// Path to the frontend dist directory
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
dist: Option<PathBuf>,
|
dist: Option<PathBuf>,
|
||||||
|
|
@ -61,6 +70,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
.with_ansi(true)
|
.with_ansi(true)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
// Initialize Prometheus metrics
|
||||||
|
let metrics_handle = metrics::init_metrics();
|
||||||
|
info!("Prometheus metrics initialized");
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
let parquet_path = &cli.data;
|
let parquet_path = &cli.data;
|
||||||
|
|
@ -107,6 +120,33 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let poi_grid =
|
let poi_grid =
|
||||||
utils::GridIndex::build(&poi_data.lat, &poi_data.lng, consts::GRID_CELL_SIZE);
|
utils::GridIndex::build(&poi_data.lat, &poi_data.lng, consts::GRID_CELL_SIZE);
|
||||||
|
|
||||||
|
// Load postcode boundaries
|
||||||
|
let postcodes_path = &cli.postcodes;
|
||||||
|
if !postcodes_path.exists() {
|
||||||
|
bail!(
|
||||||
|
"Postcode boundaries not found: {}",
|
||||||
|
postcodes_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Loading postcode boundaries from {}",
|
||||||
|
postcodes_path.display()
|
||||||
|
);
|
||||||
|
let postcode_data = data::PostcodeData::load(postcodes_path)?;
|
||||||
|
info!(
|
||||||
|
postcodes = postcode_data.postcodes.len(),
|
||||||
|
"Postcode boundaries loaded"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize tile reader
|
||||||
|
let tiles_path = &cli.tiles;
|
||||||
|
if !tiles_path.exists() {
|
||||||
|
bail!("PMTiles file not found: {}", tiles_path.display());
|
||||||
|
}
|
||||||
|
info!("Loading PMTiles from {}", tiles_path.display());
|
||||||
|
let tile_reader = Arc::new(routes::init_tile_reader(tiles_path).await?);
|
||||||
|
info!("PMTiles loaded successfully");
|
||||||
|
|
||||||
let min_keys: Vec<String> = property_data
|
let min_keys: Vec<String> = property_data
|
||||||
.feature_names
|
.feature_names
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -165,12 +205,20 @@ async fn main() -> anyhow::Result<()> {
|
||||||
"Precomputed features response"
|
"Precomputed features response"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Record data loading metrics
|
||||||
|
metrics::record_data_stats(
|
||||||
|
property_data.lat.len(),
|
||||||
|
poi_data.lat.len(),
|
||||||
|
postcode_data.postcodes.len(),
|
||||||
|
);
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
data: property_data,
|
data: property_data,
|
||||||
grid,
|
grid,
|
||||||
h3_cells,
|
h3_cells,
|
||||||
poi_data,
|
poi_data,
|
||||||
poi_grid,
|
poi_grid,
|
||||||
|
postcode_data,
|
||||||
min_keys,
|
min_keys,
|
||||||
max_keys,
|
max_keys,
|
||||||
poi_category_groups,
|
poi_category_groups,
|
||||||
|
|
@ -188,6 +236,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let state_features = state.clone();
|
let state_features = state.clone();
|
||||||
let state_hexagons = state.clone();
|
let state_hexagons = state.clone();
|
||||||
|
let state_postcodes = state.clone();
|
||||||
let state_pois = state.clone();
|
let state_pois = state.clone();
|
||||||
let state_poi_categories = state.clone();
|
let state_poi_categories = state.clone();
|
||||||
let state_hexagon_properties = state.clone();
|
let state_hexagon_properties = state.clone();
|
||||||
|
|
@ -204,6 +253,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
"/api/hexagons",
|
"/api/hexagons",
|
||||||
get(move |query| routes::get_hexagons(state_hexagons.clone(), query)),
|
get(move |query| routes::get_hexagons(state_hexagons.clone(), query)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/postcodes",
|
||||||
|
get(move |query| routes::get_postcodes(state_postcodes.clone(), query)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/pois",
|
"/api/pois",
|
||||||
get(move |query| routes::get_pois(state_pois.clone(), query)),
|
get(move |query| routes::get_pois(state_pois.clone(), query)),
|
||||||
|
|
@ -227,6 +280,22 @@ async fn main() -> anyhow::Result<()> {
|
||||||
get(move |query| routes::get_og_image(state_og_image.clone(), query)),
|
get(move |query| routes::get_og_image(state_og_image.clone(), query)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add tile routes
|
||||||
|
let reader_tile = tile_reader.clone();
|
||||||
|
let reader_style = tile_reader.clone();
|
||||||
|
let api = api
|
||||||
|
.route(
|
||||||
|
"/api/tiles/{z}/{x}/{y}",
|
||||||
|
get(move |path| routes::get_tile(axum::extract::State(reader_tile.clone()), path)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/tiles/style.json",
|
||||||
|
get(move |headers, query| {
|
||||||
|
routes::get_style(axum::extract::State(reader_style.clone()), headers, query)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route("/metrics", get(move || metrics::metrics_handler(metrics_handle.clone())));
|
||||||
|
|
||||||
let app = if frontend_dist.exists() {
|
let app = if frontend_dist.exists() {
|
||||||
api.fallback_service(ServeDir::new(&frontend_dist))
|
api.fallback_service(ServeDir::new(&frontend_dist))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -234,6 +303,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = app
|
let app = app
|
||||||
|
.layer(middleware::from_fn(metrics::track_metrics))
|
||||||
.layer(middleware::from_fn(
|
.layer(middleware::from_fn(
|
||||||
move |req: axum::extract::Request, next: middleware::Next| {
|
move |req: axum::extract::Request, next: middleware::Next| {
|
||||||
let st = state_crawler.clone();
|
let st = state_crawler.clone();
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,15 @@ mod hexagon_stats;
|
||||||
pub(crate) mod hexagons;
|
pub(crate) mod hexagons;
|
||||||
mod og_image;
|
mod og_image;
|
||||||
mod pois;
|
mod pois;
|
||||||
|
mod postcodes;
|
||||||
pub(crate) mod properties;
|
pub(crate) mod properties;
|
||||||
|
mod tiles;
|
||||||
|
|
||||||
pub use features::{build_features_response, get_features, FeaturesResponse};
|
pub use features::{build_features_response, get_features, FeaturesResponse};
|
||||||
pub use hexagon_stats::get_hexagon_stats;
|
pub use hexagon_stats::get_hexagon_stats;
|
||||||
pub use hexagons::get_hexagons;
|
pub use hexagons::get_hexagons;
|
||||||
pub use og_image::get_og_image;
|
pub use og_image::get_og_image;
|
||||||
pub use pois::{get_poi_categories, get_pois};
|
pub use pois::{get_poi_categories, get_pois};
|
||||||
|
pub use postcodes::get_postcodes;
|
||||||
pub use properties::get_hexagon_properties;
|
pub use properties::get_hexagon_properties;
|
||||||
|
pub use tiles::{get_style, get_tile, init_tile_reader};
|
||||||
|
|
|
||||||
246
server-rs/src/routes/postcodes.rs
Normal file
246
server-rs/src/routes/postcodes.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::Query;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Json;
|
||||||
|
use rustc_hash::FxHashMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::parsing::{parse_bounds, parse_filters, row_passes_filters};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PostcodesResponse {
|
||||||
|
features: Vec<Map<String, Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PostcodeParams {
|
||||||
|
bounds: Option<String>,
|
||||||
|
/// Comma-separated filters: `name:min:max,...`
|
||||||
|
filters: Option<String>,
|
||||||
|
/// Comma-separated feature names to include in min/max aggregation.
|
||||||
|
fields: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-postcode accumulator for aggregating features.
|
||||||
|
struct PostcodeAgg {
|
||||||
|
count: u32,
|
||||||
|
mins: Box<[f32]>,
|
||||||
|
maxs: Box<[f32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostcodeAgg {
|
||||||
|
fn new(num_features: usize) -> Self {
|
||||||
|
PostcodeAgg {
|
||||||
|
count: 0,
|
||||||
|
mins: vec![f32::INFINITY; num_features].into_boxed_slice(),
|
||||||
|
maxs: vec![f32::NEG_INFINITY; num_features].into_boxed_slice(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn add_row(&mut self, feature_data: &[f32], row: usize, num_features: usize) {
|
||||||
|
self.count += 1;
|
||||||
|
let base = row * num_features;
|
||||||
|
let row_slice = &feature_data[base..base + num_features];
|
||||||
|
for (feat_index, &value) in row_slice.iter().enumerate() {
|
||||||
|
if value.is_finite() {
|
||||||
|
if value < self.mins[feat_index] {
|
||||||
|
self.mins[feat_index] = value;
|
||||||
|
}
|
||||||
|
if value > self.maxs[feat_index] {
|
||||||
|
self.maxs[feat_index] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn add_row_selective(
|
||||||
|
&mut self,
|
||||||
|
feature_data: &[f32],
|
||||||
|
row: usize,
|
||||||
|
num_features: usize,
|
||||||
|
indices: &[usize],
|
||||||
|
) {
|
||||||
|
self.count += 1;
|
||||||
|
let base = row * num_features;
|
||||||
|
for &feat_index in indices {
|
||||||
|
let value = feature_data[base + feat_index];
|
||||||
|
if value.is_finite() {
|
||||||
|
if value < self.mins[feat_index] {
|
||||||
|
self.mins[feat_index] = value;
|
||||||
|
}
|
||||||
|
if value > self.maxs[feat_index] {
|
||||||
|
self.maxs[feat_index] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_postcodes(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
Query(params): Query<PostcodeParams>,
|
||||||
|
) -> Result<Json<PostcodesResponse>, (StatusCode, String)> {
|
||||||
|
let bounds_str = params.bounds.ok_or((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"bounds parameter is required".into(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let (south, west, north, east) = parse_bounds(&bounds_str)?;
|
||||||
|
|
||||||
|
let filters_str = params.filters.clone();
|
||||||
|
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||||
|
params.filters.as_deref(),
|
||||||
|
&state.data.feature_names,
|
||||||
|
&state.data.enum_values,
|
||||||
|
);
|
||||||
|
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||||
|
|
||||||
|
// Parse optional `fields` param into feature indices
|
||||||
|
let field_indices: Option<Vec<usize>> = params.fields.as_ref().map(|fields_str| {
|
||||||
|
if fields_str.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
fields_str
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|name| {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.data
|
||||||
|
.feature_names
|
||||||
|
.iter()
|
||||||
|
.position(|feat| feat == name)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = tokio::task::spawn_blocking(move || -> Result<PostcodesResponse, String> {
|
||||||
|
let postcode_data = &state.postcode_data;
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
|
||||||
|
let num_features = state.data.num_features;
|
||||||
|
let feature_data = &state.data.feature_data;
|
||||||
|
let min_keys = &state.min_keys;
|
||||||
|
let max_keys = &state.max_keys;
|
||||||
|
|
||||||
|
let has_selective = field_indices.is_some();
|
||||||
|
let sel_indices = field_indices.as_deref().unwrap_or(&[]);
|
||||||
|
|
||||||
|
// Step 1: Find postcodes within bounds using spatial grid on centroids
|
||||||
|
let postcode_indices: Vec<u32> = postcode_data.grid.query(south, west, north, east);
|
||||||
|
|
||||||
|
// Step 2: For each postcode, aggregate properties
|
||||||
|
let mut postcode_aggs: FxHashMap<usize, PostcodeAgg> = FxHashMap::default();
|
||||||
|
|
||||||
|
// Build postcode -> rows mapping by iterating properties in bounds
|
||||||
|
// and grouping by their postcode
|
||||||
|
let mut postcode_rows: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
|
||||||
|
|
||||||
|
state
|
||||||
|
.grid
|
||||||
|
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||||
|
let row = row_idx as usize;
|
||||||
|
if !row_passes_filters(
|
||||||
|
row,
|
||||||
|
&parsed_filters,
|
||||||
|
&parsed_enum_filters,
|
||||||
|
feature_data,
|
||||||
|
num_features,
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get postcode for this property
|
||||||
|
let postcode = state.data.postcode(row);
|
||||||
|
if let Some(&pc_idx) = postcode_data.postcode_to_idx.get(postcode) {
|
||||||
|
postcode_rows.entry(pc_idx).or_default().push(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now aggregate for each postcode that's in bounds and has properties
|
||||||
|
for &pc_idx in &postcode_indices {
|
||||||
|
let idx = pc_idx as usize;
|
||||||
|
if let Some(rows) = postcode_rows.get(&idx) {
|
||||||
|
let agg = postcode_aggs
|
||||||
|
.entry(idx)
|
||||||
|
.or_insert_with(|| PostcodeAgg::new(num_features));
|
||||||
|
for &row in rows {
|
||||||
|
if has_selective {
|
||||||
|
agg.add_row_selective(feature_data, row, num_features, sel_indices);
|
||||||
|
} else {
|
||||||
|
agg.add_row(feature_data, row, num_features);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
let mut features = Vec::with_capacity(postcode_aggs.len());
|
||||||
|
|
||||||
|
for (pc_idx, aggregation) in postcode_aggs {
|
||||||
|
if aggregation.count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut map = Map::new();
|
||||||
|
map.insert(
|
||||||
|
"postcode".into(),
|
||||||
|
Value::String(postcode_data.postcodes[pc_idx].clone()),
|
||||||
|
);
|
||||||
|
map.insert("count".into(), Value::Number(aggregation.count.into()));
|
||||||
|
|
||||||
|
// Add vertices as array of [lon, lat] pairs
|
||||||
|
let vertices_array: Vec<Value> = postcode_data.vertices[pc_idx]
|
||||||
|
.iter()
|
||||||
|
.map(|[lon, lat]| Value::Array(vec![Value::from(*lon as f64), Value::from(*lat as f64)]))
|
||||||
|
.collect();
|
||||||
|
map.insert("vertices".into(), Value::Array(vertices_array));
|
||||||
|
|
||||||
|
let iter: Box<dyn Iterator<Item = usize>> = if let Some(idx) = field_indices.as_ref() {
|
||||||
|
Box::new(idx.iter().copied())
|
||||||
|
} else {
|
||||||
|
Box::new(0..num_features)
|
||||||
|
};
|
||||||
|
|
||||||
|
for feat_index in iter {
|
||||||
|
if aggregation.mins[feat_index].is_finite()
|
||||||
|
&& aggregation.maxs[feat_index].is_finite()
|
||||||
|
{
|
||||||
|
if let (Some(min_num), Some(max_num)) = (
|
||||||
|
serde_json::Number::from_f64(aggregation.mins[feat_index] as f64),
|
||||||
|
serde_json::Number::from_f64(aggregation.maxs[feat_index] as f64),
|
||||||
|
) {
|
||||||
|
map.insert(min_keys[feat_index].clone(), Value::Number(min_num));
|
||||||
|
map.insert(max_keys[feat_index].clone(), Value::Number(max_num));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
features.push(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
let t_total = t0.elapsed();
|
||||||
|
info!(
|
||||||
|
postcodes = features.len(),
|
||||||
|
filters = num_filters,
|
||||||
|
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||||
|
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
||||||
|
"GET /api/postcodes"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(PostcodesResponse { features })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||||
|
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::data::{POICategoryGroup, POIData, PropertyData};
|
use crate::data::{POICategoryGroup, POIData, PostcodeData, PropertyData};
|
||||||
use crate::routes::FeaturesResponse;
|
use crate::routes::FeaturesResponse;
|
||||||
use crate::utils::GridIndex;
|
use crate::utils::GridIndex;
|
||||||
|
|
||||||
|
|
@ -10,6 +10,8 @@ pub struct AppState {
|
||||||
pub h3_cells: Vec<u64>,
|
pub h3_cells: Vec<u64>,
|
||||||
pub poi_data: POIData,
|
pub poi_data: POIData,
|
||||||
pub poi_grid: GridIndex,
|
pub poi_grid: GridIndex,
|
||||||
|
/// Postcode boundary data for high-zoom rendering
|
||||||
|
pub postcode_data: PostcodeData,
|
||||||
/// Precomputed JSON key names: "min_{feature_name}" for each feature
|
/// Precomputed JSON key names: "min_{feature_name}" for each feature
|
||||||
pub min_keys: Vec<String>,
|
pub min_keys: Vec<String>,
|
||||||
/// Precomputed JSON key names: "max_{feature_name}" for each feature
|
/// Precomputed JSON key names: "max_{feature_name}" for each feature
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue