A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
245 lines
7.9 KiB
TypeScript
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)
|