diff --git a/internal/database/models.go b/internal/database/models.go index e949062..564b421 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -263,27 +263,29 @@ func buildWeatherDataQuery(interval string) string { ORDER BY g.time_group` } -// GetForecastData 获取指定站点的预报数据(优先最新issued_at) -func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time, provider string) ([]types.ForecastPoint, error) { +// GetForecastData 获取指定站点的预报数据(支持返回每个forecast_time的多版本issued_at) +func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time, provider string, versions int) ([]types.ForecastPoint, error) { var query string var args []interface{} + if versions <= 0 { + versions = 1 + } + if provider != "" { - // 指定预报提供商 if provider == "open-meteo" { - // 合并实时与历史,优先实时的最新issued_at + // 合并实时与历史,按 issued_at 降序为每个 forecast_time 取前 N 个版本 query = ` - WITH latest_forecasts AS ( - SELECT DISTINCT ON (forecast_time) + WITH ranked AS ( + SELECT 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 + wind_dir_deg, rain_mm_x1000, precip_prob_pct, uv_index, pressure_hpa_x100, + ROW_NUMBER() OVER (PARTITION BY forecast_time ORDER BY issued_at DESC) AS rn, + CEIL(EXTRACT(EPOCH FROM (forecast_time - issued_at)) / 3600.0)::int AS lead_hours 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, @@ -296,49 +298,24 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time, 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) - } + uv_index as uv, + lead_hours + FROM ranked + WHERE rn <= $4 + ORDER BY forecast_time, issued_at DESC` + args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07"), versions} } else { query = ` - WITH latest_forecasts AS ( - SELECT DISTINCT ON (forecast_time) + WITH ranked AS ( + SELECT 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 + wind_dir_deg, rain_mm_x1000, precip_prob_pct, uv_index, pressure_hpa_x100, + ROW_NUMBER() OVER (PARTITION BY forecast_time ORDER BY issued_at DESC) AS rn, + CEIL(EXTRACT(EPOCH FROM (forecast_time - issued_at)) / 3600.0)::int AS lead_hours FROM forecast_hourly WHERE station_id = $1 AND provider = $2 AND forecast_time BETWEEN $3 AND $4 - ORDER BY forecast_time, issued_at DESC ) SELECT to_char(forecast_time, 'YYYY-MM-DD HH24:MI:SS') as date_time, @@ -351,22 +328,25 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time, 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, provider, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")} + uv_index as uv, + lead_hours + FROM ranked + WHERE rn <= $5 + ORDER BY forecast_time, issued_at DESC` + args = []interface{}{stationID, provider, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07"), versions} } } else { - // 不指定预报提供商,取所有 + // 不指定预报提供商:对每个 provider,forecast_time 返回前 N 个 issued_at 版本 query = ` - WITH latest_forecasts AS ( - SELECT DISTINCT ON (provider, forecast_time) + WITH ranked AS ( + SELECT 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 + wind_dir_deg, rain_mm_x1000, precip_prob_pct, uv_index, pressure_hpa_x100, + ROW_NUMBER() OVER (PARTITION BY provider, forecast_time ORDER BY issued_at DESC) AS rn, + CEIL(EXTRACT(EPOCH FROM (forecast_time - issued_at)) / 3600.0)::int AS lead_hours FROM forecast_hourly WHERE station_id = $1 AND forecast_time BETWEEN $2 AND $3 - ORDER BY provider, forecast_time, issued_at DESC ) SELECT to_char(forecast_time, 'YYYY-MM-DD HH24:MI:SS') as date_time, @@ -379,13 +359,14 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time, 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, provider` - args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")} + uv_index as uv, + lead_hours + FROM ranked + WHERE rn <= $4 + ORDER BY forecast_time, provider, issued_at DESC` + args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07"), versions} } - // 执行查询 rows, err := db.Query(query, args...) if err != nil { return nil, fmt.Errorf("查询预报数据失败: %v", err) @@ -407,6 +388,7 @@ func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time, &point.Rainfall, &point.PrecipProb, &point.UV, + &point.LeadHours, ) if err != nil { log.Printf("数据扫描错误: %v", err) diff --git a/internal/server/gin.go b/internal/server/gin.go index 00deb56..ad36cb2 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -150,6 +150,11 @@ func getForecastHandler(c *gin.Context) { startTime := c.Query("from") endTime := c.Query("to") provider := c.Query("provider") + versionsStr := c.DefaultQuery("versions", "1") + versions, _ := strconv.Atoi(versionsStr) + if versions <= 0 { + versions = 1 + } if stationID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少station_id参数"}) @@ -186,8 +191,8 @@ func getForecastHandler(c *gin.Context) { } // 获取预报数据 - log.Printf("查询预报数据: stationID=%s, provider=%s, start=%s, end=%s", stationID, provider, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")) - points, err := database.GetForecastData(database.GetDB(), stationID, start, end, provider) + log.Printf("查询预报数据: stationID=%s, provider=%s, versions=%d, start=%s, end=%s", stationID, provider, versions, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")) + points, err := database.GetForecastData(database.GetDB(), stationID, start, end, provider, versions) if err != nil { log.Printf("查询预报数据失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ @@ -197,11 +202,5 @@ func getForecastHandler(c *gin.Context) { } 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) } diff --git a/pkg/types/types.go b/pkg/types/types.go index 0cbaa59..536ac97 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -55,4 +55,5 @@ type ForecastPoint struct { PrecipProb *float64 `json:"precip_prob"` UV *float64 `json:"uv"` Source string `json:"source"` // "forecast" + LeadHours int `json:"lead_hours"` } diff --git a/static/js/app.js b/static/js/app.js index f7fadb2..62ee7dd 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -197,7 +197,8 @@ const WeatherApp = { station_id: stationID, from: startTime.replace('T', ' ') + ':00', to: endTime.replace('T', ' ') + ':00', - provider: forecastProvider + provider: forecastProvider, + versions: '3' }); const forecastResponse = await fetch(`/api/forecast?${forecastParams}`); if (forecastResponse.ok) { diff --git a/static/js/weather-chart.js b/static/js/weather-chart.js index 290c16e..23abaad 100644 --- a/static/js/weather-chart.js +++ b/static/js/weather-chart.js @@ -1,192 +1,166 @@ // 图表相关功能 const WeatherChart = { - chart: null, + chart: null, - // 显示图表 - display(historyData = [], forecastData = []) { - // 确保数据是数组 - historyData = Array.isArray(historyData) ? historyData : []; - forecastData = Array.isArray(forecastData) ? forecastData : []; - - // 如果没有任何数据,则不绘制图表 - if (historyData.length === 0 && forecastData.length === 0) { - return; - } - - // 合并历史数据和预报数据的时间轴 - const allLabels = [...new Set([ - ...historyData.map(item => item.date_time), - ...forecastData.map(item => item.date_time) - ])].sort(); + // 显示图表 + display(historyData = [], forecastData = []) { + // 确保数据是数组 + historyData = Array.isArray(historyData) ? historyData : []; + forecastData = Array.isArray(forecastData) ? forecastData : []; + + // 如果没有任何数据,则不绘制图表 + if (historyData.length === 0 && forecastData.length === 0) { + return; + } + + // 合并历史数据和预报数据的时间轴(按 date_time 对齐) + const allLabels = [...new Set([ + ...historyData.map(item => item.date_time), + ...forecastData.map(item => item.date_time) + ])].sort(); - // 准备历史数据 - const historyTemperatures = allLabels.map(label => { - const item = historyData.find(d => d.date_time === label); - return item ? item.temperature : null; - }); - const historyHumidities = allLabels.map(label => { - const item = historyData.find(d => d.date_time === label); - return item ? item.humidity : null; - }); - const historyRainfalls = allLabels.map(label => { - const item = historyData.find(d => d.date_time === label); - return item ? item.rainfall : null; - }); - const historyRainTotals = allLabels.map(label => { - const item = historyData.find(d => d.date_time === label); - return item && item.rain_total !== undefined ? item.rain_total : null; - }); + // 历史数据 + const historyTemperatures = allLabels.map(label => { + const item = historyData.find(d => d.date_time === label); + return item ? item.temperature : null; + }); + const historyHumidities = allLabels.map(label => { + const item = historyData.find(d => d.date_time === label); + return item ? item.humidity : null; + }); + const historyRainfalls = allLabels.map(label => { + const item = historyData.find(d => d.date_time === label); + return item ? item.rainfall : null; + }); + const historyRainTotals = allLabels.map(label => { + const item = historyData.find(d => d.date_time === label); + return item && item.rain_total !== undefined ? item.rain_total : null; + }); - // 准备预报数据 - const forecastTemperatures = allLabels.map(label => { - const item = forecastData.find(d => d.date_time === label); - return item && item.temperature !== null ? item.temperature : null; - }); - const forecastHumidities = allLabels.map(label => { - const item = forecastData.find(d => d.date_time === label); - return item && item.humidity !== null ? item.humidity : null; - }); - const forecastRainfalls = allLabels.map(label => { - const item = forecastData.find(d => d.date_time === label); - return item && item.rainfall !== null ? item.rainfall : null; - }); + // 预报:按同一 forecast_time 的不同 lead_hours 组织(仅用 0..3h) + const byTime = new Map(); + forecastData.forEach(fp => { + if (!byTime.has(fp.date_time)) byTime.set(fp.date_time, {}); + const bucket = byTime.get(fp.date_time); + const h = typeof fp.lead_hours === 'number' ? fp.lead_hours : null; + if (h !== null && h >= 0 && h <= 3) { + bucket[h] = fp; + } + }); - // 销毁旧图表 - if (this.chart) this.chart.destroy(); + const getRainAtLead = (label, lead) => { + const b = byTime.get(label); + if (!b || !b[lead]) return null; + return b[lead].rainfall != null ? b[lead].rainfall : null; + }; + const getTempAtLead0 = (label) => { + const b = byTime.get(label); + if (!b || !b[0]) return null; + return b[0].temperature != null ? b[0].temperature : null; + }; - // 创建数据集 - const datasets = [ - { - label: '温度 (°C) - 实测', - data: historyTemperatures, - borderColor: 'rgb(255, 99, 132)', - backgroundColor: 'rgba(255, 99, 132, 0.1)', - yAxisID: 'y-temperature', - tension: 0.4, - spanGaps: false - }, - { - label: '湿度 (%) - 实测', - data: historyHumidities, - borderColor: 'rgb(54, 162, 235)', - backgroundColor: 'rgba(54, 162, 235, 0.1)', - yAxisID: 'y-humidity', - tension: 0.4, - hidden: true, - spanGaps: false - }, - { - label: '雨量 (mm) - 实测', - data: historyRainfalls, - type: 'bar', - backgroundColor: 'rgba(54, 162, 235, 0.6)', - borderColor: 'rgb(54, 162, 235)', - yAxisID: 'y-rainfall' - }, - { - label: '累计雨量 (mm) - 实测', - data: historyRainTotals, - borderColor: 'rgb(75, 192, 192)', - backgroundColor: 'rgba(75, 192, 192, 0.1)', - yAxisID: 'y-rainfall', - tension: 0.2, - spanGaps: false, - pointRadius: 0, - hidden: true - } - ]; + const forecastRainfallsH0 = allLabels.map(label => getRainAtLead(label, 0)); + const forecastRainfallsH1 = allLabels.map(label => getRainAtLead(label, 1)); + const forecastRainfallsH2 = allLabels.map(label => getRainAtLead(label, 2)); + const forecastRainfallsH3 = allLabels.map(label => getRainAtLead(label, 3)); + const pickNearestTemp = (label) => { + // 近似优先级:0h > 1h > 2h > 3h + const b = byTime.get(label); + if (!b) return null; + if (b[0] && b[0].temperature != null) return b[0].temperature; + if (b[1] && b[1].temperature != null) return b[1].temperature; + if (b[2] && b[2].temperature != null) return b[2].temperature; + if (b[3] && b[3].temperature != null) return b[3].temperature; + return null; + }; + const forecastTemperaturesNearest = allLabels.map(label => pickNearestTemp(label)); - // 添加预报数据集 - if (forecastData.length > 0) { - datasets.push( - { - label: '温度 (°C) - 预报', - data: forecastTemperatures, - borderColor: 'rgb(255, 165, 0)', - backgroundColor: 'rgba(255, 165, 0, 0.1)', - borderDash: [5, 5], - yAxisID: 'y-temperature', - tension: 0.4, - spanGaps: false, - }, - { - label: '湿度 (%) - 预报', - data: forecastHumidities, - borderColor: 'rgba(255, 165, 0, 0.8)', - backgroundColor: 'rgba(255, 165, 0, 0.05)', - borderDash: [5, 5], - yAxisID: 'y-humidity', - tension: 0.4, - spanGaps: false, - hidden: true - }, - { - label: '雨量 (mm) - 预报', - data: forecastRainfalls, - type: 'bar', - backgroundColor: 'rgba(255, 165, 0, 0.4)', - borderColor: 'rgb(255, 165, 0)', - yAxisID: 'y-rainfall' - } - ); - } + // 销毁旧图表 + if (this.chart) this.chart.destroy(); - // 创建组合图表 - const ctx = document.getElementById('combinedChart').getContext('2d'); - this.chart = new Chart(ctx, { - type: 'line', - data: { - labels: allLabels, - datasets: datasets - }, - options: { - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false, - }, - scales: { - 'y-temperature': { - type: 'linear', - display: true, - position: 'left', - title: { - display: true, - text: '温度 (°C)' - } - }, - 'y-humidity': { - type: 'linear', - display: true, - position: 'right', - title: { - display: true, - text: '湿度 (%)' - }, - grid: { - drawOnChartArea: false - }, - min: 0, - max: 100 - }, - 'y-rainfall': { - type: 'linear', - display: true, - position: 'right', - title: { - display: true, - text: '雨量 (mm)' - }, - grid: { - drawOnChartArea: false - }, - beginAtZero: true - } - } - } - }); - } + // 数据集 + const datasets = [ + { + label: '温度 (°C) - 实测', + data: historyTemperatures, + borderColor: 'rgb(255, 99, 132)', + backgroundColor: 'rgba(255, 99, 132, 0.1)', + yAxisID: 'y-temperature', + tension: 0.4, + spanGaps: false + }, + { + label: '湿度 (%) - 实测', + data: historyHumidities, + borderColor: 'rgb(54, 162, 235)', + backgroundColor: 'rgba(54, 162, 235, 0.1)', + yAxisID: 'y-humidity', + tension: 0.4, + hidden: true, + spanGaps: false + }, + { + label: '雨量 (mm) - 实测', + data: historyRainfalls, + type: 'bar', + backgroundColor: 'rgba(54, 162, 235, 0.6)', + borderColor: 'rgb(54, 162, 235)', + yAxisID: 'y-rainfall' + }, + { + label: '累计雨量 (mm) - 实测', + data: historyRainTotals, + borderColor: 'rgb(75, 192, 192)', + backgroundColor: 'rgba(75, 192, 192, 0.1)', + yAxisID: 'y-rainfall', + tension: 0.2, + spanGaps: false, + pointRadius: 0, + hidden: true + } + ]; + + if (forecastData.length > 0) { + // 雨量 仅显示 -1h/-2h/-3h + datasets.push( + { label: '雨量 (mm) - 预报 (-1h)', data: forecastRainfallsH1, type: 'bar', backgroundColor: 'rgba(255, 99, 71, 0.55)', borderColor: 'rgb(255, 99, 71)', yAxisID: 'y-rainfall' }, + { label: '雨量 (mm) - 预报 (-2h)', data: forecastRainfallsH2, type: 'bar', backgroundColor: 'rgba(255, 205, 86, 0.55)', borderColor: 'rgb(255, 205, 86)', yAxisID: 'y-rainfall' }, + { label: '雨量 (mm) - 预报 (-3h)', data: forecastRainfallsH3, type: 'bar', backgroundColor: 'rgba(76, 175, 80, 0.55)', borderColor: 'rgb(76, 175, 80)', yAxisID: 'y-rainfall' } + ); + + // 温度 取最接近的预报(0h>1h>2h>3h) + datasets.push({ + label: '温度 (°C) - 预报', + data: forecastTemperaturesNearest, + borderColor: 'rgb(255, 159, 64)', + backgroundColor: 'rgba(255, 159, 64, 0.1)', + borderDash: [5, 5], + yAxisID: 'y-temperature', + tension: 0.4, + spanGaps: false + }); + } + + // 创建组合图表 + const ctx = document.getElementById('combinedChart').getContext('2d'); + this.chart = new Chart(ctx, { + type: 'line', + data: { + labels: allLabels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + scales: { + 'y-temperature': { type: 'linear', display: true, position: 'left', title: { display: true, text: '温度 (°C)' } }, + 'y-humidity': { type: 'linear', display: true, position: 'right', title: { display: true, text: '湿度 (%)' }, grid: { drawOnChartArea: false }, min: 0, max: 100 }, + 'y-rainfall': { type: 'linear', display: true, position: 'right', title: { display: true, text: '雨量 (mm)' }, grid: { drawOnChartArea: false }, beginAtZero: true } + } + } + }); + } }; // 导出图表对象 diff --git a/static/js/weather-table.js b/static/js/weather-table.js index 1332b5d..cb48037 100644 --- a/static/js/weather-table.js +++ b/static/js/weather-table.js @@ -37,10 +37,10 @@ const WeatherTable = { thead.appendChild(th); }); if (hasForecast) { - const th = document.createElement('th'); - th.textContent = '降水概率 (%)'; - th.className = 'bg-gray-50 font-semibold'; - thead.appendChild(th); + const th0 = document.createElement('th'); + th0.textContent = '降水概率 (%)'; + th0.className = 'bg-gray-50 font-semibold'; + thead.appendChild(th0); } const remainingHeaders = ['光照 (lux)', '紫外线']; remainingHeaders.forEach(text => { @@ -75,8 +75,16 @@ const WeatherTable = { row.style.backgroundColor = 'rgba(255, 165, 0, 0.08)'; } + const issuedBadge = (() => { + if (item.source !== '预报') return ''; + const lead = (typeof item.lead_hours === 'number' && item.lead_hours >= 0) ? ` -${item.lead_hours}h` : ''; + const issued = item.issued_at ? new Date(item.issued_at.replace(' ', 'T')) : null; + const issuedStr = issued ? `${String(issued.getHours()).padStart(2,'0')}:${String(issued.getMinutes()).padStart(2,'0')}` : '-'; + return `(发布: ${issuedStr}${lead})`; + })(); + const columns = [ - `