diff --git a/core/cmd/core-api/main.go b/core/cmd/core-api/main.go
new file mode 100644
index 0000000..420e2d6
--- /dev/null
+++ b/core/cmd/core-api/main.go
@@ -0,0 +1,29 @@
+package main
+
+import (
+ "log"
+ "os"
+
+ "weatherstation/core/internal/config"
+ "weatherstation/core/internal/server"
+)
+
+func main() {
+ cfg := config.Load()
+ r := server.NewRouter(server.Options{
+ UIServeDir: cfg.UIServeDir,
+ TemplateDir: cfg.TemplateDir,
+ StaticDir: cfg.StaticDir,
+ EnableCORS: cfg.DevEnableCORS,
+ })
+
+ addr := cfg.Addr
+ if env := os.Getenv("PORT"); env != "" {
+ addr = ":" + env
+ }
+
+ log.Printf("core-api listening on %s", addr)
+ if err := r.Run(addr); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/core/cmd/core-bigscreen/main.go b/core/cmd/core-bigscreen/main.go
new file mode 100644
index 0000000..e6f67c1
--- /dev/null
+++ b/core/cmd/core-bigscreen/main.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+ "log"
+ oldserver "weatherstation/internal/server"
+)
+
+func main() {
+ const port = 10008
+ log.Printf("core-bigscreen listening on :%d", port)
+ if err := oldserver.StartBigscreenServerOn(port); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/core/frontend/angular.json b/core/frontend/angular.json
index e3bf50f..fcddf6f 100644
--- a/core/frontend/angular.json
+++ b/core/frontend/angular.json
@@ -18,7 +18,13 @@
"src/polyfills.ts"
],
"tsConfig": "tsconfig.app.json",
- "assets": [],
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "../../static",
+ "output": "static"
+ }
+ ],
"styles": [
"src/styles.css"
],
diff --git a/core/frontend/package.json b/core/frontend/package.json
index c5c3bb4..7d428e7 100644
--- a/core/frontend/package.json
+++ b/core/frontend/package.json
@@ -3,7 +3,7 @@
"version": "0.0.1",
"private": true,
"scripts": {
- "start": "ng serve",
+ "start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build --configuration production --base-href /ui/",
"dev": "ng serve",
"test": "ng test"
@@ -27,4 +27,3 @@
"typescript": "~5.3.3"
}
}
-
diff --git a/core/frontend/proxy.conf.json b/core/frontend/proxy.conf.json
new file mode 100644
index 0000000..459850d
--- /dev/null
+++ b/core/frontend/proxy.conf.json
@@ -0,0 +1,14 @@
+{
+ "/api": {
+ "target": "http://localhost:10003",
+ "secure": false,
+ "changeOrigin": true,
+ "logLevel": "info"
+ },
+ "/static": {
+ "target": "http://localhost:10003",
+ "secure": false,
+ "changeOrigin": true,
+ "logLevel": "info"
+ }
+}
diff --git a/core/frontend/src/app.component.html b/core/frontend/src/app.component.html
index c1c0e6b..aed2cb7 100644
--- a/core/frontend/src/app.component.html
+++ b/core/frontend/src/app.component.html
@@ -1,4 +1,103 @@
-
-
Hello World
+
+
+
+
+
+
+
在线设备: {{onlineDevices}} 个 |
+
总设备:
+
{{ wh65lpCount }} 个
+
+
+
+
+
+
+
+
+
+
+
+
{{ selectedTitle }}
+
+
+
+
diff --git a/core/frontend/src/app/api.service.ts b/core/frontend/src/app/api.service.ts
new file mode 100644
index 0000000..5a6fb8f
--- /dev/null
+++ b/core/frontend/src/app/api.service.ts
@@ -0,0 +1,47 @@
+import { Injectable } from '@angular/core';
+
+export type WeatherPoint = {
+ date_time: string;
+ temperature?: number;
+ humidity?: number;
+ pressure?: number;
+ wind_speed?: number;
+ wind_direction?: number;
+ rainfall?: number;
+ rain_total?: number;
+ light?: number;
+ uv?: number;
+};
+
+export type ForecastPoint = WeatherPoint & {
+ provider?: string;
+ issued_at?: string;
+ precip_prob?: number;
+ lead_hours?: number;
+};
+
+@Injectable({ providedIn: 'root' })
+export class ApiService {
+ async getStatus(): Promise<{ online_devices: number; server_time: string } | null> {
+ try { const r = await fetch('/api/system/status'); return await r.json(); } catch { return null; }
+ }
+
+ async getStations(): Promise
{
+ try { const r = await fetch('/api/stations'); return await r.json(); } catch { return []; }
+ }
+
+ async getHistory(decimalId: string, from: string, to: string, interval: string): Promise {
+ const params = new URLSearchParams({ decimal_id: decimalId, start_time: from, end_time: to, interval });
+ const r = await fetch(`/api/data?${params.toString()}`);
+ if (!r.ok) return [];
+ return await r.json();
+ }
+
+ async getForecast(stationId: string, from: string, to: string, provider = '', versions = 1): Promise {
+ const params = new URLSearchParams({ station_id: stationId, from, to, provider, versions: String(versions) });
+ const r = await fetch(`/api/forecast?${params.toString()}`);
+ if (!r.ok) return [];
+ return await r.json();
+ }
+}
+
diff --git a/core/frontend/src/app/chart-panel.component.html b/core/frontend/src/app/chart-panel.component.html
new file mode 100644
index 0000000..444e6da
--- /dev/null
+++ b/core/frontend/src/app/chart-panel.component.html
@@ -0,0 +1,13 @@
+
+
+
+ +1h:--
+ +2h:--
+ +3h:--
+
+
+
+
+
+
+
diff --git a/core/frontend/src/app/chart-panel.component.ts b/core/frontend/src/app/chart-panel.component.ts
new file mode 100644
index 0000000..131860f
--- /dev/null
+++ b/core/frontend/src/app/chart-panel.component.ts
@@ -0,0 +1,215 @@
+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';
+
+ @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;
+ 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 (exact zero), (0,5], (5,10], (10,∞)
+ const bucketOf = (mm: any): number|null => {
+ 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:number|null) => b===0 ? '0mm' : b===1 ? '(0,5]' : b===2 ? '(5,10]' : b===3 ? '>10' : '--';
+
+ 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; } }
+}
diff --git a/core/frontend/src/app/header.component.html b/core/frontend/src/app/header.component.html
new file mode 100644
index 0000000..d4d421a
--- /dev/null
+++ b/core/frontend/src/app/header.component.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
diff --git a/core/frontend/src/app/header.component.ts b/core/frontend/src/app/header.component.ts
new file mode 100644
index 0000000..451ce2c
--- /dev/null
+++ b/core/frontend/src/app/header.component.ts
@@ -0,0 +1,46 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+type Station = { station_id: string; device_type?: string; name?: string; station_alias?: string; location?: string };
+
+@Component({
+ selector: 'app-header',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './header.component.html'
+})
+export class HeaderComponent {
+ @Input() onlineDevices = 0;
+ @Input() stations: Station[] = [];
+ @Output() selectStation = new EventEmitter<{ stationId: string, decimalId: string }>();
+
+ modalOpen = false;
+ currentPage = 1;
+ itemsPerPage = 10;
+
+ get wh65Count(): number { return (this.stations||[]).filter(s => s.device_type === 'WH65LP').length; }
+ get totalPages(): number { return Math.max(1, Math.ceil(this.stations.length / this.itemsPerPage)); }
+ get pageItems(): Station[] {
+ const start = (this.currentPage - 1) * this.itemsPerPage;
+ return this.stations.slice(start, start + this.itemsPerPage);
+ }
+
+ open() { this.modalOpen = true; this.currentPage = 1; }
+ close() { this.modalOpen = false; }
+ prev() { if (this.currentPage > 1) this.currentPage--; }
+ next() { if (this.currentPage < this.totalPages) this.currentPage++; }
+
+ decId(s: Station): string {
+ const id = s.station_id || '';
+ if (id.length <= 6) return '';
+ const hex = id.slice(-6);
+ const n = parseInt(hex, 16);
+ return isNaN(n) ? '' : String(n);
+ }
+
+ choose(s: Station) {
+ const dec = this.decId(s);
+ if (dec) this.selectStation.emit({ stationId: s.station_id, decimalId: dec });
+ this.close();
+ }
+}
diff --git a/core/frontend/src/app/table-panel.component.html b/core/frontend/src/app/table-panel.component.html
new file mode 100644
index 0000000..62b2d26
--- /dev/null
+++ b/core/frontend/src/app/table-panel.component.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+ | {{h}} |
+
+
+
+
+ |
+
+ {{ r.date_time }}
+ [预报]
+
+ (发布: {{ r.issued_hhmm || '-' }}{{ r.lead_hours!=null ? ' +' + r.lead_hours + 'h' : '' }})
+ |
+ {{r.temperature}} |
+ {{r.humidity}} |
+ {{r.pressure}} |
+ {{r.wind_speed}} |
+ {{r.wind_direction}} |
+ {{r.rainfall}} |
+ {{r.precip_prob ?? '-'}} |
+ {{r.light}} |
+ {{r.uv}} |
+
+
+
+
+
diff --git a/core/frontend/src/app/table-panel.component.ts b/core/frontend/src/app/table-panel.component.ts
new file mode 100644
index 0000000..4d702d8
--- /dev/null
+++ b/core/frontend/src/app/table-panel.component.ts
@@ -0,0 +1,66 @@
+import { Component, Input, OnChanges } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import type { ForecastPoint, WeatherPoint } from './api.service';
+
+import { FormsModule } from '@angular/forms';
+
+@Component({
+ selector: 'table-panel',
+ standalone: true,
+ imports: [CommonModule, FormsModule],
+ templateUrl: './table-panel.component.html'
+})
+export class TablePanelComponent implements OnChanges {
+ @Input() history: WeatherPoint[] = [];
+ @Input() forecast: ForecastPoint[] = [];
+ @Input() showPastForecast = false;
+ @Input() endDate: string = '';
+
+ headers: string[] = ['时间','温度 (°C)','湿度 (%)','气压 (hPa)','风速 (m/s)','风向 (°)','雨量 (mm)'];
+ rows: any[] = [];
+ hasForecast = false;
+
+ ngOnChanges(): void { this.build(); }
+
+ build() {
+ const endTs = this.endDate ? new Date(this.endDate).getTime() : Date.now();
+ const future3h = endTs + 3*60*60*1000;
+ const filteredFcst = (Array.isArray(this.forecast)?this.forecast:[]).filter(it => {
+ const t = new Date(it.date_time.replace(' ', 'T')).getTime();
+ const isFuture3h = t > endTs && t <= future3h;
+ const isPast = t <= endTs;
+ return isFuture3h || (this.showPastForecast && isPast);
+ }).map(it => ({...it, __source: '预报'}));
+
+ const hist = (Array.isArray(this.history)?this.history:[]).map(it => ({...it, __source: '实测'}));
+ const all: any[] = [...hist as any[], ...filteredFcst as any[]];
+ this.hasForecast = filteredFcst.length > 0;
+ this.headers = ['时间','温度 (°C)','湿度 (%)','气压 (hPa)','风速 (m/s)','风向 (°)','雨量 (mm)'];
+ if (this.hasForecast) this.headers = [...this.headers, '降水概率 (%)'];
+ this.headers = [...this.headers, '光照 (lux)','紫外线'];
+
+ const fmt = (v: any, d: number) => (v===null || v===undefined || v==='' || isNaN(Number(v))) ? '' : Number(v).toFixed(d);
+ this.rows = all.sort((a,b)=> new Date(b.date_time).getTime() - new Date(a.date_time).getTime()).map(row => ({
+ date_time: row.date_time,
+ isForecast: row.__source==='预报',
+ issued_at: (row as any).issued_at,
+ issued_hhmm: (()=>{
+ const ia = (row as any).issued_at as string | undefined;
+ if (!ia) return null;
+ // expected format: YYYY-MM-DD HH:MM:SS
+ const t = ia.includes(' ') ? ia.split(' ')[1] : ia;
+ return t ? t.substring(0,5) : null;
+ })(),
+ lead_hours: (row as any).lead_hours,
+ temperature: fmt(row.temperature,1),
+ humidity: fmt(row.humidity,1),
+ pressure: fmt(row.pressure,1),
+ wind_speed: fmt(row.wind_speed,1),
+ wind_direction: fmt(row.wind_direction,0),
+ rainfall: fmt(row.rainfall,2),
+ precip_prob: (row.__source==='预报' && (row as any).precip_prob!=null) ? (row as any).precip_prob : null,
+ light: fmt(row.light,0),
+ uv: fmt(row.uv,1)
+ }));
+ }
+}
diff --git a/core/frontend/src/index.html b/core/frontend/src/index.html
index 4beaf4f..4b10b9b 100644
--- a/core/frontend/src/index.html
+++ b/core/frontend/src/index.html
@@ -2,11 +2,15 @@
- WeatherStation UI
+ 英卓气象站
+
+
Loading...
+
+
diff --git a/core/frontend/src/main.ts b/core/frontend/src/main.ts
new file mode 100644
index 0000000..040607a
--- /dev/null
+++ b/core/frontend/src/main.ts
@@ -0,0 +1,418 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { Component, OnInit, AfterViewInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ApiService, ForecastPoint, WeatherPoint } from './app/api.service';
+import { ChartPanelComponent } from './app/chart-panel.component';
+import { TablePanelComponent } from './app/table-panel.component';
+import { HeaderComponent } from './app/header.component';
+
+type Station = {
+ station_id: string;
+ decimal_id?: string;
+ latitude?: number;
+ longitude?: number;
+ location?: string;
+ device_type?: string;
+ last_update?: string;
+ name?: string;
+ station_alias?: string;
+};
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [CommonModule, FormsModule, ChartPanelComponent, TablePanelComponent, HeaderComponent],
+ templateUrl: './app.component.html',
+})
+export class AppComponent implements OnInit, AfterViewInit {
+ constructor(private api: ApiService) {}
+
+ onlineDevices = 0;
+ serverTime = '';
+ stations: Station[] = [];
+ mapType = 'satellite';
+
+ decimalId = '';
+ interval = '1hour';
+ start = '';
+ end = '';
+ // 默认英卓 V4
+ provider = 'imdroid_mix';
+ legendMode = 'combo_standard';
+ showPastForecast = false;
+ showPanels = false;
+ selectedLocation = '';
+ selectedTitle = '';
+ isLoading = false;
+
+ history: WeatherPoint[] = [];
+ forecast: ForecastPoint[] = [];
+
+ private map: any;
+ private layers: any = {};
+ private stationSource: any;
+ private clusterSource: any;
+ private stationLayer: any;
+ private clusterLayer: any;
+ private CLUSTER_THRESHOLD = 10;
+ private tileOverlayGroup: any;
+ private tileLastList: any[] = [];
+ tileTimes: string[] = [];
+ tileIndex = -1;
+ tileZ = 7; tileY = 40; tileX = 102;
+ tileDt = '';
+ tileProduct: 'none'|'radar'|'rain' = 'radar';
+ isMapCollapsed = false;
+
+ async ngOnInit() {
+ await Promise.all([this.loadStatus(), this.loadStations()]);
+ const now = new Date();
+ const pad = (n:number)=> String(n).padStart(2,'0');
+ const toLocal = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ const end = new Date(now.getTime() + 4*3600*1000); // 当前时间后4小时
+ const start = new Date(now.getTime() - 24*3600*1000); // 当前时间前24小时
+ this.end = toLocal(end);
+ this.start = toLocal(start);
+ }
+
+ ngAfterViewInit() { this.initMap(); this.reloadTileTimesAndShow(); }
+
+ private async loadStatus() {
+ const s = await this.api.getStatus();
+ if (s) { this.onlineDevices = s.online_devices || 0; this.serverTime = s.server_time || ''; }
+ }
+
+ private async loadStations() { this.stations = await this.api.getStations(); this.updateStationsOnMap(); }
+
+ private getTiandituKey(): string {
+ const anyWin = (window as any);
+ return anyWin.TIANDITU_KEY || '0c260b8a094a4e0bc507808812cefdac';
+ }
+
+ get wh65lpCount(): number {
+ return (this.stations || []).filter(s => s.device_type === 'WH65LP').length;
+ }
+
+ private initMap() {
+ const ol: any = (window as any).ol; if (!ol) return;
+ const tk = this.getTiandituKey();
+ const mkLayer = (url: string) => new ol.layer.Tile({ source: new ol.source.XYZ({ url }) });
+ this.layers = {
+ satellite: new ol.layer.Group({ layers: [
+ mkLayer(`https://t{0-7}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`),
+ mkLayer(`https://t{0-7}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`)
+ ]}),
+ vector: new ol.layer.Group({ layers: [
+ mkLayer(`https://t{0-7}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`),
+ mkLayer(`https://t{0-7}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`)
+ ], visible: false}),
+ terrain: new ol.layer.Group({ layers: [
+ mkLayer(`https://t{0-7}.tianditu.gov.cn/ter_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ter&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`),
+ mkLayer(`https://t{0-7}.tianditu.gov.cn/cta_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cta&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`)
+ ], visible: false}),
+ hybrid: new ol.layer.Group({ layers: [
+ mkLayer(`https://t{0-7}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`),
+ mkLayer(`https://t{0-7}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`)
+ ], visible: false})
+ };
+
+ this.stationSource = new ol.source.Vector();
+ this.clusterSource = new ol.source.Cluster({ distance: 60, minDistance: 20, source: this.stationSource });
+ this.clusterLayer = new ol.layer.Vector({ source: this.clusterSource, style: (f:any)=> this.createClusterStyle(f) });
+ this.stationLayer = new ol.layer.Vector({ source: this.stationSource, visible: false, style: (f:any)=> this.createStationStyle(f) });
+ this.tileOverlayGroup = new ol.layer.Group({ layers: [], zIndex: 999, visible: true });
+
+ this.map = new ol.Map({ target: 'map', layers: [
+ this.layers.satellite,
+ this.layers.vector,
+ this.layers.terrain,
+ this.layers.hybrid,
+ this.tileOverlayGroup,
+ this.clusterLayer,
+ this.stationLayer
+ ], view: new ol.View({ center: ol.proj.fromLonLat([108, 35]), zoom: 5, minZoom: 3, maxZoom: 18 }) });
+
+ this.map.getView().on('change:resolution', () => {
+ const z = this.map.getView().getZoom();
+ this.updateClusterDistance(z);
+ this.updateLayerVisibility(z);
+ });
+
+ if (this.stations?.length) this.updateStationsOnMap();
+ }
+
+ switchLayer(layerType: string) {
+ const layers = this.layers; if (!layers) return;
+ Object.keys(layers).forEach(key => { if (layers[key].setVisible) layers[key].setVisible(key === layerType); });
+ }
+
+ private updateClusterDistance(zoom: number) {
+ if (!this.clusterSource) return;
+ const distance = zoom < this.CLUSTER_THRESHOLD ? 60 : 20;
+ this.clusterSource.setDistance(distance);
+ }
+
+ private updateLayerVisibility(zoom: number) {
+ if (!this.clusterLayer || !this.stationLayer) return;
+ const showCluster = zoom < this.CLUSTER_THRESHOLD;
+ this.clusterLayer.setVisible(showCluster);
+ this.stationLayer.setVisible(!showCluster);
+ }
+
+ private markerIcon(isOnline: boolean) {
+ const ol: any = (window as any).ol;
+ const src = isOnline ? '/static/images/marker-online.svg' : '/static/images/marker-offline.svg';
+ return new ol.style.Icon({ src, anchor: [0.5,1], anchorXUnits: 'fraction', anchorYUnits: 'fraction', scale: 0.9 });
+ }
+
+ private createStationStyle(feature: any) {
+ const ol: any = (window as any).ol;
+ const last = feature.get('lastUpdate');
+ const online = last ? (new Date(last).getTime() > Date.now() - 5*60*1000) : false;
+ const location = feature.get('location') || '';
+ return new ol.style.Style({
+ image: this.markerIcon(online),
+ text: location ? new ol.style.Text({ text: location, offsetY: -28, fill: new ol.style.Fill({ color: '#111' }), stroke: new ol.style.Stroke({ color: '#fff', width: 3 }), font: '12px sans-serif' }) : undefined
+ });
+ }
+
+ private createClusterStyle(feature: any) {
+ const ol: any = (window as any).ol;
+ const features = feature.get('features') || [];
+ const size = features.length;
+ const zoom = this.map.getView().getZoom();
+ if (zoom < this.CLUSTER_THRESHOLD) {
+ if (size > 1) {
+ const radius = Math.min(16 + size * 0.8, 32);
+ const fontSize = Math.min(11 + size/12, 16);
+ return new ol.style.Style({
+ image: new ol.style.Circle({ radius, fill: new ol.style.Fill({ color: 'rgba(0,123,255,0.8)' }), stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) }),
+ text: new ol.style.Text({ text: String(size), fill: new ol.style.Fill({ color: '#fff' }), font: `bold ${fontSize}px Arial`, offsetY: 1 })
+ });
+ }
+ const f0 = features[0];
+ return this.createStationStyle(f0);
+ }
+ return this.createStationStyle(features[0]);
+ }
+
+ private updateStationsOnMap() {
+ const ol: any = (window as any).ol; if (!ol || !this.stationSource) return;
+ this.stationSource.clear();
+ (this.stations||[]).forEach(s => {
+ if (typeof s.longitude !== 'number' || typeof s.latitude !== 'number') return;
+ const f = new ol.Feature({ geometry: new ol.geom.Point(ol.proj.fromLonLat([s.longitude, s.latitude])), lastUpdate: (s as any).last_update || '', stationId: s.station_id, location: s.location || '' });
+ this.stationSource.addFeature(f);
+ });
+ // click to select
+ if (this.map) {
+ this.map.on('singleclick', async (evt:any) => {
+ const olAny: any = (window as any).ol;
+ const features = this.map.getFeaturesAtPixel(evt.pixel, { layerFilter: (l:any)=> l===this.stationLayer || l===this.clusterLayer });
+ if (!features || features.length===0) return;
+ let f = features[0];
+ const subs = f.get('features');
+ if (subs && subs.length>0) {
+ // zoom in on cluster instead of selecting
+ const view = this.map.getView();
+ const z = view.getZoom();
+ view.animate({ zoom: z + 1, center: evt.coordinate, duration: 200 });
+ return;
+ }
+ const sid = f.get('stationId');
+ const loc = f.get('location') || '';
+ if (!sid) return;
+ const hex = String(sid).slice(-6); const dec = parseInt(hex, 16);
+ if (!isNaN(dec)) { this.decimalId = String(dec); await this.query(); this.scrollToChart(); }
+ });
+ this.map.on('pointermove', (evt:any) => {
+ const features = this.map.getFeaturesAtPixel(evt.pixel, { layerFilter: (l:any)=> l===this.stationLayer || l===this.clusterLayer });
+ const el = this.map.getTargetElement();
+ if (el) el.style.cursor = (features && features.length>0) ? 'pointer' : '';
+ this.showTileTooltip(evt);
+ });
+ }
+ }
+
+ async loadTileTimes(product: 'radar'|'rain') {
+ try {
+ const params = new URLSearchParams({ z: String(this.tileZ), y: String(this.tileY), x: String(this.tileX), limit: '60' });
+ const path = product==='rain' ? '/api/rain/times' : '/api/radar/times';
+ const r = await fetch(`${path}?${params.toString()}`);
+ if (!r.ok) return;
+ const j = await r.json();
+ this.tileTimes = j.times || [];
+ this.tileIndex = 0;
+ this.tileDt = this.tileTimes[0] || '';
+ if (this.tileDt) await this.renderTilesAt(this.tileDt);
+ } catch {}
+ }
+
+ async renderTilesAt(dt: string) {
+ try {
+ const params = new URLSearchParams({ z: String(this.tileZ), dt: dt });
+ const path = this.tileProduct==='rain' ? '/api/rain/tiles_at' : '/api/radar/tiles_at';
+ const r = await fetch(`${path}?${params.toString()}`);
+ if (!r.ok) { this.clearTileOverlays(); return; }
+ const j = await r.json();
+ const tiles = Array.isArray(j.tiles) ? j.tiles : [];
+ if (tiles.length === 0) { this.clearTileOverlays(); return; }
+ await this.renderTilesOnMap(this.tileProduct, tiles);
+ } catch { this.clearTileOverlays(); }
+ }
+
+ clearTileOverlays() {
+ if (!this.tileOverlayGroup) return;
+ const coll = this.tileOverlayGroup.getLayers();
+ if (coll) coll.clear();
+ }
+
+ addImageOverlayFromCanvas(canvas: HTMLCanvasElement, extent4326: [number,number,number,number]) {
+ const ol: any = (window as any).ol; if (!ol || !this.map) return;
+ const proj = this.map.getView().getProjection();
+ const extentProj = ol.proj.transformExtent(extent4326, 'EPSG:4326', proj);
+ const src = new ol.source.ImageStatic({ url: canvas.toDataURL('image/png'), imageExtent: extentProj, projection: proj });
+ const layer = new ol.layer.Image({ source: src, opacity: 0.8, visible: true });
+ this.tileOverlayGroup.getLayers().push(layer);
+ }
+
+ async renderTilesOnMap(product: 'none'|'radar'|'rain', tiles: any[]) {
+ this.clearTileOverlays();
+ const lastList: any[] = [];
+ for (const t of tiles) {
+ const w = t.width, h = t.height; if (!w||!h||!t.values) continue;
+ const canvas = document.createElement('canvas'); canvas.width=w; canvas.height=h;
+ const ctx = canvas.getContext('2d')!; const img = ctx.createImageData(w,h);
+ const radarColors = [[0,0,255],[0,191,255],[0,255,255],[127,255,212],[124,252,0],[173,255,47],[255,255,0],[255,215,0],[255,165,0],[255,140,0],[255,69,0],[255,0,0],[220,20,60],[199,21,133],[139,0,139]];
+ const rainEdges = [0,5,7.5,10,12.5,15,17.5,20,25,30,40,50,75,100, Infinity];
+ const rainColors = [
+ [126,212,121], [126,212,121], [110,200,109], [97,169,97], [81,148,76], [90,158,112],
+ [143,194,254], [92,134,245], [66,87,240], [45,48,214], [26,15,166], [63,22,145], [191,70,148], [213,1,146], [213,1,146]
+ ];
+ for (let row=0; row=rainEdges[idx] && v=radarColors.length) bin=radarColors.length-1;
+ const c = radarColors[bin]; img.data[off]=c[0]; img.data[off+1]=c[1]; img.data[off+2]=c[2]; img.data[off+3]=220;
+ }
+ }
+ }
+ ctx.putImageData(img,0,0);
+ this.addImageOverlayFromCanvas(canvas, [t.west, t.south, t.east, t.north]);
+ lastList.push({ product, meta: { west: t.west, south: t.south, east: t.east, north: t.north, width: w, height: h }, values: t.values });
+ }
+ this.tileLastList = lastList;
+ }
+
+ onProductChange() { this.reloadTileTimesAndShow(); }
+ async reloadTileTimesAndShow() {
+ if (this.tileProduct==='none') { this.clearTileOverlays(); this.tileTimes=[]; this.tileDt=''; return; }
+ await this.loadTileTimes(this.tileProduct);
+ }
+
+ private makeStationId(dec: string): string | null {
+ const n = Number(dec);
+ if (!isFinite(n)) return null;
+ const hex = Math.trunc(n).toString(16).toUpperCase().padStart(6,'0');
+ return `RS485-${hex}`;
+ }
+
+ async query() {
+ const dec = this.decimalId.trim();
+ if (!dec) return;
+ const sid = this.makeStationId(dec);
+ if (!sid) return;
+ const toFmt = (s: string) => s.replace('T',' ') + ':00';
+ const from = toFmt(this.start);
+ const to = toFmt(this.end);
+ this.isLoading = true;
+ try {
+ [this.history, this.forecast] = await Promise.all([
+ this.api.getHistory(dec, from, to, this.interval),
+ this.provider ? this.api.getForecast(sid, from, to, this.provider, 3) : Promise.resolve([])
+ ]);
+ } finally {
+ this.isLoading = false;
+ }
+ this.showPanels = true;
+ this.isMapCollapsed = true;
+ const st = this.stations.find(s => s.station_id === sid);
+ const ol: any = (window as any).ol;
+ if (st && ol && typeof st.longitude === 'number' && typeof st.latitude === 'number' && this.map) {
+ this.map.getView().animate({ center: ol.proj.fromLonLat([st.longitude, st.latitude]), zoom: 11, duration: 400 });
+ }
+ this.selectedLocation = (st && st.location) ? st.location : '';
+ const titleName = st?.name || st?.station_alias || st?.station_id || '';
+ this.selectedTitle = titleName ? `${titleName}${this.selectedLocation ? ` | ${this.selectedLocation}` : ''}` : (this.selectedLocation || '');
+ setTimeout(()=>{ try{ this.map.updateSize(); }catch{} }, 300);
+ this.scrollToChart();
+ }
+
+ onSelectStation(ev: { stationId: string, decimalId: string }) {
+ this.decimalId = ev.decimalId;
+ this.query();
+ }
+
+ prevTile() {
+ if (!this.tileTimes || this.tileTimes.length===0) return;
+ if (this.tileIndex < this.tileTimes.length-1) {
+ this.tileIndex += 1;
+ this.tileDt = this.tileTimes[this.tileIndex];
+ this.renderTilesAt(this.tileDt);
+ }
+ }
+
+ nextTile() {
+ if (!this.tileTimes || this.tileTimes.length===0) return;
+ if (this.tileIndex > 0) {
+ this.tileIndex -= 1;
+ this.tileDt = this.tileTimes[this.tileIndex];
+ this.renderTilesAt(this.tileDt);
+ }
+ }
+
+ toggleMap() {
+ this.isMapCollapsed = !this.isMapCollapsed;
+ setTimeout(()=>{ try{ this.map.updateSize(); }catch{} }, 300);
+ }
+
+ private scrollToChart() {
+ const el = document.getElementById('chartSection');
+ if (el) { try { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} }
+ }
+
+ private showTileTooltip(evt:any) {
+ const tip = document.getElementById('tileValueTooltip');
+ if (!tip || !this.map || !this.tileLastList || this.tileLastList.length===0) { if (tip) tip.style.display='none'; return; }
+ try {
+ const coord = this.map.getEventCoordinate(evt.originalEvent);
+ const lonlat = (window as any).ol.proj.transform(coord, this.map.getView().getProjection(), 'EPSG:4326');
+ const lon = lonlat[0], lat = lonlat[1];
+ let value: number|null = null; let unit = '';
+ for (const it of this.tileLastList) {
+ const { west,south,east,north,width,height } = it.meta;
+ if (lon < west || lon > east || lat < south || lat > north) continue;
+ const px = Math.floor((lon - west) / ((east - west) / width));
+ const py = Math.floor((lat - south) / ((north - south) / height));
+ if (px < 0 || px >= width || py < 0 || py >= height) continue;
+ const v = it.values?.[py]?.[px];
+ if (v != null) { value = Number(v); unit = it.product==='rain' ? 'mm' : 'dBZ'; break; }
+ }
+ if (value == null) { tip.style.display='none'; return; }
+ tip.textContent = `${value.toFixed(1)} ${unit}`;
+ const px = evt.pixel[0] + 12; const py = evt.pixel[1] + 12;
+ tip.style.left = `${px}px`; tip.style.top = `${py}px`; tip.style.display='block';
+ } catch { if (tip) tip.style.display='none'; }
+ }
+}
+
+bootstrapApplication(AppComponent).catch(err => console.error(err));
diff --git a/core/internal/config/config.go b/core/internal/config/config.go
new file mode 100644
index 0000000..01b1dea
--- /dev/null
+++ b/core/internal/config/config.go
@@ -0,0 +1,58 @@
+package config
+
+import (
+ "fmt"
+ "log"
+ "os"
+ legacy "weatherstation/internal/config"
+)
+
+type Config struct {
+ Addr string
+ UIServeDir string
+ TemplateDir string
+ StaticDir string
+ DevEnableCORS bool
+ Legacy *legacy.Config
+}
+
+func Load() Config {
+ lg := legacy.GetConfig()
+
+ port := lg.Server.WebPort
+ if port <= 0 {
+ port = 8080
+ }
+
+ cfg := Config{
+ Addr: fmt.Sprintf(":%d", port),
+ UIServeDir: "core/frontend/dist/ui",
+ TemplateDir: "templates",
+ StaticDir: "static",
+ DevEnableCORS: true,
+ Legacy: lg,
+ }
+
+ if v := os.Getenv("CORE_ADDR"); v != "" {
+ cfg.Addr = v
+ }
+ if v := os.Getenv("CORE_UI_DIR"); v != "" {
+ cfg.UIServeDir = v
+ }
+ if v := os.Getenv("CORE_TEMPLATE_DIR"); v != "" {
+ cfg.TemplateDir = v
+ }
+ if v := os.Getenv("CORE_STATIC_DIR"); v != "" {
+ cfg.StaticDir = v
+ }
+ if v := os.Getenv("CORE_ENABLE_CORS"); v != "" {
+ if v == "0" || v == "false" {
+ cfg.DevEnableCORS = false
+ } else {
+ cfg.DevEnableCORS = true
+ }
+ }
+
+ log.Printf("config: addr=%s ui=%s tpl=%s static=%s cors=%v", cfg.Addr, cfg.UIServeDir, cfg.TemplateDir, cfg.StaticDir, cfg.DevEnableCORS)
+ return cfg
+}
diff --git a/core/internal/data/store.go b/core/internal/data/store.go
new file mode 100644
index 0000000..e0cfe05
--- /dev/null
+++ b/core/internal/data/store.go
@@ -0,0 +1,26 @@
+package data
+
+import (
+ "database/sql"
+ "time"
+ "weatherstation/internal/database"
+ "weatherstation/pkg/types"
+)
+
+func DB() *sql.DB { return database.GetDB() }
+
+func OnlineDevices() int { return database.GetOnlineDevicesCount(DB()) }
+
+func Stations() ([]types.Station, error) { return database.GetStations(DB()) }
+
+func SeriesRaw(stationID string, start, end time.Time) ([]types.WeatherPoint, error) {
+ return database.GetSeriesRaw(DB(), stationID, start, end)
+}
+
+func SeriesFrom10Min(stationID string, start, end time.Time, interval string) ([]types.WeatherPoint, error) {
+ return database.GetSeriesFrom10Min(DB(), stationID, start, end, interval)
+}
+
+func Forecast(stationID string, start, end time.Time, provider string, versions int) ([]types.ForecastPoint, error) {
+ return database.GetForecastData(DB(), stationID, start, end, provider, versions)
+}
diff --git a/core/internal/server/handlers.go b/core/internal/server/handlers.go
new file mode 100644
index 0000000..e3a835f
--- /dev/null
+++ b/core/internal/server/handlers.go
@@ -0,0 +1,130 @@
+package server
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+ "weatherstation/core/internal/data"
+)
+
+import "github.com/gin-gonic/gin"
+
+func handleHealth(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"status": "ok", "ts": time.Now().UTC().Format(time.RFC3339)})
+}
+
+func handleSystemStatus(c *gin.Context) {
+ online := data.OnlineDevices()
+ c.JSON(http.StatusOK, gin.H{
+ "online_devices": online,
+ "server_time": time.Now().Format("2006-01-02 15:04:05"),
+ })
+}
+
+func handleStations(c *gin.Context) {
+ stations, err := data.Stations()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("query stations failed: %v", err)})
+ return
+ }
+ for i := range stations {
+ if len(stations[i].StationID) > 6 {
+ hexID := stations[i].StationID[len(stations[i].StationID)-6:]
+ if decimalID, err := strconv.ParseInt(hexID, 16, 64); err == nil {
+ stations[i].DecimalID = strconv.FormatInt(decimalID, 10)
+ }
+ }
+ }
+ c.JSON(http.StatusOK, stations)
+}
+
+func handleData(c *gin.Context) {
+ dec := c.Query("decimal_id")
+ startTime := c.Query("start_time")
+ endTime := c.Query("end_time")
+ interval := c.DefaultQuery("interval", "1hour")
+
+ decimalNum, err := strconv.ParseInt(dec, 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid decimal_id"})
+ return
+ }
+ hexID := fmt.Sprintf("%06X", decimalNum)
+ stationID := fmt.Sprintf("RS485-%s", hexID)
+
+ loc, _ := time.LoadLocation("Asia/Shanghai")
+ if loc == nil {
+ loc = time.FixedZone("CST", 8*3600)
+ }
+ start, err := time.ParseInLocation("2006-01-02 15:04:05", startTime, loc)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid start_time"})
+ return
+ }
+ end, err := time.ParseInLocation("2006-01-02 15:04:05", endTime, loc)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid end_time"})
+ return
+ }
+
+ var points interface{}
+ if interval == "raw" {
+ points, err = data.SeriesRaw(stationID, start, end)
+ } else {
+ points, err = data.SeriesFrom10Min(stationID, start, end, interval)
+ }
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("query failed: %v", err)})
+ return
+ }
+ c.JSON(http.StatusOK, points)
+}
+
+func handleForecast(c *gin.Context) {
+ stationID := c.Query("station_id")
+ from := c.Query("from")
+ to := c.Query("to")
+ provider := c.Query("provider")
+ versionsStr := c.DefaultQuery("versions", "1")
+ versions, _ := strconv.Atoi(versionsStr)
+ if versions <= 0 {
+ versions = 1
+ }
+
+ if stationID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing station_id"})
+ return
+ }
+
+ loc, _ := time.LoadLocation("Asia/Shanghai")
+ if loc == nil {
+ loc = time.FixedZone("CST", 8*3600)
+ }
+
+ var start, end time.Time
+ var err error
+ if from == "" || to == "" {
+ now := time.Now().In(loc)
+ start = now.Truncate(time.Hour).Add(1 * time.Hour)
+ end = start.Add(3 * time.Hour)
+ } else {
+ start, err = time.ParseInLocation("2006-01-02 15:04:05", from, loc)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid from"})
+ return
+ }
+ end, err = time.ParseInLocation("2006-01-02 15:04:05", to, loc)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid to"})
+ return
+ }
+ }
+
+ points, err := data.Forecast(stationID, start, end, provider, versions)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("query forecast failed: %v", err)})
+ return
+ }
+ c.JSON(http.StatusOK, points)
+}
diff --git a/core/internal/server/radar_handlers.go b/core/internal/server/radar_handlers.go
new file mode 100644
index 0000000..c7cd7ad
--- /dev/null
+++ b/core/internal/server/radar_handlers.go
@@ -0,0 +1,172 @@
+package server
+
+import (
+ "database/sql"
+ "encoding/binary"
+ "net/http"
+ "time"
+ "weatherstation/internal/database"
+
+ "github.com/gin-gonic/gin"
+)
+
+type radarTileRecord struct {
+ DT time.Time
+ Z int
+ Y int
+ X int
+ Width int
+ Height int
+ West float64
+ South float64
+ East float64
+ North float64
+ ResDeg float64
+ Data []byte
+}
+
+type radarTileResponse struct {
+ DT string `json:"dt"`
+ Z int `json:"z"`
+ Y int `json:"y"`
+ X int `json:"x"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ West float64 `json:"west"`
+ South float64 `json:"south"`
+ East float64 `json:"east"`
+ North float64 `json:"north"`
+ ResDeg float64 `json:"res_deg"`
+ Values [][]*float64 `json:"values"`
+}
+
+func handleRadarTimes(c *gin.Context) {
+ z := parseInt(c.Query("z"), 7)
+ y := parseInt(c.Query("y"), 40)
+ x := parseInt(c.Query("x"), 102)
+ fromStr := c.Query("from")
+ toStr := c.Query("to")
+ loc, _ := time.LoadLocation("Asia/Shanghai")
+ if loc == nil {
+ loc = time.FixedZone("CST", 8*3600)
+ }
+
+ var rows *sql.Rows
+ var err error
+ if fromStr != "" && toStr != "" {
+ from, err1 := time.ParseInLocation("2006-01-02 15:04:05", fromStr, loc)
+ to, err2 := time.ParseInLocation("2006-01-02 15:04:05", toStr, loc)
+ if err1 != nil || err2 != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid time range"})
+ return
+ }
+ const qRange = `SELECT dt FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 ORDER BY dt DESC`
+ rows, err = database.GetDB().Query(qRange, z, y, x, from, to)
+ } else {
+ limit := parseInt(c.Query("limit"), 48)
+ const q = `SELECT dt FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 ORDER BY dt DESC LIMIT $4`
+ rows, err = database.GetDB().Query(q, z, y, x, limit)
+ }
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
+ return
+ }
+ defer rows.Close()
+ var times []string
+ for rows.Next() {
+ var dt time.Time
+ if err := rows.Scan(&dt); err == nil {
+ times = append(times, dt.In(loc).Format("2006-01-02 15:04:05"))
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{"times": times})
+}
+
+func handleRadarTilesAt(c *gin.Context) {
+ z := parseInt(c.Query("z"), 7)
+ dtStr := c.Query("dt")
+ if dtStr == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing dt"})
+ return
+ }
+ loc, _ := time.LoadLocation("Asia/Shanghai")
+ if loc == nil {
+ loc = time.FixedZone("CST", 8*3600)
+ }
+ dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid dt"})
+ return
+ }
+
+ const q = `SELECT dt,z,y,x,width,height,west,south,east,north,res_deg,data FROM radar_tiles WHERE z=$1 AND dt=$2 ORDER BY y,x`
+ rows, qerr := database.GetDB().Query(q, z, dt)
+ if qerr != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "db failed"})
+ return
+ }
+ defer rows.Close()
+
+ var tiles []radarTileResponse
+ for rows.Next() {
+ var r radarTileRecord
+ if err := rows.Scan(&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data); err != nil {
+ continue
+ }
+ w, h := r.Width, r.Height
+ if w <= 0 || h <= 0 || len(r.Data) < w*h*2 {
+ continue
+ }
+ vals := make([][]*float64, h)
+ off := 0
+ for row := 0; row < h; row++ {
+ rowVals := make([]*float64, w)
+ for col := 0; col < w; col++ {
+ v := int16(binary.BigEndian.Uint16(r.Data[off : off+2]))
+ off += 2
+ if v >= 32766 {
+ rowVals[col] = nil
+ continue
+ }
+ dbz := float64(v) / 10.0
+ if dbz < 0 {
+ dbz = 0
+ } else if dbz > 75 {
+ dbz = 75
+ }
+ vv := dbz
+ rowVals[col] = &vv
+ }
+ vals[row] = rowVals
+ }
+ tiles = append(tiles, radarTileResponse{DT: r.DT.In(loc).Format("2006-01-02 15:04:05"), Z: r.Z, Y: r.Y, X: r.X, Width: w, Height: h, West: r.West, South: r.South, East: r.East, North: r.North, ResDeg: r.ResDeg, Values: vals})
+ }
+ if len(tiles) == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"tiles": tiles})
+}
+
+func parseInt(s string, def int) int {
+ if s == "" {
+ return def
+ }
+ n := 0
+ sign := 1
+ i := 0
+ if s[0] == '-' || s[0] == '+' {
+ if s[0] == '-' {
+ sign = -1
+ }
+ i = 1
+ }
+ for ; i < len(s); i++ {
+ ch := s[i]
+ if ch < '0' || ch > '9' {
+ return def
+ }
+ n = n*10 + int(ch-'0')
+ }
+ return sign * n
+}
diff --git a/core/internal/server/rain_handlers.go b/core/internal/server/rain_handlers.go
new file mode 100644
index 0000000..10a7548
--- /dev/null
+++ b/core/internal/server/rain_handlers.go
@@ -0,0 +1,152 @@
+package server
+
+import (
+ "database/sql"
+ "encoding/binary"
+ "net/http"
+ "time"
+ "weatherstation/internal/database"
+
+ "github.com/gin-gonic/gin"
+)
+
+type rainTileRecord struct {
+ DT time.Time
+ Z int
+ Y int
+ X int
+ Width int
+ Height int
+ West float64
+ South float64
+ East float64
+ North float64
+ ResDeg float64
+ Data []byte
+}
+
+type rainTileResponse struct {
+ DT string `json:"dt"`
+ Z int `json:"z"`
+ Y int `json:"y"`
+ X int `json:"x"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ West float64 `json:"west"`
+ South float64 `json:"south"`
+ East float64 `json:"east"`
+ North float64 `json:"north"`
+ ResDeg float64 `json:"res_deg"`
+ Values [][]*float64 `json:"values"`
+}
+
+func handleRainTimes(c *gin.Context) {
+ z := parseInt(c.Query("z"), 7)
+ y := parseInt(c.Query("y"), 40)
+ x := parseInt(c.Query("x"), 102)
+ fromStr := c.Query("from")
+ toStr := c.Query("to")
+ loc, _ := time.LoadLocation("Asia/Shanghai")
+ if loc == nil {
+ loc = time.FixedZone("CST", 8*3600)
+ }
+
+ var rows *sql.Rows
+ var err error
+ if fromStr != "" && toStr != "" {
+ from, err1 := time.ParseInLocation("2006-01-02 15:04:05", fromStr, loc)
+ to, err2 := time.ParseInLocation("2006-01-02 15:04:05", toStr, loc)
+ if err1 != nil || err2 != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid time range"})
+ return
+ }
+ const qRange = `SELECT dt FROM rain_tiles WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 ORDER BY dt DESC`
+ rows, err = database.GetDB().Query(qRange, z, y, x, from, to)
+ } else {
+ limit := parseInt(c.Query("limit"), 48)
+ const q = `SELECT dt FROM rain_tiles WHERE z=$1 AND y=$2 AND x=$3 ORDER BY dt DESC LIMIT $4`
+ rows, err = database.GetDB().Query(q, z, y, x, limit)
+ }
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
+ return
+ }
+ defer rows.Close()
+ var times []string
+ for rows.Next() {
+ var dt time.Time
+ if err := rows.Scan(&dt); err == nil {
+ times = append(times, dt.In(loc).Format("2006-01-02 15:04:05"))
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{"times": times})
+}
+
+func handleRainTilesAt(c *gin.Context) {
+ z := parseInt(c.Query("z"), 7)
+ dtStr := c.Query("dt")
+ if dtStr == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing dt"})
+ return
+ }
+ loc, _ := time.LoadLocation("Asia/Shanghai")
+ if loc == nil {
+ loc = time.FixedZone("CST", 8*3600)
+ }
+ dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid dt"})
+ return
+ }
+
+ const q = `SELECT dt,z,y,x,width,height,west,south,east,north,res_deg,data FROM rain_tiles WHERE z=$1 AND dt=$2 ORDER BY y,x`
+ rows, qerr := database.GetDB().Query(q, z, dt)
+ if qerr != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "db failed"})
+ return
+ }
+ defer rows.Close()
+
+ var tiles []rainTileResponse
+ for rows.Next() {
+ var r rainTileRecord
+ if err := rows.Scan(&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data); err != nil {
+ continue
+ }
+ w, h := r.Width, r.Height
+ if w <= 0 || h <= 0 || len(r.Data) < w*h*2 {
+ continue
+ }
+ vals := decodeRain(r.Data, w, h)
+ tiles = append(tiles, rainTileResponse{DT: r.DT.In(loc).Format("2006-01-02 15:04:05"), Z: r.Z, Y: r.Y, X: r.X, Width: w, Height: h, West: r.West, South: r.South, East: r.East, North: r.North, ResDeg: r.ResDeg, Values: vals})
+ }
+ if len(tiles) == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"tiles": tiles})
+}
+
+func decodeRain(buf []byte, w, h int) [][]*float64 {
+ vals := make([][]*float64, h)
+ off := 0
+ for row := 0; row < h; row++ {
+ rowVals := make([]*float64, w)
+ for col := 0; col < w; col++ {
+ v := int16(binary.BigEndian.Uint16(buf[off : off+2]))
+ off += 2
+ if v >= 32766 {
+ rowVals[col] = nil
+ continue
+ }
+ mm := float64(v) / 10.0
+ if mm < 0 {
+ mm = 0
+ }
+ vv := mm
+ rowVals[col] = &vv
+ }
+ vals[row] = rowVals
+ }
+ return vals
+}
diff --git a/core/internal/server/router.go b/core/internal/server/router.go
new file mode 100644
index 0000000..be93d53
--- /dev/null
+++ b/core/internal/server/router.go
@@ -0,0 +1,73 @@
+package server
+
+import (
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Options struct {
+ UIServeDir string
+ TemplateDir string
+ StaticDir string
+ EnableCORS bool
+}
+
+func NewRouter(opts Options) *gin.Engine {
+ r := gin.New()
+ r.Use(gin.Logger())
+ r.Use(gin.Recovery())
+
+ if opts.EnableCORS {
+ r.Use(func(c *gin.Context) {
+ c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
+ c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
+ c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ if c.Request.Method == http.MethodOptions {
+ c.AbortWithStatus(http.StatusNoContent)
+ return
+ }
+ c.Next()
+ })
+ }
+
+ if strings.TrimSpace(opts.StaticDir) != "" {
+ r.Static("/static", opts.StaticDir)
+ }
+ // Do not render legacy templates; keep core frontend under /ui
+
+ api := r.Group("/api")
+ {
+ api.GET("/health", handleHealth)
+ api.GET("/system/status", handleSystemStatus)
+ api.GET("/stations", handleStations)
+ api.GET("/data", handleData)
+ api.GET("/forecast", handleForecast)
+ api.GET("/radar/times", handleRadarTimes)
+ api.GET("/radar/tiles_at", handleRadarTilesAt)
+ api.GET("/rain/times", handleRainTimes)
+ api.GET("/rain/tiles_at", handleRainTilesAt)
+ }
+
+ if strings.TrimSpace(opts.UIServeDir) != "" {
+ // Serve built Angular assets under /ui for static files
+ r.Static("/ui", opts.UIServeDir)
+ // Serve Angular index.html at root
+ r.GET("/", func(c *gin.Context) {
+ c.File(filepath.Join(opts.UIServeDir, "index.html"))
+ })
+ // Optional SPA fallback: serve index.html for non-API, non-static routes
+ r.NoRoute(func(c *gin.Context) {
+ p := c.Request.URL.Path
+ if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/static/") {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ c.File(filepath.Join(opts.UIServeDir, "index.html"))
+ })
+ }
+
+ return r
+}
diff --git a/static/js/weather-chart.js b/static/js/weather-chart.js
index a2bd91e..96690b7 100644
--- a/static/js/weather-chart.js
+++ b/static/js/weather-chart.js
@@ -84,48 +84,49 @@ const WeatherChart = {
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)}%`;
+ // 计算降水分类准确率(四档: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 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));
+ 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];
@@ -133,21 +134,21 @@ const WeatherChart = {
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}`
- );
+ 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}`);
- console.log(`+2h: ${r2.correct}/${r2.total}`);
- console.log(`+3h: ${r3.correct}/${r3.total}`);
- console.groupEnd();
+ 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) : '--';