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(/^$/, ''); 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; 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; iy) !== (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= 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=rainEdges[idx] && v=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));