Compare commits
2 Commits
e91b423b1a
...
weather-fx
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a723418ff | ||
|
|
9b20040b49 |
@@ -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 ""
|
||||
}
|
||||
|
||||
487
internal/web/static/js/weather-2d.js
Normal file
487
internal/web/static/js/weather-2d.js
Normal file
@@ -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 };
|
||||
};
|
||||
})();
|
||||
1028
internal/web/static/js/weather-gl.js
Normal file
1028
internal/web/static/js/weather-gl.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 <html data-weather / data-intensity>. 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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -312,6 +312,8 @@
|
||||
window.PETE_TTS = {{.TTS}};
|
||||
</script>
|
||||
<script src="/static/js/prefs.js" defer></script>
|
||||
<script src="/static/js/weather-2d.js" defer></script>
|
||||
<script src="/static/js/weather-gl.js" defer></script>
|
||||
<script src="/static/js/weather.js" defer></script>
|
||||
<script src="/static/js/weather-forecast.js" defer></script>
|
||||
<script src="/static/js/search.js" defer></script>
|
||||
|
||||
@@ -3,14 +3,21 @@
|
||||
{{define "main"}}
|
||||
<div class="space-y-8">
|
||||
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<h1 class="font-display text-3xl font-bold mb-1">Weather demo</h1>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. The canvas reloads on each click.</p>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
|
||||
<h1 class="font-display text-3xl font-bold">Weather demo</h1>
|
||||
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||
<span data-demo-renderer class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 uppercase tracking-wider text-[color:var(--ink)]/60">renderer: …</span>
|
||||
<span data-demo-fps class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 tabular-nums text-[color:var(--ink)]/60">— fps</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. Switching is instant, no reload.</p>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
|
||||
{{range $v := .Variants}}
|
||||
<a href="?variant={{$v}}&intensity={{$.Intensity}}&phase={{$.PhaseSel}}"
|
||||
data-demo-variant="{{$v}}"
|
||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
|
||||
{{end}}
|
||||
@@ -19,6 +26,7 @@
|
||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
|
||||
{{range $i := .Intensities}}
|
||||
<a href="?variant={{$.Variant}}&intensity={{$i}}&phase={{$.PhaseSel}}"
|
||||
data-demo-intensity="{{$i}}"
|
||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
|
||||
{{end}}
|
||||
@@ -27,6 +35,7 @@
|
||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
|
||||
{{range $p := .Phases}}
|
||||
<a href="?variant={{$.Variant}}&intensity={{$.Intensity}}&phase={{$p}}"
|
||||
data-demo-phase="{{$p}}"
|
||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
|
||||
{{end}}
|
||||
@@ -34,7 +43,8 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
|
||||
Current: <code class="font-semibold">{{.Weather.Season}} / {{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
|
||||
Current: <code data-demo-current class="font-semibold">{{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
|
||||
<span class="mx-1">·</span> Tip: aurora and clear night want the night phase; wind and haze are the new autumn gusts and Saharan calima.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -50,8 +60,76 @@
|
||||
</div>
|
||||
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<p class="font-display">Card B</p>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Move your window taller to give the particles more room.</p>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Make your window taller to give the particles more room.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Instant switching: the pill links stay real URLs for no-JS visitors, but
|
||||
// with JS we swap the effect in place via PeteWeather.set and keep the URL
|
||||
// in sync with replaceState.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var root = document.documentElement;
|
||||
var state = {
|
||||
variant: {{.Variant}},
|
||||
intensity: {{.Intensity}},
|
||||
phase: {{.PhaseSel}}
|
||||
};
|
||||
|
||||
function markActive(attr, value) {
|
||||
document.querySelectorAll("[" + attr + "]").forEach(function (el) {
|
||||
var on = el.getAttribute(attr) === value;
|
||||
el.classList.toggle("bg-[color:var(--accent)]", on);
|
||||
el.classList.toggle("text-white", on);
|
||||
el.classList.toggle("font-semibold", on);
|
||||
});
|
||||
}
|
||||
|
||||
function apply() {
|
||||
root.dataset.phase = state.phase;
|
||||
if (window.PeteWeather) window.PeteWeather.set(state.variant, state.intensity);
|
||||
markActive("data-demo-variant", state.variant);
|
||||
markActive("data-demo-intensity", state.intensity);
|
||||
markActive("data-demo-phase", state.phase);
|
||||
var cur = document.querySelector("[data-demo-current]");
|
||||
if (cur) cur.textContent = state.variant + " / " + state.intensity + " / " + state.phase;
|
||||
var label = document.querySelector("[data-phase-label]");
|
||||
if (label) label.textContent = state.phase;
|
||||
history.replaceState(null, "", "?variant=" + state.variant + "&intensity=" + state.intensity + "&phase=" + state.phase);
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
var a = e.target.closest && e.target.closest("[data-demo-variant],[data-demo-intensity],[data-demo-phase]");
|
||||
if (!a || !window.PeteWeather) return;
|
||||
e.preventDefault();
|
||||
if (a.hasAttribute("data-demo-variant")) state.variant = a.getAttribute("data-demo-variant");
|
||||
if (a.hasAttribute("data-demo-intensity")) state.intensity = a.getAttribute("data-demo-intensity");
|
||||
if (a.hasAttribute("data-demo-phase")) state.phase = a.getAttribute("data-demo-phase");
|
||||
apply();
|
||||
});
|
||||
|
||||
var rEl = document.querySelector("[data-demo-renderer]");
|
||||
if (rEl && window.PeteWeather && window.PeteWeather.renderer) {
|
||||
rEl.textContent = "renderer: " + window.PeteWeather.renderer();
|
||||
}
|
||||
|
||||
// Lightweight FPS meter, demo page only. Counts frames on its own rAF and
|
||||
// refreshes the pill once a second.
|
||||
var fpsEl = document.querySelector("[data-demo-fps]");
|
||||
if (fpsEl) {
|
||||
var frames = 0, lastStamp = performance.now();
|
||||
function tick(now) {
|
||||
frames++;
|
||||
if (now - lastStamp >= 1000) {
|
||||
fpsEl.textContent = Math.round(frames * 1000 / (now - lastStamp)) + " fps";
|
||||
frames = 0;
|
||||
lastStamp = now;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user