Files
breakpilot-lehrer/website/components/admin/GermanySchoolMap.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
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>
2026-02-11 23:47:26 +01:00

245 lines
7.9 KiB
TypeScript

'use client'
/**
* Germany School Map Component
*
* Displays a choropleth map of Germany showing school density by state.
* Uses react-simple-maps for SVG rendering.
*/
import { useState, useEffect, useCallback, memo } from 'react'
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from 'react-simple-maps'
// Germany GeoJSON - local file (simplified boundaries)
const GERMANY_GEO_URL = '/germany-states.json'
const STATE_NAMES: Record<string, string> = {
'BW': 'Baden-Wuerttemberg',
'BY': 'Bayern',
'BE': 'Berlin',
'BB': 'Brandenburg',
'HB': 'Bremen',
'HH': 'Hamburg',
'HE': 'Hessen',
'MV': 'Mecklenburg-Vorpommern',
'NI': 'Niedersachsen',
'NW': 'Nordrhein-Westfalen',
'RP': 'Rheinland-Pfalz',
'SL': 'Saarland',
'SN': 'Sachsen',
'ST': 'Sachsen-Anhalt',
'SH': 'Schleswig-Holstein',
'TH': 'Thueringen',
}
interface SchoolStats {
total_schools: number
by_state?: Record<string, number>
}
interface GermanySchoolMapProps {
stats: SchoolStats | null
onStateClick?: (stateCode: string) => void
selectedState?: string
}
// Color scale for school density
const getStateColor = (count: number, maxCount: number): string => {
if (count === 0) return '#f1f5f9' // slate-100
const intensity = count / maxCount
if (intensity < 0.2) return '#dbeafe' // blue-100
if (intensity < 0.4) return '#93c5fd' // blue-300
if (intensity < 0.6) return '#3b82f6' // blue-500
if (intensity < 0.8) return '#1d4ed8' // blue-700
return '#1e3a8a' // blue-900
}
function GermanySchoolMap({ stats, onStateClick, selectedState }: GermanySchoolMapProps) {
const [tooltipContent, setTooltipContent] = useState('')
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 })
const [showTooltip, setShowTooltip] = useState(false)
const [zoom, setZoom] = useState(1)
const [center, setCenter] = useState<[number, number]>([10.5, 51.2])
const maxSchools = stats?.by_state
? Math.max(...Object.values(stats.by_state))
: 0
const handleStateHover = useCallback((geo: { properties: { code?: string; name?: string } }, evt: React.MouseEvent) => {
const stateCode = geo.properties.code || ''
const stateName = geo.properties.name || STATE_NAMES[stateCode] || stateCode
const schoolCount = stats?.by_state?.[stateCode] || 0
setTooltipContent(`${stateName}: ${schoolCount.toLocaleString()} Schulen`)
setTooltipPosition({ x: evt.clientX, y: evt.clientY })
setShowTooltip(true)
}, [stats])
const handleStateLeave = useCallback(() => {
setShowTooltip(false)
}, [])
const handleZoomIn = () => {
if (zoom < 4) setZoom(zoom * 1.5)
}
const handleZoomOut = () => {
if (zoom > 1) setZoom(zoom / 1.5)
}
const handleReset = () => {
setZoom(1)
setCenter([10.5, 51.2])
}
return (
<div className="relative bg-white rounded-lg border p-4">
{/* Map Controls */}
<div className="absolute top-6 right-6 z-10 flex flex-col gap-2">
<button
onClick={handleZoomIn}
className="w-8 h-8 bg-white border rounded shadow hover:bg-gray-50 flex items-center justify-center text-lg font-bold"
title="Vergroessern"
>
+
</button>
<button
onClick={handleZoomOut}
className="w-8 h-8 bg-white border rounded shadow hover:bg-gray-50 flex items-center justify-center text-lg font-bold"
title="Verkleinern"
>
-
</button>
<button
onClick={handleReset}
className="w-8 h-8 bg-white border rounded shadow hover:bg-gray-50 flex items-center justify-center text-xs"
title="Zuruecksetzen"
>
Reset
</button>
</div>
{/* Legend */}
<div className="absolute bottom-6 left-6 z-10 bg-white/90 p-3 rounded border shadow-sm">
<h4 className="text-xs font-medium text-slate-700 mb-2">Schulen pro Bundesland</h4>
<div className="flex items-center gap-1">
<div className="w-4 h-4 bg-blue-100 rounded-sm" />
<div className="w-4 h-4 bg-blue-300 rounded-sm" />
<div className="w-4 h-4 bg-blue-500 rounded-sm" />
<div className="w-4 h-4 bg-blue-700 rounded-sm" />
<div className="w-4 h-4 bg-blue-900 rounded-sm" />
</div>
<div className="flex justify-between text-xs text-slate-500 mt-1">
<span>0</span>
<span>{maxSchools.toLocaleString()}</span>
</div>
</div>
{/* Tooltip */}
{showTooltip && (
<div
className="fixed z-50 px-3 py-2 bg-slate-900 text-white text-sm rounded shadow-lg pointer-events-none"
style={{
left: tooltipPosition.x + 10,
top: tooltipPosition.y - 30,
}}
>
{tooltipContent}
</div>
)}
{/* Map */}
<ComposableMap
projection="geoMercator"
projectionConfig={{
scale: 2800,
center: [10.5, 51.2],
}}
className="w-full h-[500px]"
>
<ZoomableGroup
zoom={zoom}
center={center}
onMoveEnd={({ coordinates, zoom: newZoom }: { coordinates: [number, number]; zoom: number }) => {
setCenter(coordinates)
setZoom(newZoom)
}}
>
<Geographies geography={GERMANY_GEO_URL}>
{({ geographies }: { geographies: any[] }) =>
geographies.map((geo: any) => {
const stateCode = geo.properties.code || ''
const schoolCount = stats?.by_state?.[stateCode] || 0
const isSelected = selectedState === stateCode
return (
<Geography
key={geo.rsmKey}
geography={geo}
onMouseEnter={(evt: React.MouseEvent) => handleStateHover(geo, evt)}
onMouseLeave={handleStateLeave}
onClick={() => onStateClick?.(stateCode)}
style={{
default: {
fill: isSelected
? '#f59e0b' // amber-500 for selected
: getStateColor(schoolCount, maxSchools),
stroke: '#ffffff',
strokeWidth: 0.5,
outline: 'none',
},
hover: {
fill: isSelected ? '#d97706' : '#60a5fa', // blue-400
stroke: '#ffffff',
strokeWidth: 1,
outline: 'none',
cursor: 'pointer',
},
pressed: {
fill: '#f59e0b',
stroke: '#ffffff',
strokeWidth: 1,
outline: 'none',
},
}}
/>
)
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
{/* State List */}
{stats?.by_state && (
<div className="mt-4 pt-4 border-t">
<h4 className="text-sm font-medium text-slate-700 mb-2">Schulen nach Bundesland</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{Object.entries(stats.by_state)
.sort(([,a], [,b]) => b - a)
.map(([code, count]) => (
<button
key={code}
onClick={() => onStateClick?.(code)}
className={`flex justify-between items-center p-2 rounded hover:bg-slate-100 transition-colors ${
selectedState === code ? 'bg-amber-100 text-amber-800' : ''
}`}
>
<span>{STATE_NAMES[code] || code}</span>
<span className="font-medium">{count.toLocaleString()}</span>
</button>
))}
</div>
</div>
)}
</div>
)
}
export default memo(GermanySchoolMap)