From fe46cb3379e4e67d11fb22023e453de61984a03e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 10:21:32 +0100 Subject: [PATCH] Extract components --- README.md | 29 + frontend/src/components/map/Filters.tsx | 1823 +---------------- frontend/src/components/map/MapPage.tsx | 1122 +++------- .../src/components/map/MobileBottomSheet.tsx | 120 +- .../map/filters/ActiveFilterList.tsx | 253 +++ .../map/filters/ActiveFiltersPanel.tsx | 204 ++ .../components/map/filters/AddFilterPanel.tsx | 162 ++ .../map/filters/ClearFiltersDialog.tsx | 94 + .../map/filters/EnumFeatureFilterCard.tsx | 71 + .../map/filters/EthnicityFilterCard.tsx | 223 ++ .../map/filters/NumericFeatureFilterCard.tsx | 138 ++ .../map/filters/PoiDistanceFilterCard.tsx | 220 ++ .../map/filters/SchoolFilterCard.tsx | 234 +++ .../components/map/filters/SliderLabels.tsx | 145 ++ .../map/filters/SpecificCrimeFilterCard.tsx | 223 ++ .../map/filters/TravelTimeFilterCards.tsx | 76 + .../map/map-page/DesktopMapPage.tsx | 225 ++ .../src/components/map/map-page/Fallbacks.tsx | 17 + .../map/map-page/LoadingOverlay.tsx | 20 + .../map/map-page/MobileMapLegend.tsx | 94 + .../components/map/map-page/MobileMapPage.tsx | 177 ++ .../map/map-page/ScreenshotMapPage.tsx | 65 + .../src/components/map/map-page/Toasts.tsx | 67 + .../components/map/map-page/derivedState.ts | 95 + .../src/components/map/map-page/effects.ts | 138 ++ .../components/map/map-page/lazyComponents.ts | 17 + frontend/src/components/map/map-page/types.ts | 60 + .../map/map-page/useExportController.ts | 176 ++ frontend/src/lib/ethnicity-filter.ts | 106 + frontend/src/lib/poi-distance-filter.ts | 291 +++ 30 files changed, 4075 insertions(+), 2610 deletions(-) create mode 100644 frontend/src/components/map/filters/ActiveFilterList.tsx create mode 100644 frontend/src/components/map/filters/ActiveFiltersPanel.tsx create mode 100644 frontend/src/components/map/filters/AddFilterPanel.tsx create mode 100644 frontend/src/components/map/filters/ClearFiltersDialog.tsx create mode 100644 frontend/src/components/map/filters/EnumFeatureFilterCard.tsx create mode 100644 frontend/src/components/map/filters/EthnicityFilterCard.tsx create mode 100644 frontend/src/components/map/filters/NumericFeatureFilterCard.tsx create mode 100644 frontend/src/components/map/filters/PoiDistanceFilterCard.tsx create mode 100644 frontend/src/components/map/filters/SchoolFilterCard.tsx create mode 100644 frontend/src/components/map/filters/SliderLabels.tsx create mode 100644 frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx create mode 100644 frontend/src/components/map/filters/TravelTimeFilterCards.tsx create mode 100644 frontend/src/components/map/map-page/DesktopMapPage.tsx create mode 100644 frontend/src/components/map/map-page/Fallbacks.tsx create mode 100644 frontend/src/components/map/map-page/LoadingOverlay.tsx create mode 100644 frontend/src/components/map/map-page/MobileMapLegend.tsx create mode 100644 frontend/src/components/map/map-page/MobileMapPage.tsx create mode 100644 frontend/src/components/map/map-page/ScreenshotMapPage.tsx create mode 100644 frontend/src/components/map/map-page/Toasts.tsx create mode 100644 frontend/src/components/map/map-page/derivedState.ts create mode 100644 frontend/src/components/map/map-page/effects.ts create mode 100644 frontend/src/components/map/map-page/lazyComponents.ts create mode 100644 frontend/src/components/map/map-page/types.ts create mode 100644 frontend/src/components/map/map-page/useExportController.ts create mode 100644 frontend/src/lib/ethnicity-filter.ts create mode 100644 frontend/src/lib/poi-distance-filter.ts diff --git a/README.md b/README.md index 13a2531..10f45ce 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,35 @@ a React/deck.gl map. The public product is branded as Perfect Postcodes, while this repository is still named `property-map`. +## Public SEO Pages + +The indexable public pages are listed in `frontend/public/sitemap.xml` and +prerendered by `frontend/scripts/prerender.mjs`: + +- [Home](https://perfect-postcode.co.uk/) - `/` +- [Learn](https://perfect-postcode.co.uk/learn) - `/learn` +- [Pricing](https://perfect-postcode.co.uk/pricing) - `/pricing` +- [Property price map](https://perfect-postcode.co.uk/property-price-map) - + `/property-price-map` +- [Postcode property search](https://perfect-postcode.co.uk/postcode-property-search) - + `/postcode-property-search` +- [Commute property search](https://perfect-postcode.co.uk/commute-property-search) - + `/commute-property-search` +- [School property search](https://perfect-postcode.co.uk/school-property-search) - + `/school-property-search` +- [Postcode checker](https://perfect-postcode.co.uk/postcode-checker) - + `/postcode-checker` +- [Birmingham property search](https://perfect-postcode.co.uk/property-search/birmingham) - + `/property-search/birmingham` +- [Manchester property search](https://perfect-postcode.co.uk/property-search/manchester) - + `/property-search/manchester` +- [Bristol property search](https://perfect-postcode.co.uk/property-search/bristol) - + `/property-search/bristol` +- [Data sources](https://perfect-postcode.co.uk/data-sources) - `/data-sources` +- [Methodology](https://perfect-postcode.co.uk/methodology) - `/methodology` +- [Privacy and security](https://perfect-postcode.co.uk/privacy-security) - + `/privacy-security` + ## What Is In Here - `frontend/` - React 18, TypeScript, Tailwind, MapLibre, and deck.gl. The app diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 583a255..5a57218 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -1,1053 +1,55 @@ -import { Fragment, memo, useState, useMemo, useRef, useCallback, useEffect } from 'react'; +import { memo, useState, useMemo, useRef, useCallback, useEffect, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { ts } from '../../i18n/server'; -import { Slider } from '../ui/Slider'; -import { ChevronIcon, CloseIcon, LightbulbIcon, SpinnerIcon } from '../ui/icons'; -import { PillToggle } from '../ui/PillToggle'; -import { PillGroup } from '../ui/PillGroup'; import type { FeatureMeta, FeatureFilters } from '../../types'; -import { - formatFilterValue, - formatNumber, - parseInputValue, - buildPercentileScale, -} from '../../lib/format'; +import { buildPercentileScale } from '../../lib/format'; import type { PercentileScale } from '../../lib/format'; import InfoPopup from '../ui/InfoPopup'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; -import { FeatureActions } from '../ui/FeatureIcons'; -import { FeatureLabel } from '../ui/FeatureLabel'; -import { getFeatureIcon } from '../../lib/feature-icons'; -import { getGroupIcon } from '../../lib/group-icons'; -import AiFilterInput from './AiFilterInput'; import type { AiFilterErrorType } from '../../hooks/useAiFilters'; -import FeatureBrowser from './FeatureBrowser'; -import { TravelTimeCard } from './TravelTimeCard'; -import { - type TransportMode, - type TravelTimeEntry, - travelFieldKey, -} from '../../hooks/useTravelTime'; +import { type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; +import { ActiveFiltersPanel } from './filters/ActiveFiltersPanel'; +import { AddFilterPanel } from './filters/AddFilterPanel'; +import { ClearFiltersDialog } from './filters/ClearFiltersDialog'; import { SPECIFIC_CRIMES_FILTER_NAME, - SPECIFIC_CRIME_FEATURE_NAMES, - clampSpecificCrimeRange, getDefaultSpecificCrimeFeatureName, getSpecificCrimeFeatureName, getSpecificCrimeFilterMeta, isSpecificCrimeFeatureName, isSpecificCrimeFilterName, - replaceSpecificCrimeFilterKeySelection, } from '../../lib/crime-filter'; import { ETHNICITIES_FILTER_NAME, - ETHNICITY_FEATURE_NAMES, - clampEthnicityRange, getDefaultEthnicityFeatureName, getEthnicityFeatureName, getEthnicityFilterMeta, isEthnicityFeatureName, isEthnicityFilterName, - replaceEthnicityFilterKeySelection, } from '../../lib/ethnicity-filter'; import { SCHOOL_FILTER_NAME, - clampSchoolRange, getDefaultSchoolFeatureName, getSchoolBackendFeatureName, - getSchoolFilterConfig, getSchoolFilterMeta, isSchoolFilterName, - replaceSchoolFilterKeySelection, - type SchoolDistance, - type SchoolPhase, - type SchoolRating, } from '../../lib/school-filter'; import { POI_FILTER_NAMES, POI_DISTANCE_FILTER_NAME, POI_COUNT_2KM_FILTER_NAME, POI_COUNT_5KM_FILTER_NAME, - clampPoiFilterRange, getDefaultPoiDistanceFeatureName, getDefaultPoiFilterFeatureName, - getPoiFeatureCategory, getPoiDistanceFeatureName, - getPoiFilterFeatureOptions, getPoiFilterMeta, getPoiDistanceFilterMeta, getPoiFilterName, isPoiDistanceFilterName, isPoiFilterFeatureName, - replacePoiFilterKeySelection, type PoiFilterName, } from '../../lib/poi-distance-filter'; -function EditableLabel({ - value, - formatted, - onCommit, - prefix, - suffix, - className, - style, -}: { - value: number; - formatted: string; - onCommit: (v: number) => void; - prefix?: string; - suffix?: string; - className?: string; - style?: React.CSSProperties; -}) { - const [editing, setEditing] = useState(false); - const [text, setText] = useState(''); - const inputRef = useRef(null); - - const startEdit = () => { - setEditing(true); - setText(String(Math.round(value))); - }; - - const commit = () => { - const parsed = parseInputValue(text, { prefix, suffix }); - if (parsed != null) onCommit(parsed); - setEditing(false); - }; - - useEffect(() => { - if (editing) { - inputRef.current?.focus(); - inputRef.current?.select(); - } - }, [editing]); - - if (editing) { - return ( - setText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commit(); - if (e.key === 'Escape') setEditing(false); - }} - onBlur={commit} - className="absolute w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400" - style={style} - /> - ); - } - - return ( - - {formatted} - - ); -} - -function SliderLabels({ - min, - max, - value, - displayValues, - isAtMin, - isAtMax, - raw, - feature, - onValueChange, -}: { - min: number; - max: number; - value: [number, number]; - displayValues?: [number, number]; - isAtMin?: boolean; - isAtMax?: boolean; - raw?: boolean; - feature?: FeatureMeta; - onValueChange?: (v: [number, number]) => void; -}) { - const range = max - min || 1; - const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100)); - const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100)); - const labels = displayValues || value; - const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw; - - const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat); - const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat); - - // Smoothly spread labels apart as thumbs get close to prevent overlap. - // t=1 (centered) when far apart, t=0 (split) when touching. - const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating - const gapPct = rightPct - leftPct; - const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD)); - const leftTranslate = `translateX(${-100 + t * 50}%)`; - const rightTranslate = `translateX(${-t * 50}%)`; - - if (feature && onValueChange) { - return ( -
- onValueChange([v, Math.max(v, labels[1])])} - prefix={feature.prefix} - suffix={feature.suffix} - style={{ left: `${leftPct}%`, transform: leftTranslate }} - /> - onValueChange([Math.min(labels[0], v), v])} - prefix={feature.prefix} - suffix={feature.suffix} - style={{ left: `${rightPct}%`, transform: rightTranslate }} - /> -
- ); - } - - return ( -
- - {minLabel} - - - {maxLabel} - -
- ); -} - -function SchoolFilterCard({ - features, - schoolFeature, - filters, - activeFeature, - dragValue, - pinnedFeature, - filterImpact, - onFilterChange, - onDragStart, - onDragChange, - onDragEnd, - onTogglePin, - onShowInfo, - onRemove, -}: { - features: FeatureMeta[]; - schoolFeature: FeatureMeta; - filters: FeatureFilters; - activeFeature: string | null; - dragValue: [number, number] | null; - pinnedFeature: string | null; - filterImpact?: number; - onFilterChange: (name: string, value: [number, number] | string[]) => void; - onDragStart: (name: string) => void; - onDragChange: (value: [number, number]) => void; - onDragEnd: () => void; - onTogglePin: (name: string) => void; - onShowInfo: (feature: FeatureMeta) => void; - onRemove: () => void; -}) { - const config = getSchoolFilterConfig(schoolFeature.name); - const schoolMeta = getSchoolFilterMeta(features); - const backendFeature = config - ? features.find((feature) => feature.name === config.featureName) - : undefined; - const isActive = activeFeature === schoolFeature.name; - const isPinned = pinnedFeature === schoolFeature.name; - const hist = backendFeature?.histogram; - const dataMin = hist?.min ?? backendFeature?.min ?? 0; - const dataMax = hist?.max ?? backendFeature?.max ?? 10; - const displayValue = - isActive && dragValue - ? dragValue - : (filters[schoolFeature.name] as [number, number]) || [dataMin, dataMax]; - const sliderValue: [number, number] = [ - displayValue[0] <= dataMin ? (backendFeature?.min ?? dataMin) : displayValue[0], - displayValue[1] >= dataMax ? (backendFeature?.max ?? dataMax) : displayValue[1], - ]; - - if (!config) return null; - - const replaceSchoolFeature = ( - next: Partial<{ - phase: SchoolPhase; - rating: SchoolRating; - distance: SchoolDistance; - }> - ) => { - const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next); - if (nextName === schoolFeature.name) return; - - const nextBackendName = getSchoolBackendFeatureName(nextName); - const nextFeature = nextBackendName - ? features.find((feature) => feature.name === nextBackendName) - : undefined; - const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; - const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); - const nextRange = clampSchoolRange( - [ - displayValue[0] <= dataMin ? nextDataMin : displayValue[0], - displayValue[1] >= dataMax ? nextDataMax : displayValue[1], - ], - nextFeature - ); - onFilterChange(nextName, nextRange); - if (isPinned) onTogglePin(nextName); - }; - - const segmentedClass = - 'grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800'; - const optionClass = (active: boolean) => - `px-2 py-1 text-xs font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${ - active - ? 'bg-teal-600 text-white dark:bg-teal-500' - : 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700' - }`; - - return ( -
-
- - onTogglePin(schoolFeature.name)} - onShowInfo={() => onShowInfo(schoolMeta)} - onRemove={onRemove} - /> -
- -
-
-
- School type -
-
- - -
-
-
-
- Rating -
-
- - -
-
-
-
- Distance -
-
- - -
-
-
- - - onDragChange([ - min <= (backendFeature?.min ?? dataMin) ? dataMin : min, - max >= (backendFeature?.max ?? dataMax) ? dataMax : max, - ]) - } - onPointerDown={() => onDragStart(schoolFeature.name)} - onPointerUp={() => onDragEnd()} - /> - onFilterChange(schoolFeature.name, v)} - /> - {filterImpact != null && filterImpact > 0 && ( -

- +{formatNumber(filterImpact)} without this filter -

- )} -
- ); -} - -function SpecificCrimeFilterCard({ - features, - crimeFeature, - filters, - activeFeature, - dragValue, - pinnedFeature, - filterImpact, - percentileScale, - onFilterChange, - onDragStart, - onDragChange, - onDragEnd, - onTogglePin, - onShowInfo, - onRemove, -}: { - features: FeatureMeta[]; - crimeFeature: FeatureMeta; - filters: FeatureFilters; - activeFeature: string | null; - dragValue: [number, number] | null; - pinnedFeature: string | null; - filterImpact?: number; - percentileScale?: PercentileScale; - onFilterChange: (name: string, value: [number, number] | string[]) => void; - onDragStart: (name: string) => void; - onDragChange: (value: [number, number]) => void; - onDragEnd: () => void; - onTogglePin: (name: string) => void; - onShowInfo: (feature: FeatureMeta) => void; - onRemove: () => void; -}) { - const specificCrimeMeta = getSpecificCrimeFilterMeta(features); - const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) => - features.find((feature) => feature.name === name) - ).filter((feature): feature is FeatureMeta => Boolean(feature)); - const selectedFeatureName = - getSpecificCrimeFeatureName(crimeFeature.name) ?? getDefaultSpecificCrimeFeatureName(features); - const selectedFeature = selectedFeatureName - ? features.find((feature) => feature.name === selectedFeatureName) - : undefined; - - if (!selectedFeature || crimeOptions.length === 0 || !selectedFeatureName) return null; - - const isActive = activeFeature === crimeFeature.name; - const isPinned = pinnedFeature === crimeFeature.name; - const hist = selectedFeature.histogram; - const dataMin = hist?.min ?? selectedFeature.min ?? 0; - const dataMax = hist?.max ?? selectedFeature.max ?? 100; - const displayValue = - isActive && dragValue - ? dragValue - : (filters[crimeFeature.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; - 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], - ]; - - const replaceCrimeFeature = (nextFeatureName: string) => { - const nextName = replaceSpecificCrimeFilterKeySelection(crimeFeature.name, nextFeatureName); - if (nextName === crimeFeature.name) return; - - const nextFeature = features.find((feature) => feature.name === nextFeatureName); - const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; - const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); - const nextRange = clampSpecificCrimeRange( - [ - displayValue[0] <= dataMin ? nextDataMin : displayValue[0], - displayValue[1] >= dataMax ? nextDataMax : displayValue[1], - ], - nextFeature - ); - - onFilterChange(nextName, nextRange); - if (isPinned) onTogglePin(nextName); - }; - - const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; - const mobileIcon = - getFeatureIcon(selectedFeature.name, mobileIconClass) || - (() => { - const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null; - return G ? : null; - })(); - - return ( -
-
- - -
- -
- -
- - -
-
- -
- {mobileIcon &&
{mobileIcon}
} -
- { - const step = selectedFeature.step ?? 1; - const snap = (v: number) => Math.round(v / step) * step; - onDragChange([ - pMin <= 0 ? dataMin : snap(scale.toValue(pMin)), - pMax >= 100 ? dataMax : snap(scale.toValue(pMax)), - ]); - } - : ([min, max]) => - onDragChange([ - min <= (selectedFeature.min ?? dataMin) ? dataMin : min, - max >= (selectedFeature.max ?? dataMax) ? dataMax : max, - ]) - } - onPointerDown={() => onDragStart(crimeFeature.name)} - onPointerUp={() => onDragEnd()} - /> - - onFilterChange(crimeFeature.name, clampSpecificCrimeRange(v, selectedFeature)) - } - /> - {filterImpact != null && filterImpact > 0 && ( -

- +{formatNumber(filterImpact)} without this filter -

- )} -
-
-
- ); -} - -function EthnicityFilterCard({ - features, - ethnicityFeature, - filters, - activeFeature, - dragValue, - pinnedFeature, - filterImpact, - percentileScale, - onFilterChange, - onDragStart, - onDragChange, - onDragEnd, - onTogglePin, - onShowInfo, - onRemove, -}: { - features: FeatureMeta[]; - ethnicityFeature: FeatureMeta; - filters: FeatureFilters; - activeFeature: string | null; - dragValue: [number, number] | null; - pinnedFeature: string | null; - filterImpact?: number; - percentileScale?: PercentileScale; - onFilterChange: (name: string, value: [number, number] | string[]) => void; - onDragStart: (name: string) => void; - onDragChange: (value: [number, number]) => void; - onDragEnd: () => void; - onTogglePin: (name: string) => void; - onShowInfo: (feature: FeatureMeta) => void; - onRemove: () => void; -}) { - const ethnicityMeta = getEthnicityFilterMeta(features); - const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) => - features.find((feature) => feature.name === name) - ).filter((feature): feature is FeatureMeta => Boolean(feature)); - const selectedFeatureName = - getEthnicityFeatureName(ethnicityFeature.name) ?? getDefaultEthnicityFeatureName(features); - const selectedFeature = selectedFeatureName - ? features.find((feature) => feature.name === selectedFeatureName) - : undefined; - - if (!selectedFeature || ethnicityOptions.length === 0 || !selectedFeatureName) return null; - - const isActive = activeFeature === ethnicityFeature.name; - const isPinned = pinnedFeature === ethnicityFeature.name; - const hist = selectedFeature.histogram; - const dataMin = hist?.min ?? selectedFeature.min ?? 0; - const dataMax = hist?.max ?? selectedFeature.max ?? 100; - const displayValue = - isActive && dragValue - ? dragValue - : (filters[ethnicityFeature.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; - 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], - ]; - - const replaceEthnicityFeature = (nextFeatureName: string) => { - const nextName = replaceEthnicityFilterKeySelection(ethnicityFeature.name, nextFeatureName); - if (nextName === ethnicityFeature.name) return; - - const nextFeature = features.find((feature) => feature.name === nextFeatureName); - const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; - const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); - const nextRange = clampEthnicityRange( - [ - displayValue[0] <= dataMin ? nextDataMin : displayValue[0], - displayValue[1] >= dataMax ? nextDataMax : displayValue[1], - ], - nextFeature - ); - - onFilterChange(nextName, nextRange); - if (isPinned) onTogglePin(nextName); - }; - - const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; - const mobileIcon = - getFeatureIcon(selectedFeature.name, mobileIconClass) || - (() => { - const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null; - return G ? : null; - })(); - - return ( -
-
- - -
- -
- -
- - -
-
- -
- {mobileIcon &&
{mobileIcon}
} -
- { - const step = selectedFeature.step ?? 1; - const snap = (v: number) => Math.round(v / step) * step; - onDragChange([ - pMin <= 0 ? dataMin : snap(scale.toValue(pMin)), - pMax >= 100 ? dataMax : snap(scale.toValue(pMax)), - ]); - } - : ([min, max]) => - onDragChange([ - min <= (selectedFeature.min ?? dataMin) ? dataMin : min, - max >= (selectedFeature.max ?? dataMax) ? dataMax : max, - ]) - } - onPointerDown={() => onDragStart(ethnicityFeature.name)} - onPointerUp={() => onDragEnd()} - /> - - onFilterChange(ethnicityFeature.name, clampEthnicityRange(v, selectedFeature)) - } - /> - {filterImpact != null && filterImpact > 0 && ( -

- +{formatNumber(filterImpact)} without this filter -

- )} -
-
-
- ); -} - -function PoiDistanceFilterCard({ - features, - poiFeature, - filters, - activeFeature, - dragValue, - pinnedFeature, - filterImpact, - percentileScale, - onFilterChange, - onDragStart, - onDragChange, - onDragEnd, - onTogglePin, - onShowInfo, - onRemove, -}: { - features: FeatureMeta[]; - poiFeature: FeatureMeta; - filters: FeatureFilters; - activeFeature: string | null; - dragValue: [number, number] | null; - pinnedFeature: string | null; - filterImpact?: number; - percentileScale?: PercentileScale; - onFilterChange: (name: string, value: [number, number] | string[]) => void; - onDragStart: (name: string) => void; - onDragChange: (value: [number, number]) => void; - onDragEnd: () => void; - onTogglePin: (name: string) => void; - onShowInfo: (feature: FeatureMeta) => void; - onRemove: () => void; -}) { - const filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME; - const poiMeta = getPoiFilterMeta(features, filterName); - const poiOptions = getPoiFilterFeatureOptions(features, filterName); - const selectedFeatureName = - getPoiDistanceFeatureName(poiFeature.name) ?? - getDefaultPoiFilterFeatureName(features, filterName); - const selectedFeature = selectedFeatureName - ? features.find((feature) => feature.name === selectedFeatureName) - : undefined; - - if (!selectedFeature || poiOptions.length === 0 || !selectedFeatureName) return null; - - const isActive = activeFeature === poiFeature.name; - const isPinned = pinnedFeature === poiFeature.name; - const hist = selectedFeature.histogram; - const dataMin = hist?.min ?? selectedFeature.min ?? 0; - const dataMax = hist?.max ?? selectedFeature.max ?? 5; - const displayValue = - 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; - 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], - ]; - - const replacePoiFeature = (nextFeatureName: string) => { - const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName); - if (nextName === poiFeature.name) return; - - const nextFeature = features.find((feature) => feature.name === nextFeatureName); - const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; - const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); - const nextRange = clampPoiFilterRange( - [ - displayValue[0] <= dataMin ? nextDataMin : displayValue[0], - displayValue[1] >= dataMax ? nextDataMax : displayValue[1], - ], - nextFeature - ); - - onFilterChange(nextName, nextRange); - if (isPinned) onTogglePin(nextName); - }; - - const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; - const mobileIcon = - getFeatureIcon(selectedFeature.name, mobileIconClass) || - (() => { - const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null; - return G ? : null; - })(); - - return ( -
-
- - -
- -
- -
- - -
-
- -
- {mobileIcon &&
{mobileIcon}
} -
- { - const step = selectedFeature.step ?? 0.1; - const snap = (v: number) => Math.round(v / step) * step; - onDragChange([ - pMin <= 0 ? dataMin : snap(scale.toValue(pMin)), - pMax >= 100 ? dataMax : snap(scale.toValue(pMax)), - ]); - } - : ([min, max]) => - onDragChange([ - min <= (selectedFeature.min ?? dataMin) ? dataMin : min, - max >= (selectedFeature.max ?? dataMax) ? dataMax : max, - ]) - } - onPointerDown={() => onDragStart(poiFeature.name)} - onPointerUp={() => onDragEnd()} - /> - - onFilterChange(poiFeature.name, clampPoiFilterRange(v, selectedFeature)) - } - /> - {filterImpact != null && filterImpact > 0 && ( -

- +{formatNumber(filterImpact)} without this filter -

- )} -
-
-
- ); -} - interface FiltersProps { features: FeatureMeta[]; filters: FeatureFilters; @@ -1389,13 +391,6 @@ export default memo(function Filters({ ] ); - const handleRemoveSchoolFilter = useCallback( - (name: string) => { - onRemoveFilter(name); - }, - [onRemoveFilter] - ); - const handleAddTravelTimeAndScroll = useCallback( (mode: TransportMode) => { pendingScrollRef.current = `tt_${travelTimeEntries.length}`; @@ -1444,7 +439,7 @@ export default memo(function Filters({ }, [badgeCount, onSaveSearch, onClearAll]); const handleSaveAndClear = useCallback( - async (e: React.FormEvent) => { + async (e: FormEvent) => { e.preventDefault(); if (!clearSaveName.trim() || savingSearch) return; try { @@ -1463,679 +458,80 @@ export default memo(function Filters({ onClearAll(); }, [onClearAll]); - useEffect(() => { - if (!showClearPopup) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setShowClearPopup(false); - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [showClearPopup]); - return (
-
- + setActiveFilterCollapsed((v) => !v)} + onClearAllClick={handleClearAllClick} + onShowPhilosophy={() => setShowPhilosophy(true)} + onAiFilterSubmit={onAiFilterSubmit} + onLoginRequired={onLoginRequired} + onFilterChange={onFilterChange} + onRemoveFilter={onRemoveFilter} + onDragStart={onDragStart} + onDragChange={onDragChange} + onDragEnd={onDragEnd} + onTogglePin={onTogglePin} + onShowInfo={setActiveInfoFeature} + onTravelTimeRemoveEntry={onTravelTimeRemoveEntry} + onTravelTimeSetDestination={onTravelTimeSetDestination} + onTravelTimeRangeChange={onTravelTimeRangeChange} + onTravelTimeDragEnd={onTravelTimeDragEnd} + onTravelTimeToggleBest={onTravelTimeToggleBest} + /> - {!activeFilterCollapsed && ( -
- -
- -
- {enabledFeatureList.length === 0 && activeEntryCount === 0 && ( -

- {t('filters.addFiltersHint')} -

- )} - -
- {enabledFeatureList.map((feature, featureIdx) => { - if (isSchoolFilterName(feature.name)) { - const schoolBackendName = getSchoolBackendFeatureName(feature.name); - return ( - - {featureIdx === travelInsertIdx && - travelTimeEntries.map((entry, index) => ( -
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label, lat, lon) => - onTravelTimeSetDestination(index, slug, label, lat, lon) - } - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onDragStart={() => onDragStart(travelFieldKey(entry))} - onDragChange={onDragChange} - onDragEnd={() => onTravelTimeDragEnd(index)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - filterImpact={filterImpacts?.[travelFieldKey(entry)]} - destinationDropdownPortal={destinationDropdownPortal} - /> -
- ))} - handleRemoveSchoolFilter(feature.name)} - /> -
- ); - } - - if (isSpecificCrimeFilterName(feature.name)) { - const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name); - return ( - - {featureIdx === travelInsertIdx && - travelTimeEntries.map((entry, index) => ( -
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label, lat, lon) => - onTravelTimeSetDestination(index, slug, label, lat, lon) - } - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onDragStart={() => onDragStart(travelFieldKey(entry))} - onDragChange={onDragChange} - onDragEnd={() => onTravelTimeDragEnd(index)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - filterImpact={filterImpacts?.[travelFieldKey(entry)]} - destinationDropdownPortal={destinationDropdownPortal} - /> -
- ))} - onRemoveFilter(feature.name)} - /> -
- ); - } - - if (isEthnicityFilterName(feature.name)) { - const ethnicityBackendName = getEthnicityFeatureName(feature.name); - return ( - - {featureIdx === travelInsertIdx && - travelTimeEntries.map((entry, index) => ( -
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label, lat, lon) => - onTravelTimeSetDestination(index, slug, label, lat, lon) - } - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onDragStart={() => onDragStart(travelFieldKey(entry))} - onDragChange={onDragChange} - onDragEnd={() => onTravelTimeDragEnd(index)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - filterImpact={filterImpacts?.[travelFieldKey(entry)]} - destinationDropdownPortal={destinationDropdownPortal} - /> -
- ))} - onRemoveFilter(feature.name)} - /> -
- ); - } - - if (isPoiDistanceFilterName(feature.name)) { - const poiBackendName = getPoiDistanceFeatureName(feature.name); - return ( - - {featureIdx === travelInsertIdx && - travelTimeEntries.map((entry, index) => ( -
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label, lat, lon) => - onTravelTimeSetDestination(index, slug, label, lat, lon) - } - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onDragStart={() => onDragStart(travelFieldKey(entry))} - onDragChange={onDragChange} - onDragEnd={() => onTravelTimeDragEnd(index)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - filterImpact={filterImpacts?.[travelFieldKey(entry)]} - destinationDropdownPortal={destinationDropdownPortal} - /> -
- ))} - onRemoveFilter(feature.name)} - /> -
- ); - } - - if (feature.type === 'enum') { - const selectedValues = (filters[feature.name] as string[]) || []; - const allValues = feature.values || []; - return ( - - {featureIdx === travelInsertIdx && - travelTimeEntries.map((entry, index) => ( -
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label, lat, lon) => - onTravelTimeSetDestination(index, slug, label, lat, lon) - } - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onDragStart={() => onDragStart(travelFieldKey(entry))} - onDragChange={onDragChange} - onDragEnd={() => onTravelTimeDragEnd(index)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - filterImpact={filterImpacts?.[travelFieldKey(entry)]} - destinationDropdownPortal={destinationDropdownPortal} - /> -
- ))} -
-
- - -
- - {allValues.map((val) => ( - { - const next = selectedValues.includes(val) - ? selectedValues.filter((v) => v !== val) - : [...selectedValues, val]; - onFilterChange(feature.name, next); - }} - size="xs" - /> - ))} - - {filterImpacts?.[feature.name] != null && - filterImpacts[feature.name] > 0 && ( -

- +{formatNumber(filterImpacts[feature.name])} without this filter -

- )} -
-
- ); - } - - const isActive = activeFeature === feature.name; - const isPinned = pinnedFeature === feature.name; - const hist = feature.histogram; - const displayValue = - isActive && dragValue - ? dragValue - : (filters[feature.name] as [number, number]) || [ - hist?.min ?? feature.min!, - hist?.max ?? feature.max!, - ]; - const scale = percentileScales.get(feature.name); - const dataMin = hist?.min ?? feature.min!; - const dataMax = hist?.max ?? feature.max!; - const clampMin = displayValue[0] <= dataMin; - const clampMax = displayValue[1] >= dataMax; - const isAtMin = displayValue[0] === dataMin; - const isAtMax = 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 ? feature.min! : displayValue[0], - clampMax ? feature.max! : displayValue[1], - ]; - - const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; - const mobileIcon = - getFeatureIcon(feature.name, mobileIconClass) || - (() => { - const G = feature.group ? getGroupIcon(feature.group) : null; - return G ? : null; - })(); - - return ( - - {featureIdx === travelInsertIdx && - travelTimeEntries.map((entry, index) => ( -
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label, lat, lon) => - onTravelTimeSetDestination(index, slug, label, lat, lon) - } - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onDragStart={() => onDragStart(travelFieldKey(entry))} - onDragChange={onDragChange} - onDragEnd={() => onTravelTimeDragEnd(index)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - filterImpact={filterImpacts?.[travelFieldKey(entry)]} - destinationDropdownPortal={destinationDropdownPortal} - /> -
- ))} -
-
- - -
-
- {mobileIcon && ( -
{mobileIcon}
- )} -
- { - const step = feature.step ?? 1; - const snap = (v: number) => Math.round(v / step) * step; - onDragChange([ - pMin <= 0 - ? (hist?.min ?? feature.min!) - : snap(scale.toValue(pMin)), - pMax >= 100 - ? (hist?.max ?? feature.max!) - : snap(scale.toValue(pMax)), - ]); - } - : ([min, max]) => - onDragChange([ - min <= feature.min! ? (hist?.min ?? feature.min!) : min, - max >= feature.max! ? (hist?.max ?? feature.max!) : max, - ]) - } - onPointerDown={() => onDragStart(feature.name)} - onPointerUp={() => onDragEnd()} - /> - onFilterChange(feature.name, v)} - /> - {filterImpacts?.[feature.name] != null && - filterImpacts[feature.name] > 0 && ( -

- +{formatNumber(filterImpacts[feature.name])} without this filter -

- )} -
-
-
-
- ); - })} - {travelInsertIdx >= enabledFeatureList.length && - travelTimeEntries.map((entry, index) => ( -
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label, lat, lon) => - onTravelTimeSetDestination(index, slug, label, lat, lon) - } - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onDragStart={() => onDragStart(travelFieldKey(entry))} - onDragChange={onDragChange} - onDragEnd={() => onTravelTimeDragEnd(index)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - filterImpact={filterImpacts?.[travelFieldKey(entry)]} - destinationDropdownPortal={destinationDropdownPortal} - /> -
- ))} -
-
- )} -
- -
- - {(!addFilterCollapsed || !isLicensed) && ( -
-
- {!addFilterCollapsed && ( - { - if (name === SCHOOL_FILTER_NAME) { - if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName); - return; - } - if (name === SPECIFIC_CRIMES_FILTER_NAME) { - if (defaultSpecificCrimeFeatureName) - onTogglePin(defaultSpecificCrimeFeatureName); - return; - } - if (name === ETHNICITIES_FILTER_NAME) { - if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName); - return; - } - if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { - const defaultPoiFeatureName = - defaultPoiFilterFeatureNames[name as PoiFilterName]; - if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName); - return; - } - onTogglePin(name); - }} - onNavigateToSource={onNavigateToSource} - openInfoFeature={openInfoFeature} - onClearOpenInfoFeature={onClearOpenInfoFeature} - travelTimeEntries={travelTimeEntries} - onAddTravelTimeEntry={handleAddTravelTimeAndScroll} - /> - )} - {!isLicensed && ( -
-

- {t('filters.upgradePrompt')} -

-

- {t('filters.oneTimeLifetime')} -

- - - - - - - -
- )} -
-
- )} -
+ setAddFilterCollapsed((v) => !v)} + onAddFilter={handleAddAndScroll} + onTogglePin={onTogglePin} + onNavigateToSource={onNavigateToSource} + onClearOpenInfoFeature={onClearOpenInfoFeature} + onAddTravelTimeEntry={handleAddTravelTimeAndScroll} + onUpgradeClick={onUpgradeClick} + /> {showPhilosophy && ( )} - {showClearPopup && ( -
setShowClearPopup(false)} - > -
-
e.stopPropagation()} - > -
-

- {t('filters.clearAllTitle')} -

- -
-
-

- {t('filters.clearAllSavePrompt')} -

-
- setClearSaveName(e.target.value)} - className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" - placeholder={t('saveSearch.namePlaceholder')} - autoFocus - /> -
- {clearSaveError && ( -

{clearSaveError}

- )} -
- - -
-
-
-
- )} + setShowClearPopup(false)} + onSaveNameChange={setClearSaveName} + onSaveAndClear={handleSaveAndClear} + onClearWithoutSaving={handleClearWithoutSaving} + />
); }); diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 497e1f2..3ff755a 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -1,19 +1,8 @@ -import { lazy, Suspense, useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { cellToLatLng } from 'h3-js'; -import type { - FeatureMeta, - FeatureFilters, - POICategoryGroup, - ViewState, - PostcodeGeometry, - Property, - MapFlyToOptions, -} from '../../types'; + +import type { PostcodeGeometry, Property } from '../../types'; import type { SearchedLocation } from './LocationSearch'; -import type { Page } from '../ui/Header'; -import MobileBottomSheet from './MobileBottomSheet'; -import MapLegend from './MapLegend'; import { useMapData } from '../../hooks/useMapData'; import { usePOIData } from '../../hooks/usePOIData'; import { useFilters } from '../../hooks/useFilters'; @@ -24,159 +13,43 @@ import { useAiFilters } from '../../hooks/useAiFilters'; import { useUrlSync } from '../../hooks/useUrlSync'; import { useTutorial } from '../../hooks/useTutorial'; import { getTutorialStyles } from '../../lib/tutorial-styles'; -import { - useTravelTime, - useTranslatedModes, - travelFieldKey, - type TravelTimeInitial, -} from '../../hooks/useTravelTime'; -import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api'; +import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime'; +import { apiUrl, authHeaders } from '../../lib/api'; import { useFilterCounts } from '../../hooks/useFilterCounts'; -import { ts } from '../../i18n/server'; import { trackEvent } from '../../lib/analytics'; -import { canWheelScrollInsideTarget } from '../../lib/dom-scroll'; import { INITIAL_VIEW_STATE } from '../../lib/consts'; -import { getSchoolBackendFeatureName } from '../../lib/school-filter'; -import { getSpecificCrimeFeatureName } from '../../lib/crime-filter'; -import { getEthnicityFeatureName } from '../../lib/ethnicity-filter'; -import { getPoiDistanceFeatureName } from '../../lib/poi-distance-filter'; import { useLicense } from '../../hooks/useLicense'; -import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; -import { MapPinIcon } from '../ui/icons/MapPinIcon'; -import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; -import { CheckIcon } from '../ui/icons/CheckIcon'; -import { CloseIcon } from '../ui/icons/CloseIcon'; -import { InfoIcon } from '../ui/icons/InfoIcon'; +import { + AreaPane, + Filters, + POIPane, + PropertiesPane, + UpgradeModal, +} from './map-page/lazyComponents'; +import { PaneFallback } from './map-page/Fallbacks'; +import { DesktopMapPage } from './map-page/DesktopMapPage'; +import { MobileMapPage } from './map-page/MobileMapPage'; +import { ScreenshotMapPage } from './map-page/ScreenshotMapPage'; +import { BookmarkToast, ExportToast } from './map-page/Toasts'; +import { MobileMapLegend } from './map-page/MobileMapLegend'; +import { useExportController } from './map-page/useExportController'; +import { + useHexagonLocation, + useJourneyDestination, + useMapViewFeature, + useMobileDensityRange, + useMobileLegendMeta, +} from './map-page/derivedState'; +import { + useHorizontalSwipeNavigationGuard, + useInitialMapPageView, + useInitialPostcodeSelection, + useMobileBackNavigationGuard, + useScreenshotReadySignal, +} from './map-page/effects'; +import type { MapFlyTo, MapPageProps } from './map-page/types'; -const Map = lazy(() => import('./Map')); -const Filters = lazy(() => import('./Filters')); -const POIPane = lazy(() => import('./POIPane')); -const AreaPane = lazy(() => import('./AreaPane')); -const PropertiesPane = lazy(() => - import('./PropertiesPane').then((module) => ({ default: module.PropertiesPane })) -); -const MobileDrawer = lazy(() => import('./MobileDrawer')); -const MapPageSelectionPane = lazy(() => - import('./MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane })) -); -const UpgradeModal = lazy(() => import('../ui/UpgradeModal')); -const Joyride = lazy(() => import('react-joyride').then((module) => ({ default: module.Joyride }))); - -const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx'; -const EXPORT_TIMEOUT_MS = 150_000; -const EXPORT_NOTICE_MS = 6000; -const EXPORT_ERROR_NOTICE_MS = 9000; - -type ExportNotice = { - kind: 'success' | 'error'; - message: string; -}; - -function getExportFileName(res: Response): string { - const disposition = res.headers.get('content-disposition'); - if (!disposition) return EXPORT_FILE_NAME; - - const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i); - if (encodedMatch?.[1]) { - try { - return decodeURIComponent(encodedMatch[1].trim()); - } catch { - return encodedMatch[1].trim(); - } - } - - const match = disposition.match(/filename="?([^";]+)"?/i); - return match?.[1]?.trim() || EXPORT_FILE_NAME; -} - -async function getExportErrorMessage(res: Response): Promise { - const fallback = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`; - const contentType = res.headers.get('content-type') ?? ''; - - try { - if (contentType.includes('application/json')) { - const data: unknown = await res.json(); - if (data && typeof data === 'object') { - const record = data as Record; - const message = record.message ?? record.error; - if (typeof message === 'string' && message.trim()) return message.trim(); - } - return fallback; - } - - const text = await res.text(); - return text.trim() || fallback; - } catch { - return fallback; - } -} - -function triggerExportDownload(blob: Blob, fileName: string): void { - const objectUrl = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = objectUrl; - link.download = fileName; - link.rel = 'noopener'; - link.style.display = 'none'; - - document.body.appendChild(link); - link.click(); - link.remove(); - - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); -} - -function MapFallback() { - return ( -
- -
- ); -} - -function PaneFallback() { - return ( -
- -
- ); -} - -export interface ExportState { - onExport: () => void; - exporting: boolean; -} - -interface MapPageProps { - features: FeatureMeta[]; - poiCategoryGroups: POICategoryGroup[]; - initialFilters: FeatureFilters; - initialViewState: ViewState; - initialPOICategories: Set; - initialTab: 'properties' | 'area'; - initialLoading: boolean; - theme: 'light' | 'dark'; - pendingInfoFeature: string | null; - onClearPendingInfoFeature: () => void; - onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void; - onExportStateChange?: (state: ExportState) => void; - screenshotMode?: boolean; - ogMode?: boolean; - isMobile?: boolean; - initialTravelTime?: TravelTimeInitial; - initialPostcode?: string; - shareCode?: string; - user?: { id: string; subscription: string; isAdmin?: boolean } | null; - onLoginClick: () => void; - onRegisterClick: () => void; - onSaveProperty?: (property: Property) => void; - onUnsaveProperty?: (id: string) => void; - isPropertySaved?: (address?: string, postcode?: string) => boolean; - getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined; - deferTutorial?: boolean; - onSaveSearch?: (name: string) => Promise; - savingSearch?: boolean; -} +export type { ExportState } from './map-page/types'; export default function MapPage({ features, @@ -208,21 +81,17 @@ export default function MapPage({ onSaveSearch, savingSearch, }: MapPageProps) { + const { t } = useTranslation(); const [selectedPOICategories, setSelectedPOICategories] = useState>(initialPOICategories); - const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left'); const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right'); - const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0); const [poiPaneOpen, setPoiPaneOpen] = useState(false); const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null); - const [showBookmarkToast, setShowBookmarkToast] = useState(false); const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1'); - const [exportNotice, setExportNotice] = useState(null); - const exportNoticeTimeoutRef = useRef(null); const handleSavePropertyWithToast = useCallback( (property: Property) => { @@ -235,38 +104,6 @@ export default function MapPage({ [onSaveProperty] ); - const { t } = useTranslation(); - const modes = useTranslatedModes(); - - const clearExportNoticeTimer = useCallback(() => { - if (exportNoticeTimeoutRef.current !== null) { - window.clearTimeout(exportNoticeTimeoutRef.current); - exportNoticeTimeoutRef.current = null; - } - }, []); - - const clearExportNotice = useCallback(() => { - clearExportNoticeTimer(); - setExportNotice(null); - }, [clearExportNoticeTimer]); - - const showExportNotice = useCallback( - (notice: ExportNotice) => { - clearExportNoticeTimer(); - setExportNotice(notice); - exportNoticeTimeoutRef.current = window.setTimeout( - () => { - setExportNotice(null); - exportNoticeTimeoutRef.current = null; - }, - notice.kind === 'error' ? EXPORT_ERROR_NOTICE_MS : EXPORT_NOTICE_MS - ); - }, - [clearExportNoticeTimer] - ); - - useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]); - const { filters, activeFeature, @@ -311,9 +148,7 @@ export default function MapPage({ handleToggleBest, } = useTravelTime(initialTravelTime); - const mapFlyToRef = useRef< - ((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null - >(null); + const mapFlyToRef = useRef(null); const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null); const mobileDrawerPanelRectRef = useRef(null); @@ -326,9 +161,9 @@ export default function MapPage({ travelTimeEntries: entries, shareCode, }); + const handleAiFilterSubmit = useCallback( async (query: string) => { - // Build context from current filters for conversational refinement const context = { filters, travelTime: activeEntries.map((entry) => ({ @@ -342,48 +177,48 @@ export default function MapPage({ const result = await fetchAiFilters(query, hasContext ? context : undefined); if (!result) return; - handleSetFilters(result.filters); - // Always sync travel time entries — clear stale ones when AI returns none - const newEntries = result.travelTimeFilters.map((tt) => ({ - mode: tt.mode, - slug: tt.slug, - label: tt.label, - timeRange: [tt.min ?? 0, tt.max ?? 120] as [number, number], - useBest: false, - })); - handleSetEntries(newEntries); - // Pan to the first travel time destination (mirroring handleTravelTimeSetDestination) - const firstTT = result.travelTimeFilters[0]; - if (firstTT?.slug) { - try { - const res = await fetch( - apiUrl('travel-destinations', new URLSearchParams({ mode: firstTT.mode })), - authHeaders({}) + handleSetFilters(result.filters); + handleSetEntries( + result.travelTimeFilters.map((travelTimeFilter) => ({ + mode: travelTimeFilter.mode, + slug: travelTimeFilter.slug, + label: travelTimeFilter.label, + timeRange: [travelTimeFilter.min ?? 0, travelTimeFilter.max ?? 120] as [number, number], + useBest: false, + })) + ); + + const firstTravelTime = result.travelTimeFilters[0]; + if (!firstTravelTime?.slug) return; + + try { + const res = await fetch( + apiUrl('travel-destinations', new URLSearchParams({ mode: firstTravelTime.mode })), + authHeaders({}) + ); + if (!res.ok) return; + + const data: { destinations: { slug: string; lat: number; lon: number }[] } = + await res.json(); + const destination = data.destinations.find((item) => item.slug === firstTravelTime.slug); + if (destination) { + mapFlyToRef.current?.( + destination.lat, + destination.lon, + mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom ); - if (res.ok) { - const data: { destinations: { slug: string; lat: number; lon: number }[] } = - await res.json(); - const dest = data.destinations.find((d) => d.slug === firstTT.slug); - if (dest) { - mapFlyToRef.current?.( - dest.lat, - dest.lon, - mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom - ); - } - } - } catch { - // Non-critical — filters are already applied, just skip the pan } + } catch { + // Filters are already applied; destination panning is non-critical. } }, [ - fetchAiFilters, - handleSetFilters, - handleSetEntries, activeEntries, + fetchAiFilters, filters, + handleSetEntries, + handleSetFilters, mapData.currentView?.zoom, ] ); @@ -402,20 +237,19 @@ export default function MapPage({ } handleRemoveEntry(index); }, - [handleRemoveEntry, entries, pinnedFeature, handleCancelPin] + [handleCancelPin, handleRemoveEntry, entries, pinnedFeature] ); const handleTravelTimeDragEnd = useCallback( (index: number) => { - const dv = handleDragEndNoCommit(); - if (dv) handleTimeRangeChange(index, dv); + const dragEndValue = handleDragEndNoCommit(); + if (dragEndValue) handleTimeRangeChange(index, dragEndValue); }, [handleDragEndNoCommit, handleTimeRangeChange] ); - const license = useLicense(); - const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries); + const license = useLicense(); const handleTravelTimeSetDestination = useCallback( (index: number, slug: string, label: string, lat: number, lon: number) => { @@ -427,11 +261,7 @@ export default function MapPage({ [handleSetDestination, mapData.currentView?.zoom] ); - // First transit destination — used to pick the best central_postcode for journey display - const journeyDest = useMemo(() => { - const entry = entries.find((e) => e.mode === 'transit' && e.slug); - return entry ? { mode: entry.mode, slug: entry.slug } : null; - }, [entries]); + const journeyDest = useJourneyDestination(entries); const { selectedHexagon, @@ -486,7 +316,7 @@ export default function MapPage({ handleCloseSelection(); } }, - [handleLocationSearch, handleCloseSelection, isMobile] + [handleCloseSelection, handleLocationSearch, isMobile] ); const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => { @@ -530,10 +360,6 @@ export default function MapPage({ setMobileDrawerOpen(false); }, []); - // For share-link recipients, "Continue with Demo" snaps back to the shared - // coords (the area their link was meant to show), not the central-London - // free-zone demo. Captured once on mount so a later URL rewrite by - // useUrlSync (which tracks the user's current view) doesn't overwrite it. const shareReturnViewRef = useRef(shareCode ? initialViewState : null); const handleZoomToFreeZone = useCallback(() => { const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE; @@ -553,68 +379,23 @@ export default function MapPage({ shareCode ); - useEffect(() => { - mapData.setInitialView(initialViewState); - setRightPaneTab(initialTab); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Navigate to a specific postcode on mount (e.g. from saved properties) - useEffect(() => { - if (!initialPostcode) return; - // Strip the `pc` param from the URL so it doesn't persist - const params = new URLSearchParams(window.location.search); - params.delete('pc'); - const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard'; - window.history.replaceState(window.history.state, '', newUrl); - - // Fetch postcode geometry and fly to it - fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders()) - .then((res) => { - if (!res.ok) throw new Error('Postcode not found'); - return res.json(); - }) - .then( - (data: { - postcode: string; - latitude: number; - longitude: number; - geometry: PostcodeGeometry; - }) => { - mapFlyToRef.current?.(data.latitude, data.longitude, 16); - handleLocationSearch(data.postcode, data.geometry, data.latitude, data.longitude); - if (isMobile) setMobileDrawerOpen(true); - } - ) - .catch(() => { - // Silently fail — postcode might not exist - }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Prevent browser back/forward navigation from horizontal trackpad swipes - useEffect(() => { - const handleWheel = (e: WheelEvent) => { - if ( - Math.abs(e.deltaX) > Math.abs(e.deltaY) && - !canWheelScrollInsideTarget(e.target, e.deltaX, e.deltaY) - ) { - e.preventDefault(); - } - }; - document.addEventListener('wheel', handleWheel, { passive: false }); - return () => document.removeEventListener('wheel', handleWheel); - }, []); - - // On mobile, push a guard history entry to absorb accidental back navigations - // (e.g. iOS Safari edge-swipe that CSS touch-action can't prevent) - useEffect(() => { - if (!isMobile) return; - window.history.pushState({ dashboardGuard: true }, ''); - const handlePopState = () => { - window.history.pushState({ dashboardGuard: true }, ''); - }; - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, [isMobile]); + useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab); + useInitialPostcodeSelection({ + initialPostcode, + isMobile, + flyTo: mapFlyToRef, + onLocationSearch: handleLocationSearch, + onOpenMobileDrawer: () => setMobileDrawerOpen(true), + }); + useHorizontalSwipeNavigationGuard(); + useMobileBackNavigationGuard(isMobile); + useScreenshotReadySignal({ + screenshotMode, + loading: mapData.loading, + dataLength: mapData.data.length, + postcodeDataLength: mapData.postcodeData.length, + usePostcodeView: mapData.usePostcodeView, + }); const handleMobileHexagonClick = useCallback( (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => { @@ -626,254 +407,44 @@ export default function MapPage({ [handleHexagonClick] ); - const hexagonLocation = useMemo(() => { - const hexId = selectedHexagon?.id; - const isPostcode = selectedHexagon?.type === 'postcode'; - - if (isPostcode) { - // For postcodes, get centroid from postcodeData; postcode string is the selection id - const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId); - if (!postcodeFeature?.properties.centroid) return null; - const [lon, lat] = postcodeFeature.properties.centroid; - return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true }; - } else { - if (!hexId) return null; - const [lat, lon] = cellToLatLng(hexId); - return { - lat, - lon, - resolution: selectedHexagon?.resolution ?? mapData.resolution, - postcode: areaStats?.central_postcode, - }; - } - }, [ - selectedHexagon?.id, - selectedHexagon?.resolution, - selectedHexagon?.type, + const hexagonLocation = useHexagonLocation( + selectedHexagon, mapData.postcodeData, mapData.resolution, - areaStats?.central_postcode, - ]); - + areaStats + ); const tutorial = useTutorial(initialLoading, isMobile, deferTutorial); const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]); - - const [exporting, setExporting] = useState(false); - const handleExport = useCallback(() => { - if (exporting) return; - if (!mapData.bounds) { - showExportNotice({ kind: 'error', message: t('header.exportUnavailable') }); - return; - } - - const { south, west, north, east } = mapData.bounds; - const params = new URLSearchParams({ - bounds: `${south},${west},${north},${east}`, - }); - const filterStr = buildFilterString(filters, features); - if (filterStr) params.set('filters', filterStr); - const url = apiUrl('export', params); - - const controller = new AbortController(); - let timedOut = false; - const timeoutId = window.setTimeout(() => { - timedOut = true; - controller.abort(); - }, EXPORT_TIMEOUT_MS); - - setExporting(true); - clearExportNotice(); - - void (async () => { - try { - const res = await fetch(url, authHeaders({ signal: controller.signal })); - if (!res.ok) throw new Error(await getExportErrorMessage(res)); - - const blob = await res.blob(); - if (blob.size === 0) throw new Error(t('header.exportEmpty')); - - triggerExportDownload(blob, getExportFileName(res)); - trackEvent('Export'); - showExportNotice({ kind: 'success', message: t('header.exportReady') }); - } catch (err) { - if (!timedOut) logNonAbortError('Export failed', err); - const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : ''; - showExportNotice({ - kind: 'error', - message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`, - }); - } finally { - window.clearTimeout(timeoutId); - setExporting(false); - } - })(); - }, [ - clearExportNotice, - exporting, - features, + const densityLabel = t('mapLegend.historicalMatches'); + const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0; + const mobileLegendMeta = useMobileLegendMeta(viewFeature, features); + const mapViewFeature = useMapViewFeature(viewFeature); + const mobileDensityRange = useMobileDensityRange(mapData); + const { exportNotice, clearExportNotice } = useExportController({ + bounds: mapData.bounds, filters, - mapData.bounds, - showExportNotice, + features, t, - ]); - - useEffect(() => { - onExportStateChange?.({ onExport: handleExport, exporting }); - }, [handleExport, exporting, onExportStateChange]); + onExportStateChange, + }); useEffect(() => { if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown'); }, [mapData.licenseRequired]); - const densityLabel = t('mapLegend.historicalMatches'); - const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0; - - const mobileLegendMeta = useMemo(() => { - const featureName = viewFeature - ? (getSchoolBackendFeatureName(viewFeature) ?? - getSpecificCrimeFeatureName(viewFeature) ?? - getEthnicityFeatureName(viewFeature) ?? - getPoiDistanceFeatureName(viewFeature) ?? - viewFeature) - : null; - return featureName ? features.find((f) => f.name === featureName) || null : null; - }, [viewFeature, features]); - const mapViewFeature = useMemo( - () => - viewFeature - ? (getSchoolBackendFeatureName(viewFeature) ?? - getSpecificCrimeFeatureName(viewFeature) ?? - getEthnicityFeatureName(viewFeature) ?? - getPoiDistanceFeatureName(viewFeature) ?? - viewFeature) - : null, - [viewFeature] - ); - const mobileDensityRange = useMemo((): [number, number] => { - const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data; - if (items.length === 0) return [0, 1]; - let min = Infinity; - let max = -Infinity; - for (const d of items) { - const c = 'count' in d ? d.count : d.properties.count; - if (c < min) min = c; - if (c > max) max = c; - } - if (min === Infinity) return [0, 1]; - if (min === max) return [min, min + 1]; - return [min, max]; - }, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]); - - useEffect(() => { - if (screenshotMode && !mapData.loading) { - const hasData = mapData.usePostcodeView - ? mapData.postcodeData.length > 0 - : mapData.data.length > 0; - if (hasData) { - // Wait for both deck.gl data AND MapLibre base map tile rendering. - // __map_idle is set by Map's onIdle callback, which fires after all - // tiles are loaded and rendered — critical for SwiftShader where - // edge tiles can lag behind the center. - const waitAndSignal = () => { - if (window.__map_idle) { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - window.__screenshot_ready = true; - }); - }); - } else { - requestAnimationFrame(waitAndSignal); - } - }; - waitAndSignal(); - } - } - }, [ - screenshotMode, - mapData.loading, - mapData.data.length, - mapData.postcodeData.length, - mapData.usePostcodeView, - ]); - - const bookmarkToast = showBookmarkToast && ( -
- - Property saved! - - -
- ); - - const exportToast = exportNotice && ( -
- {exportNotice.kind === 'success' ? ( - - ) : ( - - )} - {exportNotice.message} - -
- ); - if (screenshotMode) { return ( -
- }> - {}} - onResetPreviewScale={mapData.handleResetPreviewScale} - canResetPreviewScale={mapData.canResetPreviewScale} - features={features} - selectedHexagonId={null} - hoveredHexagonId={null} - onHexagonClick={() => {}} - onHexagonHover={() => {}} - initialViewState={initialViewState} - theme={theme} - screenshotMode - ogMode={ogMode} - bounds={mapData.bounds} - travelTimeEntries={entries} - /> - -
+ ); } @@ -887,8 +458,9 @@ export default function MapPage({ isPostcode={selectedHexagon?.type === 'postcode'} postcodeData={ selectedHexagon?.type === 'postcode' - ? mapData.postcodeData.find((f) => f.properties.postcode === selectedHexagon?.id) || - null + ? mapData.postcodeData.find( + (feature) => feature.properties.postcode === selectedHexagon?.id + ) || null : null } onViewProperties={handleViewPropertiesFromArea} @@ -976,319 +548,157 @@ export default function MapPage({ ); - const renderMobileLegend = () => { - if (mapViewFeature && mapData.colorRange) { - if (mapViewFeature.startsWith('tt_')) { - return ( - - ); - } - - if (mobileLegendMeta) { - return ( - - ); - } - - return null; + const handleTogglePoiPane = () => setPoiPaneOpen((open) => !open); + const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => { + if (tab === 'properties') { + handlePropertiesTabClick(); + } else { + setRightPaneTab(tab); } - - return ( - - ); }; + const bookmarkToast = ( + { + setShowBookmarkToast(false); + onNavigateTo('saved', 'properties'); + }} + onDismissForever={() => { + setShowBookmarkToast(false); + localStorage.setItem('bookmark_toast_dismissed', '1'); + }} + /> + ); + const exportToast = ( + + ); + const toasts = ( + <> + {bookmarkToast} + {exportToast} + + ); + const upgradeModal = mapData.licenseRequired ? ( + + license.startCheckout()} + onZoomToFreeZone={handleZoomToFreeZone} + isShareReturn={!!shareReturnViewRef.current} + /> + + ) : null; + if (isMobile) { return ( -
- {initialLoading && ( -
-
- -

- Connecting to server... -

-
-
- )} - -
- }> - - -
- - - - {poiPaneOpen && ( -
- {renderPOIPane()} -
- )} - - - {renderFilters({ destinationDropdownPortal: false })} - - - {mobileDrawerOpen && selectedHexagon && ( - }> - { - if (t === 'properties') { - handlePropertiesTabClick(); - } else { - setRightPaneTab(t); - } - }} - /> - - )} - - {bookmarkToast} - {exportToast} - - {mapData.licenseRequired && ( - - license.startCheckout()} - onZoomToFreeZone={handleZoomToFreeZone} - isShareReturn={!!shareReturnViewRef.current} - /> - - )} -
+ + } + renderAreaPane={renderAreaPane} + renderPropertiesPane={renderPropertiesPane} + toasts={toasts} + upgradeModal={upgradeModal} + /> ); } return ( -
- {initialLoading && ( -
-
- -

- Connecting to server... -

-
-
- )} - - {tutorial.run && ( - - - - )} - -
-
{renderFilters()}
-
-
-
-
-
-
-
-
- -
- {tutorial.run && ( - <> - - - {selectedHexagon && ( - }> - setRightPaneTab('area')} - onPropertiesTabClick={handlePropertiesTabClick} - onClose={handleCloseSelection} - renderAreaPane={renderAreaPane} - renderPropertiesPane={renderPropertiesPane} - /> - - )} - - {bookmarkToast} - {exportToast} - - {mapData.licenseRequired && ( - - license.startCheckout()} - onZoomToFreeZone={handleZoomToFreeZone} - isShareReturn={!!shareReturnViewRef.current} - /> - - )} -
+ setRightPaneTab('area')} + onPropertiesTabClick={handlePropertiesTabClick} + onCloseSelection={handleCloseSelection} + renderAreaPane={renderAreaPane} + renderPropertiesPane={renderPropertiesPane} + toasts={toasts} + upgradeModal={upgradeModal} + /> ); } diff --git a/frontend/src/components/map/MobileBottomSheet.tsx b/frontend/src/components/map/MobileBottomSheet.tsx index 1209335..54dec45 100644 --- a/frontend/src/components/map/MobileBottomSheet.tsx +++ b/frontend/src/components/map/MobileBottomSheet.tsx @@ -9,9 +9,17 @@ interface VisualViewportState { interface MobileBottomSheetProps { children: ReactNode; legend?: ReactNode; + onCoveredHeightChange?: (height: number) => void; } -function getVisualViewportState(): VisualViewportState { +function getVisualViewportState(avoidKeyboard: boolean): VisualViewportState { + if (!avoidKeyboard) { + return { + height: window.innerHeight, + bottomInset: 0, + }; + } + const vv = window.visualViewport; if (!vv) { return { @@ -27,25 +35,36 @@ function getVisualViewportState(): VisualViewportState { }; } -function useVisualViewportState(): VisualViewportState { - const [state, setState] = useState(getVisualViewportState); +function useVisualViewportState(avoidKeyboard: boolean): VisualViewportState { + const [state, setState] = useState(() => getVisualViewportState(avoidKeyboard)); useEffect(() => { const vv = window.visualViewport; - const update = () => setState(getVisualViewportState()); + const update = () => { + const next = getVisualViewportState(avoidKeyboard); + setState((prev) => + prev.height === next.height && prev.bottomInset === next.bottomInset ? prev : next + ); + }; + + update(); window.addEventListener('resize', update); window.addEventListener('orientationchange', update); - vv?.addEventListener('resize', update); - vv?.addEventListener('scroll', update); + if (avoidKeyboard) { + vv?.addEventListener('resize', update); + vv?.addEventListener('scroll', update); + } return () => { window.removeEventListener('resize', update); window.removeEventListener('orientationchange', update); - vv?.removeEventListener('resize', update); - vv?.removeEventListener('scroll', update); + if (avoidKeyboard) { + vv?.removeEventListener('resize', update); + vv?.removeEventListener('scroll', update); + } }; - }, []); + }, [avoidKeyboard]); return state; } @@ -54,12 +73,46 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -export default function MobileBottomSheet({ children, legend }: MobileBottomSheetProps) { - const viewport = useVisualViewportState(); +function isKeyboardEditableElement(element: HTMLElement): boolean { + if (element instanceof HTMLTextAreaElement) return true; + if (element instanceof HTMLInputElement) { + return ![ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit', + ].includes(element.type); + } + return element.isContentEditable; +} + +function getKeyboardEditableElement(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Element)) return null; + + const element = target.closest('input, textarea, [contenteditable]'); + if (!(element instanceof HTMLElement)) return null; + + return isKeyboardEditableElement(element) ? element : null; +} + +export default function MobileBottomSheet({ + children, + legend, + onCoveredHeightChange, +}: MobileBottomSheetProps) { + const [keyboardAvoidanceActive, setKeyboardAvoidanceActive] = useState(false); + const viewport = useVisualViewportState(keyboardAvoidanceActive); const sheetRef = useRef(null); const scrollRef = useRef(null); const dragStartYRef = useRef(0); const dragStartHeightRef = useRef(0); + const scrollIntoViewTimerRef = useRef(null); const [height, setHeight] = useState(null); const [isDragging, setIsDragging] = useState(false); @@ -80,6 +133,10 @@ export default function MobileBottomSheet({ children, legend }: MobileBottomShee ); }, [heightBounds]); + useEffect(() => { + onCoveredHeightChange?.(Math.round(currentHeight + viewport.bottomInset)); + }, [currentHeight, onCoveredHeightChange, viewport.bottomInset]); + const handlePointerDown = useCallback( (e: React.PointerEvent) => { e.preventDefault(); @@ -106,30 +163,61 @@ export default function MobileBottomSheet({ children, legend }: MobileBottomShee setIsDragging(false); }, []); + const handleSheetPointerDown = useCallback((event: React.PointerEvent) => { + if (getKeyboardEditableElement(event.target)) return; + + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLElement && + sheetRef.current?.contains(activeElement) && + isKeyboardEditableElement(activeElement) + ) { + activeElement.blur(); + } + }, []); + useEffect(() => { const sheet = sheetRef.current; if (!sheet) return; const handleFocusIn = (event: FocusEvent) => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - if (!target.matches('input, textarea, select, [contenteditable="true"]')) return; + const target = getKeyboardEditableElement(event.target); + if (!target || !sheet.contains(target)) return; + setKeyboardAvoidanceActive(true); const keyboardMinHeight = Math.min(heightBounds.max, Math.max(300, viewport.height * 0.55)); setHeight((value) => Math.max(value ?? heightBounds.initial, keyboardMinHeight)); - window.setTimeout(() => { + if (scrollIntoViewTimerRef.current != null) { + window.clearTimeout(scrollIntoViewTimerRef.current); + } + scrollIntoViewTimerRef.current = window.setTimeout(() => { target.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 120); }; + const handleFocusOut = (event: FocusEvent) => { + const nextTarget = getKeyboardEditableElement(event.relatedTarget); + if (nextTarget && sheet.contains(nextTarget)) return; + + setKeyboardAvoidanceActive(false); + }; + sheet.addEventListener('focusin', handleFocusIn); - return () => sheet.removeEventListener('focusin', handleFocusIn); + sheet.addEventListener('focusout', handleFocusOut); + return () => { + sheet.removeEventListener('focusin', handleFocusIn); + sheet.removeEventListener('focusout', handleFocusOut); + if (scrollIntoViewTimerRef.current != null) { + window.clearTimeout(scrollIntoViewTimerRef.current); + } + }; }, [heightBounds.initial, heightBounds.max, viewport.height]); return (
; + percentileScales: Map; + destinationDropdownPortal: boolean; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onRemoveFilter: (name: string) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onTravelTimeRemoveEntry: (index: number) => void; + onTravelTimeSetDestination: ( + index: number, + slug: string, + label: string, + lat: number, + lon: number + ) => void; + onTravelTimeRangeChange: (index: number, range: [number, number]) => void; + onTravelTimeDragEnd: (index: number) => void; + onTravelTimeToggleBest: (index: number) => void; +} + +export function ActiveFilterList({ + features, + filters, + enabledFeatureList, + activeFeature, + dragValue, + pinnedFeature, + travelTimeEntries, + travelInsertIdx, + filterImpacts, + percentileScales, + destinationDropdownPortal, + onFilterChange, + onRemoveFilter, + onDragStart, + onDragChange, + onDragEnd, + onTogglePin, + onShowInfo, + onTravelTimeRemoveEntry, + onTravelTimeSetDestination, + onTravelTimeRangeChange, + onTravelTimeDragEnd, + onTravelTimeToggleBest, +}: ActiveFilterListProps) { + const travelCards = ( + + ); + + return ( +
+ {enabledFeatureList.map((feature, featureIdx) => { + const insertTravelCards = featureIdx === travelInsertIdx; + + if (isSchoolFilterName(feature.name)) { + const schoolBackendName = getSchoolBackendFeatureName(feature.name); + return ( + + {insertTravelCards && travelCards} + onRemoveFilter(feature.name)} + /> + + ); + } + + if (isSpecificCrimeFilterName(feature.name)) { + const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name); + return ( + + {insertTravelCards && travelCards} + onRemoveFilter(feature.name)} + /> + + ); + } + + if (isEthnicityFilterName(feature.name)) { + const ethnicityBackendName = getEthnicityFeatureName(feature.name); + return ( + + {insertTravelCards && travelCards} + onRemoveFilter(feature.name)} + /> + + ); + } + + if (isPoiDistanceFilterName(feature.name)) { + const poiBackendName = getPoiDistanceFeatureName(feature.name); + return ( + + {insertTravelCards && travelCards} + onRemoveFilter(feature.name)} + /> + + ); + } + + return ( + + {insertTravelCards && travelCards} + {feature.type === 'enum' ? ( + + ) : ( + + )} + + ); + })} + {travelInsertIdx >= enabledFeatureList.length && travelCards} +
+ ); +} diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx new file mode 100644 index 0000000..d9bec44 --- /dev/null +++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx @@ -0,0 +1,204 @@ +import type { RefObject } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { AiFilterErrorType } from '../../../hooks/useAiFilters'; +import type { TravelTimeEntry } from '../../../hooks/useTravelTime'; +import type { PercentileScale } from '../../../lib/format'; +import type { FeatureFilters, FeatureMeta } from '../../../types'; +import { ChevronIcon, LightbulbIcon } from '../../ui/icons'; +import AiFilterInput from '../AiFilterInput'; +import { ActiveFilterList } from './ActiveFilterList'; + +interface ActiveFiltersPanelProps { + scrollRef: RefObject; + collapsed: boolean; + badgeCount: number; + activeEntryCount: number; + features: FeatureMeta[]; + filters: FeatureFilters; + enabledFeatureList: FeatureMeta[]; + activeFeature: string | null; + dragValue: [number, number] | null; + pinnedFeature: string | null; + travelTimeEntries: TravelTimeEntry[]; + travelInsertIdx: number; + filterImpacts?: Record; + percentileScales: Map; + destinationDropdownPortal: boolean; + aiFilterLoading: boolean; + aiFilterError: string | null; + aiFilterErrorType: AiFilterErrorType | null; + aiFilterNotes: string | null; + aiFilterSummary: string | null; + isLoggedIn: boolean; + onToggleCollapsed: () => void; + onClearAllClick: () => void; + onShowPhilosophy: () => void; + onAiFilterSubmit: (query: string) => void; + onLoginRequired: () => void; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onRemoveFilter: (name: string) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onTravelTimeRemoveEntry: (index: number) => void; + onTravelTimeSetDestination: ( + index: number, + slug: string, + label: string, + lat: number, + lon: number + ) => void; + onTravelTimeRangeChange: (index: number, range: [number, number]) => void; + onTravelTimeDragEnd: (index: number) => void; + onTravelTimeToggleBest: (index: number) => void; +} + +export function ActiveFiltersPanel({ + scrollRef, + collapsed, + badgeCount, + activeEntryCount, + features, + filters, + enabledFeatureList, + activeFeature, + dragValue, + pinnedFeature, + travelTimeEntries, + travelInsertIdx, + filterImpacts, + percentileScales, + destinationDropdownPortal, + aiFilterLoading, + aiFilterError, + aiFilterErrorType, + aiFilterNotes, + aiFilterSummary, + isLoggedIn, + onToggleCollapsed, + onClearAllClick, + onShowPhilosophy, + onAiFilterSubmit, + onLoginRequired, + onFilterChange, + onRemoveFilter, + onDragStart, + onDragChange, + onDragEnd, + onTogglePin, + onShowInfo, + onTravelTimeRemoveEntry, + onTravelTimeSetDestination, + onTravelTimeRangeChange, + onTravelTimeDragEnd, + onTravelTimeToggleBest, +}: ActiveFiltersPanelProps) { + const { t } = useTranslation(); + + return ( +
+ + + {!collapsed && ( +
+ +
+ +
+ {enabledFeatureList.length === 0 && activeEntryCount === 0 && ( +

+ {t('filters.addFiltersHint')} +

+ )} + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/map/filters/AddFilterPanel.tsx b/frontend/src/components/map/filters/AddFilterPanel.tsx new file mode 100644 index 0000000..c398c4e --- /dev/null +++ b/frontend/src/components/map/filters/AddFilterPanel.tsx @@ -0,0 +1,162 @@ +import { useTranslation } from 'react-i18next'; + +import type { TravelTimeEntry, TransportMode } from '../../../hooks/useTravelTime'; +import type { FeatureMeta } from '../../../types'; +import { ChevronIcon } from '../../ui/icons'; +import FeatureBrowser from '../FeatureBrowser'; +import { SPECIFIC_CRIMES_FILTER_NAME, isSpecificCrimeFilterName } from '../../../lib/crime-filter'; +import { ETHNICITIES_FILTER_NAME, isEthnicityFilterName } from '../../../lib/ethnicity-filter'; +import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter'; +import { + POI_DISTANCE_FILTER_NAME, + POI_FILTER_NAMES, + getPoiFilterName, + isPoiDistanceFilterName, + type PoiFilterName, +} from '../../../lib/poi-distance-filter'; + +interface AddFilterPanelProps { + collapsed: boolean; + isLicensed: boolean; + availableFeatures: FeatureMeta[]; + allFeatures: FeatureMeta[]; + pinnedFeature: string | null; + defaultSchoolFeatureName: string | null; + defaultSpecificCrimeFeatureName: string | null; + defaultEthnicityFeatureName: string | null; + defaultPoiFilterFeatureNames: Record; + openInfoFeature?: string | null; + travelTimeEntries: TravelTimeEntry[]; + onToggleCollapsed: () => void; + onAddFilter: (name: string) => void; + onTogglePin: (name: string) => void; + onNavigateToSource?: (slug: string, featureName: string) => void; + onClearOpenInfoFeature?: () => void; + onAddTravelTimeEntry: (mode: TransportMode) => void; + onUpgradeClick?: () => void; +} + +export function AddFilterPanel({ + collapsed, + isLicensed, + availableFeatures, + allFeatures, + pinnedFeature, + defaultSchoolFeatureName, + defaultSpecificCrimeFeatureName, + defaultEthnicityFeatureName, + defaultPoiFilterFeatureNames, + openInfoFeature, + travelTimeEntries, + onToggleCollapsed, + onAddFilter, + onTogglePin, + onNavigateToSource, + onClearOpenInfoFeature, + onAddTravelTimeEntry, + onUpgradeClick, +}: AddFilterPanelProps) { + const { t } = useTranslation(); + + const browserPinnedFeature = + pinnedFeature && isSchoolFilterName(pinnedFeature) + ? SCHOOL_FILTER_NAME + : pinnedFeature && isSpecificCrimeFilterName(pinnedFeature) + ? SPECIFIC_CRIMES_FILTER_NAME + : pinnedFeature && isEthnicityFilterName(pinnedFeature) + ? ETHNICITIES_FILTER_NAME + : pinnedFeature && isPoiDistanceFilterName(pinnedFeature) + ? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME) + : pinnedFeature; + + const handleTogglePin = (name: string) => { + if (name === SCHOOL_FILTER_NAME) { + if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName); + return; + } + if (name === SPECIFIC_CRIMES_FILTER_NAME) { + if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName); + return; + } + if (name === ETHNICITIES_FILTER_NAME) { + if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName); + return; + } + if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { + const defaultPoiFeatureName = defaultPoiFilterFeatureNames[name as PoiFilterName]; + if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName); + return; + } + onTogglePin(name); + }; + + return ( +
+ + {(!collapsed || !isLicensed) && ( +
+
+ {!collapsed && ( + + )} + {!isLicensed && ( +
+

+ {t('filters.upgradePrompt')} +

+

+ {t('filters.oneTimeLifetime')} +

+ + + + + + + +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/map/filters/ClearFiltersDialog.tsx b/frontend/src/components/map/filters/ClearFiltersDialog.tsx new file mode 100644 index 0000000..04d35db --- /dev/null +++ b/frontend/src/components/map/filters/ClearFiltersDialog.tsx @@ -0,0 +1,94 @@ +import { useEffect, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CloseIcon, SpinnerIcon } from '../../ui/icons'; + +interface ClearFiltersDialogProps { + open: boolean; + saveName: string; + saveError: string | null; + savingSearch?: boolean; + onClose: () => void; + onSaveNameChange: (value: string) => void; + onSaveAndClear: (e: FormEvent) => void; + onClearWithoutSaving: () => void; +} + +export function ClearFiltersDialog({ + open, + saveName, + saveError, + savingSearch, + onClose, + onSaveNameChange, + onSaveAndClear, + onClearWithoutSaving, +}: ClearFiltersDialogProps) { + const { t } = useTranslation(); + + useEffect(() => { + if (!open) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
+
e.stopPropagation()} + > +
+

+ {t('filters.clearAllTitle')} +

+ +
+
+

+ {t('filters.clearAllSavePrompt')} +

+
+ onSaveNameChange(e.target.value)} + className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" + placeholder={t('saveSearch.namePlaceholder')} + autoFocus + /> +
+ {saveError &&

{saveError}

} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx new file mode 100644 index 0000000..ada8871 --- /dev/null +++ b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx @@ -0,0 +1,71 @@ +import { ts } from '../../../i18n/server'; +import type { FeatureFilters, FeatureMeta } from '../../../types'; +import { formatNumber } from '../../../lib/format'; +import { PillGroup } from '../../ui/PillGroup'; +import { PillToggle } from '../../ui/PillToggle'; +import { FeatureActions } from '../../ui/FeatureIcons'; +import { FeatureLabel } from '../../ui/FeatureLabel'; + +interface EnumFeatureFilterCardProps { + feature: FeatureMeta; + filters: FeatureFilters; + pinnedFeature: string | null; + filterImpact?: number; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onRemoveFilter: (name: string) => void; +} + +export function EnumFeatureFilterCard({ + feature, + filters, + pinnedFeature, + filterImpact, + onFilterChange, + onTogglePin, + onShowInfo, + onRemoveFilter, +}: EnumFeatureFilterCardProps) { + const selectedValues = (filters[feature.name] as string[]) || []; + const allValues = feature.values || []; + + return ( +
+
+ + +
+ + {allValues.map((val) => ( + { + const next = selectedValues.includes(val) + ? selectedValues.filter((v) => v !== val) + : [...selectedValues, val]; + onFilterChange(feature.name, next); + }} + size="xs" + /> + ))} + + {filterImpact != null && filterImpact > 0 && ( +

+ +{formatNumber(filterImpact)} without this filter +

+ )} +
+ ); +} diff --git a/frontend/src/components/map/filters/EthnicityFilterCard.tsx b/frontend/src/components/map/filters/EthnicityFilterCard.tsx new file mode 100644 index 0000000..6b839e2 --- /dev/null +++ b/frontend/src/components/map/filters/EthnicityFilterCard.tsx @@ -0,0 +1,223 @@ +import { ts } from '../../../i18n/server'; +import { Slider } from '../../ui/Slider'; +import { ChevronIcon } from '../../ui/icons'; +import { FeatureActions } from '../../ui/FeatureIcons'; +import { FeatureLabel } from '../../ui/FeatureLabel'; +import type { FeatureFilters, FeatureMeta } from '../../../types'; +import { formatNumber, type PercentileScale } from '../../../lib/format'; +import { getFeatureIcon } from '../../../lib/feature-icons'; +import { getGroupIcon } from '../../../lib/group-icons'; +import { + ETHNICITIES_FILTER_NAME, + ETHNICITY_FEATURE_NAMES, + clampEthnicityRange, + getDefaultEthnicityFeatureName, + getEthnicityFeatureName, + getEthnicityFilterMeta, + replaceEthnicityFilterKeySelection, +} from '../../../lib/ethnicity-filter'; +import { SliderLabels } from './SliderLabels'; + +export function EthnicityFilterCard({ + features, + ethnicityFeature, + filters, + activeFeature, + dragValue, + pinnedFeature, + filterImpact, + percentileScale, + onFilterChange, + onDragStart, + onDragChange, + onDragEnd, + onTogglePin, + onShowInfo, + onRemove, +}: { + features: FeatureMeta[]; + ethnicityFeature: FeatureMeta; + filters: FeatureFilters; + activeFeature: string | null; + dragValue: [number, number] | null; + pinnedFeature: string | null; + filterImpact?: number; + percentileScale?: PercentileScale; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onRemove: () => void; +}) { + const ethnicityMeta = getEthnicityFilterMeta(features); + const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) => + features.find((feature) => feature.name === name) + ).filter((feature): feature is FeatureMeta => Boolean(feature)); + const selectedFeatureName = + getEthnicityFeatureName(ethnicityFeature.name) ?? getDefaultEthnicityFeatureName(features); + const selectedFeature = selectedFeatureName + ? features.find((feature) => feature.name === selectedFeatureName) + : undefined; + + if (!selectedFeature || ethnicityOptions.length === 0 || !selectedFeatureName) return null; + + const isActive = activeFeature === ethnicityFeature.name; + const isPinned = pinnedFeature === ethnicityFeature.name; + const hist = selectedFeature.histogram; + const dataMin = hist?.min ?? selectedFeature.min ?? 0; + const dataMax = hist?.max ?? selectedFeature.max ?? 100; + const displayValue = + isActive && dragValue + ? dragValue + : (filters[ethnicityFeature.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; + 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], + ]; + + const replaceEthnicityFeature = (nextFeatureName: string) => { + const nextName = replaceEthnicityFilterKeySelection(ethnicityFeature.name, nextFeatureName); + if (nextName === ethnicityFeature.name) return; + + const nextFeature = features.find((feature) => feature.name === nextFeatureName); + const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; + const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); + const nextRange = clampEthnicityRange( + [ + displayValue[0] <= dataMin ? nextDataMin : displayValue[0], + displayValue[1] >= dataMax ? nextDataMax : displayValue[1], + ], + nextFeature + ); + + onFilterChange(nextName, nextRange); + if (isPinned) onTogglePin(nextName); + }; + + const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; + const mobileIcon = + getFeatureIcon(selectedFeature.name, mobileIconClass) || + (() => { + const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null; + return G ? : null; + })(); + + return ( +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ {mobileIcon &&
{mobileIcon}
} +
+ { + const step = selectedFeature.step ?? 1; + const snap = (v: number) => Math.round(v / step) * step; + onDragChange([ + pMin <= 0 ? dataMin : snap(scale.toValue(pMin)), + pMax >= 100 ? dataMax : snap(scale.toValue(pMax)), + ]); + } + : ([min, max]) => + onDragChange([ + min <= (selectedFeature.min ?? dataMin) ? dataMin : min, + max >= (selectedFeature.max ?? dataMax) ? dataMax : max, + ]) + } + onPointerDown={() => onDragStart(ethnicityFeature.name)} + onPointerUp={() => onDragEnd()} + /> + + onFilterChange(ethnicityFeature.name, clampEthnicityRange(v, selectedFeature)) + } + /> + {filterImpact != null && filterImpact > 0 && ( +

+ +{formatNumber(filterImpact)} without this filter +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/map/filters/NumericFeatureFilterCard.tsx b/frontend/src/components/map/filters/NumericFeatureFilterCard.tsx new file mode 100644 index 0000000..a73112a --- /dev/null +++ b/frontend/src/components/map/filters/NumericFeatureFilterCard.tsx @@ -0,0 +1,138 @@ +import type { FeatureFilters, FeatureMeta } from '../../../types'; +import { formatNumber, type PercentileScale } from '../../../lib/format'; +import { getFeatureIcon } from '../../../lib/feature-icons'; +import { getGroupIcon } from '../../../lib/group-icons'; +import { Slider } from '../../ui/Slider'; +import { FeatureActions } from '../../ui/FeatureIcons'; +import { FeatureLabel } from '../../ui/FeatureLabel'; +import { SliderLabels } from './SliderLabels'; + +interface NumericFeatureFilterCardProps { + feature: FeatureMeta; + filters: FeatureFilters; + activeFeature: string | null; + dragValue: [number, number] | null; + pinnedFeature: string | null; + filterImpact?: number; + percentileScale?: PercentileScale; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onRemoveFilter: (name: string) => void; +} + +export function NumericFeatureFilterCard({ + feature, + filters, + activeFeature, + dragValue, + pinnedFeature, + filterImpact, + percentileScale, + onFilterChange, + onDragStart, + onDragChange, + onDragEnd, + onTogglePin, + onShowInfo, + onRemoveFilter, +}: NumericFeatureFilterCardProps) { + const isActive = activeFeature === feature.name; + const isPinned = pinnedFeature === feature.name; + const hist = feature.histogram; + const displayValue = + isActive && dragValue + ? dragValue + : (filters[feature.name] as [number, number]) || [ + hist?.min ?? feature.min!, + hist?.max ?? feature.max!, + ]; + const scale = percentileScale; + const dataMin = hist?.min ?? feature.min!; + const dataMax = hist?.max ?? feature.max!; + const clampMin = displayValue[0] <= dataMin; + const clampMax = displayValue[1] >= dataMax; + const isAtMin = displayValue[0] === dataMin; + const isAtMax = 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 ? feature.min! : displayValue[0], clampMax ? feature.max! : displayValue[1]]; + + const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; + const mobileIcon = + getFeatureIcon(feature.name, mobileIconClass) || + (() => { + const G = feature.group ? getGroupIcon(feature.group) : null; + return G ? : null; + })(); + + return ( +
+
+ + +
+
+ {mobileIcon &&
{mobileIcon}
} +
+ { + const step = feature.step ?? 1; + const snap = (v: number) => Math.round(v / step) * step; + onDragChange([ + pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)), + pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)), + ]); + } + : ([min, max]) => + onDragChange([ + min <= feature.min! ? (hist?.min ?? feature.min!) : min, + max >= feature.max! ? (hist?.max ?? feature.max!) : max, + ]) + } + onPointerDown={() => onDragStart(feature.name)} + onPointerUp={() => onDragEnd()} + /> + onFilterChange(feature.name, v)} + /> + {filterImpact != null && filterImpact > 0 && ( +

+ +{formatNumber(filterImpact)} without this filter +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx b/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx new file mode 100644 index 0000000..bdbb1e3 --- /dev/null +++ b/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx @@ -0,0 +1,220 @@ +import { ts } from '../../../i18n/server'; +import { Slider } from '../../ui/Slider'; +import { ChevronIcon } from '../../ui/icons'; +import { FeatureActions } from '../../ui/FeatureIcons'; +import { FeatureLabel } from '../../ui/FeatureLabel'; +import type { FeatureFilters, FeatureMeta } from '../../../types'; +import { formatNumber, type PercentileScale } from '../../../lib/format'; +import { getFeatureIcon } from '../../../lib/feature-icons'; +import { getGroupIcon } from '../../../lib/group-icons'; +import { + POI_DISTANCE_FILTER_NAME, + clampPoiFilterRange, + getDefaultPoiFilterFeatureName, + getPoiFeatureCategory, + getPoiDistanceFeatureName, + getPoiFilterFeatureOptions, + getPoiFilterMeta, + getPoiFilterName, + replacePoiFilterKeySelection, +} from '../../../lib/poi-distance-filter'; +import { SliderLabels } from './SliderLabels'; + +export function PoiDistanceFilterCard({ + features, + poiFeature, + filters, + activeFeature, + dragValue, + pinnedFeature, + filterImpact, + percentileScale, + onFilterChange, + onDragStart, + onDragChange, + onDragEnd, + onTogglePin, + onShowInfo, + onRemove, +}: { + features: FeatureMeta[]; + poiFeature: FeatureMeta; + filters: FeatureFilters; + activeFeature: string | null; + dragValue: [number, number] | null; + pinnedFeature: string | null; + filterImpact?: number; + percentileScale?: PercentileScale; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onRemove: () => void; +}) { + const filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME; + const poiMeta = getPoiFilterMeta(features, filterName); + const poiOptions = getPoiFilterFeatureOptions(features, filterName); + const selectedFeatureName = + getPoiDistanceFeatureName(poiFeature.name) ?? + getDefaultPoiFilterFeatureName(features, filterName); + const selectedFeature = selectedFeatureName + ? features.find((feature) => feature.name === selectedFeatureName) + : undefined; + + if (!selectedFeature || poiOptions.length === 0 || !selectedFeatureName) return null; + + const isActive = activeFeature === poiFeature.name; + const isPinned = pinnedFeature === poiFeature.name; + const hist = selectedFeature.histogram; + const dataMin = hist?.min ?? selectedFeature.min ?? 0; + const dataMax = hist?.max ?? selectedFeature.max ?? 5; + const displayValue = + 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; + 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], + ]; + + const replacePoiFeature = (nextFeatureName: string) => { + const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName); + if (nextName === poiFeature.name) return; + + const nextFeature = features.find((feature) => feature.name === nextFeatureName); + const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; + const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); + const nextRange = clampPoiFilterRange( + [ + displayValue[0] <= dataMin ? nextDataMin : displayValue[0], + displayValue[1] >= dataMax ? nextDataMax : displayValue[1], + ], + nextFeature + ); + + onFilterChange(nextName, nextRange); + if (isPinned) onTogglePin(nextName); + }; + + const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; + const mobileIcon = + getFeatureIcon(selectedFeature.name, mobileIconClass) || + (() => { + const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null; + return G ? : null; + })(); + + return ( +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ {mobileIcon &&
{mobileIcon}
} +
+ { + const step = selectedFeature.step ?? 0.1; + const snap = (v: number) => Math.round(v / step) * step; + onDragChange([ + pMin <= 0 ? dataMin : snap(scale.toValue(pMin)), + pMax >= 100 ? dataMax : snap(scale.toValue(pMax)), + ]); + } + : ([min, max]) => + onDragChange([ + min <= (selectedFeature.min ?? dataMin) ? dataMin : min, + max >= (selectedFeature.max ?? dataMax) ? dataMax : max, + ]) + } + onPointerDown={() => onDragStart(poiFeature.name)} + onPointerUp={() => onDragEnd()} + /> + + onFilterChange(poiFeature.name, clampPoiFilterRange(v, selectedFeature)) + } + /> + {filterImpact != null && filterImpact > 0 && ( +

+ +{formatNumber(filterImpact)} without this filter +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/map/filters/SchoolFilterCard.tsx b/frontend/src/components/map/filters/SchoolFilterCard.tsx new file mode 100644 index 0000000..d06ac4f --- /dev/null +++ b/frontend/src/components/map/filters/SchoolFilterCard.tsx @@ -0,0 +1,234 @@ +import { Slider } from '../../ui/Slider'; +import { FeatureActions } from '../../ui/FeatureIcons'; +import { FeatureLabel } from '../../ui/FeatureLabel'; +import type { FeatureFilters, FeatureMeta } from '../../../types'; +import { formatNumber } from '../../../lib/format'; +import { + SCHOOL_FILTER_NAME, + clampSchoolRange, + getSchoolBackendFeatureName, + getSchoolFilterConfig, + getSchoolFilterMeta, + replaceSchoolFilterKeySelection, + type SchoolDistance, + type SchoolPhase, + type SchoolRating, +} from '../../../lib/school-filter'; +import { SliderLabels } from './SliderLabels'; + +export function SchoolFilterCard({ + features, + schoolFeature, + filters, + activeFeature, + dragValue, + pinnedFeature, + filterImpact, + onFilterChange, + onDragStart, + onDragChange, + onDragEnd, + onTogglePin, + onShowInfo, + onRemove, +}: { + features: FeatureMeta[]; + schoolFeature: FeatureMeta; + filters: FeatureFilters; + activeFeature: string | null; + dragValue: [number, number] | null; + pinnedFeature: string | null; + filterImpact?: number; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onRemove: () => void; +}) { + const config = getSchoolFilterConfig(schoolFeature.name); + const schoolMeta = getSchoolFilterMeta(features); + const backendFeature = config + ? features.find((feature) => feature.name === config.featureName) + : undefined; + const isActive = activeFeature === schoolFeature.name; + const isPinned = pinnedFeature === schoolFeature.name; + const hist = backendFeature?.histogram; + const dataMin = hist?.min ?? backendFeature?.min ?? 0; + const dataMax = hist?.max ?? backendFeature?.max ?? 10; + const displayValue = + isActive && dragValue + ? dragValue + : (filters[schoolFeature.name] as [number, number]) || [dataMin, dataMax]; + const sliderValue: [number, number] = [ + displayValue[0] <= dataMin ? (backendFeature?.min ?? dataMin) : displayValue[0], + displayValue[1] >= dataMax ? (backendFeature?.max ?? dataMax) : displayValue[1], + ]; + + if (!config) return null; + + const replaceSchoolFeature = ( + next: Partial<{ + phase: SchoolPhase; + rating: SchoolRating; + distance: SchoolDistance; + }> + ) => { + const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next); + if (nextName === schoolFeature.name) return; + + const nextBackendName = getSchoolBackendFeatureName(nextName); + const nextFeature = nextBackendName + ? features.find((feature) => feature.name === nextBackendName) + : undefined; + const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; + const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); + const nextRange = clampSchoolRange( + [ + displayValue[0] <= dataMin ? nextDataMin : displayValue[0], + displayValue[1] >= dataMax ? nextDataMax : displayValue[1], + ], + nextFeature + ); + onFilterChange(nextName, nextRange); + if (isPinned) onTogglePin(nextName); + }; + + const segmentedClass = + 'grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800'; + const optionClass = (active: boolean) => + `px-2 py-1 text-xs font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${ + active + ? 'bg-teal-600 text-white dark:bg-teal-500' + : 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700' + }`; + + return ( +
+
+ + onTogglePin(schoolFeature.name)} + onShowInfo={() => onShowInfo(schoolMeta)} + onRemove={onRemove} + /> +
+ +
+
+
+ School type +
+
+ + +
+
+
+
+ Rating +
+
+ + +
+
+
+
+ Distance +
+
+ + +
+
+
+ + + onDragChange([ + min <= (backendFeature?.min ?? dataMin) ? dataMin : min, + max >= (backendFeature?.max ?? dataMax) ? dataMax : max, + ]) + } + onPointerDown={() => onDragStart(schoolFeature.name)} + onPointerUp={() => onDragEnd()} + /> + onFilterChange(schoolFeature.name, v)} + /> + {filterImpact != null && filterImpact > 0 && ( +

+ +{formatNumber(filterImpact)} without this filter +

+ )} +
+ ); +} diff --git a/frontend/src/components/map/filters/SliderLabels.tsx b/frontend/src/components/map/filters/SliderLabels.tsx new file mode 100644 index 0000000..e66c495 --- /dev/null +++ b/frontend/src/components/map/filters/SliderLabels.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState } from 'react'; +import type React from 'react'; + +import type { FeatureMeta } from '../../../types'; +import { formatFilterValue, parseInputValue } from '../../../lib/format'; + +function EditableLabel({ + value, + formatted, + onCommit, + prefix, + suffix, + className, + style, +}: { + value: number; + formatted: string; + onCommit: (v: number) => void; + prefix?: string; + suffix?: string; + className?: string; + style?: React.CSSProperties; +}) { + const [editing, setEditing] = useState(false); + const [text, setText] = useState(''); + const inputRef = useRef(null); + + const startEdit = () => { + setEditing(true); + setText(String(Math.round(value))); + }; + + const commit = () => { + const parsed = parseInputValue(text, { prefix, suffix }); + if (parsed != null) onCommit(parsed); + setEditing(false); + }; + + useEffect(() => { + if (editing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [editing]); + + if (editing) { + return ( + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') setEditing(false); + }} + onBlur={commit} + className="absolute w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400" + style={style} + /> + ); + } + + return ( + + {formatted} + + ); +} + +export function SliderLabels({ + min, + max, + value, + displayValues, + isAtMin, + isAtMax, + raw, + feature, + onValueChange, +}: { + min: number; + max: number; + value: [number, number]; + displayValues?: [number, number]; + isAtMin?: boolean; + isAtMax?: boolean; + raw?: boolean; + feature?: FeatureMeta; + onValueChange?: (v: [number, number]) => void; +}) { + const range = max - min || 1; + const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100)); + const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100)); + const labels = displayValues || value; + const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw; + + const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat); + const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat); + + // Smoothly spread labels apart as thumbs get close to prevent overlap. + // t=1 (centered) when far apart, t=0 (split) when touching. + const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating + const gapPct = rightPct - leftPct; + const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD)); + const leftTranslate = `translateX(${-100 + t * 50}%)`; + const rightTranslate = `translateX(${-t * 50}%)`; + + if (feature && onValueChange) { + return ( +
+ onValueChange([v, Math.max(v, labels[1])])} + prefix={feature.prefix} + suffix={feature.suffix} + style={{ left: `${leftPct}%`, transform: leftTranslate }} + /> + onValueChange([Math.min(labels[0], v), v])} + prefix={feature.prefix} + suffix={feature.suffix} + style={{ left: `${rightPct}%`, transform: rightTranslate }} + /> +
+ ); + } + + return ( +
+ + {minLabel} + + + {maxLabel} + +
+ ); +} diff --git a/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx b/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx new file mode 100644 index 0000000..d47bf49 --- /dev/null +++ b/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx @@ -0,0 +1,223 @@ +import { ts } from '../../../i18n/server'; +import { Slider } from '../../ui/Slider'; +import { ChevronIcon } from '../../ui/icons'; +import { FeatureActions } from '../../ui/FeatureIcons'; +import { FeatureLabel } from '../../ui/FeatureLabel'; +import type { FeatureFilters, FeatureMeta } from '../../../types'; +import { formatNumber, type PercentileScale } from '../../../lib/format'; +import { getFeatureIcon } from '../../../lib/feature-icons'; +import { getGroupIcon } from '../../../lib/group-icons'; +import { + SPECIFIC_CRIMES_FILTER_NAME, + SPECIFIC_CRIME_FEATURE_NAMES, + clampSpecificCrimeRange, + getDefaultSpecificCrimeFeatureName, + getSpecificCrimeFeatureName, + getSpecificCrimeFilterMeta, + replaceSpecificCrimeFilterKeySelection, +} from '../../../lib/crime-filter'; +import { SliderLabels } from './SliderLabels'; + +export function SpecificCrimeFilterCard({ + features, + crimeFeature, + filters, + activeFeature, + dragValue, + pinnedFeature, + filterImpact, + percentileScale, + onFilterChange, + onDragStart, + onDragChange, + onDragEnd, + onTogglePin, + onShowInfo, + onRemove, +}: { + features: FeatureMeta[]; + crimeFeature: FeatureMeta; + filters: FeatureFilters; + activeFeature: string | null; + dragValue: [number, number] | null; + pinnedFeature: string | null; + filterImpact?: number; + percentileScale?: PercentileScale; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onRemove: () => void; +}) { + const specificCrimeMeta = getSpecificCrimeFilterMeta(features); + const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) => + features.find((feature) => feature.name === name) + ).filter((feature): feature is FeatureMeta => Boolean(feature)); + const selectedFeatureName = + getSpecificCrimeFeatureName(crimeFeature.name) ?? getDefaultSpecificCrimeFeatureName(features); + const selectedFeature = selectedFeatureName + ? features.find((feature) => feature.name === selectedFeatureName) + : undefined; + + if (!selectedFeature || crimeOptions.length === 0 || !selectedFeatureName) return null; + + const isActive = activeFeature === crimeFeature.name; + const isPinned = pinnedFeature === crimeFeature.name; + const hist = selectedFeature.histogram; + const dataMin = hist?.min ?? selectedFeature.min ?? 0; + const dataMax = hist?.max ?? selectedFeature.max ?? 100; + const displayValue = + isActive && dragValue + ? dragValue + : (filters[crimeFeature.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; + 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], + ]; + + const replaceCrimeFeature = (nextFeatureName: string) => { + const nextName = replaceSpecificCrimeFilterKeySelection(crimeFeature.name, nextFeatureName); + if (nextName === crimeFeature.name) return; + + const nextFeature = features.find((feature) => feature.name === nextFeatureName); + const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; + const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); + const nextRange = clampSpecificCrimeRange( + [ + displayValue[0] <= dataMin ? nextDataMin : displayValue[0], + displayValue[1] >= dataMax ? nextDataMax : displayValue[1], + ], + nextFeature + ); + + onFilterChange(nextName, nextRange); + if (isPinned) onTogglePin(nextName); + }; + + const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; + const mobileIcon = + getFeatureIcon(selectedFeature.name, mobileIconClass) || + (() => { + const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null; + return G ? : null; + })(); + + return ( +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ {mobileIcon &&
{mobileIcon}
} +
+ { + const step = selectedFeature.step ?? 1; + const snap = (v: number) => Math.round(v / step) * step; + onDragChange([ + pMin <= 0 ? dataMin : snap(scale.toValue(pMin)), + pMax >= 100 ? dataMax : snap(scale.toValue(pMax)), + ]); + } + : ([min, max]) => + onDragChange([ + min <= (selectedFeature.min ?? dataMin) ? dataMin : min, + max >= (selectedFeature.max ?? dataMax) ? dataMax : max, + ]) + } + onPointerDown={() => onDragStart(crimeFeature.name)} + onPointerUp={() => onDragEnd()} + /> + + onFilterChange(crimeFeature.name, clampSpecificCrimeRange(v, selectedFeature)) + } + /> + {filterImpact != null && filterImpact > 0 && ( +

+ +{formatNumber(filterImpact)} without this filter +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/map/filters/TravelTimeFilterCards.tsx b/frontend/src/components/map/filters/TravelTimeFilterCards.tsx new file mode 100644 index 0000000..6255ca5 --- /dev/null +++ b/frontend/src/components/map/filters/TravelTimeFilterCards.tsx @@ -0,0 +1,76 @@ +import { type TravelTimeEntry, travelFieldKey } from '../../../hooks/useTravelTime'; +import { TravelTimeCard } from '../TravelTimeCard'; + +interface TravelTimeFilterCardsProps { + entries: TravelTimeEntry[]; + activeFeature: string | null; + dragValue: [number, number] | null; + pinnedFeature: string | null; + filterImpacts?: Record; + destinationDropdownPortal: boolean; + onTogglePin: (name: string) => void; + onTravelTimeRemoveEntry: (index: number) => void; + onTravelTimeSetDestination: ( + index: number, + slug: string, + label: string, + lat: number, + lon: number + ) => void; + onTravelTimeRangeChange: (index: number, range: [number, number]) => void; + onTravelTimeDragEnd: (index: number) => void; + onTravelTimeToggleBest: (index: number) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; +} + +export function TravelTimeFilterCards({ + entries, + activeFeature, + dragValue, + pinnedFeature, + filterImpacts, + destinationDropdownPortal, + onTogglePin, + onTravelTimeRemoveEntry, + onTravelTimeSetDestination, + onTravelTimeRangeChange, + onTravelTimeDragEnd, + onTravelTimeToggleBest, + onDragStart, + onDragChange, +}: TravelTimeFilterCardsProps) { + return ( + <> + {entries.map((entry, index) => { + const fieldKey = travelFieldKey(entry); + return ( +
+ onTogglePin(fieldKey)} + onSetDestination={(slug, label, lat, lon) => + onTravelTimeSetDestination(index, slug, label, lat, lon) + } + onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} + onDragStart={() => onDragStart(fieldKey)} + onDragChange={onDragChange} + onDragEnd={() => onTravelTimeDragEnd(index)} + onToggleBest={() => onTravelTimeToggleBest(index)} + onRemove={() => onTravelTimeRemoveEntry(index)} + filterImpact={filterImpacts?.[fieldKey]} + destinationDropdownPortal={destinationDropdownPortal} + /> +
+ ); + })} + + ); +} diff --git a/frontend/src/components/map/map-page/DesktopMapPage.tsx b/frontend/src/components/map/map-page/DesktopMapPage.tsx new file mode 100644 index 0000000..a163634 --- /dev/null +++ b/frontend/src/components/map/map-page/DesktopMapPage.tsx @@ -0,0 +1,225 @@ +import { Suspense, type MutableRefObject, type ReactNode } from 'react'; + +import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types'; +import type { useMapData } from '../../../hooks/useMapData'; +import type { useTutorial } from '../../../hooks/useTutorial'; +import type { TravelTimeEntry } from '../../../hooks/useTravelTime'; +import type { getTutorialStyles } from '../../../lib/tutorial-styles'; +import type { SearchedLocation } from '../LocationSearch'; +import { MapPinIcon } from '../../ui/icons/MapPinIcon'; +import type { MapFlyTo, PaneResizeHandlers } from './types'; +import { MapFallback, PaneFallback } from './Fallbacks'; +import { LoadingOverlay } from './LoadingOverlay'; +import { Joyride, Map, MapPageSelectionPane } from './lazyComponents'; + +type MapData = ReturnType; +type Tutorial = ReturnType; +type TutorialTheme = ReturnType; +type RightPaneTab = 'properties' | 'area'; + +interface DesktopMapPageProps { + initialLoading: boolean; + tutorial: Tutorial; + tutorialTheme: TutorialTheme; + leftPaneWidth: number; + leftPaneHandlers: PaneResizeHandlers; + filtersPane: ReactNode; + mapData: MapData; + pois: POI[]; + mapViewFeature: string | null; + filterRange: [number, number] | null; + viewSource: 'drag' | 'eye' | null; + onCancelPin: () => void; + features: FeatureMeta[]; + selectedHexagonId: string | null; + hoveredHexagonId: string | null; + onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void; + onHexagonHover: (h3: string | null, x?: number, y?: number) => void; + initialViewState: ViewState; + flyToRef: MutableRefObject; + theme: 'light' | 'dark'; + filters: FeatureFilters; + selectedPostcodeGeometry: PostcodeGeometry | null; + onLocationSearched: (location: SearchedLocation | null) => void; + onCurrentLocationFound: (lat: number, lng: number) => void; + currentLocation: { lat: number; lng: number } | null; + travelTimeEntries: TravelTimeEntry[]; + densityLabel: string; + totalCount?: number; + poiPaneOpen: boolean; + onTogglePoiPane: () => void; + poiPane: ReactNode; + showSelectionPane: boolean; + rightPaneWidth: number; + rightPaneHandlers: PaneResizeHandlers; + rightPaneTab: RightPaneTab; + onAreaTabClick: () => void; + onPropertiesTabClick: () => void; + onCloseSelection: () => void; + renderAreaPane: () => ReactNode; + renderPropertiesPane: () => ReactNode; + toasts: ReactNode; + upgradeModal: ReactNode; +} + +export function DesktopMapPage({ + initialLoading, + tutorial, + tutorialTheme, + leftPaneWidth, + leftPaneHandlers, + filtersPane, + mapData, + pois, + mapViewFeature, + filterRange, + viewSource, + onCancelPin, + features, + selectedHexagonId, + hoveredHexagonId, + onHexagonClick, + onHexagonHover, + initialViewState, + flyToRef, + theme, + filters, + selectedPostcodeGeometry, + onLocationSearched, + onCurrentLocationFound, + currentLocation, + travelTimeEntries, + densityLabel, + totalCount, + poiPaneOpen, + onTogglePoiPane, + poiPane, + showSelectionPane, + rightPaneWidth, + rightPaneHandlers, + rightPaneTab, + onAreaTabClick, + onPropertiesTabClick, + onCloseSelection, + renderAreaPane, + renderPropertiesPane, + toasts, + upgradeModal, +}: DesktopMapPageProps) { + return ( +
+ + + {tutorial.run && ( + + + + )} + +
+
{filtersPane}
+
+
+
+
+
+
+
+
+ +
+ {tutorial.run && ( + + + {showSelectionPane && ( + }> + + + )} + + {toasts} + {upgradeModal} +
+ ); +} diff --git a/frontend/src/components/map/map-page/Fallbacks.tsx b/frontend/src/components/map/map-page/Fallbacks.tsx new file mode 100644 index 0000000..37c85be --- /dev/null +++ b/frontend/src/components/map/map-page/Fallbacks.tsx @@ -0,0 +1,17 @@ +import { SpinnerIcon } from '../../ui/icons/SpinnerIcon'; + +export function MapFallback() { + return ( +
+ +
+ ); +} + +export function PaneFallback() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/map/map-page/LoadingOverlay.tsx b/frontend/src/components/map/map-page/LoadingOverlay.tsx new file mode 100644 index 0000000..01767b7 --- /dev/null +++ b/frontend/src/components/map/map-page/LoadingOverlay.tsx @@ -0,0 +1,20 @@ +import { SpinnerIcon } from '../../ui/icons/SpinnerIcon'; + +interface LoadingOverlayProps { + show: boolean; +} + +export function LoadingOverlay({ show }: LoadingOverlayProps) { + if (!show) return null; + + return ( +
+
+ +

+ Connecting to server... +

+
+
+ ); +} diff --git a/frontend/src/components/map/map-page/MobileMapLegend.tsx b/frontend/src/components/map/map-page/MobileMapLegend.tsx new file mode 100644 index 0000000..0659b2c --- /dev/null +++ b/frontend/src/components/map/map-page/MobileMapLegend.tsx @@ -0,0 +1,94 @@ +import { useTranslation } from 'react-i18next'; + +import type { FeatureMeta } from '../../../types'; +import { useTranslatedModes, type TransportMode } from '../../../hooks/useTravelTime'; +import { ts } from '../../../i18n/server'; +import MapLegend from '../MapLegend'; + +interface MobileMapLegendProps { + mapViewFeature: string | null; + colorRange: [number, number] | null; + viewSource: 'drag' | 'eye' | null; + mobileLegendMeta: FeatureMeta | null; + densityLabel: string; + densityRange: [number, number]; + theme: 'light' | 'dark'; + canResetPreviewScale: boolean; + onCancelPin: () => void; + onResetPreviewScale: () => void; +} + +export function MobileMapLegend({ + mapViewFeature, + colorRange, + viewSource, + mobileLegendMeta, + densityLabel, + densityRange, + theme, + canResetPreviewScale, + onCancelPin, + onResetPreviewScale, +}: MobileMapLegendProps) { + const { t } = useTranslation(); + const modes = useTranslatedModes(); + + if (mapViewFeature && colorRange) { + if (mapViewFeature.startsWith('tt_')) { + return ( + + ); + } + + if (mobileLegendMeta) { + return ( + + ); + } + + return null; + } + + return ( + + ); +} diff --git a/frontend/src/components/map/map-page/MobileMapPage.tsx b/frontend/src/components/map/map-page/MobileMapPage.tsx new file mode 100644 index 0000000..5fa8335 --- /dev/null +++ b/frontend/src/components/map/map-page/MobileMapPage.tsx @@ -0,0 +1,177 @@ +import { Suspense, type MutableRefObject, type ReactNode } from 'react'; + +import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types'; +import type { useMapData } from '../../../hooks/useMapData'; +import type { TravelTimeEntry } from '../../../hooks/useTravelTime'; +import type { SearchedLocation } from '../LocationSearch'; +import MobileBottomSheet from '../MobileBottomSheet'; +import { MapPinIcon } from '../../ui/icons/MapPinIcon'; +import type { MapFlyTo } from './types'; +import { MapFallback, PaneFallback } from './Fallbacks'; +import { LoadingOverlay } from './LoadingOverlay'; +import { Map, MobileDrawer } from './lazyComponents'; + +type MapData = ReturnType; +type RightPaneTab = 'properties' | 'area'; + +interface MobileMapPageProps { + initialLoading: boolean; + mapData: MapData; + pois: POI[]; + mapViewFeature: string | null; + filterRange: [number, number] | null; + viewSource: 'drag' | 'eye' | null; + onCancelPin: () => void; + features: FeatureMeta[]; + selectedHexagonId: string | null; + hoveredHexagonId: string | null; + onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void; + onHexagonHover: (h3: string | null, x?: number, y?: number) => void; + initialViewState: ViewState; + flyToRef: MutableRefObject; + theme: 'light' | 'dark'; + filters: FeatureFilters; + selectedPostcodeGeometry: PostcodeGeometry | null; + onLocationSearched: (location: SearchedLocation | null) => void; + onCurrentLocationFound: (lat: number, lng: number) => void; + currentLocation: { lat: number; lng: number } | null; + travelTimeEntries: TravelTimeEntry[]; + bottomScreenInset: number; + onBottomSheetCoveredHeightChange: (height: number) => void; + mobileDrawerOpen: boolean; + onMobileDrawerClose: () => void; + onMobileDrawerPanelRectChange: (rect: DOMRectReadOnly) => void; + rightPaneTab: RightPaneTab; + onMobileDrawerTabChange: (tab: RightPaneTab) => void; + poiPaneOpen: boolean; + onTogglePoiPane: () => void; + poiButtonLabel: string; + poiPane: ReactNode; + filtersPane: ReactNode; + mobileLegend: ReactNode; + renderAreaPane: () => ReactNode; + renderPropertiesPane: () => ReactNode; + toasts: ReactNode; + upgradeModal: ReactNode; +} + +export function MobileMapPage({ + initialLoading, + mapData, + pois, + mapViewFeature, + filterRange, + viewSource, + onCancelPin, + features, + selectedHexagonId, + hoveredHexagonId, + onHexagonClick, + onHexagonHover, + initialViewState, + flyToRef, + theme, + filters, + selectedPostcodeGeometry, + onLocationSearched, + onCurrentLocationFound, + currentLocation, + travelTimeEntries, + bottomScreenInset, + onBottomSheetCoveredHeightChange, + mobileDrawerOpen, + onMobileDrawerClose, + onMobileDrawerPanelRectChange, + rightPaneTab, + onMobileDrawerTabChange, + poiPaneOpen, + onTogglePoiPane, + poiButtonLabel, + poiPane, + filtersPane, + mobileLegend, + renderAreaPane, + renderPropertiesPane, + toasts, + upgradeModal, +}: MobileMapPageProps) { + return ( +
+ + +
+ }> + + +
+ + + + {poiPaneOpen && ( +
+ {poiPane} +
+ )} + + + {filtersPane} + + + {mobileDrawerOpen && selectedHexagonId && ( + }> + + + )} + + {toasts} + {upgradeModal} +
+ ); +} diff --git a/frontend/src/components/map/map-page/ScreenshotMapPage.tsx b/frontend/src/components/map/map-page/ScreenshotMapPage.tsx new file mode 100644 index 0000000..1bc60a9 --- /dev/null +++ b/frontend/src/components/map/map-page/ScreenshotMapPage.tsx @@ -0,0 +1,65 @@ +import { Suspense } from 'react'; + +import type { FeatureMeta, ViewState } from '../../../types'; +import type { useMapData } from '../../../hooks/useMapData'; +import type { TravelTimeEntry } from '../../../hooks/useTravelTime'; +import { MapFallback } from './Fallbacks'; +import { Map } from './lazyComponents'; + +type MapData = ReturnType; + +interface ScreenshotMapPageProps { + mapData: MapData; + mapViewFeature: string | null; + filterRange: [number, number] | null; + viewSource: 'drag' | 'eye' | null; + features: FeatureMeta[]; + initialViewState: ViewState; + theme: 'light' | 'dark'; + ogMode?: boolean; + travelTimeEntries: TravelTimeEntry[]; +} + +export function ScreenshotMapPage({ + mapData, + mapViewFeature, + filterRange, + viewSource, + features, + initialViewState, + theme, + ogMode, + travelTimeEntries, +}: ScreenshotMapPageProps) { + return ( +
+ }> + {}} + onResetPreviewScale={mapData.handleResetPreviewScale} + canResetPreviewScale={mapData.canResetPreviewScale} + features={features} + selectedHexagonId={null} + hoveredHexagonId={null} + onHexagonClick={() => {}} + onHexagonHover={() => {}} + initialViewState={initialViewState} + theme={theme} + screenshotMode + ogMode={ogMode} + bounds={mapData.bounds} + travelTimeEntries={travelTimeEntries} + /> + +
+ ); +} diff --git a/frontend/src/components/map/map-page/Toasts.tsx b/frontend/src/components/map/map-page/Toasts.tsx new file mode 100644 index 0000000..84765ef --- /dev/null +++ b/frontend/src/components/map/map-page/Toasts.tsx @@ -0,0 +1,67 @@ +import type { ExportNotice } from './types'; +import { BookmarkIcon } from '../../ui/icons/BookmarkIcon'; +import { CheckIcon } from '../../ui/icons/CheckIcon'; +import { CloseIcon } from '../../ui/icons/CloseIcon'; +import { InfoIcon } from '../../ui/icons/InfoIcon'; + +interface BookmarkToastProps { + show: boolean; + onViewSaved: () => void; + onDismissForever: () => void; +} + +export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) { + if (!show) return null; + + return ( +
+ + Property saved! + + +
+ ); +} + +interface ExportToastProps { + notice: ExportNotice | null; + offsetForBookmark: boolean; + closeLabel: string; + onClose: () => void; +} + +export function ExportToast({ notice, offsetForBookmark, closeLabel, onClose }: ExportToastProps) { + if (!notice) return null; + + return ( +
+ {notice.kind === 'success' ? ( + + ) : ( + + )} + {notice.message} + +
+ ); +} diff --git a/frontend/src/components/map/map-page/derivedState.ts b/frontend/src/components/map/map-page/derivedState.ts new file mode 100644 index 0000000..89fe75e --- /dev/null +++ b/frontend/src/components/map/map-page/derivedState.ts @@ -0,0 +1,95 @@ +import { useMemo } from 'react'; +import { cellToLatLng } from 'h3-js'; + +import type { FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../../types'; +import type { HexagonLocation } from '../../../lib/external-search'; +import type { useMapData } from '../../../hooks/useMapData'; +import type { TravelTimeEntry } from '../../../hooks/useTravelTime'; +import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter'; +import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter'; +import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter'; +import { getSchoolBackendFeatureName } from '../../../lib/school-filter'; + +type MapData = ReturnType; + +interface SelectedHexagon { + id: string; + type: 'hexagon' | 'postcode'; + resolution: number; +} + +export function getMapPageBackendFeatureName(featureName: string): string { + return ( + getSchoolBackendFeatureName(featureName) ?? + getSpecificCrimeFeatureName(featureName) ?? + getEthnicityFeatureName(featureName) ?? + getPoiDistanceFeatureName(featureName) ?? + featureName + ); +} + +export function useJourneyDestination(entries: TravelTimeEntry[]) { + return useMemo(() => { + const entry = entries.find((item) => item.mode === 'transit' && item.slug); + return entry ? { mode: entry.mode, slug: entry.slug } : null; + }, [entries]); +} + +export function useMapViewFeature(viewFeature: string | null) { + return useMemo( + () => (viewFeature ? getMapPageBackendFeatureName(viewFeature) : null), + [viewFeature] + ); +} + +export function useMobileLegendMeta(viewFeature: string | null, features: FeatureMeta[]) { + return useMemo(() => { + const featureName = viewFeature ? getMapPageBackendFeatureName(viewFeature) : null; + return featureName ? features.find((feature) => feature.name === featureName) || null : null; + }, [viewFeature, features]); +} + +export function useMobileDensityRange(mapData: MapData): [number, number] { + return useMemo(() => { + const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data; + if (items.length === 0) return [0, 1]; + let min = Infinity; + let max = -Infinity; + for (const item of items) { + const count = 'count' in item ? item.count : item.properties.count; + if (count < min) min = count; + if (count > max) max = count; + } + if (min === Infinity) return [0, 1]; + if (min === max) return [min, min + 1]; + return [min, max]; + }, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]); +} + +export function useHexagonLocation( + selectedHexagon: SelectedHexagon | null, + postcodeData: PostcodeFeature[], + resolution: number, + areaStats: HexagonStatsResponse | null +): HexagonLocation | null { + return useMemo(() => { + const hexId = selectedHexagon?.id; + const isPostcode = selectedHexagon?.type === 'postcode'; + + if (isPostcode) { + const postcodeFeature = postcodeData.find((feature) => feature.properties.postcode === hexId); + if (!postcodeFeature?.properties.centroid) return null; + const [lon, lat] = postcodeFeature.properties.centroid; + return { lat, lon, resolution, postcode: hexId, isPostcode: true }; + } + + if (!hexId) return null; + const [lat, lon] = cellToLatLng(hexId); + return { + lat, + lon, + resolution: selectedHexagon?.resolution ?? resolution, + postcode: areaStats?.central_postcode, + }; + }, [selectedHexagon, postcodeData, resolution, areaStats?.central_postcode]); +} diff --git a/frontend/src/components/map/map-page/effects.ts b/frontend/src/components/map/map-page/effects.ts new file mode 100644 index 0000000..d0ed00d --- /dev/null +++ b/frontend/src/components/map/map-page/effects.ts @@ -0,0 +1,138 @@ +import { useEffect } from 'react'; +import type { MutableRefObject } from 'react'; + +import type { PostcodeGeometry, ViewState } from '../../../types'; +import type { useMapData } from '../../../hooks/useMapData'; +import { authHeaders } from '../../../lib/api'; +import { canWheelScrollInsideTarget } from '../../../lib/dom-scroll'; +import type { MapFlyTo } from './types'; + +type MapData = ReturnType; +type RightPaneTab = 'properties' | 'area'; + +export function useInitialMapPageView( + mapData: MapData, + initialViewState: ViewState, + initialTab: RightPaneTab, + setRightPaneTab: (tab: RightPaneTab) => void +) { + useEffect(() => { + mapData.setInitialView(initialViewState); + setRightPaneTab(initialTab); + }, []); // eslint-disable-line react-hooks/exhaustive-deps +} + +interface UseInitialPostcodeSelectionOptions { + initialPostcode?: string; + isMobile: boolean; + flyTo: MutableRefObject; + onLocationSearch: ( + postcode: string, + geometry: PostcodeGeometry, + lat?: number, + lng?: number + ) => void; + onOpenMobileDrawer: () => void; +} + +export function useInitialPostcodeSelection({ + initialPostcode, + isMobile, + flyTo, + onLocationSearch, + onOpenMobileDrawer, +}: UseInitialPostcodeSelectionOptions) { + useEffect(() => { + if (!initialPostcode) return; + + const params = new URLSearchParams(window.location.search); + params.delete('pc'); + const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard'; + window.history.replaceState(window.history.state, '', newUrl); + + fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders()) + .then((res) => { + if (!res.ok) throw new Error('Postcode not found'); + return res.json(); + }) + .then( + (data: { + postcode: string; + latitude: number; + longitude: number; + geometry: PostcodeGeometry; + }) => { + flyTo.current?.(data.latitude, data.longitude, 16); + onLocationSearch(data.postcode, data.geometry, data.latitude, data.longitude); + if (isMobile) onOpenMobileDrawer(); + } + ) + .catch(() => { + // Silently fail because the postcode might no longer exist. + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps +} + +export function useHorizontalSwipeNavigationGuard() { + useEffect(() => { + const handleWheel = (e: WheelEvent) => { + if ( + Math.abs(e.deltaX) > Math.abs(e.deltaY) && + !canWheelScrollInsideTarget(e.target, e.deltaX, e.deltaY) + ) { + e.preventDefault(); + } + }; + document.addEventListener('wheel', handleWheel, { passive: false }); + return () => document.removeEventListener('wheel', handleWheel); + }, []); +} + +export function useMobileBackNavigationGuard(isMobile: boolean) { + useEffect(() => { + if (!isMobile) return; + window.history.pushState({ dashboardGuard: true }, ''); + const handlePopState = () => { + window.history.pushState({ dashboardGuard: true }, ''); + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, [isMobile]); +} + +interface UseScreenshotReadySignalOptions { + screenshotMode?: boolean; + loading: boolean; + dataLength: number; + postcodeDataLength: number; + usePostcodeView: boolean; +} + +export function useScreenshotReadySignal({ + screenshotMode, + loading, + dataLength, + postcodeDataLength, + usePostcodeView, +}: UseScreenshotReadySignalOptions) { + useEffect(() => { + if (screenshotMode && !loading) { + const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0; + if (hasData) { + // Wait for both deck.gl data and MapLibre base map tile rendering. + const waitAndSignal = () => { + if (window.__map_idle) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.__screenshot_ready = true; + }); + }); + } else { + requestAnimationFrame(waitAndSignal); + } + }; + waitAndSignal(); + } + } + }, [screenshotMode, loading, dataLength, postcodeDataLength, usePostcodeView]); +} diff --git a/frontend/src/components/map/map-page/lazyComponents.ts b/frontend/src/components/map/map-page/lazyComponents.ts new file mode 100644 index 0000000..0309914 --- /dev/null +++ b/frontend/src/components/map/map-page/lazyComponents.ts @@ -0,0 +1,17 @@ +import { lazy } from 'react'; + +export const Map = lazy(() => import('../Map')); +export const Filters = lazy(() => import('../Filters')); +export const POIPane = lazy(() => import('../POIPane')); +export const AreaPane = lazy(() => import('../AreaPane')); +export const PropertiesPane = lazy(() => + import('../PropertiesPane').then((module) => ({ default: module.PropertiesPane })) +); +export const MobileDrawer = lazy(() => import('../MobileDrawer')); +export const MapPageSelectionPane = lazy(() => + import('../MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane })) +); +export const UpgradeModal = lazy(() => import('../../ui/UpgradeModal')); +export const Joyride = lazy(() => + import('react-joyride').then((module) => ({ default: module.Joyride })) +); diff --git a/frontend/src/components/map/map-page/types.ts b/frontend/src/components/map/map-page/types.ts new file mode 100644 index 0000000..7a1d489 --- /dev/null +++ b/frontend/src/components/map/map-page/types.ts @@ -0,0 +1,60 @@ +import type { + FeatureFilters, + FeatureMeta, + MapFlyToOptions, + POICategoryGroup, + Property, + ViewState, +} from '../../../types'; +import type { TravelTimeInitial } from '../../../hooks/useTravelTime'; +import type { Page } from '../../ui/Header'; +import type { PointerEvent } from 'react'; + +export interface ExportState { + onExport: () => void; + exporting: boolean; +} + +export type ExportNotice = { + kind: 'success' | 'error'; + message: string; +}; + +export interface MapPageProps { + features: FeatureMeta[]; + poiCategoryGroups: POICategoryGroup[]; + initialFilters: FeatureFilters; + initialViewState: ViewState; + initialPOICategories: Set; + initialTab: 'properties' | 'area'; + initialLoading: boolean; + theme: 'light' | 'dark'; + pendingInfoFeature: string | null; + onClearPendingInfoFeature: () => void; + onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void; + onExportStateChange?: (state: ExportState) => void; + screenshotMode?: boolean; + ogMode?: boolean; + isMobile?: boolean; + initialTravelTime?: TravelTimeInitial; + initialPostcode?: string; + shareCode?: string; + user?: { id: string; subscription: string; isAdmin?: boolean } | null; + onLoginClick: () => void; + onRegisterClick: () => void; + onSaveProperty?: (property: Property) => void; + onUnsaveProperty?: (id: string) => void; + isPropertySaved?: (address?: string, postcode?: string) => boolean; + getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined; + deferTutorial?: boolean; + onSaveSearch?: (name: string) => Promise; + savingSearch?: boolean; +} + +export type MapFlyTo = (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void; + +export interface PaneResizeHandlers { + onPointerDown: (event: PointerEvent) => void; + onPointerMove: (event: PointerEvent) => void; + onPointerUp: () => void; +} diff --git a/frontend/src/components/map/map-page/useExportController.ts b/frontend/src/components/map/map-page/useExportController.ts new file mode 100644 index 0000000..9006399 --- /dev/null +++ b/frontend/src/components/map/map-page/useExportController.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { TFunction } from 'i18next'; + +import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types'; +import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api'; +import { trackEvent } from '../../../lib/analytics'; +import type { ExportNotice, ExportState } from './types'; + +const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx'; +const EXPORT_TIMEOUT_MS = 150_000; +const EXPORT_NOTICE_MS = 6000; +const EXPORT_ERROR_NOTICE_MS = 9000; + +function getExportFileName(res: Response): string { + const disposition = res.headers.get('content-disposition'); + if (!disposition) return EXPORT_FILE_NAME; + + const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (encodedMatch?.[1]) { + try { + return decodeURIComponent(encodedMatch[1].trim()); + } catch { + return encodedMatch[1].trim(); + } + } + + const match = disposition.match(/filename="?([^";]+)"?/i); + return match?.[1]?.trim() || EXPORT_FILE_NAME; +} + +async function getExportErrorMessage(res: Response): Promise { + const fallback = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`; + const contentType = res.headers.get('content-type') ?? ''; + + try { + if (contentType.includes('application/json')) { + const data: unknown = await res.json(); + if (data && typeof data === 'object') { + const record = data as Record; + const message = record.message ?? record.error; + if (typeof message === 'string' && message.trim()) return message.trim(); + } + return fallback; + } + + const text = await res.text(); + return text.trim() || fallback; + } catch { + return fallback; + } +} + +function triggerExportDownload(blob: Blob, fileName: string): void { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + link.rel = 'noopener'; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + link.remove(); + + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); +} + +interface UseExportControllerOptions { + bounds: Bounds | null; + filters: FeatureFilters; + features: FeatureMeta[]; + t: TFunction; + onExportStateChange?: (state: ExportState) => void; +} + +export function useExportController({ + bounds, + filters, + features, + t, + onExportStateChange, +}: UseExportControllerOptions) { + const [exporting, setExporting] = useState(false); + const [exportNotice, setExportNotice] = useState(null); + const exportNoticeTimeoutRef = useRef(null); + + const clearExportNoticeTimer = useCallback(() => { + if (exportNoticeTimeoutRef.current !== null) { + window.clearTimeout(exportNoticeTimeoutRef.current); + exportNoticeTimeoutRef.current = null; + } + }, []); + + const clearExportNotice = useCallback(() => { + clearExportNoticeTimer(); + setExportNotice(null); + }, [clearExportNoticeTimer]); + + const showExportNotice = useCallback( + (notice: ExportNotice) => { + clearExportNoticeTimer(); + setExportNotice(notice); + exportNoticeTimeoutRef.current = window.setTimeout( + () => { + setExportNotice(null); + exportNoticeTimeoutRef.current = null; + }, + notice.kind === 'error' ? EXPORT_ERROR_NOTICE_MS : EXPORT_NOTICE_MS + ); + }, + [clearExportNoticeTimer] + ); + + useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]); + + const handleExport = useCallback(() => { + if (exporting) return; + if (!bounds) { + showExportNotice({ kind: 'error', message: t('header.exportUnavailable') }); + return; + } + + const { south, west, north, east } = bounds; + const params = new URLSearchParams({ + bounds: `${south},${west},${north},${east}`, + }); + const filterStr = buildFilterString(filters, features); + if (filterStr) params.set('filters', filterStr); + const url = apiUrl('export', params); + + const controller = new AbortController(); + let timedOut = false; + const timeoutId = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, EXPORT_TIMEOUT_MS); + + setExporting(true); + clearExportNotice(); + + void (async () => { + try { + const res = await fetch(url, authHeaders({ signal: controller.signal })); + if (!res.ok) throw new Error(await getExportErrorMessage(res)); + + const blob = await res.blob(); + if (blob.size === 0) throw new Error(t('header.exportEmpty')); + + triggerExportDownload(blob, getExportFileName(res)); + trackEvent('Export'); + showExportNotice({ kind: 'success', message: t('header.exportReady') }); + } catch (err) { + if (!timedOut) logNonAbortError('Export failed', err); + const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : ''; + showExportNotice({ + kind: 'error', + message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`, + }); + } finally { + window.clearTimeout(timeoutId); + setExporting(false); + } + })(); + }, [bounds, clearExportNotice, exporting, features, filters, showExportNotice, t]); + + useEffect(() => { + onExportStateChange?.({ onExport: handleExport, exporting }); + }, [handleExport, exporting, onExportStateChange]); + + return { + exporting, + exportNotice, + clearExportNotice, + handleExport, + }; +} diff --git a/frontend/src/lib/ethnicity-filter.ts b/frontend/src/lib/ethnicity-filter.ts new file mode 100644 index 0000000..5352e3d --- /dev/null +++ b/frontend/src/lib/ethnicity-filter.ts @@ -0,0 +1,106 @@ +import type { FeatureFilters, FeatureMeta } from '../types'; + +export const ETHNICITIES_FILTER_NAME = 'Ethnicities'; +export const ETHNICITIES_FILTER_KEY_PREFIX = `${ETHNICITIES_FILTER_NAME}:`; + +export const ETHNICITY_FEATURE_NAMES = [ + '% White', + '% South Asian', + '% East Asian', + '% Black', + '% Mixed', + '% Other', +] as const; + +const ETHNICITY_FEATURE_NAME_SET = new Set(ETHNICITY_FEATURE_NAMES); + +export function isEthnicityFeatureName(name: string): boolean { + return ETHNICITY_FEATURE_NAME_SET.has(name); +} + +export function isEthnicityFilterName(name: string): boolean { + return isEthnicityFeatureName(name) || name.startsWith(ETHNICITIES_FILTER_KEY_PREFIX); +} + +export function createEthnicityFilterKey(featureName: string, id: number | string): string { + return `${ETHNICITIES_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`; +} + +export function getEthnicityFilterKeyId(name: string): string | null { + if (!name.startsWith(ETHNICITIES_FILTER_KEY_PREFIX)) return null; + const rest = name.substring(ETHNICITIES_FILTER_KEY_PREFIX.length); + const lastColon = rest.lastIndexOf(':'); + return lastColon === -1 ? null : rest.substring(lastColon + 1); +} + +export function parseEthnicityFilterKey(name: string): string | null { + if (!name.startsWith(ETHNICITIES_FILTER_KEY_PREFIX)) return null; + const rest = name.substring(ETHNICITIES_FILTER_KEY_PREFIX.length); + const lastColon = rest.lastIndexOf(':'); + if (lastColon === -1) return null; + + const decoded = decodeURIComponent(rest.substring(0, lastColon)); + return isEthnicityFeatureName(decoded) ? decoded : null; +} + +export function getEthnicityFeatureName(name: string): string | null { + if (isEthnicityFeatureName(name)) return name; + return parseEthnicityFilterKey(name); +} + +export function replaceEthnicityFilterKeySelection(key: string, featureName: string): string { + const id = getEthnicityFilterKeyId(key) ?? '0'; + return createEthnicityFilterKey(featureName, id); +} + +export function getDefaultEthnicityFeatureName(features: FeatureMeta[]): string | null { + return ( + ETHNICITY_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ?? + null + ); +} + +export function normalizeEthnicityFilters(filters: FeatureFilters): FeatureFilters { + let changed = false; + const next: FeatureFilters = {}; + + for (const [name, value] of Object.entries(filters)) { + if (isEthnicityFeatureName(name)) { + next[createEthnicityFilterKey(name, Object.keys(next).length)] = value; + changed = true; + continue; + } + next[name] = value; + } + + return changed ? next : filters; +} + +export function getEthnicityFilterMeta(features: FeatureMeta[]): FeatureMeta { + const sourceFeatureName = getDefaultEthnicityFeatureName(features); + const sourceFeature = sourceFeatureName + ? features.find((feature) => feature.name === sourceFeatureName) + : undefined; + + return { + name: ETHNICITIES_FILTER_NAME, + type: 'numeric', + group: 'Demographics', + min: sourceFeature?.min ?? 0, + max: sourceFeature?.max ?? 100, + step: 0.1, + description: 'Population percentage by ethnic group', + detail: 'Filter by one Census 2021 ethnicity percentage at a time.', + source: 'ethnicity', + suffix: '%', + }; +} + +export function clampEthnicityRange( + value: [number, number], + feature?: FeatureMeta +): [number, number] { + const min = feature?.histogram?.min ?? feature?.min ?? 0; + const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]); + return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))]; +} diff --git a/frontend/src/lib/poi-distance-filter.ts b/frontend/src/lib/poi-distance-filter.ts new file mode 100644 index 0000000..5c63a98 --- /dev/null +++ b/frontend/src/lib/poi-distance-filter.ts @@ -0,0 +1,291 @@ +import type { FeatureFilters, FeatureMeta } from '../types'; + +export const POI_DISTANCE_FILTER_NAME = 'POI distance'; +export const POI_COUNT_2KM_FILTER_NAME = 'POIs within 2km'; +export const POI_COUNT_5KM_FILTER_NAME = 'POIs within 5km'; + +export const POI_FILTER_NAMES = [ + POI_DISTANCE_FILTER_NAME, + POI_COUNT_2KM_FILTER_NAME, + POI_COUNT_5KM_FILTER_NAME, +] as const; + +export type PoiFilterName = (typeof POI_FILTER_NAMES)[number]; +type PoiMetric = 'distance' | 'count_2km' | 'count_5km'; + +export const POI_DISTANCE_FILTER_KEY_PREFIX = `${POI_DISTANCE_FILTER_NAME}:`; + +export const POI_DISTANCE_FEATURE_NAMES = [ + 'Distance to nearest park (km)', + 'Distance to nearest grocery store (km)', + 'Distance to nearest tube station (km)', + 'Distance to nearest rail station (km)', + 'Distance to nearest Waitrose (km)', + 'Distance to nearest Tesco (km)', + 'Distance to nearest cafe (km)', + 'Distance to nearest pub (km)', + 'Distance to nearest restaurant (km)', +] as const; + +const LEGACY_POI_DISTANCE_FEATURE_NAME_SET = new Set(POI_DISTANCE_FEATURE_NAMES); +const LEGACY_POI_DISTANCE_AGGREGATE_OPTIONS = [ + 'Distance to nearest park (km)', + 'Distance to nearest grocery store (km)', +] as const; + +const DYNAMIC_DISTANCE_RE = /^Distance to nearest (.+) POI \(km\)$/; +const DYNAMIC_COUNT_RE = /^Number of (.+) POIs within (2|5)km$/; + +const POI_FILTER_CONFIGS: Record< + PoiFilterName, + { + metric: PoiMetric; + keyPrefix: string; + description: string; + detail: string; + defaultMax: number; + step: number; + suffix: string; + } +> = { + [POI_DISTANCE_FILTER_NAME]: { + metric: 'distance', + keyPrefix: POI_DISTANCE_FILTER_KEY_PREFIX, + description: 'Distance to nearby points of interest', + detail: 'Filter by distance to one nearby point-of-interest type at a time.', + defaultMax: 5, + step: 0.1, + suffix: ' km', + }, + [POI_COUNT_2KM_FILTER_NAME]: { + metric: 'count_2km', + keyPrefix: `${POI_COUNT_2KM_FILTER_NAME}:`, + description: 'Number of nearby points of interest within 2km', + detail: 'Filter by the count of one point-of-interest type within 2km.', + defaultMax: 20, + step: 1, + suffix: '', + }, + [POI_COUNT_5KM_FILTER_NAME]: { + metric: 'count_5km', + keyPrefix: `${POI_COUNT_5KM_FILTER_NAME}:`, + description: 'Number of nearby points of interest within 5km', + detail: 'Filter by the count of one point-of-interest type within 5km.', + defaultMax: 50, + step: 1, + suffix: '', + }, +}; + +function isPoiFilterNameValue(name: string): name is PoiFilterName { + return POI_FILTER_NAMES.includes(name as PoiFilterName); +} + +function getConfig(filterName: PoiFilterName) { + return POI_FILTER_CONFIGS[filterName]; +} + +function isDynamicPoiDistanceFeatureName(name: string): boolean { + return DYNAMIC_DISTANCE_RE.test(name); +} + +function getPoiMetric(name: string): PoiMetric | null { + if (isDynamicPoiDistanceFeatureName(name) || LEGACY_POI_DISTANCE_FEATURE_NAME_SET.has(name)) { + return 'distance'; + } + + const countMatch = name.match(DYNAMIC_COUNT_RE); + if (!countMatch) return null; + return countMatch[2] === '2' ? 'count_2km' : 'count_5km'; +} + +function getFilterNameForMetric(metric: PoiMetric): PoiFilterName { + if (metric === 'count_2km') return POI_COUNT_2KM_FILTER_NAME; + if (metric === 'count_5km') return POI_COUNT_5KM_FILTER_NAME; + return POI_DISTANCE_FILTER_NAME; +} + +export function getPoiFeatureCategory(name: string): string | null { + const distanceMatch = name.match(DYNAMIC_DISTANCE_RE); + if (distanceMatch) return distanceMatch[1]; + + const countMatch = name.match(DYNAMIC_COUNT_RE); + if (countMatch) return countMatch[1]; + + return null; +} + +export function isPoiDistanceFeatureName(name: string): boolean { + return isDynamicPoiDistanceFeatureName(name) || LEGACY_POI_DISTANCE_FEATURE_NAME_SET.has(name); +} + +export function isPoiFilterFeatureName(name: string): boolean { + return getPoiMetric(name) != null; +} + +export function getPoiFilterName(name: string): PoiFilterName | null { + for (const filterName of POI_FILTER_NAMES) { + if (name.startsWith(getConfig(filterName).keyPrefix)) return filterName; + } + const metric = getPoiMetric(name); + return metric ? getFilterNameForMetric(metric) : null; +} + +export function isPoiDistanceFilterName(name: string): boolean { + return getPoiFilterName(name) != null; +} + +export function createPoiFilterKey( + filterName: PoiFilterName, + featureName: string, + id: number | string +): string { + return `${getConfig(filterName).keyPrefix}${encodeURIComponent(featureName)}:${id}`; +} + +export function createPoiDistanceFilterKey(featureName: string, id: number | string): string { + return createPoiFilterKey(POI_DISTANCE_FILTER_NAME, featureName, id); +} + +export function getPoiFilterKeyId(name: string): string | null { + const filterName = getPoiFilterName(name); + if (!filterName) return null; + const prefix = getConfig(filterName).keyPrefix; + if (!name.startsWith(prefix)) return null; + const rest = name.substring(prefix.length); + const lastColon = rest.lastIndexOf(':'); + return lastColon === -1 ? null : rest.substring(lastColon + 1); +} + +export function getPoiDistanceFilterKeyId(name: string): string | null { + return getPoiFilterKeyId(name); +} + +export function parsePoiFilterKey(name: string): string | null { + const filterName = getPoiFilterName(name); + if (!filterName) return null; + const prefix = getConfig(filterName).keyPrefix; + if (!name.startsWith(prefix)) return null; + const rest = name.substring(prefix.length); + const lastColon = rest.lastIndexOf(':'); + if (lastColon === -1) return null; + + const decoded = decodeURIComponent(rest.substring(0, lastColon)); + const metric = getPoiMetric(decoded); + return metric === getConfig(filterName).metric ? decoded : null; +} + +export function parsePoiDistanceFilterKey(name: string): string | null { + return parsePoiFilterKey(name); +} + +export function getPoiDistanceFeatureName(name: string): string | null { + if (isPoiFilterFeatureName(name)) return name; + return parsePoiFilterKey(name); +} + +export function replacePoiFilterKeySelection(key: string, featureName: string): string { + const filterName = getPoiFilterName(key) ?? getFilterNameForMetric(getPoiMetric(featureName)!); + const id = getPoiFilterKeyId(key) ?? '0'; + return createPoiFilterKey(filterName, featureName, id); +} + +export function replacePoiDistanceFilterKeySelection(key: string, featureName: string): string { + return replacePoiFilterKeySelection(key, featureName); +} + +export function getPoiFilterFeatureOptions( + features: FeatureMeta[], + filterName: PoiFilterName +): FeatureMeta[] { + const metric = getConfig(filterName).metric; + const dynamicOptions = features.filter((feature) => { + const featureMetric = getPoiMetric(feature.name); + if (featureMetric !== metric) return false; + return metric !== 'distance' || isDynamicPoiDistanceFeatureName(feature.name); + }); + + if (dynamicOptions.length > 0 && metric === 'distance') { + const aggregateOptions = LEGACY_POI_DISTANCE_AGGREGATE_OPTIONS.map((name) => + features.find((feature) => feature.name === name) + ).filter((feature): feature is FeatureMeta => Boolean(feature)); + return [...dynamicOptions, ...aggregateOptions]; + } + + if (dynamicOptions.length > 0 || metric !== 'distance') { + return dynamicOptions; + } + + return POI_DISTANCE_FEATURE_NAMES.map((name) => + features.find((feature) => feature.name === name) + ).filter((feature): feature is FeatureMeta => Boolean(feature)); +} + +export function getDefaultPoiFilterFeatureName( + features: FeatureMeta[], + filterName: PoiFilterName +): string | null { + return getPoiFilterFeatureOptions(features, filterName)[0]?.name ?? null; +} + +export function getDefaultPoiDistanceFeatureName(features: FeatureMeta[]): string | null { + return getDefaultPoiFilterFeatureName(features, POI_DISTANCE_FILTER_NAME); +} + +export function getPoiFilterMeta(features: FeatureMeta[], filterName: PoiFilterName): FeatureMeta { + const sourceFeatureName = getDefaultPoiFilterFeatureName(features, filterName); + const sourceFeature = sourceFeatureName + ? features.find((feature) => feature.name === sourceFeatureName) + : undefined; + const config = getConfig(filterName); + + return { + name: filterName, + type: 'numeric', + group: 'Nearby POIs', + min: sourceFeature?.min ?? 0, + max: sourceFeature?.max ?? config.defaultMax, + step: config.step, + description: config.description, + detail: config.detail, + source: sourceFeature?.source ?? 'osm-pois', + suffix: config.suffix, + }; +} + +export function getPoiDistanceFilterMeta(features: FeatureMeta[]): FeatureMeta { + return getPoiFilterMeta(features, POI_DISTANCE_FILTER_NAME); +} + +export function normalizePoiDistanceFilters(filters: FeatureFilters): FeatureFilters { + let changed = false; + const next: FeatureFilters = {}; + + for (const [name, value] of Object.entries(filters)) { + if (isPoiFilterFeatureName(name)) { + const filterName = getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME; + next[createPoiFilterKey(filterName, name, Object.keys(next).length)] = value; + changed = true; + continue; + } + next[name] = value; + } + + return changed ? next : filters; +} + +export function clampPoiFilterRange( + value: [number, number], + feature?: FeatureMeta +): [number, number] { + const min = feature?.histogram?.min ?? feature?.min ?? 0; + const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]); + return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))]; +} + +export function clampPoiDistanceRange( + value: [number, number], + feature?: FeatureMeta +): [number, number] { + return clampPoiFilterRange(value, feature); +}