645 lines
31 KiB
HTML
645 lines
31 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; }
|
||
.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">历史时次查询</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/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 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>
|
||
let gTileValues = null, gXs = null, gYs = null;
|
||
let gWindFromDeg = null, gWindSpeedMS = null;
|
||
let gTimes = [];
|
||
let gCurrentIdx = -1;
|
||
|
||
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();
|
||
await renderTile(t);
|
||
}
|
||
|
||
async function loadTileAt(dtStr) {
|
||
const url = `/api/radar/at?z=7&y=40&x=102&dt=${encodeURIComponent(dtStr)}`;
|
||
const res = await fetch(url);
|
||
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=7&y=40&x=102';
|
||
if (fromStr && toStr) {
|
||
url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`;
|
||
} else {
|
||
url += '&limit=60';
|
||
}
|
||
const res = await fetch(url);
|
||
if (!res.ok) return;
|
||
const j = await res.json();
|
||
const sel = document.getElementById('timeSelect');
|
||
while (sel.options.length > 1) sel.remove(1);
|
||
gTimes = j.times || [];
|
||
gTimes.forEach(dt => {
|
||
const opt = document.createElement('option');
|
||
opt.value = dt; opt.textContent = dt; sel.appendChild(opt);
|
||
});
|
||
if (gTimes.length > 0 && gCurrentIdx < 0) {
|
||
sel.value = gTimes[0];
|
||
gCurrentIdx = 0;
|
||
}
|
||
updateCountAndButtons();
|
||
updateSlider();
|
||
} catch {}
|
||
}
|
||
|
||
async function renderTile(t, forcedDt) {
|
||
const fmt5 = (n) => Number(n).toFixed(5);
|
||
|
||
document.getElementById('dt').textContent = t.dt;
|
||
document.getElementById('z').textContent = t.z;
|
||
document.getElementById('y').textContent = t.y;
|
||
document.getElementById('x').textContent = t.x;
|
||
document.getElementById('size').textContent = `${t.width} × ${t.height}`;
|
||
document.getElementById('west').textContent = fmt5(t.west);
|
||
document.getElementById('south').textContent = fmt5(t.south);
|
||
document.getElementById('east').textContent = fmt5(t.east);
|
||
document.getElementById('north').textContent = fmt5(t.north);
|
||
document.getElementById('res').textContent = fmt5(t.res_deg);
|
||
document.getElementById('titleDt').textContent = `(${t.dt})`;
|
||
const selBox = document.getElementById('timeSelect');
|
||
for (let i = 0; i < selBox.options.length; i++) {
|
||
if (selBox.options[i].value === t.dt) { selBox.selectedIndex = i; break; }
|
||
}
|
||
gCurrentIdx = gTimes.indexOf(t.dt);
|
||
updateCountAndButtons();
|
||
|
||
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;
|
||
|
||
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;
|
||
|
||
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;
|
||
}
|
||
|
||
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>'
|
||
}];
|
||
|
||
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},
|
||
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);
|
||
});
|
||
|
||
try {
|
||
const dt = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
|
||
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);
|
||
}
|
||
|
||
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;
|
||
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;
|
||
}
|
||
function angDiff(a, b){
|
||
let d = ((a - b + 540) % 360) - 180;
|
||
return Math.abs(d);
|
||
}
|
||
function destPoint(lat, lon, brgDeg, distM){
|
||
const R = 6371000;
|
||
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 };
|
||
}
|
||
function maybeCalcSector(){
|
||
try{
|
||
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null){
|
||
return;
|
||
}
|
||
const stLat = 23.097234, stLon = 108.715433;
|
||
const halfAngle = 30;
|
||
const rangeM = gWindSpeedMS * 3 * 3600;
|
||
|
||
let best = null;
|
||
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;
|
||
const stLat = 23.097234, stLon = 108.715433;
|
||
const rangeM = gWindSpeedMS * 3 * 3600;
|
||
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;
|
||
|
||
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>'
|
||
}];
|
||
|
||
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: [
|
||
{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>`;
|
||
}
|
||
}
|
||
|
||
(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 => {
|
||
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>`;
|
||
});
|
||
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);
|
||
}
|
||
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();
|
||
});
|
||
|
||
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('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();
|
||
});
|
||
loadRealtimeLatest().catch(err => {
|
||
const info = document.getElementById('rtInfo');
|
||
info.innerHTML = `<div class="text-sm text-red-600">${err.message}</div>`;
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|