'use client' import { useEffect, useRef, useState, useCallback } from 'react' import { GeoJSONPolygon } from '@/app/geo-lernwelt/types' // MapLibre GL JS types (imported dynamically) type MapInstance = { on: (event: string, callback: (...args: unknown[]) => void) => void addSource: (id: string, source: object) => void addLayer: (layer: object) => void getSource: (id: string) => { setData: (data: object) => void } | undefined removeLayer: (id: string) => void removeSource: (id: string) => void getCanvas: () => { style: { cursor: string } } remove: () => void fitBounds: (bounds: [[number, number], [number, number]], options?: object) => void } interface AOISelectorProps { onPolygonDrawn: (polygon: GeoJSONPolygon) => void initialPolygon?: GeoJSONPolygon | null maxAreaKm2?: number geoServiceUrl: string } // Germany bounds const GERMANY_BOUNDS: [[number, number], [number, number]] = [ [5.87, 47.27], [15.04, 55.06], ] // Default center (Germany) const DEFAULT_CENTER: [number, number] = [10.45, 51.16] const DEFAULT_ZOOM = 6 export default function AOISelector({ onPolygonDrawn, initialPolygon, maxAreaKm2 = 4, geoServiceUrl, }: AOISelectorProps) { const mapContainerRef = useRef(null) const mapRef = useRef(null) const [isDrawing, setIsDrawing] = useState(false) const [drawPoints, setDrawPoints] = useState<[number, number][]>([]) const [mapReady, setMapReady] = useState(false) const [areaKm2, setAreaKm2] = useState(null) const [validationError, setValidationError] = useState(null) // Initialize map useEffect(() => { if (!mapContainerRef.current) return const initMap = async () => { const maplibregl = await import('maplibre-gl') // CSS is loaded via CDN in head to avoid Next.js dynamic import issues if (!document.querySelector('link[href*="maplibre-gl.css"]')) { const link = document.createElement('link') link.rel = 'stylesheet' link.href = 'https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css' document.head.appendChild(link) } const map = new maplibregl.Map({ container: mapContainerRef.current!, style: { version: 8, name: 'GeoEdu Map', sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors', }, }, layers: [ { id: 'osm-tiles', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19, }, ], }, center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, maxBounds: [ [GERMANY_BOUNDS[0][0] - 1, GERMANY_BOUNDS[0][1] - 1], [GERMANY_BOUNDS[1][0] + 1, GERMANY_BOUNDS[1][1] + 1], ], }) as unknown as MapInstance map.on('load', () => { // Add drawing layer sources map.addSource('draw-polygon', { type: 'geojson', data: { type: 'FeatureCollection', features: [], }, }) map.addSource('draw-points', { type: 'geojson', data: { type: 'FeatureCollection', features: [], }, }) // Add polygon fill layer map.addLayer({ id: 'draw-polygon-fill', type: 'fill', source: 'draw-polygon', paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.3, }, }) // Add polygon outline layer map.addLayer({ id: 'draw-polygon-outline', type: 'line', source: 'draw-polygon', paint: { 'line-color': '#3b82f6', 'line-width': 2, }, }) // Add points layer map.addLayer({ id: 'draw-points-layer', type: 'circle', source: 'draw-points', paint: { 'circle-radius': 6, 'circle-color': '#3b82f6', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2, }, }) mapRef.current = map setMapReady(true) // Load initial polygon if provided if (initialPolygon) { loadPolygon(map, initialPolygon) } }) } initMap() return () => { if (mapRef.current) { mapRef.current.remove() } } }, []) // Update polygon when initialPolygon changes useEffect(() => { if (mapReady && mapRef.current && initialPolygon) { loadPolygon(mapRef.current, initialPolygon) } }, [initialPolygon, mapReady]) const loadPolygon = (map: MapInstance, polygon: GeoJSONPolygon) => { const source = map.getSource('draw-polygon') if (source) { source.setData({ type: 'Feature', geometry: polygon, properties: {}, }) } // Calculate and validate area validatePolygon(polygon) // Fit bounds to polygon const coords = polygon.coordinates[0] const bounds = coords.reduce<[[number, number], [number, number]]>( (acc, coord) => [ [Math.min(acc[0][0], coord[0]), Math.min(acc[0][1], coord[1])], [Math.max(acc[1][0], coord[0]), Math.max(acc[1][1], coord[1])], ], [ [Infinity, Infinity], [-Infinity, -Infinity], ] ) map.fitBounds(bounds, { padding: 50 }) } const validatePolygon = async (polygon: GeoJSONPolygon) => { try { const res = await fetch(`${geoServiceUrl}/api/v1/aoi/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(polygon), }) if (res.ok) { const data = await res.json() setAreaKm2(data.area_km2) if (!data.valid) { setValidationError(data.error || 'Ungueltiges Polygon') } else if (!data.within_germany) { setValidationError('Gebiet muss innerhalb Deutschlands liegen') } else if (!data.within_size_limit) { setValidationError(`Gebiet zu gross (max. ${maxAreaKm2} km²)`) } else { setValidationError(null) } } } catch (e) { // Fallback to client-side calculation console.error('Validation error:', e) } } const handleMapClick = useCallback( (e: { lngLat: { lng: number; lat: number } }) => { if (!isDrawing || !mapRef.current) return const newPoint: [number, number] = [e.lngLat.lng, e.lngLat.lat] const newPoints = [...drawPoints, newPoint] setDrawPoints(newPoints) // Update points layer const pointsSource = mapRef.current.getSource('draw-points') if (pointsSource) { pointsSource.setData({ type: 'FeatureCollection', features: newPoints.map((pt) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: pt }, properties: {}, })), }) } // Update polygon preview if we have at least 3 points if (newPoints.length >= 3) { const polygonCoords = [...newPoints, newPoints[0]] const polygonSource = mapRef.current.getSource('draw-polygon') if (polygonSource) { polygonSource.setData({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [polygonCoords], }, properties: {}, }) } } }, [isDrawing, drawPoints] ) // Attach click handler when drawing useEffect(() => { if (!mapReady || !mapRef.current) return const map = mapRef.current if (isDrawing) { map.getCanvas().style.cursor = 'crosshair' map.on('click', handleMapClick as (...args: unknown[]) => void) } else { map.getCanvas().style.cursor = '' } return () => { // Note: maplibre doesn't have off() in the same way, handled by cleanup } }, [isDrawing, mapReady, handleMapClick]) const startDrawing = () => { setIsDrawing(true) setDrawPoints([]) setAreaKm2(null) setValidationError(null) // Clear existing polygon if (mapRef.current) { const polygonSource = mapRef.current.getSource('draw-polygon') const pointsSource = mapRef.current.getSource('draw-points') if (polygonSource) { polygonSource.setData({ type: 'FeatureCollection', features: [] }) } if (pointsSource) { pointsSource.setData({ type: 'FeatureCollection', features: [] }) } } } const finishDrawing = () => { if (drawPoints.length < 3) { setValidationError('Mindestens 3 Punkte erforderlich') return } setIsDrawing(false) // Close the polygon const closedCoords = [...drawPoints, drawPoints[0]] const polygon: GeoJSONPolygon = { type: 'Polygon', coordinates: [closedCoords], } // Clear points layer if (mapRef.current) { const pointsSource = mapRef.current.getSource('draw-points') if (pointsSource) { pointsSource.setData({ type: 'FeatureCollection', features: [] }) } } // Validate and callback validatePolygon(polygon) onPolygonDrawn(polygon) } const cancelDrawing = () => { setIsDrawing(false) setDrawPoints([]) // Clear layers if (mapRef.current) { const polygonSource = mapRef.current.getSource('draw-polygon') const pointsSource = mapRef.current.getSource('draw-points') if (polygonSource) { polygonSource.setData({ type: 'FeatureCollection', features: [] }) } if (pointsSource) { pointsSource.setData({ type: 'FeatureCollection', features: [] }) } } } return (
{/* Map Container */}
{/* Drawing Toolbar */}
{!isDrawing ? ( ) : ( <> )}
{/* Drawing Instructions */} {isDrawing && (

Klicke auf die Karte um Punkte zu setzen.

Mindestens 3 Punkte erforderlich.

)} {/* Area Info */} {areaKm2 !== null && (
Flaeche: maxAreaKm2 ? 'text-red-400' : 'text-green-400'}> {areaKm2.toFixed(2)} km² / {maxAreaKm2} km² max
)} {/* Validation Error */} {validationError && (
{validationError}
)} {/* Loading Overlay */} {!mapReady && (
Karte wird geladen...
)}
) }