/** * Staustufen — Wasserkraft in der Kaskade * Level 1 — Engine * * Ein Durchgang: 5 simulierte Tage à 12 Ticks (= 60 Ticks gesamt). * Bei normalem Tempo läuft ein Tick alle 1.5 s → komplette Bilanz in 90 s. * Im 4×-Tempo entsprechend kürzer. * * Mechanik (vereinfacht für L1): * - 2 Speicher (oben/unten), Kapazität je 1000 Einheiten "Wasser-Energie" * - 1 Pumpe (Unten → Oben), 4 Stufen * - 1 Turbine (Oben → Unten), 4 Stufen * - Pump-Wirkungsgrad 75 %, Turbinen-Wirkungsgrad 85 % * - Konstanter Zufluss 2 Einheiten/Tick in den oberen Speicher * - Bedarfskurve fix: Tag 1 niedrig, Tag 2 mittel, Tag 3 Spitze, * Tag 4 niedrig, Tag 5 Spitze * * Bewertungs-Modell (Atlas-Update 2026-04-26): Netzbalance-Korridor. * Pro Tick wird die Abweichung |consume - supply| / demand gemessen: * ≤ 15 % = grüner Korridor (Netz im Gleichgewicht) * ≤ 30 % = gelb (Lücke oder Überschuss spürbar) * > 30 % = rot (kritische Abweichung) * End-Bewertung: Anteil grüner Ticks + Verlustquote. */ (function () { 'use strict'; // ---------- Konstanten ---------- var TICKS_PER_DAY = 12; var TOTAL_DAYS = 5; var TOTAL_TICKS = TICKS_PER_DAY * TOTAL_DAYS; var TANK_CAP = 1000; // Maximale Wassermengen pro Tick (Volllast). Steuerwert ∈ [-1..+1] // skaliert linear: pumpWater = -control × MAX_PUMP_RATE (für control < 0), // turbWater = control × MAX_TURB_RATE (für control > 0). Das ist die // stufenlose Variante — der Slider gibt jeden Wert zwischen voller Pumpe // und voller Turbine frei. var MAX_PUMP_RATE = 16; var MAX_TURB_RATE = 16; var PUMP_EFF = 0.75; // benötigte Strom-Einheit / Wasser-Einheit = 1/0.75 ≈ 1.33 var TURB_EFF = 0.85; // erzeugte Strom-Einheit / Wasser-Einheit = 0.85 var INFLOW_PER_TICK = 2; // konstanter natürlicher Zufluss in Tank Oben var INIT_TOP = 300; var INIT_BOT = 700; // Bedarf pro Tick je Tag (Tagesmittel, Strom-Einheiten/Tick) // Tag 1 niedrig, Tag 2 mittel, Tag 3 Spitze, Tag 4 niedrig, Tag 5 Spitze var DEMAND_PER_DAY = [6, 12, 22, 6, 22]; // Tageswellenform — Strombedarf schwankt im Tagesverlauf // (Stunde, Faktor) — Faktoren sind so kalibriert, dass das Tagesmittel // exakt 1.0 ergibt. var DEMAND_ANCHORS = [ { h: 0, v: 0.72 }, // Nacht-Tal { h: 8, v: 1.16 }, // Morgenspitze { h: 13, v: 0.94 }, // Mittagsdelle { h: 19, v: 1.21 }, // Abendspitze { h: 24, v: 0.72 }, // wraparound ]; // Mittelwert per Trapez über 24h, gewichtet nach Segmentlänge: // ((0.72+1.16)/2 * 8 + (1.16+0.94)/2 * 5 // + (0.94+1.21)/2 * 6 + (1.21+0.72)/2 * 5) / 24 ≈ 1.000 var DEMAND_FACTOR_MEAN = 1.000; // Freie Einspeisung über den Tag — Solar (Mittag-Spitze) + Wind/Lauf // (niedrige Grundlast nachts). Jeder Tag identisch. // Tagesmittel ≈ 10.6 — bei Tag 1/4 (Bedarf-Mittel 6) entsteht Überschuss // tagsüber, bei Tag 3/5 (Bedarf-Mittel 22) eine deutliche Lücke. var SUPPLY_ANCHORS = [ { h: 0, v: 6 }, // Nacht: nur Wind/Lauf { h: 6, v: 6 }, // Sonnenaufgang { h: 9, v: 12 }, // Solar steigt { h: 12, v: 22 }, // Mittags-Spitze (Solar) { h: 15, v: 18 }, // Nachmittag { h: 18, v: 8 }, // Sonnenuntergang { h: 21, v: 6 }, // Wind/Lauf { h: 24, v: 6 }, // wraparound ]; function supplyFactorAt(hour) { if (hour < 0) hour = 0; if (hour > 24) hour = 24; for (var i = 0; i < SUPPLY_ANCHORS.length - 1; i++) { var a = SUPPLY_ANCHORS[i], b = SUPPLY_ANCHORS[i + 1]; if (hour >= a.h && hour <= b.h) { var t = (hour - a.h) / (b.h - a.h); var s = t * t * (3 - 2 * t); return a.v + (b.v - a.v) * s; } } return SUPPLY_ANCHORS[0].v; } function supplyAtTick(day, tickInDay) { var hour = tickInDay * 2; return supplyFactorAt(hour); } // Smoothstep-Interpolation zwischen den Stützpunkten — weiche Kurve. function demandFactorAt(hour) { if (hour < 0) hour = 0; if (hour > 24) hour = 24; for (var i = 0; i < DEMAND_ANCHORS.length - 1; i++) { var a = DEMAND_ANCHORS[i], b = DEMAND_ANCHORS[i + 1]; if (hour >= a.h && hour <= b.h) { var t = (hour - a.h) / (b.h - a.h); var s = t * t * (3 - 2 * t); return (a.v + (b.v - a.v) * s) / DEMAND_FACTOR_MEAN; } } return DEMAND_ANCHORS[0].v / DEMAND_FACTOR_MEAN; } // Bedarf an einem konkreten Tick: Tagesmittel × Wellenfaktor function demandAtTick(day, tickInDay) { var hour = tickInDay * 2; // 12 Ticks à 2 Std if (day < 1) day = 1; if (day > TOTAL_DAYS) day = TOTAL_DAYS; return DEMAND_PER_DAY[day - 1] * demandFactorAt(hour); } // Tempo (Tick-Intervalle in ms) // 1× = 5 s/Tick → 5 min Gesamt-Durchgang (L1 ruhig zum Lesen + Reagieren) // 4× = 1.25 s/Tick → 75 s Vorlauf var SPEED_MS = { '1x': 5000, '4x': 1250 }; // Netzbalance-Korridor (Anteil der Bedarfshöhe) var CORRIDOR_GREEN = 0.15; // ≤ 15 % Abweichung = im Gleichgewicht var CORRIDOR_YELLOW = 0.30; // ≤ 30 % = spürbar, aber nicht kritisch // ---------- State ---------- var state = { day: 1, // 1..5 tickInDay: 0, // 0..11 (0 = "00:00", 11 = "22:00") totalTick: 0, // 0..60 paused: false, finished: false, speed: '1x', rafId: null, // requestAnimationFrame-ID simProgress: 0, // float, in Tick-Einheiten — pixelweiser Cursor lastFrameMs: 0, // letzter rAF-Zeitstempel tankTop: INIT_TOP, tankBot: INIT_BOT, // Stufenlose Steuerung — zwischen voller Pumpe (-1) und voller Turbine (+1). // Mittelpunkt 0 = beides aus. Wird vom bidirektionalen Slider gesetzt. control: 0, // Erst-Aktivierungs-Flags, damit InfoCards „pumpe" / „turbine" // einmalig feuern, sobald ein Wert in die jeweilige Hälfte wandert. firstPumpUsed: false, firstTurbineUsed: false, // Pro Tick aktualisierte Anzeige-Werte lastTurbineWater: 0, lastPumpWater: 0, lastTurbineElec: 0, lastPumpElec: 0, // Aktuelle Netzbalance-Klasse: 'green' | 'yellow-deficit' | 'yellow-surplus' // | 'red-deficit' | 'red-surplus' lastBalance: 'green', lastGapRel: 0, // (consume - supply) / demand (signed) // Pro-Tick-Historie für den Bedarfsgraph (Versorgungslinie) history: { supply: [] }, totals: { demand: 0, // gesamt Strom-Einheiten Bedarf (über 5 Tage) freeSupplyTotal: 0, // gesamt freie Einspeisung turbineSupplied: 0, // gesamt vom Turbinenstrom geliefert pumpDrawn: 0, // gesamt vom Pumpen-Strom-Verbrauch unmet: 0, // gesamt nicht-gedeckte Bedarfslücke surplus: 0, // gesamt überschüssige Einspeisung losses: 0, // gesamt Verluste durch Pumpen + Turbinieren waterMoved: 0, // gesamt bewegtes Wasser // Netzbalance-Korridor — Tick-Counter ticksGreen: 0, ticksYellow: 0, ticksRed: 0, }, hintsShown: { welcome: false, imbalance: false }, infocardsShown: { anlage: false, pumpe: false, turbine: false, korridor: false, spitze: false, }, audioEnabled: false, // Default-Mute (Klassenraum-tauglich) assessment: { startMs: null, pingTimer: null, sentStarted: false, sentCompleted: false, }, }; // ---------- Helper ---------- function $(id) { return document.getElementById(id); } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function fmt(n, dec) { if (typeof dec !== 'number') dec = 0; if (!isFinite(n)) return '0'; return n.toFixed(dec).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } function pct(v, base) { if (!base) return 0; return clamp(Math.round((v / base) * 100), 0, 100); } // ---------- Tick-Logik ---------- function tick() { if (state.paused || state.finished) return; if (state.totalTick >= TOTAL_TICKS) { finish(); return; } var demand = demandAtTick(state.day, state.tickInDay); // 1) Zufluss in oberen Speicher state.tankTop = clamp(state.tankTop + INFLOW_PER_TICK, 0, TANK_CAP); // 2) Pumpe Unten → Oben (control < 0) — stufenlos skaliert var pumpWater = state.control < 0 ? Math.abs(state.control) * MAX_PUMP_RATE : 0; pumpWater = Math.min(pumpWater, state.tankBot, TANK_CAP - state.tankTop); if (pumpWater < 0) pumpWater = 0; state.tankBot -= pumpWater; state.tankTop += pumpWater; var pumpElec = pumpWater > 0 ? pumpWater / PUMP_EFF : 0; var pumpLoss = pumpElec - pumpWater; // 3) Turbine Oben → Unten (control > 0) — stufenlos skaliert var turbWater = state.control > 0 ? state.control * MAX_TURB_RATE : 0; turbWater = Math.min(turbWater, state.tankTop, TANK_CAP - state.tankBot); if (turbWater < 0) turbWater = 0; state.tankTop -= turbWater; state.tankBot += turbWater; var turbElec = turbWater * TURB_EFF; var turbLoss = turbWater - turbElec; // 4) Bilanz pro Tick — Netzbalance-Korridor // Verfügbar = freie Einspeisung (Solar/Wind, schwankt) + Turbinen-Strom // Verbraucht = Bedarf + Pumpen-Strom-Verbrauch // Korridor wird relativ zum Bedarf gemessen: |gap| / demand var freeSupply = supplyAtTick(state.day, state.tickInDay); var supply = freeSupply + turbElec; var consume = demand + pumpElec; var gap = consume - supply; // > 0 Lücke, < 0 Überschuss var unmetT = gap > 0 ? gap : 0; var surplusT = gap < 0 ? -gap : 0; var gapRel = demand > 0 ? gap / demand : 0; var absRel = Math.abs(gapRel); var balance; if (absRel <= CORRIDOR_GREEN) { balance = 'green'; state.totals.ticksGreen += 1; } else if (absRel <= CORRIDOR_YELLOW) { balance = gap > 0 ? 'yellow-deficit' : 'yellow-surplus'; state.totals.ticksYellow += 1; } else { balance = gap > 0 ? 'red-deficit' : 'red-surplus'; state.totals.ticksRed += 1; } state.lastBalance = balance; state.lastGapRel = gapRel; // Erstes Mal außerhalb Korridor → InfoCard erklärt das Konzept. // Erst ab Tick 2, damit nicht direkt nach dem Welcome-Card gefeuert wird. if (balance !== 'green' && state.totalTick >= 1 && !state.infocardsShown.korridor) { showInfoCard('korridor'); } state.totals.demand += demand; state.totals.freeSupplyTotal += freeSupply; state.totals.turbineSupplied += turbElec; state.totals.pumpDrawn += pumpElec; state.totals.unmet += unmetT; state.totals.surplus += surplusT; state.totals.losses += pumpLoss + turbLoss; state.totals.waterMoved += pumpWater + turbWater; // Versorgungs-Historie für den Bedarfsgraph: Netto-Lieferung ans Netz // (freie Einspeisung + Turbine-Erzeugung − Pump-Verbrauch) state.history.supply.push(supply - pumpElec); state.lastTurbineWater = turbWater; state.lastPumpWater = pumpWater; state.lastTurbineElec = turbElec; state.lastPumpElec = pumpElec; // 5) Tickzähler weiter state.totalTick += 1; state.tickInDay += 1; if (state.tickInDay >= TICKS_PER_DAY) { state.tickInDay = 0; state.day += 1; } // Spitzen-Tag-InfoCard zu Beginn von Tag 3 if (state.day === 3 && state.tickInDay === 0 && !state.infocardsShown.spitze) { showInfoCard('spitze'); } render(); // Ende erreicht? if (state.totalTick >= TOTAL_TICKS) { finish(); return; } // Hint #2 — Tag 1, Mitte, falls Bearbeiter:in noch gar nichts angefasst hat if (state.day === 1 && state.tickInDay === 5 && !state.hintsShown.imbalance && state.control === 0) { showHint('imbalance', 'Tipp: Solar liefert mehr als der Bedarf braucht.', 'Heute (Tag 1) liegt die gelbe Solar-Linie mittags weit ' + 'über der roten Bedarfs-Linie — es gibt Stromüberschuss. ' + 'Wenn du nichts machst, verpufft der einfach. Schieb den Regler ' + 'beim Maschinenhaus nach links (Pumpe), dann fängst du ' + 'den Überschuss ein und hebst Wasser nach oben. Das brauchst du ' + 'für die Abendspitzen in Tag 3 und 5.'); } } // ---------- Render ---------- function render() { // state.day kann nach dem letzten Tick auf 6 stehen (Übergangsmoment vor finish). var dayIdx = clamp(state.day - 1, 0, TOTAL_DAYS - 1); var displayDay = dayIdx + 1; var displayTickInDay = clamp(state.tickInDay, 0, TICKS_PER_DAY - 1); var demand = demandAtTick(displayDay, displayTickInDay); // Tag-Pille var dayCapped = Math.min(state.day, TOTAL_DAYS); $('dayPill').textContent = 'Tag ' + dayCapped + ' / ' + TOTAL_DAYS; // Uhrzeit (00:00..22:00 in 2-h-Schritten) var hour = state.tickInDay * 2; $('kpiUhrzeit').textContent = (hour < 10 ? '0' : '') + hour + ':00'; $('kpiTickInfo').textContent = 'Tick ' + (state.totalTick % TICKS_PER_DAY) + ' / ' + TICKS_PER_DAY; // Netzbalance-KPI: Anteil Ticks im grünen Korridor (bisher) var ticksDone = state.totals.ticksGreen + state.totals.ticksYellow + state.totals.ticksRed; var greenPct = ticksDone > 0 ? Math.round((state.totals.ticksGreen / ticksDone) * 100) : 100; $('kpiNetzbalance').textContent = greenPct + ' %'; var bar = $('kpiNetzbalanceBar'); bar.style.width = greenPct + '%'; bar.classList.remove('warn', 'danger'); if (greenPct < 65) bar.classList.add('danger'); else if (greenPct < 80) bar.classList.add('warn'); // Sub-Zeile mit Lücke + Überschuss für Kontext var unmetSoFar = state.totals.unmet; var surplusSoFar = state.totals.surplus; var subParts = []; if (unmetSoFar >= 0.5) subParts.push('Lücke ' + fmt(unmetSoFar, 0)); if (surplusSoFar >= 0.5) subParts.push('Überschuss ' + fmt(surplusSoFar, 0)); $('kpiNetzbalanceSub').textContent = subParts.length === 0 ? 'Bisher im Gleichgewicht.' : subParts.join(' · ') + ' GWh'; // Verluste $('kpiVerluste').textContent = fmt(state.totals.losses, 0) + ' GWh'; // Aktueller Korridor-Status — Live-Indikator setBalanceIndicator(state.lastBalance, state.lastGapRel); // Wasserstände auf dem SVG-Querschnitt var topPct = Math.round((state.tankTop / TANK_CAP) * 100); var botPct = Math.round((state.tankBot / TANK_CAP) * 100); renderUpperLake(topPct); renderLowerLake(botPct); $('stTopPctLabel').textContent = topPct + ' %'; $('stBotPctLabel').textContent = botPct + ' %'; // Pumpe/Turbine-Symbole + Druckrohr-Animation renderPipeFlow(); refreshControlUi(); var pumpSym = document.getElementById('stPumpSymbol'); var turbSym = document.getElementById('stTurbSymbol'); if (pumpSym) pumpSym.classList.toggle('active', state.control < -0.02); if (turbSym) turbSym.classList.toggle('active', state.control > 0.02); // Graph-Meta (Cursor-Bedarfswert + aktuelle freie Einspeisung) var freeSupplyNow = supplyAtTick(displayDay, displayTickInDay); $('graphDemandNow').textContent = fmt(demand, 0); $('graphSupplyNow').textContent = fmt(freeSupplyNow, 0); // Graph wird jetzt von der RAF-Schleife pixelweise gerendert — // hier kein drawGraph()-Aufruf mehr nötig. } // Oberbecken — Wasserkörper innerhalb des Mulden-Polygons. // ClipPath stUpperLakeClip: 660,140 → 910,140 → 850,200 → 680,200 // y=140 (Wasseroberkante bei voller Füllung) // y=200 (Beckenboden — leer wenn Wasser bis dahin) function renderUpperLake(pct) { var el = document.getElementById('stWaterTop'); if (!el) return; var bottomY = 200, topYAtFull = 140; var depth = (bottomY - topYAtFull) * (pct / 100); var y = bottomY - depth; el.setAttribute('y', y); el.setAttribute('height', depth); el.classList.toggle('danger', pct < 15); } // Unterbecken — Wasserkörper im Tal. // ClipPath stLowerLakeClip: 130,420 → 383,420 → 300,480 → 150,480 function renderLowerLake(pct) { var el = document.getElementById('stWaterBot'); if (!el) return; var bottomY = 480, topYAtFull = 420; var depth = (bottomY - topYAtFull) * (pct / 100); var y = bottomY - depth; el.setAttribute('y', y); el.setAttribute('height', depth); el.classList.toggle('danger', pct < 15); } // Druckrohr-Wasseranimation: zeigt Fließrichtung + Geschwindigkeit. // Stufenlos — Geschwindigkeit ∝ |state.control|. Es können mehrere Pfade // (Pump-Pfad + Turbinen-Pfad) gleichzeitig animiert werden. function renderPipeFlow() { var nodes = document.querySelectorAll('.st-pipe-flow'); if (!nodes.length) return; var c = state.control; var mag = Math.abs(c); // Geschwindigkeit invers zur Magnitude — bei Volllast 0.6 s, sehr schwach // ~3 s. Min-Cap damit's nicht stehen bleibt bei wirklich kleinem Hub. var dur = mag > 0.02 ? (0.6 + (1 - Math.min(mag, 1)) * 2.4).toFixed(2) + 's' : '1.5s'; nodes.forEach(function (el) { el.classList.remove('active', 'pump', 'turbine'); el.style.setProperty('--st-flow-dur', dur); if (c > 0.02) el.classList.add('active', 'turbine'); if (c < -0.02) el.classList.add('active', 'pump'); }); } function setBalanceIndicator(balance, gapRel) { var el = $('kpiKorridor'); var needle = $('livemeterNeedle'); // Magnitude immer positiv — Vorzeichen kommt aus dem Wort-Label var pctStr = Math.abs(Math.round(gapRel * 100)) + ' %'; // Skala: Erzeugung − Verbrauch (also −gapRel). // positiv = Überschuss → Nadel rechts (passt zum „+50 % Überschuss"-Label) // negativ = Lücke → Nadel links (passt zum „−50 % Lücke"-Label) if (needle) { var meterValue = -gapRel; var clamped = Math.max(-0.5, Math.min(0.5, meterValue)); var pos = (clamped + 0.5) * 100; // 0..100 % needle.style.left = pos.toFixed(1) + '%'; } if (!el) return; var label, sub, cls; switch (balance) { case 'yellow-deficit': label = '🟡 Lücke'; sub = 'unter Bedarf · ' + pctStr; cls = 'st-balance-yellow'; break; case 'red-deficit': label = '🔴 Lücke'; sub = 'kritisch unter Bedarf · ' + pctStr; cls = 'st-balance-red'; break; case 'yellow-surplus': label = '🟡 Überschuss'; sub = 'über Bedarf · ' + pctStr; cls = 'st-balance-yellow'; break; case 'red-surplus': label = '🔴 Überschuss'; sub = 'kritisch über Bedarf · ' + pctStr; cls = 'st-balance-red'; break; case 'green': default: label = '🟢 im Korridor'; sub = '±15 % um den Bedarf'; cls = 'st-balance-green'; break; } el.textContent = label; $('kpiKorridorSub').textContent = sub; el.classList.remove('st-balance-green', 'st-balance-yellow', 'st-balance-red'); el.classList.add(cls); } // ---------- Bedarfs-Graph: Wellenform + Korridor-Band ---------- // Cursor-Position kommt aus state.simProgress (float in Tick-Einheiten), // nicht aus state.totalTick — dadurch bewegt sich der Cursor pixelweise. function drawGraph() { var svg = $('graphSvg'); if (!svg) return; var W = 1200, H = 240; var padL = 38, padR = 14, padT = 14, padB = 24; var innerW = W - padL - padR; var innerH = H - padT - padB; // Y-Range: 0 bis Tagesmaximum * Spitzen-Faktor 1.21 + Headroom var dayMax = Math.max.apply(null, DEMAND_PER_DAY); var yMax = dayMax * 1.21 * 1.20; // 20 % Headroom über die Spitze function tx(t) { return padL + (t / TOTAL_TICKS) * innerW; } function ty(v) { return padT + innerH - (v / yMax) * innerH; } // Bedarfs- und Einspeisungs-Werte für alle Ticks vorberechnen (deterministic) var demandVals = []; var supplyVals = []; for (var t = 0; t < TOTAL_TICKS; t++) { var d = Math.floor(t / TICKS_PER_DAY) + 1; var ti = t % TICKS_PER_DAY; demandVals.push(demandAtTick(d, ti)); supplyVals.push(supplyAtTick(d, ti)); } // Bedarfslinie als smooth Path (L-Verbindungen reichen, da 60 Punkte // bei der Wellenform schon weich aussehen) var demandPath = ''; var freeSupplyPath = ''; for (var i = 0; i < TOTAL_TICKS; i++) { demandPath += (i === 0 ? 'M ' : ' L ') + tx(i).toFixed(1) + ' ' + ty(demandVals[i]).toFixed(1); freeSupplyPath += (i === 0 ? 'M ' : ' L ') + tx(i).toFixed(1) + ' ' + ty(supplyVals[i]).toFixed(1); } // Korridor-Band ±15 % um die Bedarfslinie (geschlossener Pfad) var corridorPath = ''; // Oberkante (links → rechts) for (var u = 0; u < TOTAL_TICKS; u++) { corridorPath += (u === 0 ? 'M ' : ' L ') + tx(u).toFixed(1) + ' ' + ty(demandVals[u] * 1.15).toFixed(1); } // Unterkante (rechts → links) for (var lo = TOTAL_TICKS - 1; lo >= 0; lo--) { corridorPath += ' L ' + tx(lo).toFixed(1) + ' ' + ty(demandVals[lo] * 0.85).toFixed(1); } corridorPath += ' Z'; // Versorgungslinie — nur bis zum aktuellen Tick gezeichnet var supplyPath = ''; var sup = state.history.supply; for (var s = 0; s < sup.length; s++) { supplyPath += (s === 0 ? 'M ' : ' L ') + tx(s).toFixed(1) + ' ' + ty(Math.max(0, sup[s])).toFixed(1); } // Außerhalb-Korridor-Marker — gelbe/rote Punkte auf der Versorgungslinie var markerHtml = ''; for (var m = 0; m < sup.length; m++) { var dem = demandVals[m]; var rel = Math.abs(dem - sup[m]) / dem; if (rel > 0.15) { var col = rel > 0.30 ? '#c85c4a' : '#d6a04e'; markerHtml += ''; } } var html = ''; // Tagesstreifen (abwechselnd) for (var d = 0; d < TOTAL_DAYS; d++) { var x1 = tx(d * TICKS_PER_DAY); var x2 = tx((d + 1) * TICKS_PER_DAY); var bg = d % 2 === 0 ? '#fafaf6' : '#f0ebde'; html += ''; } // Y-Achsen-Hilfslinien (zuerst, hinter den Daten) var yMarks = [0, Math.round(yMax * 0.25), Math.round(yMax * 0.5), Math.round(yMax * 0.75), Math.round(yMax)]; yMarks.forEach(function (val) { if (val === 0) return; var yy = ty(val); html += ''; }); // Korridor-Band html += ''; // Tag-Trenner (vertikale Linien) for (var dt = 1; dt < TOTAL_DAYS; dt++) { var dx = tx(dt * TICKS_PER_DAY); html += ''; } // Bedarfslinie (rot, durchgezogen) html += ''; // Freie Einspeisung (Solar/Wind, gelb gestrichelt) — Wellenform unabhängig vom Bedarf html += ''; // Versorgungslinie (blau, was tatsächlich geliefert wurde — nur Vergangenheit) if (supplyPath) { html += ''; } // Außerhalb-Korridor-Marker html += markerHtml; // Cursor (jetzt) — pixelweise per state.simProgress var cp = clamp(state.simProgress, 0, TOTAL_TICKS); var cx = tx(cp); // Werte am Cursor — Bedarf interpoliert, Versorgung am letzten Tick var cpDay = Math.floor(cp / TICKS_PER_DAY) + 1; var cpTick = cp - Math.floor(cp / TICKS_PER_DAY) * TICKS_PER_DAY; if (cpDay > TOTAL_DAYS) cpDay = TOTAL_DAYS; var cursorDemand = demandAtTick(cpDay, cpTick); var lastSup = sup.length > 0 ? sup[sup.length - 1] : null; html += ''; html += ''; if (lastSup !== null) { // Versorgungs-Punkt: läuft auf der bisher gezeichneten Linie mit // dem Cursor weiter, indem wir bis zum aktuellen X linear extrapolieren var lastX = tx(sup.length); var supPointX = Math.min(cx, lastX); html += ''; } // Tag-Beschriftung (unter dem Plot) for (var dl = 0; dl < TOTAL_DAYS; dl++) { var lx = tx(dl * TICKS_PER_DAY + TICKS_PER_DAY / 2); html += 'Tag ' + (dl + 1) + ''; } // Y-Achsen-Texte am linken Rand (über dem Plot, oberhalb) yMarks.forEach(function (val) { var yy = ty(val); html += '' + val + ''; }); svg.innerHTML = html; } // ---------- InfoCards (slide-in, nicht-blockierend) ---------- // Inhalts-Definitionen pro Trigger-Key. Audio-File-Namen sind optional — // gibt es kein audio-Feld, wird nichts abgespielt. var INFOCARDS = { anlage: { tone: '', title: 'Eine Pumpspeicher-Anlage', body: 'Du steuerst zwei Speicherseen. Pumpspeicher sind ' + 'Zwischenspeicher: wenn Solar oder Wind mehr Strom ' + 'liefern als gerade gebraucht wird, hebst du Wasser nach oben ' + '(Überschuss speichern). Wenn der Bedarf hochgeht und Solar ' + 'wegfällt, lässt du Wasser ab und turbinierst.', audio: 'anlage.mp3', }, pumpe: { tone: 'tone-pump', title: 'Pumpen — Überschuss in den Speicher', body: 'Du pumpst Wasser nach oben. Das verbraucht Strom — am besten ' + 'genau dann, wenn Solar mehr liefert als das Netz braucht. ' + 'Sonst verschiebst du Energie zum Schaden des Netzes. ' + 'Wirkungsgrad ~75 %, ein Viertel geht verloren.', audio: 'pumpe.mp3', }, turbine: { tone: 'tone-turbine', title: 'Turbinen — Lücke im Bedarf decken', body: 'Wasser fließt nach unten, treibt die Turbine, erzeugt Strom. ' + 'Mach das in den Spitzen, wenn Solar und Wind nicht reichen. ' + 'Wirkungsgrad ~85 %, knapp ein Sechstel als Verlust.', audio: 'turbine.mp3', }, korridor: { tone: 'tone-warn', title: 'Netz aus dem Gleichgewicht', body: 'Zu wenig Strom = Lücke (Verbraucher kriegen nichts). ' + 'Zu viel = Überschuss (Strom geht ungenutzt verloren). ' + 'Beides ist schlecht. Halte das Netz im grünen Korridor ' + '±15 % um den Bedarf.', audio: 'korridor.mp3', }, spitze: { tone: 'tone-spike', title: 'Heute kommt die Spitze', body: 'Tag 3 hat den höchsten Bedarf des Durchgangs. Hoffentlich ' + 'hast du oben genug Wasser — sonst kannst du die Spitze ' + 'nicht decken.', audio: 'spitze.mp3', }, }; function showInfoCard(key) { if (state.infocardsShown[key]) return; var def = INFOCARDS[key]; if (!def) return; state.infocardsShown[key] = true; var container = $('stInfocards'); if (!container) return; var card = document.createElement('div'); card.className = 'st-infocard ' + (def.tone || ''); var html = ''; html += '
'; html += '

'; html += '' + def.title + ''; html += ''; html += '

'; html += '

' + def.body + '

'; html += '
'; card.innerHTML = html; container.appendChild(card); // Sliding-in: zwei rAF damit Browser den Initial-Transform anwendet requestAnimationFrame(function () { requestAnimationFrame(function () { card.classList.add('visible'); }); }); var dismissTimer = setTimeout(function () { dismissCard(card); }, 9000); card.querySelector('.st-infocard-close').addEventListener('click', function () { clearTimeout(dismissTimer); dismissCard(card); }); // Optionale Sprachausgabe — wenn Audio-Datei existiert + nicht stumm if (def.audio && state.audioEnabled) { try { var a = new Audio((window.STAUSTUFEN_BASE || '') + 'sounds/' + def.audio); a.volume = 0.85; a.play().catch(function () {}); } catch (e) { /* still */ } } } function dismissCard(card) { card.classList.remove('visible'); card.classList.add('dismissing'); setTimeout(function () { if (card.parentNode) card.remove(); }, 380); } // ---------- Hint-Pop-ups ---------- function showHint(key, title, body) { if (state.hintsShown[key]) return; state.hintsShown[key] = true; state.paused = true; $('hintTitle').textContent = title; $('hintBody').innerHTML = body; $('hintOverlay').classList.add('open'); } function closeHint() { $('hintOverlay').classList.remove('open'); state.paused = false; // Welcome-Hint dismissed → erste InfoCard direkt einblenden if (state.hintsShown.welcome && !state.infocardsShown.anlage) { showInfoCard('anlage'); } } // ---------- Steuerung ---------- // Stufenlose Steuerung — value ∈ [-100..+100] vom Slider, intern −1..+1. // Negativ = Pumpe, positiv = Turbine, 0 = aus. // step="any" am Range-Input → Float-Werte ohne Rasterung. function setControl(sliderValue) { var v = clamp(parseFloat(sliderValue) || 0, -100, 100); var prev = state.control; state.control = v / 100; // Float, keine Rundung refreshControlUi(); if (state.control < -0.02 && !state.firstPumpUsed) { state.firstPumpUsed = true; showInfoCard('pumpe'); } if (state.control > 0.02 && !state.firstTurbineUsed) { state.firstTurbineUsed = true; showInfoCard('turbine'); } if (prev !== state.control) render(); } function refreshControlUi() { var slider = $('sliderControl'); var readout = $('readoutControl'); if (!slider) return; // Float-Wert (-100..+100) für Slider, ohne Rundung var v = state.control * 100; if (Math.abs(parseFloat(slider.value) - v) > 0.05) slider.value = v; // Track-Highlight via Klasse + CSS-Variable var thumbPct = (v + 100) / 2; // 0..100 (% der Track-Breite) slider.style.setProperty('--st-thumb-pct', thumbPct.toFixed(2) + '%'); slider.classList.remove('mode-pump', 'mode-turbine', 'mode-off'); if (v > 1) slider.classList.add('mode-turbine'); else if (v < -1) slider.classList.add('mode-pump'); else slider.classList.add('mode-off'); if (readout) { var label; var absV = Math.round(Math.abs(v)); // Anzeige integer, intern bleibt float if (absV === 0) label = 'aus'; else if (v > 0) label = 'Turbine ' + absV + ' %'; else label = 'Pumpe ' + absV + ' %'; readout.textContent = label; readout.classList.toggle('pump', v < -0.5); readout.classList.toggle('turbine', v > 0.5); readout.classList.toggle('off', absV === 0); } } function setSpeed(mode) { if (mode === 'pause') { state.paused = true; } else { state.paused = false; state.speed = mode; } document.querySelectorAll('.st-speed-btn').forEach(function (b) { b.classList.remove('active'); }); var map = { 'pause':'speedPause', '1x':'speed1x', '4x':'speed4x' }; var elt = $(map[mode]); if (elt) elt.classList.add('active'); } // ---------- RAF-Loop: pixelweise Zeit-Achse ---------- // simProgress läuft als float in Tick-Einheiten (0..TOTAL_TICKS). // Der Cursor liest direkt simProgress, deshalb bewegt er sich smooth. // Sobald simProgress eine ganze Zahl überschreitet, wird ein Tick verarbeitet. function rafLoop() { var now = Date.now(); var dt = now - state.lastFrameMs; state.lastFrameMs = now; if (!state.paused && !state.finished) { var tickMs = SPEED_MS[state.speed] || SPEED_MS['1x']; state.simProgress += dt / tickMs; if (state.simProgress > TOTAL_TICKS) state.simProgress = TOTAL_TICKS; // Tick-Verarbeitung holt die Sprünge ein, falls dt > tickMs (z. B. nach Tab-Wechsel) while (state.totalTick < Math.floor(state.simProgress) && state.totalTick < TOTAL_TICKS) { tick(); if (state.finished) break; } } drawGraph(); if (!state.finished) { state.rafId = requestAnimationFrame(rafLoop); } else { state.rafId = null; } } function startRaf() { if (state.rafId) cancelAnimationFrame(state.rafId); state.lastFrameMs = Date.now(); state.rafId = requestAnimationFrame(rafLoop); } // ---------- Bewertung ---------- function evaluate() { // Netzbalance-Score = Anteil grüner Ticks var ticksTotal = state.totals.ticksGreen + state.totals.ticksYellow + state.totals.ticksRed; var greenPct = ticksTotal > 0 ? Math.round((state.totals.ticksGreen / ticksTotal) * 100) : 0; var yellowPct = ticksTotal > 0 ? Math.round((state.totals.ticksYellow / ticksTotal) * 100) : 0; var redPct = ticksTotal > 0 ? Math.round((state.totals.ticksRed / ticksTotal) * 100) : 0; // Verlust-Quote: losses / waterMoved var lossPct = state.totals.waterMoved > 0 ? state.totals.losses / state.totals.waterMoved : 0; var grade = 'E'; if (greenPct >= 90 && lossPct <= 0.22) grade = 'A'; else if (greenPct >= 78 && lossPct <= 0.30) grade = 'B'; else if (greenPct >= 60) grade = 'C'; else if (greenPct >= 40) grade = 'D'; // Narrative: schaut sich Lücke vs. Überschuss + Verluste an var unmet = state.totals.unmet; var surplus = state.totals.surplus; var dominantSide = unmet > surplus ? 'deficit' : surplus > unmet ? 'surplus' : 'balanced'; var narrative = ''; if (grade === 'A') { narrative = 'Das Netz lief fast durchgehend im grünen Korridor — ' + 'sauber gepumpt, sauber turbiniert. Spitze und Tal sind zueinander ' + 'in Balance gebracht worden.'; } else if (grade === 'B') { narrative = 'Das Netz war meistens stabil. ' + (dominantSide === 'surplus' ? 'In manchen Phasen lief etwas zu viel — Strom geht ungenutzt ' + 'verloren, wenn Erzeugung den Bedarf zu deutlich übersteigt.' : dominantSide === 'deficit' ? 'In manchen Phasen war zu wenig Strom verfügbar — ' + 'Verbraucher bekamen weniger als sie brauchten.' : 'Mit etwas weniger Hin-und-Her wäre A drin gewesen.'); } else if (grade === 'C') { narrative = 'Das Netz hat öfter den grünen Korridor verlassen. ' + (dominantSide === 'surplus' ? 'Häufig lief Turbine oder freie Einspeisung über den Bedarf — ' + 'das ist Verschwendung. Tipp: Turbine nur so stark, wie der ' + 'Bedarf wirklich braucht.' : 'Häufig blieb der Bedarf ungedeckt. Tipp: in den niedrigen ' + 'Tagen 1 und 4 oben Wasser sammeln, damit Tag 3 und 5 ' + 'durchgehalten werden.'); } else if (grade === 'D') { narrative = 'Das Netz war oft im roten Bereich. ' + 'Strom-Überschuss und -Lücke beide vermeiden: Pumpe nur wenn ' + 'wirklich Überschuss da ist, Turbine nur so stark wie Bedarf.'; } else { narrative = 'Das System wurde mehrfach aus dem Gleichgewicht ' + 'gebracht. Das Netz braucht ständige Ausbalancierung — schau dir ' + 'die Bedarfskurve genau an und reagiere mit der passenden Stufe.'; } return { greenPct: greenPct, yellowPct: yellowPct, redPct: redPct, losses: state.totals.losses, unmet: state.totals.unmet, surplus: state.totals.surplus, lossPct: Math.round(lossPct * 100), grade: grade, narrative: narrative, dominantSide: dominantSide, }; } // ---------- Ende ---------- function finish() { if (state.finished) return; state.finished = true; // RAF-Loop hört von selbst auf (wegen state.finished), keine clearInterval nötig if (state.assessment.pingTimer) { clearInterval(state.assessment.pingTimer); state.assessment.pingTimer = null; } var r = evaluate(); $('endNetzbalance').textContent = r.greenPct + ' %'; $('endVerluste').textContent = fmt(r.losses, 0) + ' GWh'; var deviationLabel = ''; var deviation = 0; if (r.dominantSide === 'deficit') { deviationLabel = 'Lücke'; deviation = r.unmet; } else if (r.dominantSide === 'surplus') { deviationLabel = 'Überschuss'; deviation = r.surplus; } else { deviationLabel = 'Abweichung'; deviation = Math.max(r.unmet, r.surplus); } $('endDeviation').textContent = fmt(deviation, 0) + ' GWh'; $('endDeviationLabel').textContent = deviationLabel; var endGrade = $('endGrade'); endGrade.textContent = r.grade; endGrade.className = 'st-endscreen-grade ' + r.grade; var endCard = $('endCard'); endCard.classList.remove('success', 'failed'); if (r.grade === 'A' || r.grade === 'B') { endCard.classList.add('success'); $('endIcon').textContent = '🏆'; $('endTitle').textContent = 'Erfolgreich durchgeführt'; } else if (r.grade === 'D' || r.grade === 'E') { endCard.classList.add('failed'); $('endIcon').textContent = '⚠️'; $('endTitle').textContent = 'Mit Lücken durchgeführt'; } else { $('endIcon').textContent = '🏞️'; $('endTitle').textContent = 'Durchgang abgeschlossen'; } $('endSubtitle').textContent = '5 Tage Netzbalance bilanziert — ' + fmt(state.totals.turbineSupplied, 0) + ' GWh aus Turbinen, ' + fmt(state.totals.pumpDrawn, 0) + ' GWh in Pumpspeicher.'; $('endNarrative').textContent = r.narrative; $('endOverlay').style.display = 'flex'; assessmentComplete(r); } function restart() { // Komplett zurücksetzen state.day = 1; state.tickInDay = 0; state.totalTick = 0; state.paused = false; state.finished = false; state.tankTop = INIT_TOP; state.tankBot = INIT_BOT; state.control = 0; state.firstPumpUsed = false; state.firstTurbineUsed = false; state.lastTurbineWater = 0; state.lastPumpWater = 0; state.lastTurbineElec = 0; state.lastPumpElec = 0; state.totals = { demand: 0, freeSupplyTotal: 0, turbineSupplied: 0, pumpDrawn: 0, unmet: 0, surplus: 0, losses: 0, waterMoved: 0, ticksGreen: 0, ticksYellow: 0, ticksRed: 0, }; state.lastBalance = 'green'; state.lastGapRel = 0; state.history = { supply: [] }; state.hintsShown = { welcome: false, imbalance: false }; state.infocardsShown = { anlage: false, pumpe: false, turbine: false, korridor: false, spitze: false, }; var ic = $('stInfocards'); if (ic) ic.innerHTML = ''; state.assessment.startMs = null; state.assessment.sentStarted = false; state.assessment.sentCompleted = false; refreshControlUi(); state.simProgress = 0; state.lastFrameMs = Date.now(); setSpeed('1x'); startRaf(); $('endOverlay').style.display = 'none'; render(); showWelcomeHint(); assessmentStart(); } // ---------- Assessment-API ---------- function assessmentSend(payload) { try { if (!window.STAUSTUFEN_SESSION_ID) return; // Demo-Modus var url = (window.STAUSTUFEN_API_BASE || '/api') + '/assessment'; fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(payload), }).catch(function () {}); } catch (e) { /* still */ } } function assessmentStart() { state.assessment.startMs = Date.now(); state.assessment.sentStarted = true; assessmentSend({ simId: 'staustufen', processLog: { phase: 'started', forcedLevel: window.STAUSTUFEN_FORCED_LEVEL || 1, mode: window.STAUSTUFEN_SESSION_MODE || 'free', easy: !!window.STUDENT_EASY, }, duration: 0, completedPhases: [], }); if (state.assessment.pingTimer) clearInterval(state.assessment.pingTimer); state.assessment.pingTimer = setInterval(assessmentPing, 30000); } function assessmentPing() { if (!state.assessment.startMs || state.finished) return; var dt = Date.now() - state.assessment.startMs; assessmentSend({ simId: 'staustufen', processLog: { phase: 'running', day: state.day, tickTotal: state.totalTick, tankTopPct: Math.round(state.tankTop / TANK_CAP * 100), tankBotPct: Math.round(state.tankBot / TANK_CAP * 100), control: Math.round(state.control * 100), // -100..+100, stufenlos ticksGreen: state.totals.ticksGreen, ticksYellow: state.totals.ticksYellow, ticksRed: state.totals.ticksRed, unmetSoFar: Math.round(state.totals.unmet), surplusSoFar: Math.round(state.totals.surplus), lossesSoFar: Math.round(state.totals.losses), }, duration: dt, completedPhases: ['day' + state.day], }); } function assessmentComplete(result) { if (state.assessment.sentCompleted) return; state.assessment.sentCompleted = true; var dt = state.assessment.startMs ? Date.now() - state.assessment.startMs : 0; assessmentSend({ simId: 'staustufen', processLog: { phase: 'completed', finalDay: TOTAL_DAYS, }, results: { grade: result.grade, netzbalance: result.greenPct, // Anteil grüner Ticks ticksGreen: state.totals.ticksGreen, ticksYellow: state.totals.ticksYellow, ticksRed: state.totals.ticksRed, losses: Math.round(result.losses), lossPct: result.lossPct, unmet: Math.round(result.unmet), surplus: Math.round(result.surplus), dominantSide: result.dominantSide, turbineSupplied: Math.round(state.totals.turbineSupplied), pumpDrawn: Math.round(state.totals.pumpDrawn), }, duration: dt, completedPhases: ['day1','day2','day3','day4','day5','results'], }); } // ---------- Welcome-Hint ---------- function showWelcomeHint() { showHint('welcome', 'Willkommen in der Kaskade.', 'Im Netz schwankt beides: der Bedarf (rote Linie) und die ' + 'freie Einspeisung aus Solar und Wind (gelbe Linie). ' + 'Wenn die gelbe Linie über der roten liegt — Überschuss, du ' + 'kannst pumpen und Wasser für später nach oben heben. ' + 'Wenn sie darunter liegt — Lücke, du musst turbinieren ' + 'und mit gespeichertem Wasser auffüllen. Halte das Netz im ' + 'grünen Korridor ±15 % um den Bedarf — beide Seiten sind schlecht.'); } // ---------- Init ---------- function init() { // Steuerung — bidirektionaler Slider (-100..+100, stufenlos) var ctrl = $('sliderControl'); if (ctrl) { ctrl.addEventListener('input', function (e) { setControl(e.target.value); }); ctrl.addEventListener('change', function (e) { setControl(e.target.value); }); } // Doppel-Tap auf den Mittelpunkt → zurück auf 0 (Notbremse) var ctrlZero = $('btnControlZero'); if (ctrlZero) { ctrlZero.addEventListener('click', function () { setControl(0); }); } // Tempo $('speedPause').addEventListener('click', function () { setSpeed('pause'); }); $('speed1x').addEventListener('click', function () { setSpeed('1x'); }); $('speed4x').addEventListener('click', function () { setSpeed('4x'); }); // Hint $('hintClose').addEventListener('click', closeHint); $('hintOverlay').addEventListener('click', function (e) { if (e.target === $('hintOverlay')) closeHint(); }); // Restart $('endRestart').addEventListener('click', restart); // Audio-Toggle (Sprachausgabe) var audioBtn = $('btnAudioToggle'); if (audioBtn) { audioBtn.addEventListener('click', function () { state.audioEnabled = !state.audioEnabled; audioBtn.classList.toggle('muted', !state.audioEnabled); audioBtn.textContent = state.audioEnabled ? '🔊' : '🔇'; audioBtn.setAttribute('aria-pressed', state.audioEnabled ? 'true' : 'false'); audioBtn.title = state.audioEnabled ? 'Sprachausgabe aus' : 'Sprachausgabe an'; }); } // Erst-Render render(); // Welcome-Hint sofort beim Start (Pause) showWelcomeHint(); // Assessment + Smooth-RAF-Loop assessmentStart(); startRaf(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();