weather-station/templates/radar_nanning.html

655 lines
32 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">历史时次查询</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><br>
位置lat=<span id="sectorLat"></span>lon=<span id="sectorLon"></span> 组合反射率:<span id="sectorDBZ"></span> dBZ
</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;
// 当前渲染瓦片的时间(用于 ETA 基准时间)
let gTileDT = null;
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,'/'));
// 记录本次瓦片的时间,供 ETA 计算使用
gTileDT = dt;
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;
// 使用雷达瓦片时间作为 ETA 基准
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){
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>