fix: Restore all files lost during destructive rebase
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>
This commit is contained in:
438
studio-v2/components/geo-lernwelt/AOISelector.tsx
Normal file
438
studio-v2/components/geo-lernwelt/AOISelector.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
329
studio-v2/components/geo-lernwelt/UnityViewer.tsx
Normal file
329
studio-v2/components/geo-lernwelt/UnityViewer.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { LearningNode } from '@/app/geo-lernwelt/types'
|
||||
|
||||
interface UnityViewerProps {
|
||||
aoiId: string
|
||||
manifestUrl?: string
|
||||
learningNodes: LearningNode[]
|
||||
geoServiceUrl: string
|
||||
}
|
||||
|
||||
interface UnityInstance {
|
||||
SendMessage: (objectName: string, methodName: string, value?: string | number) => void
|
||||
}
|
||||
|
||||
export default function UnityViewer({
|
||||
aoiId,
|
||||
manifestUrl,
|
||||
learningNodes,
|
||||
geoServiceUrl,
|
||||
}: UnityViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [unityInstance, setUnityInstance] = useState<UnityInstance | null>(null)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<LearningNode | null>(null)
|
||||
const [showNodePanel, setShowNodePanel] = useState(false)
|
||||
|
||||
// Placeholder mode when Unity build is not available
|
||||
const [placeholderMode, setPlaceholderMode] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Unity build exists
|
||||
// For now, always use placeholder mode since Unity build needs to be created separately
|
||||
setPlaceholderMode(true)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
// Unity message handler (for when Unity build exists)
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).handleUnityMessage = (message: string) => {
|
||||
try {
|
||||
const data = JSON.parse(message)
|
||||
|
||||
switch (data.type) {
|
||||
case 'nodeSelected':
|
||||
const node = learningNodes.find((n) => n.id === data.nodeId)
|
||||
if (node) {
|
||||
setSelectedNode(node)
|
||||
setShowNodePanel(true)
|
||||
}
|
||||
break
|
||||
|
||||
case 'terrainLoaded':
|
||||
console.log('Unity terrain loaded')
|
||||
break
|
||||
|
||||
case 'error':
|
||||
setError(data.message)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing Unity message:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (window as any).handleUnityMessage
|
||||
}
|
||||
}
|
||||
}, [learningNodes])
|
||||
|
||||
const sendToUnity = useCallback(
|
||||
(objectName: string, methodName: string, value?: string | number) => {
|
||||
if (unityInstance) {
|
||||
unityInstance.SendMessage(objectName, methodName, value)
|
||||
}
|
||||
},
|
||||
[unityInstance]
|
||||
)
|
||||
|
||||
// Send AOI data to Unity when ready
|
||||
useEffect(() => {
|
||||
if (unityInstance && manifestUrl) {
|
||||
sendToUnity('TerrainManager', 'LoadManifest', manifestUrl)
|
||||
|
||||
// Send learning nodes
|
||||
const nodesJson = JSON.stringify(learningNodes)
|
||||
sendToUnity('LearningNodeManager', 'LoadNodes', nodesJson)
|
||||
}
|
||||
}, [unityInstance, manifestUrl, learningNodes, sendToUnity])
|
||||
|
||||
const handleNodeClick = (node: LearningNode) => {
|
||||
setSelectedNode(node)
|
||||
setShowNodePanel(true)
|
||||
|
||||
if (unityInstance) {
|
||||
sendToUnity('CameraController', 'FocusOnNode', node.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseNodePanel = () => {
|
||||
setShowNodePanel(false)
|
||||
setSelectedNode(null)
|
||||
}
|
||||
|
||||
// Placeholder 3D view (when Unity is not available)
|
||||
const PlaceholderView = () => (
|
||||
<div className="w-full h-full bg-gradient-to-b from-sky-400 to-sky-200 relative overflow-hidden">
|
||||
{/* Sky */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-sky-500 via-sky-400 to-sky-300" />
|
||||
|
||||
{/* Sun */}
|
||||
<div className="absolute top-8 right-12 w-16 h-16 bg-yellow-300 rounded-full shadow-lg shadow-yellow-400/50" />
|
||||
|
||||
{/* Clouds */}
|
||||
<div className="absolute top-12 left-8 w-24 h-8 bg-white/80 rounded-full blur-sm" />
|
||||
<div className="absolute top-20 left-20 w-16 h-6 bg-white/70 rounded-full blur-sm" />
|
||||
<div className="absolute top-16 right-32 w-20 h-7 bg-white/75 rounded-full blur-sm" />
|
||||
|
||||
{/* Mountains */}
|
||||
<svg
|
||||
className="absolute bottom-0 left-0 w-full h-1/2"
|
||||
viewBox="0 0 1200 400"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* Background mountains */}
|
||||
<path d="M0,400 L200,150 L400,300 L600,100 L800,250 L1000,80 L1200,200 L1200,400 Z" fill="#4ade80" fillOpacity="0.5" />
|
||||
{/* Foreground mountains */}
|
||||
<path d="M0,400 L150,200 L300,350 L500,150 L700,300 L900,120 L1100,280 L1200,180 L1200,400 Z" fill="#22c55e" />
|
||||
{/* Ground */}
|
||||
<path d="M0,400 L0,350 Q300,320 600,350 Q900,380 1200,340 L1200,400 Z" fill="#15803d" />
|
||||
</svg>
|
||||
|
||||
{/* Learning Nodes as Markers */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative w-full h-full">
|
||||
{learningNodes.map((node, idx) => {
|
||||
// Position nodes across the view
|
||||
const positions = [
|
||||
{ left: '20%', top: '55%' },
|
||||
{ left: '40%', top: '45%' },
|
||||
{ left: '60%', top: '50%' },
|
||||
{ left: '75%', top: '55%' },
|
||||
{ left: '30%', top: '60%' },
|
||||
]
|
||||
const pos = positions[idx % positions.length]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Marker pin */}
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold shadow-lg group-hover:bg-blue-600 transition-colors animate-bounce">
|
||||
{idx + 1}
|
||||
</div>
|
||||
{/* Pulse effect */}
|
||||
<div className="absolute inset-0 w-8 h-8 bg-blue-500 rounded-full animate-ping opacity-25" />
|
||||
{/* Label */}
|
||||
<div className="absolute top-10 left-1/2 transform -translate-x-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{node.title}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3D View Notice */}
|
||||
<div className="absolute bottom-4 left-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
|
||||
<p className="font-medium">Vorschau-Modus</p>
|
||||
<p className="text-white/70 text-xs">
|
||||
Unity WebGL-Build wird fuer die volle 3D-Ansicht benoetigt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls hint */}
|
||||
<div className="absolute bottom-4 right-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
|
||||
<p className="text-white/70">Klicke auf die Marker um Lernstationen anzuzeigen</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-slate-900">
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-slate-900 flex flex-col items-center justify-center z-10">
|
||||
<div className="w-64 mb-4">
|
||||
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${loadingProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/60 text-sm">Lade 3D-Lernwelt... {loadingProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 bg-slate-900 flex items-center justify-center z-10">
|
||||
<div className="text-center p-8">
|
||||
<div className="text-red-400 text-6xl mb-4">⚠️</div>
|
||||
<p className="text-white text-lg mb-2">Fehler beim Laden</p>
|
||||
<p className="text-white/60 text-sm max-w-md">{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
setLoadingProgress(0)
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unity Canvas or Placeholder */}
|
||||
{placeholderMode ? (
|
||||
<PlaceholderView />
|
||||
) : (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
id="unity-canvas"
|
||||
className="w-full h-full"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Learning Node Panel */}
|
||||
{showNodePanel && selectedNode && (
|
||||
<div className="absolute top-4 right-4 w-80 bg-white/95 dark:bg-slate-800/95 backdrop-blur-lg rounded-2xl shadow-2xl overflow-hidden z-20">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-purple-500 p-4 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm opacity-80">Station {learningNodes.indexOf(selectedNode) + 1}</span>
|
||||
<button
|
||||
onClick={handleCloseNodePanel}
|
||||
className="w-6 h-6 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mt-1">{selectedNode.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Question */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">Aufgabe</h4>
|
||||
<p className="text-slate-800 dark:text-white">{selectedNode.question}</p>
|
||||
</div>
|
||||
|
||||
{/* Hints */}
|
||||
{selectedNode.hints.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">Hinweise</h4>
|
||||
<ul className="list-disc list-inside text-sm text-slate-600 dark:text-slate-300 space-y-1">
|
||||
{selectedNode.hints.map((hint, idx) => (
|
||||
<li key={idx}>{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer (collapsible) */}
|
||||
<details className="bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<summary className="p-3 cursor-pointer text-green-700 dark:text-green-400 font-medium text-sm">
|
||||
Loesung anzeigen
|
||||
</summary>
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-green-800 dark:text-green-300 text-sm">{selectedNode.answer}</p>
|
||||
{selectedNode.explanation && (
|
||||
<p className="text-green-600 dark:text-green-400 text-xs mt-2 italic">
|
||||
{selectedNode.explanation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Points */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-200 dark:border-slate-700">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">Punkte</span>
|
||||
<span className="font-bold text-blue-500">{selectedNode.points}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node List (minimized) */}
|
||||
{!showNodePanel && learningNodes.length > 0 && (
|
||||
<div className="absolute top-4 right-4 bg-black/70 rounded-xl p-3 z-20 max-h-64 overflow-y-auto">
|
||||
<h4 className="text-white text-sm font-medium mb-2">Lernstationen</h4>
|
||||
<div className="space-y-1">
|
||||
{learningNodes.map((node, idx) => (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
className="w-full text-left px-3 py-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white text-sm flex items-center gap-2"
|
||||
>
|
||||
<span className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center text-xs">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="truncate">{node.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
studio-v2/components/geo-lernwelt/index.ts
Normal file
7
studio-v2/components/geo-lernwelt/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* GeoEdu Components
|
||||
* Exports all geo-lernwelt components
|
||||
*/
|
||||
|
||||
export { default as AOISelector } from './AOISelector'
|
||||
export { default as UnityViewer } from './UnityViewer'
|
||||
Reference in New Issue
Block a user