weather-station/templates/radar_guangzhou.html
2025-09-23 15:22:29 +08:00

269 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/css/tailwind.min.css">
<script src="/static/js/plotly-2.27.0.min.js"></script>
<style>
body { font-family: Arial, sans-serif; margin: 0; }
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; }
.plot-box { width: clamp(320px, 80vw, 720px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
@supports not (aspect-ratio: 1 / 1) { .plot-box { height: 520px; } }
</style>
</head>
<body>
{{ template "header" . }}
<div class="content-narrow p-4">
<div class="card mb-4">
<div class="text-lg font-semibold mb-2">最新 7/40/104 瓦片信息</div>
<div class="text-sm space-y-1">
<div>时间:<span id="dt" class="font-mono"></span></div>
<div>索引z=<span id="z"></span> / y=<span id="y"></span> / x=<span id="x"></span></div>
<div>尺寸:<span id="size"></span></div>
<div>边界W=<span id="west"></span>S=<span id="south"></span>E=<span id="east"></span>N=<span id="north"></span></div>
<div>分辨率(度/像素):<span id="res"></span></div>
</div>
</div>
<div class="card mb-4">
<div class="text-lg font-semibold mb-2">最新雷达站气象(广州雷达站,金山楼顶坐标)</div>
<div id="rtInfo" class="text-sm grid grid-cols-2 gap-y-1 gap-x-6">
<div>站点:<span id="rt_alias"></span></div>
<div>位置:<span id="rt_lat"></span><span id="rt_lon"></span></div>
<div>时间:<span id="rt_dt" class="font-mono"></span></div>
<div>温度:<span id="rt_t"></span></div>
<div>湿度:<span id="rt_h"></span></div>
<div>云量:<span id="rt_c"></span></div>
<div>能见度:<span id="rt_vis"></span> km</div>
<div>下行短波:<span id="rt_dswrf"></span> W/m²</div>
<div>风速:<span id="rt_ws"></span> m/s</div>
<div>风向:<span id="rt_wd"></span> °</div>
<div>气压:<span id="rt_p"></span> Pa</div>
</div>
</div>
<div class="card mb-4">
<div class="text-lg font-semibold mb-2">未来 3H 预报</div>
<div id="sectorInfo" class="text-sm">
<div id="sectorStatus">计算中…</div>
<div id="sectorDetail" class="mt-1 hidden">
最近距离:<span id="sectorDist"></span> km预计到达时间<span id="sectorETA"></span>
</div>
</div>
</div>
<div class="card" style="width:100%;">
<div class="text-lg font-semibold mb-2">雷达组合反射率</div>
<div id="radarPlot" class="plot-box"></div>
</div>
<div class="card mt-4" style="width:100%;">
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
<div id="squarePlot" class="plot-box"></div>
</div>
</div>
<script>
const ST_ALIAS = '广州雷达站';
const ST_LAT = 23.146400, ST_LON = 113.341200;
const TILE_Z = 7, TILE_Y = 40, TILE_X = 104;
let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null;
function toRad(d){ return d * Math.PI / 180; }
function toDeg(r){ return r * 180 / Math.PI; }
function haversine(lat1, lon1, lat2, lon2){
const R = 6371000; const dLat = toRad(lat2-lat1); const dLon = toRad(lon2-lon1);
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
const c = 2*Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R*c;
}
function bearingDeg(lat1, lon1, lat2, lon2){
const φ1=toRad(lat1), φ2=toRad(lat2), Δλ=toRad(lon2-lon1);
const y=Math.sin(Δλ)*Math.cos(φ2); const x=Math.cos(φ1)*Math.sin(φ2)-Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
return (toDeg(Math.atan2(y,x))+360)%360;
}
function angDiff(a,b){ let d=((a-b+540)%360)-180; return Math.abs(d); }
function destPoint(lat, lon, brgDeg, distM){
const R=6371000, δ=distM/R, θ=toRad(brgDeg), φ1=toRad(lat), λ1=toRad(lon);
const sinφ1=Math.sin(φ1), cosφ1=Math.cos(φ1), sinδ=Math.sin(δ), cosδ=Math.cos(δ);
const sinφ2=sinφ1*cosδ+cosφ1*sinδ*Math.cos(θ); const φ2=Math.asin(sinφ2);
const y=Math.sin(θ)*sinδ*cosφ1; const x=cosδ-sinφ1*sinφ2; const λ2=λ1+Math.atan2(y,x);
return { lat: toDeg(φ2), lon: ((toDeg(λ2)+540)%360)-180 };
}
async function loadLatestTile(){
const res = await fetch(`/api/radar/latest?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`);
if(!res.ok) throw new Error('加载最新瓦片失败');
const t = await res.json();
const fmt5 = (n)=>Number(n).toFixed(5);
document.getElementById('dt').textContent = t.dt;
document.getElementById('z').textContent = t.z;
document.getElementById('y').textContent = t.y;
document.getElementById('x').textContent = t.x;
document.getElementById('size').textContent = `${t.width} × ${t.height}`;
document.getElementById('west').textContent = fmt5(t.west);
document.getElementById('south').textContent = fmt5(t.south);
document.getElementById('east').textContent = fmt5(t.east);
document.getElementById('north').textContent = fmt5(t.north);
document.getElementById('res').textContent = fmt5(t.res_deg);
// x/y 等角坐标
const w=t.width,h=t.height; gTileValues=t.values;
const xs=new Array(w), ys=new Array(h);
const stepX=(t.east-t.west)/w, stepY=(t.north-t.south)/h;
for(let i=0;i<w;i++){ xs[i]=t.west+(i+0.5)*stepX; }
for(let j=0;j<h;j++){ ys[j]=t.south+(j+0.5)*stepY; }
gXs=xs; gYs=ys;
// 色带
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
const zBins=[]; const custom=[];
for(let r=0;r<h;r++){
const row=gTileValues[r]; const rowBins=[], rowCustom=[];
for(let c=0;c<w;c++){
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
rowBins.push(bin); rowCustom.push([r,c,dbz]);
}
zBins.push(rowBins); custom.push(rowCustom);
}
const heatTrace={ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' };
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90}, xaxis:{title:'经度',tickformat:'.2f',zeroline:false,constrain:'domain',automargin:true},
yaxis:{title:{text:'纬度',standoff:12},tickformat:'.2f',zeroline:false,scaleanchor:'x',scaleratio:1,constrain:'domain',automargin:true} };
// 扇形覆盖
const data=[heatTrace];
if(gWindFromDeg!==null && gWindSpeedMS>0){
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
const xsFan=[ST_LON], ysFan=[ST_LAT];
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
xsFan.push(ST_LON); ysFan.push(ST_LAT);
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
}
const plotEl=document.getElementById('radarPlot');
Plotly.newPlot(plotEl, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); });
window.addEventListener('resize',()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); Plotly.Plots.resize(plotEl); });
// 与瓦片时间对齐的10分钟气象
try{
const dt=new Date(t.dt.replace(/-/g,'/')); const ceil10=new Date(dt); const m=dt.getMinutes(); const up=(Math.floor(m/10)*10+10)%60; ceil10.setMinutes(up,0,0); if(up===0){ ceil10.setHours(dt.getHours()+1); }
const pad=(n)=>String(n).padStart(2,'0'); const dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
await loadRealtimeAt(dtStr);
maybeCalcSector();
maybePlotSquare();
}catch(e){ await loadRealtimeLatest(); maybeCalcSector(); maybePlotSquare(); }
}
async function loadRealtimeLatest(){
const res=await fetch(`/api/radar/weather_latest?alias=${encodeURIComponent(ST_ALIAS)}`);
if(!res.ok) throw new Error('加载实时气象失败');
const r=await res.json(); fillRealtime(r);
}
async function loadRealtimeAt(dtStr){
const res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
if(!res.ok) throw new Error('加载指定时间气象失败');
const r=await res.json(); fillRealtime(r);
}
function fillRealtime(r){
const f2=(n)=>Number(n).toFixed(2), f4=(n)=>Number(n).toFixed(4);
document.getElementById('rt_alias').textContent=r.alias;
document.getElementById('rt_lat').textContent=f4(r.lat);
document.getElementById('rt_lon').textContent=f4(r.lon);
document.getElementById('rt_dt').textContent=r.dt;
document.getElementById('rt_t').textContent=f2(r.temperature);
document.getElementById('rt_h').textContent=f2(r.humidity);
document.getElementById('rt_c').textContent=f2(r.cloudrate);
document.getElementById('rt_vis').textContent=f2(r.visibility);
document.getElementById('rt_dswrf').textContent=f2(r.dswrf);
document.getElementById('rt_ws').textContent=f2(r.wind_speed);
document.getElementById('rt_wd').textContent=f2(r.wind_direction);
document.getElementById('rt_p').textContent=f2(r.pressure);
gWindFromDeg=Number(r.wind_direction); gWindSpeedMS=Number(r.wind_speed);
}
function maybeCalcSector(){
try{
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null) return;
const halfAngle=30; const rangeM=gWindSpeedMS*3*3600; let best=null;
const h=gTileValues.length, w=gTileValues[0].length;
for(let r=0;r<h;r++){
const lat=gYs[r]; for(let c=0;c<w;c++){
const val=gTileValues[r][c]; if(val==null) continue; const dbz=Number(val); if(!(dbz>=40)) continue;
const lon=gXs[c]; const dist=haversine(ST_LAT,ST_LON,lat,lon); if(dist>rangeM) continue;
const brg=bearingDeg(ST_LAT,ST_LON,lat,lon); if(angDiff(brg,gWindFromDeg)>halfAngle) continue;
if(!best || dist<best.dist){ best={dist,lat,lon,dbz}; }
}
}
const statusEl=document.getElementById('sectorStatus'); const detailEl=document.getElementById('sectorDetail');
if(!best){ statusEl.textContent='无≥40 dBZ'; detailEl.classList.add('hidden'); }
else {
const etaSec=best.dist/gWindSpeedMS; const eta=new Date(Date.now()+etaSec*1000);
const pad=(n)=>String(n).padStart(2,'0'); const etaStr=`${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
document.getElementById('sectorDist').textContent=(best.dist/1000).toFixed(1);
document.getElementById('sectorETA').textContent=etaStr;
statusEl.textContent='三小时内可能有降雨≥40 dBZ '; detailEl.classList.remove('hidden');
}
}catch(e){ document.getElementById('sectorStatus').textContent='风险评估计算失败:'+e.message; }
}
function maybePlotSquare(){
try{
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null) return;
const R=6371000; const rangeM=gWindSpeedMS*3*3600;
// 以站点为中心的正方形3小时可达半径作为“细节放大”裁剪窗口
const dLat=(rangeM/R)*(180/Math.PI);
const dLon=(rangeM/(R*Math.cos(toRad(ST_LAT))))*(180/Math.PI);
const latMin=ST_LAT-dLat, latMax=ST_LAT+dLat, lonMin=ST_LON-dLon, lonMax=ST_LON+dLon;
const xs=gXs, ys=gYs; const h=gTileValues.length,w=gTileValues[0].length;
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
const zBins=[], custom=[];
for(let r=0;r<h;r++){
const row=gTileValues[r]; const rowBins=[], rowCustom=[]; const lat=ys[r];
for(let c=0;c<w;c++){
const lon=xs[c]; if(lat<latMin||lat>latMax||lon<lonMin||lon>lonMax){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
rowBins.push(bin); rowCustom.push([r,c,dbz]);
}
zBins.push(rowBins); custom.push(rowCustom);
}
const data=[{ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' }];
// 扇形覆盖(同样默认显示)
if(gWindFromDeg!==null && gWindSpeedMS>0){
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
const xsFan=[ST_LON], ysFan=[ST_LAT];
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
xsFan.push(ST_LON); ysFan.push(ST_LAT);
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
}
const el=document.getElementById('squarePlot');
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90},
xaxis:{title:'经度', tickformat:'.2f', zeroline:false, constrain:'domain', automargin:true, range:[lonMin, lonMax]},
yaxis:{title:{text:'纬度',standoff:12}, tickformat:'.2f', zeroline:false, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true, range:[latMin, latMax]},
shapes:[{type:'rect',xref:'x',yref:'y',x0:lonMin,x1:lonMax,y0:latMin,y1:latMax,line:{color:'#111',width:1,dash:'dot'},fillcolor:'rgba(0,0,0,0)'}] };
Plotly.newPlot(el, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); });
window.addEventListener('resize',()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); });
}catch(e){ document.getElementById('squarePlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">正方形热力图渲染失败:${e.message}</div>`; }
}
// 启动加载
loadLatestTile().catch(err=>{ document.getElementById('radarPlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.message}</div>`; });
loadRealtimeLatest().catch(err=>{ document.getElementById('rtInfo').innerHTML=`<div class="text-sm text-red-600">${err.message}</div>`; });
</script>
</body>
</html>