new demo mode & tenure
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m43s
CI / Check (push) Failing after 8m49s

This commit is contained in:
Andras Schmelczer 2026-06-17 07:54:30 +01:00
parent 7656f24544
commit 4a0f00f2a4
64 changed files with 2875 additions and 338 deletions

View file

@ -14,6 +14,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::travel_time::TravelData;
use crate::data::{PostcodePoiMetrics, QuantRef};
use crate::features;
use crate::licensing::{check_license_bounds, resolve_share_code};
@ -369,15 +370,98 @@ fn bounds_for_postcode_indices(
(south, west, north, east)
}
/// A travel-time column derived from the active `travel` / `tt` query params.
/// Each active travel destination becomes one column showing the per-postcode
/// median (or best-case) travel time in minutes.
struct TravelColumn {
/// Human-readable destination name for the header (e.g. "Bank tube station").
header: String,
/// Description-row text (e.g. "Travel time by public transport (minutes)").
description: String,
/// Postcode → travel time data for this destination.
data: TravelData,
/// Whether to report the best-case (5th percentile) time instead of median.
use_best: bool,
}
/// Parse the repeated `tt` frontend-state params into a (mode, slug) → label map.
/// Each value looks like `mode:slug:EncodedLabel[:b][:min:max]`, where the label
/// was percent-encoded with the frontend's `encodeURIComponent`.
fn parse_travel_labels(tt_params: &[String]) -> FxHashMap<(String, String), String> {
let mut map = FxHashMap::default();
for raw in tt_params {
// splitn(4) keeps the label as a single piece; it never contains a raw
// ':' because encodeURIComponent escapes it to %3A.
let mut parts = raw.splitn(4, ':');
let (Some(mode), Some(slug), Some(encoded_label)) =
(parts.next(), parts.next(), parts.next())
else {
continue;
};
let label = urlencoding::decode(encoded_label)
.map(|cow| cow.into_owned())
.unwrap_or_else(|_| encoded_label.to_string());
if !label.is_empty() {
map.insert((mode.to_string(), slug.to_string()), label);
}
}
map
}
/// Turn a destination slug into a display name when no label is supplied.
/// "bank-tube-station" → "Bank tube station".
fn humanize_slug(slug: &str) -> String {
let mut s = slug.replace('-', " ");
if let Some(first) = s.get_mut(0..1) {
first.make_ascii_uppercase();
}
s
}
/// Friendly name for a travel mode (incl. transit variants) for the desc row.
fn pretty_travel_mode(mode: &str) -> &'static str {
match mode {
"car" => "car",
"bicycle" => "bike",
"walking" => "walking",
m if m.starts_with("transit") => "public transport",
_ => "travel",
}
}
/// The reported travel time (median, or best-case when `use_best`) for a
/// postcode, or None when the destination has no data for it.
#[inline]
fn travel_minutes_for(data: &TravelData, postcode: &str, use_best: bool) -> Option<i16> {
data.get(postcode).map(|row| {
if use_best {
row.best_minutes.unwrap_or(row.minutes)
} else {
row.minutes
}
})
}
pub async fn get_export(
State(shared): State<Arc<SharedState>>,
headers: HeaderMap,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
uri: Uri,
Query(params): Query<ExportParams>,
) -> Result<impl IntoResponse, axum::response::Response> {
let state = shared.load_state();
// Exporting requires an account — no anonymous/demo exports.
if user.0.is_none() {
return Err((
StatusCode::UNAUTHORIZED,
[(header::CONTENT_TYPE, "application/json")],
r#"{"error":"account_required","message":"Sign in to export data"}"#,
)
.into_response());
}
// Two modes: bounds-based (default) and explicit postcode list.
let postcode_list = match params.postcodes.as_deref() {
Some(raw) if !raw.trim().is_empty() => {
@ -418,7 +502,7 @@ pub async fn get_export(
}
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
check_license_bounds(&user.0, (south, west, north, east), geo.free_zone, share_bounds)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
@ -450,20 +534,17 @@ pub async fn get_export(
} else {
params.filters
};
let travel_entries = if is_postcode_mode {
Vec::new()
} else {
parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
};
let has_travel_filters = travel_entries
.iter()
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
let travel_state_params = if is_postcode_mode {
Vec::new()
} else {
collect_travel_state_params(uri.query())
};
// Travel times are per-postcode global data, so they appear as columns in
// both bounds and list mode. The time-range *filter* is only applied on the
// bounds path (`has_travel_filters` below); list mode never filters rows, so
// every requested postcode still shows up with its travel time.
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let has_travel_filters = !is_postcode_mode
&& travel_entries
.iter()
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
let travel_state_params = collect_travel_state_params(uri.query());
let overlay_state_params = if is_postcode_mode {
Vec::new()
} else {
@ -553,6 +634,33 @@ pub async fn get_export(
let postcode_data = &state.postcode_data;
let poi_metrics = &state.data.poi_metrics;
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
// One column per active travel destination. `travel_data` is built by
// mapping over `travel_entries` in order, so the two are index-aligned.
// Display labels come from the `tt` frontend-state params.
let travel_labels = parse_travel_labels(&travel_state_params);
let travel_columns: Vec<TravelColumn> = travel_entries
.iter()
.zip(travel_data.iter())
.map(|(entry, data)| {
let header = travel_labels
.get(&(entry.mode.clone(), entry.slug.clone()))
.cloned()
.unwrap_or_else(|| humanize_slug(&entry.slug));
let description = format!(
"Travel time by {}{} (minutes)",
pretty_travel_mode(&entry.mode),
if entry.use_best { ", best case" } else { "" }
);
TravelColumn {
header,
description,
data: Arc::clone(data),
use_best: entry.use_best,
}
})
.collect();
let poi_offset = num_features;
let total_export_features = num_features + poi_metrics.num_features();
let (pc_interner, pc_keys) = state.data.postcode_parts();
@ -701,6 +809,15 @@ pub async fn get_export(
ordered
};
// The "All Data" sheet lists property/area features only. POI amenity
// counts & distances (virtual indices >= poi_offset) are reserved for the
// "Selected" sheet, where they show up when the user filters/selects them.
let all_data_feature_indices: Vec<usize> = all_feature_indices
.iter()
.copied()
.filter(|&idx| idx < poi_offset)
.collect();
// Filter-only feature indices for the Selected sheet
let filter_feature_indices: Vec<usize> = filter_feature_names
.iter()
@ -839,6 +956,12 @@ pub async fn get_export(
let group_label_fmt = Format::new().set_bold().set_font_color("#1F4E79");
let group_count_fmt = Format::new().set_bold();
// Travel-time cells render the integer minute count with a " min" suffix.
let travel_num_fmt = Format::new().set_num_format("0\" min\"");
// Postcode-centroid coordinates (WGS84), ~1 m precision at 5 decimals.
let coord_num_fmt = Format::new().set_num_format("0.00000");
// Dashboard URL
let dashboard_url = format!(
"{}/dashboard?{}",
@ -847,14 +970,16 @@ pub async fn get_export(
);
// Two sheets in both modes: "Selected" (just the features behind the
// active filters) and "All Data" (every feature). The Selected sheet
// carries the dashboard link + screenshot only in bounds mode, where the
// export is tied to a map view; in list mode the user picked the
// postcodes explicitly, so there's no map view to link or screenshot.
// active filters, including any POI amenity metrics) and "All Data"
// (every property/area feature, but no POI amenity counts/distances —
// those live only on the Selected sheet). The Selected sheet carries the
// dashboard link + screenshot only in bounds mode, where the export is
// tied to a map view; in list mode the user picked the postcodes
// explicitly, so there's no map view to link or screenshot.
let is_list_mode = postcode_list_entries.is_some();
let sheet_configs: Vec<(&str, &[usize], bool)> = vec![
("Selected", &filter_feature_indices, !is_list_mode),
("All Data", &all_feature_indices, false),
("All Data", &all_data_feature_indices, false),
];
for (sheet_name, feat_indices, include_header) in &sheet_configs {
@ -904,6 +1029,10 @@ pub async fn get_export(
// Header row
let header_row = current_row;
// Travel-time columns sit to the right of the feature columns, with
// the Latitude/Longitude pair last.
let travel_col_base = (feat_indices.len() + 2) as u16;
let coord_col_base = travel_col_base + travel_columns.len() as u16;
sheet
.write_string_with_format(header_row, 0, "Postcode", &header_fmt)
.map_err(|e| format!("Failed to write header: {e}"))?;
@ -923,6 +1052,24 @@ pub async fn get_export(
.map_err(|e| format!("Failed to write header: {e}"))?;
}
for (i, tc) in travel_columns.iter().enumerate() {
sheet
.write_string_with_format(
header_row,
travel_col_base + i as u16,
&tc.header,
&header_fmt,
)
.map_err(|e| format!("Failed to write travel header: {e}"))?;
}
sheet
.write_string_with_format(header_row, coord_col_base, "Latitude", &header_fmt)
.map_err(|e| format!("Failed to write header: {e}"))?;
sheet
.write_string_with_format(header_row, coord_col_base + 1, "Longitude", &header_fmt)
.map_err(|e| format!("Failed to write header: {e}"))?;
// Description row
let desc_row = header_row + 1;
sheet
@ -943,6 +1090,29 @@ pub async fn get_export(
.map_err(|e| format!("Failed to write desc: {e}"))?;
}
for (i, tc) in travel_columns.iter().enumerate() {
sheet
.write_string_with_format(
desc_row,
travel_col_base + i as u16,
&tc.description,
&desc_fmt,
)
.map_err(|e| format!("Failed to write travel desc: {e}"))?;
}
sheet
.write_string_with_format(desc_row, coord_col_base, "Postcode centroid", &desc_fmt)
.map_err(|e| format!("Failed to write desc: {e}"))?;
sheet
.write_string_with_format(
desc_row,
coord_col_base + 1,
"Postcode centroid",
&desc_fmt,
)
.map_err(|e| format!("Failed to write desc: {e}"))?;
// Put the collapse/expand controls above each group so the bold
// outcode summary row acts as the header for its postcodes.
sheet.group_symbols_above(true);
@ -976,6 +1146,63 @@ pub async fn get_export(
&integer_feature_indices,
&feat_num_fmts,
)?;
// Travel time rolled up across the group's postcodes, weighted by
// property count to match the property-weighted feature means.
for (i, tc) in travel_columns.iter().enumerate() {
let mut weighted_sum = 0.0_f64;
let mut weight = 0u32;
for &member in &group.members {
let (member_pc_idx, member_agg) = &postcode_aggs[member];
let pc = &postcode_data.postcodes[*member_pc_idx];
if let Some(minutes) = travel_minutes_for(&tc.data, pc, tc.use_best) {
weighted_sum += minutes as f64 * member_agg.count as f64;
weight += member_agg.count;
}
}
if weight > 0 {
let mean = (weighted_sum / weight as f64).round();
sheet
.write_number_with_format(
summary_row,
travel_col_base + i as u16,
mean,
&travel_num_fmt,
)
.map_err(|e| format!("Failed to write travel summary: {e}"))?;
}
}
// Property-weighted centroid of the group's postcodes.
{
let mut lat_sum = 0.0_f64;
let mut lon_sum = 0.0_f64;
let mut weight = 0u32;
for &member in &group.members {
let (member_pc_idx, member_agg) = &postcode_aggs[member];
let (lat, lon) = postcode_data.centroids[*member_pc_idx];
lat_sum += lat as f64 * member_agg.count as f64;
lon_sum += lon as f64 * member_agg.count as f64;
weight += member_agg.count;
}
if weight > 0 {
let w = weight as f64;
sheet
.write_number_with_format(
summary_row,
coord_col_base,
lat_sum / w,
&coord_num_fmt,
)
.map_err(|e| format!("Failed to write latitude: {e}"))?;
sheet
.write_number_with_format(
summary_row,
coord_col_base + 1,
lon_sum / w,
&coord_num_fmt,
)
.map_err(|e| format!("Failed to write longitude: {e}"))?;
}
}
row += 1;
// Individual postcode rows for this outcode.
@ -999,6 +1226,34 @@ pub async fn get_export(
&integer_feature_indices,
&feat_num_fmts,
)?;
for (i, tc) in travel_columns.iter().enumerate() {
if let Some(minutes) = travel_minutes_for(
&tc.data,
&postcode_data.postcodes[*pc_idx],
tc.use_best,
) {
sheet
.write_number_with_format(
row,
travel_col_base + i as u16,
minutes as f64,
&travel_num_fmt,
)
.map_err(|e| format!("Failed to write travel time: {e}"))?;
}
}
let (lat, lon) = postcode_data.centroids[*pc_idx];
sheet
.write_number_with_format(row, coord_col_base, lat as f64, &coord_num_fmt)
.map_err(|e| format!("Failed to write latitude: {e}"))?;
sheet
.write_number_with_format(
row,
coord_col_base + 1,
lon as f64,
&coord_num_fmt,
)
.map_err(|e| format!("Failed to write longitude: {e}"))?;
row += 1;
}
@ -1012,7 +1267,8 @@ pub async fn get_export(
// Sample note
if was_sampled {
let note_row = row + 1;
let total_cols = (feat_indices.len() + 2) as u16;
// +2 for the Latitude/Longitude columns.
let total_cols = (feat_indices.len() + 2 + travel_columns.len() + 2) as u16;
sheet
.merge_range(
note_row,
@ -1043,6 +1299,18 @@ pub async fn get_export(
.set_column_width(col, width)
.map_err(|e| format!("Failed to set column width: {e}"))?;
}
for (i, tc) in travel_columns.iter().enumerate() {
let width = (tc.header.chars().count() as f64 * 1.1).clamp(10.0, 30.0);
sheet
.set_column_width(travel_col_base + i as u16, width)
.map_err(|e| format!("Failed to set travel column width: {e}"))?;
}
sheet
.set_column_width(coord_col_base, 11)
.map_err(|e| format!("Failed to set coordinate column width: {e}"))?;
sheet
.set_column_width(coord_col_base + 1, 11)
.map_err(|e| format!("Failed to set coordinate column width: {e}"))?;
}
let buf = workbook
@ -1128,6 +1396,50 @@ mod tests {
assert_eq!(outcode_of("E14"), "E14");
}
#[test]
fn parse_travel_labels_decodes_label_keyed_by_mode_and_slug() {
// As produced by collect_travel_state_params (outer URL layer already
// decoded); the label is still encodeURIComponent-encoded.
let params = vec![
"transit:bank-tube-station:Bank%20tube%20station:0:52".to_string(),
"car:heathrow-airport:Heathrow%20Airport:b:0:90".to_string(),
];
let labels = parse_travel_labels(&params);
assert_eq!(
labels.get(&("transit".to_string(), "bank-tube-station".to_string())),
Some(&"Bank tube station".to_string())
);
assert_eq!(
labels.get(&("car".to_string(), "heathrow-airport".to_string())),
Some(&"Heathrow Airport".to_string())
);
}
#[test]
fn parse_travel_labels_skips_entries_without_a_label() {
// Empty label (encodeURIComponent("")) and a too-short value are ignored.
let params = vec![
"transit:bank-tube-station::b:0:52".to_string(),
"car:somewhere".to_string(),
];
assert!(parse_travel_labels(&params).is_empty());
}
#[test]
fn humanize_slug_falls_back_to_a_readable_name() {
assert_eq!(humanize_slug("bank-tube-station"), "Bank tube station");
assert_eq!(humanize_slug("london"), "London");
}
#[test]
fn pretty_travel_mode_maps_transit_variants() {
assert_eq!(pretty_travel_mode("transit"), "public transport");
assert_eq!(pretty_travel_mode("transit-no-buses"), "public transport");
assert_eq!(pretty_travel_mode("car"), "car");
assert_eq!(pretty_travel_mode("bicycle"), "bike");
assert_eq!(pretty_travel_mode("walking"), "walking");
}
#[test]
fn export_query_deserializes_when_tt_is_a_single_string() {
let uri: Uri = "/api/export?bounds=1,2,3,4&tt=transit%3Abank%3ABank%2520station%3A0%3A52"