500 lines
17 KiB
TypeScript
500 lines
17 KiB
TypeScript
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
import type {
|
|
HexagonData,
|
|
PostcodeFeature,
|
|
PostcodeGeometry,
|
|
ViewState,
|
|
ViewChangeParams,
|
|
POI,
|
|
FeatureMeta,
|
|
Bounds,
|
|
} from '../../types';
|
|
|
|
import {
|
|
zoomToResolution,
|
|
getBoundsFromViewState,
|
|
getMapStyle,
|
|
getPoiIconUrl,
|
|
} from '../../lib/map-utils';
|
|
import {
|
|
INITIAL_VIEW_STATE,
|
|
MAP_MIN_ZOOM,
|
|
MAP_BOUNDS,
|
|
POI_GROUP_COLORS,
|
|
POI_DEFAULT_COLOR,
|
|
} from '../../lib/consts';
|
|
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
|
import MapLegend from './MapLegend';
|
|
import HoverCard from './HoverCard';
|
|
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';
|
|
|
|
interface MapProps {
|
|
data: HexagonData[];
|
|
postcodeData: PostcodeFeature[];
|
|
usePostcodeView: boolean;
|
|
pois: POI[];
|
|
onViewChange: (params: ViewChangeParams) => void;
|
|
viewFeature: string | null;
|
|
colorRange: [number, number] | null;
|
|
filterRange: [number, number] | null;
|
|
viewSource: 'drag' | 'eye' | null;
|
|
onCancelPin: () => void;
|
|
onResetPreviewScale?: () => void;
|
|
canResetPreviewScale?: boolean;
|
|
features: FeatureMeta[];
|
|
selectedHexagonId: string | null;
|
|
hoveredHexagonId: string | null;
|
|
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
|
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
|
initialViewState?: ViewState;
|
|
flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>;
|
|
theme?: 'light' | 'dark';
|
|
screenshotMode?: boolean;
|
|
ogMode?: boolean;
|
|
filters?: FeatureFilters;
|
|
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
|
onLocationSearched?: (location: SearchedLocation | null) => void;
|
|
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
|
currentLocation?: { lat: number; lng: number } | null;
|
|
bounds?: Bounds | null;
|
|
hideLegend?: boolean;
|
|
travelTimeEntries?: TravelTimeEntry[];
|
|
densityLabel?: string;
|
|
totalCount?: number;
|
|
}
|
|
|
|
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
|
|
|
interface Dimensions {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
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<MapboxOverlay['setProps']>[0]) {
|
|
super.setProps(props);
|
|
patchNullViewportDraw(this);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export default memo(function Map({
|
|
data,
|
|
postcodeData,
|
|
usePostcodeView,
|
|
pois,
|
|
onViewChange,
|
|
viewFeature,
|
|
colorRange,
|
|
filterRange,
|
|
viewSource,
|
|
onCancelPin,
|
|
onResetPreviewScale,
|
|
canResetPreviewScale = false,
|
|
features,
|
|
selectedHexagonId,
|
|
hoveredHexagonId,
|
|
onHexagonClick,
|
|
onHexagonHover,
|
|
initialViewState,
|
|
flyToRef,
|
|
theme = 'light',
|
|
screenshotMode = false,
|
|
ogMode = false,
|
|
filters = {},
|
|
selectedPostcodeGeometry,
|
|
onLocationSearched,
|
|
onCurrentLocationFound,
|
|
currentLocation,
|
|
bounds: viewportBounds,
|
|
hideLegend = false,
|
|
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
|
densityLabel: densityLabelProp,
|
|
totalCount: totalCountProp,
|
|
}: MapProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const { t } = useTranslation();
|
|
const modes = useTranslatedModes();
|
|
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
|
|
const [internalViewState, setInternalViewState] = useState<ViewState>(
|
|
initialViewState || INITIAL_VIEW_STATE
|
|
);
|
|
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
|
|
|
// In screenshot mode, use the prop directly for instant updates (no async lag)
|
|
const viewState = screenshotMode && initialViewState ? initialViewState : internalViewState;
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let initialized = false;
|
|
const observer = new ResizeObserver((entries) => {
|
|
const { width, height } = entries[0].contentRect;
|
|
if (width > 0 && height > 0) {
|
|
if (!initialized) {
|
|
initialized = true;
|
|
setDimensions({ width, height });
|
|
} else {
|
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => setDimensions({ width, height }), 150);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(container);
|
|
return () => {
|
|
observer.disconnect();
|
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (dimensions.width === 0 || dimensions.height === 0) return;
|
|
|
|
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
|
const resolution = zoomToResolution(viewState.zoom);
|
|
|
|
onViewChange({
|
|
resolution,
|
|
bounds,
|
|
zoom: viewState.zoom,
|
|
latitude: viewState.latitude,
|
|
longitude: viewState.longitude,
|
|
});
|
|
}, [viewState, dimensions, onViewChange]);
|
|
|
|
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
|
setInternalViewState((prev) => {
|
|
const next = evt.viewState;
|
|
// Skip re-render when viewport values haven't changed (e.g. container resize
|
|
// fires move events with identical lat/lng/zoom). Returning the same reference
|
|
// tells React to bail out.
|
|
if (
|
|
prev.latitude === next.latitude &&
|
|
prev.longitude === next.longitude &&
|
|
prev.zoom === next.zoom &&
|
|
prev.pitch === next.pitch &&
|
|
prev.bearing === next.bearing
|
|
) {
|
|
return prev;
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
|
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
|
}, []);
|
|
|
|
if (flyToRef) flyToRef.current = handleFlyTo;
|
|
|
|
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
|
|
|
|
const {
|
|
layers,
|
|
popupInfo,
|
|
clearPopupInfo,
|
|
hoverPosition,
|
|
countRange,
|
|
postcodeCountRange,
|
|
colorFeatureMeta,
|
|
handleMouseLeave,
|
|
} = useDeckLayers({
|
|
data,
|
|
postcodeData,
|
|
usePostcodeView,
|
|
zoom: viewState.zoom,
|
|
pois,
|
|
viewFeature,
|
|
colorRange,
|
|
filterRange,
|
|
features,
|
|
selectedHexagonId,
|
|
hoveredHexagonId,
|
|
onHexagonClick,
|
|
onHexagonHover,
|
|
theme,
|
|
selectedPostcodeGeometry,
|
|
currentLocation,
|
|
bounds: viewportBounds,
|
|
travelTimeEntries,
|
|
});
|
|
|
|
return (
|
|
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>
|
|
<MapGL
|
|
{...viewState}
|
|
onMove={handleMove}
|
|
onLoad={undefined}
|
|
onIdle={
|
|
screenshotMode
|
|
? () => {
|
|
window.__map_idle = true;
|
|
}
|
|
: undefined
|
|
}
|
|
mapStyle={mapStyle}
|
|
style={{ width: '100%', height: '100%' }}
|
|
attributionControl={false}
|
|
dragRotate={false}
|
|
touchZoomRotate={true}
|
|
touchPitch={false}
|
|
keyboard={true}
|
|
pitchWithRotate={false}
|
|
minZoom={MAP_MIN_ZOOM}
|
|
maxBounds={MAP_BOUNDS}
|
|
>
|
|
<DeckOverlay layers={layers} getTooltip={null} />
|
|
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
|
</MapGL>
|
|
{screenshotMode ? (
|
|
ogMode ? (
|
|
<div className="absolute inset-0 z-20 pointer-events-none flex flex-col">
|
|
{/* Center: Logo card with hero text */}
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10">
|
|
<LogoIcon className="w-24 h-24 text-teal-400" />
|
|
<span
|
|
className="font-bold text-white whitespace-nowrap"
|
|
style={{ fontSize: '5rem' }}
|
|
>
|
|
Your perfect postcode
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom bar */}
|
|
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-10 py-4 bg-white">
|
|
<div className="flex items-center gap-6">
|
|
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
|
Property prices
|
|
</span>
|
|
<span className="text-warm-300">|</span>
|
|
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
|
Energy ratings
|
|
</span>
|
|
<span className="text-warm-300">|</span>
|
|
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
|
Schools
|
|
</span>
|
|
<span className="text-warm-300">|</span>
|
|
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
|
Crime stats
|
|
</span>
|
|
<span className="text-warm-300">|</span>
|
|
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
|
Transport
|
|
</span>
|
|
</div>
|
|
<span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
|
|
perfect-postcode.co.uk
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : null
|
|
) : (
|
|
<>
|
|
<div className="absolute top-3 left-3 right-3 z-[60] flex flex-wrap items-start justify-between gap-2 pointer-events-none">
|
|
<LocationSearch
|
|
onFlyTo={handleFlyTo}
|
|
onLocationSearched={onLocationSearched}
|
|
onCurrentLocationFound={onCurrentLocationFound}
|
|
onMouseEnter={handleMouseLeave}
|
|
/>
|
|
{!hideLegend &&
|
|
(viewFeature && colorRange ? (
|
|
viewFeature.startsWith('tt_') ? (
|
|
<MapLegend
|
|
featureLabel={t('travel.travelTime', {
|
|
mode: modes.label(
|
|
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
|
|
),
|
|
})}
|
|
range={colorRange}
|
|
showCancel={viewSource === 'eye'}
|
|
onCancel={onCancelPin}
|
|
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
|
resetScaleDisabled={!canResetPreviewScale}
|
|
mode="feature"
|
|
theme={theme}
|
|
suffix=" min"
|
|
/>
|
|
) : colorFeatureMeta ? (
|
|
<MapLegend
|
|
featureLabel={
|
|
viewSource === 'eye'
|
|
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
|
|
: ts(colorFeatureMeta.name)
|
|
}
|
|
range={colorRange}
|
|
showCancel={viewSource === 'eye'}
|
|
onCancel={onCancelPin}
|
|
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
|
resetScaleDisabled={!canResetPreviewScale}
|
|
mode="feature"
|
|
enumValues={
|
|
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
|
|
}
|
|
featureName={colorFeatureMeta.name}
|
|
theme={theme}
|
|
raw={colorFeatureMeta.raw}
|
|
/>
|
|
) : null
|
|
) : (
|
|
<MapLegend
|
|
featureLabel={densityLabel}
|
|
range={
|
|
usePostcodeView
|
|
? [postcodeCountRange.min, postcodeCountRange.max]
|
|
: [countRange.min, countRange.max]
|
|
}
|
|
totalCount={
|
|
totalCountProp ??
|
|
(usePostcodeView ? postcodeCountRange.total : countRange.total)
|
|
}
|
|
showCancel={false}
|
|
onCancel={onCancelPin}
|
|
mode="density"
|
|
theme={theme}
|
|
/>
|
|
))}
|
|
</div>
|
|
{popupInfo && (
|
|
<div
|
|
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
|
style={{
|
|
left: popupInfo.x,
|
|
top: popupInfo.y - 50,
|
|
transform: 'translateX(-50%)',
|
|
zIndex: 9999,
|
|
}}
|
|
>
|
|
<button
|
|
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
|
|
onClick={clearPopupInfo}
|
|
>
|
|
<CloseIcon className="w-3 h-3" />
|
|
</button>
|
|
{popupInfo.isCluster ? (
|
|
<div className="px-3 py-2 text-center">
|
|
<div className="text-lg font-bold text-teal-600 dark:text-teal-400">
|
|
{popupInfo.clusterCount}
|
|
</div>
|
|
<div className="text-warm-500 dark:text-warm-400 text-xs">places</div>
|
|
</div>
|
|
) : (
|
|
<div className="px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<img
|
|
src={getPoiIconUrl(popupInfo.category, popupInfo.emoji)}
|
|
alt=""
|
|
aria-hidden="true"
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
|
|
/>
|
|
<div>
|
|
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
|
|
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
|
|
<span
|
|
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
|
style={{
|
|
backgroundColor: `rgb(${(POI_GROUP_COLORS[popupInfo.group] || POI_DEFAULT_COLOR).join(',')})`,
|
|
}}
|
|
/>
|
|
{popupInfo.category}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
|
|
<HoverCard
|
|
x={hoverPosition.x}
|
|
y={hoverPosition.y}
|
|
id={hoveredHexagonId}
|
|
isPostcode={usePostcodeView}
|
|
data={
|
|
usePostcodeView
|
|
? postcodeData.find((f) => f.properties.postcode === hoveredHexagonId)
|
|
?.properties || null
|
|
: data.find((d) => d.h3 === hoveredHexagonId) || null
|
|
}
|
|
filters={filters}
|
|
features={features}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|