/* ARBEITSSCHIRM — Live-Wetter-Engine (Canvas-Partikel) Vier Modi: wind · regen · sturm · schnee. Sanfte Übergänge per Lerp. Respektiert prefers-reduced-motion (statischer, ruhiger Zustand). */ (function () { const MODES = { wind: { rain: 0.00, rainSpeed: 15, wind: 13.0, snow: 0.00, gust: 1.00, lightning: 0, sun: 0 }, regen: { rain: 1.00, rainSpeed: 22, wind: 1.8, snow: 0.00, gust: 0.10, lightning: 0, sun: 0 }, sturm: { rain: 1.00, rainSpeed: 26, wind: 7.0, snow: 0.00, gust: 0.55, lightning: 0.011, sun: 0 }, schnee: { rain: 0.00, rainSpeed: 0, wind: 1.8, snow: 1.00, gust: 0.30, lightning: 0, sun: 0 }, sonne: { rain: 0.00, rainSpeed: 0, wind: 0.8, snow: 0.00, gust: 0.00, lightning: 0, sun: 1 }, }; const MAX_MOTES = 90; const MAX_RAIN = 360; const MAX_SNOW = 240; const MAX_GUST = 120; const lerp = (a, b, t) => a + (b - a) * t; const rnd = (a, b) => a + Math.random() * (b - a); function createSystem(canvas, getMode, opts) { opts = opts || {}; const global = !!opts.global; // Colours: the global overlay must read on BOTH light and dark bands, // so rain/gust use a mid steel and snow carries a soft shadow halo. const COL = global ? { rain: 'rgba(108,124,142,0.46)', gust: 'rgba(128,140,154,0.09)', snow: 'rgba(255,255,255,0.92)' } : { rain: 'rgba(176,196,214,0.42)', gust: 'rgba(204,216,228,', snow: 'rgba(255,255,255,0.85)' }; const ctx = canvas.getContext('2d'); let W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2); let raf = 0, running = true; const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; // current (animated) state — lerps toward the active mode target const cur = { rain: 0, rainSpeed: 18, wind: 4, snow: 0, gust: 0.4, lightning: 0, sun: 0 }; const rain = Array.from({ length: MAX_RAIN }, () => ({ x: 0, y: 0, l: 0, s: 0 })); const snow = Array.from({ length: MAX_SNOW }, () => ({ x: 0, y: 0, r: 0, p: 0, sp: 0 })); const gust = Array.from({ length: MAX_GUST }, () => ({ x: 0, y: 0, l: 0, s: 0, a: 0 })); const motes = Array.from({ length: MAX_MOTES }, () => ({ x: 0, y: 0, r: 0, p: 0, sp: 0, a: 0 })); let flash = 0, boltPts = null, boltLife = 0, tSun = 0; function resize() { const rect = canvas.getBoundingClientRect(); W = rect.width; H = rect.height; canvas.width = Math.max(1, Math.floor(W * dpr)); canvas.height = Math.max(1, Math.floor(H * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } function seedRain(d) { d.x = rnd(-40, W + 40); d.y = rnd(0, H); d.l = rnd(12, 30); d.s = rnd(0.7, 1.25); } function seedSnow(f) { f.x = rnd(0, W); f.y = rnd(0, H); f.r = rnd(1.1, 3.2); f.p = rnd(0, Math.PI * 2); f.sp = rnd(0.5, 1.4); } function seedGust(g) { g.x = rnd(-80, W); g.y = rnd(0, H); g.l = rnd(90, 320); g.s = rnd(0.9, 2.0); g.a = rnd(0.05, 0.18); } function seedMote(m) { m.x = rnd(0, W); m.y = rnd(0, H); m.r = rnd(0.6, 2.0); m.p = rnd(0, Math.PI * 2); m.sp = rnd(0.12, 0.45); m.a = rnd(0.18, 0.6); } function seedAll() { rain.forEach(seedRain); snow.forEach(seedSnow); gust.forEach(seedGust); motes.forEach(seedMote); } function makeBolt() { const x0 = rnd(W * 0.2, W * 0.8); const pts = [{ x: x0, y: 0 }]; let x = x0, y = 0; while (y < H * rnd(0.5, 0.85)) { y += rnd(24, 60); x += rnd(-44, 44); pts.push({ x, y }); } return pts; } function step() { if (!running) return; const tgt = MODES[getMode()] || MODES.sturm; // Density (rain/snow/gust amount) is set INSTANTLY — switching feels immediate. // Only motion params (wind/speed) ease a touch so it still looks natural. const kMove = 0.16; cur.rain = tgt.rain; cur.snow = tgt.snow; cur.gust = tgt.gust; cur.wind = lerp(cur.wind, tgt.wind, kMove); cur.rainSpeed = lerp(cur.rainSpeed, tgt.rainSpeed || 18, kMove); cur.lightning = tgt.lightning; cur.sun = tgt.sun || 0; ctx.clearRect(0, 0, W, H); // --- sun: warm glow + god rays + drifting backlit dust motes --- if (cur.sun > 0) { tSun += 0.01; tSun += 0.01; // origin sits OUTSIDE the top-right corner so no bright hotspot shows const gx = W * 1.04, gy = -H * 0.14; const grd = ctx.createRadialGradient(gx, gy, 0, gx, gy, Math.max(W, H) * 1.05); grd.addColorStop(0, 'rgba(255,206,128,' + (global ? 0.09 : 0.13) + ')'); grd.addColorStop(0.45, 'rgba(255,190,110,' + (global ? 0.03 : 0.05) + ')'); grd.addColorStop(1, 'rgba(255,190,110,0)'); ctx.fillStyle = grd; ctx.fillRect(0, 0, W, H); // god rays: soft warm shafts fanning down-left from the off-screen sun const rayLen = Math.max(W, H) * 1.9; const RAYS = 14; ctx.save(); ctx.translate(gx, gy); for (let i = 0; i < RAYS; i++) { const ang = 2.35 + (i - RAYS / 2) * 0.12 + Math.sin(tSun * 0.5 + i) * 0.01; const shimmer = 0.5 + 0.5 * Math.sin(tSun * 1.3 + i * 1.7); const a = (global ? 0.014 : 0.024) * (0.4 + 0.6 * shimmer); const wNear = 6, wFar = 34 + 16 * shimmer; ctx.save(); ctx.rotate(ang); const rg = ctx.createLinearGradient(0, 0, rayLen, 0); rg.addColorStop(0, 'rgba(255,214,150,' + a.toFixed(3) + ')'); rg.addColorStop(1, 'rgba(255,214,150,0)'); ctx.fillStyle = rg; ctx.beginPath(); ctx.moveTo(0, -wNear); ctx.lineTo(rayLen, -wFar); ctx.lineTo(rayLen, wFar); ctx.lineTo(0, wNear); ctx.closePath(); ctx.fill(); ctx.restore(); } ctx.restore(); for (let i = 0; i < MAX_MOTES; i++) { const m = motes[i]; m.p += 0.012; m.y -= m.sp; m.x += Math.sin(m.p) * 0.5 + cur.wind * 0.15; if (m.y < -6) { m.y = H + 6; m.x = rnd(0, W); } if (m.x > W + 6) m.x = -6; else if (m.x < -6) m.x = W + 6; ctx.fillStyle = 'rgba(255,214,150,' + m.a.toFixed(2) + ')'; ctx.beginPath(); ctx.arc(m.x, m.y, m.r, 0, Math.PI * 2); ctx.fill(); } } // --- wind gust streaks (faint, behind) --- const gN = Math.floor(MAX_GUST * cur.gust); ctx.lineCap = 'round'; for (let i = 0; i < gN; i++) { const g = gust[i]; g.x += (cur.wind * 2.8 + 4) * g.s; if (g.x - g.l > W + 80) seedGust(g), (g.x = -80); ctx.strokeStyle = (global ? 'rgba(128,140,154,' : 'rgba(204,216,228,') + g.a.toFixed(3) + ')'; ctx.lineWidth = 1 + g.a * 4; ctx.beginPath(); ctx.moveTo(g.x, g.y); ctx.lineTo(g.x - g.l, g.y - cur.wind * 1.2); ctx.stroke(); } // --- rain --- const rN = Math.floor(MAX_RAIN * cur.rain); ctx.strokeStyle = COL.rain; ctx.lineWidth = global ? 1.25 : 1.15; ctx.beginPath(); for (let i = 0; i < MAX_RAIN; i++) { const d = rain[i]; d.y += cur.rainSpeed * d.s; d.x += cur.wind * d.s; if (d.y > H + 20) { d.y = -20; d.x = rnd(-40, W + 40); } if (i >= rN) continue; ctx.moveTo(d.x, d.y); ctx.lineTo(d.x - cur.wind * 0.9, d.y - d.l); } ctx.stroke(); // --- snow (always advancing so the field is pre-filled across the // whole height; only the active count is actually drawn) --- const sN = Math.floor(MAX_SNOW * cur.snow); for (let i = 0; i < MAX_SNOW; i++) { const f = snow[i]; f.p += 0.01; f.y += f.sp + 0.4; f.x += Math.sin(f.p) * 0.8 + cur.wind * 0.35; if (f.y > H + 6) { f.y = -6; f.x = rnd(0, W); } if (f.x > W + 6) f.x = -6; else if (f.x < -6) f.x = W + 6; if (i >= sN) continue; ctx.fillStyle = COL.snow; if (global) { ctx.shadowColor = 'rgba(40,48,60,0.55)'; ctx.shadowBlur = 2; } ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2); ctx.fill(); } if (global) ctx.shadowBlur = 0; // --- lightning (sturm only) --- if (cur.lightning > 0 && Math.random() < cur.lightning) { flash = 1; boltPts = makeBolt(); boltLife = 7; } if (flash > 0.01) { ctx.fillStyle = 'rgba(232,236,245,' + (flash * 0.5).toFixed(3) + ')'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = 'rgba(200,36,58,' + (flash * 0.14).toFixed(3) + ')'; ctx.fillRect(0, 0, W, H); flash *= 0.82; } if (boltLife > 0 && boltPts) { ctx.strokeStyle = 'rgba(244,247,255,' + Math.min(1, boltLife / 7).toFixed(2) + ')'; ctx.lineWidth = 2.2; ctx.shadowColor = 'rgba(220,230,255,0.9)'; ctx.shadowBlur = 16; ctx.beginPath(); ctx.moveTo(boltPts[0].x, boltPts[0].y); for (let i = 1; i < boltPts.length; i++) ctx.lineTo(boltPts[i].x, boltPts[i].y); ctx.stroke(); ctx.shadowBlur = 0; boltLife -= 1; } raf = requestAnimationFrame(step); } function drawStatic() { // reduced-motion: one calm frame of faint diagonal streaks ctx.clearRect(0, 0, W, H); ctx.strokeStyle = 'rgba(176,196,214,0.16)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < 70; i++) { const x = rnd(0, W), y = rnd(0, H); ctx.moveTo(x, y); ctx.lineTo(x - 5, y - 16); } ctx.stroke(); } resize(); // sets W/H first seedAll(); // THEN scatter particles across the real canvas size const onResize = () => resize(); window.addEventListener('resize', onResize); if (reduced) drawStatic(); else raf = requestAnimationFrame(step); return { destroy() { running = false; cancelAnimationFrame(raf); window.removeEventListener('resize', onResize); }, }; } function WeatherCanvas({ mode = 'sturm', global = false, style }) { const ref = React.useRef(null); const modeRef = React.useRef(mode); modeRef.current = mode; React.useEffect(() => { const sys = createSystem(ref.current, () => modeRef.current, { global }); return () => sys.destroy(); }, []); const base = global ? { position: 'fixed', inset: 0, width: '100vw', height: '100vh', pointerEvents: 'none', zIndex: 40 } : { position: 'absolute', inset: 0, width: '100%', height: '100%' }; return