diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 58d994b..e413ed5 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -84,10 +84,16 @@ export default function AreaPane({ } return ( -
-
-
-
+
+
+ + + +
+ +
+
+

{isPostcode ? hexagonId : 'Area Statistics'} @@ -100,35 +106,29 @@ export default function AreaPane({
)}
- - - -

- {propertyCount != null && ( -

- {propertyCount.toLocaleString()} properties + {propertyCount != null && ( +

+ {propertyCount.toLocaleString()} properties +

+ )} +

+ Stats for {isPostcode ? 'current and historical' : 'all'} properties + in this {isPostcode ? 'postcode' : 'area'} + {Object.keys(filters).length > 0 ? ' matching all active filters' : ''}

- )} -

- Stats for {isPostcode ? 'current and historical' : 'all'} properties - in this {isPostcode ? 'postcode' : 'area'} - {Object.keys(filters).length > 0 ? ' matching all active filters' : ''} -

- {stats && stats.count > 0 && ( - - )} -
+ {stats && stats.count > 0 && ( + + )} +
- {hexagonLocation && stats && ( - - )} - -
+ {hexagonLocation && stats && ( + + )} {loading && !stats ? ( ) : stats ? ( diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 674afe0..60a51ee 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -296,10 +296,15 @@ export default function MapPage({ }, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]); useEffect(() => { - if (screenshotMode && !mapData.loading && mapData.data.length > 0) { - window.__screenshot_ready = true; + if (screenshotMode && !mapData.loading) { + const hasData = mapData.usePostcodeView + ? mapData.postcodeData.length > 0 + : mapData.data.length > 0; + if (hasData) { + window.__screenshot_ready = true; + } } - }, [screenshotMode, mapData.loading, mapData.data.length]); + }, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]); if (screenshotMode) { return ( @@ -519,6 +524,14 @@ export default function MapPage({ onClose={() => setMobileDrawerOpen(false)} renderArea={renderAreaPane} renderProperties={renderPropertiesPane} + tab={selection.rightPaneTab} + onTabChange={(t) => { + if (t === 'properties') { + selection.handlePropertiesTabClick(); + } else { + selection.setRightPaneTab(t); + } + }} /> )} diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index ee25c8e..4d63bdc 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -50,7 +50,7 @@ export function PropertiesPane({ } return ( -
+
{showInfo && ( )} -
+
-
+
{loading && properties.length === 0 ? ( ) : ( diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 81df15a..8bdc20a 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -1,9 +1,12 @@ -import { useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { Slider } from '../ui/Slider'; import { IconButton } from '../ui/IconButton'; +import { PillToggle } from '../ui/PillToggle'; import { PlaceSearchInput } from '../ui/PlaceSearchInput'; +import InfoPopup from '../ui/InfoPopup'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { EyeIcon } from '../ui/icons/EyeIcon'; +import { InfoIcon } from '../ui/icons/InfoIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon'; import { CarIcon } from '../ui/icons/CarIcon'; import { BicycleIcon } from '../ui/icons/BicycleIcon'; @@ -50,6 +53,7 @@ export function TravelTimeCard({ }: TravelTimeCardProps) { const search = useLocationSearch(mode); const containerRef = useRef(null); + const [showBestInfo, setShowBestInfo] = useState(false); // Close dropdown on outside click useEffect(() => { @@ -100,43 +104,52 @@ export function TravelTimeCard({
- {/* Destination search */} -
- - - {slug && label && ( -
- - - {label} - -
- )} -
+ {/* Destination */} + {slug && label ? ( +
+ + + {label} + + +
+ ) : ( +
+ +
+ )} {/* Best-case toggle — transit only, shown when destination is set */} {slug && mode === 'transit' && ( - +
+ + setShowBestInfo(true)} title="What is best case?"> + + +
+ )} + + {showBestInfo && ( + setShowBestInfo(false)}> +

+ Uses the 5th percentile travel time — the fastest realistic journey + if you time your departure to catch optimal connections. The default uses the{' '} + median, representing a typical journey regardless of when you leave. +

+
)} {/* Time range slider — only show when we have data */} diff --git a/frontend/src/components/ui/MobileMenu.tsx b/frontend/src/components/ui/MobileMenu.tsx index 313e790..42652dd 100644 --- a/frontend/src/components/ui/MobileMenu.tsx +++ b/frontend/src/components/ui/MobileMenu.tsx @@ -145,14 +145,14 @@ export default function MobileMenu({ {/* Auth buttons */}
{user ? ( -
+
{user.email} diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts index 09f4797..8f8cc02 100644 --- a/frontend/src/hooks/useTravelTime.ts +++ b/frontend/src/hooks/useTravelTime.ts @@ -69,7 +69,7 @@ export function useTravelTime(initial?: TravelTimeInitial) { (index: number) => { setEntries((prev) => prev.map((entry, i) => - i === index ? { ...entry, useBest: !entry.useBest, timeRange: null } : entry + i === index ? { ...entry, useBest: !entry.useBest } : entry ) ); }, diff --git a/server-rs/src/routes.rs b/server-rs/src/routes.rs index f04e226..4edab63 100644 --- a/server-rs/src/routes.rs +++ b/server-rs/src/routes.rs @@ -9,6 +9,7 @@ mod me; mod pb_proxy; mod places; mod pois; +mod postcode_properties; mod postcode_stats; mod postcodes; pub(crate) mod properties; @@ -35,6 +36,7 @@ pub use me::get_me; pub use pb_proxy::proxy_to_pocketbase; pub use places::get_places; pub use pois::{get_poi_categories, get_pois}; +pub use postcode_properties::get_postcode_properties; pub use postcode_stats::get_postcode_stats; pub use postcodes::{get_postcode_lookup, get_postcodes}; pub use properties::get_hexagon_properties; diff --git a/server-rs/src/routes/pb_proxy.rs b/server-rs/src/routes/pb_proxy.rs index 06fc2db..4655d8d 100644 --- a/server-rs/src/routes/pb_proxy.rs +++ b/server-rs/src/routes/pb_proxy.rs @@ -17,6 +17,7 @@ static PROXY_CLIENT: LazyLock = LazyLock::new(|| { reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_secs(5)) + .referer(false) .build() .expect("Failed to build proxy HTTP client") }); diff --git a/server-rs/src/routes/postcode_properties.rs b/server-rs/src/routes/postcode_properties.rs new file mode 100644 index 0000000..75f8785 --- /dev/null +++ b/server-rs/src/routes/postcode_properties.rs @@ -0,0 +1,144 @@ +use std::sync::Arc; + +use axum::extract::Query; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Json}; +use axum::Extension; +use serde::Deserialize; +use tracing::{info, warn}; + +use crate::auth::OptionalUser; +use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET}; +use crate::licensing::check_license_point; +use crate::parsing::{parse_filters, row_passes_filters}; +use crate::state::AppState; + +use super::properties::{HexagonPropertiesResponse, Property}; + +#[derive(Deserialize)] +pub struct PostcodePropertiesParams { + pub postcode: String, + pub filters: Option, + pub limit: Option, + pub offset: Option, +} + +pub async fn get_postcode_properties( + state: Arc, + Extension(user): Extension, + Query(params): Query, +) -> Result, axum::response::Response> { + let normalized = params + .postcode + .to_uppercase() + .split_whitespace() + .collect::>() + .join(" "); + + let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) { + Some(&idx) => idx, + None => { + warn!(postcode = %normalized, "Postcode not found"); + return Err(( + StatusCode::NOT_FOUND, + format!("Postcode not found: {}", normalized), + ) + .into_response()); + } + }; + let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx]; + + check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64) + .map_err(|(_, resp)| resp)?; + + let filters_str = params.filters.clone(); + let (parsed_filters, parsed_enum_filters) = parse_filters( + params.filters.as_deref(), + &state.feature_name_to_index, + &state.data.enum_values, + ) + .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; + let num_filters = parsed_filters.len() + parsed_enum_filters.len(); + + let postcode_str = normalized.clone(); + + let result = tokio::task::spawn_blocking(move || { + let t0 = std::time::Instant::now(); + let num_features = state.data.num_features; + let feature_data = &state.data.feature_data; + let feature_names = &state.data.feature_names; + let feature_name_to_index = &state.feature_name_to_index; + let enum_values = &state.data.enum_values; + + let offset_deg: f64 = POSTCODE_SEARCH_OFFSET; + let min_lat = centroid_lat as f64 - offset_deg; + let max_lat = centroid_lat as f64 + offset_deg; + let min_lon = centroid_lon as f64 - offset_deg; + let max_lon = centroid_lon as f64 + offset_deg; + + let mut matching_rows: Vec = Vec::new(); + state + .grid + .for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| { + let row = row_idx as usize; + if state.data.postcode(row) == postcode_str + && row_passes_filters( + row, + &parsed_filters, + &parsed_enum_filters, + feature_data, + num_features, + ) + { + matching_rows.push(row); + } + }); + + matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty()); + + let total = matching_rows.len(); + let limit = params + .limit + .unwrap_or(DEFAULT_PROPERTIES_LIMIT) + .min(MAX_PROPERTIES_LIMIT); + let page_offset = params.offset.unwrap_or(0); + let truncated = total > page_offset + limit; + + let properties: Vec = matching_rows + .iter() + .skip(page_offset) + .take(limit) + .map(|&row| { + super::properties::build_property( + row, &state, feature_names, feature_name_to_index, feature_data, + num_features, enum_values, + ) + }) + .collect(); + + let elapsed = t0.elapsed(); + info!( + postcode = %postcode_str, + total, + returned = properties.len(), + offset = page_offset, + filters = num_filters, + filters_raw = filters_str.as_deref().unwrap_or("-"), + ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0), + "GET /api/postcode-properties" + ); + + Ok(HexagonPropertiesResponse { + properties, + total, + limit, + offset: page_offset, + truncated, + }) + }) + .await + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())? + .map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?; + + Ok(Json(result)) +} diff --git a/server-rs/src/routes/properties.rs b/server-rs/src/routes/properties.rs index e047623..df5a4dd 100644 --- a/server-rs/src/routes/properties.rs +++ b/server-rs/src/routes/properties.rs @@ -98,6 +98,60 @@ fn lookup_enum_value( } } +pub fn build_property( + row: usize, + state: &AppState, + feature_names: &[String], + feature_name_to_index: &FxHashMap, + feature_data: &[f32], + num_features: usize, + enum_values: &FxHashMap>, +) -> Property { + let mut features = FxHashMap::default(); + let base = row * num_features; + for (feat_idx, feat_name) in feature_names.iter().enumerate() { + if enum_values.contains_key(&feat_idx) { + continue; + } + let value = feature_data[base + feat_idx]; + if value.is_finite() { + features.insert(feat_name.clone(), value); + } + } + + Property { + address: non_empty_string(state.data.address(row)), + postcode: non_empty_string(state.data.postcode(row)), + is_construction_date_approximate: Some(state.data.is_approx_build_date(row)), + property_type: lookup_enum_value( + feature_name_to_index, feature_data, num_features, enum_values, row, "Property type", + ), + built_form: lookup_enum_value( + feature_name_to_index, feature_data, num_features, enum_values, row, "Property type/built form", + ), + duration: lookup_enum_value( + feature_name_to_index, feature_data, num_features, enum_values, row, "Leashold/Freehold", + ), + current_energy_rating: lookup_enum_value( + feature_name_to_index, feature_data, num_features, enum_values, row, "Current energy rating", + ), + potential_energy_rating: lookup_enum_value( + feature_name_to_index, feature_data, num_features, enum_values, row, "Potential energy rating", + ), + lat: state.data.lat[row], + lon: state.data.lon[row], + renovation_history: state.data.renovation_history(row).to_vec(), + listing_features: state.data.listing_features(row).to_vec(), + listing_status: lookup_enum_value( + feature_name_to_index, feature_data, num_features, enum_values, row, "Listing status", + ), + listing_url: state.data.listing_url(row).map(String::from), + property_sub_type: state.data.property_sub_type(row).map(String::from), + price_qualifier: state.data.price_qualifier(row).map(String::from), + features, + } +} + pub async fn get_hexagon_properties( state: Arc, Extension(user): Extension, @@ -178,80 +232,10 @@ pub async fn get_hexagon_properties( .skip(offset) .take(limit) .map(|&row| { - let mut features = FxHashMap::default(); - let base = row * num_features; - for (feat_idx, feat_name) in feature_names.iter().enumerate() { - // Skip enum features in the generic features map - if enum_values.contains_key(&feat_idx) { - continue; - } - let value = feature_data[base + feat_idx]; - if value.is_finite() { - features.insert(feat_name.clone(), value); - } - } - - Property { - address: non_empty_string(state.data.address(row)), - postcode: non_empty_string(state.data.postcode(row)), - is_construction_date_approximate: Some(state.data.is_approx_build_date(row)), - property_type: lookup_enum_value( - feature_name_to_index, - feature_data, - num_features, - enum_values, - row, - "Property type", - ), - built_form: lookup_enum_value( - feature_name_to_index, - feature_data, - num_features, - enum_values, - row, - "Property type/built form", - ), - duration: lookup_enum_value( - feature_name_to_index, - feature_data, - num_features, - enum_values, - row, - "Leashold/Freehold", - ), - current_energy_rating: lookup_enum_value( - feature_name_to_index, - feature_data, - num_features, - enum_values, - row, - "Current energy rating", - ), - potential_energy_rating: lookup_enum_value( - feature_name_to_index, - feature_data, - num_features, - enum_values, - row, - "Potential energy rating", - ), - lat: state.data.lat[row], - lon: state.data.lon[row], - renovation_history: state.data.renovation_history(row).to_vec(), - listing_features: state.data.listing_features(row).to_vec(), - listing_status: lookup_enum_value( - feature_name_to_index, - feature_data, - num_features, - enum_values, - row, - "Listing status", - ), - listing_url: state.data.listing_url(row).map(String::from), - property_sub_type: state.data.property_sub_type(row).map(String::from), - price_qualifier: state.data.price_qualifier(row).map(String::from), - features, - } + build_property( + row, &state, feature_names, feature_name_to_index, feature_data, + num_features, enum_values, + ) }) .collect();