feat: 雷达历史数据

This commit is contained in:
yarnom 2025-09-23 17:15:42 +08:00
parent 2085fd9a31
commit a2a7cfd744
2 changed files with 29 additions and 67 deletions

View File

@ -103,7 +103,7 @@
let gTileValues = null, gXs = null, gYs = null; let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null; let gWindFromDeg = null, gWindSpeedMS = null;
let gTimes = []; let gTimes = [];
let gCurrentIdx = -1; // 在 gTimes 中的索引倒序0=最新) let gCurrentIdx = -1;
function toRad(d){ return d * Math.PI / 180; } function toRad(d){ return d * Math.PI / 180; }
function toDeg(r){ return r * 180 / Math.PI; } function toDeg(r){ return r * 180 / Math.PI; }
@ -172,14 +172,12 @@
document.getElementById('east').textContent = fmt5(t.east); document.getElementById('east').textContent = fmt5(t.east);
document.getElementById('north').textContent = fmt5(t.north); document.getElementById('north').textContent = fmt5(t.north);
document.getElementById('res').textContent = fmt5(t.res_deg); document.getElementById('res').textContent = fmt5(t.res_deg);
// 标题时间与索引同步
document.getElementById('titleDt').textContent = `${t.dt}`; document.getElementById('titleDt').textContent = `${t.dt}`;
const selBox = document.getElementById('timeSelect'); 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; } } 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); gCurrentIdx = gTimes.indexOf(t.dt);
updateCountAndButtons(); updateSlider(); updateCountAndButtons(); updateSlider();
// x/y 等角坐标
const w=t.width,h=t.height; gTileValues=t.values; const w=t.width,h=t.height; gTileValues=t.values;
const xs=new Array(w), ys=new Array(h); const xs=new Array(w), ys=new Array(h);
const stepX=(t.east-t.west)/w, stepY=(t.north-t.south)/h; const stepX=(t.east-t.west)/w, stepY=(t.north-t.south)/h;
@ -187,7 +185,6 @@
for(let j=0;j<h;j++){ ys[j]=t.south+(j+0.5)*stepY; } for(let j=0;j<h;j++){ ys[j]=t.south+(j+0.5)*stepY; }
gXs=xs; gYs=ys; gXs=xs; gYs=ys;
// 色带
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"]; 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 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=[]; const zBins=[]; const custom=[];
@ -206,7 +203,6 @@
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' }; 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}, 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} }; yaxis:{title:{text:'纬度',standoff:12},tickformat:'.2f',zeroline:false,scaleanchor:'x',scaleratio:1,constrain:'domain',automargin:true} };
// 扇形覆盖
const data=[heatTrace]; const data=[heatTrace];
if(gWindFromDeg!==null && gWindSpeedMS>0){ if(gWindFromDeg!==null && gWindSpeedMS>0){
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600; const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
@ -219,7 +215,6 @@
Plotly.newPlot(plotEl, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); }); 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); }); window.addEventListener('resize',()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); Plotly.Plots.resize(plotEl); });
// 与瓦片时间对齐的10分钟气象
try{ try{
const base = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/')); const base = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
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 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); }
@ -286,7 +281,6 @@
try{ try{
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null) return; if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null) return;
const R=6371000; const rangeM=gWindSpeedMS*3*3600; const R=6371000; const rangeM=gWindSpeedMS*3*3600;
// 以站点为中心的正方形3小时可达半径作为“细节放大”裁剪窗口
const dLat=(rangeM/R)*(180/Math.PI); const dLat=(rangeM/R)*(180/Math.PI);
const dLon=(rangeM/(R*Math.cos(toRad(ST_LAT))))*(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 latMin=ST_LAT-dLat, latMax=ST_LAT+dLat, lonMin=ST_LON-dLon, lonMax=ST_LON+dLon;
@ -308,7 +302,6 @@
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'}, 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))}, 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>' }]; hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' }];
// 扇形覆盖(同样默认显示)
if(gWindFromDeg!==null && gWindSpeedMS>0){ if(gWindFromDeg!==null && gWindSpeedMS>0){
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600; const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
const xsFan=[ST_LON], ysFan=[ST_LAT]; const xsFan=[ST_LON], ysFan=[ST_LAT];
@ -326,7 +319,6 @@
}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>`; } }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>`; }
} }
// 启动加载预填近3小时范围并填充时次
(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); })(); (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 startStr = fromDTLocalInput(document.getElementById('tsStart').value);
const endStr = fromDTLocalInput(document.getElementById('tsEnd').value); const endStr = fromDTLocalInput(document.getElementById('tsEnd').value);
@ -340,7 +332,7 @@
}); });
document.getElementById('tsQuery').addEventListener('click', async ()=>{ const s=fromDTLocalInput(document.getElementById('tsStart').value); const e=fromDTLocalInput(document.getElementById('tsEnd').value); await populateTimes(s,e); }); 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(){ function updateCountAndButtons(){
const N=gTimes.length; const k=gCurrentIdx>=0?(gCurrentIdx+1):0; document.getElementById('countInfo').textContent=`共${N}条数据,-${k-1}时`; 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'); const prev=document.getElementById('btnPrev'); const next=document.getElementById('btnNext');
prev.disabled = !(N>0 && gCurrentIdx>=0 && gCurrentIdx<N-1); prev.disabled = !(N>0 && gCurrentIdx>=0 && gCurrentIdx<N-1);
next.disabled = !(N>0 && gCurrentIdx>0); next.disabled = !(N>0 && gCurrentIdx>0);
@ -350,7 +342,7 @@
const N = gTimes.length; const N = gTimes.length;
slider.max = N > 0 ? String(N-1) : '0'; slider.max = N > 0 ? String(N-1) : '0';
if (N > 0 && gCurrentIdx >= 0) { if (N > 0 && gCurrentIdx >= 0) {
const sliderVal = (N - 1) - gCurrentIdx; // 值越大越新 const sliderVal = (N - 1) - gCurrentIdx;
slider.value = String(sliderVal); slider.value = String(sliderVal);
} }
slider.disabled = N === 0; slider.disabled = N === 0;
@ -365,7 +357,7 @@
const N = gTimes.length; if (N === 0) return; const N = gTimes.length; if (N === 0) return;
const raw = parseInt(e.target.value, 10); const raw = parseInt(e.target.value, 10);
const sliderVal = Math.max(0, Math.min(N-1, isNaN(raw)?0:raw)); const sliderVal = Math.max(0, Math.min(N-1, isNaN(raw)?0:raw));
const idx = (N - 1) - sliderVal; // 反向映射到数组索引0=最新) const idx = (N - 1) - sliderVal;
if (idx === gCurrentIdx) return; if (idx === gCurrentIdx) return;
gCurrentIdx = idx; gCurrentIdx = idx;
const dt = gTimes[gCurrentIdx]; const dt = gTimes[gCurrentIdx];

View File

@ -87,7 +87,6 @@
<button id="btnNext" class="px-2 py-1 border rounded">下一时次</button> <button id="btnNext" class="px-2 py-1 border rounded">下一时次</button>
</div> </div>
</div> </div>
<!-- 居中滑动条联动历史时次0=最新,值越大越旧) -->
<div class="w-full flex justify-center mb-2"> <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" /> <input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
</div> </div>
@ -101,9 +100,9 @@
<script> <script>
let gTileValues = null, gXs = null, gYs = null; let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null; // 来风方向0=北,顺时针),风速 m/s let gWindFromDeg = null, gWindSpeedMS = null;
let gTimes = []; let gTimes = [];
let gCurrentIdx = -1; // 在 gTimes 中的索引倒序0=最新) let gCurrentIdx = -1;
async function loadLatestTile() { async function loadLatestTile() {
const res = await fetch('/api/radar/latest?z=7&y=40&x=102'); const res = await fetch('/api/radar/latest?z=7&y=40&x=102');
@ -125,10 +124,8 @@
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`; return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
} }
function fromDTLocalInput(s){ function fromDTLocalInput(s){
// s like 'YYYY-MM-DDTHH:MM'
if(!s) return null; if(!s) return null;
const t = new Date(s.replace('T','-').replace(/-/g,'/')); // iOS safe parse later const t = new Date(s.replace('T','-').replace(/-/g,'/'));
// We will manually format to server expected 'YYYY-MM-DD HH:MM:SS'
const pad=(n)=> String(n).padStart(2,'0'); 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`; return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`;
} }
@ -162,7 +159,6 @@
async function renderTile(t, forcedDt) { async function renderTile(t, forcedDt) {
const fmt5 = (n) => Number(n).toFixed(5); const fmt5 = (n) => Number(n).toFixed(5);
// 填充元信息
document.getElementById('dt').textContent = t.dt; document.getElementById('dt').textContent = t.dt;
document.getElementById('z').textContent = t.z; document.getElementById('z').textContent = t.z;
document.getElementById('y').textContent = t.y; document.getElementById('y').textContent = t.y;
@ -173,7 +169,6 @@
document.getElementById('east').textContent = fmt5(t.east); document.getElementById('east').textContent = fmt5(t.east);
document.getElementById('north').textContent = fmt5(t.north); document.getElementById('north').textContent = fmt5(t.north);
document.getElementById('res').textContent = fmt5(t.res_deg); document.getElementById('res').textContent = fmt5(t.res_deg);
// 标题时间与索引同步
document.getElementById('titleDt').textContent = `${t.dt}`; document.getElementById('titleDt').textContent = `${t.dt}`;
const selBox = document.getElementById('timeSelect'); const selBox = document.getElementById('timeSelect');
for (let i = 0; i < selBox.options.length; i++) { for (let i = 0; i < selBox.options.length; i++) {
@ -182,7 +177,6 @@
gCurrentIdx = gTimes.indexOf(t.dt); gCurrentIdx = gTimes.indexOf(t.dt);
updateCountAndButtons(); updateCountAndButtons();
// Plotly 热力图(离散 5 dBZ 色带)
const colors = [ const colors = [
"#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800", "#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800",
"#019000","#FFFF00","#E7C000","#FF9000","#FF0000", "#019000","#FFFF00","#E7C000","#FF9000","#FF0000",
@ -192,17 +186,14 @@
const resDeg = t.res_deg; const resDeg = t.res_deg;
const west = t.west, south = t.south, north = t.north; const west = t.west, south = t.south, north = t.north;
// 经纬度轴像元中心。x 自西向东递增y 自南向北递增(保证上=北,下=南)。
const xs = new Array(w); const xs = new Array(w);
for (let c = 0; c < w; c++) xs[c] = west + (c + 0.5) * resDeg; for (let c = 0; c < w; c++) xs[c] = west + (c + 0.5) * resDeg;
const ys = new Array(h); const ys = new Array(h);
for (let r = 0; r < h; r++) ys[r] = south + (r + 0.5) * resDeg; for (let r = 0; r < h; r++) ys[r] = south + (r + 0.5) * resDeg;
// 缓存用于后续扇形计算
gTileValues = t.values; gTileValues = t.values;
gXs = xs; gYs = ys; gXs = xs; gYs = ys;
// 将 dBZ 离散到 0..14 档,并保留原值用于 hover
const zBins = new Array(h); const zBins = new Array(h);
const custom = new Array(h); const custom = new Array(h);
for (let r = 0; r < h; r++) { for (let r = 0; r < h; r++) {
@ -218,7 +209,6 @@
zBins[r] = rowBins; custom[r] = rowCustom; zBins[r] = rowBins; custom[r] = rowCustom;
} }
// 颜色刻度0..14 归一化到 0..1
const scale = []; const scale = [];
for (let i = 0; i < colors.length; i++) { for (let i = 0; i < colors.length; i++) {
const tpos = colors.length === 1 ? 0 : i / (colors.length - 1); const tpos = colors.length === 1 ? 0 : i / (colors.length - 1);
@ -252,11 +242,10 @@
'dBZ=%{customdata[2]:.1f}<extra></extra>' 'dBZ=%{customdata[2]:.1f}<extra></extra>'
}]; }];
// 叠加“来风±30°扇形3小时可达半径”覆盖层默认显示、无图例项到主图
if (gWindFromDeg !== null && gWindSpeedMS > 0) { if (gWindFromDeg !== null && gWindSpeedMS > 0) {
const stLat = 23.097234, stLon = 108.715433; const stLat = 23.097234, stLon = 108.715433;
const half = 30; // 半开角 const half = 30;
const samples = 64; // 弧线采样点 const samples = 64;
const start = gWindFromDeg - half; const start = gWindFromDeg - half;
const end = gWindFromDeg + half; const end = gWindFromDeg + half;
const rangeM = gWindSpeedMS * 3 * 3600; const rangeM = gWindSpeedMS * 3 * 3600;
@ -281,13 +270,11 @@
autosize: true, autosize: true,
margin: {l:40, r:8, t:8, b:90}, margin: {l:40, r:8, t:8, b:90},
xaxis: {title: '经度', tickformat: '.2f', zeroline: false, constrain: 'domain', automargin: true}, xaxis: {title: '经度', tickformat: '.2f', zeroline: false, constrain: 'domain', automargin: true},
// y 轴默认从小到大(下→上),配合 ys 自南到北递增,实现“上=北,下=南”
yaxis: {title: {text: '纬度', standoff: 12}, tickformat: '.2f', zeroline: false, scaleanchor: 'x', scaleratio: 1, constrain: 'domain', automargin: true}, yaxis: {title: {text: '纬度', standoff: 12}, tickformat: '.2f', zeroline: false, scaleanchor: 'x', scaleratio: 1, constrain: 'domain', automargin: true},
}; };
const plotEl = document.getElementById('radarPlot'); const plotEl = document.getElementById('radarPlot');
Plotly.newPlot(plotEl, data, layout, {responsive: true, displayModeBar: false}).then(() => { Plotly.newPlot(plotEl, data, layout, {responsive: true, displayModeBar: false}).then(() => {
// 强制按照容器宽度设置高度,保持方形
const size = plotEl.clientWidth; const size = plotEl.clientWidth;
Plotly.relayout(plotEl, {height: size}); Plotly.relayout(plotEl, {height: size});
}); });
@ -297,7 +284,6 @@
Plotly.Plots.resize(plotEl); Plotly.Plots.resize(plotEl);
}); });
// 同步加载与瓦片时间对应的10分钟气象向上取整到10分
try { try {
const dt = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/')); const dt = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
const ceil10 = new Date(dt); const ceil10 = new Date(dt);
@ -308,11 +294,9 @@
const pad = (n)=> n.toString().padStart(2,'0'); const pad = (n)=> n.toString().padStart(2,'0');
const dtStr = `${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`; const dtStr = `${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
await loadRealtimeAt(dtStr); await loadRealtimeAt(dtStr);
// 扇形计算 + 正方形裁剪热力图
maybeCalcSector(); maybeCalcSector();
maybePlotSquare(); maybePlotSquare();
} catch (e) { } catch (e) {
// 回退到最新
await loadRealtimeLatest(); await loadRealtimeLatest();
maybeCalcSector(); maybeCalcSector();
maybePlotSquare(); maybePlotSquare();
@ -339,8 +323,8 @@
document.getElementById('rt_wd').textContent = f2(r.wind_direction); document.getElementById('rt_wd').textContent = f2(r.wind_direction);
document.getElementById('rt_p').textContent = f2(r.pressure); document.getElementById('rt_p').textContent = f2(r.pressure);
gWindFromDeg = Number(r.wind_direction); // 来风方向(度) gWindFromDeg = Number(r.wind_direction);
gWindSpeedMS = Number(r.wind_speed); // m/s后端已转好 gWindSpeedMS = Number(r.wind_speed);
} }
async function loadRealtimeAt(dtStr) { async function loadRealtimeAt(dtStr) {
@ -370,7 +354,7 @@
function toRad(d){ return d * Math.PI / 180; } function toRad(d){ return d * Math.PI / 180; }
function toDeg(r){ return r * 180 / Math.PI; } function toDeg(r){ return r * 180 / Math.PI; }
function haversine(lat1, lon1, lat2, lon2){ function haversine(lat1, lon1, lat2, lon2){
const R = 6371000; // m const R = 6371000;
const dLat = toRad(lat2 - lat1); const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1); 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 a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
@ -382,15 +366,14 @@
const y = Math.sin(Δλ) * Math.cos(φ2); const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ); const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
let brng = toDeg(Math.atan2(y, x)); let brng = toDeg(Math.atan2(y, x));
return (brng + 360) % 360; // 0..360, 0=北,顺时针 return (brng + 360) % 360;
} }
function angDiff(a, b){ function angDiff(a, b){
let d = ((a - b + 540) % 360) - 180; // -180..180 let d = ((a - b + 540) % 360) - 180;
return Math.abs(d); return Math.abs(d);
} }
// 前向大地解算:从(lat,lon)出发沿方位角brgDeg距离distM求目的地经纬度
function destPoint(lat, lon, brgDeg, distM){ function destPoint(lat, lon, brgDeg, distM){
const R = 6371000; // m const R = 6371000;
const δ = distM / R; const δ = distM / R;
const θ = toRad(brgDeg); const θ = toRad(brgDeg);
const φ1 = toRad(lat); const φ1 = toRad(lat);
@ -402,19 +385,18 @@
const y = Math.sin(θ) * sinδ * cosφ1; const y = Math.sin(θ) * sinδ * cosφ1;
const x = cosδ - sinφ1 * sinφ2; const x = cosδ - sinφ1 * sinφ2;
const λ2 = λ1 + Math.atan2(y, x); const λ2 = λ1 + Math.atan2(y, x);
return { lat: toDeg(φ2), lon: ((toDeg(λ2) + 540) % 360) - 180 }; // 归一化到[-180,180) return { lat: toDeg(φ2), lon: ((toDeg(λ2) + 540) % 360) - 180 };
} }
function maybeCalcSector(){ function maybeCalcSector(){
try{ try{
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null){ if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null){
return; // 等待数据就绪 return;
} }
// 站点(南宁雷达站经纬度)
const stLat = 23.097234, stLon = 108.715433; const stLat = 23.097234, stLon = 108.715433;
const halfAngle = 30; // ±30° const halfAngle = 30;
const rangeM = gWindSpeedMS * 3 * 3600; // 三小时覆盖半径 const rangeM = gWindSpeedMS * 3 * 3600;
let best = null; // {dist, lat, lon, dbz} let best = null;
const h = gTileValues.length; const h = gTileValues.length;
const w = gTileValues[0].length; const w = gTileValues[0].length;
for(let r=0; r<h; r++){ for(let r=0; r<h; r++){
@ -423,12 +405,11 @@
const val = gTileValues[r][c]; const val = gTileValues[r][c];
if(val==null) continue; if(val==null) continue;
const dbz = Number(val); const dbz = Number(val);
if(!(dbz >= 40)) continue; // 门限 if(!(dbz >= 40)) continue;
const lon = gXs[c]; const lon = gXs[c];
const dist = haversine(stLat, stLon, lat, lon); const dist = haversine(stLat, stLon, lat, lon);
if(dist > rangeM) continue; if(dist > rangeM) continue;
const brg = bearingDeg(stLat, stLon, lat, lon); const brg = bearingDeg(stLat, stLon, lat, lon);
// 以“来风方向”为扇形中心
if(angDiff(brg, gWindFromDeg) > halfAngle) continue; if(angDiff(brg, gWindFromDeg) > halfAngle) continue;
if(!best || dist < best.dist){ best = {dist, lat, lon, dbz}; } if(!best || dist < best.dist){ best = {dist, lat, lon, dbz}; }
} }
@ -457,19 +438,16 @@
function maybePlotSquare(){ function maybePlotSquare(){
try{ try{
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null){ if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null){
return; // 等待数据 return;
} }
// 常量与站点 const R = 6371000;
const R = 6371000; // 地球半径m
const stLat = 23.097234, stLon = 108.715433; const stLat = 23.097234, stLon = 108.715433;
const rangeM = gWindSpeedMS * 3 * 3600; // 3小时 const rangeM = gWindSpeedMS * 3 * 3600;
// 米->度(近似):
const dLat = (rangeM / R) * (180/Math.PI); const dLat = (rangeM / R) * (180/Math.PI);
const dLon = (rangeM / (R * Math.cos(toRad(stLat)))) * (180/Math.PI); const dLon = (rangeM / (R * Math.cos(toRad(stLat)))) * (180/Math.PI);
const latMin = stLat - dLat, latMax = stLat + dLat; const latMin = stLat - dLat, latMax = stLat + dLat;
const lonMin = stLon - dLon, lonMax = stLon + dLon; const lonMin = stLon - dLon, lonMax = stLon + dLon;
// 定位索引范围y从南到北递增, x从西到东递增
const h = gYs.length, w = gXs.length; const h = gYs.length, w = gXs.length;
let rStart = 0; while(rStart < h && gYs[rStart] < latMin) rStart++; let rStart = 0; while(rStart < h && gYs[rStart] < latMin) rStart++;
let rEnd = h-1; while(rEnd >= 0 && gYs[rEnd] > latMax) rEnd--; let rEnd = h-1; while(rEnd >= 0 && gYs[rEnd] > latMax) rEnd--;
@ -484,7 +462,6 @@
const xs = gXs.slice(cStart, cEnd+1); const xs = gXs.slice(cStart, cEnd+1);
const ys = gYs.slice(rStart, rEnd+1); const ys = gYs.slice(rStart, rEnd+1);
// 离散到色带(与主图一致)
const colors = [ const colors = [
"#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800", "#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800",
"#019000","#FFFF00","#E7C000","#FF9000","#FF0000", "#019000","#FFFF00","#E7C000","#FF9000","#FF0000",
@ -527,15 +504,13 @@
hovertemplate: 'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' hovertemplate: 'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>'
}]; }];
// 叠加“来风±30°扇形3小时可达半径”覆盖层默认显示、无图例项
if (gWindFromDeg !== null && gWindSpeedMS > 0) { if (gWindFromDeg !== null && gWindSpeedMS > 0) {
const half = 30; // 半开角 const half = 30;
const samples = 64; // 弧线采样点 const samples = 64;
const start = gWindFromDeg - half; const start = gWindFromDeg - half;
const end = gWindFromDeg + half; const end = gWindFromDeg + half;
const xsFan = []; const xsFan = [];
const ysFan = []; const ysFan = [];
// 从中心起点,沿弧线,再回到中心,闭合多边形
xsFan.push(stLon); ysFan.push(stLat); xsFan.push(stLon); ysFan.push(stLat);
for (let i = 0; i <= samples; i++) { for (let i = 0; i <= samples; i++) {
const θ = start + (end - start) * (i / samples); const θ = start + (end - start) * (i / samples);
@ -556,7 +531,6 @@
xaxis: {title: '经度', tickformat: '.2f', zeroline: false, constrain: 'domain', automargin: true}, 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}, yaxis: {title: {text: '纬度', standoff: 12}, tickformat: '.2f', zeroline: false, scaleanchor: 'x', scaleratio: 1, constrain: 'domain', automargin: true},
shapes: [ shapes: [
// 画出正方形边界lonMin..lonMax, latMin..latMax
{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)'} {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)'}
] ]
}; };
@ -576,8 +550,6 @@
} }
} }
// 初始化加载最新瓦片、填充时间下拉默认近3小时、绑定事件
// 预填范围
(function initRange(){ (function initRange(){
const end = new Date(); const end = new Date();
const start = new Date(end.getTime() - 3*3600*1000); const start = new Date(end.getTime() - 3*3600*1000);
@ -611,7 +583,7 @@
function updateCountAndButtons(){ function updateCountAndButtons(){
const N = gTimes.length; const N = gTimes.length;
const k = gCurrentIdx >= 0 ? (gCurrentIdx + 1) : 0; const k = gCurrentIdx >= 0 ? (gCurrentIdx + 1) : 0;
document.getElementById('countInfo').textContent=`共${N}条数据,-${k-1}时`; document.getElementById('countInfo').textContent=`共${N}条数据,-${k-1}时`;
const prev = document.getElementById('btnPrev'); const prev = document.getElementById('btnPrev');
const next = document.getElementById('btnNext'); const next = document.getElementById('btnNext');
prev.disabled = !(N > 0 && gCurrentIdx >= 0 && gCurrentIdx < N-1); prev.disabled = !(N > 0 && gCurrentIdx >= 0 && gCurrentIdx < N-1);
@ -641,13 +613,12 @@
updateSlider(); updateSlider();
}); });
// 滑动条联动
function updateSlider(){ function updateSlider(){
const slider = document.getElementById('timeSlider'); const slider = document.getElementById('timeSlider');
const N = gTimes.length; const N = gTimes.length;
slider.max = N > 0 ? String(N-1) : '0'; slider.max = N > 0 ? String(N-1) : '0';
if (N > 0 && gCurrentIdx >= 0) { if (N > 0 && gCurrentIdx >= 0) {
const sliderVal = (N - 1) - gCurrentIdx; // 值越大越新 const sliderVal = (N - 1) - gCurrentIdx;
slider.value = String(sliderVal); slider.value = String(sliderVal);
} }
slider.disabled = N === 0; slider.disabled = N === 0;
@ -656,7 +627,7 @@
const N = gTimes.length; if (N === 0) return; const N = gTimes.length; if (N === 0) return;
const raw = parseInt(e.target.value, 10); const raw = parseInt(e.target.value, 10);
const sliderVal = Math.max(0, Math.min(N-1, isNaN(raw)?0:raw)); const sliderVal = Math.max(0, Math.min(N-1, isNaN(raw)?0:raw));
const idx = (N - 1) - sliderVal; // 反向映射到数组索引0=最新) const idx = (N - 1) - sliderVal;
if (idx === gCurrentIdx) return; if (idx === gCurrentIdx) return;
gCurrentIdx = idx; gCurrentIdx = idx;
const dt = gTimes[gCurrentIdx]; const dt = gTimes[gCurrentIdx];
@ -664,7 +635,6 @@
await loadTileAt(dt); await loadTileAt(dt);
updateCountAndButtons(); updateCountAndButtons();
}); });
// 兜底加载最新
loadRealtimeLatest().catch(err => { loadRealtimeLatest().catch(err => {
const info = document.getElementById('rtInfo'); const info = document.getElementById('rtInfo');
info.innerHTML = `<div class="text-sm text-red-600">${err.message}</div>`; info.innerHTML = `<div class="text-sm text-red-600">${err.message}</div>`;