Good changes

This commit is contained in:
Andras Schmelczer 2026-02-10 22:09:46 +00:00
parent d39d1b15fd
commit 1f68ca0512
23 changed files with 670 additions and 289 deletions

View file

@ -93,21 +93,38 @@ fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec<String> {
names
}
/// Build frontend-style query params for screenshot/dashboard URLs.
fn build_frontend_params(
center_lat: f64,
center_lon: f64,
zoom: f64,
filters_str: Option<&str>,
) -> String {
let mut parts = vec![
format!("lat={:.4}", center_lat),
format!("lon={:.4}", center_lon),
format!("zoom={:.1}", zoom),
];
if let Some(fs) = filters_str {
if !fs.is_empty() {
for entry in fs.split(',') {
if !entry.is_empty() {
parts.push(format!("filter={}", urlencoding::encode(entry.trim())));
}
}
}
}
parts.join("&")
}
/// Fetch a screenshot image from the screenshot service for Excel export.
async fn fetch_screenshot(
state: &AppState,
view_param: &str,
filters_str: Option<&str>,
frontend_params: &str,
) -> Option<Vec<u8>> {
let screenshot_base = &state.screenshot_url;
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?{}", screenshot_base, params.join("&"));
let url = format!("{}/screenshot?{}", screenshot_base, frontend_params);
match state.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
@ -147,7 +164,7 @@ pub async fn get_export(
let public_url = state.public_url.clone();
// Compute view param for screenshot and dashboard URL
// Compute view center for screenshot and dashboard URL
let center_lat = (south + north) / 2.0;
let center_lon = (west + east) / 2.0;
let lat_span = north - south;
@ -156,10 +173,11 @@ pub async fn get_export(
} else {
12.0
};
let view_param = format!("{:.4},{:.4},{:.1}", center_lat, center_lon, zoom);
let frontend_params =
build_frontend_params(center_lat, center_lon, zoom, filters_str.as_deref());
// Fetch screenshot (async, before spawn_blocking)
let screenshot_bytes = fetch_screenshot(&state, &view_param, filters_str.as_deref()).await;
let screenshot_bytes = fetch_screenshot(&state, &frontend_params).await;
// Build feature name → description map from the precomputed features response
let feature_descriptions: FxHashMap<String, String> = state
@ -273,9 +291,50 @@ pub async fn get_export(
ordered
};
// Build Excel workbook
// Filter-only feature indices for the Selected sheet
let filter_feature_indices: Vec<usize> = filter_feature_names
.iter()
.filter_map(|name| state.feature_name_to_index.get(name.as_str()).copied())
.collect();
// Build feature unit map (feat_idx → (prefix, suffix)) for number formatting
let feature_units: FxHashMap<usize, (&str, &str)> = state
.features_response
.groups
.iter()
.flat_map(|group| &group.features)
.filter_map(|feat| match feat {
FeatureInfo::Numeric {
name,
prefix,
suffix,
..
} => {
let idx = state.feature_name_to_index.get(name.as_str())?;
Some((*idx, (*prefix, *suffix)))
}
_ => None,
})
.collect();
// Build Excel number formats per feature index for unit display
let mut feat_num_fmts: FxHashMap<usize, Format> = FxHashMap::default();
for &feat_idx in &all_feature_indices {
if let Some(&(prefix, suffix)) = feature_units.get(&feat_idx) {
if prefix.is_empty() && suffix.is_empty() {
continue;
}
let num_fmt_str = if !prefix.is_empty() {
format!("\"{}\"#,##0", prefix)
} else {
format!("#,##0.0\"{}\"", suffix)
};
feat_num_fmts.insert(feat_idx, Format::new().set_num_format(&num_fmt_str));
}
}
// Build Excel workbook with two sheets
let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet();
// Formats
let header_fmt = Format::new()
@ -300,160 +359,184 @@ pub async fn get_export(
.set_font_color("#666666")
.set_align(FormatAlign::Left);
// Row 0: "View on Perfect Postcodes" 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("&"));
}
// Dashboard URL
let dashboard_url = format!("{}/?{}", public_url, frontend_params);
sheet
.write_url(0, 0, Url::new(&dashboard_url).set_text("View on Perfect Postcodes"))
.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}"))?;
// Sheet 1: "Selected" (filter features only) with link + screenshot
// Sheet 2: "All Data" (all features)
let sheet_configs: [(&str, &[usize], bool); 2] = [
("Selected", &filter_feature_indices, true),
("All Data", &all_feature_indices, false),
];
// Row 1: screenshot (if available)
let mut current_row = 1u32;
if let Some(ref img_bytes) = screenshot_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 screenshot: {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 screenshot for export: {err}");
// Skip image row, don't leave a gap
for (sheet_name, feat_indices, include_header) in &sheet_configs {
let sheet = workbook.add_worksheet();
sheet
.set_name(*sheet_name)
.map_err(|e| format!("Failed to set sheet name: {e}"))?;
let mut current_row = 0u32;
if *include_header {
// URL row
sheet
.write_url(
0,
0,
Url::new(&dashboard_url).set_text("View on Perfect Postcode"),
)
.map_err(|e| format!("Failed to write URL: {e}"))?;
sheet
.set_row_format(0, &link_fmt)
.map_err(|e| format!("Failed to set row format: {e}"))?;
current_row = 1;
// Screenshot
if let Some(ref img_bytes) = screenshot_bytes {
match Image::new_from_buffer(img_bytes) {
Ok(mut image) => {
image = image.set_scale_to_size(400, 300, true);
sheet
.insert_image(current_row, 0, &image)
.map_err(|e| format!("Failed to insert screenshot: {e}"))?;
sheet
.set_row_height(current_row, IMAGE_ROW_HEIGHT)
.map_err(|e| format!("Failed to set image row height: {e}"))?;
current_row += 1;
}
Err(err) => {
warn!("Failed to parse screenshot for export: {err}");
}
}
}
// Blank row between image and header
current_row += 1;
}
}
// 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;
// Header row
let header_row = current_row;
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("");
.write_string_with_format(header_row, 0, "Postcode", &header_fmt)
.map_err(|e| format!("Failed to write header: {e}"))?;
sheet
.write_string_with_format(desc_row, col, desc, &desc_fmt)
.map_err(|err| format!("Failed to write desc: {err}"))?;
}
.write_string_with_format(header_row, 1, "Properties", &header_fmt)
.map_err(|e| format!("Failed to write header: {e}"))?;
// 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() {
for (col_offset, &feat_idx) in feat_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(|e| format!("Failed to write header: {e}"))?;
}
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}"),
)?;
// Description row
let desc_row = header_row + 1;
sheet
.write_string_with_format(desc_row, 0, "", &desc_fmt)
.map_err(|e| format!("Failed to write desc: {e}"))?;
sheet
.write_string_with_format(desc_row, 1, "Count of properties", &desc_fmt)
.map_err(|e| format!("Failed to write desc: {e}"))?;
for (col_offset, &feat_idx) in feat_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(|e| format!("Failed to write desc: {e}"))?;
}
// Data rows
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(|e| format!("Failed to write postcode: {e}"))?;
sheet
.write_number(row, 1, agg.count as f64)
.map_err(|e| format!("Failed to write count: {e}"))?;
for (col_offset, &feat_idx) in feat_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(|e| {
format!("Failed to write enum value: {e}")
})?;
}
}
}
}
}
} 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}"))?;
} else {
let fc = agg.finite_counts[feat_idx];
if fc > 0 {
let mean = (agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0;
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
sheet
.write_number_with_format(row, col, mean, fmt)
.map_err(|e| {
format!("Failed to write numeric value: {e}")
})?;
} else {
sheet.write_number(row, col, mean).map_err(|e| {
format!("Failed to write numeric value: {e}")
})?;
}
}
}
}
}
}
// 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
),
&note_fmt,
)
.map_err(|err| format!("Failed to write note: {err}"))?;
}
// Sample note
if was_sampled {
let note_row = data_start_row + postcode_aggs.len() as u32 + 1;
let total_cols = (feat_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
),
&note_fmt,
)
.map_err(|e| format!("Failed to write note: {e}"))?;
}
// 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();
// Column widths
sheet.set_column_width(0, 12).ok();
sheet.set_column_width(1, 12).ok();
for col_offset in 0..feat_indices.len() {
let col = (col_offset + 2) as u16;
let feat_name = &feature_names[feat_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
@ -485,7 +568,7 @@ pub async fn get_export(
),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"perfect-postcodes-export.xlsx\"",
"attachment; filename=\"perfect-postcode-export.xlsx\"",
),
],
bytes,

View file

@ -1,53 +1,19 @@
use std::sync::Arc;
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::http::{header, StatusCode, Uri};
use axum::response::IntoResponse;
use tracing::{info, warn};
use crate::state::AppState;
#[derive(serde::Deserialize)]
pub struct ScreenshotQuery {
#[serde(rename = "v")]
view: Option<String>,
#[serde(rename = "f")]
filters: Option<String>,
poi: Option<String>,
tab: Option<String>,
/// When "1", renders the OG heading overlay on the screenshot
og: Option<String>,
}
pub async fn get_screenshot(
state: Arc<AppState>,
Query(query): Query<ScreenshotQuery>,
) -> impl IntoResponse {
pub async fn get_screenshot(state: Arc<AppState>, uri: Uri) -> impl IntoResponse {
let screenshot_base = &state.screenshot_url;
let mut params = Vec::new();
if query.og.as_deref() == Some("1") {
params.push("og=1".to_string());
}
if let Some(ref val) = query.view {
params.push(format!("v={}", urlencoding::encode(val)));
}
if let Some(ref val) = query.filters {
params.push(format!("f={}", urlencoding::encode(val)));
}
if let Some(ref val) = query.poi {
params.push(format!("poi={}", urlencoding::encode(val)));
}
if let Some(ref val) = query.tab {
params.push(format!("tab={}", urlencoding::encode(val)));
}
let qs = if params.is_empty() {
String::new()
} else {
format!("?{}", params.join("&"))
};
let url = format!("{}/screenshot{}", screenshot_base, qs);
let qs = uri
.query()
.map(|q| format!("?{q}"))
.unwrap_or_default();
let url = format!("{screenshot_base}/screenshot{qs}");
info!("Proxying screenshot request to: {}", url);
match state.http_client.get(&url).send().await {