feat: 新增海珠和番禺气象站

This commit is contained in:
yarnom 2025-09-24 10:58:48 +08:00
parent c14759933f
commit e41a242223
2 changed files with 749 additions and 0 deletions

405
templates/radar_haizhu.html Normal file
View File

@ -0,0 +1,405 @@
<!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>
<script>
// 防止页面在未加载 Plotly 前渲染空白区域
</script>
</head>
<body>
{{ template "header" . }}
<div class="content-narrow p-4">
<div class="card mb-4">
<div class="text-lg font-semibold mb-2">历史时次查询</div>
<div class="w-full flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-2">
<input id="tsStart" type="datetime-local" class="border rounded px-2 py-1"/>
<span></span>
<input id="tsEnd" type="datetime-local" class="border rounded px-2 py-1"/>
<button id="tsQuery" class="px-3 py-1 bg-blue-600 text-white rounded">查询</button>
</div>
<div>
<select id="timeSelect" class="border rounded px-2 py-1 min-w-[260px]">
<option value="">最新</option>
</select>
</div>
</div>
</div>
<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><br>
位置lat=<span id="sectorLat"></span>lon=<span id="sectorLon"></span> 组合反射率:<span id="sectorDBZ"></span> dBZ
</div>
</div>
</div>
<div class="card" style="width:100%;">
<div class="text-lg font-semibold mb-2 flex items-center justify-between">
<div>
雷达组合反射率 <span id="titleDt" class="text-gray-500 text-sm"></span>
</div>
<div class="flex items-center gap-2 text-sm">
<button id="btnPrev" class="px-2 py-1 border rounded">上一时次</button>
<span id="countInfo" class="text-gray-600">共0条第0条</span>
<button id="btnNext" class="px-2 py-1 border rounded">下一时次</button>
</div>
</div>
<div class="w-full flex justify-center mb-2">
<input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
</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.09, ST_LON = 113.35;
// 使用与广州相同的瓦片数据
const TILE_Z = 7, TILE_Y = 40, TILE_X = 104;
let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null;
let gTimes = [];
let gCurrentIdx = -1;
let gTileDT = null; // 当前瓦片时间,用于 ETA
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();
await renderTile(t);
}
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;
return 2*R*Math.asin(Math.sqrt(a));
}
function bearingDeg(lat1, lon1, lat2, lon2){
const φ1=toRad(lat1), φ2=toRad(lat2), λ1=toRad(lon1), λ2=toRad(lon2);
const y=Math.sin(λ2-λ1)*Math.cos(φ2);
const x=Math.cos(φ1)*Math.sin(φ2)-Math.sin(φ1)*Math.cos(φ2)*Math.cos(λ2-λ1);
const θ=Math.atan2(y,x); let brg=(toDeg(θ)+360)%360; return brg;
}
function angDiff(a,b){ let d=Math.abs(((a-b+540)%360)-180); return d; }
function destPoint(lat,lon,brg,dist){
const R=6371000; const δ=dist/R; const θ=toRad(brg);
const φ1=toRad(lat), λ1=toRad(lon);
const φ2=Math.asin(Math.sin(φ1)*Math.cos(δ)+Math.cos(φ1)*Math.sin(δ)*Math.cos(θ));
const λ2=λ1+Math.atan2(Math.sin(θ)*Math.sin(δ)*Math.cos(φ1),Math.cos(δ)-Math.sin(φ1)*Math.sin(φ2));
return {lat:toDeg(φ2), lon:((toDeg(λ2)+540)%360)-180};
}
async function loadTileAt(dtStr){
// 同步瓦片与实时气象时间,便于 ETA 计算
const tRes = await fetch(`/api/radar/at?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}&dt=${encodeURIComponent(dtStr)}`);
if(!tRes.ok){ document.getElementById('titleDt').textContent='(无数据)'; return; }
const t = await tRes.json(); await renderTile(t, dtStr);
}
function fmtLocal(datetimeLocalStr){
const s=datetimeLocalStr; if(!s) return null; const t=new Date(s.replace('T','-').replace(/-/g,'/'));
const pad=(n)=>String(n).padStart(2,'0');
return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`;
}
function fmtDTLocal(dt){ const pad=(n)=>String(n).padStart(2,'0'); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`; }
function fromDTLocalInput(s){ if(!s) return null; const t=new Date(s.replace('T','-').replace(/-/g,'/')); const pad=(n)=>String(n).padStart(2,'0'); return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`; }
async function populateTimes(fromStr, toStr){
try{
let url = `/api/radar/times?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`;
if(fromStr && toStr){ url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`; } else { url += `&limit=60`; }
const res = await fetch(url); if(!res.ok) return; const j = await res.json();
const sel = document.getElementById('timeSelect'); while (sel.options.length > 1) sel.remove(1);
gTimes = j.times || [];
gTimes.forEach(dt=>{ const opt=document.createElement('option'); opt.value=dt; opt.textContent=dt; sel.appendChild(opt); });
if (gTimes.length>0 && gCurrentIdx<0){ sel.value=gTimes[0]; gCurrentIdx=0; }
updateCountAndButtons(); updateSlider();
}catch{}
}
async function renderTile(t, forcedDt){
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);
document.getElementById('titleDt').textContent = `${t.dt}`;
const selBox = document.getElementById('timeSelect');
for(let i=0;i<selBox.options.length;i++){ if(selBox.options[i].value===t.dt){ selBox.selectedIndex=i; break; } }
gCurrentIdx = gTimes.indexOf(t.dt);
updateCountAndButtons(); updateSlider();
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); });
try{
const base = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
gTileDT = base; // 记录瓦片时间
await fetchRealtimeWithFallback(base);
maybeCalcSector();
maybePlotSquare();
}catch(e){ await loadRealtimeLatest(); maybeCalcSector(); maybePlotSquare(); }
}
async function fetchRealtimeWithFallback(base){
const pad=(n)=>String(n).padStart(2,'0');
// 尝试下一10分钟整
const ceil10=new Date(base); const m=base.getMinutes(); const up=(Math.floor(m/10)*10+10)%60; ceil10.setMinutes(up,0,0); if(up===0){ ceil10.setHours(base.getHours()+1); }
let dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
let res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
// 海珠此10分钟缺测则用广州相同10分钟时次
res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent('广州雷达站')}&dt=${encodeURIComponent(dtStr)}`);
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
// 最后退回海珠最新
await loadRealtimeLatest();
}
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 base = gTileDT instanceof Date ? gTileDT : new Date();
const eta=new Date(base.getTime()+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;
document.getElementById('sectorLat').textContent=Number(best.lat).toFixed(4);
document.getElementById('sectorLon').textContent=Number(best.lon).toFixed(4);
document.getElementById('sectorDBZ').textContent=Number(best.dbz).toFixed(1);
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;
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 h=gYs.length, w=gXs.length;
let rStart=0; while(rStart<h && gYs[rStart] < latMin) rStart++;
let rEnd=h-1; while(rEnd>=0 && gYs[rEnd] > latMax) rEnd--;
let cStart=0; while(cStart<w && gXs[cStart] < lonMin) cStart++;
let cEnd=w-1; while(cEnd>=0 && gXs[cEnd] > lonMax) cEnd--;
if(rStart>=rEnd || cStart>=cEnd){ const el=document.getElementById('squarePlot'); el.innerHTML='<div class="p-3 text-sm text-gray-600">正方形范围超出当前瓦片或无有效像元</div>'; return; }
const xs=gXs.slice(cStart,cEnd+1);
const ys=gYs.slice(rStart,rEnd+1);
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=rStart;r<=rEnd;r++){
const rowBins=[], rowCustom=[];
for(let c=cStart;c<=cEnd;c++){
const val=gTileValues[r][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;
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 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)'}] };
const plotEl=document.getElementById('squarePlot');
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); });
}catch{}
}
// 控件事件
document.getElementById('tsQuery').addEventListener('click', async ()=>{
const from=fromDTLocalInput(document.getElementById('tsStart').value); const to=fromDTLocalInput(document.getElementById('tsEnd').value); await populateTimes(from,to);
});
document.getElementById('timeSelect').addEventListener('change', async (e)=>{ const v=e.target.value; if(!v){ if(gTimes.length>0){ gCurrentIdx=0; await loadTileAt(gTimes[0]); } else { gCurrentIdx=-1; await loadLatestTile(); } } else { gCurrentIdx=gTimes.indexOf(v); await loadTileAt(v);} updateCountAndButtons(); updateSlider(); });
function updateCountAndButtons(){
const N=gTimes.length; const el=document.getElementById('countInfo'); if(N===0||gCurrentIdx<0){ el.textContent='共0条数据第0条'; return; }
el.textContent=`共${N}条数据,-${k-1}时次`;
const prev=document.getElementById('btnPrev'), next=document.getElementById('btnNext');
prev.disabled = !(N>0 && gCurrentIdx>=0 && gCurrentIdx<N-1);
next.disabled = !(N>0 && gCurrentIdx>0);
}
function updateSlider(){
const slider = document.getElementById('timeSlider');
const N = gTimes.length;
slider.max = N > 0 ? String(N-1) : '0';
if (N > 0 && gCurrentIdx >= 0) {
const sliderVal = (N - 1) - gCurrentIdx;
slider.value = String(sliderVal);
}
slider.disabled = N === 0;
}
document.getElementById('btnPrev').addEventListener('click', async ()=>{
if(gTimes.length===0) return; if(gCurrentIdx<0) gCurrentIdx=0; if(gCurrentIdx<gTimes.length-1){ gCurrentIdx++; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider();
});
document.getElementById('btnNext').addEventListener('click', async ()=>{
if(gTimes.length===0) return; if(gCurrentIdx>0){ gCurrentIdx--; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider();
});
document.getElementById('timeSlider').addEventListener('input', async (e)=>{
const N = gTimes.length; if (N === 0) return;
const raw = parseInt(e.target.value, 10);
const sliderVal = Math.max(0, Math.min(N-1, isNaN(raw)?0:raw));
const idx = (N - 1) - sliderVal;
if (idx === gCurrentIdx) return;
gCurrentIdx = idx;
const dt = gTimes[gCurrentIdx];
document.getElementById('timeSelect').value = dt;
await loadTileAt(dt);
updateCountAndButtons();
});
// 初始化:设置默认时间范围并加载最新瓦片,再填充时序;预加载实时气象保证扇形叠加
(function initRange(){ const end=new Date(); const start=new Date(end.getTime()-3*3600*1000); document.getElementById('tsStart').value=fmtDTLocal(start); document.getElementById('tsEnd').value=fmtDTLocal(end); })();
const startStr=fromDTLocalInput(document.getElementById('tsStart').value); const endStr=fromDTLocalInput(document.getElementById('tsEnd').value);
loadRealtimeLatest().catch(()=>{});
loadLatestTile().then(()=>populateTimes(startStr,endStr)).catch(err=>{ const plot=document.getElementById('radarPlot'); plot.innerHTML = `<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.message}</div>`; });
</script>
</body>
</html>

344
templates/radar_panyu.html Normal file
View File

@ -0,0 +1,344 @@
<!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">历史时次查询</div>
<div class="w-full flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-2">
<input id="tsStart" type="datetime-local" class="border rounded px-2 py-1"/>
<span></span>
<input id="tsEnd" type="datetime-local" class="border rounded px-2 py-1"/>
<button id="tsQuery" class="px-3 py-1 bg-blue-600 text-white rounded">查询</button>
</div>
<div>
<select id="timeSelect" class="border rounded px-2 py-1 min-w-[260px]">
<option value="">最新</option>
</select>
</div>
</div>
</div>
<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><br>
位置lat=<span id="sectorLat"></span>lon=<span id="sectorLon"></span> 组合反射率:<span id="sectorDBZ"></span> dBZ
</div>
</div>
</div>
<div class="card" style="width:100%;">
<div class="text-lg font-semibold mb-2 flex items-center justify-between">
<div>
雷达组合反射率 <span id="titleDt" class="text-gray-500 text-sm"></span>
</div>
<div class="flex items-center gap-2 text-sm">
<button id="btnPrev" class="px-2 py-1 border rounded">上一时次</button>
<span id="countInfo" class="text-gray-600">共0条第0条</span>
<button id="btnNext" class="px-2 py-1 border rounded">下一时次</button>
</div>
</div>
<div class="w-full flex justify中心 mb-2">
<input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
</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.0225, ST_LON = 113.3313;
const TILE_Z = 7, TILE_Y = 40, TILE_X = 104; // 使用广州瓦片
let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null;
let gTimes = [];
let gCurrentIdx = -1;
let gTileDT = null; // 当前瓦片时间(用于 ETA
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;
return 2*R*Math.asin(Math.sqrt(a));
}
function bearingDeg(lat1, lon1, lat2, lon2){
const φ1=toRad(lat1), φ2=toRad(lat2), λ1=toRad(lon1), λ2=toRad(lon2);
const y=Math.sin(λ2-λ1)*Math.cos(φ2);
const x=Math.cos(φ1)*Math.sin(φ2)-Math.sin(φ1)*Math.cos(φ2)*Math.cos(λ2-λ1);
const θ=Math.atan2(y,x); return (toDeg(θ)+360)%360;
}
function angDiff(a,b){ return Math.abs(((a-b+540)%360)-180); }
function destPoint(lat,lon,brg,dist){
const R=6371000; const δ=dist/R; const θ=toRad(brg);
const φ1=toRad(lat), λ1=toRad(lon);
const φ2=Math.asin(Math.sin(φ1)*Math.cos(δ)+Math.cos(φ1)*Math.sin(δ)*Math.cos(θ));
const λ2=λ1+Math.atan2(Math.sin(θ)*Math.sin(δ)*Math.cos(φ1),Math.cos(δ)-Math.sin(φ1)*Math.sin(φ2));
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(); await renderTile(t);
}
async function loadTileAt(dtStr){
const res = await fetch(`/api/radar/at?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}&dt=${encodeURIComponent(dtStr)}`);
if(!res.ok) throw new Error('加载指定时间瓦片失败');
const t = await res.json(); await renderTile(t, dtStr);
}
function fmtDTLocal(dt){ const pad=(n)=>String(n).padStart(2,'0'); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`; }
function fromDTLocalInput(s){ if(!s) return null; const t=new Date(s.replace('T','-').replace(/-/g,'/')); const pad=(n)=>String(n).padStart(2,'0'); return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`; }
async function populateTimes(fromStr, toStr){
try{
let url = `/api/radar/times?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`;
if(fromStr && toStr){ url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`; } else { url += `&limit=60`; }
const res = await fetch(url); if(!res.ok) return; const j = await res.json();
const sel = document.getElementById('timeSelect'); while (sel.options.length > 1) sel.remove(1);
gTimes = j.times || [];
gTimes.forEach(dt=>{ const opt=document.createElement('option'); opt.value=dt; opt.textContent=dt; sel.appendChild(opt); });
if (gTimes.length>0 && gCurrentIdx<0){ sel.value=gTimes[0]; gCurrentIdx=0; }
updateCountAndButtons(); updateSlider();
}catch{}
}
async function renderTile(t, forcedDt){
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);
document.getElementById('titleDt').textContent=`${t.dt}`;
const selBox=document.getElementById('timeSelect'); for(let i=0;i<selBox.options.length;i++){ if(selBox.options[i].value===t.dt){ selBox.selectedIndex=i; break; } }
gCurrentIdx=gTimes.indexOf(t.dt); updateCountAndButtons(); updateSlider();
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=[], 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); });
try{
const base = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
gTileDT = base; // 记录瓦片时间
await fetchRealtimeWithFallback(base);
maybeCalcSector();
maybePlotSquare();
}catch(e){ await loadRealtimeLatest(); maybeCalcSector(); maybePlotSquare(); }
}
async function fetchRealtimeWithFallback(base){
const pad=(n)=>String(n).padStart(2,'0');
const ceil10=new Date(base); const m=base.getMinutes(); const up=(Math.floor(m/10)*10+10)%60; ceil10.setMinutes(up,0,0); if(up===0){ ceil10.setHours(base.getHours()+1); }
const dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
let res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
// 番禺此10分钟缺测则用广州相同10分钟时次
res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent('广州雷达站')}&dt=${encodeURIComponent(dtStr)}`);
if(res.ok){ const r=await res.json(); fillRealtime(r); return; }
// 最后退回番禺最新
await loadRealtimeLatest();
}
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);
}
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 base = gTileDT instanceof Date ? gTileDT : new Date();
const eta=new Date(base.getTime()+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;
document.getElementById('sectorLat').textContent=Number(best.lat).toFixed(4);
document.getElementById('sectorLon').textContent=Number(best.lon).toFixed(4);
document.getElementById('sectorDBZ').textContent=Number(best.dbz).toFixed(1);
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;
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 h=gYs.length, w=gXs.length;
let rStart=0; while(rStart<h && gYs[rStart]<latMin) rStart++;
let rEnd=h-1; while(rEnd>=0 && gYs[rEnd]>latMax) rEnd--;
let cStart=0; while(cStart<w && gXs[cStart]<lonMin) cStart++;
let cEnd=w-1; while(cEnd>=0 && gXs[cEnd]>lonMax) cEnd--;
if(rStart>=rEnd || cStart>=cEnd){ document.getElementById('squarePlot').innerHTML='<div class="p-3 text-sm text-gray-600">正方形范围超出当前瓦片或无有效像元</div>'; return; }
const xs=gXs.slice(cStart,cEnd+1), ys=gYs.slice(rStart,rEnd+1);
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=rStart;r<=rEnd;r++){
const rowBins=[], rowCustom=[];
for(let c=cStart;c<=cEnd;c++){
const val=gTileValues[r][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, hoverinfo:'skip', showscale:false }];
if(gWindFromDeg!==null && gWindSpeedMS>0){
const half=30,samples=64,start=gWindFromDeg-half,end=gWindFromDeg+half;
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 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)'}] };
const el=document.getElementById('squarePlot'); 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>`; }
}
// 初始化默认时间范围与加载顺序
(function initRange(){ const end=new Date(); const start=new Date(end.getTime()-3*3600*1000); document.getElementById('tsStart').value=fmtDTLocal(start); document.getElementById('tsEnd').value=fmtDTLocal(end); })();
const startStr=fromDTLocalInput(document.getElementById('tsStart').value);
const endStr=fromDTLocalInput(document.getElementById('tsEnd').value);
loadRealtimeLatest().catch(()=>{});
loadLatestTile().then(()=>populateTimes(startStr,endStr)).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>`; });
document.getElementById('timeSelect').addEventListener('change', async (e)=>{ const v=e.target.value; if(!v){ if(gTimes.length>0){ gCurrentIdx=0; await loadTileAt(gTimes[0]); } else { gCurrentIdx=-1; await loadLatestTile(); } } else { gCurrentIdx=gTimes.indexOf(v); await loadTileAt(v);} updateCountAndButtons(); updateSlider(); });
document.getElementById('tsQuery').addEventListener('click', async ()=>{ const s=fromDTLocalInput(document.getElementById('tsStart').value); const e=fromDTLocalInput(document.getElementById('tsEnd').value); await populateTimes(s,e); });
function updateCountAndButtons(){ const N=gTimes.length; const k=gCurrentIdx>=0?(gCurrentIdx+1):0; document.getElementById('countInfo').textContent=`共${N}条数据,-${k-1}时次`; const prev=document.getElementById('btnPrev'); const next=document.getElementById('btnNext'); prev.disabled=!(N>0 && gCurrentIdx>=0 && gCurrentIdx<N-1); next.disabled=!(N>0 && gCurrentIdx>0); }
function updateSlider(){ const slider=document.getElementById('timeSlider'); const N=gTimes.length; slider.max=N>0?String(N-1):'0'; if(N>0 && gCurrentIdx>=0){ const sliderVal=(N-1)-gCurrentIdx; slider.value=String(sliderVal);} slider.disabled=N===0; }
document.getElementById('btnPrev').addEventListener('click', async ()=>{ if(gTimes.length===0) return; if(gCurrentIdx<0) gCurrentIdx=0; if(gCurrentIdx<gTimes.length-1){ gCurrentIdx++; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider(); });
document.getElementById('btnNext').addEventListener('click', async ()=>{ if(gTimes.length===0) return; if(gCurrentIdx>0){ gCurrentIdx--; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); updateSlider(); });
document.getElementById('timeSlider').addEventListener('input', async (e)=>{ const N=gTimes.length; if(N===0) return; const raw=parseInt(e.target.value,10); const sliderVal=Math.max(0,Math.min(N-1,isNaN(raw)?0:raw)); const idx=(N-1)-sliderVal; if(idx===gCurrentIdx) return; gCurrentIdx=idx; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt); updateCountAndButtons(); });
</script>
</body>
</html>