找回密码
 立即注册
首页 业界区 业界 做了一个网页天气可视化

做了一个网页天气可视化

刘凤 9 小时前
搜索"网页天气效果",你大概率会找到两类东西:一类是纯 CSS 写的下雨动画,十几行代码,@keyframes 让 div 从上往下飘;另一类是"调用天气 API 展示温度"的教程,跟视觉效果没半点关系。
真正意义上的"沉浸式天气可视化"——雨滴打到界面元素上溅射、雪花堆积在导航栏、镜头光斑随太阳位置偏移——这类东西,中文社区几乎是空白。英文社区也好不到哪去,CodePen 上那些酷炫的效果基本不开源,或者用了 WebGL 库,拿过来改也费劲。
所以我干脆自己做了一个。
1.gif
项目用 Next.js + Canvas 2D + CSS 实现,支持晴天、雨天、雪天、阴天、雾天五种天气,每种都有一套可以实时调节的参数面板——雨量、风力、温度、雷暴概率、能见度,拖滑块即时生效。还接了 Open-Meteo 的免费 API,开启自动模式后会读取你的浏览器定位,展示你当前位置的真实天气。
在线体验:https://weather.anhejin.cn

开源地址:https://github.com/greywen/web-weather
Canvas、CSS、WebGL,选哪个

这是个经常被过度讨论的问题。
我的答案是:Canvas 2D 做粒子效果,CSS 做雾气和云层,WebGL 完全没用到。
不是说 WebGL 不好,是杀鸡用牛刀。雨滴最多也就两三百个粒子,雪花上限我设了五百个,Canvas 2D 跑起来 60fps 没什么压力。WebGL 的优势在几万个粒子以上,引入 Three.js 或者 raw WebGL 反而增加了整个项目的复杂度,调试也麻烦。
CSS 适合处理"大范围、有纹理感"的东西。雾气那层我用 CSS 做了烟雾纹理飘动,配合 backdrop-filter: blur() 做整体模糊感,效果比 Canvas 画出来的要自然很多。云层也是纯 CSS 动画,用 Web Animations API 做速度控制,这样可以根据风力参数实时改变云的移动速度,不用每帧重新计算。
Canvas 和 CSS 混用,有一个麻烦点:层叠顺序。Canvas 是一个 DOM 元素,CSS overlay 是另外几个 div,你得管好谁在谁上面,不然会出现 fog blur 把 Canvas 的 rain 也模糊掉这种情况。我在 WeatherProvider 里专门处理了这个,用 z-index 把各层分开,Canvas 在底,CSS fog 在上,控制面板最顶。
雨天:从一条线到溅射粒子

最早的版本,雨滴就是一条线——ctx.moveTo 到 ctx.lineTo,简单粗暴。后来改成了梯形,上窄下宽,模拟真实水滴下落时被空气拉扁的形态:
  1. // 梯形雨滴:上窄下宽const topHalfWidth = 0.3;const bottomHalfWidth = 1.2;ctx.moveTo(tx - topHalfWidth, ty);ctx.lineTo(tx + topHalfWidth, ty);ctx.lineTo(bx + bottomHalfWidth, by);  // bx = tx + windOffsetctx.lineTo(bx - bottomHalfWidth, by);
复制代码
风力通过 windOffset 让梯形底部偏移,雨滴看起来是斜着落的,比单纯的线条有质感多了。
数据结构上,雨滴没有用 class,而是用了 SoA(Struct of Arrays)布局——所有 x 坐标放一个 Float32Array,所有 y 坐标放另一个,速度、长度、透明度也各自一个数组。这样做的好处是内存连续访问,CPU 缓存命中率高,几百个粒子循环更新的时候比逐个对象访问快不少。绘制的时候按透明度分三档批量 ctx.fill(),一次 beginPath 画一批,减少 draw call。
溅射粒子也做了类似的处理——用一个固定大小的 Float32Array 对象池,移除死亡粒子时用 swap-and-pop(把末尾元素换到当前位置),O(1) 移除,不用 splice。画的时候统一一个 fillStyle,所有溅射点一次 fill 搞定。这个效果加进去之后整个场景的"物理感"一下子就上来了。
2.gif
雷暴是另一个让我花了不少时间的东西。闪电要有分叉,不然看起来就是一条直线,完全没感觉。我用了递归算法:
  1. functioncreateBolt(  x1: number, y1: number,  x2: number, y2: number,  depth: number) {if (depth === 0) return;const midX = (x1 + x2) / 2 + (Math.random() - 0.5) * 80;const midY = (y1 + y2) / 2 + (Math.random() - 0.5) * 20;  segments.push({ x1, y1, x2: midX, y2: midY });  segments.push({ x1: midX, y1: midY, x2, y2 });if (Math.random() > 0.5) {createBolt(midX, midY, midX + 60, midY + 80, depth - 1);  }createBolt(x1, y1, midX, midY, depth);createBolt(midX, midY, x2, y2, depth);}
复制代码
depth 控制分叉深度,我最多允许一层分支,再深下去视觉上反而乱。闪烁效果是每帧用 Math.random() > 0.8 随机跳过绘制,模拟真实闪电的频闪感。
积雪系统

雪花本身没什么特别的,飘落轨迹加点正弦波模拟摇摆就行。有意思的是积雪。
雪花落到导航栏上不会消失,而是堆积起来。SnowPile 系统记录每个雪花的落点,雪花越来越多,堆积层越来越厚。
然后是融化。温度参数控制融化速率,温度高于零度时,堆积的雪从边缘开始缩减。这里我没有做真实的物理模拟,就是每帧从 pile 列表里随机移除一定数量的点,配合渲染时按照 x 坐标排序画出轮廓,看起来是从两侧融化。
低温结冰是另一个细节。温度低于 -5°C 时,积雪颜色从白色渐变成冰蓝色,整个堆积层上面会覆盖一层半透明的白色渐变,模拟冻硬的质感。颜色插值用的是:
  1. const iceRatio = Math.min(1, (-temp - 5) / 15);const r = Math.round(220 - iceRatio * 60);const g = Math.round(235 - iceRatio * 20);const b = Math.round(255);ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
复制代码
效果出来之后比我预想的要好很多。看着雪一点点堆上去,然后调高温度,边缘开始融化,这个动态过程比静态截图有意思多了。
3.gif
积雪数量我设了 500 个点的上限。超过就停止新增,不然老设备上跑几分钟之后帧率会掉得很惨。
晴天:拿 Canvas 画镜头光斑

晴天是所有天气里视觉层次最多的,因为光效要叠很多层。
太阳本体是一个 radialGradient,从中心的亮白往外渐变到橙黄再到透明。外层加一圈辉光,半径更大,透明度更低,模拟大气散射。
镜头光斑(lens flare)是我专门花时间研究的东西。真实相机里,光斑是光穿过镜片折射产生的,位置和主光源成镜像关系——光源在右上,光斑出现在左下,而且距离和亮度都有规律。
我用了一个 distRatio 数组定义每个光斑相对于屏幕中心的偏移比例,然后根据太阳位置动态计算:
  1. flares.forEach((flare) => {const fx = screenCenterX + (sunX - screenCenterX) * flare.distRatio;const fy = screenCenterY + (sunY - screenCenterY) * flare.distRatio;const gradient = ctx.createRadialGradient(fx, fy, 0, fx, fy, flare.radius);  gradient.addColorStop(0, `rgba(255,255,200,${flare.alpha})`);  gradient.addColorStop(1, 'rgba(255,255,200,0)');  ctx.fillStyle = gradient;  ctx.fill();});
复制代码
distRatio 是负数时光斑在太阳的对侧,正数时在同侧。实际效果是太阳移动时,光斑会跟着漂移,整个画面有一种"正在用相机拍太阳"的感觉。
导航栏上还有一条反光条,单独用 clip 限定绘制区域,只在导航栏表面画一条弧形高光。这种细节多了,画面就显得精致很多。
昼夜系统我用了一个 0 到 24 的时间滑块,控制整体背景亮度和太阳/月亮的位置。凌晨三点是最暗的,正午最亮。亮度变化用的是 globalAlpha 叠一层半透明黑色蒙版,配合颜色色调偏移。夜晚的雨和雪也会相应变暗,不然会有种白天效果贴在夜空上的割裂感。
雾天:两套方案拼出来的

纯 Canvas 画雾不够自然,纯 CSS 做不出"你在雾里"的层次感,所以我把两个混在一起用。
Canvas 层画了 25 个大型 FogPuff。早期版本每个雾团每帧都 createRadialGradient 生成渐变,25 个雾团就是 25 次渐变创建,性能开销不小。后来改成了离屏 Canvas 预渲染:启动时在一个 256x256 的离屏 canvas 上画好渐变纹理,运行时用 drawImage + globalAlpha 控制透明度,不再每帧创建渐变对象。这一改雾天的帧率直接稳了。
CSS 层做纹理烟雾,用 mix-blend-mode: screen 叠加,透明烟雾纹理在画面上慢慢飘,这一层给雾补充细节纹理。最外层再加一个 backdrop-filter: blur() 的 div,模糊整个背景,加强"能见度低"的感觉。最后一圈 vignette 渐变压暗四周边缘,强化沉浸感。
能见度参数控制的是 CSS blur 的半径和 Canvas 雾团的透明度,两个联动,所以调一个滑块,三层效果同时变化。
天气切换动画

这个设计上花了一点心思。直接切换太生硬,fade out 再 fade in 又太慢,我最后用的是两层 Canvas + 两层 CSS overlay 同时淡入淡出。
新天气的 Canvas 从透明度 0 淡入,旧天气的 Canvas 从 1 淡出,两者交叠,过渡时间 800ms。缓动用的是 easeInOut:
  1. consteaseInOut = (t: number) =>  t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
复制代码
这个模式在 Canvas + React 的场景里几乎是必须的,但网上教程很少提到。很多人遇到"拖滑块动画卡一下"的问题,根本原因就在这里。
后面又做了一轮性能优化,主要是三件事:
一是把雨滴和溅射粒子从 class 实例改成 SoA + Float32Array。原来几百个 new RainDrop() 每个都是独立对象,GC 压力大,内存也不连续。改成 SoA 之后所有同类属性挨着存,循环跑起来对缓存友好很多。溅射粒子用固定大小的对象池,spawn 的时候写入下一个空位,死亡时 swap-and-pop 移除,整个生命周期零分配。
二是批量绘制。之前每个雨滴单独 beginPath + stroke,改成按透明度分三档,同一档的雨滴合进一个 path 一次 fill。溅射粒子更简单,统一颜色直接一把画完。Canvas 2D 的瓶颈很多时候不在像素量,而在 draw call 次数——state change 越少越快。
三是雾气的离屏 Canvas。25 个雾团每帧 createRadialGradient 太浪费了,改成启动时预渲染一张 256x256 的渐变纹理,运行时 drawImage 缩放绘制,用 globalAlpha 控制浓淡。这个改动对雾天帧率的提升最明显。
自动模式

我接了 Open-Meteo,完全免费,不需要 API key,直接 GET 请求就行。先用浏览器 navigator.geolocation 拿坐标,然后把经纬度传给 Open-Meteo,拿回 WMO 天气代码,再映射到我自己的五种天气类型。每十分钟刷新一次天气,每分钟更新一次时间,时间影响昼夜状态。
WMO 代码挺繁琐的,61-65 是不同强度的雨,71-77 是雪,各有各的范围,写映射表的时候对着文档抄了好一会儿。
做完这个项目之后,我把它放在家里俩个屏幕电脑上面上跑了一周。开着自动模式,放在第二屏当动态壁纸,效果意外地好。上海冬天那几天大雾,界面里真的飘着雾,雪花粒子跑起来有点废内存,但没到不能接受的程度。
代码写得不算优雅,1000 多行的 Canvas 组件本来应该拆开,但懒得动了,能跑就行。
有感兴趣的可以拿去改,或者告诉我哪里可以做得更好。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册