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 }} 个 +
+ +
+ +
+ + + + + + + + + + + +
+ + +
+ + + + + + + + +
+ + +
+ + + + + + + 共{{tileTimes.length}}条,第{{tileIndex>=0? (tileIndex+1):0}}条 + +
+
+ +
+
+ + +
+ +
+
{{ 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 @@ +
+
+ +
+
+ +
+
+ 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) : '--';