diff --git a/core/cmd/core-api/main.go b/core/cmd/core-api/main.go index e9a1698..b9a01d2 100644 --- a/core/cmd/core-api/main.go +++ b/core/cmd/core-api/main.go @@ -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 diff --git a/core/frontend/angular.json b/core/frontend/angular.json index fcddf6f..63fc785 100644 --- a/core/frontend/angular.json +++ b/core/frontend/angular.json @@ -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" + } + } } } } diff --git a/core/frontend/bigscreen/src/app/app.component.css b/core/frontend/bigscreen/src/app/app.component.css new file mode 100644 index 0000000..dc8376f --- /dev/null +++ b/core/frontend/bigscreen/src/app/app.component.css @@ -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; +} diff --git a/core/frontend/bigscreen/src/app/app.component.html b/core/frontend/bigscreen/src/app/app.component.html new file mode 100644 index 0000000..8ef7f9b --- /dev/null +++ b/core/frontend/bigscreen/src/app/app.component.html @@ -0,0 +1,99 @@ +
+
+
+
{{ selectedTitle }}
+
+ +
+
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + {{ tileCountInfo }} + + + + +
+ +
+
{{ futureRainText }}
+
{{ pastAccuracyText }}
+
+ +
+ +
+
+
+ +
+
+
diff --git a/core/frontend/bigscreen/src/app/app.component.ts b/core/frontend/bigscreen/src/app/app.component.ts new file mode 100644 index 0000000..21b0b6c --- /dev/null +++ b/core/frontend/bigscreen/src/app/app.component.ts @@ -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(); + 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'); + } +} diff --git a/core/frontend/bigscreen/src/index.html b/core/frontend/bigscreen/src/index.html new file mode 100644 index 0000000..77dfa73 --- /dev/null +++ b/core/frontend/bigscreen/src/index.html @@ -0,0 +1,15 @@ + + + + + 北斗气象站 + + + + + + Loading... + + + + diff --git a/core/frontend/bigscreen/src/main.ts b/core/frontend/bigscreen/src/main.ts new file mode 100644 index 0000000..3602d79 --- /dev/null +++ b/core/frontend/bigscreen/src/main.ts @@ -0,0 +1,4 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { BigscreenAppComponent } from './app/app.component'; + +bootstrapApplication(BigscreenAppComponent).catch((err) => console.error(err)); diff --git a/core/frontend/bigscreen/src/polyfills.ts b/core/frontend/bigscreen/src/polyfills.ts new file mode 100644 index 0000000..a7426b7 --- /dev/null +++ b/core/frontend/bigscreen/src/polyfills.ts @@ -0,0 +1,2 @@ +// Angular default change detection requires Zone.js. +import 'zone.js'; diff --git a/core/frontend/bigscreen/src/styles.css b/core/frontend/bigscreen/src/styles.css new file mode 100644 index 0000000..85018d0 --- /dev/null +++ b/core/frontend/bigscreen/src/styles.css @@ -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; + } +} diff --git a/core/frontend/bigscreen/tsconfig.app.json b/core/frontend/bigscreen/tsconfig.app.json new file mode 100644 index 0000000..1102b94 --- /dev/null +++ b/core/frontend/bigscreen/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts", + "src/polyfills.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/core/frontend/package.json b/core/frontend/package.json index 7d428e7..9c98509 100644 --- a/core/frontend/package.json +++ b/core/frontend/package.json @@ -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": { diff --git a/core/frontend/src/app.component.html b/core/frontend/src/app.component.html index f18aa50..6651db5 100644 --- a/core/frontend/src/app.component.html +++ b/core/frontend/src/app.component.html @@ -20,7 +20,7 @@
- +