666 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));