Canvas-based seasonal weather effects + demo page
Server picks variant from Lisbon-local date (rain/petals/jacaranda/motes/leaves) and renders behind content via a canvas particle layer. Each variant has a hand-drawn silhouette so shapes are recognizable. /weather demo route exposes variant + intensity + phase pickers, locking the time-of-day phase override.
This commit is contained in:
File diff suppressed because one or more lines are too long
246
internal/web/static/js/weather.js
Normal file
246
internal/web/static/js/weather.js
Normal file
@@ -0,0 +1,246 @@
|
||||
// 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;
|
||||
if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
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);
|
||||
}
|
||||
raf = requestAnimationFrame(step);
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
} else if (!raf) {
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user