Add pocketbase and other changes
This commit is contained in:
parent
a9717d570d
commit
229150b641
14 changed files with 1178 additions and 91 deletions
507
server-rs/src/routes/export.rs
Normal file
507
server-rs/src/routes/export.rs
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Image, Url, Workbook};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::parsing::{parse_bounds, parse_filters, row_passes_filters};
|
||||
use crate::routes::FeatureInfo;
|
||||
use crate::state::AppState;
|
||||
|
||||
const MAX_EXPORT_POSTCODES: usize = 250;
|
||||
/// Height (in pixels) reserved for the OG image row
|
||||
const IMAGE_ROW_HEIGHT: f64 = 225.0;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExportParams {
|
||||
bounds: Option<String>,
|
||||
filters: Option<String>,
|
||||
fields: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-postcode accumulator for export aggregation (mean for numeric, mode for enum).
|
||||
struct PostcodeExportAgg {
|
||||
count: u32,
|
||||
sums: Vec<f64>,
|
||||
finite_counts: Vec<u32>,
|
||||
/// feat_idx -> (value_bits -> count) for enum mode calculation
|
||||
enum_freqs: FxHashMap<usize, FxHashMap<u32, u32>>,
|
||||
}
|
||||
|
||||
impl PostcodeExportAgg {
|
||||
fn new(num_features: usize) -> Self {
|
||||
Self {
|
||||
count: 0,
|
||||
sums: vec![0.0; num_features],
|
||||
finite_counts: vec![0; num_features],
|
||||
enum_freqs: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn add_row(
|
||||
&mut self,
|
||||
feature_data: &[f32],
|
||||
row: usize,
|
||||
num_features: usize,
|
||||
enum_indices: &FxHashMap<usize, ()>,
|
||||
) {
|
||||
self.count += 1;
|
||||
let base = row * num_features;
|
||||
let row_slice = &feature_data[base..base + num_features];
|
||||
for (feat_idx, &value) in row_slice.iter().enumerate() {
|
||||
if !value.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if enum_indices.contains_key(&feat_idx) {
|
||||
*self
|
||||
.enum_freqs
|
||||
.entry(feat_idx)
|
||||
.or_default()
|
||||
.entry(value.to_bits())
|
||||
.or_insert(0) += 1;
|
||||
} else {
|
||||
self.sums[feat_idx] += value as f64;
|
||||
self.finite_counts[feat_idx] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract feature names referenced in the filters param (preserving order).
|
||||
fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec<String> {
|
||||
let input = match filters_str.filter(|text| !text.is_empty()) {
|
||||
Some(text) => text,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
for entry in input.split(',') {
|
||||
let parts: Vec<&str> = entry.splitn(2, ':').collect();
|
||||
if parts.len() == 2 {
|
||||
let name = parts[0].trim().to_string();
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Fetch the OG screenshot image from the sidecar service.
|
||||
async fn fetch_og_image(
|
||||
state: &AppState,
|
||||
view_param: &str,
|
||||
filters_str: Option<&str>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let sidecar_url = state.og_sidecar_url.as_deref()?;
|
||||
|
||||
let mut params = vec![format!("v={}", urlencoding::encode(view_param))];
|
||||
if let Some(fs) = filters_str {
|
||||
if !fs.is_empty() {
|
||||
params.push(format!("f={}", urlencoding::encode(fs)));
|
||||
}
|
||||
}
|
||||
let url = format!("{}/screenshot?{}", sidecar_url, params.join("&"));
|
||||
|
||||
match state.http_client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
||||
Ok(bytes) => {
|
||||
info!(bytes = bytes.len(), "Fetched OG image for export");
|
||||
Some(bytes.to_vec())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to read OG sidecar response for export: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(resp) => {
|
||||
warn!(status = %resp.status(), "OG sidecar returned error for export");
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to reach OG sidecar for export: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_export(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<ExportParams>,
|
||||
) -> Result<impl IntoResponse, (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 fields_str = params.fields.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
);
|
||||
|
||||
let public_url = state.public_url.clone();
|
||||
|
||||
// Compute view param for OG image and dashboard URL
|
||||
let center_lat = (south + north) / 2.0;
|
||||
let center_lon = (west + east) / 2.0;
|
||||
let lat_span = north - south;
|
||||
let zoom = if lat_span > 0.0 {
|
||||
(360.0 / lat_span).log2().clamp(1.0, 18.0)
|
||||
} else {
|
||||
12.0
|
||||
};
|
||||
let view_param = format!("{:.4},{:.4},{:.1}", center_lat, center_lon, zoom);
|
||||
|
||||
// Fetch OG image from sidecar (async, before spawn_blocking)
|
||||
let og_image_bytes = fetch_og_image(&state, &view_param, filters_str.as_deref()).await;
|
||||
|
||||
// Build feature name → description map from the precomputed features response
|
||||
let feature_descriptions: FxHashMap<String, String> = state
|
||||
.features_response
|
||||
.groups
|
||||
.iter()
|
||||
.flat_map(|group| &group.features)
|
||||
.map(|feat| match feat {
|
||||
FeatureInfo::Numeric {
|
||||
name, description, ..
|
||||
} => (name.clone(), description.to_string()),
|
||||
FeatureInfo::Enum {
|
||||
name, description, ..
|
||||
} => (name.clone(), description.to_string()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
let num_features = state.data.num_features;
|
||||
let feature_data = &state.data.feature_data;
|
||||
let feature_names = &state.data.feature_names;
|
||||
let enum_values = &state.data.enum_values;
|
||||
let postcode_data = &state.postcode_data;
|
||||
|
||||
// Build set of enum feature indices for quick lookup
|
||||
let enum_indices: FxHashMap<usize, ()> = enum_values.keys().map(|&idx| (idx, ())).collect();
|
||||
|
||||
// Group rows by 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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate per postcode
|
||||
let mut postcode_aggs: Vec<(usize, PostcodeExportAgg)> =
|
||||
Vec::with_capacity(postcode_rows.len());
|
||||
for (pc_idx, rows) in postcode_rows {
|
||||
let mut agg = PostcodeExportAgg::new(num_features);
|
||||
for &row in &rows {
|
||||
agg.add_row(feature_data, row, num_features, &enum_indices);
|
||||
}
|
||||
if agg.count > 0 {
|
||||
postcode_aggs.push((pc_idx, agg));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by property count descending
|
||||
postcode_aggs.sort_unstable_by(|lhs, rhs| rhs.1.count.cmp(&lhs.1.count));
|
||||
|
||||
// Sample if too many postcodes
|
||||
let was_sampled = postcode_aggs.len() > MAX_EXPORT_POSTCODES;
|
||||
if was_sampled {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
bounds_str.hash(&mut hasher);
|
||||
let seed = hasher.finish();
|
||||
|
||||
let len = postcode_aggs.len();
|
||||
for pick in 0..MAX_EXPORT_POSTCODES {
|
||||
let swap_idx = pick
|
||||
+ ((seed.wrapping_mul(pick as u64 + 1).wrapping_add(pick as u64)) as usize
|
||||
% (len - pick));
|
||||
postcode_aggs.swap(pick, swap_idx);
|
||||
}
|
||||
postcode_aggs.truncate(MAX_EXPORT_POSTCODES);
|
||||
postcode_aggs.sort_unstable_by(|lhs, rhs| rhs.1.count.cmp(&lhs.1.count));
|
||||
}
|
||||
|
||||
// Determine column order: filter features first, then remaining
|
||||
let filter_feature_names = extract_filter_feature_names(filters_str.as_deref());
|
||||
|
||||
let field_indices: Option<Vec<usize>> = fields_str.as_ref().map(|fs| {
|
||||
if fs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
fs.split(',')
|
||||
.filter_map(|name| {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
state.feature_name_to_index.get(name).copied()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let all_feature_indices: Vec<usize> = if let Some(ref indices) = field_indices {
|
||||
indices.clone()
|
||||
} else {
|
||||
let mut ordered = Vec::with_capacity(num_features);
|
||||
let mut used = FxHashSet::default();
|
||||
|
||||
for name in &filter_feature_names {
|
||||
if let Some(&idx) = state.feature_name_to_index.get(name.as_str()) {
|
||||
if used.insert(idx) {
|
||||
ordered.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
for idx in 0..num_features {
|
||||
if used.insert(idx) {
|
||||
ordered.push(idx);
|
||||
}
|
||||
}
|
||||
ordered
|
||||
};
|
||||
|
||||
// Build Excel workbook
|
||||
let mut workbook = Workbook::new();
|
||||
let sheet = workbook.add_worksheet();
|
||||
|
||||
// Formats
|
||||
let header_fmt = Format::new()
|
||||
.set_bold()
|
||||
.set_border_bottom(FormatBorder::Thin)
|
||||
.set_align(FormatAlign::Center);
|
||||
|
||||
let desc_fmt = Format::new()
|
||||
.set_italic()
|
||||
.set_font_color("#666666")
|
||||
.set_font_size(9)
|
||||
.set_align(FormatAlign::Center)
|
||||
.set_text_wrap();
|
||||
|
||||
let link_fmt = Format::new()
|
||||
.set_font_color("#0563C1")
|
||||
.set_underline(rust_xlsxwriter::FormatUnderline::Single)
|
||||
.set_font_size(11);
|
||||
|
||||
let note_fmt = Format::new()
|
||||
.set_italic()
|
||||
.set_font_color("#666666")
|
||||
.set_align(FormatAlign::Left);
|
||||
|
||||
// Row 0: "View on Narrowit" link
|
||||
let mut dashboard_url = format!("{}/", public_url);
|
||||
let mut query_parts: Vec<String> = Vec::new();
|
||||
query_parts.push(format!("v={}", view_param));
|
||||
if let Some(ref fs) = filters_str {
|
||||
if !fs.is_empty() {
|
||||
query_parts.push(format!("f={}", urlencoding::encode(fs)));
|
||||
}
|
||||
}
|
||||
if !query_parts.is_empty() {
|
||||
dashboard_url.push('?');
|
||||
dashboard_url.push_str(&query_parts.join("&"));
|
||||
}
|
||||
|
||||
sheet
|
||||
.write_url(0, 0, Url::new(&dashboard_url).set_text("View on Narrowit"))
|
||||
.map_err(|err| format!("Failed to write URL: {err}"))?;
|
||||
sheet
|
||||
.set_row_format(0, &link_fmt)
|
||||
.map_err(|err| format!("Failed to set row format: {err}"))?;
|
||||
|
||||
// Row 1: OG image (if available)
|
||||
let mut current_row = 1u32;
|
||||
if let Some(ref img_bytes) = og_image_bytes {
|
||||
match Image::new_from_buffer(img_bytes) {
|
||||
Ok(mut image) => {
|
||||
// Scale image to fit: ~400px wide, auto height preserving aspect ratio
|
||||
image = image.set_scale_to_size(400, 300, true);
|
||||
sheet
|
||||
.insert_image(current_row, 0, &image)
|
||||
.map_err(|err| format!("Failed to insert OG image: {err}"))?;
|
||||
// Set row height to accommodate the image
|
||||
sheet
|
||||
.set_row_height(current_row, IMAGE_ROW_HEIGHT)
|
||||
.map_err(|err| format!("Failed to set image row height: {err}"))?;
|
||||
current_row += 1;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to parse OG image for export: {err}");
|
||||
// Skip image row, don't leave a gap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leave a blank row between image and header
|
||||
current_row += 1;
|
||||
|
||||
// Header row
|
||||
let header_row = current_row;
|
||||
sheet
|
||||
.write_string_with_format(header_row, 0, "Postcode", &header_fmt)
|
||||
.map_err(|err| format!("Failed to write header: {err}"))?;
|
||||
sheet
|
||||
.write_string_with_format(header_row, 1, "Properties", &header_fmt)
|
||||
.map_err(|err| format!("Failed to write header: {err}"))?;
|
||||
|
||||
for (col_offset, &feat_idx) in all_feature_indices.iter().enumerate() {
|
||||
let col = (col_offset + 2) as u16;
|
||||
sheet
|
||||
.write_string_with_format(header_row, col, &feature_names[feat_idx], &header_fmt)
|
||||
.map_err(|err| format!("Failed to write header: {err}"))?;
|
||||
}
|
||||
|
||||
// Description row (below header)
|
||||
let desc_row = header_row + 1;
|
||||
// Empty descriptions for Postcode and Properties columns
|
||||
sheet
|
||||
.write_string_with_format(desc_row, 0, "", &desc_fmt)
|
||||
.map_err(|err| format!("Failed to write desc: {err}"))?;
|
||||
sheet
|
||||
.write_string_with_format(desc_row, 1, "Count of properties", &desc_fmt)
|
||||
.map_err(|err| format!("Failed to write desc: {err}"))?;
|
||||
|
||||
for (col_offset, &feat_idx) in all_feature_indices.iter().enumerate() {
|
||||
let col = (col_offset + 2) as u16;
|
||||
let desc = feature_descriptions
|
||||
.get(&feature_names[feat_idx])
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
sheet
|
||||
.write_string_with_format(desc_row, col, desc, &desc_fmt)
|
||||
.map_err(|err| format!("Failed to write desc: {err}"))?;
|
||||
}
|
||||
|
||||
// Write data rows (starting after description row)
|
||||
let data_start_row = desc_row + 1;
|
||||
for (row_offset, (pc_idx, agg)) in postcode_aggs.iter().enumerate() {
|
||||
let row = data_start_row + row_offset as u32;
|
||||
|
||||
sheet
|
||||
.write_string(row, 0, &postcode_data.postcodes[*pc_idx])
|
||||
.map_err(|err| format!("Failed to write postcode: {err}"))?;
|
||||
|
||||
sheet
|
||||
.write_number(row, 1, agg.count as f64)
|
||||
.map_err(|err| format!("Failed to write count: {err}"))?;
|
||||
|
||||
for (col_offset, &feat_idx) in all_feature_indices.iter().enumerate() {
|
||||
let col = (col_offset + 2) as u16;
|
||||
|
||||
if enum_indices.contains_key(&feat_idx) {
|
||||
if let Some(freqs) = agg.enum_freqs.get(&feat_idx) {
|
||||
if let Some((&mode_bits, _)) = freqs.iter().max_by_key(|(_, &count)| count)
|
||||
{
|
||||
let mode_f32 = f32::from_bits(mode_bits);
|
||||
let mode_idx = mode_f32 as usize;
|
||||
if let Some(values) = enum_values.get(&feat_idx) {
|
||||
if mode_idx < values.len() {
|
||||
sheet.write_string(row, col, &values[mode_idx]).map_err(
|
||||
|err| format!("Failed to write enum value: {err}"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let fc = agg.finite_counts[feat_idx];
|
||||
if fc > 0 {
|
||||
let mean = agg.sums[feat_idx] / fc as f64;
|
||||
sheet
|
||||
.write_number(row, col, mean)
|
||||
.map_err(|err| format!("Failed to write numeric value: {err}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If sampled, add a note at the bottom
|
||||
if was_sampled {
|
||||
let note_row = data_start_row + postcode_aggs.len() as u32 + 1;
|
||||
let total_cols = (all_feature_indices.len() + 2) as u16;
|
||||
sheet
|
||||
.merge_range(
|
||||
note_row,
|
||||
0,
|
||||
note_row,
|
||||
total_cols.saturating_sub(1),
|
||||
&format!(
|
||||
"Only the first {} postcodes shown (randomly sampled from results)",
|
||||
MAX_EXPORT_POSTCODES
|
||||
),
|
||||
¬e_fmt,
|
||||
)
|
||||
.map_err(|err| format!("Failed to write note: {err}"))?;
|
||||
}
|
||||
|
||||
// Column widths
|
||||
sheet.set_column_width(0, 12).ok();
|
||||
sheet.set_column_width(1, 12).ok();
|
||||
for col_offset in 0..all_feature_indices.len() {
|
||||
let col = (col_offset + 2) as u16;
|
||||
let feat_name = &feature_names[all_feature_indices[col_offset]];
|
||||
let width = (feat_name.len() as f64 * 1.1).clamp(10.0, 30.0);
|
||||
sheet.set_column_width(col, width).ok();
|
||||
}
|
||||
|
||||
let buf = workbook
|
||||
.save_to_buffer()
|
||||
.map_err(|err| format!("Failed to save workbook: {err}"))?;
|
||||
|
||||
let t_total = t0.elapsed();
|
||||
info!(
|
||||
postcodes = postcode_aggs.len(),
|
||||
sampled = was_sampled,
|
||||
features = all_feature_indices.len(),
|
||||
has_og_image = og_image_bytes.is_some(),
|
||||
bytes = buf.len(),
|
||||
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
||||
"GET /api/export"
|
||||
);
|
||||
|
||||
Ok(buf)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err))?;
|
||||
|
||||
Ok((
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"narrowit-export.xlsx\"",
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
))
|
||||
}
|
||||
|
|
@ -39,11 +39,19 @@ pub struct EnumFeatureStats {
|
|||
counts: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PricePoint {
|
||||
year: f32,
|
||||
price: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HexagonStatsResponse {
|
||||
count: usize,
|
||||
numeric_features: Vec<NumericFeatureStats>,
|
||||
enum_features: Vec<EnumFeatureStats>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
price_history: Vec<PricePoint>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -148,6 +156,43 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
let total_count = matching_rows.len();
|
||||
|
||||
// Collect price history (year, price) pairs
|
||||
let price_history = {
|
||||
let year_idx = state.feature_name_to_index.get("Transaction year").copied();
|
||||
let price_idx = state.feature_name_to_index.get("Last known price").copied();
|
||||
match (year_idx, price_idx) {
|
||||
(Some(yi), Some(pi)) => {
|
||||
let mut points: Vec<PricePoint> = matching_rows
|
||||
.iter()
|
||||
.filter_map(|&row| {
|
||||
let year = feature_data[row * num_features + yi];
|
||||
let price = feature_data[row * num_features + pi];
|
||||
if year.is_finite() && price.is_finite() {
|
||||
Some(PricePoint { year, price })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Cap at 5000 points by evenly sampling
|
||||
if points.len() > 5000 {
|
||||
let step = points.len() as f64 / 5000.0;
|
||||
points = (0..5000)
|
||||
.map(|i| {
|
||||
let idx = (i as f64 * step) as usize;
|
||||
PricePoint {
|
||||
year: points[idx].year,
|
||||
price: points[idx].price,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
points
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
let mut numeric_features = Vec::new();
|
||||
let mut enum_features_out = Vec::new();
|
||||
|
||||
|
|
@ -267,6 +312,7 @@ pub async fn get_hexagon_stats(
|
|||
count: total_count,
|
||||
numeric_features,
|
||||
enum_features: enum_features_out,
|
||||
price_history,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ use serde_json::{Map, Value};
|
|||
use tracing::{info, warn};
|
||||
|
||||
use crate::consts::{H3_PRECOMPUTE_MAX, H3_REQUEST_MAX, H3_REQUEST_MIN};
|
||||
use crate::parsing::{bounds_intersect, h3_cell_bounds, parse_bounds, parse_filters, row_passes_filters};
|
||||
use crate::parsing::{
|
||||
bounds_intersect, h3_cell_bounds, parse_bounds, parse_filters, row_passes_filters,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -111,7 +113,9 @@ fn build_feature_maps(
|
|||
|
||||
// Filter out cells that don't intersect the query bounds
|
||||
let (c_south, c_west, c_north, c_east) = h3_cell_bounds(cell, 0.0);
|
||||
if !bounds_intersect(c_south, c_west, c_north, c_east, q_south, q_west, q_north, q_east) {
|
||||
if !bounds_intersect(
|
||||
c_south, c_west, c_north, c_east, q_south, q_west, q_north, q_east,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -126,8 +130,7 @@ fn build_feature_maps(
|
|||
};
|
||||
|
||||
for feat_index in iter {
|
||||
if aggregation.mins[feat_index].is_finite()
|
||||
&& aggregation.maxs[feat_index].is_finite()
|
||||
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),
|
||||
|
|
|
|||
14
server-rs/src/routes/me.rs
Normal file
14
server-rs/src/routes/me.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Extension;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
|
||||
pub async fn get_me(Extension(user): Extension<OptionalUser>) -> impl IntoResponse {
|
||||
let body = match user.0 {
|
||||
Some(usr) => json!({ "user": usr }),
|
||||
None => json!({ "user": null }),
|
||||
};
|
||||
(StatusCode::OK, axum::Json(body))
|
||||
}
|
||||
90
server-rs/src/routes/pb_proxy.rs
Normal file
90
server-rs/src/routes/pb_proxy.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::Request;
|
||||
use axum::http::{HeaderName, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl IntoResponse {
|
||||
let pb_url = match &state.pocketbase_url {
|
||||
Some(url) => url.trim_end_matches('/'),
|
||||
None => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.body(Body::from("PocketBase not configured"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
let path = req.uri().path();
|
||||
let target_path = path.strip_prefix("/pb").unwrap_or(path);
|
||||
let query = req
|
||||
.uri()
|
||||
.query()
|
||||
.map(|qs| format!("?{qs}"))
|
||||
.unwrap_or_default();
|
||||
let url = format!("{pb_url}{target_path}{query}");
|
||||
|
||||
let method = req.method().clone();
|
||||
let mut builder = state.http_client.request(method, &url);
|
||||
|
||||
// Forward headers except host
|
||||
for (name, value) in req.headers() {
|
||||
if name != "host" {
|
||||
builder = builder.header(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Forward body
|
||||
let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
warn!("Failed to read request body: {err}");
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from("Failed to read request body"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
builder = builder.body(body_bytes);
|
||||
|
||||
match builder.send().await {
|
||||
Ok(upstream) => {
|
||||
let status = upstream.status();
|
||||
let mut response = Response::builder().status(status);
|
||||
|
||||
for (name, value) in upstream.headers() {
|
||||
// Skip hop-by-hop headers
|
||||
if name == "transfer-encoding" {
|
||||
continue;
|
||||
}
|
||||
response = response.header(
|
||||
HeaderName::from_bytes(name.as_ref())
|
||||
.unwrap_or(HeaderName::from_static("x-invalid")),
|
||||
value.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
match upstream.bytes().await {
|
||||
Ok(bytes) => response.body(Body::from(bytes)).unwrap(),
|
||||
Err(err) => {
|
||||
warn!("Failed to read upstream response: {err}");
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.body(Body::from("Failed to read upstream response"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase proxy error: {err}");
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.body(Body::from("PocketBase unavailable"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ use crate::state::AppState;
|
|||
|
||||
#[derive(Serialize)]
|
||||
pub struct PostcodesResponse {
|
||||
r#type: &'static str,
|
||||
features: Vec<Map<String, Value>>,
|
||||
}
|
||||
|
||||
|
|
@ -181,37 +182,88 @@ pub async fn get_postcodes(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Compute postcode polygon bounding box and check intersection with query bounds
|
||||
let vertices = &postcode_data.vertices[pc_idx];
|
||||
// Compute postcode polygon bounding box across ALL parts and check intersection
|
||||
let rings = &postcode_data.polygons[pc_idx];
|
||||
let (mut pc_south, mut pc_north) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
let (mut pc_west, mut pc_east) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for &[lon, lat] in vertices {
|
||||
let lon_f = lon as f64;
|
||||
let lat_f = lat as f64;
|
||||
if lat_f < pc_south { pc_south = lat_f; }
|
||||
if lat_f > pc_north { pc_north = lat_f; }
|
||||
if lon_f < pc_west { pc_west = lon_f; }
|
||||
if lon_f > pc_east { pc_east = lon_f; }
|
||||
for ring in rings {
|
||||
for &[lon, lat] in ring {
|
||||
let lon_f = lon as f64;
|
||||
let lat_f = lat as f64;
|
||||
if lat_f < pc_south {
|
||||
pc_south = lat_f;
|
||||
}
|
||||
if lat_f > pc_north {
|
||||
pc_north = lat_f;
|
||||
}
|
||||
if lon_f < pc_west {
|
||||
pc_west = lon_f;
|
||||
}
|
||||
if lon_f > pc_east {
|
||||
pc_east = lon_f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !bounds_intersect(pc_south, pc_west, pc_north, pc_east, south, west, north, east) {
|
||||
if !bounds_intersect(
|
||||
pc_south, pc_west, pc_north, pc_east, south, west, north, east,
|
||||
) {
|
||||
filtered_out += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut map = Map::new();
|
||||
map.insert(
|
||||
// Build GeoJSON geometry: Polygon (1 ring) or MultiPolygon (2+ rings)
|
||||
let geometry = if rings.len() == 1 {
|
||||
let coords: Vec<Value> = rings[0]
|
||||
.iter()
|
||||
.map(|[lon, lat]| {
|
||||
Value::Array(vec![Value::from(*lon as f64), Value::from(*lat as f64)])
|
||||
})
|
||||
.collect();
|
||||
let mut geo = Map::new();
|
||||
geo.insert("type".into(), Value::String("Polygon".into()));
|
||||
geo.insert(
|
||||
"coordinates".into(),
|
||||
Value::Array(vec![Value::Array(coords)]),
|
||||
);
|
||||
geo
|
||||
} else {
|
||||
let polys: Vec<Value> = rings
|
||||
.iter()
|
||||
.map(|ring| {
|
||||
let coords: Vec<Value> = ring
|
||||
.iter()
|
||||
.map(|[lon, lat]| {
|
||||
Value::Array(vec![
|
||||
Value::from(*lon as f64),
|
||||
Value::from(*lat as f64),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
Value::Array(vec![Value::Array(coords)])
|
||||
})
|
||||
.collect();
|
||||
let mut geo = Map::new();
|
||||
geo.insert("type".into(), Value::String("MultiPolygon".into()));
|
||||
geo.insert("coordinates".into(), Value::Array(polys));
|
||||
geo
|
||||
};
|
||||
|
||||
// Build properties
|
||||
let centroid = postcode_data.centroids[pc_idx];
|
||||
let mut props = Map::new();
|
||||
props.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> = vertices
|
||||
.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));
|
||||
props.insert("count".into(), Value::Number(aggregation.count.into()));
|
||||
props.insert(
|
||||
"centroid".into(),
|
||||
Value::Array(vec![
|
||||
Value::from(centroid.1 as f64), // lon
|
||||
Value::from(centroid.0 as f64), // lat
|
||||
]),
|
||||
);
|
||||
|
||||
let iter: Box<dyn Iterator<Item = usize>> = if let Some(idx) = field_indices.as_ref() {
|
||||
Box::new(idx.iter().copied())
|
||||
|
|
@ -227,13 +279,19 @@ pub async fn get_postcodes(
|
|||
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));
|
||||
props.insert(min_keys[feat_index].clone(), Value::Number(min_num));
|
||||
props.insert(max_keys[feat_index].clone(), Value::Number(max_num));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
features.push(map);
|
||||
// Build GeoJSON Feature
|
||||
let mut feature = Map::new();
|
||||
feature.insert("type".into(), Value::String("Feature".into()));
|
||||
feature.insert("geometry".into(), Value::Object(geometry));
|
||||
feature.insert("properties".into(), Value::Object(props));
|
||||
|
||||
features.push(feature);
|
||||
}
|
||||
|
||||
let t_total = t0.elapsed();
|
||||
|
|
@ -248,7 +306,10 @@ pub async fn get_postcodes(
|
|||
"GET /api/postcodes"
|
||||
);
|
||||
|
||||
Ok(PostcodesResponse { features })
|
||||
Ok(PostcodesResponse {
|
||||
r#type: "FeatureCollection",
|
||||
features,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
|
|
@ -257,20 +318,11 @@ pub async fn get_postcodes(
|
|||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PostcodeLookupResponse {
|
||||
pub postcode: String,
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
/// Polygon vertices as [[lon, lat], ...] for rendering highlight
|
||||
pub vertices: Vec<[f64; 2]>,
|
||||
}
|
||||
|
||||
/// Look up a single postcode and return its centroid coordinates and polygon.
|
||||
/// Look up a single postcode and return its centroid coordinates and geometry.
|
||||
pub async fn get_postcode_lookup(
|
||||
state: Arc<AppState>,
|
||||
Path(postcode): Path<String>,
|
||||
) -> Result<Json<PostcodeLookupResponse>, StatusCode> {
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
// Normalize the postcode: uppercase, remove extra spaces, ensure single space
|
||||
let normalized = postcode
|
||||
.to_uppercase()
|
||||
|
|
@ -282,17 +334,40 @@ pub async fn get_postcode_lookup(
|
|||
|
||||
if let Some(&idx) = postcode_data.postcode_to_idx.get(&normalized) {
|
||||
let (lat, lon) = postcode_data.centroids[idx];
|
||||
let vertices: Vec<[f64; 2]> = postcode_data.vertices[idx]
|
||||
.iter()
|
||||
.map(|[lo, la]| [*lo as f64, *la as f64])
|
||||
.collect();
|
||||
let rings = &postcode_data.polygons[idx];
|
||||
|
||||
// Build GeoJSON geometry
|
||||
let geometry = if rings.len() == 1 {
|
||||
let coords: Vec<Value> = rings[0]
|
||||
.iter()
|
||||
.map(|[lo, la]| {
|
||||
Value::Array(vec![Value::from(*lo as f64), Value::from(*la as f64)])
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "type": "Polygon", "coordinates": [coords] })
|
||||
} else {
|
||||
let polys: Vec<Value> = rings
|
||||
.iter()
|
||||
.map(|ring| {
|
||||
let coords: Vec<Value> = ring
|
||||
.iter()
|
||||
.map(|[lo, la]| {
|
||||
Value::Array(vec![Value::from(*lo as f64), Value::from(*la as f64)])
|
||||
})
|
||||
.collect();
|
||||
Value::Array(vec![Value::Array(coords)])
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "type": "MultiPolygon", "coordinates": polys })
|
||||
};
|
||||
|
||||
info!(postcode = %normalized, "GET /api/postcode/{postcode}");
|
||||
Ok(Json(PostcodeLookupResponse {
|
||||
postcode: normalized,
|
||||
latitude: lat as f64,
|
||||
longitude: lon as f64,
|
||||
vertices,
|
||||
}))
|
||||
Ok(Json(serde_json::json!({
|
||||
"postcode": normalized,
|
||||
"latitude": lat as f64,
|
||||
"longitude": lon as f64,
|
||||
"geometry": geometry,
|
||||
})))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue