Files
Pete/internal/web/static/js/weather.js
2026-05-25 18:06:23 -07:00

289 lines
9.3 KiB
JavaScript

// Canvas-based seasonal weather. The server picks variant + intensity from
// the Lisbon-local date; this script reads those off <html data-*> and
// renders the appropriate particles. Each variant has a hand-drawn shape
// so silhouettes are recognizable instead of a generic blob.
(function () {
var root = document.documentElement;
var canvas = document.getElementById("pete-weather");
if (!canvas) return;
var variant = root.dataset.weather;
if (!variant) return;
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var STORAGE_KEY = "pete-weather-off";
var toggleBtn = document.querySelector("[data-weather-toggle]");
var star = document.querySelector("[data-weather-star]");
function userDisabled() {
try { return localStorage.getItem(STORAGE_KEY) === "1"; } catch (e) { return false; }
}
function setDisabled(v) {
try {
if (v) localStorage.setItem(STORAGE_KEY, "1");
else localStorage.removeItem(STORAGE_KEY);
} catch (e) {}
}
function syncBtn(enabled) {
if (!toggleBtn) return;
toggleBtn.setAttribute("aria-pressed", enabled ? "true" : "false");
toggleBtn.title = enabled ? "Turn off weather animation" : "Turn on weather animation";
if (star) {
star.style.color = "var(--accent)";
star.style.opacity = enabled ? "1" : "0.3";
star.style.filter = enabled ? "none" : "grayscale(1)";
}
}
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 };
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
var N = Math.round((counts[variant] || 0) * (mults[root.dataset.intensity] || 1));
function rand(a, b) { return a + Math.random() * (b - a); }
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") {
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 === "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 particles = [];
for (var i = 0; i < N; i++) particles.push(spawn(true));
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 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();
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
if (variant === "rain") {
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 === "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);
}
var enabled = !userDisabled() && !reducedMotion;
syncBtn(enabled);
if (enabled) start();
if (toggleBtn) {
toggleBtn.addEventListener("click", function () {
enabled = !enabled;
setDisabled(!enabled);
syncBtn(enabled);
if (enabled) start(); else stop();
});
}
document.addEventListener("visibilitychange", function () {
if (!enabled) return;
if (document.hidden) stop();
else start();
});
})();