perfect-postcode/frontend/src/components/map/Map.tsx
2026-02-15 22:39:54 +00:00

308 lines
9.7 KiB
TypeScript

import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import { Map as MapGL, useControl } 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,
PostcodeFeature,
PostcodeGeometry,
ViewState,
ViewChangeParams,
POI,
FeatureMeta,
Bounds,
} from '../../types';
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import type { FeatureFilters } from '../../types';
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
import { MODE_LABELS, type TravelTimeEntry, travelFieldKey } from '../../hooks/useTravelTime';
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;
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;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
ogMode?: boolean;
filters?: FeatureFilters;
selectedPostcodeGeometry?: PostcodeGeometry | null;
onLocationSearched?: (location: SearchedLocation | null) => void;
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_TRAVEL_RANGES = new globalThis.Map<number, [number, number]>();
interface Dimensions {
width: number;
height: number;
}
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 MapboxOverlay({ interleaved: true }));
const prevLayersRef = useRef(layers);
const prevTooltipRef = useRef(getTooltip);
if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) {
prevLayersRef.current = layers;
prevTooltipRef.current = getTooltip;
overlay.setProps({ layers, getTooltip });
}
return null;
}
export default memo(function Map({
data,
postcodeData,
usePostcodeView,
pois,
onViewChange,
viewFeature,
colorRange,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
theme = 'light',
screenshotMode = false,
ogMode = false,
filters = {},
selectedPostcodeGeometry,
onLocationSearched,
bounds: viewportBounds,
hideLegend = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
travelTimeColorRanges = EMPTY_TRAVEL_RANGES,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0) {
setDimensions({ width, height });
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
// Send exact viewport bounds - server will filter to only return
// hexagons/postcodes that intersect this precise AABB
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 }) => {
setViewState(evt.viewState);
}, []);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
const handleMapLoad = useCallback(
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
// Road opacity is set in getMapStyle
},
[]
);
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
const {
layers,
popupInfo,
hoverPosition,
countRange,
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
primaryTravelIndex,
} = useDeckLayers({
data,
postcodeData,
usePostcodeView,
pois,
viewFeature,
colorRange,
filterRange,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
theme,
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEntries,
travelTimeColorRanges,
});
return (
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>
<MapGL
{...viewState}
onMove={handleMove}
onLoad={handleMapLoad as never}
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} />
</MapGL>
{screenshotMode ? (
ogMode ? (
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
<h1
className="text-5xl font-bold text-white drop-shadow-lg"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
Your perfect postcode
</h1>
</div>
) : null
) : (
<>
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} />
{!hideLegend &&
(primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[travelTimeEntries[primaryTravelIndex].mode]})`}
range={travelTimeColorRanges.get(primaryTravelIndex)!}
showCancel={false}
onCancel={onCancelPin}
mode="feature"
theme={theme}
suffix=" min"
/>
) : viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
: colorFeatureMeta.name
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/>
) : (
<MapLegend
featureLabel="Property density"
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
/>
))}
{popupInfo && (
<div
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-white"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,
transform: 'translateX(-50%)',
zIndex: 9999,
}}
>
<strong className="dark:text-white">{popupInfo.name}</strong>
<div className="text-warm-500 dark:text-warm-300 text-xs">{popupInfo.category}</div>
{osmIdToUrl(popupInfo.id) && (
<a
href={osmIdToUrl(popupInfo.id)!}
target="_blank"
rel="noopener noreferrer"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 text-xs"
>
View on OSM
</a>
)}
</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}
/>
)}
</>
)}
</div>
);
});