diff --git a/frontend/src/components/map/LocationSearch.tsx b/frontend/src/components/map/LocationSearch.tsx index 9bca0ec..761016d 100644 --- a/frontend/src/components/map/LocationSearch.tsx +++ b/frontend/src/components/map/LocationSearch.tsx @@ -22,6 +22,13 @@ export interface SearchedLocation { focusAddress?: string; } +interface PostcodeLookupResponse { + postcode: string; + latitude: number; + longitude: number; + geometry: PostcodeGeometry; +} + const ZOOM_FOR_TYPE: Record = { city: 10, borough: 12, @@ -48,11 +55,15 @@ export default function LocationSearch({ onLocationSearched, onCurrentLocationFound, onMouseEnter, + className = '', + inputClassName, }: { onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void; onLocationSearched?: (postcode: SearchedLocation | null) => void; onCurrentLocationFound?: (lat: number, lng: number) => void; onMouseEnter?: () => void; + className?: string; + inputClassName?: string; }) { const { t } = useTranslation(); const search = useLocationSearch(); @@ -86,10 +97,37 @@ export default function LocationSearch({ async (result: SearchResult) => { if (result.type === 'place') { const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14; + setError(null); + setLoading(true); + search.close(); onFlyTo(result.lat, result.lon, zoom); - onLocationSearched?.(null); - search.clear(); - if (isMobile) setExpanded(false); + try { + const params = new URLSearchParams({ + lat: String(result.lat), + lng: String(result.lon), + log: 'false', + }); + const res = await fetch(`/api/nearest-postcode?${params}`, authHeaders()); + if (!res.ok) { + setError(t('locationSearch.lookupFailed')); + return; + } + const json: PostcodeLookupResponse = await res.json(); + onLocationSearched?.({ + postcode: json.postcode, + geometry: json.geometry, + latitude: json.latitude, + longitude: json.longitude, + markerLatitude: result.lat, + markerLongitude: result.lon, + }); + search.clear(); + if (isMobile) setExpanded(false); + } catch { + setError(t('locationSearch.lookupFailed')); + } finally { + setLoading(false); + } return; } @@ -106,12 +144,7 @@ export default function LocationSearch({ setError(t('locationSearch.postcodeNotFound')); return; } - const json: { - postcode: string; - latitude: number; - longitude: number; - geometry: PostcodeGeometry; - } = await res.json(); + const json: PostcodeLookupResponse = await res.json(); onFlyTo(result.lat, result.lon, 17); onLocationSearched?.({ postcode: json.postcode, @@ -143,12 +176,7 @@ export default function LocationSearch({ setError(t('locationSearch.postcodeNotFound')); return; } - const json: { - postcode: string; - latitude: number; - longitude: number; - geometry: PostcodeGeometry; - } = await res.json(); + const json: PostcodeLookupResponse = await res.json(); onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM); onLocationSearched?.({ postcode: json.postcode, @@ -237,7 +265,7 @@ export default function LocationSearch({
@@ -248,7 +276,10 @@ export default function LocationSearch({ loading={loading} placeholder={t('locationSearch.placeholder')} size="sm" - inputClassName="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500" + inputClassName={ + inputClassName ?? + 'px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500' + } inputRef={inputRef} onInputChange={() => setError(null)} /> diff --git a/frontend/src/components/map/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx index a4a9859..48c065a 100644 --- a/frontend/src/components/map/MapLegend.tsx +++ b/frontend/src/components/map/MapLegend.tsx @@ -111,6 +111,7 @@ export default function MapLegend({ totalCount, onResetScale, resetScaleDisabled = false, + className = '', }: { featureLabel: string; range: [number, number]; @@ -126,6 +127,7 @@ export default function MapLegend({ totalCount?: number; onResetScale?: () => void; resetScaleDisabled?: boolean; + className?: string; }) { const { t } = useTranslation(); const isEnum = enumValues && enumValues.length > 0; @@ -199,7 +201,9 @@ export default function MapLegend({ } return ( -
+
{featureLabel} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 66652fb..89554e5 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -260,18 +260,10 @@ export default function MapPage({ const license = useLicense(); const handleTravelTimeSetDestination = useCallback( - (index: number, slug: string, label: string, lat: number, lon: number) => { + (index: number, slug: string, label: string, _lat: number, _lon: number) => { handleSetDestination(index, slug, label); - if (slug) { - mapFlyToRef.current?.( - lat, - lon, - mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom, - getMobileMapFlyToOptions() - ); - } }, - [getMobileMapFlyToOptions, handleSetDestination, mapData.currentView?.zoom] + [handleSetDestination] ); const journeyDest = useJourneyDestination(entries); @@ -463,7 +455,11 @@ export default function MapPage({ mapData.resolution, areaStats ); - const tutorial = useTutorial(initialLoading, isMobile, deferTutorial); + const tutorial = useTutorial( + initialLoading, + isMobile, + deferTutorial || mapData.licenseRequired + ); const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]); const densityLabel = t('mapLegend.historicalMatches'); const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0; @@ -480,10 +476,13 @@ export default function MapPage({ onExportStateChange, }); + const shareAndSaveView = isMobile + ? (mapData.currentVisibleView ?? mapData.currentView) + : mapData.currentView; const dashboardParams = useMemo( () => stateToParams( - mapData.currentView, + shareAndSaveView, filters, features, selectedPOICategories, @@ -495,12 +494,18 @@ export default function MapPage({ entries, features, filters, - mapData.currentView, rightPaneTab, selectedPOICategories, shareCode, + shareAndSaveView, ] ); + const handleSaveSearch = useCallback( + async (name: string) => { + await onSaveSearch?.(name, dashboardParams); + }, + [dashboardParams, onSaveSearch] + ); const checkoutReturnPath = useMemo( () => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`, [dashboardParams] @@ -614,7 +619,7 @@ export default function MapPage({ onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined} filterImpacts={filterCounts.impacts} onClearAll={handleClearAll} - onSaveSearch={onSaveSearch} + onSaveSearch={onSaveSearch ? handleSaveSearch : undefined} savingSearch={savingSearch} destinationDropdownPortal={options?.destinationDropdownPortal} /> diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 1834c28..d644d06 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -85,7 +85,7 @@ export function TravelTimeCard({ {t('travel.travelTime', { mode: modes.label(mode) })}
-
+
setShowInfo(true)} title={t('filters.aboutData')}> diff --git a/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx b/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx index a74d35b..552fd52 100644 --- a/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx +++ b/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx @@ -16,6 +16,7 @@ import { getPoiFilterMeta, getPoiFilterName, replacePoiFilterKeySelection, + usesFixedPoiDistanceScale, } from '../../../lib/poi-distance-filter'; import { PoiTypeDropdown } from './PoiTypeDropdown'; import { SliderLabels } from './SliderLabels'; @@ -69,38 +70,45 @@ export function PoiDistanceFilterCard({ const isActive = activeFeature === poiFeature.name; const isPinned = pinnedFeature === poiFeature.name; const hist = selectedFeature.histogram; + const fixedDistanceScale = usesFixedPoiDistanceScale(selectedFeature); const dataMin = hist?.min ?? selectedFeature.min ?? 0; const dataMax = hist?.max ?? selectedFeature.max ?? 5; - const displayValue = + const sliderMin = selectedFeature.min ?? dataMin; + const sliderMax = selectedFeature.max ?? dataMax; + const rawDisplayValue = isActive && dragValue ? dragValue - : (filters[poiFeature.name] as [number, number]) || [dataMin, dataMax]; - const scale = percentileScale; - const clampMin = displayValue[0] <= dataMin; - const clampMax = displayValue[1] >= dataMax; - const isAtMin = displayValue[0] === dataMin; - const isAtMax = displayValue[1] === dataMax; + : (filters[poiFeature.name] as [number, number]) || + (fixedDistanceScale ? [sliderMin, sliderMax] : [dataMin, dataMax]); + const displayValue = fixedDistanceScale + ? clampPoiFilterRange(rawDisplayValue, selectedFeature) + : rawDisplayValue; + const scale = fixedDistanceScale ? undefined : percentileScale; + const clampMin = fixedDistanceScale ? displayValue[0] <= sliderMin : displayValue[0] <= dataMin; + const clampMax = fixedDistanceScale ? displayValue[1] >= sliderMax : displayValue[1] >= dataMax; + const isAtMin = fixedDistanceScale ? false : displayValue[0] === dataMin; + const isAtMax = fixedDistanceScale ? false : displayValue[1] === dataMax; const sliderValue: [number, number] = scale ? [ clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])), clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])), ] - : [ - clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0], - clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1], - ]; + : [clampMin ? sliderMin : displayValue[0], clampMax ? sliderMax : displayValue[1]]; const replacePoiFeature = (nextFeatureName: string) => { const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName); if (nextName === poiFeature.name) return; const nextFeature = features.find((feature) => feature.name === nextFeatureName); + const nextFixedDistanceScale = usesFixedPoiDistanceScale(nextFeature); const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); + const nextSliderMin = nextFeature?.min ?? nextDataMin; + const nextSliderMax = nextFeature?.max ?? nextDataMax; const nextRange = clampPoiFilterRange( [ - displayValue[0] <= dataMin ? nextDataMin : displayValue[0], - displayValue[1] >= dataMax ? nextDataMax : displayValue[1], + clampMin ? (nextFixedDistanceScale ? nextSliderMin : nextDataMin) : displayValue[0], + clampMax ? (nextFixedDistanceScale ? nextSliderMax : nextDataMax) : displayValue[1], ], nextFeature ); @@ -156,14 +164,9 @@ export function PoiDistanceFilterCard({ {mobileIcon &&
{mobileIcon}
}
onDragChange([ - min <= (selectedFeature.min ?? dataMin) ? dataMin : min, - max >= (selectedFeature.max ?? dataMax) ? dataMax : max, + min <= sliderMin ? sliderMin : min, + max >= sliderMax ? sliderMax : max, ]) } onPointerDown={() => onDragStart(poiFeature.name)} onPointerUp={() => onDragEnd()} />
- {tutorial.run && ( -