new demo mode & tenure
This commit is contained in:
parent
7656f24544
commit
4a0f00f2a4
64 changed files with 2875 additions and 338 deletions
|
|
@ -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(¶ms);
|
||||
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(¶ms).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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue