From 50f6e1dfe34772e27dd49b3206940352fc383002 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 4 Mar 2026 01:59:18 +0800 Subject: [PATCH] feat: add real-time weather animations (rain, snow, fog, storm, clouds) --- web/static/app.js | 8 ++ web/static/index.html | 6 + web/static/style.css | 71 +++++++++ web/static/weather-anim.js | 285 +++++++++++++++++++++++++++++++++++++ 4 files changed, 370 insertions(+) create mode 100644 web/static/weather-anim.js diff --git a/web/static/app.js b/web/static/app.js index 2914e1c9..1210a788 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -255,6 +255,12 @@ function renderPanel(data) { // Trigger reflow for animation requestAnimationFrame(() => panel.classList.add("visible")); + // Start weather animation + if (typeof WeatherAnim !== "undefined") { + // Small delay for canvas to be in DOM + setTimeout(() => WeatherAnim.start(data), 100); + } + // Header document.getElementById("panelCityName").textContent = `${data.risk?.emoji || "πŸ™οΈ"} ${data.display_name}`; @@ -710,6 +716,8 @@ function closePanel() { document .querySelectorAll(".city-item") .forEach((el) => el.classList.remove("active")); + // Stop weather animation + if (typeof WeatherAnim !== "undefined") WeatherAnim.stop(); } // ────────────────────────────────────────────────────────── diff --git a/web/static/index.html b/web/static/index.html index ebe2679f..d6d1ad89 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -63,6 +63,11 @@
+ +
+ +
+
@@ -148,6 +153,7 @@ + diff --git a/web/static/style.css b/web/static/style.css index 479d5c31..a5272ef6 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -495,10 +495,81 @@ body { letter-spacing: 0.3px; } +/* ── Weather Animation ── */ +.weather-anim-container { + position: relative; + width: 100%; + height: 200px; + overflow: hidden; + border-radius: 16px; + margin-bottom: -180px; /* Overlap with hero */ + z-index: 0; +} + +.weather-anim-container canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +/* Night sky gradient for the animation background */ +.weather-anim-container.active { + background: linear-gradient( + 180deg, + rgba(10, 14, 30, 0.9) 0%, + rgba(15, 20, 40, 0.6) 40%, + rgba(10, 14, 26, 0.3) 100% + ); +} +.weather-anim-container.clear { + background: linear-gradient( + 180deg, + rgba(30, 58, 95, 0.5) 0%, + rgba(15, 25, 50, 0.3) 60%, + transparent 100% + ); +} +.weather-anim-container.rain { + background: linear-gradient( + 180deg, + rgba(30, 40, 60, 0.7) 0%, + rgba(20, 30, 50, 0.4) 60%, + transparent 100% + ); +} +.weather-anim-container.snow { + background: linear-gradient( + 180deg, + rgba(50, 60, 80, 0.6) 0%, + rgba(30, 40, 60, 0.3) 60%, + transparent 100% + ); +} +.weather-anim-container.fog { + background: linear-gradient( + 180deg, + rgba(100, 110, 130, 0.4) 0%, + rgba(60, 70, 90, 0.2) 60%, + transparent 100% + ); +} +.weather-anim-container.storm { + background: linear-gradient( + 180deg, + rgba(20, 20, 35, 0.85) 0%, + rgba(15, 15, 30, 0.5) 60%, + transparent 100% + ); +} + /* ── Hero Section ── */ .hero-section { text-align: center; padding-top: 12px !important; + position: relative; + z-index: 1; } .hero-weather { diff --git a/web/static/weather-anim.js b/web/static/weather-anim.js new file mode 100644 index 00000000..c6d1dc23 --- /dev/null +++ b/web/static/weather-anim.js @@ -0,0 +1,285 @@ +// ────────────────────────────────────────────────────────── +// Weather Animation Engine +// Canvas-based particle system for rain, snow, fog, etc. +// ────────────────────────────────────────────────────────── + +const WeatherAnim = (() => { + let canvas, ctx; + let animId = null; + let particles = []; + let currentType = null; + let lightningTimer = 0; + let lightningFlash = 0; + + const CONFIGS = { + rain: { + count: 120, + init: (w, h) => ({ + x: Math.random() * w * 1.2 - w * 0.1, + y: Math.random() * h - h, + len: 12 + Math.random() * 18, + speed: 8 + Math.random() * 6, + opacity: 0.2 + Math.random() * 0.4, + wind: 2 + Math.random() * 2, + }), + draw: (p, ctx) => { + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x + p.wind * 0.5, p.y + p.len); + ctx.strokeStyle = `rgba(174, 214, 241, ${p.opacity})`; + ctx.lineWidth = 1.5; + ctx.stroke(); + }, + update: (p, w, h) => { + p.y += p.speed; + p.x += p.wind; + if (p.y > h) { + p.y = -p.len; + p.x = Math.random() * w * 1.2 - w * 0.1; + } + }, + }, + + heavyrain: { + count: 200, + init: (w, h) => ({ + x: Math.random() * w * 1.3 - w * 0.15, + y: Math.random() * h - h, + len: 18 + Math.random() * 24, + speed: 12 + Math.random() * 8, + opacity: 0.3 + Math.random() * 0.5, + wind: 4 + Math.random() * 3, + }), + draw: (p, ctx) => { + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x + p.wind * 0.6, p.y + p.len); + ctx.strokeStyle = `rgba(160, 200, 230, ${p.opacity})`; + ctx.lineWidth = 2; + ctx.stroke(); + }, + update: (p, w, h) => { + p.y += p.speed; + p.x += p.wind; + if (p.y > h) { + p.y = -p.len; + p.x = Math.random() * w * 1.3 - w * 0.15; + } + }, + }, + + snow: { + count: 80, + init: (w, h) => ({ + x: Math.random() * w, + y: Math.random() * h - h, + r: 1.5 + Math.random() * 3, + speed: 0.8 + Math.random() * 1.5, + opacity: 0.4 + Math.random() * 0.4, + wobble: Math.random() * Math.PI * 2, + wobbleSpeed: 0.01 + Math.random() * 0.03, + wobbleAmp: 0.5 + Math.random() * 1.5, + }), + draw: (p, ctx) => { + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255, 255, 255, ${p.opacity})`; + ctx.fill(); + }, + update: (p, w, h) => { + p.y += p.speed; + p.wobble += p.wobbleSpeed; + p.x += Math.sin(p.wobble) * p.wobbleAmp; + if (p.y > h + p.r) { + p.y = -p.r * 2; + p.x = Math.random() * w; + } + }, + }, + + fog: { + count: 15, + init: (w, h) => ({ + x: Math.random() * w, + y: 20 + Math.random() * (h - 40), + w: 120 + Math.random() * 200, + h: 30 + Math.random() * 60, + speed: 0.15 + Math.random() * 0.3, + opacity: 0.03 + Math.random() * 0.08, + phase: Math.random() * Math.PI * 2, + }), + draw: (p, ctx) => { + const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.w / 2); + const osc = Math.sin(p.phase) * 0.02; + grad.addColorStop(0, `rgba(200, 210, 230, ${p.opacity + osc})`); + grad.addColorStop(1, "rgba(200, 210, 230, 0)"); + ctx.fillStyle = grad; + ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h); + }, + update: (p, w) => { + p.x += p.speed; + p.phase += 0.01; + if (p.x - p.w / 2 > w) { + p.x = -p.w / 2; + } + }, + }, + + clear: { + count: 0, + init: () => ({}), + draw: () => {}, + update: () => {}, + }, + + cloudy: { + count: 8, + init: (w, h) => ({ + x: Math.random() * w, + y: 10 + Math.random() * 60, + w: 100 + Math.random() * 160, + h: 25 + Math.random() * 35, + speed: 0.1 + Math.random() * 0.15, + opacity: 0.04 + Math.random() * 0.06, + }), + draw: (p, ctx) => { + const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.w / 2); + grad.addColorStop(0, `rgba(150, 160, 180, ${p.opacity})`); + grad.addColorStop(1, "rgba(150, 160, 180, 0)"); + ctx.fillStyle = grad; + ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h); + }, + update: (p, w) => { + p.x += p.speed; + if (p.x - p.w / 2 > w) p.x = -p.w / 2; + }, + }, + }; + + function detectWeatherType(data) { + if (!data || !data.current) return "clear"; + const wx = (data.current.wx_desc || "").toUpperCase(); + const cloud = (data.current.cloud_desc || "").toLowerCase(); + + // Priority: precipitation > fog > clouds > clear + if (wx.includes("TS") || wx.includes("TSRA")) return "storm"; + if (wx.includes("+RA") || wx.includes("+SN")) return "heavyrain"; + if (wx.includes("RA") || wx.includes("DZ") || wx.includes("SHRA")) + return "rain"; + if (wx.includes("SN") || wx.includes("GR") || wx.includes("GS")) + return "snow"; + if (wx.includes("FG") || wx.includes("BR") || wx.includes("HZ")) + return "fog"; + + // Cloud-based + if (cloud.includes("阴倩") || cloud.includes("ε€šδΊ‘")) return "cloudy"; + + return "clear"; + } + + function initCanvas() { + canvas = document.getElementById("weatherCanvas"); + if (!canvas) return false; + const container = canvas.parentElement; + canvas.width = container.offsetWidth * window.devicePixelRatio; + canvas.height = container.offsetHeight * window.devicePixelRatio; + canvas.style.width = container.offsetWidth + "px"; + canvas.style.height = container.offsetHeight + "px"; + ctx = canvas.getContext("2d"); + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + return true; + } + + function createParticles(type) { + const config = CONFIGS[type] || CONFIGS.clear; + const container = canvas.parentElement; + const w = container.offsetWidth; + const h = container.offsetHeight; + particles = []; + for (let i = 0; i < config.count; i++) { + particles.push(config.init(w, h)); + } + } + + function animate() { + if (!ctx || !canvas) return; + const container = canvas.parentElement; + const w = container.offsetWidth; + const h = container.offsetHeight; + const config = CONFIGS[currentType] || CONFIGS.clear; + + ctx.clearRect(0, 0, w, h); + + // Lightning effect for storms + if (currentType === "storm") { + lightningTimer++; + if (lightningTimer > 120 + Math.random() * 200) { + lightningFlash = 6; + lightningTimer = 0; + } + if (lightningFlash > 0) { + const alpha = (lightningFlash / 6) * 0.25; + ctx.fillStyle = `rgba(200, 220, 255, ${alpha})`; + ctx.fillRect(0, 0, w, h); + lightningFlash--; + } + + // Storm uses heavyrain particles + const stormConfig = CONFIGS.heavyrain; + particles.forEach((p) => { + stormConfig.draw(p, ctx); + stormConfig.update(p, w, h); + }); + } else { + particles.forEach((p) => { + config.draw(p, ctx); + config.update(p, w, h); + }); + } + + animId = requestAnimationFrame(animate); + } + + function start(data) { + stop(); + if (!initCanvas()) return; + + const type = detectWeatherType(data); + currentType = type; + + // Set container CSS class for background gradient + const container = document.getElementById("weatherAnimContainer"); + container.className = "weather-anim-container active " + type; + + // Storm uses heavyrain particles config + if (type === "storm") { + const stormW = container.offsetWidth; + const stormH = container.offsetHeight; + particles = []; + for (let i = 0; i < CONFIGS.heavyrain.count; i++) { + particles.push(CONFIGS.heavyrain.init(stormW, stormH)); + } + lightningTimer = 0; + lightningFlash = 0; + } else { + createParticles(type); + } + + animate(); + } + + function stop() { + if (animId) { + cancelAnimationFrame(animId); + animId = null; + } + particles = []; + currentType = null; + const container = document.getElementById("weatherAnimContainer"); + if (container) { + container.className = "weather-anim-container"; + } + } + + return { start, stop, detectWeatherType }; +})();