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

500 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/css/tailwind.min.css">
<script src="/static/js/plotly-2.27.0.min.js"></script>
<style>
body { font-family: Arial, sans-serif; margin: 0; }
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
.placeholder { padding: 40px 0; color: #666; text-align: center; }
.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; }
}
#radarTooltip { position: absolute; pointer-events: none; background: #fff; border: 1px solid #e5e7eb; font-size: 12px; padding: 6px 8px; border-radius: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.15); display: none; z-index: 10; }
</style>
</head>
<body>
{{ template "header" . }}
<div class="content-narrow p-4">
<div class="card mb-4">
<div class="text-lg font-semibold mb-2">最新 7/40/102 瓦片信息</div>
<div id="tileInfo" 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> </div>
<div>风速:<span id="rt_ws"></span> m/s</div>
<div>风向:<span id="rt_wd"></span> °</div>
<div>气压:<span id="rt_p"></span> Pa</div>
</div>
</div>
<div class="card mb-4">
<div class="text-lg font-semibold mb-2">未来 3H 预报</div>
<div id="sectorInfo" class="text-sm">
<div id="sectorStatus">计算中…</div>
<div id="sectorDetail" class="mt-1 hidden">
最近距离:<span id="sectorDist"></span> km
预计到达时间:<span id="sectorETA"></span>
</div>
</div>
</div>
<div class="card" style="width: 100%;">
<div class="text-lg font-semibold mb-2">雷达组合反射率</div>
<div id="radarPlot" class="plot-box"></div>
</div>
<div class="card mt-4" style="width: 100%;">
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
<div id="squarePlot" class="plot-box"></div>
</div>
</div>
<script>
let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null; // 来风方向0=北,顺时针),风速 m/s
async function loadLatestTile() {
const res = await fetch('/api/radar/latest?z=7&y=40&x=102');
if (!res.ok) { throw new Error('加载最新瓦片失败'); }
const t = await res.json();
const fmt5 = (n) => Number(n).toFixed(5);
// 填充元信息
document.getElementById('dt').textContent = t.dt;
document.getElementById('z').textContent = t.z;
document.getElementById('y').textContent = t.y;
document.getElementById('x').textContent = t.x;
document.getElementById('size').textContent = `${t.width} × ${t.height}`;
document.getElementById('west').textContent = fmt5(t.west);
document.getElementById('south').textContent = fmt5(t.south);
document.getElementById('east').textContent = fmt5(t.east);
document.getElementById('north').textContent = fmt5(t.north);
document.getElementById('res').textContent = fmt5(t.res_deg);
// Plotly 热力图(离散 5 dBZ 色带)
const colors = [
"#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800",
"#019000","#FFFF00","#E7C000","#FF9000","#FF0000",
"#D60000","#C00000","#FF00F0","#780084","#AD90F0"
];
const w = t.width, h = t.height;
const resDeg = t.res_deg;
const west = t.west, south = t.south, north = t.north;
// 经纬度轴像元中心。x 自西向东递增y 自南向北递增(保证上=北,下=南)。
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;
// 将 dBZ 离散到 0..14 档,并保留原值用于 hover
const zBins = new Array(h);
const custom = new Array(h);
for (let r = 0; r < h; r++) {
const rowBins = new Array(w);
const rowCustom = new Array(w);
for (let c = 0; c < w; c++) {
const val = t.values[r][c];
if (val === null || val === undefined) { rowBins[c] = null; rowCustom[c] = [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[c] = bin; rowCustom[c] = [r, c, dbz];
}
zBins[r] = rowBins; custom[r] = rowCustom;
}
// 颜色刻度0..14 归一化到 0..1
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: 'row=%{customdata[0]}, col=%{customdata[1]}<br>'+
'lon=%{x:.3f}, lat=%{y:.3f}<br>'+
'dBZ=%{customdata[2]:.1f}<extra></extra>'
}];
// 叠加“来风±30°扇形3小时可达半径”覆盖层默认显示、无图例项到主图
if (gWindFromDeg !== null && gWindSpeedMS > 0) {
const stLat = 23.097234, stLon = 108.715433;
const half = 30; // 半开角
const samples = 64; // 弧线采样点
const start = gWindFromDeg - half;
const end = gWindFromDeg + half;
const rangeM = gWindSpeedMS * 3 * 3600;
const xsFan = [];
const ysFan = [];
xsFan.push(stLon); ysFan.push(stLat);
for (let i = 0; i <= samples; i++) {
const θ = start + (end - start) * (i / samples);
const p = destPoint(stLat, stLon, ((θ % 360) + 360) % 360, rangeM);
xsFan.push(p.lon); ysFan.push(p.lat);
}
xsFan.push(stLon); ysFan.push(stLat);
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},
// y 轴默认从小到大(下→上),配合 ys 自南到北递增,实现“上=北,下=南”
yaxis: {title: {text: '纬度', standoff: 12}, tickformat: '.2f', zeroline: false, scaleanchor: 'x', scaleratio: 1, constrain: 'domain', automargin: true},
};
const plotEl = document.getElementById('radarPlot');
Plotly.newPlot(plotEl, data, layout, {responsive: true, displayModeBar: false}).then(() => {
// 强制按照容器宽度设置高度,保持方形
const size = plotEl.clientWidth;
Plotly.relayout(plotEl, {height: size});
});
window.addEventListener('resize', () => {
const size = plotEl.clientWidth;
Plotly.relayout(plotEl, {height: size});
Plotly.Plots.resize(plotEl);
});
// 同步加载与瓦片时间对应的10分钟气象向上取整到10分
try {
const dt = new Date(t.dt.replace(/-/g,'/')); // iOS兼容
const ceil10 = new Date(dt);
const m = dt.getMinutes();
const up = (Math.floor(m/10)*10 + 10) % 60;
ceil10.setMinutes(up, 0, 0);
if (up === 0) { ceil10.setHours(dt.getHours()+1); }
const pad = (n)=> n.toString().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 alias = encodeURIComponent('南宁雷达站');
const res = await fetch(`/api/radar/weather_latest?alias=${alias}`);
if (!res.ok) throw new Error('加载实时气象失败');
const r = await res.json();
const f2 = (n) => Number(n).toFixed(2);
const 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); // m/s后端已转好
}
async function loadRealtimeAt(dtStr) {
const alias = encodeURIComponent('南宁雷达站');
const res = await fetch(`/api/radar/weather_at?alias=${alias}&dt=${encodeURIComponent(dtStr)}`);
if (!res.ok) throw new Error('加载指定时间气象失败');
const r = await res.json();
const f2 = (n) => Number(n).toFixed(2);
const 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 toRad(d){ return d * Math.PI / 180; }
function toDeg(r){ return r * 180 / Math.PI; }
function haversine(lat1, lon1, lat2, lon2){
const R = 6371000; // m
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(Δλ);
let brng = toDeg(Math.atan2(y, x));
return (brng + 360) % 360; // 0..360, 0=北,顺时针
}
function angDiff(a, b){
let d = ((a - b + 540) % 360) - 180; // -180..180
return Math.abs(d);
}
// 前向大地解算:从(lat,lon)出发沿方位角brgDeg距离distM求目的地经纬度
function destPoint(lat, lon, brgDeg, distM){
const R = 6371000; // m
const δ = distM / R;
const θ = toRad(brgDeg);
const φ1 = toRad(lat);
const λ1 = toRad(lon);
const sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
const 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 }; // 归一化到[-180,180)
}
function maybeCalcSector(){
try{
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null){
return; // 等待数据就绪
}
// 站点(南宁雷达站经纬度)
const stLat = 23.097234, stLon = 108.715433;
const halfAngle = 30; // ±30°
const rangeM = gWindSpeedMS * 3 * 3600; // 三小时覆盖半径
let best = null; // {dist, lat, lon, dbz}
const h = gTileValues.length;
const 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(stLat, stLon, lat, lon);
if(dist > rangeM) continue;
const brg = bearingDeg(stLat, stLon, lat, lon);
// 以“来风方向”为扇形中心
if(angDiff(brg, gWindFromDeg) > halfAngle) continue;
if(!best || dist < best.dist){ best = {dist, lat, lon, dbz}; }
}
}
const statusEl = document.getElementById('sectorStatus');
const detailEl = document.getElementById('sectorDetail');
if(!best){
statusEl.textContent = '无≥40 dBZ';
detailEl.classList.add('hidden');
}else{
const etaSec = best.dist / gWindSpeedMS;
const eta = new Date(Date.now() + etaSec*1000);
const pad = (n)=> String(n).padStart(2,'0');
const etaStr = `${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
document.getElementById('sectorDist').textContent = (best.dist/1000).toFixed(1);
document.getElementById('sectorETA').textContent = etaStr;
statusEl.textContent = '三小时内可能有降雨≥40 dBZ ';
detailEl.classList.remove('hidden');
}
}catch(e){
const statusEl = document.getElementById('sectorStatus');
statusEl.textContent = '风险评估计算失败:' + e.message;
}
}
function maybePlotSquare(){
try{
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null){
return; // 等待数据
}
// 常量与站点
const R = 6371000; // 地球半径m
const stLat = 23.097234, stLon = 108.715433;
const rangeM = gWindSpeedMS * 3 * 3600; // 3小时
// 米->度(近似):
const dLat = (rangeM / R) * (180/Math.PI);
const dLon = (rangeM / (R * Math.cos(toRad(stLat)))) * (180/Math.PI);
const latMin = stLat - dLat, latMax = stLat + dLat;
const lonMin = stLon - dLon, lonMax = stLon + dLon;
// 定位索引范围y从南到北递增, x从西到东递增
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 = [
"#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800",
"#019000","#FFFF00","#E7C000","#FF9000","#FF0000",
"#D60000","#C00000","#FF00F0","#780084","#AD90F0"
];
const zBins = [];
const custom = [];
for(let r=rStart; r<=rEnd; r++){
const rowBins = [];
const 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 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>'
}];
// 叠加“来风±30°扇形3小时可达半径”覆盖层默认显示、无图例项
if (gWindFromDeg !== null && gWindSpeedMS > 0) {
const half = 30; // 半开角
const samples = 64; // 弧线采样点
const start = gWindFromDeg - half;
const end = gWindFromDeg + half;
const xsFan = [];
const ysFan = [];
// 从中心起点,沿弧线,再回到中心,闭合多边形
xsFan.push(stLon); ysFan.push(stLat);
for (let i = 0; i <= samples; i++) {
const θ = start + (end - start) * (i / samples);
const p = destPoint(stLat, stLon, ((θ % 360) + 360) % 360, rangeM);
xsFan.push(p.lon); ysFan.push(p.lat);
}
xsFan.push(stLon); ysFan.push(stLat);
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},
yaxis: {title: {text: '纬度', standoff: 12}, tickformat: '.2f', zeroline: false, scaleanchor: 'x', scaleratio: 1, constrain: 'domain', automargin: true},
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)'}
]
};
const el = document.getElementById('squarePlot');
Plotly.newPlot(el, data, layout, {responsive: true, displayModeBar: false}).then(()=>{
const size = el.clientWidth;
Plotly.relayout(el, {height: size});
});
window.addEventListener('resize', () => {
const size = el.clientWidth;
Plotly.relayout(el, {height: size});
Plotly.Plots.resize(el);
});
}catch(e){
const el = document.getElementById('squarePlot');
el.innerHTML = `<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">正方形热力图渲染失败:${e.message}</div>`;
}
}
loadLatestTile().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}(请确认 /static/js/plotly-2.27.0.min.js 已存在)</div>`;
});
// 兜底加载最新
loadRealtimeLatest().catch(err => {
const info = document.getElementById('rtInfo');
info.innerHTML = `<div class="text-sm text-red-600">${err.message}</div>`;
});
</script>
</body>
</html>