feat: 新增 angular 框架
This commit is contained in:
parent
59472de103
commit
d879c76d07
29
core/cmd/core-api/main.go
Normal file
29
core/cmd/core-api/main.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
14
core/cmd/core-bigscreen/main.go
Normal file
14
core/cmd/core-bigscreen/main.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,13 @@
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [],
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "../../static",
|
||||
"output": "static"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
core/frontend/proxy.conf.json
Normal file
14
core/frontend/proxy.conf.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,103 @@
|
||||
<div style="font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans, 'Apple Color Emoji', 'Segoe UI Emoji'; padding: 24px;">
|
||||
<h1>Hello World</h1>
|
||||
<app-header #hdr [onlineDevices]="onlineDevices" [stations]="stations" (selectStation)="onSelectStation($event)"></app-header>
|
||||
|
||||
<div class="border-b" style="border-color: #ddd;margin-bottom: 1em;">
|
||||
<div class="content-narrow mx-auto" style="max-width:1200px;">
|
||||
<h1 class="text-xl md:text-3xl font-semibold" style="margin:0.8em 0; padding:12px 16px;">英卓气象站</h1>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="content-narrow mx-auto px-4" style="max-width:1200px; margin-bottom:3em;">
|
||||
<div *ngIf="isLoading" style="position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,0.2);display:flex;align-items:center;justify-content:center;">
|
||||
<div class="animate-spin" style="height:40px;width:40px;border:4px solid #e5e7eb;border-top-color:#2563eb;border-radius:9999px;"></div>
|
||||
</div>
|
||||
<div class="system-info bg-gray-100 p-3 mb-5 rounded text-sm border" style="border-color:#ddd;">
|
||||
<strong>在线设备: </strong> {{onlineDevices}} 个 |
|
||||
<strong>总设备: </strong>
|
||||
<a href="#" class="text-blue-600 hover:text-blue-700 underline-offset-2" (click)="$event.preventDefault(); hdr.open()">{{ wh65lpCount }} 个</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border rounded p-3 mb-4" style="border-color:#ddd;">
|
||||
<!-- 第一行:站点编号 / 地图类型 / 预报源 / 图例 -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||||
<label class="text-sm text-gray-600">站点编号</label>
|
||||
<input class="px-2 py-1 border rounded w-32 font-mono text-sm" [(ngModel)]="decimalId" placeholder="十进制ID" />
|
||||
|
||||
<label class="text-sm text-gray-600">地图类型</label>
|
||||
<select class="px-2 py-1 border rounded text-sm" [(ngModel)]="mapType" (change)="switchLayer(mapType)">
|
||||
<option value="satellite">卫星图</option>
|
||||
<option value="vector">矢量图</option>
|
||||
<option value="terrain">地形图</option>
|
||||
<option value="hybrid">混合地形图</option>
|
||||
</select>
|
||||
|
||||
<label class="text-sm text-gray-600">预报源</label>
|
||||
<select class="px-2 py-1 border rounded text-sm" [(ngModel)]="provider">
|
||||
<option value="">不显示预报</option>
|
||||
<option value="imdroid_mix">英卓 V4</option>
|
||||
<option value="open-meteo">英卓 V3</option>
|
||||
<option value="caiyun">英卓 V2</option>
|
||||
<option value="imdroid">英卓 V1</option>
|
||||
</select>
|
||||
|
||||
<label class="text-sm text-gray-600">图例</label>
|
||||
<select class="px-2 py-1 border rounded text-sm" [(ngModel)]="legendMode">
|
||||
<option value="combo_standard">综合</option>
|
||||
<option value="verify_all">全部对比</option>
|
||||
<option value="temp_compare">温度对比</option>
|
||||
<option value="hum_compare">湿度对比</option>
|
||||
<option value="rain_all">降水(+1/+2/+3h)</option>
|
||||
<option value="pressure_compare">气压对比</option>
|
||||
<option value="wind_compare">风速对比</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:数据粒度 / 开始 / 结束 / 查询按钮 -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||||
<label class="text-sm text-gray-600">数据粒度</label>
|
||||
<select class="px-2 py-1 border rounded text-sm" [(ngModel)]="interval">
|
||||
<option value="raw">原始(16s)</option>
|
||||
<option value="10min">10分钟</option>
|
||||
<option value="30min">30分钟</option>
|
||||
<option value="1hour">1小时</option>
|
||||
</select>
|
||||
|
||||
<label class="text-sm text-gray-600">开始</label>
|
||||
<input type="datetime-local" class="px-2 py-1 border rounded text-sm" [(ngModel)]="start" />
|
||||
<label class="text-sm text-gray-600">结束</label>
|
||||
<input type="datetime-local" class="px-2 py-1 border rounded text-sm" [(ngModel)]="end" />
|
||||
<button class="bg-blue-600 text-white px-3 py-1 rounded text-sm" (click)="query()">查看历史数据</button>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:叠加显示 / 时间选择 / 上一/下一时次 / 计数 -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="text-sm text-gray-600">叠加显示</label>
|
||||
<select class="px-2 py-1 border rounded text-sm" [(ngModel)]="tileProduct" (change)="onProductChange()">
|
||||
<option value="none">不显示</option>
|
||||
<option value="radar">水汽含量</option>
|
||||
<option value="rain">1h 实际降雨</option>
|
||||
</select>
|
||||
|
||||
<label class="text-sm text-gray-600">时间</label>
|
||||
<select class="px-2 py-1 border rounded text-sm min-w-[220px]" [(ngModel)]="tileDt" (change)="renderTilesAt(tileDt)">
|
||||
<option [ngValue]="''">请选择时间</option>
|
||||
<option *ngFor="let t of tileTimes" [ngValue]="t">{{t}}</option>
|
||||
</select>
|
||||
<button class="px-2 py-1 text-sm border rounded bg-white" (click)="prevTile()">上一时次</button>
|
||||
<span class="text-xs text-gray-800">共{{tileTimes.length}}条,第{{tileIndex>=0? (tileIndex+1):0}}条</span>
|
||||
<button class="px-2 py-1 text-sm border rounded bg-white" (click)="nextTile()">下一时次</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mapContainer" class="rounded border mb-4" [ngClass]="{ 'collapsed': isMapCollapsed }" [style.borderColor]="'#ddd'" style="position:relative; overflow:hidden;" [style.height]="isMapCollapsed ? '38vh' : '60vh'">
|
||||
<div id="map" style="width:100%; height:100%;"></div>
|
||||
<button class="map-toggle-btn bg-blue-600 hover:bg-blue-700 text-white" style="position:absolute;top:10px;right:10px;z-index:1001;border-radius:4px;padding:5px 10px;font-size:12px;font-weight:bold;" (click)="toggleMap()">{{ isMapCollapsed ? '展开地图' : '折叠地图' }}</button>
|
||||
<div id="tileValueTooltip" style="position:absolute;pointer-events:none;z-index:1003;display:none;background:rgba(0,0,0,0.65);color:#fff;font-size:12px;padding:4px 6px;border-radius:4px;"></div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="showPanels" id="chartSection" class="border rounded p-3 mb-4" style="border-color:#ddd;">
|
||||
<div class="font-bold mb-5 mt-5 text-center">{{ selectedTitle }}</div>
|
||||
<chart-panel [history]="history" [forecast]="forecast" [legendMode]="legendMode"></chart-panel>
|
||||
</div>
|
||||
<table-panel *ngIf="showPanels" [history]="history" [forecast]="forecast" [showPastForecast]="showPastForecast" [endDate]="end"></table-panel>
|
||||
</div>
|
||||
|
||||
47
core/frontend/src/app/api.service.ts
Normal file
47
core/frontend/src/app/api.service.ts
Normal file
@ -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<any[]> {
|
||||
try { const r = await fetch('/api/stations'); return await r.json(); } catch { return []; }
|
||||
}
|
||||
|
||||
async getHistory(decimalId: string, from: string, to: string, interval: string): Promise<WeatherPoint[]> {
|
||||
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<ForecastPoint[]> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
13
core/frontend/src/app/chart-panel.component.html
Normal file
13
core/frontend/src/app/chart-panel.component.html
Normal file
@ -0,0 +1,13 @@
|
||||
<div class="chart-container show">
|
||||
<div class="station-info-title" style="text-align:right;margin-bottom:8px">
|
||||
<div #accPanel id="accuracyPanel" class="accuracy-panel" style="display:none;font-size:12px;color:#374151;white-space:nowrap;">
|
||||
<span class="item"><span class="label">+1h:</span><span #accH1 id="accH1" class="value">--</span></span>
|
||||
<span class="item" style="margin-left:8px"><span class="label"> +2h:</span><span #accH2 id="accH2" class="value">--</span></span>
|
||||
<span class="item" style="margin-left:8px"><span class="label"> +3h:</span><span #accH3 id="accH3" class="value">--</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-wrapper" style="height:500px">
|
||||
<canvas #canvas id="combinedChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
215
core/frontend/src/app/chart-panel.component.ts
Normal file
215
core/frontend/src/app/chart-panel.component.ts
Normal file
@ -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<HTMLCanvasElement>;
|
||||
@ViewChild('accPanel', { static: true }) accPanel!: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('accH1', { static: true }) accH1!: ElementRef<HTMLSpanElement>;
|
||||
@ViewChild('accH2', { static: true }) accH2!: ElementRef<HTMLSpanElement>;
|
||||
@ViewChild('accH3', { static: true }) accH3!: ElementRef<HTMLSpanElement>;
|
||||
|
||||
private chart: any;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
const historyData = Array.isArray(this.history) ? this.history : [];
|
||||
const forecastData = Array.isArray(this.forecast) ? this.forecast : [];
|
||||
if (historyData.length === 0 && forecastData.length === 0) { this.destroy(); return; }
|
||||
|
||||
const labels = [...new Set([
|
||||
...historyData.map(x => x.date_time),
|
||||
...forecastData.map(x => x.date_time)
|
||||
])].sort();
|
||||
|
||||
const pickSeries = (arr: any[], key: string) => labels.map(l => {
|
||||
const it = arr.find(d => d.date_time === l); return it ? (it as any)[key] ?? null : null;
|
||||
});
|
||||
|
||||
const hTemp = pickSeries(historyData, 'temperature');
|
||||
const hHum = pickSeries(historyData, 'humidity');
|
||||
const hPres = pickSeries(historyData, 'pressure');
|
||||
const hWind = pickSeries(historyData, 'wind_speed');
|
||||
const hRain = pickSeries(historyData, 'rainfall');
|
||||
const hRainTotal = pickSeries(historyData, 'rain_total');
|
||||
|
||||
const byTime = new Map<string, any>();
|
||||
forecastData.forEach(fp => {
|
||||
const t = fp.date_time; if (!byTime.has(t)) byTime.set(t, {});
|
||||
const lead = typeof fp.lead_hours === 'number' ? fp.lead_hours : null;
|
||||
if (lead !== null && lead >= 0 && lead <= 3) {
|
||||
const bucket = byTime.get(t);
|
||||
if (bucket[lead] == null) bucket[lead] = fp; // assume DESC issued_at
|
||||
}
|
||||
});
|
||||
|
||||
const getAtLead = (l: string, field: string, lead: number) => {
|
||||
const b = byTime.get(l); if (!b || !b[lead]) return null; const v = b[lead][field as keyof ForecastPoint];
|
||||
return v == null ? null : (v as number);
|
||||
};
|
||||
|
||||
const rainH1 = labels.map(l => getAtLead(l, 'rainfall', 1));
|
||||
const rainH2 = labels.map(l => getAtLead(l, 'rainfall', 2));
|
||||
const rainH3 = labels.map(l => getAtLead(l, 'rainfall', 3));
|
||||
|
||||
const pickNearest = (l: string, field: keyof ForecastPoint) => {
|
||||
const b = byTime.get(l); if (!b) return null;
|
||||
for (const k of [0,1,2,3]) { if (b[k] && b[k][field] != null) return b[k][field] as number; }
|
||||
return null;
|
||||
};
|
||||
const fTempN = labels.map(l => pickNearest(l, 'temperature'));
|
||||
const fHumN = labels.map(l => pickNearest(l, 'humidity'));
|
||||
const fPresN = labels.map(l => pickNearest(l, 'pressure'));
|
||||
const fWindN = labels.map(l => pickNearest(l, 'wind_speed'));
|
||||
|
||||
this.destroy();
|
||||
// @ts-ignore
|
||||
const Chart = (window as any).Chart;
|
||||
if (!Chart || !this.canvas?.nativeElement) return;
|
||||
|
||||
const datasets: any[] = [
|
||||
{ label: '温度 (°C) - 实测', seriesKey: 'temp_actual', data: hTemp, borderColor: 'rgb(255,99,132)', backgroundColor: 'rgba(255,99,132,0.1)', yAxisID: 'y-temperature', tension: 0.4, spanGaps: false },
|
||||
{ label: '湿度 (%) - 实测', seriesKey: 'hum_actual', data: hHum, borderColor: 'rgb(54,162,235)', backgroundColor: 'rgba(54,162,235,0.1)', yAxisID: 'y-humidity', tension: 0.4, hidden: true, spanGaps: false },
|
||||
{ label: '大气压 (hPa) - 实测', seriesKey: 'pressure_actual', data: hPres, borderColor: 'rgb(153,102,255)', backgroundColor: 'rgba(153,102,255,0.1)', yAxisID: 'y-pressure', tension: 0.4, hidden: true, spanGaps: false },
|
||||
{ label: '风速 (m/s) - 实测', seriesKey: 'wind_actual', data: hWind, borderColor: 'rgb(75,192,192)', backgroundColor: 'rgba(75,192,192,0.1)', yAxisID: 'y-wind', tension: 0.4, hidden: true, spanGaps: false },
|
||||
{ label: '雨量 (mm) - 实测', seriesKey: 'rain_actual', data: hRain, type: 'bar', backgroundColor: 'rgba(54,162,235,0.6)', borderColor: 'rgb(54,162,235)', yAxisID: 'y-rainfall' },
|
||||
{ label: '累计雨量 (mm) - 实测', seriesKey: 'rain_total', data: hRainTotal, borderColor: 'rgb(75,192,192)', backgroundColor: 'rgba(75,192,192,0.1)', yAxisID: 'y-rainfall', tension: 0.2, spanGaps: false, pointRadius: 0, hidden: true }
|
||||
];
|
||||
if (forecastData.length > 0) {
|
||||
datasets.push(
|
||||
{ label: '雨量 (mm) - 预报 (+1h)', seriesKey: 'rain_fcst_h1', data: rainH1, type: 'bar', backgroundColor: 'rgba(255,99,71,0.55)', borderColor: 'rgb(255,99,71)', yAxisID: 'y-rainfall' },
|
||||
{ label: '雨量 (mm) - 预报 (+2h)', seriesKey: 'rain_fcst_h2', data: rainH2, type: 'bar', backgroundColor: 'rgba(255,205,86,0.55)', borderColor: 'rgb(255,205,86)', yAxisID: 'y-rainfall' },
|
||||
{ label: '雨量 (mm) - 预报 (+3h)', seriesKey: 'rain_fcst_h3', data: rainH3, type: 'bar', backgroundColor: 'rgba(76,175,80,0.55)', borderColor: 'rgb(76,175,80)', yAxisID: 'y-rainfall' },
|
||||
{ label: '温度 (°C) - 预报', seriesKey: 'temp_fcst', data: fTempN, borderColor: 'rgb(255,159,64)', backgroundColor: 'rgba(255,159,64,0.1)', borderDash: [5,5], yAxisID: 'y-temperature', tension: 0.4, spanGaps: false, hidden: true },
|
||||
{ label: '湿度 (%) - 预报', seriesKey: 'hum_fcst', data: fHumN, borderColor: 'rgb(54,162,235)', backgroundColor: 'rgba(54,162,235,0.1)', borderDash: [5,5], yAxisID: 'y-humidity', tension: 0.4, hidden: true, spanGaps: false },
|
||||
{ label: '大气压 (hPa) - 预报', seriesKey: 'pressure_fcst', data: fPresN, borderColor: 'rgb(153,102,255)', backgroundColor: 'rgba(153,102,255,0.1)', borderDash: [5,5], yAxisID: 'y-pressure', tension: 0.4, hidden: true, spanGaps: false },
|
||||
{ label: '风速 (m/s) - 预报', seriesKey: 'wind_fcst', data: fWindN, borderColor: 'rgb(75,192,192)', backgroundColor: 'rgba(75,192,192,0.1)', borderDash: [5,5], yAxisID: 'y-wind', tension: 0.4, hidden: true, spanGaps: false }
|
||||
);
|
||||
}
|
||||
|
||||
const totalLabels = labels.length;
|
||||
const tickStep = Math.max(1, Math.ceil(totalLabels / 10));
|
||||
this.chart = new Chart(this.canvas.nativeElement.getContext('2d'), {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
layout: { padding: { top: 12, right: 16, bottom: 12, left: 16 } },
|
||||
plugins: { legend: { display: true, position: 'top', align: 'center', labels: { padding: 16 } } },
|
||||
scales: {
|
||||
x: { type: 'category', ticks: {
|
||||
autoSkip: false, maxRotation: 0, minRotation: 0,
|
||||
callback: function(value: any, index: number) {
|
||||
if (index % tickStep !== 0) return '';
|
||||
const labels = (this as any).chart?.data?.labels || [];
|
||||
const raw = labels[index] || '';
|
||||
return (typeof raw === 'string' && raw.length >= 13) ? raw.substring(5, 13) : raw;
|
||||
}
|
||||
}, grid: { display: true, drawOnChartArea: true } },
|
||||
'y-temperature': { type: 'linear', display: true, position: 'right', title: { display: true, text: '温度 (°C)' } },
|
||||
'y-humidity': { type: 'linear', display: true, position: 'right', title: { display: true, text: '湿度 (%)' }, grid: { drawOnChartArea: false }, min: 0, max: 100 },
|
||||
'y-pressure': { type: 'linear', display: true, position: 'left', title: { display: true, text: '大气压 (hPa)' }, grid: { drawOnChartArea: false } },
|
||||
'y-wind': { type: 'linear', display: true, position: 'left', title: { display: true, text: '风速 (m/s)' }, grid: { drawOnChartArea: false }, beginAtZero: true },
|
||||
'y-rainfall': { type: 'linear', display: true, position: 'right', title: { display: true, text: '雨量 (mm)' }, grid: { drawOnChartArea: false }, beginAtZero: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.applyLegendMode(this.legendMode);
|
||||
this.updateAccuracyPanel(labels, hRain, rainH1, rainH2, rainH3);
|
||||
}
|
||||
|
||||
private applyLegendMode(mode: string) {
|
||||
if (!this.chart) return;
|
||||
const map = new Map<string, any>();
|
||||
this.chart.data.datasets.forEach((ds: any) => { if (ds.seriesKey) map.set(ds.seriesKey, ds); });
|
||||
const setVisible = (keys: string[]) => {
|
||||
const all = ['temp_actual','hum_actual','rain_actual','rain_total','temp_fcst','hum_fcst','pressure_actual','pressure_fcst','wind_actual','wind_fcst','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3'];
|
||||
all.forEach(k => { const ds = map.get(k); if (ds) ds.hidden = true; });
|
||||
keys.forEach(k => { const ds = map.get(k); if (ds) ds.hidden = false; });
|
||||
};
|
||||
switch (mode) {
|
||||
case 'verify_all': setVisible(['temp_actual','temp_fcst','hum_actual','hum_fcst','pressure_actual','pressure_fcst','wind_actual','wind_fcst','rain_actual','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']); break;
|
||||
case 'temp_compare': setVisible(['temp_actual','temp_fcst']); break;
|
||||
case 'hum_compare': setVisible(['hum_actual','hum_fcst']); break;
|
||||
case 'rain_all': setVisible(['rain_actual','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']); break;
|
||||
case 'pressure_compare': setVisible(['pressure_actual','pressure_fcst']); break;
|
||||
case 'wind_compare': setVisible(['wind_actual','wind_fcst']); break;
|
||||
case 'combo_standard':
|
||||
default: setVisible(['temp_actual','temp_fcst','rain_actual','rain_fcst_h1','rain_fcst_h2','rain_fcst_h3']);
|
||||
}
|
||||
this.chart.update();
|
||||
}
|
||||
|
||||
private updateAccuracyPanel(labels: string[], historyRain: (number|null)[], h1: (number|null)[], h2: (number|null)[], h3: (number|null)[]) {
|
||||
const panel = this.accPanel?.nativeElement; if (!panel) return;
|
||||
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; } }
|
||||
}
|
||||
25
core/frontend/src/app/header.component.html
Normal file
25
core/frontend/src/app/header.component.html
Normal file
@ -0,0 +1,25 @@
|
||||
<div class="device-modal" *ngIf="modalOpen" (click)="close()" style="position:fixed;inset:0;background:rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;z-index:2000">
|
||||
<div class="device-modal-content" (click)="$event.stopPropagation()" style="background:#fff;width:80%;max-width:640px;border-radius:8px;box-shadow:0 10px 25px rgba(0,0,0,.15);display:flex;flex-direction:column;max-height:70vh;">
|
||||
<div class="device-list-header" style="padding:12px 16px;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;">
|
||||
<div class="text-sm">设备列表</div>
|
||||
<button (click)="close()" class="text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div class="device-list" style="flex:1;overflow:auto;">
|
||||
<button class="device-item" *ngFor="let s of pageItems" (click)="choose(s)" style="padding:10px 16px;border-bottom:1px solid #f5f5f5;display:flex;justify-content:space-between;align-items:center;width:100%;text-align:left;">
|
||||
<div>
|
||||
<div class="text-sm font-mono">{{s.station_id}}</div>
|
||||
<div class="text-xs text-gray-500">十进制: {{decId(s)}}</div>
|
||||
<div class="text-xs text-gray-500" *ngIf="s.location">位置: {{s.location}}</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">{{s.device_type}}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="device-list-footer" style="padding:10px;border-top:1px solid #eee;display:flex;justify-content:space-between;align-items:center;">
|
||||
<div class="pagination text-xs text-gray-600">第 {{currentPage}} 页,共 {{totalPages}} 页</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="px-2 py-1 border rounded" (click)="prev()" [disabled]="currentPage<=1">上一页</button>
|
||||
<button class="px-2 py-1 border rounded" (click)="next()" [disabled]="currentPage>=totalPages">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
46
core/frontend/src/app/header.component.ts
Normal file
46
core/frontend/src/app/header.component.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
36
core/frontend/src/app/table-panel.component.html
Normal file
36
core/frontend/src/app/table-panel.component.html
Normal file
@ -0,0 +1,36 @@
|
||||
<div class="table-container show" style="margin-top:16px; border:1px solid #ddd; border-radius:4px; background:#fff;">
|
||||
<div style="padding:8px 12px; font-size:12px; color:#666; display:flex; align-items:center; gap:8px;">
|
||||
<label style="display:flex; align-items:center; gap:6px;">
|
||||
<input type="checkbox" [(ngModel)]="showPastForecast" (ngModelChange)="build()"> 显示历史预报
|
||||
</label>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="min-w-full text-sm text-center" style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th *ngFor="let h of headers" style="border:1px solid #ddd; padding:12px 8px; background:#f8f9fa; font-weight:bold;">{{h}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let r of rows" [style.background-color]="r.isForecast ? 'rgba(255,165,0,0.08)' : ''">
|
||||
<td style="border:1px solid #ddd; padding:12px 8px;">
|
||||
<div>
|
||||
{{ r.date_time }}
|
||||
<span *ngIf="r.isForecast" class="text-xs text-orange-500">[预报]</span>
|
||||
</div>
|
||||
<div *ngIf="r.isForecast" class="text-xs text-gray-500">(发布: {{ r.issued_hhmm || '-' }}{{ r.lead_hours!=null ? ' +' + r.lead_hours + 'h' : '' }})</div>
|
||||
</td>
|
||||
<td style="border:1px solid #ddd; padding:12px 8px;">{{r.temperature}}</td>
|
||||
<td style="border:1px solid #ddd; padding:12px 8px;">{{r.humidity}}</td>
|
||||
<td style="border:1px solid #ddd; padding:12px 8px;">{{r.pressure}}</td>
|
||||
<td style="border:1px solid #ddd; padding:12px 8px;">{{r.wind_speed}}</td>
|
||||
<td style="border:1px solid #ddd; padding:12px 8px;">{{r.wind_direction}}</td>
|
||||
<td style="border:1px solid #ddd; padding:12px 8px;">{{r.rainfall}}</td>
|
||||
<td *ngIf="headers.includes('降水概率 (%)')" style="border:1px solid #ddd; padding:12px 8px;">{{r.precip_prob ?? '-'}}</td>
|
||||
<td style="border:1px solid #ddd; padding:12px 8px;">{{r.light}}</td>
|
||||
<td style="border:1px solid #ddd; padding:12px 8px;">{{r.uv}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
66
core/frontend/src/app/table-panel.component.ts
Normal file
66
core/frontend/src/app/table-panel.component.ts
Normal file
@ -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)
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,15 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>WeatherStation UI</title>
|
||||
<title>英卓气象站</title>
|
||||
<base href="/ui/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/css/ol.css">
|
||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<app-root>Loading...</app-root>
|
||||
<script src="/static/js/chart.js"></script>
|
||||
<script src="/static/js/ol.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
418
core/frontend/src/main.ts
Normal file
418
core/frontend/src/main.ts
Normal file
@ -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<h; row++) {
|
||||
const srcRow = t.values[row] as (number|null)[];
|
||||
const dstRow = (h - 1 - row);
|
||||
for (let col=0; col<w; col++) {
|
||||
const v = srcRow[col];
|
||||
const off = (dstRow*w + col)*4;
|
||||
if (v==null || v===0) { img.data[off+3]=0; continue; }
|
||||
if (product==='rain') {
|
||||
let idx=0; while(idx<rainEdges.length-1 && !(v>=rainEdges[idx] && v<rainEdges[idx+1])) idx++;
|
||||
const c = rainColors[Math.min(idx, rainColors.length-1)]; img.data[off]=c[0]; img.data[off+1]=c[1]; img.data[off+2]=c[2]; img.data[off+3]=220;
|
||||
} else {
|
||||
let bin = Math.floor(Math.max(0, Math.min(75, v))/5); if (bin>=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));
|
||||
58
core/internal/config/config.go
Normal file
58
core/internal/config/config.go
Normal file
@ -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
|
||||
}
|
||||
26
core/internal/data/store.go
Normal file
26
core/internal/data/store.go
Normal file
@ -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)
|
||||
}
|
||||
130
core/internal/server/handlers.go
Normal file
130
core/internal/server/handlers.go
Normal file
@ -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)
|
||||
}
|
||||
172
core/internal/server/radar_handlers.go
Normal file
172
core/internal/server/radar_handlers.go
Normal file
@ -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
|
||||
}
|
||||
152
core/internal/server/rain_handlers.go
Normal file
152
core/internal/server/rain_handlers.go
Normal file
@ -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
|
||||
}
|
||||
73
core/internal/server/router.go
Normal file
73
core/internal/server/router.go
Normal file
@ -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
|
||||
}
|
||||
@ -84,47 +84,48 @@ const WeatherChart = {
|
||||
|
||||
if (this.chart) this.chart.destroy();
|
||||
|
||||
// 计算降水分类准确率(+1h/+2h/+3h)
|
||||
// 计算降水分类准确率(四档:0mm、(0,5]、(5,10]、>10),分别统计 +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)}%`;
|
||||
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,∞)'));
|
||||
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];
|
||||
@ -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) : '--';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user