feat: angular 重构前端页面

This commit is contained in:
yarnom 2025-11-06 17:51:24 +08:00
parent 9b40bde332
commit 13d24a1322
14 changed files with 1475 additions and 20 deletions

View File

@ -13,10 +13,11 @@ func main() {
cfg := config.Load() cfg := config.Load()
_ = data.DB() _ = data.DB()
r := server.NewRouter(server.Options{ r := server.NewRouter(server.Options{
UIServeDir: cfg.UIServeDir, UIServeDir: cfg.UIServeDir,
TemplateDir: cfg.TemplateDir, BigscreenDir: cfg.BigscreenDir,
StaticDir: cfg.StaticDir, TemplateDir: cfg.TemplateDir,
EnableCORS: cfg.DevEnableCORS, StaticDir: cfg.StaticDir,
EnableCORS: cfg.DevEnableCORS,
}) })
addr := cfg.Addr addr := cfg.Addr

View File

@ -65,6 +65,69 @@
"defaultConfiguration": "development" "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"
}
}
} }
} }
} }

View 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;
}

View 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>

View 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');
}
}

View 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>

View File

@ -0,0 +1,4 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { BigscreenAppComponent } from './app/app.component';
bootstrapApplication(BigscreenAppComponent).catch((err) => console.error(err));

View File

@ -0,0 +1,2 @@
// Angular default change detection requires Zone.js.
import 'zone.js';

View 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;
}
}

View 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"
]
}

View File

@ -3,9 +3,12 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve weatherstation-ui --proxy-config proxy.conf.json",
"build": "ng build --configuration production --base-href /ui/", "start:bigscreen": "ng serve weatherstation-bigscreen --proxy-config proxy.conf.json",
"dev": "ng serve", "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" "test": "ng test"
}, },
"dependencies": { "dependencies": {

View File

@ -20,7 +20,7 @@
<div class="bg-white border rounded p-3 mb-4" style="border-color:#ddd;"> <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"> <div class="flex flex-wrap items-center gap-3 mb-3">
<label class="text-sm text-gray-600">站点编号</label> <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> <label class="text-sm text-gray-600">地图类型</label>
<select class="px-2 py-1 border rounded text-sm" [(ngModel)]="mapType" (change)="switchLayer(mapType)"> <select class="px-2 py-1 border rounded text-sm" [(ngModel)]="mapType" (change)="switchLayer(mapType)">

View File

@ -10,6 +10,7 @@ import (
type Config struct { type Config struct {
Addr string Addr string
UIServeDir string UIServeDir string
BigscreenDir string
TemplateDir string TemplateDir string
StaticDir string StaticDir string
DevEnableCORS bool DevEnableCORS bool
@ -27,6 +28,7 @@ func Load() Config {
cfg := Config{ cfg := Config{
Addr: fmt.Sprintf(":%d", port), Addr: fmt.Sprintf(":%d", port),
UIServeDir: "core/frontend/dist/ui", UIServeDir: "core/frontend/dist/ui",
BigscreenDir: "core/frontend/dist/bigscreen",
TemplateDir: "templates", TemplateDir: "templates",
StaticDir: "static", StaticDir: "static",
DevEnableCORS: true, DevEnableCORS: true,
@ -39,6 +41,9 @@ func Load() Config {
if v := os.Getenv("CORE_UI_DIR"); v != "" { if v := os.Getenv("CORE_UI_DIR"); v != "" {
cfg.UIServeDir = v cfg.UIServeDir = v
} }
if v := os.Getenv("CORE_BIGSCREEN_DIR"); v != "" {
cfg.BigscreenDir = v
}
if v := os.Getenv("CORE_TEMPLATE_DIR"); v != "" { if v := os.Getenv("CORE_TEMPLATE_DIR"); v != "" {
cfg.TemplateDir = 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 return cfg
} }

View File

@ -2,6 +2,7 @@ package server
import ( import (
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -9,10 +10,11 @@ import (
) )
type Options struct { type Options struct {
UIServeDir string UIServeDir string
TemplateDir string BigscreenDir string
StaticDir string TemplateDir string
EnableCORS bool StaticDir string
EnableCORS bool
} }
func NewRouter(opts Options) *gin.Engine { func NewRouter(opts Options) *gin.Engine {
@ -51,23 +53,61 @@ func NewRouter(opts Options) *gin.Engine {
api.GET("/rain/tiles_at", handleRainTilesAt) 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 // Serve built Angular assets under /ui for static files
r.Static("/ui", opts.UIServeDir) r.Static("/ui", opts.UIServeDir)
// Serve Angular index.html at root // Serve Angular index.html at root
r.GET("/", func(c *gin.Context) { r.GET("/", func(c *gin.Context) {
c.File(filepath.Join(opts.UIServeDir, "index.html")) 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 hasBigscreen := strings.TrimSpace(opts.BigscreenDir) != ""
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/static/") { var bigscreenIndex string
c.AbortWithStatus(http.StatusNotFound) 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 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 return r
} }