@@ -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) => (