广州塔的塔 發表於 2026-3-18 07:47:00

做了一个网页天气可视化

<p dir="auto" data-line="2" data-pm-slice="2 2 []"><span><span>搜索"网页天气效果",你大概率会找到两类东西:一类是纯 CSS 写的下雨动画,十几行代码,@keyframes 让 div 从上往下飘;另一类是"调用天气 API 展示温度"的教程,跟视觉效果没半点关系。</span></span></p>
<p dir="auto" data-line="4"><span><span>真正意义上的"沉浸式天气可视化"——雨滴打到界面元素上溅射、雪花堆积在导航栏、镜头光斑随太阳位置偏移——这类东西,中文社区几乎是空白。英文社区也好不到哪去,CodePen 上那些酷炫的效果基本不开源,或者用了 WebGL 库,拿过来改也费劲。</span></span></p>
<p dir="auto" data-line="6"><span><span>所以我干脆自己做了一个。</span></span></p>
<img class="rich_pages wxw-img lazyload" data-src="https://img2024.cnblogs.com/blog/809672/202603/809672-20260311154056611-1441874793.gif" data-ratio="0.5632352941176471" data-type="gif" data-w="680" data-imgfileid="100000012" data-imgqrcoded="1" data-aistatus="1">
<p dir="auto" data-line="10"><span><span>项目用 Next.js + Canvas 2D + CSS 实现,支持晴天、雨天、雪天、阴天、雾天五种天气,每种都有一套可以实时调节的参数面板——雨量、风力、温度、雷暴概率、能见度,拖滑块即时生效。还接了 Open-Meteo 的免费 API,开启自动模式后会读取你的浏览器定位,展示你当前位置的真实天气。</span></span></p>
<p dir="auto" data-line="14"><span><span>在线体验:https://weather.anhejin.cn<span><br></span></span></span></p>
<p dir="auto" data-line="14"><span><span>开源地址:https://github.com/greywen/web-weather</span></span></p>
<hr>
<h2 dir="auto" data-line="18"><span><span>Canvas、CSS、WebGL,选哪个</span></span></h2>
<p dir="auto" data-line="20"><span><span>这是个经常被过度讨论的问题。</span></span></p>
<p dir="auto" data-line="22"><span><span>我的答案是:Canvas 2D 做粒子效果,CSS 做雾气和云层,WebGL 完全没用到。</span></span></p>
<p dir="auto" data-line="24"><span><span>不是说 WebGL 不好,是杀鸡用牛刀。雨滴最多也就两三百个粒子,雪花上限我设了五百个,Canvas 2D 跑起来 60fps 没什么压力。WebGL 的优势在几万个粒子以上,引入 Three.js 或者 raw WebGL 反而增加了整个项目的复杂度,调试也麻烦。</span></span></p>
<p dir="auto" data-line="26"><span><span>CSS 适合处理"大范围、有纹理感"的东西。雾气那层我用 CSS 做了烟雾纹理飘动,配合 backdrop-filter: blur() 做整体模糊感,效果比 Canvas 画出来的要自然很多。云层也是纯 CSS 动画,用 Web Animations API 做速度控制,这样可以根据风力参数实时改变云的移动速度,不用每帧重新计算。</span></span></p>
<p dir="auto" data-line="28"><span><span>Canvas 和 CSS 混用,有一个麻烦点:层叠顺序。Canvas 是一个 DOM 元素,CSS overlay 是另外几个 div,你得管好谁在谁上面,不然会出现 fog blur 把 Canvas 的 rain 也模糊掉这种情况。我在 WeatherProvider 里专门处理了这个,用 z-index 把各层分开,Canvas 在底,CSS fog 在上,控制面板最顶。</span></span></p>
<hr>
<h2 dir="auto" data-line="32"><span><span>雨天:从一条线到溅射粒子</span></span></h2>
<p dir="auto" data-line="34"><span><span>最早的版本,雨滴就是一条线——ctx.moveTo 到 ctx.lineTo,简单粗暴。后来改成了梯形,上窄下宽,模拟真实水滴下落时被空气拉扁的形态:</span></span></p>
<pre><code dir="auto" data-line="36">// 梯形雨滴:上窄下宽<span>const<span>&nbsp;topHalfWidth =&nbsp;0.3<span>;<span>const<span>&nbsp;bottomHalfWidth =&nbsp;1.2<span>;<span><span>ctx.moveTo<span>(tx - topHalfWidth, ty);<span><span>ctx.lineTo<span>(tx + topHalfWidth, ty);<span><span>ctx.lineTo<span>(bx + bottomHalfWidth, by); &nbsp;// bx = tx + windOffset<span><span>ctx.lineTo<span>(bx - bottomHalfWidth, by);</span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></code></pre>
<p dir="auto" data-line="46"><span><span>风力通过 windOffset 让梯形底部偏移,雨滴看起来是斜着落的,比单纯的线条有质感多了。</span></span></p>
<p dir="auto" data-line="48"><span><span>数据结构上,雨滴没有用 class,而是用了 SoA(Struct of Arrays)布局——所有 x 坐标放一个 Float32Array,所有 y 坐标放另一个,速度、长度、透明度也各自一个数组。这样做的好处是内存连续访问,CPU 缓存命中率高,几百个粒子循环更新的时候比逐个对象访问快不少。绘制的时候按透明度分三档批量 ctx.fill(),一次 beginPath 画一批,减少 draw call。</span></span></p>
<p dir="auto" data-line="50"><span><span>溅射粒子也做了类似的处理——用一个固定大小的 Float32Array 对象池,移除死亡粒子时用 swap-and-pop(把末尾元素换到当前位置),O(1) 移除,不用 splice。画的时候统一一个 fillStyle,所有溅射点一次 fill 搞定。这个效果加进去之后整个场景的"物理感"一下子就上来了。</span></span></p>
<img class="rich_pages wxw-img lazyload" data-src="https://img2024.cnblogs.com/blog/809672/202603/809672-20260311154057197-803319514.gif" data-ratio="0.5632352941176471" data-type="gif" data-w="680" data-imgfileid="100000013" data-imgqrcoded="1" data-aistatus="1">
<p dir="auto" data-line="54"><span><span>雷暴是另一个让我花了不少时间的东西。闪电要有分叉,不然看起来就是一条直线,完全没感觉。我用了递归算法:</span></span></p>
<pre><code dir="auto" data-line="56">functioncreateBolt<span>(<span><span>&nbsp; x1:&nbsp;<span>number<span>, y1:&nbsp;<span>number<span>,<span><span>&nbsp; x2:&nbsp;<span>number<span>, y2:&nbsp;<span>number<span>,<span><span>&nbsp; depth:&nbsp;<span>number<span><span>) {<span>if<span>&nbsp;(depth ===&nbsp;0<span>)&nbsp;return<span>;<span>const<span>&nbsp;midX = (x1 + x2) /&nbsp;2<span>&nbsp;+ (Math<span>.random<span>() -&nbsp;0.5<span>) *&nbsp;80<span>;<span>const<span>&nbsp;midY = (y1 + y2) /&nbsp;2<span>&nbsp;+ (Math<span>.random<span>() -&nbsp;0.5<span>) *&nbsp;20<span>;<span><span>&nbsp; segments.push<span>({ x1, y1,&nbsp;x2<span>: midX,&nbsp;y2<span>: midY });<span><span>&nbsp; segments.push<span>({&nbsp;x1<span>: midX,&nbsp;y1<span>: midY, x2, y2 });<span>if<span>&nbsp;(Math<span>.random<span>() &gt;&nbsp;0.5<span>) {<span>createBolt<span>(midX, midY, midX +&nbsp;60<span>, midY +&nbsp;80<span>, depth -&nbsp;1<span>);<span><span>&nbsp; }<span>createBolt<span>(x1, y1, midX, midY, depth);<span>createBolt<span>(midX, midY, x2, y2, depth);<span><span>}</span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></code></pre>
<p dir="auto" data-line="75"><span><span>depth 控制分叉深度,我最多允许一层分支,再深下去视觉上反而乱。闪烁效果是每帧用 Math.random() &gt; 0.8 随机跳过绘制,模拟真实闪电的频闪感。</span></span></p>
<hr>
<h2 dir="auto" data-line="79"><span><span>积雪系统</span></span></h2>
<p dir="auto" data-line="81"><span><span>雪花本身没什么特别的,飘落轨迹加点正弦波模拟摇摆就行。有意思的是积雪。</span></span></p>
<p dir="auto" data-line="83"><span><span>雪花落到导航栏上不会消失,而是堆积起来。SnowPile 系统记录每个雪花的落点,雪花越来越多,堆积层越来越厚。</span></span></p>
<p dir="auto" data-line="85"><span><span>然后是融化。温度参数控制融化速率,温度高于零度时,堆积的雪从边缘开始缩减。这里我没有做真实的物理模拟,就是每帧从 pile 列表里随机移除一定数量的点,配合渲染时按照 x 坐标排序画出轮廓,看起来是从两侧融化。</span></span></p>
<p dir="auto" data-line="87"><span><span>低温结冰是另一个细节。温度低于 -5°C 时,积雪颜色从白色渐变成冰蓝色,整个堆积层上面会覆盖一层半透明的白色渐变,模拟冻硬的质感。颜色插值用的是:</span></span></p>
<pre><code dir="auto" data-line="89">const<span>&nbsp;iceRatio =&nbsp;Math<span>.min<span>(1<span>, (-temp -&nbsp;5<span>) /&nbsp;15<span>);<span>const<span>&nbsp;r =&nbsp;Math<span>.round<span>(220<span>&nbsp;- iceRatio *&nbsp;60<span>);<span>const<span>&nbsp;g =&nbsp;Math<span>.round<span>(235<span>&nbsp;- iceRatio *&nbsp;20<span>);<span>const<span>&nbsp;b =&nbsp;Math<span>.round<span>(255<span>);<span><span>ctx.<span>fillStyle<span>&nbsp;=&nbsp;<span>`rgb(<span>${r}<span>,&nbsp;<span>${g}<span>,&nbsp;<span>${b}<span>)`<span>;</span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></code></pre>
<p dir="auto" data-line="97"><span><span>效果出来之后比我预想的要好很多。看着雪一点点堆上去,然后调高温度,边缘开始融化,这个动态过程比静态截图有意思多了。</span></span></p>
<img class="rich_pages wxw-img lazyload" data-src="https://img2024.cnblogs.com/blog/809672/202603/809672-20260311154056532-723480073.gif" data-ratio="0.5632352941176471" data-type="gif" data-w="680" data-imgfileid="100000011" data-imgqrcoded="1" data-aistatus="1">
<p dir="auto" data-line="101"><span><span>积雪数量我设了 500 个点的上限。超过就停止新增,不然老设备上跑几分钟之后帧率会掉得很惨。</span></span></p>
<hr>
<h2 dir="auto" data-line="105"><span><span>晴天:拿 Canvas 画镜头光斑</span></span></h2>
<p dir="auto" data-line="107"><span><span>晴天是所有天气里视觉层次最多的,因为光效要叠很多层。</span></span></p>
<p dir="auto" data-line="109"><span><span>太阳本体是一个 radialGradient,从中心的亮白往外渐变到橙黄再到透明。外层加一圈辉光,半径更大,透明度更低,模拟大气散射。</span></span></p>
<p dir="auto" data-line="111"><span><span>镜头光斑(lens flare)是我专门花时间研究的东西。真实相机里,光斑是光穿过镜片折射产生的,位置和主光源成镜像关系——光源在右上,光斑出现在左下,而且距离和亮度都有规律。</span></span></p>
<p dir="auto" data-line="113"><span><span>我用了一个 distRatio 数组定义每个光斑相对于屏幕中心的偏移比例,然后根据太阳位置动态计算:</span></span></p>
<pre><code dir="auto" data-line="115"><span>flares.forEach<span>(<span>(<span>flare<span>) =&gt;<span>&nbsp;{<span>const<span>&nbsp;fx = screenCenterX + (sunX - screenCenterX) * flare.<span>distRatio<span>;<span>const<span>&nbsp;fy = screenCenterY + (sunY - screenCenterY) * flare.<span>distRatio<span>;<span>const<span>&nbsp;gradient = ctx.createRadialGradient<span>(fx, fy,&nbsp;0<span>, fx, fy, flare.<span>radius<span>);<span><span>&nbsp; gradient.addColorStop<span>(0<span>,&nbsp;<span>`rgba(255,255,200,<span>${flare.alpha}<span>)`<span>);<span><span>&nbsp; gradient.addColorStop<span>(1<span>,&nbsp;'rgba(255,255,200,0)'<span>);<span><span>&nbsp; ctx.<span>fillStyle<span>&nbsp;= gradient;<span><span>&nbsp; ctx.fill<span>();<span><span>});</span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></code></pre>
<p dir="auto" data-line="127"><span><span>distRatio 是负数时光斑在太阳的对侧,正数时在同侧。实际效果是太阳移动时,光斑会跟着漂移,整个画面有一种"正在用相机拍太阳"的感觉。</span></span></p>
<p dir="auto" data-line="129"><span><span>导航栏上还有一条反光条,单独用 clip 限定绘制区域,只在导航栏表面画一条弧形高光。这种细节多了,画面就显得精致很多。</span></span></p>
<hr>
<p dir="auto" data-line="133"><span><span>昼夜系统我用了一个 0 到 24 的时间滑块,控制整体背景亮度和太阳/月亮的位置。凌晨三点是最暗的,正午最亮。亮度变化用的是 globalAlpha 叠一层半透明黑色蒙版,配合颜色色调偏移。夜晚的雨和雪也会相应变暗,不然会有种白天效果贴在夜空上的割裂感。</span></span></p>
<hr>
<h2 dir="auto" data-line="137"><span><span>雾天:两套方案拼出来的</span></span></h2>
<p dir="auto" data-line="139"><span><span>纯 Canvas 画雾不够自然,纯 CSS 做不出"你在雾里"的层次感,所以我把两个混在一起用。</span></span></p>
<p dir="auto" data-line="141"><span><span>Canvas 层画了 25 个大型 FogPuff。早期版本每个雾团每帧都 createRadialGradient 生成渐变,25 个雾团就是 25 次渐变创建,性能开销不小。后来改成了离屏 Canvas 预渲染:启动时在一个 256x256 的离屏 canvas 上画好渐变纹理,运行时用 drawImage + globalAlpha 控制透明度,不再每帧创建渐变对象。这一改雾天的帧率直接稳了。</span></span></p>
<p dir="auto" data-line="143"><span><span>CSS 层做纹理烟雾,用 mix-blend-mode: screen 叠加,透明烟雾纹理在画面上慢慢飘,这一层给雾补充细节纹理。最外层再加一个 backdrop-filter: blur() 的 div,模糊整个背景,加强"能见度低"的感觉。最后一圈 vignette 渐变压暗四周边缘,强化沉浸感。</span></span></p>
<p dir="auto" data-line="145"><span><span>能见度参数控制的是 CSS blur 的半径和 Canvas 雾团的透明度,两个联动,所以调一个滑块,三层效果同时变化。</span></span></p>
<hr>
<h2 dir="auto" data-line="149"><span><span>天气切换动画</span></span></h2>
<p dir="auto" data-line="151"><span><span>这个设计上花了一点心思。直接切换太生硬,fade out 再 fade in 又太慢,我最后用的是两层 Canvas + 两层 CSS overlay 同时淡入淡出。</span></span></p>
<p dir="auto" data-line="153"><span><span>新天气的 Canvas 从透明度 0 淡入,旧天气的 Canvas 从 1 淡出,两者交叠,过渡时间 800ms。缓动用的是 easeInOut:</span></span></p>
<pre><code dir="auto" data-line="155">consteaseInOut<span>&nbsp;= (<span>t:&nbsp;<span>number<span>) =&gt;<span><span>&nbsp; t &lt;&nbsp;0.5<span>&nbsp;?&nbsp;2<span>&nbsp;* t * t :&nbsp;1<span>&nbsp;-&nbsp;Math<span>.pow<span>(-2<span>&nbsp;* t +&nbsp;2<span>,&nbsp;2<span>) /&nbsp;2<span>;</span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></code></pre>
<p dir="auto" data-line="160"><span><span>CSS 层同步处理,fog 和 cloud 的 overlay 也跟着淡入淡出。这样整个切换过程不会出现"底层露出来"的闪烁。</span></span></p>
<hr>
<h2 dir="auto" data-line="164"><span><span>性能:踩过的坑和后来的优化</span></span></h2>
<p dir="auto" data-line="166"><span><span>React 里用 Canvas 动画有一个经典陷阱:状态变化触发重渲染,useEffect 依赖项更新,动画循环被取消再重启,粒子全部重置。</span></span></p>
<p dir="auto" data-line="168"><span><span>我的解法是 configRef 模式——把天气配置存在 useRef 里,不用 useState。动画循环每帧直接读 configRef.current,不会订阅 React 状态变化。用户拖动滑块时,只更新 ref,不触发重渲染,动画连续不断。</span></span></p>
<pre><code dir="auto" data-line="170">const<span>&nbsp;configRef =&nbsp;useRef<span>(defaultConfig);<span><span>// 控制面板更新<span>consthandleParamChange<span>&nbsp;= (<span>key:&nbsp;<span>string<span>, value:&nbsp;<span>number<span>) =&gt; {<span><span>&nbsp; configRef.<span>current<span>&nbsp;= { ...configRef.<span>current<span>, : value };<span>// 不 setState,不触发重渲染<span><span>};<span><span>// 动画循环里<span>constanimate<span>&nbsp;= (<span>) =&gt; {<span>const<span>&nbsp;cfg = configRef.<span>current<span>;<span>updateParticles<span>(cfg.<span>windSpeed<span>, cfg.<span>rainRate<span>);<span>requestAnimationFrame<span>(animate);<span><span>};</span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></code></pre>
<p dir="auto" data-line="187"><span><span>这个模式在 Canvas + React 的场景里几乎是必须的,但网上教程很少提到。很多人遇到"拖滑块动画卡一下"的问题,根本原因就在这里。</span></span></p>
<p dir="auto" data-line="189"><span><span>后面又做了一轮性能优化,主要是三件事:</span></span></p>
<p dir="auto" data-line="191"><span><span>一是把雨滴和溅射粒子从 class 实例改成 SoA + Float32Array。原来几百个 new RainDrop() 每个都是独立对象,GC 压力大,内存也不连续。改成 SoA 之后所有同类属性挨着存,循环跑起来对缓存友好很多。溅射粒子用固定大小的对象池,spawn 的时候写入下一个空位,死亡时 swap-and-pop 移除,整个生命周期零分配。</span></span></p>
<p dir="auto" data-line="193"><span><span>二是批量绘制。之前每个雨滴单独 beginPath + stroke,改成按透明度分三档,同一档的雨滴合进一个 path 一次 fill。溅射粒子更简单,统一颜色直接一把画完。Canvas 2D 的瓶颈很多时候不在像素量,而在 draw call 次数——state change 越少越快。</span></span></p>
<p dir="auto" data-line="195"><span><span>三是雾气的离屏 Canvas。25 个雾团每帧 createRadialGradient 太浪费了,改成启动时预渲染一张 256x256 的渐变纹理,运行时 drawImage 缩放绘制,用 globalAlpha 控制浓淡。这个改动对雾天帧率的提升最明显。</span></span></p>
<hr>
<h2 dir="auto" data-line="199"><span><span>自动模式</span></span></h2>
<p dir="auto" data-line="201"><span><span>我接了 Open-Meteo,完全免费,不需要 API key,直接 GET 请求就行。先用浏览器 navigator.geolocation 拿坐标,然后把经纬度传给 Open-Meteo,拿回 WMO 天气代码,再映射到我自己的五种天气类型。每十分钟刷新一次天气,每分钟更新一次时间,时间影响昼夜状态。</span></span></p>
<p dir="auto" data-line="203"><span><span>WMO 代码挺繁琐的,61-65 是不同强度的雨,71-77 是雪,各有各的范围,写映射表的时候对着文档抄了好一会儿。</span></span></p>
<hr>
<p dir="auto" data-line="207"><span><span>做完这个项目之后,我把它放在家里俩个屏幕电脑上面上跑了一周。开着自动模式,放在第二屏当动态壁纸,效果意外地好。上海冬天那几天大雾,界面里真的飘着雾,雪花粒子跑起来有点废内存,但没到不能接受的程度。</span></span></p>
<p dir="auto" data-line="209"><span><span>代码写得不算优雅,1000 多行的 Canvas 组件本来应该拆开,但懒得动了,能跑就行。</span></span></p>
<p dir="auto" data-line="211"><span><span>有感兴趣的可以拿去改,或者告诉我哪里可以做得更好。</span></span></p><br><br>
来源:https://www.cnblogs.com/greywen/p/19702808
頁: [1]
查看完整版本: 做了一个网页天气可视化