Stand 2026-04-13: PHP/MySQL Infrastruktur, Flussmanagement, Stadt-Prototyp

- PHP/MySQL Backend (XAMPP + Produktionsserver)
- Front-Controller, API-Endpunkte, Session-Management
- Flussmanagement-Simulation (Echtzeit, Punkt-basierter Fluss)
- Stadt & Raumplanung (Prototyp, Top-Down Kachelsystem)
- Klimawaechter 3D: Deiche kleiner, Baeume kippen, Budget angepasst
- persistence.ts: Dualer Speicher (localStorage + Server-API)
- 6 Unit-Test-Dateien fuer bestehende Simulationen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 16:43:42 +02:00
commit f22c5ebbfe
83 changed files with 22709 additions and 0 deletions
@@ -0,0 +1,806 @@
/**
* Klimawächter — Canvas Renderer (V3)
*
* Designprinzipien (nach User-Feedback):
* - RUHE: Kein Tag-Nacht-Wechsel, immer Tagslicht
* - DEZENT: Jahreszeiten als sehr leichte Farbverschiebung, keine Vollbild-Effekte
* - KLIMASTRESS sichtbar: Mit steigender Temperatur wird Himmel gelblicher,
* Land trockener — das ist die einzige langfristige Farbveränderung
* - DETERMINISTISCHE BAUTEN: Position fest beim Bauen
* - ZEITLEISTE am unteren Rand: 2025 ━━●━━━ 2100
* - LEBEN am Rand: Möwen, Hintergrundschiffe, springender Fisch
*
* Zeitraum: 20252100 (75 Jahre, 75 Ticks)
*/
import { KlimawaechterGame } from './game'
interface PlacedObject {
type: 'tree' | 'solar' | 'wind' | 'green-roof' | 'dike' | 'sea-wall'
x: number // 0..1
scale: number
ownerId: string
builtTick: number
}
interface Bird {
x: number
y: number
vx: number
wingPhase: number
size: number
}
interface BgShip {
x: number
speed: number
size: number
}
export class KlimawaechterRenderer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private game: KlimawaechterGame
private W = 0
private H = 0
private t = 0
private animId = 0
private placed: PlacedObject[] = []
private knownIds = new Set<string>()
private lastTick = -1
private tickStartT = 0
private birds: Bird[] = []
private bgShips: BgShip[] = []
private fishTimer = 0
private fishX = 0
private fishPhase = -1
private houseSeeds: number[] = []
private landSurfaceFn: (x: number) => number = () => 0
constructor(container: HTMLElement, game: KlimawaechterGame) {
this.game = game
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'width:100%;display:block;border-radius:12px;background:#dde3da;'
container.appendChild(this.canvas)
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('Canvas not supported')
this.ctx = ctx
this.resize()
this.initFauna()
window.addEventListener('resize', () => this.resize())
}
private resize(): void {
const rect = this.canvas.parentElement!.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.W = rect.width
this.H = Math.min(rect.width * 0.55, 420)
this.canvas.width = this.W * dpr
this.canvas.height = this.H * dpr
this.canvas.style.height = this.H + 'px'
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
private initFauna(): void {
if (this.houseSeeds.length === 0) {
for (let i = 0; i < 12; i++) {
this.houseSeeds.push(this.seededRand(i * 7919) * 0.04 - 0.02)
}
}
for (let i = 0; i < 4; i++) {
this.birds.push({
x: Math.random() * this.W,
y: this.H * (0.05 + Math.random() * 0.18),
vx: 0.15 + Math.random() * 0.2,
wingPhase: Math.random() * Math.PI * 2,
size: 3 + Math.random() * 3,
})
}
this.bgShips = [
{ x: this.W * 0.1, speed: 0.05, size: 0.7 },
{ x: this.W * 0.6, speed: 0.03, size: 0.5 },
]
}
private seededRand(seed: number): number {
const x = Math.sin(seed * 12.9898) * 43758.5453
return x - Math.floor(x)
}
start(): void {
let lastFrame = performance.now()
const loop = (now: number) => {
const dt = (now - lastFrame) / 1000
lastFrame = now
this.t += dt
this.updateScene()
this.draw()
this.animId = requestAnimationFrame(loop)
}
this.animId = requestAnimationFrame(loop)
}
stop(): void {
cancelAnimationFrame(this.animId)
}
private updateScene(): void {
const snap = this.game.getSnapshot()
if (snap.tick !== this.lastTick) {
this.lastTick = snap.tick
this.tickStartT = this.t
}
const owned = this.game.getOwnedMeasures()
for (const m of owned) {
for (let i = 0; i < m.count; i++) {
const id = `${m.measureId}-${i}`
if (!this.knownIds.has(id)) {
this.knownIds.add(id)
this.placed.push(this.placeMeasure(m.measureId, i, snap.tick))
}
}
}
for (const b of this.birds) {
b.x += b.vx
b.wingPhase += 0.18
b.y += Math.sin(this.t * 0.6 + b.wingPhase * 0.3) * 0.1
if (b.x > this.W + 30) {
b.x = -30
b.y = this.H * (0.05 + Math.random() * 0.18)
}
}
for (const s of this.bgShips) {
s.x += s.speed
if (s.x > this.W + 80) s.x = -80
}
}
private placeMeasure(id: string, index: number, tick: number): PlacedObject {
const baseScale = 0.85 + this.seededRand(index * 991 + 31) * 0.3
const stableId = `${id}-${index}`
if (id === 'forest') {
const slot = index % 6
const x = 0.04 + slot * 0.025 + this.seededRand(index * 13 + 7) * 0.015
return { type: 'tree', x, scale: baseScale, ownerId: stableId, builtTick: tick }
}
if (id === 'solar') {
const slot = index % 5
const x = 0.78 - slot * 0.035 + this.seededRand(index * 17 + 3) * 0.01
return { type: 'solar', x, scale: 0.95, ownerId: stableId, builtTick: tick }
}
if (id === 'wind') {
const slot = index % 4
const x = 0.02 + slot * 0.04 + this.seededRand(index * 23 + 11) * 0.01
return { type: 'wind', x, scale: 1, ownerId: stableId, builtTick: tick }
}
if (id === 'green-roof') {
return { type: 'green-roof', x: 0, scale: 1, ownerId: stableId, builtTick: tick }
}
if (id === 'dike') {
const slot = index % 4
const x = 0.83 + slot * 0.035
return { type: 'dike', x, scale: 1, ownerId: stableId, builtTick: tick }
}
if (id === 'sea-wall') {
const x = 0.95
return { type: 'sea-wall', x, scale: 1 + index * 0.08, ownerId: stableId, builtTick: tick }
}
return { type: 'tree', x: 0.5, scale: 1, ownerId: stableId, builtTick: tick }
}
// ============================================================
// RENDERING
// ============================================================
private draw(): void {
const { ctx, W, H } = this
ctx.clearRect(0, 0, W, H)
const snap = this.game.getSnapshot()
const co2 = snap.resources.co2 ?? 425
const temp = snap.resources.temperature ?? 15
const seaCm = snap.resources.sealevel ?? 0
const flooded = snap.resources.flooded ?? 0
const speed = snap.speed || 1
// Klimastress: subtiler Farbwandel über die Zeit
// Temperatur 15 → 19+ → Himmel wird gelblicher, Land trockener
const tempStress = Math.max(0, Math.min(1, (temp - 15) / 4))
const co2Stress = Math.max(0, Math.min(1, (co2 - 400) / 400))
// Saison-Fortschritt innerhalb des aktuellen Jahres (für sehr dezente Akzente)
const msPerTick = 4000 / Math.max(0.001, speed)
const elapsedInTick = (this.t - this.tickStartT) * 1000
const tickProgress = Math.min(1, elapsedInTick / msPerTick)
const seasonF = tickProgress * 4
const seasonIdx = Math.floor(seasonF) % 4
const seasonBlend = seasonF - Math.floor(seasonF)
// Reservierter Bereich für die Zeitleiste am unteren Rand
const timelineH = 28
const sceneH = H - timelineH
const groundY = sceneH * 0.7
const baseSeaY = sceneH * 0.78
const seaY = baseSeaY - Math.min(baseSeaY * 0.2, seaCm * 0.4)
// ===== HIMMEL — basiert auf Klimastress, NICHT auf Tageszeit =====
const skyTopBase = [200, 215, 210] // hell-bläulich
const skyMidBase = [215, 225, 215]
const skyBottomBase = [225, 230, 215]
// Klimastress: Himmel wird gelb-bräunlicher
const stressedSky = (base: number[]) => {
const r = Math.round(base[0] + tempStress * 25 + co2Stress * 10)
const g = Math.round(base[1] + tempStress * 5 - co2Stress * 5)
const b = Math.round(base[2] - tempStress * 30 - co2Stress * 25)
return `rgb(${r},${g},${b})`
}
const sky = ctx.createLinearGradient(0, 0, 0, groundY)
sky.addColorStop(0, stressedSky(skyTopBase))
sky.addColorStop(0.6, stressedSky(skyMidBase))
sky.addColorStop(1, stressedSky(skyBottomBase))
ctx.fillStyle = sky
ctx.fillRect(0, 0, W, groundY)
// ===== SONNE — fix oben rechts, dezent =====
const sunX = W * 0.85
const sunY = sceneH * 0.13
const sunGlow = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, 60)
sunGlow.addColorStop(0, 'rgba(255,235,180,0.35)')
sunGlow.addColorStop(1, 'rgba(255,235,180,0)')
ctx.fillStyle = sunGlow
ctx.fillRect(sunX - 60, sunY - 60, 120, 120)
// Sonnen-Farbe wird leicht orange-rot bei Klimastress
const sunR = 240 + tempStress * 15
const sunG = 200 - tempStress * 30
const sunB = 100 - tempStress * 30
ctx.fillStyle = `rgb(${sunR},${sunG},${sunB})`
ctx.beginPath()
ctx.arc(sunX, sunY, 18, 0, Math.PI * 2)
ctx.fill()
// ===== WOLKEN — dezent driftend =====
const cloudOffset = this.t * 4
for (let i = 0; i < 4; i++) {
const baseX = (i / 4) * W * 1.4 - W * 0.1
const cx = ((baseX + cloudOffset * (1 + i * 0.1)) % (W + 100)) - 50
const cy = sceneH * (0.08 + i * 0.04)
const cloudOpacity = 0.7 - co2Stress * 0.2
this.drawCloud(ctx, cx, cy, 0.7 + (i % 3) * 0.15, `rgba(255,255,255,${cloudOpacity})`)
}
// ===== KEIL-INSEL — schräg ansteigend von rechts (Meer) nach links (Vulkan) =====
// landSurface(x) = y-Koordinate der Landoberfläche an horizontaler Position x
// Hinten/links hoch, vorne/rechts niedrig (sinkt knapp unter baseSeaY)
const landHighY = groundY - 40 // linke Seite (hinter Vulkan)
const landLowY = baseSeaY - 6 // rechte Seite — knapp über Wasser
const landSurface = (x: number): number => {
const t = x / W // 0 links → 1 rechts
// Leicht quadratisch abfallend für natürlicheren Keil
const ease = t * t * 0.4 + t * 0.6
const base = landHighY * (1 - ease) + landLowY * ease
// Kleine Welligkeit
return base + Math.sin(x * 0.02) * 3 + Math.sin(x * 0.006 + 1.2) * 4
}
// Speicher als Render-State für Maßnahmen-Platzierung
this.landSurfaceFn = landSurface
// Hintergrund-Berge (weit links, entsprechen weiterem Hinterland)
const mountainR = 150 + tempStress * 25
const mountainG = 165 - tempStress * 30
const mountainB = 150 - tempStress * 35
ctx.fillStyle = `rgb(${mountainR},${mountainG},${mountainB})`
ctx.beginPath()
ctx.moveTo(0, landSurface(0) - 20)
for (let x = 0; x <= W * 0.6; x += 25) {
const my = landSurface(x) - 30 - Math.sin(x * 0.007 + 1) * 18 - Math.sin(x * 0.014 + 0.3) * 10
ctx.lineTo(x, my)
}
ctx.lineTo(W * 0.6, landSurface(W * 0.6))
ctx.lineTo(0, landSurface(0))
ctx.closePath()
ctx.fill()
// === VULKAN mit Gletscher-Deckel (links, ragt aus der Landschaft) ===
const volcanoBaseX = W * 0.16
const volcanoBaseY = landSurface(volcanoBaseX) - 2
const volcanoHeight = 120
const volcanoHalfBase = 55
const volcanoTopHalf = 14
// Fels
ctx.fillStyle = `rgb(${106 + tempStress * 20},${90 + tempStress * 10},${74})`
ctx.beginPath()
ctx.moveTo(volcanoBaseX - volcanoHalfBase, volcanoBaseY)
ctx.lineTo(volcanoBaseX - volcanoTopHalf, volcanoBaseY - volcanoHeight)
ctx.lineTo(volcanoBaseX + volcanoTopHalf, volcanoBaseY - volcanoHeight)
ctx.lineTo(volcanoBaseX + volcanoHalfBase, volcanoBaseY)
ctx.closePath()
ctx.fill()
// Krater-Delle
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath()
ctx.ellipse(volcanoBaseX, volcanoBaseY - volcanoHeight + 1, volcanoTopHalf - 2, 2, 0, 0, Math.PI * 2)
ctx.fill()
// Gletscher-Deckel (1/3 der Höhe, opak, schrumpft mit Temperatur)
const glacierFraction = Math.max(0, Math.min(1, 1 - (temp - 15) / 3))
if (glacierFraction > 0.02) {
const glacierFullH = volcanoHeight / 3
const glacierH = glacierFullH * glacierFraction
const glacierBaseY = volcanoBaseY - (volcanoHeight - glacierFullH + glacierFullH - glacierH)
const grime = (1 - glacierFraction) * 40
// Die Deckel-Form ist ein Trapez, breiter als der Vulkan-Top
const capBottomHalf = (volcanoTopHalf + 6) * (0.5 + glacierFraction * 0.5)
const capTopHalf = volcanoTopHalf * (0.6 + glacierFraction * 0.4)
ctx.fillStyle = `rgb(${240 - grime},${246 - grime},${250 - grime})`
ctx.beginPath()
ctx.moveTo(volcanoBaseX - capBottomHalf, glacierBaseY)
ctx.lineTo(volcanoBaseX - capTopHalf, glacierBaseY - glacierH)
ctx.lineTo(volcanoBaseX + capTopHalf, glacierBaseY - glacierH)
ctx.lineTo(volcanoBaseX + capBottomHalf, glacierBaseY)
ctx.closePath()
ctx.fill()
// Schatten-Linie unten am Deckel
ctx.strokeStyle = `rgba(140,150,160,${0.3 * glacierFraction})`
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(volcanoBaseX - capBottomHalf, glacierBaseY)
ctx.lineTo(volcanoBaseX + capBottomHalf, glacierBaseY)
ctx.stroke()
}
// ===== LAND (Keil) — Grasfarbe verändert sich mit Klimastress =====
const grassR = 140 + tempStress * 30
const grassG = 165 - tempStress * 35
const grassB = 110 - tempStress * 25
ctx.fillStyle = `rgb(${grassR},${grassG},${grassB})`
ctx.beginPath()
ctx.moveTo(0, landSurface(0))
for (let x = 0; x <= W; x += 10) {
ctx.lineTo(x, landSurface(x))
}
// Rechte untere Ecke: in den Boden / unter die Wasserlinie
ctx.lineTo(W, sceneH)
ctx.lineTo(0, sceneH)
ctx.closePath()
ctx.fill()
// Dünner Bodenstreifen direkt unter dem Gras
ctx.fillStyle = `rgb(${110 + tempStress * 20},${100 - tempStress * 15},${80 - tempStress * 20})`
for (let x = 0; x <= W; x += 4) {
ctx.fillRect(x, landSurface(x) + 4, 4, 6)
}
// ===== BÄUME =====
for (const obj of this.placed.filter(p => p.type === 'tree')) {
const px = obj.x * W
this.drawTree(ctx, px, landSurface(px) - 1, obj.scale, tempStress)
}
// ===== WINDRÄDER (stehen auf dem Keil — weiter oben links ist windiger) =====
for (const obj of this.placed.filter(p => p.type === 'wind')) {
const px = obj.x * W
this.drawWind(ctx, px, landSurface(px) - 5, this.t)
}
// ===== HÄUSER — bevorzugt auf der höheren (linken) Seite =====
const greenRoofs = this.placed.filter(s => s.type === 'green-roof').length
const totalHouses = 12
const floodedCount = Math.round((flooded / 100) * totalHouses)
for (let i = 0; i < totalHouses; i++) {
// Häuser eher im Mittelteil/links (0.28..0.72) — Tiefland rechts bleibt leer
const x = (0.28 + (i / totalHouses) * 0.44 + this.houseSeeds[i] * 0.8) * W
const isFlooded = i >= (totalHouses - floodedCount)
const hasGreenRoof = i < greenRoofs
this.drawHouse(ctx, x, landSurface(x) - 1, 0.95 + this.houseSeeds[i] * 4, isFlooded, hasGreenRoof)
}
// ===== SOLARANLAGEN =====
for (const obj of this.placed.filter(p => p.type === 'solar')) {
const px = obj.x * W
this.drawSolar(ctx, px, landSurface(px) - 4)
}
// ===== MEER =====
const seaR = 145 - tempStress * 10
const seaG = 175 - tempStress * 15
const seaB = 175 - tempStress * 5
const seaGrad = ctx.createLinearGradient(0, seaY, 0, sceneH)
seaGrad.addColorStop(0, `rgb(${seaR},${seaG},${seaB})`)
seaGrad.addColorStop(1, `rgb(${seaR - 25},${seaG - 25},${seaB - 15})`)
ctx.fillStyle = seaGrad
ctx.fillRect(0, seaY, W, sceneH - seaY)
// Hintergrundschiffe
for (const ship of this.bgShips) {
this.drawBgShip(ctx, ship.x, seaY - 4, ship.size)
}
// Wellen
for (let i = 0; i < 4; i++) {
ctx.beginPath()
ctx.moveTo(0, seaY)
for (let x = 0; x <= W; x += 4) {
ctx.lineTo(x, seaY + Math.sin(x * 0.02 + this.t * (1 + i * 0.5)) * (1.2 + i))
}
ctx.lineTo(W, sceneH); ctx.lineTo(0, sceneH); ctx.closePath()
ctx.fillStyle = `rgba(255,255,255,${0.04 - i * 0.008})`
ctx.fill()
}
// Sonnenreflexion auf dem Wasser (immer, da Sonne fix oben)
ctx.globalAlpha = 0.25
ctx.fillStyle = `rgb(${sunR},${sunG},${sunB})`
for (let i = 0; i < 5; i++) {
const ry = seaY + 4 + i * 4
const rw = 24 - i * 3 + Math.sin(this.t * 2 + i) * 3
ctx.fillRect(sunX - rw / 2, ry, rw, 1)
}
ctx.globalAlpha = 1
// ===== DEICHE & MAUERN =====
for (const obj of this.placed.filter(p => p.type === 'dike')) {
this.drawDike(ctx, obj.x * W, groundY)
}
for (const obj of this.placed.filter(p => p.type === 'sea-wall')) {
this.drawSeaWall(ctx, obj.x * W, groundY - 18, baseSeaY)
}
// ===== BEWOHNER =====
const popLossPct = Math.max(0, 10000 - snap.resources.population) / 10000
const peopleCount = Math.round(8 * (1 - popLossPct))
for (let i = 0; i < peopleCount; i++) {
const px = W * (0.22 + (i / 8) * 0.58 + Math.sin(this.t * 0.5 + i) * 0.003)
const py = groundY - 1
ctx.fillStyle = '#3a3a3a'
ctx.fillRect(px - 1, py - 5, 2, 5)
ctx.beginPath()
ctx.arc(px, py - 6, 1.4, 0, Math.PI * 2)
ctx.fill()
}
// ===== VÖGEL =====
for (const b of this.birds) {
this.drawBird(ctx, b)
}
// ===== SPRINGENDER FISCH =====
this.fishTimer -= 0.016
if (this.fishTimer <= 0) {
this.fishTimer = 8 + Math.random() * 12
this.fishX = W * (0.55 + Math.random() * 0.35)
this.fishPhase = 0
}
if (this.fishPhase >= 0 && this.fishPhase < 1) {
this.fishPhase += 0.018
const fy = seaY - Math.sin(this.fishPhase * Math.PI) * 18
const rot = -Math.PI * 0.3 + this.fishPhase * Math.PI * 0.6
ctx.save()
ctx.translate(this.fishX, fy)
ctx.rotate(rot)
ctx.globalAlpha = 0.5
ctx.fillStyle = '#5a7a7e'
ctx.beginPath()
ctx.ellipse(0, 0, 5, 2, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.moveTo(-5, 0); ctx.lineTo(-8, -2); ctx.lineTo(-8, 2); ctx.closePath()
ctx.fill()
ctx.restore()
ctx.globalAlpha = 1
}
// ===== DEZENTE SAISON-AKZENTE (sehr klein, nicht aufdringlich) =====
if (seasonIdx === 2) this.drawLeaves(ctx, seasonBlend, sceneH)
if (seasonIdx === 3) this.drawSnow(ctx, seasonBlend, sceneH)
// ===== ZEITLEISTE am unteren Rand =====
this.drawTimeline(ctx, timelineH, snap.tick, snap.events)
}
// ============================================================
// ZEITLEISTE
// ============================================================
private drawTimeline(ctx: CanvasRenderingContext2D, h: number, currentTick: number, events: any[]): void {
const { W, H } = this
const y = H - h
const marginX = 40
// Hintergrund
ctx.fillStyle = 'rgba(255,255,255,0.92)'
ctx.fillRect(0, y, W, h)
ctx.strokeStyle = 'rgba(0,0,0,0.06)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, y); ctx.lineTo(W, y)
ctx.stroke()
// Zeitleiste
const lineY = y + h / 2 + 2
const lineX0 = marginX
const lineX1 = W - marginX
// Linie
ctx.strokeStyle = '#c8c4b8'
ctx.lineWidth = 2
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(lineX0, lineY); ctx.lineTo(lineX1, lineY)
ctx.stroke()
// Dezimal-Markierungen (alle 10 Jahre)
const totalTicks = 75
for (let i = 0; i <= 7; i++) {
const decade = i * 10
const x = lineX0 + (decade / totalTicks) * (lineX1 - lineX0)
ctx.strokeStyle = '#a8a497'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, lineY - 3); ctx.lineTo(x, lineY + 3)
ctx.stroke()
// Jahreszahl
if (i === 0 || i === 7 || i % 2 === 0) {
ctx.fillStyle = '#7a7468'
ctx.font = '9px Inter, system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${2025 + decade}`, x, lineY + 14)
}
}
// Aktueller Stand — runder Marker
const progress = Math.min(1, currentTick / totalTicks)
const markerX = lineX0 + progress * (lineX1 - lineX0)
// Track bis hierher leicht hervorheben
ctx.strokeStyle = '#4a7c8a'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(lineX0, lineY); ctx.lineTo(markerX, lineY)
ctx.stroke()
// Marker-Kreis
ctx.fillStyle = '#4a7c8a'
ctx.beginPath()
ctx.arc(markerX, lineY, 5, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#fff'
ctx.beginPath()
ctx.arc(markerX, lineY, 2, 0, Math.PI * 2)
ctx.fill()
// Aktuelles Jahr über dem Marker
const currentYear = 2025 + currentTick
ctx.fillStyle = '#4a7c8a'
ctx.font = 'bold 10px Inter, system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${currentYear}`, markerX, lineY - 8)
// Event-Marker auf der Linie (kleine Punkte)
for (const ev of events) {
if (ev.tick > currentTick) continue
const ex = lineX0 + (ev.tick / totalTicks) * (lineX1 - lineX0)
ctx.fillStyle = ev.severity === 'danger' ? '#c0503c' : ev.severity === 'warning' ? '#c4a35a' : ev.severity === 'success' ? '#5a8a5e' : '#8a8a8a'
ctx.beginPath()
ctx.arc(ex, lineY - 8, 2, 0, Math.PI * 2)
ctx.fill()
}
}
// ============================================================
// DRAW HELPERS
// ============================================================
private drawCloud(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, color: string): void {
ctx.fillStyle = color
ctx.beginPath()
ctx.ellipse(x, y, 24*s, 9*s, 0, 0, Math.PI*2); ctx.fill()
ctx.beginPath()
ctx.ellipse(x-13*s, y+2*s, 17*s, 7*s, 0, 0, Math.PI*2); ctx.fill()
ctx.beginPath()
ctx.ellipse(x+14*s, y+2*s, 18*s, 7*s, 0, 0, Math.PI*2); ctx.fill()
}
private drawTree(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, stress: number): void {
// Bei Klimastress: Bäume werden bräunlicher
const leafG = 130 - stress * 40
const leafR = 90 + stress * 60
const leafB = 80 - stress * 30
const shadeG = 100 - stress * 35
const shadeR = 70 + stress * 55
const shadeB = 60 - stress * 25
ctx.fillStyle = '#5a4a3a'
ctx.fillRect(x - 1.5*s, y - 9*s, 3*s, 9*s)
ctx.fillStyle = `rgb(${shadeR},${shadeG},${shadeB})`
ctx.beginPath()
ctx.arc(x - 4*s, y - 10*s, 6*s, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(x + 4*s, y - 10*s, 6*s, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = `rgb(${leafR},${leafG},${leafB})`
ctx.beginPath()
ctx.arc(x, y - 14*s, 7*s, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(x - 3*s, y - 11*s, 5*s, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(x + 3*s, y - 11*s, 5*s, 0, Math.PI * 2)
ctx.fill()
}
private drawHouse(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, flooded: boolean, greenRoof: boolean): void {
if (flooded) {
ctx.globalAlpha = 0.6
ctx.fillStyle = '#a08878'
ctx.fillRect(x - 7*s, y - 8*s, 14*s, 8*s)
ctx.fillStyle = '#5a4a3a'
ctx.fillRect(x - 7*s, y - 4*s, 14*s, 4*s)
ctx.globalAlpha = 1
return
}
// Wand
ctx.fillStyle = '#e8d8b8'
ctx.fillRect(x - 8*s, y - 14*s, 16*s, 14*s)
// Dach
if (greenRoof) {
ctx.fillStyle = '#6aa86e'
} else {
ctx.fillStyle = '#b06a5a'
}
ctx.beginPath()
ctx.moveTo(x - 10*s, y - 14*s)
ctx.lineTo(x, y - 22*s)
ctx.lineTo(x + 10*s, y - 14*s)
ctx.closePath()
ctx.fill()
// Fenster
ctx.fillStyle = '#a8c8d0'
ctx.fillRect(x - 3*s, y - 11*s, 6*s, 5*s)
// Tür
ctx.fillStyle = '#6a4a3a'
ctx.fillRect(x - 2*s, y - 5*s, 4*s, 5*s)
}
private drawSolar(ctx: CanvasRenderingContext2D, x: number, y: number): void {
const tilt = -0.3
ctx.save()
ctx.translate(x, y)
ctx.rotate(tilt)
ctx.fillStyle = 'rgba(255,220,150,0.3)'
ctx.fillRect(-10, -12, 20, 12)
ctx.fillStyle = '#3a4a6a'
ctx.fillRect(-8, -10, 16, 8)
ctx.strokeStyle = '#5a6a8a'
ctx.lineWidth = 0.5
for (let i = -6; i <= 6; i += 4) {
ctx.beginPath()
ctx.moveTo(i, -10); ctx.lineTo(i, -2)
ctx.stroke()
}
ctx.beginPath()
ctx.moveTo(-8, -6); ctx.lineTo(8, -6)
ctx.stroke()
ctx.restore()
ctx.fillStyle = '#5a5a5a'
ctx.fillRect(x - 1, y - 8, 2, 8)
}
private drawWind(ctx: CanvasRenderingContext2D, x: number, y: number, t: number): void {
ctx.fillStyle = '#e0dcd0'
ctx.fillRect(x - 1.5, y - 30, 3, 30)
ctx.beginPath()
ctx.arc(x, y - 30, 2.5, 0, Math.PI * 2)
ctx.fill()
const angle = t * 1.8
for (let i = 0; i < 3; i++) {
const a = angle + (i / 3) * Math.PI * 2
ctx.save()
ctx.translate(x, y - 30)
ctx.rotate(a)
ctx.fillStyle = '#f0ece0'
ctx.beginPath()
ctx.ellipse(0, -8, 1.2, 10, 0, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
}
private drawDike(ctx: CanvasRenderingContext2D, x: number, groundY: number): void {
ctx.fillStyle = '#8a7a6a'
ctx.beginPath()
ctx.moveTo(x - 12, groundY)
ctx.lineTo(x - 6, groundY - 14)
ctx.lineTo(x + 6, groundY - 14)
ctx.lineTo(x + 12, groundY)
ctx.closePath()
ctx.fill()
ctx.fillStyle = '#6a5a4a'
ctx.fillRect(x - 7, groundY - 16, 14, 2)
}
private drawSeaWall(ctx: CanvasRenderingContext2D, x: number, topY: number, baseY: number): void {
ctx.fillStyle = '#a8a8a8'
ctx.fillRect(x - 4, topY, 8, baseY - topY + 10)
ctx.fillStyle = '#888'
ctx.fillRect(x - 5, topY, 10, 3)
}
private drawBird(ctx: CanvasRenderingContext2D, b: Bird): void {
const wing = Math.sin(b.wingPhase) * 0.5
ctx.globalAlpha = 0.5
ctx.strokeStyle = '#3a3a3a'
ctx.lineWidth = 1.3
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(b.x - b.size, b.y - wing * b.size)
ctx.quadraticCurveTo(b.x, b.y + 1.5, b.x + b.size, b.y - wing * b.size)
ctx.stroke()
ctx.globalAlpha = 1
}
private drawBgShip(ctx: CanvasRenderingContext2D, x: number, y: number, s: number): void {
ctx.globalAlpha = 0.4
ctx.fillStyle = '#6a5a5a'
ctx.beginPath()
ctx.moveTo(x - 14 * s, y)
ctx.lineTo(x - 11 * s, y + 5 * s)
ctx.lineTo(x + 11 * s, y + 5 * s)
ctx.lineTo(x + 14 * s, y)
ctx.closePath()
ctx.fill()
ctx.strokeStyle = '#4a4a4a'
ctx.lineWidth = 0.8
ctx.beginPath()
ctx.moveTo(x, y); ctx.lineTo(x, y - 14 * s)
ctx.stroke()
ctx.fillStyle = '#e8e3d8'
ctx.beginPath()
ctx.moveTo(x + 1, y - 13 * s)
ctx.lineTo(x + 9 * s, y - 2 * s)
ctx.lineTo(x + 1, y - 1 * s)
ctx.closePath()
ctx.fill()
ctx.globalAlpha = 1
}
private drawLeaves(ctx: CanvasRenderingContext2D, blend: number, sceneH: number): void {
// Sehr dezent — nur 4 Blätter
ctx.globalAlpha = blend * 0.3
for (let i = 0; i < 4; i++) {
const lx = (i * 173 + this.t * 6) % this.W
const ly = ((this.t * 8 + i * 89) % (sceneH * 0.7))
ctx.fillStyle = i % 2 ? '#c87a3a' : '#a85a2a'
ctx.beginPath()
ctx.ellipse(lx, ly, 1.5, 0.8, this.t + i, 0, Math.PI * 2)
ctx.fill()
}
ctx.globalAlpha = 1
}
private drawSnow(ctx: CanvasRenderingContext2D, blend: number, sceneH: number): void {
// Sehr dezent — nur 8 Flocken
ctx.globalAlpha = Math.min(1, blend * 1.5) * 0.3
ctx.fillStyle = '#fff'
for (let i = 0; i < 8; i++) {
const sx = (i * 119 + this.t * 4) % this.W
const sy = ((this.t * 10 + i * 89) % (sceneH * 0.85))
ctx.beginPath()
ctx.arc(sx + Math.sin(this.t + i) * 3, sy, 1, 0, Math.PI * 2)
ctx.fill()
}
ctx.globalAlpha = 1
}
}
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
/**
* SIM-05: Treibhauseffekt-Simulator — LOGIK
*
* Vereinfachtes Klimamodell:
* - Sonneneinstrahlung (konstant ~1361 W/m²)
* - Albedo (Reflexion, ~0.3)
* - CO₂-Konzentration beeinflusst Treibhauseffekt
* - Ergebnis: Gleichgewichtstemperatur der Erde
*
* Gezielt gegen Fehlkonzept: "Ozonloch = Klimawandel"
* (Schuler 2011, Reinfried et al. 2010)
*
* Didaktischer Ablauf: Predict → Observe → Explain
*/
import { Simulation, SimulationMeta } from '@core/simulation'
const META: SimulationMeta = {
id: 'sim-05',
name: 'Treibhauseffekt-Simulator',
educationLevels: [5, 6, 7, 8], // AT: 1.4. Kl. MS, DE: 5.8., CH: Zyklus 3
primaryLevel: 5, // Primär für AT 1. Klasse MS (= 5. Schulstufe)
kompetenzbereich: 'Leben und Wirtschaften im Hinblick auf nachhaltige Ernährung',
lernziele: [
'Grundprinzip des Treibhauseffekts erklären können',
'Zusammenhang zwischen CO₂-Konzentration und Temperatur verstehen',
'Treibhauseffekt vom Ozonloch unterscheiden können',
],
basiskonzepte: ['Veränderung und Wandel', 'Maßstabsebenen und Raum'],
requiresReading: true, // Text-basierte Reflexionsfragen
dpiMinuten: 20,
typ: 'sachsimulation',
tier: 1,
}
/** Physikalische Konstanten (vereinfacht für Schulniveau) */
const SOLAR_CONSTANT = 1361 // W/m², Solarkonstante
const STEFAN_BOLTZMANN = 5.67e-8 // W/(m²·K⁴)
const PRE_INDUSTRIAL_CO2 = 280 // ppm
const CURRENT_CO2 = 425 // ppm (ca. 2026)
/**
* Berechnet die Gleichgewichtstemperatur der Erde
* basierend auf einem vereinfachten Strahlungsmodell.
*
* Ohne Treibhauseffekt: ~-18°C
* Mit natürlichem Treibhauseffekt (280 ppm): ~15°C
* Aktuell (425 ppm): ~16.1°C
*/
export function computeTemperature(co2ppm: number, albedo: number): number {
// Absorbierte Sonnenstrahlung pro m²
const absorbed = (SOLAR_CONSTANT / 4) * (1 - albedo)
// Treibhauseffekt als logarithmische Funktion der CO₂-Konzentration
// ΔT ≈ λ * ln(CO₂/CO₂_ref) — vereinfacht nach Arrhenius
const climateSensitivity = 3.0 // °C pro Verdoppelung CO₂
const deltaT = climateSensitivity * Math.log2(co2ppm / PRE_INDUSTRIAL_CO2)
// Basistemperatur ohne Treibhauseffekt
const tempNoGreenhouse = Math.pow(absorbed / STEFAN_BOLTZMANN, 0.25) - 273.15 // ~-18°C
// Natürlicher Treibhauseffekt ~33°C
const naturalGreenhouse = 33
return tempNoGreenhouse + naturalGreenhouse + deltaT
}
/**
* Berechnet Folgen der Temperaturänderung (vereinfacht)
*/
export function computeEffects(tempC: number): {
seaLevelRise: number // cm über vorindustriellem Niveau
arcticIce: number // % verbleibend (100% = vorindustriell)
extremeEvents: number // Faktor (1 = normal, 2 = doppelt so häufig)
} {
const deltaT = tempC - 15 // Differenz zum vorindustriellen Mittel
return {
seaLevelRise: Math.max(0, deltaT * 15), // ~15cm pro °C (vereinfacht)
arcticIce: Math.max(0, Math.min(100, 100 - deltaT * 12)),
extremeEvents: Math.max(1, 1 + deltaT * 0.3),
}
}
export class TreibhausSimulation extends Simulation {
constructor() {
super(META)
// Startwerte setzen
const ranges = this.getVariableRanges()
for (const [key, range] of Object.entries(ranges)) {
this.state.variables[key] = range.default
}
}
getVariableRanges() {
return {
co2: {
min: 200,
max: 1000,
default: CURRENT_CO2,
unit: 'ppm',
label: 'CO₂-Konzentration',
},
albedo: {
min: 0.1,
max: 0.6,
default: 0.3,
unit: '',
label: 'Albedo (Reflexion)',
},
}
}
compute() {
const co2 = this.getVariable('co2')
const albedo = this.getVariable('albedo')
const temp = computeTemperature(co2, albedo)
const effects = computeEffects(temp)
const results = {
temperature: Math.round(temp * 10) / 10,
seaLevelRise: Math.round(effects.seaLevelRise),
arcticIce: Math.round(effects.arcticIce),
extremeEvents: Math.round(effects.extremeEvents * 10) / 10,
tempWithoutGreenhouse: Math.round((Math.pow((SOLAR_CONSTANT / 4) * (1 - albedo) / STEFAN_BOLTZMANN, 0.25) - 273.15) * 10) / 10,
}
this.state.results = results
return results
}
protected onVariableChange(_name: string, _value: number): void {
this.compute()
}
}
+345
View File
@@ -0,0 +1,345 @@
/**
* SIM-05: Treibhauseffekt — Canvas Renderer
*
* Visualisiert:
* - Sonne → Sonnenstrahlen → Erdoberfläche
* - Wärmestrahlung von der Erde nach oben
* - CO₂-Schicht fängt Wärmestrahlung ab (je dicker, desto mehr)
* - Temperaturanzeige
* - Auswirkungen (Meeresspiegel, Eis, Extremereignisse)
*
* Stil: Skandinavisch minimal — gedeckte Farben, sanfte Animationen
*/
import { TreibhausSimulation, computeTemperature, computeEffects } from './logic'
interface Particle {
x: number; y: number; vx: number; vy: number
type: 'solar' | 'heat' | 'reflected'
life: number; maxLife: number
absorbed: boolean
}
export class TreibhausRenderer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private sim: TreibhausSimulation
private W = 0
private H = 0
private t = 0
private particles: Particle[] = []
private animId = 0
// Layout zones (ratios of height)
private sunY = 0
private atmoTop = 0
private atmoBot = 0
private groundY = 0
private seaY = 0
// Colors — skandinavisch
private col = {
sky: '#dde3da',
space: '#c5cdc2',
sun: '#e8c84a',
sunGlow: 'rgba(232,200,74,0.15)',
solar: '#e8c84a',
heat: '#c07a6b',
reflected:'#8ab0b8',
co2: 'rgba(180,160,130,VAR)', // opacity varies
ground: '#8a9a82',
groundDark:'#6a7a62',
sea: '#9ab5b8',
ice: '#d8e0dc',
text: '#1a1a1a',
muted: '#6a6a6a',
}
constructor(container: HTMLElement, sim: TreibhausSimulation) {
this.sim = sim
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:12px;'
container.appendChild(this.canvas)
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('Canvas not supported')
this.ctx = ctx
this.resize()
window.addEventListener('resize', () => this.resize())
}
private resize(): void {
const rect = this.canvas.parentElement!.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.W = rect.width
this.H = Math.min(rect.width * 0.65, 500)
this.canvas.width = this.W * dpr
this.canvas.height = this.H * dpr
this.canvas.style.height = this.H + 'px'
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
// Layout zones
this.sunY = this.H * 0.08
this.atmoTop = this.H * 0.25
this.atmoBot = this.H * 0.45
this.groundY = this.H * 0.7
this.seaY = this.H * 0.75
}
start(): void {
const loop = () => {
this.t += 0.016
this.update()
this.draw()
this.animId = requestAnimationFrame(loop)
}
loop()
}
stop(): void {
cancelAnimationFrame(this.animId)
}
private update(): void {
const co2 = this.sim.getVariable('co2')
const absorptionRate = Math.min(0.9, (co2 - 200) / 800 * 0.85)
// Spawn solar particles
if (Math.random() < 0.15) {
this.particles.push({
x: this.W * 0.3 + Math.random() * this.W * 0.4,
y: 0,
vx: (Math.random() - 0.5) * 0.3,
vy: 1.5 + Math.random() * 0.5,
type: 'solar',
life: 0, maxLife: 300,
absorbed: false,
})
}
// Update particles
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i]
p.x += p.vx
p.y += p.vy
p.life++
if (p.type === 'solar' && p.y >= this.groundY) {
// Solar hits ground → becomes heat radiation going up
p.type = 'heat'
p.vy = -(1.0 + Math.random() * 0.5)
p.vx = (Math.random() - 0.5) * 0.8
p.y = this.groundY - 2
}
if (p.type === 'heat' && !p.absorbed && p.y <= this.atmoBot && p.y >= this.atmoTop) {
// Heat in CO₂ layer — chance of absorption
if (Math.random() < absorptionRate * 0.03) {
p.absorbed = true
p.vy = 0.8 + Math.random() * 0.5 // reflected back down
p.vx = (Math.random() - 0.5) * 1.2
p.type = 'reflected'
}
}
// Remove particles that leave the canvas
if (p.y < -10 || p.y > this.H + 10 || p.x < -20 || p.x > this.W + 20 || p.life > p.maxLife) {
this.particles.splice(i, 1)
}
}
// Cap particles
if (this.particles.length > 120) {
this.particles.splice(0, this.particles.length - 120)
}
}
private draw(): void {
const { ctx, W, H } = this
const co2 = this.sim.getVariable('co2')
const albedo = this.sim.getVariable('albedo')
const temp = computeTemperature(co2, albedo)
const effects = computeEffects(temp)
const co2Opacity = Math.min(0.4, (co2 - 200) / 800 * 0.35)
ctx.clearRect(0, 0, W, H)
// Background — space/sky gradient
const skyGrad = ctx.createLinearGradient(0, 0, 0, this.groundY)
skyGrad.addColorStop(0, this.col.space)
skyGrad.addColorStop(0.3, this.col.sky)
skyGrad.addColorStop(1, '#c8d4c6')
ctx.fillStyle = skyGrad
ctx.fillRect(0, 0, W, this.groundY)
// Sun
const sunX = W * 0.8
const sunR = 28
// Glow
const glow = ctx.createRadialGradient(sunX, this.sunY, sunR * 0.5, sunX, this.sunY, sunR * 3)
glow.addColorStop(0, 'rgba(232,200,74,0.3)')
glow.addColorStop(1, 'rgba(232,200,74,0)')
ctx.fillStyle = glow
ctx.fillRect(sunX - sunR * 3, this.sunY - sunR * 3, sunR * 6, sunR * 6)
// Sun disc
ctx.fillStyle = this.col.sun
ctx.beginPath()
ctx.arc(sunX, this.sunY, sunR, 0, Math.PI * 2)
ctx.fill()
// CO₂ layer
ctx.fillStyle = `rgba(180,160,130,${co2Opacity})`
ctx.fillRect(0, this.atmoTop, W, this.atmoBot - this.atmoTop)
// CO₂ label
ctx.fillStyle = `rgba(100,80,60,${Math.min(0.6, co2Opacity + 0.15)})`
ctx.font = '11px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`CO₂: ${co2} ppm`, 12, this.atmoTop + 16)
// Atmosphere borders (subtle)
ctx.strokeStyle = `rgba(150,130,100,${co2Opacity * 0.5})`
ctx.lineWidth = 0.5
ctx.setLineDash([4, 4])
ctx.beginPath()
ctx.moveTo(0, this.atmoTop); ctx.lineTo(W, this.atmoTop)
ctx.moveTo(0, this.atmoBot); ctx.lineTo(W, this.atmoBot)
ctx.stroke()
ctx.setLineDash([])
// Ground
ctx.fillStyle = this.col.ground
ctx.fillRect(0, this.groundY, W, H - this.groundY)
// Ground detail — hills
ctx.fillStyle = this.col.groundDark
ctx.beginPath()
ctx.moveTo(0, this.groundY)
for (let x = 0; x <= W; x += 5) {
ctx.lineTo(x, this.groundY - Math.sin(x * 0.02 + 1) * 6 - Math.sin(x * 0.007) * 10)
}
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath()
ctx.fill()
// Sea (rises with temperature)
const seaRise = effects.seaLevelRise * 0.15
const seaLevel = this.seaY - seaRise
ctx.fillStyle = this.col.sea
ctx.globalAlpha = 0.7
ctx.fillRect(W * 0.55, seaLevel, W * 0.45, H - seaLevel)
ctx.globalAlpha = 1
// Ice cap (shrinks with temperature)
const iceWidth = W * 0.12 * (effects.arcticIce / 100)
if (iceWidth > 2) {
ctx.fillStyle = this.col.ice
ctx.beginPath()
ctx.ellipse(W * 0.15, this.groundY - 8, iceWidth, 8, 0, 0, Math.PI * 2)
ctx.fill()
}
// Small trees
for (let i = 0; i < 5; i++) {
const tx = W * 0.05 + i * W * 0.09
this.drawTree(ctx, tx, this.groundY - 12, 0.5 + Math.sin(i) * 0.15)
}
// Small houses
this.drawHouse(ctx, W * 0.35, this.groundY - 10, 0.7)
this.drawHouse(ctx, W * 0.42, this.groundY - 8, 0.5)
// Particles
for (const p of this.particles) {
ctx.globalAlpha = Math.max(0, 1 - p.life / p.maxLife) * 0.7
if (p.type === 'solar') {
ctx.fillStyle = this.col.solar
ctx.beginPath()
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
ctx.fill()
} else if (p.type === 'heat') {
ctx.fillStyle = this.col.heat
ctx.beginPath()
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2)
ctx.fill()
} else if (p.type === 'reflected') {
ctx.fillStyle = this.col.heat
ctx.globalAlpha *= 0.8
ctx.beginPath()
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
ctx.fill()
}
ctx.globalAlpha = 1
}
// Temperature display
this.drawThermometer(ctx, W - 55, this.groundY * 0.5, temp)
// Info panel bottom
ctx.fillStyle = 'rgba(255,255,255,0.75)'
ctx.fillRect(0, H - 50, W, 50)
ctx.fillStyle = this.col.text
ctx.font = 'bold 13px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`🌡️ ${temp.toFixed(1)}°C`, 15, H - 20)
ctx.font = '11px Inter, sans-serif'
ctx.fillStyle = this.col.muted
ctx.fillText(`Meeresspiegel: +${effects.seaLevelRise.toFixed(0)} cm`, W * 0.25, H - 20)
ctx.fillText(`Arktis-Eis: ${effects.arcticIce.toFixed(0)}%`, W * 0.52, H - 20)
ctx.fillText(`Extremereignisse: ×${effects.extremeEvents.toFixed(1)}`, W * 0.75, H - 20)
}
private drawThermometer(ctx: CanvasRenderingContext2D, x: number, y: number, temp: number): void {
const h = 80
const w = 14
const fill = Math.max(0, Math.min(1, (temp + 20) / 50)) // -20..+30°C range
// Background
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.beginPath()
ctx.roundRect(x - w/2, y - h/2, w, h, 7)
ctx.fill()
ctx.strokeStyle = 'rgba(0,0,0,0.1)'
ctx.lineWidth = 1
ctx.stroke()
// Fill
const fillH = h * fill * 0.85
const fillColor = temp > 17 ? '#c07a6b' : temp > 15 ? '#c4a35a' : '#4a7c8a'
ctx.fillStyle = fillColor
ctx.beginPath()
ctx.roundRect(x - w/2 + 2, y + h/2 - fillH - 2, w - 4, fillH, 4)
ctx.fill()
// Temperature text
ctx.fillStyle = this.col.text
ctx.font = 'bold 11px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${temp.toFixed(1)}°`, x, y - h/2 - 6)
}
private drawTree(ctx: CanvasRenderingContext2D, x: number, y: number, s: number): void {
ctx.fillStyle = '#5a5a4a'
ctx.fillRect(x - 1.5 * s, y, 3 * s, 10 * s)
ctx.fillStyle = '#6a8a5e'
ctx.beginPath()
ctx.arc(x, y - 2 * s, 8 * s, 0, Math.PI * 2)
ctx.fill()
}
private drawHouse(ctx: CanvasRenderingContext2D, x: number, y: number, s: number): void {
// Wall
ctx.fillStyle = '#d8c8b0'
ctx.fillRect(x - 8 * s, y - 10 * s, 16 * s, 12 * s)
// Roof
ctx.fillStyle = '#c07a6b'
ctx.beginPath()
ctx.moveTo(x - 10 * s, y - 10 * s)
ctx.lineTo(x, y - 18 * s)
ctx.lineTo(x + 10 * s, y - 10 * s)
ctx.closePath()
ctx.fill()
// Window
ctx.fillStyle = '#a8c8d0'
ctx.fillRect(x - 3 * s, y - 7 * s, 6 * s, 5 * s)
}
}