1e51ef7def
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/ - 12 code-workspace-Files - STATUS-*.md - viele M/D/R-Änderungen an bereits getrackten Files - .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1233 lines
46 KiB
JavaScript
1233 lines
46 KiB
JavaScript
/**
|
||
* 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 <b>gelbe Solar-Linie</b> mittags weit ' +
|
||
'über der <b>roten Bedarfs-Linie</b> — es gibt Stromüberschuss. ' +
|
||
'Wenn du nichts machst, verpufft der einfach. Schieb den Regler ' +
|
||
'beim Maschinenhaus <b>nach links</b> (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 += '<circle cx="' + tx(m).toFixed(1) +
|
||
'" cy="' + ty(Math.max(0, sup[m])).toFixed(1) +
|
||
'" r="3" fill="' + col + '"/>';
|
||
}
|
||
}
|
||
|
||
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 += '<rect x="' + x1.toFixed(1) + '" y="' + padT +
|
||
'" width="' + (x2 - x1).toFixed(1) +
|
||
'" height="' + innerH + '" fill="' + bg + '"/>';
|
||
}
|
||
|
||
// 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 += '<line x1="' + padL + '" y1="' + yy.toFixed(1) +
|
||
'" x2="' + (padL + innerW) + '" y2="' + yy.toFixed(1) +
|
||
'" stroke="#d6d1c4" stroke-width="0.5" opacity="0.5"/>';
|
||
});
|
||
|
||
// Korridor-Band
|
||
html += '<path d="' + corridorPath +
|
||
'" fill="rgba(90,138,94,.20)" stroke="rgba(90,138,94,.40)" stroke-width="0.6"/>';
|
||
|
||
// Tag-Trenner (vertikale Linien)
|
||
for (var dt = 1; dt < TOTAL_DAYS; dt++) {
|
||
var dx = tx(dt * TICKS_PER_DAY);
|
||
html += '<line x1="' + dx.toFixed(1) + '" y1="' + padT +
|
||
'" x2="' + dx.toFixed(1) + '" y2="' + (padT + innerH) +
|
||
'" stroke="#c8c4b3" stroke-width="1"/>';
|
||
}
|
||
|
||
// Bedarfslinie (rot, durchgezogen)
|
||
html += '<path d="' + demandPath +
|
||
'" stroke="#c85c4a" stroke-width="2.6" fill="none" stroke-linejoin="round"/>';
|
||
|
||
// Freie Einspeisung (Solar/Wind, gelb gestrichelt) — Wellenform unabhängig vom Bedarf
|
||
html += '<path d="' + freeSupplyPath +
|
||
'" stroke="#e8a82e" stroke-width="2" fill="none" stroke-linejoin="round" ' +
|
||
'stroke-dasharray="6 4" opacity="0.85"/>';
|
||
|
||
// Versorgungslinie (blau, was tatsächlich geliefert wurde — nur Vergangenheit)
|
||
if (supplyPath) {
|
||
html += '<path d="' + supplyPath +
|
||
'" stroke="#1f4e5a" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>';
|
||
}
|
||
|
||
// 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 += '<line x1="' + cx.toFixed(1) + '" y1="' + padT +
|
||
'" x2="' + cx.toFixed(1) + '" y2="' + (padT + innerH) +
|
||
'" stroke="#4a7c8a" stroke-width="1.6" opacity="0.65"/>';
|
||
html += '<circle cx="' + cx.toFixed(1) + '" cy="' +
|
||
ty(cursorDemand).toFixed(1) +
|
||
'" r="5" fill="#c85c4a" stroke="#fff" stroke-width="1.8"/>';
|
||
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 += '<circle cx="' + supPointX.toFixed(1) + '" cy="' +
|
||
ty(Math.max(0, lastSup)).toFixed(1) +
|
||
'" r="5" fill="#1f4e5a" stroke="#fff" stroke-width="1.8"/>';
|
||
}
|
||
|
||
// 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 += '<text x="' + lx.toFixed(1) + '" y="' + (H - 8) +
|
||
'" text-anchor="middle" font-size="13" fill="#666" font-weight="600">Tag ' +
|
||
(dl + 1) + '</text>';
|
||
}
|
||
|
||
// Y-Achsen-Texte am linken Rand (über dem Plot, oberhalb)
|
||
yMarks.forEach(function (val) {
|
||
var yy = ty(val);
|
||
html += '<text x="' + (padL - 6) + '" y="' + yy.toFixed(1) +
|
||
'" font-size="11" fill="#888" text-anchor="end" ' +
|
||
'alignment-baseline="middle">' + val + '</text>';
|
||
});
|
||
|
||
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 ' +
|
||
'<b>Zwischenspeicher</b>: 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 = <b>Lücke</b> (Verbraucher kriegen nichts). ' +
|
||
'Zu viel = <b>Überschuss</b> (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 += '<div class="st-infocard-body">';
|
||
html += '<h3 class="st-infocard-title">';
|
||
html += '<span class="st-infocard-title-text">' + def.title + '</span>';
|
||
html += '<button class="st-infocard-close" aria-label="schließen">×</button>';
|
||
html += '</h3>';
|
||
html += '<p class="st-infocard-text">' + def.body + '</p>';
|
||
html += '</div>';
|
||
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 <b>Bedarf</b> (rote Linie) und die ' +
|
||
'<b>freie Einspeisung</b> aus Solar und Wind (gelbe Linie). ' +
|
||
'Wenn die gelbe Linie über der roten liegt — <b>Überschuss</b>, du ' +
|
||
'kannst <b>pumpen</b> und Wasser für später nach oben heben. ' +
|
||
'Wenn sie darunter liegt — <b>Lücke</b>, du musst <b>turbinieren</b> ' +
|
||
'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();
|
||
}
|
||
})();
|