weather-station/static/js/weather-chart.js

419 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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: {
'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;