Files
rfcp/frontend/src/components/map/Map.tsx

192 lines
7.3 KiB
TypeScript

import { useRef, useCallback, useEffect } from 'react';
import { MapContainer, TileLayer, useMapEvents, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import type { Map as LeafletMap } from 'leaflet';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useSettingsStore } from '@/store/settings.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import SiteMarker from './SiteMarker.tsx';
import MapExtras from './MapExtras.tsx';
import CoordinateGrid from './CoordinateGrid.tsx';
import MeasurementTool from './MeasurementTool.tsx';
import ElevationDisplay from './ElevationDisplay.tsx';
interface MapViewProps {
onMapClick: (lat: number, lon: number) => void;
onEditSite: (site: Site) => void;
children?: React.ReactNode;
}
function MapClickHandler({
onMapClick,
}: {
onMapClick: (lat: number, lon: number) => void;
}) {
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
useMapEvents({
click: (e) => {
if (isPlacingMode) {
onMapClick(e.latlng.lat, e.latlng.lng);
}
},
});
return null;
}
/**
* Inner component that exposes the map instance via ref callback
*/
function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject<LeafletMap | null> }) {
const map = useMap();
useEffect(() => {
mapRef.current = map;
}, [map, mapRef]);
return null;
}
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
const sites = useSitesStore((s) => s.sites);
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
const showTerrain = useSettingsStore((s) => s.showTerrain);
const terrainOpacity = useSettingsStore((s) => s.terrainOpacity);
const setShowTerrain = useSettingsStore((s) => s.setShowTerrain);
const showGrid = useSettingsStore((s) => s.showGrid);
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
const measurementMode = useSettingsStore((s) => s.measurementMode);
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
const showElevationInfo = useSettingsStore((s) => s.showElevationInfo);
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
const addToast = useToastStore((s) => s.addToast);
const mapRef = useRef<LeafletMap | null>(null);
const handleFitToSites = useCallback(() => {
if (sites.length === 0 || !mapRef.current) return;
const bounds = sites.map((site) => [site.lat, site.lon] as [number, number]);
mapRef.current.fitBounds(bounds, { padding: [50, 50] });
}, [sites]);
const handleResetView = useCallback(() => {
mapRef.current?.setView([48.4, 35.0], 7);
}, []);
return (
<>
<MapContainer
center={[48.4, 35.0]}
zoom={7}
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
>
<MapRefSetter mapRef={mapRef} />
{/* Base OSM layer */}
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Terrain overlay (OpenTopoMap, above base map, below heatmap) */}
{showTerrain && (
<TileLayer
attribution='Map data: &copy; OpenStreetMap, SRTM | Style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a>'
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
opacity={terrainOpacity}
zIndex={100}
/>
)}
{/* Elevation color overlay (Stamen Terrain via Stadia Maps) */}
{showElevationOverlay && (
<TileLayer
attribution='&copy; Stamen Design'
url="https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.png"
opacity={0.5}
zIndex={97}
/>
)}
<MapClickHandler onMapClick={onMapClick} />
<MapExtras />
{showElevationInfo && <ElevationDisplay />}
<CoordinateGrid visible={showGrid} />
<MeasurementTool
enabled={measurementMode}
onComplete={(distKm) => {
addToast(`Distance: ${distKm.toFixed(2)} km (${(distKm * 1000).toFixed(0)} m)`, 'info');
setMeasurementMode(false);
}}
/>
{sites
.filter((s) => s.visible)
.map((site) => (
<SiteMarker key={site.id} site={site} onEdit={onEditSite} />
))}
{children}
</MapContainer>
{/* Map control buttons */}
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-2">
<button
onClick={handleFitToSites}
disabled={sites.length === 0}
className="bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
disabled:opacity-40 disabled:cursor-not-allowed
text-gray-700 dark:text-dark-text min-h-[36px]"
title="Fit view to all sites"
>
Fit
</button>
<button
onClick={handleResetView}
className="bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]"
title="Reset to Ukraine view"
>
Reset
</button>
<button
onClick={() => setShowTerrain(!showTerrain)}
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]
${showTerrain ? 'ring-2 ring-blue-500' : ''}`}
title={showTerrain ? 'Hide terrain' : 'Show terrain elevation'}
>
Topo
</button>
<button
onClick={() => setShowGrid(!showGrid)}
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]
${showGrid ? 'ring-2 ring-green-500' : ''}`}
title={showGrid ? 'Hide coordinate grid' : 'Show coordinate grid'}
>
Grid
</button>
<button
onClick={() => setMeasurementMode(!measurementMode)}
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]
${measurementMode ? 'ring-2 ring-orange-500' : ''}`}
title={measurementMode ? 'Exit measurement mode' : 'Measure distance (click points, right-click to finish)'}
>
Ruler
</button>
<button
onClick={() => setShowElevationOverlay(!showElevationOverlay)}
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]
${showElevationOverlay ? 'ring-2 ring-amber-500' : ''}`}
title={showElevationOverlay ? 'Hide elevation colors' : 'Show elevation color overlay'}
>
Elev
</button>
</div>
</>
);
}