weather-station/core/frontend/src/app/chart-panel.component.ts

218 lines
13 KiB
TypeScript

import { Component, ElementRef, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
import type { ForecastPoint, WeatherPoint } from './api.service';
@Component({
selector: 'chart-panel',
standalone: true,
templateUrl: './chart-panel.component.html'
})
export class ChartPanelComponent implements OnChanges {
@Input() history: WeatherPoint[] = [];
@Input() forecast: ForecastPoint[] = [];
@Input() legendMode: string = 'combo_standard';
@Input() showAccuracy: boolean = true;
@ViewChild('canvas', { static: true }) canvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('accPanel', { static: true }) accPanel!: ElementRef<HTMLDivElement>;
@ViewChild('accH1', { static: true }) accH1!: ElementRef<HTMLSpanElement>;
@ViewChild('accH2', { static: true }) accH2!: ElementRef<HTMLSpanElement>;
@ViewChild('accH3', { static: true }) accH3!: ElementRef<HTMLSpanElement>;
private chart: any;
ngOnChanges(changes: SimpleChanges): void {
this.render();
}
private render() {
const historyData = Array.isArray(this.history) ? this.history : [];
const forecastData = Array.isArray(this.forecast) ? this.forecast : [];
if (historyData.length === 0 && forecastData.length === 0) { this.destroy(); return; }
const labels = [...new Set([
...historyData.map(x => x.date_time),
...forecastData.map(x => x.date_time)
])].sort();
const pickSeries = (arr: any[], key: string) => labels.map(l => {
const it = arr.find(d => d.date_time === l); return it ? (it as any)[key] ?? null : null;
});
const hTemp = pickSeries(historyData, 'temperature');
const hHum = pickSeries(historyData, 'humidity');
const hPres = pickSeries(historyData, 'pressure');
const hWind = pickSeries(historyData, 'wind_speed');
const hRain = pickSeries(historyData, 'rainfall');
const hRainTotal = pickSeries(historyData, 'rain_total');
const byTime = new Map<string, any>();
forecastData.forEach(fp => {
const t = fp.date_time; if (!byTime.has(t)) byTime.set(t, {});
const lead = typeof fp.lead_hours === 'number' ? fp.lead_hours : null;
if (lead !== null && lead >= 0 && lead <= 3) {
const bucket = byTime.get(t);
if (bucket[lead] == null) bucket[lead] = fp; // assume DESC issued_at
}
});
const getAtLead = (l: string, field: string, lead: number) => {
const b = byTime.get(l); if (!b || !b[lead]) return null; const v = b[lead][field as keyof ForecastPoint];
return v == null ? null : (v as number);
};
const rainH1 = labels.map(l => getAtLead(l, 'rainfall', 1));
const rainH2 = labels.map(l => getAtLead(l, 'rainfall', 2));
const rainH3 = labels.map(l => getAtLead(l, 'rainfall', 3));
const pickNearest = (l: string, field: keyof ForecastPoint) => {
const b = byTime.get(l); if (!b) return null;
for (const k of [0,1,2,3]) { if (b[k] && b[k][field] != null) return b[k][field] as number; }
return null;
};
const fTempN = labels.map(l => pickNearest(l, 'temperature'));
const fHumN = labels.map(l => pickNearest(l, 'humidity'));
const fPresN = labels.map(l => pickNearest(l, 'pressure'));
const fWindN = labels.map(l => pickNearest(l, 'wind_speed'));
this.destroy();
// @ts-ignore
const Chart = (window as any).Chart;
if (!Chart || !this.canvas?.nativeElement) return;
const datasets: any[] = [
{ label: '温度 (°C) - 实测', seriesKey: 'temp_actual', data: hTemp, 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: hHum, 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: hPres, 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: hWind, 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: hRain, type: 'bar', backgroundColor: 'rgba(54,162,235,0.6)', borderColor: 'rgb(54,162,235)', yAxisID: 'y-rainfall' },
{ label: '累计雨量 (mm) - 实测', seriesKey: 'rain_total', data: hRainTotal, 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: rainH1, 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: rainH2, 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: rainH3, type: 'bar', backgroundColor: 'rgba(76,175,80,0.55)', borderColor: 'rgb(76,175,80)', yAxisID: 'y-rainfall' },
{ label: '温度 (°C) - 预报', seriesKey: 'temp_fcst', data: fTempN, 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: fHumN, 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: fPresN, 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: fWindN, 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 totalLabels = labels.length;
const tickStep = Math.max(1, Math.ceil(totalLabels / 10));
this.chart = new Chart(this.canvas.nativeElement.getContext('2d'), {
type: 'line',
data: { labels, 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: any, index: number) {
if (index % tickStep !== 0) return '';
const labels = (this as any).chart?.data?.labels || [];
const raw = labels[index] || '';
return (typeof raw === 'string' && raw.length >= 13) ? raw.substring(5, 13) : 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.applyLegendMode(this.legendMode);
this.updateAccuracyPanel(labels, hRain, rainH1, rainH2, rainH3);
}
private applyLegendMode(mode: string) {
if (!this.chart) return;
const map = new Map<string, any>();
this.chart.data.datasets.forEach((ds: any) => { if (ds.seriesKey) map.set(ds.seriesKey, ds); });
const setVisible = (keys: string[]) => {
const all = ['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'];
all.forEach(k => { const ds = map.get(k); if (ds) ds.hidden = true; });
keys.forEach(k => { const ds = map.get(k); if (ds) ds.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']);
}
this.chart.update();
}
private updateAccuracyPanel(labels: string[], historyRain: (number|null)[], h1: (number|null)[], h2: (number|null)[], h3: (number|null)[]) {
const panel = this.accPanel?.nativeElement; if (!panel) return;
if (!this.showAccuracy) { panel.style.display = 'none'; return; }
const isNum = (v:any)=> v!=null && !isNaN(Number(v));
const usedIdx = historyRain.map((v, idx) => ({v, idx})).filter(x => isNum(x.v)).map(x => x.idx);
const total = usedIdx.length;
if (total === 0 || labels.length === 0) { panel.style.display = 'none'; return; }
// Buckets: 0mm, (0,4), [4,8), [8, +∞)
const bucketOf = (mm: any): number|null => {
if (!isNum(mm)) return null;
const v = Math.max(0, Number(mm));
if (v === 0) return 0; // 0
if (v > 0 && v < 4) return 1; // (0,4)
if (v >= 4 && v < 8) return 2; // [4,8)
return 3; // [8, +∞)
};
const nameOf = (b:number|null) => b===0 ? '0' : b===1 ? '(0,4)' : b===2 ? '[4,8)' : b===3 ? '≥8' : '--';
const calcFor = (arr: (number|null)[]) => {
let correct=0;
usedIdx.forEach(i=>{ const ba=bucketOf(historyRain[i]); const bf=bucketOf(arr[i]); if(ba!==null && bf!==null && ba===bf) correct++; });
return {correct,total};
};
const fmtPct = (n: number) => `${n.toFixed(1)}%`;
// Detailed console logs for validation
try {
console.groupCollapsed('[准确率] (+1h/+2h/+3h)');
console.log('总小时(实测):', total);
usedIdx.forEach(i => {
const label = labels[i];
const a = historyRain[i];
const bA = bucketOf(a);
const f1 = h1[i]; const b1 = bucketOf(f1);
const f2 = h2[i]; const b2 = bucketOf(f2);
const f3 = h3[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';
const fv = (v:any)=> (v==null||isNaN(Number(v)))? 'NULL' : Number(v).toFixed(2);
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 {}
const r1 = calcFor(h1), r2 = calcFor(h2), r3 = calcFor(h3);
try {
console.log(`命中/总 (+1h): ${r1.correct}/${r1.total} => ${fmtPct((r1.correct/total)*100)}`);
console.log(`命中/总 (+2h): ${r2.correct}/${r2.total} => ${fmtPct((r2.correct/total)*100)}`);
console.log(`命中/总 (+3h): ${r3.correct}/${r3.total} => ${fmtPct((r3.correct/total)*100)}`);
console.groupEnd?.();
} catch {}
this.accH1.nativeElement.textContent = total>0 ? fmtPct((r1.correct/total)*100) : '--';
this.accH2.nativeElement.textContent = total>0 ? fmtPct((r2.correct/total)*100) : '--';
this.accH3.nativeElement.textContent = total>0 ? fmtPct((r3.correct/total)*100) : '--';
panel.style.display = 'block';
}
destroy() { if (this.chart) { this.chart.destroy(); this.chart = null; } }
}