diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e6c5b3..4c05ed9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import MapPage, { type ExportState } from './components/map/MapPage'; import PricingPage from './components/pricing/PricingPage'; import HomePage from './components/home/HomePage'; @@ -67,9 +67,14 @@ function pathToPage(pathname: string): { page: Page; inviteCode?: string } | nul export default function App() { const urlState = useMemo(() => parseUrlState(), []); + const [mapUrlState, setMapUrlState] = useState(urlState); + const dashboardSearchRef = useRef( + window.location.pathname === '/dashboard' ? window.location.search : '' + ); + const activePageRef = useRef('home'); const initialViewState = useMemo( - () => urlState.viewState || INITIAL_VIEW_STATE, - [urlState.viewState] + () => mapUrlState.viewState || INITIAL_VIEW_STATE, + [mapUrlState.viewState] ); const isScreenshotMode = useMemo(() => { @@ -179,17 +184,30 @@ export default function App() { const navigateTo = useCallback( (page: Page, hash?: string, infoFeature?: string) => { + // Save dashboard search params before navigating away + if (activePageRef.current === 'dashboard') { + dashboardSearchRef.current = window.location.search; + } if (infoFeature) { window.history.replaceState({ ...window.history.state, infoFeature }, ''); } const path = pageToPath(page, inviteCode ?? undefined); - const url = hash ? `${path}#${hash}` : path; + // Restore dashboard search params when navigating back + const search = page === 'dashboard' ? dashboardSearchRef.current : ''; + const url = hash ? `${path}${search}#${hash}` : `${path}${search}`; window.history.pushState({ page }, '', url); + if (page === 'dashboard') { + setMapUrlState(parseUrlState()); + } setActivePage(page); }, [inviteCode] ); + useEffect(() => { + activePageRef.current = activePage; + }, [activePage]); + useEffect(() => { if (!window.history.state?.page) { window.history.replaceState( @@ -199,17 +217,24 @@ export default function App() { ); } const handlePopState = (e: PopStateEvent) => { + let page: Page; if (e.state?.page) { - setActivePage(e.state.page); + page = e.state.page; + setActivePage(page); if (e.state.infoFeature) { setPendingInfoFeature(e.state.infoFeature); } } else { // Fall back to deriving page from pathname const parsed = pathToPage(window.location.pathname); - setActivePage(parsed?.page || 'home'); + page = parsed?.page || 'home'; + setActivePage(page); if (parsed?.inviteCode) setInviteCode(parsed.inviteCode); } + // Re-parse URL state when returning to dashboard via back/forward + if (page === 'dashboard') { + setMapUrlState(parseUrlState()); + } }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); @@ -367,10 +392,10 @@ export default function App() { { setAuthModalTab('login'); diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 0ef5407..e77806a 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -567,6 +567,7 @@ export default function MapPage({ selectedCategories={selectedPOICategories} onCategoriesChange={setSelectedPOICategories} poiCount={pois.length} + onClose={() => setPoiPaneOpen(false)} /> ); diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx index 1c730be..a5f49bc 100644 --- a/frontend/src/components/map/POIPane.tsx +++ b/frontend/src/components/map/POIPane.tsx @@ -6,7 +6,7 @@ import InfoPopup from '../ui/InfoPopup'; import { SearchInput } from '../ui/SearchInput'; import { PillToggle } from '../ui/PillToggle'; import { PillGroup } from '../ui/PillGroup'; -import { InfoIcon, ChevronIcon } from '../ui/icons'; +import { InfoIcon, ChevronIcon, CloseIcon } from '../ui/icons'; import { IconButton } from '../ui/IconButton'; interface POIPaneProps { @@ -15,6 +15,7 @@ interface POIPaneProps { onCategoriesChange: (categories: Set) => void; poiCount: number; onNavigateToSource?: (slug: string) => void; + onClose?: () => void; } export default function POIPane({ @@ -23,6 +24,7 @@ export default function POIPane({ onCategoriesChange, poiCount: _poiCount, onNavigateToSource, + onClose, }: POIPaneProps) { const [searchTerm, setSearchTerm] = useState(''); const [isGroupExpanded, toggleCollapse] = useCollapsibleGroups(); @@ -96,7 +98,7 @@ export default function POIPane({ setShowInfo(true)} title="Data source info"> -
+
+ {onClose && ( + + )}
diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts index 72973e2..921ffe6 100644 --- a/frontend/src/hooks/useDeckLayers.ts +++ b/frontend/src/hooks/useDeckLayers.ts @@ -316,10 +316,12 @@ export function useDeckLayers({ number, ]; } + const ttMin = (d[`min_${vf}`] as number) ?? ttVal; + const ttMax = (d[`max_${vf}`] as number) ?? ttVal; return getFeatureFillColor( ttVal as number, - ttVal as number, - ttVal as number, + ttMin as number, + ttMax as number, clr, fr, 0, @@ -417,10 +419,12 @@ export function useDeckLayers({ number, ]; } + const ttMin = (d[`min_${vf}`] as number) ?? ttVal; + const ttMax = (d[`max_${vf}`] as number) ?? ttVal; return getFeatureFillColor( ttVal as number, - ttVal as number, - ttVal as number, + ttMin as number, + ttMax as number, clr, fr, 0, diff --git a/pipeline/download/tiles.py b/pipeline/download/tiles.py index 38eb1f6..1fece9c 100644 --- a/pipeline/download/tiles.py +++ b/pipeline/download/tiles.py @@ -7,22 +7,23 @@ import subprocess import sys import tarfile import urllib.request -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from io import BytesIO from pathlib import Path PROTOMAPS_BASE = "https://build.protomaps.com" UK_BBOX = "-10.5,49,5,61" MAX_AGE_DAYS = 14 +USER_AGENT = "property-map-tiles/1.0" def find_latest_build() -> str: """Find the most recent available Protomaps daily build.""" - today = datetime.utcnow().date() + today = datetime.now(UTC).date() for i in range(MAX_AGE_DAYS): d = today - timedelta(days=i) url = f"{PROTOMAPS_BASE}/{d:%Y%m%d}.pmtiles" - req = urllib.request.Request(url, method="HEAD") + req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT}) try: urllib.request.urlopen(req) print(f"Found build: {d:%Y%m%d}") diff --git a/server-rs/src/routes/ai_filters.rs b/server-rs/src/routes/ai_filters.rs index c634c62..0492e9c 100644 --- a/server-rs/src/routes/ai_filters.rs +++ b/server-rs/src/routes/ai_filters.rs @@ -175,8 +175,7 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu .find_map(|(idx, name_lower)| { let words_match = query_words.iter().all(|word| name_lower.contains(word)); let slug = slugify(&pd.name[idx]); - let slug_match = - slug.contains(&query_slug) || query_slug.contains(&slug); + let slug_match = slug.contains(&query_slug) || query_slug.contains(&slug); if (words_match || slug_match) && pd.type_rank[idx] == 0 { Some(pd.name[idx].as_str()) } else { @@ -704,7 +703,7 @@ fn count_matching_rows( let (pc_interner, pc_keys) = state.data.postcode_parts(); let mut count = 0usize; - for row in 0..num_rows { + for (row, pc_key) in pc_keys.iter().enumerate().take(num_rows) { if !row_passes_filters( row, &parsed_filters, @@ -716,12 +715,11 @@ fn count_matching_rows( } if has_travel { - let postcode = pc_interner.resolve(&pc_keys[row]); + let postcode = pc_interner.resolve(pc_key); let mut passes_travel = true; for (data, fmin, fmax) in &travel_data { let pass = if let Some(mins) = data.get(postcode).map(|r| r.minutes as f32) { - fmin.map_or(true, |min| mins >= min) - && fmax.map_or(true, |max| mins <= max) + fmin.is_none_or(|min| mins >= min) && fmax.is_none_or(|max| mins <= max) } else { false // no travel data → postcode not reachable }; @@ -880,7 +878,12 @@ pub async fn post_ai_filters( let fn_args = fc.get("args").cloned().unwrap_or(json!({})); tool_call_count += 1; - info!(function = fn_name, round = round, tool_call = tool_call_count, "AI called tool"); + info!( + function = fn_name, + round = round, + tool_call = tool_call_count, + "AI called tool" + ); if tool_call_count > MAX_TOOL_CALLS { warn!("Tool call budget exhausted, forcing text output"); @@ -929,9 +932,15 @@ pub async fn post_ai_filters( if text.is_empty() { retry_count += 1; - warn!("Gemini returned empty text content (round {}, retry {})", round, retry_count); + warn!( + "Gemini returned empty text content (round {}, retry {})", + round, retry_count + ); if retry_count > MAX_RETRIES { - return Err((StatusCode::BAD_GATEWAY, "AI returned empty responses".into())); + return Err(( + StatusCode::BAD_GATEWAY, + "AI returned empty responses".into(), + )); } contents.push(candidate.clone()); contents.push(json!({ @@ -988,7 +997,11 @@ pub async fn post_ai_filters( // Count matching properties and refine if too restrictive let match_count = count_matching_rows(&state, &filters, &travel_time_filters); - info!(match_count = match_count, round = round, "AI filter match count"); + info!( + match_count = match_count, + round = round, + "AI filter match count" + ); if match_count == 0 { refinement_attempts += 1; @@ -1008,7 +1021,10 @@ pub async fn post_ai_filters( let notes = if notes.is_empty() { "No properties match these filters. Try relaxing some constraints.".to_string() } else { - format!("{}. No properties match — try relaxing some constraints.", notes) + format!( + "{}. No properties match — try relaxing some constraints.", + notes + ) }; return Ok(Json(AiFiltersResponse { @@ -1193,8 +1209,7 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type: } => { // Only include features valid for the chosen listing mode if modes.is_empty() || modes.contains(&listing_type) { - numeric_features - .insert(name, (*min, *max, histogram.min, histogram.max)); + numeric_features.insert(name, (*min, *max, histogram.min, histogram.max)); } } FeatureInfo::Enum { name, values, .. } => { @@ -1217,11 +1232,10 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type: Some(name) => name, None => continue, }; - let (slider_min, slider_max, data_min, data_max) = - match numeric_features.get(name) { - Some(range) => *range, - None => continue, - }; + let (slider_min, slider_max, data_min, data_max) = match numeric_features.get(name) { + Some(range) => *range, + None => continue, + }; let bound = match item.get("bound").and_then(|val| val.as_str()) { Some(b) => b, None => continue, diff --git a/server-rs/src/routes/shorten.rs b/server-rs/src/routes/shorten.rs index 0fb8f05..f383840 100644 --- a/server-rs/src/routes/shorten.rs +++ b/server-rs/src/routes/shorten.rs @@ -140,10 +140,7 @@ pub async fn get_short_url( match params { Some(params) => { let redirect_url = format!("/dashboard?{params}"); - let og_image_url = format!( - "{}/api/screenshot?og=1&{params}", - state.public_url - ); + let og_image_url = format!("{}/api/screenshot?og=1&{params}", state.public_url); let og_url = format!("{}/s/{code}", state.public_url); let og_title = "Perfect Postcode \u{2014} Every neighbourhood in England"; let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";