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:
276
studio-v2/lib/geo-lernwelt/GeoContext.tsx
Normal file
276
studio-v2/lib/geo-lernwelt/GeoContext.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
AOIResponse,
|
||||
AOITheme,
|
||||
AOIQuality,
|
||||
Difficulty,
|
||||
GeoJSONPolygon,
|
||||
LearningNode,
|
||||
GeoLernweltState,
|
||||
} from '@/app/geo-lernwelt/types'
|
||||
|
||||
// Initial state
|
||||
const initialState: GeoLernweltState = {
|
||||
currentAOI: null,
|
||||
drawnPolygon: null,
|
||||
selectedTheme: 'topographie',
|
||||
quality: 'medium',
|
||||
difficulty: 'mittel',
|
||||
learningNodes: [],
|
||||
selectedNode: null,
|
||||
isDrawing: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
unityReady: false,
|
||||
unityProgress: 0,
|
||||
}
|
||||
|
||||
// Action types
|
||||
type GeoAction =
|
||||
| { type: 'SET_AOI'; payload: AOIResponse | null }
|
||||
| { type: 'SET_POLYGON'; payload: GeoJSONPolygon | null }
|
||||
| { type: 'SET_THEME'; payload: AOITheme }
|
||||
| { type: 'SET_QUALITY'; payload: AOIQuality }
|
||||
| { type: 'SET_DIFFICULTY'; payload: Difficulty }
|
||||
| { type: 'SET_LEARNING_NODES'; payload: LearningNode[] }
|
||||
| { type: 'ADD_LEARNING_NODE'; payload: LearningNode }
|
||||
| { type: 'UPDATE_LEARNING_NODE'; payload: LearningNode }
|
||||
| { type: 'REMOVE_LEARNING_NODE'; payload: string }
|
||||
| { type: 'SELECT_NODE'; payload: LearningNode | null }
|
||||
| { type: 'SET_DRAWING'; payload: boolean }
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_UNITY_READY'; payload: boolean }
|
||||
| { type: 'SET_UNITY_PROGRESS'; payload: number }
|
||||
| { type: 'RESET' }
|
||||
|
||||
// Reducer
|
||||
function geoReducer(state: GeoLernweltState, action: GeoAction): GeoLernweltState {
|
||||
switch (action.type) {
|
||||
case 'SET_AOI':
|
||||
return { ...state, currentAOI: action.payload }
|
||||
|
||||
case 'SET_POLYGON':
|
||||
return { ...state, drawnPolygon: action.payload }
|
||||
|
||||
case 'SET_THEME':
|
||||
return { ...state, selectedTheme: action.payload }
|
||||
|
||||
case 'SET_QUALITY':
|
||||
return { ...state, quality: action.payload }
|
||||
|
||||
case 'SET_DIFFICULTY':
|
||||
return { ...state, difficulty: action.payload }
|
||||
|
||||
case 'SET_LEARNING_NODES':
|
||||
return { ...state, learningNodes: action.payload }
|
||||
|
||||
case 'ADD_LEARNING_NODE':
|
||||
return {
|
||||
...state,
|
||||
learningNodes: [...state.learningNodes, action.payload],
|
||||
}
|
||||
|
||||
case 'UPDATE_LEARNING_NODE':
|
||||
return {
|
||||
...state,
|
||||
learningNodes: state.learningNodes.map((node) =>
|
||||
node.id === action.payload.id ? action.payload : node
|
||||
),
|
||||
}
|
||||
|
||||
case 'REMOVE_LEARNING_NODE':
|
||||
return {
|
||||
...state,
|
||||
learningNodes: state.learningNodes.filter(
|
||||
(node) => node.id !== action.payload
|
||||
),
|
||||
selectedNode:
|
||||
state.selectedNode?.id === action.payload ? null : state.selectedNode,
|
||||
}
|
||||
|
||||
case 'SELECT_NODE':
|
||||
return { ...state, selectedNode: action.payload }
|
||||
|
||||
case 'SET_DRAWING':
|
||||
return { ...state, isDrawing: action.payload }
|
||||
|
||||
case 'SET_LOADING':
|
||||
return { ...state, isLoading: action.payload }
|
||||
|
||||
case 'SET_ERROR':
|
||||
return { ...state, error: action.payload }
|
||||
|
||||
case 'SET_UNITY_READY':
|
||||
return { ...state, unityReady: action.payload }
|
||||
|
||||
case 'SET_UNITY_PROGRESS':
|
||||
return { ...state, unityProgress: action.payload }
|
||||
|
||||
case 'RESET':
|
||||
return initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Context types
|
||||
interface GeoContextValue {
|
||||
state: GeoLernweltState
|
||||
dispatch: React.Dispatch<GeoAction>
|
||||
// Convenience actions
|
||||
setAOI: (aoi: AOIResponse | null) => void
|
||||
setPolygon: (polygon: GeoJSONPolygon | null) => void
|
||||
setTheme: (theme: AOITheme) => void
|
||||
setQuality: (quality: AOIQuality) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
setLearningNodes: (nodes: LearningNode[]) => void
|
||||
addNode: (node: LearningNode) => void
|
||||
updateNode: (node: LearningNode) => void
|
||||
removeNode: (nodeId: string) => void
|
||||
selectNode: (node: LearningNode | null) => void
|
||||
setDrawing: (drawing: boolean) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
setUnityReady: (ready: boolean) => void
|
||||
setUnityProgress: (progress: number) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
// Create context
|
||||
const GeoContext = createContext<GeoContextValue | null>(null)
|
||||
|
||||
// Provider component
|
||||
export function GeoProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(geoReducer, initialState)
|
||||
|
||||
// Convenience action creators
|
||||
const setAOI = useCallback(
|
||||
(aoi: AOIResponse | null) => dispatch({ type: 'SET_AOI', payload: aoi }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setPolygon = useCallback(
|
||||
(polygon: GeoJSONPolygon | null) =>
|
||||
dispatch({ type: 'SET_POLYGON', payload: polygon }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setTheme = useCallback(
|
||||
(theme: AOITheme) => dispatch({ type: 'SET_THEME', payload: theme }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setQuality = useCallback(
|
||||
(quality: AOIQuality) => dispatch({ type: 'SET_QUALITY', payload: quality }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setDifficulty = useCallback(
|
||||
(difficulty: Difficulty) =>
|
||||
dispatch({ type: 'SET_DIFFICULTY', payload: difficulty }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setLearningNodes = useCallback(
|
||||
(nodes: LearningNode[]) =>
|
||||
dispatch({ type: 'SET_LEARNING_NODES', payload: nodes }),
|
||||
[]
|
||||
)
|
||||
|
||||
const addNode = useCallback(
|
||||
(node: LearningNode) => dispatch({ type: 'ADD_LEARNING_NODE', payload: node }),
|
||||
[]
|
||||
)
|
||||
|
||||
const updateNode = useCallback(
|
||||
(node: LearningNode) =>
|
||||
dispatch({ type: 'UPDATE_LEARNING_NODE', payload: node }),
|
||||
[]
|
||||
)
|
||||
|
||||
const removeNode = useCallback(
|
||||
(nodeId: string) =>
|
||||
dispatch({ type: 'REMOVE_LEARNING_NODE', payload: nodeId }),
|
||||
[]
|
||||
)
|
||||
|
||||
const selectNode = useCallback(
|
||||
(node: LearningNode | null) => dispatch({ type: 'SELECT_NODE', payload: node }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setDrawing = useCallback(
|
||||
(drawing: boolean) => dispatch({ type: 'SET_DRAWING', payload: drawing }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setLoading = useCallback(
|
||||
(loading: boolean) => dispatch({ type: 'SET_LOADING', payload: loading }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setError = useCallback(
|
||||
(error: string | null) => dispatch({ type: 'SET_ERROR', payload: error }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setUnityReady = useCallback(
|
||||
(ready: boolean) => dispatch({ type: 'SET_UNITY_READY', payload: ready }),
|
||||
[]
|
||||
)
|
||||
|
||||
const setUnityProgress = useCallback(
|
||||
(progress: number) => dispatch({ type: 'SET_UNITY_PROGRESS', payload: progress }),
|
||||
[]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => dispatch({ type: 'RESET' }), [])
|
||||
|
||||
const value: GeoContextValue = {
|
||||
state,
|
||||
dispatch,
|
||||
setAOI,
|
||||
setPolygon,
|
||||
setTheme,
|
||||
setQuality,
|
||||
setDifficulty,
|
||||
setLearningNodes,
|
||||
addNode,
|
||||
updateNode,
|
||||
removeNode,
|
||||
selectNode,
|
||||
setDrawing,
|
||||
setLoading,
|
||||
setError,
|
||||
setUnityReady,
|
||||
setUnityProgress,
|
||||
reset,
|
||||
}
|
||||
|
||||
return <GeoContext.Provider value={value}>{children}</GeoContext.Provider>
|
||||
}
|
||||
|
||||
// Hook to use context
|
||||
export function useGeo() {
|
||||
const context = useContext(GeoContext)
|
||||
if (!context) {
|
||||
throw new Error('useGeo must be used within a GeoProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Hook for just the state (read-only)
|
||||
export function useGeoState() {
|
||||
const { state } = useGeo()
|
||||
return state
|
||||
}
|
||||
29
studio-v2/lib/geo-lernwelt/index.ts
Normal file
29
studio-v2/lib/geo-lernwelt/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* GeoEdu Service - Library Exports
|
||||
*/
|
||||
|
||||
// Context
|
||||
export { GeoProvider, useGeo, useGeoState } from './GeoContext'
|
||||
|
||||
// Map Styles
|
||||
export {
|
||||
createMapStyle,
|
||||
createFallbackStyle,
|
||||
createDarkStyle,
|
||||
createTerrainStyle,
|
||||
GERMANY_BOUNDS,
|
||||
GERMANY_CENTER,
|
||||
MAINAU_CENTER,
|
||||
MAINAU_BOUNDS,
|
||||
} from './mapStyles'
|
||||
|
||||
// Unity Bridge
|
||||
export {
|
||||
UnityBridge,
|
||||
getUnityBridge,
|
||||
createLoaderConfig,
|
||||
type UnityInstance,
|
||||
type UnityMessage,
|
||||
type UnityMessageHandler,
|
||||
type UnityLoaderConfig,
|
||||
} from './unityBridge'
|
||||
369
studio-v2/lib/geo-lernwelt/mapStyles.ts
Normal file
369
studio-v2/lib/geo-lernwelt/mapStyles.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* MapLibre Style Configurations for GeoEdu Service
|
||||
* Styles for displaying OSM data with terrain
|
||||
*/
|
||||
|
||||
import { MapStyle, MapLayer } from '@/app/geo-lernwelt/types'
|
||||
|
||||
// Germany bounds
|
||||
export const GERMANY_BOUNDS: [[number, number], [number, number]] = [
|
||||
[5.87, 47.27],
|
||||
[15.04, 55.06],
|
||||
]
|
||||
|
||||
// Default center (Germany)
|
||||
export const GERMANY_CENTER: [number, number] = [10.45, 51.16]
|
||||
|
||||
// Mainau island (demo location)
|
||||
export const MAINAU_CENTER: [number, number] = [9.1925, 47.7085]
|
||||
export const MAINAU_BOUNDS: [[number, number], [number, number]] = [
|
||||
[9.185, 47.705],
|
||||
[9.200, 47.712],
|
||||
]
|
||||
|
||||
/**
|
||||
* Create a MapLibre style for the self-hosted tile server
|
||||
*/
|
||||
export function createMapStyle(geoServiceUrl: string): MapStyle {
|
||||
return {
|
||||
version: 8,
|
||||
name: 'GeoEdu Germany',
|
||||
metadata: {
|
||||
description: 'Self-hosted OSM tiles for DSGVO-compliant education',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'vector',
|
||||
tiles: [`${geoServiceUrl}/api/v1/tiles/{z}/{x}/{y}.pbf`],
|
||||
minzoom: 0,
|
||||
maxzoom: 14,
|
||||
attribution: '© OpenStreetMap contributors (ODbL)',
|
||||
},
|
||||
terrain: {
|
||||
type: 'raster-dem',
|
||||
tiles: [`${geoServiceUrl}/api/v1/terrain/{z}/{x}/{y}.png`],
|
||||
tileSize: 256,
|
||||
attribution: '© Copernicus DEM GLO-30',
|
||||
},
|
||||
hillshade: {
|
||||
type: 'raster',
|
||||
tiles: [`${geoServiceUrl}/api/v1/terrain/hillshade/{z}/{x}/{y}.png`],
|
||||
tileSize: 256,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
// Background
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: { 'background-color': '#f8f4f0' },
|
||||
},
|
||||
// Hillshade
|
||||
{
|
||||
id: 'hillshade',
|
||||
type: 'raster',
|
||||
source: 'hillshade',
|
||||
paint: { 'raster-opacity': 0.3 },
|
||||
},
|
||||
// Water areas
|
||||
{
|
||||
id: 'water',
|
||||
type: 'fill',
|
||||
source: 'osm',
|
||||
'source-layer': 'water',
|
||||
paint: { 'fill-color': '#a0c8f0' },
|
||||
},
|
||||
// Parks
|
||||
{
|
||||
id: 'landuse-park',
|
||||
type: 'fill',
|
||||
source: 'osm',
|
||||
'source-layer': 'landuse',
|
||||
filter: ['==', 'class', 'park'],
|
||||
paint: { 'fill-color': '#c8e6c8', 'fill-opacity': 0.5 },
|
||||
},
|
||||
// Forest
|
||||
{
|
||||
id: 'landuse-forest',
|
||||
type: 'fill',
|
||||
source: 'osm',
|
||||
'source-layer': 'landuse',
|
||||
filter: ['==', 'class', 'wood'],
|
||||
paint: { 'fill-color': '#94d294', 'fill-opacity': 0.5 },
|
||||
},
|
||||
// Buildings
|
||||
{
|
||||
id: 'building',
|
||||
type: 'fill',
|
||||
source: 'osm',
|
||||
'source-layer': 'building',
|
||||
minzoom: 13,
|
||||
paint: {
|
||||
'fill-color': '#d9d0c9',
|
||||
'fill-opacity': 0.8,
|
||||
},
|
||||
},
|
||||
// Building outlines
|
||||
{
|
||||
id: 'building-outline',
|
||||
type: 'line',
|
||||
source: 'osm',
|
||||
'source-layer': 'building',
|
||||
minzoom: 13,
|
||||
paint: {
|
||||
'line-color': '#b8a89a',
|
||||
'line-width': 1,
|
||||
},
|
||||
},
|
||||
// Minor roads
|
||||
{
|
||||
id: 'road-minor',
|
||||
type: 'line',
|
||||
source: 'osm',
|
||||
'source-layer': 'transportation',
|
||||
filter: ['all', ['==', '$type', 'LineString'], ['in', 'class', 'minor', 'service']],
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 10, 0.5, 14, 2],
|
||||
},
|
||||
},
|
||||
// Secondary roads
|
||||
{
|
||||
id: 'road-secondary',
|
||||
type: 'line',
|
||||
source: 'osm',
|
||||
'source-layer': 'transportation',
|
||||
filter: ['all', ['==', '$type', 'LineString'], ['in', 'class', 'secondary', 'tertiary']],
|
||||
paint: {
|
||||
'line-color': '#ffc107',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1, 14, 4],
|
||||
},
|
||||
},
|
||||
// Primary roads
|
||||
{
|
||||
id: 'road-primary',
|
||||
type: 'line',
|
||||
source: 'osm',
|
||||
'source-layer': 'transportation',
|
||||
filter: ['all', ['==', '$type', 'LineString'], ['==', 'class', 'primary']],
|
||||
paint: {
|
||||
'line-color': '#ff9800',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 6, 1, 14, 6],
|
||||
},
|
||||
},
|
||||
// Highways
|
||||
{
|
||||
id: 'road-highway',
|
||||
type: 'line',
|
||||
source: 'osm',
|
||||
'source-layer': 'transportation',
|
||||
filter: ['all', ['==', '$type', 'LineString'], ['==', 'class', 'motorway']],
|
||||
paint: {
|
||||
'line-color': '#ff6f00',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1, 14, 8],
|
||||
},
|
||||
},
|
||||
// Railways
|
||||
{
|
||||
id: 'railway',
|
||||
type: 'line',
|
||||
source: 'osm',
|
||||
'source-layer': 'transportation',
|
||||
filter: ['==', 'class', 'rail'],
|
||||
paint: {
|
||||
'line-color': '#555555',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [3, 3],
|
||||
},
|
||||
},
|
||||
// Water lines (rivers, streams)
|
||||
{
|
||||
id: 'waterway',
|
||||
type: 'line',
|
||||
source: 'osm',
|
||||
'source-layer': 'waterway',
|
||||
paint: {
|
||||
'line-color': '#a0c8f0',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 14, 3],
|
||||
},
|
||||
},
|
||||
// Place labels
|
||||
{
|
||||
id: 'place-label-city',
|
||||
type: 'symbol',
|
||||
source: 'osm',
|
||||
'source-layer': 'place',
|
||||
filter: ['==', 'class', 'city'],
|
||||
layout: {
|
||||
'text-field': '{name}',
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-size': 16,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#333333',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'place-label-town',
|
||||
type: 'symbol',
|
||||
source: 'osm',
|
||||
'source-layer': 'place',
|
||||
filter: ['==', 'class', 'town'],
|
||||
minzoom: 8,
|
||||
layout: {
|
||||
'text-field': '{name}',
|
||||
'text-font': ['Open Sans Semibold'],
|
||||
'text-size': 14,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#444444',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 1.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'place-label-village',
|
||||
type: 'symbol',
|
||||
source: 'osm',
|
||||
'source-layer': 'place',
|
||||
filter: ['==', 'class', 'village'],
|
||||
minzoom: 10,
|
||||
layout: {
|
||||
'text-field': '{name}',
|
||||
'text-font': ['Open Sans Regular'],
|
||||
'text-size': 12,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#555555',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
terrain: {
|
||||
source: 'terrain',
|
||||
exaggeration: 1.5,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback style using OSM raster tiles (when self-hosted tiles not available)
|
||||
*/
|
||||
export function createFallbackStyle(): MapStyle {
|
||||
return {
|
||||
version: 8,
|
||||
name: 'OSM Fallback',
|
||||
metadata: {
|
||||
description: 'Fallback style using public OSM raster tiles',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark mode variant of the map style
|
||||
*/
|
||||
export function createDarkStyle(geoServiceUrl: string): MapStyle {
|
||||
const baseStyle = createMapStyle(geoServiceUrl)
|
||||
|
||||
return {
|
||||
...baseStyle,
|
||||
name: 'GeoEdu Germany (Dark)',
|
||||
layers: baseStyle.layers.map((layer: MapLayer) => {
|
||||
// Adjust colors for dark mode
|
||||
if (layer.id === 'background') {
|
||||
return { ...layer, paint: { 'background-color': '#1a1a2e' } }
|
||||
}
|
||||
if (layer.id === 'water') {
|
||||
return { ...layer, paint: { 'fill-color': '#1e3a5f' } }
|
||||
}
|
||||
if (layer.type === 'symbol') {
|
||||
return {
|
||||
...layer,
|
||||
paint: {
|
||||
...layer.paint,
|
||||
'text-color': '#e0e0e0',
|
||||
'text-halo-color': '#1a1a2e',
|
||||
},
|
||||
}
|
||||
}
|
||||
return layer
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain-focused style (for topography theme)
|
||||
*/
|
||||
export function createTerrainStyle(geoServiceUrl: string): MapStyle {
|
||||
const baseStyle = createMapStyle(geoServiceUrl)
|
||||
|
||||
// Add contour lines source and layer
|
||||
return {
|
||||
...baseStyle,
|
||||
name: 'GeoEdu Terrain',
|
||||
sources: {
|
||||
...baseStyle.sources,
|
||||
contours: {
|
||||
type: 'vector',
|
||||
tiles: [`${geoServiceUrl}/api/v1/terrain/contours/{z}/{x}/{y}.pbf`],
|
||||
maxzoom: 14,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
...baseStyle.layers,
|
||||
{
|
||||
id: 'contour-lines',
|
||||
type: 'line',
|
||||
source: 'contours',
|
||||
'source-layer': 'contour',
|
||||
minzoom: 10,
|
||||
paint: {
|
||||
'line-color': '#8b4513',
|
||||
'line-width': ['match', ['get', 'index'], 5, 1.5, 0.5],
|
||||
'line-opacity': 0.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'contour-labels',
|
||||
type: 'symbol',
|
||||
source: 'contours',
|
||||
'source-layer': 'contour',
|
||||
minzoom: 12,
|
||||
filter: ['==', ['%', ['get', 'ele'], 50], 0],
|
||||
layout: {
|
||||
'text-field': '{ele}m',
|
||||
'text-font': ['Open Sans Regular'],
|
||||
'text-size': 10,
|
||||
'symbol-placement': 'line',
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#8b4513',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
359
studio-v2/lib/geo-lernwelt/unityBridge.ts
Normal file
359
studio-v2/lib/geo-lernwelt/unityBridge.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Unity WebGL Bridge
|
||||
* Communication layer between React and Unity WebGL
|
||||
*/
|
||||
|
||||
import { LearningNode, AOIManifest } from '@/app/geo-lernwelt/types'
|
||||
|
||||
// Message types from Unity to React
|
||||
export type UnityMessageType =
|
||||
| 'nodeSelected'
|
||||
| 'terrainLoaded'
|
||||
| 'cameraPosition'
|
||||
| 'error'
|
||||
| 'ready'
|
||||
| 'progress'
|
||||
|
||||
export interface UnityMessage {
|
||||
type: UnityMessageType
|
||||
nodeId?: string
|
||||
position?: { x: number; y: number; z: number }
|
||||
message?: string
|
||||
progress?: number
|
||||
}
|
||||
|
||||
// Message handler type
|
||||
export type UnityMessageHandler = (message: UnityMessage) => void
|
||||
|
||||
// Unity instance interface
|
||||
export interface UnityInstance {
|
||||
SendMessage: (objectName: string, methodName: string, value?: string | number) => void
|
||||
Quit: () => Promise<void>
|
||||
}
|
||||
|
||||
// Unity loader configuration
|
||||
export interface UnityLoaderConfig {
|
||||
dataUrl: string
|
||||
frameworkUrl: string
|
||||
codeUrl: string
|
||||
streamingAssetsUrl: string
|
||||
companyName: string
|
||||
productName: string
|
||||
productVersion: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Unity Bridge class for managing communication
|
||||
*/
|
||||
export class UnityBridge {
|
||||
private instance: UnityInstance | null = null
|
||||
private messageHandlers: UnityMessageHandler[] = []
|
||||
private isReady: boolean = false
|
||||
|
||||
constructor() {
|
||||
// Set up global message handler
|
||||
if (typeof window !== 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).handleUnityMessage = this.handleMessage.bind(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Unity instance (called after Unity loads)
|
||||
*/
|
||||
setInstance(instance: UnityInstance) {
|
||||
this.instance = instance
|
||||
this.isReady = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Unity is ready
|
||||
*/
|
||||
ready(): boolean {
|
||||
return this.isReady && this.instance !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a message handler
|
||||
*/
|
||||
onMessage(handler: UnityMessageHandler): () => void {
|
||||
this.messageHandlers.push(handler)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const index = this.messageHandlers.indexOf(handler)
|
||||
if (index > -1) {
|
||||
this.messageHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message from Unity
|
||||
*/
|
||||
private handleMessage(messageJson: string) {
|
||||
try {
|
||||
const message: UnityMessage = JSON.parse(messageJson)
|
||||
|
||||
// Notify all handlers
|
||||
this.messageHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler(message)
|
||||
} catch (e) {
|
||||
console.error('Error in Unity message handler:', e)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to parse Unity message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to Unity
|
||||
*/
|
||||
sendMessage(objectName: string, methodName: string, value?: string | number) {
|
||||
if (!this.instance) {
|
||||
console.warn('Unity instance not ready, message queued')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
this.instance.SendMessage(objectName, methodName, value)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Error sending message to Unity:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Terrain Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Load terrain from manifest
|
||||
*/
|
||||
loadTerrain(manifestUrl: string) {
|
||||
return this.sendMessage('TerrainManager', 'LoadManifest', manifestUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set terrain exaggeration (vertical scale)
|
||||
*/
|
||||
setTerrainExaggeration(exaggeration: number) {
|
||||
return this.sendMessage('TerrainManager', 'SetExaggeration', exaggeration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle terrain wireframe mode
|
||||
*/
|
||||
toggleWireframe(enabled: boolean) {
|
||||
return this.sendMessage('TerrainManager', 'SetWireframe', enabled ? 1 : 0)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Camera Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Focus camera on a learning node
|
||||
*/
|
||||
focusOnNode(nodeId: string) {
|
||||
return this.sendMessage('CameraController', 'FocusOnNode', nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus camera on a position
|
||||
*/
|
||||
focusOnPosition(x: number, y: number, z: number) {
|
||||
return this.sendMessage(
|
||||
'CameraController',
|
||||
'FocusOnPosition',
|
||||
JSON.stringify({ x, y, z })
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera mode
|
||||
*/
|
||||
setCameraMode(mode: 'orbit' | 'fly' | 'firstPerson') {
|
||||
return this.sendMessage('CameraController', 'SetMode', mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset camera to default view
|
||||
*/
|
||||
resetCamera() {
|
||||
return this.sendMessage('CameraController', 'Reset')
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Learning Node Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Load learning nodes
|
||||
*/
|
||||
loadNodes(nodes: LearningNode[]) {
|
||||
return this.sendMessage(
|
||||
'LearningNodeManager',
|
||||
'LoadNodes',
|
||||
JSON.stringify(nodes)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single learning node
|
||||
*/
|
||||
addNode(node: LearningNode) {
|
||||
return this.sendMessage(
|
||||
'LearningNodeManager',
|
||||
'AddNode',
|
||||
JSON.stringify(node)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a learning node
|
||||
*/
|
||||
removeNode(nodeId: string) {
|
||||
return this.sendMessage('LearningNodeManager', 'RemoveNode', nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a learning node
|
||||
*/
|
||||
updateNode(node: LearningNode) {
|
||||
return this.sendMessage(
|
||||
'LearningNodeManager',
|
||||
'UpdateNode',
|
||||
JSON.stringify(node)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a learning node
|
||||
*/
|
||||
highlightNode(nodeId: string, highlight: boolean) {
|
||||
return this.sendMessage(
|
||||
'LearningNodeManager',
|
||||
'HighlightNode',
|
||||
JSON.stringify({ nodeId, highlight })
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide all nodes
|
||||
*/
|
||||
setNodesVisible(visible: boolean) {
|
||||
return this.sendMessage('LearningNodeManager', 'SetVisible', visible ? 1 : 0)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UI Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Toggle UI visibility
|
||||
*/
|
||||
toggleUI(visible: boolean) {
|
||||
return this.sendMessage('UIManager', 'SetVisible', visible ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast message
|
||||
*/
|
||||
showToast(message: string) {
|
||||
return this.sendMessage('UIManager', 'ShowToast', message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set language
|
||||
*/
|
||||
setLanguage(lang: 'de' | 'en') {
|
||||
return this.sendMessage('UIManager', 'SetLanguage', lang)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Screenshot & Recording
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Take a screenshot
|
||||
*/
|
||||
takeScreenshot(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (message: UnityMessage) => {
|
||||
if (message.type === 'error') {
|
||||
reject(new Error(message.message))
|
||||
return true
|
||||
}
|
||||
// Would need custom message type for screenshot result
|
||||
return false
|
||||
}
|
||||
|
||||
const unsubscribe = this.onMessage(handler)
|
||||
this.sendMessage('ScreenshotManager', 'TakeScreenshot')
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
unsubscribe()
|
||||
reject(new Error('Screenshot timeout'))
|
||||
}, 5000)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Quit Unity
|
||||
*/
|
||||
async quit() {
|
||||
if (this.instance) {
|
||||
await this.instance.Quit()
|
||||
this.instance = null
|
||||
this.isReady = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup
|
||||
*/
|
||||
destroy() {
|
||||
this.messageHandlers = []
|
||||
if (typeof window !== 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (window as any).handleUnityMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let bridgeInstance: UnityBridge | null = null
|
||||
|
||||
/**
|
||||
* Get or create the Unity bridge instance
|
||||
*/
|
||||
export function getUnityBridge(): UnityBridge {
|
||||
if (!bridgeInstance) {
|
||||
bridgeInstance = new UnityBridge()
|
||||
}
|
||||
return bridgeInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Unity loader configuration
|
||||
*/
|
||||
export function createLoaderConfig(buildPath: string): UnityLoaderConfig {
|
||||
return {
|
||||
dataUrl: `${buildPath}/WebGL.data`,
|
||||
frameworkUrl: `${buildPath}/WebGL.framework.js`,
|
||||
codeUrl: `${buildPath}/WebGL.wasm`,
|
||||
streamingAssetsUrl: `${buildPath}/StreamingAssets`,
|
||||
companyName: 'BreakPilot',
|
||||
productName: 'GeoEdu Lernwelt',
|
||||
productVersion: '1.0.0',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user