Files
geograsim/App/assets/js/glossar-experiments-wirtschaft.js
Adminator fecd8bb9c9 Textsystem: Bis-Strich mit Leerzeichen (Zahl – Zahl) per Agent-Sweep
562 Ersetzungen in 50 Dateien: Halbgeviertstrich zwischen zwei Ziffern (=
"bis"-Bereich) bekommt Leerzeichen davor/danach (3–4 -> 3 – 4). Regex
/([0-9])\s*–\s*(?=[0-9])/ auf Rohtext (format-erhaltend, CRLF/Einrueckung
unveraendert), Bindestriche in Woertern und Minuszeichen unangetastet, alle
JSON weiter gueltig. Energiemanager-Zeitbloecke sind Display-Labels (kein
Logik-Key) -> safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:38:19 +02:00

742 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Glossar-Experimente — Wirtschaft & Energie.
*
* Registrierung wie in glossar-experiments.js:
* window.GlossarExperiments[key_slug] = function (root) { … }
* Der Renderer (glossar.php) hängt einen leeren Container ein und ruft die
* Funktion mit diesem Container als einzigem Argument auf.
*
* Richtlinien:
* - Keine externen Libraries, Vanilla JS + SVG.
* - Mobile-/Tap-freundlich: grosse Buttons, keine Hover-Abhängigkeit.
* - Zahlen quellenbar, kurze Begründung in der UI sichtbar (.ge-note).
* - Rechenzeichen: Malpunkt · und Doppelpunkt : (nie ×, ÷, *).
*/
(function () {
'use strict';
const NS = 'http://www.w3.org/2000/svg';
const svgEl = (tag, attrs, parent) => {
const el = document.createElementNS(NS, tag);
if (attrs) for (const k in attrs) el.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(el);
return el;
};
// Ganzzahl mit Tausenderpunkt: 56250 → "56.250"
const fmtInt = n => Math.round(n).toLocaleString('de-AT');
// Dezimalzahl mit Komma: 22.5 → "22,50" (digits Nachkommastellen)
const fmtDec = (n, digits) => n.toLocaleString('de-AT', {
minimumFractionDigits: digits, maximumFractionDigits: digits
});
// =====================================================================
// Experiment: Preiselastizität — der beste Skipass-Preis
// =====================================================================
function priceElasticity(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>🎿 Finde den besten Skipass-Preis</h3>
<p>Je teurer der Skipass, desto weniger Gäste kommen — die Nachfrage reagiert auf den Preis.
Der Umsatz ist <strong>Preis · Gäste</strong>. Wo ist er am größten?</p>
</div>
<div class="ge-gh-slider">
<label for="ge-pe-range">Skipass-Preis: <strong id="ge-pe-price">25 €</strong></label>
<input id="ge-pe-range" type="range" min="15" max="60" value="25" step="1">
<div class="ge-gh-markers"><span>15 €</span><span>30 €</span><span>45 €</span><span>60 €</span></div>
</div>
<svg class="ge-bal-svg" viewBox="0 0 600 170" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-gh-result">
<div class="ge-gh-pill">Rechnung: <strong id="ge-pe-calc">25 € · 2.000 Gäste = 50.000 €</strong></div>
<div class="ge-gh-pill" id="ge-pe-verdict">…</div>
</div>
<p class="ge-note">Vereinfachtes lineares Modell wie in der Tourismustal-Simulation:
Gäste = 3.000 40 · Preis. Echte Nachfragekurven sind komplizierter, aber das Prinzip
der Preiselastizität gilt überall: Der höchste Preis bringt nicht automatisch den
höchsten Umsatz.</p>
</div>
`;
const svg = root.querySelector('.ge-bal-svg');
const rng = root.querySelector('#ge-pe-range');
const priceOut = root.querySelector('#ge-pe-price');
const calcOut = root.querySelector('#ge-pe-calc');
const verdict = root.querySelector('#ge-pe-verdict');
const BAR_X = 120, BAR_W = 400;
const MAX_GUESTS = 2400, MAX_REV = 60000, BEST_REV = 56250; // Maximum bei 37,50 €
// Zeile 1: Gäste
svgEl('text', { x: BAR_X - 10, y: 50, 'text-anchor': 'end', 'font-size': '13', 'font-weight': '700', fill: '#1f4e5a' }, svg).textContent = 'Gäste';
svgEl('rect', { x: BAR_X, y: 32, width: BAR_W, height: 26, fill: '#eff5f6', rx: '6' }, svg);
const guestBar = svgEl('rect', { x: BAR_X, y: 32, width: 0, height: 26, fill: '#4a7c8a', rx: '6' }, svg);
const guestTxt = svgEl('text', { x: BAR_X, y: 50, 'font-size': '13', 'font-weight': '800', fill: '#1f4e5a' }, svg);
// Zeile 2: Umsatz
svgEl('text', { x: BAR_X - 10, y: 118, 'text-anchor': 'end', 'font-size': '13', 'font-weight': '700', fill: '#1f4e5a' }, svg).textContent = 'Umsatz';
svgEl('rect', { x: BAR_X, y: 100, width: BAR_W, height: 26, fill: '#eff5f6', rx: '6' }, svg);
const revBar = svgEl('rect', { x: BAR_X, y: 100, width: 0, height: 26, fill: '#5a8a5e', rx: '6' }, svg);
const revTxt = svgEl('text', { x: BAR_X, y: 118, 'font-size': '13', 'font-weight': '800', fill: '#1f4e5a' }, svg);
// Markierung: bestmöglicher Umsatz
const bestX = BAR_X + (BEST_REV / MAX_REV) * BAR_W;
svgEl('line', { x1: bestX, y1: 88, x2: bestX, y2: 138, stroke: '#c85c4a', 'stroke-width': '2', 'stroke-dasharray': '4 3' }, svg);
const bestLbl = svgEl('text', { x: bestX, y: 82, 'text-anchor': 'middle', 'font-size': '11', 'font-weight': '700', fill: '#c85c4a' }, svg);
bestLbl.textContent = 'Maximum: 56.250 €';
function update() {
const p = +rng.value;
const guests = Math.max(0, 3000 - 40 * p);
const rev = p * guests;
priceOut.textContent = p + ' €';
calcOut.textContent = p + ' € · ' + fmtInt(guests) + ' Gäste = ' + fmtInt(rev) + ' €';
const gw = (guests / MAX_GUESTS) * BAR_W;
guestBar.setAttribute('width', gw);
guestTxt.setAttribute('x', BAR_X + gw + 8);
guestTxt.textContent = fmtInt(guests);
const rw = (rev / MAX_REV) * BAR_W;
revBar.setAttribute('width', rw);
revTxt.setAttribute('x', BAR_X + rw + 8);
revTxt.textContent = fmtInt(rev) + ' €';
// Text nicht mit der Maximum-Linie kollidieren lassen
revTxt.setAttribute('opacity', (BAR_X + rw + 75 > bestX && BAR_X + rw < bestX + 10) ? '0.35' : '1');
let v, state;
if (Math.abs(p - 37.5) <= 2) { v = '🎯 Fast genau das Maximum!'; state = 'good'; }
else if (p < 30) { v = '🟡 Zu billig — volle Pisten, wenig Umsatz'; state = 'warn'; }
else if (p <= 45) { v = '🟢 Guter Bereich'; state = 'now'; }
else { v = '🔴 Zu teuer — die Gäste bleiben aus'; state = 'bad'; }
verdict.textContent = v;
verdict.dataset.state = state;
}
rng.addEventListener('input', update);
update();
}
// =====================================================================
// Experiment: Engpass — die Kette ist so stark wie ihr schwächstes Glied
// =====================================================================
function bottleneckChain(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>⛓ Wo klemmt es im Urlaubsort?</h3>
<p>Gäste durchlaufen drei Stationen: <strong>Anreise → Betten → Personal</strong>.
Stelle die Kapazitäten ein und beobachte, wo sich die Punkte stauen.</p>
</div>
<svg class="ge-bal-svg" viewBox="0 0 600 230" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-gh-slider">
<label for="ge-en-anreise">🚌 Anreise (Busse, Bahn, Straße): <strong id="ge-en-anreise-val">160</strong> Gäste pro Tag</label>
<input id="ge-en-anreise" type="range" min="20" max="200" value="160" step="10">
</div>
<div class="ge-gh-slider">
<label for="ge-en-betten">🛏 Betten (Hotels, Pensionen): <strong id="ge-en-betten-val">80</strong> Gäste pro Tag</label>
<input id="ge-en-betten" type="range" min="20" max="200" value="80" step="10">
</div>
<div class="ge-gh-slider">
<label for="ge-en-personal">👩‍🍳 Personal (Küche, Service, Reinigung): <strong id="ge-en-personal-val">120</strong> Gäste pro Tag</label>
<input id="ge-en-personal" type="range" min="20" max="200" value="120" step="10">
</div>
<div class="ge-gh-result">
<div class="ge-gh-pill">Zufriedene Gäste: <strong id="ge-en-out">Minimum(160; 80; 120) = 80</strong> pro Tag</div>
<div class="ge-gh-pill" id="ge-en-verdict" data-state="warn">Engpass: Betten</div>
</div>
<p class="ge-note">Prinzip der Engpass-Theorie („Theory of Constraints", E. M. Goldratt 1984):
Der Durchsatz einer Kette wird allein vom schwächsten Glied bestimmt. Mehr Kapazität an
anderen Stellen bringt nichts, solange der Engpass bleibt. Modellzahlen vereinfacht.</p>
</div>
`;
const svg = root.querySelector('.ge-bal-svg');
const ROAD_Y = 185;
const stations = [
{ key: 'anreise', label: 'Anreise', emoji: '🚌', x: 150 },
{ key: 'betten', label: 'Betten', emoji: '🛏', x: 310 },
{ key: 'personal', label: 'Personal', emoji: '👩‍🍳', x: 470 }
];
const caps = { anreise: 160, betten: 80, personal: 120 };
// Straße
svgEl('line', { x1: 10, y1: ROAD_Y, x2: 590, y2: ROAD_Y, stroke: '#dae8ec', 'stroke-width': '14', 'stroke-linecap': 'round' }, svg);
svgEl('text', { x: 14, y: ROAD_Y - 16, 'font-size': '11', fill: '#4a4a4a', 'font-weight': '600' }, svg).textContent = 'Gäste →';
svgEl('text', { x: 588, y: ROAD_Y - 16, 'text-anchor': 'end', 'font-size': '11', fill: '#5a8a5e', 'font-weight': '700' }, svg).textContent = 'zufrieden 😊';
// Stations-Boxen über der Straße
stations.forEach(s => {
s.box = svgEl('rect', { x: s.x - 55, y: 30, width: 110, height: 92, rx: '10', fill: '#ffffff', stroke: '#b5c9cf', 'stroke-width': '2' }, svg);
const em = svgEl('text', { x: s.x, y: 66, 'text-anchor': 'middle', 'font-size': '26' }, svg);
em.textContent = s.emoji;
const lbl = svgEl('text', { x: s.x, y: 90, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '800', fill: '#1f4e5a' }, svg);
lbl.textContent = s.label;
s.capTxt = svgEl('text', { x: s.x, y: 108, 'text-anchor': 'middle', 'font-size': '11', 'font-weight': '600', fill: '#4a4a4a' }, svg);
// Schranke auf der Straße
svgEl('line', { x1: s.x, y1: ROAD_Y - 12, x2: s.x, y2: ROAD_Y + 12, stroke: '#4a7c8a', 'stroke-width': '3', 'stroke-dasharray': '4 3' }, svg);
});
const dotGroup = svgEl('g', {}, svg);
// --- Mini-Verkehrssimulation -------------------------------------
const DAY_MS = 12000; // 12 s entsprechen einem Tag
const PER_DOT = 10; // 1 Punkt entspricht 10 Gästen
const INFLOW = 240; // Interessierte Gäste pro Tag (Nachfrage)
const SPEED = 0.085; // px pro ms
const GAP = 14; // Mindestabstand der Punkte
let particles = []; // { x, el }
let lastSpawn = 0;
const lastPass = { anreise: 0, betten: 0, personal: 0 };
let animId = null, lastT = 0;
function bottleneck() {
let minKey = 'anreise';
for (const k in caps) if (caps[k] < caps[minKey]) minKey = k;
return minKey;
}
function refreshUI() {
const minKey = bottleneck();
const minVal = caps[minKey];
const bLabel = stations.find(s => s.key === minKey).label;
root.querySelector('#ge-en-out').textContent =
'Minimum(' + caps.anreise + '; ' + caps.betten + '; ' + caps.personal + ') = ' + minVal;
const verdict = root.querySelector('#ge-en-verdict');
verdict.textContent = 'Engpass: ' + bLabel;
verdict.dataset.state = minVal >= 160 ? 'good' : (minVal >= 80 ? 'warn' : 'bad');
stations.forEach(s => {
s.capTxt.textContent = caps[s.key] + ' / Tag';
const isMin = s.key === minKey;
s.box.setAttribute('stroke', isMin ? '#c85c4a' : '#b5c9cf');
s.box.setAttribute('stroke-width', isMin ? '3' : '2');
});
}
function spawn(now) {
const interval = DAY_MS / (INFLOW / PER_DOT);
if (now - lastSpawn < interval || particles.length >= 70) return;
// Startplatz frei?
if (particles.some(p => p.x < 24)) return;
lastSpawn = now;
const el = svgEl('circle', { cx: 10, cy: ROAD_Y + (Math.random() * 8 - 4), r: 5, fill: '#4a7c8a', opacity: '0.9' }, dotGroup);
particles.push({ x: 10, el });
}
function step(now, dt) {
spawn(now);
// vorderste zuerst (größtes x)
particles.sort((a, b) => b.x - a.x);
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
let nx = p.x + SPEED * dt;
// Nicht auf den Vordermann auffahren
if (i > 0) nx = Math.min(nx, particles[i - 1].x - GAP);
// Nächste Schranke vor uns?
for (const s of stations) {
if (p.x < s.x && nx >= s.x) {
const interval = DAY_MS / (caps[s.key] / PER_DOT);
if (now - lastPass[s.key] >= interval) {
lastPass[s.key] = now; // durchgelassen
} else {
nx = s.x - 0.5; // warten → Stau bildet sich
}
break;
}
}
if (nx > p.x) p.x = nx; // nur vorwärts, nie rückwärts
p.el.setAttribute('cx', p.x);
}
// Ziel erreicht
particles = particles.filter(p => {
if (p.x > 585) { p.el.remove(); return false; }
return true;
});
}
function tick(now) {
if (!root.isConnected) { cancelAnimationFrame(animId); return; }
const dt = lastT ? Math.min(50, now - lastT) : 16;
lastT = now;
step(now, dt);
animId = requestAnimationFrame(tick);
}
animId = requestAnimationFrame(tick);
stations.forEach(s => {
const rng = root.querySelector('#ge-en-' + s.key);
rng.addEventListener('input', () => {
caps[s.key] = +rng.value;
root.querySelector('#ge-en-' + s.key + '-val').textContent = rng.value;
refreshUI();
});
});
refreshUI();
}
// =====================================================================
// Experiment: Skaleneffekt — Fixkosten verteilen sich auf mehr Gäste
// =====================================================================
function scaleEffect(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>🏠 Vier Gasthäuser — einzeln oder zusammengelegt?</h3>
<p>Küche, Verwaltung und Werbung kosten <strong>120.000 € im Jahr</strong> — egal ob für
ein Haus oder für vier. Lege Gasthäuser zu einem Betrieb zusammen und beobachte die
Kosten <strong>pro Gast</strong>.</p>
</div>
<div class="ge-gh-slider">
<label for="ge-sk-range">Zusammengelegt: <strong id="ge-sk-n">1 Gasthaus</strong></label>
<input id="ge-sk-range" type="range" min="1" max="4" value="1" step="1">
<div class="ge-gh-markers"><span>1</span><span>2</span><span>3</span><span>4</span></div>
</div>
<svg class="ge-bal-svg" viewBox="0 0 600 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-gh-result">
<div class="ge-gh-pill">Rechnung: <strong id="ge-sk-calc">…</strong></div>
<div class="ge-gh-pill" id="ge-sk-verdict">…</div>
</div>
<p class="ge-note">Modellzahlen: zentrale Fixkosten (Küche, Verwaltung, Werbung) 120.000 € pro Jahr,
Gebäudekosten 60.000 € je Haus, 4.000 Gäste je Haus. Dieses Sinken der Stückkosten bei
wachsender Betriebsgröße heißt Skaleneffekt (Fixkostendegression) — ein Hauptgrund, warum
sich Betriebe zusammenschließen.</p>
</div>
`;
const svg = root.querySelector('.ge-bal-svg');
const rng = root.querySelector('#ge-sk-range');
const nOut = root.querySelector('#ge-sk-n');
const calcOut = root.querySelector('#ge-sk-calc');
const verdict = root.querySelector('#ge-sk-verdict');
const CENTRAL = 120000, PER_HOUSE = 60000, GUESTS = 4000;
const costPerGuest = n => (CENTRAL + n * PER_HOUSE) / (n * GUESTS); // 45 / 30 / 25 / 22,50
function drawHouse(parent, x, y, w, h, joined) {
svgEl('rect', { x, y, width: w, height: h, fill: '#e8d5b5', stroke: '#1f4e5a', 'stroke-width': '1.5' }, parent);
svgEl('polygon', { points: (x - 3) + ',' + y + ' ' + (x + w / 2) + ',' + (y - 16) + ' ' + (x + w + 3) + ',' + y, fill: joined ? '#4a7c8a' : '#c85c4a' }, parent);
}
// Linke Hälfte: 4 einzelne Häuser (statisch), jedes mit eigener Küche
svgEl('text', { x: 150, y: 22, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '800', fill: '#1f4e5a' }, svg).textContent = 'Jedes Haus für sich';
for (let i = 0; i < 4; i++) {
const hx = 45 + i * 56;
drawHouse(svg, hx, 48, 46, 34, false);
svgEl('text', { x: hx + 23, y: 72, 'text-anchor': 'middle', 'font-size': '15' }, svg).textContent = '🍳';
}
// Rechte Hälfte: zusammengelegter Betrieb (dynamisch)
svgEl('text', { x: 450, y: 22, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '800', fill: '#1f4e5a' }, svg).textContent = 'Zusammengelegt';
const mergedG = svgEl('g', {}, svg);
// Balken: Stückkosten je Gast (Skala 0 50 €, Höhe 110 px)
const BAR_BASE = 240, BAR_MAXH = 110, SCALE = 50;
svgEl('line', { x1: 30, y1: BAR_BASE, x2: 570, y2: BAR_BASE, stroke: '#b5c9cf', 'stroke-width': '1.5' }, svg);
// Referenzbalken links: 45 € (statisch)
const refH = 45 / SCALE * BAR_MAXH;
svgEl('rect', { x: 120, y: BAR_BASE - refH, width: 60, height: refH, fill: '#c85c4a', rx: '6', opacity: '0.85' }, svg);
svgEl('text', { x: 150, y: BAR_BASE - refH - 8, 'text-anchor': 'middle', 'font-size': '13', 'font-weight': '800', fill: '#c85c4a' }, svg).textContent = '45,00 € je Gast';
// Dynamischer Balken rechts
const dynBar = svgEl('rect', { x: 420, y: BAR_BASE, width: 60, height: 0, fill: '#5a8a5e', rx: '6' }, svg);
const dynTxt = svgEl('text', { x: 450, y: BAR_BASE - 8, 'text-anchor': 'middle', 'font-size': '13', 'font-weight': '800', fill: '#5a8a5e' }, svg);
function update() {
const n = +rng.value;
nOut.textContent = n + (n === 1 ? ' Gasthaus' : ' Gasthäuser');
// Zusammengelegte Häuser zeichnen: n Häuser ohne Lücke, EINE Küche
mergedG.innerHTML = '';
const w = 46, totalW = n * w;
const startX = 450 - totalW / 2;
for (let i = 0; i < n; i++) drawHouse(mergedG, startX + i * w, 48, w, 34, true);
svgEl('text', { x: 450, y: 72, 'text-anchor': 'middle', 'font-size': '15' }, mergedG).textContent = '🍳';
const cap = svgEl('text', { x: 450, y: 100, 'text-anchor': 'middle', 'font-size': '10', fill: '#4a4a4a' }, mergedG);
cap.textContent = n === 1 ? 'eine Küche, eine Verwaltung' : 'eine Küche, eine Verwaltung für ' + n + ' Häuser';
const c = costPerGuest(n);
const h = c / SCALE * BAR_MAXH;
dynBar.setAttribute('y', BAR_BASE - h);
dynBar.setAttribute('height', h);
dynTxt.setAttribute('y', BAR_BASE - h - 8);
dynTxt.textContent = fmtDec(c, 2) + ' € je Gast';
calcOut.textContent = '(120.000 € + ' + n + ' · 60.000 €) : ' + fmtInt(n * GUESTS) + ' Gäste = ' + fmtDec(c, 2) + ' €';
if (n === 1) {
verdict.textContent = 'Ausgangslage: jedes Haus trägt alle Fixkosten allein';
verdict.dataset.state = 'now';
} else if (n < 4) {
verdict.textContent = '🟢 Stückkosten sinken: Fixkosten verteilen sich auf ' + fmtInt(n * GUESTS) + ' Gäste';
verdict.dataset.state = 'good';
} else {
verdict.textContent = '🎯 4 kleine: 45,00 € — 1 großes: 22,50 € je Gast, also halbiert!';
verdict.dataset.state = 'good';
}
}
rng.addEventListener('input', update);
update();
}
// =====================================================================
// Experiment: Modal Split — Verkehrsmittel-Mix und CO₂
// =====================================================================
function modalSplit(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>🚦 Wer fährt womit in den Urlaub?</h3>
<p>Verteile die Anreisen auf Auto, Bus und Bahn — die Summe bleibt automatisch 100 %.
Beobachte, wie sich der CO₂-Ausstoß je Person verändert.</p>
</div>
<div class="ge-gh-slider">
<label for="ge-ms-auto">🚗 Auto: <strong id="ge-ms-auto-val">60 %</strong></label>
<input id="ge-ms-auto" type="range" min="0" max="100" value="60" step="1">
</div>
<div class="ge-gh-slider">
<label for="ge-ms-bus">🚌 Reisebus: <strong id="ge-ms-bus-val">20 %</strong></label>
<input id="ge-ms-bus" type="range" min="0" max="100" value="20" step="1">
</div>
<div class="ge-gh-slider">
<label for="ge-ms-bahn">🚆 Bahn: <strong id="ge-ms-bahn-val">20 %</strong></label>
<input id="ge-ms-bahn" type="range" min="0" max="100" value="20" step="1">
</div>
<div id="ge-ms-stack" style="display:flex;height:26px;border-radius:8px;overflow:hidden;border:1px solid #b5c9cf">
<div data-seg="auto" style="background:#c85c4a;transition:width .3s ease"></div>
<div data-seg="bus" style="background:#e8833a;transition:width .3s ease"></div>
<div data-seg="bahn" style="background:#5a8a5e;transition:width .3s ease"></div>
</div>
<div class="ge-calc-bar"><div class="ge-calc-fill" id="ge-ms-co2bar" style="width:100%"></div></div>
<div class="ge-calc-scale"><span>nur Bahn: 26 g</span><span>nur Bus: 30 g</span><span>nur Auto: 164 g</span></div>
<div class="ge-gh-result">
<div class="ge-gh-pill">Ø CO₂: <strong id="ge-ms-gkm">…</strong> je Personen-km</div>
<div class="ge-gh-pill">Anreise 400 km: <strong id="ge-ms-trip">…</strong> je Person</div>
<div class="ge-gh-pill" id="ge-ms-verdict">…</div>
</div>
<p class="ge-note">Durchschnittswerte je Personen-Kilometer: Pkw 164 g · Reisebus 30 g ·
Bahn 26 g CO₂. Quelle: Umweltbundesamt (DE), Emissionsdaten Personenverkehr 2024.
Die Aufteilung der Wege auf Verkehrsmittel heißt Modal Split — Besucherlenkung Richtung
Bus und Bahn ist einer der größten Klimahebel im Tourismus.</p>
</div>
`;
const FACTORS = { auto: 164, bus: 30, bahn: 26 };
const shares = { auto: 60, bus: 20, bahn: 20 };
const keys = ['auto', 'bus', 'bahn'];
const sliders = {}, labels = {};
keys.forEach(k => {
sliders[k] = root.querySelector('#ge-ms-' + k);
labels[k] = root.querySelector('#ge-ms-' + k + '-val');
});
const stack = root.querySelector('#ge-ms-stack');
const co2bar = root.querySelector('#ge-ms-co2bar');
const gkmOut = root.querySelector('#ge-ms-gkm');
const tripOut = root.querySelector('#ge-ms-trip');
const verdict = root.querySelector('#ge-ms-verdict');
function render() {
keys.forEach(k => {
sliders[k].value = shares[k];
labels[k].textContent = shares[k] + ' %';
stack.querySelector('[data-seg="' + k + '"]').style.width = shares[k] + '%';
});
const gkm = (shares.auto * FACTORS.auto + shares.bus * FACTORS.bus + shares.bahn * FACTORS.bahn) / 100;
gkmOut.textContent = fmtDec(gkm, 0) + ' g';
tripOut.textContent = fmtDec(gkm * 400 / 1000, 1) + ' kg CO₂';
co2bar.style.width = Math.max(3, gkm / FACTORS.auto * 100) + '%';
if (gkm < 55) { verdict.textContent = '🟢 Klimafreundlicher Mix'; verdict.dataset.state = 'good'; }
else if (gkm < 110) { verdict.textContent = '🟠 Mittelfeld — Bahn hilft'; verdict.dataset.state = 'warn'; }
else { verdict.textContent = '🔴 Fast alles Auto'; verdict.dataset.state = 'bad'; }
}
function onInput(key) {
const val = Math.max(0, Math.min(100, Math.round(+sliders[key].value)));
const others = keys.filter(k => k !== key);
const rest = 100 - val;
const oldSum = shares[others[0]] + shares[others[1]];
let a;
if (oldSum <= 0) a = Math.round(rest / 2);
else a = Math.round(rest * shares[others[0]] / oldSum);
shares[key] = val;
shares[others[0]] = a;
shares[others[1]] = rest - a;
render();
}
keys.forEach(k => sliders[k].addEventListener('input', () => onInput(k)));
render();
}
// =====================================================================
// Experiment: Netzfrequenz — der Pulsschlag des Stromnetzes
// =====================================================================
function gridFrequency(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>⚡ Halte das Netz bei 50 Hertz</h3>
<p>Im Stromnetz müssen Erzeugung und Verbrauch <strong>in jeder Sekunde</strong> gleich groß
sein. Passen sie nicht zusammen, driftet die Netzfrequenz weg von 50 Hz. Probiere es!</p>
</div>
<svg class="ge-gh-svg" viewBox="0 0 600 290" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-gh-slider">
<label for="ge-nf-gen">🏭 Erzeugung (Kraftwerke, Wind, Sonne): <strong id="ge-nf-gen-val">80</strong> GW</label>
<input id="ge-nf-gen" type="range" min="60" max="100" value="80" step="1">
</div>
<div class="ge-gh-slider">
<label for="ge-nf-load">🏘 Verbrauch (Haushalte, Industrie): <strong id="ge-nf-load-val">80</strong> GW</label>
<input id="ge-nf-load" type="range" min="60" max="100" value="80" step="1">
</div>
<div class="ge-gh-result">
<div class="ge-gh-pill">Bilanz: Erzeugung Verbrauch = <strong id="ge-nf-diff">0 GW</strong></div>
<div class="ge-gh-pill" id="ge-nf-status" data-state="good">🟢 Netz stabil</div>
</div>
<div class="ge-controls">
<button class="ge-btn" data-act="reset">↺ Zurücksetzen auf 50,0 Hz</button>
</div>
<p class="ge-note">Sollfrequenz im europäischen Verbundnetz: 50 Hz. Sinkt sie unter 49,8 Hz,
greift der 5-Stufen-Plan der Netzbetreiber: Erste Verbraucher werden automatisch
abgeworfen (Lastabwurf), um einen Blackout zu verhindern. Quellen: ENTSO-E ·
VDE-Netzregeln. Vereinfachtes Modell — echte Netze steuern mit Regelreserven binnen
Sekunden dagegen.</p>
</div>
`;
const svg = root.querySelector('.ge-gh-svg');
const CX = 300, CY = 245, R = 185;
const rad = a => a * Math.PI / 180;
const pt = (a, r) => [CX + r * Math.sin(rad(a)), CY - r * Math.cos(rad(a))];
const freqToAngle = f => Math.max(-80, Math.min(80, (f - 50) / 0.2 * 80));
const arcPath = (a1, a2, r) => {
const [x1, y1] = pt(a1, r), [x2, y2] = pt(a2, r);
return 'M ' + x1 + ' ' + y1 + ' A ' + r + ' ' + r + ' 0 0 1 ' + x2 + ' ' + y2;
};
// Farbzonen
[
{ a1: -80, a2: -40, c: '#c85c4a' }, // 49,80 49,90
{ a1: -40, a2: -20, c: '#e8833a' }, // 49,90 49,95
{ a1: -20, a2: 20, c: '#5a8a5e' }, // 49,95 50,05
{ a1: 20, a2: 40, c: '#e8833a' },
{ a1: 40, a2: 80, c: '#c85c4a' }
].forEach(z => svgEl('path', { d: arcPath(z.a1, z.a2, R), stroke: z.c, 'stroke-width': '20', fill: 'none', 'stroke-linecap': 'butt', opacity: '0.9' }, svg));
// Skala
[[49.8, '49,8'], [49.9, '49,9'], [50.0, '50,0'], [50.1, '50,1'], [50.2, '50,2']].forEach(([f, lbl]) => {
const a = freqToAngle(f);
const [x1, y1] = pt(a, R - 14), [x2, y2] = pt(a, R + 14);
svgEl('line', { x1, y1, x2, y2, stroke: '#1f4e5a', 'stroke-width': '2' }, svg);
const [tx, ty] = pt(a, R + 30);
const t = svgEl('text', { x: tx, y: ty + 4, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '700', fill: '#1f4e5a' }, svg);
t.textContent = lbl;
});
svgEl('text', { x: CX, y: 60, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '600', fill: '#4a4a4a' }, svg).textContent = 'Hertz (Hz) = Schwingungen pro Sekunde';
// Zeiger
const needle = svgEl('g', { transform: 'rotate(0, ' + CX + ', ' + CY + ')' }, svg);
svgEl('line', { x1: CX, y1: CY, x2: CX, y2: CY - R + 30, stroke: '#1f4e5a', 'stroke-width': '5', 'stroke-linecap': 'round' }, needle);
svgEl('circle', { cx: CX, cy: CY, r: 10, fill: '#1f4e5a' }, svg);
// Digitalanzeige
const digital = svgEl('text', { x: CX, y: CY + 34, 'text-anchor': 'middle', 'font-size': '22', 'font-weight': '900', fill: '#1f4e5a' }, svg);
const genRng = root.querySelector('#ge-nf-gen');
const loadRng = root.querySelector('#ge-nf-load');
const genVal = root.querySelector('#ge-nf-gen-val');
const loadVal = root.querySelector('#ge-nf-load-val');
const diffOut = root.querySelector('#ge-nf-diff');
const status = root.querySelector('#ge-nf-status');
let freq = 50.0;
const K = 0.0055; // Hz pro (GW · Sekunde) — bewusst anschaulich skaliert
let animId = null, lastT = 0;
function refreshLabels() {
genVal.textContent = genRng.value;
loadVal.textContent = loadRng.value;
const d = genRng.value - loadRng.value;
diffOut.textContent = (d > 0 ? '+' : '') + d + ' GW';
}
function tick(now) {
if (!root.isConnected) { cancelAnimationFrame(animId); return; }
const dt = lastT ? Math.min(60, now - lastT) : 16;
lastT = now;
const imbalance = (+genRng.value) - (+loadRng.value);
freq = Math.max(49.78, Math.min(50.22, freq + imbalance * K * dt / 1000));
needle.setAttribute('transform', 'rotate(' + freqToAngle(freq).toFixed(2) + ', ' + CX + ', ' + CY + ')');
digital.textContent = freq.toFixed(3).replace('.', ',') + ' Hz';
if (freq <= 49.8) {
status.textContent = '🔴 Unter 49,8 Hz: Lastabwurf! Verbraucher werden abgetrennt';
status.dataset.state = 'bad';
} else if (freq >= 50.2) {
status.textContent = '🔴 Über 50,2 Hz: Kraftwerke müssen sofort drosseln';
status.dataset.state = 'bad';
} else if (Math.abs(freq - 50) > 0.05) {
status.textContent = '🟠 Warnzone — Regelenergie nötig';
status.dataset.state = 'warn';
} else {
status.textContent = '🟢 Netz stabil';
status.dataset.state = 'good';
}
animId = requestAnimationFrame(tick);
}
genRng.addEventListener('input', refreshLabels);
loadRng.addEventListener('input', refreshLabels);
root.querySelector('[data-act="reset"]').addEventListener('click', () => {
freq = 50.0;
genRng.value = 80;
loadRng.value = 80;
refreshLabels();
});
refreshLabels();
animId = requestAnimationFrame(tick);
}
// =====================================================================
// Experiment: Lageenergie / Pumpspeicher — Strom als gehobenes Wasser
// =====================================================================
function pumpedStorage(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>🏔 Der Stromspeicher im Berg</h3>
<p>Bei Stromüberschuss pumpt das Kraftwerk Wasser ins Oberbecken — Strom wird zu
<strong>Lageenergie</strong>. Bei Strommangel rauscht das Wasser durch die Turbine
wieder hinunter. Aber: Ein Teil der Energie geht verloren.</p>
</div>
<svg class="ge-gh-svg" viewBox="0 0 600 330" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-controls">
<button class="ge-btn ge-btn-primary" data-act="pump">▲ Pumpen — Strom hinein (20 kWh)</button>
<button class="ge-btn" data-act="turbine">▼ Turbinieren — Strom heraus</button>
</div>
<div class="ge-gh-result">
<div class="ge-gh-pill">Strom eingesetzt: <strong id="ge-ps-in">0 kWh</strong></div>
<div class="ge-gh-pill">Strom zurückgewonnen: <strong id="ge-ps-out">0 kWh</strong></div>
<div class="ge-gh-pill">Verlust (Wärme, Reibung): <strong id="ge-ps-loss">0 kWh</strong></div>
<div class="ge-gh-pill" id="ge-ps-status" data-state="now">Das Unterbecken ist voll — pumpe Wasser hinauf!</div>
</div>
<p class="ge-note">Pumpspeicherkraftwerke sind bis heute der einzige großtechnische
Stromspeicher: Aus 100 kWh Pumpstrom kommen rund 75 80 kWh wieder heraus
(Gesamtwirkungsgrad, hier gerechnet mit 78 %). Quellen: VERBUND (Malta- und
Kaprun-Kraftwerke) · Fraunhofer ISE.</p>
</div>
`;
const svg = root.querySelector('.ge-gh-svg');
const EFF = 0.78; // Gesamtwirkungsgrad
const STEP_UNITS = 20; // Wasser-Einheiten je Klick; 1 Einheit ≙ 1 kWh Pumpstrom
// --- Szene ---------------------------------------------------------
// Berg links mit Plateau für das Oberbecken
svgEl('polygon', { points: '0,330 0,150 60,80 250,80 290,160 360,310 600,310 600,330', fill: '#5a8a5e' }, svg);
svgEl('polygon', { points: '0,330 0,240 120,200 300,260 600,315 600,330', fill: '#4a7050', opacity: '.6' }, svg);
// Oberbecken (Behälter)
svgEl('rect', { x: 66, y: 82, width: 168, height: 48, fill: '#3f5f52', rx: '4' }, svg);
const upperWater = svgEl('rect', { x: 70, y: 126, width: 160, height: 0, fill: '#4a7c8a' }, svg);
svgEl('text', { x: 150, y: 74, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '800', fill: '#1f4e5a' }, svg).textContent = 'Oberbecken';
// Fallhöhe-Markierung
svgEl('line', { x1: 320, y1: 130, x2: 320, y2: 250, stroke: '#1f4e5a', 'stroke-width': '1.2', 'stroke-dasharray': '4 3', opacity: '.6' }, svg);
svgEl('text', { x: 328, y: 195, 'font-size': '10', fill: '#1f4e5a', opacity: '.8' }, svg).textContent = 'Fallhöhe';
// Druckrohr Oberbecken → Krafthaus
const pipeA = { x: 226, y: 124 }, pipeB = { x: 408, y: 262 };
svgEl('line', { x1: pipeA.x, y1: pipeA.y, x2: pipeB.x, y2: pipeB.y, stroke: '#4a4a4a', 'stroke-width': '12', 'stroke-linecap': 'round' }, svg);
svgEl('line', { x1: pipeA.x, y1: pipeA.y, x2: pipeB.x, y2: pipeB.y, stroke: '#dae8ec', 'stroke-width': '6', 'stroke-linecap': 'round' }, svg);
// Krafthaus mit Pumpe/Turbine
svgEl('rect', { x: 385, y: 252, width: 76, height: 58, fill: '#e8d5b5', stroke: '#1f4e5a', 'stroke-width': '1.5', rx: '4' }, svg);
svgEl('polygon', { points: '382,253 423,235 464,253', fill: '#c85c4a' }, svg);
const gear = svgEl('text', { x: 423, y: 288, 'text-anchor': 'middle', 'font-size': '20' }, svg);
gear.textContent = '⚙';
const modeLbl = svgEl('text', { x: 423, y: 326, 'text-anchor': 'middle', 'font-size': '11', 'font-weight': '800', fill: '#1f4e5a' }, svg);
modeLbl.textContent = 'Pumpe / Turbine';
// Unterbecken rechts vom Krafthaus
svgEl('rect', { x: 476, y: 252, width: 112, height: 56, fill: '#3f5f52', rx: '4' }, svg);
const lowerWater = svgEl('rect', { x: 480, y: 304, width: 104, height: 0, fill: '#4a7c8a' }, svg);
svgEl('text', { x: 532, y: 244, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '800', fill: '#1f4e5a' }, svg).textContent = 'Unterbecken';
// Wasser-Punkte im Rohr (nur während der Animation sichtbar)
const flowDots = [];
for (let i = 0; i < 4; i++) flowDots.push(svgEl('circle', { r: 4.5, fill: '#7fb3c4', opacity: '0' }, svg));
// --- Zustand ---------------------------------------------------------
let upper = 0, lower = 100; // Wasser-Einheiten
let energyIn = 0, energyOut = 0; // kWh
let animId = null, animating = false;
const inOut = root.querySelector('#ge-ps-in');
const outOut = root.querySelector('#ge-ps-out');
const lossOut = root.querySelector('#ge-ps-loss');
const status = root.querySelector('#ge-ps-status');
const btnPump = root.querySelector('[data-act="pump"]');
const btnTurb = root.querySelector('[data-act="turbine"]');
function setWater(u) {
// Oberbecken: max 44 px, Unterbecken: max 48 px
const uh = u / 100 * 44;
upperWater.setAttribute('y', 126 - uh);
upperWater.setAttribute('height', uh);
const lh = (100 - u) / 100 * 48;
lowerWater.setAttribute('y', 304 - lh);
lowerWater.setAttribute('height', lh);
}
function refreshAccount() {
inOut.textContent = fmtDec(energyIn, 0) + ' kWh';
outOut.textContent = fmtDec(energyOut, 1).replace(',0', '') + ' kWh';
const stored = upper * EFF; // noch abrufbarer Strom
const loss = Math.max(0, energyIn - energyOut - stored);
lossOut.textContent = fmtDec(loss, 1).replace(',0', '') + ' kWh';
}
function refreshButtons() {
btnPump.disabled = animating || lower < STEP_UNITS;
btnTurb.disabled = animating || upper < STEP_UNITS;
btnPump.style.opacity = btnPump.disabled ? '.45' : '1';
btnTurb.style.opacity = btnTurb.disabled ? '.45' : '1';
}
function animate(mode) {
if (animating) return;
if (mode === 'pump' && lower < STEP_UNITS) return;
if (mode === 'turbine' && upper < STEP_UNITS) return;
animating = true;
refreshButtons();
modeLbl.textContent = mode === 'pump' ? 'Pumpe läuft ▲' : 'Turbine läuft ▼';
const startU = upper;
const targetU = mode === 'pump' ? upper + STEP_UNITS : upper - STEP_UNITS;
const DUR = 1400;
const t0 = performance.now();
function frame(now) {
if (!root.isConnected) { cancelAnimationFrame(animId); return; }
const t = Math.min(1, (now - t0) / DUR);
const e = 1 - Math.pow(1 - t, 3);
setWater(startU + (targetU - startU) * e);
// Wasser-Punkte im Rohr
flowDots.forEach((d, i) => {
let k = (t * 2.2 + i / flowDots.length) % 1;
if (mode === 'turbine') k = 1 - k; // bergab
d.setAttribute('cx', pipeA.x + (pipeB.x - pipeA.x) * k);
d.setAttribute('cy', pipeA.y + (pipeB.y - pipeA.y) * k);
d.setAttribute('opacity', '0.95');
});
if (t < 1) { animId = requestAnimationFrame(frame); return; }
// fertig
flowDots.forEach(d => d.setAttribute('opacity', '0'));
upper = targetU;
lower = 100 - upper;
if (mode === 'pump') {
energyIn += STEP_UNITS; // 20 kWh Strom hinein
status.textContent = '⚡ 20 kWh Strom als Lageenergie gespeichert';
status.dataset.state = 'now';
} else {
const gain = STEP_UNITS * EFF; // 15,6 kWh zurück
energyOut += gain;
status.textContent = '💡 ' + fmtDec(gain, 1) + ' kWh zurückgewonnen — ' + fmtDec(STEP_UNITS - gain, 1) + ' kWh verloren (78 %)';
status.dataset.state = 'good';
}
modeLbl.textContent = 'Pumpe / Turbine';
animating = false;
refreshAccount();
refreshButtons();
}
animId = requestAnimationFrame(frame);
}
btnPump.addEventListener('click', () => animate('pump'));
btnTurb.addEventListener('click', () => animate('turbine'));
setWater(upper);
refreshAccount();
refreshButtons();
}
// Registry — Keys entsprechen key_slug aus der Tabelle `glossar`
window.GlossarExperiments = Object.assign(window.GlossarExperiments || {}, {
'preiselastizitaet': priceElasticity,
'engpass': bottleneckChain,
'skaleneffekt': scaleEffect,
'modal-split': modalSplit,
'netzfrequenz': gridFrequency,
'hertz': gridFrequency,
'lageenergie': pumpedStorage,
'pumpspeicher': pumpedStorage
});
})();