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; @ViewChild('accPanel', { static: true }) accPanel!: ElementRef; @ViewChild('accH1', { static: true }) accH1!: ElementRef; @ViewChild('accH2', { static: true }) accH2!: ElementRef; @ViewChild('accH3', { static: true }) accH3!: ElementRef; 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(); 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(); 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; } } }