Atlas: Infrastruktur + Team-Konventionen + Sprachregel Lernarbeit
- Design-System (assets/css/design-system.css) mit 21 Komponenten, iPad-Responsive-Breakpoints, Touch-Ziele 36px, Music-Player, Glossar-Tooltips - Templates (sims/template.html + student/teacher-Dashboard) - Docs: module-interface.md (inkl. 4a Sprachregel, 4b Leichte Sprache, 4c iPad), content-architecture.md, crash-recovery.md, music-registry.md - Admin-Infrastruktur: admin-modules.html + api/admin-modules.php (Titel, Emoji, Bild, Status, Dauer, Alter pro Modul) - Inbox-System: _inbox/README.md + _status.md fuer Atlas + Briefings an Klima, Glossar, Lehrplan, Fluss - Zentrale SFX-Pipeline (scripts/generate-sounds.py) - DALL-E-Bilder: 8 Badges + 5 Glossar-Repraesentationsbilder (Querformat) - Logo + Inter-Font lokal - PHP-APIs: admin, glossar, levels, licenses, progress, waypoints, assignments, profile, tickets - Spielsprache entfernt (admin-modules, admin-levels, schueler) - Landing-Page-Bearbeitungen (Boote sichtbarer, Button-Hintergrund) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,358 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Administration</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh}
|
||||
|
||||
.topbar{background:#fff;padding:.5rem 1rem;display:flex;align-items:center;gap:.6rem;border-bottom:1px solid rgba(0,0,0,.06);flex-wrap:wrap;position:sticky;top:0;z-index:100}
|
||||
.topbar h1{font-size:.9rem;font-weight:800;color:#1f4e5a;display:flex;align-items:center;gap:.5rem}
|
||||
.topbar h1 img{height:28px}
|
||||
.topbar .spacer{flex:1}
|
||||
.nav-btn{padding:.35rem .7rem;border:1.5px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.7rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
|
||||
.nav-btn:hover{background:#dae8ec;color:#4a7c8a}
|
||||
.nav-btn.on{background:#1f4e5a;color:#fff;border-color:#1f4e5a}
|
||||
|
||||
.wrap{max-width:1100px;margin:0 auto;padding:1rem}
|
||||
.card{background:#fff;border-radius:10px;padding:1rem;box-shadow:0 1px 4px rgba(0,0,0,.03);border:1px solid rgba(0,0,0,.04);margin-bottom:1rem}
|
||||
.card-h{font-size:.62rem;font-weight:700;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.5rem}
|
||||
.btn{padding:.4rem .8rem;border:none;border-radius:6px;background:#4a7c8a;color:#fff;font-size:.72rem;font-weight:700;cursor:pointer}
|
||||
.btn:hover{background:#3a6470}
|
||||
.btn-outline{background:transparent;color:#4a7c8a;border:1.5px solid #4a7c8a}
|
||||
select,input[type=text],input[type=number]{padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.75rem;font-family:inherit}
|
||||
|
||||
/* Sim Cards Grid */
|
||||
.sim-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.8rem;margin-bottom:1.5rem}
|
||||
.sim-card{background:#fff;border-radius:12px;overflow:hidden;cursor:pointer;border:2px solid rgba(0,0,0,.05);transition:all .2s}
|
||||
.sim-card:hover{border-color:#4a7c8a;transform:translateY(-2px);box-shadow:0 6px 20px rgba(74,124,138,.1)}
|
||||
.sim-card.active{border-color:#1f4e5a;box-shadow:0 0 0 3px rgba(31,78,90,.15)}
|
||||
.sim-card img{width:100%;height:100px;object-fit:cover}
|
||||
.sim-card-body{padding:.6rem}
|
||||
.sim-card-body h3{font-size:.78rem;font-weight:700;color:#1f4e5a}
|
||||
.sim-card-body p{font-size:.6rem;color:#8a8a8a}
|
||||
|
||||
/* Slider rows */
|
||||
.slider-row{display:grid;grid-template-columns:140px 1fr 40px;gap:8px;align-items:center;margin-bottom:.4rem;font-size:.75rem}
|
||||
.slider-row label{color:#4a4a4a}
|
||||
.slider-row input[type=range]{width:100%;accent-color:#4a7c8a}
|
||||
.slider-row .sval{text-align:right;font-weight:700;color:#1f4e5a;font-variant-numeric:tabular-nums}
|
||||
|
||||
/* Waypoint rows */
|
||||
.wp-row{display:grid;grid-template-columns:100px 1fr 80px 80px 70px 60px 110px 50px;gap:5px;align-items:center;padding:.4rem .2rem;border-bottom:1px solid rgba(0,0,0,.03);font-size:.7rem}
|
||||
.wp-row:hover{background:#f0f8fa}
|
||||
.wp-header{font-weight:700;font-size:.58rem;color:#8a8a8a;text-transform:uppercase;border-bottom:2px solid #dae8ec}
|
||||
.wp-type{font-size:.58rem;padding:1px 5px;border-radius:3px;font-weight:600}
|
||||
.wp-type-base{background:#f2eacc;color:#8a7020}
|
||||
.wp-type-city{background:#dae8ec;color:#4a7c8a}
|
||||
.wp-type-village{background:#f0eeea;color:#666}
|
||||
.wp-type-peak{background:#dceadd;color:#3a6b3e}
|
||||
.wp-type-target{background:#f2ddd8;color:#8a3a2a}
|
||||
.wp-map-mini{width:110px;height:65px;border-radius:5px;border:1px solid rgba(0,0,0,.06)}
|
||||
|
||||
/* Level cards */
|
||||
.level-card{border:1.5px solid rgba(0,0,0,.06);border-radius:8px;padding:.7rem;margin-bottom:.4rem}
|
||||
.level-card h4{font-size:.78rem;font-weight:700;color:#1f4e5a}
|
||||
.level-card .meta{font-size:.62rem;color:#8a8a8a}
|
||||
|
||||
/* Modal */
|
||||
.modal{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:1000}
|
||||
.modal-card{background:#fff;border-radius:14px;padding:1.2rem;max-width:600px;width:95%;max-height:90vh;overflow-y:auto}
|
||||
.modal-card h3{font-size:.95rem;font-weight:800;color:#1f4e5a;margin-bottom:.6rem}
|
||||
.modal-card label{display:block;font-size:.65rem;font-weight:600;color:#4a4a4a;margin-top:.5rem}
|
||||
.modal-card input,.modal-card select{width:100%}
|
||||
#editMap{height:400px;border-radius:8px;margin-top:.5rem}
|
||||
.hidden{display:none}
|
||||
|
||||
/* Tab content */
|
||||
.tab-content{display:none}
|
||||
.tab-content.active{display:block}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<h1><img src="assets/img/geograsim_t.svg" alt="">Administration</h1>
|
||||
<span class="spacer"></span>
|
||||
<a href="#" class="nav-btn on" onclick="showTab('sims')">Simulationen</a>
|
||||
<a href="#" class="nav-btn" onclick="showTab('waypoints')">📍 Waypoints</a>
|
||||
<a href="heli" class="nav-btn" target="_blank">🗺️ Gesamtkarte</a>
|
||||
<a href="admin-modules.html" class="nav-btn">🧩 Module</a>
|
||||
<a href="admin-licenses.html" class="nav-btn">🔑 Lizenzen</a>
|
||||
<a href="admin.html" class="nav-btn">Logout</a>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<!-- TAB: Simulationen -->
|
||||
<div class="tab-content active" id="tab-sims">
|
||||
<div class="card">
|
||||
<div class="card-h">Simulationen — Wähle ein Modul zur Konfiguration</div>
|
||||
<div class="sim-grid" id="simGrid"></div>
|
||||
</div>
|
||||
<div id="simDetail"></div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Waypoints -->
|
||||
<div class="tab-content" id="tab-waypoints">
|
||||
<div class="card">
|
||||
<div class="card-h">📍 Waypoints — Koordinaten (<span id="wp-count">0</span>)</div>
|
||||
<div style="display:flex;gap:.4rem;margin-bottom:.5rem;flex-wrap:wrap">
|
||||
<select id="wp-filter-region" onchange="loadWaypoints()"><option value="">Alle</option><option value="vorarlberg">Vorarlberg</option><option value="tirol">Tirol</option></select>
|
||||
<select id="wp-filter-type" onchange="loadWaypoints()"><option value="">Alle Typen</option><option value="base">Basis</option><option value="city">Stadt</option><option value="village">Dorf</option><option value="peak">Gipfel</option><option value="target">Einsatzort</option></select>
|
||||
<button class="btn" onclick="openAdd()">+ Waypoint</button>
|
||||
</div>
|
||||
<div class="wp-row wp-header"><div>Key</div><div>Name</div><div>Lat</div><div>Lon</div><div>Region</div><div>Typ</div><div>Karte</div><div></div></div>
|
||||
<div id="wp-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal hidden" id="editModal">
|
||||
<div class="modal-card">
|
||||
<h3 id="editTitle">Waypoint bearbeiten</h3>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
||||
<div><label>Key</label><input type="text" id="e-key" placeholder="bregenz"></div>
|
||||
<div><label>Name</label><input type="text" id="e-name" placeholder="Bregenz"></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.5rem">
|
||||
<div><label>Lat</label><input type="number" id="e-lat" step="0.00001"></div>
|
||||
<div><label>Lon</label><input type="number" id="e-lon" step="0.00001"></div>
|
||||
<div><label>Region</label><select id="e-region"><option value="vorarlberg">Vorarlberg</option><option value="tirol">Tirol</option></select></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
||||
<div><label>Typ</label><select id="e-type"><option value="village">Dorf</option><option value="city">Stadt</option><option value="base">Basis</option><option value="peak">Gipfel</option><option value="target">Einsatzort</option><option value="pass">Pass</option></select></div>
|
||||
<div><label>Info</label><input type="text" id="e-info" placeholder="Beschreibung"></div>
|
||||
</div>
|
||||
<p style="font-size:.68rem;color:#8a8a8a;margin-top:.4rem">Klicke auf die Karte um die Position zu setzen:</p>
|
||||
<div id="editMap"></div>
|
||||
<div style="display:flex;gap:.4rem;margin-top:.6rem">
|
||||
<button class="btn" onclick="saveEdit()">Speichern</button>
|
||||
<button class="btn btn-outline" onclick="closeEdit()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
var BASE = (window.__GGS__?.baseUrl) || '';
|
||||
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
|
||||
|
||||
async function api(endpoint, data) {
|
||||
var opts = data ? {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(data)} : {credentials:'same-origin'};
|
||||
return (await fetch(BASE + '/api/' + endpoint, opts)).json();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TABS
|
||||
// ==========================================
|
||||
function showTab(tab) {
|
||||
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.toggle('active', el.id === 'tab-' + tab); });
|
||||
document.querySelectorAll('.nav-btn').forEach(function(b) { b.classList.remove('on'); });
|
||||
event.target.classList.add('on');
|
||||
if (tab === 'waypoints') loadWaypoints();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SIMULATIONEN
|
||||
// ==========================================
|
||||
var SIMS = [
|
||||
{id:'klima', name:'Klimawächter V2', icon:'🌍', img:'assets/img/card-climate.png',
|
||||
params:{
|
||||
start_budget:{min:100,max:2000,def:600,label:'Start-Budget (Mio €)'},
|
||||
start_population:{min:1000,max:20000,def:6000,label:'Start-Bevölkerung'},
|
||||
income_per_10k:{min:50,max:500,def:220,label:'Einnahmen pro 10.000 Bürger'},
|
||||
budget_loss:{min:-2000,max:0,def:-500,label:'Pleite-Grenze (Mio €, negativ)'},
|
||||
event_frequency:{min:0,max:1,def:0.3,step:0.1,label:'Ereignis-Häufigkeit (0–1)'},
|
||||
time_limit:{min:30,max:150,def:75,label:'Simulationsdauer (Runden)'}
|
||||
}},
|
||||
{id:'sim-05', name:'Klimawächter 2D (V1)', icon:'🌡️', img:'assets/img/card-climate.png',
|
||||
params:{start_co2:{min:300,max:600,def:425,label:'Start-CO₂ (ppm)'},start_temp:{min:14,max:18,def:15.2,label:'Start-Temperatur (°C)'},budget:{min:200,max:2000,def:950,label:'Budget (Mrd)'},income:{min:100,max:500,def:290,label:'Einkommen/Jahr'},damage_mult:{min:0.5,max:3,def:1,label:'Schadensmultiplikator'}}},
|
||||
{id:'sim-05-3d', name:'Klimawächter 3D (V1)', icon:'🌊', img:'assets/img/card-climate.png',
|
||||
params:{start_co2:{min:300,max:600,def:425,label:'Start-CO₂ (ppm)'},start_temp:{min:14,max:18,def:15.2,label:'Start-Temperatur (°C)'},budget:{min:200,max:2000,def:950,label:'Budget (Mrd)'},income:{min:100,max:500,def:290,label:'Einkommen/Jahr'}}},
|
||||
{id:'sim-07', name:'Erdbeben', icon:'🌋', img:'assets/img/card-tectonic.png',
|
||||
params:{quake_freq:{min:1,max:10,def:3,label:'Erdbeben-Häufigkeit'},build_cost:{min:50,max:500,def:200,label:'Baukosten'},population:{min:1000,max:50000,def:10000,label:'Bevölkerung'}}},
|
||||
{id:'sim-09', name:'Energiemix', icon:'⚡', img:'assets/img/card-energy.png',
|
||||
params:{demand_growth:{min:0,max:5,def:2,label:'Nachfrage-Wachstum (%/Jahr)'},co2_target:{min:20,max:80,def:50,label:'CO₂-Reduktionsziel (%)'},budget:{min:500,max:5000,def:2000,label:'Budget (Mio)'}}},
|
||||
{id:'sim-10', name:'Lieferketten', icon:'👕', img:'assets/img/card-globe.png',
|
||||
params:{price_weight:{min:0,max:100,def:40,label:'Preis-Gewichtung (%)'},fair_weight:{min:0,max:100,def:30,label:'Fairness-Gewichtung (%)'},co2_weight:{min:0,max:100,def:30,label:'CO₂-Gewichtung (%)'}}},
|
||||
{id:'sim-11', name:'Regenwald', icon:'🌴', img:'assets/img/card-climate.png', params:{}},
|
||||
{id:'sim-12', name:'Flussmanagement', icon:'🏞️', img:'assets/img/card-fluss.png',
|
||||
params:{flood_freq:{min:1,max:10,def:3,label:'Hochwasser-Häufigkeit'},budget:{min:500,max:5000,def:2000,label:'Budget'},bio_weight:{min:0,max:100,def:40,label:'Biodiversitäts-Gewichtung (%)'}}},
|
||||
{id:'sim-13', name:'Stadt & Raumplanung', icon:'🏗️', img:'assets/img/card-city.png',
|
||||
params:{rounds:{min:5,max:20,def:10,label:'Runden'},budget_per_round:{min:100,max:1000,def:300,label:'Budget/Runde'},grid_size:{min:6,max:12,def:8,label:'Raster-Größe'}}},
|
||||
{id:'sim-14', name:'Helikopter-Navigation', icon:'🚁', img:'assets/img/card-heli.png',
|
||||
params:{houses:{min:0,max:14,def:8,label:'Häuser'},trees_tall:{min:0,max:14,def:6,label:'Hohe Bäume'},flocks:{min:0,max:8,def:2,label:'Vogelschwärme'},airliners:{min:0,max:3,def:1,label:'Verkehrsflugzeuge'},sportplanes:{min:0,max:4,def:2,label:'Sportflieger'},storms:{min:0,max:4,def:1,label:'Gewitterzellen'}}},
|
||||
];
|
||||
|
||||
var selectedSim = null;
|
||||
|
||||
function renderSimGrid() {
|
||||
var html = '';
|
||||
SIMS.forEach(function(s) {
|
||||
var cls = selectedSim === s.id ? ' active' : '';
|
||||
html += '<div class="sim-card'+cls+'" onclick="selectSim(\''+s.id+'\')">';
|
||||
html += '<img src="'+s.img+'" alt="">';
|
||||
html += '<div class="sim-card-body"><h3>'+s.icon+' '+s.name+'</h3><p>'+Object.keys(s.params).length+' Parameter</p></div>';
|
||||
html += '</div>';
|
||||
});
|
||||
document.getElementById('simGrid').innerHTML = html;
|
||||
}
|
||||
|
||||
async function selectSim(id) {
|
||||
selectedSim = id;
|
||||
renderSimGrid();
|
||||
var sim = SIMS.find(function(s){return s.id===id;});
|
||||
if (!sim) return;
|
||||
|
||||
// Bestehende Levels laden
|
||||
var levels = await api('levels?game_id=' + id);
|
||||
if (levels.error) levels = [];
|
||||
|
||||
var html = '<div class="card"><div class="card-h">'+sim.icon+' '+sim.name+' — Level-Konfiguration</div>';
|
||||
var params = sim.params || {};
|
||||
var paramKeys = Object.keys(params);
|
||||
|
||||
// Für jedes der 3 Standard-Levels (Leicht/Mittel/Schwer) alle Schieberegler anzeigen
|
||||
var levelNames = ['Leicht','Mittel','Schwer'];
|
||||
var levelColors = ['#5a8a5e','#c4a35a','#c85c4a'];
|
||||
|
||||
levelNames.forEach(function(lName, li) {
|
||||
var existingLevel = levels.find(function(l){return l.level_name===lName;});
|
||||
var existingParams = existingLevel ? existingLevel.params : {};
|
||||
var levelId = existingLevel ? existingLevel.id : 0;
|
||||
|
||||
html += '<div style="border-left:4px solid '+levelColors[li]+';padding:.6rem .8rem;margin-bottom:.6rem;background:rgba(0,0,0,.01);border-radius:0 8px 8px 0">';
|
||||
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.4rem">';
|
||||
html += '<strong style="font-size:.85rem;color:'+levelColors[li]+'">'+lName+'</strong>';
|
||||
html += '<button class="btn" style="font-size:.62rem;padding:.2rem .6rem" onclick="saveLevel(\''+id+'\',\''+lName+'\','+li+','+levelId+')">Speichern</button>';
|
||||
html += '</div>';
|
||||
|
||||
paramKeys.forEach(function(key) {
|
||||
var p = params[key];
|
||||
var val = existingParams[key] !== undefined ? existingParams[key] : p.def;
|
||||
var sliderId = 'sl-'+li+'-'+key;
|
||||
var valId = 'sv-'+li+'-'+key;
|
||||
html += '<div class="slider-row">';
|
||||
html += '<label>'+p.label+'</label>';
|
||||
html += '<input type="range" min="'+p.min+'" max="'+p.max+'" value="'+val+'" step="'+(p.max-p.min > 10 ? 1 : 0.1)+'" id="'+sliderId+'" oninput="document.getElementById(\''+valId+'\').textContent=this.value">';
|
||||
html += '<span class="sval" id="'+valId+'">'+val+'</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('simDetail').innerHTML = html;
|
||||
}
|
||||
|
||||
async function saveLevel(gameId, levelName, levelIndex, existingId) {
|
||||
var sim = SIMS.find(function(s){return s.id===gameId;});
|
||||
var params = {};
|
||||
Object.keys(sim.params||{}).forEach(function(key) {
|
||||
var el = document.getElementById('sl-'+levelIndex+'-'+key);
|
||||
if (el) params[key] = parseFloat(el.value);
|
||||
});
|
||||
|
||||
var r = await api('levels', {action:'save', gameId:gameId, levelId:existingId||undefined, levelName:levelName, scenario:'standard', params:params, sortOrder:levelIndex+1});
|
||||
if (r.error) { alert(r.error); return; }
|
||||
alert(levelName + ' gespeichert!');
|
||||
selectSim(gameId);
|
||||
}
|
||||
|
||||
async function deleteLevel(id, gameId) {
|
||||
if (!confirm('Level löschen?')) return;
|
||||
await api('levels', {action:'delete', levelId:id});
|
||||
selectSim(gameId);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// WAYPOINTS
|
||||
// ==========================================
|
||||
var allWaypoints = [];
|
||||
async function loadWaypoints() {
|
||||
var region = document.getElementById('wp-filter-region').value;
|
||||
var type = document.getElementById('wp-filter-type').value;
|
||||
var url = 'waypoints?';
|
||||
if (region) url += 'region=' + region + '&';
|
||||
if (type) url += 'type=' + type;
|
||||
var wps = await api(url);
|
||||
if (wps.error) wps = [];
|
||||
allWaypoints = wps;
|
||||
document.getElementById('wp-count').textContent = wps.length;
|
||||
|
||||
var html = '';
|
||||
wps.forEach(function(w) {
|
||||
var typeClass = 'wp-type-' + w.wp_type;
|
||||
var typeLabel = {base:'Basis',city:'Stadt',village:'Dorf',peak:'Gipfel',target:'Ziel',pass:'Pass'}[w.wp_type] || w.wp_type;
|
||||
var lat = parseFloat(w.lat).toFixed(5), lon = parseFloat(w.lon).toFixed(5);
|
||||
html += '<div class="wp-row">';
|
||||
html += '<div style="font-family:monospace;font-size:.65rem;color:#4a7c8a;font-weight:600">'+w.wp_key+'</div>';
|
||||
html += '<div><strong>'+w.name+'</strong>'+(w.info?'<br><span style="font-size:.58rem;color:#8a8a8a">'+w.info+'</span>':'')+'</div>';
|
||||
html += '<div style="font-variant-numeric:tabular-nums;font-size:.65rem">'+lat+'</div>';
|
||||
html += '<div style="font-variant-numeric:tabular-nums;font-size:.65rem">'+lon+'</div>';
|
||||
html += '<div style="font-size:.58rem;color:#8a8a8a">'+w.region+'</div>';
|
||||
html += '<div><span class="wp-type '+typeClass+'">'+typeLabel+'</span></div>';
|
||||
html += '<div><img class="wp-map-mini" src="https://staticmap.openstreetmap.de/staticmap.php?center='+lat+','+lon+'&zoom=13&size=110x65&markers='+lat+','+lon+',ol-marker" loading="lazy"></div>';
|
||||
html += '<div><button style="padding:.2rem .4rem;font-size:.6rem;border:1px solid rgba(0,0,0,.08);border-radius:4px;background:#fff;cursor:pointer" onclick="openEdit(\''+w.wp_key+'\')">✏️</button></div>';
|
||||
html += '</div>';
|
||||
});
|
||||
document.getElementById('wp-list').innerHTML = html;
|
||||
}
|
||||
|
||||
var editMap = null, editMarker = null;
|
||||
function openEdit(key) {
|
||||
var w = allWaypoints.find(function(x){return x.wp_key===key;});
|
||||
if (!w) return;
|
||||
document.getElementById('editTitle').textContent = 'Bearbeiten: ' + w.name;
|
||||
document.getElementById('e-key').value = w.wp_key;
|
||||
document.getElementById('e-name').value = w.name;
|
||||
document.getElementById('e-lat').value = parseFloat(w.lat);
|
||||
document.getElementById('e-lon').value = parseFloat(w.lon);
|
||||
document.getElementById('e-region').value = w.region;
|
||||
document.getElementById('e-type').value = w.wp_type;
|
||||
document.getElementById('e-info').value = w.info || '';
|
||||
document.getElementById('editModal').classList.remove('hidden');
|
||||
setTimeout(function() {
|
||||
if (!editMap) {
|
||||
editMap = L.map('editMap').setView([w.lat, w.lon], 14);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OSM',maxZoom:18}).addTo(editMap);
|
||||
editMap.on('click',function(e){document.getElementById('e-lat').value=e.latlng.lat.toFixed(5);document.getElementById('e-lon').value=e.latlng.lng.toFixed(5);if(editMarker)editMap.removeLayer(editMarker);editMarker=L.marker(e.latlng).addTo(editMap);});
|
||||
} else { editMap.setView([w.lat,w.lon],14); editMap.invalidateSize(); }
|
||||
if(editMarker)editMap.removeLayer(editMarker);
|
||||
editMarker=L.marker([w.lat,w.lon]).addTo(editMap);
|
||||
}, 200);
|
||||
}
|
||||
function openAdd() {
|
||||
document.getElementById('editTitle').textContent = 'Neuer Waypoint';
|
||||
['e-key','e-name','e-lat','e-lon','e-info'].forEach(function(id){document.getElementById(id).value='';});
|
||||
document.getElementById('editModal').classList.remove('hidden');
|
||||
setTimeout(function(){if(!editMap){editMap=L.map('editMap').setView([47.3,10.5],9);L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OSM',maxZoom:18}).addTo(editMap);editMap.on('click',function(e){document.getElementById('e-lat').value=e.latlng.lat.toFixed(5);document.getElementById('e-lon').value=e.latlng.lng.toFixed(5);if(editMarker)editMap.removeLayer(editMarker);editMarker=L.marker(e.latlng).addTo(editMap);});}else{editMap.setView([47.3,10.5],9);editMap.invalidateSize();}if(editMarker){editMap.removeLayer(editMarker);editMarker=null;}},200);
|
||||
}
|
||||
function closeEdit(){document.getElementById('editModal').classList.add('hidden');}
|
||||
async function saveEdit() {
|
||||
var r = await api('waypoints',{action:'save',wpKey:document.getElementById('e-key').value.trim().toLowerCase().replace(/[^a-z0-9_]/g,''),name:document.getElementById('e-name').value.trim(),lat:parseFloat(document.getElementById('e-lat').value),lon:parseFloat(document.getElementById('e-lon').value),region:document.getElementById('e-region').value,wpType:document.getElementById('e-type').value,info:document.getElementById('e-info').value.trim()});
|
||||
if(r.error){alert(r.error);return;}
|
||||
closeEdit(); loadWaypoints();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INIT
|
||||
// ==========================================
|
||||
fetch(BASE+'/api/admin',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({action:'status'})})
|
||||
.then(function(r){return r.json()}).then(function(r){
|
||||
if(!r.authenticated){location.href='admin.html';return;}
|
||||
renderSimGrid();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Lizenzverwaltung</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh}
|
||||
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06)}
|
||||
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem}
|
||||
.topbar h1 img{height:28px}
|
||||
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
|
||||
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
|
||||
.wrap{max-width:1000px;margin:1rem auto;padding:0 1rem}
|
||||
.card{background:#fff;border-radius:10px;padding:1rem;box-shadow:0 1px 4px rgba(0,0,0,.03);border:1px solid rgba(0,0,0,.04);margin-bottom:1rem}
|
||||
.card-h{font-size:.62rem;font-weight:700;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.5rem}
|
||||
.stat-row{display:flex;gap:1rem;margin-bottom:1rem}
|
||||
.stat{background:#f7f6f3;border-radius:8px;padding:.8rem 1.2rem;text-align:center;flex:1}
|
||||
.stat .n{font-size:1.5rem;font-weight:900;color:#4a7c8a}
|
||||
.stat .l{font-size:.62rem;color:#8a8a8a}
|
||||
.btn{padding:.45rem .9rem;border:none;border-radius:6px;background:#4a7c8a;color:#fff;font-size:.75rem;font-weight:700;cursor:pointer}
|
||||
.btn:hover{background:#3a6470}
|
||||
.btn-outline{background:transparent;color:#4a7c8a;border:1.5px solid #4a7c8a}
|
||||
table{width:100%;border-collapse:collapse;font-size:.72rem}
|
||||
th{background:#f7f6f3;padding:.35rem .5rem;text-align:left;font-weight:600;font-size:.6rem;color:#8a8a8a}
|
||||
td{padding:.35rem .5rem;border-bottom:1px solid rgba(0,0,0,.03)}
|
||||
.code{font-family:'Courier New',monospace;font-weight:700;letter-spacing:.04em;font-size:.72rem}
|
||||
.free{color:#5a8a5e}
|
||||
.used{color:#8a8a8a}
|
||||
.filter-bar{display:flex;gap:.4rem;margin-bottom:.8rem;flex-wrap:wrap;align-items:center}
|
||||
.filter-bar select,.filter-bar input{padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.72rem;font-family:inherit}
|
||||
.toolbar{display:flex;gap:.4rem;margin-bottom:.8rem;flex-wrap:wrap}
|
||||
.msg{font-size:.72rem;padding:.4rem .6rem;border-radius:6px;margin-top:.5rem}
|
||||
.msg.ok{background:#dceadd;color:#5a8a5e}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"> Lizenzverwaltung (Admin)</h1>
|
||||
<a href="admin-modules.html" class="btn-sm">🧩 Module</a>
|
||||
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
|
||||
<a href="admin.html" class="btn-sm">Logout</a>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<div class="stat-row">
|
||||
<div class="stat"><div class="n" id="stat-total">—</div><div class="l">Gesamt</div></div>
|
||||
<div class="stat"><div class="n free" id="stat-free">—</div><div class="l">Frei</div></div>
|
||||
<div class="stat"><div class="n used" id="stat-used">—</div><div class="l">Zugewiesen</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">Neue Lizenzen generieren</div>
|
||||
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap">
|
||||
<div>
|
||||
<label style="font-size:.65rem;font-weight:600;color:#4a4a4a;display:block">Anzahl</label>
|
||||
<input type="number" id="gen-count" value="100" min="1" max="1000" style="width:80px;padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:.65rem;font-weight:600;color:#4a4a4a;display:block">Jahr</label>
|
||||
<input type="number" id="gen-year" value="2026" min="2025" max="2035" style="width:80px;padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem">
|
||||
</div>
|
||||
<button class="btn" onclick="generate()">Generieren</button>
|
||||
</div>
|
||||
<div id="gen-msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">Lizenzen</div>
|
||||
<div class="filter-bar">
|
||||
<select id="filter-status" onchange="loadLicenses()">
|
||||
<option value="">Alle</option>
|
||||
<option value="free">Frei</option>
|
||||
<option value="used">Zugewiesen</option>
|
||||
</select>
|
||||
<button class="btn btn-outline" onclick="exportFree()">Freie exportieren (CSV)</button>
|
||||
</div>
|
||||
<div id="license-table" style="max-height:500px;overflow-y:auto"></div>
|
||||
<div style="display:flex;gap:.4rem;margin-top:.5rem">
|
||||
<button class="btn btn-outline" id="btn-prev" onclick="prevPage()" disabled>← Zurück</button>
|
||||
<span style="font-size:.68rem;color:#8a8a8a;padding:.35rem" id="page-info"></span>
|
||||
<button class="btn btn-outline" id="btn-next" onclick="nextPage()">Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var BASE = (window.__GGS__?.baseUrl) || '';
|
||||
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
|
||||
|
||||
var page = 0, pageSize = 100;
|
||||
|
||||
async function api(endpoint, data) {
|
||||
var opts = data ? {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(data)} : {credentials:'same-origin'};
|
||||
var r = await fetch(BASE + '/api/' + endpoint, opts);
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
async function loadLicenses() {
|
||||
var status = document.getElementById('filter-status').value;
|
||||
var url = 'licenses?admin=1&limit='+pageSize+'&offset='+(page*pageSize);
|
||||
if (status) url += '&status=' + status;
|
||||
var r = await api(url);
|
||||
if (r.error) return;
|
||||
|
||||
document.getElementById('stat-total').textContent = r.total;
|
||||
document.getElementById('stat-free').textContent = r.free;
|
||||
document.getElementById('stat-used').textContent = r.used;
|
||||
|
||||
var html = '<table><tr><th>Code</th><th>Schuljahr</th><th>Status</th><th>Schüler*in</th><th>Lehrperson</th></tr>';
|
||||
r.licenses.forEach(function(l) {
|
||||
var st = l.student_id ? '<span class="used">zugewiesen</span>' : '<span class="free">frei</span>';
|
||||
html += '<tr>';
|
||||
html += '<td class="code">'+l.code+'</td>';
|
||||
html += '<td>'+l.school_year+'</td>';
|
||||
html += '<td>'+st+'</td>';
|
||||
html += '<td>'+(l.student_name||'—')+'</td>';
|
||||
html += '<td>'+(l.teacher_name||'—')+'</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
document.getElementById('license-table').innerHTML = html;
|
||||
|
||||
var totalPages = Math.ceil(r.total / pageSize);
|
||||
document.getElementById('page-info').textContent = 'Seite '+(page+1)+' von '+totalPages;
|
||||
document.getElementById('btn-prev').disabled = page === 0;
|
||||
document.getElementById('btn-next').disabled = page >= totalPages - 1;
|
||||
}
|
||||
|
||||
function prevPage() { if(page>0){page--;loadLicenses();} }
|
||||
function nextPage() { page++; loadLicenses(); }
|
||||
|
||||
async function generate() {
|
||||
var count = parseInt(document.getElementById('gen-count').value) || 100;
|
||||
var year = parseInt(document.getElementById('gen-year').value) || 2026;
|
||||
var r = await api('licenses', {action:'generate', count:count, year:year});
|
||||
if (r.error) { document.getElementById('gen-msg').innerHTML = '<div class="msg" style="background:#f2ddd8;color:#c07a6b">'+r.error+'</div>'; return; }
|
||||
document.getElementById('gen-msg').innerHTML = '<div class="msg ok">'+r.generated+' Lizenzen generiert.</div>';
|
||||
loadLicenses();
|
||||
}
|
||||
|
||||
function exportFree() {
|
||||
api('licenses?admin=1&status=free&limit=10000').then(function(r) {
|
||||
if (!r.licenses) return;
|
||||
var csv = 'Code;Schuljahr\n';
|
||||
r.licenses.forEach(function(l) { csv += l.code + ';' + l.school_year + '\n'; });
|
||||
var blob = new Blob([csv], {type:'text/csv'});
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'lizenzen_frei.csv';
|
||||
a.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Admin auth check (2FA required)
|
||||
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(r) {
|
||||
if (!r.authenticated) { location.href = 'admin.html'; return; }
|
||||
loadLicenses();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,394 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Modul-Verwaltung</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh}
|
||||
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06)}
|
||||
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem}
|
||||
.topbar h1 img{height:28px}
|
||||
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
|
||||
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
|
||||
.wrap{max-width:1200px;margin:1rem auto;padding:0 1rem}
|
||||
.intro{color:#4a4a4a;font-size:.82rem;margin-bottom:1rem}
|
||||
|
||||
.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}
|
||||
.module-card{background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.03);border:1px solid rgba(0,0,0,.04);display:flex;flex-direction:column}
|
||||
.module-img{position:relative;aspect-ratio:16/10;background:#dae8ec;overflow:hidden}
|
||||
.module-img img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.module-img .no-img{display:flex;align-items:center;justify-content:center;height:100%;color:#8a8a8a;font-size:.72rem}
|
||||
.module-img .status-badge{position:absolute;top:8px;right:8px;padding:3px 8px;border-radius:12px;font-size:.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em}
|
||||
.status-aktiv{background:#dceadd;color:#3a6b3e}
|
||||
.status-beta{background:#fff3cd;color:#8a6400}
|
||||
.status-geplant{background:#e0e0e0;color:#666}
|
||||
.status-archiv{background:#f2ddd8;color:#c07a6b}
|
||||
|
||||
.module-body{padding:.9rem;display:flex;flex-direction:column;gap:.6rem;flex:1}
|
||||
.module-title{display:flex;align-items:center;gap:.5rem}
|
||||
.module-emoji{font-size:1.8rem;line-height:1}
|
||||
.module-title-text{font-size:1rem;font-weight:800;color:#1f4e5a;flex:1}
|
||||
.module-sub{font-size:.74rem;color:#4a4a4a;line-height:1.35}
|
||||
.module-meta{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.3rem}
|
||||
.meta-chip{background:#f7f6f3;padding:2px 8px;border-radius:10px;font-size:.62rem;color:#4a4a4a}
|
||||
.module-actions{display:flex;gap:.4rem;margin-top:.6rem}
|
||||
.btn{padding:.45rem .9rem;border:none;border-radius:6px;background:#4a7c8a;color:#fff;font-size:.72rem;font-weight:700;cursor:pointer;font-family:inherit}
|
||||
.btn:hover{background:#3a6470}
|
||||
.btn-outline{background:transparent;color:#4a7c8a;border:1.5px solid #4a7c8a}
|
||||
|
||||
/* Edit Modal */
|
||||
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:flex-start;justify-content:center;z-index:100;padding:2rem;overflow-y:auto}
|
||||
.overlay.open{display:flex}
|
||||
.edit-card{background:#fff;border-radius:12px;max-width:600px;width:100%;padding:1.5rem;box-shadow:0 8px 30px rgba(0,0,0,.15)}
|
||||
.edit-card h2{font-size:1.1rem;font-weight:800;color:#1f4e5a;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}
|
||||
.form-grid{display:grid;gap:.8rem}
|
||||
.form-row{display:grid;grid-template-columns:auto 1fr;gap:.8rem;align-items:center}
|
||||
.form-row label{font-size:.72rem;font-weight:600;color:#4a4a4a}
|
||||
.form-row input,.form-row select,.form-row textarea{padding:.45rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.82rem;font-family:inherit;width:100%}
|
||||
.form-row textarea{min-height:80px;resize:vertical}
|
||||
.form-row .emoji-preview{display:inline-flex;align-items:center;gap:.4rem;background:#f7f6f3;padding:.35rem .6rem;border-radius:6px;font-size:1.4rem;min-width:2.5rem;justify-content:center}
|
||||
.form-footer{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}
|
||||
.emoji-picker{display:grid;grid-template-columns:repeat(10,1fr);gap:.3rem;margin-top:.4rem;max-height:140px;overflow-y:auto}
|
||||
.emoji-pick{font-size:1.3rem;padding:.3rem;border:none;background:transparent;border-radius:6px;cursor:pointer}
|
||||
.emoji-pick:hover{background:#dae8ec}
|
||||
.upload-section{background:#f7f6f3;padding:.8rem;border-radius:8px}
|
||||
.upload-section label{font-size:.72rem;font-weight:600;color:#4a4a4a;display:block;margin-bottom:.4rem}
|
||||
.current-img{display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem}
|
||||
.current-img img{width:80px;height:50px;object-fit:cover;border-radius:4px}
|
||||
.current-img .fname{font-family:monospace;font-size:.7rem;color:#8a8a8a}
|
||||
|
||||
.msg{font-size:.74rem;padding:.5rem .7rem;border-radius:6px;margin-top:.5rem}
|
||||
.msg.ok{background:#dceadd;color:#3a6b3e}
|
||||
.msg.err{background:#f2ddd8;color:#c07a6b}
|
||||
|
||||
.toolbar{display:flex;gap:.4rem;margin-bottom:1rem;flex-wrap:wrap;align-items:center}
|
||||
.toolbar select{padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.74rem;font-family:inherit}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<h1><img src="assets/img/textlogo_geograsim.svg" alt="GeoGraSim"> Modul-Verwaltung (Admin)</h1>
|
||||
<a href="admin-licenses.html" class="btn-sm">🎟️ Lizenzen</a>
|
||||
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
|
||||
<a href="admin.html" class="btn-sm">Logout</a>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<p class="intro">Hier kannst du Titel, Emoji, Bild und Status aller Module anpassen. Änderungen werden sofort in die Datenbank geschrieben und sind überall in der App wirksam (Landing Page, Detailseiten, Dashboards).</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<label style="font-size:.72rem;font-weight:600">Filter:</label>
|
||||
<select id="filter-status" onchange="render()">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="geplant">Geplant</option>
|
||||
<option value="archiv">Archiv</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="grid" class="module-grid">
|
||||
<p style="color:#8a8a8a;font-size:.8rem">Lade Module …</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="overlay" id="overlay" onclick="if(event.target===this)closeEdit()">
|
||||
<div class="edit-card">
|
||||
<h2><span id="edit-emoji-display">🧩</span> <span id="edit-title-display">Modul bearbeiten</span></h2>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-row">
|
||||
<label>Modul-ID</label>
|
||||
<input id="f-id" readonly style="background:#f7f6f3;color:#8a8a8a">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Emoji</label>
|
||||
<div>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<span class="emoji-preview" id="emoji-preview">🧩</span>
|
||||
<input id="f-icon" maxlength="16" style="max-width:80px" oninput="document.getElementById('emoji-preview').textContent=this.value||'–';document.getElementById('edit-emoji-display').textContent=this.value||'🧩'">
|
||||
<button class="btn-sm" type="button" onclick="togglePicker()">🎨 Auswählen</button>
|
||||
</div>
|
||||
<div id="emoji-picker" class="emoji-picker" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Titel</label>
|
||||
<input id="f-title" maxlength="128" oninput="document.getElementById('edit-title-display').textContent=this.value||'Modul bearbeiten'">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Untertitel</label>
|
||||
<input id="f-subtitle" maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Status</label>
|
||||
<select id="f-status">
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="geplant">Geplant</option>
|
||||
<option value="archiv">Archiv</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Dauer (min)</label>
|
||||
<input id="f-duration" type="number" min="0">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Alter</label>
|
||||
<div style="display:flex;gap:.4rem;align-items:center">
|
||||
<input id="f-agemin" type="number" min="5" max="20" style="width:70px">
|
||||
<span>bis</span>
|
||||
<input id="f-agemax" type="number" min="5" max="20" style="width:70px">
|
||||
<span style="font-size:.7rem;color:#8a8a8a">Jahre</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Simulations-URL</label>
|
||||
<input id="f-playurl" placeholder="klima-2d">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Seiten-URL</label>
|
||||
<input id="f-pageurl" placeholder="modul-klima">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Reihenfolge</label>
|
||||
<input id="f-sort" type="number">
|
||||
</div>
|
||||
|
||||
<div class="upload-section">
|
||||
<label>Card-Bild</label>
|
||||
<div class="current-img">
|
||||
<img id="current-img-preview" src="" alt="" style="display:none">
|
||||
<span class="fname" id="current-img-name">(kein Bild)</span>
|
||||
</div>
|
||||
<input type="file" id="f-image" accept="image/png,image/jpeg,image/webp,image/svg+xml">
|
||||
<div style="font-size:.66rem;color:#8a8a8a;margin-top:.3rem">Empfohlen: 1024×640 PNG/JPG, max 5 MB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-msg"></div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button class="btn btn-outline" onclick="closeEdit()">Abbrechen</button>
|
||||
<button class="btn" onclick="save()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var BASE = location.pathname.replace(/\/[^\/]*$/, '');
|
||||
var modules = [];
|
||||
var editingId = null;
|
||||
|
||||
// Kuratierte Emoji-Auswahl passend zu GeoGraSim-Themen
|
||||
var EMOJI_SET = ['🌍','🌎','🌏','🗺️','🧭','🏔️','⛰️','🌋','🏕️','🏞️',
|
||||
'🌊','💧','🌧️','☀️','☁️','❄️','🌡️','🌪️','🌈','💨',
|
||||
'🏙️','🏛️','🏘️','🏠','🏭','🚁','✈️','🚆','🚌','🚲',
|
||||
'🌲','🌳','🌴','🌿','🍃','🌱','🐟','🐝','🦋','🐼',
|
||||
'⚡','🔥','💡','🔋','☢️','🛢️','⛽','🌬️','♻️','🌿',
|
||||
'📊','📈','📉','🎯','🏆','🎮','📚','🧪','🔬','🧬'];
|
||||
|
||||
function loadModules() {
|
||||
fetch(BASE + '/api/admin-modules', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.modules) {
|
||||
modules = d.modules;
|
||||
render();
|
||||
} else {
|
||||
document.getElementById('grid').innerHTML = '<p style="color:#c07a6b">Fehler: ' + (d.error || 'unbekannt') + '</p>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
var filter = document.getElementById('filter-status').value;
|
||||
var filtered = filter ? modules.filter(m => m.status === filter) : modules;
|
||||
var grid = document.getElementById('grid');
|
||||
if (!filtered.length) {
|
||||
grid.innerHTML = '<p style="color:#8a8a8a;font-size:.8rem">Keine Module vorhanden.</p>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = filtered.map(m => {
|
||||
var imgSrc = m.card_image ? 'assets/img/' + m.card_image : '';
|
||||
var img = imgSrc
|
||||
? '<img src="' + imgSrc + '" alt="' + esc(m.title) + '">'
|
||||
: '<div class="no-img">Kein Bild</div>';
|
||||
var dur = m.duration_min ? m.duration_min + ' min' : '—';
|
||||
var age = (m.age_min && m.age_max) ? m.age_min + '–' + m.age_max + ' J' : '—';
|
||||
return '<div class="module-card">' +
|
||||
'<div class="module-img">' + img +
|
||||
'<div class="status-badge status-' + m.status + '">' + m.status + '</div></div>' +
|
||||
'<div class="module-body">' +
|
||||
'<div class="module-title"><span class="module-emoji">' + (m.icon || '🧩') + '</span>' +
|
||||
'<span class="module-title-text">' + esc(m.title) + '</span></div>' +
|
||||
(m.subtitle ? '<div class="module-sub">' + esc(m.subtitle) + '</div>' : '') +
|
||||
'<div class="module-meta">' +
|
||||
'<span class="meta-chip">🆔 ' + m.module_id + '</span>' +
|
||||
'<span class="meta-chip">⏱ ' + dur + '</span>' +
|
||||
'<span class="meta-chip">👥 ' + age + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="module-actions">' +
|
||||
'<button class="btn" onclick="editModule(\'' + m.module_id + '\')">Bearbeiten</button>' +
|
||||
(m.play_url ? '<a class="btn btn-outline" href="' + m.play_url + '" target="_blank">▶ Testen</a>' : '') +
|
||||
'</div>' +
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function esc(s) { return (s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); }
|
||||
|
||||
function editModule(id) {
|
||||
var m = modules.find(x => x.module_id === id);
|
||||
if (!m) return;
|
||||
editingId = id;
|
||||
document.getElementById('f-id').value = m.module_id;
|
||||
document.getElementById('f-icon').value = m.icon || '';
|
||||
document.getElementById('emoji-preview').textContent = m.icon || '–';
|
||||
document.getElementById('edit-emoji-display').textContent = m.icon || '🧩';
|
||||
document.getElementById('f-title').value = m.title || '';
|
||||
document.getElementById('edit-title-display').textContent = m.title || 'Modul bearbeiten';
|
||||
document.getElementById('f-subtitle').value = m.subtitle || '';
|
||||
document.getElementById('f-status').value = m.status || 'beta';
|
||||
document.getElementById('f-duration').value = m.duration_min || '';
|
||||
document.getElementById('f-agemin').value = m.age_min || '';
|
||||
document.getElementById('f-agemax').value = m.age_max || '';
|
||||
document.getElementById('f-playurl').value = m.play_url || '';
|
||||
document.getElementById('f-pageurl').value = m.page_url || '';
|
||||
document.getElementById('f-sort').value = m.sort_order || 0;
|
||||
|
||||
var imgPreview = document.getElementById('current-img-preview');
|
||||
var imgName = document.getElementById('current-img-name');
|
||||
if (m.card_image) {
|
||||
imgPreview.src = 'assets/img/' + m.card_image;
|
||||
imgPreview.style.display = '';
|
||||
imgName.textContent = m.card_image;
|
||||
} else {
|
||||
imgPreview.style.display = 'none';
|
||||
imgName.textContent = '(kein Bild)';
|
||||
}
|
||||
document.getElementById('f-image').value = '';
|
||||
document.getElementById('edit-msg').innerHTML = '';
|
||||
document.getElementById('overlay').classList.add('open');
|
||||
|
||||
renderEmojiPicker();
|
||||
}
|
||||
|
||||
function renderEmojiPicker() {
|
||||
var p = document.getElementById('emoji-picker');
|
||||
p.innerHTML = EMOJI_SET.map(e =>
|
||||
'<button type="button" class="emoji-pick" onclick="selectEmoji(\'' + e + '\')">' + e + '</button>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function togglePicker() {
|
||||
var p = document.getElementById('emoji-picker');
|
||||
p.style.display = p.style.display === 'none' ? 'grid' : 'none';
|
||||
}
|
||||
|
||||
function selectEmoji(e) {
|
||||
document.getElementById('f-icon').value = e;
|
||||
document.getElementById('emoji-preview').textContent = e;
|
||||
document.getElementById('edit-emoji-display').textContent = e;
|
||||
document.getElementById('emoji-picker').style.display = 'none';
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
document.getElementById('overlay').classList.remove('open');
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!editingId) return;
|
||||
var msg = document.getElementById('edit-msg');
|
||||
msg.innerHTML = '';
|
||||
|
||||
var data = {
|
||||
module_id: editingId,
|
||||
title: document.getElementById('f-title').value.trim(),
|
||||
subtitle: document.getElementById('f-subtitle').value.trim(),
|
||||
icon: document.getElementById('f-icon').value.trim(),
|
||||
status: document.getElementById('f-status').value,
|
||||
duration_min: document.getElementById('f-duration').value || null,
|
||||
age_min: document.getElementById('f-agemin').value || null,
|
||||
age_max: document.getElementById('f-agemax').value || null,
|
||||
play_url: document.getElementById('f-playurl').value.trim(),
|
||||
page_url: document.getElementById('f-pageurl').value.trim(),
|
||||
sort_order: document.getElementById('f-sort').value || 0
|
||||
};
|
||||
|
||||
fetch(BASE + '/api/admin-modules', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
// Wenn Bild hochgeladen: zusätzlich POST
|
||||
var file = document.getElementById('f-image').files[0];
|
||||
if (file) return uploadImage(file);
|
||||
msg.innerHTML = '<div class="msg ok">✓ Gespeichert</div>';
|
||||
loadModules();
|
||||
setTimeout(closeEdit, 800);
|
||||
} else {
|
||||
msg.innerHTML = '<div class="msg err">Fehler: ' + (r.error || 'unbekannt') + '</div>';
|
||||
}
|
||||
})
|
||||
.catch(e => { msg.innerHTML = '<div class="msg err">Netzwerkfehler</div>'; });
|
||||
}
|
||||
|
||||
function uploadImage(file) {
|
||||
var msg = document.getElementById('edit-msg');
|
||||
var fd = new FormData();
|
||||
fd.append('module_id', editingId);
|
||||
fd.append('image', file);
|
||||
return fetch(BASE + '/api/admin-modules', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: fd
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
msg.innerHTML = '<div class="msg ok">✓ Gespeichert inkl. Bild</div>';
|
||||
loadModules();
|
||||
setTimeout(closeEdit, 800);
|
||||
} else {
|
||||
msg.innerHTML = '<div class="msg err">Bild-Upload fehlgeschlagen: ' + (r.error || '') + '</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auth-Check
|
||||
fetch(BASE + '/api/admin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ action: 'status' })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(r => {
|
||||
if (!r.authenticated) { location.href = 'admin.html'; return; }
|
||||
loadModules();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Administration</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#1f4e5a;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:#fff;border-radius:16px;padding:2rem;max-width:380px;width:90%;box-shadow:0 12px 40px rgba(0,0,0,.2)}
|
||||
.logo{text-align:center;margin-bottom:1rem}
|
||||
.logo img{height:36px}
|
||||
.logo p{font-size:.7rem;color:#8a8a8a;margin-top:.3rem}
|
||||
h2{font-size:1rem;font-weight:800;color:#1f4e5a;margin-bottom:.8rem;text-align:center}
|
||||
label{display:block;font-size:.7rem;font-weight:600;color:#4a4a4a;margin-bottom:.15rem;margin-top:.6rem}
|
||||
input{width:100%;padding:.55rem .7rem;border:1.5px solid rgba(0,0,0,.1);border-radius:7px;font-size:.82rem;font-family:inherit;outline:none;transition:border .15s}
|
||||
input:focus{border-color:#4a7c8a}
|
||||
.pin-input{text-align:center;font-size:1.5rem;font-weight:900;letter-spacing:.3em;font-family:'Courier New',monospace;color:#1f4e5a}
|
||||
.btn{width:100%;padding:.6rem;border:none;border-radius:8px;background:#1f4e5a;color:#fff;font-size:.85rem;font-weight:700;cursor:pointer;margin-top:.8rem;transition:background .15s}
|
||||
.btn:hover{background:#163a44}
|
||||
.msg{margin-top:.6rem;font-size:.72rem;padding:.4rem .6rem;border-radius:6px;display:none}
|
||||
.msg.err{display:block;background:#f2ddd8;color:#c07a6b}
|
||||
.msg.ok{display:block;background:#dceadd;color:#5a8a5e}
|
||||
.hidden{display:none}
|
||||
.step-hint{font-size:.68rem;color:#8a8a8a;text-align:center;margin-bottom:.6rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<img src="assets/img/geograsim_t.svg" alt="GeoGraSim">
|
||||
<p>Administration</p>
|
||||
</div>
|
||||
|
||||
<!-- Schritt 1: Login -->
|
||||
<div id="step-login">
|
||||
<h2>🔐 Admin-Anmeldung</h2>
|
||||
<label>Benutzername</label>
|
||||
<input type="text" id="a-user" autocomplete="off">
|
||||
<label>Passwort</label>
|
||||
<input type="password" id="a-pass">
|
||||
<button class="btn" onclick="doLogin()">Anmelden</button>
|
||||
</div>
|
||||
|
||||
<!-- Schritt 2: PIN -->
|
||||
<div id="step-pin" class="hidden">
|
||||
<h2>📧 PIN eingeben</h2>
|
||||
<p class="step-hint">Ein 4-stelliger PIN wurde an <strong id="pin-email"></strong> gesendet.</p>
|
||||
<input type="text" id="a-pin" class="pin-input" maxlength="4" placeholder="0000" autocomplete="off">
|
||||
<button class="btn" onclick="doVerify()">Bestätigen</button>
|
||||
</div>
|
||||
|
||||
<div class="msg" id="msg"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var BASE = (window.__GGS__?.baseUrl) || '';
|
||||
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
|
||||
|
||||
async function api(data) {
|
||||
var r = await fetch(BASE + '/api/admin', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
function showMsg(text, type) {
|
||||
var m = document.getElementById('msg');
|
||||
m.textContent = text; m.className = 'msg ' + type;
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
var user = document.getElementById('a-user').value.trim();
|
||||
var pass = document.getElementById('a-pass').value;
|
||||
if (!user || !pass) { showMsg('Bitte ausfüllen.', 'err'); return; }
|
||||
showMsg('Anmelden...', 'ok');
|
||||
var r = await api({action:'login', username:user, password:pass});
|
||||
if (r.error) { showMsg(r.error, 'err'); return; }
|
||||
document.getElementById('pin-email').textContent = r.email || '***';
|
||||
document.getElementById('step-login').classList.add('hidden');
|
||||
document.getElementById('step-pin').classList.remove('hidden');
|
||||
document.getElementById('msg').className = 'msg';
|
||||
document.getElementById('a-pin').focus();
|
||||
}
|
||||
|
||||
async function doVerify() {
|
||||
var pin = document.getElementById('a-pin').value.trim();
|
||||
if (pin.length !== 4) { showMsg('Bitte 4-stelligen PIN eingeben.', 'err'); return; }
|
||||
var r = await api({action:'verify_pin', pin:pin});
|
||||
if (r.error) { showMsg(r.error, 'err'); return; }
|
||||
showMsg('Zugang gewährt!', 'ok');
|
||||
setTimeout(function() { location.href = 'admin-licenses.html'; }, 600);
|
||||
}
|
||||
|
||||
// Auto-redirect if already authenticated
|
||||
api({action:'status'}).then(function(r) {
|
||||
if (r.authenticated) location.href = 'admin-licenses.html';
|
||||
});
|
||||
|
||||
// Enter key handling
|
||||
document.getElementById('a-pass').addEventListener('keydown', function(e) { if(e.key==='Enter') doLogin(); });
|
||||
document.getElementById('a-pin').addEventListener('keydown', function(e) { if(e.key==='Enter') doVerify(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
|
||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" lang="en"/>
|
||||
<title>OSM3S Response</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<p>The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.</p>
|
||||
<p><strong style="color:#FF0000">Error</strong>: runtime error: open64: 0 Success /osm3s_osm_base Dispatcher_Client::request_read_and_idx::timeout. The server is probably too busy to handle your request. </p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
|
||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" lang="en"/>
|
||||
<title>OSM3S Response</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<p>The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.</p>
|
||||
<p><strong style="color:#FF0000">Error</strong>: runtime error: open64: 0 Success /osm3s_osm_base Dispatcher_Client::request_read_and_idx::timeout. The server is probably too busy to handle your request. </p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
CREATE TABLE IF NOT EXISTS geo_waypoints (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
wp_key VARCHAR(32) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
lat DECIMAL(8,5) NOT NULL,
|
||||
lon DECIMAL(8,5) NOT NULL,
|
||||
region VARCHAR(32) DEFAULT 'vorarlberg',
|
||||
wp_type ENUM('base','city','village','peak','target','pass') DEFAULT 'village',
|
||||
info VARCHAR(255),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
DELETE FROM geo_waypoints;
|
||||
|
||||
INSERT INTO geo_waypoints (wp_key, name, lat, lon, region, wp_type, info) VALUES
|
||||
('nenzing','C8 Nenzing (ÖAMTC)',47.18528,9.70556,'vorarlberg','base','ÖAMTC Stützpunkt'),
|
||||
('hohenems','Hohenems (Flugplatz)',47.33833,9.69972,'vorarlberg','base','Flugplatz LOIH'),
|
||||
('feldkirch','Feldkirch',47.23528,9.59806,'vorarlberg','city','Grenzstadt CH/FL'),
|
||||
('rankweil','Rankweil',47.27222,9.64444,'vorarlberg','city','Wallfahrtsort'),
|
||||
('goetzis','Götzis',47.33167,9.63444,'vorarlberg','city','Hypomeeting'),
|
||||
('dornbirn','Dornbirn',47.41250,9.74167,'vorarlberg','city','Größte Stadt Vlbg'),
|
||||
('bregenz','Bregenz',47.50278,9.74722,'vorarlberg','city','Landeshauptstadt'),
|
||||
('lustenau','Lustenau',47.42083,9.65833,'vorarlberg','city','Grenzgemeinde CH'),
|
||||
('bludenz','Bludenz',47.15444,9.82167,'vorarlberg','city','Tor zu 3 Tälern'),
|
||||
('buers','Bürs',47.15139,9.80000,'vorarlberg','village',NULL),
|
||||
('nueziders','Nüziders',47.17083,9.80000,'vorarlberg','village',NULL),
|
||||
('innerbraz','Innerbraz',47.14750,9.90000,'vorarlberg','village','Klostertal'),
|
||||
('kloesterle','Klösterle',47.12917,10.08333,'vorarlberg','village','Klostertal'),
|
||||
('stuben','Stuben am Arlberg',47.13000,10.18750,'vorarlberg','village','Arlberg'),
|
||||
('langen','Langen am Arlberg',47.12833,10.11667,'vorarlberg','village','Arlbergtunnel'),
|
||||
('schruns','Schruns',47.07972,9.91667,'vorarlberg','city','Montafon Hauptort'),
|
||||
('tschagguns','Tschagguns',47.07778,9.90000,'vorarlberg','village','Montafon'),
|
||||
('stgallenk','St. Gallenkirch',47.02222,9.97222,'vorarlberg','village','Montafon'),
|
||||
('gaschurn','Gaschurn',46.98556,10.02278,'vorarlberg','village','Hinteres Montafon'),
|
||||
('partenen','Partenen',46.96889,10.06389,'vorarlberg','village','Silvretta-Zugang'),
|
||||
('thueringen','Thüringen',47.21667,9.76667,'vorarlberg','village','Walgau'),
|
||||
('zwischenwasser','Zwischenwasser',47.23333,9.81667,'vorarlberg','village','Walgau'),
|
||||
('laterns','Laterns',47.26111,9.72500,'vorarlberg','village','Laternsertal'),
|
||||
('damuels','Damüls',47.28333,9.88611,'vorarlberg','village','Schneereichstes Dorf'),
|
||||
('fontanella','Fontanella',47.26667,9.93889,'vorarlberg','village','Großes Walsertal'),
|
||||
('sonntag','Sonntag',47.23333,9.91111,'vorarlberg','village','Großes Walsertal'),
|
||||
('egg','Egg',47.43167,9.89444,'vorarlberg','city','Bregenzerwald'),
|
||||
('bezau','Bezau',47.38611,9.90000,'vorarlberg','city','Käsetradition'),
|
||||
('mellau','Mellau',47.35000,9.88056,'vorarlberg','village','Canyoning'),
|
||||
('au','Au',47.32500,9.97500,'vorarlberg','village','Bregenzerwald'),
|
||||
('schoppernau','Schoppernau',47.30833,10.01667,'vorarlberg','village','Bregenzerwald'),
|
||||
('warth','Warth',47.25333,10.18833,'vorarlberg','village','Ski Arlberg'),
|
||||
('schroecken','Schröcken',47.25833,10.09167,'vorarlberg','village','Hochtannberg'),
|
||||
('brand','Brand',47.10028,9.73361,'vorarlberg','village','Brandnertal'),
|
||||
('riezlern','Riezlern',47.35833,10.19167,'vorarlberg','village','Kleinwalsertal'),
|
||||
('mittelberg','Mittelberg',47.33333,10.15000,'vorarlberg','village','Kleinwalsertal'),
|
||||
('innsbruck','C1 Innsbruck (ÖAMTC)',47.26333,11.39444,'tirol','base','Klinik Innsbruck'),
|
||||
('zams_c5','C5 Zams (ÖAMTC)',47.15972,10.59333,'tirol','base','KH Zams'),
|
||||
('kitzbuehel_c4','C4 Kitzbühel (ÖAMTC)',47.38011,12.35800,'tirol','base','Reith bei Kitzbühel'),
|
||||
('lienz_c7','C7 Lienz (ÖAMTC)',46.82970,12.76470,'tirol','base','Osttirol'),
|
||||
('hall','Hall in Tirol',47.28944,11.50833,'tirol','city','Münzstadt'),
|
||||
('schwaz','Schwaz',47.35222,11.71028,'tirol','city','Silberstadt'),
|
||||
('woergl','Wörgl',47.48889,12.06389,'tirol','city','Freigeld-Experiment'),
|
||||
('kufstein','Kufstein',47.58333,12.16667,'tirol','city','Festungsstadt'),
|
||||
('telfs','Telfs',47.31278,11.06972,'tirol','city','Fasnacht'),
|
||||
('imst','Imst',47.24500,10.73972,'tirol','city','Schemenlaufen'),
|
||||
('landeck','Landeck',47.13944,10.56667,'tirol','city','Inn und Sanna'),
|
||||
('reutte','Reutte',47.48583,10.71806,'tirol','city','Außerfern'),
|
||||
('oetz','Ötz',47.20833,10.89333,'tirol','village','Ötztal'),
|
||||
('soelden','Sölden',46.96667,10.87500,'tirol','village','Gletscherskigebiet'),
|
||||
('obergurgl','Obergurgl',46.87000,11.03000,'tirol','village','1930m Höhe'),
|
||||
('jenbach','Jenbach',47.39222,11.77500,'tirol','city','Dampfzahnradbahn'),
|
||||
('zell_ziller','Zell am Ziller',47.22861,11.88222,'tirol','city','Gauder Fest'),
|
||||
('mayrhofen','Mayrhofen',47.15944,11.86111,'tirol','city','Zillertal'),
|
||||
('hintertux','Hintertux',47.08333,11.65000,'tirol','village','Gletscher 3250m'),
|
||||
('steinach','Steinach am Brenner',47.08861,11.46722,'tirol','village','Wipptal'),
|
||||
('brenner','Brenner',47.04222,11.50667,'tirol','pass','Alpenpass nach Italien'),
|
||||
('matrei','Matrei in Osttirol',47.00000,12.53333,'tirol','village','Iseltal'),
|
||||
('kals','Kals am Großglockner',47.00833,12.64167,'tirol','village','Glockner-Zugang'),
|
||||
('sillian','Sillian',46.75194,12.42194,'tirol','village','Grenze Italien'),
|
||||
('st_anton','St. Anton am Arlberg',47.12861,10.26528,'tirol','village','Wiege des Skilaufs'),
|
||||
('kitzbuehel_ort','Kitzbühel',47.44639,12.39222,'tirol','city','Hahnenkamm'),
|
||||
('st_johann','St. Johann in Tirol',47.52278,12.42472,'tirol','city','Kaisergebirge'),
|
||||
('seefeld','Seefeld',47.33000,11.19000,'tirol','village','Olympia-Ort'),
|
||||
('scharnitz','Scharnitz',47.38889,11.26472,'tirol','village','Karwendel-Tor'),
|
||||
('fulpmes','Fulpmes',47.15583,11.35333,'tirol','village','Stubaital'),
|
||||
('neustift','Neustift im Stubaital',47.11083,11.31167,'tirol','village','Stubaital'),
|
||||
('bergrettung_muels','Skiunfall Damüls-Mellau',47.28000,9.88500,'vorarlberg','target','Skigebiet'),
|
||||
('skiunfall_soelden','Skiunfall Sölden',46.97000,10.86000,'tirol','target','Skigebiet'),
|
||||
('verkehr_a14','Unfall A14 Rankweil',47.26500,9.64750,'vorarlberg','target','Autobahnabfahrt');
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,42 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZg.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuI6fMZg.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuDyYMZg.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuBWYMZg.ttf) format('truetype');
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Inter Font — lokal gehostet (kein Google-Server) */
|
||||
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap; src: url('inter-400.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 500; font-display: swap; src: url('inter-500.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 600; font-display: swap; src: url('inter-600.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: swap; src: url('inter-700.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 800; font-display: swap; src: url('inter-800.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 900; font-display: swap; src: url('inter-900.ttf') format('truetype'); }
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 1018 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 857 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1010 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,583 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0.00 0.00 1008.00 159.00">
|
||||
<path fill="#2eabac" d="
|
||||
M 639.6017 14.2979
|
||||
A 14.14 13.50 -179.1 0 1 653.9520 1.0217
|
||||
A 14.14 13.50 -179.1 0 1 667.8783 14.7421
|
||||
A 14.14 13.50 -179.1 0 1 653.5280 28.0183
|
||||
A 14.14 13.50 -179.1 0 1 639.6017 14.2979
|
||||
Z"
|
||||
/>
|
||||
<path fill="#f18215" d="
|
||||
M 958.91 93.20
|
||||
L 958.90 92.29
|
||||
Q 958.76 91.36 959.23 90.70
|
||||
A 0.81 0.77 -28.5 0 0 959.38 90.24
|
||||
L 959.38 60.73
|
||||
A 0.63 0.63 0.0 0 0 958.75 60.10
|
||||
L 950.20 60.10
|
||||
A 1.55 1.55 0.0 0 1 948.65 58.55
|
||||
L 948.65 43.17
|
||||
A 0.80 0.79 90.0 0 1 949.44 42.37
|
||||
L 957.55 42.37
|
||||
A 1.36 1.36 0.0 0 0 958.91 41.01
|
||||
L 958.91 23.73
|
||||
A 0.27 0.25 83.1 0 1 959.10 23.47
|
||||
Q 959.34 23.41 959.37 23.39
|
||||
A 0.26 0.26 0.0 0 0 959.36 22.95
|
||||
Q 959.26 22.90 959.20 22.74
|
||||
A 0.15 0.15 0.0 0 1 959.29 22.55
|
||||
Q 960.93 21.96 961.51 21.95
|
||||
Q 971.27 21.86 981.52 21.90
|
||||
A 1.98 1.97 -90.0 0 1 983.49 23.88
|
||||
L 983.49 41.17
|
||||
A 1.17 1.17 0.0 0 0 984.66 42.34
|
||||
L 1002.66 42.34
|
||||
A 1.42 1.42 0.0 0 1 1004.08 43.76
|
||||
L 1004.08 58.54
|
||||
A 1.86 1.86 0.0 0 1 1002.22 60.40
|
||||
L 984.13 60.40
|
||||
A 0.56 0.56 0.0 0 0 983.57 60.96
|
||||
L 983.57 97.43
|
||||
A 2.30 2.21 35.8 0 0 983.71 98.21
|
||||
Q 985.24 102.41 990.20 104.08
|
||||
Q 991.53 104.53 993.32 104.01
|
||||
Q 994.25 103.75 995.46 103.86
|
||||
Q 996.27 103.93 997.50 103.40
|
||||
Q 999.72 102.43 1001.78 101.40
|
||||
A 0.56 0.55 67.1 0 1 1002.55 101.72
|
||||
L 1007.39 116.00
|
||||
A 1.19 1.18 65.2 0 1 1006.88 117.40
|
||||
Q 998.23 122.64 989.50 123.28
|
||||
C 975.60 124.30 963.82 118.37 960.38 104.37
|
||||
Q 959.76 101.88 958.91 93.20
|
||||
Z"
|
||||
/>
|
||||
<path fill="#143b4b" d="
|
||||
M 67.41 42.82
|
||||
Q 67.57 44.51 68.77 45.22
|
||||
A 0.48 0.47 -63.5 0 0 69.45 44.99
|
||||
Q 69.99 43.66 69.11 42.97
|
||||
L 69.59 35.41
|
||||
A 1.67 1.66 -88.2 0 1 71.25 33.85
|
||||
L 92.64 33.85
|
||||
A 1.17 1.16 90.0 0 1 93.80 35.02
|
||||
Q 93.69 86.70 93.74 113.26
|
||||
Q 93.75 118.58 93.38 121.21
|
||||
Q 92.39 128.25 92.09 128.96
|
||||
Q 91.03 131.40 90.53 134.27
|
||||
Q 90.47 134.59 90.31 134.68
|
||||
Q 90.25 134.72 90.09 134.81
|
||||
A 0.63 0.60 86.4 0 0 89.81 135.14
|
||||
C 86.62 144.38 77.88 151.31 69.02 154.27
|
||||
Q 67.28 154.86 63.72 155.90
|
||||
A 6.38 5.92 33.1 0 1 62.32 156.14
|
||||
C 58.05 156.41 53.99 157.54 49.73 157.52
|
||||
Q 43.78 157.49 40.03 157.41
|
||||
A 3.20 3.15 55.0 0 1 39.17 157.25
|
||||
Q 35.67 156.06 31.76 156.37
|
||||
A 0.44 0.42 78.2 0 1 31.33 156.08
|
||||
Q 31.31 156.00 31.30 155.87
|
||||
A 0.26 0.25 -88.7 0 0 31.09 155.64
|
||||
Q 21.55 154.04 12.55 148.99
|
||||
Q 10.82 148.02 9.28 146.62
|
||||
A 1.00 0.99 -57.7 0 1 9.03 145.50
|
||||
L 15.62 129.71
|
||||
A 0.33 0.33 0.0 0 1 16.21 129.68
|
||||
Q 16.57 130.34 18.08 130.83
|
||||
Q 18.64 131.01 19.87 131.73
|
||||
Q 27.47 136.20 36.14 137.70
|
||||
A 1.12 0.17 35.4 0 1 36.77 138.02
|
||||
Q 36.90 138.10 37.12 138.12
|
||||
C 44.49 138.82 51.75 139.33 57.73 136.54
|
||||
Q 67.37 132.04 69.05 120.63
|
||||
Q 69.88 115.05 69.13 108.72
|
||||
Q 69.67 107.54 69.31 106.46
|
||||
A 0.46 0.45 -23.5 0 0 68.66 106.21
|
||||
Q 67.36 106.90 68.02 108.02
|
||||
C 66.49 109.05 64.69 109.93 63.40 111.10
|
||||
Q 58.45 115.60 53.07 117.28
|
||||
Q 43.27 120.35 32.52 118.35
|
||||
Q 22.18 116.43 14.40 108.89
|
||||
Q 5.16 99.95 1.94 86.58
|
||||
Q 1.12 83.15 1.40 82.29
|
||||
Q 1.44 82.18 0.90 79.71
|
||||
C 0.20 76.51 0.23 71.00 0.71 69.44
|
||||
Q 1.42 67.11 1.48 64.62
|
||||
A 5.99 5.85 -36.9 0 1 1.73 62.99
|
||||
Q 2.76 59.43 3.93 56.68
|
||||
Q 8.42 46.12 17.16 38.91
|
||||
Q 21.44 35.38 27.76 33.50
|
||||
Q 39.41 30.04 51.35 32.59
|
||||
A 1.93 0.17 24.3 0 1 52.29 32.92
|
||||
Q 52.81 33.14 54.34 33.65
|
||||
Q 61.75 36.09 66.46 42.31
|
||||
Q 66.65 42.56 67.41 42.82
|
||||
Z
|
||||
M 41.28 97.88
|
||||
Q 45.12 99.32 49.34 98.77
|
||||
A 0.31 0.31 0.0 0 0 49.59 98.58
|
||||
Q 49.66 98.42 49.63 98.30
|
||||
C 66.86 97.37 70.27 80.00 66.73 66.48
|
||||
Q 64.46 57.76 56.56 53.80
|
||||
Q 55.42 53.23 54.20 53.13
|
||||
Q 53.25 53.05 51.69 52.40
|
||||
A 2.21 2.11 57.7 0 0 51.05 52.24
|
||||
L 44.30 51.73
|
||||
A 3.45 3.44 -48.9 0 0 43.29 51.80
|
||||
Q 31.02 54.52 27.61 66.79
|
||||
Q 24.74 77.10 28.51 86.66
|
||||
Q 29.45 89.03 31.14 91.04
|
||||
Q 32.09 92.18 34.70 94.56
|
||||
Q 36.93 96.60 41.28 97.88
|
||||
Z"
|
||||
/>
|
||||
<g stroke-width="2.00" fill="none" stroke-linecap="butt">
|
||||
<path stroke="#21737c" vector-effect="non-scaling-stroke" d="
|
||||
M 41.28 97.88
|
||||
Q 45.12 99.32 49.34 98.77
|
||||
A 0.31 0.31 0.0 0 0 49.59 98.58
|
||||
Q 49.66 98.42 49.63 98.30"
|
||||
/>
|
||||
<path stroke="#21737c" vector-effect="non-scaling-stroke" d="
|
||||
M 45.79 97.86
|
||||
L 48.46 97.90"
|
||||
/>
|
||||
</g>
|
||||
<path fill="#143b4b" d="
|
||||
M 192.95 85.39
|
||||
Q 178.82 85.45 157.50 85.33
|
||||
Q 155.77 85.32 151.57 85.02
|
||||
Q 142.07 84.34 132.86 85.35
|
||||
A 0.59 0.59 0.0 0 0 132.34 85.96
|
||||
Q 132.53 90.28 134.78 94.27
|
||||
Q 137.08 98.34 141.21 101.19
|
||||
Q 144.64 103.55 150.01 104.45
|
||||
Q 154.96 105.28 160.47 104.45
|
||||
Q 165.36 103.71 169.80 101.09
|
||||
Q 174.10 98.55 177.84 95.10
|
||||
A 0.86 0.86 0.0 0 1 179.08 95.16
|
||||
L 190.77 108.35
|
||||
A 0.58 0.57 46.9 0 1 190.75 109.14
|
||||
Q 180.17 119.66 165.73 122.39
|
||||
Q 155.72 124.28 144.99 122.56
|
||||
Q 139.45 121.67 135.82 120.51
|
||||
C 121.41 115.89 111.51 102.48 108.29 88.04
|
||||
Q 107.41 84.10 107.44 78.84
|
||||
Q 107.48 71.48 107.63 69.57
|
||||
Q 107.95 65.39 110.59 58.25
|
||||
Q 110.78 57.73 111.25 57.43
|
||||
C 111.60 57.20 111.53 56.10 112.02 55.29
|
||||
Q 118.26 45.16 125.03 39.78
|
||||
Q 130.26 35.62 139.36 32.95
|
||||
Q 146.36 30.89 157.27 31.65
|
||||
Q 176.28 32.98 187.63 48.73
|
||||
C 189.34 51.10 190.64 54.61 192.17 57.55
|
||||
A 2.59 2.58 28.5 0 1 192.44 58.46
|
||||
Q 192.55 59.69 193.13 60.80
|
||||
C 194.13 62.73 194.40 66.29 194.72 67.54
|
||||
C 195.92 72.22 195.49 77.76 195.17 83.28
|
||||
A 2.24 2.23 -88.5 0 1 192.95 85.39
|
||||
Z
|
||||
M 133.58 69.33
|
||||
L 171.04 69.39
|
||||
A 0.65 0.65 0.0 0 0 171.69 68.74
|
||||
L 171.70 67.91
|
||||
A 17.58 17.19 -89.9 0 0 154.54 50.30
|
||||
L 150.16 50.30
|
||||
A 17.58 17.19 -89.9 0 0 132.94 67.85
|
||||
L 132.93 68.68
|
||||
A 0.65 0.65 0.0 0 0 133.58 69.33
|
||||
Z"
|
||||
/>
|
||||
<path fill="#143b4b" d="
|
||||
M 298.47 76.50
|
||||
Q 298.50 78.06 298.47 78.76
|
||||
Q 297.40 103.35 278.53 116.04
|
||||
C 268.92 122.50 257.43 124.36 245.81 123.52
|
||||
C 229.83 122.36 216.95 114.22 209.23 100.53
|
||||
Q 205.68 94.23 204.75 87.75
|
||||
C 204.28 84.45 203.66 82.06 203.58 78.69
|
||||
C 203.51 75.33 204.01 72.91 204.33 69.60
|
||||
Q 204.96 63.08 208.21 56.62
|
||||
C 215.29 42.59 227.78 33.86 243.69 31.96
|
||||
C 255.26 30.59 266.82 31.91 276.72 37.92
|
||||
Q 296.16 49.73 298.37 74.24
|
||||
Q 298.43 74.93 298.47 76.50
|
||||
Z
|
||||
M 251.43 103.41
|
||||
Q 252.56 103.40 252.99 103.34
|
||||
Q 267.97 101.38 271.69 87.03
|
||||
Q 273.13 81.48 272.91 75.25
|
||||
C 272.85 73.37 272.15 71.97 271.99 70.25
|
||||
Q 271.82 68.46 271.16 66.52
|
||||
Q 266.90 54.12 253.47 51.91
|
||||
Q 252.49 51.74 250.76 51.76
|
||||
Q 249.04 51.79 248.06 51.97
|
||||
Q 234.69 54.53 230.75 67.04
|
||||
Q 230.14 68.99 230.02 70.79
|
||||
C 229.90 72.51 229.24 73.93 229.23 75.81
|
||||
Q 229.17 82.04 230.75 87.55
|
||||
Q 234.83 101.80 249.86 103.38
|
||||
Q 250.29 103.43 251.43 103.41
|
||||
Z"
|
||||
/>
|
||||
<path fill="#143b4b" d="
|
||||
M 372.63 42.80
|
||||
Q 373.09 44.04 374.13 44.66
|
||||
A 0.61 0.61 0.0 0 0 375.05 44.16
|
||||
Q 375.09 43.19 374.79 42.79
|
||||
L 375.19 35.55
|
||||
A 1.28 1.28 0.0 0 1 376.47 34.34
|
||||
L 397.58 34.34
|
||||
A 1.81 1.80 89.8 0 1 399.38 36.14
|
||||
Q 399.47 48.02 399.43 113.92
|
||||
Q 399.42 116.72 399.10 119.32
|
||||
Q 398.80 121.79 398.91 124.20
|
||||
A 3.19 3.18 52.0 0 1 398.79 125.20
|
||||
C 397.71 129.15 396.61 133.40 395.01 136.74
|
||||
Q 388.84 149.66 374.74 154.74
|
||||
Q 369.67 156.57 366.43 156.62
|
||||
Q 366.00 156.62 365.13 157.19
|
||||
A 2.01 2.00 -62.8 0 1 364.17 157.50
|
||||
Q 356.13 158.11 344.76 157.62
|
||||
Q 342.98 157.55 340.49 156.77
|
||||
Q 338.25 156.08 336.00 156.39
|
||||
Q 334.87 156.55 334.61 155.74
|
||||
A 0.68 0.67 79.4 0 0 333.93 155.27
|
||||
C 331.48 155.38 329.83 153.78 327.38 153.45
|
||||
Q 326.80 153.37 325.48 152.82
|
||||
Q 319.45 150.29 314.70 146.91
|
||||
A 0.40 0.39 -60.9 0 1 314.57 146.45
|
||||
L 321.31 130.29
|
||||
A 0.80 0.80 0.0 0 1 322.49 129.93
|
||||
Q 330.93 135.49 340.51 138.01
|
||||
C 342.61 138.56 344.90 138.16 346.59 139.06
|
||||
A 2.23 2.12 -27.0 0 0 347.43 139.31
|
||||
Q 355.50 139.94 360.87 137.84
|
||||
C 372.77 133.20 376.49 121.02 374.43 109.31
|
||||
Q 375.10 108.33 374.77 107.56
|
||||
A 0.53 0.53 0.0 0 0 374.03 107.29
|
||||
Q 372.87 107.87 373.26 109.10
|
||||
Q 372.01 109.43 371.40 109.91
|
||||
Q 366.36 113.95 362.80 116.04
|
||||
Q 358.47 118.58 351.23 119.55
|
||||
Q 347.21 120.09 340.45 119.28
|
||||
Q 334.71 118.60 330.17 116.22
|
||||
Q 322.44 112.15 316.65 105.39
|
||||
Q 311.27 99.12 308.62 90.15
|
||||
Q 305.04 78.07 307.65 64.67
|
||||
Q 309.72 54.03 317.44 44.70
|
||||
Q 320.61 40.87 323.23 39.00
|
||||
Q 334.95 30.64 349.27 31.58
|
||||
Q 353.39 31.85 357.78 32.70
|
||||
Q 358.06 32.76 358.34 33.06
|
||||
A 1.08 1.03 -10.3 0 0 358.78 33.35
|
||||
C 364.56 35.41 367.66 38.28 372.63 42.80
|
||||
Z
|
||||
M 360.30 97.84
|
||||
Q 368.00 95.55 370.77 89.71
|
||||
Q 376.54 77.54 371.88 64.44
|
||||
Q 369.26 57.08 361.16 53.58
|
||||
A 3.73 3.56 -27.9 0 0 360.24 53.32
|
||||
Q 358.49 53.08 354.88 52.18
|
||||
C 352.45 51.57 346.97 52.99 345.36 53.57
|
||||
C 335.30 57.15 331.73 67.60 331.91 77.61
|
||||
C 331.96 80.25 333.18 83.33 333.66 85.50
|
||||
Q 334.28 88.32 336.11 90.96
|
||||
Q 340.54 97.33 348.03 99.10
|
||||
Q 354.15 100.55 359.72 98.05
|
||||
A 4.42 3.10 -55.0 0 1 360.30 97.84
|
||||
Z"
|
||||
/>
|
||||
<path fill="#143b4b" d="
|
||||
M 441.02 47.80
|
||||
Q 440.58 48.79 440.61 49.38
|
||||
Q 440.69 50.66 441.73 49.73
|
||||
Q 442.41 49.12 442.42 47.55
|
||||
Q 443.24 47.23 443.44 46.90
|
||||
Q 451.32 33.98 466.77 34.56
|
||||
A 1.16 1.16 0.0 0 1 467.62 34.98
|
||||
Q 468.61 36.17 468.44 38.02
|
||||
C 468.05 42.53 468.53 45.49 468.31 49.65
|
||||
Q 468.09 53.61 467.85 56.06
|
||||
A 1.29 1.29 0.0 0 1 466.50 57.22
|
||||
Q 457.84 56.84 450.53 61.01
|
||||
C 445.94 63.63 443.22 68.90 441.76 74.10
|
||||
Q 440.80 77.49 440.79 83.51
|
||||
Q 440.73 105.64 440.80 120.30
|
||||
A 1.25 1.24 85.7 0 1 439.75 121.53
|
||||
L 436.28 122.07
|
||||
A 0.48 0.48 0.0 0 1 435.92 121.98
|
||||
Q 435.16 121.44 431.23 121.72
|
||||
Q 430.42 121.78 430.31 121.94
|
||||
Q 430.04 122.36 429.78 121.89
|
||||
A 0.42 0.40 75.0 0 0 429.42 121.67
|
||||
L 418.20 121.67
|
||||
A 1.44 1.43 0.0 0 1 416.76 120.24
|
||||
L 416.76 37.10
|
||||
A 0.83 0.83 0.0 0 1 417.59 36.27
|
||||
L 439.62 36.27
|
||||
A 0.65 0.65 0.0 0 1 440.27 36.88
|
||||
L 441.02 47.80
|
||||
Z"
|
||||
/>
|
||||
<path fill="#143b4b" d="
|
||||
M 526.35 114.30
|
||||
Q 526.92 113.68 526.71 112.96
|
||||
A 0.79 0.79 0.0 0 0 525.20 112.93
|
||||
Q 524.89 113.79 524.97 114.23
|
||||
Q 519.11 119.08 517.02 120.07
|
||||
Q 508.73 124.01 495.47 123.34
|
||||
A 4.12 4.10 56.5 0 1 494.21 123.08
|
||||
Q 492.64 122.50 490.34 122.50
|
||||
A 3.80 3.79 54.9 0 1 489.04 122.27
|
||||
Q 486.31 121.25 484.61 120.42
|
||||
Q 479.37 117.87 475.87 113.39
|
||||
C 471.33 107.58 470.49 99.53 471.92 92.45
|
||||
Q 472.98 87.13 476.33 83.19
|
||||
Q 479.63 79.29 484.62 77.11
|
||||
Q 487.75 75.75 490.48 75.03
|
||||
C 496.58 73.43 501.67 72.52 507.97 72.51
|
||||
Q 523.34 72.50 524.81 72.44
|
||||
A 0.78 0.77 -87.3 0 0 525.54 71.77
|
||||
Q 526.88 62.40 519.65 58.29
|
||||
Q 513.12 54.57 505.51 55.82
|
||||
Q 500.48 56.64 496.82 57.71
|
||||
Q 493.63 58.66 493.43 58.84
|
||||
Q 492.61 59.60 491.64 59.53
|
||||
Q 490.89 59.49 490.50 60.11
|
||||
Q 490.16 60.66 489.20 60.57
|
||||
A 0.75 0.74 -76.2 0 0 488.46 61.02
|
||||
Q 488.19 61.69 487.32 61.56
|
||||
A 1.12 1.10 25.9 0 0 486.37 61.88
|
||||
Q 485.45 62.83 485.04 62.74
|
||||
A 3.67 3.18 -87.4 0 1 482.54 59.57
|
||||
Q 482.43 58.38 481.94 57.57
|
||||
Q 481.16 56.28 479.23 51.82
|
||||
Q 478.16 49.35 477.53 48.69
|
||||
Q 477.42 48.58 476.93 47.35
|
||||
A 1.09 1.09 0.0 0 1 477.35 46.02
|
||||
Q 484.58 41.32 492.72 39.00
|
||||
Q 509.16 34.31 524.70 37.50
|
||||
Q 539.91 40.63 546.54 54.20
|
||||
Q 549.78 60.83 549.78 68.48
|
||||
Q 549.77 99.53 549.79 111.51
|
||||
A 4.52 4.23 -53.4 0 0 549.90 112.49
|
||||
Q 550.64 116.02 550.23 120.17
|
||||
A 1.67 1.67 0.0 0 1 548.57 121.67
|
||||
L 528.06 121.67
|
||||
A 1.55 1.55 0.0 0 1 526.51 120.16
|
||||
L 526.35 114.30
|
||||
Z
|
||||
M 495.68 99.88
|
||||
Q 496.32 102.01 497.84 103.28
|
||||
Q 498.35 103.71 500.81 104.92
|
||||
C 506.75 107.84 515.11 107.11 520.30 102.78
|
||||
Q 526.48 97.63 525.55 88.08
|
||||
A 0.77 0.76 -1.7 0 0 524.81 87.39
|
||||
Q 517.19 87.17 506.47 87.29
|
||||
Q 500.18 87.36 496.92 90.84
|
||||
A 3.64 3.51 -11.2 0 0 496.30 91.73
|
||||
Q 494.39 95.59 495.68 99.88
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 570.30 97.97
|
||||
C 579.68 103.02 588.05 107.23 598.99 106.55
|
||||
C 602.59 106.33 606.61 104.04 606.49 99.90
|
||||
C 606.37 96.00 601.94 92.74 598.58 91.69
|
||||
Q 587.97 88.41 580.70 85.37
|
||||
Q 574.37 82.73 569.31 77.87
|
||||
C 562.15 71.02 561.61 60.69 566.14 52.23
|
||||
C 571.37 42.49 580.25 37.66 591.03 36.57
|
||||
Q 610.68 34.60 626.41 46.30
|
||||
A 0.97 0.97 0.0 0 1 626.67 47.57
|
||||
L 619.12 60.65
|
||||
A 0.64 0.64 0.0 0 1 618.24 60.89
|
||||
C 609.57 55.88 597.81 50.23 587.92 56.15
|
||||
A 3.16 3.16 0.0 0 0 586.43 58.34
|
||||
Q 585.39 64.45 591.14 66.89
|
||||
C 600.34 70.81 612.97 73.58 621.57 81.02
|
||||
C 629.07 87.50 629.24 95.13 628.04 104.50
|
||||
A 3.88 3.61 61.4 0 1 627.80 105.44
|
||||
C 623.68 116.26 612.78 121.89 601.73 123.09
|
||||
Q 584.19 124.99 567.59 116.67
|
||||
Q 563.80 114.77 560.46 111.68
|
||||
A 0.59 0.58 39.6 0 1 560.38 110.90
|
||||
L 569.57 98.14
|
||||
A 0.57 0.57 0.0 0 1 570.30 97.97
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 682.39 120.98
|
||||
Q 681.90 118.57 681.89 117.28
|
||||
Q 681.41 77.58 681.79 40.05
|
||||
A 1.63 1.63 0.0 0 1 683.42 38.43
|
||||
L 704.85 38.43
|
||||
A 0.65 0.64 0.0 0 1 705.50 39.07
|
||||
L 705.50 48.36
|
||||
A 0.50 0.50 0.0 0 0 705.98 48.86
|
||||
C 707.21 48.91 707.75 48.80 708.56 47.74
|
||||
C 715.99 38.07 728.73 34.30 740.36 38.23
|
||||
Q 749.41 41.28 755.48 50.15
|
||||
A 1.61 1.61 0.0 0 0 758.09 50.22
|
||||
C 764.84 41.37 775.23 36.30 786.02 36.57
|
||||
Q 795.86 36.82 802.81 42.55
|
||||
C 810.28 48.71 812.84 56.21 814.30 65.65
|
||||
Q 814.75 68.58 814.55 73.88
|
||||
Q 814.40 77.85 814.51 85.00
|
||||
Q 814.76 100.98 814.59 120.17
|
||||
A 1.44 1.43 -89.8 0 1 813.16 121.60
|
||||
L 790.84 121.60
|
||||
A 0.55 0.55 0.0 0 1 790.29 121.02
|
||||
Q 790.49 117.64 790.26 115.99
|
||||
Q 789.91 113.42 789.91 110.99
|
||||
Q 789.96 77.88 789.80 71.50
|
||||
C 789.70 67.36 787.29 62.54 783.56 60.02
|
||||
C 779.63 57.37 771.68 58.27 767.77 60.48
|
||||
C 759.68 65.04 760.71 77.86 760.66 86.12
|
||||
Q 760.57 102.06 760.66 121.12
|
||||
A 0.34 0.33 -0.8 0 1 760.32 121.46
|
||||
L 737.53 121.46
|
||||
A 1.00 0.99 0.0 0 1 736.53 120.47
|
||||
Q 736.60 92.20 736.44 75.26
|
||||
C 736.34 64.41 731.27 57.60 719.74 58.17
|
||||
C 709.84 58.67 706.30 67.27 706.34 75.93
|
||||
Q 706.45 99.49 706.40 120.53
|
||||
A 0.91 0.91 0.0 0 1 705.49 121.44
|
||||
L 682.94 121.44
|
||||
A 0.57 0.56 84.4 0 1 682.39 120.98
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 665.57 120.36
|
||||
A 1.25 1.25 0.0 0 1 664.32 121.61
|
||||
L 642.64 121.61
|
||||
A 1.25 1.25 0.0 0 1 641.39 120.36
|
||||
L 641.39 39.50
|
||||
A 1.25 1.25 0.0 0 1 642.64 38.25
|
||||
L 664.32 38.25
|
||||
A 1.25 1.25 0.0 0 1 665.57 39.50
|
||||
L 665.57 120.36
|
||||
Z"
|
||||
/>
|
||||
<path fill="#f18215" d="
|
||||
M 915.55 73.16
|
||||
C 915.76 66.63 914.22 61.35 907.77 58.97
|
||||
Q 907.40 58.84 901.88 57.60
|
||||
A 7.50 7.37 -40.8 0 0 899.55 57.45
|
||||
Q 893.26 58.02 887.50 59.40
|
||||
C 883.22 60.42 877.98 63.20 874.53 64.55
|
||||
A 0.33 0.32 -24.1 0 1 874.14 64.41
|
||||
L 865.94 50.21
|
||||
A 1.01 1.00 -30.4 0 1 866.30 48.84
|
||||
C 878.38 41.70 890.91 37.65 905.24 38.34
|
||||
C 919.04 39.01 932.19 46.29 937.18 59.60
|
||||
Q 939.51 65.84 939.48 74.01
|
||||
Q 939.36 103.96 939.49 120.90
|
||||
A 0.72 0.72 0.0 0 1 938.77 121.62
|
||||
L 917.23 121.62
|
||||
A 0.81 0.81 0.0 0 1 916.42 120.81
|
||||
L 916.42 115.34
|
||||
A 1.41 1.41 0.0 0 0 914.06 114.30
|
||||
C 910.30 117.76 907.24 120.23 902.51 121.76
|
||||
Q 894.21 124.46 885.52 123.48
|
||||
C 873.42 122.11 863.05 114.55 861.55 101.69
|
||||
Q 860.63 93.81 865.30 86.88
|
||||
C 870.58 79.06 882.88 75.38 891.99 74.49
|
||||
A 2.55 1.91 -55.8 0 0 892.92 74.17
|
||||
C 894.07 73.48 904.71 73.86 914.91 73.78
|
||||
A 0.64 0.64 0.0 0 0 915.55 73.16
|
||||
Z
|
||||
M 915.40 89.52
|
||||
A 0.35 0.35 0.0 0 0 915.05 89.20
|
||||
L 893.02 89.20
|
||||
A 4.50 4.31 -53.3 0 0 891.59 89.44
|
||||
Q 883.19 92.34 885.91 100.81
|
||||
Q 887.20 104.81 890.50 105.97
|
||||
Q 898.06 108.62 906.16 106.51
|
||||
Q 910.68 105.33 913.45 100.02
|
||||
Q 915.89 95.33 915.40 89.52
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 69.11 42.97
|
||||
Q 69.99 43.66 69.45 44.99
|
||||
A 0.48 0.47 -63.5 0 1 68.77 45.22
|
||||
Q 67.57 44.51 67.41 42.82
|
||||
Q 67.88 42.68 69.11 42.97
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 374.79 42.79
|
||||
Q 375.09 43.19 375.05 44.16
|
||||
A 0.61 0.61 0.0 0 1 374.13 44.66
|
||||
Q 373.09 44.04 372.63 42.80
|
||||
L 374.79 42.79
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 442.42 47.55
|
||||
Q 442.41 49.12 441.73 49.73
|
||||
Q 440.69 50.66 440.61 49.38
|
||||
Q 440.58 48.79 441.02 47.80
|
||||
L 442.42 47.55
|
||||
Z"
|
||||
/>
|
||||
<path fill="#143b4b" d="
|
||||
M 958.90 92.29
|
||||
L 958.91 93.20
|
||||
Q 958.83 92.77 958.90 92.29
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 45.79 97.86
|
||||
L 48.46 97.90
|
||||
L 49.63 98.30
|
||||
Q 49.66 98.42 49.59 98.58
|
||||
A 0.31 0.31 0.0 0 1 49.34 98.77
|
||||
Q 45.12 99.32 41.28 97.88
|
||||
L 45.79 97.86
|
||||
Z"
|
||||
/>
|
||||
<path fill="#143b4b" d="
|
||||
M 48.46 97.90
|
||||
L 45.79 97.86
|
||||
L 48.46 97.90
|
||||
Z"
|
||||
/>
|
||||
<path fill="#f18215" d="
|
||||
M 827.5821 107.0823
|
||||
A 12.87 12.44 -164.6 0 1 843.2935 98.5067
|
||||
A 12.87 12.44 -164.6 0 1 852.3979 113.9177
|
||||
A 12.87 12.44 -164.6 0 1 836.6865 122.4933
|
||||
A 12.87 12.44 -164.6 0 1 827.5821 107.0823
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 69.13 108.72
|
||||
L 68.02 108.02
|
||||
Q 67.36 106.90 68.66 106.21
|
||||
A 0.46 0.45 -23.5 0 1 69.31 106.46
|
||||
Q 69.67 107.54 69.13 108.72
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 374.43 109.31
|
||||
Q 374.10 109.00 373.26 109.10
|
||||
Q 372.87 107.87 374.03 107.29
|
||||
A 0.53 0.53 0.0 0 1 374.77 107.56
|
||||
Q 375.10 108.33 374.43 109.31
|
||||
Z"
|
||||
/>
|
||||
<path fill="#2eabac" d="
|
||||
M 526.35 114.30
|
||||
L 524.97 114.23
|
||||
Q 524.89 113.79 525.20 112.93
|
||||
A 0.79 0.79 0.0 0 1 526.71 112.96
|
||||
Q 526.92 113.68 526.35 114.30
|
||||
Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Barrierefreiheitserklärung</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;color:#1a1a1a;line-height:1.7}
|
||||
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);flex-wrap:wrap}
|
||||
.topbar a{text-decoration:none}
|
||||
.topbar img{height:32px}
|
||||
.topbar .spacer{flex:1}
|
||||
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;color:#4a4a4a;text-decoration:none}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:2rem 1.5rem 4rem}
|
||||
h1{font-size:1.6rem;font-weight:900;color:#1f4e5a;margin-bottom:.5rem}
|
||||
h2{font-size:1rem;font-weight:800;color:#1f4e5a;margin:1.5rem 0 .4rem;padding-top:.8rem;border-top:1px solid rgba(0,0,0,.06)}
|
||||
h3{font-size:.85rem;font-weight:700;color:#4a7c8a;margin:1rem 0 .2rem}
|
||||
p,li{font-size:.85rem;color:#333;margin-bottom:.5rem}
|
||||
ul{margin-left:1.2rem;margin-bottom:.8rem}
|
||||
.meta{font-size:.75rem;color:#8a8a8a;margin-bottom:1.5rem}
|
||||
.footer-note{font-size:.72rem;color:#8a8a8a;text-align:center;margin-top:2rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.06)}
|
||||
.footer-note a{color:#4a7c8a;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<a href="index.html"><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"></a>
|
||||
<span class="spacer"></span>
|
||||
<a href="index.html" class="btn-sm">Startseite</a>
|
||||
<a href="ueber.html" class="btn-sm">Über</a>
|
||||
<a href="handbuch.html" class="btn-sm">Handbuch</a>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>Barrierefreiheitserklärung</h1>
|
||||
<p class="meta">für die Lern-App GeoGraSim (geograsim.at) — gemäß Web-Zugänglichkeits-Gesetz (WZG), BGBl. I Nr. 59/2019</p>
|
||||
|
||||
<p>GeoGraSim ist eine webbasierte Lernumgebung für interaktive Geografie-Simulationen. Die Anwendung richtet sich an Schüler*innen der 5. bis 10. Schulstufe und wird bewusst für den inklusiven Unterricht konzipiert.</p>
|
||||
|
||||
<h2>Stand der Vereinbarkeit</h2>
|
||||
<p>Diese Lern-App ist <strong>teilweise vereinbar</strong> mit den Anforderungen der Richtlinie (EU) 2016/2102 in Verbindung mit den Web Content Accessibility Guidelines (WCAG) 2.1, Konformitätsstufe AA. Es wird kontinuierlich an Verbesserungen gearbeitet.</p>
|
||||
|
||||
<h2>Bereits umgesetzte Barrierefreiheitsfeatures</h2>
|
||||
|
||||
<h3>1. Semantische HTML-Struktur</h3>
|
||||
<ul>
|
||||
<li>Verwendung semantischer HTML5-Elemente (nav, main, section, article, header, footer)</li>
|
||||
<li>Korrekte Überschriftenhierarchie (h1–h3)</li>
|
||||
<li>Labels für alle Formularfelder</li>
|
||||
<li>Alt-Texte für Bilder und Icons</li>
|
||||
</ul>
|
||||
|
||||
<h3>2. Kontrast und Farbgestaltung</h3>
|
||||
<ul>
|
||||
<li>Kontrastverhältnis von mindestens 4.5:1 für normalen Text</li>
|
||||
<li>Farben werden nie als einziges Unterscheidungsmerkmal verwendet — Symbole und Text ergänzen Farbkodierungen</li>
|
||||
<li>Berücksichtigung von Farbfehlsichtigkeit bei allen Rückmeldungen (Rot/Grün wird durch Symbole ✓/✗ ergänzt)</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Responsive Design</h3>
|
||||
<ul>
|
||||
<li>Alle Seiten funktionieren auf Smartphones, Tablets und Desktop-Computern</li>
|
||||
<li>Zoom-Funktion des Browsers wird unterstützt (bis 200% ohne Informationsverlust)</li>
|
||||
<li>Touch-Bedienung für Tablets (On-Screen-Steuerung bei Simulationen)</li>
|
||||
</ul>
|
||||
|
||||
<h3>4. Tastaturbedienbarkeit</h3>
|
||||
<ul>
|
||||
<li>Alle interaktiven Elemente (Links, Buttons, Formulare) sind per Tastatur erreichbar</li>
|
||||
<li>Simulationen bieten Tastatursteuerung (Pfeiltasten, R für Reset, M für Mute)</li>
|
||||
<li>Sichtbarer Fokus-Indikator bei Navigation per Tastatur</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Reizarme Darstellung</h3>
|
||||
<ul>
|
||||
<li>Mute-Button in allen Simulationen (Taste M) zum Abschalten aller Sounds</li>
|
||||
<li>Keine blinkenden oder flackernden Elemente</li>
|
||||
<li>Reduzierte Animationen — SVG-Landschaftselemente sind dezent und langsam</li>
|
||||
<li>Klare, lineare Aufgabenstruktur in allen Simulationen</li>
|
||||
</ul>
|
||||
|
||||
<h3>6. Differenzierung und Nachteilsausgleich</h3>
|
||||
<ul>
|
||||
<li>Drei Schwierigkeitsstufen pro Simulation</li>
|
||||
<li>Individuelles Lerntempo — kein Zeitdruck (außer bei speziellen Szenarien)</li>
|
||||
<li>Lehrpersonen können Module individuell pro Schüler*in freischalten</li>
|
||||
<li>Anonyme Pseudonyme statt Klarnamen</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Textverständlichkeit</h3>
|
||||
<ul>
|
||||
<li>Einfache, klare Sprache in allen Anleitungen</li>
|
||||
<li>Gender-inklusive Schreibweise (Schüler*innen)</li>
|
||||
<li>Pilot-Kommentare in der Helikopter-Simulation sind verständlich und kurz</li>
|
||||
<li><strong>Untertitel</strong> zu allen Pilot-Sprachausgaben — Funksprüche werden immer als Text angezeigt</li>
|
||||
</ul>
|
||||
|
||||
<h2>Bekannte Einschränkungen</h2>
|
||||
<ul>
|
||||
<li>Canvas-basierte Simulationen (Klimawächter, Flussmanagement, Helikopter) sind für Screenreader nur eingeschränkt zugänglich</li>
|
||||
<li>Die Kartennavigation (Leaflet/OpenStreetMap) hat begrenzte Screenreader-Unterstützung</li>
|
||||
<li>Die Emoji-basierte Stadtplanung ist auf visuelle Wahrnehmung angewiesen</li>
|
||||
</ul>
|
||||
|
||||
<h2>Geplante Verbesserungen</h2>
|
||||
<ul>
|
||||
<li>ARIA-Labels für alle interaktiven Canvas-Elemente</li>
|
||||
<li>High-Contrast-Modus als Option</li>
|
||||
<li>Screenreader-optimierte Alternativdarstellung für Simulationsergebnisse</li>
|
||||
</ul>
|
||||
|
||||
<h2>Kontakt bei Barrieren</h2>
|
||||
<p>Wenn Sie Barrieren in der Anwendung feststellen, kontaktieren Sie uns bitte:</p>
|
||||
<p><a href="mailto:support@geograsim.at" style="color:#4a7c8a">support@geograsim.at</a></p>
|
||||
<p>Wir bemühen uns, gemeldete Barrieren zeitnah zu beheben.</p>
|
||||
|
||||
<div class="footer-note">
|
||||
<p>Stand: April 2026 · <a href="index.html">geograsim.at</a> · <a href="datenschutz.html">Datenschutz</a> · <a href="impressum.html">Impressum</a></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Datenschutzerklärung</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;color:#1a1a1a;line-height:1.7}
|
||||
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06)}
|
||||
.topbar a{text-decoration:none}
|
||||
.topbar img{height:32px}
|
||||
.topbar .spacer{flex:1}
|
||||
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:2rem 1.5rem 4rem}
|
||||
h1{font-size:1.6rem;font-weight:900;color:#1f4e5a;margin-bottom:.5rem}
|
||||
h2{font-size:1rem;font-weight:800;color:#1f4e5a;margin:1.5rem 0 .4rem;padding-top:.8rem;border-top:1px solid rgba(0,0,0,.06)}
|
||||
h3{font-size:.85rem;font-weight:700;color:#4a7c8a;margin:1rem 0 .2rem}
|
||||
p,li{font-size:.85rem;color:#333;margin-bottom:.5rem}
|
||||
ul{margin-left:1.2rem;margin-bottom:.8rem}
|
||||
.meta{font-size:.75rem;color:#8a8a8a;margin-bottom:1.5rem}
|
||||
.footer-note{font-size:.72rem;color:#8a8a8a;text-align:center;margin-top:2rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.06)}
|
||||
.footer-note a{color:#4a7c8a;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<a href="index.html"><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"></a>
|
||||
<span class="spacer"></span>
|
||||
<a href="index.html" class="btn-sm">Startseite</a>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>Datenschutzerklärung</h1>
|
||||
<p class="meta">gemäß österreichischem Datenschutzanpassungsgesetz 2018 und der Datenschutz-Grundverordnung (EU-DSGVO) für das Online-Lernprogramm geograsim.at</p>
|
||||
|
||||
<h2>1. Grundlegendes</h2>
|
||||
<p>Wir erforschen Lerntätigkeit im Bereich Geografie und Wirtschaftskunde und stellen eine optimal gestaltete Lernumgebung für den Unterricht bereit. Die gewonnenen Erkenntnisse aus anonymisierten Leistungsdaten bilden die Basis für wissenschaftliche Veröffentlichungen und die Verbesserung des Programms. Wir sehen uns einer ethischen Forschungshaltung verpflichtet und betrachten Datenschutz als grundlegend und selbstverständlich.</p>
|
||||
|
||||
<h2>2. Welche Daten speichern wir und wozu?</h2>
|
||||
|
||||
<h3>2.1 Bei der Anmeldung als Lehrperson</h3>
|
||||
<ul>
|
||||
<li><strong>E-Mail-Adresse</strong> (Pflichtfeld) zur Authentifizierung, Passwort-Wiederherstellung und gelegentlichen Informationen über wichtige Updates.</li>
|
||||
<li><strong>Vorname und Nachname</strong> zur Anzeige im Dashboard und auf Dokumenten.</li>
|
||||
<li><strong>Schulname</strong> (optional) zur organisatorischen Zuordnung.</li>
|
||||
<li><strong>Passwort</strong> in verschlüsselter Form (bcrypt-Hash). Das Klartext-Passwort wird zu keinem Zeitpunkt gespeichert.</li>
|
||||
</ul>
|
||||
|
||||
<h3>2.2 Bei der Anmeldung von Schüler*innen</h3>
|
||||
<p>GeoGraSim bietet die Möglichkeit, Schüler*innen-Zugänge <strong>vollständig pseudonymisiert</strong> zu erstellen:</p>
|
||||
<ul>
|
||||
<li><strong>Benutzername</strong> (Pseudonym, z.B. „schueler01") — kein Klarname erforderlich.</li>
|
||||
<li><strong>Passwort</strong> in verschlüsselter Form.</li>
|
||||
<li><strong>Vorname, Nachname, E-Mail</strong> (alle optional) — nur wenn die Lehrperson diese Daten bewusst eingibt.</li>
|
||||
<li><strong>Emoji-Avatar</strong> zur Personalisierung der Lernumgebung.</li>
|
||||
<li><strong>Lernfortschrittsdaten</strong> (Simulationsergebnisse, Bewertungen, Zeitdaten) zum Zwecke der pädagogischen Begleitung durch die Lehrperson und zur Darstellung des individuellen Lernfortschritts.</li>
|
||||
</ul>
|
||||
<p>Im Regelfall ist <strong>keine Angabe personenbezogener Daten</strong> der Schüler*innen erforderlich. Die Lehrperson kann die Zugänge vollständig anonym gestalten.</p>
|
||||
|
||||
<h3>2.3 Bei der Verwendung des Kontaktformulars</h3>
|
||||
<ul>
|
||||
<li><strong>E-Mail-Adresse</strong> für die Beantwortung der Anfrage.</li>
|
||||
<li><strong>Nachrichtentext</strong> mit üblichem Inhalt.</li>
|
||||
</ul>
|
||||
|
||||
<h3>2.4 Bei allen Seitenbesucher*innen</h3>
|
||||
<h3>2.4.1 Zugriffsdaten</h3>
|
||||
<p>Der Server erhebt automatisch Zugriffsdaten (Server-Logfiles), die zum Betrieb und Schutz des Servers notwendig sind:</p>
|
||||
<ul>
|
||||
<li>Name der abgerufenen Webseite und Dateien</li>
|
||||
<li>Datum und Uhrzeit des Zugriffs</li>
|
||||
<li>Übertragene Datenmenge</li>
|
||||
<li>Browsertyp und Version</li>
|
||||
<li>Betriebssystem</li>
|
||||
<li>Referrer-URL</li>
|
||||
<li>IP-Adresse</li>
|
||||
</ul>
|
||||
<p>Diese Daten werden nur für statistische Auswertungen zum Betrieb, zur Sicherheit und Optimierung verwendet.</p>
|
||||
|
||||
<h3>2.4.2 Cookies</h3>
|
||||
<p>GeoGraSim verwendet <strong>ausschließlich technisch notwendige Cookies</strong>:</p>
|
||||
<ul>
|
||||
<li><strong>Session-Cookie</strong> (PHP-Session) zur Authentifizierung eingeloggter Benutzer*innen.</li>
|
||||
<li><strong>Schüler*innen-Session-Cookie</strong> (UUID) zur Zuordnung von Spielständen.</li>
|
||||
</ul>
|
||||
<p>Es werden <strong>keine Tracking-Cookies</strong>, keine Analyse-Tools (kein Google Analytics) und <strong>keine Werbecookies</strong> verwendet.</p>
|
||||
|
||||
<h3>2.4.3 Einbindung von Diensten Dritter</h3>
|
||||
<ul>
|
||||
<li><strong>OpenStreetMap</strong>: Für die Helikopter-Navigation werden Kartenkacheln von OpenStreetMap geladen. Dabei wird die IP-Adresse an die OpenStreetMap-Server übermittelt. <a href="https://wiki.openstreetmap.org/wiki/Privacy_Policy" style="color:#4a7c8a">Datenschutzerklärung von OpenStreetMap</a>.</li>
|
||||
<li><strong>Leaflet.js</strong>: JavaScript-Bibliothek für Kartendarstellung, wird von einem CDN geladen.</li>
|
||||
</ul>
|
||||
<p>Es werden <strong>keine Werbenetzwerke</strong> und <strong>keine sozialen Netzwerke</strong> eingebunden.</p>
|
||||
|
||||
<h2>3. Was wir mit Ihren Daten NICHT machen</h2>
|
||||
<ul>
|
||||
<li>Wir geben personenbezogene Daten <strong>nicht an Dritte</strong> weiter.</li>
|
||||
<li>Wir machen personenbezogene Daten <strong>nicht öffentlich</strong> zugänglich.</li>
|
||||
<li>Wir speichern Daten <strong>nicht länger als nötig</strong> oder gewünscht.</li>
|
||||
<li>Wir verwenden <strong>keine Werbung</strong> und kein Tracking.</li>
|
||||
<li>Wir erstellen <strong>keine Nutzerprofile</strong> für kommerzielle Zwecke.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Widerruf, Auskunft, Löschung</h2>
|
||||
<p>Sie können Ihre Einwilligung jederzeit widerrufen:</p>
|
||||
<ul>
|
||||
<li><strong>Lehrpersonen</strong> können ihren Account im Profil selbst löschen. Die Löschung setzt ein Löschungs-Flag (Soft-Delete) — Schüler*innen werden zu Autodidakten.</li>
|
||||
<li><strong>Schüler*innen</strong> können von der Lehrperson gelöscht werden.</li>
|
||||
<li>Sie haben jederzeit das Recht auf <strong>Auskunft</strong> über alle gespeicherten Daten.</li>
|
||||
<li>Sie haben das Recht auf <strong>Berichtigung</strong> unrichtiger Daten.</li>
|
||||
<li>Sie haben das Recht auf <strong>Löschung</strong> Ihrer Daten.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Auftragsverarbeiter</h2>
|
||||
<p>Da wir unsere Webanwendung nicht selbst hosten, nehmen wir Dienste eines Auftragsverarbeiters in Anspruch:</p>
|
||||
|
||||
<h3>Hosting und E-Mail</h3>
|
||||
<p>
|
||||
<strong>World4You Internet Services GmbH</strong><br>
|
||||
Wolfgang-Pauli-Straße 2, BT3<br>
|
||||
4020 Linz, Österreich<br>
|
||||
FN 232485s<br>
|
||||
<a href="https://www.world4you.com/unternehmen/datenschutzerklaerung" style="color:#4a7c8a">Datenschutzerklärung von World4You</a><br>
|
||||
Datenschutz-Kontakt: dataprotection@world4you.com
|
||||
</p>
|
||||
<p>Alle Server befinden sich in <strong>Österreich/EU</strong>. Es findet keine Datenübertragung in Drittstaaten statt (mit Ausnahme der unter 2.4.3 genannten CDN-Dienste).</p>
|
||||
|
||||
<h2>6. Datensicherheit</h2>
|
||||
<ul>
|
||||
<li>Verschlüsselte Übertragung via <strong>HTTPS/TLS</strong></li>
|
||||
<li>Passwörter werden als <strong>bcrypt-Hash</strong> gespeichert</li>
|
||||
<li>Zugang zum Server nur via <strong>SSH mit Schlüssel</strong></li>
|
||||
<li>Datenbank durch <strong>Docker-Container</strong> isoliert</li>
|
||||
<li>Regelmäßige Updates der Server-Software</li>
|
||||
</ul>
|
||||
|
||||
<h2>7. Kontakt</h2>
|
||||
<p>Bei Fragen zur Datenschutzerklärung können Sie sich gerne unentgeltlich an uns wenden:</p>
|
||||
<p>
|
||||
<strong>GeoGraSim — Schroffenegger & Mößlang</strong><br>
|
||||
E-Mail: <a href="mailto:support@geograsim.at" style="color:#4a7c8a">support@geograsim.at</a><br>
|
||||
Web: <a href="https://geograsim.at" style="color:#4a7c8a">geograsim.at</a>
|
||||
</p>
|
||||
|
||||
<div class="footer-note">
|
||||
<p>Stand: April 2026 · <a href="index.html">geograsim.at</a> · <a href="impressum.html">Impressum</a> · <a href="barrierefreiheit.html">Barrierefreiheit</a></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,643 @@
|
||||
# GeoGraSim — Architektur & Schnittstellen
|
||||
|
||||
> **Zweck dieses Dokuments**
|
||||
> Zentrale Referenz für alle Module, Datentypen, Schnittstellen und Konventionen.
|
||||
> Geschrieben so, dass sowohl ein Mensch als auch eine KI das gesamte Projekt verstehen
|
||||
> kann, ohne den Source-Code zu lesen. Bei jeder größeren Änderung dieses Dokument
|
||||
> aktualisieren.
|
||||
>
|
||||
> **Stand:** April 2026 (nach Strom-System, 3 Schwierigkeitsgraden, Bilanz-UI, Steg-Rampe, Graph-Zoom, Auto-Pause)
|
||||
|
||||
---
|
||||
|
||||
## 1. Verzeichnis-Struktur
|
||||
|
||||
```
|
||||
App/
|
||||
├── index.html # Übersicht aller Simulationen
|
||||
├── game-3d.html # Klimawächter 3D-Renderer (Hauptspiel)
|
||||
├── game.html # Klimawächter 2D-Renderer (älter, hinkt hinterher)
|
||||
├── erdbeben.html # SIM-07: Erdbeben
|
||||
├── energiemix.html # SIM-09: Energiemix
|
||||
├── stilauswahl.html # Frontend-Stil-Auswahl-Tool
|
||||
│
|
||||
├── src/
|
||||
│ ├── core/
|
||||
│ │ └── game-engine.ts # Basis-Klasse aller Simulationen
|
||||
│ ├── ui/
|
||||
│ │ ├── game-ui.ts # Generic GameUI (Resources, Goals, Shop, Graphs, ...)
|
||||
│ │ ├── info-overlay.ts # Tooltips + erklärende Overlay-Modals
|
||||
│ │ └── theme.ts # (falls vorhanden) Farb-Themes
|
||||
│ └── sims/
|
||||
│ ├── sim-05-treibhaus/ # Klimawächter Logik (engine-level)
|
||||
│ │ ├── game.ts # KlimawaechterGame, MEASURES, DIFFICULTY_LEVELS
|
||||
│ │ └── logic.ts # computeTemperature() etc.
|
||||
│ ├── sim-05-treibhaus-3d/ # 3D-Renderer für Klimawächter
|
||||
│ │ └── game-renderer-3d.ts (~1900 Zeilen)
|
||||
│ ├── sim-07-erdbeben/ # SIM-07
|
||||
│ └── sim-09-energiemix/ # SIM-09
|
||||
│
|
||||
├── tests/unit/
|
||||
│ ├── sim-05-trace.test.ts # Mechanik-Trace über 75 Jahre, 7 Strategien
|
||||
│ ├── sim-05-treibhaus.test.ts # Standard-Tests
|
||||
│ └── ... (pro Sim)
|
||||
│
|
||||
└── docs/
|
||||
├── architektur.md # ← DIESES DOKUMENT
|
||||
├── klimawaechter-dev-log.html # Entwicklungs-Log
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Core-Schicht: `game-engine.ts`
|
||||
|
||||
Die `GameEngine`-Basisklasse stellt alle gemeinsamen Mechaniken bereit:
|
||||
Ressourcen, Ziele, Tutorial, Events, Tick-Loop, Save/Load, Citizen-Events, State-Machine.
|
||||
|
||||
### 2.1 Lifecycle
|
||||
|
||||
```
|
||||
constructor(meta) → setupResources() → setupGoals() → setupTutorial()
|
||||
↓
|
||||
state: 'tutorial' (warten auf Spieler-Klick durch Tutorial-Schritte)
|
||||
↓
|
||||
state: 'running' (msPerTick × speed → simulateTick() pro Zeitschritt)
|
||||
↓
|
||||
state: 'won' | 'lost' (checkWinCondition / checkLossCondition)
|
||||
```
|
||||
|
||||
### 2.2 GameMeta
|
||||
|
||||
```typescript
|
||||
interface GameMeta {
|
||||
id: string // 'sim-05'
|
||||
title: string // 'Klimawächter'
|
||||
description: string
|
||||
msPerTick: number // Wieviele ms pro Tick (4000 = 4 Sek/Jahr)
|
||||
tickUnit: string // 'Jahr', 'Monat', etc. (UI-Beschriftung)
|
||||
maxTicks: number // Spielende-Tick (75 für 2025-2100)
|
||||
tutorialSteps: number // Anzahl der Schritte (siehe setTutorial)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Resources
|
||||
|
||||
```typescript
|
||||
interface Resource {
|
||||
id: string // eindeutig: 'budget', 'population', ...
|
||||
name: string // Anzeige
|
||||
icon: string // Emoji
|
||||
initial: number
|
||||
unit: string // 'Mio €', 'MW', 'cm', ...
|
||||
format?: (v: number) => string // Custom Display-Format
|
||||
current: number // wird vom Engine gehalten
|
||||
}
|
||||
|
||||
// API:
|
||||
addResource(r: Resource) // im constructor() aufrufen
|
||||
setResource(id, value)
|
||||
getResource(id): number
|
||||
changeResource(id, delta)
|
||||
getResourcesArray(): Resource[]
|
||||
```
|
||||
|
||||
**Konvention:** Custom `format`-Funktionen können auf andere Ressourcen zugreifen:
|
||||
```typescript
|
||||
this.addResource({
|
||||
id: 'power', initial: 0,
|
||||
format: (v) => {
|
||||
const demand = Math.round(this.getResource('population') / 1000)
|
||||
return `${demand}/${Math.round(v)} MW`
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 2.4 Variables (interne, nicht angezeigte Werte)
|
||||
|
||||
```typescript
|
||||
setVariable(key, value)
|
||||
getVariable(key): number
|
||||
```
|
||||
|
||||
Werden für interne Berechnungen verwendet, erscheinen nicht in der UI. Beispiele:
|
||||
`co2Reduction`, `protection`, `upkeepTotal`.
|
||||
|
||||
### 2.5 Goals
|
||||
|
||||
```typescript
|
||||
interface Goal {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
check: (game: GameEngine) => boolean // erreicht?
|
||||
progress?: (game: GameEngine) => number // 0-100 für Progress-Bar
|
||||
required: boolean // muss für 'won' erfüllt sein
|
||||
}
|
||||
|
||||
addGoal(g: Goal)
|
||||
```
|
||||
|
||||
### 2.6 Events (Ereignis-Karten in der UI)
|
||||
|
||||
```typescript
|
||||
addEvent(id, text, severity, infoKey?)
|
||||
// severity: 'info' | 'success' | 'warning' | 'danger'
|
||||
// infoKey: optional Schlüssel für INFO_TOPICS Lookup
|
||||
getEvents(limit): Event[]
|
||||
```
|
||||
|
||||
`id` macht es idempotent (jedes Event nur einmal). Mit `eventFired(id)` aus
|
||||
einer Subklasse kann man prüfen, ob ein Event schon ausgelöst wurde.
|
||||
|
||||
### 2.7 Citizen Events (modale Bürger-Dilemmata)
|
||||
|
||||
```typescript
|
||||
interface CitizenEvent {
|
||||
id: string
|
||||
character: string // Emoji-Avatar
|
||||
title: string // 'Lina, die Fischerin'
|
||||
message: string // 1-3 Sätze, kindgerecht
|
||||
choices: CitizenChoice[]
|
||||
}
|
||||
|
||||
interface CitizenChoice {
|
||||
label: string // Button-Text
|
||||
description: string // Vor-/Nachteile-Hint
|
||||
apply: (game: GameEngine) => void
|
||||
}
|
||||
|
||||
triggerCitizenEvent(ev: CitizenEvent)
|
||||
getPendingCitizenEvent(): CitizenEvent | null
|
||||
resolveCitizenEvent(choiceIdx: number)
|
||||
```
|
||||
|
||||
Die UI zeigt das Modal automatisch an, wenn `pendingCitizenEvent !== null`.
|
||||
**Nur eines gleichzeitig.**
|
||||
|
||||
### 2.8 Tick-Loop
|
||||
|
||||
```typescript
|
||||
protected simulateTick(): void // ← in Subklasse implementieren
|
||||
|
||||
setSpeed(s: 0 | 1 | 2 | 4) // 0 = Pause
|
||||
getSnapshot(): { tick, speed, state, goals[], timeline[] }
|
||||
```
|
||||
|
||||
Die Engine ruft `simulateTick()` periodisch auf. `tick` zählt bei jedem Aufruf
|
||||
hoch. Bei jedem Tick wird `notify()` gerufen → UI rendert neu.
|
||||
|
||||
### 2.9 Save / Load
|
||||
|
||||
```typescript
|
||||
serialize(): string // JSON
|
||||
deserialize(s: string): boolean // gibt false bei alter Version
|
||||
|
||||
// Subklassen-Hooks (überschreibbar):
|
||||
protected serializeSubclass(): Record<string, unknown>
|
||||
protected deserializeSubclass(data: Record<string, unknown>): void
|
||||
```
|
||||
|
||||
**Versionierung:** `SAVE_VERSION = 2`. Saves mit anderer Version werden
|
||||
abgelehnt → neues Spiel. Bei Mechanik-Änderungen Version erhöhen.
|
||||
|
||||
**Save-Format (Auszug):**
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"tick": 12,
|
||||
"state": "running",
|
||||
"speed": 1,
|
||||
"resources": { "budget": 437, ... },
|
||||
"variables": { "co2Reduction": 1.2, ... },
|
||||
"goalsAchieved": ["temp"],
|
||||
"events": [...],
|
||||
"subclass": { /* serializeSubclass() output */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. UI-Schicht: `game-ui.ts`
|
||||
|
||||
Generische UI-Klasse, die von jeder Sim-HTML-Seite verwendet wird. Erwartet
|
||||
DOM-Elemente mit bestimmten IDs (siehe unten) und rendert da hinein.
|
||||
|
||||
### 3.1 GameUIConfig
|
||||
|
||||
```typescript
|
||||
interface GameUIConfig {
|
||||
game: GameEngine
|
||||
Renderer: new (container: HTMLElement, game: any) => Renderer
|
||||
shopItems: ShopItem[]
|
||||
onBuy: (id: string) => boolean
|
||||
onDemolish?: (id: string) => boolean // Optional: 🗑-Button
|
||||
getOwnedCount?: (id: string) => number // Optional: ×N Badge
|
||||
graphs: GraphConfig[]
|
||||
saveKey: string // localStorage key
|
||||
finishOnTick?: number
|
||||
yearOffset?: number // 2025
|
||||
mode?: 'free' | 'guided'
|
||||
totalDurationSec?: number // bei mode=guided
|
||||
statusResources?: string[] // Whitelist welche Resources im Status-Panel
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 ShopItem
|
||||
|
||||
```typescript
|
||||
interface ShopItem {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
description: string
|
||||
cost: number
|
||||
upkeep: number
|
||||
badges?: { label: string; type: BadgeType }[]
|
||||
}
|
||||
|
||||
type BadgeType = 'cost' | 'reduction' | 'protection' | 'capacity' | 'quality' | 'neutral'
|
||||
```
|
||||
|
||||
### 3.3 GraphConfig
|
||||
|
||||
```typescript
|
||||
interface GraphConfig {
|
||||
id: string // SVG-Element-ID
|
||||
title: string
|
||||
field: string // Resource-ID, dessen Werte geplottet werden
|
||||
yMin: number
|
||||
yMax: number
|
||||
color: string // CSS-Farbe
|
||||
unit?: string
|
||||
zones?: GraphZone[] // farbige Bänder im Hintergrund
|
||||
lines?: GraphLine[] // horizontale Referenzlinien
|
||||
autoScale?: boolean // yMax wandert mit Daten mit
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Erwartete DOM-Element-IDs (HTML-Seite)
|
||||
|
||||
| ID | Inhalt |
|
||||
|---|---|
|
||||
| `#canvas-wrap` | Container für 3D/2D Renderer |
|
||||
| `#resources` | Status-Box (Resources werden hier eingefügt) |
|
||||
| `#status-goals` ODER `#goals` | Ziele-Liste |
|
||||
| `#measures` | Shop |
|
||||
| `#events` | Vollständige Ereignis-Liste (Popout) |
|
||||
| `#events-latest` | Nur das letzte Event (Trigger in Status-Box) |
|
||||
| `#tutorial`, `#tut-title`, `#tut-text`, `#tut-dots`, `#tut-next` | Tutorial-Modal |
|
||||
| `#end-screen-host`, `#citizen-event-host`, `#info-overlay-host` | Modal-Slots |
|
||||
| `[data-speed="0|1|2|4"]` | Speed-Buttons |
|
||||
| `#btn-save`, `#btn-load`, `#btn-finish`, `#btn-reset` | System-Buttons |
|
||||
|
||||
### 3.5 Sim-Spezifische UI-Sonderfälle (Duck-Typing)
|
||||
|
||||
`GameUI` ist generisch, hat aber **drei Hooks** für sim-spezifische UI-Erweiterungen:
|
||||
|
||||
1. **`power`-Resource** → `updatePowerGrid()` rendert Bedarf/Kapazitäts-Balken
|
||||
2. **`budget`-Resource + `game.getYearlyBalance()`** → `updateBudgetBalance()` rendert Bilanz-Tabelle
|
||||
3. **Resource-Format-Closures** dürfen `this` (das Game-Objekt) nutzen
|
||||
|
||||
**Konvention:** Sim-spezifische UI-Logik in `game-ui.ts` mit Kommentar
|
||||
`// Spezialfall <NAME>:` markieren.
|
||||
|
||||
### 3.6 Public API (von außen aufrufbar)
|
||||
|
||||
```typescript
|
||||
ui.getRenderer(): Renderer | null // für Kamera-Steuerung
|
||||
ui.redrawGraphs(): void // erzwingt Graph-Neuzeichnung (nach Resize)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Renderer-Schicht: `game-renderer-3d.ts`
|
||||
|
||||
Three.js-basierter 3D-Renderer. Konstruktor erhält `(container, game)` und
|
||||
hängt sich selbst an `container`.
|
||||
|
||||
### 4.1 Erwartete Methoden eines Renderers
|
||||
|
||||
```typescript
|
||||
interface Renderer {
|
||||
start(): void
|
||||
stop(): void
|
||||
// optional, für UI-Buttons:
|
||||
zoomIn?(): void
|
||||
zoomOut?(): void
|
||||
recenter?(): void
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 KlimawaechterRenderer3D — interne Struktur
|
||||
|
||||
```
|
||||
Scene
|
||||
├── Wasserfläche (steigt mit sealevel)
|
||||
├── Himmel-Kuppel
|
||||
├── Insel (PlaneGeometry mit Vertex-Colors, 120×160 Segmente)
|
||||
├── Vulkan-Kuppel + Schnee-Gletscher (LatheGeometry, ClippingPlane)
|
||||
├── Steg + Rampe + wartende Bewohner-Sprites
|
||||
├── villageGroup (Häuser + 3 Baumtypen mit Stadien)
|
||||
├── smokeGroup (Heizwolken pro Haus)
|
||||
├── fxGroup (FX-Effekte: Totenkopf, Boot, Skulls)
|
||||
├── placed[] (gekaufte Maßnahmen)
|
||||
└── mountainShieldGroup? (wenn hasMountainShield)
|
||||
```
|
||||
|
||||
### 4.3 Wichtige Konstanten
|
||||
|
||||
```typescript
|
||||
ISLAND_HALF_WIDTH = 13 // X-Halbachse
|
||||
ISLAND_HALF_LENGTH = 17 // Z-Halbachse
|
||||
WATER_Y_BASE = 0.0 // Start-Wasserlinie
|
||||
MAX_SEA_RISE_UNITS = 1.6 // Wasser steigt bis +1.6 Y bei 200 cm Sealevel
|
||||
VOLCANO_X = -3.0
|
||||
VOLCANO_Z = -11.0
|
||||
VOLCANO_DOME_HEIGHT = 2.1
|
||||
VOLCANO_DOME_RADIUS = 2.2
|
||||
ZONE_BEACH_END = 0.38 // Vertex-Color-Zonen
|
||||
ZONE_MEADOW_END = 0.95
|
||||
ZONE_FOREST_END = 1.80
|
||||
```
|
||||
|
||||
### 4.4 FX-System
|
||||
|
||||
```typescript
|
||||
interface FxEffect {
|
||||
root: THREE.Object3D
|
||||
lifetime: number
|
||||
age: number
|
||||
update: (dt: number, ef: FxEffect) => boolean // false → entfernen
|
||||
}
|
||||
|
||||
private fxEffects: FxEffect[] = []
|
||||
private fxGroup: THREE.Group
|
||||
|
||||
// Pro Frame in updateScene():
|
||||
for (let i = fxEffects.length - 1; i >= 0; i--) {
|
||||
if (!fxEffects[i].update(dt, fxEffects[i])) fxEffects.splice(i, 1)
|
||||
}
|
||||
```
|
||||
|
||||
Spawn-Helpers:
|
||||
- `spawnSkull(x, y, z)` — 💀 schwebt 1.2 Units nach oben, 3.2s Lebensdauer
|
||||
- `spawnRefugeeBoat()` — Boot mit Boarding → Spirale → Escape (28s total)
|
||||
- `makeEmojiSprite(emoji, size)` — Billboard-Sprite mit CanvasTexture
|
||||
|
||||
### 4.5 Maßnahmen-Platzierung
|
||||
|
||||
```typescript
|
||||
private placeMeasure(typeId: string, index: number): THREE.Object3D | null
|
||||
```
|
||||
|
||||
Switch über `typeId`. Pro neuem Measure-Typ ein neuer Case + ein
|
||||
`makeXxx()`-Helper. Das Renderer-Update-Loop scannt `game.getOwnedMeasures()`
|
||||
und ruft `placeMeasure` für neue, entfernt Meshes für demolierte.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sim-Spezifisch: `sim-05-treibhaus/game.ts`
|
||||
|
||||
### 5.1 KlimawaechterGame
|
||||
|
||||
Erbt von `GameEngine`. Ungefährer Aufbau:
|
||||
|
||||
```typescript
|
||||
export class KlimawaechterGame extends GameEngine {
|
||||
private measures: OwnedMeasure[]
|
||||
private diff: DifficultyConfig
|
||||
private co2Ppm = 425
|
||||
private currentTemp = 0
|
||||
private targetTemp = 0
|
||||
private seaLevelCm = 0
|
||||
private floodedHouses = 0
|
||||
researchDiscount = 1.0
|
||||
tourismMode = false
|
||||
hasMountainShield = false
|
||||
treesChoppedForHeat = 0
|
||||
private blackoutStreak = 0
|
||||
private firedEvents = new Set<string>()
|
||||
|
||||
constructor(difficulty: 1|2|3 = 2)
|
||||
|
||||
// Public API
|
||||
buyMeasure(id): boolean
|
||||
demolishMeasure(id): boolean
|
||||
getOwnedMeasures(): OwnedMeasure[]
|
||||
getMeasureCount(id): number
|
||||
getDifficulty(): DifficultyConfig
|
||||
getYearlyBalance(): { income, tourism, upkeep, climate, net }
|
||||
getIncomeAtCurrentLevel(): number
|
||||
|
||||
// Subclass-Hooks
|
||||
protected simulateTick()
|
||||
protected serializeSubclass()
|
||||
protected deserializeSubclass()
|
||||
protected checkLossCondition()
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 MEASURES-Tabelle (Stand April 2026)
|
||||
|
||||
| id | name | cost | upkeep | co2Reduction | protection | powerOutput |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `forest` | 🌲 Wald | 12 | 0.5 | -0.04 | – | – |
|
||||
| `solar` | ☀️ Solar | 40 | 2 | -0.14 | – | +1 MW |
|
||||
| `wind` | 🌬 Wind | 150 | 4 | -0.50 | – | +3 MW |
|
||||
| `green-roof` | 🏡 Gründach | 30 | **0** | -0.04 | – | – |
|
||||
| `dike` | 🌊 Deich | 80 | 3 | – | +12 cm | – |
|
||||
| `sea-wall` | 🛡 Hochw.schutz | 240 | 6 | – | +50 cm | – |
|
||||
| `coal` | 🏭 Kohle | 20 | 1 | **+0.38** | – | +4 MW |
|
||||
| `airport` | ✈️ Flughafen | 62 | 3 | **+0.22** | – | – |
|
||||
| `cloud-seed` | 🌤 Wolken-Impfung | 125 | 4.5 | -0.04 | – | – |
|
||||
|
||||
**Differenzierung:**
|
||||
- **Solar vs Wind:** Solar günstig pro Stück, Wind effizienter pro MW Betrieb
|
||||
- **Wald:** Günstigste Wartung pro CO₂-ppm (didaktisch: Naturlösung)
|
||||
- **Gründach:** Einzige Maßnahme mit 0 Wartung (Set-and-forget)
|
||||
- **Deich vs Hochwasserschutz:** Niedrige Hürde vs effiziente Großlösung
|
||||
- **Kohle/Flughafen/Wolken:** Negative oder unwirksame Maßnahmen (Lehrtraps)
|
||||
|
||||
### 5.3 DIFFICULTY_LEVELS
|
||||
|
||||
```typescript
|
||||
DIFFICULTY_LEVELS[1] // 🟢 Lernen: 750/220, 3J Schonfrist, ×0.5 Folgen
|
||||
DIFFICULTY_LEVELS[2] // 🟡 Üben: 550/175, 1J Schonfrist, ×1.0 Folgen
|
||||
DIFFICULTY_LEVELS[3] // 🔴 Profi: 400/145, 0J Schonfrist, ×1.5 Folgen
|
||||
```
|
||||
|
||||
Beeinflusst: `startBudget`, `incomePer10k`, `blackoutGrace`, `treesPerBlackout`,
|
||||
`co2BlackoutPerYear`, `climateDamageMul`, `popLossMul`.
|
||||
|
||||
### 5.4 Klimaberechnung (`computeClimate()`)
|
||||
|
||||
```
|
||||
targetTemp = computeTemperature(co2Ppm, sensitivity=0.3)
|
||||
currentTemp += (targetTemp - currentTemp) * 0.08 // Trägheit (Ozean)
|
||||
sealevelTarget = (currentTemp - 15) * 30 cm/°C
|
||||
sealevelCm += (target - current) * (rising ? 0.08 : 0.005) // committed rise
|
||||
floodedHouses = max(0, (sealevel - protection - 35) * 0.9) [%]
|
||||
```
|
||||
|
||||
### 5.5 simulateTick — Reihenfolge
|
||||
|
||||
```
|
||||
1. Steuern (income = popRatio × diff.incomePer10k)
|
||||
1b. Tourismus (Bonus + CO₂, oder Kollaps wenn Strand weg)
|
||||
2. Wartung
|
||||
3. CO₂-Update (emissions - reduction)
|
||||
4. computeClimate()
|
||||
4b. Strom-Bilanz + Stromausfall-Folgen (Bäume + CO₂ + Pop-Drift)
|
||||
5. Häuser-Überflutung
|
||||
5b. Sealevel-Folgen (Versalzung, Pop-Drift)
|
||||
6. Zeit-Events
|
||||
7. Citizen-Events (maybeTriggerCitizenEvent)
|
||||
```
|
||||
|
||||
### 5.6 Citizen Events
|
||||
|
||||
| Tick | ID | Trigger |
|
||||
|---|---|---|
|
||||
| 15 | `citizen-fisher` | Lina, Fischerin (warmes Wasser) |
|
||||
| 9 | `citizen-scientist` | Dr. Hassan, Forschungszentrum |
|
||||
| 14 | `citizen-farmer` | Yusuf, Bewässerung |
|
||||
| 20 | `citizen-tourism` | Maria, Hotel |
|
||||
| 28 | `citizen-youth` | Lia, 14, Streik |
|
||||
| 38 | `citizen-industry` | Konzernchef Vogel |
|
||||
| 50 | `citizen-mountain` | Anna, Bergdorf |
|
||||
|
||||
---
|
||||
|
||||
## 6. Auto-Pause-System (in HTML-Seite)
|
||||
|
||||
In `game-3d.html` als Token-basiertes Pause-System:
|
||||
|
||||
```javascript
|
||||
const overlayPauseTokens = new Set()
|
||||
let speedBeforeOverlayPause = 1
|
||||
|
||||
function pauseGameForOverlay(token) { ... }
|
||||
function resumeGameForOverlay(token) { ... }
|
||||
|
||||
// Auto-Hooks via MutationObserver:
|
||||
observeOverlayForPause('.citizen-overlay', 'citizen-event')
|
||||
observeOverlayForPause('.info-overlay', 'info-overlay')
|
||||
|
||||
// Manuell für Graph-Zoom + Events-Popout
|
||||
```
|
||||
|
||||
**Konvention:** Jeder neue Overlay-Typ registriert sich mit eigenem Token.
|
||||
Beim letzten Schließen wird `speedBeforeOverlayPause` wiederhergestellt.
|
||||
|
||||
---
|
||||
|
||||
## 7. Konventionen & Best Practices
|
||||
|
||||
### 7.1 Tooltip-System
|
||||
|
||||
**Globaler Listener:** `installTooltips()` in `info-overlay.ts` registriert
|
||||
einen delegated `mouseover`-Handler. Jedes Element mit `data-tip="key"`
|
||||
zeigt automatisch das `TIPS[key]`-Tooltip beim Hover.
|
||||
|
||||
```html
|
||||
<span data-tip="ppm">ppm <span class="unit-i">i</span></span>
|
||||
```
|
||||
|
||||
**Erweitern:** Neuen Eintrag in `TIPS: Record<string, TipDef>` hinzufügen.
|
||||
|
||||
### 7.2 Display-Animation
|
||||
|
||||
Numeric Resources werden via gedämpfter rAF-Loop (12% pro Frame) zum
|
||||
Zielwert hingezogen. Implementiert in `GameUI.ensureDisplayAnim()`.
|
||||
Snapping bei `absDelta < 0.5`.
|
||||
|
||||
### 7.3 Graph-Zoom (FLIP-Pattern)
|
||||
|
||||
Beim Klick auf eine `.graph-card`:
|
||||
1. Card via `appendChild(body)` aus Container herauslösen (wegen `backdrop-filter`)
|
||||
2. Platzhalter im Original-Grid lassen
|
||||
3. `position: fixed` mit Original-Rect setzen, Reflow erzwingen
|
||||
4. Transition zu zentrierten Größen
|
||||
5. Nach Animation `ui.redrawGraphs()` für SVG-Resize
|
||||
|
||||
Bei Unzoom: zurück animieren, Card in Platzhalter-Position wieder einfügen,
|
||||
Inline-Styles aufräumen.
|
||||
|
||||
### 7.4 Save-Versionierung
|
||||
|
||||
Bei jeder Mechanik-Änderung in einer Sim:
|
||||
1. `SAVE_VERSION` in `game-engine.ts` erhöhen (oder Sim-spezifisches `SCHEMA_VERSION` falls vorhanden)
|
||||
2. Alte Saves werden automatisch verworfen
|
||||
3. UI zeigt freundlichen Hinweis
|
||||
|
||||
### 7.5 Trace-Tests
|
||||
|
||||
`tests/unit/sim-05-trace.test.ts` simuliert verschiedene Strategien rein
|
||||
rechnerisch über alle 75 Jahre und prüft, dass:
|
||||
- Optimale Strategie gewinnt
|
||||
- "Nichts tun" verliert
|
||||
- "Nur Wald" verliert (zu langsam)
|
||||
- "Nur Deich" verliert (CO₂ killt sie trotzdem) — **TODO**: aktuell unsicher
|
||||
|
||||
---
|
||||
|
||||
## 8. Ranking-System (vorgeschlagen, noch nicht implementiert)
|
||||
|
||||
Erweitere `GameMeta` um:
|
||||
|
||||
```typescript
|
||||
interface GameRanking {
|
||||
complexity: 1|2|3|4|5
|
||||
timeMinutes: number
|
||||
gradeRecommendation: { min: number; max: number } // 1-12 Schulstufe
|
||||
focusedLearning: 1|2|3|4|5
|
||||
topics: string[]
|
||||
}
|
||||
```
|
||||
|
||||
### Klimawächter (sim-05) Vorschlag:
|
||||
|
||||
```typescript
|
||||
ranking: {
|
||||
complexity: 4,
|
||||
timeMinutes: 15,
|
||||
gradeRecommendation: { min: 6, max: 8 },
|
||||
focusedLearning: 3,
|
||||
topics: ['Klimawandel', 'Energiewende', 'Anpassung vs Mitigation', 'Wirtschaftsbilanz']
|
||||
}
|
||||
```
|
||||
|
||||
`index.html` filtert/sortiert dann Sims nach Ranking.
|
||||
|
||||
---
|
||||
|
||||
## 9. Offene Punkte (kurzgefasst)
|
||||
|
||||
| Was | Wo dokumentiert |
|
||||
|---|---|
|
||||
| Phase B: Drag-and-Drop-Baueditor | dev-log §4 |
|
||||
| Phase C: Wohnungsbau + Neuankömmlinge | dev-log §4 |
|
||||
| Mitigation vs Adaptation Kipppunkt | dev-log §4 (idee) |
|
||||
| Insel-Leben Sprites (Wolken, Möwen, Boote) | brainstorm offen |
|
||||
| Sand-Aufschüttung mit Erosion | brainstorm offen |
|
||||
| Mangroven (Win-Win-Maßnahme) | brainstorm offen |
|
||||
| Sterne-Ranking implementieren | dieses Dok §8 |
|
||||
| 2D-Renderer auf Strom-System updaten | dev-log §8 |
|
||||
|
||||
---
|
||||
|
||||
## 10. Konventionen für die KI
|
||||
|
||||
Wenn die KI Code-Änderungen macht:
|
||||
|
||||
1. **Erst lesen, dann ändern.** Nie Annahmen über Code, der nicht im Kontext ist.
|
||||
2. **Build + Tests nach jeder Änderung.** `npx vite build && npx vitest run`.
|
||||
3. **Headless-Verify für UI-Änderungen.** Playwright-Script in `App/scripts/`.
|
||||
4. **Save-Schema bei Mechanik-Änderung erhöhen.** Sonst kaputte Saves.
|
||||
5. **Trace-Test grün halten.** Wenn er rot wird: Balance kalibrieren, nicht ignorieren.
|
||||
6. **Diese Doku aktualisieren** bei Schnittstellen-Änderungen.
|
||||
7. **Wald = günstigste CO₂-Reduktion pro Mio €.** Heilig. Nie verschlechtern.
|
||||
8. **Mio €** ist die Geldeinheit. Nie nackte Euro.
|
||||
9. **Tick = Jahr** in sim-05. Falls Wechsel auf Monate: alle Werte ÷12.
|
||||
10. **Einfache emojis bevorzugen** (Single-Codepoint, kein ZWJ). 🧍 ja, 🧍♀️ nein.
|
||||
@@ -0,0 +1,296 @@
|
||||
# GeoGraSim — Informationsarchitektur & Content-Spezifikation
|
||||
|
||||
Dieses Dokument beschreibt die öffentlichen und didaktischen Inhalte von
|
||||
GeoGraSim (Landing Page, Modul-Detailseiten, Lehrplan-Bezug, Glossar). Es
|
||||
richtet sich an die Content-Instanz, die diese Seiten erstellt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Drei Benutzer-Pfade
|
||||
|
||||
```
|
||||
ANONYM SCHÜLER:IN LEHRPERSON
|
||||
────── ────────── ──────────
|
||||
Landing Page (index.html) Mein Dashboard Klassen-Dashboard
|
||||
↓ Überblick (schueler.html) (teacher.html)
|
||||
↓ "Sofort ausprobieren" ↓ Meine Module ↓ Modulfreigabe
|
||||
↓ Für Lehrpersonen ↓ Badges, Fortschritt ↓ Ergebnisse
|
||||
↓ Für Schüler:innen
|
||||
Profil Lehrplan-Matching
|
||||
Simulationen-Übersicht (neu, pro Modul)
|
||||
↓ Cards je Modul Glossar (mit Markierung
|
||||
"schon gelernt")
|
||||
Modul-Detailseite (NEU)
|
||||
↓ Was lerne ich?
|
||||
↓ Welche Begriffe?
|
||||
↓ Lehrplan-Bezug
|
||||
↓ Jetzt probieren
|
||||
|
||||
Glossar (wird parallel gebaut)
|
||||
|
||||
Lehrplan-Seite (NEU)
|
||||
↓ AT / DE / CH / LI Filter
|
||||
↓ Kompetenz-Mapping
|
||||
↓ Welches Modul passt wohin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Kern-Prinzip: Kompetenz-Brücke
|
||||
|
||||
**Problem:** DACH+LI haben zusammen ~40 verschiedene Lehrpläne (Bundesländer,
|
||||
Kantone, Schultypen). Alle einzeln zu pflegen ist nicht leistbar.
|
||||
|
||||
**Lösung:** Module werden auf **didaktische Kompetenzbereiche** gemappt, nicht
|
||||
auf einzelne Lehrpläne. Jeder Lehrplan referenziert ähnliche Kompetenzen — die
|
||||
Lehrperson erkennt den Bezug selbst.
|
||||
|
||||
Beispiel:
|
||||
```
|
||||
Kompetenzbereich: "Klimawandel verstehen"
|
||||
├── Modul: Klimawächter 2D (Level 1–3)
|
||||
├── Modul: Klimawächter 3D (Level 1–3)
|
||||
│
|
||||
└── Lehrplan-Anker:
|
||||
├── 🇦🇹 AT Mittelschule GW, Kl. 8 — "Anthropogener Klimawandel"
|
||||
├── 🇩🇪 DE Berlin GL, Kl. 9 — "Klimawandel als globale Herausforderung"
|
||||
├── 🇨🇭 CH Lehrplan 21, NMG.3.2 — "Klima und Wetter"
|
||||
└── 🇱🇮 LI 7. Schulstufe GW — "Naturphänomene und Klimasystem"
|
||||
```
|
||||
|
||||
Die Lehrperson sieht: "Ja, passt zu meinem Lehrplan-Punkt" und weiß, dass das
|
||||
Modul didaktisch fundiert zugeordnet ist.
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenbank-Schema (Vorschlag — abstimmen mit Zentrale)
|
||||
|
||||
```sql
|
||||
-- Didaktische Kompetenzbereiche (zentrale Brücke)
|
||||
CREATE TABLE kompetenzen (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
key_slug VARCHAR(64) NOT NULL UNIQUE, -- 'klimawandel', 'raumplanung'
|
||||
title VARCHAR(128) NOT NULL, -- 'Klimawandel verstehen'
|
||||
description TEXT, -- Didaktisch ausformuliert (3-5 Sätze)
|
||||
category VARCHAR(32), -- 'klima', 'raum', 'gesellschaft'
|
||||
age_min TINYINT UNSIGNED, -- 10 (Klasse 5)
|
||||
age_max TINYINT UNSIGNED, -- 16 (Klasse 10)
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- Modul ↔ Kompetenz (N:M)
|
||||
CREATE TABLE kompetenz_modules (
|
||||
kompetenz_id INT UNSIGNED NOT NULL,
|
||||
module_id VARCHAR(16) NOT NULL, -- 'klima', 'heli', 'fluss', 'stadt'
|
||||
level TINYINT UNSIGNED, -- 1, 2, 3 (optional)
|
||||
strength ENUM('primär', 'sekundär') DEFAULT 'primär',
|
||||
PRIMARY KEY (kompetenz_id, module_id, level),
|
||||
FOREIGN KEY (kompetenz_id) REFERENCES kompetenzen(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Lehrplan-Anker pro Kompetenz
|
||||
CREATE TABLE lehrplan_anchors (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kompetenz_id INT UNSIGNED NOT NULL,
|
||||
country CHAR(2) NOT NULL, -- 'AT', 'DE', 'CH', 'LI'
|
||||
region VARCHAR(32), -- Bundesland/Kanton (optional)
|
||||
school_type VARCHAR(32), -- 'MS', 'AHS', 'Gym', ...
|
||||
grade VARCHAR(16), -- 'Kl. 8', '7.-8. Stufe'
|
||||
title VARCHAR(255), -- 'Anthropogener Klimawandel'
|
||||
reference VARCHAR(255), -- Offizielle Lehrplan-Referenz/URL
|
||||
quote TEXT, -- Exaktes Zitat aus dem Lehrplan
|
||||
FOREIGN KEY (kompetenz_id) REFERENCES kompetenzen(id) ON DELETE CASCADE,
|
||||
INDEX (country, school_type, grade)
|
||||
);
|
||||
|
||||
-- Modul-Metadaten (für Detailseiten)
|
||||
CREATE TABLE module_info (
|
||||
module_id VARCHAR(16) NOT NULL PRIMARY KEY,
|
||||
title VARCHAR(128) NOT NULL, -- 'Klimawächter'
|
||||
subtitle VARCHAR(255), -- 'Klima retten, Küste schützen'
|
||||
short_desc TEXT, -- 1-2 Sätze für Card
|
||||
long_desc TEXT, -- Ausführlich für Detailseite
|
||||
learning_goals TEXT, -- JSON-Array oder Markdown
|
||||
duration_min INT UNSIGNED, -- Geschätzte Spieldauer in Minuten
|
||||
age_min TINYINT UNSIGNED,
|
||||
age_max TINYINT UNSIGNED,
|
||||
icon VARCHAR(16), -- Emoji
|
||||
card_image VARCHAR(255), -- card-climate.png etc.
|
||||
status ENUM('aktiv', 'beta', 'archiv') DEFAULT 'beta',
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Arbeitsbereich Content-Instanz
|
||||
|
||||
### SCHREIBEN
|
||||
- `App/pages/modul-klima.php` — Detailseite Klimawächter
|
||||
- `App/pages/modul-heli.php` — Detailseite Heli
|
||||
- `App/pages/modul-fluss.php` — Detailseite Fluss
|
||||
- `App/pages/modul-stadt.php` — Detailseite Stadt
|
||||
- `App/pages/lehrplan.php` — Lehrplan-Übersicht mit Filtern
|
||||
- `App/pages/simulationen.php` — Übersichtsseite aller Module (ersetzt evtl. eine Section auf der Landing)
|
||||
- DB-Tabellen: `kompetenzen`, `kompetenz_modules`, `lehrplan_anchors`,
|
||||
`module_info` (über PHP/PDO, nicht via mysql.exe — Umlaute!)
|
||||
|
||||
### LESEN
|
||||
- Gesamtes Projekt
|
||||
- `App/docs/module-interface.md`
|
||||
- `App/docs/content-architecture.md` (dieses Dokument)
|
||||
- Bestehende Module zum Verstehen des Inhalts
|
||||
|
||||
### NICHT ANFASSEN
|
||||
- Module selbst (`App/sims/...`)
|
||||
- Glossar (macht die Glossar-Instanz) — aber du darfst Glossar-Einträge per
|
||||
Inbox an Glossar-Instanz anfragen, wenn sie für Modul-Detailseiten nützlich
|
||||
sind
|
||||
- Landing Page (`index.html`, `index.php`) — du darfst Änderungs-Vorschläge
|
||||
per Zentrale-Inbox schicken, aber nicht selbst editieren
|
||||
- Design-System, Admin, APIs
|
||||
|
||||
---
|
||||
|
||||
## 5. Recherche-Quellen für Lehrpläne
|
||||
|
||||
### Österreich
|
||||
- Lehrplan Mittelschule (BMBWF): https://www.bmbwf.gv.at → Unterricht → Lehrpläne
|
||||
- Lehrplan AHS Unterstufe
|
||||
- GW (Geographie und Wirtschaftsbildung) steht seit 2023 neu strukturiert
|
||||
|
||||
### Deutschland
|
||||
- Konzentration auf große Bundesländer + neutrale Lehrplan-Kernelemente
|
||||
- Bildungsstandards KMK (Kultusministerkonferenz) als Dach
|
||||
- Pro Bundesland evtl. nur 1–2 Referenzen
|
||||
|
||||
### Schweiz
|
||||
- Lehrplan 21 (21 Kantone, gemeinsame Struktur)
|
||||
- NMG (Natur, Mensch, Gesellschaft) in Sek I, RZG (Räume, Zeiten, Gesellschaften)
|
||||
|
||||
### Liechtenstein
|
||||
- Eigener Lehrplan, lehnt sich stark an CH an
|
||||
- Lehrplan FL ab 2012 (LiLe)
|
||||
|
||||
### Prinzip der Recherche
|
||||
**Weniger Tiefe, mehr Breite.** Für jede Kompetenz **3–5 Anker** aus
|
||||
verschiedenen Ländern. Nicht alle 16 Bundesländer + 26 Kantone einzeln. Die
|
||||
Lehrperson muss das Modul als lehrplankonform erkennen, nicht den exakten
|
||||
Paragrafen finden.
|
||||
|
||||
---
|
||||
|
||||
## 6. Modul-Detailseiten: Struktur
|
||||
|
||||
Jede Modul-Detailseite (z.B. `modul-klima.php`) hat:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ HEADER (bildLogo + textlogo + Navi) │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ HERO-BEREICH │
|
||||
│ ┌──────────┐ 🌍 KLIMAWÄCHTER │
|
||||
│ │ Card-Bild│ Klima retten, Küste schützen. │
|
||||
│ │ (DALL-E) │ [Sofort probieren] │
|
||||
│ └──────────┘ [Für Lehrpersonen: Lehrplan- │
|
||||
│ Bezug] │
|
||||
│ │
|
||||
│ META-ANGABEN │
|
||||
│ Dauer: 30-45 Min · Alter: 12-16 · 3 Levels │
|
||||
│ │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ WAS LERNST DU? │
|
||||
│ [Lernziel 1] [Lernziel 2] [Lernziel 3] │
|
||||
│ (Aus learning_goals, als Bullet-Liste) │
|
||||
│ │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ WIE FUNKTIONIERT'S? │
|
||||
│ Ausführliche Beschreibung der Spielmechanik, │
|
||||
│ evtl. mit Screenshots / GIFs │
|
||||
│ │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ BEGRIFFE DIE DU KENNENLERNST │
|
||||
│ (Aus glossar-Tabelle: alle Einträge │
|
||||
│ mit module='klima') │
|
||||
│ [CO₂] [ppm] [Watt] [Megawatt] [Windpark] ... │
|
||||
│ → Kleine Chips mit Hover-Definition │
|
||||
│ │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ LEHRPLAN-BEZUG │
|
||||
│ [🇦🇹] [🇩🇪] [🇨🇭] [🇱🇮] │
|
||||
│ │
|
||||
│ Kompetenzbereich: Klimawandel verstehen │
|
||||
│ → AT MS GW Kl. 8: "Anthropogener Klimawandel"│
|
||||
│ → DE Berlin GL Kl. 9: "Klimawandel global" │
|
||||
│ → CH NMG.3.2: "Klima und Wetter" │
|
||||
│ │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ FOOTER (wie alle Seiten) │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Lehrplan-Übersichtsseite (`lehrplan.php`)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ HEADER │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ LEHRPLAN-BEZUG │
|
||||
│ │
|
||||
│ Land: [Alle] [🇦🇹 AT] [🇩🇪 DE] [🇨🇭 CH] [🇱🇮 LI]│
|
||||
│ Stufe: [Alle] [Sek I] [Sek II] │
|
||||
│ Thema: [Alle] [Klima] [Raum] [Gesellschaft] │
|
||||
│ │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ GRUPPIERT NACH KOMPETENZBEREICH │
|
||||
│ │
|
||||
│ ▸ Klimawandel verstehen │
|
||||
│ 🌍 Klimawächter 2D · 🌍 Klimawächter 3D │
|
||||
│ 🇦🇹 AT MS GW Kl. 8 — "Anthropogener..." │
|
||||
│ 🇩🇪 DE Berlin GL Kl. 9 — "Globale..." │
|
||||
│ 🇨🇭 CH Lehrplan 21 NMG.3.2 │
|
||||
│ │
|
||||
│ ▸ Naturgefahren und Hochwasser │
|
||||
│ 🏞️ Flussmanagement │
|
||||
│ 🇦🇹 ... 🇩🇪 ... 🇨🇭 ... 🇱🇮 ... │
|
||||
│ │
|
||||
│ ▸ Alpine Geografie │
|
||||
│ 🚁 Heli-Navigation │
|
||||
│ │
|
||||
│ ▸ Stadtplanung und Raumordnung │
|
||||
│ 🏙️ Stadt & Raumplanung │
|
||||
│ │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Landing-Page-Ergänzungen (Vorschlag an Zentrale)
|
||||
|
||||
Die Landing Page (`index.html`) sollte minimal ergänzt werden:
|
||||
|
||||
- Link zum Glossar im Footer oder als eigene Section
|
||||
- Link zur Lehrplan-Seite im Lehrpersonen-Bereich
|
||||
- Simulations-Cards sollen auf die neuen Modul-Detailseiten verlinken
|
||||
(nicht direkt ins Spiel) — ausser der Gast probiert direkt
|
||||
|
||||
**Du änderst die Landing NICHT selbst** — du schickst einen konkreten
|
||||
Änderungs-Vorschlag (mit Code-Diff) per Nachricht an `zentrale/`, die
|
||||
Zentrale integriert das.
|
||||
|
||||
---
|
||||
|
||||
## 9. Erste Schritte
|
||||
|
||||
1. Dieses Dokument lesen
|
||||
2. DB-Schema vorschlagen / mit Zentrale abstimmen (Inbox-Nachricht)
|
||||
3. `kompetenzen`, `module_info` etc. per PHP-Script anlegen
|
||||
4. Ersten Modul-Detailseite (`modul-klima.php`) als Template bauen
|
||||
5. Die anderen Modul-Seiten nachziehen
|
||||
6. Lehrplan-Recherche für AT + Lehrplan 21 (CH/LI) starten
|
||||
7. `lehrplan.php` mit echten Ankern befüllen
|
||||
|
||||
Bei Fragen oder DB-Änderungen: Nachricht in `App/sims/_inbox/zentrale/`.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Crash-Recovery-Protokoll
|
||||
|
||||
Was tun, wenn Windows, VS Code oder der Rechner abstürzen und alle
|
||||
Claude-Sessions weg sind? Hier die Schritt-für-Schritt-Anleitung.
|
||||
|
||||
## Was überlebt einen Absturz immer
|
||||
|
||||
- Alle **Code-Dateien** auf der Platte
|
||||
- Alle **Inbox-Nachrichten** (Markdown in `App/sims/_inbox/`)
|
||||
- Alle **Memory-Einträge** von Atlas (`~/.claude/projects/.../memory/`)
|
||||
- Alle **DB-Inhalte** (MySQL)
|
||||
- Alle **Git-Commits** (lokal im `.git/`-Ordner)
|
||||
- Die **_status.md** jeder Instanz (siehe unten)
|
||||
|
||||
## Was verloren geht
|
||||
|
||||
- Der laufende Chat jeder Instanz
|
||||
- Kontext, den eine Instanz im Kopf hatte (welche Dateien gerade gelesen,
|
||||
welche Gedanken gerade offen)
|
||||
|
||||
---
|
||||
|
||||
## Wiederherstellung — Schritt für Schritt
|
||||
|
||||
### 1. Atlas (Plattform-Zentrale) reaktivieren
|
||||
|
||||
Starte eine neue Claude-Code-Session im Projektordner.
|
||||
Erster Prompt:
|
||||
|
||||
```
|
||||
Du bist Atlas, die Plattform-Zentrale. Lies als erstes:
|
||||
1. ~/.claude/projects/c--xampp-htdocs-geograsim/memory/MEMORY.md
|
||||
2. App/sims/_inbox/zentrale/ (neuste 10 Nachrichten)
|
||||
|
||||
Dann melde dich bei mir mit einem Statusbericht: Welche Module aktiv,
|
||||
welche Aufträge offen, welche Probleme drohen.
|
||||
```
|
||||
|
||||
### 2. Aktive Modul-Instanzen reaktivieren
|
||||
|
||||
Für jede Instanz (Klima, Fluss, Glossar, Lehrplan) — neue Session, erster
|
||||
Prompt:
|
||||
|
||||
```
|
||||
Du bist die {Name}-Instanz im GeoGraSim-Projekt. Lies als erstes:
|
||||
1. App/sims/_inbox/{ordner}/_status.md (falls vorhanden — aktueller Stand)
|
||||
2. App/sims/_inbox/{ordner}/ (neuste Nachrichten, chronologisch)
|
||||
3. App/docs/module-interface.md (wenn du das Interface neu brauchst)
|
||||
|
||||
Melde dich dann bei Thomas mit: "Hier {Name}, habe Stand rekonstruiert,
|
||||
arbeite an {Task}, als nächstes {Next-Step}."
|
||||
```
|
||||
|
||||
Mapping Name → Ordner:
|
||||
- **Atlas** → `_inbox/zentrale/`
|
||||
- **Klima** → `_inbox/klima/`
|
||||
- **Glossar** → `_inbox/glossar/`
|
||||
- **Lehrplan** → `_inbox/content/`
|
||||
- **Fluss** → `_inbox/fluss/`
|
||||
|
||||
### 3. Git-Stand prüfen
|
||||
|
||||
```bash
|
||||
cd c:/xampp/htdocs/geograsim
|
||||
git status
|
||||
git log --oneline -10
|
||||
```
|
||||
|
||||
Falls uncommittete Änderungen da sind: die Instanz, die dran war, muss
|
||||
entscheiden, ob sie sie übernimmt oder verwirft. Im Zweifel **erst
|
||||
committen**, dann Instanz Stand lesen lassen.
|
||||
|
||||
### 4. Prüfe auf laufende Background-Tasks
|
||||
|
||||
Manchmal laufen Python-Scripts (Audio-Generierung, DB-Seeds) im Hintergrund.
|
||||
Nach Crash sind die tot — einfach neu starten.
|
||||
|
||||
---
|
||||
|
||||
## Status-Datei `_status.md`
|
||||
|
||||
Jede Instanz führt eine `_status.md` in ihrer eigenen Inbox. Struktur:
|
||||
|
||||
```markdown
|
||||
---
|
||||
instanz: klima
|
||||
zuletzt_aktualisiert: 2026-04-18 22:45
|
||||
session_id: informell (z.B. "engine-refactor-phase-1")
|
||||
---
|
||||
|
||||
# Aktueller Stand
|
||||
|
||||
## Wo ich stehe
|
||||
Schritt 2 fertig (Engine extrahiert, 2D umgestellt).
|
||||
Schritte 3-5 (3D V2) offen.
|
||||
|
||||
## Zuletzt geänderte Dateien
|
||||
- App/sims/klima/engine.js (neu)
|
||||
- App/sims/klima/game-2d.html (refactored, −515 Zeilen)
|
||||
|
||||
## Als nächstes
|
||||
Schritt 3: game-3d.html V2 aufbauen, Three.js per CDN, Three.js-Szene
|
||||
aus V1 portieren.
|
||||
|
||||
## Offene Entscheidungen
|
||||
- 2D/3D-Toggle auf modul-klima.php (mit Lehrplan abstimmen)
|
||||
|
||||
## Blocker
|
||||
Keine.
|
||||
```
|
||||
|
||||
**Updates:**
|
||||
- Bei jedem grösseren Fortschritt überschreiben (nicht append)
|
||||
- Am Session-Ende definitiv aktualisieren
|
||||
- Bei Pause/Übergabe ebenfalls
|
||||
|
||||
**Atlas pflegt seine Status-Datei in `_inbox/zentrale/_status.md`.**
|
||||
|
||||
---
|
||||
|
||||
## Commit-Konvention
|
||||
|
||||
### Wer committet
|
||||
|
||||
- **Atlas** committet projektweite Änderungen (Design-System, APIs, Admin,
|
||||
gemeinsame Docs)
|
||||
- **Jede Modul-Instanz** committet ihre eigenen Änderungen
|
||||
(App/sims/<modul>/*, App/pages/<modul>*.php, modul-spezifische Assets)
|
||||
|
||||
### Wann committen
|
||||
|
||||
**Pflicht:**
|
||||
- Am Ende jeder Arbeits-Session (auch wenn unfertig)
|
||||
- Nach jedem bedeutenden Meilenstein (neues Feature, Phase abgeschlossen)
|
||||
- Vor jedem Kontext-Wechsel (neue Session übernimmt)
|
||||
|
||||
**Empfohlen:**
|
||||
- Täglich mindestens einmal
|
||||
- Nach grösseren Refactorings
|
||||
- Bevor riskante Experimente starten
|
||||
|
||||
### Commit-Message-Format
|
||||
|
||||
```
|
||||
<Instanz>: <Kurzbeschreibung>
|
||||
|
||||
<Längere Erklärung falls nötig — was und warum>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Beispiele:
|
||||
```
|
||||
Klima: Engine extrahiert (Schritt 1-2)
|
||||
Klima: WIP 3D V2, Three.js-Setup + Insel-Geometrie
|
||||
Fluss: Phase 1 fertig - PHP-Wrapper + leeres Canvas
|
||||
Atlas: Music-Registry erweitert um Drama-Slot
|
||||
Glossar: 5 Repräsentationsbilder im Querformat
|
||||
```
|
||||
|
||||
### Commit-Befehl
|
||||
|
||||
```bash
|
||||
cd c:/xampp/htdocs/geograsim
|
||||
git add <dateien>
|
||||
git commit -m "<Instanz>: <Kurzbeschreibung>"
|
||||
# Kein push — lokal reicht (kein Remote eingerichtet)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional: Remote-Backup
|
||||
|
||||
Momentan **kein Remote** eingerichtet. Bei Festplatten-Defekt wäre alles
|
||||
verloren. Möglichkeiten (entscheidet Thomas):
|
||||
|
||||
1. **Eigener Server** (geograsim.at hat SSH): Meister-Instanz kann ein
|
||||
Bare-Repo in `/root/git/geograsim.git` einrichten. Dann `git push`
|
||||
als Backup.
|
||||
2. **Externe Platte**: `robocopy` nach USB-HDD, täglich.
|
||||
3. **Keine Cloud-Dienste** (Thomas hat GitHub abgelehnt).
|
||||
|
||||
Atlas schlägt vor, irgendwann Option 1 einzurichten. Nicht akut, aber
|
||||
planbar.
|
||||
@@ -0,0 +1,485 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Klimawächter — Entwicklungs-Log</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
|
||||
:root {
|
||||
--bg: #f7f6f3; --bg2: #efeee9; --white: #fff;
|
||||
--text: #1a1a1a; --text2: #4a4a4a; --text3: #8a8a8a;
|
||||
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
|
||||
--moss: #5a8a5e; --moss-l: #dceadd;
|
||||
--sand: #c4a35a; --sand-l: #f2eacc;
|
||||
--coral: #c07a6b; --coral-l: #f2ddd8;
|
||||
--danger: #b04a3a;
|
||||
}
|
||||
body {
|
||||
font-family:'Inter',system-ui,sans-serif; background:var(--bg);
|
||||
color:var(--text); line-height:1.6; padding:2rem 1rem;
|
||||
}
|
||||
.w { max-width: 880px; margin: 0 auto; }
|
||||
header {
|
||||
border-bottom: 3px solid var(--fjord); padding-bottom: 1.2rem; margin-bottom: 2rem;
|
||||
}
|
||||
h1 { font-size: 2rem; font-weight: 900; color: var(--fjord); margin-bottom: .3rem; }
|
||||
header .sub { font-size: .9rem; color: var(--text3); font-weight: 500; }
|
||||
h2 {
|
||||
font-size: 1.4rem; font-weight: 800; color: var(--fjord-d);
|
||||
margin: 2.2rem 0 .8rem; padding-top: 1rem;
|
||||
border-top: 1px solid rgba(0,0,0,.08);
|
||||
}
|
||||
h3 { font-size: 1.05rem; font-weight: 700; color: var(--text); margin: 1.2rem 0 .5rem; }
|
||||
p { font-size: .95rem; color: var(--text2); margin-bottom: .8rem; }
|
||||
ul, ol { margin: .5rem 0 1rem 1.4rem; }
|
||||
li { font-size: .93rem; color: var(--text2); margin-bottom: .35rem; }
|
||||
code {
|
||||
background: var(--bg2); padding: 1px 6px; border-radius: 4px;
|
||||
font-size: .85em; font-family: "SF Mono", Menlo, monospace;
|
||||
color: var(--fjord-d);
|
||||
}
|
||||
pre {
|
||||
background: #1a1a1a; color: #e8e6de; padding: 1rem 1.2rem;
|
||||
border-radius: 10px; font-size: .82rem; overflow-x: auto;
|
||||
font-family: "SF Mono", Menlo, monospace; margin: .8rem 0 1.2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--white); border: 1px solid rgba(0,0,0,.06);
|
||||
border-radius: 12px; padding: 1.1rem 1.3rem; margin: .8rem 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.03);
|
||||
}
|
||||
.card.green { border-left: 4px solid var(--moss); }
|
||||
.card.sand { border-left: 4px solid var(--sand); }
|
||||
.card.coral { border-left: 4px solid var(--coral); }
|
||||
.card.fjord { border-left: 4px solid var(--fjord); }
|
||||
.card h3 { margin-top: 0; }
|
||||
.tag {
|
||||
display: inline-block; font-size: .68rem; font-weight: 700;
|
||||
padding: 2px 8px; border-radius: 6px;
|
||||
background: var(--fjord-l); color: var(--fjord-d);
|
||||
text-transform: uppercase; letter-spacing: .05em;
|
||||
margin-right: .3rem;
|
||||
}
|
||||
.tag.done { background: var(--moss-l); color: var(--moss); }
|
||||
.tag.todo { background: var(--sand-l); color: var(--sand); }
|
||||
.tag.idea { background: var(--coral-l); color: var(--danger); }
|
||||
table {
|
||||
width: 100%; border-collapse: collapse; margin: .8rem 0 1.2rem;
|
||||
font-size: .85rem;
|
||||
}
|
||||
th, td {
|
||||
text-align: left; padding: .55rem .7rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,.08);
|
||||
}
|
||||
th { background: var(--bg2); font-weight: 700; color: var(--text); }
|
||||
.quote {
|
||||
border-left: 3px solid var(--fjord);
|
||||
background: var(--fjord-l);
|
||||
padding: .6rem .9rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: italic;
|
||||
color: var(--fjord-d);
|
||||
margin: .5rem 0 1rem;
|
||||
font-size: .9rem;
|
||||
}
|
||||
nav.toc {
|
||||
background: var(--bg2); border-radius: 10px; padding: 1rem 1.3rem; margin-bottom: 2rem;
|
||||
}
|
||||
nav.toc h4 {
|
||||
font-size: .7rem; font-weight: 800; color: var(--fjord); text-transform: uppercase;
|
||||
letter-spacing: .06em; margin-bottom: .5rem;
|
||||
}
|
||||
nav.toc ol { margin-left: 1.2rem; }
|
||||
nav.toc a {
|
||||
color: var(--text2); text-decoration: none; font-size: .88rem;
|
||||
}
|
||||
nav.toc a:hover { color: var(--fjord); }
|
||||
footer {
|
||||
margin-top: 3rem; padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(0,0,0,.08);
|
||||
font-size: .8rem; color: var(--text3); text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="w">
|
||||
|
||||
<header>
|
||||
<h1>Klimawächter — Entwicklungs-Log</h1>
|
||||
<div class="sub">Erkenntnisse, offene Punkte und nächste Schritte · Stand 2026-04-11 (3 Schwierigkeitsgrade + Bilanz + Steg-Rampe)</div>
|
||||
</header>
|
||||
|
||||
<nav class="toc">
|
||||
<h4>Inhalt</h4>
|
||||
<ol>
|
||||
<li><a href="#ziel">Didaktisches Ziel und Zielgruppe</a></li>
|
||||
<li><a href="#architektur">Architektur-Überblick</a></li>
|
||||
<li><a href="#mechanik">Mechanik-Erkenntnisse aus Trace-Tests</a></li>
|
||||
<li><a href="#ui">UI / Interaktion</a></li>
|
||||
<li><a href="#visual">Visualisierung (2D / 3D)</a></li>
|
||||
<li><a href="#erklaerungen">Erklärungen für Kinder 10–14</a></li>
|
||||
<li><a href="#folgen">Weitere Folgen des Klimawandels</a></li>
|
||||
<li><a href="#offen">Offene Punkte & nächste Schritte</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<section id="ziel">
|
||||
<h2>1. Didaktisches Ziel und Zielgruppe</h2>
|
||||
|
||||
<p>Der <strong>Klimawächter</strong> ist kein Spiel um einen high score. Er soll Schülerinnen und Schülern der 1.–4. Klasse Sekundarstufe I (ca. <strong>10–14 Jahre</strong>) vermitteln, dass:</p>
|
||||
<ul>
|
||||
<li>der Klimawandel träge ist — Folgen kommen verzögert, Entscheidungen wirken erst in Jahren</li>
|
||||
<li>es keine Wunderlösung gibt — man muss mehrere Strategien kombinieren</li>
|
||||
<li>reine Anpassung (Deiche, Strandaufschüttung) die Ursache nicht löst</li>
|
||||
<li>Klimaschutz kein isoliertes Naturthema ist — gesellschaftliche Konflikte gehören dazu</li>
|
||||
<li>eine Insel stellvertretend für den Planeten stehen kann</li>
|
||||
</ul>
|
||||
|
||||
<div class="quote">
|
||||
Kinder haben typischerweise Fehlkonzepte wie „Ozonloch = Klimawandel" oder „Wenn ich hier einen Baum pflanze, ist alles gut". Das Spiel arbeitet dezent dagegen an.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<section id="architektur">
|
||||
<h2>2. Architektur-Überblick</h2>
|
||||
|
||||
<div class="card fjord">
|
||||
<h3>Frontend-Stack</h3>
|
||||
<ul>
|
||||
<li><code>TypeScript + Vite</code> (keine Framework-Schicht)</li>
|
||||
<li><code>Three.js</code> für die 3D-Version</li>
|
||||
<li>2D-Version nutzt Canvas-API direkt</li>
|
||||
<li>Multi-Page HTML (<code>game.html</code>, <code>game-3d.html</code>, <code>erdbeben.html</code>, <code>energiemix.html</code>)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card fjord">
|
||||
<h3>Kernklassen</h3>
|
||||
<table>
|
||||
<tr><th>Datei</th><th>Rolle</th></tr>
|
||||
<tr><td><code>src/core/game-engine.ts</code></td><td>Abstract base: Ticks, Resources, Goals, Save/Load, Subscribe-Pattern, Citizen-Events</td></tr>
|
||||
<tr><td><code>src/ui/game-ui.ts</code></td><td>Wiederverwendbare UI-Komponente mit Panels, Graphen, Auto-Save, End-Screen</td></tr>
|
||||
<tr><td><code>src/ui/info-overlay.ts</code></td><td>Kindgerechte Begriffserklärungen (Info-Buttons öffnen Overlay)</td></tr>
|
||||
<tr><td><code>src/sims/sim-05-treibhaus/game.ts</code></td><td>Klimawächter-Spiellogik</td></tr>
|
||||
<tr><td><code>src/sims/sim-05-treibhaus/game-renderer.ts</code></td><td>2D-Canvas-Renderer</td></tr>
|
||||
<tr><td><code>src/sims/sim-05-treibhaus-3d/game-renderer-3d.ts</code></td><td>Three.js 3D-Renderer</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card fjord">
|
||||
<h3>Save-System</h3>
|
||||
<p>Alle Spielstände werden pro Spiel-ID in <code>localStorage</code> persistiert. Version-Bump (v2) verhindert, dass alte inkonsistente Saves geladen werden. Ein <code>resetting</code>-Flag verhindert, dass der <code>beforeunload</code>-Handler beim Reset sofort wieder speichert.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<section id="mechanik">
|
||||
<h2>3. Mechanik-Erkenntnisse aus Trace-Tests</h2>
|
||||
|
||||
<p>Es existiert ein automatischer Test unter <code>tests/unit/sim-05-trace.test.ts</code>, der das Spiel rein rechnerisch über 75 Jahre für verschiedene Strategien durchspielt und die Werte tabellarisch ausdruckt. Starten mit:</p>
|
||||
<pre>npx vitest run sim-05-trace --reporter=verbose</pre>
|
||||
|
||||
<h3>Balance-Status (aktuell kalibriert)</h3>
|
||||
<table>
|
||||
<tr><th>Strategie</th><th>Ergebnis 2100</th><th>Status</th></tr>
|
||||
<tr><td>Nichts tun</td><td>Temp 17.9°C, Flut 52%</td><td>❌</td></tr>
|
||||
<tr><td>Nur Wälder</td><td>Temp 17.1°C</td><td>❌ (knapp)</td></tr>
|
||||
<tr><td>Nur Solar</td><td>Temp 15.5°C, Budget knapp</td><td>✅</td></tr>
|
||||
<tr><td>Nur Wind</td><td>Temp 15.5°C, Budget ok</td><td>✅</td></tr>
|
||||
<tr><td>Nur Deiche</td><td>Temp 17.9°C — Klima läuft durch</td><td>❌</td></tr>
|
||||
<tr><td>Wälder + Solar</td><td>grenzwertig — meist verloren</td><td>⚠️</td></tr>
|
||||
<tr><td>Solar + Wind + 1 Deich</td><td>alle Goals ✅</td><td>✅</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="card sand">
|
||||
<h3>Didaktische Aussagen, die aus dem Trace sichtbar werden</h3>
|
||||
<ul>
|
||||
<li>Einzelmaßnahme = zu wenig. Man muss kombinieren.</li>
|
||||
<li>Deiche allein retten kurzfristig Häuser, aber nicht das Klima.</li>
|
||||
<li>Aufforsten ist wichtig, aber als Einzelmaßnahme zu langsam.</li>
|
||||
<li>Wind ist stärker als Solar, aber wegen Wartungskosten nicht immer die beste Wahl.</li>
|
||||
<li>Das Klima hat eine Trägheit von ca. 8 %/Jahr für Temperatur und 6–8 %/Jahr für Meeresspiegel.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3>Bug-Historie (chronologisch)</h3>
|
||||
<ol>
|
||||
<li><strong>Subclass-Felder nicht im Save</strong> → Werte driften zwischen UI und Rechnung. <em>Fix: <code>serializeSubclass()</code> Hook.</em></li>
|
||||
<li><strong>Wasser stand still im 3D-Renderer</strong> → Protection wurde von waterY abgezogen. <em>Fix: Wasser steigt jetzt absolut, Protection wirkt nur auf Submerge-Check der Häuser.</em></li>
|
||||
<li><strong>Solar überpowered</strong> → CO₂ fiel unter vorindustriell. <em>Fix: Maßnahmen-Reduktion halbiert, CO₂-Floor 350 ppm.</em></li>
|
||||
<li><strong>Reset-Button ohne Wirkung</strong> → beforeunload schrieb Stand wieder ins localStorage. <em>Fix: <code>resetting</code>-Flag.</em></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<section id="ui">
|
||||
<h2>4. UI / Interaktion</h2>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Auto-Save</h3>
|
||||
<p>Jede Aktion → debounced Save nach 1.5 s. Sofortiges Save bei Tab-Wechsel oder Page-Verlassen. Auto-Load beim erneuten Öffnen.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Bürger-Beschwerden mit Choices</h3>
|
||||
<p>Modaler Dialog mit Avatar, Message und 2–3 Choice-Karten. Jede Wahl hat echte Konsequenzen. Spiel pausiert automatisch bis der Spieler entscheidet. Bibliothek: 7 Events über die 75 Jahre verteilt (Fischerin, Forscherin, Bauer, Hotel, Jugend-Streik, Industrie, Bergdorf).</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Reset-Button</h3>
|
||||
<p>Im Header oben rechts (🔄). Löscht den Save-Slot und lädt die Seite neu.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Header-Refactor</h3>
|
||||
<p>Save/Load/Finish/Reset/Home sind jetzt reine Symbole mit Tooltips. Speed-Buttons mit Zahlen (×1, ×2, ×4) und Kamera-Controls (+, −, ⊙) sind in den Header gewandert. Das spart Platz unten und macht die Graphen größer.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Guided / Free Mode</h3>
|
||||
<p>GameUI unterstützt <code>mode: 'guided'</code> mit <code>totalDurationSec</code>. Im Guided-Mode wird <code>msPerTick</code> automatisch so gesetzt, dass das Spiel genau die vorgegebene Zeit dauert, und Speed-Buttons > 1 sind unsichtbar. Bereit für späteres Lehrer-Backend.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Strom-System (Phase A der SimCity-Erweiterung)</h3>
|
||||
<p>Neue Ressource <code>⚡ Strom</code>, Anzeige als „Bedarf/Kapazität MW" in der Status-Box. Bedarf = 1 MW pro 1.000 Einwohner. Kapazität ergibt sich aus den gebauten Kraftwerken:</p>
|
||||
<ul>
|
||||
<li><strong>☀️ Solar</strong> = +1 MW (klein, sauber)</li>
|
||||
<li><strong>🌬️ Wind</strong> = +3 MW (mittel, sauber, teuer)</li>
|
||||
<li><strong>🏭 Kohle</strong> = +8 MW (sofort viel Strom — aber +1.5 ppm CO₂/Jahr)</li>
|
||||
</ul>
|
||||
<p>Bei Stromausfall (Bedarf > Kapazität): siehe nächste Karte.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> „Bäume verheizen" bei Stromausfall</h3>
|
||||
<p>Wenn der Strom ausfällt, fällen die Bewohner pro Jahr 2 Bäume und verheizen das Holz. Konsequenzen:</p>
|
||||
<ul>
|
||||
<li>+0.6 ppm CO₂ pro Jahr (Holzfeuer)</li>
|
||||
<li>Bäume gehen sichtbar ins „dead"-Stadium über (schwarzer Strunk, Totenkopf-Sprite)</li>
|
||||
<li>Tote Bäume binden kein CO₂ mehr → langfristig schlimmer</li>
|
||||
<li>Ab 3. Stromausfall-Jahr in Folge: Bevölkerung wandert ab</li>
|
||||
</ul>
|
||||
<p>Game führt einen Counter <code>treesChoppedForHeat</code>; Renderer liest ihn pro Frame und tötet die Differenz an lebendigen Bäumen.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Maßnahme abreißen (50 % Rückzahlung)</h3>
|
||||
<p>Neue Methode <code>game.demolishMeasure(id)</code>. UI: 🗑-Button auf jeder Karte mit count>0. Renderer entfernt das zuletzt platzierte Mesh, gibt Geometrie/Material frei. Erlaubt Reparatur falscher Frühentscheidungen (z.B. Kohlekraftwerk wieder loswerden, sobald Wind genug liefert).</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Lina Fischerin nach hinten verschoben</h3>
|
||||
<p>Citizen-Event „Lina, die Fischerin" war auf Tick 4 — viel zu früh. Ist jetzt auf Tick 15 (≈ 1 Minute Spielzeit). Spieler kann zuerst seine Strom-Versorgung in den Griff bekommen, bevor das erste Bürger-Dilemma kommt.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Drei Schwierigkeitsgrade (Lernen / Üben / Profi)</h3>
|
||||
<p>Ein <code>DifficultyConfig</code>-System mit 3 Levels (🟢/🟡/🔴) bestimmt Startbudget, Steuern, Stromausfall-Schonfrist und die Härte der Klima-Folgen. Spieler wählt beim ersten Start in einem Selector-Overlay; danach klickt er das Level-Badge oben in der Topbar, um zu wechseln (mit Bestätigung, weil das Spiel neu startet).</p>
|
||||
<ul>
|
||||
<li><strong>🟢 Lernen:</strong> 750 Mio € Start, 220 €/10k, 3 Jahre Schonfrist, halbe Klima-Folgen — sehr viel Puffer</li>
|
||||
<li><strong>🟡 Üben:</strong> 550 Mio €, 175 €/10k, 1 Jahr Schonfrist — Standard, Strategie nötig</li>
|
||||
<li><strong>🔴 Profi:</strong> 400 Mio €, 145 €/10k, 0 Schonfrist, 1.5× Klima-Folgen — kein Spielraum</li>
|
||||
</ul>
|
||||
<p>URL-Parameter <code>?level=N</code> erzwingt ein Level (für späteren Lehrer-Override). Save-File enthält das Level für Resume.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Maßnahmen halbiert (zweite Runde) + Wald-Wartung-Fix</h3>
|
||||
<p>Alle Maßnahmen-Werte (Cost, Upkeep, CO₂-Wirkung, Schutz) erneut halbiert — gleicher Effekt pro Mio €, aber doppelt so viele individuelle Bauschritte. Mehr Streuung, mehr Spielmöglichkeiten.</p>
|
||||
<p>Wald-Upkeep zusätzlich auf <strong>0.5 Mio €/Jahr</strong> reduziert (statt 1) — damit ist Wald jetzt die <strong>günstigste Option pro CO₂-Reduktion</strong> (12.5 Mio €/ppm vs Wind 15, Solar 17.9). Das passt zur didaktischen Erwartung „Bäume sind die Naturlösung".</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Jahres-Bilanz unter Budget</h3>
|
||||
<p>Kleine Tabelle direkt unter dem Budget-Wert in der Status-Box zeigt: + Steuern, (+ Tourismus), − Wartung, (− Klima-Schaden), = Netto pro Jahr. Tourism- und Klima-Zeilen sind dynamisch sichtbar. Netto wird grün/rot gefärbt. Lebt mit der gedämpften Display-Animation.</p>
|
||||
<p>Hat in der ersten Spielanalyse sofort sichtbar gemacht, dass eine Bilanz von −9 Mio €/Jahr auf Dauer nicht überleben kann.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Steg mit Rampe ans Land</h3>
|
||||
<p>Der Holzsteg am vorderen Strand hat jetzt eine schräge <strong>Holz-Rampe</strong>, die vom Inland-Niveau (auf der Insel selbst) hochsteigt zur Plankenkante (Y=0.85). Damit bleibt der Steg auch bei steigendem Meeresspiegel über die Rampe erreichbar. Die Rampe sitzt physisch auf dem Inselhang und wird vom Wasser nicht überspült.</p>
|
||||
</div>
|
||||
|
||||
<div class="card sand">
|
||||
<h3><span class="tag idea">idee</span> Mitigation vs. Adaptation — didaktische Diskussion</h3>
|
||||
<p>Aktuell könnte ein Spieler theoretisch nur Deiche/Hochwasserschutz bauen und ignorieren, dass CO₂ die Ursache ist — solange Temperatur knapp unter 17 °C bleibt. Das vermittelt die falsche Lektion „Mauern reichen". Ideen zum Adressieren:</p>
|
||||
<ul>
|
||||
<li><strong>Kipppunkt:</strong> Ab Sealevel > 80 cm sind Deiche nicht mehr genug — sie reißen bei Sturmflut</li>
|
||||
<li><strong>Wartung steigt mit Sealevel:</strong> Erosion macht Deiche teurer im Unterhalt</li>
|
||||
<li><strong>Trace-Test:</strong> „Nur-Deich-Strategie" muss langfristig verlieren</li>
|
||||
</ul>
|
||||
<p>Wichtig für die nächste Iteration — adressiert die Kernfrage „Sind Deiche wirklich eine gute Lösung?" (Antwort: nur als Pflaster, nicht als Heilung).</p>
|
||||
</div>
|
||||
|
||||
<div class="card sand">
|
||||
<h3><span class="tag todo">todo</span> Drag-and-Drop Platzierung — Phase B (kurzfristig!)</h3>
|
||||
<p>Option A aus dem ursprünglichen Interaktions-Konzept. Maßnahmen werden aus dem Shop auf die 3D-Insel gezogen, statt automatisch platziert. Standortabhängige Wirkung: Solar im Vulkan-Schatten = −20% Effizienz, Wind am Hang = +20% etc.</p>
|
||||
<p><strong>Voraussetzung für Phase C (Wohnungsbau):</strong> Sobald Schüler Häuser kauft, muss er sie auch SETZEN können — sonst frustrierend.</p>
|
||||
</div>
|
||||
|
||||
<div class="card sand">
|
||||
<h3><span class="tag todo">todo</span> Wohnungsbau + Neuankömmlinge — Phase C</h3>
|
||||
<p>SimCity-artige Bevölkerungsdynamik:</p>
|
||||
<ul>
|
||||
<li>Start mit ~10 Häusern statt 18 (heute schon 6.000 Einwohner statt 10.000)</li>
|
||||
<li>Neue Maßnahme <strong>🏘 Wohnhäuser</strong> (cost 120, +1.000 Kapazität, +1 MW Bedarf)</li>
|
||||
<li>Neuankömmlinge-Queue: pro Tick wollen X Familien zuziehen — kommen nur, wenn freie Wohnungen UND Strom da sind</li>
|
||||
<li>Visuell: 2-3 wandernde Sprite-Figuren am Strand (Billboards wie das Boot)</li>
|
||||
<li>Tutorial gestaffelt: erst Strom (Phase A), dann Bauen (Phase B), dann Wohnen (Phase C)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card sand">
|
||||
<h3><span class="tag todo">todo</span> Krisen-Mini-Spiele</h3>
|
||||
<p>Bei Sturmflut, Hitzewelle, Murenabgang ein kurzes (10–20 s) Klick-Mini-Spiel. Erfolg mildert den Schaden.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<section id="visual">
|
||||
<h2>5. Visualisierung (2D / 3D)</h2>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Insel als Keil (schrägansteigend)</h3>
|
||||
<p>Beide Renderer (2D und 3D) bilden die Insel nicht mehr als Pfannkuchen ab, sondern als geneigtes Plateau. Die vordere (niedrige) Seite taucht absichtlich ins Wasser — so ist schon am Start sichtbar, dass man nicht die ganze Insel retten kann.</p>
|
||||
<ul>
|
||||
<li><strong>3D:</strong> Ellipse, per PlaneGeometry aus 80×96 Segmenten, mit Noise-Verformung und Keil-Interpolation von 1.3 (hinten) nach −0.15 (vorne).</li>
|
||||
<li><strong>2D:</strong> horizontaler Side-View, linke Seite hoch (Vulkan), rechts niedrig ins Meer. <code>landSurface(x)</code>-Helper interpoliert Y-Koordinate für Platzierung von Häusern, Bäumen, Solaranlagen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Vulkan mit Gletscher-Deckel</h3>
|
||||
<p>Im 3D-Renderer steht ein erloschener Vulkan am hohen Ende der Insel. Sein oberes Drittel ist von einem opaken Gletscher-Deckel bedeckt. Der Deckel schrumpft mit steigender Temperatur:</p>
|
||||
<ul>
|
||||
<li>15.0 °C → 100 % Eis</li>
|
||||
<li>16.5 °C → 50 % Eis</li>
|
||||
<li>18.0 °C → 0 % Eis (kahler Fels)</li>
|
||||
</ul>
|
||||
<p>Im 2D-Renderer wird derselbe Gletscher-Deckel als Trapez mit Alpha-loser Farbverschiebung dargestellt.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Heizwolken über Häusern ohne Solar</h3>
|
||||
<p>Jedes Starthaus im 3D hat eine kleine graue Sphere-Wolke über dem Dach. Sobald der Spieler Solar oder Gründächer baut, verschwindet pro Anlage eine Wolke. Direkte visuelle Verbindung zwischen Maßnahme und Effekt.</p>
|
||||
</div>
|
||||
|
||||
<div class="card green">
|
||||
<h3><span class="tag done">done</span> Graphen mit Zonen und Referenzlinien</h3>
|
||||
<p>Die neue <code>GraphConfig</code> unterstützt:</p>
|
||||
<ul>
|
||||
<li><strong>Farbbänder</strong> (z. B. "Klimaziel", "Kritisch", "Gefahr" bei Temperatur)</li>
|
||||
<li><strong>Horizontale Referenzlinien</strong> (z. B. "Klimaziel 17°C", "Strand", "Häuser")</li>
|
||||
<li><strong>Sinnvolle Y-Range</strong> pro Graph — ausreizen der Höhe statt Standard 0..1</li>
|
||||
<li><strong>Live-Wert</strong> als farbiger Badge rechts oben</li>
|
||||
</ul>
|
||||
<p>Graphen sind jetzt etwa doppelt so groß wie vorher (90 px statt ca. 36 px Höhe).</p>
|
||||
</div>
|
||||
|
||||
<div class="card sand">
|
||||
<h3><span class="tag idea">idee</span> Weitere Visualisierungs-Ideen</h3>
|
||||
<ul>
|
||||
<li>Strand schrumpft mit steigendem Meer sichtbar</li>
|
||||
<li>Mangroven-Streifen am Rand, die bei Sealevel>20 cm verfärben/sterben</li>
|
||||
<li>Verstreute Tote-Pflanzen-Sprites auf überschwemmtem Gebiet</li>
|
||||
<li>Vulkan kann bei >18°C tatsächlich „Lava" aus dem Krater drücken (Kipppunkt)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<section id="erklaerungen">
|
||||
<h2>6. Erklärungen für Kinder 10–14</h2>
|
||||
|
||||
<p>Das neue <code>info-overlay.ts</code>-Modul bietet eine Bibliothek kindgerechter Erklärtexte. Jeder Graph und jedes wichtige Event hat einen (i)-Button, der ein Overlay öffnet. Zielgruppe: 10–14 Jahre, einfache Sätze, max. 3 Absätze plus "Merksatz".</p>
|
||||
|
||||
<h3>Bisher erklärte Begriffe</h3>
|
||||
<ul>
|
||||
<li><strong>CO₂</strong> — Was es ist, wie es wirkt, warum 420 ppm gefährlich sind</li>
|
||||
<li><strong>Temperatur</strong> — Durchschnittstemperatur, Pariser Ziel, was 2°C bedeuten</li>
|
||||
<li><strong>Meeresspiegel</strong> — Warum er steigt (Wärmeausdehnung + Eis), Inselstaaten</li>
|
||||
<li><strong>Budget</strong> — Wie Einnahmen/Wartung funktionieren</li>
|
||||
<li><strong>Gletscher</strong> — Was sie sind, wie sie schmelzen, warum das Trinkwasser fehlt</li>
|
||||
<li><strong>Insel-Keilform</strong> — Warum echte Inseln nicht flach sind</li>
|
||||
<li><strong>Deich</strong> — Wie er funktioniert, Niederlande, "Anpassung" vs. Ursachenbekämpfung</li>
|
||||
<li><strong>Solar / Wind / Wald</strong> — Jeweils wie sie wirken und warum alleine nicht reichen</li>
|
||||
<li><strong>Vegetation stirbt durch Salzwasser</strong> — Versalzung, konkretes Beispiel Kenia</li>
|
||||
<li><strong>Trinkwasser</strong> — Süßwasser-Brunnen auf Inseln, salzige Vermischung</li>
|
||||
</ul>
|
||||
|
||||
<div class="quote">
|
||||
Alle Texte sind in <code>src/ui/info-overlay.ts</code> als <code>INFO_TOPICS</code>-Objekt zentralisiert und können leicht ergänzt werden. Aufruf: <code>openInfoOverlay(INFO_TOPICS.co2)</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<section id="folgen">
|
||||
<h2>7. Weitere Folgen des Klimawandels</h2>
|
||||
|
||||
<p>Bisher hat das Spiel folgende Folgen von Temperatur- und Meeresspiegelanstieg modelliert:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Folge</th><th>Auslöser</th><th>Wirkung</th></tr>
|
||||
<tr><td>Häuser werden überflutet</td><td>Sealevel > 35 cm (nach Protection)</td><td>Bevölkerungsrückgang</td></tr>
|
||||
<tr><td>Vegetation stirbt (Mangroven, Felder)</td><td>Sealevel > 20 cm</td><td>Warn-Event mit Info-Overlay</td></tr>
|
||||
<tr><td>Trinkwasser versalzt</td><td>Sealevel > 35 cm</td><td>Budget-Abzug pro Jahr + Bevölkerungsrückgang</td></tr>
|
||||
<tr><td>Gletscher schmilzt</td><td>Temp > 16.8 °C</td><td>Warn-Event, visueller Schrumpf</td></tr>
|
||||
<tr><td>Bergdorf gefährdet</td><td>Temp > 16.5 °C (Tick 50)</td><td>Citizen-Event (Anna vom Bergdorf)</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="card sand">
|
||||
<h3><span class="tag idea">idee</span> Noch fehlende Folgen</h3>
|
||||
<ul>
|
||||
<li><strong>Korallenbleiche</strong> — Meereswärme > +1.5°C → Fisch-Ertrag sinkt → Budget</li>
|
||||
<li><strong>Tropenstürme häufiger</strong> — Kleine Schadensereignisse mit kurzem Mini-Spiel</li>
|
||||
<li><strong>Waldbrände</strong> — Wenn zu viele Wälder + zu hohe Temperatur → ein Wald brennt ab</li>
|
||||
<li><strong>Klima-Migration</strong> — Bevölkerung wandert zur nächsten Insel ab (negativer Trend)</li>
|
||||
<li><strong>Kipppunkte</strong> — Ab bestimmten Schwellwerten unumkehrbare Verschlechterung</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<section id="offen">
|
||||
<h2>8. Offene Punkte & nächste Schritte</h2>
|
||||
|
||||
<h3>Kurzfristig</h3>
|
||||
<ul>
|
||||
<li><strong>Phase B: Drag-and-Drop-Platzierung (Baueditor)</strong> — Voraussetzung für Phase C</li>
|
||||
<li>2D-Renderer: Zeitleiste und Housing-Logik auf <code>landSurface(x)</code> prüfen (Häuser auf dem Keil, nicht auf dem alten groundY)</li>
|
||||
<li>2D-Renderer: Strom-System aus 3D-Version übernehmen</li>
|
||||
<li>Graph-Info-Buttons auch in die anderen HTML-Pages (<code>game.html</code>, <code>erdbeben.html</code>, <code>energiemix.html</code>) einbauen</li>
|
||||
<li>Citizen-Events mit <code>infoKey</code> versehen (mehr Kontext für Kinder)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Mittelfristig</h3>
|
||||
<ul>
|
||||
<li><strong>Phase C: Wohnungsbau + Neuankömmlinge</strong> (siehe Karte oben)</li>
|
||||
<li>Krisen-Mini-Spiele (Option D)</li>
|
||||
<li>Trace-Test um Citizen-Events und Stromausfall erweitern</li>
|
||||
<li>Strom-Tooltip ausführlich machen + Bilder/Diagramm</li>
|
||||
</ul>
|
||||
|
||||
<h3>Langfristig</h3>
|
||||
<ul>
|
||||
<li>Login/Account-System mit Node + Express + SQLite (pausiert)</li>
|
||||
<li>Lehrer-Dashboard mit Klassenverwaltung</li>
|
||||
<li>Forschungs-Export von <code>game_runs</code> als CSV</li>
|
||||
<li>Weitere Simulationen im selben Framework (Bevölkerungsdynamik, Raumplanung, Planetary Boundaries)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
GeoGraSim · Klimawächter Entwicklungs-Log ·
|
||||
automatisch generiert aus Chat-Dialog mit Claude ·
|
||||
letzte Aktualisierung: 2026-04-11
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,618 @@
|
||||
# GeoGraSim — Module Interface Specification
|
||||
|
||||
Dieses Dokument definiert die Schnittstellen zwischen der Lernplattform und den einzelnen Simulationsmodulen. Jede Modul-Instanz (Claude Code Session) bekommt dieses Dokument als Kontext.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur-Überblick
|
||||
|
||||
```
|
||||
Lernplattform (PHP + MySQL) Simulationsmodul (HTML/JS/Canvas)
|
||||
───────────────────────────── ─────────────────────────────────
|
||||
Authentifizierung & Sessions → Spieler-Kontext (wer, welches Level)
|
||||
Modul-Freigabe pro Klasse → Zugang ja/nein
|
||||
Level-Konfiguration → Schwierigkeitsparameter
|
||||
← Live-Fortschritt (während Spiel)
|
||||
← Achievements (Badges, Sterne)
|
||||
← Reflexionsantworten
|
||||
← Abschluss-Assessment
|
||||
Detail-View (Schüler) ← Modulspezifische Ergebnis-Daten
|
||||
Detail-View (Lehrer) ← Aktionslog + Metriken
|
||||
Summary-Kachel ← Standardisierte Zusammenfassung
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Einbettung: PHP-Page pro Modul
|
||||
|
||||
Jedes Modul wird über eine PHP-Seite ausgeliefert: `App/pages/{modul-id}.php`
|
||||
|
||||
Die PHP-Seite:
|
||||
1. Prüft die Session (Schüler oder Gast)
|
||||
2. Lädt Level-Konfiguration aus der DB
|
||||
3. Injiziert den Spieler-Kontext als JavaScript-Objekt
|
||||
4. Lädt die HTML-Datei des Moduls (Template-Replacement oder direktes PHP)
|
||||
|
||||
### Spieler-Kontext (PHP → JS)
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.__GGS__ = {
|
||||
// Session
|
||||
sessionId: '<?= $_SESSION["student_session"] ?? "" ?>',
|
||||
studentId: <?= $_SESSION['student_id'] ?? 'null' ?>,
|
||||
studentName: '<?= addslashes($_SESSION["student_name"] ?? "Gast") ?>',
|
||||
classId: <?= $_SESSION['class_id'] ?? 'null' ?>,
|
||||
|
||||
// Modul
|
||||
simId: 'klima', // eindeutige Modul-ID
|
||||
simName: 'Klimawächter', // Anzeigename
|
||||
level: 1, // aktuelles Level (1, 2, 3)
|
||||
|
||||
// Level-Konfiguration (aus DB: game_levels)
|
||||
levelConfig: {
|
||||
difficulty: 'easy',
|
||||
timeLimit: 75, // Runden/Ticks
|
||||
startBudget: 200,
|
||||
eventFrequency: 0.3,
|
||||
// ... modulspezifische Parameter
|
||||
},
|
||||
|
||||
// Vorheriges Ergebnis (falls vorhanden)
|
||||
previousScore: null, // oder {stars: 3, score: 72, ...}
|
||||
|
||||
// URLs
|
||||
baseUrl: '/geograsim/App', // oder '' bei geograsim.at
|
||||
apiUrl: '/geograsim/App/php/api'
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API-Endpunkte (JS → PHP)
|
||||
|
||||
Alle API-Calls gehen an `{apiUrl}/{endpoint}.php` via `fetch()` mit JSON body.
|
||||
|
||||
### 3.1 Live-Fortschritt melden
|
||||
|
||||
```
|
||||
POST /php/api/progress.php
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"sim_id": "klima",
|
||||
"action": "update",
|
||||
"data": {
|
||||
"level": 1,
|
||||
"phase": "playing", // "tutorial", "playing", "paused", "finished"
|
||||
"tick": 34, // aktueller Spielfortschritt
|
||||
"tick_max": 75, // maximale Ticks
|
||||
"score_so_far": 45, // Zwischenstand (0–100)
|
||||
"metrics": { // modulspezifische Metriken
|
||||
"co2": 780,
|
||||
"temperature": 1.1,
|
||||
"budget": 145
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Wann aufrufen:** Alle 10 Ticks oder bei wichtigen Ereignissen. Nicht bei jedem Tick — das wäre zu viel Traffic.
|
||||
|
||||
### 3.2 Achievement melden
|
||||
|
||||
```
|
||||
POST /php/api/progress.php
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"sim_id": "klima",
|
||||
"action": "achievement",
|
||||
"data": {
|
||||
"level": 1,
|
||||
"badge_key": "first_windpark", // eindeutiger Key
|
||||
"badge_name": "Windkraft-Pionier", // Anzeigename
|
||||
"badge_icon": "🌬️", // Emoji
|
||||
"badge_desc": "Ersten Windpark gebaut",
|
||||
"xp": 50 // XP-Belohnung
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Reflexionsantwort speichern
|
||||
|
||||
```
|
||||
POST /php/api/progress.php
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"sim_id": "klima",
|
||||
"action": "reflection",
|
||||
"data": {
|
||||
"level": 1,
|
||||
"question": "Was war deine wirksamste Entscheidung?",
|
||||
"answer": "Windpark gebaut",
|
||||
"answer_index": 0, // Index der gewählten Option
|
||||
"options": ["Windpark gebaut", "Küstenschutz ausgebaut", "Verkehr reduziert", "Ich bin mir nicht sicher"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Abschluss-Assessment
|
||||
|
||||
```
|
||||
POST /php/api/progress.php
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"sim_id": "klima",
|
||||
"action": "submit_assessment",
|
||||
"data": {
|
||||
"level": 1,
|
||||
"stars": 3, // 1–5
|
||||
"score": 72, // 0–100 (Gesamtbewertung)
|
||||
"duration_ms": 324000, // Spielzeit in Millisekunden
|
||||
"completed": true, // Level geschafft?
|
||||
|
||||
// Modulspezifische Ergebnisse
|
||||
"results": {
|
||||
"final_co2": 580,
|
||||
"final_temperature": 1.3,
|
||||
"final_budget": 42,
|
||||
"final_flooded_pct": 12,
|
||||
"renewable_pct": 78,
|
||||
"measures_bought": 8,
|
||||
"events_survived": 5
|
||||
},
|
||||
|
||||
// Aktionslog (für Lehrer-Detailansicht)
|
||||
"action_log": [
|
||||
{"tick": 3, "action": "buy", "item": "windpark", "cost": 25},
|
||||
{"tick": 5, "action": "buy", "item": "solar", "cost": 18},
|
||||
{"tick": 8, "action": "buy", "item": "deich", "cost": 30},
|
||||
{"tick": 12, "action": "event", "type": "sturmflut", "result": "survived"},
|
||||
// ...
|
||||
],
|
||||
|
||||
// Entscheidungseffizienz (automatisch berechenbar)
|
||||
"efficiency": {
|
||||
"score_per_action": 9.0, // score / anzahl_aktionen
|
||||
"budget_efficiency": 0.71 // ergebnis / ausgegebenes_budget
|
||||
},
|
||||
|
||||
// Badges verdient in diesem Level
|
||||
"badges_earned": ["first_windpark", "coastal_defense"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Summary-Format (für Klassenübersicht)
|
||||
|
||||
Jedes Modul liefert nach Abschluss eines Levels eine standardisierte Zusammenfassung. Die Plattform rendert daraus einheitliche Kacheln.
|
||||
|
||||
```json
|
||||
{
|
||||
"sim_id": "klima",
|
||||
"sim_name": "Klimawächter",
|
||||
"sim_icon": "🌍",
|
||||
"level": 1,
|
||||
"stars": 3,
|
||||
"score": 72,
|
||||
"completed": true,
|
||||
"duration_formatted": "5:24",
|
||||
"key_metric": {
|
||||
"label": "Endtemperatur",
|
||||
"value": "+1.3 °C",
|
||||
"status": "warning"
|
||||
},
|
||||
"badges_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4a. Sprache — Lernarbeit statt Spielsprache (PFLICHT)
|
||||
|
||||
GeoGraSim ist kein Spiel, sondern eine **Simulations-Lernumgebung**.
|
||||
Alle sichtbaren UI-Texte folgen dem Prinzip: **Lernarbeit**, nicht
|
||||
Unterhaltung.
|
||||
|
||||
### Bildungstheoretischer Hintergrund
|
||||
|
||||
- **Wygotski**: Lernen als aktive Konstruktion in der Zone der nächsten
|
||||
Entwicklung
|
||||
- Sprache formt Haltung: "Spiel" → abschalten dürfen. "Arbeit" →
|
||||
ernstgenommen werden.
|
||||
- Gegenüber Eltern, Schulleitung, Lehrplan-Koordinator:innen:
|
||||
"Simulation" ist verteidigungsfähig, "Spiel" nicht.
|
||||
|
||||
### Lexikon (verbindlich)
|
||||
|
||||
| NICHT verwenden | STATTDESSEN |
|
||||
|-------------------------|---------------------------------------------|
|
||||
| spielen, Spiel | arbeiten mit, Simulation, bearbeiten |
|
||||
| Weiterspielen | Fortsetzen, weiterarbeiten |
|
||||
| "Jetzt spielen" | "Simulation starten", "Los arbeiten" |
|
||||
| Spielstand | Arbeitsstand, Fortschritt |
|
||||
| Spieler:in | Lernende, Bearbeiter:in |
|
||||
| Spielrunde | Durchgang, Arbeitsphase |
|
||||
| Gameplay | Simulationsablauf, Arbeitsweise |
|
||||
| "verloren", "Game Over" | "Ziel verfehlt", "Durchgang beendet" |
|
||||
| Highscore | Beste Leistung, Bestwert |
|
||||
| Level | OK (pädagogisch vertretbar), besser "Schwierigkeit" oder "Stufe" |
|
||||
|
||||
### Ausnahmen
|
||||
|
||||
- **Interne Variablen-/Tabellennamen** (`game.tick`, `gameState`,
|
||||
`game_saves`, `game_levels`) sind weiterhin OK — nicht sichtbar,
|
||||
keine pädagogische Wirkung.
|
||||
- **Sichtbare UI-Texte**, **Marketing-Inhalte**, **Dokumentation für
|
||||
Endnutzer** folgen strikt der Lexikon-Regel.
|
||||
|
||||
### Anwendung bei deinem Modul
|
||||
|
||||
- Prüfe bei jeder neuen UI-Zeile, bei jedem Overlay-Titel, bei jedem
|
||||
Button-Text: Kommt "spielen" oder ein verwandtes Wort vor?
|
||||
- Beim Übernehmen von V1-Texten: V1-Wortwahl korrigieren, nicht
|
||||
1:1 kopieren.
|
||||
- Tutorial-Karten, Achievements, Endscreen, Reflexionsfragen: besonders
|
||||
sensibel, weil prominent sichtbar.
|
||||
|
||||
### Beispiele aus der Praxis
|
||||
|
||||
| Vorher (typisch V1) | Nachher (V2) |
|
||||
|----------------------------------------|---------------------------------------|
|
||||
| "Willkommen zum Klimaspiel!" | "Willkommen zur Klima-Simulation." |
|
||||
| "Spiel starten" | "Simulation starten" |
|
||||
| "Speichern & weiterspielen" | "Speichern & fortsetzen" |
|
||||
| "Du hast gewonnen! 🏆" | "Ziel erreicht! 🏆" |
|
||||
| "Game Over" | "Durchgang beendet" |
|
||||
| "Dein Spielstand wird geladen ..." | "Dein Arbeitsstand wird geladen ..." |
|
||||
| "Schwierigkeitsgrad wählen" | ok, oder "Stufe wählen" |
|
||||
| "Dein Highscore" | "Deine beste Leistung" |
|
||||
|
||||
---
|
||||
|
||||
## 4b. Leichte Sprache (Barrierefreiheit, Inklusion)
|
||||
|
||||
Die Lehrperson kann pro Schüler:in in der Klassenliste den Flag
|
||||
**"Leichte Sprache"** aktivieren. Wenn gesetzt, werden Texte im gesamten
|
||||
System (Glossar-Tooltips, Beispiele, Modul-Beschreibungen, Info-Topics)
|
||||
in vereinfachter Sprache ausgeliefert.
|
||||
|
||||
### Schüler-Flag in der DB
|
||||
|
||||
```sql
|
||||
students.easy_language BOOLEAN NOT NULL DEFAULT 0
|
||||
```
|
||||
|
||||
Setzen via:
|
||||
```
|
||||
POST /api/students {action: "update", studentId: N, easyLanguage: true}
|
||||
```
|
||||
|
||||
### Was automatisch in Easy übersetzt wird (Backend-API)
|
||||
|
||||
| Tabelle | Normal-Spalte | Easy-Spalte |
|
||||
|---|---|---|
|
||||
| `glossar` | `short`, `text` | `short_easy`, `text_easy` |
|
||||
| `glossar_examples` | `text` | `text_easy` |
|
||||
| `module_info` | `short_desc`, `long_desc`, `learning_goals` | `short_desc_easy`, `long_desc_easy`, `learning_goals_easy` |
|
||||
|
||||
**Fallback-Logik:** Wenn die Easy-Spalte leer ist, liefert die API die
|
||||
Normal-Version. So bricht nichts, wenn noch nicht alles übersetzt wurde.
|
||||
|
||||
### API-Verhalten
|
||||
|
||||
Die Glossar-API (`/api/glossar.php`) erkennt den aktiven Schüler automatisch
|
||||
über die Session und liefert dann die Easy-Variante. Keine zusätzlichen
|
||||
Parameter nötig.
|
||||
|
||||
Optional zum Testen / expliziten Überschreiben:
|
||||
- `?easy=1` → erzwingt Easy
|
||||
- `?easy=0` → erzwingt Normal
|
||||
|
||||
Response enthält ein Meta-Feld `_easy: true|false`, damit das Frontend
|
||||
optional einen Hinweis "In leichter Sprache" einblenden kann.
|
||||
|
||||
### Wer schreibt die Easy-Versionen
|
||||
|
||||
- **Glossar-Instanz** schreibt `short_easy`, `text_easy`, `text_easy`
|
||||
(Examples) für alle Glossar-Einträge
|
||||
- **Lehrplan-Instanz** schreibt `*_easy` für `module_info`
|
||||
- **Modul-Instanzen** schreiben eigene Event-Info-Topics direkt mit einer
|
||||
Easy-Variante (wenn sie Info-Topics haben)
|
||||
|
||||
### Merkregeln für "Leichte Sprache"
|
||||
|
||||
- Kurze Sätze (max. ~10–12 Wörter)
|
||||
- Häufige, einfache Wörter
|
||||
- Aktiv statt Passiv
|
||||
- Fremdwörter vermeiden oder erklären
|
||||
- Zahlen als Ziffern, nicht ausgeschrieben
|
||||
- Keine Metaphern, keine Ironie
|
||||
- Hauptaussage an den Anfang
|
||||
|
||||
### Richtlinien als Inspiration
|
||||
|
||||
- Netzwerk Leichte Sprache: https://www.leichte-sprache.org/
|
||||
- capito Leichte Sprache Standards (A1 / A2 / B1)
|
||||
|
||||
---
|
||||
|
||||
## 4c. Referenzgerät — iPad Landscape (1180×820)
|
||||
|
||||
**iPad ist unser Hauptreferenzgerät.** Schulen in Österreich und DACH setzen
|
||||
überwiegend iPads ein. Jedes Modul muss auf iPad Landscape wunderbar funktionieren.
|
||||
|
||||
### Layout-Breiten (aus design-system.css, automatisch)
|
||||
|
||||
| Viewport | Links | Canvas | Rechts |
|
||||
|---|---|---|---|
|
||||
| Desktop ≥1200px | 240px | 1fr | 280px |
|
||||
| **iPad Landscape 900–1199px** | **210px** | **1fr** | **250px** |
|
||||
| iPad Portrait / Mobil <900px | einspaltig, Panels unter Canvas | | |
|
||||
|
||||
Die Breakpoints sind im Design-System gesetzt — Module müssen nichts dafür tun,
|
||||
wenn sie `.ggs-sim-layout` verwenden.
|
||||
|
||||
### Pflichten für alle Module
|
||||
|
||||
**Touch-Ziele:** Alle Buttons, Cards und klickbaren Elemente mindestens
|
||||
36×36px (idealerweise 40×40). Das ist in `.ggs-speed-btn`, `.ggs-btn-*`,
|
||||
`.ggs-card` bereits so gesetzt. Eigene klickbare Elemente bitte auch so
|
||||
dimensionieren.
|
||||
|
||||
**Kein Hover-Abhängigkeit:** Hover-Effekte sind schön, aber auf iPad gibt es
|
||||
keinen Cursor. Jede Funktion, die per Hover erreichbar ist, muss auch per
|
||||
Tap funktionieren. Hover ist Bonus, nicht Pflicht-UX. Im Design-System sind
|
||||
Hover-Effekte schon in `@media (hover: hover)` gekapselt — Cards zeigen auf
|
||||
Touch einen `:active`-Zustand statt klebendem Hover.
|
||||
|
||||
**Scrolling im Canvas:** Wenn das Canvas per Drag interagiert (3D-Orbit, Map-
|
||||
Pan), muss verhindert werden, dass iPad Safari stattdessen die Page scrollt:
|
||||
`touch-action: none` auf dem Canvas-Element setzen. Für Canvas-Zonen ohne
|
||||
Drag-Interaktion nicht nötig.
|
||||
|
||||
**Kein Double-Tap-Zoom im Body:** Bereits im Design-System gesetzt
|
||||
(`touch-action: manipulation` auf body). Kein Bedarf, das pro Modul zu
|
||||
ändern — ausser du willst Pinch-to-zoom für ein spezielles Element
|
||||
erlauben.
|
||||
|
||||
**Formular-Eingaben (Reflexion, Textfelder):** `font-size: 16px` mindestens,
|
||||
sonst zoomt iPad Safari beim Fokus rein. Das Design-System setzt das für
|
||||
Standardfelder bereits.
|
||||
|
||||
### Was noch zu testen ist
|
||||
|
||||
- Graph-Zoom per Tap: funktioniert
|
||||
- Card-Tap: funktioniert, kein Hover-Kleber
|
||||
- Speed-Buttons: gross genug
|
||||
- Overlay-Schließen per Tap außerhalb: funktioniert
|
||||
- Glossar-Tooltip per Tap öffnet, zweiter Tap außerhalb schließt: ✓
|
||||
- Dropdown-Menüs (z.B. Country-Picker, falls im Header): Select statt
|
||||
Custom-Dropdown, weil iPad native Selects perfekt rendert
|
||||
|
||||
---
|
||||
|
||||
## 5. Design-System
|
||||
|
||||
Alle Module binden `assets/css/design-system.css` ein. Diese Datei definiert:
|
||||
|
||||
### Farb-Variablen (Pflicht)
|
||||
```css
|
||||
--ggs-fjord /* Primärblau */
|
||||
--ggs-moss /* Erfolg/Grün */
|
||||
--ggs-sand /* Kosten/Neutral */
|
||||
--ggs-coral /* Gefahr/Rot */
|
||||
--ggs-orange /* Warnung */
|
||||
```
|
||||
|
||||
### Layout-Zonen (verwende diese Klassen)
|
||||
```
|
||||
.ggs-header → App-Header (Logo + Textlogo + Separator + Modulname)
|
||||
.ggs-sim-layout → 3-Spalten Grid (Standard: Canvas in der Mitte)
|
||||
.ggs-sim-layout.fullscreen → Canvas randlos, Panels als Glass-Overlays darüber
|
||||
.ggs-zone-status → Links: Parameter, Ziele, Badges
|
||||
.ggs-zone-sim → Mitte: Canvas/3D/Karte
|
||||
.ggs-zone-actions → Rechts: Maßnahmen/Werkzeuge/Missionen
|
||||
.ggs-zone-graphs → Unten: Zeitverlaufs-Graphen (klickbar zum Vergrössern)
|
||||
.ggs-event-viewport → In-Canvas Notifications (erscheinen gross, verschwinden)
|
||||
.ggs-event-stack → Event-Stapel links unten (aufklappbar)
|
||||
.ggs-loading → Ladebildschirm mit drehendem Logo
|
||||
```
|
||||
|
||||
### UI-Komponenten (verwende diese Klassen)
|
||||
```
|
||||
.ggs-card → Auswahl-Card (Maßnahme, Werkzeug, Mission)
|
||||
.ggs-param → Parameter-Anzeige mit Balken
|
||||
.ggs-graph-card → Zeitverlaufs-Graph (SVG)
|
||||
.ggs-event → Event-Pill im Feed
|
||||
.ggs-speed → Geschwindigkeits-Buttons
|
||||
.ggs-overlay → Modal/Overlay (Tutorial, Level-Ende, Endscreen)
|
||||
.ggs-stars → Sterne-Bewertung
|
||||
.ggs-toast → Achievement-Toast
|
||||
.ggs-btn → Button (primary, secondary, ghost, success, danger)
|
||||
.ggs-badge → Mini-Badge (cost, effect, protect, level-easy/medium/hard)
|
||||
.ggs-phases → Phasen-Dots im Header
|
||||
```
|
||||
|
||||
### Template-Datei
|
||||
`App/sims/template.html` enthält alle Zonen mit Platzhalter-Inhalten und minimalen JS-Helfern:
|
||||
- `showOverlay(id)` / `hideOverlay(id)`
|
||||
- `showAchievement(icon, title, subtitle)` — Toast oben rechts
|
||||
- `showEvent(icon, text, type)` — Notification im Canvas + Stapel (type: 'good'/'bad'/'neutral')
|
||||
- `toggleEventStack()` — Event-Stapel auf/zuklappen
|
||||
- `updateGraph(index, points, currentValue)` — Klick auf Graph vergrössert
|
||||
- `updateParam(index, value, percent, trend)`
|
||||
- `reportProgress(data)`
|
||||
- `submitAssessment(results)`
|
||||
- `submitReflection(level, question, answer)`
|
||||
|
||||
---
|
||||
|
||||
## 6. Modul-IDs
|
||||
|
||||
| ID | Name | Status |
|
||||
|----|------|--------|
|
||||
| `klima` | Klimawächter (2D + 3D) | Aktiv |
|
||||
| `fluss` | Flussmanagement | Aktiv |
|
||||
| `heli` | Heli-Navigation | Aktiv |
|
||||
| `stadt` | Stadt & Raumplanung | In Entwicklung |
|
||||
| `regenwald` | Regenwald-Expedition | Überarbeitung nötig |
|
||||
|
||||
---
|
||||
|
||||
## 7. Datenbank-Tabellen
|
||||
|
||||
### Gemeinsam (Plattform verwaltet)
|
||||
- `teachers`, `classes`, `students` — Benutzerverwaltung
|
||||
- `student_sessions` — Anonyme Sessions
|
||||
- `game_levels` — Level-Konfiguration pro Modul × Schwierigkeit
|
||||
- `player_progress` — XP, Level-Stand, Badges
|
||||
- `assessment_answers` — Reflexionsantworten
|
||||
- `class_modules` — Modul-Freigabe pro Klasse
|
||||
- `licenses` — Lizenz-System
|
||||
|
||||
### Modulspezifisch (Modul verwaltet)
|
||||
- `game_saves` — Spielstände (session_id + save_key)
|
||||
- `assessments` — Detaillierte Ergebnisdaten (JSON-Felder)
|
||||
- Module können eigene Tabellen anlegen (z.B. `geo_waypoints` für Heli)
|
||||
|
||||
---
|
||||
|
||||
## 7b. Persistenz-Pflicht: Autosave und Resume
|
||||
|
||||
**Kernprinzip:** Ein Browser-Reload (F5, Tab-Wechsel, Stromausfall, Schüler schliesst aus Versehen das Fenster) darf **niemals** zum Neustart des Spiels führen. Der Schüler muss dort weitermachen können, wo er aufgehört hat.
|
||||
|
||||
### Was das bedeutet
|
||||
|
||||
- Beim **Seitenaufruf / Reload** lädt das Modul den letzten gespeicherten Stand und setzt genau dort fort (gleiche Runde, gleiche Werte, gleiche platzierte Massnahmen, gleicher CO₂-Stand, etc.).
|
||||
- Bei **Klick auf Reset** (und nur dann!) wird der Spielstand gelöscht und das Spiel beginnt von vorne.
|
||||
- **Autosave** nach jeder wichtigen Aktion (Tick-Ende, Kauf einer Massnahme, Level-Übergang). Kein manuelles "Speichern"-Knopfdrücken nötig.
|
||||
|
||||
### Wie
|
||||
|
||||
Zwei Speicher, in dieser Reihenfolge:
|
||||
|
||||
1. **localStorage** (Primär): Sofortiges, synchrones Speichern im Browser. Funktioniert auch offline.
|
||||
- Schlüssel-Format: `ggs-save-{modul-id}-{level}` (z.B. `ggs-save-klima-2`)
|
||||
- Wert: JSON-Serialisierung des Spielzustands
|
||||
2. **Server-Backup** (Sekundär, bei vorhandener Session): Zusätzlich per POST an `/api/saves.php` schicken, damit der Fortschritt auf anderen Geräten verfügbar ist. Fällt das Netz aus, ist localStorage der Fallback.
|
||||
|
||||
### Konkrete Implementierung
|
||||
|
||||
```javascript
|
||||
// Speichern
|
||||
function autoSave() {
|
||||
const state = game.serialize(); // dein kompletter Spielzustand
|
||||
const key = `ggs-save-klima-${game.level}`;
|
||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch {}
|
||||
// Optional: Server-Spiegelung
|
||||
if (window.__GGS__.sessionId) {
|
||||
fetch(window.__GGS__.apiUrl + '/saves.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sim_id: 'klima',
|
||||
save_key: `level-${game.level}`,
|
||||
save_data: state
|
||||
})
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Laden beim Start
|
||||
function loadOrInit() {
|
||||
const key = `ggs-save-klima-${level}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
try {
|
||||
game.deserialize(JSON.parse(raw));
|
||||
showEvent('💾', 'Spielstand geladen — weiter bei Runde ' + game.tick, 'neutral');
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
// Fallback: neues Spiel
|
||||
game.init();
|
||||
}
|
||||
|
||||
// Reset-Knopf
|
||||
function resetGame() {
|
||||
if (!confirm('Fortschritt wirklich löschen und neu starten?')) return;
|
||||
localStorage.removeItem(`ggs-save-klima-${game.level}`);
|
||||
// Auch Server-Save löschen (DELETE), falls Session vorhanden
|
||||
game.init();
|
||||
}
|
||||
```
|
||||
|
||||
### Wann speichern?
|
||||
|
||||
- Nach jedem Tick/Runden-Ende (automatisch)
|
||||
- Nach jedem Kauf/Aktion (automatisch)
|
||||
- Nach jeder Reflexionsantwort (automatisch)
|
||||
- Bei Phasenwechsel (Tutorial → Spiel, Level → Level)
|
||||
- Vor `beforeunload` als Sicherheit (falls Tab geschlossen wird)
|
||||
|
||||
### Welche Daten gehören in den Save
|
||||
|
||||
Alles, was der Spieler getan hat oder was sich durch Spielverlauf geändert hat:
|
||||
- Aktuelle Runde / Tick
|
||||
- Aktuelles Level
|
||||
- Alle Spielparameter (Budget, CO₂, Temperatur, etc.)
|
||||
- Gekaufte / platzierte Massnahmen inkl. Position
|
||||
- Absolvierte Achievements / Badges
|
||||
- Tutorial-Fortschritt (welche Schritte sind durch)
|
||||
- Event-Historie (falls didaktisch relevant)
|
||||
|
||||
### UI-Hinweise
|
||||
|
||||
- Beim Laden eines bestehenden Stands: kurze Info einblenden ("Spielstand geladen — weiter bei Runde 34")
|
||||
- Reset-Button (🔄) sichtbar im Header, mit Bestätigungsdialog ("Fortschritt löschen?")
|
||||
- Save-/Load-Buttons im Header sind optional (für explizites Speichern) — Autosave passiert ohnehin
|
||||
|
||||
### Warum das wichtig ist
|
||||
|
||||
Didaktisch: Schüler:innen arbeiten in 45-Minuten-Einheiten. Wird das Modul aus Zeitgründen unterbrochen, soll in der nächsten Stunde dort weitergespielt werden können. Ohne Persistenz wäre das Modul unterrichtstauglich nur bei 100%-Fertigstellung in einer Einheit — unrealistisch.
|
||||
|
||||
Technisch: Browser-Crashs, versehentliche Reloads, Netzausfälle — alles passiert. Ohne Autosave verliert der Schüler seine Arbeit und frustriert sich. Ein Tabubruch im Lernkontext.
|
||||
|
||||
---
|
||||
|
||||
## 8. Datei-Struktur pro Modul
|
||||
|
||||
```
|
||||
App/sims/{modul-id}/
|
||||
├── game.html ← Hauptdatei (oder Template für PHP-Injection)
|
||||
├── start.html ← Optional: Mini-Game (iframe)
|
||||
├── landing.html ← Optional: Mini-Game (iframe)
|
||||
└── assets/ ← Modul-eigene Assets (Sounds, Bilder)
|
||||
|
||||
App/pages/
|
||||
├── {modul-id}.php ← PHP-Wrapper (Session, DB, Kontext-Injection)
|
||||
└── {modul-id}-game.php ← Optional: Wenn Hauptseite DB-Daten braucht
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Checkliste für neue Module
|
||||
|
||||
- [ ] Design-System CSS eingebunden
|
||||
- [ ] Einheitlicher Header mit Logo, Modulname, Level-Badge
|
||||
- [ ] Layout nutzt `ggs-sim-layout` Zonen
|
||||
- [ ] Action-Cards nutzen `ggs-card` Klasse
|
||||
- [ ] Parameter nutzen `ggs-param` mit Farbstufen
|
||||
- [ ] Graphen nutzen `ggs-graph-card` (mindestens 1)
|
||||
- [ ] Event-Feed zeigt Spielereignisse
|
||||
- [ ] Speed-Control vorhanden (falls zeitbasiert)
|
||||
- [ ] Tutorial-Overlay beim ersten Start
|
||||
- [ ] Between-Level Screen mit Sterne + Reflexionsfrage
|
||||
- [ ] Endscreen mit Stats-Grid
|
||||
- [ ] Achievement-Toasts bei Meilensteinen
|
||||
- [ ] API: `reportProgress()` alle 10 Ticks
|
||||
- [ ] API: `showAchievement()` + POST bei Badge
|
||||
- [ ] API: `submitAssessment()` bei Level-Ende
|
||||
- [ ] API: `submitReflection()` bei Level-Übergang
|
||||
- [ ] PHP-Page erstellt mit `window.__GGS__` Kontext
|
||||
- [ ] **Autosave nach jeder Aktion** (localStorage + optional Server)
|
||||
- [ ] **Resume beim Reload** (lädt letzten Stand, kein Neustart)
|
||||
- [ ] **Reset-Button** löscht Save und startet neu (mit Bestätigungsdialog)
|
||||
@@ -0,0 +1,61 @@
|
||||
# GeoGraSim — Music Registry
|
||||
|
||||
Zentrale Zuordnung der Hintergrund-Tracks aus dem Suno-Pool zu den Modulen.
|
||||
|
||||
**Regel:** Jeder Track gehört einem Modul. Keine Doppel-Nutzung — sonst
|
||||
hat der Schüler beim Modulwechsel dasselbe Stück im Ohr.
|
||||
|
||||
**Quell-Pool:** `.humanInput/Background Music/` (lokal, nicht deployed)
|
||||
**Ziel-Ordner:** `App/sims/<modul>/assets/music/`
|
||||
|
||||
## Claim-Prozess
|
||||
|
||||
1. Modul schickt Claim-Wunsch an Atlas-Inbox (`_inbox/zentrale/`).
|
||||
2. Atlas prüft diese Registry — Doppel-Nutzung ausgeschlossen?
|
||||
3. Atlas bestätigt in Modul-Inbox, trägt Track hier ein.
|
||||
4. Modul kopiert die Datei aus Pool nach `App/sims/<modul>/assets/music/<kebab-case>.mp3`.
|
||||
5. Atlas verschiebt die Quelldatei nach `.humanInput/Background Music/_claimed/`
|
||||
(damit andere Module sie nicht auswählen).
|
||||
|
||||
## Aktuelle Zuordnungen
|
||||
|
||||
| Track (Original) | Stil | Zugewiesen an | Zielname | Datum |
|
||||
|----------------------------|--------------------------|---------------|----------------------------|-------------|
|
||||
| Piano Lobby.mp3 | Lounge-Klavier | **klima** | `piano-lobby.mp3` | 2026-04-18 |
|
||||
| Fingertip Rain.mp3 | Ambient-Nature (Regen) | **klima** | `fingertip-rain.mp3` | 2026-04-18 |
|
||||
| Limestone Lullaby.mp3 | Ruhig, wiegend | **klima** | `limestone-lullaby.mp3` | 2026-04-18 |
|
||||
| Vibrocoffee Calm.mp3 | Chill / Lo-Fi | **klima** | `vibrocoffee-calm.mp3` | 2026-04-18 |
|
||||
| Rising Pressure.mp3 (v1) | Drama/Tension (2:31) | **klima** | `rising-pressure-v1.mp3` | 2026-04-18 |
|
||||
| Rising Pressure (1).mp3 (v2)| Drama/Tension (4:12) | **klima** | `rising-pressure-v2.mp3` | 2026-04-18 |
|
||||
| Porcelain Rain.mp3 | Zart, Regen-Ambiente | **fluss** | `porcelain-rain.mp3` | 2026-04-18 |
|
||||
|
||||
## Freie Tracks (noch claimbar)
|
||||
|
||||
| Track (Original) | Stil | Kandidaten |
|
||||
|----------------------------|--------------------------|------------------|
|
||||
| Patagonia Stringmap.mp3 | Natur-Expedition | heli, regenwald |
|
||||
|
||||
## Vorläufige Stil-Slots pro Modul
|
||||
|
||||
| Modul | Lounge | Ambient/Nature | Ruhig | Chill | Drama/Tension |
|
||||
|-----------|------------|--------------------|------------------|------------------|---------------------|
|
||||
| klima | Piano Lobby| Fingertip Rain | Limestone Lullaby| Vibrocoffee Calm | Rising Pressure (A/B-Test)|
|
||||
| heli | — | Patagonia Stringmap| — | — | — |
|
||||
| fluss | — | **Porcelain Rain** | — | — | — |
|
||||
| stadt | — | — | — | — | — |
|
||||
| regenwald | — | — | — | — | — |
|
||||
|
||||
Drama/Tension wird bei Game-Loss / Extremwerten getriggert — nicht vom
|
||||
Spieler wählbar, sondern automatisch angesteuert.
|
||||
|
||||
Der Pool wächst — Thomas generiert bei Bedarf neue Suno-Tracks.
|
||||
|
||||
## Spieler-Auswahl
|
||||
|
||||
Pro Modul soll im Header ein **Musik-Dropdown** angeboten werden:
|
||||
- Stil-Auswahl (Lounge / Ambient / Ruhig / Chill)
|
||||
- Play/Pause, Lautstärke, Mute
|
||||
- Einstellung in localStorage (persistent pro Gerät)
|
||||
|
||||
Das Dropdown wird im Template-System als Standard-Komponente bereitgestellt
|
||||
(`ggs-music-player`), damit jedes Modul es gleich einbauen kann.
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 143 KiB |
@@ -0,0 +1,454 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Helikopter-Navigation</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',system-ui,sans-serif;overflow:hidden;height:100vh;background:#e8e4d8}
|
||||
#map{width:100%;height:100vh}
|
||||
|
||||
/* Aktive Basen: groß, farbig */
|
||||
.heli-active{font-size:32px;text-align:center;line-height:1;filter:drop-shadow(0 2px 4px rgba(0,0,0,.4))}
|
||||
.heli-active.oeamtc{filter:drop-shadow(0 2px 4px rgba(0,0,0,.3)) brightness(1.3) saturate(3) hue-rotate(-30deg) sepia(.3)}
|
||||
.heli-active.ara{filter:drop-shadow(0 2px 4px rgba(0,0,0,.3)) hue-rotate(320deg) saturate(2)}
|
||||
.heli-active.police{filter:drop-shadow(0 2px 4px rgba(0,0,0,.3)) hue-rotate(180deg) saturate(1.5) brightness(1.1)}
|
||||
.heli-active.bh{filter:drop-shadow(0 2px 4px rgba(0,0,0,.3)) hue-rotate(90deg) saturate(2)}
|
||||
|
||||
/* Inaktive Basen: klein, deutlich sichtbar aber entsättigt */
|
||||
.heli-inactive{font-size:18px;text-align:center;line-height:1;filter:drop-shadow(0 1px 3px rgba(0,0,0,.3)) grayscale(.7) brightness(.7) contrast(1.2)}
|
||||
|
||||
/* Labels/Pills */
|
||||
.heli-pill{font-family:'Inter',system-ui,sans-serif;font-weight:600;font-size:11px;white-space:nowrap;padding:3px 8px;border-radius:5px;background:rgba(255,255,255,.92);box-shadow:0 1px 4px rgba(0,0,0,.15);line-height:1.3;max-width:180px}
|
||||
.heli-pill .pill-name{font-weight:800;display:block}
|
||||
.heli-pill .pill-org{font-size:8px;opacity:.7;display:block}
|
||||
.heli-pill.oeamtc{border-left:4px solid #d4a017;color:#7a5f00}
|
||||
.heli-pill.ara{border-left:4px solid #cc3333;color:#882222}
|
||||
.heli-pill.police{border-left:4px solid #2255aa;color:#1a3a77}
|
||||
.heli-pill.bh{border-left:4px solid #228833;color:#145522}
|
||||
|
||||
.heli-pill-sm{font-family:'Inter',system-ui,sans-serif;font-weight:700;font-size:10px;white-space:nowrap;padding:2px 6px;border-radius:3px;background:rgba(255,255,255,.88);box-shadow:0 1px 3px rgba(0,0,0,.15);color:#555;border-left:3px solid #888}
|
||||
|
||||
/* Marker: weiße Kreise mit Rahmen */
|
||||
.marker-circle{width:26px;height:26px;background:#fff;border:2.5px solid #1f4e5a;border-radius:50%;box-shadow:0 2px 6px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;font-size:13px;line-height:1}
|
||||
.marker-circle.capital{border-color:#e8833a;width:30px;height:30px;font-size:15px}
|
||||
.marker-circle.peak{border-color:#5a8a5e;width:24px;height:24px;font-size:12px}
|
||||
.marker-circle.heli-m{border-color:#d4a017;width:30px;height:30px;font-size:16px}
|
||||
.marker-circle.heli-m.inactive{width:22px;height:22px;font-size:11px;border-color:#888;opacity:.7}
|
||||
|
||||
/* Stadt-Pills */
|
||||
.city-pill{font-family:'Inter',system-ui,sans-serif;font-weight:700;font-size:11px;white-space:nowrap;padding:4px 10px;border-radius:5px;background:rgba(218,232,236,.94);box-shadow:0 1px 4px rgba(0,0,0,.15);color:#1f4e5a;border-bottom:2.5px solid #4a7c8a;display:flex;align-items:center;height:22px}
|
||||
.city-pill.capital{color:#e8833a;background:rgba(232,200,170,.92);border-bottom-color:#e8833a}
|
||||
.city-pill .city-pop{font-size:8px;font-weight:500;color:#8a8a8a;margin-left:4px}
|
||||
|
||||
/* Berg-Pills */
|
||||
.peak-pill{font-family:'Inter',system-ui,sans-serif;font-weight:700;font-size:10px;white-space:nowrap;padding:4px 9px;border-radius:5px;background:rgba(220,234,221,.94);box-shadow:0 1px 4px rgba(0,0,0,.12);color:#3a6b3e;border-bottom:2.5px solid #5a8a5e;display:flex;align-items:center;height:20px}
|
||||
.peak-pill .peak-ele{font-size:8px;font-weight:500;color:#5a8a5e;margin-left:4px}
|
||||
|
||||
/* Quartett-Popup */
|
||||
.heli-popup .leaflet-popup-content{margin:0;padding:0}
|
||||
.heli-popup .leaflet-popup-content-wrapper{border-radius:12px;padding:0;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.15)}
|
||||
.quartett{width:260px;font-family:'Inter',system-ui,sans-serif}
|
||||
.quartett-img{height:140px;overflow:hidden;background:#f7f6f3;display:flex;align-items:center;justify-content:center}
|
||||
.quartett-img img{width:100%;height:100%;object-fit:cover}
|
||||
.quartett-body{padding:10px 12px 12px}
|
||||
.quartett-name{font-size:13px;font-weight:800;color:#1f4e5a;margin-bottom:2px}
|
||||
.quartett-org{font-size:10px;color:#8a8a8a;margin-bottom:8px}
|
||||
.quartett-stats{display:grid;grid-template-columns:1fr 1fr;gap:4px}
|
||||
.quartett-stat{background:#f7f6f3;border-radius:5px;padding:4px 6px}
|
||||
.quartett-stat .qs-label{font-size:8px;color:#8a8a8a;font-weight:500}
|
||||
.quartett-stat .qs-val{font-size:12px;font-weight:800;color:#1f4e5a}
|
||||
.quartett-fact{margin-top:8px;font-size:10px;color:#4a4a4a;font-style:italic;line-height:1.4;border-top:1px solid #eee;padding-top:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
var bounds = L.latLngBounds(L.latLng(46.3, 9.4), L.latLng(49.1, 17.2));
|
||||
|
||||
var map = L.map('map', {
|
||||
zoomControl: false,
|
||||
maxBounds: bounds.pad(0.05),
|
||||
maxBoundsViscosity: 1.0,
|
||||
minZoom: 7,
|
||||
maxZoom: 15
|
||||
}).setView([47.4, 13.2], 8);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 18
|
||||
}).addTo(map);
|
||||
|
||||
L.control.zoom({position: 'topright'}).addTo(map);
|
||||
L.control.scale({position: 'bottomleft', metric: true, imperial: false, maxWidth: 200}).addTo(map);
|
||||
|
||||
// === HUBSCHRAUBER-TYPEN (Quartett-Daten) ===
|
||||
var HELI_TYPES = {
|
||||
'EC135': {name:'Eurocopter EC135', img:'sims/heli/assets/heli-ec135.png', engines:2, crew:'1 Pilot + 1 Notarzt + 1 Sanitäter', maxSpeed:254, range:635, ceiling:6096, weight:2910, seats:6, role:'Rettung & Notfall', fact:'Der EC135 ist der meistgenutzte Rettungshubschrauber in Europa.', wiki:'https://de.wikipedia.org/wiki/Eurocopter_EC_135'},
|
||||
'H145': {name:'Airbus H145', img:'sims/heli/assets/heli-h145.png', engines:2, crew:'1–2 Piloten + Rettungsteam', maxSpeed:268, range:680, ceiling:5485, weight:3700, seats:8, role:'Rettung & Intensivtransport', fact:'Der H145 kann auch bei Nacht und schlechtem Wetter fliegen (IFR).', wiki:'https://de.wikipedia.org/wiki/Airbus_Helicopters_H145'},
|
||||
'AW169': {name:'Leonardo AW169', img:'sims/heli/assets/heli-aw169.png', engines:2, crew:'1–2 Piloten + Besatzung', maxSpeed:280, range:820, ceiling:4572, weight:4600, seats:10, role:'Polizei & Überwachung', fact:'Die Flugpolizei nennt ihre Hubschrauber „Libelle".', wiki:'https://de.wikipedia.org/wiki/Leonardo_AW169'},
|
||||
'AB212': {name:'Agusta-Bell AB212', img:'sims/heli/assets/heli-ab212.png', engines:2, crew:'2 Piloten + Besatzung', maxSpeed:240, range:450, ceiling:4630, weight:5070, seats:14, role:'Militär & Katastrophenhilfe', fact:'Das Bundesheer setzt den AB212 seit den 1980er-Jahren ein.', wiki:'https://de.wikipedia.org/wiki/Agusta-Bell_AB_212'},
|
||||
};
|
||||
|
||||
// === ALLE HUBSCHRAUBER-BASEN ÖSTERREICH ===
|
||||
var bases = [
|
||||
// ÖAMTC Christophorus
|
||||
{name:'Christophorus 1', code:'C1', org:'ÖAMTC', orgClass:'oeamtc', lat:47.2600, lon:11.3439, active:true, heli:'EC135', region:'Innsbruck / Nordtirol'},
|
||||
{name:'Christophorus 2', code:'C2', org:'ÖAMTC', orgClass:'oeamtc', lat:48.4306, lon:15.6100, active:false, heli:'EC135', region:'Gneixendorf / Waldviertel'},
|
||||
{name:'Christophorus 3', code:'C3', org:'ÖAMTC', orgClass:'oeamtc', lat:47.8419, lon:16.2331, active:false, heli:'EC135', region:'Wiener Neustadt'},
|
||||
{name:'Christophorus 4', code:'C4', org:'ÖAMTC', orgClass:'oeamtc', lat:47.3801, lon:12.3580, active:true, heli:'H145', region:'Reith bei Kitzbühel'},
|
||||
{name:'Christophorus 5', code:'C5', org:'ÖAMTC', orgClass:'oeamtc', lat:47.1597, lon:10.5933, active:true, heli:'H145', region:'Zams / Oberinntal'},
|
||||
{name:'Christophorus 6', code:'C6', org:'ÖAMTC', orgClass:'oeamtc', lat:47.7950, lon:13.0044, active:false, heli:'H145', region:'Salzburg'},
|
||||
{name:'Christophorus 7', code:'C7', org:'ÖAMTC', orgClass:'oeamtc', lat:46.8297, lon:12.7647, active:true, heli:'EC135', region:'Lienz / Osttirol'},
|
||||
{name:'Christophorus 8', code:'C8', org:'ÖAMTC', orgClass:'oeamtc', lat:47.1828, lon:9.7072, active:true, heli:'H145', region:'Nenzing / Vorarlberg'},
|
||||
{name:'Christophorus 9', code:'C9', org:'ÖAMTC', orgClass:'oeamtc', lat:48.1172, lon:16.5025, active:false, heli:'EC135', region:'Wien'},
|
||||
{name:'Christophorus 10', code:'C10', org:'ÖAMTC', orgClass:'oeamtc', lat:48.2331, lon:14.1875, active:false, heli:'EC135', region:'Linz'},
|
||||
{name:'Christophorus 11', code:'C11', org:'ÖAMTC', orgClass:'oeamtc', lat:46.6503, lon:14.3372, active:false, heli:'EC135', region:'Klagenfurt'},
|
||||
{name:'Christophorus 12', code:'C12', org:'ÖAMTC', orgClass:'oeamtc', lat:47.0094, lon:15.4106, active:false, heli:'EC135', region:'Graz'},
|
||||
{name:'Christophorus 14', code:'C14', org:'ÖAMTC', orgClass:'oeamtc', lat:47.4756, lon:14.0042, active:false, heli:'H145', region:'Niederöblarn'},
|
||||
{name:'Christophorus 15', code:'C15', org:'ÖAMTC', orgClass:'oeamtc', lat:47.9483, lon:14.8894, active:false, heli:'EC135', region:'Ybbsitz'},
|
||||
{name:'Christophorus 16', code:'C16', org:'ÖAMTC', orgClass:'oeamtc', lat:47.2886, lon:16.2069, active:false, heli:'EC135', region:'Oberwart'},
|
||||
{name:'Christophorus 17', code:'C17', org:'ÖAMTC', orgClass:'oeamtc', lat:47.3500, lon:11.7000, active:false, heli:'EC135', region:'Vomp / Inntal'},
|
||||
|
||||
// ARA Flugrettung
|
||||
{name:'Alpin 1 Reutte', code:'RK1', org:'ARA Flugrettung', orgClass:'ara', lat:47.4858, lon:10.7181, active:true, heli:'H145', region:'Reutte / Außerfern'},
|
||||
{name:'Alpin 2 Fresach', code:'RK2', org:'ARA Flugrettung', orgClass:'ara', lat:46.7250, lon:13.6825, active:false, heli:'H145', region:'Fresach / Kärnten'},
|
||||
{name:'Alpin 3 Ybbsitz', code:'RK3', org:'ARA Flugrettung', orgClass:'ara', lat:47.9500, lon:14.8900, active:false, heli:'EC135', region:'Ybbsitz / Mostviertel'},
|
||||
{name:'Alpin 5 Salzburg', code:'RK5', org:'ARA Flugrettung', orgClass:'ara', lat:47.7933, lon:13.0100, active:false, heli:'H145', region:'Salzburg'},
|
||||
{name:'Alpin 6 Linz', code:'RK6', org:'ARA Flugrettung', orgClass:'ara', lat:48.2333, lon:14.2000, active:false, heli:'EC135', region:'Linz'},
|
||||
|
||||
// Polizei/BMI Flugpolizei
|
||||
{name:'Libelle Innsbruck', code:'LIB-I', org:'Flugpolizei BMI', orgClass:'police', lat:47.2583, lon:11.3556, active:true, heli:'AW169', region:'Innsbruck'},
|
||||
{name:'Hohenems (Flugplatz)', code:'LIB-H', org:'Flugpolizei BMI', orgClass:'police', lat:47.38154, lon:9.69851, active:true, heli:'AW169', region:'Hohenems / Rheintal'},
|
||||
{name:'Libelle Wien', code:'LIB-W', org:'Flugpolizei BMI', orgClass:'police', lat:48.1103, lon:16.5697, active:false, heli:'AW169', region:'Wien'},
|
||||
{name:'Libelle Salzburg', code:'LIB-S', org:'Flugpolizei BMI', orgClass:'police', lat:47.7933, lon:13.0033, active:false, heli:'AW169', region:'Salzburg'},
|
||||
{name:'Libelle Klagenfurt', code:'LIB-K', org:'Flugpolizei BMI', orgClass:'police', lat:46.6428, lon:14.3375, active:false, heli:'AW169', region:'Klagenfurt'},
|
||||
{name:'Libelle Linz', code:'LIB-L', org:'Flugpolizei BMI', orgClass:'police', lat:48.2400, lon:14.1867, active:false, heli:'AW169', region:'Linz'},
|
||||
{name:'Libelle Graz', code:'LIB-G', org:'Flugpolizei BMI', orgClass:'police', lat:47.0094, lon:15.4400, active:false, heli:'AW169', region:'Graz'},
|
||||
|
||||
// Bundesheer
|
||||
{name:'Aigen im Ennstal', code:'BH-A', org:'Bundesheer', orgClass:'bh', lat:47.5236, lon:14.1350, active:false, heli:'AB212', region:'Aigen / Ennstal'},
|
||||
{name:'Höttinger Au', code:'BH-I', org:'Bundesheer', orgClass:'bh', lat:47.2722, lon:11.3753, active:true, heli:'AB212', region:'Innsbruck'},
|
||||
{name:'Langenlebarn', code:'BH-L', org:'Bundesheer', orgClass:'bh', lat:48.3239, lon:15.9836, active:false, heli:'AB212', region:'Langenlebarn / Tullnerfeld'},
|
||||
|
||||
// Krankenhaus-Landeplätze
|
||||
{name:'LKH Innsbruck', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:47.2636, lon:11.3833, active:false},
|
||||
{name:'LKH Feldkirch', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:47.2400, lon:9.5956, active:false},
|
||||
{name:'AKH Wien', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:48.2206, lon:16.3453, active:false},
|
||||
{name:'LKH Salzburg', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:47.7989, lon:13.0333, active:false},
|
||||
{name:'LKH Graz', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:47.0808, lon:15.4678, active:false},
|
||||
{name:'KH Kufstein', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:47.5836, lon:12.1647, active:false},
|
||||
{name:'KH St. Johann', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:47.3528, lon:12.4247, active:false},
|
||||
{name:'KH Zams', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:47.1575, lon:10.5917, active:false},
|
||||
{name:'BKH Lienz', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:46.8300, lon:12.7700, active:false},
|
||||
{name:'LKH Bregenz', code:'KH', org:'Krankenhaus', orgClass:'oeamtc', lat:47.4997, lon:9.7431, active:false},
|
||||
];
|
||||
|
||||
function makeHeliPopup(b) {
|
||||
var t = HELI_TYPES[b.heli];
|
||||
if (!t) return '<strong>' + b.name + '</strong><br>' + b.org;
|
||||
var bp = location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1);
|
||||
return '<div class="quartett">'
|
||||
+ '<div class="quartett-img"><img src="' + bp + t.img + '" alt="' + t.name + '"></div>'
|
||||
+ '<div class="quartett-body">'
|
||||
+ '<div class="quartett-name">' + b.code + ' — ' + b.name + '</div>'
|
||||
+ '<div class="quartett-org">' + b.org + ' · ' + (b.region||'') + '</div>'
|
||||
+ '<div class="quartett-stats">'
|
||||
+ '<div class="quartett-stat"><div class="qs-label">Typ</div><div class="qs-val">' + t.name + '</div></div>'
|
||||
+ '<div class="quartett-stat"><div class="qs-label">Max. Speed</div><div class="qs-val">' + t.maxSpeed + ' km/h</div></div>'
|
||||
+ '<div class="quartett-stat"><div class="qs-label">Reichweite</div><div class="qs-val">' + t.range + ' km</div></div>'
|
||||
+ '<div class="quartett-stat"><div class="qs-label">Gipfelhöhe</div><div class="qs-val">' + t.ceiling + ' m</div></div>'
|
||||
+ '<div class="quartett-stat"><div class="qs-label">Triebwerke</div><div class="qs-val">' + t.engines + '</div></div>'
|
||||
+ '<div class="quartett-stat"><div class="qs-label">Besatzung</div><div class="qs-val">' + t.crew + '</div></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="quartett-fact">💡 ' + t.fact + '</div>'
|
||||
+ (t.wiki ? '<div style="text-align:center;margin-top:6px"><a href="'+t.wiki+'" target="_blank" style="font-size:10px;color:#4a7c8a;text-decoration:none;font-weight:600">📖 Wikipedia →</a></div>' : '')
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
bases.forEach(function(b) {
|
||||
var size = b.active ? 30 : 22;
|
||||
var circleClass = b.active ? 'marker-circle heli-m' : 'marker-circle heli-m inactive';
|
||||
|
||||
L.marker([b.lat, b.lon], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="' + circleClass + '">🚁</div>',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size/2, size/2]
|
||||
}),
|
||||
title: b.name + ' (' + b.org + ')',
|
||||
zIndexOffset: b.active ? 1000 : 0
|
||||
}).addTo(map).bindPopup(makeHeliPopup(b), {maxWidth:280, className:'heli-popup'});
|
||||
|
||||
// Pill-Label
|
||||
if (b.active) {
|
||||
L.marker([b.lat, b.lon], {
|
||||
icon: L.divIcon({
|
||||
className: 'heli-pill ' + b.orgClass,
|
||||
html: '<span class="pill-name">' + b.code + ' ' + b.name + '</span><span class="pill-org">' + b.org + '</span>',
|
||||
iconSize: [160, 28],
|
||||
iconAnchor: [-20, 14]
|
||||
}),
|
||||
interactive: false,
|
||||
zIndexOffset: 900
|
||||
}).addTo(map);
|
||||
} else {
|
||||
L.marker([b.lat, b.lon], {
|
||||
icon: L.divIcon({
|
||||
className: 'heli-pill-sm',
|
||||
html: b.code,
|
||||
iconSize: [40, 14],
|
||||
iconAnchor: [-10, 7]
|
||||
}),
|
||||
interactive: false
|
||||
}).addTo(map);
|
||||
}
|
||||
});
|
||||
|
||||
// === STÄDTE >30.000 EW + Bregenz ===
|
||||
var cities = [
|
||||
{name:'Wien', lat:48.2082, lon:16.3738, pop:1982097, capital:true, land:'Wien', fun:'Hat mehr Einwohner*innen als alle anderen Landeshauptstädte zusammen!', fact:'Wurde 5× zur lebenswertesten Stadt der Welt gewählt.', wiki:'https://de.wikipedia.org/wiki/Wien', img:'sims/heli/assets/cities/wien.png'},
|
||||
{name:'Graz', lat:47.0707, lon:15.4395, pop:295424, capital:true, land:'Steiermark', fun:'Die Altstadt ist UNESCO-Weltkulturerbe.', fact:'Zweitgrößte Stadt Österreichs, Universitätsstadt mit über 60.000 Studierenden.', wiki:'https://de.wikipedia.org/wiki/Graz', img:'sims/heli/assets/cities/graz.png'},
|
||||
{name:'Linz', lat:48.3069, lon:14.2858, pop:210118, capital:true, land:'Oberösterreich', fun:'War 2009 Europäische Kulturhauptstadt.', fact:'Wichtigster Industriestandort Österreichs (Stahl, Chemie).', wiki:'https://de.wikipedia.org/wiki/Linz', img:'sims/heli/assets/cities/linz.png'},
|
||||
{name:'Salzburg', lat:47.8095, lon:13.0550, pop:157745, capital:true, land:'Salzburg', fun:'Geburtsstadt von Wolfgang Amadeus Mozart (1756).', fact:'Die Altstadt gehört zum UNESCO-Weltkulturerbe.', wiki:'https://de.wikipedia.org/wiki/Salzburg', img:'sims/heli/assets/cities/salzburg.png'},
|
||||
{name:'Innsbruck', lat:47.2654, lon:11.3928, pop:130585, capital:true, land:'Tirol', fun:'Hat schon 3× Olympische Winterspiele ausgerichtet!', fact:'Liegt auf 574 m im Inntal, umgeben von Bergen bis 2.300 m.', wiki:'https://de.wikipedia.org/wiki/Innsbruck', img:'sims/heli/assets/cities/innsbruck.png'},
|
||||
{name:'Klagenfurt', lat:46.6247, lon:14.3050, pop:103671, capital:true, land:'Kärnten', fun:'Am Wörthersee — einem der wärmsten Badeseen der Alpen.', fact:'Südlichste Landeshauptstadt Österreichs.', wiki:'https://de.wikipedia.org/wiki/Klagenfurt_am_Wörthersee', img:'sims/heli/assets/cities/klagenfurt.png'},
|
||||
{name:'Villach', lat:46.6111, lon:13.8558, pop:65127, capital:false, land:'Kärnten', fun:'Hat einen eigenen Fasching — den Villacher Fasching!', fact:'Zweitgrößte Stadt Kärntens, wichtiger Verkehrsknotenpunkt Richtung Italien/Slowenien.', wiki:'https://de.wikipedia.org/wiki/Villach', img:'sims/heli/assets/cities/villach.png'},
|
||||
{name:'Wels', lat:48.1575, lon:14.0289, pop:63539, capital:false, land:'Oberösterreich', fun:'Hier findet die größte Agrarmesse Österreichs statt.', fact:'Zweitgrößte Stadt Oberösterreichs.', wiki:'https://de.wikipedia.org/wiki/Wels_(Stadt)', img:'sims/heli/assets/cities/wels.png'},
|
||||
{name:'St. Pölten', lat:48.2047, lon:15.6256, pop:57169, capital:true, land:'Niederösterreich', fun:'Jüngste Landeshauptstadt — erst seit 1986!', fact:'Vorher war Wien auch Hauptstadt von Niederösterreich.', wiki:'https://de.wikipedia.org/wiki/St._Pölten', img:'sims/heli/assets/cities/stpoelten.png'},
|
||||
{name:'Dornbirn', lat:47.4136, lon:9.7424, pop:51222, capital:false, land:'Vorarlberg', fun:'Größte Stadt Vorarlbergs — nicht Bregenz!', fact:'Wichtiger Messestandort und Wirtschaftszentrum im Rheintal.', wiki:'https://de.wikipedia.org/wiki/Dornbirn', img:'sims/heli/assets/cities/dornbirn.png'},
|
||||
{name:'Wiener Neustadt', lat:47.8133, lon:16.2467, pop:48519, capital:false, land:'Niederösterreich', fun:'Hier wurde die erste Militärakademie der Welt gegründet (1752).', fact:'Wichtiger Standort des Bundesheers.', wiki:'https://de.wikipedia.org/wiki/Wiener_Neustadt', img:'sims/heli/assets/cities/wiener_neustadt.png'},
|
||||
{name:'Steyr', lat:48.0386, lon:14.4214, pop:38572, capital:false, land:'Oberösterreich', fun:'Die Altstadt sieht aus wie ein Märchen — mit Flüssen und mittelalterlichen Häusern.', fact:'Berühmt für Stahl und Waffen seit dem Mittelalter.', wiki:'https://de.wikipedia.org/wiki/Steyr', img:'sims/heli/assets/cities/steyr.png'},
|
||||
{name:'Feldkirch', lat:47.2376, lon:9.5982, pop:35793, capital:false, land:'Vorarlberg', fun:'Hat eine echte mittelalterliche Burg — die Schattenburg.', fact:'Grenzstadt zur Schweiz und Liechtenstein.', wiki:'https://de.wikipedia.org/wiki/Feldkirch', img:'sims/heli/assets/cities/feldkirch.png'},
|
||||
{name:'Bregenz', lat:47.5026, lon:9.7473, pop:29620, capital:true, land:'Vorarlberg', fun:'Die Seebühne auf dem Bodensee ist die größte Freilichtbühne der Welt!', fact:'Kleinste Landeshauptstadt Österreichs.', wiki:'https://de.wikipedia.org/wiki/Bregenz', img:'sims/heli/assets/cities/bregenz.png'},
|
||||
{name:'Leoben', lat:47.3765, lon:15.0910, pop:32768, capital:false, land:'Steiermark', fun:'Hier wird an der Montanuniversität Bergbau studiert.', fact:'War jahrhundertelang das Zentrum des steirischen Erzbergbaus.', wiki:'https://de.wikipedia.org/wiki/Leoben', img:'sims/heli/assets/cities/leoben.png'},
|
||||
{name:'Eisenstadt', lat:47.8456, lon:16.5189, pop:15073, capital:true, land:'Burgenland', fun:'Hier lebte der Komponist Joseph Haydn über 30 Jahre.', fact:'Kleinste Landeshauptstadt nach Einwohnern. Schloss Esterházy ist das Wahrzeichen.', wiki:'https://de.wikipedia.org/wiki/Eisenstadt', img:'sims/heli/assets/cities/eisenstadt.png'},
|
||||
];
|
||||
|
||||
cities.forEach(function(c) {
|
||||
var dotSize = c.capital ? 30 : 26;
|
||||
var cls = '';
|
||||
|
||||
// Punkt
|
||||
var capitalBadge = c.capital ? '<div style="display:inline-block;background:#e8833a;color:#fff;font-size:9px;font-weight:700;padding:2px 8px;border-radius:99px;margin-bottom:6px">★ Landeshauptstadt' + (c.land ? ' von ' + c.land : '') + '</div>' : (c.land ? '<div style="font-size:10px;color:#8a8a8a;margin-bottom:4px">' + c.land + '</div>' : '');
|
||||
L.marker([c.lat, c.lon], {
|
||||
icon: L.divIcon({
|
||||
className: cls,
|
||||
html: '<div class="marker-circle' + (c.capital ? ' capital' : '') + '">🏙️</div>',
|
||||
iconSize: [dotSize, dotSize],
|
||||
iconAnchor: [dotSize/2, dotSize/2]
|
||||
}),
|
||||
zIndexOffset: c.capital ? 500 : 100
|
||||
}).addTo(map).bindPopup(
|
||||
'<div style="font-family:Inter,sans-serif;width:260px;padding:0">'
|
||||
+ (c.img ? '<div style="height:130px;overflow:hidden;border-radius:8px 8px 0 0;margin:-12px -12px 8px"><img src="'+c.img+'" style="width:100%;height:100%;object-fit:cover"></div>' : '')
|
||||
+ '<div style="text-align:center">'
|
||||
+ capitalBadge
|
||||
+ '<div style="font-size:16px;font-weight:900;color:#1f4e5a;margin-bottom:2px">' + c.name + '</div>'
|
||||
+ '<div style="font-size:22px;font-weight:900;color:#4a7c8a">' + c.pop.toLocaleString() + '</div>'
|
||||
+ '<div style="font-size:9px;color:#8a8a8a;margin-bottom:8px">Einwohner*innen</div>'
|
||||
+ '</div>'
|
||||
+ (c.fun ? '<div style="font-size:11px;color:#2d5434;background:rgba(90,138,94,.08);border-radius:6px;padding:6px 8px;margin-bottom:6px">🎯 ' + c.fun + '</div>' : '')
|
||||
+ (c.fact ? '<div style="font-size:11px;color:#4a4a4a;line-height:1.4;margin-bottom:6px">📚 ' + c.fact + '</div>' : '')
|
||||
+ (c.wiki ? '<div style="text-align:center"><a href="'+c.wiki+'" target="_blank" style="font-size:10px;color:#4a7c8a;text-decoration:none;font-weight:600">📖 Wikipedia →</a></div>' : '')
|
||||
+ '</div>'
|
||||
, {maxWidth:260});
|
||||
|
||||
// Pill
|
||||
var popStr = c.pop >= 1000000 ? (c.pop/1000000).toFixed(1) + ' Mio' : Math.round(c.pop/1000) + 'k';
|
||||
var star = c.capital ? ' ★' : '';
|
||||
L.marker([c.lat, c.lon], {
|
||||
icon: L.divIcon({
|
||||
className: 'city-pill' + (c.capital ? ' capital' : ''),
|
||||
html: c.name + star + '<span class="city-pop">' + popStr + '</span>',
|
||||
iconSize: [140, 22],
|
||||
iconAnchor: [-16, 11]
|
||||
}),
|
||||
interactive: false,
|
||||
zIndexOffset: c.capital ? 400 : 50
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
// === BERGGIPFEL ===
|
||||
var peaks = [
|
||||
{name:'Großglockner', lat:47.0742, lon:12.6947, ele:3798, fact:'Höchster Berg Österreichs! Die berühmte Großglockner Hochalpenstraße führt bis auf 2.504 m.', wiki:'https://de.wikipedia.org/wiki/Großglockner'},
|
||||
{name:'Wildspitze', lat:46.8853, lon:10.8672, ele:3768, fact:'Höchster Berg Tirols und zweithöchster Österreichs. Hat den größten Gletscher der Ostalpen.', wiki:'https://de.wikipedia.org/wiki/Wildspitze'},
|
||||
{name:'Weißkugel', lat:46.8439, lon:10.7833, ele:3738, fact:'Dritthöchster Berg Österreichs, direkt an der Grenze zu Italien.', wiki:'https://de.wikipedia.org/wiki/Weißkugel'},
|
||||
{name:'Großvenediger', lat:47.1092, lon:12.3464, ele:3657, fact:'Wird „Weltalte Majestät" genannt. Einer der schönsten Gletscherberge der Alpen.', wiki:'https://de.wikipedia.org/wiki/Großvenediger'},
|
||||
{name:'Similaun', lat:46.7578, lon:10.8853, ele:3599, fact:'Hier wurde 1991 die Gletschermumie „Ötzi" gefunden — 5.300 Jahre alt!', wiki:'https://de.wikipedia.org/wiki/Similaun'},
|
||||
{name:'Zuckerhütl', lat:46.9833, lon:11.1167, ele:3505, fact:'Höchster Berg der Stubaier Alpen. Beliebtes Ziel für Skitouren.', wiki:'https://de.wikipedia.org/wiki/Zuckerhütl'},
|
||||
{name:'Piz Buin', lat:46.8444, lon:10.1186, ele:3312, fact:'Höchster Berg Vorarlbergs. Die Sonnencreme-Marke wurde nach ihm benannt!', wiki:'https://de.wikipedia.org/wiki/Piz_Buin'},
|
||||
{name:'Zugspitze', lat:47.4211, lon:10.9856, ele:2962, fact:'Höchster Berg Deutschlands, direkt an der Grenze zu Österreich. Seilbahn fährt bis zum Gipfel.', wiki:'https://de.wikipedia.org/wiki/Zugspitze'},
|
||||
{name:'Dachstein', lat:47.4753, lon:13.6061, ele:2995, fact:'Höchster Berg der Steiermark. Hat eine Eishöhle und den berühmten Skywalk.', wiki:'https://de.wikipedia.org/wiki/Hoher_Dachstein'},
|
||||
{name:'Valluga', lat:47.1567, lon:10.2117, ele:2809, fact:'Hausberg von St. Anton am Arlberg, eines der berühmtesten Skigebiete der Welt.', wiki:'https://de.wikipedia.org/wiki/Valluga'},
|
||||
{name:'Parseierspitze', lat:47.0825, lon:10.5086, ele:3036, fact:'Höchster Berg der Lechtaler Alpen. Nur für erfahrene Bergsteiger*innen!', wiki:'https://de.wikipedia.org/wiki/Parseierspitze'},
|
||||
{name:'Brenner', lat:47.0422, lon:11.5067, ele:1370, fact:'Der wichtigste Alpenpass zwischen Österreich und Italien. Schon die Römer nutzten ihn.', wiki:'https://de.wikipedia.org/wiki/Brennerpass'},
|
||||
{name:'Patscherkofel', lat:47.2083, lon:11.4611, ele:2246, fact:'Hausberg von Innsbruck. Hier fanden Olympische Skirennen statt.', wiki:'https://de.wikipedia.org/wiki/Patscherkofel'},
|
||||
{name:'Arlberg', lat:47.1297, lon:10.2119, ele:1793, fact:'Grenze zwischen Tirol und Vorarlberg. Wiege des alpinen Skilaufs.', wiki:'https://de.wikipedia.org/wiki/Arlberg'},
|
||||
];
|
||||
|
||||
peaks.forEach(function(p) {
|
||||
L.marker([p.lat, p.lon], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="marker-circle peak">🏔️</div>',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
})
|
||||
}).addTo(map).bindPopup(
|
||||
'<div style="font-family:Inter,sans-serif;width:220px;padding:2px">'
|
||||
+ '<div style="text-align:center">'
|
||||
+ '<div style="font-size:2.5rem">🏔️</div>'
|
||||
+ '<div style="font-size:15px;font-weight:900;color:#3a6b3e">' + p.name + '</div>'
|
||||
+ '<div style="font-size:24px;font-weight:900;color:#1f4e5a">' + p.ele.toLocaleString() + ' m</div>'
|
||||
+ '</div>'
|
||||
+ (p.fact ? '<div style="font-size:11px;color:#4a4a4a;line-height:1.4;margin:8px 0 4px;background:rgba(90,138,94,.06);border-radius:6px;padding:6px 8px">🌿 ' + p.fact + '</div>' : '')
|
||||
+ (p.wiki ? '<div style="text-align:center"><a href="'+p.wiki+'" target="_blank" style="font-size:10px;color:#4a7c8a;text-decoration:none;font-weight:600">📖 Wikipedia →</a></div>' : '')
|
||||
+ '</div>'
|
||||
, {maxWidth:240});
|
||||
|
||||
L.marker([p.lat, p.lon], {
|
||||
icon: L.divIcon({
|
||||
className: 'peak-pill',
|
||||
html: p.name + '<span class="peak-ele">' + p.ele.toLocaleString() + ' m</span>',
|
||||
iconSize: [140, 20],
|
||||
iconAnchor: [-14, 10]
|
||||
}),
|
||||
interactive: false
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
// === DB-SYNC: Koordinaten aus der Datenbank laden und Marker aktualisieren ===
|
||||
(async function() {
|
||||
try {
|
||||
var bp = location.pathname.replace(/\/[^\/]*$/, '');
|
||||
var resp = await fetch(bp + '/api/waypoints');
|
||||
var dbWaypoints = await resp.json();
|
||||
if (!Array.isArray(dbWaypoints)) return;
|
||||
|
||||
// Index nach wp_key
|
||||
var dbMap = {};
|
||||
dbWaypoints.forEach(function(w) { dbMap[w.wp_key] = {lat: parseFloat(w.lat), lon: parseFloat(w.lon), name: w.name}; });
|
||||
|
||||
// Alle Leaflet-Marker durchgehen und Positionen updaten
|
||||
map.eachLayer(function(layer) {
|
||||
if (layer.getLatLng && layer.options && layer.options.title) {
|
||||
// Versuche den Marker anhand des Titels zu identifizieren
|
||||
// (Heli-Basen haben title gesetzt)
|
||||
}
|
||||
});
|
||||
|
||||
// Einfacher: Seite neu rendern wenn DB-Daten signifikant abweichen
|
||||
// Für jetzt: Konsolenwarnung bei Abweichungen
|
||||
var fixes = 0;
|
||||
bases.forEach(function(b) {
|
||||
var key = b.name.toLowerCase().replace(/[^a-z0-9]/g,'');
|
||||
// Versuche verschiedene Key-Matches
|
||||
for (var dbKey in dbMap) {
|
||||
if (dbMap[dbKey].name === b.name || dbMap[dbKey].name.indexOf(b.code) !== -1) {
|
||||
var dLat = Math.abs(b.lat - dbMap[dbKey].lat);
|
||||
var dLon = Math.abs(b.lon - dbMap[dbKey].lon);
|
||||
if (dLat > 0.001 || dLon > 0.001) {
|
||||
console.warn('DB-Korrektur: ' + b.name + ' (' + b.lat + ',' + b.lon + ') → (' + dbMap[dbKey].lat + ',' + dbMap[dbKey].lon + ')');
|
||||
fixes++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Marker-Positionen korrigieren: alle Layer entfernen und neu setzen
|
||||
if (Object.keys(dbMap).length > 0) {
|
||||
// Alle bestehenden Marker entfernen
|
||||
var layersToRemove = [];
|
||||
map.eachLayer(function(layer) {
|
||||
if (layer.getLatLng && !(layer instanceof L.TileLayer)) {
|
||||
layersToRemove.push(layer);
|
||||
}
|
||||
});
|
||||
layersToRemove.forEach(function(l) { map.removeLayer(l); });
|
||||
|
||||
// Basen neu setzen mit DB-Koordinaten
|
||||
bases.forEach(function(b) {
|
||||
// Finde passenden DB-Eintrag
|
||||
for (var dbKey in dbMap) {
|
||||
if (dbMap[dbKey].name.indexOf(b.code) !== -1 || dbMap[dbKey].name === b.name) {
|
||||
b.lat = dbMap[dbKey].lat;
|
||||
b.lon = dbMap[dbKey].lon;
|
||||
break;
|
||||
}
|
||||
}
|
||||
var size = b.active ? 30 : 22;
|
||||
var circleClass = b.active ? 'marker-circle heli-m' : 'marker-circle heli-m inactive';
|
||||
L.marker([b.lat, b.lon], {
|
||||
icon: L.divIcon({className:'', html:'<div class="'+circleClass+'">🚁</div>', iconSize:[size,size], iconAnchor:[size/2,size/2]}),
|
||||
title: b.name+' ('+b.org+')', zIndexOffset: b.active ? 1000 : 0
|
||||
}).addTo(map).bindPopup(makeHeliPopup(b), {maxWidth:280, className:'heli-popup'});
|
||||
if (b.active) {
|
||||
L.marker([b.lat, b.lon], {
|
||||
icon: L.divIcon({className:'heli-pill '+b.orgClass, html:'<span class="pill-name">'+b.code+' '+b.name+'</span><span class="pill-org">'+b.org+'</span>', iconSize:[160,28], iconAnchor:[-20,14]}),
|
||||
interactive:false, zIndexOffset:900
|
||||
}).addTo(map);
|
||||
}
|
||||
});
|
||||
|
||||
// Städte neu setzen
|
||||
cities.forEach(function(c) {
|
||||
var dbKey = c.name.toLowerCase().replace(/[^a-zäöüß]/g,'');
|
||||
for (var k in dbMap) {
|
||||
if (dbMap[k].name.toLowerCase().replace(/[^a-zäöüß]/g,'') === dbKey) {
|
||||
c.lat = dbMap[k].lat; c.lon = dbMap[k].lon; break;
|
||||
}
|
||||
}
|
||||
var dotSize = c.capital ? 30 : 26;
|
||||
var capitalBadge = c.capital ? '<div style="display:inline-block;background:#e8833a;color:#fff;font-size:9px;font-weight:700;padding:2px 8px;border-radius:99px;margin-bottom:6px">★ Landeshauptstadt'+(c.land?' von '+c.land:'')+'</div>' : (c.land?'<div style="font-size:10px;color:#8a8a8a;margin-bottom:4px">'+c.land+'</div>':'');
|
||||
L.marker([c.lat, c.lon], {
|
||||
icon: L.divIcon({className:'', html:'<div class="marker-circle'+(c.capital?' capital':'')+'">🏙️</div>', iconSize:[dotSize,dotSize], iconAnchor:[dotSize/2,dotSize/2]}),
|
||||
zIndexOffset: c.capital ? 500 : 100
|
||||
}).addTo(map).bindPopup(
|
||||
'<div style="font-family:Inter,sans-serif;width:260px;padding:0">'
|
||||
+(c.img?'<div style="height:130px;overflow:hidden;border-radius:8px 8px 0 0;margin:-12px -12px 8px"><img src="'+c.img+'" style="width:100%;height:100%;object-fit:cover"></div>':'')
|
||||
+'<div style="text-align:center">'+capitalBadge
|
||||
+'<div style="font-size:16px;font-weight:900;color:#1f4e5a;margin-bottom:2px">'+c.name+'</div>'
|
||||
+'<div style="font-size:22px;font-weight:900;color:#4a7c8a">'+c.pop.toLocaleString()+'</div>'
|
||||
+'<div style="font-size:9px;color:#8a8a8a;margin-bottom:8px">Einwohner*innen</div></div>'
|
||||
+(c.fun?'<div style="font-size:11px;color:#2d5434;background:rgba(90,138,94,.08);border-radius:6px;padding:6px 8px;margin-bottom:6px">🎯 '+c.fun+'</div>':'')
|
||||
+(c.fact?'<div style="font-size:11px;color:#4a4a4a;line-height:1.4;margin-bottom:6px">📚 '+c.fact+'</div>':'')
|
||||
+(c.wiki?'<div style="text-align:center"><a href="'+c.wiki+'" target="_blank" style="font-size:10px;color:#4a7c8a;text-decoration:none;font-weight:600">📖 Wikipedia →</a></div>':'')
|
||||
+'</div>', {maxWidth:260});
|
||||
var star = c.capital ? ' ★' : '';
|
||||
var popStr = c.pop >= 1000000 ? (c.pop/1000000).toFixed(1)+' Mio' : Math.round(c.pop/1000)+'k';
|
||||
L.marker([c.lat, c.lon], {
|
||||
icon: L.divIcon({className:'city-pill'+(c.capital?' capital':''), html:c.name+star+'<span class="city-pop">'+popStr+'</span>', iconSize:[140,22], iconAnchor:[-16,11]}),
|
||||
interactive:false, zIndexOffset: c.capital ? 400 : 50
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
// Berge neu setzen
|
||||
peaks.forEach(function(p) {
|
||||
L.marker([p.lat, p.lon], {
|
||||
icon: L.divIcon({className:'', html:'<div class="marker-circle peak">🏔️</div>', iconSize:[24,24], iconAnchor:[12,12]})
|
||||
}).addTo(map).bindPopup(
|
||||
'<div style="font-family:Inter,sans-serif;width:220px;padding:2px"><div style="text-align:center">'
|
||||
+'<div style="font-size:2.5rem">🏔️</div><div style="font-size:15px;font-weight:900;color:#3a6b3e">'+p.name+'</div>'
|
||||
+'<div style="font-size:24px;font-weight:900;color:#1f4e5a">'+p.ele.toLocaleString()+' m</div></div>'
|
||||
+(p.fact?'<div style="font-size:11px;color:#4a4a4a;line-height:1.4;margin:8px 0 4px;background:rgba(90,138,94,.06);border-radius:6px;padding:6px 8px">🌿 '+p.fact+'</div>':'')
|
||||
+(p.wiki?'<div style="text-align:center"><a href="'+p.wiki+'" target="_blank" style="font-size:10px;color:#4a7c8a;text-decoration:none;font-weight:600">📖 Wikipedia →</a></div>':'')
|
||||
+'</div>', {maxWidth:240});
|
||||
L.marker([p.lat, p.lon], {
|
||||
icon: L.divIcon({className:'peak-pill', html:p.name+'<span class="peak-ele">'+p.ele.toLocaleString()+' m</span>', iconSize:[140,20], iconAnchor:[-14,10]}),
|
||||
interactive:false
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
console.log('Karte mit DB-Koordinaten aktualisiert (' + Object.keys(dbMap).length + ' Waypoints)');
|
||||
}
|
||||
} catch(e) { console.warn('DB-Sync fehlgeschlagen:', e); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Impressum</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;color:#1a1a1a;line-height:1.7}
|
||||
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);flex-wrap:wrap}
|
||||
.topbar a{text-decoration:none}
|
||||
.topbar img{height:32px}
|
||||
.topbar .spacer{flex:1}
|
||||
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;color:#4a4a4a;text-decoration:none}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:2rem 1.5rem 4rem}
|
||||
h1{font-size:1.6rem;font-weight:900;color:#1f4e5a;margin-bottom:.5rem}
|
||||
h2{font-size:1rem;font-weight:800;color:#1f4e5a;margin:1.5rem 0 .4rem;padding-top:.8rem;border-top:1px solid rgba(0,0,0,.06)}
|
||||
p,li{font-size:.85rem;color:#333;margin-bottom:.5rem}
|
||||
.card{background:#fff;border-radius:12px;padding:1.2rem;border:1px solid rgba(0,0,0,.04);margin-bottom:1rem}
|
||||
label{display:block;font-size:.72rem;font-weight:600;color:#4a4a4a;margin-bottom:.2rem;margin-top:.6rem}
|
||||
input,textarea,select{width:100%;padding:.5rem .7rem;border:1.5px solid rgba(0,0,0,.1);border-radius:7px;font-size:.82rem;font-family:inherit;outline:none}
|
||||
input:focus,textarea:focus{border-color:#4a7c8a}
|
||||
textarea{resize:vertical;min-height:100px}
|
||||
.btn{padding:.5rem 1.2rem;border:none;border-radius:8px;background:#4a7c8a;color:#fff;font-size:.82rem;font-weight:700;cursor:pointer;margin-top:.6rem}
|
||||
.btn:hover{background:#3a6470}
|
||||
.captcha-box{background:#f7f6f3;border-radius:8px;padding:.8rem;margin-top:.6rem;display:flex;align-items:center;gap:.8rem;flex-wrap:wrap}
|
||||
.captcha-q{font-size:.82rem;font-weight:600;color:#1f4e5a}
|
||||
.captcha-input{width:80px;text-align:center;font-size:1rem;font-weight:700}
|
||||
.msg{font-size:.75rem;padding:.4rem .6rem;border-radius:6px;margin-top:.5rem;display:none}
|
||||
.msg.ok{display:block;background:#dceadd;color:#5a8a5e}
|
||||
.msg.err{display:block;background:#f2ddd8;color:#c07a6b}
|
||||
.footer-note{font-size:.72rem;color:#8a8a8a;text-align:center;margin-top:2rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.06)}
|
||||
.footer-note a{color:#4a7c8a;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<a href="index.html"><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"></a>
|
||||
<span class="spacer"></span>
|
||||
<a href="index.html" class="btn-sm">Startseite</a>
|
||||
<a href="ueber.html" class="btn-sm">Über</a>
|
||||
<a href="handbuch.html" class="btn-sm">Handbuch</a>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>Impressum</h1>
|
||||
<p>Angaben gemäß § 5 E-Commerce-Gesetz (ECG) und § 25 Mediengesetz (MedienG)</p>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>Medieninhaber und Herausgeber:</strong></p>
|
||||
<p>
|
||||
Dominik Mößlang<br>
|
||||
Laimburggasse 32/Tür 18<br>
|
||||
8010 Graz, Österreich
|
||||
</p>
|
||||
<p><strong>Kontakt:</strong></p>
|
||||
<p>
|
||||
E-Mail: <a href="mailto:support@geograsim.at" style="color:#4a7c8a">support@geograsim.at</a><br>
|
||||
Web: <a href="https://geograsim.at" style="color:#4a7c8a">geograsim.at</a>
|
||||
</p>
|
||||
<p><strong>Unternehmensgegenstand:</strong><br>Entwicklung und Betrieb einer webbasierten Lernplattform für den Geografie- und Wirtschaftskunde-Unterricht.</p>
|
||||
<p><strong>Anwendbare Rechtsvorschriften:</strong><br>E-Commerce-Gesetz (ECG), Mediengesetz (MedienG), Datenschutz-Grundverordnung (DSGVO), Telekommunikationsgesetz 2021 (TKG 2021)</p>
|
||||
</div>
|
||||
|
||||
<h2>Haftungsausschluss</h2>
|
||||
<p>Die Inhalte dieser Website wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich.</p>
|
||||
<p>Für die Inhalte externer Links (OpenStreetMap, Wikipedia) übernehmen wir keine Haftung. Für den Inhalt der verlinkten Seiten sind ausschließlich deren Betreiber verantwortlich.</p>
|
||||
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem österreichischen Urheberrecht. Die Simulationen, Texte, Grafiken und das didaktische Konzept sind geistiges Eigentum von Schroffenegger & Mößlang.</p>
|
||||
<p>Geodaten: © <a href="https://www.openstreetmap.org/copyright" style="color:#4a7c8a">OpenStreetMap</a> contributors.</p>
|
||||
|
||||
<h2>Kontaktformular</h2>
|
||||
<div class="card">
|
||||
<label>Ihr Name</label>
|
||||
<input type="text" id="cf-name" placeholder="Vor- und Nachname">
|
||||
<label>E-Mail-Adresse</label>
|
||||
<input type="email" id="cf-email" placeholder="name@example.at">
|
||||
<label>Betreff</label>
|
||||
<select id="cf-subject">
|
||||
<option value="Allgemeine Anfrage">Allgemeine Anfrage</option>
|
||||
<option value="Technisches Problem">Technisches Problem</option>
|
||||
<option value="Datenschutz">Datenschutz</option>
|
||||
<option value="Barrierefreiheit">Barrierefreiheit</option>
|
||||
<option value="Lizenzanfrage">Lizenzanfrage</option>
|
||||
<option value="Sonstiges">Sonstiges</option>
|
||||
</select>
|
||||
<label>Nachricht</label>
|
||||
<textarea id="cf-message" placeholder="Ihre Nachricht…"></textarea>
|
||||
|
||||
<div class="captcha-box">
|
||||
<span class="captcha-q" id="captcha-question"></span>
|
||||
<input type="text" id="captcha-answer" class="captcha-input" placeholder="?">
|
||||
<span style="font-size:.65rem;color:#8a8a8a">(Spam-Schutz)</span>
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="sendContact()">Nachricht senden</button>
|
||||
<div class="msg" id="cf-msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="footer-note">
|
||||
<p><a href="datenschutz.html">Datenschutz</a> · <a href="barrierefreiheit.html">Barrierefreiheit</a> · <a href="index.html">geograsim.at</a></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var BASE = (window.__GGS__?.baseUrl) || '';
|
||||
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
|
||||
|
||||
// Eigenes Captcha: einfache Rechenaufgabe
|
||||
var captchaA, captchaB, captchaOp, captchaResult;
|
||||
function newCaptcha() {
|
||||
captchaA = 2 + Math.floor(Math.random() * 8);
|
||||
captchaB = 1 + Math.floor(Math.random() * 9);
|
||||
captchaOp = Math.random() < 0.5 ? '+' : '×';
|
||||
captchaResult = captchaOp === '+' ? captchaA + captchaB : captchaA * captchaB;
|
||||
document.getElementById('captcha-question').textContent = 'Was ist ' + captchaA + ' ' + captchaOp + ' ' + captchaB + '?';
|
||||
document.getElementById('captcha-answer').value = '';
|
||||
}
|
||||
newCaptcha();
|
||||
|
||||
async function sendContact() {
|
||||
var name = document.getElementById('cf-name').value.trim();
|
||||
var email = document.getElementById('cf-email').value.trim();
|
||||
var subject = document.getElementById('cf-subject').value;
|
||||
var message = document.getElementById('cf-message').value.trim();
|
||||
var answer = parseInt(document.getElementById('captcha-answer').value);
|
||||
var msgEl = document.getElementById('cf-msg');
|
||||
|
||||
if (!name || !email || !message) { msgEl.textContent = 'Bitte alle Felder ausfüllen.'; msgEl.className = 'msg err'; return; }
|
||||
if (answer !== captchaResult) { msgEl.textContent = 'Rechenaufgabe falsch. Bitte nochmal versuchen.'; msgEl.className = 'msg err'; newCaptcha(); return; }
|
||||
|
||||
try {
|
||||
var r = await fetch(BASE + '/api/auth', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action:'contact', name:name, email:email, subject:subject, message:message})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.error) { msgEl.textContent = d.error; msgEl.className = 'msg err'; return; }
|
||||
msgEl.textContent = 'Nachricht gesendet! Wir melden uns zeitnah.'; msgEl.className = 'msg ok';
|
||||
document.getElementById('cf-name').value = '';
|
||||
document.getElementById('cf-email').value = '';
|
||||
document.getElementById('cf-message').value = '';
|
||||
newCaptcha();
|
||||
} catch(e) {
|
||||
msgEl.textContent = 'Fehler beim Senden. Bitte versuchen Sie es per E-Mail.'; msgEl.className = 'msg err';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Geografie erleben</title>
|
||||
<link rel="icon" type="image/png" href="assets/img/logo.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
|
||||
|
||||
@@ -24,27 +24,27 @@
|
||||
|
||||
/* ===== NAV ===== */
|
||||
.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; padding: .7rem 0; transition: all .4s; }
|
||||
.nav.scrolled { backdrop-filter: blur(20px); background: rgba(247,246,243,0.9); box-shadow: 0 1px 0 rgba(0,0,0,0.04); }
|
||||
.nav.scrolled { backdrop-filter: blur(20px); background: rgba(247,246,243,0.92); box-shadow: 0 1px 0 rgba(0,0,0,0.04); }
|
||||
.nav .w { max-width: 1200px; margin: 0 auto; padding: 0 2rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
.nav-brand { display: flex; align-items: center; gap: .5rem; font-weight: 800; font-size: 1.05rem; color: var(--fjord); text-decoration: none; }
|
||||
.nav-brand img { width: 30px; height: 30px; border-radius: 7px; }
|
||||
.nav-brand { display: flex; align-items: center; text-decoration: none; }
|
||||
.nav-brand img { height: 38px; }
|
||||
.nav-links { display: flex; gap: .2rem; align-items: center; }
|
||||
.nav-links a { color: var(--text2); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; font-size: .82rem; font-weight: 500; transition: all .2s; }
|
||||
.nav-links a:hover { background: rgba(0,0,0,0.04); }
|
||||
.btn { display: inline-flex; align-items: center; gap: .4rem; padding: .55rem 1.3rem; border-radius: var(--rs); font-weight: 600; font-size: .82rem; text-decoration: none; border: none; cursor: pointer; transition: all .25s cubic-bezier(.22,1,.36,1); }
|
||||
.btn-p { background: var(--fjord); color: #fff; box-shadow: 0 2px 10px rgba(74,124,138,.2); }
|
||||
.btn-p:hover { background: var(--fjord-d); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(74,124,138,.25); }
|
||||
.btn-o { background: transparent; color: var(--fjord); border: 1.5px solid rgba(74,124,138,.3); }
|
||||
.btn-o { background: rgba(255,255,255,.85); color: var(--fjord); border: 1.5px solid rgba(74,124,138,.4); }
|
||||
.btn-o:hover { border-color: var(--fjord); background: var(--fjord-l); }
|
||||
|
||||
/* ===== HERO ===== */
|
||||
.hero { height: 100vh; min-height: 700px; position: relative; overflow: hidden; display: flex; align-items: center; }
|
||||
.hero { min-height: 85vh; position: relative; overflow: hidden; display: flex; align-items: center; }
|
||||
.hero canvas { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; }
|
||||
.hero-content { position: relative; z-index: 2; max-width: 1200px; margin: 0 auto; padding: 0 2rem; width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; align-items: center; }
|
||||
.hero-content { position: relative; z-index: 2; max-width: 1200px; margin: 0 auto; padding: 6rem 2rem 3rem; width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; align-items: center; }
|
||||
.hero-text h1 { font-size: clamp(2.2rem, 4.5vw, 3.8rem); font-weight: 900; line-height: 1.05; letter-spacing: -.04em; margin-bottom: .8rem; }
|
||||
.hero-text h1 em { font-style: normal; color: var(--fjord); }
|
||||
.hero-text .lead { font-size: 1.05rem; color: var(--text2); max-width: 420px; margin-bottom: 1.5rem; }
|
||||
.hero-btns { display: flex; gap: .6rem; }
|
||||
.hero-text .lead { font-size: 1.05rem; color: var(--text2); max-width: 440px; margin-bottom: 1.5rem; }
|
||||
.hero-btns { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.hero-visual { display: flex; justify-content: center; perspective: 800px; }
|
||||
.hero-card { width: 340px; background: var(--white); border-radius: var(--r); box-shadow: 0 20px 60px rgba(0,0,0,.1); overflow: hidden; transform: rotateY(-8deg) rotateX(3deg); transition: transform .5s cubic-bezier(.22,1,.36,1); }
|
||||
.hero-card:hover { transform: rotateY(0) rotateX(0); }
|
||||
@@ -58,43 +58,22 @@
|
||||
@keyframes bobA { 0%,100% { transform: rotate(3deg) translateY(0); } 50% { transform: rotate(3deg) translateY(-8px); } }
|
||||
@keyframes bobB { 0%,100% { transform: rotate(-2deg) translateY(0); } 50% { transform: rotate(-2deg) translateY(6px); } }
|
||||
|
||||
/* ===== TICKER ===== */
|
||||
.ticker { background: var(--fjord); padding: .9rem 0; overflow: hidden; white-space: nowrap; }
|
||||
.ticker-inner { display: inline-flex; animation: scroll 35s linear infinite; }
|
||||
.ticker-item { color: rgba(255,255,255,.85); font-weight: 600; font-size: .8rem; padding: 0 1.8rem; }
|
||||
@keyframes scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
|
||||
|
||||
/* ===== SECTION UTILS ===== */
|
||||
.sec { padding: 5rem 0; }
|
||||
/* ===== SECTION ===== */
|
||||
.sec { padding: 4rem 0; }
|
||||
.w { max-width: 1200px; margin: 0 auto; padding: 0 2rem; }
|
||||
.label { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--fjord); }
|
||||
.sec-title { font-size: 2.2rem; font-weight: 900; letter-spacing: -.03em; margin: .3rem 0; }
|
||||
.sec-sub { color: var(--text2); max-width: 460px; font-size: .95rem; }
|
||||
.sec-sub { color: var(--text2); max-width: 520px; font-size: .95rem; }
|
||||
|
||||
/* ===== STATS ===== */
|
||||
.stats { margin-top: 2rem; position: relative; z-index: 3; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; }
|
||||
.stat { background: var(--white); border-radius: var(--r); padding: 1.3rem; text-align: center; box-shadow: 0 6px 20px rgba(0,0,0,.05); border: 1px solid rgba(0,0,0,.04); }
|
||||
.stat-n { font-size: 2rem; font-weight: 900; letter-spacing: -.03em; }
|
||||
.stat-l { font-size: .72rem; color: var(--text3); margin-top: .1rem; }
|
||||
|
||||
/* ===== FLOATING EMOJIS ===== */
|
||||
.emoji-layer { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; overflow: hidden; }
|
||||
.emoji-float { position: absolute; opacity: .06; animation: emojiDrift linear infinite; }
|
||||
@keyframes emojiDrift {
|
||||
0% { transform: translateY(105vh) rotate(0deg); }
|
||||
100% { transform: translateY(-10vh) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== SIM CARDS GRID ===== */
|
||||
/* ===== SIM CARDS ===== */
|
||||
.sim-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 2rem; }
|
||||
.sim-card { background: var(--white); border-radius: 16px; overflow: hidden; position: relative; box-shadow: 0 4px 16px rgba(0,0,0,.06); border: 1.5px solid rgba(0,0,0,.05); transition: all .35s cubic-bezier(.22,1,.36,1); cursor: pointer; padding: .6rem; }
|
||||
.sim-card { background: var(--white); border-radius: 16px; overflow: hidden; position: relative; box-shadow: 0 4px 16px rgba(0,0,0,.06); border: 1.5px solid rgba(0,0,0,.05); transition: all .35s cubic-bezier(.22,1,.36,1); cursor: pointer; padding: .6rem; text-decoration: none; color: inherit; display: block; }
|
||||
.sim-card:hover { transform: translateY(-5px); box-shadow: 0 12px 35px rgba(74,124,138,.12); border-color: rgba(74,124,138,.2); }
|
||||
.sim-card-img { height: 150px; overflow: hidden; position: relative; border-radius: 10px; }
|
||||
.sim-card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform .5s; }
|
||||
.sim-card:hover .sim-card-img img { transform: scale(1.06); }
|
||||
.sim-card-body { padding: .8rem .4rem .4rem; }
|
||||
.sim-card-body h3 { font-size: .88rem; font-weight: 700; margin-bottom: .2rem; }
|
||||
.sim-card-body h3 { font-size: .88rem; font-weight: 700; margin-bottom: .2rem; display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
|
||||
.sim-card-body p { font-size: .75rem; color: var(--text2); margin-bottom: .5rem; }
|
||||
.sim-card-tags { display: flex; gap: .3rem; flex-wrap: wrap; }
|
||||
.sim-tag { font-size: .62rem; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
|
||||
@@ -103,40 +82,66 @@
|
||||
.st3 { background: var(--sand-l); color: var(--sand); }
|
||||
.st4 { background: var(--coral-l); color: var(--coral); }
|
||||
.st-time { background: #f0eeea; color: var(--text3); }
|
||||
.badge-new { background: linear-gradient(135deg,#4a7c8a,#6aa8b8); color: #fff; font-size: .58rem; padding: 2px 6px; border-radius: 4px; font-weight: 800; letter-spacing: .04em; }
|
||||
.badge-free { background: linear-gradient(135deg,#5a8a5e,#7ab88a); color: #fff; font-size: .58rem; padding: 2px 6px; border-radius: 4px; font-weight: 800; letter-spacing: .04em; }
|
||||
|
||||
/* Locked sim cards */
|
||||
.sim-card.locked { opacity: .75; }
|
||||
.sim-card.locked:hover { transform: translateY(-3px); }
|
||||
.sim-card.locked .sim-card-img::after { content: ''; position: absolute; inset: 0; background: rgba(0,0,0,.15); border-radius: 10px; }
|
||||
.lock-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); background: rgba(255,255,255,.95); border-radius: 12px; padding: .6rem 1rem; font-size: .72rem; font-weight: 600; color: var(--fjord); box-shadow: 0 4px 15px rgba(0,0,0,.1); text-align: center; z-index: 2; }
|
||||
|
||||
/* ===== VALUE PROPOSITION (for teachers) ===== */
|
||||
.value-sec { background: var(--bg2); position: relative; }
|
||||
.value-sec::before { content: ''; position: absolute; top: -50px; left: 0; right: 0; height: 100px; background: var(--bg2); transform: skewY(-2deg); z-index: -1; }
|
||||
.value-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; margin-top: 2rem; align-items: center; }
|
||||
.value-text h3 { font-size: 1.5rem; font-weight: 800; margin-bottom: .8rem; letter-spacing: -.02em; }
|
||||
.value-text p { font-size: .9rem; color: var(--text2); margin-bottom: 1rem; line-height: 1.7; }
|
||||
.value-list { list-style: none; display: flex; flex-direction: column; gap: .5rem; }
|
||||
.value-list li { display: flex; align-items: flex-start; gap: .6rem; font-size: .85rem; color: var(--text2); }
|
||||
.value-list li .vi { font-size: 1.1rem; flex-shrink: 0; margin-top: .05rem; }
|
||||
|
||||
/* ===== FEATURES ===== */
|
||||
.feat-sec { background: var(--bg2); position: relative; }
|
||||
.feat-sec::before { content: ''; position: absolute; top: -60px; left: 0; right: 0; height: 120px; background: var(--bg2); transform: skewY(-2.5deg); z-index: -1; }
|
||||
.feat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 2rem; }
|
||||
.feat { background: var(--white); border-radius: var(--r); padding: 1.6rem; border: 1px solid rgba(0,0,0,.03); transition: all .3s; }
|
||||
.feat { background: var(--white); border-radius: var(--r); padding: 1.4rem; border: 1px solid rgba(0,0,0,.03); transition: all .3s; }
|
||||
.feat:hover { transform: translateY(-4px); box-shadow: 0 8px 25px rgba(0,0,0,.06); }
|
||||
.feat-ico { font-size: 1.8rem; margin-bottom: .6rem; }
|
||||
.feat h3 { font-size: .88rem; font-weight: 700; margin-bottom: .2rem; }
|
||||
.feat p { font-size: .78rem; color: var(--text2); }
|
||||
.feat-ico { font-size: 1.6rem; margin-bottom: .5rem; }
|
||||
.feat h3 { font-size: .85rem; font-weight: 700; margin-bottom: .2rem; }
|
||||
.feat p { font-size: .75rem; color: var(--text2); }
|
||||
|
||||
/* ===== KLASSEN ===== */
|
||||
.kl-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-top: 2rem; }
|
||||
.kl { border-radius: var(--r); padding: 1.8rem 1.3rem; color: #fff; transition: all .3s; position: relative; overflow: hidden; }
|
||||
.kl:hover { transform: translateY(-5px) rotate(0deg) !important; }
|
||||
.kl h3 { font-size: 1.3rem; font-weight: 800; }
|
||||
.kl .ksub { font-size: .82rem; opacity: .9; margin: .2rem 0 .6rem; }
|
||||
.kl .kdet { font-size: .75rem; opacity: .7; line-height: 1.5; }
|
||||
.kl::after { content: ''; position: absolute; top: -30px; right: -30px; width: 100px; height: 100px; background: rgba(255,255,255,.08); border-radius: 50%; }
|
||||
.k1 { background: linear-gradient(135deg, #4a7c8a, #5a9aaa); transform: rotate(-1.2deg); }
|
||||
.k2 { background: linear-gradient(135deg, #5a8a5e, #6aaa6e); transform: rotate(.8deg); }
|
||||
.k3 { background: linear-gradient(135deg, #c4a35a, #d4b36a); transform: rotate(-.6deg); }
|
||||
.k4 { background: linear-gradient(135deg, #c07a6b, #d08a7b); transform: rotate(1deg); }
|
||||
/* ===== SVG SCENE DECORATIONS ===== */
|
||||
.scene-deco { position: absolute; pointer-events: none; z-index: 1; opacity: .12; }
|
||||
.scene-deco svg { display: block; }
|
||||
.deco-mountains { bottom: 0; left: 0; right: 0; }
|
||||
.deco-float { animation: decoFloat 6s ease-in-out infinite; }
|
||||
.deco-drift { animation: decoDrift 20s linear infinite; }
|
||||
.deco-wave { animation: decoWave 3s ease-in-out infinite; }
|
||||
@keyframes decoFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
|
||||
@keyframes decoDrift { 0% { transform: translateX(-100px); } 100% { transform: translateX(calc(100vw + 100px)); } }
|
||||
@keyframes decoWave { 0%,100% { transform: translateY(0) rotate(0deg); } 25% { transform: translateY(-3px) rotate(2deg); } 75% { transform: translateY(2px) rotate(-1deg); } }
|
||||
|
||||
/* Section dividers with landscape SVG */
|
||||
.landscape-divider { position: relative; overflow: hidden; height: 80px; margin: -1px 0; }
|
||||
.landscape-divider svg { position: absolute; bottom: 0; width: 100%; height: 100%; }
|
||||
|
||||
/* Sailboat that drifts across sections */
|
||||
.sailboat-container { position: fixed; bottom: 15vh; z-index: 0; pointer-events: none; opacity: .25; animation: sailDrift 45s linear infinite; }
|
||||
@keyframes sailDrift { 0% { left: -80px; } 100% { left: calc(100vw + 80px); } }
|
||||
|
||||
/* ===== CTA ===== */
|
||||
.cta { text-align: center; padding: 5rem 2rem; }
|
||||
.cta h2 { font-size: 2.5rem; font-weight: 900; letter-spacing: -.03em; }
|
||||
.cta h2 em { font-style: normal; color: var(--fjord); }
|
||||
.cta p { color: var(--text2); margin: .5rem 0 1.5rem; }
|
||||
.cta p { color: var(--text2); margin: .5rem 0 1.5rem; max-width: 500px; margin-left: auto; margin-right: auto; }
|
||||
|
||||
/* ===== FOOTER ===== */
|
||||
footer { padding: 1.5rem; text-align: center; color: var(--text3); font-size: .72rem; border-top: 1px solid rgba(0,0,0,.04); }
|
||||
footer a { color: var(--fjord); text-decoration: none; }
|
||||
|
||||
/* ===== USER PILL (logged in state) ===== */
|
||||
.user-pill { display: flex; align-items: center; gap: .4rem; padding: .3rem .7rem .3rem .4rem; background: var(--fjord-l); border-radius: 99px; font-size: .75rem; font-weight: 600; color: var(--fjord); text-decoration: none; }
|
||||
.user-pill .emoji { font-size: 1.1rem; }
|
||||
|
||||
/* ===== SCROLL REVEAL ===== */
|
||||
.reveal { opacity: 0; transform: translateY(40px); transition: all .8s cubic-bezier(.22,1,.36,1); }
|
||||
.reveal.visible { opacity: 1; transform: translateY(0); }
|
||||
@@ -147,13 +152,17 @@
|
||||
.hero-text .lead { margin: 0 auto .8rem; }
|
||||
.hero-btns { justify-content: center; }
|
||||
.hero-visual { display: none; }
|
||||
.stats-grid, .kl-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.feat-grid { grid-template-columns: 1fr; }
|
||||
.sim-grid { grid-template-columns: repeat(2, 1fr) !important; }
|
||||
.nav-links { display: none; }
|
||||
.value-grid { grid-template-columns: 1fr; }
|
||||
.steps { grid-template-columns: repeat(2, 1fr); }
|
||||
.step::after { display: none; }
|
||||
.nav-links { gap: 0; }
|
||||
.nav-links .hide-m { display: none; }
|
||||
}
|
||||
@media (max-width: 550px) {
|
||||
.sim-grid { grid-template-columns: 1fr !important; }
|
||||
.steps { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -162,12 +171,13 @@
|
||||
<!-- NAV -->
|
||||
<nav class="nav" id="nav">
|
||||
<div class="w">
|
||||
<a href="#" class="nav-brand"><img src="assets/img/logo.png" alt="">GeoGraSim</a>
|
||||
<a href="#" class="nav-brand"><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"></a>
|
||||
<div class="nav-links">
|
||||
<a href="#sims">Simulationen</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#klassen">Klassen</a>
|
||||
<a href="#" class="btn btn-p" style="margin-left:.3rem">Anmelden</a>
|
||||
<a href="#sims" class="hide-m">Simulationen</a>
|
||||
<a href="#fuer-lehrkraefte" class="hide-m">Lehrperson</a>
|
||||
<a href="ueber.html" class="hide-m">Über</a>
|
||||
<a href="handbuch.html" class="hide-m">Handbuch</a>
|
||||
<span id="nav-auth"></span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -178,394 +188,482 @@
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<h1>Geografie<br><em>erleben.</em><br>Nicht nur lesen.</h1>
|
||||
<p class="lead">Interaktive Simulationen für den GW-Unterricht. Klimawandel verstehen. Erdbeben simulieren. Städte planen. Zukunft gestalten.</p>
|
||||
<p class="lead">Interaktive Simulationen für den GW-Unterricht. Klimawandel verstehen. Städte planen. Flüsse managen. Evidenzbasiert und lehrplankonform.</p>
|
||||
<div class="hero-btns">
|
||||
<a href="#sims" class="btn btn-p">🚀 Entdecken</a>
|
||||
<a href="#features" class="btn btn-o">Für Lehrkräfte</a>
|
||||
<a href="#sims" class="btn btn-p">Jetzt ausprobieren</a>
|
||||
<a href="#fuer-lehrkraefte" class="btn btn-o">Für Lehrpersonen</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual">
|
||||
<div class="hero-card">
|
||||
<img src="assets/img/card-climate.png" alt="">
|
||||
<img src="assets/img/card-climate.png" alt="Klimasimulation">
|
||||
<div class="hero-card-body">
|
||||
<h3>🌡️ Treibhauseffekt-Simulator</h3>
|
||||
<p>CO₂ verändern → Temperatur beobachten. 20 min.</p>
|
||||
<h3>Klimasimulator 3D</h3>
|
||||
<p>Inselstaat durch die Klimakrise fuehren. Kostenlos ausprobieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-mini hero-mini-1">⚡ 24 Module</div>
|
||||
<div class="hero-mini hero-mini-2">📊 330 Quellen</div>
|
||||
<div class="hero-mini hero-mini-1">3 Module sofort testen</div>
|
||||
<div class="hero-mini hero-mini-2">Aktueller Stand didaktischer Forschung</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TICKER -->
|
||||
<div class="ticker"><div class="ticker-inner">
|
||||
<span class="ticker-item">🌡️ Treibhauseffekt</span><span class="ticker-item">🌋 Plattentektonik</span><span class="ticker-item">⚡ Energiemix</span><span class="ticker-item">🏗️ Raumplanung</span><span class="ticker-item">🌍 Planetary Boundaries</span><span class="ticker-item">🤝 Klimakonferenz</span><span class="ticker-item">📊 Bevölkerungsdynamik</span><span class="ticker-item">💧 Wasserkreislauf</span><span class="ticker-item">🏙️ Urbanisierung</span><span class="ticker-item">♻️ Nachhaltigkeit</span><span class="ticker-item">🗺️ Geomedien</span><span class="ticker-item">🇦🇹 Lehrplan 2023</span>
|
||||
<span class="ticker-item">🌡️ Treibhauseffekt</span><span class="ticker-item">🌋 Plattentektonik</span><span class="ticker-item">⚡ Energiemix</span><span class="ticker-item">🏗️ Raumplanung</span><span class="ticker-item">🌍 Planetary Boundaries</span><span class="ticker-item">🤝 Klimakonferenz</span><span class="ticker-item">📊 Bevölkerungsdynamik</span><span class="ticker-item">💧 Wasserkreislauf</span><span class="ticker-item">🏙️ Urbanisierung</span><span class="ticker-item">♻️ Nachhaltigkeit</span><span class="ticker-item">🗺️ Geomedien</span><span class="ticker-item">🇦🇹 Lehrplan 2023</span>
|
||||
</div></div>
|
||||
|
||||
<!-- FLOATING EMOJIS -->
|
||||
<div class="emoji-layer" id="emojiLayer"></div>
|
||||
|
||||
<!-- STATS -->
|
||||
<section class="sec" style="padding-top:3rem;padding-bottom:2rem">
|
||||
<div class="w stats reveal">
|
||||
<div class="stats-grid">
|
||||
<div class="stat"><div class="stat-n" style="color:var(--fjord)">24</div><div class="stat-l">Simulationen</div></div>
|
||||
<div class="stat"><div class="stat-n" style="color:var(--moss)">4</div><div class="stat-l">Schulstufen</div></div>
|
||||
<div class="stat"><div class="stat-n" style="color:var(--sand)">~25'</div><div class="stat-l">pro Einsatz</div></div>
|
||||
<div class="stat"><div class="stat-n" style="color:var(--coral)">330</div><div class="stat-l">Forschungsquellen</div></div>
|
||||
</div>
|
||||
<!-- SVG Landscape Divider: Mountains + Trees -->
|
||||
<div class="landscape-divider" style="background:var(--bg)">
|
||||
<svg viewBox="0 0 1440 80" preserveAspectRatio="none">
|
||||
<!-- Mountains -->
|
||||
<path d="M0,80 L120,25 L200,55 L320,10 L440,45 L520,20 L640,50 L720,15 L840,40 L960,8 L1080,35 L1160,18 L1280,42 L1360,12 L1440,38 L1440,80 Z" fill="#8a9a82" opacity=".15"/>
|
||||
<path d="M0,80 L80,45 L180,60 L280,30 L400,55 L500,35 L620,58 L740,28 L860,50 L980,32 L1100,52 L1200,38 L1320,55 L1440,40 L1440,80 Z" fill="#5a8a5e" opacity=".1"/>
|
||||
<!-- Trees -->
|
||||
<g fill="#5a8a5e" opacity=".12">
|
||||
<path d="M100,80 L108,55 L96,58 L104,40 L92,43 L100,28 L108,43 L96,40 L104,58 L92,55 Z"/>
|
||||
<path d="M300,80 L308,58 L296,61 L304,45 L292,48 L300,35 L308,48 L296,45 L304,61 L292,58 Z"/>
|
||||
<path d="M500,80 L507,60 L494,63 L502,48 L490,51 L500,38 L510,51 L498,48 L506,63 L494,60 Z"/>
|
||||
<path d="M750,80 L758,55 L746,58 L754,40 L742,43 L750,28 L758,43 L746,40 L754,58 L742,55 Z"/>
|
||||
<path d="M1000,80 L1007,62 L995,64 L1003,50 L991,52 L1000,40 L1009,52 L997,50 L1005,64 L993,62 Z"/>
|
||||
<path d="M1200,80 L1208,56 L1196,59 L1204,42 L1192,45 L1200,30 L1208,45 L1196,42 L1204,59 L1192,56 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SIMULATIONS -->
|
||||
<section class="sec" id="sims" style="padding-top:2rem">
|
||||
<!-- Drifting sailboat (very subtle, fixed position) -->
|
||||
<div class="sailboat-container">
|
||||
<svg width="60" height="70" viewBox="0 0 60 70">
|
||||
<path d="M30,5 L30,50" stroke="#1f4e5a" stroke-width="2" opacity=".6"/>
|
||||
<path d="M30,8 L50,35 L30,30 Z" fill="#4a7c8a" opacity=".5"/>
|
||||
<path d="M30,12 L15,32 L30,28 Z" fill="#e8833a" opacity=".4"/>
|
||||
<path d="M10,52 Q30,45 50,52 Q40,58 30,56 Q20,58 10,52 Z" fill="#1f4e5a" opacity=".4"/>
|
||||
<!-- Water ripples -->
|
||||
<ellipse cx="30" cy="58" rx="22" ry="3" fill="none" stroke="#4a7c8a" stroke-width=".5" opacity=".3">
|
||||
<animate attributeName="rx" values="22;26;22" dur="2s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".3;.1;.3" dur="2s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- ======================== SIMULATIONEN ======================== -->
|
||||
<section class="sec" id="sims" style="padding-top:3rem">
|
||||
<div class="w">
|
||||
<div class="reveal">
|
||||
<span class="label">Simulationen</span>
|
||||
<h2 class="sec-title">Lernziele, die man<br>erleben kann.</h2>
|
||||
<p class="sec-sub">Jedes Modul: lehrplankonform, evidenzbasiert, 20–30 Minuten.</p>
|
||||
<span class="label">Jetzt ausprobieren</span>
|
||||
<h2 class="sec-title">Drei Simulationen.<br>Sofort testen.</h2>
|
||||
<p class="sec-sub">Einfach starten und Geografie erleben.</p>
|
||||
</div>
|
||||
<div class="sim-grid reveal">
|
||||
|
||||
<a href="game.html" class="sim-card" style="text-decoration:none;color:inherit">
|
||||
<div class="sim-card-img"><img src="assets/img/card-climate.png" alt=""></div>
|
||||
<!-- FREE: Klimasimulator 3D -->
|
||||
<a href="game-3d.html" class="sim-card">
|
||||
<div class="sim-card-img"><img src="assets/img/card-climate.png" alt="Klimasimulator 3D"></div>
|
||||
<div class="sim-card-body">
|
||||
<h3>🌡️ Klimawächter (2D)</h3>
|
||||
<p>Inselstaat bis 2100 durch die Klimakrise führen. 75 Jahre, echte Klimaphysik.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">⏱ 20 min</span></div>
|
||||
<h3>Klimasimulator 3D <span class="badge-free">TESTEN</span></h3>
|
||||
<p>Führe einen Inselstaat durch 75 Jahre Klimakrise. Steigende Meeresspiegel, Extremwetter, politische Entscheidungen.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">20 min</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="game-3d.html" class="sim-card" style="text-decoration:none;color:inherit">
|
||||
<div class="sim-card-img"><img src="assets/img/card-climate.png" alt=""></div>
|
||||
<!-- FREE: Flussmanagement -->
|
||||
<a href="fluss.html" class="sim-card">
|
||||
<div class="sim-card-img"><img src="assets/img/card-fluss.png" alt="Flussmanagement"></div>
|
||||
<div class="sim-card-body">
|
||||
<h3>🌊 Klimawächter (3D) <span style="background:linear-gradient(135deg,#4a7c8a,#6aa8b8);color:#fff;font-size:.58rem;padding:2px 6px;border-radius:4px;font-weight:800;letter-spacing:.04em;vertical-align:middle">NEU</span></h3>
|
||||
<p>Dieselbe Insel — jetzt in echtem 3D. Steigender Meeresspiegel wird sichtbar.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">⏱ 20 min</span></div>
|
||||
<h3>Flussmanagement <span class="badge-free">TESTEN</span></h3>
|
||||
<p>Deiche bauen, Fluss begradigen oder renaturieren? Finde die Balance zwischen Hochwasserschutz, Ökologie und Wirtschaft.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1.–2. Klasse</span><span class="sim-tag st-time">10–35 min</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="erdbeben.html" class="sim-card" style="text-decoration:none;color:inherit">
|
||||
<div class="sim-card-img"><img src="assets/img/card-tectonic.png" alt=""></div>
|
||||
<!-- FREE: Stadt & Raumplanung -->
|
||||
<a href="stadt.html" class="sim-card">
|
||||
<div class="sim-card-img"><img src="assets/img/card-city.png" alt="Stadtsimulation"></div>
|
||||
<div class="sim-card-body">
|
||||
<h3>🌋 Stadtplaner*in in Erdbebenregion</h3>
|
||||
<p>50 Jahre Stadt in Erdbebenzone bauen. Naturgefahr ≠ Naturkatastrophe — Bauqualität entscheidet.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">⏱ 25 min</span></div>
|
||||
<h3>Stadt & Raumplanung <span class="badge-free">TESTEN</span></h3>
|
||||
<p>Plane eine Stadt mit Wohngebieten, Gewerbe, Infrastruktur. Nutzungskonflikte und Standortentscheidungen erleben.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st3">3. Klasse</span><span class="sim-tag st-time">25 min</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="energiemix.html" class="sim-card" style="text-decoration:none;color:inherit">
|
||||
<div class="sim-card-img"><img src="assets/img/card-energy.png" alt=""></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================== WEITERE MODULE ======================== -->
|
||||
<section class="sec" style="padding-top:1rem">
|
||||
<div class="w">
|
||||
<div class="reveal">
|
||||
<span class="label">Im Unterricht</span>
|
||||
<h2 class="sec-title">Alle Module —<br>mit Begleitung der Lehrperson.</h2>
|
||||
<p class="sec-sub">Diese Simulationen entfalten ihren vollen Wert im begleiteten Unterricht: mit Lernzielen, Assessment und Ergebnissicherung.</p>
|
||||
</div>
|
||||
<div class="sim-grid reveal">
|
||||
|
||||
<a href="login.html" class="sim-card locked">
|
||||
<div class="sim-card-img">
|
||||
<img src="assets/img/card-climate.png" alt="Klimawächter 2D">
|
||||
<div class="lock-overlay">Anmelden</div>
|
||||
</div>
|
||||
<div class="sim-card-body">
|
||||
<h3>⚡ Energiewende-Planer*in</h3>
|
||||
<p>Bis 2050 deine Region auf 80 % Erneuerbare bringen — ohne Blackout, ohne Pleite.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st2">2. Klasse</span><span class="sim-tag st-time">⏱ 25 min</span></div>
|
||||
<h3>Klimasimulator 2D</h3>
|
||||
<p>Dieselbe Klimasimulation in 2D-Ansicht. Ideal für ältere Geräte und barrierefreien Unterricht.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">20 min</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="lieferketten.html" class="sim-card" style="text-decoration:none;color:inherit">
|
||||
<div class="sim-card-img"><img src="assets/img/card-globe.png" alt=""></div>
|
||||
<a href="login.html" class="sim-card locked">
|
||||
<div class="sim-card-img"><img src="assets/img/card-tectonic.png" alt="Erdbeben"></div>
|
||||
<div class="sim-card-body">
|
||||
<h3>👕 Lieferketten-Planer*in <span style="background:linear-gradient(135deg,#4a7c8a,#6aa8b8);color:#fff;font-size:.58rem;padding:2px 6px;border-radius:4px;font-weight:800;letter-spacing:.04em;vertical-align:middle">NEU</span></h3>
|
||||
<p>Was kostet dein T-Shirt wirklich? Bestelle aus aller Welt — und finde die Balance aus Preis, CO₂ und fairen Bedingungen.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st2">2. Klasse</span><span class="sim-tag st-time">⏱ 15 min</span></div>
|
||||
<h3>Erdbeben & Plattentektonik</h3>
|
||||
<p>50 Jahre Stadtplanung in einer Erdbebenzone. Bauqualität und Vorsorge entscheiden über Überleben.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">25 min</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="regenwald.html" class="sim-card" style="text-decoration:none;color:inherit">
|
||||
<div class="sim-card-img"><img src="assets/img/card-climate.png" alt=""></div>
|
||||
<a href="login.html" class="sim-card locked">
|
||||
<div class="sim-card-img"><img src="assets/img/card-energy.png" alt="Energiemix"></div>
|
||||
<div class="sim-card-body">
|
||||
<h3>🌴 Forscher*in im Regenwald <span style="background:linear-gradient(135deg,#5a8a5e,#7ab88a);color:#fff;font-size:.58rem;padding:2px 6px;border-radius:4px;font-weight:800;letter-spacing:.04em;vertical-align:middle">NEU</span></h3>
|
||||
<p>Fahre mit dem Boot den Fluss hoch. Entdecke 15 Tiere, Pflanzen und Menschen — vom Delta bis zum Nebelwald.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">⏱ 10 min</span></div>
|
||||
<h3>Energiewende-Planer</h3>
|
||||
<p>Deine Region bis 2050 auf 80 % Erneuerbare bringen — ohne Blackout, ohne Pleite.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st2">2. Klasse</span><span class="sim-tag st-time">25 min</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="fluss.html" class="sim-card" style="text-decoration:none;color:inherit">
|
||||
<div class="sim-card-img" style="background:linear-gradient(135deg,#87CEEB,#5a8a5e);display:flex;align-items:center;justify-content:center;"><span style="font-size:4rem">🏞️</span></div>
|
||||
<a href="login.html" class="sim-card locked">
|
||||
<div class="sim-card-img"><img src="assets/img/card-globe.png" alt="Lieferketten"></div>
|
||||
<div class="sim-card-body">
|
||||
<h3>🏞️ Flussmanagement <span style="background:linear-gradient(135deg,#3a7ca5,#5ab8d8);color:#fff;font-size:.58rem;padding:2px 6px;border-radius:4px;font-weight:800;letter-spacing:.04em;vertical-align:middle">NEU</span></h3>
|
||||
<p>Steuere ein Flusssystem: Deiche, Renaturierung, Bewässerung. Finde die Balance zwischen Schutz, Ökologie und Wirtschaft.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1.–2. Klasse</span><span class="sim-tag st-time">⏱ 10–35 min</span><span class="sim-tag" style="background:var(--fjord-l);color:var(--fjord)">5 Levels</span></div>
|
||||
<h3>Lieferketten-Planer</h3>
|
||||
<p>Was kostet dein T-Shirt wirklich? Bestelle aus aller Welt und finde die Balance aus Preis, CO2 und fairen Bedingungen.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st2">2. Klasse</span><span class="sim-tag st-time">15 min</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="sim-card">
|
||||
<div class="sim-card-img"><img src="assets/img/card-city.png" alt=""></div>
|
||||
<a href="login.html" class="sim-card locked">
|
||||
<div class="sim-card-img"><img src="assets/img/card-climate.png" alt="Regenwald"></div>
|
||||
<div class="sim-card-body">
|
||||
<h3>🏗️ Raumplanung — Gemeinde</h3>
|
||||
<p>Wohngebiete, Gewerbe und Parks platzieren. Nutzungskonflikte entstehen von selbst.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st3">3. Klasse</span><span class="sim-tag st-time">⏱ 45 min</span></div>
|
||||
<h3>Regenwald-Expedition</h3>
|
||||
<p>Fahre mit dem Boot den Fluss hoch. Entdecke Tiere, Pflanzen und Menschen vom Delta bis zum Nebelwald.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">10 min</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="sim-card">
|
||||
<div class="sim-card-img"><img src="assets/img/card-population.png" alt=""></div>
|
||||
<a href="login.html" class="sim-card locked">
|
||||
<div class="sim-card-img"><img src="assets/img/card-heli.png" alt="Helikopter-Navigation"><div class="lock-overlay">Anmelden</div></div>
|
||||
<div class="sim-card-body">
|
||||
<h3>📊 Bevölkerungsdynamik</h3>
|
||||
<p>Geburtenrate, Sterberate, Migration — Bevölkerungspyramiden über 100 Jahre.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st4">4. Klasse</span><span class="sim-tag st-time">⏱ 25 min</span></div>
|
||||
<h3>🚁 Helikopter-Navigation <span class="badge-new">NEU</span></h3>
|
||||
<p>Steuere einen Rettungshubschrauber durch Österreich. Starten, navigieren, landen. Echte Geografie lernen.</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st1">1.–2. Klasse</span><span class="sim-tag st-time">15–30 min</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="sim-card">
|
||||
<div class="sim-card-img"><img src="assets/img/card-globe.png" alt=""></div>
|
||||
<div class="sim-card locked" style="cursor:default;opacity:.5;border-style:dashed">
|
||||
<div class="sim-card-img" style="background:var(--bg2);display:flex;align-items:center;justify-content:center"><span style="font-size:2rem;color:var(--text3)">+ 18 weitere</span></div>
|
||||
<div class="sim-card-body">
|
||||
<h3>🌍 Planetary Boundaries</h3>
|
||||
<p>Neun planetare Grenzen in Echtzeit. Überschreitest du die Kipppunkte?</p>
|
||||
<div class="sim-card-tags"><span class="sim-tag st4">4. Klasse</span><span class="sim-tag st-time">⏱ 30 min</span></div>
|
||||
<h3 style="color:var(--text3)">Weitere Module in Entwicklung</h3>
|
||||
<p>Bevölkerungsdynamik, Planetary Boundaries, Klimakonferenz, Urbanisierung und mehr.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<p style="text-align:center;margin-top:1.5rem;color:var(--text3);font-size:.82rem" class="reveal">+ 18 weitere Module für alle Kompetenzbereiche</p>
|
||||
<p style="text-align:center;margin-top:1.5rem;font-size:.85rem;color:var(--text2)" class="reveal">
|
||||
Alle Module sind im begleiteten Unterricht verfügbar. <a href="login.html" style="color:var(--fjord);font-weight:600;text-decoration:none">Jetzt anmelden</a> oder <a href="handbuch.html" style="color:var(--fjord);font-weight:600;text-decoration:none">Handbuch lesen</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="sec feat-sec" id="features">
|
||||
<!-- SVG Divider: City Skyline + Water -->
|
||||
<div class="landscape-divider" style="height:100px;background:var(--bg)">
|
||||
<svg viewBox="0 0 1440 100" preserveAspectRatio="none">
|
||||
<!-- City skyline -->
|
||||
<g fill="#1f4e5a" opacity=".08">
|
||||
<rect x="80" y="40" width="25" height="60"/>
|
||||
<rect x="110" y="55" width="20" height="45"/>
|
||||
<rect x="135" y="30" width="15" height="70" rx="1"/>
|
||||
<rect x="155" y="50" width="22" height="50"/>
|
||||
<rect x="400" y="45" width="18" height="55"/>
|
||||
<rect x="422" y="35" width="12" height="65" rx="1"/>
|
||||
<rect x="438" y="55" width="25" height="45"/>
|
||||
<rect x="468" y="42" width="14" height="58"/>
|
||||
<rect x="800" y="38" width="20" height="62"/>
|
||||
<rect x="825" y="50" width="16" height="50"/>
|
||||
<rect x="845" y="28" width="12" height="72" rx="1"/>
|
||||
<rect x="862" y="48" width="22" height="52"/>
|
||||
<rect x="1100" y="52" width="18" height="48"/>
|
||||
<rect x="1122" y="40" width="14" height="60"/>
|
||||
<rect x="1140" y="55" width="24" height="45"/>
|
||||
<!-- Windows (tiny dots) -->
|
||||
<rect x="84" y="48" width="3" height="3" fill="#e8833a" opacity=".5"/>
|
||||
<rect x="92" y="48" width="3" height="3" fill="#e8833a" opacity=".3"/>
|
||||
<rect x="84" y="56" width="3" height="3" fill="#e8833a" opacity=".4"/>
|
||||
<rect x="92" y="56" width="3" height="3" fill="#e8833a" opacity=".6"/>
|
||||
<rect x="84" y="64" width="3" height="3" fill="#e8833a" opacity=".3"/>
|
||||
<rect x="406" y="52" width="2" height="2" fill="#e8833a" opacity=".5"/>
|
||||
<rect x="412" y="52" width="2" height="2" fill="#e8833a" opacity=".3"/>
|
||||
<rect x="406" y="58" width="2" height="2" fill="#e8833a" opacity=".4"/>
|
||||
<rect x="806" y="46" width="3" height="3" fill="#e8833a" opacity=".5"/>
|
||||
<rect x="813" y="46" width="3" height="3" fill="#e8833a" opacity=".3"/>
|
||||
<rect x="806" y="54" width="3" height="3" fill="#e8833a" opacity=".4"/>
|
||||
</g>
|
||||
<!-- Water waves -->
|
||||
<path d="M0,88 Q60,82 120,88 Q180,94 240,88 Q300,82 360,88 Q420,94 480,88 Q540,82 600,88 Q660,94 720,88 Q780,82 840,88 Q900,94 960,88 Q1020,82 1080,88 Q1140,94 1200,88 Q1260,82 1320,88 Q1380,94 1440,88 L1440,100 L0,100 Z" fill="#4a7c8a" opacity=".06">
|
||||
<animate attributeName="d" dur="4s" repeatCount="indefinite"
|
||||
values="M0,88 Q60,82 120,88 Q180,94 240,88 Q300,82 360,88 Q420,94 480,88 Q540,82 600,88 Q660,94 720,88 Q780,82 840,88 Q900,94 960,88 Q1020,82 1080,88 Q1140,94 1200,88 Q1260,82 1320,88 Q1380,94 1440,88 L1440,100 L0,100 Z;
|
||||
M0,90 Q60,84 120,90 Q180,96 240,90 Q300,84 360,90 Q420,96 480,90 Q540,84 600,90 Q660,96 720,90 Q780,84 840,90 Q900,96 960,90 Q1020,84 1080,90 Q1140,96 1200,90 Q1260,84 1320,90 Q1380,96 1440,90 L1440,100 L0,100 Z;
|
||||
M0,88 Q60,82 120,88 Q180,94 240,88 Q300,82 360,88 Q420,94 480,88 Q540,82 600,88 Q660,94 720,88 Q780,82 840,88 Q900,94 960,88 Q1020,82 1080,88 Q1140,94 1200,88 Q1260,82 1320,88 Q1380,94 1440,88 L1440,100 L0,100 Z"/>
|
||||
</path>
|
||||
<!-- Small sailboats -->
|
||||
<g opacity=".3">
|
||||
<g transform="translate(250,72)">
|
||||
<path d="M0,0 L0,-15" stroke="#1f4e5a" stroke-width="1"/>
|
||||
<path d="M0,-13 L10,-4 L0,-6 Z" fill="#4a7c8a"/>
|
||||
<path d="M0,-10 L-6,-3 L0,-5 Z" fill="#e8833a"/>
|
||||
<ellipse cx="0" cy="2" rx="8" ry="2" fill="#1f4e5a"/>
|
||||
<animateTransform attributeName="transform" type="translate" values="250,72;260,71;250,72" dur="5s" repeatCount="indefinite"/>
|
||||
</g>
|
||||
<g transform="translate(1050,75)">
|
||||
<path d="M0,0 L0,-12" stroke="#1f4e5a" stroke-width="1"/>
|
||||
<path d="M0,-10 L8,-3 L0,-5 Z" fill="#4a7c8a"/>
|
||||
<path d="M0,-8 L-5,-2 L0,-4 Z" fill="#e8833a"/>
|
||||
<ellipse cx="0" cy="2" rx="6" ry="1.5" fill="#1f4e5a"/>
|
||||
<animateTransform attributeName="transform" type="translate" values="1050,75;1058,74;1050,75" dur="7s" repeatCount="indefinite"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- ======================== WERT-VERSPRECHEN (Lehrkraefte) ======================== -->
|
||||
<section class="sec value-sec" id="fuer-lehrkraefte">
|
||||
<div class="w">
|
||||
<div class="reveal">
|
||||
<span class="label">Für Lehrpersonen</span>
|
||||
<h2 class="sec-title">Simulationen wirken im Unterricht.<br>Nicht als Einzelerlebnis.</h2>
|
||||
</div>
|
||||
<div class="value-grid reveal">
|
||||
<div class="value-text">
|
||||
<p>
|
||||
Jede Simulation ist für eine 45-Minuten-Einheit konzipiert: mit Hinleitung, Vorhersage, Durchführung, Auswertung und Reflexion. Das didaktische Modell basiert auf <strong>Predict–Observe–Explain</strong> — Schüler*innen formulieren zuerst eine Hypothese, beobachten dann die Simulation und erklären abschließend die Abweichung.
|
||||
</p>
|
||||
<p>
|
||||
Der eigentliche Wert liegt nicht in der Software allein, sondern in der <strong>Einbettung im Unterricht</strong>: Lehrpersonen steuern, welche Module wann freigeschaltet sind, begleiten den Lernprozess und sichern Ergebnisse. Genau wie bei <em>Typewriter</em> — einem Werkzeug, das erst im schulischen Kontext seine volle Wirkung entfaltet.
|
||||
</p>
|
||||
<ul class="value-list">
|
||||
<li><span class="vi">📊</span> <span><strong>Dashboard:</strong> Echtzeit-Überblick über Fortschritt, Ergebnisse und Fehlkonzepte aller Schüler*innen</span></li>
|
||||
<li><span class="vi">🔒</span> <span><strong>Modulsteuerung:</strong> Module einzeln freischalten, sperren oder auf Knopfdruck starten</span></li>
|
||||
<li><span class="vi">👩🎓</span> <span><strong>Anonyme Zugänge:</strong> Automatisch generierte Benutzernamen — DSGVO-konform, kein Aufwand mit Schüler*innendaten</span></li>
|
||||
<li><span class="vi">🎫</span> <span><strong>Einladungstickets:</strong> PDF mit Zugangsdaten zum Ausdrucken und Verteilen</span></li>
|
||||
<li><span class="vi">📖</span> <span><strong>Didaktisches Handbuch:</strong> Zu jedem Modul ein fertiger Unterrichtsentwurf für 45 Minuten</span></li>
|
||||
<li><span class="vi">🎯</span> <span><strong>Lehrplan 2023:</strong> Alle Module auf den österreichischen GW-Lehrplan gemapped</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="feat"><div class="feat-ico">1.</div><h3>Registrieren</h3><p>E-Mail-Adresse als Benutzername. Kostenlos starten.</p></div>
|
||||
<div class="feat"><div class="feat-ico">2.</div><h3>Klasse anlegen</h3><p>Schüler*innen-Zugänge automatisch generieren oder Daten einzeln eingeben.</p></div>
|
||||
<div class="feat"><div class="feat-ico">3.</div><h3>Modul freischalten</h3><p>Bestimmen Sie, welche Simulation wann gespielt wird.</p></div>
|
||||
<div class="feat"><div class="feat-ico">4.</div><h3>Ergebnisse sichern</h3><p>Alle Entscheidungen, Vorhersagen und Reflexionen werden gespeichert.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:2.5rem" class="reveal">
|
||||
<a href="login.html" class="btn btn-p" style="padding:.7rem 2rem;font-size:.9rem">Jetzt als Lehrperson registrieren</a>
|
||||
<a href="handbuch.html" class="btn btn-o" style="margin-left:.6rem">Handbuch lesen</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================== FEATURES ======================== -->
|
||||
<section class="sec" id="features">
|
||||
<div class="w">
|
||||
<div class="reveal">
|
||||
<span class="label">Features</span>
|
||||
<h2 class="sec-title">Für Schüler*innen<br>und Lehrkräfte.</h2>
|
||||
<h2 class="sec-title">Für Schüler*innen und Lehrpersonen.</h2>
|
||||
</div>
|
||||
<div class="feat-grid reveal">
|
||||
<div class="feat"><div class="feat-ico">📊</div><h3>Dashboard</h3><p>Echtzeit-Überblick, Ergebnisvergleich, Fehlkonzept-Erkennung, PDF-Export.</p></div>
|
||||
<div class="feat"><div class="feat-ico">♿</div><h3>Inklusion</h3><p>Nachteilsausgleich, vereinfachte Sprache, Vorlesefunktion, flexible Zeiten.</p></div>
|
||||
<div class="feat"><div class="feat-ico">🗳️</div><h3>Live-Abstimmung</h3><p>Polls starten — Ergebnis fließt in die Simulation ein.</p></div>
|
||||
<div class="feat"><div class="feat-ico">🎯</div><h3>Lehrplan 2023</h3><p>Kompetenzziele und Basiskonzepte referenziert. DACH-kompatibel.</p></div>
|
||||
<div class="feat"><div class="feat-ico">⚙️</div><h3>Steuerbar</h3><p>Module freischalten, Schwierigkeit und Zeitlimits pro Schüler*in anpassen.</p></div>
|
||||
<div class="feat"><div class="feat-ico">📈</div><h3>Assessment</h3><p>Predict-Observe-Explain eingebaut. Prozess- und Ergebnisdaten.</p></div>
|
||||
<div class="feat"><div class="feat-ico">📊</div><h3>Dashboard</h3><p>Echtzeit-Überblick, Ergebnisvergleich, Fehlkonzept-Erkennung.</p></div>
|
||||
<div class="feat"><div class="feat-ico">♿</div><h3>Inklusion</h3><p>Vereinfachte Sprache, flexible Zeiten, barrierefreie Bedienung.</p></div>
|
||||
<div class="feat"><div class="feat-ico">🎯</div><h3>Lehrplan 2023</h3><p>Alle Kompetenzziele und Basiskonzepte referenziert. DACH-kompatibel.</p></div>
|
||||
<div class="feat"><div class="feat-ico">⚙️</div><h3>Steuerbar</h3><p>Module freischalten, Schwierigkeit und Zeitlimits anpassen.</p></div>
|
||||
<div class="feat"><div class="feat-ico">📈</div><h3>Assessment</h3><p>Predict–Observe–Explain eingebaut. Prozess- und Ergebnisdaten.</p></div>
|
||||
<div class="feat"><div class="feat-ico">🔐</div><h3>DSGVO-konform</h3><p>Anonyme Pseudonyme, keine personenbezogenen Daten nötig. Hosting in Europa.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- KLASSEN -->
|
||||
<section class="sec" id="klassen">
|
||||
<div class="w">
|
||||
<div class="reveal">
|
||||
<span class="label">Vier Jahre</span>
|
||||
<h2 class="sec-title">Ein Lehrplan.<br>24 Simulationen.</h2>
|
||||
<p class="sec-sub">Spiralcurricular — jedes Jahr hat eigene Themen und Module.</p>
|
||||
</div>
|
||||
<div class="kl-grid reveal">
|
||||
<div class="kl k1"><h3>1. Klasse</h3><div class="ksub">Leben und Wirtschaften</div><div class="kdet">8 Simulationen · 2 WoStd.<br>Klima · Naturgefahren · Geomedien</div></div>
|
||||
<div class="kl k2"><h3>2. Klasse</h3><div class="ksub">Nachhaltigkeit</div><div class="kdet">4 Simulationen · 1 WoStd.<br>Energie · Wasser · Lieferketten</div></div>
|
||||
<div class="kl k3"><h3>3. Klasse</h3><div class="ksub">Österreich</div><div class="kdet">6 Simulationen · 2 WoStd.<br>Standort · Raumplanung · Wirtschaft</div></div>
|
||||
<div class="kl k4"><h3>4. Klasse</h3><div class="ksub">Globalisierte Welt</div><div class="kdet">6 Simulationen · 2 WoStd.<br>Boundaries · Klima · EU</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- SVG Divider: Mountain + Forest panorama -->
|
||||
<div class="landscape-divider" style="height:90px;background:var(--bg)">
|
||||
<svg viewBox="0 0 1440 90" preserveAspectRatio="none">
|
||||
<!-- Far mountains -->
|
||||
<path d="M0,90 L0,60 Q100,35 200,55 Q280,25 380,50 Q460,18 560,45 Q640,12 720,40 Q800,20 900,48 Q980,15 1060,42 Q1140,22 1240,50 Q1320,30 1440,55 L1440,90 Z" fill="#1f4e5a" opacity=".06"/>
|
||||
<!-- Near hills -->
|
||||
<path d="M0,90 L0,70 Q120,55 240,68 Q360,50 480,65 Q600,48 720,62 Q840,52 960,66 Q1080,50 1200,64 Q1320,55 1440,68 L1440,90 Z" fill="#5a8a5e" opacity=".08"/>
|
||||
<!-- Trees on hills -->
|
||||
<g fill="#5a8a5e" opacity=".1">
|
||||
<path d="M200,68 L206,52 L194,55 L200,42 L206,55 L194,52 Z"/>
|
||||
<path d="M215,70 L220,58 L210,60 L215,50 L220,60 L210,58 Z"/>
|
||||
<path d="M550,65 L556,50 L544,53 L550,40 L556,53 L544,50 Z"/>
|
||||
<path d="M565,67 L570,56 L560,58 L565,48 L570,58 L560,56 Z"/>
|
||||
<path d="M900,66 L906,51 L894,54 L900,41 L906,54 L894,51 Z"/>
|
||||
<path d="M920,68 L925,57 L915,59 L920,49 L925,59 L915,57 Z"/>
|
||||
<path d="M1250,64 L1256,50 L1244,53 L1250,40 L1256,53 L1244,50 Z"/>
|
||||
</g>
|
||||
<!-- Subtle cloud -->
|
||||
<ellipse cx="350" cy="25" rx="50" ry="10" fill="#fff" opacity=".15">
|
||||
<animate attributeName="cx" values="350;380;350" dur="12s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
<ellipse cx="1000" cy="20" rx="40" ry="8" fill="#fff" opacity=".1">
|
||||
<animate attributeName="cx" values="1000;1030;1000" dur="15s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="cta reveal">
|
||||
<h2>Bereit, Geografie<br>zu <em>erleben</em>?</h2>
|
||||
<p>Evidenzbasiert · Lehrplan-kompatibel · Inklusiv</p>
|
||||
<div style="display:flex;gap:.6rem;justify-content:center">
|
||||
<a href="#" class="btn btn-p" style="padding:.7rem 2rem;font-size:.9rem">🚀 Jetzt starten</a>
|
||||
<a href="../Konzept/lehrplan-simulation-mapping.html" class="btn btn-o">Lehrplan-Mapping</a>
|
||||
<!-- ======================== CTA ======================== -->
|
||||
<section class="cta reveal" style="position:relative;overflow:hidden">
|
||||
<!-- Decorative mini landscape behind CTA -->
|
||||
<svg style="position:absolute;bottom:0;left:0;right:0;height:60px;opacity:.05" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path d="M0,60 L0,40 Q200,20 400,35 Q600,10 800,30 Q1000,15 1200,32 Q1350,20 1440,35 L1440,60 Z" fill="#4a7c8a"/>
|
||||
<path d="M0,60 L0,48 Q300,35 600,45 Q900,30 1200,42 L1440,38 L1440,60 Z" fill="#5a8a5e"/>
|
||||
</svg>
|
||||
<h2 style="position:relative">Bereit, Geografie<br>zu <em>erleben</em>?</h2>
|
||||
<p style="position:relative">Drei Module sofort testen. Alle Module mit Lehrpersonen-Konto.</p>
|
||||
<div style="display:flex;gap:.6rem;justify-content:center;flex-wrap:wrap;position:relative">
|
||||
<a href="#sims" class="btn btn-p" style="padding:.7rem 2rem;font-size:.9rem">Module ausprobieren</a>
|
||||
<a href="login.html" class="btn btn-o">Anmelden / Registrieren</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>GeoGraSim — Schroffenegger & Mößlang, 2026 · 330 Quellen · <a href="../claude-research-sim/standalone.html">Forschung</a> · <a href="../claude-research/standalone.html">Didaktik</a></p>
|
||||
<div style="margin-bottom:.5rem"><img src="assets/img/bildLogo.png" alt="GeoGraSim" style="height:40px;opacity:.5"></div>
|
||||
<p>GeoGraSim — Schroffenegger & Mößlang, 2026 · <a href="ueber.html">Über</a> · <a href="handbuch.html">Handbuch</a> · <a href="impressum.html">Impressum</a> · <a href="datenschutz.html">Datenschutz</a> · <a href="barrierefreiheit.html">Barrierefreiheit</a></p>
|
||||
<p style="font-size:.62rem;margin-top:.3rem"><a href="../didaktik_geografie/standalone.html">Aktueller Forschungsstand GW-Didaktik</a> · <a href="../didaktik_simulation/standalone.html">Simulationen im Unterricht</a> · <a href="../didaktik_geografie/quellen.html">Literaturübersicht</a></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var BASE = (window.__GGS__?.baseUrl) || '';
|
||||
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
|
||||
|
||||
// === AUTH STATE ===
|
||||
(async function() {
|
||||
try {
|
||||
var r = await fetch(BASE + '/api/auth', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'}, credentials: 'same-origin',
|
||||
body: JSON.stringify({action: 'status'})
|
||||
});
|
||||
var d = await r.json();
|
||||
var el = document.getElementById('nav-auth');
|
||||
if (d.role === 'teacher') {
|
||||
el.innerHTML = '<a href="teacher.html" class="user-pill"><span class="emoji">👩🏫</span>' + (d.user?.display_name || 'Lehrperson') + '</a>';
|
||||
// Unlock all sim cards for logged-in teachers
|
||||
document.querySelectorAll('.sim-card.locked').forEach(function(c) {
|
||||
var href = c.getAttribute('href');
|
||||
if (href === 'login.html') {
|
||||
// Map to actual sim pages
|
||||
var h3 = c.querySelector('h3');
|
||||
if (!h3) return;
|
||||
var t = h3.textContent;
|
||||
if (t.match(/2D/)) c.href = 'game.html';
|
||||
else if (t.match(/Erdbeben/)) c.href = 'erdbeben.html';
|
||||
else if (t.match(/Energie/)) c.href = 'energiemix.html';
|
||||
else if (t.match(/Liefer/)) c.href = 'lieferketten.html';
|
||||
else if (t.match(/Regenwald/)) c.href = 'regenwald.html';
|
||||
}
|
||||
c.classList.remove('locked');
|
||||
var ov = c.querySelector('.lock-overlay');
|
||||
if (ov) ov.remove();
|
||||
});
|
||||
} else if (d.role === 'student') {
|
||||
el.innerHTML = '<a href="profil.html" class="user-pill"><span class="emoji">🧑🎓</span>' + (d.session?.display_name || 'Schueler') + '</a>';
|
||||
// Check which modules are unlocked for student's class
|
||||
// For now, unlock all for logged-in students
|
||||
document.querySelectorAll('.sim-card.locked').forEach(function(c) {
|
||||
var href = c.getAttribute('href');
|
||||
if (href === 'login.html') {
|
||||
var h3 = c.querySelector('h3');
|
||||
if (!h3) return;
|
||||
var t = h3.textContent;
|
||||
if (t.match(/2D/)) c.href = 'game.html';
|
||||
else if (t.match(/Erdbeben/)) c.href = 'erdbeben.html';
|
||||
else if (t.match(/Energie/)) c.href = 'energiemix.html';
|
||||
else if (t.match(/Liefer/)) c.href = 'lieferketten.html';
|
||||
else if (t.match(/Regenwald/)) c.href = 'regenwald.html';
|
||||
}
|
||||
c.classList.remove('locked');
|
||||
var ov = c.querySelector('.lock-overlay');
|
||||
if (ov) ov.remove();
|
||||
});
|
||||
} else {
|
||||
el.innerHTML = '<a href="login.html" class="btn btn-p" style="margin-left:.3rem">Anmelden</a>';
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('nav-auth').innerHTML = '<a href="login.html" class="btn btn-p" style="margin-left:.3rem">Anmelden</a>';
|
||||
}
|
||||
})();
|
||||
|
||||
// === NAV SCROLL ===
|
||||
const nav = document.getElementById('nav');
|
||||
window.addEventListener('scroll', () => nav.classList.toggle('scrolled', scrollY > 50));
|
||||
var nav = document.getElementById('nav');
|
||||
window.addEventListener('scroll', function() { nav.classList.toggle('scrolled', scrollY > 50); });
|
||||
|
||||
// === SCROLL REVEAL ===
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); obs.unobserve(e.target); } });
|
||||
var obs = new IntersectionObserver(function(entries) {
|
||||
entries.forEach(function(e) { if (e.isIntersecting) { e.target.classList.add('visible'); obs.unobserve(e.target); } });
|
||||
}, { threshold: 0.15 });
|
||||
document.querySelectorAll('.reveal').forEach(el => obs.observe(el));
|
||||
|
||||
// === FLOATING EMOJIS ===
|
||||
const emojis = ['🌍','🌎','🌏','🏔️','🌋','🌊','🌿','☀️','⛈️','🔥','💧','🌱','🏙️','🗺️','📊','🧭','⚡','♻️','🌡️','🤝','🏗️','📈','💨','🐟','⛵','🦅'];
|
||||
const emojiLayer = document.getElementById('emojiLayer');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'emoji-float';
|
||||
el.textContent = emojis[Math.floor(Math.random() * emojis.length)];
|
||||
el.style.left = Math.random() * 100 + '%';
|
||||
el.style.fontSize = (1 + Math.random() * 2) + 'rem';
|
||||
el.style.animationDuration = (18 + Math.random() * 30) + 's';
|
||||
el.style.animationDelay = (-Math.random() * 35) + 's';
|
||||
emojiLayer.appendChild(el);
|
||||
}
|
||||
document.querySelectorAll('.reveal').forEach(function(el) { obs.observe(el); });
|
||||
|
||||
// === SCENIC CANVAS ===
|
||||
const C = document.getElementById('sc'), X = C.getContext('2d');
|
||||
let W, H, t = 0;
|
||||
var C = document.getElementById('sc'), X = C.getContext('2d');
|
||||
var W, H, t = 0;
|
||||
function resize() {
|
||||
const dpr = devicePixelRatio || 1;
|
||||
var dpr = devicePixelRatio || 1;
|
||||
W = C.parentElement.offsetWidth; H = C.parentElement.offsetHeight;
|
||||
C.width = W * dpr; C.height = H * dpr; X.scale(dpr, dpr);
|
||||
}
|
||||
resize(); addEventListener('resize', resize);
|
||||
|
||||
// Clouds
|
||||
const cls = Array.from({length:7}, () => ({
|
||||
x: Math.random()*W*1.5, y: H*(.04+Math.random()*.18),
|
||||
w: 40+Math.random()*100, sp: .06+Math.random()*.14, op: .2+Math.random()*.4
|
||||
}));
|
||||
// Birds
|
||||
const bds = Array.from({length:5}, () => ({
|
||||
x: Math.random()*W, y: H*(.06+Math.random()*.24),
|
||||
sp: .25+Math.random()*.35, sz: 3+Math.random()*5,
|
||||
wp: Math.random()*6.28, ws: 2.5+Math.random()*2, op: .15+Math.random()*.2
|
||||
}));
|
||||
// Boats
|
||||
const boats = [
|
||||
{ x: W*.15, sp: .22, ph: 0, sc: .9, op: .65 },
|
||||
{ x: W*.6, sp: .12, ph: 2, sc: .45, op: .3 },
|
||||
{ x: W*.85, sp: .08, ph: 4, sc: .3, op: .2 }
|
||||
];
|
||||
// Fish jumping
|
||||
let fishTimer = 0, fishX = 0, fishY = 0, fishPhase = -1;
|
||||
var cls = [];
|
||||
for (var i = 0; i < 7; i++) cls.push({ x: Math.random()*1500, y: H*(.04+Math.random()*.18), w: 40+Math.random()*100, sp: .06+Math.random()*.14, op: .2+Math.random()*.4 });
|
||||
|
||||
function frame() {
|
||||
t += .016; X.clearRect(0,0,W,H);
|
||||
const wl = H * .56;
|
||||
var wl = H * .56;
|
||||
|
||||
// Sky
|
||||
const sk = X.createLinearGradient(0,0,0,wl);
|
||||
var sk = X.createLinearGradient(0,0,0,wl);
|
||||
sk.addColorStop(0,'#e8ebe4'); sk.addColorStop(.5,'#dde3d8'); sk.addColorStop(1,'#ced8ca');
|
||||
X.fillStyle = sk; X.fillRect(0,0,W,wl);
|
||||
|
||||
// Sun
|
||||
const sg = X.createRadialGradient(W*.78,H*.1,0,W*.78,H*.1,H*.3);
|
||||
// Sun glow
|
||||
var sg = X.createRadialGradient(W*.78,H*.1,0,W*.78,H*.1,H*.3);
|
||||
sg.addColorStop(0,'rgba(255,235,200,.3)'); sg.addColorStop(1,'rgba(255,235,200,0)');
|
||||
X.fillStyle = sg; X.fillRect(0,0,W,wl);
|
||||
|
||||
// Mountains (3 layers)
|
||||
[{c:'#c0caba',p:6,r:.05,h:.3},{c:'#a8b5a0',p:9,r:.08,h:.4},{c:'#8a9a82',p:12,r:.11,h:.5}].forEach(m => {
|
||||
// Mountains
|
||||
[{c:'#c0caba',p:6,r:.05,h:.3},{c:'#a8b5a0',p:9,r:.08,h:.4},{c:'#8a9a82',p:12,r:.11,h:.5}].forEach(function(m) {
|
||||
X.beginPath(); X.moveTo(0,wl);
|
||||
const sw = W/m.p;
|
||||
for(let i=0;i<=m.p;i++){
|
||||
const px=i*sw, py=wl-wl*m.h*(.4+Math.sin(i*1.7+.3)*.3+Math.cos(i*.9)*m.r);
|
||||
i===0?X.lineTo(px,py):X.quadraticCurveTo(px-sw*.4,py-wl*m.r*.2,px,py);
|
||||
var sw = W/m.p;
|
||||
for(var j=0;j<=m.p;j++){
|
||||
var px=j*sw, py=wl-wl*m.h*(.4+Math.sin(j*1.7+.3)*.3+Math.cos(j*.9)*m.r);
|
||||
j===0?X.lineTo(px,py):X.quadraticCurveTo(px-sw*.4,py-wl*m.r*.2,px,py);
|
||||
}
|
||||
X.lineTo(W,wl); X.closePath(); X.fillStyle=m.c; X.fill();
|
||||
|
||||
// Snow caps on nearest layer
|
||||
if(m.h===.5){
|
||||
X.save(); X.globalAlpha=.35;
|
||||
for(let i=1;i<m.p;i++){
|
||||
const px=i*sw, peak=wl-wl*m.h*(.4+Math.sin(i*1.7+.3)*.3+Math.cos(i*.9)*m.r);
|
||||
if(peak<wl*.35){ X.beginPath(); X.ellipse(px,peak+5,12,8,0,0,Math.PI*2); X.fillStyle='#e8e8e0'; X.fill(); }
|
||||
}
|
||||
X.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Clouds
|
||||
cls.forEach(c => {
|
||||
c.x += c.sp; if(c.x>W+c.w*2){c.x=-c.w*2; c.y=H*(.04+Math.random()*.18);}
|
||||
X.globalAlpha=c.op; X.fillStyle='#fff';
|
||||
const s=c.w/80;
|
||||
X.beginPath(); X.ellipse(c.x,c.y,32*s,13*s,0,0,Math.PI*2); X.fill();
|
||||
X.beginPath(); X.ellipse(c.x-18*s,c.y+2*s,22*s,10*s,0,0,Math.PI*2); X.fill();
|
||||
X.beginPath(); X.ellipse(c.x+20*s,c.y+3*s,24*s,9*s,0,0,Math.PI*2); X.fill();
|
||||
X.globalAlpha=1;
|
||||
});
|
||||
|
||||
// Birds
|
||||
bds.forEach(b => {
|
||||
b.x+=b.sp; b.wp+=b.ws*.016; b.y+=Math.sin(t*.4+b.wp)*.12;
|
||||
if(b.x>W+60){b.x=-60; b.y=H*(.06+Math.random()*.24);}
|
||||
X.globalAlpha=b.op; X.strokeStyle='#5a5a5a'; X.lineWidth=1.2; X.lineCap='round';
|
||||
const w=Math.sin(b.wp)*.45;
|
||||
X.beginPath(); X.moveTo(b.x-b.sz,b.y-w*b.sz);
|
||||
X.quadraticCurveTo(b.x,b.y+1.5,b.x+b.sz,b.y-w*b.sz); X.stroke();
|
||||
X.globalAlpha=1;
|
||||
});
|
||||
|
||||
// Water
|
||||
const wg = X.createLinearGradient(0,wl,0,H);
|
||||
wg.addColorStop(0,'#9ab5b8'); wg.addColorStop(.4,'#88a8ab'); wg.addColorStop(1,'#7a9ea2');
|
||||
X.fillStyle=wg; X.fillRect(0,wl,W,H-wl);
|
||||
var wg = X.createLinearGradient(0,wl,0,H);
|
||||
wg.addColorStop(0,'#9ab5a8'); wg.addColorStop(.4,'#7a9a8a'); wg.addColorStop(1,'#5a7a6a');
|
||||
X.fillStyle = wg; X.fillRect(0,wl,W,H-wl);
|
||||
|
||||
// Waves
|
||||
for(let i=0;i<5;i++){
|
||||
X.beginPath(); X.moveTo(0,wl);
|
||||
for(let x=0;x<=W;x+=3) X.lineTo(x, wl+Math.sin(x*.005+t*(.25+i*.1)+i*2)*(1.2+i*1.1)+i*.3);
|
||||
X.lineTo(W,H); X.lineTo(0,H); X.closePath();
|
||||
X.fillStyle=`rgba(255,255,255,${.025+i*.008})`; X.fill();
|
||||
// Water shimmer
|
||||
X.globalAlpha = .08;
|
||||
for(var j=0;j<15;j++){
|
||||
var wy = wl+10+j*(H-wl)/15;
|
||||
X.beginPath(); X.moveTo(0,wy);
|
||||
for(var k=0;k<W;k+=30) X.lineTo(k,wy+Math.sin(k*.01+t*1.5+j)*.8);
|
||||
X.strokeStyle='#fff'; X.lineWidth=.5; X.stroke();
|
||||
}
|
||||
X.globalAlpha = 1;
|
||||
|
||||
// Water sparkles
|
||||
for(let i=0;i<12;i++){
|
||||
const rx=(W*.1+i*W*.08+Math.sin(t*.15+i)*15)%W;
|
||||
const ry=wl+12+i*6+Math.sin(t*.4+i*2.5)*2.5;
|
||||
X.globalAlpha=Math.sin(t*1.8+i*1.3)*.5+.5; X.globalAlpha*=.1;
|
||||
X.fillStyle='#fff'; X.fillRect(rx,ry,8+Math.random()*10,1.2);
|
||||
}
|
||||
X.globalAlpha=1;
|
||||
|
||||
// Boats
|
||||
boats.forEach(b => {
|
||||
b.x+=b.sp; b.ph+=.015; if(b.x>W+120)b.x=-120;
|
||||
const by=wl-5*b.sc+Math.sin(b.ph)*2.5*b.sc;
|
||||
const tilt=Math.sin(b.ph+.5)*.03;
|
||||
X.save(); X.translate(b.x,by); X.rotate(tilt); X.globalAlpha=b.op;
|
||||
const s=b.sc;
|
||||
// Hull
|
||||
X.fillStyle='#b06a5a'; X.beginPath();
|
||||
X.moveTo(-22*s,0); X.lineTo(-17*s,9*s); X.lineTo(17*s,9*s); X.lineTo(22*s,0); X.closePath(); X.fill();
|
||||
// Water line
|
||||
X.fillStyle='#8a4a3a'; X.fillRect(-17*s,7*s,34*s,2*s);
|
||||
// Mast
|
||||
X.strokeStyle='#4a4a4a'; X.lineWidth=1.5*s;
|
||||
X.beginPath(); X.moveTo(0,0); X.lineTo(0,-28*s); X.stroke();
|
||||
// Großsegel (achtern, in Fahrtrichtung — größer)
|
||||
X.fillStyle='#f0ece5'; X.beginPath();
|
||||
X.moveTo(1,-26*s); X.lineTo(16*s,-5*s); X.lineTo(1,-2*s); X.closePath(); X.fill();
|
||||
// Vorsegel / Fock (vorn, kleiner)
|
||||
X.fillStyle='#e8e3d8'; X.beginPath();
|
||||
X.moveTo(-1,-22*s); X.lineTo(-11*s,-3*s); X.lineTo(-1,-1*s); X.closePath(); X.fill();
|
||||
// Flag
|
||||
X.fillStyle='#c07a6b'; X.beginPath();
|
||||
X.moveTo(0,-28*s); X.lineTo(8*s,-25*s); X.lineTo(0,-22*s); X.closePath(); X.fill();
|
||||
// Wake
|
||||
X.globalAlpha=b.op*.4; X.strokeStyle='rgba(255,255,255,.3)'; X.lineWidth=1;
|
||||
X.beginPath(); X.moveTo(-22*s,4*s);
|
||||
X.quadraticCurveTo(-35*s,6*s,-50*s,3*s); X.stroke();
|
||||
X.restore(); X.globalAlpha=1;
|
||||
// Clouds
|
||||
cls.forEach(function(c) {
|
||||
c.x += c.sp; if(c.x>W+c.w*2){c.x=-c.w*2; c.y=H*(.04+Math.random()*.18);}
|
||||
X.globalAlpha=c.op; X.fillStyle='#fff';
|
||||
X.beginPath(); X.ellipse(c.x,c.y,c.w*.5,c.w*.18,0,0,Math.PI*2); X.fill();
|
||||
X.beginPath(); X.ellipse(c.x+c.w*.2,c.y-c.w*.06,c.w*.35,c.w*.15,0,0,Math.PI*2); X.fill();
|
||||
X.globalAlpha=1;
|
||||
});
|
||||
|
||||
// Fish jumping
|
||||
fishTimer-=.016;
|
||||
if(fishTimer<=0){ fishTimer=4+Math.random()*6; fishX=W*.2+Math.random()*W*.6; fishY=wl+5; fishPhase=0; }
|
||||
if(fishPhase>=0 && fishPhase<1){
|
||||
fishPhase+=.02;
|
||||
const fy=fishY-Math.sin(fishPhase*Math.PI)*30;
|
||||
const rot=-Math.PI*.3+fishPhase*Math.PI*.6;
|
||||
X.save(); X.translate(fishX,fy); X.rotate(rot); X.globalAlpha=.4;
|
||||
X.fillStyle='#6a8a8e';
|
||||
X.beginPath(); X.ellipse(0,0,6,2.5,0,0,Math.PI*2); X.fill();
|
||||
// Tail
|
||||
X.beginPath(); X.moveTo(-6,0); X.lineTo(-10,-3); X.lineTo(-10,3); X.closePath(); X.fill();
|
||||
X.restore(); X.globalAlpha=1;
|
||||
// Splash
|
||||
if(fishPhase>.45 && fishPhase<.55){
|
||||
X.globalAlpha=.15; X.fillStyle='#fff';
|
||||
for(let i=0;i<5;i++){
|
||||
X.beginPath(); X.arc(fishX+(Math.random()-.5)*15,fishY-2,1+Math.random()*2,0,Math.PI*2); X.fill();
|
||||
}
|
||||
X.globalAlpha=1;
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom fade
|
||||
const fd = X.createLinearGradient(0,H*.78,0,H);
|
||||
fd.addColorStop(0,'rgba(247,246,243,0)'); fd.addColorStop(1,'rgba(247,246,243,1)');
|
||||
X.fillStyle=fd; X.fillRect(0,H*.78,W,H*.22);
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
frame();
|
||||
|
||||
@@ -8,6 +8,10 @@ require_once __DIR__ . '/php/config/app.php';
|
||||
require_once __DIR__ . '/php/lib/Database.php';
|
||||
require_once __DIR__ . '/php/lib/Session.php';
|
||||
require_once __DIR__ . '/php/lib/Response.php';
|
||||
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
}
|
||||
require_once __DIR__ . '/php/lib/Mailer.php';
|
||||
require_once __DIR__ . '/php/templates/_scripts.php';
|
||||
|
||||
Session::start();
|
||||
|
||||
@@ -4,18 +4,14 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Anmelden</title>
|
||||
<link rel="icon" type="image/png" href="assets/img/logo.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:#fff;border-radius:16px;padding:2rem;max-width:400px;width:90%;box-shadow:0 8px 30px rgba(0,0,0,.08)}
|
||||
.logo{text-align:center;margin-bottom:1.2rem}
|
||||
.logo h1{font-size:1.3rem;font-weight:800;color:#4a7c8a;letter-spacing:-.02em}
|
||||
.logo p{font-size:.78rem;color:#8a8a8a;margin-top:.2rem}
|
||||
.tabs{display:flex;gap:0;margin-bottom:1.2rem;border-radius:8px;overflow:hidden;border:1px solid rgba(0,0,0,.08)}
|
||||
.tab{flex:1;padding:.5rem;text-align:center;font-size:.75rem;font-weight:600;cursor:pointer;background:#f7f6f3;color:#8a8a8a;transition:all .15s}
|
||||
.tab.on{background:#4a7c8a;color:#fff}
|
||||
.logo{text-align:center;margin-bottom:1rem}
|
||||
.logo p{font-size:.78rem;color:#8a8a8a;margin-top:.3rem}
|
||||
label{display:block;font-size:.72rem;font-weight:600;color:#4a4a4a;margin-bottom:.2rem;margin-top:.8rem}
|
||||
input{width:100%;padding:.55rem .7rem;border:1.5px solid rgba(0,0,0,.1);border-radius:7px;font-size:.82rem;font-family:inherit;outline:none;transition:border .15s}
|
||||
input:focus{border-color:#4a7c8a}
|
||||
@@ -28,148 +24,261 @@
|
||||
.msg.ok{display:block;background:#dceadd;color:#5a8a5e}
|
||||
.link{text-align:center;margin-top:.8rem;font-size:.72rem;color:#8a8a8a}
|
||||
.link a{color:#4a7c8a;text-decoration:none;font-weight:600}
|
||||
.hidden{display:none}
|
||||
.hidden{display:none !important}
|
||||
.pw-wrap{position:relative}
|
||||
.pw-wrap input{padding-right:2.2rem}
|
||||
.pw-eye{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);cursor:pointer;font-size:1rem;opacity:.4;user-select:none;line-height:1}
|
||||
.pw-eye:hover{opacity:.7}
|
||||
.tabs{display:flex;gap:0;margin-bottom:1rem;border-radius:8px;overflow:hidden;border:1px solid rgba(0,0,0,.08)}
|
||||
.tab{flex:1;padding:.5rem;text-align:center;font-size:.75rem;font-weight:600;cursor:pointer;background:#f7f6f3;color:#8a8a8a;transition:all .15s}
|
||||
.tab.on{background:#4a7c8a;color:#fff}
|
||||
.divider{text-align:center;margin:1rem 0;color:#ccc;font-size:.72rem;position:relative}
|
||||
.divider::before,.divider::after{content:'';position:absolute;top:50%;width:35%;height:1px;background:#e0e0e0}
|
||||
.divider::before{left:0}
|
||||
.divider::after{right:0}
|
||||
.quick-section{border-top:1px solid rgba(0,0,0,.06);margin-top:1.2rem;padding-top:1rem}
|
||||
.quick-section p{font-size:.68rem;color:#8a8a8a;text-align:center;margin-bottom:.6rem}
|
||||
.quick-btns{display:flex;gap:.35rem}
|
||||
.quick-btns button{flex:1;font-size:.72rem;padding:.45rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<h1>🌍 GeoGraSim</h1>
|
||||
<a href="index.html"><img src="assets/img/geograsim_t.svg" alt="GeoGraSim" style="height:52px"></a>
|
||||
<p>Interaktive Simulationen für den Geografieunterricht</p>
|
||||
</div>
|
||||
<div style="text-align:center;margin-bottom:1rem"><a href="index.html" style="font-size:.72rem;color:#4a7c8a;text-decoration:none;font-weight:600">← Zurück zur Startseite</a></div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab on" data-tab="student">Schüler:in</div>
|
||||
<div class="tab" data-tab="teacher">Lehrkraft</div>
|
||||
<!-- LOGIN (einheitlich für alle) -->
|
||||
<div id="view-login">
|
||||
<h2 style="font-size:1rem;font-weight:800;color:#1f4e5a;margin-bottom:.8rem;text-align:center">Einloggen</h2>
|
||||
<label>E-Mail oder Benutzername</label>
|
||||
<input type="text" id="l-user" placeholder="name@schule.at oder schueler01">
|
||||
<label>Passwort</label>
|
||||
<div class="pw-wrap"><input type="password" id="l-pass"><span class="pw-eye" onclick="togglePw(this)">👁</span></div>
|
||||
<button class="btn" onclick="doLogin()">Einloggen</button>
|
||||
<div class="link"><a href="#" onclick="showView('forgot');return false">Passwort vergessen?</a></div>
|
||||
<div class="divider">oder</div>
|
||||
<button class="btn btn-secondary" onclick="showView('register')" style="margin-top:0">Registrieren — Account erstellen</button>
|
||||
</div>
|
||||
|
||||
<!-- SCHUELER LOGIN -->
|
||||
<form id="f-student">
|
||||
<label>Klassencode</label>
|
||||
<input type="text" id="s-code" placeholder="z.B. GEO1A" maxlength="6" autocapitalize="characters">
|
||||
<label>Benutzername</label>
|
||||
<input type="text" id="s-user" placeholder="z.B. schueler01">
|
||||
<label>Passwort</label>
|
||||
<input type="password" id="s-pass">
|
||||
<button type="submit" class="btn">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<!-- LEHRER LOGIN -->
|
||||
<form id="f-teacher" class="hidden">
|
||||
<div id="teacher-login">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" id="t-email" placeholder="name@schule.at">
|
||||
<label>Passwort</label>
|
||||
<input type="password" id="t-pass">
|
||||
<button type="submit" class="btn">Anmelden</button>
|
||||
<div class="link"><a href="#" id="show-register">Noch kein Konto? Jetzt registrieren</a></div>
|
||||
<!-- REGISTRIERUNG -->
|
||||
<div id="view-register" class="hidden">
|
||||
<h2 style="font-size:1rem;font-weight:800;color:#1f4e5a;margin-bottom:.8rem;text-align:center">Registrieren — Account erstellen</h2>
|
||||
<div class="tabs">
|
||||
<div class="tab on" onclick="setRegType('teacher')">Lehrperson</div>
|
||||
<div class="tab" onclick="setRegType('student')">Schüler*in</div>
|
||||
</div>
|
||||
<div id="teacher-register" class="hidden">
|
||||
<label>Name</label>
|
||||
<input type="text" id="r-name" placeholder="Vor- und Nachname">
|
||||
<label>Schule</label>
|
||||
<input type="text" id="r-school" placeholder="Name der Schule">
|
||||
|
||||
<div id="reg-teacher">
|
||||
<label>Vorname</label>
|
||||
<input type="text" id="r-first" placeholder="Vorname">
|
||||
<label>Nachname</label>
|
||||
<input type="text" id="r-last" placeholder="Nachname">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" id="r-email" placeholder="name@schule.at">
|
||||
<label>Schule (optional)</label>
|
||||
<input type="text" id="r-school" placeholder="Name der Schule">
|
||||
<label>Passwort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="r-pass" minlength="8">
|
||||
<div class="pw-wrap"><input type="password" id="r-pass" minlength="8"><span class="pw-eye" onclick="togglePw(this)">👁</span></div>
|
||||
<label>Passwort bestätigen</label>
|
||||
<input type="password" id="r-pass2">
|
||||
<button type="submit" class="btn">Registrieren</button>
|
||||
<div class="link"><a href="#" id="show-login">Bereits registriert? Anmelden</a></div>
|
||||
<button class="btn" onclick="doRegisterTeacher()">Registrieren</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="reg-student" class="hidden">
|
||||
<label>Klassencode</label>
|
||||
<input type="text" id="rs-code" placeholder="z.B. GEO1A" maxlength="6" autocapitalize="characters">
|
||||
<label>Benutzername</label>
|
||||
<input type="text" id="rs-user" placeholder="z.B. max01">
|
||||
<label>Passwort (mind. 4 Zeichen)</label>
|
||||
<div class="pw-wrap"><input type="password" id="rs-pass"><span class="pw-eye" onclick="togglePw(this)">👁</span></div>
|
||||
<p style="font-size:.68rem;color:#8a8a8a;margin-top:.5rem">Schüler*innen-Zugänge werden von der Lehrperson erstellt. Frage deine Lehrperson nach dem Klassencode und deinen Zugangsdaten.</p>
|
||||
<button class="btn" onclick="doRegisterStudent()">Registrieren</button>
|
||||
</div>
|
||||
|
||||
<div class="link"><a href="#" onclick="showView('login');return false">Bereits registriert? Anmelden</a></div>
|
||||
</div>
|
||||
|
||||
<!-- PASSWORT VERGESSEN -->
|
||||
<div id="view-forgot" class="hidden">
|
||||
<label>E-Mail-Adresse</label>
|
||||
<input type="email" id="f-email" placeholder="name@schule.at">
|
||||
<button class="btn" onclick="doResetRequest()">Link senden</button>
|
||||
<div class="link"><a href="#" onclick="showView('login');return false">Zurück zur Anmeldung</a></div>
|
||||
</div>
|
||||
|
||||
<!-- PASSWORT RESET (via URL-Token) -->
|
||||
<div id="view-reset" class="hidden">
|
||||
<label>Neues Passwort (mind. 8 Zeichen)</label>
|
||||
<div class="pw-wrap"><input type="password" id="n-pass" minlength="8"><span class="pw-eye" onclick="togglePw(this)">👁</span></div>
|
||||
<label>Passwort bestätigen</label>
|
||||
<input type="password" id="n-pass2">
|
||||
<button class="btn" onclick="doResetConfirm()">Passwort setzen</button>
|
||||
</div>
|
||||
|
||||
<div class="msg" id="msg"></div>
|
||||
|
||||
<!-- Schnell-Login -->
|
||||
<div class="quick-section">
|
||||
<p>Zum Ausprobieren — Schnell-Login:</p>
|
||||
<div style="display:flex;flex-direction:column;gap:.35rem">
|
||||
<button class="btn-secondary btn" onclick="quickLogin('teacher')" style="width:100%;font-size:.72rem;padding:.45rem">👩🏫 Test-Lehrperson</button>
|
||||
<div class="quick-btns">
|
||||
<button class="btn-secondary btn" onclick="quickLogin('anna')" style="font-size:.72rem;padding:.45rem">👧 Anna</button>
|
||||
<button class="btn-secondary btn" onclick="quickLogin('ben')" style="font-size:.72rem;padding:.45rem">👦 Ben</button>
|
||||
<button class="btn-secondary btn" onclick="quickLogin('clara')" style="font-size:.72rem;padding:.45rem">👧 Clara</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var BASE = (window.__GGS__?.baseUrl) || ''
|
||||
var tabs = document.querySelectorAll('.tab')
|
||||
var fStudent = document.getElementById('f-student')
|
||||
var fTeacher = document.getElementById('f-teacher')
|
||||
var msg = document.getElementById('msg')
|
||||
var isRegister = false
|
||||
function togglePw(el) {
|
||||
var inp = el.previousElementSibling;
|
||||
if (inp.type === 'password') { inp.type = 'text'; el.textContent = '🙈'; }
|
||||
else { inp.type = 'password'; el.textContent = '👁'; }
|
||||
}
|
||||
|
||||
tabs.forEach(function(t) {
|
||||
t.addEventListener('click', function() {
|
||||
tabs.forEach(function(x){x.className='tab'})
|
||||
this.className='tab on'
|
||||
fStudent.classList.toggle('hidden', this.dataset.tab !== 'student')
|
||||
fTeacher.classList.toggle('hidden', this.dataset.tab !== 'teacher')
|
||||
msg.className = 'msg'
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('show-register').addEventListener('click', function(e) {
|
||||
e.preventDefault(); isRegister = true
|
||||
document.getElementById('teacher-login').classList.add('hidden')
|
||||
document.getElementById('teacher-register').classList.remove('hidden')
|
||||
})
|
||||
document.getElementById('show-login').addEventListener('click', function(e) {
|
||||
e.preventDefault(); isRegister = false
|
||||
document.getElementById('teacher-login').classList.remove('hidden')
|
||||
document.getElementById('teacher-register').classList.add('hidden')
|
||||
})
|
||||
var BASE = (window.__GGS__?.baseUrl) || '';
|
||||
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
|
||||
|
||||
function showMsg(text, type) {
|
||||
msg.textContent = text
|
||||
msg.className = 'msg ' + type
|
||||
var m = document.getElementById('msg');
|
||||
m.textContent = text; m.className = 'msg ' + type;
|
||||
}
|
||||
|
||||
async function api(action, data) {
|
||||
var res = await fetch(BASE + '/api/auth', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'}, credentials:'same-origin',
|
||||
body: JSON.stringify(Object.assign({action: action}, data))
|
||||
})
|
||||
return await res.json()
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// Schueler-Login
|
||||
fStudent.addEventListener('submit', async function(e) {
|
||||
e.preventDefault()
|
||||
var code = document.getElementById('s-code').value.trim().toUpperCase()
|
||||
var user = document.getElementById('s-user').value.trim()
|
||||
var pass = document.getElementById('s-pass').value
|
||||
if (!code || !user || !pass) { showMsg('Alle Felder ausfüllen!', 'err'); return }
|
||||
var r = await api('student_login', {classCode: code, username: user, password: pass})
|
||||
if (r.error) { showMsg(r.error, 'err'); return }
|
||||
showMsg('Willkommen, ' + r.displayName + '!', 'ok')
|
||||
setTimeout(function() { location.href = 'index.html' }, 800)
|
||||
})
|
||||
// === VIEWS ===
|
||||
function showView(view) {
|
||||
['login','register','forgot','reset'].forEach(function(v) {
|
||||
document.getElementById('view-' + v).classList.toggle('hidden', v !== view);
|
||||
});
|
||||
document.getElementById('msg').className = 'msg';
|
||||
}
|
||||
|
||||
// Lehrer-Login/Register
|
||||
fTeacher.addEventListener('submit', async function(e) {
|
||||
e.preventDefault()
|
||||
if (isRegister) {
|
||||
var name = document.getElementById('r-name').value.trim()
|
||||
var school = document.getElementById('r-school').value.trim()
|
||||
var email = document.getElementById('r-email').value.trim()
|
||||
var pass = document.getElementById('r-pass').value
|
||||
var pass2 = document.getElementById('r-pass2').value
|
||||
if (!name || !email || !pass) { showMsg('Alle Felder ausfüllen!', 'err'); return }
|
||||
if (pass !== pass2) { showMsg('Passwörter stimmen nicht überein!', 'err'); return }
|
||||
if (pass.length < 8) { showMsg('Passwort muss mindestens 8 Zeichen haben!', 'err'); return }
|
||||
var r = await api('register', {displayName: name, schoolName: school, email: email, password: pass})
|
||||
if (r.error) { showMsg(r.error, 'err'); return }
|
||||
showMsg('Registrierung erfolgreich!', 'ok')
|
||||
setTimeout(function() { location.href = 'teacher.html' }, 800)
|
||||
} else {
|
||||
var email = document.getElementById('t-email').value.trim()
|
||||
var pass = document.getElementById('t-pass').value
|
||||
if (!email || !pass) { showMsg('E-Mail und Passwort ausfüllen!', 'err'); return }
|
||||
var r = await api('login', {email: email, password: pass})
|
||||
if (r.error) { showMsg(r.error, 'err'); return }
|
||||
showMsg('Willkommen, ' + r.displayName + '!', 'ok')
|
||||
setTimeout(function() { location.href = 'teacher.html' }, 800)
|
||||
function setRegType(type) {
|
||||
document.querySelectorAll('.tabs .tab').forEach(function(t, i) {
|
||||
t.classList.toggle('on', (i === 0 && type === 'teacher') || (i === 1 && type === 'student'));
|
||||
});
|
||||
document.getElementById('reg-teacher').classList.toggle('hidden', type !== 'teacher');
|
||||
document.getElementById('reg-student').classList.toggle('hidden', type !== 'student');
|
||||
}
|
||||
|
||||
// === LOGIN (einheitlich) ===
|
||||
async function doLogin() {
|
||||
var user = document.getElementById('l-user').value.trim();
|
||||
var pass = document.getElementById('l-pass').value;
|
||||
if (!user || !pass) { showMsg('Bitte ausfüllen.', 'err'); return; }
|
||||
|
||||
// Versuche als Lehrperson
|
||||
var r = await api('login', {email: user, password: pass});
|
||||
if (!r.error) {
|
||||
showMsg('Willkommen, ' + r.displayName + '!', 'ok');
|
||||
setTimeout(function() { location.href = 'teacher.html'; }, 600);
|
||||
return;
|
||||
}
|
||||
})
|
||||
|
||||
// Versuche als Schüler*in (sucht über alle Klassen)
|
||||
var r2 = await api('student_login_by_username', {username: user, password: pass});
|
||||
if (!r2.error) {
|
||||
showMsg('Willkommen, ' + r2.displayName + '!', 'ok');
|
||||
setTimeout(function() { location.href = 'schueler.html'; }, 600);
|
||||
return;
|
||||
}
|
||||
|
||||
showMsg('Benutzername oder Passwort falsch.', 'err');
|
||||
}
|
||||
|
||||
// === REGISTRIERUNG LEHRPERSON ===
|
||||
async function doRegisterTeacher() {
|
||||
var first = document.getElementById('r-first').value.trim();
|
||||
var last = document.getElementById('r-last').value.trim();
|
||||
var email = document.getElementById('r-email').value.trim();
|
||||
var school = document.getElementById('r-school').value.trim();
|
||||
var pass = document.getElementById('r-pass').value;
|
||||
var pass2 = document.getElementById('r-pass2').value;
|
||||
if (!first || !last || !email || !pass) { showMsg('Bitte alle Pflichtfelder ausfüllen.', 'err'); return; }
|
||||
if (pass !== pass2) { showMsg('Passwörter stimmen nicht überein.', 'err'); return; }
|
||||
if (pass.length < 8) { showMsg('Passwort muss mindestens 8 Zeichen haben.', 'err'); return; }
|
||||
var displayName = first + ' ' + last;
|
||||
var r = await api('register', {displayName: displayName, schoolName: school, email: email, password: pass});
|
||||
if (r.error) { showMsg(r.error, 'err'); return; }
|
||||
showMsg('Registrierung erfolgreich! Klasse "Meine Klasse" wurde angelegt.', 'ok');
|
||||
setTimeout(function() { location.href = 'teacher.html'; }, 1200);
|
||||
}
|
||||
|
||||
// === REGISTRIERUNG SCHÜLER*IN ===
|
||||
async function doRegisterStudent() {
|
||||
var code = document.getElementById('rs-code').value.trim().toUpperCase();
|
||||
var user = document.getElementById('rs-user').value.trim();
|
||||
var pass = document.getElementById('rs-pass').value;
|
||||
if (!code || !user || !pass) { showMsg('Alle Felder ausfüllen.', 'err'); return; }
|
||||
if (pass.length < 4) { showMsg('Passwort muss mindestens 4 Zeichen haben.', 'err'); return; }
|
||||
var r = await api('student_register', {classCode: code, username: user, password: pass});
|
||||
if (r.error) { showMsg(r.error, 'err'); return; }
|
||||
showMsg('Account erstellt! Willkommen, ' + r.displayName + '!', 'ok');
|
||||
setTimeout(function() { location.href = 'schueler.html'; }, 800);
|
||||
}
|
||||
|
||||
// === PASSWORT VERGESSEN ===
|
||||
async function doResetRequest() {
|
||||
var email = document.getElementById('f-email').value.trim();
|
||||
if (!email) { showMsg('Bitte E-Mail-Adresse eingeben.', 'err'); return; }
|
||||
var r = await api('reset_request', {email: email});
|
||||
showMsg(r.message || 'Falls ein Konto existiert, wurde eine E-Mail gesendet.', 'ok');
|
||||
}
|
||||
|
||||
// === PASSWORT RESET (Token) ===
|
||||
var resetToken = new URLSearchParams(location.search).get('reset');
|
||||
if (resetToken) { showView('reset'); }
|
||||
|
||||
async function doResetConfirm() {
|
||||
var pass = document.getElementById('n-pass').value;
|
||||
var pass2 = document.getElementById('n-pass2').value;
|
||||
if (!pass || pass.length < 8) { showMsg('Passwort muss mindestens 8 Zeichen haben.', 'err'); return; }
|
||||
if (pass !== pass2) { showMsg('Passwörter stimmen nicht überein.', 'err'); return; }
|
||||
var r = await api('reset_confirm', {token: resetToken, newPassword: pass});
|
||||
if (r.error) { showMsg(r.error, 'err'); return; }
|
||||
showMsg('Passwort geändert! Sie können sich jetzt anmelden.', 'ok');
|
||||
setTimeout(function() { location.href = 'login.html'; }, 1500);
|
||||
}
|
||||
|
||||
// === QUICK LOGIN ===
|
||||
async function quickLogin(who) {
|
||||
showMsg('Anmelden...', 'ok');
|
||||
if (who === 'teacher') {
|
||||
var r = await api('login', {email: 'test@geograsim.at', password: 'test1234'});
|
||||
if (r.error) { showMsg(r.error, 'err'); return; }
|
||||
showMsg('Willkommen, ' + r.displayName + '!', 'ok');
|
||||
setTimeout(function() { location.href = 'teacher.html'; }, 600);
|
||||
} else {
|
||||
var r = await api('student_login', {classCode: 'TEST01', username: who, password: 'test1234'});
|
||||
if (r.error) { showMsg(r.error, 'err'); return; }
|
||||
showMsg('Willkommen, ' + r.displayName + '!', 'ok');
|
||||
setTimeout(function() { location.href = 'schueler.html'; }, 600);
|
||||
}
|
||||
}
|
||||
|
||||
// Enter-Key
|
||||
document.getElementById('l-pass').addEventListener('keydown', function(e) { if(e.key==='Enter') doLogin(); });
|
||||
document.getElementById('l-user').addEventListener('keydown', function(e) { if(e.key==='Enter') doLogin(); });
|
||||
|
||||
// Check ob schon eingeloggt
|
||||
api('status', {}).then(function(r) {
|
||||
if (r.role === 'teacher') location.href = 'teacher.html'
|
||||
if (r.role === 'student') location.href = 'index.html'
|
||||
})
|
||||
if (!resetToken) {
|
||||
api('status', {}).then(function(r) {
|
||||
if (r.role === 'teacher') location.href = 'teacher.html';
|
||||
if (r.role === 'student') location.href = 'schueler.html';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<?php renderPage('barrierefreiheit.html');
|
||||
@@ -0,0 +1 @@
|
||||
<?php renderPage('datenschutz.html');
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/**
|
||||
* Heli-Spiel — saubere PHP-Seite
|
||||
* Waypoints kommen ausschließlich aus der DB
|
||||
*/
|
||||
require_once __DIR__ . '/../php/config/app.php';
|
||||
require_once __DIR__ . '/../php/config/db.php';
|
||||
|
||||
$db = getDB();
|
||||
$rows = $db->query("SELECT wp_key, name, lat, lon, wp_type, info FROM geo_waypoints")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$wpJs = "{\n";
|
||||
foreach ($rows as $w) {
|
||||
$key = $w['wp_key'];
|
||||
$name = addslashes($w['name']);
|
||||
$wpJs .= " '$key':{lat:{$w['lat']},lon:{$w['lon']},name:'$name'},\n";
|
||||
}
|
||||
$wpJs .= "}";
|
||||
|
||||
// POIs für die Karte (nur Basen, Städte, Gipfel)
|
||||
$poisJs = "[\n";
|
||||
foreach ($rows as $w) {
|
||||
if (in_array($w['wp_type'], ['base','city','peak'])) {
|
||||
$name = addslashes($w['name']);
|
||||
$info = addslashes($w['info'] ?? '');
|
||||
$poisJs .= " {key:'{$w['wp_key']}',name:'$name',lat:{$w['lat']},lon:{$w['lon']},type:'{$w['wp_type']}',info:'$info'},\n";
|
||||
}
|
||||
}
|
||||
$poisJs .= "]";
|
||||
|
||||
// game.html laden
|
||||
$html = file_get_contents(__DIR__ . '/../sims/heli/game.html');
|
||||
|
||||
// Placeholder ersetzen
|
||||
$html = str_replace('/*__DB_WAYPOINTS__*/', "var WP = $wpJs;\nvar wpLoaded = true;\nvar DB_POIS = $poisJs;", $html);
|
||||
|
||||
echo $html;
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/**
|
||||
* Heli-Übersichtskarte — Waypoints aus der DB
|
||||
*/
|
||||
require_once __DIR__ . '/../php/config/app.php';
|
||||
require_once __DIR__ . '/../php/config/db.php';
|
||||
|
||||
$db = getDB();
|
||||
$waypoints = $db->query("SELECT wp_key, name, lat, lon, region, wp_type, info FROM geo_waypoints ORDER BY region, name")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Als JSON für JavaScript
|
||||
$wpJson = json_encode($waypoints, JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Helikopter-Übersichtskarte</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',system-ui,sans-serif;overflow:hidden;height:100vh;background:#e8e4d8}
|
||||
#map{width:100%;height:100vh}
|
||||
.marker-circle{width:26px;height:26px;background:#fff;border:2.5px solid #1f4e5a;border-radius:50%;box-shadow:0 2px 6px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;font-size:13px;line-height:1}
|
||||
.marker-circle.capital{border-color:#e8833a;width:30px;height:30px;font-size:15px}
|
||||
.marker-circle.peak{border-color:#5a8a5e;width:24px;height:24px;font-size:12px}
|
||||
.marker-circle.base{border-color:#d4a017;width:30px;height:30px;font-size:16px}
|
||||
.marker-circle.base-inactive{border-color:#888;width:22px;height:22px;font-size:11px;opacity:.7}
|
||||
.wp-pill{font-family:'Inter',sans-serif;font-weight:700;font-size:11px;white-space:nowrap;padding:3px 9px;border-radius:5px;box-shadow:0 1px 4px rgba(0,0,0,.12);display:flex;align-items:center;height:20px}
|
||||
.wp-pill.city{background:rgba(218,232,236,.94);color:#1f4e5a;border-bottom:2.5px solid #4a7c8a}
|
||||
.wp-pill.capital{background:rgba(232,200,170,.92);color:#e8833a;border-bottom:2.5px solid #e8833a}
|
||||
.wp-pill.peak{background:rgba(220,234,221,.94);color:#3a6b3e;border-bottom:2.5px solid #5a8a5e}
|
||||
.wp-pill.base{background:rgba(255,255,255,.92);color:#9a7800;border-left:3px solid #d4a017;font-size:10px}
|
||||
.wp-pill .pop{font-size:8px;font-weight:500;color:#8a8a8a;margin-left:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
// Waypoints direkt aus der DB (PHP-injiziert)
|
||||
var DB_WAYPOINTS = <?= $wpJson ?>;
|
||||
|
||||
var map = L.map('map', {zoomControl:false, maxBoundsViscosity:1.0, minZoom:7, maxZoom:15})
|
||||
.setView([47.4, 13.2], 8);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', maxZoom:18
|
||||
}).addTo(map);
|
||||
L.control.zoom({position:'topright'}).addTo(map);
|
||||
L.control.scale({position:'bottomleft', metric:true, imperial:false, maxWidth:200}).addTo(map);
|
||||
|
||||
// Render alle Waypoints nach Typ
|
||||
DB_WAYPOINTS.forEach(function(w) {
|
||||
var lat = parseFloat(w.lat), lon = parseFloat(w.lon);
|
||||
var icon, pillClass, pillHtml, popupHtml, size;
|
||||
|
||||
if (w.wp_type === 'base') {
|
||||
size = 30;
|
||||
icon = '<div class="marker-circle base">🚁</div>';
|
||||
pillClass = 'wp-pill base';
|
||||
pillHtml = w.name;
|
||||
popupHtml = '<div style="font-family:Inter,sans-serif;text-align:center;padding:4px">'
|
||||
+'<div style="font-size:2rem">🚁</div>'
|
||||
+'<div style="font-size:14px;font-weight:800;color:#1f4e5a">'+w.name+'</div>'
|
||||
+(w.info?'<div style="font-size:11px;color:#8a8a8a">'+w.info+'</div>':'')
|
||||
+'</div>';
|
||||
} else if (w.wp_type === 'city') {
|
||||
size = 26;
|
||||
icon = '<div class="marker-circle">🏙️</div>';
|
||||
pillClass = 'wp-pill city';
|
||||
pillHtml = w.name;
|
||||
popupHtml = '<div style="font-family:Inter,sans-serif;text-align:center;padding:4px">'
|
||||
+'<div style="font-size:16px;font-weight:900;color:#1f4e5a">'+w.name+'</div>'
|
||||
+'<div style="font-size:10px;color:#8a8a8a">'+w.region+'</div>'
|
||||
+(w.info?'<div style="font-size:11px;color:#4a4a4a;margin-top:4px">'+w.info+'</div>':'')
|
||||
+'</div>';
|
||||
} else if (w.wp_type === 'peak') {
|
||||
size = 24;
|
||||
icon = '<div class="marker-circle peak">🏔️</div>';
|
||||
pillClass = 'wp-pill peak';
|
||||
pillHtml = w.name + (w.info?' <span class="pop">'+w.info+'</span>':'');
|
||||
popupHtml = '<div style="font-family:Inter,sans-serif;text-align:center">'
|
||||
+'<div style="font-size:2rem">🏔️</div>'
|
||||
+'<div style="font-size:15px;font-weight:900;color:#3a6b3e">'+w.name+'</div>'
|
||||
+(w.info?'<div style="font-size:13px;color:#1f4e5a;font-weight:800">'+w.info+'</div>':'')
|
||||
+'</div>';
|
||||
} else if (w.wp_type === 'village') {
|
||||
size = 20;
|
||||
icon = '<div style="width:16px;height:16px;background:#fff;border:2px solid #8a8a8a;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,.2)"></div>';
|
||||
pillClass = null; // Kein Pill für kleine Dörfer
|
||||
popupHtml = '<div style="font-family:Inter,sans-serif"><strong>'+w.name+'</strong>'
|
||||
+(w.info?'<br><span style="font-size:11px;color:#8a8a8a">'+w.info+'</span>':'')+'</div>';
|
||||
} else {
|
||||
size = 18;
|
||||
icon = '<div style="width:14px;height:14px;background:#c85c4a;border:2px solid #fff;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,.2)"></div>';
|
||||
pillClass = null;
|
||||
popupHtml = '<div style="font-family:Inter,sans-serif"><strong>'+w.name+'</strong>'
|
||||
+(w.info?'<br><span style="font-size:11px;color:#8a8a8a">'+w.info+'</span>':'')+'</div>';
|
||||
}
|
||||
|
||||
// Marker
|
||||
L.marker([lat, lon], {
|
||||
icon: L.divIcon({className:'', html:icon, iconSize:[size,size], iconAnchor:[size/2,size/2]}),
|
||||
zIndexOffset: w.wp_type==='base'?1000:w.wp_type==='city'?500:w.wp_type==='peak'?300:100
|
||||
}).addTo(map).bindPopup(popupHtml, {maxWidth:260});
|
||||
|
||||
// Pill (nur für Basen, Städte, Gipfel)
|
||||
if (pillClass) {
|
||||
L.marker([lat, lon], {
|
||||
icon: L.divIcon({className:pillClass, html:pillHtml, iconSize:[140,20], iconAnchor:[-14,10]}),
|
||||
interactive:false, zIndexOffset: w.wp_type==='base'?900:w.wp_type==='city'?400:200
|
||||
}).addTo(map);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<?php renderPage('impressum.html');
|
||||
@@ -0,0 +1 @@
|
||||
<?php renderPage('profil.html');
|
||||
@@ -0,0 +1 @@
|
||||
<?php renderPage('schueler.html');
|
||||
@@ -0,0 +1 @@
|
||||
<?php renderPage('ueber.html');
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin API: Module-Verwaltung
|
||||
* GET /api/admin-modules → Liste aller Module (mit Emoji, Titel, Bild, Status)
|
||||
* PUT /api/admin-modules → Ein Modul aktualisieren (Emoji, Titel, Subtitle, Status, Bild)
|
||||
* POST /api/admin-modules → Upload eines neuen Card-Bildes (multipart)
|
||||
*
|
||||
* Auth: Super-Admin Session ($_SESSION['admin_id'])
|
||||
*/
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
|
||||
if (empty($_SESSION['admin_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Nicht eingeloggt']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$db = Database::get();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
try {
|
||||
// === GET: Liste aller Module ===
|
||||
if ($method === 'GET') {
|
||||
$modules = $db->fetchAll('
|
||||
SELECT module_id, title, subtitle, icon, card_image, status,
|
||||
duration_min, age_min, age_max, play_url, page_url, sort_order
|
||||
FROM module_info
|
||||
ORDER BY sort_order, title
|
||||
');
|
||||
Response::json(['modules' => $modules]);
|
||||
}
|
||||
|
||||
// === PUT: Modul aktualisieren ===
|
||||
if ($method === 'PUT') {
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$id = trim($body['module_id'] ?? '');
|
||||
if (!$id) Response::error('module_id fehlt');
|
||||
|
||||
$exists = $db->fetchOne('SELECT module_id FROM module_info WHERE module_id = ?', [$id]);
|
||||
if (!$exists) Response::error('Modul nicht gefunden', 404);
|
||||
|
||||
$fields = [];
|
||||
$params = [];
|
||||
|
||||
// Erlaubte Felder
|
||||
$allowed = ['title', 'subtitle', 'icon', 'card_image', 'status',
|
||||
'duration_min', 'age_min', 'age_max', 'play_url', 'page_url', 'sort_order'];
|
||||
foreach ($allowed as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$fields[] = "$f = ?";
|
||||
$params[] = $body[$f] === '' ? null : $body[$f];
|
||||
}
|
||||
}
|
||||
if (!$fields) Response::error('Keine Felder zum Aktualisieren');
|
||||
|
||||
$params[] = $id;
|
||||
$sql = 'UPDATE module_info SET ' . implode(', ', $fields) . ' WHERE module_id = ?';
|
||||
$db->execute($sql, $params);
|
||||
|
||||
$updated = $db->fetchOne('SELECT * FROM module_info WHERE module_id = ?', [$id]);
|
||||
Response::json(['ok' => true, 'module' => $updated]);
|
||||
}
|
||||
|
||||
// === POST: Bild-Upload ===
|
||||
if ($method === 'POST') {
|
||||
$id = trim($_POST['module_id'] ?? '');
|
||||
if (!$id) Response::error('module_id fehlt');
|
||||
if (empty($_FILES['image'])) Response::error('Kein Bild hochgeladen');
|
||||
|
||||
$file = $_FILES['image'];
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) Response::error('Upload-Fehler');
|
||||
if ($file['size'] > 5 * 1024 * 1024) Response::error('Bild zu gross (max 5 MB)');
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['png', 'jpg', 'jpeg', 'webp', 'svg'])) Response::error('Nur PNG, JPG, WEBP, SVG');
|
||||
|
||||
$targetDir = __DIR__ . '/../../assets/img/';
|
||||
$filename = 'card-' . preg_replace('/[^a-z0-9]/', '', $id) . '.' . $ext;
|
||||
$targetPath = $targetDir . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
Response::error('Datei konnte nicht gespeichert werden');
|
||||
}
|
||||
|
||||
$db->execute('UPDATE module_info SET card_image = ? WHERE module_id = ?', [$filename, $id]);
|
||||
Response::json(['ok' => true, 'card_image' => $filename]);
|
||||
}
|
||||
|
||||
Response::error('Methode nicht unterstützt', 405);
|
||||
} catch (Throwable $e) {
|
||||
Response::error('Fehler: ' . $e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Super-Admin Authentifizierung (2FA mit E-Mail-PIN)
|
||||
* POST /api/admin {action: "login"|"verify_pin"|"status"|"logout"}
|
||||
*/
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
Response::error('Nur POST erlaubt', 405);
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $body['action'] ?? '';
|
||||
$db = Database::get();
|
||||
|
||||
// === LOGIN (Schritt 1: Username + Passwort → PIN per E-Mail) ===
|
||||
if ($action === 'login') {
|
||||
$username = trim($body['username'] ?? '');
|
||||
$password = $body['password'] ?? '';
|
||||
|
||||
if (!$username || !$password) Response::error('Benutzername und Passwort erforderlich');
|
||||
|
||||
$admin = $db->fetchOne('SELECT id, password, email_2fa FROM admin_users WHERE username = ?', [$username]);
|
||||
if (!$admin || !password_verify($password, $admin['password'])) {
|
||||
sleep(1); // Brute-force delay
|
||||
Response::error('Benutzername oder Passwort falsch');
|
||||
}
|
||||
|
||||
// PIN generieren (4-stellig)
|
||||
$pin = str_pad(random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||
$db->execute(
|
||||
'INSERT INTO admin_pins (admin_id, pin, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE))',
|
||||
[$admin['id'], $pin]
|
||||
);
|
||||
|
||||
// PIN per E-Mail senden
|
||||
if (class_exists('Mailer')) {
|
||||
Mailer::sendAdminPin($admin['email_2fa'], $pin);
|
||||
}
|
||||
|
||||
// Admin-ID in Session speichern (aber noch nicht authentifiziert)
|
||||
Session::start();
|
||||
$_SESSION['admin_pending'] = (int)$admin['id'];
|
||||
unset($_SESSION['admin_id']); // Sicherstellen dass nicht schon eingeloggt
|
||||
|
||||
Response::ok(['message' => 'PIN wurde an Ihre E-Mail gesendet.', 'email' => maskEmail($admin['email_2fa'])]);
|
||||
}
|
||||
|
||||
// === VERIFY PIN (Schritt 2: PIN eingeben → Admin-Session) ===
|
||||
if ($action === 'verify_pin') {
|
||||
Session::start();
|
||||
$adminId = $_SESSION['admin_pending'] ?? null;
|
||||
if (!$adminId) Response::error('Kein ausstehender Login. Bitte erneut anmelden.');
|
||||
|
||||
$pin = trim($body['pin'] ?? '');
|
||||
if (!$pin || strlen($pin) !== 4) Response::error('4-stelliger PIN erforderlich');
|
||||
|
||||
$valid = $db->fetchOne(
|
||||
'SELECT id FROM admin_pins WHERE admin_id = ? AND pin = ? AND expires_at > NOW() AND used = 0 ORDER BY id DESC LIMIT 1',
|
||||
[$adminId, $pin]
|
||||
);
|
||||
|
||||
if (!$valid) {
|
||||
sleep(1);
|
||||
Response::error('PIN ungültig oder abgelaufen');
|
||||
}
|
||||
|
||||
// PIN als benutzt markieren
|
||||
$db->execute('UPDATE admin_pins SET used = 1 WHERE id = ?', [$valid['id']]);
|
||||
|
||||
// Admin-Session aktivieren
|
||||
$_SESSION['admin_id'] = $adminId;
|
||||
unset($_SESSION['admin_pending']);
|
||||
|
||||
Response::ok(['message' => 'Anmeldung erfolgreich.']);
|
||||
}
|
||||
|
||||
// === STATUS ===
|
||||
if ($action === 'status') {
|
||||
Session::start();
|
||||
$adminId = $_SESSION['admin_id'] ?? null;
|
||||
if ($adminId) {
|
||||
$admin = $db->fetchOne('SELECT id, username FROM admin_users WHERE id = ?', [$adminId]);
|
||||
Response::ok(['authenticated' => true, 'admin' => $admin]);
|
||||
}
|
||||
Response::ok(['authenticated' => false]);
|
||||
}
|
||||
|
||||
// === LOGOUT ===
|
||||
if ($action === 'logout') {
|
||||
Session::start();
|
||||
unset($_SESSION['admin_id'], $_SESSION['admin_pending']);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
Response::error('Unbekannte Aktion');
|
||||
|
||||
// Helper: E-Mail maskieren (t***@ph-vorarlberg.ac.at)
|
||||
function maskEmail(string $email): string {
|
||||
$parts = explode('@', $email);
|
||||
return substr($parts[0], 0, 1) . '***@' . $parts[1];
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Klassen-Simulationen (Aufgaben/Hausübungen)
|
||||
* GET /api/assignments?class_id=X → Aktive Aufgaben einer Klasse
|
||||
* GET /api/assignments?student=1 → Aufgaben für eingeloggten Schüler*in
|
||||
* POST /api/assignments {action} → Aufgabe erstellen/beenden
|
||||
*/
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$db = Database::get();
|
||||
|
||||
if ($method === 'GET') {
|
||||
// Schüler*innen-Ansicht
|
||||
if (isset($_GET['student']) && $_GET['student'] === '1') {
|
||||
$sessionId = Session::requireStudent();
|
||||
$session = $db->fetchOne('SELECT class_id FROM student_sessions WHERE id = ?', [$sessionId]);
|
||||
if (!$session || !$session['class_id']) Response::ok([]);
|
||||
|
||||
$assignments = $db->fetchAll(
|
||||
'SELECT a.id, a.module_id, a.level, a.starts_at, a.ends_at, a.created_at
|
||||
FROM class_assignments a
|
||||
WHERE a.class_id = ? AND a.active = 1 AND a.starts_at <= NOW() AND (a.ends_at IS NULL OR a.ends_at > NOW())
|
||||
ORDER BY a.created_at DESC',
|
||||
[$session['class_id']]
|
||||
);
|
||||
Response::ok($assignments);
|
||||
}
|
||||
|
||||
// Lehrperson-Ansicht
|
||||
$teacherId = Session::requireTeacher();
|
||||
$classId = (int)($_GET['class_id'] ?? 0);
|
||||
if (!$classId) Response::error('class_id erforderlich');
|
||||
|
||||
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
|
||||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||||
|
||||
$assignments = $db->fetchAll(
|
||||
'SELECT id, module_id, level, starts_at, ends_at, active, created_at FROM class_assignments WHERE class_id = ? ORDER BY created_at DESC',
|
||||
[$classId]
|
||||
);
|
||||
Response::ok($assignments);
|
||||
}
|
||||
|
||||
if ($method === 'POST') {
|
||||
$teacherId = Session::requireTeacher();
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $body['action'] ?? '';
|
||||
|
||||
if ($action === 'create') {
|
||||
$classId = (int)($body['classId'] ?? 0);
|
||||
$moduleId = $body['moduleId'] ?? '';
|
||||
$level = max(1, min(5, (int)($body['level'] ?? 1)));
|
||||
$endsAt = !empty($body['endsAt']) ? $body['endsAt'] : null;
|
||||
|
||||
if (!$classId || !$moduleId) Response::error('classId und moduleId erforderlich');
|
||||
|
||||
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
|
||||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||||
|
||||
// Vorherige aktive Aufgabe für dieses Modul deaktivieren
|
||||
$db->execute(
|
||||
'UPDATE class_assignments SET active = 0 WHERE class_id = ? AND module_id = ? AND active = 1',
|
||||
[$classId, $moduleId]
|
||||
);
|
||||
|
||||
$db->execute(
|
||||
'INSERT INTO class_assignments (class_id, teacher_id, module_id, level, starts_at, ends_at) VALUES (?, ?, ?, ?, NOW(), ?)',
|
||||
[$classId, $teacherId, $moduleId, $level, $endsAt]
|
||||
);
|
||||
Response::ok(['id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
|
||||
if ($action === 'stop') {
|
||||
$assignmentId = (int)($body['assignmentId'] ?? 0);
|
||||
if (!$assignmentId) Response::error('assignmentId erforderlich');
|
||||
$db->execute(
|
||||
'UPDATE class_assignments SET active = 0, ends_at = NOW() WHERE id = ? AND teacher_id = ?',
|
||||
[$assignmentId, $teacherId]
|
||||
);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
Response::error('Unbekannte Aktion');
|
||||
}
|
||||
|
||||
Response::error('Methode nicht erlaubt', 405);
|
||||
@@ -39,6 +39,25 @@ if ($action === 'register') {
|
||||
Session::start();
|
||||
Session::loginTeacher($teacherId);
|
||||
|
||||
// "Meine Klasse" automatisch anlegen
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$joinCode = '';
|
||||
do {
|
||||
$joinCode = '';
|
||||
for ($i = 0; $i < 6; $i++) $joinCode .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
$exists = $db->fetchOne('SELECT id FROM classes WHERE join_code = ?', [$joinCode]);
|
||||
} while ($exists);
|
||||
|
||||
$db->execute(
|
||||
'INSERT INTO classes (teacher_id, name, school_year, join_code) VALUES (?, ?, ?, ?)',
|
||||
[$teacherId, 'Meine Klasse', date('Y') . '/' . (date('y') + 1), $joinCode]
|
||||
);
|
||||
|
||||
// Willkommens-Mail senden (nicht-blockierend)
|
||||
if (class_exists('Mailer')) {
|
||||
Mailer::sendWelcomeTeacher($email, $displayName);
|
||||
}
|
||||
|
||||
Response::ok([
|
||||
'teacherId' => $teacherId,
|
||||
'displayName' => $displayName,
|
||||
@@ -75,6 +94,68 @@ if ($action === 'login') {
|
||||
]);
|
||||
}
|
||||
|
||||
// === SCHÜLER*IN SELBST-REGISTRIERUNG (mit Klassencode) ===
|
||||
if ($action === 'student_register') {
|
||||
$classCode = strtoupper(trim($body['classCode'] ?? ''));
|
||||
$username = trim($body['username'] ?? '');
|
||||
$password = $body['password'] ?? '';
|
||||
|
||||
if (!$classCode || !$username || !$password) Response::error('Klassencode, Benutzername und Passwort erforderlich');
|
||||
if (strlen($password) < 4) Response::error('Passwort muss mindestens 4 Zeichen haben');
|
||||
|
||||
$class = $db->fetchOne('SELECT id, name FROM classes WHERE join_code = ?', [$classCode]);
|
||||
if (!$class) Response::error('Klassencode ungültig');
|
||||
|
||||
// Prüfen ob Username schon existiert
|
||||
$existing = $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND username = ?', [$class['id'], $username]);
|
||||
if ($existing) Response::error('Benutzername in dieser Klasse bereits vergeben');
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$db->execute(
|
||||
'INSERT INTO students (class_id, username, password, display_name, is_anonymous) VALUES (?, ?, ?, ?, 0)',
|
||||
[$class['id'], $username, $hash, $username]
|
||||
);
|
||||
|
||||
// Direkt einloggen
|
||||
$sessionId = Session::createStudent((int)$class['id'], $username);
|
||||
|
||||
Response::ok([
|
||||
'sessionId' => $sessionId,
|
||||
'displayName' => $username,
|
||||
'className' => $class['name'],
|
||||
]);
|
||||
}
|
||||
|
||||
// === SCHÜLER*IN LOGIN OHNE KLASSENCODE (sucht über alle Klassen) ===
|
||||
if ($action === 'student_login_by_username') {
|
||||
$username = trim($body['username'] ?? '');
|
||||
$password = $body['password'] ?? '';
|
||||
|
||||
if (!$username || !$password) Response::error('Benutzername und Passwort erforderlich');
|
||||
|
||||
$student = $db->fetchOne(
|
||||
'SELECT s.id, s.password, s.display_name, s.class_id, c.name as class_name, c.join_code
|
||||
FROM students s JOIN classes c ON c.id = s.class_id
|
||||
WHERE s.username = ? AND s.deleted_at IS NULL
|
||||
ORDER BY s.last_login DESC LIMIT 1',
|
||||
[$username]
|
||||
);
|
||||
|
||||
if (!$student || !password_verify($password, $student['password'])) {
|
||||
Response::error('Benutzername oder Passwort falsch');
|
||||
}
|
||||
|
||||
$sessionId = Session::createStudent((int)$student['class_id'], $student['display_name'] ?: $username);
|
||||
$db->execute('UPDATE students SET last_login = NOW() WHERE id = ?', [$student['id']]);
|
||||
|
||||
Response::ok([
|
||||
'sessionId' => $sessionId,
|
||||
'studentId' => (int)$student['id'],
|
||||
'displayName' => $student['display_name'] ?: $username,
|
||||
'className' => $student['class_name'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
// === LOGOUT ===
|
||||
if ($action === 'logout') {
|
||||
Session::logout();
|
||||
@@ -89,7 +170,7 @@ if ($action === 'student_login') {
|
||||
|
||||
if (!$classCode || !$username || !$password) Response::error('Klassencode, Benutzername und Passwort erforderlich');
|
||||
|
||||
$class = $db->fetchOne('SELECT id FROM classes WHERE join_code = ?', [$classCode]);
|
||||
$class = $db->fetchOne('SELECT id, name FROM classes WHERE join_code = ?', [$classCode]);
|
||||
if (!$class) Response::error('Klasse nicht gefunden');
|
||||
|
||||
$student = $db->fetchOne(
|
||||
@@ -118,6 +199,44 @@ if ($action === 'student_login') {
|
||||
]);
|
||||
}
|
||||
|
||||
// === PASSWORT-RESET ANFORDERN ===
|
||||
if ($action === 'reset_request') {
|
||||
$email = trim($body['email'] ?? '');
|
||||
if (!$email) Response::error('E-Mail-Adresse erforderlich');
|
||||
|
||||
$teacher = $db->fetchOne('SELECT id, display_name FROM teachers WHERE email = ?', [$email]);
|
||||
if (!$teacher) {
|
||||
// Kein Fehler zurückgeben (Datenschutz)
|
||||
Response::ok(['message' => 'Falls ein Konto existiert, wurde eine E-Mail gesendet.']);
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$db->execute('UPDATE teachers SET reset_token = ?, reset_expires = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ?', [$token, $teacher['id']]);
|
||||
|
||||
if (class_exists('Mailer')) {
|
||||
Mailer::sendPasswordReset($email, $teacher['display_name'] ?: 'Lehrkraft', $token);
|
||||
}
|
||||
|
||||
Response::ok(['message' => 'Falls ein Konto existiert, wurde eine E-Mail gesendet.']);
|
||||
}
|
||||
|
||||
// === PASSWORT-RESET BESTÄTIGEN ===
|
||||
if ($action === 'reset_confirm') {
|
||||
$token = trim($body['token'] ?? '');
|
||||
$newPassword = $body['newPassword'] ?? '';
|
||||
|
||||
if (!$token || !$newPassword) Response::error('Token und neues Passwort erforderlich');
|
||||
if (strlen($newPassword) < 8) Response::error('Passwort muss mindestens 8 Zeichen haben');
|
||||
|
||||
$teacher = $db->fetchOne('SELECT id FROM teachers WHERE reset_token = ? AND reset_expires > NOW()', [$token]);
|
||||
if (!$teacher) Response::error('Link ist ungültig oder abgelaufen');
|
||||
|
||||
$db->execute('UPDATE teachers SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?',
|
||||
[password_hash($newPassword, PASSWORD_DEFAULT), $teacher['id']]);
|
||||
|
||||
Response::ok(['message' => 'Passwort wurde geändert.']);
|
||||
}
|
||||
|
||||
// === STATUS ===
|
||||
if ($action === 'status') {
|
||||
Session::start();
|
||||
@@ -138,4 +257,26 @@ if ($action === 'status') {
|
||||
Response::ok(['role' => 'guest']);
|
||||
}
|
||||
|
||||
// === KONTAKTFORMULAR ===
|
||||
if ($action === 'contact') {
|
||||
$name = mb_substr(trim($body['name'] ?? ''), 0, 128);
|
||||
$email = trim($body['email'] ?? '');
|
||||
$subject = mb_substr(trim($body['subject'] ?? ''), 0, 128);
|
||||
$message = mb_substr(trim($body['message'] ?? ''), 0, 5000);
|
||||
|
||||
if (!$name || !$email || !$message) Response::error('Alle Felder erforderlich');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Ungültige E-Mail-Adresse');
|
||||
|
||||
if (class_exists('Mailer')) {
|
||||
$html = '<p><strong>Kontaktformular GeoGraSim</strong></p>'
|
||||
. '<p><strong>Von:</strong> ' . htmlspecialchars($name) . ' (' . htmlspecialchars($email) . ')</p>'
|
||||
. '<p><strong>Betreff:</strong> ' . htmlspecialchars($subject) . '</p>'
|
||||
. '<p><strong>Nachricht:</strong></p>'
|
||||
. '<p>' . nl2br(htmlspecialchars($message)) . '</p>';
|
||||
Mailer::send('support@geograsim.at', 'Kontakt: ' . $subject . ' — ' . $name, $html);
|
||||
}
|
||||
|
||||
Response::ok(['message' => 'Nachricht gesendet.']);
|
||||
}
|
||||
|
||||
Response::error('Unbekannte Aktion');
|
||||
|
||||
@@ -11,13 +11,13 @@ $db = Database::get();
|
||||
if ($method === 'GET') {
|
||||
$teacherId = Session::requireTeacher();
|
||||
$classes = $db->fetchAll(
|
||||
'SELECT id, name, school_year, join_code, created_at FROM classes WHERE teacher_id = ? ORDER BY created_at DESC',
|
||||
'SELECT id, name, school_year, join_code, created_at FROM classes WHERE teacher_id = ? AND deleted_at IS NULL ORDER BY created_at DESC',
|
||||
[$teacherId]
|
||||
);
|
||||
// Schueleranzahl pro Klasse
|
||||
foreach ($classes as &$c) {
|
||||
$c['student_count'] = (int)$db->fetchOne(
|
||||
'SELECT COUNT(*) as cnt FROM students WHERE class_id = ?', [$c['id']]
|
||||
'SELECT COUNT(*) as cnt FROM students WHERE class_id = ? AND deleted_at IS NULL', [$c['id']]
|
||||
)['cnt'];
|
||||
}
|
||||
Response::ok($classes);
|
||||
@@ -64,13 +64,14 @@ if ($method === 'POST') {
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
// === KLASSE LOESCHEN ===
|
||||
// === KLASSE LOESCHEN (Soft-Delete, Schüler*innen werden Autodidakten) ===
|
||||
if ($action === 'delete') {
|
||||
$classId = (int)($body['classId'] ?? 0);
|
||||
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
|
||||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||||
|
||||
$db->execute('DELETE FROM classes WHERE id = ?', [$classId]);
|
||||
// Schüler*innen werden zu Autodidakten (class_id bleibt für Referenz, aber Klasse ist gelöscht)
|
||||
$db->execute('UPDATE classes SET deleted_at = NOW() WHERE id = ?', [$classId]);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Level-Konfiguration (Admin)
|
||||
* GET /api/levels?game_id=heli_start → Alle Levels eines Spiels
|
||||
* POST /api/levels {action} → Level erstellen/bearbeiten/löschen
|
||||
*/
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$db = Database::get();
|
||||
|
||||
if ($method === 'GET') {
|
||||
$gameId = $_GET['game_id'] ?? '';
|
||||
if (!$gameId) Response::error('game_id erforderlich');
|
||||
|
||||
$levels = $db->fetchAll(
|
||||
'SELECT id, game_id, level_name, scenario, params, sort_order FROM game_levels WHERE game_id = ? ORDER BY sort_order, id',
|
||||
[$gameId]
|
||||
);
|
||||
// Parse JSON params
|
||||
foreach ($levels as &$l) {
|
||||
$l['params'] = json_decode($l['params'], true);
|
||||
}
|
||||
Response::ok($levels);
|
||||
}
|
||||
|
||||
if ($method === 'POST') {
|
||||
// Admin check
|
||||
Session::start();
|
||||
$adminId = $_SESSION['admin_id'] ?? null;
|
||||
$teacherId = Session::teacherId();
|
||||
if (!$adminId && !$teacherId) Response::error('Nicht autorisiert', 401);
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $body['action'] ?? '';
|
||||
|
||||
if ($action === 'save') {
|
||||
$gameId = $body['gameId'] ?? '';
|
||||
$levelName = mb_substr(trim($body['levelName'] ?? ''), 0, 64);
|
||||
$scenario = $body['scenario'] ?? '';
|
||||
$params = json_encode($body['params'] ?? []);
|
||||
$sortOrder = (int)($body['sortOrder'] ?? 0);
|
||||
$levelId = (int)($body['levelId'] ?? 0);
|
||||
|
||||
if (!$gameId || !$levelName || !$scenario) Response::error('gameId, levelName und scenario erforderlich');
|
||||
|
||||
if ($levelId) {
|
||||
$db->execute(
|
||||
'UPDATE game_levels SET level_name = ?, scenario = ?, params = ?, sort_order = ?, updated_at = NOW() WHERE id = ?',
|
||||
[$levelName, $scenario, $params, $sortOrder, $levelId]
|
||||
);
|
||||
} else {
|
||||
$db->execute(
|
||||
'INSERT INTO game_levels (game_id, level_name, scenario, params, sort_order) VALUES (?, ?, ?, ?, ?)',
|
||||
[$gameId, $levelName, $scenario, $params, $sortOrder]
|
||||
);
|
||||
$levelId = (int)$db->lastInsertId();
|
||||
}
|
||||
Response::ok(['id' => $levelId]);
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$levelId = (int)($body['levelId'] ?? 0);
|
||||
if (!$levelId) Response::error('levelId erforderlich');
|
||||
$db->execute('DELETE FROM game_levels WHERE id = ?', [$levelId]);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
Response::error('Unbekannte Aktion');
|
||||
}
|
||||
|
||||
Response::error('Methode nicht erlaubt', 405);
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Lizenzverwaltung
|
||||
* GET /api/licenses?class_id=X → Lizenzen der Klasse (Lehrer)
|
||||
* GET /api/licenses?admin=1 → Alle Lizenzen (Super-Admin)
|
||||
* POST /api/licenses {action} → Lizenz einlösen/zuweisen/generieren
|
||||
*/
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$db = Database::get();
|
||||
|
||||
// === GET ===
|
||||
if ($method === 'GET') {
|
||||
// Super-Admin: alle Lizenzen
|
||||
if (isset($_GET['admin']) && $_GET['admin'] === '1') {
|
||||
Session::start();
|
||||
$adminId = $_SESSION['admin_id'] ?? null;
|
||||
$teacherId = Session::teacherId();
|
||||
if (!$adminId && !$teacherId) {
|
||||
Response::error('Nicht autorisiert', 401);
|
||||
}
|
||||
|
||||
$year = $_GET['year'] ?? null;
|
||||
$status = $_GET['status'] ?? null; // 'free', 'used'
|
||||
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
if ($year) { $where .= ' AND l.school_year = ?'; $params[] = $year; }
|
||||
if ($status === 'free') { $where .= ' AND l.student_id IS NULL'; }
|
||||
if ($status === 'used') { $where .= ' AND l.student_id IS NOT NULL'; }
|
||||
|
||||
$total = $db->fetchOne("SELECT COUNT(*) as c FROM licenses l WHERE $where", $params);
|
||||
$free = $db->fetchOne("SELECT COUNT(*) as c FROM licenses l WHERE student_id IS NULL" . ($year ? " AND school_year = ?" : ""), $year ? [$year] : []);
|
||||
$used = $db->fetchOne("SELECT COUNT(*) as c FROM licenses l WHERE student_id IS NOT NULL" . ($year ? " AND school_year = ?" : ""), $year ? [$year] : []);
|
||||
|
||||
$limit = (int)($_GET['limit'] ?? 100);
|
||||
$offset = (int)($_GET['offset'] ?? 0);
|
||||
|
||||
$licenses = $db->fetchAll(
|
||||
"SELECT l.id, l.code, l.school_year, l.student_id, l.teacher_id, l.redeemed_at, l.created_at,
|
||||
s.username as student_name, t.display_name as teacher_name
|
||||
FROM licenses l
|
||||
LEFT JOIN students s ON s.id = l.student_id
|
||||
LEFT JOIN teachers t ON t.id = l.teacher_id
|
||||
WHERE $where
|
||||
ORDER BY l.id DESC
|
||||
LIMIT $limit OFFSET $offset",
|
||||
$params
|
||||
);
|
||||
|
||||
Response::ok([
|
||||
'total' => (int)$total['c'],
|
||||
'free' => (int)$free['c'],
|
||||
'used' => (int)$used['c'],
|
||||
'licenses' => $licenses
|
||||
]);
|
||||
}
|
||||
|
||||
// Lehrer: Lizenzen einer Klasse
|
||||
$teacherId = Session::requireTeacher();
|
||||
$classId = (int)($_GET['class_id'] ?? 0);
|
||||
if (!$classId) Response::error('class_id erforderlich');
|
||||
|
||||
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
|
||||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||||
|
||||
$licenses = $db->fetchAll(
|
||||
'SELECT l.id, l.code, l.school_year, l.student_id, l.redeemed_at,
|
||||
s.username as student_name, s.display_name as student_display
|
||||
FROM licenses l
|
||||
JOIN students s ON s.id = l.student_id
|
||||
WHERE s.class_id = ?
|
||||
ORDER BY s.username',
|
||||
[$classId]
|
||||
);
|
||||
|
||||
// Auch freie Lizenzen des Lehrers (noch nicht zugewiesen)
|
||||
$freeLicenses = $db->fetchAll(
|
||||
'SELECT id, code, school_year FROM licenses WHERE teacher_id = ? AND student_id IS NULL ORDER BY code',
|
||||
[$teacherId]
|
||||
);
|
||||
|
||||
Response::ok(['assigned' => $licenses, 'free' => $freeLicenses]);
|
||||
}
|
||||
|
||||
// === POST ===
|
||||
if ($method === 'POST') {
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $body['action'] ?? '';
|
||||
|
||||
// Lizenz einlösen (Lehrer gibt Code ein → wird ihm zugeordnet)
|
||||
if ($action === 'redeem') {
|
||||
$teacherId = Session::requireTeacher();
|
||||
$code = strtoupper(trim($body['code'] ?? ''));
|
||||
if (!$code) Response::error('Lizenzcode erforderlich');
|
||||
|
||||
$license = $db->fetchOne('SELECT id, student_id, teacher_id FROM licenses WHERE code = ?', [$code]);
|
||||
if (!$license) Response::error('Lizenzcode ungültig');
|
||||
if ($license['student_id']) Response::error('Diese Lizenz ist bereits bereits zugewiesen');
|
||||
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) Response::error('Diese Lizenz gehört einer anderen Lehrperson');
|
||||
|
||||
$db->execute('UPDATE licenses SET teacher_id = ?, redeemed_at = NOW() WHERE id = ?', [$teacherId, $license['id']]);
|
||||
Response::ok(['licenseId' => (int)$license['id']]);
|
||||
}
|
||||
|
||||
// Mehrere Lizenzen auf einmal einlösen
|
||||
if ($action === 'redeem_batch') {
|
||||
$teacherId = Session::requireTeacher();
|
||||
$codes = $body['codes'] ?? [];
|
||||
if (!is_array($codes) || empty($codes)) Response::error('Lizenzcodes erforderlich');
|
||||
|
||||
$redeemed = 0;
|
||||
$errors = [];
|
||||
foreach ($codes as $code) {
|
||||
$code = strtoupper(trim($code));
|
||||
if (!$code) continue;
|
||||
$license = $db->fetchOne('SELECT id, student_id, teacher_id FROM licenses WHERE code = ?', [$code]);
|
||||
if (!$license) { $errors[] = "$code: ungültig"; continue; }
|
||||
if ($license['student_id']) { $errors[] = "$code: bereits zugewiesen"; continue; }
|
||||
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) { $errors[] = "$code: gehört anderer Lehrperson"; continue; }
|
||||
$db->execute('UPDATE licenses SET teacher_id = ?, redeemed_at = NOW() WHERE id = ?', [$teacherId, $license['id']]);
|
||||
$redeemed++;
|
||||
}
|
||||
Response::ok(['redeemed' => $redeemed, 'errors' => $errors]);
|
||||
}
|
||||
|
||||
// Lizenz einem Schüler zuweisen
|
||||
if ($action === 'assign') {
|
||||
$teacherId = Session::requireTeacher();
|
||||
$licenseId = (int)($body['licenseId'] ?? 0);
|
||||
$studentId = (int)($body['studentId'] ?? 0);
|
||||
if (!$licenseId || !$studentId) Response::error('licenseId und studentId erforderlich');
|
||||
|
||||
$license = $db->fetchOne('SELECT id, teacher_id FROM licenses WHERE id = ? AND student_id IS NULL', [$licenseId]);
|
||||
if (!$license) Response::error('Lizenz nicht gefunden oder bereits zugewiesen');
|
||||
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) Response::error('Lizenz gehört anderer Lehrperson');
|
||||
|
||||
// Prüfen dass Schüler dem Lehrer gehört
|
||||
$student = $db->fetchOne(
|
||||
'SELECT s.id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?',
|
||||
[$studentId, $teacherId]
|
||||
);
|
||||
if (!$student) Response::error('Schüler*in nicht gefunden');
|
||||
|
||||
$db->execute('UPDATE licenses SET student_id = ?, teacher_id = ? WHERE id = ?', [$studentId, $teacherId, $licenseId]);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
// Auto-Assign: nächste freie Lizenz einem Schüler zuweisen
|
||||
if ($action === 'auto_assign') {
|
||||
$teacherId = Session::requireTeacher();
|
||||
$studentId = (int)($body['studentId'] ?? 0);
|
||||
if (!$studentId) Response::error('studentId erforderlich');
|
||||
|
||||
$student = $db->fetchOne(
|
||||
'SELECT s.id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?',
|
||||
[$studentId, $teacherId]
|
||||
);
|
||||
if (!$student) Response::error('Schüler*in nicht gefunden');
|
||||
|
||||
// Nächste freie Lizenz des Lehrers
|
||||
$license = $db->fetchOne(
|
||||
'SELECT id FROM licenses WHERE teacher_id = ? AND student_id IS NULL ORDER BY id LIMIT 1',
|
||||
[$teacherId]
|
||||
);
|
||||
if (!$license) Response::error('Keine freien Lizenzen verfügbar');
|
||||
|
||||
$db->execute('UPDATE licenses SET student_id = ? WHERE id = ?', [$studentId, $license['id']]);
|
||||
Response::ok(['licenseId' => (int)$license['id']]);
|
||||
}
|
||||
|
||||
// Super-Admin: neue Lizenzen generieren
|
||||
if ($action === 'generate') {
|
||||
$teacherId = Session::requireTeacher();
|
||||
$count = min(1000, max(1, (int)($body['count'] ?? 100)));
|
||||
$year = (int)($body['year'] ?? date('Y'));
|
||||
$schoolYear = ($year % 100) . '/' . (($year % 100) + 1);
|
||||
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$generated = 0;
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$code = '';
|
||||
for ($j = 0; $j < 3; $j++) {
|
||||
if ($j > 0) $code .= '-';
|
||||
for ($k = 0; $k < 4; $k++) $code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
$code .= '-' . $year;
|
||||
try {
|
||||
$db->execute('INSERT INTO licenses (code, school_year) VALUES (?, ?)', [$code, $schoolYear]);
|
||||
$generated++;
|
||||
} catch (\Exception $e) {
|
||||
$i--; // Collision, retry
|
||||
}
|
||||
}
|
||||
Response::ok(['generated' => $generated]);
|
||||
}
|
||||
|
||||
Response::error('Unbekannte Aktion');
|
||||
}
|
||||
|
||||
Response::error('Methode nicht erlaubt', 405);
|
||||
@@ -1,26 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Modulfreigabe (Lehrer steuert welche Module Schueler sehen/spielen)
|
||||
* GET /api/modules?class_id=X → Modulstatus fuer Klasse
|
||||
* POST /api/modules {action} → Modul freigeben/sperren/starten
|
||||
* API: Modulfreigabe (Klasse + individuell pro Schüler)
|
||||
* GET /api/modules?class_id=X → Modulstatus für Klasse
|
||||
* GET /api/modules?class_id=X&matrix=1 → Matrix: alle Schüler × alle Module
|
||||
* POST /api/modules {action} → Modul freigeben/sperren
|
||||
*/
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$db = Database::get();
|
||||
|
||||
// Alle verfuegbaren Module
|
||||
$ALL_MODULES = [
|
||||
['id' => 'sim-05', 'name' => 'Klimawächter', 'desc' => 'Treibhauseffekt-Simulation'],
|
||||
['id' => 'sim-05-3d', 'name' => 'Klimawächter 3D', 'desc' => '3D-Version der Klimasimulation'],
|
||||
['id' => 'sim-07', 'name' => 'Erdbeben', 'desc' => 'Plattentektonik und Stadtplanung'],
|
||||
['id' => 'sim-09', 'name' => 'Energiemix', 'desc' => 'Energiewende planen'],
|
||||
['id' => 'sim-10', 'name' => 'Lieferketten', 'desc' => 'Globale Lieferketten verstehen'],
|
||||
['id' => 'sim-11', 'name' => 'Regenwald', 'desc' => 'Regenwald-Exploration'],
|
||||
['id' => 'sim-12', 'name' => 'Flussmanagement', 'desc' => 'Fluss und Hochwasserschutz'],
|
||||
['id' => 'sim-13', 'name' => 'Stadt & Raumplanung', 'desc' => 'Stadtsimulation mit Kacheln'],
|
||||
['id' => 'sim-05', 'name' => 'Klimawächter 2D', 'desc' => 'Treibhauseffekt-Simulation', 'icon' => '🌡️'],
|
||||
['id' => 'sim-05-3d', 'name' => 'Klimawächter 3D', 'desc' => '3D-Klimasimulation', 'icon' => '🌊'],
|
||||
['id' => 'sim-07', 'name' => 'Erdbeben', 'desc' => 'Plattentektonik & Stadtplanung', 'icon' => '🌋'],
|
||||
['id' => 'sim-09', 'name' => 'Energiemix', 'desc' => 'Energiewende planen', 'icon' => '⚡'],
|
||||
['id' => 'sim-10', 'name' => 'Lieferketten', 'desc' => 'Globale Lieferketten', 'icon' => '👕'],
|
||||
['id' => 'sim-11', 'name' => 'Regenwald', 'desc' => 'Regenwald-Expedition', 'icon' => '🌴'],
|
||||
['id' => 'sim-12', 'name' => 'Flussmanagement', 'desc' => 'Fluss & Hochwasserschutz', 'icon' => '🏞️'],
|
||||
['id' => 'sim-13', 'name' => 'Stadt & Raumplanung', 'desc' => 'Stadtsimulation mit Kacheln', 'icon' => '🏗️'],
|
||||
['id' => 'sim-14', 'name' => 'Helikopter-Navigation', 'desc' => 'Rettungshubschrauber steuern', 'icon' => '🚁'],
|
||||
];
|
||||
|
||||
if ($method === 'GET') {
|
||||
// Schüler*innen-Ansicht: welche Module sind für mich freigeschaltet?
|
||||
if (isset($_GET['student']) && $_GET['student'] === '1') {
|
||||
$sessionId = Session::requireStudent();
|
||||
$session = $db->fetchOne('SELECT class_id FROM student_sessions WHERE id = ?', [$sessionId]);
|
||||
if (!$session || !$session['class_id']) {
|
||||
// Kein Klasse = alle Module frei (Autodidakt)
|
||||
$result = [];
|
||||
foreach ($ALL_MODULES as $mod) {
|
||||
$result[] = ['id' => $mod['id'], 'name' => $mod['name'], 'icon' => $mod['icon'], 'mode' => 'free'];
|
||||
}
|
||||
Response::ok($result);
|
||||
}
|
||||
$classId = $session['class_id'];
|
||||
$classMap = [];
|
||||
$settings = $db->fetchAll('SELECT module_id, mode FROM class_modules WHERE class_id = ?', [$classId]);
|
||||
foreach ($settings as $s) $classMap[$s['module_id']] = $s['mode'];
|
||||
|
||||
// Individuelle Overrides für diesen Schüler
|
||||
// Finde student_id aus session
|
||||
$studentRow = $db->fetchOne(
|
||||
'SELECT s.id FROM students s JOIN student_sessions ss ON ss.class_id = s.class_id AND ss.display_name = s.display_name WHERE ss.id = ?',
|
||||
[$sessionId]
|
||||
);
|
||||
$overrides = [];
|
||||
if ($studentRow) {
|
||||
$rows = $db->fetchAll('SELECT module_id, mode FROM student_modules WHERE student_id = ?', [$studentRow['id']]);
|
||||
foreach ($rows as $r) $overrides[$r['module_id']] = $r['mode'];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($ALL_MODULES as $mod) {
|
||||
$mode = $overrides[$mod['id']] ?? $classMap[$mod['id']] ?? 'locked';
|
||||
$result[] = ['id' => $mod['id'], 'name' => $mod['name'], 'icon' => $mod['icon'], 'mode' => $mode];
|
||||
}
|
||||
Response::ok($result);
|
||||
}
|
||||
|
||||
$teacherId = Session::requireTeacher();
|
||||
$classId = (int)($_GET['class_id'] ?? 0);
|
||||
if (!$classId) Response::error('class_id erforderlich');
|
||||
@@ -28,22 +66,72 @@ if ($method === 'GET') {
|
||||
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
|
||||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||||
|
||||
// Aktuelle Freigaben laden
|
||||
$settings = $db->fetchAll('SELECT module_id, mode, started_at, due_date FROM class_modules WHERE class_id = ?', [$classId]);
|
||||
$settingsMap = [];
|
||||
foreach ($settings as $s) $settingsMap[$s['module_id']] = $s;
|
||||
// Klassen-Defaults laden
|
||||
$settings = $db->fetchAll('SELECT module_id, mode FROM class_modules WHERE class_id = ?', [$classId]);
|
||||
$classMap = [];
|
||||
foreach ($settings as $s) $classMap[$s['module_id']] = $s['mode'];
|
||||
|
||||
// Mit allen Modulen zusammenfuehren
|
||||
// Matrix-Modus: alle Schüler × alle Module
|
||||
if (isset($_GET['matrix']) && $_GET['matrix'] === '1') {
|
||||
$students = $db->fetchAll(
|
||||
'SELECT id, username, display_name, emoji_avatar FROM students WHERE class_id = ? ORDER BY username',
|
||||
[$classId]
|
||||
);
|
||||
|
||||
// Individuelle Overrides laden
|
||||
$studentIds = array_column($students, 'id');
|
||||
$overrides = [];
|
||||
if (!empty($studentIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($studentIds), '?'));
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT student_id, module_id, mode FROM student_modules WHERE student_id IN ($placeholders)",
|
||||
$studentIds
|
||||
);
|
||||
foreach ($rows as $r) {
|
||||
$overrides[$r['student_id']][$r['module_id']] = $r['mode'];
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix aufbauen
|
||||
$matrix = [];
|
||||
foreach ($students as $s) {
|
||||
$row = [
|
||||
'id' => (int)$s['id'],
|
||||
'username' => $s['username'],
|
||||
'displayName' => $s['display_name'] ?: $s['username'],
|
||||
'emoji' => $s['emoji_avatar'] ?: '🧑🎓',
|
||||
'modules' => [],
|
||||
];
|
||||
foreach ($ALL_MODULES as $mod) {
|
||||
$row['modules'][$mod['id']] = $overrides[$s['id']][$mod['id']] ?? $classMap[$mod['id']] ?? 'locked';
|
||||
}
|
||||
$matrix[] = $row;
|
||||
}
|
||||
|
||||
// Modul-Infos mit Klassen-Defaults
|
||||
$modulesInfo = [];
|
||||
foreach ($ALL_MODULES as $mod) {
|
||||
$modulesInfo[] = [
|
||||
'id' => $mod['id'],
|
||||
'name' => $mod['name'],
|
||||
'desc' => $mod['desc'],
|
||||
'icon' => $mod['icon'],
|
||||
'classMode' => $classMap[$mod['id']] ?? 'locked',
|
||||
];
|
||||
}
|
||||
|
||||
Response::ok(['modules' => $modulesInfo, 'students' => $matrix]);
|
||||
}
|
||||
|
||||
// Standard: nur Klassen-Defaults
|
||||
$result = [];
|
||||
foreach ($ALL_MODULES as $mod) {
|
||||
$s = $settingsMap[$mod['id']] ?? null;
|
||||
$result[] = [
|
||||
'id' => $mod['id'],
|
||||
'name' => $mod['name'],
|
||||
'desc' => $mod['desc'],
|
||||
'mode' => $s['mode'] ?? 'locked',
|
||||
'startedAt' => $s['started_at'] ?? null,
|
||||
'dueDate' => $s['due_date'] ?? null,
|
||||
'icon' => $mod['icon'],
|
||||
'mode' => $classMap[$mod['id']] ?? 'locked',
|
||||
];
|
||||
}
|
||||
Response::ok($result);
|
||||
@@ -61,24 +149,46 @@ if ($method === 'POST') {
|
||||
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
|
||||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||||
|
||||
// Klassen-Default setzen
|
||||
if ($action === 'set_mode') {
|
||||
$mode = $body['mode'] ?? 'locked';
|
||||
if (!in_array($mode, ['locked', 'free', 'teacher_started'])) Response::error('Ungültiger Modus');
|
||||
|
||||
$dueDate = $body['dueDate'] ?? null;
|
||||
$db->execute(
|
||||
'INSERT INTO class_modules (class_id, module_id, enabled, mode)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON DUPLICATE KEY UPDATE mode = VALUES(mode), enabled = 1',
|
||||
[$classId, $moduleId, $mode]
|
||||
);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
// Individuell pro Schüler setzen
|
||||
if ($action === 'set_student_mode') {
|
||||
$studentId = (int)($body['studentId'] ?? 0);
|
||||
$mode = $body['mode'] ?? 'locked';
|
||||
if (!$studentId) Response::error('studentId erforderlich');
|
||||
if (!in_array($mode, ['locked', 'free', 'teacher_started'])) Response::error('Ungültiger Modus');
|
||||
|
||||
// Prüfen dass Schüler zur Klasse gehört
|
||||
$student = $db->fetchOne('SELECT id FROM students WHERE id = ? AND class_id = ?', [$studentId, $classId]);
|
||||
if (!$student) Response::error('Schüler nicht in dieser Klasse');
|
||||
|
||||
$db->execute(
|
||||
'INSERT INTO class_modules (class_id, module_id, enabled, mode, started_at, due_date)
|
||||
VALUES (?, ?, ?, ?, NOW(), ?)
|
||||
ON DUPLICATE KEY UPDATE mode = VALUES(mode), started_at = VALUES(started_at), due_date = VALUES(due_date), enabled = 1',
|
||||
[$classId, $moduleId, 1, $mode, $dueDate]
|
||||
'INSERT INTO student_modules (student_id, module_id, mode)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE mode = VALUES(mode)',
|
||||
[$studentId, $moduleId, $mode]
|
||||
);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
// Klassen-Default für ALLE Schüler übernehmen (reset overrides)
|
||||
if ($action === 'reset_overrides') {
|
||||
$db->execute(
|
||||
"INSERT INTO activity_log (user_type, user_id, action, detail) VALUES ('teacher', ?, 'set_module_mode', ?)",
|
||||
[$teacherId, "$moduleId → $mode"]
|
||||
'DELETE sm FROM student_modules sm JOIN students s ON s.id = sm.student_id WHERE s.class_id = ? AND sm.module_id = ?',
|
||||
[$classId, $moduleId]
|
||||
);
|
||||
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Profil (Schueler + Lehrer)
|
||||
* GET /api/profile → Eigenes Profil laden
|
||||
* POST /api/profile {action} → Profil aktualisieren
|
||||
*/
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$db = Database::get();
|
||||
|
||||
if ($method === 'GET') {
|
||||
Session::start();
|
||||
$teacherId = Session::teacherId();
|
||||
$studentSessionId = Session::studentId();
|
||||
|
||||
if ($teacherId) {
|
||||
$teacher = $db->fetchOne(
|
||||
'SELECT id, username, email, display_name, school_name, created_at FROM teachers WHERE id = ?',
|
||||
[$teacherId]
|
||||
);
|
||||
Response::ok(['role' => 'teacher', 'profile' => $teacher]);
|
||||
}
|
||||
|
||||
if ($studentSessionId) {
|
||||
// Session → Student verknuepfen
|
||||
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$studentSessionId]);
|
||||
if ($session) {
|
||||
$student = $db->fetchOne(
|
||||
'SELECT s.id, s.username, s.display_name, s.first_name, s.last_name, s.email, s.emoji_avatar, s.created_at, c.name as class_name, c.join_code
|
||||
FROM students s JOIN classes c ON c.id = s.class_id
|
||||
WHERE s.class_id = ? AND s.display_name = ?',
|
||||
[$session['class_id'], $session['display_name']]
|
||||
);
|
||||
if ($student) {
|
||||
Response::ok(['role' => 'student', 'profile' => $student]);
|
||||
}
|
||||
}
|
||||
Response::ok(['role' => 'guest', 'profile' => null]);
|
||||
}
|
||||
|
||||
Response::ok(['role' => 'guest', 'profile' => null]);
|
||||
}
|
||||
|
||||
if ($method === 'POST') {
|
||||
Session::start();
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $body['action'] ?? 'update';
|
||||
|
||||
$teacherId = Session::teacherId();
|
||||
$studentSessionId = Session::studentId();
|
||||
|
||||
// Lehrperson-Profil aktualisieren
|
||||
if ($teacherId) {
|
||||
// Account löschen (Soft-Delete)
|
||||
if ($action === 'delete_account') {
|
||||
// Klassen soft-deleten
|
||||
$db->execute('UPDATE classes SET deleted_at = NOW() WHERE teacher_id = ? AND deleted_at IS NULL', [$teacherId]);
|
||||
// Lehrperson soft-deleten
|
||||
$db->execute('UPDATE teachers SET deleted_at = NOW() WHERE id = ?', [$teacherId]);
|
||||
Session::logout();
|
||||
Response::ok(['message' => 'Konto wurde gelöscht.']);
|
||||
}
|
||||
|
||||
$displayName = mb_substr(trim($body['displayName'] ?? ''), 0, 128);
|
||||
$schoolName = mb_substr(trim($body['schoolName'] ?? ''), 0, 255);
|
||||
$email = trim($body['email'] ?? '');
|
||||
$emojiAvatar = mb_substr(trim($body['emojiAvatar'] ?? ''), 0, 8);
|
||||
|
||||
if ($displayName) $db->execute('UPDATE teachers SET display_name = ? WHERE id = ?', [$displayName, $teacherId]);
|
||||
if ($schoolName !== '') $db->execute('UPDATE teachers SET school_name = ? WHERE id = ?', [$schoolName, $teacherId]);
|
||||
if ($email) {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Ungültige E-Mail-Adresse');
|
||||
$existing = $db->fetchOne('SELECT id FROM teachers WHERE email = ? AND id != ?', [$email, $teacherId]);
|
||||
if ($existing) Response::error('Diese E-Mail-Adresse wird bereits verwendet');
|
||||
$db->execute('UPDATE teachers SET email = ? WHERE id = ?', [$email, $teacherId]);
|
||||
}
|
||||
if ($emojiAvatar) $db->execute('UPDATE teachers SET emoji_avatar = ? WHERE id = ?', [$emojiAvatar, $teacherId]);
|
||||
|
||||
// Passwort ändern
|
||||
if (!empty($body['oldPassword']) && !empty($body['newPassword'])) {
|
||||
$teacher = $db->fetchOne('SELECT password FROM teachers WHERE id = ?', [$teacherId]);
|
||||
if (!password_verify($body['oldPassword'], $teacher['password'])) {
|
||||
Response::error('Altes Passwort ist falsch');
|
||||
}
|
||||
if (strlen($body['newPassword']) < 8) Response::error('Neues Passwort muss mindestens 8 Zeichen haben');
|
||||
$db->execute('UPDATE teachers SET password = ? WHERE id = ?', [password_hash($body['newPassword'], PASSWORD_DEFAULT), $teacherId]);
|
||||
}
|
||||
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
// Schueler-Profil aktualisieren
|
||||
if ($studentSessionId) {
|
||||
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$studentSessionId]);
|
||||
if (!$session) Response::error('Session nicht gefunden');
|
||||
|
||||
$student = $db->fetchOne(
|
||||
'SELECT id FROM students WHERE class_id = ? AND display_name = ?',
|
||||
[$session['class_id'], $session['display_name']]
|
||||
);
|
||||
if (!$student) Response::error('Schüler nicht gefunden');
|
||||
|
||||
$emojiAvatar = mb_substr(trim($body['emojiAvatar'] ?? ''), 0, 8);
|
||||
$displayName = mb_substr(trim($body['displayName'] ?? ''), 0, 128);
|
||||
|
||||
if ($emojiAvatar) $db->execute('UPDATE students SET emoji_avatar = ? WHERE id = ?', [$emojiAvatar, $student['id']]);
|
||||
if ($displayName) $db->execute('UPDATE students SET display_name = ? WHERE id = ?', [$displayName, $student['id']]);
|
||||
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
Response::error('Nicht angemeldet', 401);
|
||||
}
|
||||
|
||||
Response::error('Methode nicht erlaubt', 405);
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Spieler-Fortschritt + Level-System + Wirkungsklammer
|
||||
* GET /api/progress?sim_id=X → Level/XP für ein Spiel
|
||||
* GET /api/progress?student_id=X → Alle Fortschritte (Lehrer-Ansicht)
|
||||
* POST /api/progress {action} → Level updaten, Pre/Post-Antworten speichern
|
||||
*/
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$db = Database::get();
|
||||
|
||||
if ($method === 'GET') {
|
||||
$simId = $_GET['sim_id'] ?? '';
|
||||
$studentId = (int)($_GET['student_id'] ?? 0);
|
||||
|
||||
// Schüler*in: eigener Fortschritt
|
||||
if ($simId && !$studentId) {
|
||||
$sessionId = Session::requireStudent();
|
||||
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]);
|
||||
if ($session) {
|
||||
$student = $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]);
|
||||
if ($student) $studentId = (int)$student['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($studentId && $simId) {
|
||||
$progress = $db->fetchOne('SELECT level, xp, plays, best_stars FROM player_progress WHERE student_id = ? AND sim_id = ?', [$studentId, $simId]);
|
||||
$answers = $db->fetchAll('SELECT phase, question_id, answer, correct, answered_at FROM assessment_answers WHERE session_id IN (SELECT id FROM student_sessions WHERE class_id = (SELECT class_id FROM students WHERE id = ?)) AND sim_id = ? ORDER BY answered_at DESC', [$studentId, $simId]);
|
||||
Response::ok(['progress' => $progress ?: ['level'=>1,'xp'=>0,'plays'=>0,'best_stars'=>0], 'answers' => $answers]);
|
||||
}
|
||||
|
||||
// Lehrer*in: alle Fortschritte eines/r Schüler*in
|
||||
if ($studentId && !$simId) {
|
||||
$teacherId = Session::requireTeacher();
|
||||
$all = $db->fetchAll('SELECT sim_id, level, xp, plays, best_stars FROM player_progress WHERE student_id = ? ORDER BY sim_id', [$studentId]);
|
||||
Response::ok($all);
|
||||
}
|
||||
|
||||
Response::error('sim_id oder student_id erforderlich');
|
||||
}
|
||||
|
||||
if ($method === 'POST') {
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $body['action'] ?? '';
|
||||
|
||||
// === Spiel-Ergebnis speichern + Level berechnen ===
|
||||
if ($action === 'complete') {
|
||||
$sessionId = Session::requireStudent();
|
||||
$simId = $body['simId'] ?? '';
|
||||
$stars = max(0, min(5, (int)($body['stars'] ?? 0)));
|
||||
$data = json_encode($body['data'] ?? []);
|
||||
|
||||
if (!$simId) Response::error('simId erforderlich');
|
||||
|
||||
// Student-ID finden
|
||||
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]);
|
||||
$student = $session ? $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]) : null;
|
||||
if (!$student) Response::error('Schüler*in nicht gefunden');
|
||||
$studentId = (int)$student['id'];
|
||||
|
||||
// XP berechnen: 10 pro Stern + 5 Bonus für 5 Sterne
|
||||
$xpGain = $stars * 10 + ($stars >= 5 ? 5 : 0);
|
||||
|
||||
// Upsert progress
|
||||
$db->execute(
|
||||
'INSERT INTO player_progress (student_id, sim_id, level, xp, plays, best_stars)
|
||||
VALUES (?, ?, 1, ?, 1, ?)
|
||||
ON DUPLICATE KEY UPDATE xp = xp + VALUES(xp), plays = plays + 1, best_stars = GREATEST(best_stars, VALUES(best_stars))',
|
||||
[$studentId, $simId, $xpGain, $stars]
|
||||
);
|
||||
|
||||
// Level berechnen: 0-49 XP = Level 1, 50-149 = Level 2, 150-299 = Level 3, 300+ = Level 4
|
||||
$progress = $db->fetchOne('SELECT xp FROM player_progress WHERE student_id = ? AND sim_id = ?', [$studentId, $simId]);
|
||||
$xp = (int)$progress['xp'];
|
||||
$newLevel = $xp < 50 ? 1 : ($xp < 150 ? 2 : ($xp < 300 ? 3 : 4));
|
||||
$db->execute('UPDATE player_progress SET level = ? WHERE student_id = ? AND sim_id = ?', [$newLevel, $studentId, $simId]);
|
||||
|
||||
// Assessment speichern
|
||||
$db->execute(
|
||||
'INSERT INTO assessments (session_id, sim_id, class_id, results, duration_ms, submitted_at) VALUES (?, ?, ?, ?, ?, NOW())',
|
||||
[$sessionId, $simId, $session['class_id'], $data, (int)($body['durationMs'] ?? 0)]
|
||||
);
|
||||
|
||||
Response::ok(['xpGained' => $xpGain, 'totalXp' => $xp + $xpGain, 'level' => $newLevel, 'stars' => $stars]);
|
||||
}
|
||||
|
||||
// === Pre/Post-Antworten speichern ===
|
||||
if ($action === 'answer') {
|
||||
$sessionId = Session::requireStudent();
|
||||
$simId = $body['simId'] ?? '';
|
||||
$phase = $body['phase'] ?? ''; // 'pre' oder 'post'
|
||||
$questionId = (int)($body['questionId'] ?? 0);
|
||||
$answer = substr(trim($body['answer'] ?? ''), 0, 1);
|
||||
$correct = (bool)($body['correct'] ?? false);
|
||||
|
||||
if (!$simId || !$phase || !$questionId || !$answer) Response::error('Felder unvollständig');
|
||||
|
||||
$db->execute(
|
||||
'INSERT INTO assessment_answers (session_id, sim_id, phase, question_id, answer, correct) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[$sessionId, $simId, $phase, $questionId, $answer, $correct ? 1 : 0]
|
||||
);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
// === Wirkungsklammer-Ergebnis abrufen ===
|
||||
if ($action === 'wirkung') {
|
||||
$sessionId = $body['sessionId'] ?? Session::studentId();
|
||||
$simId = $body['simId'] ?? '';
|
||||
if (!$simId) Response::error('simId erforderlich');
|
||||
|
||||
$pre = $db->fetchAll('SELECT question_id, correct FROM assessment_answers WHERE session_id = ? AND sim_id = ? AND phase = "pre"', [$sessionId, $simId]);
|
||||
$post = $db->fetchAll('SELECT question_id, correct FROM assessment_answers WHERE session_id = ? AND sim_id = ? AND phase = "post"', [$sessionId, $simId]);
|
||||
|
||||
$preCorrect = count(array_filter($pre, function($a){return $a['correct'];}));
|
||||
$postCorrect = count(array_filter($post, function($a){return $a['correct'];}));
|
||||
|
||||
Response::ok([
|
||||
'pre' => ['total' => count($pre), 'correct' => $preCorrect],
|
||||
'post' => ['total' => count($post), 'correct' => $postCorrect],
|
||||
'improvement' => $postCorrect - $preCorrect,
|
||||
]);
|
||||
}
|
||||
|
||||
Response::error('Unbekannte Aktion');
|
||||
}
|
||||
|
||||
Response::error('Methode nicht erlaubt', 405);
|
||||
@@ -18,7 +18,7 @@ if ($method === 'GET') {
|
||||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||||
|
||||
$students = $db->fetchAll(
|
||||
'SELECT id, username, display_name, is_anonymous, created_at, last_login FROM students WHERE class_id = ? ORDER BY username',
|
||||
'SELECT id, username, display_name, first_name, last_name, email, emoji_avatar, is_anonymous, easy_language, created_at, last_login FROM students WHERE class_id = ? AND deleted_at IS NULL ORDER BY username',
|
||||
[$classId]
|
||||
);
|
||||
|
||||
@@ -48,10 +48,14 @@ if ($method === 'POST') {
|
||||
$username = mb_substr(trim($body['username'] ?? ''), 0, 64);
|
||||
$password = $body['password'] ?? '';
|
||||
$displayName = mb_substr(trim($body['displayName'] ?? ''), 0, 128);
|
||||
$isAnonymous = (bool)($body['isAnonymous'] ?? true);
|
||||
$firstName = mb_substr(trim($body['firstName'] ?? ''), 0, 64);
|
||||
$lastName = mb_substr(trim($body['lastName'] ?? ''), 0, 64);
|
||||
$email = trim($body['email'] ?? '');
|
||||
$isAnonymous = (bool)($body['isAnonymous'] ?? (!$firstName && !$lastName));
|
||||
|
||||
if (!$classId || !$username || !$password) Response::error('classId, username und password erforderlich');
|
||||
if (strlen($password) < 4) Response::error('Passwort muss mindestens 4 Zeichen lang sein');
|
||||
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Ungültige E-Mail-Adresse');
|
||||
|
||||
// Klasse pruefen
|
||||
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
|
||||
@@ -63,8 +67,8 @@ if ($method === 'POST') {
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$db->execute(
|
||||
'INSERT INTO students (class_id, username, password, display_name, is_anonymous) VALUES (?, ?, ?, ?, ?)',
|
||||
[$classId, $username, $hash, $displayName ?: null, $isAnonymous ? 1 : 0]
|
||||
'INSERT INTO students (class_id, username, password, display_name, first_name, last_name, email, is_anonymous) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[$classId, $username, $hash, $displayName ?: null, $firstName ?: null, $lastName ?: null, $email ?: null, $isAnonymous ? 1 : 0]
|
||||
);
|
||||
Response::ok(['id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
@@ -110,12 +114,35 @@ if ($method === 'POST') {
|
||||
);
|
||||
if (!$student) Response::error('Schüler nicht gefunden', 404);
|
||||
|
||||
$firstName = mb_substr(trim($body['firstName'] ?? ''), 0, 64);
|
||||
$lastName = mb_substr(trim($body['lastName'] ?? ''), 0, 64);
|
||||
$email = trim($body['email'] ?? '');
|
||||
$emojiAvatar = mb_substr(trim($body['emojiAvatar'] ?? ''), 0, 8);
|
||||
|
||||
if ($displayName) {
|
||||
$db->execute('UPDATE students SET display_name = ? WHERE id = ?', [$displayName, $studentId]);
|
||||
}
|
||||
if ($firstName !== '') {
|
||||
$db->execute('UPDATE students SET first_name = ? WHERE id = ?', [$firstName, $studentId]);
|
||||
}
|
||||
if ($lastName !== '') {
|
||||
$db->execute('UPDATE students SET last_name = ? WHERE id = ?', [$lastName, $studentId]);
|
||||
}
|
||||
if ($email !== '') {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Ungültige E-Mail-Adresse');
|
||||
$db->execute('UPDATE students SET email = ? WHERE id = ?', [$email, $studentId]);
|
||||
}
|
||||
if ($emojiAvatar !== '') {
|
||||
$db->execute('UPDATE students SET emoji_avatar = ? WHERE id = ?', [$emojiAvatar, $studentId]);
|
||||
}
|
||||
if ($newPassword && strlen($newPassword) >= 4) {
|
||||
$db->execute('UPDATE students SET password = ? WHERE id = ?', [password_hash($newPassword, PASSWORD_DEFAULT), $studentId]);
|
||||
}
|
||||
// Leichte Sprache (nur setzen wenn Feld mitgeschickt)
|
||||
if (array_key_exists('easyLanguage', $body)) {
|
||||
$flag = !empty($body['easyLanguage']) ? 1 : 0;
|
||||
$db->execute('UPDATE students SET easy_language = ? WHERE id = ?', [$flag, $studentId]);
|
||||
}
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
@@ -128,7 +155,7 @@ if ($method === 'POST') {
|
||||
);
|
||||
if (!$student) Response::error('Schüler nicht gefunden', 404);
|
||||
|
||||
$db->execute('DELETE FROM students WHERE id = ?', [$studentId]);
|
||||
$db->execute('UPDATE students SET deleted_at = NOW() WHERE id = ?', [$studentId]);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Einladungstickets (druckbare HTML-Seite)
|
||||
* GET /api/tickets?class_id=X → Druckbare Zugangskarten fuer alle Schueler der Klasse
|
||||
*/
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
Response::error('Nur GET erlaubt', 405);
|
||||
}
|
||||
|
||||
$teacherId = Session::requireTeacher();
|
||||
$db = Database::get();
|
||||
$classId = (int)($_GET['class_id'] ?? 0);
|
||||
if (!$classId) Response::error('class_id erforderlich');
|
||||
|
||||
$class = $db->fetchOne(
|
||||
'SELECT id, name, school_year, join_code FROM classes WHERE id = ? AND teacher_id = ?',
|
||||
[$classId, $teacherId]
|
||||
);
|
||||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||||
|
||||
$students = $db->fetchAll(
|
||||
'SELECT username, display_name, emoji_avatar FROM students WHERE class_id = ? ORDER BY username',
|
||||
[$classId]
|
||||
);
|
||||
|
||||
// Druckbare HTML-Seite ausgeben (kein JSON)
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Zugangskarten — <?= htmlspecialchars($class['name']) ?></title>
|
||||
<style>
|
||||
@page { size: A4; margin: 10mm; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', 'Segoe UI', system-ui, sans-serif; background: #fff; color: #1a1a1a; }
|
||||
.print-header { text-align: center; padding: 1rem 0 .5rem; border-bottom: 2px solid #4a7c8a; margin-bottom: 1rem; }
|
||||
.print-header h1 { font-size: 1.1rem; color: #4a7c8a; }
|
||||
.print-header p { font-size: .75rem; color: #8a8a8a; }
|
||||
.tickets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8mm; }
|
||||
.ticket {
|
||||
border: 2px solid #4a7c8a; border-radius: 10px; padding: 10px;
|
||||
page-break-inside: avoid; position: relative; min-height: 140px;
|
||||
}
|
||||
.ticket-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; border-bottom: 1px dashed #dae8ec; padding-bottom: 6px; }
|
||||
.ticket-emoji { font-size: 1.8rem; }
|
||||
.ticket-brand { font-size: .6rem; font-weight: 800; color: #4a7c8a; letter-spacing: .02em; }
|
||||
.ticket-class { font-size: .55rem; color: #8a8a8a; }
|
||||
.ticket-row { display: flex; gap: 4px; margin-bottom: 4px; font-size: .7rem; }
|
||||
.ticket-label { color: #8a8a8a; font-weight: 500; min-width: 80px; }
|
||||
.ticket-val { font-weight: 700; font-family: 'Courier New', monospace; letter-spacing: .04em; }
|
||||
.ticket-hint { font-size: .55rem; color: #aaa; margin-top: 6px; text-align: center; }
|
||||
.ticket-url { font-size: .55rem; color: #4a7c8a; text-align: center; margin-top: 4px; font-weight: 600; }
|
||||
.no-print { text-align: center; padding: 1rem; }
|
||||
.no-print button { padding: .5rem 1.5rem; background: #4a7c8a; color: #fff; border: none; border-radius: 8px; font-size: .85rem; font-weight: 700; cursor: pointer; margin: 0 .3rem; }
|
||||
.no-print button:hover { background: #3a6470; }
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="no-print">
|
||||
<button onclick="window.print()">Drucken / als PDF speichern</button>
|
||||
<button onclick="window.close()">Schliessen</button>
|
||||
</div>
|
||||
<div class="print-header">
|
||||
<h1>GeoGraSim — Zugangskarten</h1>
|
||||
<p>Klasse <?= htmlspecialchars($class['name']) ?> · <?= htmlspecialchars($class['school_year']) ?> · Code: <?= htmlspecialchars($class['join_code']) ?></p>
|
||||
</div>
|
||||
<div class="tickets">
|
||||
<?php foreach ($students as $s): ?>
|
||||
<div class="ticket">
|
||||
<div class="ticket-top">
|
||||
<span class="ticket-emoji"><?= $s['emoji_avatar'] ?: '🧑🎓' ?></span>
|
||||
<div>
|
||||
<div class="ticket-brand">GeoGraSim</div>
|
||||
<div class="ticket-class">Klasse <?= htmlspecialchars($class['name']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-row"><span class="ticket-label">Klassencode:</span><span class="ticket-val"><?= htmlspecialchars($class['join_code']) ?></span></div>
|
||||
<div class="ticket-row"><span class="ticket-label">Benutzer:</span><span class="ticket-val"><?= htmlspecialchars($s['username']) ?></span></div>
|
||||
<div class="ticket-row"><span class="ticket-label">Passwort:</span><span class="ticket-val">****</span></div>
|
||||
<div class="ticket-hint">Passwort wurde bei der Erstellung angezeigt.</div>
|
||||
<div class="ticket-url">geograsim.at</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<?php exit;
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Waypoints (Geo-Koordinaten)
|
||||
* GET /api/waypoints → Alle Waypoints
|
||||
* GET /api/waypoints?region=tirol → Gefiltert
|
||||
* POST /api/waypoints {action} → CRUD (Admin)
|
||||
*/
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$db = Database::get();
|
||||
|
||||
if ($method === 'GET') {
|
||||
$region = $_GET['region'] ?? '';
|
||||
$type = $_GET['type'] ?? '';
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
if ($region) { $where .= ' AND region = ?'; $params[] = $region; }
|
||||
if ($type) { $where .= ' AND wp_type = ?'; $params[] = $type; }
|
||||
$wps = $db->fetchAll("SELECT id, wp_key, name, lat, lon, region, wp_type, info FROM geo_waypoints WHERE $where ORDER BY region, wp_type, name", $params);
|
||||
Response::ok($wps);
|
||||
}
|
||||
|
||||
if ($method === 'POST') {
|
||||
Session::start();
|
||||
$adminId = $_SESSION['admin_id'] ?? null;
|
||||
$teacherId = Session::teacherId();
|
||||
if (!$adminId && !$teacherId) Response::error('Nicht autorisiert', 401);
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $body['action'] ?? '';
|
||||
|
||||
if ($action === 'save') {
|
||||
$wpKey = preg_replace('/[^a-z0-9_]/', '', strtolower(trim($body['wpKey'] ?? '')));
|
||||
$name = mb_substr(trim($body['name'] ?? ''), 0, 100);
|
||||
$lat = (float)($body['lat'] ?? 0);
|
||||
$lon = (float)($body['lon'] ?? 0);
|
||||
$region = trim($body['region'] ?? 'vorarlberg');
|
||||
$wpType = $body['wpType'] ?? 'village';
|
||||
$info = mb_substr(trim($body['info'] ?? ''), 0, 255);
|
||||
if (!$wpKey || !$name || !$lat || !$lon) Response::error('wpKey, name, lat, lon erforderlich');
|
||||
|
||||
$db->execute(
|
||||
'INSERT INTO geo_waypoints (wp_key, name, lat, lon, region, wp_type, info)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), lat=VALUES(lat), lon=VALUES(lon), region=VALUES(region), wp_type=VALUES(wp_type), info=VALUES(info)',
|
||||
[$wpKey, $name, $lat, $lon, $region, $wpType, $info ?: null]
|
||||
);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) Response::error('id erforderlich');
|
||||
$db->execute('DELETE FROM geo_waypoints WHERE id = ?', [$id]);
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
Response::error('Unbekannte Aktion');
|
||||
}
|
||||
|
||||
Response::error('Methode nicht erlaubt', 405);
|
||||
@@ -5,9 +5,25 @@
|
||||
*/
|
||||
|
||||
// Umgebungserkennung via Hostname
|
||||
define('IS_PRODUCTION', isset($_SERVER['HTTP_HOST']) && $_SERVER['HTTP_HOST'] !== 'localhost');
|
||||
define('BASE_PATH', '/geograsim/App');
|
||||
define('BASE_URL', (IS_PRODUCTION ? 'https://www.staatsgeheimnis.at' : 'http://localhost') . BASE_PATH);
|
||||
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
|
||||
// Docker-Container oder fehlender Host = Production wenn .env.production existiert
|
||||
$envProdExists = file_exists(dirname(__DIR__, 2) . '/.env.production');
|
||||
define('IS_PRODUCTION', $host !== 'localhost' || ($envProdExists && !file_exists(dirname(__DIR__, 2) . '/.env.local')));
|
||||
|
||||
// BASE_PATH: konfigurierbar per Host
|
||||
// geograsim.at → /
|
||||
// staatsgeheimnis.at/geograsim/ → /geograsim
|
||||
// localhost/geograsim/App/ → /geograsim/App
|
||||
if (strpos($host, 'geograsim.at') !== false) {
|
||||
define('BASE_PATH', '');
|
||||
define('BASE_URL', 'https://geograsim.at');
|
||||
} elseif (strpos($host, 'staatsgeheimnis.at') !== false) {
|
||||
define('BASE_PATH', '/geograsim');
|
||||
define('BASE_URL', 'https://www.staatsgeheimnis.at/geograsim');
|
||||
} else {
|
||||
define('BASE_PATH', '/geograsim/App');
|
||||
define('BASE_URL', 'http://localhost/geograsim/App');
|
||||
}
|
||||
define('APP_ROOT', dirname(__DIR__, 2));
|
||||
|
||||
// .env laden
|
||||
|
||||
@@ -13,6 +13,7 @@ function getDB(): PDO {
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||
]);
|
||||
}
|
||||
return $pdo;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
/**
|
||||
* GeoGraSim — E-Mail-Versand via PHPMailer
|
||||
* Alle E-Mails im einheitlichen Design (Petrol #1f4e5a, Türkis #4a7c8a, Orange #e8833a)
|
||||
*/
|
||||
|
||||
class Mailer {
|
||||
|
||||
private static function create(): \PHPMailer\PHPMailer\PHPMailer {
|
||||
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = defined('SMTP_HOST') ? SMTP_HOST : 'smtp.world4you.com';
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = defined('SMTP_USER') ? SMTP_USER : 'support@geograsim.at';
|
||||
$mail->Password = defined('SMTP_PASS') ? SMTP_PASS : 'geo8Zeichen!';
|
||||
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Port = defined('SMTP_PORT') ? (int)SMTP_PORT : 587;
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->setFrom(
|
||||
defined('SMTP_FROM') ? SMTP_FROM : 'support@geograsim.at',
|
||||
defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'GeoGraSim'
|
||||
);
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public static function send(string $to, string $subject, string $htmlBody, string $textBody = ''): bool {
|
||||
try {
|
||||
$mail = self::create();
|
||||
$mail->addAddress($to);
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = self::wrapHtml($htmlBody);
|
||||
$mail->AltBody = $textBody ?: strip_tags(str_replace(['<br>','<br/>','</p>','</li>'], "\n", $htmlBody));
|
||||
$mail->send();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
error_log('Mailer error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Willkommen (Lehrperson registriert sich) ───
|
||||
|
||||
public static function sendWelcomeTeacher(string $email, string $name): bool {
|
||||
$n = htmlspecialchars($name);
|
||||
$html = self::greeting($n)
|
||||
. self::text('Willkommen bei <strong>GeoGraSim</strong> — interaktive Simulationen für den Geografie-Unterricht!')
|
||||
. self::text('Ihr Konto wurde erfolgreich erstellt. Sie können jetzt:')
|
||||
. self::checklist([
|
||||
'Klassen anlegen und Schüler*innen-Zugänge generieren',
|
||||
'Module freischalten und den Unterricht steuern',
|
||||
'Ergebnisse und Fortschritte im Dashboard verfolgen',
|
||||
])
|
||||
. self::button('Zum Dashboard', 'https://geograsim.at/teacher.html')
|
||||
. self::hint('Im <a href="https://geograsim.at/handbuch.html" style="color:#4a7c8a">Didaktischen Handbuch</a> finden Sie zu jedem Modul einen fertigen 45-Minuten-Unterrichtsentwurf.');
|
||||
return self::send($email, 'Willkommen bei GeoGraSim', $html);
|
||||
}
|
||||
|
||||
// ─── Zugangsdaten (Schüler*in wurde erstellt) ───
|
||||
|
||||
public static function sendStudentCredentials(string $email, string $studentName, string $username, string $password, string $classCode, string $className): bool {
|
||||
$n = htmlspecialchars($studentName);
|
||||
$html = self::greeting($n)
|
||||
. self::text('Du hast einen Zugang zu <strong>GeoGraSim</strong> erhalten — interaktive Simulationen für den Geografie-Unterricht.')
|
||||
. self::credentialBox($username, $password, $classCode, $className)
|
||||
. self::button('Jetzt anmelden', 'https://geograsim.at/login.html')
|
||||
. self::hint('Bewahre diese Zugangsdaten gut auf. Bei Problemen wende dich an deine Lehrperson.');
|
||||
return self::send($email, 'Dein GeoGraSim-Zugang', $html);
|
||||
}
|
||||
|
||||
// ─── Passwort-Reset ───
|
||||
|
||||
public static function sendPasswordReset(string $email, string $name, string $token): bool {
|
||||
$link = 'https://geograsim.at/login.html?reset=' . urlencode($token);
|
||||
$n = htmlspecialchars($name);
|
||||
$html = self::greeting($n)
|
||||
. self::text('Sie haben ein neues Passwort für Ihr GeoGraSim-Konto angefordert.')
|
||||
. self::text('Klicken Sie auf den Button, um ein neues Passwort zu wählen:')
|
||||
. self::button('Neues Passwort wählen', $link)
|
||||
. self::hint('Dieser Link ist <strong>1 Stunde</strong> gültig und kann nur einmal verwendet werden.')
|
||||
. self::hint('Falls Sie kein neues Passwort angefordert haben, können Sie diese E-Mail ignorieren. Ihr bestehendes Passwort bleibt unverändert.')
|
||||
. self::hint('Bei Fragen wenden Sie sich an <a href="mailto:support@geograsim.at" style="color:#4a7c8a">support@geograsim.at</a>.');
|
||||
return self::send($email, 'GeoGraSim — Passwort zurücksetzen', $html);
|
||||
}
|
||||
|
||||
// ─── Admin 2FA PIN ───
|
||||
|
||||
public static function sendAdminPin(string $email, string $pin): bool {
|
||||
$html = self::greeting('Admin')
|
||||
. self::text('Ihr Anmelde-PIN für die GeoGraSim-Administration:')
|
||||
. self::pinBox($pin)
|
||||
. self::hint('Dieser PIN ist <strong>10 Minuten</strong> gültig.');
|
||||
return self::send($email, 'GeoGraSim Admin — Anmelde-PIN', $html);
|
||||
}
|
||||
|
||||
// ─── Batch-Zugangsdaten (Lehrperson bekommt Übersicht) ───
|
||||
|
||||
public static function sendBatchCredentials(string $teacherEmail, string $teacherName, string $className, array $students): bool {
|
||||
$n = htmlspecialchars($teacherName);
|
||||
$html = self::greeting($n)
|
||||
. self::text('Die folgenden Zugangsdaten wurden für Klasse <strong>' . htmlspecialchars($className) . '</strong> erstellt:')
|
||||
. self::studentTable($students, $className)
|
||||
. self::button('Zum Dashboard', 'https://geograsim.at/teacher.html')
|
||||
. self::hint('Sie können diese Zugangsdaten auch im Dashboard unter „Tickets drucken" als PDF herunterladen.');
|
||||
return self::send($teacherEmail, 'GeoGraSim — Zugangsdaten für ' . $className, $html);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Bausteine
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
private static function greeting(string $name): string {
|
||||
return '<p style="font-size:16px;color:#1f4e5a;margin:0 0 12px">Hallo ' . $name . ',</p>';
|
||||
}
|
||||
|
||||
private static function text(string $t): string {
|
||||
return '<p style="font-size:14px;color:#333;line-height:1.6;margin:0 0 12px">' . $t . '</p>';
|
||||
}
|
||||
|
||||
private static function button(string $label, string $url): string {
|
||||
return '<p style="text-align:center;margin:20px 0">'
|
||||
. '<a href="' . $url . '" style="display:inline-block;padding:12px 28px;background:#4a7c8a;color:#fff;text-decoration:none;border-radius:8px;font-weight:700;font-size:14px">'
|
||||
. $label . '</a></p>';
|
||||
}
|
||||
|
||||
private static function hint(string $t): string {
|
||||
return '<p style="font-size:12px;color:#8a8a8a;line-height:1.5;margin:16px 0 0">' . $t . '</p>';
|
||||
}
|
||||
|
||||
private static function checklist(array $items): string {
|
||||
$html = '<table cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 12px">';
|
||||
foreach ($items as $item) {
|
||||
$html .= '<tr><td style="padding:3px 8px 3px 0;font-size:14px;color:#5a8a5e;vertical-align:top">✓</td>'
|
||||
. '<td style="padding:3px 0;font-size:14px;color:#333">' . $item . '</td></tr>';
|
||||
}
|
||||
return $html . '</table>';
|
||||
}
|
||||
|
||||
private static function credentialBox(string $username, string $password, string $classCode, string $className): string {
|
||||
return '<div style="background:#f7f6f3;border:1.5px solid #dae8ec;border-radius:10px;padding:16px;margin:16px 0">'
|
||||
. '<table cellpadding="0" cellspacing="0" border="0" style="width:100%">'
|
||||
. self::credRow('Klassencode', $classCode, '#e8833a')
|
||||
. self::credRow('Klasse', htmlspecialchars($className), '#333')
|
||||
. self::credRow('Benutzername', $username, '#1f4e5a')
|
||||
. self::credRow('Passwort', $password, '#1f4e5a')
|
||||
. '</table>'
|
||||
. '<p style="font-size:11px;color:#8a8a8a;margin:10px 0 0;text-align:center">geograsim.at</p>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
private static function credRow(string $label, string $value, string $color): string {
|
||||
return '<tr><td style="padding:4px 0;font-size:12px;color:#8a8a8a;width:110px">' . $label . '</td>'
|
||||
. '<td style="padding:4px 0;font-size:15px;font-weight:700;font-family:\'Courier New\',monospace;color:' . $color . ';letter-spacing:.04em">' . htmlspecialchars($value) . '</td></tr>';
|
||||
}
|
||||
|
||||
private static function pinBox(string $pin): string {
|
||||
return '<div style="text-align:center;margin:20px 0">'
|
||||
. '<div style="display:inline-block;padding:16px 32px;background:#1f4e5a;border-radius:12px;font-size:32px;font-weight:900;letter-spacing:.2em;color:#fff;font-family:\'Courier New\',monospace">'
|
||||
. htmlspecialchars($pin)
|
||||
. '</div></div>';
|
||||
}
|
||||
|
||||
private static function studentTable(array $students, string $className): string {
|
||||
$html = '<table cellpadding="0" cellspacing="0" border="0" style="width:100%;margin:12px 0;border-collapse:collapse">';
|
||||
$html .= '<tr><th style="text-align:left;padding:6px 8px;font-size:11px;color:#8a8a8a;border-bottom:2px solid #dae8ec">Benutzer</th>'
|
||||
. '<th style="text-align:left;padding:6px 8px;font-size:11px;color:#8a8a8a;border-bottom:2px solid #dae8ec">Passwort</th></tr>';
|
||||
foreach ($students as $s) {
|
||||
$html .= '<tr><td style="padding:4px 8px;font-size:13px;font-weight:600;border-bottom:1px solid #f0eeea">' . htmlspecialchars($s['username']) . '</td>'
|
||||
. '<td style="padding:4px 8px;font-size:13px;font-family:\'Courier New\',monospace;color:#1f4e5a;border-bottom:1px solid #f0eeea">' . htmlspecialchars($s['password']) . '</td></tr>';
|
||||
}
|
||||
return $html . '</table>';
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// HTML-Wrapper (Briefpapier)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
private static function wrapHtml(string $content): string {
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="margin:0;padding:0;background:#f7f6f3;font-family:'Inter','Segoe UI',system-ui,sans-serif">
|
||||
<div style="max-width:540px;margin:0 auto;padding:24px 16px">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="text-align:center;padding:20px 0 16px">
|
||||
<img src="https://geograsim.at/assets/img/geograsim_t.svg" alt="GeoGraSim" style="height:38px">
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div style="background:#fff;border-radius:12px;padding:28px 24px;box-shadow:0 2px 12px rgba(0,0,0,.06);border:1px solid rgba(0,0,0,.04)">
|
||||
{$content}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="text-align:center;padding:20px 0 8px">
|
||||
<p style="font-size:11px;color:#aaa;margin:0">GeoGraSim — Interaktive Simulationen für den GW-Unterricht</p>
|
||||
<p style="font-size:11px;margin:4px 0 0"><a href="https://geograsim.at" style="color:#4a7c8a;text-decoration:none">geograsim.at</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Landscape decoration -->
|
||||
<div style="text-align:center;opacity:.08;margin-top:4px">
|
||||
<span style="font-size:10px;letter-spacing:2px;color:#1f4e5a">🏔️ 🌲 🌲 🏙️ 🌊 ⛵ 🌊</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,27 @@ function injectSessionContext(): void {
|
||||
echo '<script>window.__GGS__ = ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . ';</script>' . "\n";
|
||||
}
|
||||
|
||||
/** Seite rendern: HTML laden, Session-Kontext injizieren, ausgeben */
|
||||
/** Seite rendern: HTML laden, Session-Kontext + Favicon injizieren, ausgeben */
|
||||
function renderPage(string $htmlFile): void {
|
||||
$html = file_get_contents(APP_ROOT . '/' . $htmlFile);
|
||||
|
||||
// Favicon-Block (ersetzt alte logo.png Referenz)
|
||||
$bp = BASE_PATH;
|
||||
$favicon = <<<HTML
|
||||
<link rel="icon" type="image/png" href="{$bp}/favicon-96x96.png" sizes="96x96">
|
||||
<link rel="icon" type="image/svg+xml" href="{$bp}/favicon.svg">
|
||||
<link rel="shortcut icon" href="{$bp}/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{$bp}/apple-touch-icon.png">
|
||||
<link rel="manifest" href="{$bp}/site.webmanifest">
|
||||
HTML;
|
||||
|
||||
// Alten Favicon-Link ersetzen oder vor </head> einfuegen
|
||||
if (preg_match('/<link[^>]*rel=["\']icon["\'][^>]*>/', $html)) {
|
||||
$html = preg_replace('/<link[^>]*rel=["\']icon["\'][^>]*>\s*/', '', $html);
|
||||
}
|
||||
$html = str_replace('</head>', $favicon . "\n</head>", $html);
|
||||
|
||||
// Session-Kontext
|
||||
$ctx = '<script>window.__GGS__ = ' . json_encode([
|
||||
'sessionId' => Session::studentId(),
|
||||
'teacherId' => Session::teacherId(),
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Profil</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh}
|
||||
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:10}
|
||||
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem}
|
||||
.topbar h1 img{height:24px}
|
||||
.topbar .btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
|
||||
.topbar .btn-sm:hover{background:#dae8ec;color:#4a7c8a}
|
||||
.wrap{max-width:600px;margin:2rem auto;padding:0 1rem}
|
||||
.card{background:#fff;border-radius:12px;padding:1.2rem;box-shadow:0 2px 8px rgba(0,0,0,.04);border:1px solid rgba(0,0,0,.04);margin-bottom:1rem}
|
||||
.card-h{font-size:.62rem;font-weight:700;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.6rem}
|
||||
.avatar-section{text-align:center;padding:1rem 0}
|
||||
.avatar-current{font-size:4rem;cursor:pointer;transition:transform .2s}
|
||||
.avatar-current:hover{transform:scale(1.15)}
|
||||
.avatar-name{font-size:1.1rem;font-weight:700;margin-top:.3rem}
|
||||
.avatar-role{font-size:.72rem;color:#8a8a8a;margin-top:.1rem}
|
||||
.avatar-picker{display:grid;grid-template-columns:repeat(8,1fr);gap:.3rem;margin-top:.8rem;display:none}
|
||||
.avatar-picker.open{display:grid}
|
||||
.avatar-opt{font-size:1.6rem;text-align:center;cursor:pointer;padding:.3rem;border-radius:8px;transition:all .15s}
|
||||
.avatar-opt:hover{background:#dae8ec;transform:scale(1.2)}
|
||||
.avatar-opt.active{background:#4a7c8a;border-radius:8px}
|
||||
.field{margin-bottom:.8rem}
|
||||
.field label{display:block;font-size:.68rem;font-weight:600;color:#4a4a4a;margin-bottom:.15rem}
|
||||
.field input{width:100%;padding:.45rem .65rem;border:1.5px solid rgba(0,0,0,.1);border-radius:7px;font-size:.8rem;font-family:inherit;outline:none;transition:border .15s}
|
||||
.field input:focus{border-color:#4a7c8a}
|
||||
.field input:disabled{background:#f7f6f3;color:#8a8a8a}
|
||||
.btn{padding:.45rem .9rem;border:none;border-radius:7px;background:#4a7c8a;color:#fff;font-size:.75rem;font-weight:700;cursor:pointer;transition:background .15s}
|
||||
.btn:hover{background:#3a6470}
|
||||
.btn-danger{background:#c07a6b}
|
||||
.btn-danger:hover{background:#a05a4b}
|
||||
.msg{font-size:.72rem;padding:.35rem .6rem;border-radius:6px;margin-top:.5rem;display:none}
|
||||
.msg.ok{display:block;background:#dceadd;color:#5a8a5e}
|
||||
.msg.err{display:block;background:#f2ddd8;color:#c07a6b}
|
||||
.info-row{display:flex;justify-content:space-between;padding:.3rem 0;border-bottom:1px solid rgba(0,0,0,.03);font-size:.78rem}
|
||||
.info-row:last-child{border:none}
|
||||
.info-label{color:#8a8a8a;font-weight:500}
|
||||
.info-val{font-weight:600}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim" style="height:30px"> Mein Profil</h1>
|
||||
<a href="schueler.html" class="btn-sm" id="nav-home">Cockpit</a>
|
||||
<button class="btn-sm" onclick="logout()">Abmelden</button>
|
||||
</div>
|
||||
|
||||
<div class="wrap" id="content">
|
||||
<div style="text-align:center;padding:3rem;color:#8a8a8a">Laden...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var BASE = (window.__GGS__?.baseUrl) || '';
|
||||
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
|
||||
var EMOJI_OPTIONS = [
|
||||
'🧑🎓','👩🎓','👨🎓','🧑🏫','👩🏫','👨🏫',
|
||||
'👩','👨','🧑','👧','👦','👶',
|
||||
'👩🦰','👨🦱','👩🦳','👱♀️','👱♂️','🧔',
|
||||
'🦊','🐱','🐶','🐻','🐼','🦁',
|
||||
'🐸','🐧','🦉','🐝','🦋','🐢','🐬',
|
||||
'🌟','🌈','🔥','❄️','🌺','🍀',
|
||||
'🎵','🎮','⚽','🏀','🎨','📚','🔬','🌍','🚀','🏔️'
|
||||
];
|
||||
|
||||
async function api(endpoint, data) {
|
||||
var opts = data !== undefined
|
||||
? {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(data)}
|
||||
: {credentials:'same-origin'};
|
||||
var r = await fetch(BASE + '/api/' + endpoint, opts);
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
api('auth', {action:'logout'}).then(function() { location.href = 'login.html'; });
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
var r = await api('profile');
|
||||
if (r.role === 'guest' || !r.profile) {
|
||||
location.href = 'login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.role === 'student') {
|
||||
renderStudentProfile(r.profile);
|
||||
document.getElementById('nav-home').href = 'schueler.html';
|
||||
document.getElementById('nav-home').textContent = 'Cockpit';
|
||||
} else if (r.role === 'teacher') {
|
||||
renderTeacherProfile(r.profile);
|
||||
document.getElementById('nav-home').href = 'teacher.html';
|
||||
document.getElementById('nav-home').textContent = 'Dashboard';
|
||||
}
|
||||
}
|
||||
|
||||
function renderStudentProfile(p) {
|
||||
var html = '';
|
||||
// Avatar + Name
|
||||
html += '<div class="card"><div class="avatar-section">';
|
||||
html += '<div class="avatar-current" id="avatar-btn" onclick="togglePicker()">' + (p.emoji_avatar || '🧑🎓') + '</div>';
|
||||
html += '<div class="avatar-name">' + (p.display_name || p.username) + '</div>';
|
||||
html += '<div class="avatar-role">Klasse ' + (p.class_name || '') + '</div>';
|
||||
html += '<div class="avatar-picker" id="avatar-picker">';
|
||||
EMOJI_OPTIONS.forEach(function(e) {
|
||||
var cls = e === (p.emoji_avatar || '🧑🎓') ? ' active' : '';
|
||||
html += '<div class="avatar-opt' + cls + '" onclick="selectAvatar(\'' + e + '\')">' + e + '</div>';
|
||||
});
|
||||
html += '</div></div></div>';
|
||||
|
||||
// Info
|
||||
html += '<div class="card"><div class="card-h">Kontoinformationen</div>';
|
||||
html += '<div class="info-row"><span class="info-label">Benutzername</span><span class="info-val">' + p.username + '</span></div>';
|
||||
html += '<div class="info-row"><span class="info-label">Klasse</span><span class="info-val">' + (p.class_name || '—') + '</span></div>';
|
||||
html += '<div class="info-row"><span class="info-label">Klassencode</span><span class="info-val">' + (p.join_code || '—') + '</span></div>';
|
||||
html += '<div class="info-row"><span class="info-label">Dabei seit</span><span class="info-val">' + new Date(p.created_at).toLocaleDateString('de-AT') + '</span></div>';
|
||||
html += '</div>';
|
||||
|
||||
// Anzeigename ändern
|
||||
html += '<div class="card"><div class="card-h">Anzeigename</div>';
|
||||
html += '<div class="field"><input type="text" id="pf-name" value="' + (p.display_name || '') + '" placeholder="Dein Anzeigename"></div>';
|
||||
html += '<button class="btn" onclick="saveName()">Speichern</button>';
|
||||
html += '<div class="msg" id="msg-name"></div>';
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('content').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderTeacherProfile(p) {
|
||||
var html = '';
|
||||
html += '<div class="card"><div class="avatar-section">';
|
||||
html += '<div class="avatar-current" id="avatar-btn" onclick="togglePicker()">' + (p.emoji_avatar || '👩🏫') + '</div>';
|
||||
html += '<div class="avatar-name">' + (p.display_name || '') + '</div>';
|
||||
html += '<div class="avatar-role">Lehrperson' + (p.school_name ? ' · ' + p.school_name : '') + '</div>';
|
||||
html += '<div class="avatar-picker" id="avatar-picker">';
|
||||
EMOJI_OPTIONS.forEach(function(e) {
|
||||
var cls = e === (p.emoji_avatar || '👩🏫') ? ' active' : '';
|
||||
html += '<div class="avatar-opt' + cls + '" onclick="selectAvatar(\'' + e + '\')">' + e + '</div>';
|
||||
});
|
||||
html += '</div></div></div>';
|
||||
|
||||
html += '<div class="card"><div class="card-h">Kontoinformationen</div>';
|
||||
html += '<div class="info-row"><span class="info-label">E-Mail</span><span class="info-val">' + (p.email || '—') + '</span></div>';
|
||||
html += '<div class="info-row"><span class="info-label">Schule</span><span class="info-val">' + (p.school_name || '—') + '</span></div>';
|
||||
html += '<div class="info-row"><span class="info-label">Dabei seit</span><span class="info-val">' + new Date(p.created_at).toLocaleDateString('de-AT') + '</span></div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="card"><div class="card-h">Profil bearbeiten</div>';
|
||||
html += '<div class="field"><label>Anzeigename</label><input type="text" id="pf-name" value="' + (p.display_name || '') + '"></div>';
|
||||
html += '<div class="field"><label>E-Mail</label><input type="email" id="pf-email" value="' + (p.email || '') + '"></div>';
|
||||
html += '<div class="field"><label>Schule</label><input type="text" id="pf-school" value="' + (p.school_name || '') + '"></div>';
|
||||
html += '<button class="btn" onclick="saveTeacher()">Speichern</button>';
|
||||
html += '<div class="msg" id="msg-teacher"></div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="card"><div class="card-h">Passwort ändern</div>';
|
||||
html += '<div class="field"><label>Altes Passwort</label><input type="password" id="pf-oldpw"></div>';
|
||||
html += '<div class="field"><label>Neues Passwort (mind. 8 Zeichen)</label><input type="password" id="pf-newpw"></div>';
|
||||
html += '<button class="btn" onclick="changePw()">Passwort ändern</button>';
|
||||
html += '<div class="msg" id="msg-pw"></div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="card">';
|
||||
html += '<a href="teacher.html" class="btn" style="text-decoration:none;display:inline-block">Zum Dashboard</a>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="card" style="border-color:#f2ddd8">';
|
||||
html += '<div class="card-h" style="color:#c07a6b">Konto löschen</div>';
|
||||
html += '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Ihr Konto wird deaktiviert. Ihre Klassen bleiben erhalten, Schüler*innen werden zu Autodidakten.</p>';
|
||||
html += '<button class="btn" style="background:#c07a6b" onclick="deleteAccount()">Konto endgültig löschen</button>';
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('content').innerHTML = html;
|
||||
}
|
||||
|
||||
function togglePicker() {
|
||||
document.getElementById('avatar-picker').classList.toggle('open');
|
||||
}
|
||||
|
||||
async function selectAvatar(emoji) {
|
||||
document.getElementById('avatar-btn').textContent = emoji;
|
||||
document.querySelectorAll('.avatar-opt').forEach(function(o) { o.classList.toggle('active', o.textContent === emoji); });
|
||||
await api('profile', {emojiAvatar: emoji});
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
var name = document.getElementById('pf-name').value.trim();
|
||||
if (!name) return;
|
||||
var r = await api('profile', {displayName: name});
|
||||
var m = document.getElementById('msg-name');
|
||||
m.className = r.error ? 'msg err' : 'msg ok';
|
||||
m.textContent = r.error || 'Gespeichert!';
|
||||
}
|
||||
|
||||
async function saveTeacher() {
|
||||
var name = document.getElementById('pf-name').value.trim();
|
||||
var email = document.getElementById('pf-email').value.trim();
|
||||
var school = document.getElementById('pf-school').value.trim();
|
||||
var r = await api('profile', {displayName: name, email: email, schoolName: school});
|
||||
var m = document.getElementById('msg-teacher');
|
||||
m.className = r.error ? 'msg err' : 'msg ok';
|
||||
m.textContent = r.error || 'Gespeichert!';
|
||||
}
|
||||
|
||||
async function changePw() {
|
||||
var old = document.getElementById('pf-oldpw').value;
|
||||
var pw = document.getElementById('pf-newpw').value;
|
||||
if (!old || !pw) return;
|
||||
var r = await api('profile', {oldPassword: old, newPassword: pw});
|
||||
var m = document.getElementById('msg-pw');
|
||||
m.className = r.error ? 'msg err' : 'msg ok';
|
||||
m.textContent = r.error || 'Passwort geändert!';
|
||||
if (!r.error) { document.getElementById('pf-oldpw').value = ''; document.getElementById('pf-newpw').value = ''; }
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
if (!confirm('Sind Sie sicher? Ihr Konto wird deaktiviert. Diese Aktion kann nicht rückgängig gemacht werden.')) return;
|
||||
if (!confirm('Wirklich löschen? Alle Ihre Klassen werden archiviert.')) return;
|
||||
var r = await api('profile', {action: 'delete_account'});
|
||||
if (r.error) { alert(r.error); return; }
|
||||
alert('Konto wurde gelöscht.');
|
||||
location.href = 'index.html';
|
||||
}
|
||||
|
||||
loadProfile();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,6 +17,10 @@ CREATE TABLE IF NOT EXISTS students (
|
||||
username VARCHAR(64) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(128),
|
||||
first_name VARCHAR(64),
|
||||
last_name VARCHAR(64),
|
||||
email VARCHAR(255),
|
||||
emoji_avatar VARCHAR(8) DEFAULT '🧑🎓',
|
||||
is_anonymous BOOLEAN DEFAULT TRUE, -- true = Pseudonym, false = echter Name
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP NULL,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Meine Simulationen</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;color:#1a1a1a;min-height:100vh}
|
||||
|
||||
.topbar{background:#fff;padding:.55rem 1.2rem;display:flex;align-items:center;gap:.7rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:10}
|
||||
.topbar .logo{height:28px}
|
||||
.topbar .spacer{flex:1}
|
||||
.topbar .user-pill{display:flex;align-items:center;gap:.35rem;padding:.25rem .6rem .25rem .3rem;background:#dae8ec;border-radius:99px;font-size:.72rem;font-weight:600;color:#4a7c8a;text-decoration:none;cursor:pointer}
|
||||
.topbar .user-pill:hover{background:#c8dce2}
|
||||
.topbar .user-pill .emoji{font-size:1.2rem}
|
||||
.topbar .btn-sm{padding:.25rem .55rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.65rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
|
||||
.topbar .btn-sm:hover{background:#dae8ec;color:#4a7c8a}
|
||||
|
||||
.wrap{max-width:900px;margin:0 auto;padding:1.2rem 1rem}
|
||||
|
||||
/* Tabs */
|
||||
.tabs{display:flex;gap:0;margin-bottom:1.2rem;border-radius:10px;overflow:hidden;border:1.5px solid rgba(0,0,0,.06);background:#fff}
|
||||
.tab{flex:1;padding:.6rem;text-align:center;font-size:.78rem;font-weight:700;cursor:pointer;color:#8a8a8a;transition:all .15s;border:none;background:transparent}
|
||||
.tab.on{background:#4a7c8a;color:#fff}
|
||||
|
||||
/* Module grid */
|
||||
.mod-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.8rem}
|
||||
.mod-card{background:#fff;border-radius:12px;overflow:hidden;border:1.5px solid rgba(0,0,0,.05);transition:all .25s;cursor:pointer;text-decoration:none;color:inherit;display:block}
|
||||
.mod-card:hover{transform:translateY(-3px);box-shadow:0 8px 25px rgba(74,124,138,.1);border-color:rgba(74,124,138,.2)}
|
||||
.mod-card.locked{opacity:.55;cursor:default}
|
||||
.mod-card.locked:hover{transform:none;box-shadow:none}
|
||||
.mod-img{height:110px;background:linear-gradient(135deg,#dae8ec,#c8dce2);display:flex;align-items:center;justify-content:center;font-size:2.5rem;position:relative}
|
||||
.mod-img img{width:100%;height:100%;object-fit:cover}
|
||||
.mod-body{padding:.7rem}
|
||||
.mod-body h3{font-size:.82rem;font-weight:700;margin-bottom:.15rem}
|
||||
.mod-body p{font-size:.68rem;color:#4a4a4a;margin-bottom:.4rem}
|
||||
.mod-status{display:inline-flex;align-items:center;gap:.25rem;font-size:.6rem;font-weight:600;padding:2px 8px;border-radius:5px}
|
||||
.mod-status.available{background:#dceadd;color:#5a8a5e}
|
||||
.mod-status.locked-s{background:#f0eeea;color:#8a8a8a}
|
||||
.mod-status.done{background:#dae8ec;color:#4a7c8a}
|
||||
.lock-icon{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:1.5rem;background:rgba(255,255,255,.9);border-radius:8px;padding:.3rem .6rem}
|
||||
|
||||
/* Erfolge */
|
||||
.badge-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:.6rem}
|
||||
.badge{background:#fff;border-radius:10px;padding:.8rem;text-align:center;border:1.5px solid rgba(0,0,0,.04)}
|
||||
.badge .icon{font-size:2rem;margin-bottom:.3rem}
|
||||
.badge .name{font-size:.72rem;font-weight:700}
|
||||
.badge .desc{font-size:.6rem;color:#8a8a8a}
|
||||
.badge.earned{border-color:#4a7c8a;background:linear-gradient(135deg,#f0f8fa,#fff)}
|
||||
.badge.locked-b{opacity:.4}
|
||||
|
||||
/* Profil-Karte */
|
||||
.profile-card{background:#fff;border-radius:12px;padding:1.5rem;text-align:center;border:1.5px solid rgba(0,0,0,.04)}
|
||||
.profile-avatar{font-size:3.5rem;cursor:pointer;transition:transform .15s}
|
||||
.profile-avatar:hover{transform:scale(1.15)}
|
||||
.profile-name{font-size:1.1rem;font-weight:800;margin-top:.3rem}
|
||||
.profile-class{font-size:.75rem;color:#8a8a8a}
|
||||
.profile-stats{display:flex;justify-content:center;gap:1.5rem;margin-top:.8rem}
|
||||
.profile-stat .n{font-size:1.3rem;font-weight:900;color:#4a7c8a}
|
||||
.profile-stat .l{font-size:.6rem;color:#8a8a8a}
|
||||
|
||||
.hidden{display:none}
|
||||
@media(max-width:550px){.mod-grid{grid-template-columns:1fr}.badge-grid{grid-template-columns:repeat(2,1fr)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<img src="assets/img/geograsim_t.svg" alt="GeoGraSim" class="logo">
|
||||
<span class="spacer"></span>
|
||||
<a href="profil.html" class="user-pill" id="user-pill"><span class="emoji">🧑🎓</span><span id="user-name">…</span></a>
|
||||
<button class="btn-sm" onclick="logout()">Abmelden</button>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<div class="tab on" onclick="switchTab('modules')">Meine Module</div>
|
||||
<div class="tab" onclick="switchTab('achievements')">Meine Erfolge</div>
|
||||
<div class="tab" onclick="switchTab('profile')">Profil</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-modules"></div>
|
||||
<div id="tab-achievements" class="hidden"></div>
|
||||
<div id="tab-profile" class="hidden"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var BASE = (window.__GGS__?.baseUrl) || '';
|
||||
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
|
||||
|
||||
var profile = null;
|
||||
var modules = [];
|
||||
|
||||
var MODULE_MAP = {
|
||||
'sim-05': {name:'Klimasimulator 2D', href:'game.html', img:'assets/img/card-climate.png', icon:'🌡️'},
|
||||
'sim-05-3d': {name:'Klimasimulator 3D', href:'game-3d.html', img:'assets/img/card-climate.png', icon:'🌊'},
|
||||
'sim-07': {name:'Erdbeben & Plattentektonik', href:'erdbeben.html', img:'assets/img/card-tectonic.png', icon:'🌋'},
|
||||
'sim-09': {name:'Energiewende-Planer', href:'energiemix.html', img:'assets/img/card-energy.png', icon:'⚡'},
|
||||
'sim-10': {name:'Lieferketten-Planer', href:'lieferketten.html', img:'assets/img/card-globe.png', icon:'👕'},
|
||||
'sim-11': {name:'Regenwald-Expedition', href:'regenwald.html', img:'assets/img/card-climate.png', icon:'🌴'},
|
||||
'sim-12': {name:'Flussmanagement', href:'fluss.html', img:'assets/img/card-fluss.png', icon:'🏞️'},
|
||||
'sim-13': {name:'Stadt & Raumplanung', href:'stadt.html', img:'assets/img/card-city.png', icon:'🏗️'},
|
||||
'sim-14': {name:'Helikopter-Navigation', href:'heli-game', img:'assets/img/card-heli.png', icon:'🚁'},
|
||||
};
|
||||
|
||||
var BADGES = [
|
||||
{id:'first_sim', icon:'🎯', name:'Erste Simulation', desc:'Eine Simulation abgeschlossen'},
|
||||
{id:'three_sims', icon:'🏅', name:'Dreifach', desc:'Drei verschiedene Simulationen gespielt'},
|
||||
{id:'all_sims', icon:'🏆', name:'Komplett', desc:'Alle Module abgeschlossen'},
|
||||
{id:'high_score', icon:'⭐', name:'Spitzenscore', desc:'Score über 80 in einer Simulation'},
|
||||
{id:'speed_run', icon:'⚡', name:'Blitzschnell', desc:'Eine Simulation in unter 10 Minuten'},
|
||||
{id:'explorer', icon:'🧭', name:'Entdecker', desc:'Alle Maßnahmen in einer Simulation ausprobiert'},
|
||||
{id:'climate_hero', icon:'🌍', name:'Klimaheld', desc:'Temperaturanstieg unter 1.5°C gehalten'},
|
||||
{id:'city_planner', icon:'🏙️', name:'Stadtplaner', desc:'Stadt mit über 90% Zufriedenheit gebaut'},
|
||||
];
|
||||
|
||||
async function api(endpoint, data) {
|
||||
var opts = data ? {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(data)} : {credentials:'same-origin'};
|
||||
var r = await fetch(BASE + '/api/' + endpoint, opts);
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
function logout() { api('auth', {action:'logout'}).then(function() { location.href = 'login.html'; }); }
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(function(t, i) {
|
||||
var tabs = ['modules','achievements','profile'];
|
||||
t.classList.toggle('on', tabs[i] === tab);
|
||||
});
|
||||
document.getElementById('tab-modules').classList.toggle('hidden', tab !== 'modules');
|
||||
document.getElementById('tab-achievements').classList.toggle('hidden', tab !== 'achievements');
|
||||
document.getElementById('tab-profile').classList.toggle('hidden', tab !== 'profile');
|
||||
}
|
||||
|
||||
async function init() {
|
||||
var status = await api('auth', {action:'status'});
|
||||
if (status.role !== 'student') { location.href = 'login.html'; return; }
|
||||
|
||||
document.getElementById('user-name').textContent = status.session?.display_name || 'Schüler';
|
||||
|
||||
// Load profile
|
||||
var pr = await api('profile');
|
||||
profile = pr.profile;
|
||||
if (profile?.emoji_avatar) {
|
||||
document.querySelector('#user-pill .emoji').textContent = profile.emoji_avatar;
|
||||
}
|
||||
|
||||
await renderModules();
|
||||
renderAchievements();
|
||||
renderProfile();
|
||||
}
|
||||
|
||||
async function renderModules() {
|
||||
var html = '';
|
||||
|
||||
// Aktive Aufgaben der Klasse laden
|
||||
try {
|
||||
var assignments = await api('assignments?student=1');
|
||||
if (!assignments.error && assignments.length > 0) {
|
||||
html += '<div style="margin-bottom:1rem">';
|
||||
assignments.forEach(function(a) {
|
||||
var m = MODULE_MAP[a.module_id];
|
||||
if (!m) return;
|
||||
var end = a.ends_at ? '<br><span style="font-size:.6rem;color:#8a8a8a">Zu erledigen bis ' + new Date(a.ends_at).toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) + '</span>' : '';
|
||||
html += '<a href="'+m.href+'" class="mod-card" style="border-color:#e8833a;border-width:2px;margin-bottom:.5rem">';
|
||||
html += '<div class="mod-img" style="height:80px;background:linear-gradient(135deg,#e8833a,#f4a261)"><span style="font-size:2.5rem">'+m.icon+'</span></div>';
|
||||
html += '<div class="mod-body">';
|
||||
html += '<h3 style="color:#e8833a">📋 '+m.icon+' '+m.name+' — Level '+a.level+'</h3>';
|
||||
html += '<p>Aufgabe von deiner Lehrperson'+end+'</p>';
|
||||
html += '<span class="mod-status" style="background:#fde8d0;color:#e8833a">▶ Jetzt starten</span>';
|
||||
html += '</div></a>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Modul-Status laden
|
||||
var modStatus = {};
|
||||
try {
|
||||
var mods = await api('modules?student=1');
|
||||
if (!mods.error) mods.forEach(function(m) { modStatus[m.id] = m.mode; });
|
||||
} catch(e) {}
|
||||
|
||||
// Alle Module
|
||||
html += '<div class="mod-grid">';
|
||||
var entries = Object.entries(MODULE_MAP);
|
||||
entries.forEach(function(e) {
|
||||
var id = e[0], m = e[1];
|
||||
var mode = modStatus[id] || 'free'; // Default: frei wenn keine Klasse
|
||||
var isLocked = mode === 'locked';
|
||||
var imgHtml = m.img
|
||||
? '<img src="'+m.img+'" alt="">'
|
||||
: '<span style="font-size:3rem">'+m.icon+'</span>';
|
||||
if (isLocked) {
|
||||
html += '<div class="mod-card locked">';
|
||||
html += '<div class="mod-img" style="position:relative">'+imgHtml+'<div class="lock-icon">🔒</div></div>';
|
||||
} else {
|
||||
html += '<a href="'+m.href+'" class="mod-card">';
|
||||
html += '<div class="mod-img">'+imgHtml+'</div>';
|
||||
}
|
||||
html += '<div class="mod-body">';
|
||||
html += '<h3>'+m.icon+' '+m.name+'</h3>';
|
||||
html += '<p>'+getModuleDesc(id)+'</p>';
|
||||
if (isLocked) {
|
||||
html += '<span class="mod-status locked-s">🔒 Gesperrt</span>';
|
||||
html += '</div></div>';
|
||||
} else {
|
||||
html += '<span class="mod-status available">▶ Spielen</span>';
|
||||
html += '</div></a>';
|
||||
}
|
||||
// Level-Badge (wird asynchron gefüllt)
|
||||
// Wird nach dem Rendern per API geladen
|
||||
});
|
||||
html += '</div>';
|
||||
document.getElementById('tab-modules').innerHTML = html;
|
||||
}
|
||||
|
||||
function getModuleDesc(id) {
|
||||
var descs = {
|
||||
'sim-05': 'Inselstaat durch die Klimakrise führen (2D-Ansicht)',
|
||||
'sim-05-3d': 'Inselstaat durch die Klimakrise führen (3D)',
|
||||
'sim-07': '50 Jahre Stadtplanung in einer Erdbebenzone',
|
||||
'sim-09': 'Deine Region bis 2050 auf Erneuerbare umstellen',
|
||||
'sim-10': 'Was kostet dein T-Shirt wirklich?',
|
||||
'sim-11': 'Mit dem Boot den Fluss hoch — Tiere und Pflanzen entdecken',
|
||||
'sim-12': 'Deiche, Renaturierung und Bewässerung managen',
|
||||
'sim-13': 'Plane eine Stadt mit Wohngebieten und Infrastruktur',
|
||||
'sim-14': 'Steuere einen Rettungshubschrauber durch Österreich',
|
||||
};
|
||||
return descs[id] || '';
|
||||
}
|
||||
|
||||
async function renderAchievements() {
|
||||
// Lade echte Fortschrittsdaten
|
||||
var progressData = {};
|
||||
try {
|
||||
var allProgress = await api('progress?student_id=0'); // eigener Fortschritt
|
||||
if (Array.isArray(allProgress)) {
|
||||
allProgress.forEach(function(p) { progressData[p.sim_id] = p; });
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
var totalPlays = 0, totalStars = 0, totalXP = 0;
|
||||
Object.values(progressData).forEach(function(p) {
|
||||
totalPlays += parseInt(p.plays || 0);
|
||||
totalStars = Math.max(totalStars, parseInt(p.best_stars || 0));
|
||||
totalXP += parseInt(p.xp || 0);
|
||||
});
|
||||
|
||||
var earned = [];
|
||||
if (totalPlays >= 1) earned.push('first_sim');
|
||||
if (Object.keys(progressData).length >= 3) earned.push('three_sims');
|
||||
if (Object.keys(progressData).length >= 8) earned.push('all_sims');
|
||||
if (totalStars >= 4) earned.push('high_score');
|
||||
if (totalXP >= 100) earned.push('speed_run'); // repurposed as XP badge
|
||||
|
||||
var html = '<div style="margin-bottom:.8rem"><p style="font-size:.85rem;font-weight:700;color:#4a7c8a">Deine Erfolge</p>';
|
||||
html += '<p style="font-size:.72rem;color:#8a8a8a">Spiele Simulationen und sammle Auszeichnungen.</p></div>';
|
||||
html += '<div class="badge-grid">';
|
||||
BADGES.forEach(function(b) {
|
||||
var isEarned = earned.indexOf(b.id) !== -1;
|
||||
html += '<div class="badge '+(isEarned ? 'earned' : 'locked-b')+'">';
|
||||
html += '<div class="icon">'+b.icon+'</div>';
|
||||
html += '<div class="name">'+b.name+'</div>';
|
||||
html += '<div class="desc">'+b.desc+'</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
document.getElementById('tab-achievements').innerHTML = html;
|
||||
}
|
||||
|
||||
async function renderProfile() {
|
||||
if (!profile) {
|
||||
document.getElementById('tab-profile').innerHTML = '<p style="color:#8a8a8a">Profil wird geladen…</p>';
|
||||
return;
|
||||
}
|
||||
// Lade Fortschritt
|
||||
var totalPlays = 0, totalXP = 0, maxLevel = 1;
|
||||
try {
|
||||
var prog = await api('progress?student_id=0');
|
||||
if (Array.isArray(prog)) prog.forEach(function(p) {
|
||||
totalPlays += parseInt(p.plays||0);
|
||||
totalXP += parseInt(p.xp||0);
|
||||
maxLevel = Math.max(maxLevel, parseInt(p.level||1));
|
||||
});
|
||||
} catch(e) {}
|
||||
|
||||
var html = '<div class="profile-card">';
|
||||
html += '<div class="profile-avatar" onclick="location.href=\'profil.html\'">'+(profile.emoji_avatar || '🧑🎓')+'</div>';
|
||||
html += '<div class="profile-name">'+(profile.display_name || profile.username)+'</div>';
|
||||
html += '<div class="profile-class">Klasse '+(profile.class_name || '—')+'</div>';
|
||||
html += '<div class="profile-stats">';
|
||||
html += '<div class="profile-stat"><div class="n">'+totalPlays+'</div><div class="l">Simulationen</div></div>';
|
||||
html += '<div class="profile-stat"><div class="n">'+totalXP+'</div><div class="l">XP</div></div>';
|
||||
html += '<div class="profile-stat"><div class="n">Level '+maxLevel+'</div><div class="l">Höchstes Level</div></div>';
|
||||
html += '</div></div>';
|
||||
html += '<div style="text-align:center;margin-top:1rem"><a href="profil.html" class="tab" style="display:inline-block;padding:.5rem 1.2rem;background:#4a7c8a;color:#fff;border-radius:8px;text-decoration:none;font-size:.78rem;font-weight:700">Profil bearbeiten</a></div>';
|
||||
document.getElementById('tab-profile').innerHTML = html;
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GeoGraSim — Zentraler SFX-Generator für ElevenLabs Sound Effects API.
|
||||
|
||||
Generiert MP3-Dateien aus einem sounds-list.json pro Modul. Existierende Dateien
|
||||
werden standardmäßig übersprungen. Die Pipeline ist identisch für alle Module —
|
||||
jedes Modul hat nur seine eigene sounds-list.json mit passenden Prompts.
|
||||
|
||||
Struktur (pro Modul):
|
||||
App/sims/<modul>/scripts/sounds-list.json (Definitionen)
|
||||
App/sims/<modul>/assets/sounds/*.mp3 (Output)
|
||||
|
||||
Key:
|
||||
App/.env.local → ELEVENLABS_API_KEY=... (in .gitignore)
|
||||
|
||||
Aufruf:
|
||||
python App/scripts/generate-sounds.py <modul> # fehlende Sounds
|
||||
python App/scripts/generate-sounds.py <modul> --force # alle neu
|
||||
python App/scripts/generate-sounds.py <modul> ui-click # nur einen
|
||||
|
||||
Beispiele:
|
||||
python App/scripts/generate-sounds.py klima
|
||||
python App/scripts/generate-sounds.py heli --force
|
||||
python App/scripts/generate-sounds.py fluss build-dam
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
API_URL = "https://api.elevenlabs.io/v1/sound-generation"
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
APP_ROOT = SCRIPT_DIR.parent # App/
|
||||
ENV_FILE = APP_ROOT / ".env.local"
|
||||
|
||||
|
||||
def load_env(file_path: Path) -> dict:
|
||||
env = {}
|
||||
if not file_path.exists():
|
||||
return env
|
||||
for line in file_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
env[key.strip()] = val.strip()
|
||||
return env
|
||||
|
||||
|
||||
def generate_sound(api_key: str, prompt: str, duration: float, influence: float) -> bytes:
|
||||
body = json.dumps({
|
||||
"text": prompt,
|
||||
"duration_seconds": duration,
|
||||
"prompt_influence": influence,
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
API_URL,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"xi-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "audio/mpeg",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
def usage():
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2 or sys.argv[1].startswith("--"):
|
||||
usage()
|
||||
|
||||
module = sys.argv[1]
|
||||
sounds_list = APP_ROOT / "sims" / module / "scripts" / "sounds-list.json"
|
||||
out_dir = APP_ROOT / "sims" / module / "assets" / "sounds"
|
||||
|
||||
if not sounds_list.exists():
|
||||
print(f"FEHLER: {sounds_list} nicht gefunden.", file=sys.stderr)
|
||||
print(f"Lege für das Modul '{module}' zuerst eine sounds-list.json an.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
env = load_env(ENV_FILE)
|
||||
api_key = env.get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY")
|
||||
if not api_key:
|
||||
print(f"FEHLER: ELEVENLABS_API_KEY nicht in {ENV_FILE} oder Umgebung gefunden.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
force = "--force" in sys.argv
|
||||
only_file = None
|
||||
for arg in sys.argv[2:]:
|
||||
if arg.startswith("--"):
|
||||
continue
|
||||
only_file = arg + ".mp3" if not arg.endswith(".mp3") else arg
|
||||
break
|
||||
|
||||
data = json.loads(sounds_list.read_text(encoding="utf-8"))
|
||||
sounds = data["sounds"]
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total = len(sounds)
|
||||
generated = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
print(f"=== Modul: {module} ({total} Sounds) ===")
|
||||
|
||||
for i, s in enumerate(sounds, 1):
|
||||
name = s["file"]
|
||||
out = out_dir / name
|
||||
if only_file and name != only_file:
|
||||
continue
|
||||
if out.exists() and not force:
|
||||
skipped += 1
|
||||
print(f"[{i:2}/{total}] SKIP {name:32} (schon da; --force überschreibt)")
|
||||
continue
|
||||
|
||||
prompt = s["prompt"]
|
||||
dur = float(s.get("duration", 2.0))
|
||||
infl = float(s.get("influence", 0.5))
|
||||
print(f"[{i:2}/{total}] GEN {name:32} dur={dur}s → {prompt[:54]}…")
|
||||
try:
|
||||
audio = generate_sound(api_key, prompt, dur, infl)
|
||||
out.write_bytes(audio)
|
||||
generated += 1
|
||||
time.sleep(0.5) # Sanfte Pause zwischen Requests
|
||||
except urllib.error.HTTPError as e:
|
||||
msg = e.read().decode("utf-8", errors="ignore")[:200]
|
||||
print(f" HTTP {e.code}: {msg}", file=sys.stderr)
|
||||
errors += 1
|
||||
# 401 = Kein Credit → sofort abbrechen, Thomas muss aufladen
|
||||
if e.code == 401:
|
||||
print(" → API-Key invalid oder Budget aufgebraucht. Abbruch.", file=sys.stderr)
|
||||
return 2
|
||||
except Exception as e:
|
||||
print(f" Fehler: {e}", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
print()
|
||||
print(f"Ergebnis: {generated} neu, {skipped} übersprungen, {errors} Fehler")
|
||||
print(f"Zielordner: {out_dir}")
|
||||
return 0 if errors == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
# GeoGraSim — Inbox Protokoll
|
||||
|
||||
Asynchroner Nachrichten-Austausch zwischen der **Zentrale** (Plattform-Instanz)
|
||||
und den einzelnen **Modul-Instanzen**. Der Nutzer (Thomas) muss Nachrichten
|
||||
nicht mehr manuell hin- und herkopieren.
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```
|
||||
App/sims/_inbox/
|
||||
├── README.md ← Dieses Protokoll
|
||||
├── zentrale/ ← Eingang für die Zentrale (Fragen/Feedback von Modulen)
|
||||
│ └── YYYY-MM-DD-HHMM-<absender>-<stichwort>.md
|
||||
├── klima/ ← Eingang für die Klima-Instanz
|
||||
├── fluss/ ← Eingang für die Fluss-Instanz (spaeter)
|
||||
├── heli/ ← Eingang für die Heli-Instanz (spaeter)
|
||||
└── stadt/ ← Eingang für die Stadt-Instanz (spaeter)
|
||||
```
|
||||
|
||||
## Dateiname-Konvention
|
||||
|
||||
`YYYY-MM-DD-HHMM-<stichwort>.md`
|
||||
|
||||
Beispiele:
|
||||
- `2026-04-16-1830-spiellogik-aus-3d.md`
|
||||
- `2026-04-16-1900-frage-zu-glossar.md`
|
||||
|
||||
Die alphabetische Sortierung ergibt automatisch die chronologische Reihenfolge.
|
||||
|
||||
## Nachrichten-Format
|
||||
|
||||
Jede Nachricht beginnt mit YAML-Frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
von: zentrale | klima | fluss | heli | stadt
|
||||
an: zentrale | klima | fluss | heli | stadt
|
||||
datum: 2026-04-16 18:30
|
||||
status: neu | gelesen | beantwortet
|
||||
betrifft: kurze Themenbeschreibung
|
||||
---
|
||||
|
||||
# Betreff
|
||||
|
||||
Nachrichtentext mit Details...
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Für Modul-Instanzen (z.B. Klima)
|
||||
|
||||
1. **Vor jedem groesseren Arbeitsschritt** den eigenen Eingang pruefen:
|
||||
```
|
||||
ls App/sims/_inbox/klima/
|
||||
```
|
||||
|
||||
2. **Neue Nachrichten** (status: neu) lesen, beruecksichtigen, dann Status auf
|
||||
`gelesen` setzen (Frontmatter editieren).
|
||||
|
||||
3. **Eigene Fragen oder Feedback** an die Zentrale als neue Datei in
|
||||
`App/sims/_inbox/zentrale/` anlegen:
|
||||
```
|
||||
App/sims/_inbox/zentrale/2026-04-16-1845-klima-frage-glossar.md
|
||||
```
|
||||
|
||||
### Für die Zentrale
|
||||
|
||||
1. **Vor jedem Turn mit dem Nutzer** den Eingang pruefen:
|
||||
```
|
||||
ls App/sims/_inbox/zentrale/
|
||||
```
|
||||
|
||||
2. Neue Nachrichten lesen, dem Nutzer erwaehnen, Status aktualisieren.
|
||||
|
||||
3. **Anweisungen oder Antworten** an Module in deren Eingangsordner ablegen.
|
||||
|
||||
## Wichtig
|
||||
|
||||
- **Keine Nachrichten loeschen** — sie bleiben als Audit-Trail.
|
||||
- **Status ehrlich setzen** — so sieht die andere Seite sofort, was neu ist.
|
||||
- **Kurze Nachrichten bevorzugen** — lieber mehrere kleine als eine riesige.
|
||||
- **Bei dringenden Themen** den Nutzer explizit erwaehnen ("Bitte an Thomas
|
||||
weiterleiten: ...")
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
von: atlas
|
||||
an: lehrplan
|
||||
datum: 2026-04-19 09:50
|
||||
status: neu
|
||||
betrifft: PFLICHT-Sprachregel — keine Spielsprache, sondern Lernarbeit
|
||||
---
|
||||
|
||||
# Neue Grundsatzregel: Lernarbeit statt Spielsprache
|
||||
|
||||
Thomas hat eine bildungstheoretische Grundsatzentscheidung getroffen:
|
||||
**GeoGraSim ist kein Spiel, sondern eine Simulations-Lernumgebung.**
|
||||
|
||||
## Warum
|
||||
|
||||
Bildungstheoretisch (Wygotski: Lernen als aktive Konstruktion in der Zone
|
||||
der nächsten Entwicklung) ist "Spiel" die falsche Sprache. Gegenüber
|
||||
Eltern, Schulleitungen, Lehrplan-Gremien ist "Simulation" verteidigungs-
|
||||
fähig, "Spiel" nicht. Das ist Haltung, keine Kosmetik.
|
||||
|
||||
## Betroffen bei dir
|
||||
|
||||
- **Modul-Detailseiten** (`modul-*.php`): Hero-Texte, Beschreibungen, CTAs
|
||||
- **`lehrplan.php`**: UI-Texte, Filter-Labels
|
||||
- **`simulationen.php`** (in Arbeit): CTA-Buttons, Card-Texte
|
||||
- **`module_info.short_desc`, `long_desc`, `learning_goals`** (+ Easy-
|
||||
Varianten): falls du Spielsprache drin hast, bitte korrigieren
|
||||
- **Landing-Page-Diff** den du mir schickst: von Anfang an sauber
|
||||
|
||||
## Verbotene/ersetzte Begriffe
|
||||
|
||||
| NICHT | STATTDESSEN |
|
||||
|----------------|---------------------------------------|
|
||||
| spielen, Spiel | arbeiten mit, Simulation, bearbeiten |
|
||||
| Weiterspielen | Fortsetzen, weiterarbeiten |
|
||||
| "Jetzt spielen"| "Simulation starten" |
|
||||
| Spielstand | Arbeitsstand, Fortschritt |
|
||||
| Spieler:in | Lernende, Bearbeiter:in |
|
||||
| Spielrunde | Durchgang, Arbeitsphase |
|
||||
| "Game Over" | "Durchgang beendet" |
|
||||
| Highscore | Beste Leistung |
|
||||
|
||||
Volle Liste: `App/docs/module-interface.md` → Abschnitt **4a**
|
||||
|
||||
## Ausnahmen
|
||||
|
||||
Interne Variablennamen (`game_levels`, `game_saves`) bleiben unverändert
|
||||
— nicht sichtbar, keine pädagogische Wirkung.
|
||||
|
||||
## Konkrete CTAs
|
||||
|
||||
Für `simulationen.php` und die Modul-Detailseiten:
|
||||
|
||||
| Button-Kontext | Richtig |
|
||||
|-----------------------------|----------------------------|
|
||||
| Start-Button auf Card | "Simulation starten" oder "▶ Starten" |
|
||||
| Lehrpersonen-CTA | "Als Lehrperson ausprobieren" |
|
||||
| Schüler-CTA | "Als Lernende:r einsteigen" |
|
||||
| Detailseiten-Hauptbutton | "Simulation starten" oder "Jetzt bearbeiten" |
|
||||
|
||||
## Was zu tun ist
|
||||
|
||||
1. Bestehende `modul-*.php` Texte durchgehen, Spielsprache ersetzen
|
||||
2. `simulationen.php` (in Arbeit): von Anfang an sauber bauen
|
||||
3. `module_info`-Einträge: `short_desc`, `long_desc`, `learning_goals` prüfen
|
||||
4. Landing-Page-Diff: sauber formulieren
|
||||
|
||||
## Bestätigen
|
||||
|
||||
- status: gelesen
|
||||
- Regel ab sofort aktiv. Commit nach Phase als
|
||||
"Lehrplan: Sprachregel Lernarbeit durchgängig"
|
||||
@@ -0,0 +1,222 @@
|
||||
---
|
||||
von: atlas
|
||||
an: fluss
|
||||
datum: 2026-04-18 22:30
|
||||
status: gelesen
|
||||
betrifft: Willkommens-Briefing — du bist "Fluss", baust das Flussmanagement-Modul V2
|
||||
---
|
||||
|
||||
# Willkommen im GeoGraSim-Team
|
||||
|
||||
Du bist eine neue Claude-Code-Instanz in einem Mehrinstanzen-Projekt. Diese
|
||||
Nachricht enthält alles, was du zum Start brauchst.
|
||||
|
||||
## Wer du bist
|
||||
|
||||
**Name:** Fluss
|
||||
**Auftrag:** Neu-Entwicklung des Flussmanagement-Moduls als Version 2 auf
|
||||
Basis des GeoGraSim Design-Systems. Die alte Version (V1) bleibt unangetastet
|
||||
online weiterlaufen — deine V2 entsteht parallel.
|
||||
|
||||
## Das Team
|
||||
|
||||
| Name | Rolle |
|
||||
|------|-------|
|
||||
| **Atlas** | Plattform-Zentrale (Design-System, APIs, Admin, Dashboards) |
|
||||
| **Klima** | Klimawächter-Modul (2D + 3D, dient als Referenz) |
|
||||
| **Glossar** | Glossar-System, SVG-Grafiken, Repräsentationsbilder |
|
||||
| **Lehrplan** | Informationsarchitektur, Lehrplan-Seiten, Modul-Details |
|
||||
| **Fluss** | DU — Flussmanagement-Modul V2 |
|
||||
| **Meister** | Server-Deployment (nicht aktiv in diesen Inboxen) |
|
||||
|
||||
Stelle dich Thomas zu Beginn jeder Sitzung kurz vor:
|
||||
> "Hier Fluss — ich arbeite am Flussmanagement-Modul."
|
||||
|
||||
## Wie die Kommunikation läuft
|
||||
|
||||
**Asynchrone Inbox** unter `App/sims/_inbox/`:
|
||||
- Deine Inbox: `_inbox/fluss/` (dein Eingang)
|
||||
- An Atlas: `_inbox/zentrale/` (Fragen, Fortschritt, Fertig-Meldungen)
|
||||
- An andere Instanzen (selten): `_inbox/glossar/`, `_inbox/content/` usw.
|
||||
|
||||
**Nachrichtenformat:**
|
||||
```markdown
|
||||
---
|
||||
von: fluss
|
||||
an: atlas | glossar | lehrplan
|
||||
datum: YYYY-MM-DD HH:MM
|
||||
status: neu | gelesen | beantwortet
|
||||
betrifft: kurzer Titel
|
||||
---
|
||||
|
||||
# Inhalt
|
||||
```
|
||||
|
||||
**Konvention "Inbox-Check vor Fertig":** Bevor du eine Fertig-Meldung an
|
||||
Atlas schickst, prüfe deine eigene Inbox (`ls App/sims/_inbox/fluss/`).
|
||||
Falls `status: neu` dabei ist, erst lesen/einarbeiten, dann Fertig-Meldung.
|
||||
Das verhindert Antwort-Verluste.
|
||||
|
||||
**Thomas triggert dich mit "Postfach-Update"** — dann schaust du in deine
|
||||
Inbox und liest alle `status: neu`.
|
||||
|
||||
## Dein Arbeitsbereich
|
||||
|
||||
**SCHREIBEN darfst du:**
|
||||
- `App/sims/fluss/game.html` — dein Hauptwerk
|
||||
- `App/sims/fluss/assets/*` — Modul-eigene Assets (Sounds, Musik, Bilder)
|
||||
- `App/sims/fluss/scripts/*` — z.B. `sounds-list.json`
|
||||
- `App/pages/fluss.php` — PHP-Wrapper (Session, Level-Config, __GGS__-Kontext)
|
||||
- `App/sims/fluss/engine.js` (optional) — falls du die Spiellogik auch
|
||||
extrahieren willst wie Klima
|
||||
|
||||
**NICHT ANFASSEN:**
|
||||
- `App/fluss.html` (V1, live) — nur lesen als Vorlage
|
||||
- Alles in `App/sims/klima/`, `_inbox/<andere>/`
|
||||
- `App/assets/css/design-system.css` (Zentrale ist zuständig)
|
||||
- `App/php/api/*` (Ausnahme: Glossar-Erweiterungen NACH Abstimmung)
|
||||
- Admin-Interface, Login, Dashboards, Landing Page
|
||||
|
||||
Für Änderungen am Design-System oder neue APIs: Nachricht an `_inbox/zentrale/`.
|
||||
|
||||
## Pflicht-Dokumente zum Lesen (Reihenfolge)
|
||||
|
||||
1. **`App/docs/module-interface.md`** — Schnittstellen, Datenformate, Checkliste.
|
||||
ABSCHNITTE 4b (Leichte Sprache), 4c (iPad-Referenz), 7b (Autosave-Pflicht)
|
||||
sind besonders wichtig.
|
||||
2. **`App/docs/content-architecture.md`** — kennst du für
|
||||
Modul-Detailseiten (`modul-fluss.php` macht Lehrplan, nicht du).
|
||||
3. **`App/sims/template.html`** — Master-Template, das du als Ausgangspunkt
|
||||
nehmen kannst. Header, Zonen, JS-Helper.
|
||||
4. **`App/assets/css/design-system.css`** — Alle UI-Komponenten (`.ggs-*`).
|
||||
5. **`App/sims/klima/game-2d.html`** — Referenz-Implementierung. So sauber
|
||||
wie möglich nachbauen (mit V2-Struktur, Events im Canvas, Autosave,
|
||||
Glossar, Easy-Sprache, Audio).
|
||||
|
||||
## Dein Auftrag im Detail
|
||||
|
||||
### Ziel
|
||||
Eine `App/sims/fluss/game.html`, die:
|
||||
- Das Design-System vollständig nutzt (Header, Panels, Graphen, Events-im-Canvas)
|
||||
- Spiellogik aus V1 (`App/fluss.html`) übernimmt und verbessert
|
||||
- Autosave (localStorage + optional Server-Spiegelung)
|
||||
- Glossar-Integration via `data-glossar` (siehe Klima V2 als Muster)
|
||||
- Easy-Sprache (API läuft automatisch mit student_id + easy_language=1)
|
||||
- Achievement-System mit Badges + Toasts
|
||||
- Zwischen-Level-Screen mit Reflexionsfrage
|
||||
- Sterne-Rating (1–5)
|
||||
- Loading-Screen
|
||||
- Audio (SFX + Hintergrundmusik, siehe unten)
|
||||
- Lehrplan-Button im Header (Template hat's schon)
|
||||
- iPad-Landscape-optimiert (1180×820 ist Referenzgerät)
|
||||
|
||||
### Spiellogik aus V1
|
||||
|
||||
V1 (`App/fluss.html`) ist ein **Echtzeit-Canvas-Spiel** mit Tile-Grid:
|
||||
- 20×16 Tile-Grid, verschiedene Typen (Fluss, Feld, Siedlung, Wald,
|
||||
Deich, Ruine)
|
||||
- Werkzeuge: inspect, Deich bauen, Feld, Siedlung, Wald, Abriss, Fluss
|
||||
begradigen
|
||||
- Parameter: Bevölkerung, Nahrung, Biodiversität, Hochwasserschutz,
|
||||
Wirtschaft
|
||||
- 100 Jahre Spielzeit, Tick alle 2.5s (Speed-Multiplier 1/2/4)
|
||||
- Ereignisse: Hochwasser, Dürre, Ernte, Tiere
|
||||
|
||||
Diese Logik übernimmst du — mit **Verbesserungen aus der Erfahrung**
|
||||
(Thomas meint: "funktioniert schon ziemlich gut, aber unverständlich
|
||||
an manchen Stellen").
|
||||
|
||||
### PHP-Wrapper `App/pages/fluss.php`
|
||||
|
||||
Analog zu `App/pages/klima-2d.php`. Lädt `game.html`, injiziert
|
||||
`window.__GGS__`-Kontext (sessionId, simId='fluss', level, levelConfig,
|
||||
baseUrl, apiUrl). Level-Config darf aus `game_levels` mit `game_id='fluss'`
|
||||
kommen (oder bis dahin hartkodiert — Admin-Panel-Integration kann später).
|
||||
|
||||
### Audio
|
||||
|
||||
**SFX:** Pipeline ist zentral unter `App/scripts/generate-sounds.py`.
|
||||
Lege eine `App/sims/fluss/scripts/sounds-list.json` an (Klima hat ein
|
||||
Beispiel in `App/sims/klima/scripts/`). Run mit:
|
||||
```
|
||||
python App/scripts/generate-sounds.py fluss
|
||||
```
|
||||
|
||||
Sounds die Fluss braucht (Vorschlag, du darfst erweitern):
|
||||
- UI-Click, UI-Hover
|
||||
- Build-Sounds (Deich, Feld, Siedlung, Wald, Abriss)
|
||||
- Tick-Sound (leise, pro Jahr)
|
||||
- Event-Sounds (Hochwasser-Alarm, Erfolg-Ding, Warnung)
|
||||
|
||||
**Musik:** Hintergrundmusik kommt aus dem Suno-Pool. Registry:
|
||||
`App/docs/music-registry.md`. Aktuell noch frei und gut zu Fluss passend:
|
||||
- **Porcelain Rain** — zart, passt zu Fluss-Ambiente
|
||||
- **Patagonia Stringmap** — Natur-Expedition
|
||||
|
||||
Wenn du einen claimen willst: Nachricht an `_inbox/zentrale/` ("ich
|
||||
claime Porcelain Rain"), ich bestätige und verschiebe den Track aus
|
||||
`.humanInput/Background Music/` in `App/sims/fluss/assets/music/`. Keine
|
||||
doppelte Nutzung zwischen Modulen.
|
||||
|
||||
**Music-Player im Header:** Das Template hat schon die Standard-Komponente.
|
||||
Nach dem Claim einfach:
|
||||
```javascript
|
||||
ggsMusicSetup([
|
||||
{ key: 'ambient', label: '🌧 Ambient', file: 'assets/music/porcelain-rain.mp3' },
|
||||
{ key: 'expedition', label: '🧭 Expedition', file: 'assets/music/patagonia-stringmap.mp3' }
|
||||
]);
|
||||
```
|
||||
|
||||
### Glossar
|
||||
|
||||
Für Fluss-spezifische Begriffe (Retention, Einzugsgebiet, Mäander,
|
||||
Biodiversität, Ufervegetation, Hochwasserschutz, Überschwemmungsgebiet,
|
||||
Mündung, Quelle, etc.): Nachricht an `_inbox/glossar/`. Glossar legt
|
||||
sie in der DB an, inklusive SVG-Grafiken falls sinnvoll. Du nutzt
|
||||
einfach `data-glossar="retention"` im HTML.
|
||||
|
||||
**Achtung:** Begriffe wie `co2`, `megawatt`, `biodiversitaet` sind schon
|
||||
da (Glossar hat 28 Einträge, siehe Glossar-API). Prüfe vor dem Anfragen
|
||||
einmal die API: `GET /api/glossar.php?module=fluss` zeigt, was schon
|
||||
für Fluss getaggt ist.
|
||||
|
||||
### Lehrplan-Anker
|
||||
|
||||
Lehrplan hat in `modul-fluss.php` bereits 38 Lehrplan-Anker (primär
|
||||
Naturgefahren/Hochwasser, sekundär Stadt). Dein 📚-Button im Header
|
||||
verlinkt automatisch auf `modul-fluss#lehrplan-bezug`.
|
||||
|
||||
## Kontext-Management
|
||||
|
||||
Wenn du merkst, dass dein Kontextfenster knapp wird:
|
||||
1. Pausiere beim aktuellen Stand
|
||||
2. Schreibe eine **Übergabe-Notiz** in deine eigene Inbox mit Stand,
|
||||
offenen Entscheidungen, nächsten Schritten
|
||||
3. Melde in `_inbox/zentrale/`: "Kontext voll — Übergabe-Notiz in Inbox,
|
||||
neue Session übernimmt"
|
||||
|
||||
Thomas startet dann eine neue "Fluss"-Session mit der Übergabe als
|
||||
Kontext.
|
||||
|
||||
## Start
|
||||
|
||||
Du darfst sofort loslegen. Empfohlene Reihenfolge:
|
||||
|
||||
1. Briefing lesen (diese Nachricht)
|
||||
2. `module-interface.md` + `content-architecture.md` lesen
|
||||
3. `App/sims/klima/game-2d.html` durchsehen (Referenz-Implementierung)
|
||||
4. `App/fluss.html` (V1) studieren (Spiellogik)
|
||||
5. Einen kurzen Plan in `_inbox/zentrale/` posten: "Hier Fluss. Plan:
|
||||
Schritt 1 ... Schritt 2 ... ca. X Stunden. Starte jetzt."
|
||||
6. Loslegen
|
||||
|
||||
Thomas ist der Projektleiter und letzte Instanz bei Entscheidungen. Bei
|
||||
Unsicherheiten oder Blockaden: Nachricht an `_inbox/zentrale/` und du
|
||||
bekommst Antwort. Kein Zeitdruck — Qualität vor Tempo.
|
||||
|
||||
Willkommen!
|
||||
|
||||
## Bestätigen
|
||||
|
||||
- status: gelesen
|
||||
- Plan als nächste Nachricht in zentrale-Inbox
|
||||