Glossar visuell komplett: 162 Bilder, 13 Kapitel-Icons, 12 interaktive Widgets

- 162 neue Glossar-Bilder (Flat-Scandinavian, gpt-image-1) → alle 402 Einträge bebildert;
  image_path-Update: App/Don_t_Deploy/2026-07-16-glossar-bilder.sql
- 13 Kapitel-Icons (icon-<id>.webp) ersetzen die Emojis in Buch-Übersicht + Kapitel-h1
  (buchKapIcon-Helper mit Emoji-Fallback)
- 12 interaktive Glossar-Experimente in 2 Modulen:
  natur (Mondrotation, Kernschatten, Keeling-Kurve, Pegel/HQ100, Wärmeausdehnung,
  Klimaträgheit) + wirtschaft (Preiselastizität, Engpass, Skaleneffekt, Modal Split,
  Netzfrequenz/Hertz, Lageenergie/Pumpspeicher); Registry via Object.assign,
  Script-Einbindung in glossar.php

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 00:59:30 +02:00
parent 78a1cdca24
commit c4276083a2
196 changed files with 1752 additions and 17 deletions
+812
View File
@@ -0,0 +1,812 @@
/**
* Glossar-Experimente „Natur" — Astronomie, Klima, Hochwasser.
*
* Registrierung wie in glossar-experiments.js:
* window.GlossarExperiments[key_slug] = function (root) { … }
* Der Renderer (glossar.php) übergibt einen leeren Container; das Widget
* baut seinen DOM selbst und bindet seine Events. Styles: .ge-* aus glossar.php.
*
* Richtlinien:
* - Keine externen Libraries, Vanilla JS + SVG.
* - Mobile-/Tap-freundlich, keine Hover-Abhängigkeit.
* - Zahlen mit kurzer Quellenangabe in der .ge-note.
* - Animations-Loops stoppen selbst, sobald der Container aus dem DOM fällt
* (root.isConnected), damit kein Loop nach Modal-Schließen weiterläuft.
*/
(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;
};
const fmtDE = (n, dec) => n.toFixed(dec === undefined ? 1 : dec).replace('.', ',');
// =====================================================================
// Experiment: Gebundene Rotation — warum wir immer dieselbe Mondseite sehen
// =====================================================================
function tidalLock(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>🌗 Warum sehen wir immer dieselbe Mondseite?</h3>
<p>Der Mond umkreist die Erde. Seine <strong>rot markierte Hälfte</strong> zeigt dir, wie er sich dabei um sich selbst dreht. Schalte um und vergleiche!</p>
</div>
<svg class="ge-track" viewBox="0 0 600 340" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-controls">
<button class="ge-btn" data-act="play">⏸ Pause</button>
<button class="ge-btn ge-btn-primary" data-mode="locked">gebundene Rotation</button>
<button class="ge-btn" data-mode="free">frei (keine Eigendrehung)</button>
</div>
<div class="ge-gh-result">
<div class="ge-gh-pill" data-state="now" data-ref="insight"></div>
<div class="ge-gh-pill" data-ref="view" aria-live="polite"></div>
</div>
<p class="ge-note">Der Mond dreht sich in 27,3 Tagen genau einmal um sich selbst — exakt so lange, wie er für einen Umlauf um die Erde braucht. Diese „gebundene Rotation" entstand durch die Gezeitenkräfte der Erde. Quelle: NASA Moon Fact Sheet (siderische Rotation = siderischer Umlauf = 27,32 Tage).</p>
</div>
`;
const svg = root.querySelector('.ge-track');
const insightEl = root.querySelector('[data-ref="insight"]');
const viewEl = root.querySelector('[data-ref="view"]');
const playBtn = root.querySelector('[data-act="play"]');
const CX = 300, CY = 172, ORBIT_R = 118, MOON_R = 16;
// Weltall-Hintergrund + Sterne
svgEl('rect', { x: 0, y: 0, width: 600, height: 340, fill: '#152430', rx: 8 }, svg);
for (let i = 0; i < 40; i++) {
svgEl('circle', {
cx: Math.round(Math.random() * 590 + 5),
cy: Math.round(Math.random() * 330 + 5),
r: (Math.random() * 1.2 + 0.4).toFixed(1),
fill: '#dae8ec', opacity: (0.3 + Math.random() * 0.5).toFixed(2)
}, svg);
}
// Mondbahn
svgEl('circle', { cx: CX, cy: CY, r: ORBIT_R, fill: 'none', stroke: '#4a7c8a', 'stroke-width': '1', 'stroke-dasharray': '4 5', opacity: '.7' }, svg);
// Erde
svgEl('circle', { cx: CX, cy: CY, r: 30, fill: '#2e6b8a' }, svg);
svgEl('circle', { cx: CX - 8, cy: CY - 6, r: 10, fill: '#5a8a5e', opacity: '.9' }, svg);
svgEl('circle', { cx: CX + 10, cy: CY + 9, r: 7, fill: '#5a8a5e', opacity: '.9' }, svg);
const earthLabel = svgEl('text', { x: CX, y: CY + 48, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '700', fill: '#dae8ec' }, svg);
earthLabel.textContent = 'Erde';
// Sichtlinie Erde → Mond
const sight = svgEl('line', { x1: CX, y1: CY, x2: CX + ORBIT_R, y2: CY, stroke: '#e8c547', 'stroke-width': '1', 'stroke-dasharray': '2 4', opacity: '.6' }, svg);
// Mond: Gruppe mit heller + rot markierter Hälfte (markierte Hälfte zeigt lokal nach +x)
const moonG = svgEl('g', {}, svg);
svgEl('circle', { cx: 0, cy: 0, r: MOON_R, fill: '#cfc9bc', stroke: '#8a857a', 'stroke-width': '1' }, moonG);
svgEl('path', { d: `M 0 ${-MOON_R} A ${MOON_R} ${MOON_R} 0 0 1 0 ${MOON_R} Z`, fill: '#c85c4a' }, moonG);
// Tag-Zähler
const dayLabel = svgEl('text', { x: 590, y: 24, 'text-anchor': 'end', 'font-size': '12', 'font-weight': '700', fill: '#dae8ec' }, svg);
const PERIOD_DAYS = 27.3;
const ORBIT_MS = 12000; // 1 Umlauf in 12 s
let mode = 'locked';
let playing = true;
let angle = -Math.PI / 2; // Start oben
let last = null;
let lastViewTxt = '';
function insightText() {
return mode === 'locked'
? '🔒 Rotation = Umlauf: Die rote Seite zeigt immer zur Erde. Deshalb sehen wir die Mond-Rückseite nie.'
: '🔓 Ohne Eigendrehung: Die rote Seite zeigt mal zur Erde, mal weg — wir würden im Lauf eines Monats den ganzen Mond sehen.';
}
function applyMode(m) {
mode = m;
root.querySelectorAll('[data-mode]').forEach(b => b.classList.toggle('ge-btn-primary', b.dataset.mode === m));
insightEl.textContent = insightText();
insightEl.dataset.state = m === 'locked' ? 'good' : 'warn';
}
function draw() {
const mx = CX + Math.cos(angle) * ORBIT_R;
const my = CY + Math.sin(angle) * ORBIT_R;
const aDeg = angle * 180 / Math.PI;
const rot = mode === 'locked' ? aDeg + 180 : 0;
moonG.setAttribute('transform', `translate(${mx.toFixed(1)} ${my.toFixed(1)}) rotate(${rot.toFixed(1)})`);
sight.setAttribute('x2', mx.toFixed(1));
sight.setAttribute('y2', my.toFixed(1));
const day = ((angle + Math.PI / 2) / (2 * Math.PI)) * PERIOD_DAYS;
const dayShown = ((day % PERIOD_DAYS) + PERIOD_DAYS) % PERIOD_DAYS;
dayLabel.textContent = 'Tag ' + fmtDE(dayShown, 0) + ' von 27,3';
// Was sieht man von der Erde aus? Winkel zwischen markierter Richtung und Richtung Mond→Erde
const toEarth = aDeg + 180;
let diff = ((rot - toEarth) % 360 + 360) % 360;
if (diff > 180) diff = 360 - diff;
const txt = diff < 90
? '👁 Von der Erde aus sichtbar: die rote Seite'
: '👁 Von der Erde aus sichtbar: die Rückseite';
if (txt !== lastViewTxt) { viewEl.textContent = txt; lastViewTxt = txt; }
}
function tick(now) {
if (!root.isConnected) return; // Modal geschlossen → Loop beenden
if (playing) {
if (last !== null) angle += ((now - last) / ORBIT_MS) * 2 * Math.PI;
draw();
}
last = now;
requestAnimationFrame(tick);
}
playBtn.addEventListener('click', () => {
playing = !playing;
playBtn.textContent = playing ? '⏸ Pause' : '▶ Weiter';
});
root.querySelectorAll('[data-mode]').forEach(b => {
b.addEventListener('click', () => { applyMode(b.dataset.mode); draw(); });
});
applyMode('locked');
draw();
requestAnimationFrame(tick);
}
// =====================================================================
// Experiment: Kernschatten — Mond durch den Erdschatten schieben
// =====================================================================
function umbraCones(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>🌑 Schiebe den Mond durch den Erdschatten</h3>
<p>Die Sonne beleuchtet die Erde — dahinter entstehen <strong>Kernschatten</strong> (ganz dunkel) und <strong>Halbschatten</strong> (teilweise dunkel). Bewege den Mond mit dem Regler auf seiner Bahn.</p>
</div>
<svg class="ge-track" viewBox="0 0 600 320" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-gh-slider">
<label for="ge-umbra-range">Mond-Position quer zur Schattenachse</label>
<input id="ge-umbra-range" type="range" min="-100" max="100" value="-100" step="1">
<div class="ge-gh-markers"><span>oberhalb</span><span>mitten im Schatten</span><span>unterhalb</span></div>
</div>
<div class="ge-gh-result">
<div class="ge-gh-pill" data-ref="verdict" aria-live="polite"></div>
</div>
<p class="ge-note">Größen und Abstände sind nicht maßstäblich. Real ist der Kernschatten der Erde in Mondentfernung rund 9.000 km breit — etwa 2,6 Mond-Durchmesser. Bei einer totalen Mondfinsternis leuchtet der Mond rötlich („Blutmond"), weil die Erdatmosphäre rotes Sonnenlicht in den Schatten lenkt. Weil die Mondbahn um 5° geneigt ist, verfehlt der Mond den Schatten meistens — darum gibt es nicht jeden Vollmond eine Finsternis. Quelle: NASA Eclipse Web Site (F. Espenak).</p>
</div>
`;
const svg = root.querySelector('.ge-track');
const rng = root.querySelector('#ge-umbra-range');
const verdict = root.querySelector('[data-ref="verdict"]');
// Geometrie-Konstanten (Zeichnung und Klassifikation nutzen dieselben Werte)
const SUN = { x: 60, y: 160, r: 32 };
const EARTH = { x: 280, y: 160, r: 24 };
const MOON_X = 460, MOON_R = 7;
const D = EARTH.x - SUN.x;
// Kernschatten-Grenze: Linie von Sonnen-Oberkante durch Erd-Oberkante
const mU = (EARTH.r - SUN.r) / D * -1; // Steigung der oberen Umbra-Grenze relativ zur Achse
// Halbschatten-Grenze: Linie von Sonnen-Unterkante durch Erd-Oberkante
const mP = (SUN.r + EARTH.r) / D;
const hU = x => Math.max(0, EARTH.r - mU * (x - EARTH.x)); // halbe Kernschatten-Höhe bei x
const hP = x => EARTH.r + mP * (x - EARTH.x); // halbe Halbschatten-Höhe bei x
// Weltall + Sterne
svgEl('rect', { x: 0, y: 0, width: 600, height: 320, fill: '#152430', rx: 8 }, svg);
for (let i = 0; i < 30; i++) {
svgEl('circle', {
cx: Math.round(Math.random() * 590 + 5), cy: Math.round(Math.random() * 310 + 5),
r: (Math.random() * 1.1 + 0.4).toFixed(1), fill: '#dae8ec', opacity: (0.3 + Math.random() * 0.5).toFixed(2)
}, svg);
}
// Halbschatten-Polygon (heller), dann Kernschatten (dunkler) darüber
const X_END = 600;
svgEl('polygon', {
points: `${EARTH.x},${EARTH.y - EARTH.r} ${X_END},${EARTH.y - hP(X_END)} ${X_END},${EARTH.y + hP(X_END)} ${EARTH.x},${EARTH.y + EARTH.r}`,
fill: '#4a5a66', opacity: '.35'
}, svg);
svgEl('polygon', {
points: `${EARTH.x},${EARTH.y - EARTH.r} ${X_END},${EARTH.y - hU(X_END)} ${X_END},${EARTH.y + hU(X_END)} ${EARTH.x},${EARTH.y + EARTH.r}`,
fill: '#0c151c', opacity: '.8'
}, svg);
// Beschriftung der Schattenzonen
const lblU = svgEl('text', { x: 520, y: EARTH.y + 4, 'text-anchor': 'middle', 'font-size': '10', 'font-weight': '700', fill: '#8aa4b0' }, svg);
lblU.textContent = 'Kernschatten';
const lblP = svgEl('text', { x: 520, y: EARTH.y - hP(520) - 6, 'text-anchor': 'middle', 'font-size': '10', fill: '#8aa4b0' }, svg);
lblP.textContent = 'Halbschatten';
// Sonne mit Strahlenkranz
svgEl('circle', { cx: SUN.x, cy: SUN.y, r: SUN.r + 8, fill: '#f4c94e', opacity: '.25' }, svg);
svgEl('circle', { cx: SUN.x, cy: SUN.y, r: SUN.r, fill: '#f4c94e' }, svg);
const sunLbl = svgEl('text', { x: SUN.x, y: SUN.y + SUN.r + 18, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '700', fill: '#f4c94e' }, svg);
sunLbl.textContent = 'Sonne';
// Erde
svgEl('circle', { cx: EARTH.x, cy: EARTH.y, r: EARTH.r, fill: '#2e6b8a' }, svg);
svgEl('circle', { cx: EARTH.x - 6, cy: EARTH.y - 5, r: 8, fill: '#5a8a5e', opacity: '.9' }, svg);
// Nachtseite der Erde
svgEl('path', { d: `M ${EARTH.x} ${EARTH.y - EARTH.r} A ${EARTH.r} ${EARTH.r} 0 0 1 ${EARTH.x} ${EARTH.y + EARTH.r} Z`, fill: '#0c151c', opacity: '.55' }, svg);
const earthLbl = svgEl('text', { x: EARTH.x, y: EARTH.y + EARTH.r + 18, 'text-anchor': 'middle', 'font-size': '12', 'font-weight': '700', fill: '#dae8ec' }, svg);
earthLbl.textContent = 'Erde';
// Mondbahn (angedeutet, senkrecht)
svgEl('line', { x1: MOON_X, y1: 30, x2: MOON_X, y2: 290, stroke: '#4a7c8a', 'stroke-width': '1', 'stroke-dasharray': '3 5', opacity: '.6' }, svg);
// Mond: Basis + Schatten-Halbscheibe
const moon = svgEl('circle', { cx: MOON_X, cy: 60, r: MOON_R, fill: '#cfc9bc', stroke: '#8a857a', 'stroke-width': '.8' }, svg);
const moonHalf = svgEl('path', { d: '', fill: '#241a16', opacity: '0' }, svg);
const moonLbl = svgEl('text', { x: MOON_X + 14, y: 64, 'font-size': '11', 'font-weight': '700', fill: '#dae8ec' }, svg);
moonLbl.textContent = 'Mond';
const HU = hU(MOON_X), HPn = hP(MOON_X);
function update() {
const yOff = +rng.value * 0.9; // Slider ±100 → ±90 px
const my = EARTH.y + yOff;
moon.setAttribute('cy', my.toFixed(1));
moonLbl.setAttribute('y', (my + 4).toFixed(1));
const a = Math.abs(yOff);
let state, txt, fill, halfOp = 0;
if (a + MOON_R <= HU) {
state = 'bad'; txt = '🔴 Totale Mondfinsternis — der Mond steht ganz im Kernschatten (Blutmond)';
fill = '#a63c2a';
} else if (a - MOON_R < HU) {
state = 'warn'; txt = '🟠 Partielle Mondfinsternis — nur ein Teil des Mondes taucht in den Kernschatten';
fill = '#cfc9bc'; halfOp = 0.85;
} else if (a - MOON_R < HPn) {
state = 'now'; txt = '🌫 Halbschattenfinsternis — der Mond wird nur leicht dunkler, mit freiem Auge kaum sichtbar';
fill = '#a8a397';
} else {
state = 'good'; txt = '🌕 Keine Finsternis — der Mond verfehlt den Erdschatten (der Normalfall)';
fill = '#cfc9bc';
}
moon.setAttribute('fill', fill);
// Bei partieller Finsternis: die dem Schattenzentrum zugewandte Hälfte verdunkeln
if (halfOp > 0) {
const sweep = yOff > 0 ? 0 : 1; // Mond unterhalb → obere Hälfte im Schatten
moonHalf.setAttribute('d', `M ${MOON_X - MOON_R} ${my.toFixed(1)} A ${MOON_R} ${MOON_R} 0 0 ${sweep} ${MOON_X + MOON_R} ${my.toFixed(1)} Z`);
}
moonHalf.setAttribute('opacity', halfOp);
verdict.textContent = txt;
verdict.dataset.state = state;
}
rng.addEventListener('input', update);
update();
}
// =====================================================================
// Experiment: Keeling-Kurve — CO₂-Messung 1958 bis heute, animiert
// =====================================================================
function keelingCurve(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>📈 Die Keeling-Kurve zeichnet sich selbst</h3>
<p>Seit 1958 wird auf dem Vulkan Mauna Loa (Hawaii) die CO₂-Konzentration gemessen. Starte die Aufzeichnung — an wichtigen Stellen hält die Kurve an.</p>
</div>
<svg class="ge-track" viewBox="0 0 600 320" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-controls">
<button class="ge-btn ge-btn-primary" data-act="play">▶ Start</button>
<button class="ge-btn" data-act="next" disabled>Weiter ▶</button>
</div>
<div class="ge-gh-result">
<div class="ge-gh-pill" data-ref="milestone" aria-live="polite" style="display:none"></div>
<div class="ge-gh-pill" data-ref="season" style="display:none"></div>
</div>
<p class="ge-note">Kurve vereinfacht nachgebildet: Jahresmittel von 315 ppm (1958) auf rund 430 ppm (2025), Sägezahn ±3 ppm durch die Jahreszeiten der Nordhalbkugel. Quelle: Scripps CO₂ Program / NOAA Global Monitoring Laboratory, Mauna Loa.</p>
</div>
`;
const svg = root.querySelector('.ge-track');
const playBtn = root.querySelector('[data-act="play"]');
const nextBtn = root.querySelector('[data-act="next"]');
const milestoneEl = root.querySelector('[data-ref="milestone"]');
const seasonEl = root.querySelector('[data-ref="season"]');
// Plot-Geometrie
const X0 = 62, X1 = 580, Y0 = 275, Y1 = 30;
const YEAR0 = 1958, YEAR1 = 2025.5, PPM0 = 300, PPM1 = 445;
const xOf = year => X0 + (year - YEAR0) / (YEAR1 - YEAR0) * (X1 - X0);
const yOf = ppm => Y0 - (ppm - PPM0) / (PPM1 - PPM0) * (Y0 - Y1);
// Vereinfachtes Kurvenmodell (an Scripps/NOAA-Jahresmittel angelehnt)
const ppmAt = year => {
const t = year - 1958;
const annual = 315 + 0.55 * t + 0.0175 * t * t;
const frac = year - Math.floor(year);
return annual + 3.0 * Math.cos(2 * Math.PI * (frac - 0.30));
};
// Achsen + Gitter
[320, 360, 400, 440].forEach(p => {
svgEl('line', { x1: X0, y1: yOf(p), x2: X1, y2: yOf(p), stroke: '#dae8ec', 'stroke-width': '1' }, svg);
const t = svgEl('text', { x: X0 - 6, y: yOf(p) + 4, 'text-anchor': 'end', 'font-size': '10', fill: '#4a4a4a' }, svg);
t.textContent = p;
});
const yTitle = svgEl('text', { x: X0 - 6, y: Y1 - 8, 'text-anchor': 'end', 'font-size': '10', 'font-weight': '700', fill: '#4a4a4a' }, svg);
yTitle.textContent = 'ppm';
[1960, 1980, 2000, 2020].forEach(yr => {
svgEl('line', { x1: xOf(yr), y1: Y0, x2: xOf(yr), y2: Y0 + 5, stroke: '#4a4a4a', 'stroke-width': '1' }, svg);
const t = svgEl('text', { x: xOf(yr), y: Y0 + 18, 'text-anchor': 'middle', 'font-size': '10', fill: '#4a4a4a' }, svg);
t.textContent = yr;
});
svgEl('line', { x1: X0, y1: Y0, x2: X1, y2: Y0, stroke: '#1f4e5a', 'stroke-width': '1.2' }, svg);
svgEl('line', { x1: X0, y1: Y0, x2: X0, y2: Y1, stroke: '#1f4e5a', 'stroke-width': '1.2' }, svg);
// Datenpunkte (monatlich)
const pts = [];
for (let y = YEAR0; y <= YEAR1; y += 1 / 12) {
pts.push({ year: y, ppm: ppmAt(y), x: xOf(y), y: yOf(ppmAt(y)) });
}
const line = svgEl('polyline', { points: '', fill: 'none', stroke: '#c85c4a', 'stroke-width': '2', 'stroke-linejoin': 'round' }, svg);
const head = svgEl('circle', { cx: X0, cy: yOf(315), r: 4, fill: '#c85c4a', opacity: '0' }, svg);
const readout = svgEl('text', { x: X1, y: Y1 + 4, 'text-anchor': 'end', 'font-size': '13', 'font-weight': '800', fill: '#1f4e5a' }, svg);
const markerG = svgEl('g', {}, svg);
const milestones = [
{ year: 1968, txt: '🌲 Der Sägezahn ist die „Atmung" der Vegetation: Jeden Nordsommer nimmt sie CO₂ auf (Kurve sinkt), jeden Winter gibt sie es wieder ab (Kurve steigt).' },
{ year: 1990, txt: '📋 Um 354 ppm — der erste IPCC-Bericht (1990) warnt vor der Erwärmung. Die Kurve steigt immer schneller.' },
{ year: 2015, txt: '🌍 400 ppm überschritten — im Jahr des Pariser Klimaabkommens. So viel CO₂ war seit Millionen Jahren nicht in der Luft.' },
{ year: 2025.4, txt: '🏁 Heute: rund 430 ppm — gut ein Drittel mehr als 1958. Der Sägezahn bleibt, der Anstieg leider auch.' }
];
let idx = 0, state = 'idle', ptStrings = [], msIdx = 0;
function showMilestone(m) {
state = 'paused';
milestoneEl.textContent = m.txt;
milestoneEl.style.display = '';
milestoneEl.dataset.state = 'now';
nextBtn.disabled = false;
nextBtn.classList.add('ge-btn-primary');
svgEl('circle', { cx: xOf(m.year), cy: yOf(ppmAt(m.year)), r: 5, fill: 'none', stroke: '#1f4e5a', 'stroke-width': '2' }, markerG);
}
function updateSeason() {
if (idx < 3) return;
const rising = pts[idx - 1].ppm > pts[idx - 3].ppm;
seasonEl.style.display = '';
seasonEl.dataset.state = rising ? 'warn' : 'good';
seasonEl.textContent = rising
? '🍂 Kurve steigt gerade: Nordwinter — Laub verrottet, wenig Photosynthese'
: '🌱 Kurve sinkt gerade: Nordsommer — die Vegetation nimmt CO₂ auf';
}
function tick() {
if (!root.isConnected || state !== 'playing') return;
for (let s = 0; s < 2 && idx < pts.length; s++) {
const p = pts[idx++];
ptStrings.push(p.x.toFixed(1) + ',' + p.y.toFixed(1));
}
line.setAttribute('points', ptStrings.join(' '));
const cur = pts[Math.min(idx, pts.length) - 1];
head.setAttribute('cx', cur.x.toFixed(1));
head.setAttribute('cy', cur.y.toFixed(1));
head.setAttribute('opacity', '1');
readout.textContent = Math.floor(cur.year) + ' · ' + fmtDE(cur.ppm, 0) + ' ppm';
updateSeason();
if (msIdx < milestones.length && cur.year >= milestones[msIdx].year) {
showMilestone(milestones[msIdx++]);
return;
}
if (idx >= pts.length) { state = 'done'; return; }
requestAnimationFrame(tick);
}
function start() {
idx = 0; msIdx = 0; ptStrings = [];
markerG.innerHTML = '';
line.setAttribute('points', '');
milestoneEl.style.display = 'none';
seasonEl.style.display = 'none';
nextBtn.disabled = true;
nextBtn.classList.remove('ge-btn-primary');
playBtn.textContent = '↺ Von vorn';
state = 'playing';
requestAnimationFrame(tick);
}
function resume() {
if (state !== 'paused') return;
nextBtn.disabled = true;
nextBtn.classList.remove('ge-btn-primary');
milestoneEl.style.display = 'none';
if (idx >= pts.length) { state = 'done'; return; }
state = 'playing';
requestAnimationFrame(tick);
}
playBtn.addEventListener('click', start);
nextBtn.addEventListener('click', resume);
}
// =====================================================================
// Experiment: Pegel / HQ100 — Regen aufdrehen, Pegel verzögert beobachten
// =====================================================================
function riverGauge(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>🌧 Regen aufdrehen — was macht der Pegel?</h3>
<p>Stelle die Regen-Intensität ein und beobachte die Pegellatte. Der Fluss reagiert <strong>verzögert</strong> — genau wie in echt.</p>
</div>
<svg class="ge-track" viewBox="0 0 600 320" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-gh-slider">
<label for="ge-rain-range">Regen-Intensität: <strong data-ref="rainval">10</strong> von 100</label>
<input id="ge-rain-range" type="range" min="0" max="100" value="10" step="1">
<div class="ge-gh-markers"><span>trocken</span><span>Landregen</span><span>Starkregen</span><span>Extremereignis</span></div>
</div>
<div class="ge-gh-result">
<div class="ge-gh-pill" data-ref="level" aria-live="polite"></div>
<div class="ge-gh-pill" data-ref="trend"></div>
</div>
<p class="ge-note">HQ30 / HQ100 = Hochwasser, das im langjährigen Mittel statistisch einmal in 30 bzw. 100 Jahren erreicht oder überschritten wird (Quelle: eHYD, BML Österreich). Die Marken hier sind Beispielwerte — jeder echte Pegel hat eigene Grenzwerte. Die Verzögerung entsteht, weil Regen erst durch Boden, Bäche und Zuflüsse wandern muss, bis er am Pegel ankommt.</p>
</div>
`;
const svg = root.querySelector('.ge-track');
const rng = root.querySelector('#ge-rain-range');
const rainValEl = root.querySelector('[data-ref="rainval"]');
const levelEl = root.querySelector('[data-ref="level"]');
const trendEl = root.querySelector('[data-ref="trend"]');
// cm → y (Flussbett bei y=290, 400 cm bei y=60)
const BED_Y = 290, CM_SCALE = (290 - 60) / 400;
const yOfCm = cm => BED_Y - cm * CM_SCALE;
const BANK_CM = 280; // Uferoberkante
const MARKS = [
{ cm: 220, label: 'Vorwarnstufe', color: '#e8c547' },
{ cm: 300, label: 'HQ30', color: '#e8833a' },
{ cm: 360, label: 'HQ100', color: '#c85c4a' }
];
// Himmel
const sky = svgEl('rect', { x: 0, y: 0, width: 600, height: 320, fill: '#dae8ec', rx: 8 }, svg);
// Wasser (Polygon, dynamisch)
const water = svgEl('polygon', { points: '', fill: '#4a7c8a', opacity: '.85' }, svg);
const floodL = svgEl('rect', { x: 0, y: 0, width: 0, height: 0, fill: '#4a7c8a', opacity: '.7' }, svg);
const floodR = svgEl('rect', { x: 0, y: 0, width: 0, height: 0, fill: '#4a7c8a', opacity: '.7' }, svg);
// Ufer-Profil: links Flur — Böschung — Flussbett — Böschung — rechts Flur
const bankY = yOfCm(BANK_CM); // = 129
svgEl('polygon', { points: `0,320 0,${bankY} 90,${bankY} 150,${BED_Y} 150,320`, fill: '#5a8a5e' }, svg);
svgEl('polygon', { points: `600,320 600,${bankY} 410,${bankY} 350,${BED_Y} 350,320`, fill: '#5a8a5e' }, svg);
svgEl('rect', { x: 150, y: BED_Y, width: 200, height: 30, fill: '#6b4a2e' }, svg);
// Wolke + Regen
const cloud = svgEl('g', {}, svg);
svgEl('ellipse', { cx: 150, cy: 42, rx: 46, ry: 20, fill: '#8aa4b0' }, cloud);
svgEl('ellipse', { cx: 195, cy: 50, rx: 38, ry: 17, fill: '#8aa4b0' }, cloud);
svgEl('ellipse', { cx: 110, cy: 52, rx: 32, ry: 15, fill: '#8aa4b0' }, cloud);
const rainG = svgEl('g', { stroke: '#4a7c8a', 'stroke-width': '2', 'stroke-linecap': 'round' }, svg);
// Marken-Linien über dem Wasserbereich
MARKS.forEach(m => {
const y = yOfCm(m.cm);
svgEl('line', { x1: 60, y1: y, x2: 470, y2: y, stroke: m.color, 'stroke-width': '2', 'stroke-dasharray': '6 4' }, svg);
const t = svgEl('text', { x: 478, y: y + 4, 'font-size': '11', 'font-weight': '800', fill: m.color }, svg);
t.textContent = m.label + ' · ' + m.cm + ' cm';
});
// Pegellatte
const LATTE_X = 430;
svgEl('rect', { x: LATTE_X - 7, y: yOfCm(400), width: 14, height: BED_Y - yOfCm(400), fill: '#fff', stroke: '#1f4e5a', 'stroke-width': '1.2', rx: 2 }, svg);
for (let cm = 0; cm <= 400; cm += 50) {
const w = cm % 100 === 0 ? 10 : 6;
svgEl('line', { x1: LATTE_X - 7, y1: yOfCm(cm), x2: LATTE_X - 7 + w, y2: yOfCm(cm), stroke: '#1f4e5a', 'stroke-width': '1' }, svg);
if (cm % 100 === 0 && cm > 0) {
const t = svgEl('text', { x: LATTE_X + 12, y: yOfCm(cm) + 3, 'font-size': '9', fill: '#1f4e5a' }, svg);
t.textContent = cm;
}
}
// Ablese-Pfeil an der Latte
const pointer = svgEl('polygon', { points: '', fill: '#1f4e5a' }, svg);
// Böschungs-Geometrie fürs Wasser-Polygon
const slope = (BED_Y - bankY) / 60; // px pro px (150-90 bzw. 350-410)
function waterPoly(levelCm) {
const yW = yOfCm(Math.min(levelCm, BANK_CM));
const lxx = 150 - (BED_Y - yW) / slope;
const rxx = 350 + (BED_Y - yW) / slope;
return `${lxx.toFixed(1)},${yW.toFixed(1)} ${rxx.toFixed(1)},${yW.toFixed(1)} 350,${BED_Y} 150,${BED_Y}`;
}
function drawRain(intensity) {
rainG.innerHTML = '';
const n = Math.round(intensity / 7);
for (let i = 0; i < n; i++) {
const x = 80 + Math.random() * 150;
const y = 70 + Math.random() * 50;
svgEl('line', { x1: x, y1: y, x2: x - 3, y2: y + 12, opacity: '.8' }, rainG);
}
cloud.setAttribute('opacity', intensity > 0 ? 1 : 0.45);
sky.setAttribute('fill', intensity > 60 ? '#b8c9cf' : '#dae8ec');
}
let inflow = 10, level = 130, lastRedraw = 0;
function statusFor(cm) {
if (cm >= 360) return { s: 'bad', t: '🔴 HQ100 erreicht — Jahrhunderthochwasser' };
if (cm >= 300) return { s: 'warn', t: '🟠 HQ30 überschritten — großes Hochwasser' };
if (cm >= 220) return { s: 'now', t: '🟡 Vorwarnstufe überschritten' };
return { s: 'good', t: '🟢 Normaler Wasserstand' };
}
function tick() {
if (!root.isConnected) return;
const rain = +rng.value;
// Zweistufige Verzögerung: Boden/Zufluss-Speicher → Pegel
inflow += (rain - inflow) * 0.008;
const target = 100 + inflow * 3.0;
const before = level;
level += (target - level) * 0.015;
const cm = Math.max(0, Math.min(400, level));
water.setAttribute('points', waterPoly(cm));
// Ausuferung über die Uferkante
if (cm > BANK_CM) {
const h = (cm - BANK_CM) * CM_SCALE;
floodL.setAttribute('x', 0); floodL.setAttribute('y', bankY - h);
floodL.setAttribute('width', 150); floodL.setAttribute('height', h);
floodR.setAttribute('x', 350); floodR.setAttribute('y', bankY - h);
floodR.setAttribute('width', 250); floodR.setAttribute('height', h);
} else {
floodL.setAttribute('width', 0); floodL.setAttribute('height', 0);
floodR.setAttribute('width', 0); floodR.setAttribute('height', 0);
}
const y = yOfCm(cm);
pointer.setAttribute('points', `${LATTE_X - 22},${y - 5} ${LATTE_X - 22},${y + 5} ${LATTE_X - 10},${y}`);
// UI-Texte gedrosselt aktualisieren
const now = performance.now();
if (now - lastRedraw > 180) {
lastRedraw = now;
const st = statusFor(cm);
levelEl.textContent = 'Pegel: ' + fmtDE(cm, 0) + ' cm — ' + st.t;
levelEl.dataset.state = st.s;
const d = level - before;
trendEl.textContent = d > 0.05 ? '↗ Pegel steigt (verzögert)' : d < -0.05 ? '↘ Pegel fällt (verzögert)' : '→ Pegel stabil';
}
requestAnimationFrame(tick);
}
rng.addEventListener('input', () => {
rainValEl.textContent = rng.value;
drawRain(+rng.value);
});
drawRain(+rng.value);
requestAnimationFrame(tick);
}
// =====================================================================
// Experiment: Wärmeausdehnung — wärmeres Meer braucht mehr Platz
// =====================================================================
function thermalExpansion(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>🌡 Wärmeres Wasser braucht mehr Platz</h3>
<p>Erwärme das Meer mit dem Regler. Der Meeresspiegel steigt hier <strong>allein durch die Ausdehnung des Wassers</strong> — noch ganz ohne Gletscherschmelze.</p>
</div>
<svg class="ge-gh-svg" viewBox="0 0 600 300" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-gh-slider">
<label for="ge-thx-range">Erwärmung des Ozeans: <strong data-ref="dt">+0,0 °C</strong></label>
<input id="ge-thx-range" type="range" min="0" max="4" value="0" step="0.1">
<div class="ge-gh-markers"><span>heute</span><span>+1 °C</span><span>+2 °C</span><span>+3 °C</span><span>+4 °C</span></div>
</div>
<div class="ge-gh-result">
<div class="ge-gh-pill" data-ref="rise"></div>
<div class="ge-gh-pill" data-ref="status" aria-live="polite"></div>
</div>
<p class="ge-note">Vereinfachung: Auf lange Sicht dehnt sich das Meerwasser um rund 0,26 m je °C Erwärmung aus (Hieronymus 2019, Environ. Res. Lett. — Update zur IPCC-AR5-Schätzung von bis zu 0,42 m je °C nach Levermann et al. 2013). Schmelzwasser von Gletschern und Eisschilden kommt zusätzlich dazu. Darstellung stark überhöht.</p>
</div>
`;
const svg = root.querySelector('.ge-gh-svg');
const rng = root.querySelector('#ge-thx-range');
const dtEl = root.querySelector('[data-ref="dt"]');
const riseEl = root.querySelector('[data-ref="rise"]');
const statusEl = root.querySelector('[data-ref="status"]');
const M_PER_DEG = 0.26; // m Anstieg je °C (nur Ausdehnung, Langfrist-Näherung)
const PX_PER_M = 45; // Überhöhung: 1 m = 45 px
const Y_SEA0 = 180; // heutiger Meeresspiegel
// Strand-Profil: flaches Vorland links, Böschung nach rechts oben
const G0 = { x: 90, y: 236 }, G1 = { x: 600, y: 110 };
const gSlope = (G0.y - G1.y) / (G1.x - G0.x); // px sinken je px nach rechts: 0.247
const groundY = x => x <= G0.x ? 238 : G0.y - gSlope * (x - G0.x);
const shoreX = yW => G0.x + (G0.y - yW) / gSlope;
// Boden
svgEl('polygon', { points: `0,300 0,240 ${G0.x},${G0.y} ${G1.x},${G1.y} 600,300`, fill: '#e8d5b5' }, svg);
// Referenz-Objekte am Strand (Höhe über heutigem Meer)
const refs = [
{ m: 0.3, label: 'Strandhütte', emoji: '🛖' },
{ m: 0.6, label: 'Strandweg', emoji: '🚏' },
{ m: 1.2, label: 'Haus an der Düne', emoji: '🏠' }
];
refs.forEach(r => {
const gy = Y_SEA0 - r.m * PX_PER_M;
const gx = shoreX(gy);
r.x = gx; r.groundY = gy;
const e = svgEl('text', { x: gx, y: gy - 4, 'text-anchor': 'middle', 'font-size': '22' }, svg);
e.textContent = r.emoji;
const t = svgEl('text', { x: gx, y: gy - 30, 'text-anchor': 'middle', 'font-size': '9', 'font-weight': '700', fill: '#4a4a4a' }, svg);
t.textContent = r.label + ' · +' + fmtDE(r.m, 1) + ' m';
});
// Wasser
const water = svgEl('polygon', { points: '', fill: '#4a7c8a', opacity: '.85' }, svg);
const waterLine = svgEl('line', { x1: 0, y1: Y_SEA0, x2: 0, y2: Y_SEA0, stroke: '#1f4e5a', 'stroke-width': '1.5' }, svg);
// Heutiger Meeresspiegel als Referenzlinie
svgEl('line', { x1: 0, y1: Y_SEA0, x2: shoreX(Y_SEA0) + 40, y2: Y_SEA0, stroke: '#1f4e5a', 'stroke-width': '1', 'stroke-dasharray': '5 4', opacity: '.7' }, svg);
const refLbl = svgEl('text', { x: 8, y: Y_SEA0 + 14, 'font-size': '9', fill: '#1f4e5a' }, svg);
refLbl.textContent = 'Meeresspiegel heute';
// Maßstabs-Lineal links
[0, 0.5, 1].forEach(m => {
const y = Y_SEA0 - m * PX_PER_M;
svgEl('line', { x1: 30, y1: y, x2: 42, y2: y, stroke: '#1f4e5a', 'stroke-width': '1.5' }, svg);
const t = svgEl('text', { x: 46, y: y + 3, 'font-size': '9', fill: '#1f4e5a' }, svg);
t.textContent = m === 0 ? '±0' : '+' + fmtDE(m, 1) + ' m';
});
svgEl('line', { x1: 36, y1: Y_SEA0, x2: 36, y2: Y_SEA0 - PX_PER_M, stroke: '#1f4e5a', 'stroke-width': '1' }, svg);
// Wärme-Symbol im Wasser
const heat = svgEl('text', { x: 150, y: 250, 'text-anchor': 'middle', 'font-size': '18', opacity: '0' }, svg);
heat.textContent = '♨';
function update() {
const dT = +rng.value;
const riseM = M_PER_DEG * dT;
const yW = Y_SEA0 - riseM * PX_PER_M;
const xs = shoreX(yW);
water.setAttribute('points', `0,${yW.toFixed(1)} ${xs.toFixed(1)},${yW.toFixed(1)} ${G0.x},${G0.y} 0,240`);
waterLine.setAttribute('y1', yW.toFixed(1));
waterLine.setAttribute('y2', yW.toFixed(1));
waterLine.setAttribute('x2', xs.toFixed(1));
heat.setAttribute('opacity', Math.min(1, dT / 2).toFixed(2));
water.setAttribute('fill', dT > 2 ? '#5a7c8a' : '#4a7c8a');
dtEl.textContent = '+' + fmtDE(dT, 1) + ' °C';
riseEl.textContent = 'Anstieg nur durch Ausdehnung: +' + fmtDE(riseM, 2) + ' m (0,26 m · ' + fmtDE(dT, 1) + ')';
riseEl.dataset.state = dT < 1 ? 'good' : dT < 2.5 ? 'warn' : 'bad';
const flooded = refs.filter(r => r.groundY > yW).map(r => r.label);
if (flooded.length === 0) { statusEl.textContent = '🏖 Der Strand wird schmaler, alles bleibt trocken.'; statusEl.dataset.state = 'good'; }
else { statusEl.textContent = '🌊 Unter Wasser: ' + flooded.join(' und '); statusEl.dataset.state = flooded.length > 1 ? 'bad' : 'warn'; }
}
rng.addEventListener('input', update);
update();
}
// =====================================================================
// Experiment: Klimaträgheit — nach Netto-Null geht es trotzdem weiter
// =====================================================================
function climateInertia(root) {
root.innerHTML = `
<div class="ge-wrap">
<div class="ge-head">
<h3>🐌 Das Klima ist träge — teste es selbst</h3>
<p>Lass die Zeit laufen und stelle die Emissionen ein. Drücke dann <strong>„Emissionen sofort auf null"</strong> — und beobachte, was Temperatur und Meeresspiegel trotzdem machen.</p>
</div>
<svg class="ge-track" viewBox="0 0 600 320" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
<div class="ge-gh-slider">
<label for="ge-inertia-range">CO₂-Emissionen: <strong data-ref="eval">80 %</strong></label>
<input id="ge-inertia-range" type="range" min="0" max="100" value="80" step="5">
</div>
<div class="ge-controls">
<button class="ge-btn ge-btn-primary" data-act="play">▶ Start</button>
<button class="ge-btn" data-act="zero">⛔ Emissionen sofort auf null</button>
<button class="ge-btn" data-act="reset">↺ Zurück</button>
</div>
<ul class="ge-legend">
<li><span class="ge-dot" style="background:#8a857a"></span>CO₂-Emissionen (dein Regler)</li>
<li><span class="ge-dot" style="background:#c85c4a"></span>Temperatur</li>
<li><span class="ge-dot" style="background:#4a7c8a"></span>Meeresspiegel</li>
</ul>
<div class="ge-gh-result">
<div class="ge-gh-pill" data-ref="insight" aria-live="polite">Drücke ▶ Start, um die Zeit laufen zu lassen.</div>
</div>
<p class="ge-note">Vereinfachtes Anschauungs-Modell, keine Klimasimulation (relative Skalen). Kernaussage nach IPCC AR6 (WG1, Kap. 9): CO₂ bleibt Jahrhunderte in der Atmosphäre, die träge Tiefsee erwärmt sich nach — deshalb steigt der Meeresspiegel selbst bei Netto-Null-Emissionen noch über Jahrhunderte bis Jahrtausende weiter.</p>
</div>
`;
const svg = root.querySelector('.ge-track');
const rng = root.querySelector('#ge-inertia-range');
const eValEl = root.querySelector('[data-ref="eval"]');
const insightEl = root.querySelector('[data-ref="insight"]');
const playBtn = root.querySelector('[data-act="play"]');
const zeroBtn = root.querySelector('[data-act="zero"]');
const resetBtn = root.querySelector('[data-act="reset"]');
// Plot-Geometrie
const X0 = 55, X1 = 580, Y_BASE = 280, Y_TOP = 30;
const T_END = 160; // Modell-Jahre
const xOf = yr => X0 + (yr / T_END) * (X1 - X0);
// Achsen
svgEl('line', { x1: X0, y1: Y_BASE, x2: X1, y2: Y_BASE, stroke: '#1f4e5a', 'stroke-width': '1.2' }, svg);
svgEl('line', { x1: X0, y1: Y_BASE, x2: X0, y2: Y_TOP, stroke: '#1f4e5a', 'stroke-width': '1.2' }, svg);
[0, 40, 80, 120, 160].forEach(yr => {
svgEl('line', { x1: xOf(yr), y1: Y_BASE, x2: xOf(yr), y2: Y_BASE + 5, stroke: '#4a4a4a', 'stroke-width': '1' }, svg);
const t = svgEl('text', { x: xOf(yr), y: Y_BASE + 18, 'text-anchor': 'middle', 'font-size': '10', fill: '#4a4a4a' }, svg);
t.textContent = 'Jahr ' + yr;
});
const axisLbl = svgEl('text', { x: X0, y: Y_TOP - 8, 'font-size': '9', fill: '#4a4a4a' }, svg);
axisLbl.textContent = 'relative Skalen ↑';
// Kurven + Netto-Null-Markierung
const lineE = svgEl('polyline', { points: '', fill: 'none', stroke: '#8a857a', 'stroke-width': '2' }, svg);
const lineT = svgEl('polyline', { points: '', fill: 'none', stroke: '#c85c4a', 'stroke-width': '2.5' }, svg);
const lineS = svgEl('polyline', { points: '', fill: 'none', stroke: '#4a7c8a', 'stroke-width': '2.5' }, svg);
const zeroMark = svgEl('g', { opacity: '0' }, svg);
const zeroLine = svgEl('line', { x1: 0, y1: Y_TOP, x2: 0, y2: Y_BASE, stroke: '#1f4e5a', 'stroke-width': '1.5', 'stroke-dasharray': '5 4' }, zeroMark);
const zeroTxt = svgEl('text', { x: 0, y: Y_TOP + 12, 'font-size': '10', 'font-weight': '800', fill: '#1f4e5a' }, zeroMark);
zeroTxt.textContent = 'Netto-Null';
// Skalen: E 0..1 → unteres Band, T 0..2 °C-Index, S 0..160 Einheiten
const yE = e => Y_BASE - e * 55;
const yT = t => Y_BASE - (t / 2.0) * 200;
const yS = s => Y_BASE - (s / 160) * 230;
// Modell-Zustand
let sim, running = false;
function freshSim() {
return { year: 0, C: 0, T: 0, S: 0, zeroYear: null, pE: [], pT: [], pS: [], lastTxt: '' };
}
sim = freshSim();
function setInsight(txt, state) {
if (txt === sim.lastTxt) return;
sim.lastTxt = txt;
insightEl.textContent = txt;
insightEl.dataset.state = state;
}
function step(dt) {
const E = +rng.value / 100;
// CO₂ reichert sich an, wird nur sehr langsam abgebaut (Ozeane, Verwitterung)
sim.C += (E * 0.012 - sim.C * 0.0015) * dt;
// Temperatur folgt der Gleichgewichts-Temperatur mit Jahrzehnte-Verzögerung
const Teq = sim.C * 1.6;
const dT = (Teq - sim.T) / 45;
sim.T += dT * dt;
// Meeresspiegel integriert die Erwärmung (Tiefsee + Eis reagieren am trägsten)
sim.S += Math.max(0, sim.T) * 0.85 * dt;
sim.year += dt;
return dT;
}
function pushPoints() {
const x = xOf(sim.year).toFixed(1);
sim.pE.push(x + ',' + yE(+rng.value / 100).toFixed(1));
sim.pT.push(x + ',' + yT(sim.T).toFixed(1));
sim.pS.push(x + ',' + yS(Math.min(sim.S, 160)).toFixed(1));
lineE.setAttribute('points', sim.pE.join(' '));
lineT.setAttribute('points', sim.pT.join(' '));
lineS.setAttribute('points', sim.pS.join(' '));
}
function tick() {
if (!root.isConnected || !running) return;
let dTLast = 0;
for (let i = 0; i < 2; i++) dTLast = step(0.15);
pushPoints();
if (sim.zeroYear === null) {
setInsight('Solange emittiert wird, steigen CO₂, Temperatur und Meeresspiegel.', 'now');
} else if (dTLast > 0.0015) {
setInsight('⚠ Emissionen sind null — die Temperatur steigt trotzdem noch weiter! Die Ozeane holen die Erwärmung erst nach.', 'warn');
} else {
setInsight('Erst nach Jahrzehnten stabilisiert sich die Temperatur — der Meeresspiegel steigt sogar noch Jahrhunderte weiter. Das ist Klimaträgheit.', 'bad');
}
if (sim.year >= T_END) {
running = false;
playBtn.textContent = '▶ Start';
if (sim.zeroYear === null) {
setInsight('Zeit abgelaufen — probiere es noch einmal und drücke unterwegs „Emissionen sofort auf null".', 'now');
}
return;
}
requestAnimationFrame(tick);
}
playBtn.addEventListener('click', () => {
if (running) { running = false; playBtn.textContent = '▶ Weiter'; return; }
if (sim.year >= T_END) { sim = freshSim(); zeroMark.setAttribute('opacity', '0'); rng.value = 80; eValEl.textContent = '80 %'; }
running = true;
playBtn.textContent = '⏸ Pause';
requestAnimationFrame(tick);
});
zeroBtn.addEventListener('click', () => {
rng.value = 0;
eValEl.textContent = '0 %';
if (running && sim.zeroYear === null) {
sim.zeroYear = sim.year;
const x = xOf(sim.year);
zeroLine.setAttribute('x1', x.toFixed(1)); zeroLine.setAttribute('x2', x.toFixed(1));
zeroTxt.setAttribute('x', (x > 480 ? x - 5 : x + 5).toFixed(1));
zeroTxt.setAttribute('text-anchor', x > 480 ? 'end' : 'start');
zeroMark.setAttribute('opacity', '1');
}
});
resetBtn.addEventListener('click', () => {
running = false;
sim = freshSim();
lineE.setAttribute('points', ''); lineT.setAttribute('points', ''); lineS.setAttribute('points', '');
zeroMark.setAttribute('opacity', '0');
rng.value = 80; eValEl.textContent = '80 %';
playBtn.textContent = '▶ Start';
insightEl.textContent = 'Drücke ▶ Start, um die Zeit laufen zu lassen.';
insightEl.dataset.state = 'now';
sim.lastTxt = '';
});
rng.addEventListener('input', () => { eValEl.textContent = rng.value + ' %'; });
}
// Registry — Keys entsprechen key_slug aus der DB.
// pegel und hq100 teilen sich bewusst dasselbe Widget.
window.GlossarExperiments = Object.assign(window.GlossarExperiments || {}, {
'gebundene-rotation': tidalLock,
'kernschatten': umbraCones,
'keeling-kurve': keelingCurve,
'pegel': riverGauge,
'hq100': riverGauge,
'waermeausdehnung': thermalExpansion,
'klimatraegheit': climateInertia
});
})();
@@ -0,0 +1,741 @@
/**
* 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 050 €, 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,8049,90
{ a1: -40, a2: -20, c: '#e8833a' }, // 49,9049,95
{ a1: -20, a2: 20, c: '#5a8a5e' }, // 49,9550,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 7580 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
});
})();
+5 -3
View File
@@ -534,8 +534,10 @@
update();
}
// Registry — Key entspricht key_slug aus DB
window.GlossarExperiments = {
// Registry — Key entspricht key_slug aus DB.
// Object.assign, damit weitere Dateien (glossar-experiments-*.js) unabhängig
// von der Ladereihenfolge dazuregistrieren können.
window.GlossarExperiments = Object.assign(window.GlossarExperiments || {}, {
'kilowattstunde': kwhRace,
'ppm': ppmGrid,
'albedo': albedoSlider,
@@ -543,5 +545,5 @@
'treibhauseffekt': greenhouseSlider,
'methan': methaneBalance
// 'windenergie' bewusst raus — redundant mit windrad-groessenvergleich.svg
};
});
})();