Files
mapsift/src/features/map/MapView.tsx

172 lines
3.9 KiB
TypeScript

import { useEffect, useMemo } from 'react';
import L from 'leaflet';
import {
MapContainer,
Marker,
Popup,
TileLayer,
Tooltip,
useMap,
useMapEvents,
} from 'react-leaflet';
import type { MapTileStyle } from './mapStyles';
import type { BBox, ParsedOsmResult } from '../search/types';
type MapViewProps = {
results: ParsedOsmResult[];
selectedResultId: string | null;
tileStyle: MapTileStyle;
showResultLabels: boolean;
fitMapToResults: boolean;
dimMap: boolean;
onResultSelect: (id: string) => void;
onViewportChange: (bbox: BBox) => void;
};
const DEFAULT_CENTER: [number, number] = [52.2297, 21.0122];
const DEFAULT_ZOOM = 13;
export function MapView({
results,
selectedResultId,
tileStyle,
showResultLabels,
fitMapToResults,
dimMap,
onResultSelect,
onViewportChange,
}: MapViewProps) {
const markerIcon = useMemo(
() =>
L.divIcon({
className: 'result-marker',
html: '<span></span>',
iconSize: [18, 18],
iconAnchor: [9, 9],
}),
[],
);
const selectedMarkerIcon = useMemo(
() =>
L.divIcon({
className: 'result-marker result-marker-selected',
html: '<span></span>',
iconSize: [24, 24],
iconAnchor: [12, 12],
}),
[],
);
return (
<MapContainer
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
minZoom={3}
className={dimMap ? 'map-view map-view-dimmed' : 'map-view'}
zoomControl
>
<TileLayer
key={tileStyle.id}
attribution={tileStyle.attribution}
url={tileStyle.url}
maxZoom={tileStyle.maxZoom}
/>
<ViewportReporter onViewportChange={onViewportChange} />
<FitResults results={results} enabled={fitMapToResults} />
<SelectedResultFocus result={results.find((item) => item.id === selectedResultId) ?? null} />
{results.map((result) => (
<Marker
key={result.id}
position={[result.lat, result.lon]}
icon={result.id === selectedResultId ? selectedMarkerIcon : markerIcon}
eventHandlers={{
click: () => onResultSelect(result.id),
}}
>
<Popup>
<strong>{result.name ?? 'Unnamed place'}</strong>
<br />
{result.type} {result.osmId}
</Popup>
{showResultLabels ? (
<Tooltip permanent direction="top" offset={[0, -10]} opacity={0.94}>
{result.name ?? `${result.type} ${result.osmId}`}
</Tooltip>
) : null}
</Marker>
))}
</MapContainer>
);
}
function ViewportReporter({
onViewportChange,
}: {
onViewportChange: (bbox: BBox) => void;
}) {
const map = useMapEvents({
moveend: () => onViewportChange(boundsToBBox(map.getBounds())),
zoomend: () => onViewportChange(boundsToBBox(map.getBounds())),
});
useEffect(() => {
onViewportChange(boundsToBBox(map.getBounds()));
}, [map, onViewportChange]);
return null;
}
function FitResults({
results,
enabled,
}: {
results: ParsedOsmResult[];
enabled: boolean;
}) {
const map = useMap();
useEffect(() => {
if (!enabled || results.length === 0) {
return;
}
const bounds = L.latLngBounds(results.map((result) => [result.lat, result.lon]));
if (bounds.isValid()) {
map.fitBounds(bounds.pad(0.2), {
animate: true,
maxZoom: 16,
});
}
}, [enabled, map, results]);
return null;
}
function SelectedResultFocus({ result }: { result: ParsedOsmResult | null }) {
const map = useMap();
useEffect(() => {
if (result) {
map.setView([result.lat, result.lon], Math.max(map.getZoom(), 15), {
animate: true,
});
}
}, [map, result]);
return null;
}
function boundsToBBox(bounds: L.LatLngBounds): BBox {
const southWest = bounds.getSouthWest();
const northEast = bounds.getNorthEast();
return {
south: southWest.lat,
west: southWest.lng,
north: northEast.lat,
east: northEast.lng,
};
}