Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
439 lines
13 KiB
TypeScript
439 lines
13 KiB
TypeScript
'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<HTMLDivElement>(null)
|
|
const mapRef = useRef<MapInstance | null>(null)
|
|
const [isDrawing, setIsDrawing] = useState(false)
|
|
const [drawPoints, setDrawPoints] = useState<[number, number][]>([])
|
|
const [mapReady, setMapReady] = useState(false)
|
|
const [areaKm2, setAreaKm2] = useState<number | null>(null)
|
|
const [validationError, setValidationError] = useState<string | null>(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 (
|
|
<div className="relative w-full h-full">
|
|
{/* Map Container */}
|
|
<div ref={mapContainerRef} className="w-full h-full" />
|
|
|
|
{/* Drawing Toolbar */}
|
|
<div className="absolute top-4 left-4 flex gap-2">
|
|
{!isDrawing ? (
|
|
<button
|
|
onClick={startDrawing}
|
|
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-lg transition-colors flex items-center gap-2"
|
|
>
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
|
/>
|
|
</svg>
|
|
Gebiet zeichnen
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={finishDrawing}
|
|
disabled={drawPoints.length < 3}
|
|
className="px-4 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-500 text-white rounded-lg shadow-lg transition-colors"
|
|
>
|
|
✓ Fertig ({drawPoints.length} Punkte)
|
|
</button>
|
|
<button
|
|
onClick={cancelDrawing}
|
|
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg shadow-lg transition-colors"
|
|
>
|
|
✕ Abbrechen
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Drawing Instructions */}
|
|
{isDrawing && (
|
|
<div className="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg text-sm max-w-xs">
|
|
<p>Klicke auf die Karte um Punkte zu setzen.</p>
|
|
<p className="text-white/60 mt-1">Mindestens 3 Punkte erforderlich.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Area Info */}
|
|
{areaKm2 !== null && (
|
|
<div className="absolute bottom-4 left-4 bg-black/70 text-white px-4 py-2 rounded-lg">
|
|
<div className="text-sm">
|
|
<span className="text-white/60">Flaeche: </span>
|
|
<span className={areaKm2 > maxAreaKm2 ? 'text-red-400' : 'text-green-400'}>
|
|
{areaKm2.toFixed(2)} km²
|
|
</span>
|
|
<span className="text-white/40"> / {maxAreaKm2} km² max</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Validation Error */}
|
|
{validationError && (
|
|
<div className="absolute bottom-4 right-4 bg-red-500/90 text-white px-4 py-2 rounded-lg text-sm max-w-xs">
|
|
{validationError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading Overlay */}
|
|
{!mapReady && (
|
|
<div className="absolute inset-0 bg-slate-800 flex items-center justify-center">
|
|
<div className="text-white/60 flex flex-col items-center gap-2">
|
|
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
<span>Karte wird geladen...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|