// Canvas2D weather engine — the fallback when WebGL2 isn't available. This is // the original hand-drawn renderer wrapped as an engine factory; weather.js // owns the toggle, storage and the PeteWeather API and just calls // set/start/stop here. New GPU-only variants (hail, haze, wind, aurora) alias // to their nearest 2D look so the fallback never renders a blank page. (function () { window.PeteWeatherEngines = window.PeteWeatherEngines || {}; window.PeteWeatherEngines.canvas2d = function (canvas) { var ctx = canvas.getContext("2d"); if (!ctx) return null; var root = document.documentElement; var W = 0, H = 0, DPR = 1; function resize() { DPR = Math.min(window.devicePixelRatio || 1, 2); W = canvas.clientWidth || window.innerWidth; H = canvas.clientHeight || window.innerHeight; canvas.width = Math.floor(W * DPR); canvas.height = Math.floor(H * DPR); ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } resize(); window.addEventListener("resize", resize); var aliases = { hail: "snow", haze: "motes", wind: "leaves", aurora: "clear" }; var counts = { rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38, snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200 }; var mults = { light: 0.6, medium: 1.0, heavy: 1.4 }; function rand(a, b) { return a + Math.random() * (b - a); } var variant = null; // current rendered variant var intensity = "heavy"; // light | medium | heavy var particles = []; var flash = 0; // lightning flash decay (storm only) var nextBolt = 2; // seconds until next lightning bolt (storm only) function spawn(initial) { var p = {}; p.x = rand(0, W); p.y = initial ? rand(0, H) : rand(-40, -10); p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha if (variant === "rain" || variant === "storm") { p.vy = rand(700, 1100) * p.z; p.vx = -rand(60, 120); p.len = rand(10, 18) * p.z; p.alpha = rand(0.25, 0.55); } else if (variant === "snow") { p.vy = rand(35, 75) * p.z; p.swayAmp = rand(12, 34); p.swayFreq = rand(0.3, 0.8); p.swayPhase = rand(0, Math.PI * 2); p.size = rand(2, 4.6) * p.z; p.alpha = rand(0.65, 0.95); } else if (variant === "clouds") { // Soft puffs drifting across the upper sky. p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45); p.x = initial ? rand(0, W) : -rand(120, 320); p.vx = rand(8, 22); p.size = rand(80, 170) * p.z; p.alpha = rand(0.32, 0.55); p.sprite = Math.floor(rand(0, 3)); } else if (variant === "fog") { // Wide translucent bands creeping sideways. p.y = rand(H * 0.1, H); p.x = initial ? rand(0, W) : -rand(200, 500); p.vx = rand(6, 16); p.w = rand(260, 520); p.h = rand(70, 150); p.alpha = rand(0.05, 0.14); } else if (variant === "petals" || variant === "jacaranda") { p.vy = rand(60, 120); p.swayAmp = rand(20, 50); p.swayFreq = rand(0.4, 0.9); p.swayPhase = rand(0, Math.PI * 2); p.rot = rand(0, Math.PI * 2); p.vrot = rand(-1.2, 1.2); p.size = rand(8, 16) * p.z; p.alpha = rand(0.75, 1.0); if (variant === "jacaranda") { p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple p.sat = rand(55, 75); p.light = rand(70, 82); } else { p.hue = rand(335, 355); // pink (almond/cherry) p.sat = rand(60, 80); p.light = rand(78, 88); } } else if (variant === "motes") { p.vx = rand(-15, 25); p.vy = rand(8, 25); p.size = rand(1.2, 2.8) * p.z; p.alpha = rand(0.35, 0.7); p.twinklePhase = rand(0, Math.PI * 2); p.twinkleFreq = rand(0.5, 1.5); } else if (variant === "leaves") { p.vy = rand(70, 130); p.swayAmp = rand(30, 70); p.swayFreq = rand(0.3, 0.6); p.swayPhase = rand(0, Math.PI * 2); p.rot = rand(0, Math.PI * 2); p.vrot = rand(-2.0, 2.0); p.size = rand(10, 18) * p.z; p.alpha = rand(0.8, 1.0); p.hue = rand(18, 42); // orange→amber→brown p.light = rand(38, 55); } return p; } var night = root.dataset.phase === "night"; function refreshNight() { night = root.dataset.phase === "night"; } function drawRain(p) { // Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker, // more saturated blue plus a slightly thicker line to stay visible. Night // keeps a paler tone so streaks read against the dark palette. if (night) { ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")"; ctx.lineWidth = 1; } else { ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")"; ctx.lineWidth = 1.3; } ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - p.len * 0.12, p.y + p.len); ctx.stroke(); } function drawSnow(p) { // Soft round flake with a faint glow so it reads on light and dark palettes. var col = night ? "235,242,255" : "255,255,255"; ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")"; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); if (!night) { // A thin cool outline keeps white flakes visible against cream backgrounds. ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")"; ctx.lineWidth = 0.6; ctx.stroke(); } } // Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each // cloud reads as one fluffy mass. Built once per palette (day/night) onto // offscreen canvases, then just drifted — cheap to animate. var cloudSprites = null; var cloudSpritesNight = null; // Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a // 0..1 box (x across, y down) with a flattish base around y≈0.66. var cloudShapes = [ [[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18], [0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]], [[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16], [0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]], [[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16], [0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]] ]; function makeCloudSprite(col, shape) { var c = document.createElement("canvas"); var w = 320, h = 224; c.width = w; c.height = h; var cx = c.getContext("2d"); cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud cx.fillStyle = "rgba(" + col + ",1)"; for (var i = 0; i < shape.length; i++) { cx.beginPath(); cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2); cx.fill(); } return c; } function ensureCloudSprites() { if (cloudSprites && cloudSpritesNight === night) return; // Day sky is warm cream so clouds need a cool slate-grey; night uses a pale // tone against the dark palette. var col = night ? "206,216,236" : "150,164,190"; cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); }); cloudSpritesNight = night; } function drawCloud(p) { ensureCloudSprites(); var spr = cloudSprites[p.sprite % cloudSprites.length]; var w = p.size * 2.6; var h = w * (spr.height / spr.width); ctx.globalAlpha = p.alpha; ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h); ctx.globalAlpha = 1; } function drawFog(p) { var col = night ? "150,160,180" : "230,228,224"; var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0); g.addColorStop(0, "rgba(" + col + ",0)"); g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")"); g.addColorStop(1, "rgba(" + col + ",0)"); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2); ctx.fill(); } function drawMoon(cx, cy, mr) { var TAU = Math.PI * 2; ctx.save(); // Clip everything to the lunar disc so shading stays inside the sphere. ctx.beginPath(); ctx.arc(cx, cy, mr, 0, TAU); ctx.clip(); // Spherical body shading — light source upper-right, terminator lower-left. var lx = cx + mr * 0.45, ly = cy - mr * 0.45; var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35); body.addColorStop(0, "rgba(249,251,255,0.97)"); body.addColorStop(0.55, "rgba(214,222,240,0.92)"); body.addColorStop(1, "rgba(140,151,180,0.85)"); ctx.fillStyle = body; ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2); // Limb darkening — fade the edge so the disc reads as a ball, not a coin. var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr); limb.addColorStop(0, "rgba(18,24,46,0)"); limb.addColorStop(1, "rgba(18,24,46,0.4)"); ctx.fillStyle = limb; ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2); ctx.restore(); // Soft outer halo, drawn unclipped around the disc. var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4); halo.addColorStop(0, "rgba(220,230,255,0.28)"); halo.addColorStop(1, "rgba(220,230,255,0)"); ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(cx, cy, mr * 2.4, 0, TAU); ctx.fill(); } function drawClearGlow(t) { // No particles — render a single calm sun (day) or moon (night) glow that // breathes very slowly, plus a few stars at night. var breathe = 0.5 + 0.5 * Math.sin(t * 0.25); var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5; if (night) { // Ambient sky glow around the moon. var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")"); g.addColorStop(1, "rgba(220,230,255,0)"); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); drawMoon(cx, cy, r * 0.16); } else { var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")"); gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")"); gd.addColorStop(1, "rgba(255,214,120,0)"); ctx.fillStyle = gd; ctx.fillRect(0, 0, W, H); } } // Deterministic star field for clear nights (seeded so they don't jitter). var stars = []; function buildStars() { stars = []; var n = 60; for (var i = 0; i < n; i++) { // Cheap LCG-ish spread keyed by index — stable across frames. var sx = ((i * 73 + 11) % 100) / 100; var sy = ((i * 37 + 7) % 100) / 100; stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) }); } } function drawStars(t) { for (var i = 0; i < stars.length; i++) { var s = stars[i]; var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph)); ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")"; ctx.beginPath(); ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2); ctx.fill(); } } function drawPetals(p) { // Five-petal almond/cherry blossom viewed face-on, with a yellow center. var s = p.size; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")"; for (var i = 0; i < 5; i++) { var a = (i / 5) * Math.PI * 2 - Math.PI / 2; var cx = Math.cos(a) * s * 0.32; var cy = Math.sin(a) * s * 0.32; ctx.beginPath(); ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")"; ctx.beginPath(); ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function drawLeaf(p) { var s = p.size; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0); g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")"); g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")"); ctx.fillStyle = g; // Almond/lozenge leaf — pointed at both ends. ctx.beginPath(); ctx.moveTo(-s * 0.55, 0); ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0); ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0); ctx.closePath(); ctx.fill(); // Midrib vein. ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")"; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(-s * 0.5, 0); ctx.lineTo(s * 0.5, 0); ctx.stroke(); // Small stem nub at the base. ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")"; ctx.lineWidth = 1.0; ctx.beginPath(); ctx.moveTo(-s * 0.55, 0); ctx.lineTo(-s * 0.7, -s * 0.04); ctx.stroke(); ctx.restore(); } function drawMote(p, t) { var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase); // Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk // use a deeper saturated honey so the motes stand out against warm cream // and peach backgrounds; alpha also gets a bump. var color, a; if (night) { color = "255,240,180"; a = p.alpha * (0.45 + 0.55 * twinkle); } else { color = "130,80,180"; a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle)); } // Soft glow halo. var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5); g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")"); g.addColorStop(1, "rgba(" + color + ",0)"); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2); ctx.fill(); // Bright core. ctx.fillStyle = "rgba(" + color + "," + a + ")"; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); } var last = performance.now(); var raf = 0; function step(now) { var dt = Math.min(0.05, (now - last) / 1000); last = now; var t = now / 1000; ctx.clearRect(0, 0, W, H); refreshNight(); if (variant === "clear") { drawClearGlow(t); if (night) drawStars(t); raf = requestAnimationFrame(step); return; } // Storm: drive the lightning flash before drawing rain over it. if (variant === "storm") { nextBolt -= dt; if (nextBolt <= 0) { flash = 1; nextBolt = rand(2.5, 7); } if (flash > 0) { ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")"; ctx.fillRect(0, 0, W, H); flash = Math.max(0, flash - dt * 4); // ~0.25s decay } } for (var i = 0; i < particles.length; i++) { var p = particles[i]; if (variant === "rain" || variant === "storm") { p.x += p.vx * dt; p.y += p.vy * dt; if (p.y > H + 20 || p.x < -40) { particles[i] = spawn(false); particles[i].x = rand(0, W + 60); continue; } drawRain(p); } else if (variant === "snow") { p.y += p.vy * dt; var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp; if (p.y > H + 10) { particles[i] = spawn(false); continue; } var ox = p.x; p.x = ox + sxOff; drawSnow(p); p.x = ox; } else if (variant === "clouds") { p.x += p.vx * dt; if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; } drawCloud(p); } else if (variant === "fog") { p.x += p.vx * dt; if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; } drawFog(p); } else if (variant === "motes") { p.x += p.vx * dt; p.y += p.vy * dt; if (p.y > H + 10 || p.x < -10 || p.x > W + 10) { particles[i] = spawn(false); continue; } drawMote(p, t); } else { // Drifting variants: petals, jacaranda, leaves. p.y += p.vy * dt; p.rot += p.vrot * dt; var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp; var origX = p.x; p.x = origX + sway; if (p.y > H + 30) { particles[i] = spawn(false); continue; } if (variant === "jacaranda" || variant === "petals") drawPetals(p); else drawLeaf(p); p.x = origX; } } raf = requestAnimationFrame(step); } function start() { if (raf) return; last = performance.now(); raf = requestAnimationFrame(step); } function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; ctx.clearRect(0, 0, W, H); } // Build the particle pool for a variant. The controller decides whether to // start rendering afterwards. function set(v, inten) { variant = aliases[v] || v || null; intensity = inten || "heavy"; flash = 0; nextBolt = rand(1.5, 4); particles = []; if (variant === "clear") { buildStars(); } if (!variant) { stop(); return; } var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1)); for (var i = 0; i < N; i++) particles.push(spawn(true)); } return { name: "canvas2d", set: set, start: start, stop: stop }; }; })();