weather-station/templates/imdroid_radar.html

596 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/css/tailwind.min.css">
<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, 72vw, 680px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
.plot-box-sm { width: clamp(320px, 65vw, 612px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
@supports not (aspect-ratio: 1 / 1) { .plot-box { height: 520px; } }
</style>
<script src="/static/js/plotly-2.27.0.min.js"></script>
<script>
let gZ=0, gY=0, gX=0;
let gTimes = [];
let gCurrentIdx = -1;
let gAlias = '';
// 3H 预报相关全局量
let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null;
let gTileDT = null;
let gStLat = null, gStLon = null;
async function loadStations() {
const sel = document.getElementById('stationSelect');
sel.innerHTML = '';
const opt = document.createElement('option');
opt.value = ''; opt.textContent = '请选择站点…';
sel.appendChild(opt);
const res = await fetch('/api/stations');
const stations = await res.json();
stations
.filter(s => s.device_type === 'WH65LP' && s.latitude && s.longitude)
.forEach(s => {
const o = document.createElement('option');
o.value = s.station_id; // 用 station_id 作为联动主键
const alias = (s.station_alias && String(s.station_alias).trim().length > 0) ? s.station_alias : s.station_id;
o.textContent = alias; // 仅显示别名
o.dataset.z = s.z; o.dataset.y = s.y; o.dataset.x = s.x;
o.dataset.lat = s.latitude; o.dataset.lon = s.longitude;
sel.appendChild(o);
});
}
function setRealtimeBox(j){
const set = (id, v) => document.getElementById(id).textContent = (v ?? '') === '' ? '' : String(v);
set('rt_alias', j.alias);
set('rt_dt', j.dt);
set('rt_lat', j.lat);
set('rt_lon', j.lon);
if (typeof j.temperature === 'number') set('rt_t', j.temperature.toFixed(2)); else set('rt_t','');
if (typeof j.humidity === 'number') set('rt_h', j.humidity.toFixed(0)*100); else set('rt_h','');
if (typeof j.wind_speed === 'number') set('rt_ws', j.wind_speed.toFixed(2)); else set('rt_ws','');
if (typeof j.wind_direction === 'number') set('rt_wd', j.wind_direction.toFixed(0)); else set('rt_wd','');
if (typeof j.cloudrate === 'number') set('rt_c', j.cloudrate.toFixed(2)*100); else set('rt_c','');
if (typeof j.visibility === 'number') set('rt_vis', j.visibility.toFixed(2)); else set('rt_vis','');
if (typeof j.dswrf === 'number') set('rt_dswrf', j.dswrf.toFixed(1)); else set('rt_dswrf','');
if (typeof j.pressure === 'number') set('rt_p', j.pressure.toFixed(0)); else set('rt_p','');
if (typeof j.wind_direction === 'number') gWindFromDeg = Number(j.wind_direction);
if (typeof j.wind_speed === 'number') gWindSpeedMS = Number(j.wind_speed);
}
async function loadRealtime(alias) {
const res = await fetch(`/api/radar/weather_latest?alias=${encodeURIComponent(alias)}`);
if (!res.ok) throw new Error('实时数据不存在');
const j = await res.json();
setRealtimeBox(j);
const miss = document.getElementById('rt_missing');
if (miss) miss.classList.add('hidden');
}
async function loadRealtimeAt(alias, dtStr) {
const res = await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(alias)}&dt=${encodeURIComponent(dtStr)}`);
if (!res.ok) {
const miss = document.getElementById('rt_missing');
if (miss) miss.classList.remove('hidden');
return false;
}
const j = await res.json();
setRealtimeBox(j);
const miss = document.getElementById('rt_missing');
if (miss) miss.classList.add('hidden');
return true;
}
async function loadLatestTile(z, y, x) {
const status = document.getElementById('tile_status');
const res = await fetch(`/api/radar/latest?z=${z}&y=${y}&x=${x}`);
if (!res.ok) { status.textContent = '未找到瓦片'; return; }
const t = await res.json();
const fmt = (n, d=5)=> Number(n).toFixed(d);
document.getElementById('tile_dt').textContent = t.dt;
document.getElementById('tile_z').textContent = t.z;
document.getElementById('tile_y').textContent = t.y;
document.getElementById('tile_x').textContent = t.x;
document.getElementById('tile_w').textContent = fmt(t.west);
document.getElementById('tile_s').textContent = fmt(t.south);
document.getElementById('tile_e').textContent = fmt(t.east);
document.getElementById('tile_n').textContent = fmt(t.north);
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
status.textContent = '';
renderTilePlot(t);
const idx = gTimes.indexOf(t.dt);
if (idx >= 0) {
gCurrentIdx = idx;
updateCountAndButtons();
updateSlider();
const sel = document.getElementById('timeSelect');
sel.value = t.dt;
}
// 同步气象按瓦片时间向下取整到10分钟查询该桶的实况
if (gAlias) {
const bucket = dtToBucket10(t.dt);
if (bucket) {
await loadRealtimeAt(gAlias, bucket);
}
}
maybeCalcSector();
maybePlotSquare();
maybeCalcNearbyRain();
}
async function loadTileAt(z, y, x, dtStr) {
const status = document.getElementById('tile_status');
const res = await fetch(`/api/radar/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`);
if (!res.ok) { status.textContent = '未找到瓦片'; return; }
const t = await res.json();
const fmt = (n, d=5)=> Number(n).toFixed(d);
document.getElementById('tile_dt').textContent = t.dt;
document.getElementById('tile_z').textContent = t.z;
document.getElementById('tile_y').textContent = t.y;
document.getElementById('tile_x').textContent = t.x;
document.getElementById('tile_w').textContent = fmt(t.west);
document.getElementById('tile_s').textContent = fmt(t.south);
document.getElementById('tile_e').textContent = fmt(t.east);
document.getElementById('tile_n').textContent = fmt(t.north);
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
status.textContent = '';
renderTilePlot(t);
if (gAlias) {
const bucket = dtToBucket10(t.dt);
if (bucket) { await loadRealtimeAt(gAlias, bucket); }
}
maybeCalcSector();
maybePlotSquare();
maybeCalcNearbyRain();
}
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(z, y, x, fromStr, toStr) {
try {
let url = `/api/radar/times?z=${z}&y=${y}&x=${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) {
sel.value = gTimes[0];
gCurrentIdx = 0;
} else {
gCurrentIdx = -1;
}
updateCountAndButtons();
updateSlider();
} catch {}
}
function updateCountAndButtons(){
const info = document.getElementById('countInfo');
const total = gTimes.length;
const idxDisp = gCurrentIdx >= 0 ? (gCurrentIdx+1) : 0;
info.textContent = `${total}条,第${idxDisp}`;
const prev = document.getElementById('btnPrev');
const next = document.getElementById('btnNext');
// gTimes 按时间倒序(最新在前),上一时次=更老 => 允许在 idx < total-1
if (prev) prev.disabled = !(total > 0 && gCurrentIdx >= 0 && gCurrentIdx < total - 1);
// 下一时次=更近 => 允许在 idx > 0
if (next) next.disabled = !(total > 0 && gCurrentIdx > 0);
}
function updateSlider(){
const slider = document.getElementById('timeSlider');
if (!slider) return;
const total = gTimes.length;
slider.max = total > 0 ? String(total-1) : '0';
// 最新gCurrentIdx=0滑块在最右端value=max
slider.value = gCurrentIdx >= 0 ? String((total-1) - gCurrentIdx) : '0';
}
function dtToBucket10(dtStr){
// dtStr: "YYYY-MM-DD HH:MM:SS"
if (!dtStr) return '';
const parts = dtStr.split(/[- :]/g);
if (parts.length < 6) return '';
const y=+parts[0], m=+parts[1]-1, d=+parts[2], hh=+parts[3], mm=+parts[4];
const date = new Date(y, m, d, hh, mm, 0, 0);
const bucketMin = Math.floor(mm/10)*10;
date.setMinutes(bucketMin, 0, 0);
const pad=(n)=> String(n).padStart(2,'0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:00`;
}
function renderTilePlot(t) {
if (!t || !t.values) return;
const w = t.width, h = t.height;
const resDeg = t.res_deg;
const west = t.west, south = t.south;
const xs = new Array(w);
for (let c = 0; c < w; c++) xs[c] = west + (c + 0.5) * resDeg;
const ys = new Array(h);
for (let r = 0; r < h; r++) ys[r] = south + (r + 0.5) * resDeg;
// 保存到全局供后续计算
gTileValues = t.values; gXs = xs; gYs = ys;
try { gTileDT = new Date((t.dt || '').replace(/-/g,'/')); } catch {}
const colors = ["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
// 构建离散色阶0..14+ customdata用于 hover 展示 dBZ
const zBins = []; const custom = [];
for (let r = 0; r < h; r++) {
const row = t.values[r]; const rowBins = []; const 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 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 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 && gStLat !== null && gStLon !== null) {
const half = 30, samples = 64, start = gWindFromDeg - half, end = gWindFromDeg + half, rangeM = gWindSpeedMS * 3 * 3600;
const xsFan = [gStLon], ysFan = [gStLat];
for (let i = 0; i <= samples; i++) {
const θ = start + (end - start) * (i / samples);
const p = destPoint(gStLat, gStLon, ((θ % 360) + 360) % 360, rangeM);
xsFan.push(p.lon); ysFan.push(p.lat);
}
xsFan.push(gStLon); ysFan.push(gStLat);
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 });
}
// 叠加附近5km圆
if (gStLat !== null && gStLon !== null) {
const Rm = 8000; const samples=128; const xsC=[], ysC=[];
for(let i=0;i<=samples;i++){ const θ=i*(360/samples); const p=destPoint(gStLat,gStLon,θ,Rm); xsC.push(p.lon); ysC.push(p.lat); }
data.push({ type:'scatter', mode:'lines', x:xsC, y:ysC, line:{ color:'#66CC66', width:1, dash:'dot' }, hoverinfo:'skip', showlegend:false });
}
Plotly.newPlot('tile_plot', data, {
margin:{l:36,r:8,t:8,b:90},
xaxis:{title:{text:'经度', standoff: 12}, tickformat:'.2f', constrain:'domain', automargin:true},
yaxis:{title:{text:'纬度', standoff: 12}, tickformat:'.2f', showticklabels: true, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true}
}, {responsive:true, displayModeBar:false}).then(()=>{
const el = document.getElementById('tile_plot'); const s = el.clientWidth; Plotly.relayout(el,{height:s});
});
window.addEventListener('resize',()=>{ const el=document.getElementById('tile_plot'); const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); });
}
// 地理与3H预报工具
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 };
}
function maybeCalcSector(){
try{
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null || gStLat===null || gStLon===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(gStLat,gStLon,lat,lon); if(dist>rangeM) continue;
const brg=bearingDeg(gStLat,gStLon,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 || gStLat===null || gStLon===null) return;
const R=6371000; const rangeM=gWindSpeedMS*3*3600;
const dLat=(rangeM/R)*(180/Math.PI);
const dLon=(rangeM/(R*Math.cos(toRad(gStLat))))*(180/Math.PI);
const latMin=gStLat-dLat, latMax=gStLat+dLat, lonMin=gStLon-dLon, lonMax=gStLon+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;
const xsFan=[gStLon], ysFan=[gStLat];
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(gStLat,gStLon,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
xsFan.push(gStLon); ysFan.push(gStLat);
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 });
}
// 叠加附近5km圆
{
const Rm = 8000; const samples=128; const xsC=[], ysC=[];
for(let i=0;i<=samples;i++){ const θ=i*(360/samples); const p=destPoint(gStLat,gStLon,θ,Rm); xsC.push(p.lon); ysC.push(p.lat); }
data.push({ type:'scatter', mode:'lines', x:xsC, y:ysC, line:{ color:'#66CC66', width:1, dash:'dot' }, 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 maybeCalcNearbyRain(){
try{
const el = document.getElementById('nearbyStatus'); if(!el) return;
if(!gTileValues || !gXs || !gYs || gStLat===null || gStLon===null){ el.textContent=''; return; }
const radiusM = 8000; // 8km
const h=gTileValues.length, w=gTileValues[0].length;
let hit=false, maxDBZ=null;
for(let r=0;r<h && !hit;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(gStLat,gStLon,lat,lon);
if(dist <= radiusM){ hit=true; maxDBZ = maxDBZ==null?dbz:Math.max(maxDBZ, dbz); break; }
}
}
if(hit){ el.textContent = `附近8公里内检测到大于等于 40dBz 的雷达反射率,短时间内可能会下雨`; el.classList.remove('text-gray-700'); el.classList.add('text-red-700'); }
}catch(e){ /* ignore */ }
}
async function main() {
await loadStations();
// 初始化时间范围为最近24小时
(function initRange(){
const now = new Date();
const end = now; // 到现在
const start = new Date(now.getTime() - 6*3600*1000); // 6 小时前
const pad = (n)=> String(n).padStart(2,'0');
const toLocalInput=(d)=> `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const sEl = document.getElementById('tsStart');
const eEl = document.getElementById('tsEnd');
if (sEl && eEl) { sEl.value = toLocalInput(start); eEl.value = toLocalInput(end); }
})();
document.getElementById('btnLoad').addEventListener('click', async ()=>{
const sel = document.getElementById('stationSelect');
const alias = sel.value;
if (!alias) return;
gAlias = alias;
gZ = Number(sel.options[sel.selectedIndex].dataset.z || 0);
gY = Number(sel.options[sel.selectedIndex].dataset.y || 0);
gX = Number(sel.options[sel.selectedIndex].dataset.x || 0);
const lat = Number(sel.options[sel.selectedIndex].dataset.lat);
const lon = Number(sel.options[sel.selectedIndex].dataset.lon);
gStLat = isNaN(lat)? null : lat; gStLon = isNaN(lon)? null : lon;
document.getElementById('rt_zyx').textContent = `z=${gZ}, y=${gY}, x=${gX}`;
try { await loadRealtime(alias); } catch (e) { console.warn(e); }
if (gZ && gY && gX) {
const from = fromDTLocalInput(document.getElementById('tsStart').value);
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
await populateTimes(gZ,gY,gX, from, to);
await loadLatestTile(gZ,gY,gX);
}
});
const tsQuery = document.getElementById('tsQuery');
if (tsQuery) tsQuery.addEventListener('click', async ()=>{
if (!(gZ && gY && gX)) return;
const from = fromDTLocalInput(document.getElementById('tsStart').value);
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
await populateTimes(gZ,gY,gX, from, to);
if (gCurrentIdx >= 0) await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
});
const timeSelect = document.getElementById('timeSelect');
if (timeSelect) timeSelect.addEventListener('change', async (e)=>{
if (!(gZ && gY && gX)) return;
const dt = e.target.value;
if (!dt) { await loadLatestTile(gZ,gY,gX); return; }
gCurrentIdx = gTimes.indexOf(dt);
updateCountAndButtons();
updateSlider();
await loadTileAt(gZ,gY,gX, dt);
});
const timeSlider = document.getElementById('timeSlider');
if (timeSlider) timeSlider.addEventListener('input', async (e)=>{
if (!(gZ && gY && gX)) return;
const total = gTimes.length;
const raw = Number(e.target.value);
const idx = (total - 1) - raw; // 右端=最新
if (idx >= 0 && idx < gTimes.length) {
gCurrentIdx = idx;
updateCountAndButtons();
const sel = document.getElementById('timeSelect');
if (sel) sel.value = gTimes[idx] || '';
await loadTileAt(gZ,gY,gX, gTimes[idx]);
}
});
const btnPrev = document.getElementById('btnPrev');
if (btnPrev) btnPrev.addEventListener('click', async ()=>{
if (!(gZ && gY && gX)) return;
// 上一时次:向更老的时间移动(索引+1
if (gCurrentIdx < gTimes.length - 1) {
gCurrentIdx++;
updateCountAndButtons();
updateSlider();
const sel = document.getElementById('timeSelect');
if (sel) sel.value = gTimes[gCurrentIdx] || '';
await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
}
});
const btnNext = document.getElementById('btnNext');
if (btnNext) btnNext.addEventListener('click', async ()=>{
if (!(gZ && gY && gX)) return;
// 下一时次:向更新的时间移动(索引-1
if (gCurrentIdx > 0) {
gCurrentIdx--;
updateCountAndButtons();
updateSlider();
const sel = document.getElementById('timeSelect');
if (sel) sel.value = gTimes[gCurrentIdx] || '';
await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
}
});
}
window.addEventListener('DOMContentLoaded', main);
</script>
</head>
<body>
{{ template "header" . }}
<div class="content-narrow p-4 text-sm">
<div class="card mb-4">
<div class="text-lg font-semibold mb-2">雷达站</div>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<label class="text-sm">选择站点:</label>
<select id="stationSelect" class="border rounded px-2 py-1 text-sm min-w-[280px]"></select>
<button id="btnLoad" class="px-2.5 py-1 text-sm bg-blue-600 text-white rounded">加载数据</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-gray-700">时间范围:</span>
<input id="tsStart" type="datetime-local" class="border rounded px-2 py-1 text-sm"/>
<span></span>
<input id="tsEnd" type="datetime-local" class="border rounded px-2 py-1 text-sm"/>
<button id="tsQuery" class="px-2.5 py-1 text-sm bg-blue-600 text-white rounded">查询</button>
</div>
</div>
</div>
<div class="card mb-4">
<div class="text-base font-semibold mb-2">气象数据</div>
<div id="rtBox" class="text-sm grid grid-cols-2 gap-y-1.5 gap-x-6">
<div>站点:<span id="rt_alias"></span></div>
<div>Z/Y/X<span id="rt_zyx"></span></div>
<div>时间:<span id="rt_dt"></span></div>
<div>位置:<span id="rt_lat"></span><span id="rt_lon"></span></div>
<div>温度:<span id="rt_t"></span></div>
<div>湿度:<span id="rt_h"></span></div>
<div>风速:<span id="rt_ws"></span> m/s</div>
<div>风向:<span id="rt_wd"></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_p"></span> Pa</div>
</div>
<div id="rt_missing" class="mt-2 hidden p-2 text-sm text-yellow-800 bg-yellow-50 border border-yellow-200 rounded">提示:该时次的 10 分钟实况缺失,已显示最近/最新实况。</div>
</div>
<div class="card my-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 id="nearbyStatus" class="mt-1 text-gray-700"></div>
</div>
</div>
<div class="card">
<div class="text-base font-semibold mb-2">雷达瓦片</div>
<div class="text-sm space-y-1">
<div>时间:<span id="tile_dt" class="font-mono"></span></div>
<div>索引z=<span id="tile_z"></span> / y=<span id="tile_y"></span> / x=<span id="tile_x"></span></div>
<div>边界W=<span id="tile_w"></span>S=<span id="tile_s"></span>E=<span id="tile_e"></span>N=<span id="tile_n"></span></div>
<div>分辨率(度/像素):<span id="tile_res"></span></div>
<div id="tile_status" class="text-gray-500"></div>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
<button id="btnPrev" class="px-2.5 py-1 text-sm border rounded">上一时次</button>
<span id="countInfo" class="text-gray-600 text-sm">共0条第0条</span>
<button id="btnNext" class="px-2.5 py-1 text-sm border rounded">下一时次</button>
<select id="timeSelect" class="border rounded px-2 py-1 text-sm min-w-[240px]">
<option value="">最新</option>
</select>
</div>
<div class="w-full flex justify-center my-2">
<input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
</div>
<div id="tile_plot" class="plot-box-sm mt-3"></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>
</body>
</html>