From f59d01227b2d88483c9b2867fe75904d32f261ef Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 12 Jun 2026 21:51:37 +0100 Subject: [PATCH] SPlit up --- frontend/src/components/map/AreaPane.tsx | 10 +- .../src/components/map/CrimeYearChart.tsx | 15 +- frontend/src/components/map/DeckOverlay.tsx | 67 + .../src/components/map/HoverCardOverlay.tsx | 45 + frontend/src/components/map/ListingPopups.tsx | 146 + frontend/src/components/map/Map.tsx | 710 +--- frontend/src/components/map/MapPage.tsx | 579 ++-- frontend/src/components/map/MapTopCards.tsx | 138 + .../src/components/map/OverlayTileLayers.tsx | 163 + frontend/src/components/map/PoiPopupCard.tsx | 188 ++ .../map/map-page/useMobileDrawer.ts | 131 + frontend/src/components/ui/SubNav.tsx | 4 +- frontend/src/hooks/useMapCardLayout.ts | 43 + frontend/src/i18n/locales/de.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/fr.ts | 1 + frontend/src/i18n/locales/hi.ts | 1 + frontend/src/i18n/locales/hu.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + frontend/src/types.ts | 6 + pipeline/check_school_cutoffs.py | 11 +- pipeline/download/crime.py | 135 +- pipeline/download/ethnicity.py | 29 +- pipeline/download/gias.py | 8 +- pipeline/download/greenspace_water.py | 26 +- pipeline/download/listed_buildings.py | 36 +- pipeline/download/lsoa_children.py | 30 +- pipeline/download/lsoa_population.py | 30 +- pipeline/download/map_assets.py | 57 +- pipeline/download/median_age.py | 29 +- pipeline/download/naptan.py | 50 +- pipeline/download/oa_boundaries.py | 8 +- pipeline/download/places.py | 22 +- pipeline/download/rental_prices.py | 26 +- pipeline/download/rightmove_outcodes.py | 73 +- pipeline/download/test_crime.py | 63 + pipeline/download/test_gias.py | 54 + pipeline/download/test_naptan.py | 31 + pipeline/download/test_places.py | 6 +- pipeline/download/test_rental_prices.py | 41 +- pipeline/download/test_transit_network.py | 41 + pipeline/download/transit_network.py | 162 +- pipeline/transform/crime.py | 19 +- pipeline/transform/join_epc_pp.py | 25 + pipeline/transform/merge.py | 43 +- pipeline/transform/school_catchments.py | 31 +- pipeline/transform/test_crime.py | 45 + pipeline/transform/test_join_epc_pp.py | 4 + pipeline/transform/test_merge.py | 31 + pipeline/transform/test_school_catchments.py | 22 + pipeline/transform/transform_poi.py | 5 +- pipeline/utils/__init__.py | 11 +- pipeline/utils/download.py | 91 + pipeline/utils/fuzzy_join.py | 181 +- pipeline/utils/normalize.py | 70 + pipeline/utils/test_fuzzy_join.py | 155 +- pipeline/utils/test_normalize.py | 158 + server-rs/src/checkout_sessions.rs | 1659 --------- server-rs/src/checkout_sessions/lifecycle.rs | 589 ++++ server-rs/src/checkout_sessions/mod.rs | 133 + server-rs/src/checkout_sessions/records.rs | 564 ++++ server-rs/src/checkout_sessions/referral.rs | 312 ++ server-rs/src/checkout_sessions/stripe.rs | 175 + server-rs/src/checkout_sessions/tests.rs | 688 ++++ server-rs/src/data/crime_by_year.rs | 15 +- server-rs/src/data/places.rs | 23 + server-rs/src/data/poi.rs | 23 + server-rs/src/data/property.rs | 3005 ----------------- server-rs/src/data/property/address_search.rs | 973 ++++++ server-rs/src/data/property/h3.rs | 34 + server-rs/src/data/property/loading.rs | 1105 ++++++ server-rs/src/data/property/mod.rs | 238 ++ server-rs/src/data/property/poi_metrics.rs | 200 ++ server-rs/src/data/property/quant.rs | 46 + server-rs/src/data/property/stats.rs | 544 +++ server-rs/src/data/travel_time.rs | 15 + server-rs/src/main.rs | 37 + server-rs/src/routes/ai_filters.rs | 1585 --------- server-rs/src/routes/ai_filters/handler.rs | 448 +++ server-rs/src/routes/ai_filters/matching.rs | 158 + server-rs/src/routes/ai_filters/mod.rs | 81 + server-rs/src/routes/ai_filters/parsing.rs | 385 +++ server-rs/src/routes/ai_filters/prompt.rs | 282 ++ server-rs/src/routes/ai_filters/tools.rs | 188 ++ server-rs/src/routes/ai_filters/usage.rs | 119 + server-rs/src/routes/export.rs | 25 +- server-rs/src/routes/hexagon_stats.rs | 12 + server-rs/src/routes/pb_proxy.rs | 36 +- server-rs/src/routes/postcode_stats.rs | 7 + server-rs/src/routes/stats.rs | 19 +- server-rs/src/state.rs | 99 + 91 files changed, 10370 insertions(+), 7562 deletions(-) create mode 100644 frontend/src/components/map/DeckOverlay.tsx create mode 100644 frontend/src/components/map/HoverCardOverlay.tsx create mode 100644 frontend/src/components/map/ListingPopups.tsx create mode 100644 frontend/src/components/map/MapTopCards.tsx create mode 100644 frontend/src/components/map/OverlayTileLayers.tsx create mode 100644 frontend/src/components/map/PoiPopupCard.tsx create mode 100644 frontend/src/components/map/map-page/useMobileDrawer.ts create mode 100644 frontend/src/hooks/useMapCardLayout.ts create mode 100644 pipeline/download/test_gias.py create mode 100644 pipeline/utils/normalize.py create mode 100644 pipeline/utils/test_normalize.py delete mode 100644 server-rs/src/checkout_sessions.rs create mode 100644 server-rs/src/checkout_sessions/lifecycle.rs create mode 100644 server-rs/src/checkout_sessions/mod.rs create mode 100644 server-rs/src/checkout_sessions/records.rs create mode 100644 server-rs/src/checkout_sessions/referral.rs create mode 100644 server-rs/src/checkout_sessions/stripe.rs create mode 100644 server-rs/src/checkout_sessions/tests.rs delete mode 100644 server-rs/src/data/property.rs create mode 100644 server-rs/src/data/property/address_search.rs create mode 100644 server-rs/src/data/property/h3.rs create mode 100644 server-rs/src/data/property/loading.rs create mode 100644 server-rs/src/data/property/mod.rs create mode 100644 server-rs/src/data/property/poi_metrics.rs create mode 100644 server-rs/src/data/property/quant.rs create mode 100644 server-rs/src/data/property/stats.rs delete mode 100644 server-rs/src/routes/ai_filters.rs create mode 100644 server-rs/src/routes/ai_filters/handler.rs create mode 100644 server-rs/src/routes/ai_filters/matching.rs create mode 100644 server-rs/src/routes/ai_filters/mod.rs create mode 100644 server-rs/src/routes/ai_filters/parsing.rs create mode 100644 server-rs/src/routes/ai_filters/prompt.rs create mode 100644 server-rs/src/routes/ai_filters/tools.rs create mode 100644 server-rs/src/routes/ai_filters/usage.rs diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 35b2fd1..dddca81 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -619,7 +619,10 @@ export default function AreaPane({ /> {crimeSeries && crimeSeries.points.length > 1 && (
- +
)} @@ -663,7 +666,10 @@ export default function AreaPane({ } chart={ crimeSeries && crimeSeries.points.length > 1 ? ( - + ) : ( numericStats.histogram && (globalHistogram ? ( diff --git a/frontend/src/components/map/CrimeYearChart.tsx b/frontend/src/components/map/CrimeYearChart.tsx index 82f7a68..b518c21 100644 --- a/frontend/src/components/map/CrimeYearChart.tsx +++ b/frontend/src/components/map/CrimeYearChart.tsx @@ -1,14 +1,22 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import type { CrimeYearPoint } from '../../types'; interface CrimeYearChartProps { points: CrimeYearPoint[]; + /** + * Latest year available in the crime dataset as a whole. When the series + * ends earlier, the area's police force stopped publishing (e.g. Greater + * Manchester since mid-2019) and the chart is captioned as stale. + */ + latestAvailableYear?: number; } const PADDING = { top: 6, right: 4, bottom: 14, left: 4 }; const HEIGHT = 48; -export default function CrimeYearChart({ points }: CrimeYearChartProps) { +export default function CrimeYearChart({ points, latestAvailableYear }: CrimeYearChartProps) { + const { t } = useTranslation(); const containerRef = useRef(null); const [width, setWidth] = useState(0); @@ -97,6 +105,11 @@ export default function CrimeYearChart({ points }: CrimeYearChartProps) { )} + {latestAvailableYear != null && yearMax < latestAvailableYear && ( +

+ {t('areaPane.crimeDataEnds', { year: yearMax })} +

+ )} ); } diff --git a/frontend/src/components/map/DeckOverlay.tsx b/frontend/src/components/map/DeckOverlay.tsx new file mode 100644 index 0000000..f453aae --- /dev/null +++ b/frontend/src/components/map/DeckOverlay.tsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; +import { useControl } from 'react-map-gl/maplibre'; +import { MapboxOverlay } from '@deck.gl/mapbox'; + +interface DeckWithPrivateDraw { + _drawLayers?: ( + redrawReason: string, + renderOptions?: { viewports?: unknown[]; [key: string]: unknown } + ) => unknown; + __propertyMapNullViewportPatch?: boolean; +} + +function patchNullViewportDraw(overlay: MapboxOverlay) { + const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck; + if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') { + return; + } + + const drawLayers = deck._drawLayers.bind(deck); + deck._drawLayers = (redrawReason, renderOptions) => { + const viewports = renderOptions?.viewports; + if (viewports) { + // Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map. + const nonNullViewports = viewports.filter(Boolean); + if (nonNullViewports.length === 0) return; + if (nonNullViewports.length !== viewports.length) { + return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports }); + } + } + return drawLayers(redrawReason, renderOptions); + }; + deck.__propertyMapNullViewportPatch = true; +} + +class SafeMapboxOverlay extends MapboxOverlay { + onAdd(map: unknown) { + const element = super.onAdd(map); + patchNullViewportDraw(this); + return element; + } + + setProps(props: Parameters[0]) { + super.setProps(props); + patchNullViewportDraw(this); + } +} + +export function DeckOverlay({ + layers, + getTooltip, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layers: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getTooltip: any; +}) { + const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true })); + + useEffect(() => { + overlay.setProps({ + layers: layers.filter(Boolean), + getTooltip, + }); + }, [overlay, layers, getTooltip]); + + return null; +} diff --git a/frontend/src/components/map/HoverCardOverlay.tsx b/frontend/src/components/map/HoverCardOverlay.tsx new file mode 100644 index 0000000..87a6057 --- /dev/null +++ b/frontend/src/components/map/HoverCardOverlay.tsx @@ -0,0 +1,45 @@ +import { memo } from 'react'; + +import type { FeatureFilters, FeatureMeta, HexagonData, PostcodeFeature } from '../../types'; +import HoverCard from './HoverCard'; + +interface HoverCardOverlayProps { + x: number; + y: number; + id: string; + usePostcodeView: boolean; + data: HexagonData[]; + postcodeData: PostcodeFeature[]; + filters: FeatureFilters; + features: FeatureMeta[]; +} + +/** Resolves the hovered hexagon/postcode row from the loaded map data and + * renders the hover card for it. Memoized so the row lookup only reruns when + * the hover target or the underlying data actually changes. */ +export const HoverCardOverlay = memo(function HoverCardOverlay({ + x, + y, + id, + usePostcodeView, + data, + postcodeData, + filters, + features, +}: HoverCardOverlayProps) { + return ( + f.properties.postcode === id)?.properties || null + : data.find((d) => d.h3 === id) || null + } + filters={filters} + features={features} + /> + ); +}); diff --git a/frontend/src/components/map/ListingPopups.tsx b/frontend/src/components/map/ListingPopups.tsx new file mode 100644 index 0000000..f04e99b --- /dev/null +++ b/frontend/src/components/map/ListingPopups.tsx @@ -0,0 +1,146 @@ +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; + +import type { ActualListing } from '../../types'; + +function formatListingPrice(price: number): string { + return `£${price.toLocaleString()}`; +} + +function formatListingHeadline(listing: ActualListing, t: TFunction): string | null { + const parts: string[] = []; + if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms })); + if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms })); + if (listing.property_sub_type) parts.push(listing.property_sub_type); + else if (listing.property_type) parts.push(listing.property_type); + return parts.length > 0 ? parts.join(' · ') : null; +} + +export const ListingPopupSingleContent = memo(function ListingPopupSingleContent({ + listing, +}: { + listing: ActualListing; +}) { + const { t } = useTranslation(); + return ( + + {listing.asking_price != null && ( +
+ {formatListingPrice(listing.asking_price)} + {listing.price_qualifier ? ( + + {listing.price_qualifier} + + ) : null} +
+ )} + {formatListingHeadline(listing, t) && ( +
+ {formatListingHeadline(listing, t)} +
+ )} + {listing.address && ( +
+ {listing.address} +
+ )} + {listing.postcode && ( +
+ {listing.postcode} +
+ )} + {listing.floor_area_sqm != null && ( +
+ {Math.round(listing.floor_area_sqm)} sqm + {listing.asking_price_per_sqm != null + ? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm` + : ''} +
+ )} + {listing.features.length > 0 && ( +
    + {listing.features.slice(0, 3).map((feature, idx) => ( +
  • + {feature} +
  • + ))} +
+ )} +
+ Open listing ↗ +
+
+ ); +}); + +export const ListingClusterPopupContent = memo(function ListingClusterPopupContent({ + count, + listings, +}: { + count: number; + listings: ActualListing[]; +}) { + const { t } = useTranslation(); + const visibleCount = listings.length; + return ( +
+
+
+ {count.toLocaleString()} listings +
+
+ {visibleCount > 0 + ? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}` + : 'Grouped near this map position'} +
+
+ {visibleCount > 0 && ( + + )} +
+ ); +}); diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 4d0f4a7..588bcbe 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -1,10 +1,8 @@ import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; import type { CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TFunction } from 'i18next'; -import { Layer, Map as MapGL, Source, useControl, ScaleControl } from 'react-map-gl/maplibre'; +import { Map as MapGL, ScaleControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; -import { MapboxOverlay } from '@deck.gl/mapbox'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { HexagonData, @@ -17,7 +15,6 @@ import type { Bounds, MapFlyToOptions, ActualListing, - SchoolMetadata, } from '../../types'; import { @@ -26,28 +23,25 @@ import { getBoundsWithBottomScreenInset, getMapStyle, getMapDataBeforeId, - getPoiIconUrl, getMapCenterForTargetScreenPoint, } from '../../lib/map-utils'; -import { - MAP_MIN_ZOOM, - MAP_BOUNDS, - POI_GROUP_COLORS, - POSTCODE_ZOOM_THRESHOLD, - POI_AUTO_CARD_ZOOM_THRESHOLD, -} from '../../lib/consts'; -import LocationSearch, { type SearchedLocation } from './LocationSearch'; -import MapLegend from './MapLegend'; -import HoverCard from './HoverCard'; +import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_AUTO_CARD_ZOOM_THRESHOLD } from '../../lib/consts'; +import type { SearchedLocation } from './LocationSearch'; import { LogoIcon } from '../ui/icons/LogoIcon'; import { CloseIcon } from '../ui/icons/CloseIcon'; import type { FeatureFilters } from '../../types'; import { useDeckLayers } from '../../hooks/useDeckLayers'; -import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime'; -import { ts } from '../../i18n/server'; -import { type OverlayId, OVERLAY_MIN_ZOOM } from '../../lib/overlays'; +import { useMapCardLayout } from '../../hooks/useMapCardLayout'; +import type { TravelTimeEntry } from '../../hooks/useTravelTime'; +import { type OverlayId } from '../../lib/overlays'; import { CRIME_TYPE_VALUES } from '../../lib/crime-types'; import type { BasemapId } from '../../lib/basemaps'; +import { DeckOverlay } from './DeckOverlay'; +import { OverlayTileLayers } from './OverlayTileLayers'; +import { MapTopCards } from './MapTopCards'; +import { PoiPopupCardContent } from './PoiPopupCard'; +import { ListingClusterPopupContent, ListingPopupSingleContent } from './ListingPopups'; +import { HoverCardOverlay } from './HoverCardOverlay'; interface MapProps { data: HexagonData[]; @@ -99,168 +93,11 @@ const EMPTY_ACTUAL_LISTINGS: ActualListing[] = []; const EMPTY_OVERLAYS = new Set(); const ALL_CRIME_TYPES = new Set(CRIME_TYPE_VALUES); -function formatListingPrice(price: number): string { - return `£${price.toLocaleString()}`; -} - -function formatListingHeadline(listing: ActualListing, t: TFunction): string | null { - const parts: string[] = []; - if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms })); - if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms })); - if (listing.property_sub_type) parts.push(listing.property_sub_type); - else if (listing.property_type) parts.push(listing.property_type); - return parts.length > 0 ? parts.join(' · ') : null; -} - -function ListingPopupSingleContent({ listing, t }: { listing: ActualListing; t: TFunction }) { - return ( - - {listing.asking_price != null && ( -
- {formatListingPrice(listing.asking_price)} - {listing.price_qualifier ? ( - - {listing.price_qualifier} - - ) : null} -
- )} - {formatListingHeadline(listing, t) && ( -
- {formatListingHeadline(listing, t)} -
- )} - {listing.address && ( -
- {listing.address} -
- )} - {listing.postcode && ( -
- {listing.postcode} -
- )} - {listing.floor_area_sqm != null && ( -
- {Math.round(listing.floor_area_sqm)} sqm - {listing.asking_price_per_sqm != null - ? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm` - : ''} -
- )} - {listing.features.length > 0 && ( -
    - {listing.features.slice(0, 3).map((feature, idx) => ( -
  • - {feature} -
  • - ))} -
- )} -
- Open listing ↗ -
-
- ); -} - -function ListingClusterPopupContent({ - count, - listings, - t, -}: { - count: number; - listings: ActualListing[]; - t: TFunction; -}) { - const visibleCount = listings.length; - return ( -
-
-
- {count.toLocaleString()} listings -
-
- {visibleCount > 0 - ? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}` - : 'Grouped near this map position'} -
-
- {visibleCount > 0 && ( - - )} -
- ); -} - -interface PoiPopupCardData { - name: string; - category: string; - icon_category?: string; - group: string; - emoji: string; - school?: SchoolMetadata; -} - interface Dimensions { width: number; height: number; } -const DESKTOP_TOP_CARD_WIDTH = 300; -const DESKTOP_TOP_CARD_GAP = 8; -const DESKTOP_TOP_CARD_HORIZONTAL_INSET = 24; -const DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH = - DESKTOP_TOP_CARD_WIDTH + DESKTOP_TOP_CARD_HORIZONTAL_INSET; -const DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH = - DESKTOP_TOP_CARD_WIDTH * 2 + DESKTOP_TOP_CARD_GAP + DESKTOP_TOP_CARD_HORIZONTAL_INSET; -const DESKTOP_TOP_CARD_CLASS = 'w-[300px]'; -const DESKTOP_LOCATION_SEARCH_INPUT_CLASS = - 'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'; - type MapContainerStyle = CSSProperties & { '--map-mobile-bottom-inset'?: string; }; @@ -323,218 +160,6 @@ function getViewportRelativeVisibleAreaCenter( }; } -interface DeckWithPrivateDraw { - _drawLayers?: ( - redrawReason: string, - renderOptions?: { viewports?: unknown[]; [key: string]: unknown } - ) => unknown; - __propertyMapNullViewportPatch?: boolean; -} - -function patchNullViewportDraw(overlay: MapboxOverlay) { - const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck; - if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') { - return; - } - - const drawLayers = deck._drawLayers.bind(deck); - deck._drawLayers = (redrawReason, renderOptions) => { - const viewports = renderOptions?.viewports; - if (viewports) { - // Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map. - const nonNullViewports = viewports.filter(Boolean); - if (nonNullViewports.length === 0) return; - if (nonNullViewports.length !== viewports.length) { - return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports }); - } - } - return drawLayers(redrawReason, renderOptions); - }; - deck.__propertyMapNullViewportPatch = true; -} - -class SafeMapboxOverlay extends MapboxOverlay { - onAdd(map: unknown) { - const element = super.onAdd(map); - patchNullViewportDraw(this); - return element; - } - - setProps(props: Parameters[0]) { - super.setProps(props); - patchNullViewportDraw(this); - } -} - -function getPoiGroupColor(group: string): [number, number, number] { - const color = POI_GROUP_COLORS[group]; - if (!color) { - throw new Error(`Missing POI group color for '${group}'`); - } - return color; -} - -/** Best-effort web URL from a free-text website field — GIAS stores some with - * "http://", some without, and some as bare hostnames. */ -function normalizeSchoolWebsiteUrl(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - if (/^https?:\/\//i.test(trimmed)) return trimmed; - if (/^[\w.-]+\.[a-z]{2,}/i.test(trimmed)) return `http://${trimmed}`; - return null; -} - -function renderSchoolMetadata(school: SchoolMetadata) { - // First line collects the headline classification (phase, type, religious - // character) so the popup is scannable even when most fields are absent. - const headline: string[] = []; - if (school.phase) headline.push(school.phase); - if (school.type) headline.push(school.type); - - const pupilsLine = - school.pupils !== undefined && school.capacity !== undefined - ? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils` - : school.pupils !== undefined - ? `${school.pupils.toLocaleString()} pupils` - : school.capacity !== undefined - ? `Capacity ${school.capacity.toLocaleString()}` - : null; - - const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null; - - return ( -
- {headline.length > 0 && ( - <> -
Type
-
{headline.join(' · ')}
- - )} - {school.age_range && ( - <> -
Ages
-
{school.age_range}
- - )} - {school.gender && school.gender !== 'Mixed' && ( - <> -
Gender
-
{school.gender}
- - )} - {pupilsLine && ( - <> -
Pupils
-
{pupilsLine}
- - )} - {school.fsm_percent !== undefined && ( - <> -
Free meal
-
{school.fsm_percent.toFixed(1)}%
- - )} - {school.ofsted_rating && ( - <> -
Ofsted
-
{school.ofsted_rating}
- - )} - {school.sixth_form === 'Has a sixth form' && ( - <> -
Sixth form
-
Yes
- - )} - {school.religious_character && - school.religious_character !== 'Does not apply' && - school.religious_character !== 'None' && ( - <> -
Religion
-
{school.religious_character}
- - )} - {school.admissions_policy && ( - <> -
Admissions
-
{school.admissions_policy}
- - )} - {school.trust && ( - <> -
Trust
-
{school.trust}
- - )} - {(school.address || school.postcode) && ( - <> -
Address
-
- {[school.address, school.postcode].filter(Boolean).join(', ')} -
- - )} - {school.local_authority && ( - <> -
LA
-
{school.local_authority}
- - )} - {school.head_name && ( - <> -
Head
-
{school.head_name}
- - )} - {websiteUrl && ( - <> -
Website
-
- - {websiteUrl.replace(/^https?:\/\//, '')} - -
- - )} -
- ); -} - -function PoiPopupCardContent({ poi }: { poi: PoiPopupCardData }) { - return ( -
-
- -
-
{poi.name}
-
- - {ts(poi.category)} -
-
-
- {poi.school && renderSchoolMetadata(poi.school)} -
- ); -} - function getRenderedViewState(map: MapRef | null): ViewState | null { if (!map) return null; @@ -565,186 +190,6 @@ function getRenderedVisibleCenter( }; } -function DeckOverlay({ - layers, - getTooltip, -}: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - layers: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getTooltip: any; -}) { - const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true })); - - useEffect(() => { - overlay.setProps({ - layers: layers.filter(Boolean), - getTooltip, - }); - }, [overlay, layers, getTooltip]); - - return null; -} - -function overlayTileUrl(path: string): string { - return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`; -} - -function OverlayTileLayers({ - activeOverlays, - activeCrimeTypes, - zoom, -}: { - activeOverlays: Set; - activeCrimeTypes: Set; - zoom: number; -}) { - if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null; - - const showNoise = activeOverlays.has('noise'); - const showCrime = activeOverlays.has('crime-hotspots'); - const showTrees = activeOverlays.has('trees-outside-woodlands'); - const showPropertyBorders = activeOverlays.has('property-borders'); - - // Restrict the heatmap to the selected crime types. This must always be a - // concrete expression: passing `filter={undefined}` makes react-map-gl call - // map.addLayer({filter: undefined}), which MapLibre rejects at validation - // ("filter: array expected, undefined found"), so the layer is never created - // and the heatmap stays blank until a later setFilter call. An `in` over the - // selected types matches everything when all 14 are selected. - const crimeFilter = ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]]; - - return ( - <> - {showNoise && ( - - - - )} - - {showCrime && ( - - - - )} - - {showTrees && ( - - - - )} - - {showPropertyBorders && ( - - - - )} - - ); -} - export default memo(function Map({ data, postcodeData, @@ -790,7 +235,6 @@ export default memo(function Map({ const containerRef = useRef(null); const mapRef = useRef(null); const { t } = useTranslation(); - const modes = useTranslatedModes(); const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties'); const [internalViewState, setInternalViewState] = useState(initialViewState); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); @@ -941,23 +385,16 @@ export default memo(function Map({ () => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}), [bottomScreenInset] ); - const hideDesktopTopCardsForWidth = - hideTopCardsWhenNarrow && - dimensions.width > 0 && - dimensions.width < DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH; - const stackDesktopTopCards = - hideTopCardsWhenNarrow && - dimensions.width >= DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH && - dimensions.width < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH; - const showLocationSearch = !hideLocationSearch && !hideDesktopTopCardsForWidth; - const showLegend = !hideLegend && !hideDesktopTopCardsForWidth; + const { showLocationSearch, showLegend, topCardsLayoutClass } = useMapCardLayout({ + mapWidth: dimensions.width, + hideTopCardsWhenNarrow, + hideLegend, + hideLocationSearch, + }); const getViewportCenter = useCallback(() => { const center = mapRef.current?.getCenter(); return center ? { lat: center.lat, lng: center.lng } : null; }, []); - const desktopTopCardsLayoutClass = stackDesktopTopCards - ? 'flex-col items-start' - : 'items-start justify-between'; const { layers, @@ -1108,79 +545,29 @@ export default memo(function Map({ ) : ( <> {(showLocationSearch || showLegend) && ( -
- {showLocationSearch && ( - - )} - {showLegend && - (viewFeature && colorRange ? ( - viewFeature.startsWith('tt_') ? ( - - ) : colorFeatureMeta ? ( - - ) : null - ) : ( - - ))} -
+ )} {autoPoiCards.map(({ poi, x, y }) => (
{listingPopup.mode === 'single' ? ( - + ) : ( )}
)} {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && ( - f.properties.postcode === hoveredHexagonId) - ?.properties || null - : data.find((d) => d.h3 === hoveredHexagonId) || null - } + usePostcodeView={usePostcodeView} + data={data} + postcodeData={postcodeData} filters={filters} features={features} /> diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 9a1c9ae..acbe064 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -1,7 +1,7 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import type { ActualListing, MapFlyToOptions, PostcodeGeometry } from '../../types'; +import type { ActualListing, PostcodeGeometry } from '../../types'; import type { SearchedLocation } from './LocationSearch'; import { useMapData } from '../../hooks/useMapData'; import { usePOIData } from '../../hooks/usePOIData'; @@ -67,11 +67,11 @@ import { useMobileBackNavigationGuard, useScreenshotReadySignal, } from './map-page/effects'; +import { useMobileDrawer } from './map-page/useMobileDrawer'; import type { MapFlyTo, MapPageProps } from './map-page/types'; export type { ExportState } from './map-page/types'; -type PendingFlyTo = { lat: number; lng: number; zoom: number }; const EMPTY_ACTUAL_LISTINGS: ActualListing[] = []; export default function MapPage({ @@ -127,10 +127,11 @@ export default function MapPage({ ); 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 [overlayPaneOpen, setOverlayPaneOpen] = useState(false); + // The POI and overlay panes are mutually exclusive, so a single state tracks + // which one (if any) is open. + const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | null>(null); + const poiPaneOpen = openMapPane === 'poi'; + const overlayPaneOpen = openMapPane === 'overlay'; const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null); const [listingsToggleEnabled, setListingsToggleEnabled] = useState(true); const [pendingInitialPostcode, setPendingInitialPostcode] = useState( @@ -184,27 +185,21 @@ export default function MapPage({ } = useTravelTime(initialTravelTime); const mapFlyToRef = useRef(null); - const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null); - const pendingLocationSearchFlyToRef = useRef(null); - const mobileDrawerPanelRectRef = useRef(null); const areaPaneScrollTopRef = useRef(0); const propertiesPaneScrollTopRef = useRef(0); - const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => { - if (!isMobile) return undefined; - - const panelRect = mobileDrawerPanelRectRef.current; - if (mobileDrawerOpen && panelRect) { - const bottomInset = Math.max(0, window.innerHeight - panelRect.top); - if (bottomInset > 0) { - return { visibleViewportArea: { bottom: bottomInset } }; - } - } - - return mobileBottomSheetHeight > 0 - ? { visibleArea: { bottom: mobileBottomSheetHeight } } - : undefined; - }, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]); + const { + mobileDrawerOpen, + mobileBottomSheetHeight, + setMobileBottomSheetHeight, + openMobileDrawer, + openMobileDrawerForLocationSearch, + clearPendingLocationSearchFlyTo, + queueCurrentLocationFlyTo, + handleMobileDrawerPanelRectChange, + handleMobileDrawerClose, + getMobileMapFlyToOptions, + } = useMobileDrawer(isMobile, mapFlyToRef); const mapData = useMapData({ filters, @@ -217,6 +212,12 @@ export default function MapPage({ shareCode, }); + // Read the zoom through a ref inside handleAiFilterSubmit so panning/zooming + // doesn't recreate the callback (it sits in the Filters pane's dependency + // chain, which would otherwise re-render on every camera move). + const currentViewZoomRef = useRef(undefined); + currentViewZoomRef.current = mapData.currentView?.zoom; + const handleAiFilterSubmit = useCallback( async (query: string) => { const context = { @@ -283,7 +284,7 @@ export default function MapPage({ mapFlyToRef.current?.( destination.lat, destination.lon, - mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom, + currentViewZoomRef.current ?? INITIAL_VIEW_STATE.zoom, getMobileMapFlyToOptions() ); } @@ -298,7 +299,6 @@ export default function MapPage({ getMobileMapFlyToOptions, handleSetEntries, handleSetFilters, - mapData.currentView?.zoom, ] ); @@ -395,20 +395,6 @@ export default function MapPage({ journeyDest, }); - const consumePendingLocationSearchFlyTo = useCallback((rect?: DOMRectReadOnly | null) => { - const pending = pendingLocationSearchFlyToRef.current; - const panelRect = rect ?? mobileDrawerPanelRectRef.current; - if (!pending || !panelRect) return; - - const bottomInset = Math.max(0, window.innerHeight - panelRect.top); - const flyTo = mapFlyToRef.current; - if (!flyTo) return; - flyTo(pending.lat, pending.lng, pending.zoom, { - visibleViewportArea: { bottom: bottomInset }, - }); - pendingLocationSearchFlyToRef.current = null; - }, []); - const handleLocationSearchResult = useCallback( (result: SearchedLocation | null) => { if (result) { @@ -428,68 +414,41 @@ export default function MapPage({ result.focusAddress ); if (isMobile) { - pendingLocationSearchFlyToRef.current = { + openMobileDrawerForLocationSearch({ lat: markerLat ?? result.latitude, lng: markerLng ?? result.longitude, zoom: result.zoom, - }; - setMobileDrawerOpen(true); - consumePendingLocationSearchFlyTo(); + }); } } else { setCurrentLocation(null); - pendingLocationSearchFlyToRef.current = null; + clearPendingLocationSearchFlyTo(); handleCloseSelection(); } }, - [consumePendingLocationSearchFlyTo, handleCloseSelection, handleLocationSearch, isMobile] + [ + clearPendingLocationSearchFlyTo, + handleCloseSelection, + handleLocationSearch, + isMobile, + openMobileDrawerForLocationSearch, + ] ); - const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => { - const pending = pendingCurrentLocationFlyToRef.current; - const panelRect = rect ?? mobileDrawerPanelRectRef.current; - if (!pending || !panelRect) return; - - const bottomInset = Math.max(0, window.innerHeight - panelRect.top); - const flyTo = mapFlyToRef.current; - if (!flyTo) return; - flyTo(pending.lat, pending.lng, 17, { - visibleViewportArea: { bottom: bottomInset }, - }); - pendingCurrentLocationFlyToRef.current = null; - }, []); - const handleCurrentLocationFound = useCallback( (lat: number, lng: number) => { if (isMobile) { - pendingCurrentLocationFlyToRef.current = { lat, lng }; - consumePendingCurrentLocationFlyTo(); + queueCurrentLocationFlyTo(lat, lng); } else { mapFlyToRef.current?.(lat, lng, 17); } setCurrentLocation({ lat, lng }); handleCurrentLocationSearch(lat, lng); - if (isMobile) setMobileDrawerOpen(true); + if (isMobile) openMobileDrawer(); }, - [consumePendingCurrentLocationFlyTo, handleCurrentLocationSearch, isMobile] + [handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo] ); - const handleMobileDrawerPanelRectChange = useCallback( - (rect: DOMRectReadOnly) => { - mobileDrawerPanelRectRef.current = rect; - consumePendingCurrentLocationFlyTo(rect); - consumePendingLocationSearchFlyTo(rect); - }, - [consumePendingCurrentLocationFlyTo, consumePendingLocationSearchFlyTo] - ); - - const handleMobileDrawerClose = useCallback(() => { - pendingCurrentLocationFlyToRef.current = null; - pendingLocationSearchFlyToRef.current = null; - mobileDrawerPanelRectRef.current = null; - setMobileDrawerOpen(false); - }, []); - const shareReturnViewRef = useRef(shareCode ? initialViewState : null); // Hide the upgrade modal as soon as the user dismisses it. We can't rely on // the camera fly alone to close it: flying back to the free/shared zone only @@ -555,11 +514,7 @@ export default function MapPage({ isMobile, flyTo: mapFlyToRef, onLocationSearch: handleLocationSearch, - onOpenMobileDrawer: (target) => { - pendingLocationSearchFlyToRef.current = target; - setMobileDrawerOpen(true); - consumePendingLocationSearchFlyTo(); - }, + onOpenMobileDrawer: openMobileDrawerForLocationSearch, onSettled: () => setPendingInitialPostcode(null), }); useHorizontalSwipeNavigationGuard(); @@ -578,10 +533,10 @@ export default function MapPage({ (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => { handleHexagonClick(id, isPostcode, geometry); if (id) { - setMobileDrawerOpen(true); + openMobileDrawer(); } }, - [handleHexagonClick] + [handleHexagonClick, openMobileDrawer] ); const hexagonLocation = useHexagonLocation( @@ -641,15 +596,20 @@ export default function MapPage({ shareAndSaveView, ] ); + // dashboardParams changes on every camera move; read it through a ref so the + // save/update handlers (and the Filters pane depending on them) stay stable + // while panning. The ref always holds the params of the latest render. + const dashboardParamsRef = useRef(dashboardParams); + dashboardParamsRef.current = dashboardParams; const handleSaveSearch = useCallback( async (name: string) => { - await onSaveSearch?.(name, dashboardParams); + await onSaveSearch?.(name, dashboardParamsRef.current); }, - [dashboardParams, onSaveSearch] + [onSaveSearch] ); const handleUpdateEditInPlaceWithParams = useCallback(async () => { - await onUpdateEditInPlace?.(dashboardParams); - }, [dashboardParams, onUpdateEditInPlace]); + await onUpdateEditInPlace?.(dashboardParamsRef.current); + }, [onUpdateEditInPlace]); const checkoutReturnPath = useMemo( () => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`, [dashboardParams] @@ -686,6 +646,273 @@ export default function MapPage({ } }, [mapData.licenseRequired]); + const handleUpgradeClick = useCallback(() => { + onNavigateTo('pricing'); + }, [onNavigateTo]); + const handleTogglePoiPane = useCallback(() => { + setOpenMapPane((pane) => (pane === 'poi' ? null : 'poi')); + }, []); + const handleToggleOverlayPane = useCallback(() => { + setOpenMapPane((pane) => (pane === 'overlay' ? null : 'overlay')); + }, []); + const handleClosePoiPane = useCallback(() => { + setOpenMapPane((pane) => (pane === 'poi' ? null : pane)); + }, []); + const handleCloseOverlayPane = useCallback(() => { + setOpenMapPane((pane) => (pane === 'overlay' ? null : pane)); + }, []); + const handleAreaTabClick = useCallback(() => { + setRightPaneTab('area'); + }, [setRightPaneTab]); + const handleMobileDrawerTabChange = useCallback( + (tab: 'area' | 'properties') => { + if (tab === 'properties') { + handlePropertiesTabClick(); + } else { + setRightPaneTab(tab); + } + }, + [handlePropertiesTabClick, setRightPaneTab] + ); + + const renderAreaPane = useCallback( + () => ( + }> + + + ), + [ + activeEntries, + areaStats, + areaStatsUseFilters, + features, + filters, + hexagonLocation, + isAreaGroupExpanded, + loadingAreaStats, + selectedHexagon, + setAreaStatsUseFilters, + shareCode, + toggleAreaGroup, + unfilteredAreaCount, + ] + ); + + const renderPropertiesPane = useCallback( + () => ( + }> + + + ), + [handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon] + ); + + const poiPane = useMemo( + () => ( + }> + + + ), + [handleClosePoiPane, poiCategoryGroups, pois.length, selectedPOICategories] + ); + + const overlayPane = useMemo( + () => ( + }> + + + ), + [activeOverlays, basemap, colorOpacity, crimeTypes, handleCloseOverlayPane, overlaysZoomedIn] + ); + + const filtersPane = useMemo( + () => ( + }> + + + ), + [ + activeFeature, + aiFilterError, + aiFilterErrorType, + aiFilterLoading, + aiFilterNotes, + aiFilterSummary, + dragValue, + editingSearch, + enabledFeatures, + entries, + features, + filterCounts.impacts, + filters, + handleAddEntry, + handleAddFilter, + handleAiFilterSubmit, + handleClearAll, + handleDragChange, + handleDragEnd, + handleDragStart, + handleFilterChange, + handleRemoveFilter, + handleSaveSearch, + handleTimeRangeChange, + handleToggleBest, + handleToggleNoBuses, + handleToggleNoChange, + handleTogglePin, + handleTravelTimeDragEnd, + handleTravelTimeRemoveEntry, + handleTravelTimeSetDestination, + handleUpdateEditInPlaceWithParams, + handleUpgradeClick, + isMobile, + onCancelEdit, + onClearPendingInfoFeature, + onRegisterClick, + onSaveSearch, + onUpdateEditInPlace, + pendingInfoFeature, + pinnedFeature, + savingSearch, + tutorial.resetTutorial, + user, + ] + ); + + const mobileLegend = useMemo( + () => ( + + ), + [ + densityLabel, + handleCancelPin, + mapData.canResetPreviewScale, + mapData.colorRange, + mapData.handleResetPreviewScale, + mapViewFeature, + mobileDensityRange, + mobileLegendMeta, + theme, + viewSource, + ] + ); + + const toasts = useMemo( + () => ( + + ), + [clearExportNotice, exportNotice, t] + ); + if (screenshotMode) { return ( ( - }> - - - ); - - const renderPropertiesPane = () => ( - }> - - - ); - - const renderPOIPane = () => ( - }> - setPoiPaneOpen(false)} - /> - - ); - - const renderOverlayPane = () => ( - }> - setOverlayPaneOpen(false)} - /> - - ); - - const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => ( - }> - onNavigateTo('pricing')} - onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined} - filterImpacts={filterCounts.impacts} - onClearAll={handleClearAll} - onSaveSearch={onSaveSearch ? handleSaveSearch : undefined} - savingSearch={savingSearch} - editingSearchName={editingSearch?.name ?? null} - onUpdateSearch={ - editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined - } - onExitEditing={onCancelEdit} - destinationDropdownPortal={options?.destinationDropdownPortal} - /> - - ); - - const handleTogglePoiPane = () => { - setOverlayPaneOpen(false); - setPoiPaneOpen((open) => !open); - }; - const handleToggleOverlayPane = () => { - setPoiPaneOpen(false); - setOverlayPaneOpen((open) => !open); - }; - const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => { - if (tab === 'properties') { - handlePropertiesTabClick(); - } else { - setRightPaneTab(tab); - } - }; - - const exportToast = ( - - ); - const toasts = exportToast; - const editingBar = editingSearch && isMobile ? (
@@ -940,25 +1026,12 @@ export default function MapPage({ poiPaneOpen={poiPaneOpen} onTogglePoiPane={handleTogglePoiPane} poiButtonLabel={t('poiPane.pointsOfInterest')} - poiPane={renderPOIPane()} + poiPane={poiPane} overlayPaneOpen={overlayPaneOpen} onToggleOverlayPane={handleToggleOverlayPane} - overlayPane={renderOverlayPane()} - filtersPane={renderFilters({ destinationDropdownPortal: false })} - mobileLegend={ - - } + overlayPane={overlayPane} + filtersPane={filtersPane} + mobileLegend={mobileLegend} renderAreaPane={renderAreaPane} renderPropertiesPane={renderPropertiesPane} toasts={toasts} @@ -975,7 +1048,7 @@ export default function MapPage({ tutorialTheme={tutorialTheme} leftPaneWidth={leftPaneWidth} leftPaneHandlers={leftPaneHandlers} - filtersPane={renderFilters()} + filtersPane={filtersPane} mapData={mapData} pois={pois} activeOverlays={activeOverlays} @@ -1008,15 +1081,15 @@ export default function MapPage({ totalCount={filterCounts.total ?? undefined} poiPaneOpen={poiPaneOpen} onTogglePoiPane={handleTogglePoiPane} - poiPane={renderPOIPane()} + poiPane={poiPane} overlayPaneOpen={overlayPaneOpen} onToggleOverlayPane={handleToggleOverlayPane} - overlayPane={renderOverlayPane()} + overlayPane={overlayPane} showSelectionPane={!!selectedHexagon} rightPaneWidth={rightPaneWidth} rightPaneHandlers={rightPaneHandlers} rightPaneTab={rightPaneTab} - onAreaTabClick={() => setRightPaneTab('area')} + onAreaTabClick={handleAreaTabClick} onPropertiesTabClick={handlePropertiesTabClick} onCloseSelection={handleCloseSelection} renderAreaPane={renderAreaPane} diff --git a/frontend/src/components/map/MapTopCards.tsx b/frontend/src/components/map/MapTopCards.tsx new file mode 100644 index 0000000..1846fa5 --- /dev/null +++ b/frontend/src/components/map/MapTopCards.tsx @@ -0,0 +1,138 @@ +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FeatureMeta, MapFlyToOptions } from '../../types'; +import { useTranslatedModes } from '../../hooks/useTravelTime'; +import { ts } from '../../i18n/server'; +import LocationSearch, { type SearchedLocation } from './LocationSearch'; +import MapLegend from './MapLegend'; + +const DESKTOP_TOP_CARD_CLASS = 'w-[300px]'; +const DESKTOP_LOCATION_SEARCH_INPUT_CLASS = + 'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'; + +interface MapTopCardsProps { + layoutClass: string; + showLocationSearch: boolean; + showLegend: boolean; + onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void; + onLocationSearched?: (location: SearchedLocation | null) => void; + onCurrentLocationFound?: (lat: number, lng: number) => void; + onLocationSearchMouseEnter: () => void; + getViewportCenter: () => { lat: number; lng: number } | null; + viewFeature: string | null; + colorRange: [number, number] | null; + viewSource: 'drag' | 'eye' | null; + onCancelPin: () => void; + onResetPreviewScale?: () => void; + canResetPreviewScale: boolean; + colorFeatureMeta: FeatureMeta | null; + usePostcodeView: boolean; + countRange: { min: number; max: number }; + postcodeCountRange: { min: number; max: number }; + densityLabel: string; + totalCount?: number; + theme: 'light' | 'dark'; +} + +/** Desktop top-card overlay area: the location search box and the map legend. */ +export const MapTopCards = memo(function MapTopCards({ + layoutClass, + showLocationSearch, + showLegend, + onFlyTo, + onLocationSearched, + onCurrentLocationFound, + onLocationSearchMouseEnter, + getViewportCenter, + viewFeature, + colorRange, + viewSource, + onCancelPin, + onResetPreviewScale, + canResetPreviewScale, + colorFeatureMeta, + usePostcodeView, + countRange, + postcodeCountRange, + densityLabel, + totalCount, + theme, +}: MapTopCardsProps) { + const { t } = useTranslation(); + const modes = useTranslatedModes(); + + return ( +
+ {showLocationSearch && ( + + )} + {showLegend && + (viewFeature && colorRange ? ( + viewFeature.startsWith('tt_') ? ( + + ) : colorFeatureMeta ? ( + + ) : null + ) : ( + + ))} +
+ ); +}); diff --git a/frontend/src/components/map/OverlayTileLayers.tsx b/frontend/src/components/map/OverlayTileLayers.tsx new file mode 100644 index 0000000..8ca2ff3 --- /dev/null +++ b/frontend/src/components/map/OverlayTileLayers.tsx @@ -0,0 +1,163 @@ +import { Layer, Source } from 'react-map-gl/maplibre'; + +import { POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts'; +import { type OverlayId, OVERLAY_MIN_ZOOM } from '../../lib/overlays'; + +function overlayTileUrl(path: string): string { + return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`; +} + +export function OverlayTileLayers({ + activeOverlays, + activeCrimeTypes, + zoom, +}: { + activeOverlays: Set; + activeCrimeTypes: Set; + zoom: number; +}) { + if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null; + + const showNoise = activeOverlays.has('noise'); + const showCrime = activeOverlays.has('crime-hotspots'); + const showTrees = activeOverlays.has('trees-outside-woodlands'); + const showPropertyBorders = activeOverlays.has('property-borders'); + + // Restrict the heatmap to the selected crime types. This must always be a + // concrete expression: passing `filter={undefined}` makes react-map-gl call + // map.addLayer({filter: undefined}), which MapLibre rejects at validation + // ("filter: array expected, undefined found"), so the layer is never created + // and the heatmap stays blank until a later setFilter call. An `in` over the + // selected types matches everything when all 14 are selected. + const crimeFilter = ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]]; + + return ( + <> + {showNoise && ( + + + + )} + + {showCrime && ( + + + + )} + + {showTrees && ( + + + + )} + + {showPropertyBorders && ( + + + + )} + + ); +} diff --git a/frontend/src/components/map/PoiPopupCard.tsx b/frontend/src/components/map/PoiPopupCard.tsx new file mode 100644 index 0000000..2537432 --- /dev/null +++ b/frontend/src/components/map/PoiPopupCard.tsx @@ -0,0 +1,188 @@ +import { memo } from 'react'; + +import type { SchoolMetadata } from '../../types'; +import { POI_GROUP_COLORS } from '../../lib/consts'; +import { getPoiIconUrl } from '../../lib/map-utils'; +import { ts } from '../../i18n/server'; + +export interface PoiPopupCardData { + name: string; + category: string; + icon_category?: string; + group: string; + emoji: string; + school?: SchoolMetadata; +} + +function getPoiGroupColor(group: string): [number, number, number] { + const color = POI_GROUP_COLORS[group]; + if (!color) { + throw new Error(`Missing POI group color for '${group}'`); + } + return color; +} + +/** Best-effort web URL from a free-text website field — GIAS stores some with + * "http://", some without, and some as bare hostnames. */ +function normalizeSchoolWebsiteUrl(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (/^[\w.-]+\.[a-z]{2,}/i.test(trimmed)) return `http://${trimmed}`; + return null; +} + +function renderSchoolMetadata(school: SchoolMetadata) { + // First line collects the headline classification (phase, type, religious + // character) so the popup is scannable even when most fields are absent. + const headline: string[] = []; + if (school.phase) headline.push(school.phase); + if (school.type) headline.push(school.type); + + const pupilsLine = + school.pupils !== undefined && school.capacity !== undefined + ? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils` + : school.pupils !== undefined + ? `${school.pupils.toLocaleString()} pupils` + : school.capacity !== undefined + ? `Capacity ${school.capacity.toLocaleString()}` + : null; + + const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null; + + return ( +
+ {headline.length > 0 && ( + <> +
Type
+
{headline.join(' · ')}
+ + )} + {school.age_range && ( + <> +
Ages
+
{school.age_range}
+ + )} + {school.gender && school.gender !== 'Mixed' && ( + <> +
Gender
+
{school.gender}
+ + )} + {pupilsLine && ( + <> +
Pupils
+
{pupilsLine}
+ + )} + {school.fsm_percent !== undefined && ( + <> +
Free meal
+
{school.fsm_percent.toFixed(1)}%
+ + )} + {school.ofsted_rating && ( + <> +
Ofsted
+
{school.ofsted_rating}
+ + )} + {school.sixth_form === 'Has a sixth form' && ( + <> +
Sixth form
+
Yes
+ + )} + {school.religious_character && + school.religious_character !== 'Does not apply' && + school.religious_character !== 'None' && ( + <> +
Religion
+
{school.religious_character}
+ + )} + {school.admissions_policy && ( + <> +
Admissions
+
{school.admissions_policy}
+ + )} + {school.trust && ( + <> +
Trust
+
{school.trust}
+ + )} + {(school.address || school.postcode) && ( + <> +
Address
+
+ {[school.address, school.postcode].filter(Boolean).join(', ')} +
+ + )} + {school.local_authority && ( + <> +
LA
+
{school.local_authority}
+ + )} + {school.head_name && ( + <> +
Head
+
{school.head_name}
+ + )} + {websiteUrl && ( + <> +
Website
+
+ + {websiteUrl.replace(/^https?:\/\//, '')} + +
+ + )} +
+ ); +} + +export const PoiPopupCardContent = memo(function PoiPopupCardContent({ + poi, +}: { + poi: PoiPopupCardData; +}) { + return ( +
+
+ +
+
{poi.name}
+
+ + {ts(poi.category)} +
+
+
+ {poi.school && renderSchoolMetadata(poi.school)} +
+ ); +}); diff --git a/frontend/src/components/map/map-page/useMobileDrawer.ts b/frontend/src/components/map/map-page/useMobileDrawer.ts new file mode 100644 index 0000000..6c018f7 --- /dev/null +++ b/frontend/src/components/map/map-page/useMobileDrawer.ts @@ -0,0 +1,131 @@ +import { useCallback, useRef, useState } from 'react'; +import type { MutableRefObject } from 'react'; + +import type { MapFlyToOptions } from '../../../types'; +import type { MapFlyTo } from './types'; + +export interface PendingFlyTo { + lat: number; + lng: number; + zoom: number; +} + +/** + * Mobile drawer / bottom sheet state plus the fly-to plumbing that keeps a + * selected target visible above them. Fly-tos requested while the drawer panel + * hasn't measured itself yet are parked in refs and consumed once the panel + * rect arrives, so the camera lands in the area the drawer leaves uncovered. + */ +export function useMobileDrawer(isMobile: boolean, flyToRef: MutableRefObject) { + const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); + const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0); + const mobileDrawerPanelRectRef = useRef(null); + const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null); + const pendingLocationSearchFlyToRef = useRef(null); + + const consumePendingLocationSearchFlyTo = useCallback( + (rect?: DOMRectReadOnly | null) => { + const pending = pendingLocationSearchFlyToRef.current; + const panelRect = rect ?? mobileDrawerPanelRectRef.current; + if (!pending || !panelRect) return; + + const bottomInset = Math.max(0, window.innerHeight - panelRect.top); + const flyTo = flyToRef.current; + if (!flyTo) return; + flyTo(pending.lat, pending.lng, pending.zoom, { + visibleViewportArea: { bottom: bottomInset }, + }); + pendingLocationSearchFlyToRef.current = null; + }, + [flyToRef] + ); + + const consumePendingCurrentLocationFlyTo = useCallback( + (rect?: DOMRectReadOnly | null) => { + const pending = pendingCurrentLocationFlyToRef.current; + const panelRect = rect ?? mobileDrawerPanelRectRef.current; + if (!pending || !panelRect) return; + + const bottomInset = Math.max(0, window.innerHeight - panelRect.top); + const flyTo = flyToRef.current; + if (!flyTo) return; + flyTo(pending.lat, pending.lng, 17, { + visibleViewportArea: { bottom: bottomInset }, + }); + pendingCurrentLocationFlyToRef.current = null; + }, + [flyToRef] + ); + + const openMobileDrawer = useCallback(() => { + setMobileDrawerOpen(true); + }, []); + + /** Open the drawer and fly to the searched location once the panel rect is known. */ + const openMobileDrawerForLocationSearch = useCallback( + (target: PendingFlyTo) => { + pendingLocationSearchFlyToRef.current = target; + setMobileDrawerOpen(true); + consumePendingLocationSearchFlyTo(); + }, + [consumePendingLocationSearchFlyTo] + ); + + const clearPendingLocationSearchFlyTo = useCallback(() => { + pendingLocationSearchFlyToRef.current = null; + }, []); + + /** Park a current-location fly-to until the drawer panel has measured itself. */ + const queueCurrentLocationFlyTo = useCallback( + (lat: number, lng: number) => { + pendingCurrentLocationFlyToRef.current = { lat, lng }; + consumePendingCurrentLocationFlyTo(); + }, + [consumePendingCurrentLocationFlyTo] + ); + + const handleMobileDrawerPanelRectChange = useCallback( + (rect: DOMRectReadOnly) => { + mobileDrawerPanelRectRef.current = rect; + consumePendingCurrentLocationFlyTo(rect); + consumePendingLocationSearchFlyTo(rect); + }, + [consumePendingCurrentLocationFlyTo, consumePendingLocationSearchFlyTo] + ); + + const handleMobileDrawerClose = useCallback(() => { + pendingCurrentLocationFlyToRef.current = null; + pendingLocationSearchFlyToRef.current = null; + mobileDrawerPanelRectRef.current = null; + setMobileDrawerOpen(false); + }, []); + + const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => { + if (!isMobile) return undefined; + + const panelRect = mobileDrawerPanelRectRef.current; + if (mobileDrawerOpen && panelRect) { + const bottomInset = Math.max(0, window.innerHeight - panelRect.top); + if (bottomInset > 0) { + return { visibleViewportArea: { bottom: bottomInset } }; + } + } + + return mobileBottomSheetHeight > 0 + ? { visibleArea: { bottom: mobileBottomSheetHeight } } + : undefined; + }, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]); + + return { + mobileDrawerOpen, + mobileBottomSheetHeight, + setMobileBottomSheetHeight, + openMobileDrawer, + openMobileDrawerForLocationSearch, + clearPendingLocationSearchFlyTo, + queueCurrentLocationFlyTo, + handleMobileDrawerPanelRectChange, + handleMobileDrawerClose, + getMobileMapFlyToOptions, + }; +} diff --git a/frontend/src/components/ui/SubNav.tsx b/frontend/src/components/ui/SubNav.tsx index 6ed8515..2bac9ef 100644 --- a/frontend/src/components/ui/SubNav.tsx +++ b/frontend/src/components/ui/SubNav.tsx @@ -7,11 +7,11 @@ interface SubNavProps { export function SubNav({ tabs, activeTab, onTabChange }: SubNavProps) { return (
-
+
{tabs.map((tab) => (