diff --git a/internal/web/handlers.go b/internal/web/handlers.go index faad0ed..6053244 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -401,7 +401,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) { } var ( - demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"} + demoVariants = []string{"clear", "clouds", "rain", "storm", "hail", "snow", "fog", "haze", "wind", "aurora", "petals", "jacaranda", "motes", "leaves"} demoIntensities = []string{"light", "medium", "heavy"} demoPhases = []string{"day", "dawn", "dusk", "night"} ) @@ -431,10 +431,12 @@ func seasonForVariant(v string) string { return "winter" case "petals", "jacaranda": return "spring" - case "motes": + case "motes", "haze": return "summer" - case "leaves": + case "leaves", "wind": return "autumn" + case "hail": + return "winter" } return "" } diff --git a/internal/web/static/js/weather-2d.js b/internal/web/static/js/weather-2d.js new file mode 100644 index 0000000..325c487 --- /dev/null +++ b/internal/web/static/js/weather-2d.js @@ -0,0 +1,487 @@ +// 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 }; + }; +})(); diff --git a/internal/web/static/js/weather-gl.js b/internal/web/static/js/weather-gl.js new file mode 100644 index 0000000..da96137 --- /dev/null +++ b/internal/web/static/js/weather-gl.js @@ -0,0 +1,1034 @@ +// WebGL2 weather engine. All the fill-rate work (soft sprites, glows, fog, +// lightning) happens on the GPU so scrolling and battery stay happy; the CPU +// only nudges a few hundred particle positions per frame and uploads one +// instance buffer. weather.js picks this engine when WebGL2 is available and +// falls back to the Canvas2D engine (weather-2d.js) otherwise. +// +// Shape of the engine: one sprite atlas baked once on an offscreen 2D canvas +// (blossoms, leaves, flakes, clouds, moon, hail, light beams), one instanced +// quad program that draws every particle in a single call, and one fullscreen +// "sky" shader for the volumetric-looking stuff: fog, Saharan haze, aurora, +// sun rays, storm gloom and lightning flash. +(function () { + "use strict"; + + window.PeteWeatherEngines = window.PeteWeatherEngines || {}; + + window.PeteWeatherEngines.webgl2 = function (canvas) { + var gl = canvas.getContext("webgl2", { + alpha: true, + premultipliedAlpha: true, + antialias: false, + depth: false, + stencil: false, + powerPreference: "low-power" + }); + if (!gl) return null; + + var root = document.documentElement; + function rand(a, b) { return a + Math.random() * (b - a); } + + // ---- sprite atlas ------------------------------------------------------- + // Everything is drawn white-on-transparent where possible so a per-instance + // tint can recolor it (clouds, rain, glows); blossoms and leaves keep baked + // colors because they mix two hues that a single tint can't reproduce. + var SPR = { + GLOW: 0, STREAK: 1, FLAKE: 2, BLOSSOM_P: 3, BLOSSOM_J: 4, + LEAF0: 5, LEAF1: 6, LEAF2: 7, CLOUD0: 8, CLOUD1: 9, CLOUD2: 10, + MOON: 11, HAIL: 12, BEAM: 13 + }; + var ATLAS = 1024; + var uvRects = new Float32Array(16 * 4); + + function setRect(idx, x, y, w, h) { + // 2px inset guards against mip/linear bleed from neighboring sprites. + uvRects[idx * 4 + 0] = (x + 2) / ATLAS; + uvRects[idx * 4 + 1] = (y + 2) / ATLAS; + uvRects[idx * 4 + 2] = (w - 4) / ATLAS; + uvRects[idx * 4 + 3] = (h - 4) / ATLAS; + } + + function bakeBlossom(cx2, x, y, petalFill, petalEdge) { + var s = 56, cx = x + 64, cy = y + 64; + cx2.save(); + cx2.translate(cx, cy); + for (var i = 0; i < 5; i++) { + var a = (i / 5) * Math.PI * 2 - Math.PI / 2; + var px = Math.cos(a) * s * 0.32; + var py = Math.sin(a) * s * 0.32; + cx2.fillStyle = petalFill; + cx2.strokeStyle = petalEdge; + cx2.lineWidth = 2; + cx2.beginPath(); + cx2.ellipse(px, py, s * 0.38, s * 0.24, a, 0, Math.PI * 2); + cx2.fill(); + cx2.stroke(); + } + cx2.fillStyle = "hsl(48,90%,68%)"; + cx2.beginPath(); + cx2.arc(0, 0, s * 0.13, 0, Math.PI * 2); + cx2.fill(); + cx2.restore(); + } + + function bakeLeaf(cx2, x, y, hue, light) { + var s = 56, cx = x + 64, cy = y + 64; + cx2.save(); + cx2.translate(cx, cy); + var g = cx2.createLinearGradient(-s * 0.5, 0, s * 0.5, 0); + g.addColorStop(0, "hsl(" + hue + ",70%," + (light - 10) + "%)"); + g.addColorStop(1, "hsl(" + hue + ",75%," + (light + 10) + "%)"); + cx2.fillStyle = g; + cx2.beginPath(); + cx2.moveTo(-s * 0.55, 0); + cx2.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0); + cx2.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0); + cx2.closePath(); + cx2.fill(); + cx2.strokeStyle = "hsla(" + hue + ",60%," + (light - 22) + "%,0.7)"; + cx2.lineWidth = 2; + cx2.beginPath(); + cx2.moveTo(-s * 0.5, 0); + cx2.lineTo(s * 0.5, 0); + cx2.stroke(); + cx2.restore(); + } + + // Same silhouettes as the 2D engine so clouds keep their look. + 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 bakeCloud(cx2, x, y, shape) { + var w = 320, h = 192; + cx2.save(); + cx2.filter = "blur(16px)"; + cx2.fillStyle = "rgba(255,255,255,1)"; + for (var i = 0; i < shape.length; i++) { + cx2.beginPath(); + cx2.arc(x + shape[i][0] * w, y + shape[i][1] * h, shape[i][2] * w * 0.92, 0, Math.PI * 2); + cx2.fill(); + } + cx2.restore(); + } + + function bakeMoon(cx2, x, y) { + var TAU = Math.PI * 2; + var mr = 53, cx = x + 128, cy = y + 128; + // Halo first, unclipped. + var halo = cx2.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)"); + cx2.fillStyle = halo; + cx2.beginPath(); + cx2.arc(cx, cy, mr * 2.4, 0, TAU); + cx2.fill(); + cx2.save(); + cx2.beginPath(); + cx2.arc(cx, cy, mr, 0, TAU); + cx2.clip(); + var lx = cx + mr * 0.45, ly = cy - mr * 0.45; + var body = cx2.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)"); + cx2.fillStyle = body; + cx2.fillRect(cx - mr, cy - mr, mr * 2, mr * 2); + var limb = cx2.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)"); + cx2.fillStyle = limb; + cx2.fillRect(cx - mr, cy - mr, mr * 2, mr * 2); + // A few faint maria so the disc isn't a plain gradient. + cx2.fillStyle = "rgba(120,132,165,0.30)"; + [[-0.25, -0.1, 0.28], [0.18, 0.22, 0.20], [0.05, -0.32, 0.14], [-0.32, 0.3, 0.12]].forEach(function (m) { + cx2.beginPath(); + cx2.arc(cx + m[0] * mr, cy + m[1] * mr, m[2] * mr, 0, TAU); + cx2.fill(); + }); + cx2.restore(); + } + + function bakeAtlas() { + var c = document.createElement("canvas"); + c.width = ATLAS; c.height = ATLAS; + var x = c.getContext("2d"); + + // Row 0: 128px sprites. + // GLOW — hot core + wide falloff; doubles as mote, star, dust, splash. + var g = x.createRadialGradient(64, 64, 0, 64, 64, 62); + g.addColorStop(0, "rgba(255,255,255,1)"); + g.addColorStop(0.18, "rgba(255,255,255,0.55)"); + g.addColorStop(0.5, "rgba(255,255,255,0.16)"); + g.addColorStop(1, "rgba(255,255,255,0)"); + x.fillStyle = g; + x.fillRect(0, 0, 128, 128); + setRect(SPR.GLOW, 0, 0, 128, 128); + + // STREAK — vertical rain line with soft sides and faded tips. Baked on a + // scratch canvas because destination-in erases the whole surface, which + // would wipe every sprite already on the atlas. + var sc = document.createElement("canvas"); + sc.width = 64; sc.height = 128; + var scx = sc.getContext("2d"); + var sg = scx.createLinearGradient(0, 0, 64, 0); + sg.addColorStop(0.0, "rgba(255,255,255,0)"); + sg.addColorStop(0.42, "rgba(255,255,255,0.55)"); + sg.addColorStop(0.5, "rgba(255,255,255,1)"); + sg.addColorStop(0.58, "rgba(255,255,255,0.55)"); + sg.addColorStop(1.0, "rgba(255,255,255,0)"); + scx.fillStyle = sg; + scx.fillRect(0, 0, 64, 128); + var tip = scx.createLinearGradient(0, 0, 0, 128); + tip.addColorStop(0, "rgba(0,0,0,0)"); + tip.addColorStop(0.18, "rgba(0,0,0,1)"); + tip.addColorStop(0.9, "rgba(0,0,0,1)"); + tip.addColorStop(1, "rgba(0,0,0,0)"); + scx.globalCompositeOperation = "destination-in"; + scx.fillStyle = tip; + scx.fillRect(0, 0, 64, 128); + x.drawImage(sc, 128, 0); + setRect(SPR.STREAK, 128, 0, 64, 128); + + // FLAKE — six-arm crystal for the bigger snowflakes. + x.save(); + x.translate(256 + 64, 64); + x.strokeStyle = "rgba(255,255,255,0.95)"; + x.lineCap = "round"; + for (var i = 0; i < 6; i++) { + x.save(); + x.rotate((i / 6) * Math.PI * 2); + x.lineWidth = 6; + x.beginPath(); x.moveTo(0, 0); x.lineTo(0, -50); x.stroke(); + x.lineWidth = 4; + x.beginPath(); x.moveTo(0, -30); x.lineTo(11, -41); x.stroke(); + x.beginPath(); x.moveTo(0, -30); x.lineTo(-11, -41); x.stroke(); + x.restore(); + } + x.fillStyle = "rgba(255,255,255,1)"; + x.beginPath(); x.arc(0, 0, 7, 0, Math.PI * 2); x.fill(); + x.restore(); + setRect(SPR.FLAKE, 256, 0, 128, 128); + + bakeBlossom(x, 384, 0, "hsl(345,70%,83%)", "hsla(340,60%,68%,0.5)"); + setRect(SPR.BLOSSOM_P, 384, 0, 128, 128); + bakeBlossom(x, 512, 0, "hsl(272,62%,76%)", "hsla(268,55%,58%,0.5)"); + setRect(SPR.BLOSSOM_J, 512, 0, 128, 128); + + bakeLeaf(x, 640, 0, 22, 46); setRect(SPR.LEAF0, 640, 0, 128, 128); + bakeLeaf(x, 768, 0, 35, 50); setRect(SPR.LEAF1, 768, 0, 128, 128); + bakeLeaf(x, 896, 0, 46, 44); setRect(SPR.LEAF2, 896, 0, 128, 128); + + // Row 1: clouds, drawn white and tinted per palette at draw time. + bakeCloud(x, 0, 128, cloudShapes[0]); setRect(SPR.CLOUD0, 0, 128, 320, 192); + bakeCloud(x, 320, 128, cloudShapes[1]); setRect(SPR.CLOUD1, 320, 128, 320, 192); + bakeCloud(x, 640, 128, cloudShapes[2]); setRect(SPR.CLOUD2, 640, 128, 320, 192); + + // Row 2: moon, hailstone, beam. + bakeMoon(x, 0, 320); + setRect(SPR.MOON, 0, 320, 256, 256); + + var hg = x.createRadialGradient(256 + 38, 320 + 38, 4, 256 + 48, 320 + 48, 36); + hg.addColorStop(0, "rgba(255,255,255,1)"); + hg.addColorStop(0.7, "rgba(216,226,240,0.95)"); + hg.addColorStop(1, "rgba(170,185,210,0.9)"); + x.fillStyle = hg; + x.beginPath(); x.arc(256 + 48, 320 + 48, 34, 0, Math.PI * 2); x.fill(); + setRect(SPR.HAIL, 256, 320, 96, 96); + + // BEAM — horizontal bar with a bright core; lightning segments, gust + // streaks and shooting-star tails all stretch this. Scratch canvas for + // the same destination-in reason as STREAK. + var bc = document.createElement("canvas"); + bc.width = 128; bc.height = 32; + var bcx = bc.getContext("2d"); + var bg = bcx.createLinearGradient(0, 0, 0, 32); + bg.addColorStop(0.0, "rgba(255,255,255,0)"); + bg.addColorStop(0.4, "rgba(255,255,255,0.5)"); + bg.addColorStop(0.5, "rgba(255,255,255,1)"); + bg.addColorStop(0.6, "rgba(255,255,255,0.5)"); + bg.addColorStop(1.0, "rgba(255,255,255,0)"); + bcx.fillStyle = bg; + bcx.fillRect(0, 0, 128, 32); + var bfade = bcx.createLinearGradient(0, 0, 128, 0); + bfade.addColorStop(0, "rgba(0,0,0,0)"); + bfade.addColorStop(0.12, "rgba(0,0,0,1)"); + bfade.addColorStop(0.88, "rgba(0,0,0,1)"); + bfade.addColorStop(1, "rgba(0,0,0,0)"); + bcx.globalCompositeOperation = "destination-in"; + bcx.fillStyle = bfade; + bcx.fillRect(0, 0, 128, 32); + x.drawImage(bc, 384, 320); + setRect(SPR.BEAM, 384, 320, 128, 32); + + return c; + } + + // ---- GL setup ----------------------------------------------------------- + var PARTICLE_VS = [ + "#version 300 es", + "layout(location=0) in vec2 a_corner;", + "layout(location=1) in vec4 a_pos;", // x, y, sizeX, sizeY (CSS px) + "layout(location=2) in vec4 a_misc;", // rot, alpha, spriteIdx, unused + "layout(location=3) in vec3 a_tint;", + "uniform vec2 u_res;", + "uniform vec4 u_uv[16];", + "out vec2 v_uv;", + "out float v_alpha;", + "out vec3 v_tint;", + "void main(){", + " float c = cos(a_misc.x), s = sin(a_misc.x);", + " vec2 k = a_corner * a_pos.zw;", + " vec2 p = a_pos.xy + vec2(k.x*c - k.y*s, k.x*s + k.y*c);", + " vec2 clip = (p / u_res) * 2.0 - 1.0;", + " gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);", + " vec4 r = u_uv[int(a_misc.z + 0.5)];", + " v_uv = r.xy + (a_corner + 0.5) * r.zw;", + " v_alpha = a_misc.y;", + " v_tint = a_tint;", + "}" + ].join("\n"); + + var PARTICLE_FS = [ + "#version 300 es", + "precision mediump float;", + "uniform sampler2D u_tex;", + "in vec2 v_uv; in float v_alpha; in vec3 v_tint;", + "out vec4 o;", + "void main(){", + " vec4 t = texture(u_tex, v_uv);", // premultiplied + " o = vec4(t.rgb * v_tint, t.a) * v_alpha;", + "}" + ].join("\n"); + + var SKY_VS = [ + "#version 300 es", + "void main(){", + " vec2 p[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.));", + " gl_Position = vec4(p[gl_VertexID], 0., 1.);", + "}" + ].join("\n"); + + // The sky shader carries every fullscreen effect behind a mode switch: + // 1 clear day (sun glow + slow rays), 2 clear night glow, 3 fog fbm, + // 4 Saharan haze, 5 aurora curtains, 6 storm gloom. u_flash rides on any + // mode for the lightning whiteout. + var SKY_FS = [ + "#version 300 es", + "precision highp float;", + "out vec4 o;", + "uniform vec2 u_res;", + "uniform float u_t;", + "uniform int u_mode;", + "uniform float u_level;", // intensity multiplier 0.6 / 1.0 / 1.4 + "uniform float u_night;", // 0 day .. 1 night + "uniform float u_warm;", // dawn/dusk warmth + "uniform float u_flash;", + "float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123); }", + "float vnoise(vec2 p){", + " vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f);", + " float a = hash(i), b = hash(i+vec2(1,0)), c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));", + " return mix(mix(a,b,f.x), mix(c,d,f.x), f.y);", + "}", + "float fbm(vec2 p){", + " float v = 0.0, a = 0.5;", + " for (int i = 0; i < 4; i++){ v += a * vnoise(p); p = p*2.03 + vec2(17.0); a *= 0.5; }", + " return v;", + "}", + "void main(){", + " vec2 uv = gl_FragCoord.xy / u_res;", + " uv.y = 1.0 - uv.y;", // 0 = top of page + " float aspect = u_res.x / u_res.y;", + " vec3 rgb = vec3(0.0); float a = 0.0;", + " if (u_mode == 1) {", + " vec2 c = vec2(0.82*aspect, 0.18);", + " vec2 p = vec2(uv.x*aspect, uv.y);", + " float d = distance(p, c);", + " float breathe = 0.5 + 0.5*sin(u_t*0.25);", + " float glow = exp(-d*d*5.5) * (0.30 + 0.10*breathe);", + " float ang = atan(p.y - c.y, p.x - c.x);", + " float rays = pow(0.5 + 0.5*sin(ang*9.0 + u_t*0.06), 3.0) * exp(-d*2.4) * 0.10;", + " vec3 col = mix(vec3(1.0,0.88,0.55), vec3(1.0,0.60,0.40), u_warm);", + " rgb = col*(glow + rays); a = (glow + rays)*0.95;", + " } else if (u_mode == 2) {", + " vec2 c = vec2(0.82*aspect, 0.18);", + " vec2 p = vec2(uv.x*aspect, uv.y);", + " float d = distance(p, c);", + " float breathe = 0.5 + 0.5*sin(u_t*0.25);", + " float glow = exp(-d*d*5.0) * (0.12 + 0.05*breathe);", + " rgb = vec3(0.86,0.90,1.0)*glow; a = glow;", + " } else if (u_mode == 3) {", + " vec2 q = vec2(uv.x*aspect, uv.y);", + " float n1 = fbm(q*vec2(1.6,2.6) + vec2(u_t*0.020, 0.0));", + " float n2 = fbm(q*vec2(2.3,3.4) + vec2(-u_t*0.013, u_t*0.006) + 31.0);", + " float dens = smoothstep(0.28, 0.85, n1*0.6 + n2*0.5);", + " dens *= (0.35 + 0.75*uv.y);", // hugs the ground + " dens *= (0.40 + 0.42*u_level);", + " vec3 col = mix(vec3(0.93,0.92,0.90), vec3(0.42,0.47,0.58), u_night);", + " rgb = col*dens; a = dens*0.9;", + " } else if (u_mode == 4) {", + " vec2 q = vec2(uv.x*aspect, uv.y);", + " float n = fbm(q*vec2(2.0,2.8) + vec2(u_t*0.03, u_t*0.004));", + " float dens = (0.30 + 0.35*n) * (0.55 + 0.45*(1.0 - uv.y));", // dusty sky, clearer ground + " dens *= (0.50 + 0.45*u_level);", + " vec3 col = mix(vec3(0.87,0.64,0.36), vec3(0.55,0.40,0.24), u_night);", + " rgb = col*dens*0.55; a = dens*0.5;", + " } else if (u_mode == 5) {", + " vec3 acc = vec3(0.0); float sum = 0.0;", + " for (int i = 0; i < 3; i++){", + " float fi = float(i);", + " float band = fbm(vec2(uv.x*(2.2 + fi*0.7) + u_t*(0.030 + 0.011*fi), fi*7.3 + u_t*0.015));", + " float center = 0.10 + fi*0.10 + band*0.22;", + " float w = 0.045 + 0.030*band;", + " float s = exp(-pow((uv.y - center)/w, 2.0));", + " s *= 0.7 + 0.3*sin(uv.x*40.0 + u_t*(1.3 + fi) + fi*9.0);", + " vec3 col = mix(vec3(0.15,0.95,0.55), vec3(0.55,0.25,0.95), clamp(fi*0.5 + (uv.y - center)*6.0, 0.0, 1.0));", + " acc += col*s; sum += s;", + " }", + " float k = (0.35 + 0.40*u_level) * mix(0.35, 1.0, u_night);", + " rgb = acc*k*0.5; a = min(0.8, sum*k*0.4);", + " } else if (u_mode == 6) {", + " float dk = (1.0 - uv.y)*0.20 + 0.05;", // gloom heaviest at the top + " vec3 col = mix(vec3(0.35,0.38,0.48), vec3(0.05,0.06,0.10), u_night);", + " rgb = col*dk; a = dk;", + " }", + " if (u_flash > 0.0) { rgb += vec3(0.90,0.94,1.0)*(u_flash*0.45); a = min(1.0, a + u_flash*0.45); }", + " o = vec4(rgb, a);", // premultiplied + "}" + ].join("\n"); + + function compile(type, src) { + var sh = gl.createShader(type); + gl.shaderSource(sh, src); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + var msg = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error("shader: " + msg); + } + return sh; + } + function link(vsSrc, fsSrc) { + var p = gl.createProgram(); + gl.attachShader(p, compile(gl.VERTEX_SHADER, vsSrc)); + gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fsSrc)); + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error("link: " + gl.getProgramInfoLog(p)); + return p; + } + + var MAX_INSTANCES = 900; + var FLOATS = 11; // pos(4) + misc(4) + tint(3) + var inst = new Float32Array(MAX_INSTANCES * FLOATS); + + var prog, skyProg, vao, skyVao, instBuf, tex; + var U = {}, SU = {}; + + function initGL() { + try { + prog = link(PARTICLE_VS, PARTICLE_FS); + skyProg = link(SKY_VS, SKY_FS); + } catch (e) { + return false; + } + U.res = gl.getUniformLocation(prog, "u_res"); + U.uv = gl.getUniformLocation(prog, "u_uv"); + U.tex = gl.getUniformLocation(prog, "u_tex"); + SU.res = gl.getUniformLocation(skyProg, "u_res"); + SU.t = gl.getUniformLocation(skyProg, "u_t"); + SU.mode = gl.getUniformLocation(skyProg, "u_mode"); + SU.level = gl.getUniformLocation(skyProg, "u_level"); + SU.night = gl.getUniformLocation(skyProg, "u_night"); + SU.warm = gl.getUniformLocation(skyProg, "u_warm"); + SU.flash = gl.getUniformLocation(skyProg, "u_flash"); + + vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + var quad = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, quad); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + instBuf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, instBuf); + gl.bufferData(gl.ARRAY_BUFFER, inst.byteLength, gl.DYNAMIC_DRAW); + var stride = FLOATS * 4; + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, stride, 0); + gl.vertexAttribDivisor(1, 1); + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 4, gl.FLOAT, false, stride, 16); + gl.vertexAttribDivisor(2, 1); + gl.enableVertexAttribArray(3); + gl.vertexAttribPointer(3, 3, gl.FLOAT, false, stride, 32); + gl.vertexAttribDivisor(3, 1); + gl.bindVertexArray(null); + + skyVao = gl.createVertexArray(); + + tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bakeAtlas()); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.generateMipmap(gl.TEXTURE_2D); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.clearColor(0, 0, 0, 0); + return true; + } + if (!initGL()) return null; + + // ---- sizing ------------------------------------------------------------- + var W = 0, H = 0, DPR = 1; + function resize() { + DPR = Math.min(window.devicePixelRatio || 1, 1.75); + W = canvas.clientWidth || window.innerWidth; + H = canvas.clientHeight || window.innerHeight; + canvas.width = Math.max(1, Math.floor(W * DPR)); + canvas.height = Math.max(1, Math.floor(H * DPR)); + gl.viewport(0, 0, canvas.width, canvas.height); + } + resize(); + window.addEventListener("resize", function () { + resize(); + if (variant) buildStatics(); + }); + + // ---- palette ------------------------------------------------------------ + function phaseInfo() { + var ph = root.dataset.phase || "day"; + if (ph === "night") return { night: 1, warm: 0, isNight: true }; + if (ph === "dawn" || ph === "dusk") return { night: 0.2, warm: 1, isNight: false }; + return { night: 0, warm: 0, isNight: false }; + } + + // ---- wind --------------------------------------------------------------- + // Shared horizontal wind: a slow ambient sway plus random gust pulses. + // Every variant that falls or drifts samples this so the whole scene leans + // together instead of each particle wiggling independently. + var windParams = { base: 0, amb: 0, gust: 0 }; + var windNow = 0, gustCur = 0, gustTarget = 0, gustTimer = 0; + function windConfig(v) { + switch (v) { + case "rain": return { base: -70, amb: 25, gust: 60 }; + case "storm": return { base: -130, amb: 55, gust: 160 }; + case "snow": return { base: 10, amb: 22, gust: 40 }; + case "leaves": return { base: 25, amb: 40, gust: 90 }; + case "wind": return { base: 170, amb: 60, gust: 240 }; + case "petals": + case "jacaranda": return { base: 12, amb: 22, gust: 45 }; + case "haze": return { base: 45, amb: 15, gust: 30 }; + case "hail": return { base: -40, amb: 20, gust: 60 }; + default: return { base: 0, amb: 8, gust: 0 }; + } + } + function updateWind(t, dt) { + gustTimer -= dt; + if (gustTimer <= 0) { + gustTarget = rand(-1, 1) * windParams.gust; + gustTimer = rand(2.5, 8); + } + gustCur += (gustTarget - gustCur) * Math.min(1, dt * 0.9); + var amb = Math.sin(t * 0.13) * 0.6 + Math.sin(t * 0.071 + 2.1) * 0.4; + windNow = windParams.base + amb * windParams.amb + gustCur; + } + + // ---- particles ---------------------------------------------------------- + var counts = { + rain: 170, storm: 220, snow: 210, clouds: 10, fog: 4, + petals: 60, jacaranda: 66, motes: 80, leaves: 46, + hail: 70, haze: 110, wind: 60, clear: 0, aurora: 0 + }; + var mults = { light: 0.6, medium: 1.0, heavy: 1.4 }; + + var variant = null; + var intensity = "heavy"; + var particles = []; // dynamic sim particles + var statics = []; // stars / moon, rebuilt on resize or phase flip + var splashes = []; + var bolt = null; // { segs, life } + var flash = 0, nextBolt = 2; + var shootTimer = rand(5, 14); + var builtNight = null; + + function spawn(kind, initial) { + var p = { kind: kind, z: rand(0.7, 1.3) }; + p.x = rand(0, W); + p.y = initial ? rand(0, H) : rand(-60, -10); + if (kind === "rain") { + p.vy = rand(700, 1100) * p.z; + p.jitter = rand(-25, 25); + p.len = rand(11, 20) * p.z; + p.alpha = rand(0.3, 0.6); + } else if (kind === "drizzle") { + p.vy = rand(420, 620) * p.z; + p.jitter = rand(-20, 20); + p.len = rand(7, 12) * p.z; + p.alpha = rand(0.18, 0.35); + } else if (kind === "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); + p.rot = rand(0, Math.PI * 2); + p.vrot = rand(-0.8, 0.8); + p.crystal = p.size > 3.4; + } else if (kind === "cloud") { + p.far = Math.random() < 0.4; + p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45); + p.x = initial ? rand(0, W) : -rand(160, 420); + p.vx = (p.far ? rand(4, 9) : rand(10, 24)); + p.size = (p.far ? rand(60, 110) : rand(90, 180)) * p.z; + p.alpha = p.far ? rand(0.18, 0.30) : rand(0.32, 0.55); + p.sprite = SPR.CLOUD0 + Math.floor(rand(0, 3)); + p.bobPhase = rand(0, Math.PI * 2); + } else if (kind === "fogband") { + p.y = rand(H * 0.3, H); + p.x = initial ? rand(0, W) : -rand(200, 500); + p.vx = rand(6, 16); + p.w = rand(320, 640); + p.alpha = rand(0.04, 0.08); + p.sprite = SPR.CLOUD0 + Math.floor(rand(0, 3)); + } else if (kind === "blossom") { + 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(14, 26) * p.z; + p.alpha = rand(0.75, 1.0); + p.tumblePhase = rand(0, Math.PI * 2); + p.tumbleFreq = rand(0.6, 1.6); + p.bright = rand(0.88, 1.0); + } else if (kind === "mote") { + 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 (kind === "leaf") { + 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.4, 2.4); + p.size = rand(20, 38) * p.z; + p.alpha = rand(0.8, 1.0); + p.sprite = SPR.LEAF0 + Math.floor(rand(0, 3)); + p.tumblePhase = rand(0, Math.PI * 2); + p.tumbleFreq = rand(0.8, 2.0); + p.bright = rand(0.85, 1.0); + } else if (kind === "hail") { + p.vy = rand(480, 780) * p.z; + p.vx = rand(-20, 20); + p.size = rand(5, 11) * p.z; + p.alpha = rand(0.75, 1.0); + p.rot = rand(0, Math.PI * 2); + p.vrot = rand(-6, 6); + p.bounces = 0; + } else if (kind === "dust") { + p.vx = rand(20, 70); + p.vy = rand(-8, 14); + p.size = rand(1.0, 2.4) * p.z; + p.alpha = rand(0.15, 0.4); + p.x = initial ? rand(0, W) : -rand(5, 30); + p.y = rand(0, H); + p.twinklePhase = rand(0, Math.PI * 2); + p.twinkleFreq = rand(0.3, 0.9); + } else if (kind === "gustline") { + p.y = rand(0, H); + p.x = initial ? rand(0, W) : -rand(50, 300); + p.vx = rand(380, 640); + p.len = rand(90, 220); + p.alpha = rand(0.05, 0.12); + p.wobblePhase = rand(0, Math.PI * 2); + p.wobbleFreq = rand(1.5, 3.5); + } + return p; + } + + // Stars + moon are position-stable; only their twinkle animates. + function buildStatics() { + statics = []; + var info = phaseInfo(); + builtNight = info.isNight; + if (!info.isNight || (variant !== "clear" && variant !== "aurora")) return; + var n = variant === "aurora" ? 90 : 70; + for (var i = 0; i < n; i++) { + var sx = ((i * 73 + 11) % 100) / 100; + var sy = ((i * 37 + 7) % 100) / 100; + statics.push({ + kind: "star", + x: sx * W, y: sy * 0.7 * H, + size: 2.2 + (i % 3) * 1.6, + ph: i % 7, freq: 0.6 + (i % 5) * 0.15 + }); + } + if (variant === "clear") { + statics.push({ kind: "moon", x: W * 0.82, y: H * 0.18, size: Math.min(W, H) * 0.5 * 0.16 * 4.8 }); + } + } + + function build() { + particles = []; + splashes = []; + bolt = null; + flash = 0; + nextBolt = rand(1.5, 4); + shootTimer = rand(4, 10); + windParams = windConfig(variant); + gustCur = 0; gustTarget = 0; gustTimer = 0; + buildStatics(); + if (!variant) return; + var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1)); + var i; + if (variant === "rain" || variant === "storm") { + for (i = 0; i < N; i++) particles.push(spawn("rain", true)); + } else if (variant === "snow") { + for (i = 0; i < N; i++) particles.push(spawn("snow", true)); + } else if (variant === "clouds") { + for (i = 0; i < N; i++) particles.push(spawn("cloud", true)); + } else if (variant === "fog") { + for (i = 0; i < N; i++) particles.push(spawn("fogband", true)); + } else if (variant === "petals" || variant === "jacaranda") { + for (i = 0; i < N; i++) particles.push(spawn("blossom", true)); + } else if (variant === "motes") { + for (i = 0; i < N; i++) particles.push(spawn("mote", true)); + } else if (variant === "leaves") { + for (i = 0; i < N; i++) particles.push(spawn("leaf", true)); + } else if (variant === "hail") { + for (i = 0; i < N; i++) particles.push(spawn("hail", true)); + for (i = 0; i < Math.round(N * 0.9); i++) particles.push(spawn("drizzle", true)); + } else if (variant === "haze") { + for (i = 0; i < N; i++) particles.push(spawn("dust", true)); + } else if (variant === "wind") { + for (i = 0; i < N; i++) particles.push(spawn("leaf", true)); + for (i = 0; i < 16; i++) particles.push(spawn("gustline", true)); + } + } + + // ---- lightning ---------------------------------------------------------- + function makeBolt() { + var segs = []; + function grow(x, y, angle, maxY, w, depth) { + while (y < maxY && segs.length < 60) { + var a = angle + rand(-0.45, 0.45); + var l = rand(H * 0.03, H * 0.06); + var nx = x + Math.sin(a) * l; + var ny = y + Math.cos(a) * l; + segs.push({ x1: x, y1: y, x2: nx, y2: ny, w: w }); + x = nx; y = ny; + if (depth < 2 && Math.random() < 0.14) { + grow(x, y, a + rand(0.5, 1.1) * (Math.random() < 0.5 ? -1 : 1), y + (maxY - y) * 0.4, w * 0.55, depth + 1); + } + } + } + grow(rand(W * 0.15, W * 0.85), -10, rand(-0.3, 0.3), rand(H * 0.5, H * 0.85), 3.4, 0); + return { segs: segs, life: 0.45 }; + } + + // ---- frame -------------------------------------------------------------- + var n = 0; // instances written this frame + function push(x, y, sx, sy, rot, alpha, sprite, r, g, b) { + if (n >= MAX_INSTANCES) return; + var o = n * FLOATS; + inst[o] = x; inst[o + 1] = y; inst[o + 2] = sx; inst[o + 3] = sy; + inst[o + 4] = rot; inst[o + 5] = alpha; inst[o + 6] = sprite; inst[o + 7] = 0; + inst[o + 8] = r; inst[o + 9] = g; inst[o + 10] = b; + n++; + } + + function skyMode(info) { + if (variant === "clear") return info.isNight ? 2 : 1; + if (variant === "fog") return 3; + if (variant === "haze") return 4; + if (variant === "aurora") return 5; + if (variant === "storm") return 6; + return 0; + } + + var last = 0, raf = 0; + + function step(now) { + raf = requestAnimationFrame(step); + var dt = Math.min(0.05, (now - last) / 1000) || 0.016; + last = now; + var t = now / 1000; + var info = phaseInfo(); + var night = info.isNight; + if ((variant === "clear" || variant === "aurora") && builtNight !== night) buildStatics(); + updateWind(t, dt); + + gl.clear(gl.COLOR_BUFFER_BIT); + + // Storm bookkeeping before drawing so the flash lands this frame. + if (variant === "storm") { + nextBolt -= dt; + if (nextBolt <= 0) { + bolt = makeBolt(); + flash = 1; + nextBolt = rand(2.5, 7); + } + if (flash > 0) flash = Math.max(0, flash - dt * 4); + if (bolt) { + bolt.life -= dt; + if (bolt.life <= 0) bolt = null; + } + } + + // Sky pass. + var mode = skyMode(info); + if (mode !== 0 || flash > 0) { + gl.useProgram(skyProg); + gl.bindVertexArray(skyVao); + gl.uniform2f(SU.res, canvas.width, canvas.height); + gl.uniform1f(SU.t, t); + gl.uniform1i(SU.mode, mode); + gl.uniform1f(SU.level, mults[intensity] || 1); + gl.uniform1f(SU.night, info.night); + gl.uniform1f(SU.warm, info.warm); + gl.uniform1f(SU.flash, flash); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + + // Particle pass. + n = 0; + var i, p, sway, tw, a; + + // Stars first so the moon and everything else draws over them. + for (i = 0; i < statics.length; i++) { + p = statics[i]; + if (p.kind === "star") { + tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * p.freq + p.ph)); + push(p.x, p.y, p.size * 2.4, p.size * 2.4, 0, 0.55 * tw, SPR.GLOW, 1, 1, 1); + } else if (p.kind === "moon") { + push(p.x, p.y, p.size, p.size, 0, 1, SPR.MOON, 1, 1, 1); + } + } + + // Shooting stars on clear/aurora nights. + if ((variant === "clear" || variant === "aurora") && night) { + shootTimer -= dt; + if (shootTimer <= 0 && !stepShoot.active) { + stepShoot.active = { x: rand(W * 0.1, W * 0.7), y: rand(H * 0.05, H * 0.3), vx: rand(500, 800), vy: rand(150, 320), life: 0.9 }; + shootTimer = rand(6, 18); + } + } + if (stepShoot.active) stepShoot(dt); + + for (i = 0; i < particles.length; i++) { + p = particles[i]; + if (p.kind === "rain" || p.kind === "drizzle") { + var vx = windNow * 1.15 + p.jitter; + p.x += vx * dt; + p.y += p.vy * dt; + if (p.y > H + 20) { + if (p.kind === "rain" && splashes.length < 40 && Math.random() < 0.35) { + splashes.push({ x: p.x, y: H - rand(2, 16), life: 0.28, max: 0.28 }); + } + particles[i] = spawn(p.kind, false); + particles[i].x = rand(-40, W + 80); + continue; + } + var rot = -Math.atan2(vx, p.vy); + if (night) push(p.x, p.y, 2.6, p.len * 1.7, rot, p.alpha, SPR.STREAK, 0.78, 0.84, 0.94); + else push(p.x, p.y, 3.0, p.len * 1.7, rot, Math.min(1, p.alpha + 0.25), SPR.STREAK, 0.24, 0.37, 0.59); + } else if (p.kind === "snow") { + p.y += p.vy * dt; + p.x += windNow * 0.35 * dt; + p.rot += p.vrot * dt; + if (p.y > H + 12) { particles[i] = spawn("snow", false); continue; } + if (p.x > W + 20) p.x -= W + 40; + if (p.x < -20) p.x += W + 40; + sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp; + var col = night ? [0.92, 0.95, 1] : [0.82, 0.89, 1]; + if (p.crystal) push(p.x + sway, p.y, p.size * 2.4, p.size * 2.4, p.rot, p.alpha, SPR.FLAKE, col[0], col[1], col[2]); + else push(p.x + sway, p.y, p.size * 3.6, p.size * 3.6, 0, p.alpha, SPR.GLOW, col[0], col[1], col[2]); + } else if (p.kind === "cloud") { + p.x += p.vx * dt; + if (p.x - p.size * 1.3 > W + 60) { particles[i] = spawn("cloud", false); continue; } + var bob = Math.sin(t * 0.08 + p.bobPhase) * 6; + var ct = night ? [0.81, 0.85, 0.93] : [0.59, 0.64, 0.75]; + var cw = p.size * 2.6, ch = cw * (192 / 320); + push(p.x, p.y + bob, cw, ch, 0, p.alpha * (p.far ? 0.85 : 1), p.sprite, ct[0], ct[1], ct[2]); + } else if (p.kind === "fogband") { + p.x += p.vx * dt; + if (p.x - p.w / 2 > W + 60) { particles[i] = spawn("fogband", false); continue; } + var ft = night ? [0.5, 0.55, 0.65] : [0.9, 0.89, 0.87]; + push(p.x, p.y, p.w, p.w * 0.3, 0, p.alpha, p.sprite, ft[0], ft[1], ft[2]); + } else if (p.kind === "blossom") { + p.y += p.vy * dt; + p.x += windNow * 0.4 * dt; + p.rot += p.vrot * dt; + p.tumblePhase += p.tumbleFreq * dt; + if (p.y > H + 30) { particles[i] = spawn("blossom", false); continue; } + if (p.x > W + 30) p.x -= W + 60; + if (p.x < -30) p.x += W + 60; + sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp; + // Fake a 3D flip by squashing one axis with the tumble phase. + var squash = 0.35 + 0.65 * Math.abs(Math.sin(p.tumblePhase)); + var spr = variant === "jacaranda" ? SPR.BLOSSOM_J : SPR.BLOSSOM_P; + push(p.x + sway, p.y, p.size * squash, p.size, p.rot, p.alpha, spr, p.bright, p.bright, p.bright); + } else if (p.kind === "mote") { + 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("mote", false); continue; } + tw = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase); + if (night) { a = p.alpha * (0.45 + 0.55 * tw); push(p.x, p.y, p.size * 10, p.size * 10, 0, a, SPR.GLOW, 1, 0.94, 0.71); } + else { a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * tw)); push(p.x, p.y, p.size * 10, p.size * 10, 0, a, SPR.GLOW, 0.51, 0.31, 0.71); } + } else if (p.kind === "leaf") { + // Gusts push leaves sideways and briefly hold them up. + var lift = Math.max(0, windNow - windParams.base) * 0.25; + p.y += (p.vy - lift) * dt; + p.x += windNow * 0.8 * dt; + p.rot += p.vrot * dt * (1 + Math.abs(windNow) / 200); + p.tumblePhase += p.tumbleFreq * dt; + if (p.y > H + 40) { particles[i] = spawn("leaf", false); continue; } + if (p.y < -60) p.y = -40; + if (p.x > W + 40) { p.x -= W + 80; } + if (p.x < -40) { p.x += W + 80; } + sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp; + var lsq = 0.3 + 0.7 * Math.abs(Math.sin(p.tumblePhase)); + push(p.x + sway, p.y, p.size, p.size * lsq, p.rot, p.alpha, p.sprite, p.bright, p.bright, p.bright); + } else if (p.kind === "hail") { + p.vy += 900 * dt; // gravity keeps bounces honest + p.x += (p.vx + windNow * 0.5) * dt; + p.y += p.vy * dt; + p.rot += p.vrot * dt; + if (p.y > H - 2 && p.vy > 0) { + p.y = H - 2; + p.vy = -p.vy * rand(0.25, 0.4); + p.vx *= 0.7; + p.bounces++; + if (p.bounces > 2 || -p.vy < 60) { particles[i] = spawn("hail", false); continue; } + } + push(p.x, p.y, p.size * 1.4, p.size * 1.4, p.rot, p.alpha, SPR.HAIL, 1, 1, 1); + } else if (p.kind === "dust") { + p.x += (p.vx + windNow * 0.6) * dt; + p.y += p.vy * dt; + if (p.x > W + 30 || p.y < -20 || p.y > H + 20) { particles[i] = spawn("dust", false); continue; } + tw = 0.6 + 0.4 * Math.sin(t * p.twinkleFreq + p.twinklePhase); + var dc = night ? [0.75, 0.6, 0.4] : [0.85, 0.62, 0.34]; + push(p.x, p.y, p.size * 8, p.size * 8, 0, p.alpha * tw, SPR.GLOW, dc[0], dc[1], dc[2]); + } else if (p.kind === "gustline") { + p.x += p.vx * dt; + p.y += Math.sin(t * p.wobbleFreq + p.wobblePhase) * 30 * dt; + if (p.x - p.len > W + 60) { particles[i] = spawn("gustline", false); continue; } + var gc = night ? [0.85, 0.9, 1] : [0.55, 0.6, 0.7]; + push(p.x, p.y, p.len, 2.2, 0, p.alpha, SPR.BEAM, gc[0], gc[1], gc[2]); + } + } + + // Rain splashes: quick flat pops where drops land. + for (i = splashes.length - 1; i >= 0; i--) { + var s = splashes[i]; + s.life -= dt; + if (s.life <= 0) { splashes.splice(i, 1); continue; } + var k = 1 - s.life / s.max; + var sc = night ? [0.8, 0.86, 0.95] : [0.35, 0.48, 0.68]; + push(s.x, s.y, 6 + k * 16, 2 + k * 3, 0, (1 - k) * 0.4, SPR.GLOW, sc[0], sc[1], sc[2]); + } + + // Lightning bolt: wide soft glow underneath, hot core on top. + if (bolt) { + var ba = (bolt.life > 0.32 ? 1 : bolt.life / 0.32) * (0.75 + 0.25 * Math.sin(t * 80)); + for (i = 0; i < bolt.segs.length; i++) { + var sg2 = bolt.segs[i]; + var dx = sg2.x2 - sg2.x1, dy = sg2.y2 - sg2.y1; + var len = Math.sqrt(dx * dx + dy * dy); + var mx = (sg2.x1 + sg2.x2) / 2, my = (sg2.y1 + sg2.y2) / 2; + var brot = Math.atan2(dy, dx); + push(mx, my, len * 1.3, sg2.w * 9, brot, ba * 0.16, SPR.BEAM, 0.6, 0.7, 1); + } + for (i = 0; i < bolt.segs.length; i++) { + var sg3 = bolt.segs[i]; + var dx2 = sg3.x2 - sg3.x1, dy2 = sg3.y2 - sg3.y1; + var len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + push((sg3.x1 + sg3.x2) / 2, (sg3.y1 + sg3.y2) / 2, len2 * 1.15, sg3.w, Math.atan2(dy2, dx2), ba, SPR.BEAM, 1, 1, 1); + } + } + + if (n > 0) { + gl.useProgram(prog); + gl.bindVertexArray(vao); + gl.uniform2f(U.res, W, H); + gl.uniform4fv(U.uv, uvRects); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.uniform1i(U.tex, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, instBuf); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, inst, 0, n * FLOATS); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, n); + } + gl.bindVertexArray(null); + } + + // Shooting star renderer/updater hangs its state off the function itself so + // the main loop stays flat. + function stepShoot(dt) { + var sh = stepShoot.active; + sh.life -= dt; + if (sh.life <= 0) { stepShoot.active = null; return; } + sh.x += sh.vx * dt; + sh.y += sh.vy * dt; + var a = Math.min(1, sh.life * 2.5) * 0.85; + var ang = Math.atan2(sh.vy, sh.vx); + var tail = 90; + push(sh.x - Math.cos(ang) * tail / 2, sh.y - Math.sin(ang) * tail / 2, tail, 2.4, ang, a * 0.7, SPR.BEAM, 1, 1, 1); + push(sh.x, sh.y, 14, 14, 0, a, SPR.GLOW, 1, 1, 1); + } + stepShoot.active = null; + + // ---- lifecycle ---------------------------------------------------------- + var running = false; + function start() { + if (running) return; + running = true; + last = performance.now(); + raf = requestAnimationFrame(step); + } + function stop() { + running = false; + if (raf) cancelAnimationFrame(raf); + raf = 0; + gl.clear(gl.COLOR_BUFFER_BIT); + } + function set(v, inten) { + variant = v || null; + intensity = inten || "heavy"; + build(); + if (!variant) stop(); + } + + canvas.addEventListener("webglcontextlost", function (e) { + e.preventDefault(); + if (raf) cancelAnimationFrame(raf); + raf = 0; + }); + canvas.addEventListener("webglcontextrestored", function () { + if (initGL()) { + resize(); + build(); + if (running) { running = false; start(); } + } + }); + + return { name: "webgl2", set: set, start: start, stop: stop }; + }; +})(); diff --git a/internal/web/static/js/weather.js b/internal/web/static/js/weather.js index b3d5fa8..01a4b1c 100644 --- a/internal/web/static/js/weather.js +++ b/internal/web/static/js/weather.js @@ -1,4 +1,4 @@ -// Canvas-based weather rendered behind the page. Two drivers feed it: +// Weather controller. Two drivers feed the background canvas: // // 1. The server picks a *seasonal* variant + intensity from the Lisbon-local // date and writes them to . This is the @@ -7,15 +7,21 @@ // forecast and calls PeteWeather.set(variant, intensity) to override the // background with live conditions. // -// Each variant has a hand-drawn shape so silhouettes are recognizable instead -// of a generic blob. Seasonal variants: rain, petals, jacaranda, motes, leaves. -// Live-weather variants add: clear, clouds, snow, fog, storm. +// Rendering is delegated to an engine: the WebGL2 one (weather-gl.js) when the +// browser supports it — GPU sprites, shader fog/aurora, real lightning — with +// the original Canvas2D renderer (weather-2d.js) as the fallback. This file +// only owns the toggle button, the on/off preference and the public API. (function () { var root = document.documentElement; var canvas = document.getElementById("pete-weather"); if (!canvas) return; var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + var engines = window.PeteWeatherEngines || {}; + var engine = (engines.webgl2 && engines.webgl2(canvas)) || null; + if (!engine && engines.canvas2d) engine = engines.canvas2d(canvas); + if (!engine) return; + var STORAGE_KEY = "pete-weather-off"; var toggleBtn = document.querySelector("[data-weather-toggle]"); var star = document.querySelector("[data-weather-star]"); @@ -41,504 +47,38 @@ } } - var ctx = canvas.getContext("2d"); - 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 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 and (re)start rendering if enabled. - function configure(v, inten) { - variant = 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)); - if (enabled) start(); - } - var enabled = !userDisabled() && !reducedMotion; - // Public hook so weather-forecast.js can swap the seasonal default for live - // conditions. Passing a falsy variant clears the canvas. + // Public hook so weather-forecast.js (and the /weather demo) can swap the + // seasonal default for other conditions. Passing a falsy variant clears the + // canvas. window.PeteWeather = { set: function (v, inten) { root.dataset.weather = v || ""; if (inten) root.dataset.intensity = inten; - configure(v, inten || intensity); + engine.set(v || null, inten || root.dataset.intensity || "heavy"); + if (enabled && v) engine.start(); }, - isEnabled: function () { return enabled; } + isEnabled: function () { return enabled; }, + renderer: function () { return engine.name; } }; syncBtn(enabled); - configure(root.dataset.weather, root.dataset.intensity); + engine.set(root.dataset.weather || null, root.dataset.intensity || "heavy"); + if (enabled && root.dataset.weather) engine.start(); if (toggleBtn) { toggleBtn.addEventListener("click", function () { enabled = !enabled; setDisabled(!enabled); syncBtn(enabled); - if (enabled) start(); else stop(); + if (enabled) engine.start(); else engine.stop(); }); } document.addEventListener("visibilitychange", function () { if (!enabled) return; - if (document.hidden) stop(); - else start(); + if (document.hidden) engine.stop(); + else engine.start(); }); })(); diff --git a/internal/web/static/sw.js b/internal/web/static/sw.js index f3e8776..fd0c2ef 100644 --- a/internal/web/static/sw.js +++ b/internal/web/static/sw.js @@ -4,7 +4,7 @@ // // Bump CACHE_VERSION whenever the precached shell assets change; activate() // drops every cache that doesn't match the current version. -var CACHE_VERSION = "v3"; +var CACHE_VERSION = "v4"; var SHELL_CACHE = "pete-shell-" + CACHE_VERSION; var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION; @@ -13,6 +13,8 @@ var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION; var SHELL_ASSETS = [ "/static/css/output.css", "/static/js/prefs.js", + "/static/js/weather-2d.js", + "/static/js/weather-gl.js", "/static/js/weather.js", "/static/js/weather-forecast.js", "/static/js/search.js", diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index ab79805..23a83b4 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -312,6 +312,8 @@ window.PETE_TTS = {{.TTS}}; + + diff --git a/internal/web/templates/weather.html b/internal/web/templates/weather.html index 281fcfe..d7147a9 100644 --- a/internal/web/templates/weather.html +++ b/internal/web/templates/weather.html @@ -3,14 +3,21 @@ {{define "main"}}
Pick a variant, intensity, and phase. The canvas reloads on each click.
+Pick a variant, intensity, and phase. Switching is instant, no reload.
{{.Weather.Season}} / {{.Variant}} / {{.Intensity}} / {{.PhaseSel}}
+ Current: {{.Variant}} / {{.Intensity}} / {{.PhaseSel}}
+ · Tip: aurora and clear night want the night phase; wind and haze are the new autumn gusts and Saharan calima.
Card B
-Move your window taller to give the particles more room.
+Make your window taller to give the particles more room.