Files
breakpilot-lehrer/studio-v2/app/geo-lernwelt/page.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

502 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import dynamic from 'next/dynamic'
import {
AOIResponse,
AOITheme,
AOIQuality,
Difficulty,
GeoJSONPolygon,
LearningNode,
GeoServiceHealth,
DemoTemplate,
} from './types'
// Dynamic imports for map components (no SSR)
const AOISelector = dynamic(
() => import('@/components/geo-lernwelt/AOISelector'),
{ ssr: false, loading: () => <MapLoadingPlaceholder /> }
)
const UnityViewer = dynamic(
() => import('@/components/geo-lernwelt/UnityViewer'),
{ ssr: false }
)
// API base URL
const GEO_SERVICE_URL = process.env.NEXT_PUBLIC_GEO_SERVICE_URL || 'http://localhost:8088'
// Loading placeholder for map
function MapLoadingPlaceholder() {
return (
<div className="w-full h-[400px] bg-slate-800 rounded-xl 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>
)
}
// Theme icons and colors
const THEME_CONFIG: Record<AOITheme, { icon: string; color: string; label: string }> = {
topographie: { icon: '🏔️', color: 'bg-amber-500', label: 'Topographie' },
landnutzung: { icon: '🏘️', color: 'bg-green-500', label: 'Landnutzung' },
orientierung: { icon: '🧭', color: 'bg-blue-500', label: 'Orientierung' },
geologie: { icon: '🪨', color: 'bg-stone-500', label: 'Geologie' },
hydrologie: { icon: '💧', color: 'bg-cyan-500', label: 'Hydrologie' },
vegetation: { icon: '🌲', color: 'bg-emerald-500', label: 'Vegetation' },
}
export default function GeoLernweltPage() {
// State
const [serviceHealth, setServiceHealth] = useState<GeoServiceHealth | null>(null)
const [currentAOI, setCurrentAOI] = useState<AOIResponse | null>(null)
const [drawnPolygon, setDrawnPolygon] = useState<GeoJSONPolygon | null>(null)
const [selectedTheme, setSelectedTheme] = useState<AOITheme>('topographie')
const [quality, setQuality] = useState<AOIQuality>('medium')
const [difficulty, setDifficulty] = useState<Difficulty>('mittel')
const [learningNodes, setLearningNodes] = useState<LearningNode[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'map' | 'unity'>('map')
const [demoTemplate, setDemoTemplate] = useState<DemoTemplate | null>(null)
// Check service health on mount
useEffect(() => {
checkServiceHealth()
loadMainauTemplate()
}, [])
const checkServiceHealth = async () => {
try {
const res = await fetch(`${GEO_SERVICE_URL}/health`)
if (res.ok) {
const health = await res.json()
setServiceHealth(health)
}
} catch (e) {
console.error('Service health check failed:', e)
setServiceHealth(null)
}
}
const loadMainauTemplate = async () => {
try {
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi/templates/mainau`)
if (res.ok) {
const template = await res.json()
setDemoTemplate(template)
}
} catch (e) {
console.error('Failed to load Mainau template:', e)
}
}
const handlePolygonDrawn = useCallback((polygon: GeoJSONPolygon) => {
setDrawnPolygon(polygon)
setError(null)
}, [])
const handleCreateAOI = async () => {
if (!drawnPolygon) {
setError('Bitte zeichne zuerst ein Gebiet auf der Karte.')
return
}
setIsLoading(true)
setError(null)
try {
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
polygon: drawnPolygon,
theme: selectedTheme,
quality,
}),
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.detail || 'Fehler beim Erstellen des Gebiets')
}
const aoi = await res.json()
setCurrentAOI(aoi)
// Poll for completion
pollAOIStatus(aoi.aoi_id)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const pollAOIStatus = async (aoiId: string) => {
const poll = async () => {
try {
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi/${aoiId}`)
if (res.ok) {
const aoi = await res.json()
setCurrentAOI(aoi)
if (aoi.status === 'completed') {
// Load learning nodes
generateLearningNodes(aoiId)
} else if (aoi.status === 'failed') {
setError('Verarbeitung fehlgeschlagen')
} else {
// Continue polling
setTimeout(poll, 2000)
}
}
} catch (e) {
console.error('Polling error:', e)
}
}
poll()
}
const generateLearningNodes = async (aoiId: string) => {
try {
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/learning/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aoi_id: aoiId,
theme: selectedTheme,
difficulty,
node_count: 5,
language: 'de',
}),
})
if (res.ok) {
const data = await res.json()
setLearningNodes(data.nodes)
}
} catch (e) {
console.error('Failed to generate learning nodes:', e)
}
}
const handleLoadDemo = () => {
if (demoTemplate) {
setDrawnPolygon(demoTemplate.polygon)
setSelectedTheme(demoTemplate.suggested_themes[0] || 'topographie')
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900">
{/* Header */}
<header className="border-b border-white/10 bg-black/20 backdrop-blur-lg">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">🌍</span>
<div>
<h1 className="text-xl font-semibold text-white">Geo-Lernwelt</h1>
<p className="text-sm text-white/60">Interaktive Erdkunde-Lernplattform</p>
</div>
</div>
{/* Service Status */}
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
serviceHealth?.status === 'healthy'
? 'bg-green-500'
: 'bg-yellow-500 animate-pulse'
}`}
/>
<span className="text-sm text-white/60">
{serviceHealth?.status === 'healthy' ? 'Verbunden' : 'Verbinde...'}
</span>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-6">
{/* Tab Navigation */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('map')}
className={`px-4 py-2 rounded-lg font-medium transition-all ${
activeTab === 'map'
? 'bg-white/10 text-white'
: 'text-white/60 hover:text-white hover:bg-white/5'
}`}
>
🗺 Gebiet waehlen
</button>
<button
onClick={() => setActiveTab('unity')}
disabled={!currentAOI || currentAOI.status !== 'completed'}
className={`px-4 py-2 rounded-lg font-medium transition-all ${
activeTab === 'unity'
? 'bg-white/10 text-white'
: 'text-white/60 hover:text-white hover:bg-white/5'
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
🎮 3D-Lernwelt
</button>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200">
{error}
</div>
)}
{activeTab === 'map' ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Map Section (2/3) */}
<div className="lg:col-span-2 space-y-4">
{/* Map Card */}
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 overflow-hidden">
<div className="p-4 border-b border-white/10">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-white">Gebiet auf der Karte waehlen</h2>
{demoTemplate && (
<button
onClick={handleLoadDemo}
className="px-3 py-1.5 text-sm bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 rounded-lg transition-colors"
>
📍 Demo: Insel Mainau
</button>
)}
</div>
<p className="text-sm text-white/60 mt-1">
Zeichne ein Polygon (max. 4 km²) um das gewuenschte Lerngebiet
</p>
</div>
<div className="h-[500px]">
<AOISelector
onPolygonDrawn={handlePolygonDrawn}
initialPolygon={drawnPolygon}
maxAreaKm2={4}
geoServiceUrl={GEO_SERVICE_URL}
/>
</div>
</div>
{/* Attribution */}
<div className="text-xs text-white/40 text-center">
Kartendaten: © OpenStreetMap contributors (ODbL) | Hoehenmodell: © Copernicus DEM
</div>
</div>
{/* Settings Panel (1/3) */}
<div className="space-y-4">
{/* Theme Selection */}
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-3">Lernthema</h3>
<div className="grid grid-cols-2 gap-2">
{(Object.keys(THEME_CONFIG) as AOITheme[]).map((theme) => {
const config = THEME_CONFIG[theme]
return (
<button
key={theme}
onClick={() => setSelectedTheme(theme)}
className={`p-3 rounded-xl text-left transition-all ${
selectedTheme === theme
? 'bg-white/15 border border-white/30'
: 'bg-white/5 border border-transparent hover:bg-white/10'
}`}
>
<span className="text-2xl">{config.icon}</span>
<div className="text-sm text-white mt-1">{config.label}</div>
</button>
)
})}
</div>
</div>
{/* Quality Selection */}
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-3">Qualitaet</h3>
<div className="flex gap-2">
{(['low', 'medium', 'high'] as AOIQuality[]).map((q) => (
<button
key={q}
onClick={() => setQuality(q)}
className={`flex-1 py-2 rounded-lg text-sm transition-all ${
quality === q
? 'bg-white/15 text-white border border-white/30'
: 'bg-white/5 text-white/60 hover:bg-white/10'
}`}
>
{q === 'low' ? 'Schnell' : q === 'medium' ? 'Standard' : 'Hoch'}
</button>
))}
</div>
</div>
{/* Difficulty Selection */}
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-3">Schwierigkeitsgrad</h3>
<div className="flex gap-2">
{(['leicht', 'mittel', 'schwer'] as Difficulty[]).map((d) => (
<button
key={d}
onClick={() => setDifficulty(d)}
className={`flex-1 py-2 rounded-lg text-sm capitalize transition-all ${
difficulty === d
? 'bg-white/15 text-white border border-white/30'
: 'bg-white/5 text-white/60 hover:bg-white/10'
}`}
>
{d}
</button>
))}
</div>
</div>
{/* Area Info */}
{drawnPolygon && (
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-2">Ausgewaehltes Gebiet</h3>
<div className="text-sm text-white/60">
<p>Polygon gezeichnet </p>
<p className="text-white/40 text-xs mt-1">
Klicke &quot;Lernwelt erstellen&quot; um fortzufahren
</p>
</div>
</div>
)}
{/* Create Button */}
<button
onClick={handleCreateAOI}
disabled={!drawnPolygon || isLoading}
className={`w-full py-4 rounded-xl font-medium text-lg transition-all ${
drawnPolygon && !isLoading
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600'
: 'bg-white/10 text-white/40 cursor-not-allowed'
}`}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Wird erstellt...
</span>
) : (
'🚀 Lernwelt erstellen'
)}
</button>
{/* AOI Status */}
{currentAOI && (
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-2">Status</h3>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
currentAOI.status === 'completed'
? 'bg-green-500'
: currentAOI.status === 'failed'
? 'bg-red-500'
: 'bg-yellow-500 animate-pulse'
}`}
/>
<span className="text-sm text-white/80 capitalize">
{currentAOI.status === 'queued'
? 'In Warteschlange...'
: currentAOI.status === 'processing'
? 'Wird verarbeitet...'
: currentAOI.status === 'completed'
? 'Fertig!'
: 'Fehlgeschlagen'}
</span>
</div>
{currentAOI.area_km2 > 0 && (
<p className="text-xs text-white/50">
Flaeche: {currentAOI.area_km2.toFixed(2)} km²
</p>
)}
</div>
</div>
)}
</div>
</div>
) : (
/* Unity 3D Viewer Tab */
<div className="space-y-4">
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 overflow-hidden">
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<div>
<h2 className="text-lg font-medium text-white">3D-Lernwelt</h2>
<p className="text-sm text-white/60">
Erkunde das Gebiet und bearbeite die Lernstationen
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-white/60">
{learningNodes.length} Lernstationen
</span>
</div>
</div>
<div className="h-[600px]">
{currentAOI && currentAOI.status === 'completed' ? (
<UnityViewer
aoiId={currentAOI.aoi_id}
manifestUrl={currentAOI.manifest_url}
learningNodes={learningNodes}
geoServiceUrl={GEO_SERVICE_URL}
/>
) : (
<div className="h-full flex items-center justify-center text-white/60">
Erstelle zuerst ein Lerngebiet im Tab &quot;Gebiet waehlen&quot;
</div>
)}
</div>
</div>
{/* Learning Nodes List */}
{learningNodes.length > 0 && (
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-3">Lernstationen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{learningNodes.map((node, idx) => (
<div
key={node.id}
className="p-3 bg-white/5 rounded-xl border border-white/10"
>
<div className="flex items-center gap-2 mb-2">
<span className="w-6 h-6 bg-blue-500/30 rounded-full flex items-center justify-center text-xs text-white">
{idx + 1}
</span>
<span className="text-white font-medium text-sm">{node.title}</span>
</div>
<p className="text-xs text-white/60 line-clamp-2">{node.question}</p>
<div className="mt-2 flex items-center gap-2">
<span className="text-xs bg-white/10 px-2 py-0.5 rounded text-white/60">
{node.points} Punkte
</span>
{node.approved && (
<span className="text-xs text-green-400"> Freigegeben</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</main>
</div>
)
}