feat: 雷达历史数据

This commit is contained in:
yarnom 2025-09-23 16:54:35 +08:00
parent e6f9d500ea
commit 12b2ad5ace
4 changed files with 369 additions and 7 deletions

View File

@ -40,6 +40,8 @@ func StartGinServer() error {
api.GET("/data", getDataHandler) api.GET("/data", getDataHandler)
api.GET("/forecast", getForecastHandler) api.GET("/forecast", getForecastHandler)
api.GET("/radar/latest", latestRadarTileHandler) api.GET("/radar/latest", latestRadarTileHandler)
api.GET("/radar/at", radarTileAtHandler)
api.GET("/radar/times", radarTileTimesHandler)
api.GET("/radar/weather_latest", latestRadarWeatherHandler) api.GET("/radar/weather_latest", latestRadarWeatherHandler)
api.GET("/radar/weather_at", radarWeatherAtHandler) api.GET("/radar/weather_at", radarWeatherAtHandler)
} }

View File

@ -59,6 +59,23 @@ func getLatestRadarTile(db *sql.DB, z, y, x int) (*radarTileRecord, error) {
return &r, nil return &r, nil
} }
func getRadarTileAt(db *sql.DB, z, y, x int, dt time.Time) (*radarTileRecord, error) {
const q = `
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
FROM radar_tiles
WHERE z=$1 AND y=$2 AND x=$3 AND dt=$4
LIMIT 1`
var r radarTileRecord
err := db.QueryRow(q, z, y, x, dt).Scan(
&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height,
&r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data,
)
if err != nil {
return nil, err
}
return &r, nil
}
// latestRadarTileHandler 返回指定 z/y/x 的最新瓦片,包含栅格 dBZ 值及元数据 // latestRadarTileHandler 返回指定 z/y/x 的最新瓦片,包含栅格 dBZ 值及元数据
func latestRadarTileHandler(c *gin.Context) { func latestRadarTileHandler(c *gin.Context) {
// 固定默认 7/40/102可通过查询参数覆盖 // 固定默认 7/40/102可通过查询参数覆盖
@ -120,6 +137,124 @@ func latestRadarTileHandler(c *gin.Context) {
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
// radarTileAtHandler 返回指定 z/y/x 的指定时间瓦片
func radarTileAtHandler(c *gin.Context) {
z := parseIntDefault(c.Query("z"), 7)
y := parseIntDefault(c.Query("y"), 40)
x := parseIntDefault(c.Query("x"), 102)
dtStr := c.Query("dt")
if dtStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数YYYY-MM-DD HH:MM:SS"})
return
}
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"})
return
}
rec, err := getRadarTileAt(database.GetDB(), z, y, x, dt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间瓦片"})
return
}
w, h := rec.Width, rec.Height
if len(rec.Data) < w*h*2 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"})
return
}
vals := make([][]*float64, h)
off := 0
for row := 0; row < h; row++ {
rowVals := make([]*float64, w)
for col := 0; col < w; col++ {
v := int16(binary.BigEndian.Uint16(rec.Data[off : off+2]))
off += 2
if v >= 32766 {
rowVals[col] = nil
continue
}
dbz := float64(v) / 10.0
if dbz < 0 {
dbz = 0
} else if dbz > 75 {
dbz = 75
}
vv := dbz
rowVals[col] = &vv
}
vals[row] = rowVals
}
resp := radarTileResponse{
DT: rec.DT.Format("2006-01-02 15:04:05"),
Z: rec.Z,
Y: rec.Y,
X: rec.X,
Width: rec.Width,
Height: rec.Height,
West: rec.West,
South: rec.South,
East: rec.East,
North: rec.North,
ResDeg: rec.ResDeg,
Values: vals,
}
c.JSON(http.StatusOK, resp)
}
// radarTileTimesHandler 返回指定 z/y/x 的可用时间列表(倒序)
func radarTileTimesHandler(c *gin.Context) {
z := parseIntDefault(c.Query("z"), 7)
y := parseIntDefault(c.Query("y"), 40)
x := parseIntDefault(c.Query("x"), 102)
fromStr := c.Query("from")
toStr := c.Query("to")
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
var rows *sql.Rows
var err error
if fromStr != "" && toStr != "" {
from, err1 := time.ParseInLocation("2006-01-02 15:04:05", fromStr, loc)
to, err2 := time.ParseInLocation("2006-01-02 15:04:05", toStr, loc)
if err1 != nil || err2 != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 from/to 时间格式"})
return
}
const qRange = `
SELECT dt FROM radar_tiles
WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5
ORDER BY dt DESC`
rows, err = database.GetDB().Query(qRange, z, y, x, from, to)
} else {
limit := parseIntDefault(c.Query("limit"), 48)
const q = `
SELECT dt FROM radar_tiles
WHERE z=$1 AND y=$2 AND x=$3
ORDER BY dt DESC
LIMIT $4`
rows, err = database.GetDB().Query(q, z, y, x, limit)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询时间列表失败"})
return
}
defer rows.Close()
var times []string
for rows.Next() {
var dt time.Time
if err := rows.Scan(&dt); err != nil {
continue
}
times = append(times, dt.Format("2006-01-02 15:04:05"))
}
c.JSON(http.StatusOK, gin.H{"times": times})
}
func parseIntDefault(s string, def int) int { func parseIntDefault(s string, def int) int {
if s == "" { if s == "" {
return def return def

View File

@ -18,6 +18,22 @@
{{ template "header" . }} {{ template "header" . }}
<div class="content-narrow p-4"> <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="card mb-4">
<div class="text-lg font-semibold mb-2">最新 7/40/104 瓦片信息</div> <div class="text-lg font-semibold mb-2">最新 7/40/104 瓦片信息</div>
<div class="text-sm space-y-1"> <div class="text-sm space-y-1">
@ -57,7 +73,16 @@
</div> </div>
<div class="card" style="width:100%;"> <div class="card" style="width:100%;">
<div class="text-lg font-semibold mb-2">雷达组合反射率</div> <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 id="radarPlot" class="plot-box"></div> <div id="radarPlot" class="plot-box"></div>
</div> </div>
@ -74,6 +99,8 @@
let gTileValues = null, gXs = null, gYs = null; let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null; let gWindFromDeg = null, gWindSpeedMS = null;
let gTimes = [];
let gCurrentIdx = -1; // 在 gTimes 中的索引倒序0=最新)
function toRad(d){ return d * Math.PI / 180; } function toRad(d){ return d * Math.PI / 180; }
function toDeg(r){ return r * 180 / Math.PI; } function toDeg(r){ return r * 180 / Math.PI; }
@ -100,6 +127,32 @@
const res = await fetch(`/api/radar/latest?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`); const res = await fetch(`/api/radar/latest?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`);
if(!res.ok) throw new Error('加载最新瓦片失败'); if(!res.ok) throw new Error('加载最新瓦片失败');
const t = await res.json(); const t = await res.json();
await renderTile(t);
}
async function loadTileAt(dtStr){
const res = await fetch(`/api/radar/at?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}&dt=${encodeURIComponent(dtStr)}`);
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=${TILE_Z}&y=${TILE_Y}&x=${TILE_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 && gCurrentIdx<0){ sel.value=gTimes[0]; gCurrentIdx=0; }
updateCountAndButtons();
}catch{}
}
async function renderTile(t, forcedDt){
const fmt5 = (n)=>Number(n).toFixed(5); const fmt5 = (n)=>Number(n).toFixed(5);
document.getElementById('dt').textContent = t.dt; document.getElementById('dt').textContent = t.dt;
@ -112,6 +165,12 @@
document.getElementById('east').textContent = fmt5(t.east); document.getElementById('east').textContent = fmt5(t.east);
document.getElementById('north').textContent = fmt5(t.north); document.getElementById('north').textContent = fmt5(t.north);
document.getElementById('res').textContent = fmt5(t.res_deg); 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();
// x/y 等角坐标 // x/y 等角坐标
const w=t.width,h=t.height; gTileValues=t.values; const w=t.width,h=t.height; gTileValues=t.values;
@ -155,7 +214,8 @@
// 与瓦片时间对齐的10分钟气象 // 与瓦片时间对齐的10分钟气象
try{ try{
const dt=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 base = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
const ceil10=new Date(base); const m=base.getMinutes(); const up=(Math.floor(m/10)*10+10)%60; ceil10.setMinutes(up,0,0); if(up===0){ ceil10.setHours(base.getHours()+1); }
const pad=(n)=>String(n).padStart(2,'0'); const dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`; const pad=(n)=>String(n).padStart(2,'0'); const dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
await loadRealtimeAt(dtStr); await loadRealtimeAt(dtStr);
maybeCalcSector(); maybeCalcSector();
@ -259,9 +319,31 @@
}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>`; } }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>`; }
} }
// 启动加载 // 启动加载预填近3小时范围并填充时次
loadLatestTile().catch(err=>{ document.getElementById('radarPlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.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=>{ document.getElementById('radarPlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.message}</div>`; });
loadRealtimeLatest().catch(err=>{ document.getElementById('rtInfo').innerHTML=`<div class="text-sm text-red-600">${err.message}</div>`; }); loadRealtimeLatest().catch(err=>{ document.getElementById('rtInfo').innerHTML=`<div class="text-sm text-red-600">${err.message}</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();
});
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();
});
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();
});
</script> </script>
</body> </body>

View File

@ -23,6 +23,23 @@
{{ template "header" . }} {{ template "header" . }}
<div class="content-narrow p-4"> <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="card mb-4">
<div class="text-lg font-semibold mb-2">最新 7/40/102 瓦片信息</div> <div class="text-lg font-semibold mb-2">最新 7/40/102 瓦片信息</div>
<div id="tileInfo" class="text-sm space-y-1"> <div id="tileInfo" class="text-sm space-y-1">
@ -60,7 +77,16 @@
</div> </div>
</div> </div>
<div class="card" style="width: 100%;"> <div class="card" style="width: 100%;">
<div class="text-lg font-semibold mb-2">雷达组合反射率</div> <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 id="radarPlot" class="plot-box"></div> <div id="radarPlot" class="plot-box"></div>
</div> </div>
<div class="card mt-4" style="width: 100%;"> <div class="card mt-4" style="width: 100%;">
@ -72,11 +98,63 @@
<script> <script>
let gTileValues = null, gXs = null, gYs = null; let gTileValues = null, gXs = null, gYs = null;
let gWindFromDeg = null, gWindSpeedMS = null; // 来风方向0=北,顺时针),风速 m/s let gWindFromDeg = null, gWindSpeedMS = null; // 来风方向0=北,顺时针),风速 m/s
let gTimes = [];
let gCurrentIdx = -1; // 在 gTimes 中的索引倒序0=最新)
async function loadLatestTile() { async function loadLatestTile() {
const res = await fetch('/api/radar/latest?z=7&y=40&x=102'); const res = await fetch('/api/radar/latest?z=7&y=40&x=102');
if (!res.ok) { throw new Error('加载最新瓦片失败'); } if (!res.ok) { throw new Error('加载最新瓦片失败'); }
const t = await res.json(); 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){
// s like 'YYYY-MM-DDTHH:MM'
if(!s) return null;
const t = new Date(s.replace('T','-').replace(/-/g,'/')); // iOS safe parse later
// We will manually format to server expected 'YYYY-MM-DD HH:MM:SS'
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();
} catch {}
}
async function renderTile(t, forcedDt) {
const fmt5 = (n) => Number(n).toFixed(5); const fmt5 = (n) => Number(n).toFixed(5);
// 填充元信息 // 填充元信息
@ -90,6 +168,14 @@
document.getElementById('east').textContent = fmt5(t.east); document.getElementById('east').textContent = fmt5(t.east);
document.getElementById('north').textContent = fmt5(t.north); document.getElementById('north').textContent = fmt5(t.north);
document.getElementById('res').textContent = fmt5(t.res_deg); 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();
// Plotly 热力图(离散 5 dBZ 色带) // Plotly 热力图(离散 5 dBZ 色带)
const colors = [ const colors = [
@ -208,7 +294,7 @@
// 同步加载与瓦片时间对应的10分钟气象向上取整到10分 // 同步加载与瓦片时间对应的10分钟气象向上取整到10分
try { try {
const dt = new Date(t.dt.replace(/-/g,'/')); // iOS兼容 const dt = forcedDt ? new Date(forcedDt.replace(/-/g,'/')) : new Date(t.dt.replace(/-/g,'/'));
const ceil10 = new Date(dt); const ceil10 = new Date(dt);
const m = dt.getMinutes(); const m = dt.getMinutes();
const up = (Math.floor(m/10)*10 + 10) % 60; const up = (Math.floor(m/10)*10 + 10) % 60;
@ -485,10 +571,67 @@
} }
} }
loadLatestTile().catch(err => { // 初始化加载最新瓦片、填充时间下拉默认近3小时、绑定事件
// 预填范围
(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'); 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>`; 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();
});
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();
});
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();
});
// 兜底加载最新 // 兜底加载最新
loadRealtimeLatest().catch(err => { loadRealtimeLatest().catch(err => {
const info = document.getElementById('rtInfo'); const info = document.getElementById('rtInfo');