const WeatherChart = { 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(); 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 historyPressures = allLabels.map(label => { const item = historyData.find(d => d.date_time === label); return item ? item.pressure : null; }); const historyWindSpeeds = allLabels.map(label => { const item = historyData.find(d => d.date_time === label); return item ? item.wind_speed : 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 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) { // 保留同一 forecast_time+lead 的最新版本(查询结果已按 issued_at DESC 排序) if (bucket[h] == null) { bucket[h] = fp; } } }); 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 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 pickNearest = (label, field) => { const b = byTime.get(label); if (!b) return null; if (b[0] && b[0][field] != null) return b[0][field]; if (b[1] && b[1][field] != null) return b[1][field]; if (b[2] && b[2][field] != null) return b[2][field]; if (b[3] && b[3][field] != null) return b[3][field]; return null; }; const forecastTemperaturesNearest = allLabels.map(label => pickNearest(label, 'temperature')); const forecastHumiditiesNearest = allLabels.map(label => pickNearest(label, 'humidity')); const forecastPressuresNearest = allLabels.map(label => pickNearest(label, 'pressure')); const forecastWindSpeedsNearest = allLabels.map(label => pickNearest(label, 'wind_speed')); if (this.chart) this.chart.destroy(); // 计算降水分类准确率(+1h/+2h/+3h) const updateAccuracyPanel = () => { // 仅在有历史数据(实际)时计算 const usedIdx = historyRainfalls .map((v, idx) => ({ v, idx })) .filter(x => x.v !== null) .map(x => x.idx); const totalHours = usedIdx.length; const bucketOf = (mm) => { if (mm === null || mm === undefined || isNaN(Number(mm))) return null; const v = Math.max(0, Number(mm)); if (v < 5) return 0; if (v < 10) return 1; return 2; }; const calcFor = (arrFcst) => { let correct = 0; usedIdx.forEach(i => { const a = historyRainfalls[i]; const f = arrFcst[i]; const ba = bucketOf(a); const bf = bucketOf(f); if (ba !== null && bf !== null && ba === bf) correct += 1; }); return { correct, total: totalHours }; }; const fmt = (n) => `${n.toFixed(1)}%`; const elPanel = document.getElementById('accuracyPanel'); const elH1 = document.getElementById('accH1'); const elH2 = document.getElementById('accH2'); const elH3 = document.getElementById('accH3'); if (!elPanel || !elH1 || !elH2 || !elH3) return; if (forecastData.length === 0 || totalHours === 0) { elPanel.style.display = 'none'; return; } // 详细计算过程日志 try { console.groupCollapsed('[准确率] 降水分档 (+1h/+2h/+3h) 计算详情'); console.log('时间段总小时(有实测):', totalHours); const nameOf = (b) => (b===0?'[0,5)':(b===1?'[5,10)':'[10,∞)')); const fv = (v) => (v===null||v===undefined||isNaN(Number(v)) ? 'NULL' : Number(v).toFixed(2)); usedIdx.forEach(i => { const label = allLabels[i]; const a = historyRainfalls[i]; const bA = bucketOf(a); const f1 = forecastRainfallsH1[i]; const b1 = bucketOf(f1); const f2 = forecastRainfallsH2[i]; const b2 = bucketOf(f2); const f3 = forecastRainfallsH3[i]; const b3 = bucketOf(f3); const m1 = (bA!==null && b1!==null && bA===b1) ? '√' : '×'; const m2 = (bA!==null && b2!==null && bA===b2) ? '√' : '×'; const m3 = (bA!==null && b3!==null && bA===b3) ? '√' : '×'; console.log( `${label} | 实测 ${fv(a)}mm (${bA===null?'--':nameOf(bA)}) | +1h ${fv(f1)} (${b1===null?'--':nameOf(b1)}) ${m1} | +2h ${fv(f2)} (${b2===null?'--':nameOf(b2)}) ${m2} | +3h ${fv(f3)} (${b3===null?'--':nameOf(b3)}) ${m3}` ); }); } catch (e) { console.warn('准确率计算日志输出失败', e); } const r1 = calcFor(forecastRainfallsH1); const r2 = calcFor(forecastRainfallsH2); const r3 = calcFor(forecastRainfallsH3); console.log(`+1h: ${r1.correct}/${r1.total}`); console.log(`+2h: ${r2.correct}/${r2.total}`); console.log(`+3h: ${r3.correct}/${r3.total}`); console.groupEnd(); elH1.textContent = r1.total > 0 ? fmt((r1.correct / r1.total) * 100) : '--'; elH2.textContent = r2.total > 0 ? fmt((r2.correct / r2.total) * 100) : '--'; elH3.textContent = r3.total > 0 ? fmt((r3.correct / r3.total) * 100) : '--'; elPanel.style.display = 'block'; }; const datasets = [ { label: '温度 (°C) - 实测', seriesKey: 'temp_actual', data: historyTemperatures, borderColor: 'rgb(255, 99, 132)', backgroundColor: 'rgba(255, 99, 132, 0.1)', yAxisID: 'y-temperature', tension: 0.4, spanGaps: false }, { label: '湿度 (%) - 实测', seriesKey: 'hum_actual', 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: '大气压 (hPa) - 实测', seriesKey: 'pressure_actual', data: historyPressures, borderColor: 'rgb(153, 102, 255)', backgroundColor: 'rgba(153, 102, 255, 0.1)', yAxisID: 'y-pressure', tension: 0.4, hidden: true, spanGaps: false }, { label: '风速 (m/s) - 实测', seriesKey: 'wind_actual', data: historyWindSpeeds, borderColor: 'rgb(75, 192, 192)', backgroundColor: 'rgba(75, 192, 192, 0.1)', yAxisID: 'y-wind', tension: 0.4, hidden: true, spanGaps: false }, { label: '雨量 (mm) - 实测', seriesKey: 'rain_actual', data: historyRainfalls, type: 'bar', backgroundColor: 'rgba(54, 162, 235, 0.6)', borderColor: 'rgb(54, 162, 235)', yAxisID: 'y-rainfall' }, { label: '累计雨量 (mm) - 实测', seriesKey: 'rain_total', 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) { datasets.push( { label: '雨量 (mm) - 预报 (+1h)', seriesKey: 'rain_fcst_h1', data: forecastRainfallsH1, type: 'bar', backgroundColor: 'rgba(255, 99, 71, 0.55)', borderColor: 'rgb(255, 99, 71)', yAxisID: 'y-rainfall' }, { label: '雨量 (mm) - 预报 (+2h)', seriesKey: 'rain_fcst_h2', data: forecastRainfallsH2, type: 'bar', backgroundColor: 'rgba(255, 205, 86, 0.55)', borderColor: 'rgb(255, 205, 86)', yAxisID: 'y-rainfall' }, { label: '雨量 (mm) - 预报 (+3h)', seriesKey: 'rain_fcst_h3', data: forecastRainfallsH3, type: 'bar', backgroundColor: 'rgba(76, 175, 80, 0.55)', borderColor: 'rgb(76, 175, 80)', yAxisID: 'y-rainfall' } ); datasets.push( { label: '温度 (°C) - 预报', seriesKey: 'temp_fcst', 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, hidden: true }, { label: '湿度 (%) - 预报', seriesKey: 'hum_fcst', data: forecastHumiditiesNearest, borderColor: 'rgb(54, 162, 235)', backgroundColor: 'rgba(54, 162, 235, 0.1)', borderDash: [5, 5], yAxisID: 'y-humidity', tension: 0.4, hidden: true, spanGaps: false }, { label: '大气压 (hPa) - 预报', seriesKey: 'pressure_fcst', data: forecastPressuresNearest, borderColor: 'rgb(153, 102, 255)', backgroundColor: 'rgba(153, 102, 255, 0.1)', borderDash: [5, 5], yAxisID: 'y-pressure', tension: 0.4, hidden: true, spanGaps: false }, { label: '风速 (m/s) - 预报', seriesKey: 'wind_fcst', data: forecastWindSpeedsNearest, borderColor: 'rgb(75, 192, 192)', backgroundColor: 'rgba(75, 192, 192, 0.1)', borderDash: [5, 5], yAxisID: 'y-wind', tension: 0.4, hidden: true, spanGaps: false } ); } const ctx = document.getElementById('combinedChart').getContext('2d'); const totalLabels = allLabels.length; const tickStep = Math.max(1, Math.ceil(totalLabels / 10)); // 约10个刻度 const chartConfig = { type: 'line', data: { labels: allLabels, datasets: datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, layout: { padding: { top: 12, right: 16, bottom: 12, left: 16 } }, plugins: { legend: { display: true, position: 'top', align: 'center', labels: { padding: 16 } } }, scales: { x: { type: 'category', ticks: { autoSkip: false, maxRotation: 0, minRotation: 0, callback: function(value, index) { // 仅显示每 tickStep 个刻度,格式 MM-DD HH if (index % tickStep !== 0) return ''; const labels = this.chart?.data?.labels || []; const raw = labels[index] || ''; // 原始格式: YYYY-MM-DD HH:MM:SS if (typeof raw === 'string' && raw.length >= 13) { return raw.substring(5, 13); } return raw; } }, grid: { display: true, drawOnChartArea: true } }, 'y-temperature': { type: 'linear', display: true, position: 'right', 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-pressure': { type: 'linear', display: true, position: 'left', title: { display: true, text: '大气压 (hPa)' }, grid: { drawOnChartArea: false } }, 'y-wind': { type: 'linear', display: true, position: 'left', title: { display: true, text: '风速 (m/s)' }, grid: { drawOnChartArea: false }, beginAtZero: true }, 'y-rainfall': { type: 'linear', display: true, position: 'right', title: { display: true, text: '雨量 (mm)' }, grid: { drawOnChartArea: false }, beginAtZero: true } } } }; this.chart = new Chart(ctx, chartConfig); // 更新准确率面板 updateAccuracyPanel(); const mode = document.getElementById('legendMode')?.value || 'combo_standard'; this.applyLegendMode(mode); } }; WeatherChart.applyLegendMode = function(mode) { if (!this.chart) return; // 设置数据集可见性 const map = new Map(); this.chart.data.datasets.forEach(ds => { if (ds.seriesKey) map.set(ds.seriesKey, ds); }); const setVisible = (keys) => { const allKeys = ['temp_actual','hum_actual','rain_actual','rain_total','temp_fcst','hum_fcst','pressure_actual','pressure_fcst','wind_actual','wind_fcst','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']; allKeys.forEach(k => { if (map.has(k)) map.get(k).hidden = true; }); keys.forEach(k => { if (map.has(k)) map.get(k).hidden = false; }); }; switch (mode) { case 'verify_all': setVisible(['temp_actual','temp_fcst','hum_actual','hum_fcst','pressure_actual','pressure_fcst','wind_actual','wind_fcst','rain_actual','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']); break; case 'temp_compare': setVisible(['temp_actual','temp_fcst']); break; case 'hum_compare': setVisible(['hum_actual','hum_fcst']); break; case 'rain_all': setVisible(['rain_actual','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']); break; case 'pressure_compare': setVisible(['pressure_actual','pressure_fcst']); break; case 'wind_compare': setVisible(['wind_actual','wind_fcst']); break; case 'combo_standard': default: setVisible(['temp_actual','temp_fcst','rain_actual','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']); break; } this.chart.update(); }; WeatherChart.updateAxesVisibility = function() { if (!this.chart) return; // 检查每个轴是否有可见的数据集 const hasVisibleDatasets = (axisId) => { return this.chart.data.datasets.some(ds => !ds.hidden && ds.yAxisID === axisId); }; // 更新每个轴的显示状态 Object.entries(this.chart.options.scales).forEach(([scaleId, scale]) => { const shouldDisplay = hasVisibleDatasets(scaleId); if (scale.display !== shouldDisplay) { scale.display = shouldDisplay; } }); }; window.WeatherChart = WeatherChart;