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

1
server-rs/Cargo.lock generated
View file

@ -2374,6 +2374,7 @@ dependencies = [
"parking_lot",
"pmtiles",
"polars",
"rand 0.9.2",
"rayon",
"reqwest",
"rust_xlsxwriter",

View file

@ -25,6 +25,7 @@ reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
urlencoding = "2"
rust_xlsxwriter = "0.79"
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
rand = "0.9"
[lints.clippy]
min_ident_chars = "warn"

View file

@ -18,7 +18,7 @@ pub struct FeatureConfig {
pub description: &'static str,
/// Longer description explaining methodology, data source, and caveats
pub detail: &'static str,
/// Data source slug for linking to /data-sources#<slug>
/// Data source slug for linking to /learn#<slug>
pub source: &'static str,
/// Display prefix (e.g. "£")
pub prefix: &'static str,
@ -46,7 +46,7 @@ pub struct EnumFeatureConfig {
pub description: &'static str,
/// Longer description explaining methodology, data source, and caveats
pub detail: &'static str,
/// Data source slug for linking to /data-sources#<slug>
/// Data source slug for linking to /learn#<slug>
pub source: &'static str,
}
@ -114,6 +114,20 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
},
FeatureConfig {
name: "Est. price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 100.0,
description: "Estimated current price divided by total floor area",
detail: "Calculated by dividing the inflation-adjusted estimated current price by the total floor area from the EPC certificate. Provides a more up-to-date price-per-area comparison than the historical sale price per sqm.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
},
FeatureConfig {
name: "Total floor area (sqm)",
bounds: Bounds::Percentile {
@ -257,8 +271,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
high: 98.0,
},
step: 0.1,
description: "IoD education deprivation score for the local area",
detail: "From the English Indices of Deprivation. Measures deprivation in education, skills and training in the local area (LSOA). Higher scores indicate greater deprivation. Combines children/young people sub-domain (school attainment, entry to higher education) and adult skills sub-domain (adult qualifications, English language proficiency).",
description: "IoD education score for the local area (higher = better)",
detail: "From the English Indices of Deprivation (inverted so higher = better). Measures education, skills and training quality in the local area (LSOA). Higher scores indicate less deprivation. Combines children/young people sub-domain (school attainment, entry to higher education) and adult skills sub-domain (adult qualifications, English language proficiency).",
source: "iod",
prefix: "",
suffix: "",
@ -301,8 +315,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
name: "Income Score (rate)",
bounds: Bounds::Fixed { min: 0.0, max: 0.6 },
step: 0.01,
description: "Proportion of the population experiencing income deprivation",
detail: "From the English Indices of Deprivation. The proportion of the local population experiencing deprivation relating to low income. Includes people on Income Support, income-based Jobseeker's Allowance, income-based Employment and Support Allowance, Pension Credit, Working Tax Credit and Child Tax Credit, Universal Credit, and asylum seekers.",
description: "Income deprivation rate, inverted (higher = less deprived)",
detail: "From the English Indices of Deprivation (inverted so higher = better). Higher values indicate less income deprivation. Based on Income Support, income-based Jobseeker's Allowance, income-based Employment and Support Allowance, Pension Credit, Working Tax Credit and Child Tax Credit, Universal Credit, and asylum seekers.",
source: "iod",
prefix: "",
suffix: "",
@ -312,8 +326,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
name: "Employment Score (rate)",
bounds: Bounds::Fixed { min: 0.0, max: 0.4 },
step: 0.01,
description: "Proportion of the working-age population involuntarily excluded from work",
detail: "From the English Indices of Deprivation. The proportion of the working-age population involuntarily excluded from the labour market. Includes claimants of Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance, and relevant Universal Credit claimants.",
description: "Employment deprivation rate, inverted (higher = less deprived)",
detail: "From the English Indices of Deprivation (inverted so higher = better). Higher values indicate less employment deprivation. Based on claimants of Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance, and relevant Universal Credit claimants.",
source: "iod",
prefix: "",
suffix: "",
@ -326,8 +340,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
high: 98.0,
},
step: 0.1,
description: "Risk of premature death and quality of life impairment",
detail: "From the English Indices of Deprivation. Measures the risk of premature death and impairment of quality of life through poor physical or mental health. Derived from years of potential life lost, comparative illness and disability ratio, acute morbidity, and mood and anxiety disorders.",
description: "Health and disability score (higher = better health outcomes)",
detail: "From the English Indices of Deprivation (inverted so higher = better). Higher scores indicate lower risk of premature death and better quality of life. Derived from years of potential life lost, comparative illness and disability ratio, acute morbidity, and mood and anxiety disorders.",
source: "iod",
prefix: "",
suffix: "",
@ -340,8 +354,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
high: 98.0,
},
step: 0.1,
description: "Quality of the local indoor and outdoor environment",
detail: "From the English Indices of Deprivation. Measures deprivation in the quality of the local environment. Combines the Indoors sub-domain (housing quality, central heating, housing conditions) and Outdoors sub-domain (air quality, road traffic accidents). Higher scores indicate poorer living environments.",
description: "Quality of the local indoor and outdoor environment (higher = better)",
detail: "From the English Indices of Deprivation (inverted so higher = better). Measures the quality of the local environment. Combines the Indoors sub-domain (housing quality, central heating, housing conditions) and Outdoors sub-domain (air quality, road traffic accidents). Higher scores indicate better living environments.",
source: "iod",
prefix: "",
suffix: "",
@ -354,8 +368,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
high: 98.0,
},
step: 0.1,
description: "Housing quality and conditions in the local area",
detail: "From the English Indices of Deprivation, Living Environment domain. Measures the quality of housing stock: houses without central heating, housing in poor condition, and houses failing Decent Homes standards. Higher scores indicate worse housing conditions.",
description: "Housing quality and conditions (higher = better)",
detail: "From the English Indices of Deprivation, Living Environment domain (inverted so higher = better). Measures the quality of housing stock: central heating availability, housing condition, and Decent Homes standards. Higher scores indicate better housing conditions.",
source: "iod",
prefix: "",
suffix: "",
@ -368,8 +382,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
high: 98.0,
},
step: 0.1,
description: "Air quality and road safety in the local area",
detail: "From the English Indices of Deprivation, Living Environment domain. Measures the outdoor living environment quality through air quality indicators and road traffic accident casualties involving pedestrians and cyclists. Higher scores indicate poorer outdoor environments.",
description: "Air quality and road safety (higher = better)",
detail: "From the English Indices of Deprivation, Living Environment domain (inverted so higher = better). Measures the outdoor living environment quality through air quality indicators and road traffic accident casualties involving pedestrians and cyclists. Higher scores indicate better outdoor environments.",
source: "iod",
prefix: "",
suffix: "",

View file

@ -8,7 +8,7 @@ use axum::response::Response;
use crate::state::AppState;
const OG_PLACEHOLDER: &str = r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__"/>"#;
const OG_PLACEHOLDER: &str = r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#;
pub async fn og_middleware(request: Request, next: Next) -> Response {
// Capture the query string before passing the request through
@ -47,14 +47,14 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
};
let og_tags = format!(
r#"<meta property="og:title" content="Perfect Postcodes Every neighbourhood in England & Wales" />
r#"<meta property="og:title" content="Perfect Postcode Every neighbourhood in England & Wales" />
<meta property="og:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England & Wales on one interactive map." />
<meta property="og:type" content="website" />
<meta property="og:image" content="{og_image_url}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Perfect Postcodes — Every neighbourhood in England & Wales" />
<meta name="twitter:title" content="Perfect Postcode — Every neighbourhood in England & Wales" />
<meta name="twitter:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England & Wales on one interactive map." />"#
);

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 {