feat: angular 重构前端页面
This commit is contained in:
parent
9b40bde332
commit
13d24a1322
@ -13,10 +13,11 @@ func main() {
|
||||
cfg := config.Load()
|
||||
_ = data.DB()
|
||||
r := server.NewRouter(server.Options{
|
||||
UIServeDir: cfg.UIServeDir,
|
||||
TemplateDir: cfg.TemplateDir,
|
||||
StaticDir: cfg.StaticDir,
|
||||
EnableCORS: cfg.DevEnableCORS,
|
||||
UIServeDir: cfg.UIServeDir,
|
||||
BigscreenDir: cfg.BigscreenDir,
|
||||
TemplateDir: cfg.TemplateDir,
|
||||
StaticDir: cfg.StaticDir,
|
||||
EnableCORS: cfg.DevEnableCORS,
|
||||
})
|
||||
|
||||
addr := cfg.Addr
|
||||
|
||||
@ -65,6 +65,69 @@
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weatherstation-bigscreen": {
|
||||
"projectType": "application",
|
||||
"root": "bigscreen",
|
||||
"sourceRoot": "bigscreen/src",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/bigscreen",
|
||||
"index": "bigscreen/src/index.html",
|
||||
"main": "bigscreen/src/main.ts",
|
||||
"polyfills": [
|
||||
"bigscreen/src/polyfills.ts"
|
||||
],
|
||||
"tsConfig": "bigscreen/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "../../static",
|
||||
"output": "static"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"bigscreen/src/styles.css"
|
||||
],
|
||||
"scripts": [],
|
||||
"baseHref": "/bigscreen/"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "weatherstation-bigscreen:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "weatherstation-bigscreen:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "weatherstation-bigscreen:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
90
core/frontend/bigscreen/src/app/app.component.css
Normal file
90
core/frontend/bigscreen/src/app/app.component.css
Normal file
@ -0,0 +1,90 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:host ::ng-deep chart-panel .chart-container {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:host ::ng-deep chart-panel .station-info-title {
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
margin-bottom: 0.25rem !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep chart-panel .accuracy-panel {
|
||||
display: inline-flex !important;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8125rem !important;
|
||||
color: #a9bfe6 !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep chart-panel .accuracy-panel .label {
|
||||
color: #90a7d4 !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep chart-panel .accuracy-panel .value {
|
||||
color: #ffffff !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:host ::ng-deep chart-panel .chart-wrapper {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep chart-panel canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep table-panel .table-container {
|
||||
margin-top: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--color-fg) !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: 100% !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep table-panel .table-container > div:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep table-panel .table-container > div:last-child {
|
||||
flex: 1 !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: auto !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep table-panel table {
|
||||
color: var(--color-fg) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep table-panel thead th {
|
||||
border: none !important;
|
||||
background: var(--table-head-bg) !important;
|
||||
color: var(--table-head-fg) !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep table-panel tbody td {
|
||||
border: none !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
color: #dfe9ff !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep table-panel tbody tr:nth-child(even) {
|
||||
background: var(--table-row-alt) !important;
|
||||
}
|
||||
99
core/frontend/bigscreen/src/app/app.component.html
Normal file
99
core/frontend/bigscreen/src/app/app.component.html
Normal file
@ -0,0 +1,99 @@
|
||||
<div
|
||||
class="screen"
|
||||
[class.alert-on]="alertLevel === 'orange'"
|
||||
[class.alert-red]="alertLevel === 'red'"
|
||||
>
|
||||
<div class="left">
|
||||
<div class="chart-container" id="chartContainer">
|
||||
<div id="stationInfoTitle" class="station-info-title">{{ selectedTitle }}</div>
|
||||
<div class="chart-wrapper">
|
||||
<chart-panel [history]="history" [forecast]="forecast" [legendMode]="legendMode"></chart-panel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-container" id="mapContainer">
|
||||
<div id="map"></div>
|
||||
<div
|
||||
id="tileValueTooltip"
|
||||
class="map-tooltip"
|
||||
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>
|
||||
<div class="right">
|
||||
<div class="controls">
|
||||
<label for="stationInput">站点:</label>
|
||||
<input
|
||||
id="stationInput"
|
||||
type="text"
|
||||
placeholder=""
|
||||
[(ngModel)]="hexId"
|
||||
style="width:7.5rem;"
|
||||
/>
|
||||
|
||||
<label for="interval">粒度:</label>
|
||||
<select id="interval" [(ngModel)]="interval">
|
||||
<option value="raw">原始(16s)</option>
|
||||
<option value="10min">10分钟</option>
|
||||
<option value="30min">30分钟</option>
|
||||
<option value="1hour">1小时</option>
|
||||
</select>
|
||||
|
||||
<label for="forecastProvider">预报源:</label>
|
||||
<select id="forecastProvider" [(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 for="startDate">开始:</label>
|
||||
<input type="datetime-local" id="startDate" [(ngModel)]="start" />
|
||||
|
||||
<label for="endDate">结束:</label>
|
||||
<input type="datetime-local" id="endDate" [(ngModel)]="end" />
|
||||
|
||||
<button type="button" (click)="query()">查询</button>
|
||||
|
||||
<label for="tileProduct">叠加:</label>
|
||||
<select id="tileProduct" [(ngModel)]="tileProduct" (change)="onProductChange()">
|
||||
<option value="none">不显示</option>
|
||||
<option value="rain">1h 实际降雨</option>
|
||||
<option value="radar">水汽含量</option>
|
||||
</select>
|
||||
|
||||
<button type="button" (click)="prevTile()">上一时次</button>
|
||||
<span id="tileCountInfo">{{ tileCountInfo }}</span>
|
||||
<button type="button" (click)="nextTile()">下一时次</button>
|
||||
|
||||
<label for="tileTimeSelect">时间:</label>
|
||||
<select
|
||||
id="tileTimeSelect"
|
||||
style="min-width:12rem"
|
||||
[(ngModel)]="tileDt"
|
||||
(ngModelChange)="renderTilesAt($event)"
|
||||
>
|
||||
<option [ngValue]="''">请选择时间</option>
|
||||
<option *ngFor="let t of tileTimes" [ngValue]="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="summaryPanel"
|
||||
class="summary-panel"
|
||||
[class.alert-on]="alertLevel === 'orange'"
|
||||
[class.alert-red]="alertLevel === 'red'"
|
||||
>
|
||||
<div id="futureRainSummary" class="summary-title">{{ futureRainText }}</div>
|
||||
<div id="pastAccuracySummary" class="summary-sub">{{ pastAccuracyText }}</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container" id="tableContainer">
|
||||
<table-panel [history]="history" [forecast]="forecast" [showPastForecast]="false" [endDate]="end"></table-panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading-overlay" *ngIf="isLoading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
778
core/frontend/bigscreen/src/app/app.component.ts
Normal file
778
core/frontend/bigscreen/src/app/app.component.ts
Normal file
@ -0,0 +1,778 @@
|
||||
import { AfterViewInit, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { ApiService, ForecastPoint, WeatherPoint } from '../../../src/app/api.service';
|
||||
import { ChartPanelComponent } from '../../../src/app/chart-panel.component';
|
||||
import { TablePanelComponent } from '../../../src/app/table-panel.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;
|
||||
};
|
||||
|
||||
type AlertLevel = 'none' | 'orange' | 'red';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ChartPanelComponent, TablePanelComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
})
|
||||
export class BigscreenAppComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
constructor(private api: ApiService) {}
|
||||
|
||||
private readonly DEFAULT_STATION_HEX = '29C3';
|
||||
private readonly DEFAULT_TITLE = '第七台气象站(宾阳县细塘村东南约112米)';
|
||||
|
||||
onlineDevices = 0;
|
||||
serverTime = '';
|
||||
stations: Station[] = [];
|
||||
|
||||
hexId = this.DEFAULT_STATION_HEX;
|
||||
interval: 'raw' | '10min' | '30min' | '1hour' = '1hour';
|
||||
provider = 'imdroid_mix';
|
||||
start = '';
|
||||
end = '';
|
||||
legendMode = 'combo_standard';
|
||||
tileProduct: 'none' | 'radar' | 'rain' = 'radar';
|
||||
|
||||
history: WeatherPoint[] = [];
|
||||
forecast: ForecastPoint[] = [];
|
||||
|
||||
isLoading = false;
|
||||
|
||||
// Summary + presentation state
|
||||
selectedLocation = '';
|
||||
selectedTitle = this.DEFAULT_TITLE;
|
||||
futureRainText = '未来1~3小时降雨 -- 毫米';
|
||||
pastAccuracyText = '过去预报准确率 +1h: -- +2h: -- +3h: --';
|
||||
alertLevel: AlertLevel = 'none';
|
||||
|
||||
tileTimes: string[] = [];
|
||||
tileIndex = -1;
|
||||
tileDt = '';
|
||||
tileZ = 7;
|
||||
tileY = 40;
|
||||
tileX = 102;
|
||||
|
||||
private map: any;
|
||||
private layers: any = {};
|
||||
private stationSource: any;
|
||||
private clusterSource: any;
|
||||
private stationLayer: any;
|
||||
private clusterLayer: any;
|
||||
private readonly CLUSTER_THRESHOLD = 10;
|
||||
private tileOverlayGroup: any;
|
||||
private tileLastList: any[] = [];
|
||||
private mapReady = false;
|
||||
private stationsReady = false;
|
||||
private initialQueryScheduled = false;
|
||||
|
||||
async ngOnInit() {
|
||||
this.applyChartDefaults();
|
||||
await Promise.all([this.loadStatus(), this.loadStations()]);
|
||||
this.initializeTimeRange();
|
||||
this.ensureDefaultTitle();
|
||||
this.tryInitialQuery();
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.initMap();
|
||||
this.reloadTileTimesAndShow();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.setBodyAlertClass('none');
|
||||
}
|
||||
|
||||
get tileCountInfo(): string {
|
||||
if (!this.tileTimes.length || this.tileIndex < 0) {
|
||||
return '共0条,第0条';
|
||||
}
|
||||
return `共${this.tileTimes.length}条,第${this.tileIndex + 1}条`;
|
||||
}
|
||||
|
||||
async query(auto = false) {
|
||||
const hex = this.hexId.trim().toUpperCase();
|
||||
if (!hex) return;
|
||||
const sid = this.makeStationIdFromHex(hex);
|
||||
if (!sid) return;
|
||||
this.hexId = hex;
|
||||
|
||||
const toFmt = (s: string) => s.replace('T', ' ') + ':00';
|
||||
const from = toFmt(this.start);
|
||||
const to = toFmt(this.end);
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const [history, forecast] = await Promise.all([
|
||||
this.api.getHistory(hex, from, to, this.interval),
|
||||
this.provider ? this.api.getForecast(sid, from, to, this.provider, 3) : Promise.resolve([]),
|
||||
]);
|
||||
this.history = history;
|
||||
this.forecast = forecast;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
const station = this.stations.find((s) => s.station_id === sid);
|
||||
this.selectedLocation = station?.location || '';
|
||||
const titleName = station?.name || station?.station_alias || station?.station_id || '';
|
||||
this.selectedTitle = titleName ? `${titleName}${this.selectedLocation ? `(${this.selectedLocation})` : ''}` : this.selectedLocation;
|
||||
if (!this.selectedTitle) {
|
||||
this.ensureDefaultTitle();
|
||||
}
|
||||
|
||||
this.focusOnStation(station);
|
||||
this.updateSummaryPanel();
|
||||
if (!auto) {
|
||||
this.scrollToTop();
|
||||
}
|
||||
}
|
||||
|
||||
onProductChange() {
|
||||
this.reloadTileTimesAndShow();
|
||||
}
|
||||
|
||||
async reloadTileTimesAndShow() {
|
||||
if (this.tileProduct === 'none') {
|
||||
this.clearTileOverlays();
|
||||
this.tileTimes = [];
|
||||
this.tileDt = '';
|
||||
this.tileIndex = -1;
|
||||
return;
|
||||
}
|
||||
await this.loadTileTimes(this.tileProduct);
|
||||
}
|
||||
|
||||
prevTile() {
|
||||
if (!this.tileTimes.length) 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.length) return;
|
||||
if (this.tileIndex > 0) {
|
||||
this.tileIndex -= 1;
|
||||
this.tileDt = this.tileTimes[this.tileIndex];
|
||||
this.renderTilesAt(this.tileDt);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
try {
|
||||
const expected = this.makeStationIdFromHex(this.hexId || this.DEFAULT_STATION_HEX);
|
||||
const exists = !!this.stations.find((s) => s.station_id === expected);
|
||||
if (!exists) {
|
||||
const fallback = this.stations.find((s) => s.station_id && s.station_id.length >= 6);
|
||||
if (fallback) {
|
||||
const hex = fallback.station_id.slice(-6).toUpperCase();
|
||||
if (hex) this.hexId = hex;
|
||||
if (fallback.location) this.selectedTitle = fallback.location;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
this.stationsReady = true;
|
||||
this.tryInitialQuery();
|
||||
}
|
||||
|
||||
private ensureDefaultTitle() {
|
||||
if (!this.selectedTitle) {
|
||||
this.selectedTitle = this.DEFAULT_TITLE;
|
||||
}
|
||||
if (!this.hexId) {
|
||||
this.hexId = this.DEFAULT_STATION_HEX;
|
||||
}
|
||||
this.hexId = (this.hexId || '').toUpperCase();
|
||||
}
|
||||
|
||||
private tryInitialQuery() {
|
||||
if (this.initialQueryScheduled) return;
|
||||
if (!this.mapReady || !this.stationsReady) return;
|
||||
if (!this.hexId || !this.start || !this.end) return;
|
||||
this.initialQueryScheduled = true;
|
||||
setTimeout(() => {
|
||||
this.query(true).catch(() => {
|
||||
this.initialQueryScheduled = false;
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private initializeTimeRange() {
|
||||
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);
|
||||
const start = new Date(now.getTime() - 24 * 3600 * 1000);
|
||||
this.end = toLocal(end);
|
||||
this.start = toLocal(start);
|
||||
}
|
||||
|
||||
private applyChartDefaults() {
|
||||
try {
|
||||
const Chart = (window as any).Chart;
|
||||
if (Chart && Chart.defaults) {
|
||||
Chart.defaults.color = '#d5e3ff';
|
||||
Chart.defaults.borderColor = 'rgba(213,227,255,0.25)';
|
||||
if (Chart.defaults.scale && Chart.defaults.scale.grid) {
|
||||
Chart.defaults.scale.grid.color = 'rgba(213,227,255,0.12)';
|
||||
Chart.defaults.scale.ticks = Chart.defaults.scale.ticks || {};
|
||||
Chart.defaults.scale.ticks.color = '#cbd9ff';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
private makeStationIdFromHex(hexRaw: string): string | null {
|
||||
if (!hexRaw) return null;
|
||||
const hex = String(hexRaw).toUpperCase().replace(/[^0-9A-F]/g, '').padStart(6, '0').slice(-6);
|
||||
if (!hex) return null;
|
||||
return `RS485-${hex}`;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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;
|
||||
const feature = features[0];
|
||||
const subFeatures = feature.get('features');
|
||||
if (Array.isArray(subFeatures) && subFeatures.length > 0) {
|
||||
const extent = olAny.extent.createEmpty();
|
||||
subFeatures.forEach((sf: any) => {
|
||||
olAny.extent.extend(extent, sf.getGeometry().getExtent());
|
||||
});
|
||||
this.map.getView().fit(extent, { duration: 350, padding: [40, 40, 40, 40], maxZoom: 14 });
|
||||
return;
|
||||
}
|
||||
const hex = feature.get('stationHex');
|
||||
if (!hex) return;
|
||||
this.hexId = hex;
|
||||
await this.query();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
if (this.stations?.length) this.updateStationsOnMap();
|
||||
this.mapReady = true;
|
||||
this.tryInitialQuery();
|
||||
}
|
||||
|
||||
private getTiandituKey(): string {
|
||||
const anyWin = window as any;
|
||||
return anyWin.TIANDITU_KEY || '0c260b8a094a4e0bc507808812cefdac';
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const f0 = features[0];
|
||||
const last = f0?.get('lastUpdate');
|
||||
const online = last ? new Date(last).getTime() > Date.now() - 5 * 60 * 1000 : false;
|
||||
const color = online ? 'rgba(0,123,255,0.8)' : 'rgba(108,117,125,0.8)';
|
||||
return new ol.style.Style({
|
||||
image: new ol.style.Circle({
|
||||
radius: 6,
|
||||
fill: new ol.style.Fill({ color }),
|
||||
stroke: new ol.style.Stroke({ color: '#fff', width: 2 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const f0 = features[0];
|
||||
const last = f0?.get('lastUpdate');
|
||||
const online = last ? new Date(last).getTime() > Date.now() - 5 * 60 * 1000 : false;
|
||||
return new ol.style.Style({
|
||||
image: this.markerIcon(online),
|
||||
});
|
||||
}
|
||||
|
||||
private updateStationsOnMap() {
|
||||
const ol: any = (window as any).ol;
|
||||
if (!ol || !this.stationSource) return;
|
||||
const features = (this.stations || []).map((s) => {
|
||||
if (typeof s.longitude !== 'number' || typeof s.latitude !== 'number') return null;
|
||||
const stationId = s.station_id || '';
|
||||
if (!stationId) return null;
|
||||
const hex = stationId ? stationId.slice(-6).toUpperCase() : '';
|
||||
if (!hex) return null;
|
||||
const f = new ol.Feature({ geometry: new ol.geom.Point(ol.proj.fromLonLat([s.longitude, s.latitude])) });
|
||||
f.set('stationId', stationId);
|
||||
f.set('stationHex', hex);
|
||||
f.set('location', s.location || '');
|
||||
f.set('lastUpdate', s.last_update || '');
|
||||
return f;
|
||||
});
|
||||
this.stationSource.clear();
|
||||
features.filter(Boolean).forEach((f: any) => this.stationSource.addFeature(f));
|
||||
}
|
||||
|
||||
private 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 {
|
||||
this.tileTimes = [];
|
||||
this.tileIndex = -1;
|
||||
this.tileDt = '';
|
||||
}
|
||||
}
|
||||
|
||||
async renderTilesAt(dt: string) {
|
||||
if (!dt) {
|
||||
this.clearTileOverlays();
|
||||
return;
|
||||
}
|
||||
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);
|
||||
const idx = this.tileTimes.findIndex((t) => t === dt);
|
||||
if (idx >= 0) {
|
||||
this.tileIndex = idx;
|
||||
}
|
||||
} catch {
|
||||
this.clearTileOverlays();
|
||||
}
|
||||
}
|
||||
|
||||
private clearTileOverlays() {
|
||||
if (!this.tileOverlayGroup) return;
|
||||
const coll = this.tileOverlayGroup.getLayers();
|
||||
if (coll) coll.clear();
|
||||
this.tileLastList = [];
|
||||
}
|
||||
|
||||
private 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);
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
private focusOnStation(station?: Station) {
|
||||
const ol: any = (window as any).ol;
|
||||
if (!station || !ol || typeof station.longitude !== 'number' || typeof station.latitude !== 'number' || !this.map) return;
|
||||
this.map.getView().animate({
|
||||
center: ol.proj.fromLonLat([station.longitude, station.latitude]),
|
||||
zoom: 11,
|
||||
duration: 400,
|
||||
});
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.map.updateSize();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private scrollToTop() {
|
||||
const el = document.querySelector('.screen');
|
||||
if (el && 'scrollIntoView' in el) {
|
||||
try {
|
||||
(el as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
private updateSummaryPanel() {
|
||||
const historyData = Array.isArray(this.history) ? this.history : [];
|
||||
const forecastData = Array.isArray(this.forecast) ? this.forecast : [];
|
||||
|
||||
const fmt = (n: number | null | undefined) => {
|
||||
if (n == null || isNaN(Number(n))) return '--';
|
||||
return Number(n).toFixed(1);
|
||||
};
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const fmtDT = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:00:00`;
|
||||
const ceilHour = (d: Date) => {
|
||||
const t = new Date(d);
|
||||
if (t.getMinutes() || t.getSeconds() || t.getMilliseconds()) {
|
||||
t.setHours(t.getHours() + 1);
|
||||
}
|
||||
t.setMinutes(0, 0, 0);
|
||||
return t;
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const t1 = ceilHour(now);
|
||||
const t2 = new Date(t1.getTime() + 1 * 3600 * 1000);
|
||||
const t3 = new Date(t1.getTime() + 2 * 3600 * 1000);
|
||||
const pickBestAt = (dtStr: string) => {
|
||||
const cand = forecastData.filter((x) => x && x.date_time === dtStr && x.rainfall != null);
|
||||
if (!cand.length) return null;
|
||||
cand.sort((a, b) => (a.lead_hours ?? 99) - (b.lead_hours ?? 99));
|
||||
return cand[0].rainfall ?? null;
|
||||
};
|
||||
|
||||
const r1 = pickBestAt(fmtDT(t1));
|
||||
const r2 = pickBestAt(fmtDT(t2));
|
||||
const r3 = pickBestAt(fmtDT(t3));
|
||||
const safe = (v: number | null) => (v != null ? Number(v) : 0);
|
||||
const futureSum = safe(r1) + safe(r2) + safe(r3);
|
||||
this.futureRainText = `未来1~3小时降雨 ${fmt(futureSum)} 毫米`;
|
||||
|
||||
let level: AlertLevel = 'none';
|
||||
if (futureSum > 1) {
|
||||
level = 'red';
|
||||
} else if (futureSum > 0) {
|
||||
level = 'orange';
|
||||
}
|
||||
this.alertLevel = level;
|
||||
this.setBodyAlertClass(level);
|
||||
|
||||
const bucketOf = (mm: any): number | null => {
|
||||
if (mm == null || isNaN(Number(mm))) return null;
|
||||
const v = Math.max(0, Number(mm));
|
||||
if (v === 0) return 0;
|
||||
if (v > 0 && v <= 5) return 1;
|
||||
if (v > 5 && v <= 10) return 2;
|
||||
return 3;
|
||||
};
|
||||
|
||||
const rainActual = new Map<string, number | null>();
|
||||
historyData.forEach((it) => {
|
||||
if (it && it.date_time) rainActual.set(it.date_time, it.rainfall ?? null);
|
||||
});
|
||||
const tally = (lead: number) => {
|
||||
let correct = 0;
|
||||
let total = 0;
|
||||
forecastData.forEach((f) => {
|
||||
if (f.lead_hours !== lead) return;
|
||||
const a = rainActual.get(f.date_time);
|
||||
if (a == null) return;
|
||||
const ba = bucketOf(a);
|
||||
const bf = bucketOf(f.rainfall);
|
||||
if (ba == null || bf == null) return;
|
||||
total += 1;
|
||||
if (ba === bf) correct += 1;
|
||||
});
|
||||
return { correct, total };
|
||||
};
|
||||
const rH1 = tally(1);
|
||||
const rH2 = tally(2);
|
||||
const rH3 = tally(3);
|
||||
const pct = (r: { correct: number; total: number }) => (r.total > 0 ? `${((r.correct / r.total) * 100).toFixed(1)}%` : '--');
|
||||
this.pastAccuracyText = `过去预报准确率 +1h: ${pct(rH1)} +2h: ${pct(rH2)} +3h: ${pct(rH3)}`;
|
||||
}
|
||||
|
||||
private setBodyAlertClass(level: AlertLevel) {
|
||||
const body = document.body;
|
||||
if (!body) return;
|
||||
body.classList.toggle('alert-mode', level !== 'none');
|
||||
body.classList.toggle('alert-mode-red', level === 'red');
|
||||
body.classList.toggle('alert-mode-orange', level === 'orange');
|
||||
}
|
||||
}
|
||||
15
core/frontend/bigscreen/src/index.html
Normal file
15
core/frontend/bigscreen/src/index.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>北斗气象站</title>
|
||||
<base href="/bigscreen/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/static/css/ol.css" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root>Loading...</app-root>
|
||||
<script src="/static/js/chart.js"></script>
|
||||
<script src="/static/js/ol.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4
core/frontend/bigscreen/src/main.ts
Normal file
4
core/frontend/bigscreen/src/main.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { BigscreenAppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(BigscreenAppComponent).catch((err) => console.error(err));
|
||||
2
core/frontend/bigscreen/src/polyfills.ts
Normal file
2
core/frontend/bigscreen/src/polyfills.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// Angular default change detection requires Zone.js.
|
||||
import 'zone.js';
|
||||
341
core/frontend/bigscreen/src/styles.css
Normal file
341
core/frontend/bigscreen/src/styles.css
Normal file
@ -0,0 +1,341 @@
|
||||
:root {
|
||||
--color-bg: #0b1e39;
|
||||
--color-fg: #e6eefc;
|
||||
--color-fg-muted: #dbe7ff;
|
||||
--panel-bg: rgba(255, 255, 255, 0.04);
|
||||
--panel-border: rgba(255, 255, 255, 0.12);
|
||||
--table-head-bg: rgba(20, 45, 80, 0.98);
|
||||
--table-head-fg: #e6f0ff;
|
||||
--table-row-alt: rgba(255, 255, 255, 0.03);
|
||||
--orange-bg-1: rgba(255, 171, 64, 0.18);
|
||||
--orange-bg-2: rgba(255, 171, 64, 0.1);
|
||||
--orange-border: rgba(230, 120, 20, 0.6);
|
||||
--orange-shadow-1: rgba(255, 159, 64, 0.35);
|
||||
--orange-shadow-2: rgba(255, 159, 64, 0.1);
|
||||
--orange-shadow-3: rgba(255, 159, 64, 0.25);
|
||||
--orange-fg: #2b1900;
|
||||
--orange-fg-sub: #4a2b00;
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--radius-1: 0.25rem;
|
||||
--radius-2: 0.375rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: clamp(14px, 0.95vw, 18px);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
body.alert-mode {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body.alert-mode::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(255, 159, 64, 0) 45%,
|
||||
rgba(255, 159, 64, 0.12) 70%,
|
||||
rgba(255, 159, 64, 0.2) 100%
|
||||
);
|
||||
opacity: 0.16;
|
||||
animation: alert-vignette 3s ease-in-out infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
body.alert-mode-red::before {
|
||||
background: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(255, 107, 107, 0) 45%,
|
||||
rgba(255, 107, 107, 0.12) 70%,
|
||||
rgba(255, 107, 107, 0.2) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes alert-vignette {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.12;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.screen {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.screen.alert-on {
|
||||
box-shadow: inset 0 0 20rem var(--orange-shadow-3), inset 0 0 20rem var(--orange-shadow-1);
|
||||
animation: screenGlowOrange 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.screen.alert-red {
|
||||
box-shadow: inset 0 0 20rem rgba(255, 107, 107, 0.25), inset 0 0 20rem rgba(255, 107, 107, 0.35);
|
||||
animation: screenGlowRed 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes screenGlowOrange {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset 0 0 20rem var(--orange-shadow-3), inset 0 0 20rem var(--orange-shadow-1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes screenGlowRed {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset 0 0 20rem rgba(255, 107, 107, 0.25), inset 0 0 20rem rgba(255, 107, 107, 0.35);
|
||||
}
|
||||
50% {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: var(--radius-1);
|
||||
padding: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.station-info-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--space-2);
|
||||
color: #d5e3ff;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: var(--radius-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: var(--radius-1);
|
||||
padding: calc(var(--space-1) + 0.125rem) var(--space-2);
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
.controls label {
|
||||
font-size: 0.8125rem;
|
||||
color: #bfd0ef;
|
||||
}
|
||||
|
||||
.controls input,
|
||||
.controls select,
|
||||
.controls button {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 0.1875rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.controls input::placeholder {
|
||||
color: #98b1e0;
|
||||
}
|
||||
|
||||
.controls select option {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.02));
|
||||
cursor: pointer;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
color: #dfe9ff;
|
||||
line-height: 1.6;
|
||||
box-shadow: none;
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
|
||||
.summary-panel.alert-on {
|
||||
background: linear-gradient(180deg, var(--orange-bg-1), var(--orange-bg-2));
|
||||
border: 1px solid var(--orange-border);
|
||||
box-shadow: 0 6px 24px var(--orange-shadow-1), 0 2px 10px var(--orange-shadow-3), inset 0 1px 0 var(--orange-shadow-2);
|
||||
color: var(--orange-fg);
|
||||
animation: panelGlowOrange 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.summary-panel.alert-red {
|
||||
background: linear-gradient(180deg, rgba(255, 107, 107, 0.22), rgba(255, 107, 107, 0.12));
|
||||
border: 1px solid rgba(200, 40, 40, 0.6);
|
||||
box-shadow: 0 6px 24px rgba(255, 107, 107, 0.35), 0 2px 10px rgba(255, 107, 107, 0.25), inset 0 1px 0 rgba(255, 107, 107, 0.12);
|
||||
color: #3a0b0b;
|
||||
animation: panelGlowRed 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.summary-panel.alert-on .summary-title,
|
||||
.summary-panel.alert-red .summary-title {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.summary-sub {
|
||||
font-size: 0.875rem;
|
||||
color: #bcd0ff;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--panel-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #4f9cff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelGlowOrange {
|
||||
0%,
|
||||
100% {
|
||||
background: linear-gradient(180deg, var(--orange-bg-1), var(--orange-bg-2));
|
||||
border-color: var(--orange-border);
|
||||
box-shadow: 0 6px 24px var(--orange-shadow-1), 0 2px 10px var(--orange-shadow-3), inset 0 1px 0 var(--orange-shadow-2);
|
||||
}
|
||||
50% {
|
||||
background: var(--color-bg);
|
||||
border-color: var(--panel-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelGlowRed {
|
||||
0%,
|
||||
100% {
|
||||
background: linear-gradient(180deg, rgba(255, 107, 107, 0.22), rgba(255, 107, 107, 0.12));
|
||||
border-color: rgba(200, 40, 40, 0.6);
|
||||
box-shadow: 0 6px 24px rgba(255, 107, 107, 0.35), 0 2px 10px rgba(255, 107, 107, 0.25), inset 0 1px 0 rgba(255, 107, 107, 0.12);
|
||||
}
|
||||
50% {
|
||||
background: var(--color-bg);
|
||||
border-color: var(--panel-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
14
core/frontend/bigscreen/tsconfig.app.json
Normal file
14
core/frontend/bigscreen/tsconfig.app.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts",
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@ -3,9 +3,12 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
"build": "ng build --configuration production --base-href /ui/",
|
||||
"dev": "ng serve",
|
||||
"start": "ng serve weatherstation-ui --proxy-config proxy.conf.json",
|
||||
"start:bigscreen": "ng serve weatherstation-bigscreen --proxy-config proxy.conf.json",
|
||||
"build": "npm run build:ui && npm run build:bigscreen",
|
||||
"build:ui": "ng build weatherstation-ui --configuration production --base-href /ui/",
|
||||
"build:bigscreen": "ng build weatherstation-bigscreen --configuration production --base-href /bigscreen/",
|
||||
"dev": "ng serve weatherstation-ui",
|
||||
"test": "ng test"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
<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="十六进制(如 29CA)" />
|
||||
<input class="px-2 py-1 border rounded w-32 font-mono text-sm" [(ngModel)]="decimalId" placeholder="" />
|
||||
|
||||
<label class="text-sm text-gray-600">地图类型</label>
|
||||
<select class="px-2 py-1 border rounded text-sm" [(ngModel)]="mapType" (change)="switchLayer(mapType)">
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
type Config struct {
|
||||
Addr string
|
||||
UIServeDir string
|
||||
BigscreenDir string
|
||||
TemplateDir string
|
||||
StaticDir string
|
||||
DevEnableCORS bool
|
||||
@ -27,6 +28,7 @@ func Load() Config {
|
||||
cfg := Config{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
UIServeDir: "core/frontend/dist/ui",
|
||||
BigscreenDir: "core/frontend/dist/bigscreen",
|
||||
TemplateDir: "templates",
|
||||
StaticDir: "static",
|
||||
DevEnableCORS: true,
|
||||
@ -39,6 +41,9 @@ func Load() Config {
|
||||
if v := os.Getenv("CORE_UI_DIR"); v != "" {
|
||||
cfg.UIServeDir = v
|
||||
}
|
||||
if v := os.Getenv("CORE_BIGSCREEN_DIR"); v != "" {
|
||||
cfg.BigscreenDir = v
|
||||
}
|
||||
if v := os.Getenv("CORE_TEMPLATE_DIR"); v != "" {
|
||||
cfg.TemplateDir = v
|
||||
}
|
||||
@ -53,6 +58,6 @@ func Load() Config {
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("config: addr=%s ui=%s tpl=%s static=%s cors=%v", cfg.Addr, cfg.UIServeDir, cfg.TemplateDir, cfg.StaticDir, cfg.DevEnableCORS)
|
||||
log.Printf("config: addr=%s ui=%s bigscreen=%s tpl=%s static=%s cors=%v", cfg.Addr, cfg.UIServeDir, cfg.BigscreenDir, cfg.TemplateDir, cfg.StaticDir, cfg.DevEnableCORS)
|
||||
return cfg
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@ -9,10 +10,11 @@ import (
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
UIServeDir string
|
||||
TemplateDir string
|
||||
StaticDir string
|
||||
EnableCORS bool
|
||||
UIServeDir string
|
||||
BigscreenDir string
|
||||
TemplateDir string
|
||||
StaticDir string
|
||||
EnableCORS bool
|
||||
}
|
||||
|
||||
func NewRouter(opts Options) *gin.Engine {
|
||||
@ -51,23 +53,61 @@ func NewRouter(opts Options) *gin.Engine {
|
||||
api.GET("/rain/tiles_at", handleRainTilesAt)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(opts.UIServeDir) != "" {
|
||||
hasUI := strings.TrimSpace(opts.UIServeDir) != ""
|
||||
if hasUI {
|
||||
// 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)
|
||||
}
|
||||
|
||||
hasBigscreen := strings.TrimSpace(opts.BigscreenDir) != ""
|
||||
var bigscreenIndex string
|
||||
if hasBigscreen {
|
||||
bigscreenDir := filepath.Clean(opts.BigscreenDir)
|
||||
bigscreenIndex = filepath.Join(bigscreenDir, "index.html")
|
||||
serveBigscreenIndex := func(c *gin.Context) {
|
||||
c.File(bigscreenIndex)
|
||||
}
|
||||
r.GET("/bigscreen", serveBigscreenIndex)
|
||||
r.GET("/bigscreen/*filepath", func(c *gin.Context) {
|
||||
rel := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
if rel == "" {
|
||||
serveBigscreenIndex(c)
|
||||
return
|
||||
}
|
||||
c.File(filepath.Join(opts.UIServeDir, "index.html"))
|
||||
full := filepath.Join(bigscreenDir, filepath.FromSlash(rel))
|
||||
if !strings.HasPrefix(full, bigscreenDir+string(os.PathSeparator)) && full != bigscreenDir {
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(full); err == nil && !info.IsDir() {
|
||||
c.File(full)
|
||||
return
|
||||
}
|
||||
serveBigscreenIndex(c)
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if hasBigscreen && strings.HasPrefix(p, "/bigscreen") {
|
||||
c.File(bigscreenIndex)
|
||||
return
|
||||
}
|
||||
if hasUI {
|
||||
c.File(filepath.Join(opts.UIServeDir, "index.html"))
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user