666 lines
29 KiB
TypeScript
666 lines
29 KiB
TypeScript
import { bootstrapApplication } from '@angular/platform-browser';
|
||
import { Component, OnInit, AfterViewInit } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { ApiService, ForecastPoint, WeatherPoint } from './app/api.service';
|
||
import { ChartPanelComponent } from './app/chart-panel.component';
|
||
import { TablePanelComponent } from './app/table-panel.component';
|
||
import { HeaderComponent } from './app/header.component';
|
||
|
||
type Station = {
|
||
station_id: string;
|
||
decimal_id?: string;
|
||
latitude?: number;
|
||
longitude?: number;
|
||
location?: string;
|
||
device_type?: string;
|
||
last_update?: string;
|
||
name?: string;
|
||
station_alias?: string;
|
||
};
|
||
|
||
@Component({
|
||
selector: 'app-root',
|
||
standalone: true,
|
||
imports: [CommonModule, FormsModule, ChartPanelComponent, TablePanelComponent, HeaderComponent],
|
||
templateUrl: './app.component.html',
|
||
})
|
||
export class AppComponent implements OnInit, AfterViewInit {
|
||
constructor(private api: ApiService) {}
|
||
|
||
onlineDevices = 0;
|
||
serverTime = '';
|
||
stations: Station[] = [];
|
||
mapType = 'satellite';
|
||
|
||
decimalId = '';
|
||
interval = '1hour';
|
||
start = '';
|
||
end = '';
|
||
// 默认英卓 V4
|
||
provider = 'imdroid_mix';
|
||
legendMode = 'combo_standard';
|
||
showPastForecast = false;
|
||
showPanels = false;
|
||
selectedLocation = '';
|
||
selectedTitle = '';
|
||
isLoading = false;
|
||
|
||
history: WeatherPoint[] = [];
|
||
forecast: ForecastPoint[] = [];
|
||
|
||
private map: any;
|
||
private layers: any = {};
|
||
private stationSource: any;
|
||
private clusterSource: any;
|
||
private stationLayer: any;
|
||
private clusterLayer: any;
|
||
private kmlLayer: any;
|
||
private kmlOverlay: any;
|
||
private CLUSTER_THRESHOLD = 10;
|
||
private tileOverlayGroup: any;
|
||
private windOverlayLayer: any;
|
||
private tileLastList: any[] = [];
|
||
private refreshTimer: any;
|
||
private mapEventsBound = false;
|
||
tileTimes: string[] = [];
|
||
tileIndex = -1;
|
||
tileZ = 7; tileY = 40; tileX = 102;
|
||
tileDt = '';
|
||
tileProduct: 'none'|'radar'|'radar_detail'|'rain' = 'radar';
|
||
isMapCollapsed = false;
|
||
kmlInfoTitle = '';
|
||
kmlInfoHtml = '';
|
||
isKmlDialogOpen = false;
|
||
|
||
async ngOnInit() {
|
||
await Promise.all([this.loadStatus(), this.loadStations()]);
|
||
const now = new Date();
|
||
const pad = (n:number)=> String(n).padStart(2,'0');
|
||
const toLocal = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
const end = new Date(now.getTime() + 4*3600*1000); // 当前时间后4小时
|
||
const start = new Date(now.getTime() - 24*3600*1000); // 当前时间前24小时
|
||
this.end = toLocal(end);
|
||
this.start = toLocal(start);
|
||
|
||
// 每10分钟检查并刷新数据(状态与站点),无刷新页面
|
||
this.refreshTimer = setInterval(() => { this.refreshDataTick(); }, 10 * 60 * 1000);
|
||
}
|
||
|
||
ngAfterViewInit() { this.initMap(); this.reloadTileTimesAndShow(); }
|
||
|
||
private async loadStatus() {
|
||
const s = await this.api.getStatus();
|
||
if (s) { this.onlineDevices = s.online_devices || 0; this.serverTime = s.server_time || ''; }
|
||
}
|
||
|
||
private async loadStations() { this.stations = await this.api.getStations(); this.updateStationsOnMap(); }
|
||
|
||
private async refreshDataTick() {
|
||
try {
|
||
await Promise.all([this.loadStatus(), this.loadStations()]);
|
||
// 若已选择设备并显示图表,则同时刷新历史与预报数据(不触发滚动/动画)
|
||
if (this.showPanels && this.decimalId) {
|
||
await this.query(true);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
private getTiandituKey(): string {
|
||
const anyWin = (window as any);
|
||
return anyWin.TIANDITU_KEY || '0c260b8a094a4e0bc507808812cefdac';
|
||
}
|
||
|
||
get wh65lpCount(): number {
|
||
return (this.stations || []).filter(s => s.device_type === 'WH65LP').length;
|
||
}
|
||
|
||
private initMap() {
|
||
const ol: any = (window as any).ol; if (!ol) return;
|
||
const tk = this.getTiandituKey();
|
||
const mkLayer = (url: string) => new ol.layer.Tile({ source: new ol.source.XYZ({ url }) });
|
||
this.layers = {
|
||
satellite: new ol.layer.Group({ layers: [
|
||
mkLayer(`https://t{0-7}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`),
|
||
mkLayer(`https://t{0-7}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`)
|
||
]}),
|
||
vector: new ol.layer.Group({ layers: [
|
||
mkLayer(`https://t{0-7}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`),
|
||
mkLayer(`https://t{0-7}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`)
|
||
], visible: false}),
|
||
terrain: new ol.layer.Group({ layers: [
|
||
mkLayer(`https://t{0-7}.tianditu.gov.cn/ter_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ter&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`),
|
||
mkLayer(`https://t{0-7}.tianditu.gov.cn/cta_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cta&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`)
|
||
], visible: false}),
|
||
hybrid: new ol.layer.Group({ layers: [
|
||
mkLayer(`https://t{0-7}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`),
|
||
mkLayer(`https://t{0-7}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`)
|
||
], visible: false})
|
||
};
|
||
|
||
this.stationSource = new ol.source.Vector();
|
||
this.clusterSource = new ol.source.Cluster({ distance: 60, minDistance: 20, source: this.stationSource });
|
||
this.clusterLayer = new ol.layer.Vector({ source: this.clusterSource, style: (f:any)=> this.createClusterStyle(f) });
|
||
this.stationLayer = new ol.layer.Vector({ source: this.stationSource, visible: false, style: (f:any)=> this.createStationStyle(f) });
|
||
this.tileOverlayGroup = new ol.layer.Group({ layers: [], zIndex: 999, visible: true });
|
||
this.windOverlayLayer = new ol.layer.Vector({ source: new ol.source.Vector(), zIndex: 1000, visible: true });
|
||
|
||
// Load KML overlay from /static/kml/selected_polygons.kml
|
||
try {
|
||
const kmlSource = new ol.source.Vector({
|
||
url: '/static/kml/selected_polygons.kml',
|
||
format: new ol.format.KML({ extractStyles: true })
|
||
});
|
||
this.kmlLayer = new ol.layer.Vector({ source: kmlSource, zIndex: 800, visible: true });
|
||
} catch {}
|
||
|
||
this.map = new ol.Map({ target: 'map', layers: [
|
||
this.layers.satellite,
|
||
this.layers.vector,
|
||
this.layers.terrain,
|
||
this.layers.hybrid,
|
||
this.kmlLayer,
|
||
this.tileOverlayGroup,
|
||
this.windOverlayLayer,
|
||
this.clusterLayer,
|
||
this.stationLayer
|
||
], view: new ol.View({ center: ol.proj.fromLonLat([108, 35]), zoom: 5, minZoom: 3, maxZoom: 18 }) });
|
||
|
||
// 使用全屏遮罩的页面级弹窗显示 KML 详情
|
||
|
||
this.map.getView().on('change:resolution', () => {
|
||
const z = this.map.getView().getZoom();
|
||
this.updateClusterDistance(z);
|
||
this.updateLayerVisibility(z);
|
||
});
|
||
|
||
if (this.stations?.length) this.updateStationsOnMap();
|
||
}
|
||
|
||
openKmlPopup(feature: any, coordinate: any) {
|
||
try {
|
||
const name = feature?.get ? (feature.get('name') || '') : '';
|
||
let desc = feature?.get ? (feature.get('description') || '') : '';
|
||
// Cleanup KML-wrapped CDATA and decode HTML entities
|
||
try {
|
||
desc = String(desc);
|
||
desc = desc.replace(/^<!\[CDATA\[/, '').replace(/\]\]>$/, '');
|
||
const ta = document.createElement('textarea'); ta.innerHTML = desc; desc = ta.value;
|
||
} catch {}
|
||
this.kmlInfoTitle = String(name || '详情');
|
||
this.kmlInfoHtml = String(desc || '');
|
||
// 使用页面级模态对话框显示
|
||
this.isKmlDialogOpen = true;
|
||
} catch {}
|
||
}
|
||
|
||
closeKmlPopup() {
|
||
try {
|
||
this.isKmlDialogOpen = false;
|
||
} catch {}
|
||
}
|
||
|
||
switchLayer(layerType: string) {
|
||
const layers = this.layers; if (!layers) return;
|
||
Object.keys(layers).forEach(key => { if (layers[key].setVisible) layers[key].setVisible(key === layerType); });
|
||
}
|
||
|
||
private updateClusterDistance(zoom: number) {
|
||
if (!this.clusterSource) return;
|
||
const distance = zoom < this.CLUSTER_THRESHOLD ? 60 : 20;
|
||
this.clusterSource.setDistance(distance);
|
||
}
|
||
|
||
private updateLayerVisibility(zoom: number) {
|
||
if (!this.clusterLayer || !this.stationLayer) return;
|
||
const showCluster = zoom < this.CLUSTER_THRESHOLD;
|
||
this.clusterLayer.setVisible(showCluster);
|
||
this.stationLayer.setVisible(!showCluster);
|
||
}
|
||
|
||
private markerIcon(isOnline: boolean) {
|
||
const ol: any = (window as any).ol;
|
||
const src = isOnline ? '/static/images/marker-online.svg' : '/static/images/marker-offline.svg';
|
||
return new ol.style.Icon({ src, anchor: [0.5,1], anchorXUnits: 'fraction', anchorYUnits: 'fraction', scale: 0.9 });
|
||
}
|
||
|
||
private createStationStyle(feature: any) {
|
||
const ol: any = (window as any).ol;
|
||
const last = feature.get('lastUpdate');
|
||
const online = last ? (new Date(last).getTime() > Date.now() - 5*60*1000) : false;
|
||
const location = feature.get('location') || '';
|
||
return new ol.style.Style({
|
||
image: this.markerIcon(online),
|
||
text: location ? new ol.style.Text({ text: location, offsetY: -28, fill: new ol.style.Fill({ color: '#111' }), stroke: new ol.style.Stroke({ color: '#fff', width: 3 }), font: '12px sans-serif' }) : undefined
|
||
});
|
||
}
|
||
|
||
private createClusterStyle(feature: any) {
|
||
const ol: any = (window as any).ol;
|
||
const features = feature.get('features') || [];
|
||
const size = features.length;
|
||
const zoom = this.map.getView().getZoom();
|
||
if (zoom < this.CLUSTER_THRESHOLD) {
|
||
if (size > 1) {
|
||
const radius = Math.min(16 + size * 0.8, 32);
|
||
const fontSize = Math.min(11 + size/12, 16);
|
||
return new ol.style.Style({
|
||
image: new ol.style.Circle({ radius, fill: new ol.style.Fill({ color: 'rgba(0,123,255,0.8)' }), stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) }),
|
||
text: new ol.style.Text({ text: String(size), fill: new ol.style.Fill({ color: '#fff' }), font: `bold ${fontSize}px Arial`, offsetY: 1 })
|
||
});
|
||
} 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 }) })
|
||
});
|
||
}
|
||
}
|
||
return this.createStationStyle(features[0]);
|
||
}
|
||
|
||
private updateStationsOnMap() {
|
||
const ol: any = (window as any).ol; if (!ol || !this.stationSource) return;
|
||
this.stationSource.clear();
|
||
(this.stations||[]).forEach(s => {
|
||
if (typeof s.longitude !== 'number' || typeof s.latitude !== 'number') return;
|
||
const f = new ol.Feature({ geometry: new ol.geom.Point(ol.proj.fromLonLat([s.longitude, s.latitude])), lastUpdate: (s as any).last_update || '', stationId: s.station_id, location: s.location || '' });
|
||
this.stationSource.addFeature(f);
|
||
});
|
||
// click to select
|
||
if (this.map && !this.mapEventsBound) {
|
||
this.map.on('singleclick', async (evt:any) => {
|
||
// 先尝试命中 KML 要素
|
||
try {
|
||
let handledKml = false;
|
||
this.map.forEachFeatureAtPixel(evt.pixel, (f:any, layer:any) => {
|
||
if (this.kmlLayer && layer === this.kmlLayer) {
|
||
this.openKmlPopup(f, evt.coordinate);
|
||
handledKml = true;
|
||
return true;
|
||
}
|
||
return false;
|
||
}, { layerFilter: (l:any)=> l===this.kmlLayer, hitTolerance: 6 });
|
||
if (handledKml) return;
|
||
} catch {}
|
||
// 再处理站点/聚合点击
|
||
const olAny: any = (window as any).ol;
|
||
const features = this.map.getFeaturesAtPixel(evt.pixel, { layerFilter: (l:any)=> l===this.stationLayer || l===this.clusterLayer });
|
||
if (!features || features.length===0) return;
|
||
let f = features[0];
|
||
const subs = f.get('features');
|
||
if (subs && subs.length>0) {
|
||
const view = this.map.getView();
|
||
const ex = olAny.extent.createEmpty();
|
||
for (const sf of subs) {
|
||
olAny.extent.extend(ex, sf.getGeometry().getExtent());
|
||
}
|
||
view.fit(ex, { duration: 300, maxZoom: 14, padding: [40,40,40,40] });
|
||
return;
|
||
}
|
||
const sid = f.get('stationId');
|
||
if (!sid) return;
|
||
const hex = String(sid).slice(-6).toUpperCase();
|
||
this.decimalId = hex;
|
||
await this.query();
|
||
this.scrollToChart();
|
||
});
|
||
this.map.on('pointermove', (evt:any) => {
|
||
const features = this.map.getFeaturesAtPixel(evt.pixel, { layerFilter: (l:any)=> l===this.stationLayer || l===this.clusterLayer || l===this.kmlLayer });
|
||
const el = this.map.getTargetElement();
|
||
if (el) el.style.cursor = (features && features.length>0) ? 'pointer' : '';
|
||
this.showTileTooltip(evt);
|
||
});
|
||
this.mapEventsBound = true;
|
||
}
|
||
}
|
||
|
||
async loadTileTimes(product: 'radar'|'rain'|'radar_detail') {
|
||
try {
|
||
const params = new URLSearchParams({ z: String(this.tileZ), y: String(this.tileY), x: String(this.tileX) });
|
||
// 若指定了开始/结束时间,则按时间范围查询;否则按最近limit条
|
||
const toFmt = (s: string) => s.replace('T',' ') + ':00';
|
||
if (this.start && this.end) {
|
||
params.set('from', toFmt(this.start));
|
||
params.set('to', toFmt(this.end));
|
||
} else {
|
||
params.set('limit', '60');
|
||
}
|
||
const path = product==='rain' ? '/api/rain/times' : '/api/radar/times';
|
||
const r = await fetch(`${path}?${params.toString()}`);
|
||
if (!r.ok) return;
|
||
const j = await r.json();
|
||
this.tileTimes = j.times || [];
|
||
this.tileIndex = 0;
|
||
this.tileDt = this.tileTimes[0] || '';
|
||
if (this.tileDt) await this.renderTilesAt(this.tileDt);
|
||
} catch {}
|
||
}
|
||
|
||
async renderTilesAt(dt: string) {
|
||
try {
|
||
const params = new URLSearchParams({ z: String(this.tileZ), dt: dt });
|
||
const isRain = this.tileProduct === 'rain';
|
||
const path = isRain ? '/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(); this.clearWindOverlays(); return; }
|
||
await this.renderTilesOnMap(isRain ? 'rain' : 'radar', tiles);
|
||
// 同步 tileIndex 以匹配选择的时次
|
||
const idx = this.tileTimes.findIndex(t => t === dt);
|
||
if (idx >= 0) { this.tileIndex = idx; this.tileDt = dt; }
|
||
if (!isRain && this.tileProduct === 'radar_detail') {
|
||
this.drawWindOverlays(dt);
|
||
} else {
|
||
this.clearWindOverlays();
|
||
}
|
||
} catch { this.clearTileOverlays(); }
|
||
}
|
||
|
||
clearTileOverlays() {
|
||
if (!this.tileOverlayGroup) return;
|
||
const coll = this.tileOverlayGroup.getLayers();
|
||
if (coll) coll.clear();
|
||
}
|
||
|
||
clearWindOverlays() {
|
||
const ol: any = (window as any).ol; if (!ol || !this.windOverlayLayer) return;
|
||
const src = this.windOverlayLayer.getSource();
|
||
if (src) src.clear();
|
||
try { const box = document.getElementById('regionStats'); if (box) box.style.display='none'; } catch {}
|
||
}
|
||
|
||
private getSelectedStation(): Station | undefined {
|
||
try {
|
||
const sid = this.makeStationIdFromHex(this.decimalId || '');
|
||
if (!sid) return undefined;
|
||
return this.stations.find(s => s.station_id === sid);
|
||
} catch { return undefined; }
|
||
}
|
||
|
||
private async drawWindOverlays(dtStr: string) {
|
||
const ol: any = (window as any).ol; if (!ol || !this.map || !this.windOverlayLayer) return;
|
||
const src = this.windOverlayLayer.getSource(); if (!src) return;
|
||
src.clear();
|
||
// 选择中心:优先当前选中站点,否则地图中心
|
||
let centerLon = 108, centerLat = 35;
|
||
const st = this.getSelectedStation();
|
||
if (st && typeof st.longitude==='number' && typeof st.latitude==='number') { centerLon = st.longitude!; centerLat = st.latitude!; }
|
||
else {
|
||
try {
|
||
const c = this.map.getView().getCenter();
|
||
const lonlat = ol.proj.toLonLat(c, this.map.getView().getProjection());
|
||
centerLon = lonlat[0]; centerLat = lonlat[1];
|
||
} catch {}
|
||
}
|
||
// 调用后端根据经纬度与时间查询最近的 radar_weather 风数据
|
||
let windDir: number|null = null, windSpd: number|null = null;
|
||
try {
|
||
const params = new URLSearchParams({ lat: String(centerLat), lon: String(centerLon), dt: dtStr });
|
||
const r = await fetch(`/api/radar/weather_nearest?${params.toString()}`);
|
||
if (r.ok) {
|
||
const j = await r.json();
|
||
if (j && j.wind_direction != null) windDir = Number(j.wind_direction);
|
||
if (j && j.wind_speed != null) windSpd = Number(j.wind_speed);
|
||
if (isFinite(windDir as any)) windDir = ((windDir as number) % 360 + 360) % 360;
|
||
if (!isFinite(windSpd as any)) windSpd = null;
|
||
}
|
||
} catch {}
|
||
const proj = this.map.getView().getProjection();
|
||
const center = ol.proj.fromLonLat([centerLon, centerLat], proj);
|
||
|
||
// 8km 圆(红色虚线边框)
|
||
try {
|
||
const circle = new ol.geom.Circle(center, 8000);
|
||
const cf = new ol.Feature({ geometry: circle });
|
||
cf.setStyle(new ol.style.Style({ stroke: new ol.style.Stroke({ color: 'rgba(255,0,0,0.95)', width: 2, lineDash: [6,4] }), fill: new ol.style.Fill({ color: 'rgba(255,0,0,0.03)' }) }));
|
||
src.addFeature(cf);
|
||
} catch {}
|
||
|
||
if (windDir != null && windSpd != null && windSpd > 0.01) {
|
||
const bearingTo = (windDir + 180) % 360; // 去向
|
||
const hours = 3;
|
||
const radius = windSpd * 3600 * hours; // m
|
||
const half = 25; // 半角
|
||
const pts: number[][] = [];
|
||
pts.push(center);
|
||
for (let a = -half; a <= half; a += 2.5) {
|
||
const ang = (bearingTo + a) * Math.PI / 180;
|
||
const dx = radius * Math.sin(ang);
|
||
const dy = radius * Math.cos(ang);
|
||
pts.push([center[0] + dx, center[1] + dy]);
|
||
}
|
||
pts.push(center);
|
||
const poly = new ol.geom.Polygon([pts]);
|
||
const pf = new ol.Feature({ geometry: poly });
|
||
pf.setStyle(new ol.style.Style({ stroke: new ol.style.Stroke({ color: 'rgba(255,0,0,0.95)', width: 2, lineDash: [6,4] }), fill: new ol.style.Fill({ color: 'rgba(255,0,0,0.05)' }) }));
|
||
src.addFeature(pf);
|
||
|
||
// 统计扇形区域内的强回波像元数量(基于当前已加载的雷达瓦片)
|
||
try {
|
||
const polyLonLat: [number,number][] = pts.map(p => {
|
||
const lonlat = ol.proj.toLonLat(p, this.map.getView().getProjection());
|
||
return [lonlat[0], lonlat[1]];
|
||
});
|
||
const counts = this.countDbzInPolygon(polyLonLat);
|
||
this.updateRegionStats(counts, windDir, windSpd);
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
private updateRegionStats(counts?: { ge30:number; ge35:number; ge40:number } | null, windDir?: number|null, windSpd?: number|null) {
|
||
try {
|
||
const box = document.getElementById('regionStats');
|
||
const s30 = document.getElementById('statDbz30');
|
||
const s35 = document.getElementById('statDbz35');
|
||
const s40 = document.getElementById('statDbz40');
|
||
const sDir = document.getElementById('statWindDir');
|
||
const sSpd = document.getElementById('statWindSpd');
|
||
if (!box || !s30 || !s35 || !s40 || !sDir || !sSpd) return;
|
||
if (!counts) { box.style.display='none'; return; }
|
||
s30.textContent = String(counts.ge30);
|
||
s35.textContent = String(counts.ge35);
|
||
s40.textContent = String(counts.ge40);
|
||
if (windDir != null && isFinite(windDir)) { sDir.textContent = String(Math.round(windDir)); } else { sDir.textContent = '--'; }
|
||
if (windSpd != null && isFinite(windSpd)) { sSpd.textContent = String(Math.round((windSpd as number)*10)/10); } else { sSpd.textContent = '--'; }
|
||
box.style.display = 'block';
|
||
} catch {}
|
||
}
|
||
|
||
private countDbzInPolygon(poly: [number,number][]): { ge30:number; ge35:number; ge40:number } | null {
|
||
// 需要已有雷达瓦片数据
|
||
const tiles = (this.tileLastList || []).filter(it => it && it.product==='radar');
|
||
if (!tiles.length) return null;
|
||
const ptInPoly = (x:number,y:number, polygon:[number,number][]) => {
|
||
// ray casting
|
||
let inside = false;
|
||
for (let i=0, j=polygon.length-1; i<polygon.length; j=i++) {
|
||
const xi = polygon[i][0], yi = polygon[i][1];
|
||
const xj = polygon[j][0], yj = polygon[j][1];
|
||
const intersect = ((yi>y) !== (yj>y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-9) + xi);
|
||
if (intersect) inside = !inside;
|
||
}
|
||
return inside;
|
||
};
|
||
let c30=0, c35=0, c40=0;
|
||
for (const t of tiles) {
|
||
const { west, south, east, north, width, height } = t.meta;
|
||
const dlon = (east - west) / width;
|
||
const dlat = (north - south) / height;
|
||
const vals: (number|null)[][] = t.values || [];
|
||
for (let row=0; row<height; row++) {
|
||
const lat = south + (row + 0.5) * dlat;
|
||
const srcRow = vals[row] as (number|null)[];
|
||
if (!srcRow) continue;
|
||
for (let col=0; col<width; col++) {
|
||
const v = srcRow[col];
|
||
if (v == null || v < 30) continue; // 低于30无需落点判断
|
||
const lon = west + (col + 0.5) * dlon;
|
||
if (!ptInPoly(lon, lat, poly)) continue;
|
||
if (v >= 30) c30++;
|
||
if (v >= 35) c35++;
|
||
if (v >= 40) c40++;
|
||
}
|
||
}
|
||
}
|
||
return { ge30: c30, ge35: c35, ge40: c40 };
|
||
}
|
||
|
||
addImageOverlayFromCanvas(canvas: HTMLCanvasElement, extent4326: [number,number,number,number]) {
|
||
const ol: any = (window as any).ol; if (!ol || !this.map) return;
|
||
const proj = this.map.getView().getProjection();
|
||
const extentProj = ol.proj.transformExtent(extent4326, 'EPSG:4326', proj);
|
||
const src = new ol.source.ImageStatic({ url: canvas.toDataURL('image/png'), imageExtent: extentProj, projection: proj });
|
||
const layer = new ol.layer.Image({ source: src, opacity: 0.8, visible: true });
|
||
this.tileOverlayGroup.getLayers().push(layer);
|
||
}
|
||
|
||
async renderTilesOnMap(product: 'none'|'radar'|'rain', tiles: any[]) {
|
||
this.clearTileOverlays();
|
||
const lastList: any[] = [];
|
||
for (const t of tiles) {
|
||
const w = t.width, h = t.height; if (!w||!h||!t.values) continue;
|
||
const canvas = document.createElement('canvas'); canvas.width=w; canvas.height=h;
|
||
const ctx = canvas.getContext('2d')!; const img = ctx.createImageData(w,h);
|
||
const radarColors = [[0,0,255],[0,191,255],[0,255,255],[127,255,212],[124,252,0],[173,255,47],[255,255,0],[255,215,0],[255,165,0],[255,140,0],[255,69,0],[255,0,0],[220,20,60],[199,21,133],[139,0,139]];
|
||
const rainEdges = [0,5,7.5,10,12.5,15,17.5,20,25,30,40,50,75,100, Infinity];
|
||
const rainColors = [
|
||
[126,212,121], [126,212,121], [110,200,109], [97,169,97], [81,148,76], [90,158,112],
|
||
[143,194,254], [92,134,245], [66,87,240], [45,48,214], [26,15,166], [63,22,145], [191,70,148], [213,1,146], [213,1,146]
|
||
];
|
||
for (let row=0; row<h; row++) {
|
||
const srcRow = t.values[row] as (number|null)[];
|
||
const dstRow = (h - 1 - row);
|
||
for (let col=0; col<w; col++) {
|
||
const v = srcRow[col];
|
||
const off = (dstRow*w + col)*4;
|
||
if (v==null || v===0) { img.data[off+3]=0; continue; }
|
||
if (product==='rain') {
|
||
let idx=0; while(idx<rainEdges.length-1 && !(v>=rainEdges[idx] && v<rainEdges[idx+1])) idx++;
|
||
const c = rainColors[Math.min(idx, rainColors.length-1)]; img.data[off]=c[0]; img.data[off+1]=c[1]; img.data[off+2]=c[2]; img.data[off+3]=220;
|
||
} else {
|
||
let bin = Math.floor(Math.max(0, Math.min(75, v))/5); if (bin>=radarColors.length) bin=radarColors.length-1;
|
||
const c = radarColors[bin]; img.data[off]=c[0]; img.data[off+1]=c[1]; img.data[off+2]=c[2]; img.data[off+3]=220;
|
||
}
|
||
}
|
||
}
|
||
ctx.putImageData(img,0,0);
|
||
this.addImageOverlayFromCanvas(canvas, [t.west, t.south, t.east, t.north]);
|
||
lastList.push({ product, meta: { west: t.west, south: t.south, east: t.east, north: t.north, width: w, height: h }, values: t.values });
|
||
}
|
||
this.tileLastList = lastList;
|
||
}
|
||
|
||
onProductChange() { this.reloadTileTimesAndShow(); }
|
||
async reloadTileTimesAndShow() {
|
||
if (this.tileProduct==='none') { this.clearTileOverlays(); this.tileTimes=[]; this.tileDt=''; return; }
|
||
await this.loadTileTimes(this.tileProduct);
|
||
}
|
||
|
||
private 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}`;
|
||
}
|
||
|
||
async query(suppressUX: boolean = false) {
|
||
const dec = this.decimalId.trim();
|
||
if (!dec) return;
|
||
const sid = this.makeStationIdFromHex(dec);
|
||
if (!sid) return;
|
||
const toFmt = (s: string) => s.replace('T',' ') + ':00';
|
||
const from = toFmt(this.start);
|
||
const to = toFmt(this.end);
|
||
this.isLoading = true;
|
||
try {
|
||
[this.history, this.forecast] = await Promise.all([
|
||
this.api.getHistory(dec, from, to, this.interval),
|
||
this.provider ? this.api.getForecast(sid, from, to, this.provider, 3) : Promise.resolve([])
|
||
]);
|
||
} finally {
|
||
this.isLoading = false;
|
||
}
|
||
this.showPanels = true;
|
||
if (!suppressUX) this.isMapCollapsed = true;
|
||
const st = this.stations.find(s => s.station_id === sid);
|
||
const ol: any = (window as any).ol;
|
||
if (!suppressUX) {
|
||
if (st && ol && typeof st.longitude === 'number' && typeof st.latitude === 'number' && this.map) {
|
||
this.map.getView().animate({ center: ol.proj.fromLonLat([st.longitude, st.latitude]), zoom: 11, duration: 400 });
|
||
}
|
||
}
|
||
this.selectedLocation = (st && st.location) ? st.location : '';
|
||
const titleName = st?.name || st?.station_alias || st?.station_id || '';
|
||
this.selectedTitle = titleName ? `${titleName}${this.selectedLocation ? ` | ${this.selectedLocation}` : ''}` : (this.selectedLocation || '');
|
||
this.reloadTileTimesAndShow();
|
||
if (!suppressUX) {
|
||
setTimeout(()=>{ try{ this.map.updateSize(); }catch{} }, 300);
|
||
this.scrollToChart();
|
||
}
|
||
}
|
||
|
||
onSelectStation(ev: { stationId: string, hex: string }) {
|
||
this.decimalId = ev.hex;
|
||
this.query();
|
||
}
|
||
|
||
prevTile() {
|
||
if (!this.tileTimes || this.tileTimes.length===0) return;
|
||
if (this.tileIndex < this.tileTimes.length-1) {
|
||
this.tileIndex += 1;
|
||
this.tileDt = this.tileTimes[this.tileIndex];
|
||
this.renderTilesAt(this.tileDt);
|
||
}
|
||
}
|
||
|
||
nextTile() {
|
||
if (!this.tileTimes || this.tileTimes.length===0) return;
|
||
if (this.tileIndex > 0) {
|
||
this.tileIndex -= 1;
|
||
this.tileDt = this.tileTimes[this.tileIndex];
|
||
this.renderTilesAt(this.tileDt);
|
||
}
|
||
}
|
||
|
||
toggleMap() {
|
||
this.isMapCollapsed = !this.isMapCollapsed;
|
||
setTimeout(()=>{ try{ this.map.updateSize(); }catch{} }, 300);
|
||
}
|
||
|
||
private scrollToChart() {
|
||
const el = document.getElementById('chartSection');
|
||
if (el) { try { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} }
|
||
}
|
||
|
||
private showTileTooltip(evt:any) {
|
||
const tip = document.getElementById('tileValueTooltip');
|
||
if (!tip || !this.map || !this.tileLastList || this.tileLastList.length===0) { if (tip) tip.style.display='none'; return; }
|
||
try {
|
||
const coord = this.map.getEventCoordinate(evt.originalEvent);
|
||
const lonlat = (window as any).ol.proj.transform(coord, this.map.getView().getProjection(), 'EPSG:4326');
|
||
const lon = lonlat[0], lat = lonlat[1];
|
||
let value: number|null = null; let unit = '';
|
||
for (const it of this.tileLastList) {
|
||
const { west,south,east,north,width,height } = it.meta;
|
||
if (lon < west || lon > east || lat < south || lat > north) continue;
|
||
const px = Math.floor((lon - west) / ((east - west) / width));
|
||
const py = Math.floor((lat - south) / ((north - south) / height));
|
||
if (px < 0 || px >= width || py < 0 || py >= height) continue;
|
||
const v = it.values?.[py]?.[px];
|
||
if (v != null) { value = Number(v); unit = it.product==='rain' ? 'mm' : 'dBZ'; break; }
|
||
}
|
||
if (value == null) { tip.style.display='none'; return; }
|
||
tip.textContent = `${value.toFixed(1)} ${unit}`;
|
||
const px = evt.pixel[0] + 12; const py = evt.pixel[1] + 12;
|
||
tip.style.left = `${px}px`; tip.style.top = `${py}px`; tip.style.display='block';
|
||
} catch { if (tip) tip.style.display='none'; }
|
||
}
|
||
}
|
||
|
||
bootstrapApplication(AppComponent).catch(err => console.error(err));
|