fix: 新增open-meteo历史数据
This commit is contained in:
parent
7f4b8bb18b
commit
67ba5cf21c
@ -32,6 +32,10 @@ func main() {
|
|||||||
var forecastOnly = flag.Bool("forecast_only", false, "仅执行一次open-meteo拉取并退出")
|
var forecastOnly = flag.Bool("forecast_only", false, "仅执行一次open-meteo拉取并退出")
|
||||||
var caiyunOnly = flag.Bool("caiyun_only", false, "仅执行一次彩云拉取并退出")
|
var caiyunOnly = flag.Bool("caiyun_only", false, "仅执行一次彩云拉取并退出")
|
||||||
var forecastDay = flag.String("forecast_day", "", "按日期抓取当天0点到当前时间+3h(格式YYYY-MM-DD)")
|
var forecastDay = flag.String("forecast_day", "", "按日期抓取当天0点到当前时间+3h(格式YYYY-MM-DD)")
|
||||||
|
// 历史数据补完
|
||||||
|
var historicalOnly = flag.Bool("historical_only", false, "仅执行历史数据补完并退出")
|
||||||
|
var historicalStart = flag.String("historical_start", "", "历史数据开始日期(格式YYYY-MM-DD)")
|
||||||
|
var historicalEnd = flag.String("historical_end", "", "历史数据结束日期(格式YYYY-MM-DD)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// 设置日志
|
// 设置日志
|
||||||
@ -90,6 +94,18 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 历史数据补完
|
||||||
|
if *historicalOnly {
|
||||||
|
if *historicalStart == "" || *historicalEnd == "" {
|
||||||
|
log.Fatalln("历史数据补完需要提供 --historical_start 与 --historical_end 日期(格式YYYY-MM-DD)")
|
||||||
|
}
|
||||||
|
if err := forecast.RunOpenMeteoHistoricalFetch(context.Background(), *historicalStart, *historicalEnd); err != nil {
|
||||||
|
log.Fatalf("历史数据补完失败: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("历史数据补完完成")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Backfill 调试路径
|
// Backfill 调试路径
|
||||||
if *doBackfill {
|
if *doBackfill {
|
||||||
if *bfFrom == "" || *bfTo == "" {
|
if *bfFrom == "" || *bfTo == "" {
|
||||||
|
|||||||
@ -267,6 +267,65 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time,
|
|||||||
|
|
||||||
if provider != "" {
|
if provider != "" {
|
||||||
// 指定预报提供商
|
// 指定预报提供商
|
||||||
|
if provider == "open-meteo" {
|
||||||
|
// 合并实时与历史,优先实时的最新issued_at
|
||||||
|
query = `
|
||||||
|
WITH latest_forecasts AS (
|
||||||
|
SELECT DISTINCT ON (forecast_time)
|
||||||
|
station_id, provider, issued_at, forecast_time,
|
||||||
|
temp_c_x100, humidity_pct, wind_speed_ms_x1000, wind_gust_ms_x1000,
|
||||||
|
wind_dir_deg, rain_mm_x1000, precip_prob_pct, uv_index, pressure_hpa_x100
|
||||||
|
FROM forecast_hourly
|
||||||
|
WHERE station_id = $1 AND provider IN ('open-meteo','open-meteo_historical')
|
||||||
|
AND forecast_time BETWEEN $2 AND $3
|
||||||
|
ORDER BY forecast_time,
|
||||||
|
CASE WHEN provider='open-meteo' THEN 0 ELSE 1 END,
|
||||||
|
issued_at DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
to_char(forecast_time, 'YYYY-MM-DD HH24:MI:SS') as date_time,
|
||||||
|
provider,
|
||||||
|
to_char(issued_at, 'YYYY-MM-DD HH24:MI:SS') as issued_at,
|
||||||
|
ROUND(temp_c_x100::numeric / 100.0, 2) as temperature,
|
||||||
|
humidity_pct as humidity,
|
||||||
|
ROUND(pressure_hpa_x100::numeric / 100.0, 2) as pressure,
|
||||||
|
ROUND(wind_speed_ms_x1000::numeric / 1000.0, 2) as wind_speed,
|
||||||
|
wind_dir_deg as wind_direction,
|
||||||
|
ROUND(rain_mm_x1000::numeric / 1000.0, 3) as rainfall,
|
||||||
|
precip_prob_pct as precip_prob,
|
||||||
|
uv_index as uv
|
||||||
|
FROM latest_forecasts
|
||||||
|
ORDER BY forecast_time`
|
||||||
|
args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
log.Printf("执行open-meteo合并查询: stationID=%s, start=%s, end=%s",
|
||||||
|
stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07"))
|
||||||
|
|
||||||
|
// 检查是否有历史数据
|
||||||
|
var histCount int
|
||||||
|
err := db.QueryRow(`SELECT COUNT(*) FROM forecast_hourly
|
||||||
|
WHERE station_id = $1 AND provider = 'open-meteo_historical'
|
||||||
|
AND forecast_time BETWEEN $2 AND $3`,
|
||||||
|
stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")).Scan(&histCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("查询历史数据计数失败: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("时间范围内历史数据计数: %d 条", histCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有实时数据
|
||||||
|
var rtCount int
|
||||||
|
err = db.QueryRow(`SELECT COUNT(*) FROM forecast_hourly
|
||||||
|
WHERE station_id = $1 AND provider = 'open-meteo'
|
||||||
|
AND forecast_time BETWEEN $2 AND $3`,
|
||||||
|
stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")).Scan(&rtCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("查询实时数据计数失败: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("时间范围内实时数据计数: %d 条", rtCount)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
query = `
|
query = `
|
||||||
WITH latest_forecasts AS (
|
WITH latest_forecasts AS (
|
||||||
SELECT DISTINCT ON (forecast_time)
|
SELECT DISTINCT ON (forecast_time)
|
||||||
@ -293,6 +352,7 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time,
|
|||||||
FROM latest_forecasts
|
FROM latest_forecasts
|
||||||
ORDER BY forecast_time`
|
ORDER BY forecast_time`
|
||||||
args = []interface{}{stationID, provider, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
|
args = []interface{}{stationID, provider, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 不指定预报提供商,取所有
|
// 不指定预报提供商,取所有
|
||||||
query = `
|
query = `
|
||||||
@ -322,7 +382,7 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time,
|
|||||||
args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
|
args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
|
||||||
}
|
}
|
||||||
|
|
||||||
// log.Printf("执行预报数据查询: %s, args: %v", query, args)
|
// 执行查询
|
||||||
rows, err := db.Query(query, args...)
|
rows, err := db.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("查询预报数据失败: %v", err)
|
return nil, fmt.Errorf("查询预报数据失败: %v", err)
|
||||||
@ -351,7 +411,6 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time,
|
|||||||
}
|
}
|
||||||
point.Source = "forecast"
|
point.Source = "forecast"
|
||||||
points = append(points, point)
|
points = append(points, point)
|
||||||
// log.Printf("成功扫描预报数据: %+v", point)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return points, nil
|
return points, nil
|
||||||
|
|||||||
@ -184,3 +184,158 @@ func upsertForecast(ctx context.Context, db *sql.DB, stationID string, issuedAt,
|
|||||||
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100)
|
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新增:支持自定义provider的upsert
|
||||||
|
func upsertForecastWithProvider(ctx context.Context, db *sql.DB, stationID, provider string, issuedAt, forecastTime time.Time,
|
||||||
|
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100 int64,
|
||||||
|
) error {
|
||||||
|
// 调试日志
|
||||||
|
if provider == "open-meteo_historical" {
|
||||||
|
log.Printf("写入历史数据: station=%s, time=%s, temp=%.2f, humidity=%d",
|
||||||
|
stationID, forecastTime.Format("2006-01-02 15:04:05"), float64(tempCx100)/100.0, humidityPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO forecast_hourly (
|
||||||
|
station_id, provider, issued_at, forecast_time,
|
||||||
|
rain_mm_x1000, temp_c_x100, humidity_pct, wind_speed_ms_x1000,
|
||||||
|
wind_gust_ms_x1000, wind_dir_deg, precip_prob_pct, pressure_hpa_x100
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
ON CONFLICT (station_id, provider, issued_at, forecast_time)
|
||||||
|
DO UPDATE SET
|
||||||
|
rain_mm_x1000 = EXCLUDED.rain_mm_x1000,
|
||||||
|
temp_c_x100 = EXCLUDED.temp_c_x100,
|
||||||
|
humidity_pct = EXCLUDED.humidity_pct,
|
||||||
|
wind_speed_ms_x1000 = EXCLUDED.wind_speed_ms_x1000,
|
||||||
|
wind_gust_ms_x1000 = EXCLUDED.wind_gust_ms_x1000,
|
||||||
|
wind_dir_deg = EXCLUDED.wind_dir_deg,
|
||||||
|
precip_prob_pct = EXCLUDED.precip_prob_pct,
|
||||||
|
pressure_hpa_x100 = EXCLUDED.pressure_hpa_x100
|
||||||
|
`, stationID, provider, issuedAt, forecastTime,
|
||||||
|
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunOpenMeteoHistoricalFetch 拉取指定时间段的历史数据并写入 forecast_hourly(provider=open-meteo_historical)
|
||||||
|
func RunOpenMeteoHistoricalFetch(ctx context.Context, startDate, endDate string) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
stations, err := loadStationsWithLatLon(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("加载站点失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("开始补完历史数据: %s 到 %s,共 %d 个站点", startDate, endDate, len(stations))
|
||||||
|
|
||||||
|
for i, s := range stations {
|
||||||
|
log.Printf("处理站点 %d/%d: %s", i+1, len(stations), s.id)
|
||||||
|
|
||||||
|
apiURL := buildOpenMeteoHistoricalURL(s.lat, s.lon, startDate, endDate)
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("历史数据请求失败 station=%s err=%v", s.id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var data openMeteoResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
log.Printf("历史数据解码失败 station=%s err=%v", s.id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// 处理并写入forecast_hourly(历史)
|
||||||
|
count := 0
|
||||||
|
issuedAt := time.Now().In(loc)
|
||||||
|
for i := range data.Hourly.Time {
|
||||||
|
// 解析时间(使用CST时区)
|
||||||
|
t, err := time.ParseInLocation("2006-01-02T15:04", data.Hourly.Time[i], loc)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("解析时间失败: %s, err=%v", data.Hourly.Time[i], err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集并转换(与forecast_hourly缩放一致)
|
||||||
|
rainMmX1000 := int64(0)
|
||||||
|
if i < len(data.Hourly.Rain) {
|
||||||
|
rainMmX1000 = int64(data.Hourly.Rain[i] * 1000.0)
|
||||||
|
}
|
||||||
|
tempCx100 := int64(0)
|
||||||
|
if i < len(data.Hourly.Temperature) {
|
||||||
|
tempCx100 = int64(data.Hourly.Temperature[i] * 100.0)
|
||||||
|
}
|
||||||
|
humidityPct := int64(0)
|
||||||
|
if i < len(data.Hourly.Humidity) {
|
||||||
|
humidityPct = int64(data.Hourly.Humidity[i])
|
||||||
|
}
|
||||||
|
wsMsX1000 := int64(0)
|
||||||
|
if i < len(data.Hourly.WindSpeed) {
|
||||||
|
wsMsX1000 = int64((data.Hourly.WindSpeed[i] / 3.6) * 1000.0)
|
||||||
|
}
|
||||||
|
gustMsX1000 := int64(0) // ERA5此接口未提供阵风,置0
|
||||||
|
wdirDeg := int64(0)
|
||||||
|
if i < len(data.Hourly.WindDir) {
|
||||||
|
wdirDeg = int64(data.Hourly.WindDir[i])
|
||||||
|
}
|
||||||
|
probPct := int64(0) // 历史无降水概率,置0
|
||||||
|
pressureHpaX100 := int64(0)
|
||||||
|
if i < len(data.Hourly.SurfacePres) {
|
||||||
|
pressureHpaX100 = int64(data.Hourly.SurfacePres[i] * 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := upsertForecastWithProvider(
|
||||||
|
ctx, db, s.id, "open-meteo_historical", issuedAt, t,
|
||||||
|
rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100,
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("写入历史forecast失败 station=%s time=%s err=%v", s.id, t.Format(time.RFC3339), err)
|
||||||
|
} else {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("站点 %s 成功写入 %d 条历史forecast记录", s.id, count)
|
||||||
|
// 防止请求过频
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOpenMeteoHistoricalURL(lat, lon sql.NullFloat64, startDate, endDate string) string {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("latitude", fmt.Sprintf("%f", lat.Float64))
|
||||||
|
q.Set("longitude", fmt.Sprintf("%f", lon.Float64))
|
||||||
|
q.Set("start_date", startDate)
|
||||||
|
q.Set("end_date", endDate)
|
||||||
|
q.Set("hourly", "temperature_2m,relative_humidity_2m,surface_pressure,wind_speed_10m,wind_direction_10m,rain")
|
||||||
|
q.Set("timezone", "Asia/Shanghai")
|
||||||
|
return "https://archive-api.open-meteo.com/v1/era5?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertHistoricalData(ctx context.Context, db *sql.DB, stationID string, timestamp time.Time,
|
||||||
|
temp, humidity, pressure, windSpeed, windDir, rainfall *float64) error {
|
||||||
|
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO rs485_weather_data (
|
||||||
|
station_id, timestamp, temperature, humidity, pressure,
|
||||||
|
wind_speed, wind_direction, rainfall, raw_data
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (station_id, timestamp) DO UPDATE SET
|
||||||
|
temperature = EXCLUDED.temperature,
|
||||||
|
humidity = EXCLUDED.humidity,
|
||||||
|
pressure = EXCLUDED.pressure,
|
||||||
|
wind_speed = EXCLUDED.wind_speed,
|
||||||
|
wind_direction = EXCLUDED.wind_direction,
|
||||||
|
rainfall = EXCLUDED.rainfall,
|
||||||
|
raw_data = EXCLUDED.raw_data
|
||||||
|
`, stationID, timestamp, temp, humidity, pressure, windSpeed, windDir, rainfall,
|
||||||
|
fmt.Sprintf("open-meteo-historical:%s", timestamp.Format(time.RFC3339)))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@ -192,5 +192,11 @@ func getForecastHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("查询到预报数据: %d 条", len(points))
|
log.Printf("查询到预报数据: %d 条", len(points))
|
||||||
|
// 调试:打印前几条记录
|
||||||
|
for i, p := range points {
|
||||||
|
if i < 5 {
|
||||||
|
log.Printf("预报数据 #%d: time=%s, provider=%s, issued=%s", i, p.DateTime, p.Provider, p.IssuedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, points)
|
c.JSON(http.StatusOK, points)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.map-container.collapsed {
|
.map-container.collapsed {
|
||||||
height: 100px;
|
height: 20vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
#map {
|
#map {
|
||||||
@ -140,6 +140,29 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 1001;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
background-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-info-title {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
@ -181,7 +204,7 @@
|
|||||||
th, td {
|
th, td {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
@ -476,10 +499,12 @@
|
|||||||
<!-- 地图容器 -->
|
<!-- 地图容器 -->
|
||||||
<div class="map-container" id="mapContainer">
|
<div class="map-container" id="mapContainer">
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
|
<button class="map-toggle-btn" id="toggleMapBtn" onclick="toggleMap()">折叠地图</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图表容器 -->
|
<!-- 图表容器 -->
|
||||||
<div class="chart-container" id="chartContainer">
|
<div class="chart-container" id="chartContainer">
|
||||||
|
<div id="stationInfoTitle" class="station-info-title"></div>
|
||||||
<div class="chart-wrapper">
|
<div class="chart-wrapper">
|
||||||
<canvas id="combinedChart"></canvas>
|
<canvas id="combinedChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@ -487,9 +512,15 @@
|
|||||||
|
|
||||||
<!-- 数据表格 -->
|
<!-- 数据表格 -->
|
||||||
<div class="table-container" id="tableContainer">
|
<div class="table-container" id="tableContainer">
|
||||||
|
<div id="forecastToggleContainer" style="padding: 8px 12px; font-size: 12px; color: #666; display: none; display: flex; justify-content: center; align-items: center;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 5px;">
|
||||||
|
<input type="checkbox" id="showPastForecast" style="vertical-align: middle;">
|
||||||
|
显示历史预报
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr id="tableHeader">
|
||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
<th>温度 (°C)</th>
|
<th>温度 (°C)</th>
|
||||||
<th>湿度 (%)</th>
|
<th>湿度 (%)</th>
|
||||||
@ -497,7 +528,6 @@
|
|||||||
<th>风速 (m/s)</th>
|
<th>风速 (m/s)</th>
|
||||||
<th>风向 (°)</th>
|
<th>风向 (°)</th>
|
||||||
<th>雨量 (mm)</th>
|
<th>雨量 (mm)</th>
|
||||||
<th>降水概率 (%)</th>
|
|
||||||
<th>光照 (lux)</th>
|
<th>光照 (lux)</th>
|
||||||
<th>紫外线</th>
|
<th>紫外线</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -519,6 +549,10 @@
|
|||||||
let singleStationLayer;
|
let singleStationLayer;
|
||||||
let combinedChart = null;
|
let combinedChart = null;
|
||||||
const CLUSTER_THRESHOLD = 10; // 缩放级别阈值,小于此值时启用集群
|
const CLUSTER_THRESHOLD = 10; // 缩放级别阈值,小于此值时启用集群
|
||||||
|
let isMapCollapsed = false; // 地图折叠状态
|
||||||
|
// 缓存最近一次查询数据,便于切换“显示历史预报”选项
|
||||||
|
let cachedHistoryData = [];
|
||||||
|
let cachedForecastData = [];
|
||||||
|
|
||||||
// 十六进制转十进制
|
// 十六进制转十进制
|
||||||
function hexToDecimal(hex) {
|
function hexToDecimal(hex) {
|
||||||
@ -603,6 +637,14 @@
|
|||||||
// 每30秒刷新在线设备数量
|
// 每30秒刷新在线设备数量
|
||||||
setInterval(updateOnlineDevices, 30000);
|
setInterval(updateOnlineDevices, 30000);
|
||||||
|
|
||||||
|
// 监听"显示历史预报"复选框,切换表格渲染
|
||||||
|
const showPastForecast = document.getElementById('showPastForecast');
|
||||||
|
if (showPastForecast) {
|
||||||
|
showPastForecast.addEventListener('change', function() {
|
||||||
|
displayTable(cachedHistoryData, cachedForecastData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 添加输入框事件监听
|
// 添加输入框事件监听
|
||||||
const stationInput = document.getElementById('stationInput');
|
const stationInput = document.getElementById('stationInput');
|
||||||
stationInput.addEventListener('input', function(e) {
|
stationInput.addEventListener('input', function(e) {
|
||||||
@ -1198,30 +1240,14 @@
|
|||||||
const hexID = decimalToHex(decimalId);
|
const hexID = decimalToHex(decimalId);
|
||||||
const stationID = `RS485-${hexID}`;
|
const stationID = `RS485-${hexID}`;
|
||||||
|
|
||||||
// 检查是否查询未来时间(包含未来时间的查询)
|
// 始终按用户选择的起止时间获取全量预报
|
||||||
const now = new Date();
|
|
||||||
const endDateTime = new Date(endTime.replace('T', ' ') + ':00');
|
|
||||||
const isIncludingFuture = endDateTime > now;
|
|
||||||
|
|
||||||
let forecastParams;
|
let forecastParams;
|
||||||
if (isIncludingFuture) {
|
|
||||||
// 查询包含未来时间:只查询未来部分的预报
|
|
||||||
const futureStartTime = now.toISOString().slice(0, 19).replace('T', ' ');
|
|
||||||
forecastParams = new URLSearchParams({
|
|
||||||
station_id: stationID,
|
|
||||||
from: futureStartTime,
|
|
||||||
to: endTime.replace('T', ' ') + ':00',
|
|
||||||
provider: forecastProvider
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 查询历史时间段:查询当时发布的历史预报
|
|
||||||
forecastParams = new URLSearchParams({
|
forecastParams = new URLSearchParams({
|
||||||
station_id: stationID,
|
station_id: stationID,
|
||||||
from: startTime.replace('T', ' ') + ':00',
|
from: startTime.replace('T', ' ') + ':00',
|
||||||
to: endTime.replace('T', ' ') + ':00',
|
to: endTime.replace('T', ' ') + ':00',
|
||||||
provider: forecastProvider
|
provider: forecastProvider
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const forecastResponse = await fetch(`/api/forecast?${forecastParams}`);
|
const forecastResponse = await fetch(`/api/forecast?${forecastParams}`);
|
||||||
if (forecastResponse.ok) {
|
if (forecastResponse.ok) {
|
||||||
@ -1235,11 +1261,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 缓存本次结果,用于表格切换
|
||||||
|
cachedHistoryData = historyData;
|
||||||
|
cachedForecastData = forecastData;
|
||||||
|
|
||||||
if (historyData.length === 0 && forecastData.length === 0) {
|
if (historyData.length === 0 && forecastData.length === 0) {
|
||||||
alert('该时间段内无数据');
|
alert('该时间段内无数据');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查找当前选择的站点信息
|
||||||
|
const station = stations.find(s => s.decimal_id == decimalId);
|
||||||
|
|
||||||
|
// 更新站点信息标题
|
||||||
|
const stationInfoTitle = document.getElementById('stationInfoTitle');
|
||||||
|
if (station) {
|
||||||
|
stationInfoTitle.innerHTML = `
|
||||||
|
<strong >
|
||||||
|
${station.location || '未知位置'} ·
|
||||||
|
编号 ${decimalId} ·
|
||||||
|
坐标 ${station.latitude ? station.latitude.toFixed(6) : '未知'}, ${station.longitude ? station.longitude.toFixed(6) : '未知'}
|
||||||
|
</strong>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
stationInfoTitle.innerHTML = `编号 ${decimalId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动折叠地图
|
||||||
|
if (!isMapCollapsed) {
|
||||||
|
toggleMap();
|
||||||
|
}
|
||||||
|
|
||||||
displayChart(historyData, forecastData);
|
displayChart(historyData, forecastData);
|
||||||
displayTable(historyData, forecastData);
|
displayTable(historyData, forecastData);
|
||||||
|
|
||||||
@ -1250,11 +1302,13 @@
|
|||||||
chartContainer.classList.add('show');
|
chartContainer.classList.add('show');
|
||||||
tableContainer.classList.add('show');
|
tableContainer.classList.add('show');
|
||||||
|
|
||||||
// 平滑滚动到图表位置
|
// 平滑滚动到图表位置,添加延时确保图表完全加载
|
||||||
|
setTimeout(() => {
|
||||||
chartContainer.scrollIntoView({
|
chartContainer.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'start'
|
block: 'start'
|
||||||
});
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('查询数据失败:', error);
|
console.error('查询数据失败:', error);
|
||||||
@ -1428,10 +1482,58 @@
|
|||||||
historyData = Array.isArray(historyData) ? historyData : [];
|
historyData = Array.isArray(historyData) ? historyData : [];
|
||||||
forecastData = Array.isArray(forecastData) ? forecastData : [];
|
forecastData = Array.isArray(forecastData) ? forecastData : [];
|
||||||
|
|
||||||
|
// 计算将要展示的预报集合(未来3小时 + 可选历史)
|
||||||
|
const nowTs = Date.now();
|
||||||
|
const future3hTs = nowTs + 3 * 60 * 60 * 1000;
|
||||||
|
const showPastForecast = document.getElementById('showPastForecast');
|
||||||
|
const shouldShowPast = !!(showPastForecast && showPastForecast.checked);
|
||||||
|
const displayedForecast = forecastData.filter(item => {
|
||||||
|
const t = new Date(item.date_time).getTime();
|
||||||
|
const isFuture3h = t > nowTs && t <= future3hTs;
|
||||||
|
const isPast = t <= nowTs;
|
||||||
|
return isFuture3h || (shouldShowPast && isPast);
|
||||||
|
});
|
||||||
|
const timesWithForecast = new Set(displayedForecast.map(f => f.date_time));
|
||||||
|
const hasForecast = displayedForecast.length > 0;
|
||||||
|
|
||||||
|
// 控制"显示历史预报"选择框的显示/隐藏
|
||||||
|
const forecastToggleContainer = document.getElementById('forecastToggleContainer');
|
||||||
|
if (forecastToggleContainer) {
|
||||||
|
forecastToggleContainer.style.display = forecastData.length > 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态构建表头
|
||||||
|
const thead = document.getElementById('tableHeader');
|
||||||
|
// 清除旧的表头
|
||||||
|
thead.innerHTML = '';
|
||||||
|
|
||||||
|
// 添加固定列
|
||||||
|
const fixedHeaders = ['时间', '温度 (°C)', '湿度 (%)', '气压 (hPa)', '风速 (m/s)', '风向 (°)', '雨量 (mm)'];
|
||||||
|
fixedHeaders.forEach(text => {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = text;
|
||||||
|
thead.appendChild(th);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果有预报数据,添加降水概率列
|
||||||
|
if (hasForecast) {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = '降水概率 (%)';
|
||||||
|
thead.appendChild(th);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加剩余列
|
||||||
|
const remainingHeaders = ['光照 (lux)', '紫外线'];
|
||||||
|
remainingHeaders.forEach(text => {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = text;
|
||||||
|
thead.appendChild(th);
|
||||||
|
});
|
||||||
|
|
||||||
// 合并数据并按时间排序
|
// 合并数据并按时间排序
|
||||||
const allData = [];
|
const allData = [];
|
||||||
|
|
||||||
// 添加历史数据
|
// 添加历史数据(实测全量展示)
|
||||||
historyData.forEach(item => {
|
historyData.forEach(item => {
|
||||||
allData.push({
|
allData.push({
|
||||||
...item,
|
...item,
|
||||||
@ -1439,12 +1541,12 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加预报数据
|
// 添加将展示的预报
|
||||||
forecastData.forEach(item => {
|
displayedForecast.forEach(item => {
|
||||||
allData.push({
|
allData.push({
|
||||||
...item,
|
...item,
|
||||||
source: '预报',
|
source: '预报',
|
||||||
// 预报数据中不包含的字段设为null
|
// 预报数据中不包含的字段补缺省
|
||||||
light: null,
|
light: null,
|
||||||
wind_speed: item.wind_speed !== null ? item.wind_speed : 0,
|
wind_speed: item.wind_speed !== null ? item.wind_speed : 0,
|
||||||
wind_direction: item.wind_direction !== null ? item.wind_direction : 0
|
wind_direction: item.wind_direction !== null ? item.wind_direction : 0
|
||||||
@ -1458,21 +1560,38 @@
|
|||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
// 预报数据使用不同的背景色
|
// 预报数据使用不同的背景色
|
||||||
if (item.source === '预报') {
|
if (item.source === '预报') {
|
||||||
row.style.backgroundColor = 'rgba(255, 165, 0, 0.1)';
|
row.style.backgroundColor = 'rgba(255, 165, 0, 0.08)'; // 淡橘黄色背景
|
||||||
}
|
}
|
||||||
|
|
||||||
row.innerHTML = `
|
// 移除错误的覆盖逻辑,让实测数据正常显示
|
||||||
<td>${item.date_time} <span style="font-size: 12px; color: ${item.source === '预报' ? '#ff6600' : '#28a745'};">[${item.source}]</span></td>
|
const overrideDash = false; // 不再覆盖实测数据为'-'
|
||||||
<td>${item.temperature !== null && item.temperature !== undefined ? item.temperature.toFixed(2) : '-'}</td>
|
|
||||||
<td>${item.humidity !== null && item.humidity !== undefined ? item.humidity.toFixed(2) : '-'}</td>
|
const fmt2 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(2));
|
||||||
<td>${item.pressure !== null && item.pressure !== undefined ? item.pressure.toFixed(2) : '-'}</td>
|
const fmt3 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(3));
|
||||||
<td>${item.wind_speed !== null && item.wind_speed !== undefined ? item.wind_speed.toFixed(2) : '-'}</td>
|
|
||||||
<td>${item.wind_direction !== null && item.wind_direction !== undefined ? item.wind_direction.toFixed(2) : '-'}</td>
|
// 构建基础列
|
||||||
<td>${item.rainfall !== null && item.rainfall !== undefined ? item.rainfall.toFixed(3) : '-'}</td>
|
let columns = [
|
||||||
<td>${item.source === '预报' && item.precip_prob !== null && item.precip_prob !== undefined ? item.precip_prob : '-'}</td>
|
`<td>${item.date_time}${hasForecast ? ` <span style="font-size: 12px; color: ${item.source === '预报' ? '#ff8c00' : '#28a745'};">[${item.source}]</span>` : ''}</td>`,
|
||||||
<td>${item.light !== null && item.light !== undefined ? item.light.toFixed(2) : '-'}</td>
|
`<td>${overrideDash ? '-' : fmt2(item.temperature)}</td>`,
|
||||||
<td>${item.uv !== null && item.uv !== undefined ? item.uv.toFixed(2) : '-'}</td>
|
`<td>${overrideDash ? '-' : fmt2(item.humidity)}</td>`,
|
||||||
`;
|
`<td>${overrideDash ? '-' : fmt2(item.pressure)}</td>`,
|
||||||
|
`<td>${overrideDash ? '-' : fmt2(item.wind_speed)}</td>`,
|
||||||
|
`<td>${overrideDash ? '-' : fmt2(item.wind_direction)}</td>`,
|
||||||
|
`<td>${overrideDash ? '-' : fmt3(item.rainfall)}</td>`
|
||||||
|
];
|
||||||
|
|
||||||
|
// 如果显示预报,添加降水概率列
|
||||||
|
if (hasForecast) {
|
||||||
|
columns.push(`<td>${item.source === '预报' && item.precip_prob !== null && item.precip_prob !== undefined ? item.precip_prob : '-'}</td>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加剩余列
|
||||||
|
columns.push(
|
||||||
|
`<td>${overrideDash ? '-' : (item.light !== null && item.light !== undefined ? Number(item.light).toFixed(2) : '-')}</td>`,
|
||||||
|
`<td>${overrideDash ? '-' : (item.uv !== null && item.uv !== undefined ? Number(item.uv).toFixed(2) : '-')}</td>`
|
||||||
|
);
|
||||||
|
|
||||||
|
row.innerHTML = columns.join('');
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user