weather-station/templates/imdroid_radar.html

979 lines
48 KiB
HTML
Raw Permalink 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 = '';
// 雨量瓦片(小时累计)当前展示的时间,便于状态同步
let gRainDT = '';
// 3H 预报相关全局量
let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null;
let gTileDT = null;
let gTileDTStr = '';
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);
try {
// 1) 实际设备站点
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;
o.dataset.kind = 'station';
sel.appendChild(o);
});
} catch {}
try {
// 2) 从配置读取别名(如 海珠/番禺),追加到同一下拉
const res2 = await fetch('/api/radar/aliases');
if (res2.ok) {
const j = await res2.json();
(j.aliases || []).forEach(a => {
const o = document.createElement('option');
o.value = a.alias;
o.textContent = a.alias;
o.dataset.z = a.z; o.dataset.y = a.y; o.dataset.x = a.x;
o.dataset.lat = a.lat; o.dataset.lon = a.lon;
o.dataset.kind = 'alias';
sel.appendChild(o);
});
}
} catch {}
}
async function loadAliases() {
const sel = document.getElementById('aliasSelect');
if (!sel) return;
sel.innerHTML = '';
const opt = document.createElement('option');
opt.value = ''; opt.textContent = '或选择雷达别名(海珠/番禺)…';
sel.appendChild(opt);
try {
const res = await fetch('/api/radar/weather_aliases');
if (!res.ok) return;
const j = await res.json();
(j.aliases || []).forEach(a => {
const o = document.createElement('option');
o.value = a.alias;
o.textContent = a.alias;
o.dataset.lat = a.lat;
o.dataset.lon = a.lon;
sel.appendChild(o);
});
} catch {}
}
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);
}
async function loadRealtimeNearest(alias, dtStr) {
const res = await fetch(`/api/radar/weather_nearest?prefer=lte&alias=${encodeURIComponent(alias)}&dt=${encodeURIComponent(dtStr)}`);
if (!res.ok) return false;
const j = await res.json();
setRealtimeBox(j);
const bar = document.getElementById('rt_stale');
if (bar) {
if (j.stale) { bar.classList.remove('hidden'); }
else { bar.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 = '未找到瓦片';
// 当没有瓦片可用时,清空附近降雨提示,避免遗留旧提示
const nb = document.getElementById('nearbyStatus');
if (nb) { nb.textContent=''; nb.classList.remove('text-red-700'); nb.classList.add('text-gray-700'); }
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);
// 同步:按北京时间整点对齐加载小时雨量(优先从列表选择 ≤ 该整点)
try { await loadRainAligned(z,y,x, t.dt); } catch(e) { /* ignore */ }
const idx = gTimes.indexOf(t.dt);
if (idx >= 0) {
gCurrentIdx = idx;
updateCountAndButtons();
updateSlider();
const sel = document.getElementById('timeSelect');
sel.value = t.dt;
}
// 同步气象:就近(优先<=)匹配该瓦片时间
if (gAlias) { try { await loadRealtimeNearest(gAlias, t.dt); } catch(e){} }
maybeCalcSector();
maybePlotSquare();
maybeCalcNearbyRain();
maybeCalcTileRegionStats();
}
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 = '未找到瓦片';
// 清空附近降雨提示,避免遗留
const nb = document.getElementById('nearbyStatus');
if (nb) { nb.textContent=''; nb.classList.remove('text-red-700'); nb.classList.add('text-gray-700'); }
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);
// 同步:按北京时间整点对齐加载小时雨量(优先从列表选择 ≤ 该整点)
try { await loadRainAligned(z,y,x, t.dt); } catch(e) { /* ignore */ }
if (gAlias) { try { await loadRealtimeNearest(gAlias, t.dt); } catch(e){} }
maybeCalcSector();
maybePlotSquare();
maybeCalcNearbyRain();
maybeCalcTileRegionStats();
}
// ---- 小时雨量CMPA渲染 ----
async function loadLatestRainTile(z, y, x){
const status = document.getElementById('rain_tile_status');
const res = await fetch(`/api/rain/latest?z=${z}&y=${y}&x=${x}`);
if(!res.ok){ status.textContent='未找到雨量瓦片'; return; }
const t = await res.json();
fillRainMetaAndPlot(t);
}
function floorHourStr(dtStr){
if(!dtStr) return '';
const m = dtStr.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):\d{2}:\d{2}$/);
if(!m) return dtStr;
return `${m[1]} ${m[2]}:00:00`;
}
async function loadNearestRainTile(z, y, x, dtStr){
const status = document.getElementById('rain_tile_status');
if(!dtStr){ return loadLatestRainTile(z,y,x); }
// 将查询时间按北京时间整点对齐,提高匹配准确性
const base = floorHourStr(dtStr);
let url = `/api/rain/nearest?prefer=lte&tolerance_min=120&z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`;
let res = await fetch(url);
if(!res.ok){
// 二次尝试:放宽容差到 24 小时
url = `/api/rain/nearest?prefer=lte&tolerance_min=1440&z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`;
res = await fetch(url);
}
if(!res.ok){
// 优雅降级:若有时间列表,下拉默认第一项
const sel = document.getElementById('rainTimeSelect');
if (sel && sel.options.length > 1) {
const first = sel.options[1].value;
sel.value = first;
await loadRainAt(z,y,x, first);
return;
}
status.textContent='未找到匹配的雨量瓦片';
return;
}
const t = await res.json();
fillRainMetaAndPlot(t);
}
function compareDTStr(a, b){
// a,b 格式 "YYYY-MM-DD HH:MM:SS",直接字符串比较即可
return a === b ? 0 : (a < b ? -1 : 1);
}
async function loadRainAligned(z, y, x, dtStr){
const sel = document.getElementById('rainTimeSelect');
const status = document.getElementById('rain_tile_status');
if(!dtStr){ return loadLatestRainTile(z,y,x); }
const base = floorHourStr(dtStr);
// 优先从下拉(倒序)中选择第一个 <= base 的时次
if (sel && sel.options.length > 1){
for (let i=1; i<sel.options.length; i++){
const v = sel.options[i].value;
if (compareDTStr(v, base) <= 0){ sel.value = v; await loadRainAt(z,y,x, v); return; }
}
// 未命中则继续兜底
}
// 兜底:尝试 exact at(base),失败再 lte 24h再 latest
let res = await fetch(`/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`);
if (res.ok){ const t = await res.json(); fillRainMetaAndPlot(t); return; }
res = await fetch(`/api/rain/nearest?prefer=lte&tolerance_min=1440&z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`);
if (res.ok){ const t = await res.json(); fillRainMetaAndPlot(t); return; }
if (sel && sel.options.length > 1){ const first = sel.options[1].value; sel.value = first; await loadRainAt(z,y,x, first); return; }
status.textContent='未找到匹配的雨量瓦片';
}
function fillRainMetaAndPlot(t){
const fmt=(n,d=5)=> Number(n).toFixed(d);
document.getElementById('rain_tile_dt').textContent = t.dt || '';
document.getElementById('rain_tile_z').textContent = t.z ?? '';
document.getElementById('rain_tile_y').textContent = t.y ?? '';
document.getElementById('rain_tile_x').textContent = t.x ?? '';
document.getElementById('rain_tile_w').textContent = fmt(t.west);
document.getElementById('rain_tile_s').textContent = fmt(t.south);
document.getElementById('rain_tile_e').textContent = fmt(t.east);
document.getElementById('rain_tile_n').textContent = fmt(t.north);
document.getElementById('rain_tile_res').textContent = fmt(t.res_deg, 6);
const status = document.getElementById('rain_tile_status');
if (status) status.textContent='';
gRainDT = t.dt || '';
// 同步下拉:若该 dt 存在于列表,选中它
try{
const sel = document.getElementById('rainTimeSelect');
if (sel && gRainDT) {
for (let i=0;i<sel.options.length;i++){
if (sel.options[i].value === gRainDT){ sel.value = gRainDT; break; }
}
}
} catch {}
renderRainTilePlot(t);
}
function renderRainTilePlot(t){
if(!t || !t.values) return;
const w=t.width, h=t.height, 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;
// 色带按照给定定义,增加 index=0 作为 0mm 的纯白
// 之后 14 段与示例保持一致
const bandColors = [
'rgba(255, 255, 255, 1.0)', // 0 mm 专用(白色)
'rgba(126, 212, 121, 0.78)', // (0,5)
'rgba(110, 200, 109, 0.78)', // 57.5
'rgba(97, 169, 97, 0.78)', // 7.510
'rgba(81, 148, 76, 0.78)', // 1012.5
'rgba(90, 158, 112, 0.78)', // 12.515
'rgba(143, 194, 254, 0.78)', // 1517.5
'rgba(92, 134, 245, 0.78)', // 17.520
'rgba(66, 87, 240, 0.78)', // 2025
'rgba(45, 48, 214, 0.78)', // 2530
'rgba(26, 15, 166, 0.78)', // 3040
'rgba(63, 22, 145, 0.78)', // 4050
'rgba(191, 70, 148, 0.78)', // 5075
'rgba(213, 1, 146, 0.78)', // 75100
'rgba(213, 1, 146, 0.78)' // >=100饱和
];
// 非零阈值分割mm用于映射到上面颜色段索引需 +1
const edges = [0,5,7.5,10,12.5,15,17.5,20,25,30,40,50,75,100, Infinity];
// 量化到颜色段,同时保留原值用于 hover
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 v = row[c];
if(v==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
let mm = Number(v); if(mm<0) mm=0;
let idx = 0;
if (mm === 0) {
idx = 0; // 白色 0mm
} else {
let nz = 0; while(nz < edges.length-1 && !(mm>=edges[nz] && mm<edges[nz+1])) nz++;
idx = Math.min(nz + 1, bandColors.length - 1);
}
rowBins.push(idx);
rowCustom.push([r,c,mm]);
}
zBins.push(rowBins); custom.push(rowCustom);
}
// 将 14 段映射为均匀 positions 的 colorscale
const colorscale = [];
for(let i=0;i<bandColors.length;i++){
const tpos = bandColors.length===1 ? 0 : i/(bandColors.length-1);
colorscale.push([tpos, bandColors[i]]);
}
const data=[{
type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom,
colorscale: colorscale, zmin:0, zmax:bandColors.length-1,
colorbar:{
orientation:'h', x:0.5, y:-0.12, xanchor:'center', yanchor:'top',
len:0.8, thickness:16, title:{text:'mm', side:'bottom'},
// 对应 5/10/15/20/30/50/100 的分段索引:在加入 0mm 白色后需整体 +1
tickmode:'array', tickvals:[1,3,5,7,9,11,13], ticktext:['5','10','15','20','30','50','100']
},
hovertemplate: 'lon=%{x:.3f}, lat=%{y:.3f}<br>rain=%{customdata[2]:.1f} mm<extra></extra>'
}];
const el=document.getElementById('rain_tile_plot');
const layout={ autosize:true, margin:{l:40,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}
};
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); });
}
async function populateRainTimes(z, y, x, fromStr, toStr){
try{
let url = `/api/rain/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('rainTimeSelect');
if (!sel) return;
while (sel.options.length > 1) sel.remove(1);
const times = j.times || [];
times.forEach(dt=>{ const opt=document.createElement('option'); opt.value=dt; opt.textContent=dt; sel.appendChild(opt); });
// 若当前已选中的 gRainDT 在列表里,则保持选中
if (gRainDT){ for(let i=0;i<sel.options.length;i++){ if(sel.options[i].value===gRainDT){ sel.value=gRainDT; break; } } }
} catch {}
}
async function loadRainAt(z, y, x, dtStr){
const status = document.getElementById('rain_tile_status');
if(!dtStr){ return loadNearestRainTile(z,y,x, (gTileDTStr || '')); }
const res = await fetch(`/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`);
if(!res.ok){ status.textContent='未找到雨量瓦片'; return; }
const t = await res.json();
fillRainMetaAndPlot(t);
}
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,'/')); gTileDTStr = t.dt || ''; } catch { gTileDTStr = t.dt || ''; }
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 });
}
// 叠加附近8km圆
{
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='';
el.classList.remove('text-red-700');
el.classList.add('text-gray-700');
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');
} else {
// 未命中时清空并恢复默认样式,避免提示“停留不消失”
el.textContent = '';
el.classList.remove('text-red-700');
el.classList.add('text-gray-700');
}
}catch(e){ /* ignore */ }
}
// 计算并展示扇形区域与8km圆形区域的 >=40 和 >=30 dBZ 点数与累计值
function maybeCalcTileRegionStats(){
try{
const s40CntEl = document.getElementById('sector_ge40_cnt');
if(!s40CntEl) return; // 元素不存在则不计算
// 先清空展示
const ids=[
'sector_ge40_cnt','sector_ge40_sum','sector_ge30_cnt','sector_ge30_sum',
'circle_ge40_cnt','circle_ge40_sum','circle_ge30_cnt','circle_ge30_sum'
];
ids.forEach(id=>{ const el=document.getElementById(id); if(el) el.textContent='-'; });
if(!gTileValues || !gXs || !gYs || gStLat===null || gStLon===null) return;
const h=gTileValues.length, w=gTileValues[0].length;
// 扇形区域定义:风向 ±30°半径 = 3 小时移动距离
const hasWind = (gWindFromDeg!==null && gWindSpeedMS!==null && gWindSpeedMS>0);
const halfAngle=30;
const rangeM = hasWind ? (gWindSpeedMS*3*3600) : 0;
// 圆形区域:半径 8km
const circleR = 8000;
let sec40Cnt=0, sec40Sum=0, sec30Cnt=0, sec30Sum=0;
let cir40Cnt=0, cir40Sum=0, cir30Cnt=0, cir30Sum=0;
for(let r=0;r<h;r++){
const lat=gYs[r];
for(let c=0;c<w;c++){
const v=gTileValues[r][c];
if(v==null) continue;
const dbz=Number(v);
const lon=gXs[c];
const dist=haversine(gStLat,gStLon,lat,lon);
// 圆形区域统计
if(dist<=circleR){
if(dbz>=40){ cir40Cnt++; cir40Sum+=dbz; }
if(dbz>=30){ cir30Cnt++; cir30Sum+=dbz; }
}
// 扇形区域统计(需要风向风速)
if(hasWind){
if(dist<=rangeM){
const brg=bearingDeg(gStLat,gStLon,lat,lon);
if(angDiff(brg,gWindFromDeg)<=halfAngle){
if(dbz>=40){ sec40Cnt++; sec40Sum+=dbz; }
if(dbz>=30){ sec30Cnt++; sec30Sum+=dbz; }
}
}
}
}
}
// 更新展示
const set = (id, val)=>{ const el=document.getElementById(id); if(el) el.textContent=String(val); };
if(hasWind){
set('sector_ge40_cnt', sec40Cnt); set('sector_ge40_sum', sec40Sum.toFixed(1));
set('sector_ge30_cnt', sec30Cnt); set('sector_ge30_sum', sec30Sum.toFixed(1));
} else {
// 无风场信息时显示提示
set('sector_ge40_cnt', '无风场'); set('sector_ge40_sum', '-');
set('sector_ge30_cnt', '无风场'); set('sector_ge30_sum', '-');
}
set('circle_ge40_cnt', cir40Cnt); set('circle_ge40_sum', cir40Sum.toFixed(1));
set('circle_ge30_cnt', cir30Cnt); set('circle_ge30_sum', cir30Sum.toFixed(1));
}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');
if (!sel || !sel.value) return;
const aliasText = sel.options[sel.selectedIndex].textContent || sel.value;
const aliasParam = sel.value;
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;
gAlias = aliasParam;
document.getElementById('rt_zyx').textContent = `z=${gZ}, y=${gY}, x=${gX}`;
try { await loadRealtime(aliasParam); } 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 populateRainTimes(gZ,gY,gX, from, to);
await loadLatestTile(gZ,gY,gX);
// 同步:加载最新小时雨量(若 radar 未返回 dt则先尝试最新
try { await loadLatestRainTile(gZ,gY,gX); } catch(e) { /* ignore */ }
}
});
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);
await populateRainTimes(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 rainTimeSelect = document.getElementById('rainTimeSelect');
if (rainTimeSelect) rainTimeSelect.addEventListener('change', async (e)=>{
if (!(gZ && gY && gX)) return;
const dt = e.target.value;
if (!dt) {
// 自动匹配到当前雷达时次(就近<=
const target = (gCurrentIdx>=0 && gTimes[gCurrentIdx]) ? gTimes[gCurrentIdx] : (gTileDTStr || '');
await loadNearestRainTile(gZ,gY,gX, target);
} else {
await loadRainAt(gZ,gY,gX, dt);
}
});
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-[360px]"></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_stale" class="mt-2 hidden p-2 text-sm text-yellow-800 bg-yellow-50 border border-yellow-200 rounded">提示:该瓦片时次的就近实况相差超过 2 小时</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">
<div class="text-base font-semibold mb-2">一小时降雨瓦片</div>
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="text-sm text-gray-700">选择雨量时次:</span>
<select id="rainTimeSelect" class="border rounded px-2 py-1 text-sm min-w-[240px]">
<option value="">自动匹配(就近≤)</option>
</select>
</div>
<div class="text-sm space-y-1">
<div>时间:<span id="rain_tile_dt" class="font-mono"></span></div>
<div>索引z=<span id="rain_tile_z"></span> / y=<span id="rain_tile_y"></span> / x=<span id="rain_tile_x"></span></div>
<div>边界W=<span id="rain_tile_w"></span>S=<span id="rain_tile_s"></span>E=<span id="rain_tile_e"></span>N=<span id="rain_tile_n"></span></div>
<div>分辨率(度/像素):<span id="rain_tile_res"></span></div>
<div id="rain_tile_status" class="text-gray-500"></div>
</div>
<div id="rain_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 class="mt-3 text-sm space-y-1">
<div class="font-bold">扇形区域统计</div>
<span class="text-gray-700">≥40 dBZ点数 <span id="sector_ge40_cnt">-</span>,累计 <span id="sector_ge40_sum">-</span></span>
<span class="text-gray-700"> | ≥30 dBZ点数 <span id="sector_ge30_cnt">-</span>,累计 <span id="sector_ge30_sum">-</span></span>
<div class="font-bold mt-2">圆形区域统计</div>
<span class="text-gray-700">≥40 dBZ点数 <span id="circle_ge40_cnt">-</span>,累计 <span id="circle_ge40_sum">-</span></span>
<span class="text-gray-700"> | ≥30 dBZ点数 <span id="circle_ge30_cnt">-</span>,累计 <span id="circle_ge30_sum">-</span></span>
</div>
<div id="squarePlot" class="plot-box"></div>
</div>
</div>
</body>
</html>