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 @@
-
+