This commit is contained in:
Andras Schmelczer 2026-05-31 13:17:11 +01:00
parent c995f12f8b
commit 8dc939d761
44 changed files with 3540 additions and 2159478 deletions

View file

@ -74,11 +74,11 @@ const DEMO_FEATURES: FeatureMeta[] = [
prefix: '£',
},
{
name: 'Serious crime per 1k residents (avg/yr)',
name: 'Serious crime (avg/yr)',
type: 'numeric',
group: 'Crime',
min: 0,
max: 120,
max: 40,
step: 1,
},
{

View file

@ -9,103 +9,85 @@ type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
interface DataSourceDef {
id: string;
url: string;
license: string;
optOutUrl?: string;
}
const DATA_SOURCE_DEFS: DataSourceDef[] = [
{
id: 'price-paid',
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
license: 'Open Government Licence v3.0',
},
{
id: 'epc',
url: 'https://epc.opendatacommunities.org/downloads/domestic',
license: 'Open Government Licence v3.0',
optOutUrl:
'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
},
{
id: 'nspl',
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
license: 'Open Government Licence v3.0',
},
{
id: 'iod',
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
license: 'Open Government Licence v3.0',
},
{
id: 'ethnicity',
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
license: 'Open Government Licence v3.0',
},
{ id: 'crime', url: 'https://data.police.uk/data/', license: 'Open Government Licence v3.0' },
{ id: 'crime', license: 'Open Government Licence v3.0' },
{
id: 'osm-pois',
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
license: 'Open Data Commons Open Database License (ODbL)',
},
{
id: 'geolytix-retail-points',
url: 'https://geolytix.com/blog/supermarket-retail-points/',
license: 'GEOLYTIX Open Data License',
},
{
id: 'os-open-greenspace',
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
license: 'Open Government Licence v3.0',
},
{
id: 'forest-research-tow',
url: 'https://www.forestresearch.gov.uk/tools-and-resources/national-trees-outside-woodland-map/',
license: 'Open Government Licence v3.0',
},
{
id: 'nfi-woodland',
license: 'Open Government Licence v3.0',
},
{
id: 'conservation-areas',
url: 'https://www.planning.data.gov.uk/dataset/conservation-area',
license: 'Open Government Licence v3.0',
},
{
id: 'listed-buildings',
url: 'https://opendata-historicengland.hub.arcgis.com/datasets/historicengland::national-heritage-list-for-england-nhle/explore?layer=0',
license: 'Open Government Licence v3.0',
},
{
id: 'naptan',
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
license: 'Open Government Licence v3.0',
},
{
id: 'noise',
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
license: 'Open Government Licence v3.0',
},
{
id: 'ofsted',
url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes',
license: 'Open Government Licence v3.0',
},
{
id: 'broadband',
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
license: 'Open Government Licence v3.0',
},
{
id: 'council-tax',
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
license: 'Open Government Licence v3.0',
},
{
id: 'ons-rental',
url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland',
license: 'Open Government Licence v3.0',
},
{
id: 'election-results',
url: 'https://electionresults.parliament.uk/general-elections/6',
license: 'Open Parliament Licence v3.0',
},
];
@ -138,6 +120,7 @@ const DS_KEYS: Record<string, [string, string, string]> = {
'learnPage.dsGreenspaceUse',
],
'forest-research-tow': ['learnPage.dsTowName', 'learnPage.dsTowOrigin', 'learnPage.dsTowUse'],
'nfi-woodland': ['learnPage.dsNfiName', 'learnPage.dsNfiOrigin', 'learnPage.dsNfiUse'],
'conservation-areas': [
'learnPage.dsConservationAreasName',
'learnPage.dsConservationAreasOrigin',
@ -358,26 +341,6 @@ export default function LearnPage() {
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
>
{source.url}
</a>
{source.optOutUrl && (
<div className="mt-2">
<a
href={source.optOutUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
{t('learnPage.optOut')}
</a>
</div>
)}
</div>
);
})}
@ -393,39 +356,13 @@ export default function LearnPage() {
<ul className="space-y-1.5 text-sm">
<li>{t('learnPage.attrLandRegistry')}</li>
<li>
{t('learnPage.attrOgl')}{' '}
<a
href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:text-teal-300 hover:underline"
>
{t('learnPage.attrOglLink')}
</a>
.
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
</li>
<li>{t('learnPage.attrOs')}</li>
<li>{t('learnPage.attrTfl')}</li>
<li>
{t('learnPage.attrOsm')}{' '}
<a
href="https://www.openstreetmap.org/copyright"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:text-teal-300 hover:underline"
>
{t('learnPage.attrOsmContrib')}
</a>
, {t('learnPage.attrOsmLicense')}{' '}
<a
href="https://opendatacommons.org/licenses/odbl/"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:text-teal-300 hover:underline"
>
{t('learnPage.attrOsmLicenseLink')}
</a>
.
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
</li>
</ul>
</div>

View file

@ -166,6 +166,66 @@ describe('LocationSearch', () => {
});
});
it('waits for nearest postcode selection before flying to a place result', async () => {
window.localStorage.setItem(
RECENT_SEARCHES_STORAGE_KEY,
JSON.stringify([
{
type: 'place',
name: 'E14',
slug: 'e14',
place_type: 'outcode',
lat: 51.505,
lon: -0.01,
},
])
);
const nearestLookup = deferred<Response>();
vi.stubGlobal(
'fetch',
vi.fn((input: string | URL | Request) => {
const url = new URL(String(input), 'http://localhost');
if (url.pathname === '/api/nearest-postcode') return nearestLookup.promise;
return Promise.resolve(new Response(null, { status: 404 }));
})
);
const onFlyTo = vi.fn();
const onLocationSearched = vi.fn();
render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.mouseDown(await screen.findByRole('button', { name: 'E14' }));
expect(onFlyTo).not.toHaveBeenCalled();
nearestLookup.resolve(
jsonResponse({
postcode: 'E14 2DG',
latitude: 51.506,
longitude: -0.012,
geometry: postcodeGeometry,
})
);
await waitFor(() => {
expect(onLocationSearched).toHaveBeenCalledTimes(1);
});
expect(onLocationSearched).toHaveBeenCalledWith({
postcode: 'E14 2DG',
geometry: postcodeGeometry,
latitude: 51.506,
longitude: -0.012,
zoom: 16,
markerLatitude: 51.505,
markerLongitude: -0.01,
});
expect(onFlyTo).toHaveBeenCalledWith(51.505, -0.01, 16);
expect(onLocationSearched.mock.invocationCallOrder[0]).toBeLessThan(
onFlyTo.mock.invocationCallOrder[0]
);
});
it('keeps only the three most recent local searches', async () => {
vi.stubGlobal(
'fetch',

View file

@ -132,10 +132,7 @@ export default function LocationSearch({
if (result.type === 'place') {
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
// On mobile the drawer opens after onLocationSearched; MapPage handles
// the fly-to there with the correct viewport inset so the target isn't
// hidden behind the drawer. On desktop fly immediately for snappy feedback.
if (!isMobile) onFlyTo(result.lat, result.lon, zoom);
const flyZoom = result.place_type === 'outcode' ? POSTCODE_SEARCH_ZOOM : zoom;
try {
const params = new URLSearchParams({
lat: String(result.lat),
@ -158,10 +155,11 @@ export default function LocationSearch({
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
zoom,
zoom: flyZoom,
markerLatitude: result.lat,
markerLongitude: result.lon,
});
if (!isMobile) onFlyTo(result.lat, result.lon, flyZoom);
search.saveRecentSearch(result);
search.clear();
if (isMobile) setExpanded(false);

View file

@ -45,7 +45,8 @@ 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 } from '../../lib/overlays';
import { type OverlayId, OVERLAY_MIN_ZOOM } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
import type { BasemapId } from '../../lib/basemaps';
interface MapProps {
@ -54,7 +55,9 @@ interface MapProps {
usePostcodeView: boolean;
pois: POI[];
activeOverlays?: Set<OverlayId>;
activeCrimeTypes?: Set<string>;
basemap?: BasemapId;
colorOpacity?: number;
actualListings?: ActualListing[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
@ -94,6 +97,7 @@ interface MapProps {
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
const EMPTY_OVERLAYS = new Set<OverlayId>();
const ALL_CRIME_TYPES = new Set<string>(CRIME_TYPE_VALUES);
function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
@ -588,9 +592,11 @@ function overlayTileUrl(path: string): string {
function OverlayTileLayers({
activeOverlays,
activeCrimeTypes,
zoom,
}: {
activeOverlays: Set<OverlayId>;
activeCrimeTypes: Set<string>;
zoom: number;
}) {
if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null;
@ -598,6 +604,14 @@ function OverlayTileLayers({
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. When every type is
// selected we omit the filter entirely so all features contribute.
const crimeFilter =
activeCrimeTypes.size >= CRIME_TYPE_VALUES.length
? undefined
: ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]];
return (
<>
@ -607,6 +621,7 @@ function OverlayTileLayers({
type="raster"
tiles={[overlayTileUrl('noise')]}
tileSize={256}
minzoom={OVERLAY_MIN_ZOOM.noise}
maxzoom={14}
>
<Layer
@ -626,6 +641,7 @@ function OverlayTileLayers({
id="overlay-crime-source"
type="vector"
tiles={[overlayTileUrl('crime-hotspots')]}
minzoom={OVERLAY_MIN_ZOOM['crime-hotspots']}
maxzoom={15}
>
<Layer
@ -633,6 +649,7 @@ function OverlayTileLayers({
type="heatmap"
source-layer="crime_hotspots"
minzoom={POSTCODE_ZOOM_THRESHOLD}
filter={crimeFilter as never}
paint={
{
'heatmap-weight': [
@ -673,6 +690,7 @@ function OverlayTileLayers({
id="overlay-trees-source"
type="vector"
tiles={[overlayTileUrl('trees-outside-woodlands')]}
minzoom={OVERLAY_MIN_ZOOM['trees-outside-woodlands']}
maxzoom={16}
>
<Layer
@ -698,6 +716,30 @@ function OverlayTileLayers({
/>
</Source>
)}
{showPropertyBorders && (
<Source
id="overlay-property-borders-source"
type="vector"
tiles={[overlayTileUrl('property-borders')]}
minzoom={OVERLAY_MIN_ZOOM['property-borders']}
maxzoom={16}
>
<Layer
id="overlay-property-borders"
type="line"
source-layer="property_borders"
minzoom={POSTCODE_ZOOM_THRESHOLD}
paint={
{
'line-color': '#b45309',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0.35, 18, 0.85],
'line-width': ['interpolate', ['linear'], ['zoom'], 15, 0.4, 18, 1.4],
} as never
}
/>
</Source>
)}
</>
);
}
@ -708,7 +750,9 @@ export default memo(function Map({
usePostcodeView,
pois,
activeOverlays = EMPTY_OVERLAYS,
activeCrimeTypes = ALL_CRIME_TYPES,
basemap = 'standard',
colorOpacity = 1,
actualListings = EMPTY_ACTUAL_LISTINGS,
onViewChange,
viewFeature,
@ -929,6 +973,7 @@ export default memo(function Map({
bounds: viewportBounds,
travelTimeEntries,
mapDataBeforeId,
colorOpacity,
});
const showAutoPoiCards = !screenshotMode && viewState.zoom >= POI_AUTO_CARD_ZOOM_THRESHOLD;
@ -980,8 +1025,12 @@ export default memo(function Map({
minZoom={MAP_MIN_ZOOM}
maxBounds={maxBounds}
>
<OverlayTileLayers
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
zoom={viewState.zoom}
/>
<DeckOverlay layers={layers} getTooltip={null} />
<OverlayTileLayers activeOverlays={activeOverlays} zoom={viewState.zoom} />
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL>
{basemap === 'satellite' && (

View file

@ -27,9 +27,11 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
import type { OverlayId } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
import type { BasemapId } from '../../lib/basemaps';
import { useLicense } from '../../hooks/useLicense';
import { stateToParams } from '../../lib/url-state';
import { DEFAULT_COLOR_OPACITY, normalizeColorOpacity } from '../../lib/color-opacity';
import { groupFeaturesByCategory } from '../../lib/features';
import {
getActiveAmenityFilterFeatureNames,
@ -71,8 +73,6 @@ export type { ExportState } from './map-page/types';
type PendingFlyTo = { lat: number; lng: number; zoom: number };
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
declare const __DEV__: boolean;
export default function MapPage({
features,
poiCategoryGroups,
@ -80,7 +80,9 @@ export default function MapPage({
initialViewState,
initialPOICategories,
initialOverlays,
initialCrimeTypes,
initialBasemap = 'standard',
initialColorOpacity = DEFAULT_COLOR_OPACITY,
initialTab,
initialLoading,
theme,
@ -114,7 +116,13 @@ export default function MapPage({
const [activeOverlays, setActiveOverlays] = useState<Set<OverlayId>>(
() => new Set(initialOverlays ?? [])
);
const [crimeTypes, setCrimeTypes] = useState<Set<string>>(
() => new Set(initialCrimeTypes ?? CRIME_TYPE_VALUES)
);
const [basemap, setBasemap] = useState<BasemapId>(initialBasemap);
const [colorOpacity, setColorOpacity] = useState(() =>
normalizeColorOpacity(initialColorOpacity)
);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
@ -122,7 +130,10 @@ export default function MapPage({
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [overlayPaneOpen, setOverlayPaneOpen] = useState(false);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [devActualListingsEnabled, setDevActualListingsEnabled] = useState(true);
const [listingsToggleEnabled, setListingsToggleEnabled] = useState(true);
const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
initialPostcode ?? null
);
const {
filters,
@ -482,8 +493,12 @@ export default function MapPage({
[filters, features]
);
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
const actualListingsEnabled = !__DEV__ || devActualListingsEnabled;
const { listings: actualListings } = useActualListings(
// Listings are gated behind the per-user `can_see_listings` flag (off by
// default). Only users with the flag get the toggle button and the fetch;
// the backing API independently rejects anyone else with 403.
const canSeeListings = user?.canSeeListings ?? false;
const actualListingsEnabled = canSeeListings && listingsToggleEnabled;
const { listings: actualListings, loading: actualListingsLoading } = useActualListings(
actualListingsEnabled ? mapData.visibleBounds : null,
{
filterParam: actualListingsFilterParam,
@ -493,9 +508,13 @@ export default function MapPage({
);
const visibleActualListings = actualListingsEnabled ? actualListings : EMPTY_ACTUAL_LISTINGS;
const handleToggleActualListings = useCallback(() => {
if (!__DEV__) return;
setDevActualListingsEnabled((enabled) => !enabled);
}, []);
if (!canSeeListings) return;
setListingsToggleEnabled((enabled) => !enabled);
}, [canSeeListings]);
const selectedPostcodeParam =
selectedHexagon?.type === 'postcode'
? selectedHexagon.id
: (pendingInitialPostcode ?? undefined);
useUrlSync(
mapData.currentView,
@ -506,7 +525,10 @@ export default function MapPage({
entries,
shareCode,
activeOverlays,
basemap
basemap,
crimeTypes,
selectedPostcodeParam,
colorOpacity
);
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
@ -520,6 +542,7 @@ export default function MapPage({
setMobileDrawerOpen(true);
consumePendingLocationSearchFlyTo();
},
onSettled: () => setPendingInitialPostcode(null),
});
useHorizontalSwipeNavigationGuard();
useMobileBackNavigationGuard(isMobile);
@ -581,16 +604,22 @@ export default function MapPage({
entries,
shareCode,
activeOverlays,
basemap
basemap,
crimeTypes,
selectedPostcodeParam,
colorOpacity
).toString(),
[
activeOverlays,
basemap,
crimeTypes,
colorOpacity,
entries,
features,
filters,
rightPaneTab,
selectedPOICategories,
selectedPostcodeParam,
shareCode,
shareAndSaveView,
]
@ -630,7 +659,9 @@ export default function MapPage({
ogMode={ogMode}
travelTimeEntries={entries}
activeOverlays={activeOverlays}
activeCrimeTypes={crimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
/>
);
}
@ -691,8 +722,12 @@ export default function MapPage({
<OverlayPane
selectedOverlays={activeOverlays}
onOverlaysChange={setActiveOverlays}
selectedCrimeTypes={crimeTypes}
onCrimeTypesChange={setCrimeTypes}
basemap={basemap}
onBasemapChange={setBasemap}
colorOpacity={colorOpacity}
onColorOpacityChange={setColorOpacity}
zoomedIn={overlaysZoomedIn}
onClose={() => setOverlayPaneOpen(false)}
/>
@ -827,7 +862,9 @@ export default function MapPage({
mapData={mapData}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={crimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
@ -847,7 +884,8 @@ export default function MapPage({
currentLocation={currentLocation}
actualListings={visibleActualListings}
actualListingsEnabled={actualListingsEnabled}
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
actualListingsLoading={actualListingsLoading}
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
travelTimeEntries={entries}
bottomScreenInset={mobileBottomSheetHeight}
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
@ -898,7 +936,9 @@ export default function MapPage({
mapData={mapData}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={crimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
@ -918,7 +958,8 @@ export default function MapPage({
currentLocation={currentLocation}
actualListings={visibleActualListings}
actualListingsEnabled={actualListingsEnabled}
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
actualListingsLoading={actualListingsLoading}
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}

View file

@ -1,8 +1,17 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { PointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TabButton } from '../ui/TabButton';
const MIN_VISIBLE_PANEL_HEIGHT_PX = 104;
function clampDragOffset(panel: HTMLElement | null, offset: number): number {
const panelHeight = panel?.offsetHeight || window.innerHeight * 0.9;
const maxOffset = Math.max(0, panelHeight - MIN_VISIBLE_PANEL_HEIGHT_PX);
return Math.min(maxOffset, Math.max(0, offset));
}
interface MobileDrawerProps {
onClose: () => void;
renderArea: () => React.ReactNode;
@ -22,6 +31,12 @@ export default function MobileDrawer({
}: MobileDrawerProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement>(null);
const dragStartYRef = useRef(0);
const dragStartOffsetRef = useRef(0);
const dragOffsetRef = useRef(0);
const isDraggingRef = useRef(false);
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
useLayoutEffect(() => {
const panel = panelRef.current;
@ -42,6 +57,13 @@ export default function MobileDrawer({
};
}, [onPanelRectChange]);
useLayoutEffect(() => {
const panel = panelRef.current;
if (!panel || !onPanelRectChange) return;
onPanelRectChange(panel.getBoundingClientRect());
}, [dragOffset, onPanelRectChange]);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -51,16 +73,66 @@ export default function MobileDrawer({
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
const handleDragStart = useCallback((event: PointerEvent<HTMLElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragStartYRef.current = event.clientY;
dragStartOffsetRef.current = dragOffsetRef.current;
isDraggingRef.current = true;
setIsDragging(true);
}, []);
const handleDragMove = useCallback((event: PointerEvent<HTMLElement>) => {
if (!isDraggingRef.current) return;
const nextOffset = clampDragOffset(
panelRef.current,
dragStartOffsetRef.current + event.clientY - dragStartYRef.current
);
dragOffsetRef.current = nextOffset;
setDragOffset(nextOffset);
}, []);
const handleDragEnd = useCallback(() => {
if (!isDraggingRef.current) return;
dragStartYRef.current = 0;
dragStartOffsetRef.current = dragOffsetRef.current;
isDraggingRef.current = false;
setIsDragging(false);
}, []);
return (
<div data-tutorial="right-pane" className="fixed inset-0 z-50 flex flex-col">
{/* Backdrop — top 10% */}
<div className="h-[10%] bg-black/50" onClick={onClose} />
<div data-tutorial="right-pane" className="pointer-events-none fixed inset-0 z-50 flex flex-col">
<div className="h-[10%] shrink-0" aria-hidden="true" />
{/* Panel — bottom 90% */}
<div
ref={panelRef}
className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden"
className="pointer-events-auto h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col border-t border-x border-warm-300 ring-1 ring-navy-950/10 shadow-2xl shadow-navy-950/45 dark:border-navy-600 dark:ring-white/10 dark:shadow-black/60 overflow-hidden"
style={{
transform: dragOffset > 0 ? `translateY(${dragOffset}px)` : undefined,
transition: isDragging ? undefined : 'transform 180ms ease',
willChange: isDragging ? 'transform' : undefined,
}}
>
<div className="relative shrink-0 px-4 py-2">
<div
className="absolute inset-x-0 top-1/2 z-10 h-11 -translate-y-1/2 touch-none"
data-mobile-drawer-drag-handle
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
/>
<div
className="pointer-events-none flex w-full items-center justify-center"
role="presentation"
>
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
</div>
</div>
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton

View file

@ -1,16 +1,26 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BASEMAPS, type BasemapId } from '../../lib/basemaps';
import { OVERLAYS, type OverlayDefinition, type OverlayId } from '../../lib/overlays';
import { CRIME_TYPES, CRIME_TYPE_VALUES } from '../../lib/crime-types';
import { PillToggle } from '../ui/PillToggle';
import { IconButton } from '../ui/IconButton';
import { Slider } from '../ui/Slider';
import InfoPopup from '../ui/InfoPopup';
import { CloseIcon, InfoIcon } from '../ui/icons';
import { colorOpacityToPercent, normalizeColorOpacity } from '../../lib/color-opacity';
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
interface OverlayPaneProps {
selectedOverlays: Set<OverlayId>;
onOverlaysChange: (overlays: Set<OverlayId>) => void;
selectedCrimeTypes: Set<string>;
onCrimeTypesChange: (crimeTypes: Set<string>) => void;
basemap: BasemapId;
onBasemapChange: (basemap: BasemapId) => void;
colorOpacity: number;
onColorOpacityChange: (opacity: number) => void;
zoomedIn: boolean;
onClose?: () => void;
}
@ -18,11 +28,16 @@ interface OverlayPaneProps {
export default function OverlayPane({
selectedOverlays,
onOverlaysChange,
selectedCrimeTypes,
onCrimeTypesChange,
basemap,
onBasemapChange,
colorOpacity,
onColorOpacityChange,
zoomedIn,
onClose,
}: OverlayPaneProps) {
const { t } = useTranslation();
const [infoOverlay, setInfoOverlay] = useState<OverlayDefinition | null>(null);
const toggleOverlay = (overlay: OverlayId) => {
@ -35,12 +50,31 @@ export default function OverlayPane({
onOverlaysChange(next);
};
const crimeOverlayActive = selectedOverlays.has(CRIME_OVERLAY_ID);
const toggleCrimeType = (value: string) => {
const next = new Set(selectedCrimeTypes);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
onCrimeTypesChange(next);
};
const selectAllCrimeTypes = () => onCrimeTypesChange(new Set(CRIME_TYPE_VALUES));
const selectNoCrimeTypes = () => onCrimeTypesChange(new Set());
const selectNone = () => onOverlaysChange(new Set());
const showZoomWarning = !zoomedIn && selectedOverlays.size > 0;
const colorOpacityPercent = colorOpacityToPercent(colorOpacity);
const handleColorOpacityChange = ([value]: number[]) => {
onColorOpacityChange(normalizeColorOpacity((value ?? colorOpacityPercent) / 100));
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
<div className="flex min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
<div className="flex-shrink-0 px-3 pt-3 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
@ -78,7 +112,7 @@ export default function OverlayPane({
)}
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
<div className="min-h-0 space-y-4 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
Base map
@ -96,6 +130,25 @@ export default function OverlayPane({
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
Colour opacity
</span>
<span className="text-[10px] font-medium tabular-nums text-warm-500 dark:text-warm-400">
{colorOpacityPercent}%
</span>
</div>
<Slider
min={10}
max={100}
step={5}
value={[colorOpacityPercent]}
onValueChange={handleColorOpacityChange}
aria-label="Colour opacity"
/>
</div>
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
Data overlays
@ -120,6 +173,49 @@ export default function OverlayPane({
))}
</div>
</div>
{crimeOverlayActive && (
<div>
<div className="mb-2 flex items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
{t('filters.crimeType')}
</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
{selectedCrimeTypes.size}/{CRIME_TYPES.length}
</span>
<div className="ml-auto flex items-center gap-1">
<button
onClick={selectAllCrimeTypes}
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
>
All
</button>
<button
onClick={selectNoCrimeTypes}
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
>
None
</button>
</div>
</div>
<div className="space-y-0.5">
{CRIME_TYPES.map((crime) => (
<label
key={crime.value}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs text-warm-700 hover:bg-warm-50 dark:text-warm-300 dark:hover:bg-warm-800"
>
<input
type="checkbox"
checked={selectedCrimeTypes.has(crime.value)}
onChange={() => toggleCrimeType(crime.value)}
className="h-3.5 w-3.5 shrink-0 rounded border-warm-300 text-amber-600 focus:ring-1 focus:ring-amber-500 dark:border-warm-600 dark:bg-warm-800"
/>
<span>{crime.label}</span>
</label>
))}
</div>
</div>
)}
</div>
{infoOverlay && (

View file

@ -19,6 +19,7 @@ import type { SearchedLocation } from '../LocationSearch';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { EyeIcon } from '../../ui/icons/EyeIcon';
import { HouseIcon } from '../../ui/icons/HouseIcon';
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
@ -40,7 +41,9 @@ interface DesktopMapPageProps {
mapData: MapData;
pois: POI[];
activeOverlays: Set<OverlayId>;
activeCrimeTypes: Set<string>;
basemap: BasemapId;
colorOpacity: number;
mapViewFeature: string | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
@ -60,6 +63,7 @@ interface DesktopMapPageProps {
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
actualListingsEnabled: boolean;
actualListingsLoading: boolean;
onToggleActualListings?: () => void;
travelTimeEntries: TravelTimeEntry[];
densityLabel: string;
@ -93,7 +97,9 @@ export function DesktopMapPage({
mapData,
pois,
activeOverlays,
activeCrimeTypes,
basemap,
colorOpacity,
mapViewFeature,
filterRange,
viewSource,
@ -113,6 +119,7 @@ export function DesktopMapPage({
currentLocation,
actualListings,
actualListingsEnabled,
actualListingsLoading,
onToggleActualListings,
travelTimeEntries,
densityLabel,
@ -187,7 +194,9 @@ export function DesktopMapPage({
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
@ -223,11 +232,16 @@ export function DesktopMapPage({
type="button"
onClick={onToggleActualListings}
aria-pressed={actualListingsEnabled}
aria-busy={actualListingsLoading}
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
>
<HouseIcon className="h-5 w-5" />
{actualListingsLoading ? (
<SpinnerIcon className="h-5 w-5 animate-spin" />
) : (
<HouseIcon className="h-5 w-5" />
)}
<span className="text-sm font-medium">
Listings{actualListingsEnabled ? ` (${actualListings.length})` : ''}
</span>
@ -250,7 +264,7 @@ export function DesktopMapPage({
</button>
</div>
{overlayPaneOpen && (
<div className="absolute bottom-28 right-4 z-10 flex h-[260px] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
<div className="absolute bottom-16 right-4 z-10 flex max-h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{overlayPane}
</div>
)}

View file

@ -17,6 +17,7 @@ import MobileBottomSheet from '../MobileBottomSheet';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { EyeIcon } from '../../ui/icons/EyeIcon';
import { HouseIcon } from '../../ui/icons/HouseIcon';
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
@ -31,7 +32,9 @@ interface MobileMapPageProps {
mapData: MapData;
pois: POI[];
activeOverlays: Set<OverlayId>;
activeCrimeTypes: Set<string>;
basemap: BasemapId;
colorOpacity: number;
mapViewFeature: string | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
@ -51,6 +54,7 @@ interface MobileMapPageProps {
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
actualListingsEnabled: boolean;
actualListingsLoading: boolean;
onToggleActualListings?: () => void;
travelTimeEntries: TravelTimeEntry[];
bottomScreenInset: number;
@ -81,7 +85,9 @@ export function MobileMapPage({
mapData,
pois,
activeOverlays,
activeCrimeTypes,
basemap,
colorOpacity,
mapViewFeature,
filterRange,
viewSource,
@ -101,6 +107,7 @@ export function MobileMapPage({
currentLocation,
actualListings,
actualListingsEnabled,
actualListingsLoading,
onToggleActualListings,
travelTimeEntries,
bottomScreenInset,
@ -138,7 +145,9 @@ export function MobileMapPage({
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
@ -163,7 +172,6 @@ export function MobileMapPage({
actualListings={actualListings}
bounds={mapData.bounds}
hideLegend
hideLocationSearch={mobileDrawerOpen && !!selectedHexagonId}
travelTimeEntries={travelTimeEntries}
bottomScreenInset={bottomScreenInset}
/>
@ -177,10 +185,15 @@ export function MobileMapPage({
onClick={onToggleActualListings}
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
aria-pressed={actualListingsEnabled}
aria-busy={actualListingsLoading}
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
>
<HouseIcon className="h-5 w-5" />
{actualListingsLoading ? (
<SpinnerIcon className="h-5 w-5 animate-spin" />
) : (
<HouseIcon className="h-5 w-5" />
)}
</button>
)}
<button
@ -200,7 +213,7 @@ export function MobileMapPage({
</div>
{overlayPaneOpen && (
<div className="absolute top-24 right-3 left-3 z-20 flex h-[260px] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
<div className="absolute top-24 right-3 left-3 z-20 flex max-h-[60dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{overlayPane}
</div>
)}

View file

@ -21,7 +21,9 @@ interface ScreenshotMapPageProps {
ogMode?: boolean;
travelTimeEntries: TravelTimeEntry[];
activeOverlays: Set<OverlayId>;
activeCrimeTypes: Set<string>;
basemap: BasemapId;
colorOpacity: number;
}
export function ScreenshotMapPage({
@ -35,7 +37,9 @@ export function ScreenshotMapPage({
ogMode,
travelTimeEntries,
activeOverlays,
activeCrimeTypes,
basemap,
colorOpacity,
}: ScreenshotMapPageProps) {
return (
<div className="h-full w-full">
@ -46,7 +50,9 @@ export function ScreenshotMapPage({
usePostcodeView={mapData.usePostcodeView}
pois={[]}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}

View file

@ -35,6 +35,7 @@ interface UseInitialPostcodeSelectionOptions {
lng?: number
) => void;
onOpenMobileDrawer: (target: { lat: number; lng: number; zoom: number }) => void;
onSettled?: () => void;
}
export function useInitialPostcodeSelection({
@ -43,15 +44,11 @@ export function useInitialPostcodeSelection({
flyTo,
onLocationSearch,
onOpenMobileDrawer,
onSettled,
}: 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');
@ -77,6 +74,9 @@ export function useInitialPostcodeSelection({
)
.catch(() => {
// Silently fail because the postcode might no longer exist.
})
.finally(() => {
onSettled?.();
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}

View file

@ -28,7 +28,9 @@ export interface MapPageProps {
initialViewState: ViewState;
initialPOICategories: Set<string>;
initialOverlays?: Set<OverlayId>;
initialCrimeTypes?: Set<string>;
initialBasemap?: BasemapId;
initialColorOpacity?: number;
initialTab: 'properties' | 'area';
initialLoading: boolean;
theme: 'light' | 'dark';
@ -43,7 +45,7 @@ export interface MapPageProps {
initialTravelTime?: TravelTimeInitial;
initialPostcode?: string;
shareCode?: string;
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
user?: { id: string; subscription: string; isAdmin?: boolean; canSeeListings?: boolean } | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onCheckoutLoginClick?: (returnPath?: string) => void;