445 lines
15 KiB
JavaScript
445 lines
15 KiB
JavaScript
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();
|
||
|
||
// 计算降水分类准确率(四档:0mm、(0,5]、(5,10]、>10),分别统计 +1h/+2h/+3h
|
||
const updateAccuracyPanel = () => {
|
||
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;
|
||
|
||
const isNum = (v) => v!==null && v!==undefined && !isNaN(Number(v));
|
||
const usedIdx = historyRainfalls
|
||
.map((v, idx) => ({ v, idx }))
|
||
.filter(x => isNum(x.v))
|
||
.map(x => x.idx);
|
||
const totalHours = usedIdx.length;
|
||
if (forecastData.length === 0 || totalHours === 0) {
|
||
elPanel.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const bucketOf = (mm) => {
|
||
if (!isNum(mm)) return null;
|
||
const v = Math.max(0, Number(mm));
|
||
if (v === 0) return 0; // 0mm
|
||
if (v > 0 && v <= 5) return 1; // (0,5]
|
||
if (v > 5 && v <= 10) return 2; // (5,10]
|
||
return 3; // >10
|
||
};
|
||
const nameOf = (b) => b===0?'0mm':b===1?'(0,5]':b===2?'(5,10]':b===3?'>10':'--';
|
||
const calcFor = (arrFcst) => {
|
||
let correct = 0;
|
||
usedIdx.forEach(i => {
|
||
const ba = bucketOf(historyRainfalls[i]);
|
||
const bf = bucketOf(arrFcst[i]);
|
||
if (ba !== null && bf !== null && ba === bf) correct += 1;
|
||
});
|
||
return { correct, total: totalHours };
|
||
};
|
||
const fmt = (n) => `${n.toFixed(1)}%`;
|
||
|
||
try {
|
||
console.groupCollapsed('[准确率] (+1h/+2h/+3h)');
|
||
console.log('时间段总小时(实测):', totalHours);
|
||
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) ? 'true' : 'false';
|
||
const m2 = (bA!==null && b2!==null && bA===b2) ? 'true' : 'false';
|
||
const m3 = (bA!==null && b3!==null && bA===b3) ? 'true' : 'false';
|
||
console.log(`${label} | 实测 ${fv(a)} (${nameOf(bA)}) | +1h ${fv(f1)} (${nameOf(b1)}) ${m1} | +2h ${fv(f2)} (${nameOf(b2)}) ${m2} | +3h ${fv(f3)} (${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} => ${fmt((r1.correct / r1.total) * 100)}`);
|
||
console.log(`+2h: ${r2.correct}/${r2.total} => ${fmt((r2.correct / r2.total) * 100)}`);
|
||
console.log(`+3h: ${r3.correct}/${r3.total} => ${fmt((r3.correct / r3.total) * 100)}`);
|
||
console.groupEnd && 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;
|