Good changes
This commit is contained in:
parent
d39d1b15fd
commit
1f68ca0512
23 changed files with 670 additions and 289 deletions
|
|
@ -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
|
||||
),
|
||||
¬e_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
|
||||
),
|
||||
¬e_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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue