383 lines
22 KiB
HTML
383 lines
22 KiB
HTML
<!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-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.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;
|
||
let gTimes = [];
|
||
let gCurrentIdx = -1;
|
||
// 当前渲染瓦片的时间(用于 ETA 基准时间)
|
||
let gTileDT = 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();
|
||
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();
|
||
const shown = document.getElementById('dt').textContent;
|
||
if (gTimes.length>0 && gTimes[gCurrentIdx] && gTimes[gCurrentIdx] !== shown) {
|
||
await loadTileAt(gTimes[gCurrentIdx]);
|
||
}
|
||
}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,'/'));
|
||
// 记录本次瓦片时间供 ETA 使用
|
||
gTileDT = base;
|
||
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 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;
|
||
// 使用雷达瓦片时间作为 ETA 基准
|
||
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 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>`; }
|
||
}
|
||
|
||
(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);
|
||
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>`; });
|
||
loadRealtimeLatest().catch(err=>{ document.getElementById('rtInfo').innerHTML=`<div class="text-sm text-red-600">${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>
|