192 lines
7.3 KiB
TypeScript
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='© <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: © OpenStreetMap, SRTM | Style: © <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='© 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>
|
|
</>
|
|
);
|
|
}
|