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

445 lines
15 KiB
JavaScript
Raw 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();
// 计算降水分类准确率四档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;