UNPKG

windelsis

Version:

`Windelsis` is a JavaScript library that visualizes weather data on interactive maps using Leaflet. It provides tools to render temperature, precipitation, and wind velocity layers, as well as utilities for grid-based weather data management.

660 lines (571 loc) 25 kB
import { DataRenderer, COLOR_SCALES } from "./DataRenderer.js"; './DataRenderer.js'; import GridUtils from './gridUtils.js'; import { GridPoint } from "./gridPoint.js"; import { openMeteoApiCaller } from './apiService.js'; export class MapManager { constructor(mapId, apiCaller, options = {}) { this.apiCaller = apiCaller ?? openMeteoApiCaller; this.map = null; this.velocityLayer = null; this.temperatureRenderer = null; this.precipitationRenderer = null; this.layerControl = null; this.isUpdating = false; this.lastZoom = null; this.updateTimeout = null; this.currentGrid = { bounds: null, pointDistance: null, grid: [], gridPointsMap: null, dx: null, dy: null, nx: null, ny: null }; this.gridsMap = null; this.eventHandlers = { moveend: null, zoomend: null, click: null, }; this.handlersPaused = false; this.options = { randomData: options.randomData ?? true, demoMode: options.demoMode ?? true, // for debug center: options.center || [42.8, -8], zoom: options.zoom || 8, minZoom: options.minZoom || 3, maxZoom: options.maxZoom || 18, pointDistance: options.pointDistance ?? null, maxGridPoints: options.maxGridPoints ?? 600, maxBounds: options.maxBounds ? L.latLngBounds([options.maxBounds[0], options.maxBounds[1]]) : null, mapAdjustment: options.mapAdjustment || 0, windyParameters: {...this.getDefaultWindyParameters(), ...options.windyParams}, dateType: options.dateType || 'current', start_date: options.start_date || null, end_date: options.end_date || null, hour_index: options.hour_index || null, // to-do: just use daily data and store it layerControlPosition: options.layerControlPosition ?? 'topleft', temp_color_scale: options.temp_color_scale ?? COLOR_SCALES.temperature, prec_color_scale: options.prec_color_scale ?? COLOR_SCALES.precipitation, }; this.initialize(mapId); } initialize(mapId) {//console.log("######## initialize ########"); if (typeof mapId === 'string') { this.map = L.map(mapId, { center: this.options.center, zoom: this.options.zoom }); } else if (mapId instanceof L.Map) { this.map = mapId; // if map id is a map instance, use it directly } else { throw new Error('Invalid mapId. It should be a string or an instance of L.Map'); } // Add base layers if(this.options.demoMode) this.setupBaseLayers(); else this.layerControl = L.control.layers({},null,{ position: this.options.layerControlPosition }).addTo(this.map); // Setup grid points this.currentGrid.gridPointsMap = new Map(); this.gridsMap = new Map(); // Initialize weather layers this.initializeWindLayer(); this.initializeTemperatureLayer(); this.initializePrecipitationLayer(); // Initialize event handlers this.initializeEventHandlers(); // Initialize event listeners for layer control this.addLayerControlListeners(); } getDefaultWindyParameters() { return { minVelocity: 0, maxVelocity: 10, velocityScale: 0.005, particleAge: 90, lineWidth: 1, particleMultiplier: 1/300, frameRate: 15, colorScale: [ "rgb(0, 0, 128)", "rgb(0, 0, 255)", "rgb(75, 0, 130)", "rgb(138, 43, 226)", "rgb(255, 0, 255)", "rgb(255, 0, 200)", "rgb(255, 0, 150)", "rgb(255, 0, 100)", "rgb(255, 0, 50)", "rgb(255, 0, 0)" ] }; } getPointDistanceFromBounds(bounds) { return GridUtils.calculateOptimalPointDistance(bounds, this.options); } // Useless getPointDistanceFromZoom(zoom) {//console.log("getPointDistanceFromZoom", zoom); if (zoom <= 7) return 1; else if (zoom > 7 && zoom <= 8) return 0.5; else if (zoom > 8 && zoom <= 9) return 0.25; else if (zoom > 9 && zoom < 11) return 0.125; else return 0.0625; } getWeatherDataAt(lat, lng) { const { gridPointsMap, dx, dy, nx, ny, bounds } = this.currentGrid; if(!bounds) return null; // No bounds available const latNW = bounds.getNorthWest().lat; const lonSW = bounds.getSouthWest().lng; // Get grid cell indices const i = Math.floor((latNW - lat) / dy); const j = Math.floor((lng - lonSW) / dx); if (i < 0 || i >= ny - 1 || j < 0 || j >= nx - 1) return null; // Out of bounds // Calculate corner coordinates and retrieve grid points const corners = [ { lat: latNW - i * dy, lng: lonSW + j * dx }, { lat: latNW - i * dy, lng: lonSW + (j + 1) * dx }, { lat: latNW - (i + 1) * dy, lng: lonSW + j * dx }, { lat: latNW - (i + 1) * dy, lng: lonSW + (j + 1) * dx } ]; const points = corners.map(({ lat, lng }) => gridPointsMap.get(GridUtils.generatePointKey(lat, lng))); if (points.some(p => !p)) { throw new Error('Missing grid points for interpolation'); } const [p1, p2, p3, p4] = points; const [x1, x2] = [p1.longitude, p2.longitude]; const [y1, y2] = [p1.latitude, p3.latitude]; // Draw interpolation area if(this.options.demoMode) { const rectangle = L.rectangle([[y1, x1], [y2, x2]], { color: 'red', weight: 1 }).addTo(this.map); this.map.once('click', () => this.map.removeLayer(rectangle)); } const interpolate = (v11, v21, v12, v22) => { const R1 = ((x2 - lng) / (x2 - x1)) * v11 + ((lng - x1) / (x2 - x1)) * v21; const R2 = ((x2 - lng) / (x2 - x1)) * v12 + ((lng - x1) / (x2 - x1)) * v22; return ((y2 - lat) / (y2 - y1)) * R1 + ((lat - y1) / (y2 - y1)) * R2; }; const temperature = interpolate(p1.weatherData.temperature, p2.weatherData.temperature, p3.weatherData.temperature, p4.weatherData.temperature); const precipitation = interpolate(p1.weatherData.precipitation, p2.weatherData.precipitation, p3.weatherData.precipitation, p4.weatherData.precipitation); const windSpeed = interpolate(p1.weatherData.wind.speed, p2.weatherData.wind.speed, p3.weatherData.wind.speed, p4.weatherData.wind.speed); const windDirection = interpolate(p1.weatherData.wind.direction, p2.weatherData.wind.direction, p3.weatherData.wind.direction, p4.weatherData.wind.direction); const precipitationProb = interpolate(p1.weatherData.precipitation_prob, p2.weatherData.precipitation_prob, p3.weatherData.precipitation_prob, p4.weatherData.precipitation_prob); return { temperature, precipitation, precipitationProb, wind: { speed: windSpeed, direction: windDirection } }; } setupBaseLayers() { const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'); const Esri_WorldImagery = L.tileLayer('http://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'); const cartoDbDark = L.tileLayer('http://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'); const cartoDbLight = L.tileLayer('http://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'); this.layerControl = L.control.layers({ Satellite: Esri_WorldImagery, 'OpenStreetMap': osm, 'Carto Db Dark': cartoDbDark, 'cartoDbLight': cartoDbLight, },null,{ position: this.options.layerControlPosition }).addTo(this.map); cartoDbDark.addTo(this.map); } destroy() { // Remove all layers and clear renderers if (this.layerControl && this.layerControl._layers) { // Iterar sobre cada capa registrada en el control for (let key in this.layerControl._layers) { const layerInfo = this.layerControl._layers[key]; if (this.map.hasLayer(layerInfo.layer)) this.map.removeLayer(layerInfo.layer); } } if (this.layerControl) { this.map.removeControl(this.layerControl); this.layerControl = null; } // Clear event handlers this.map.off('moveend', this.eventHandlers.moveend); this.map.off('zoomend', this.eventHandlers.zoomend); this.map.off('click', this.eventHandlers.click); // Clear timeouts and data if (this.updateTimeout) { clearTimeout(this.updateTimeout); this.updateTimeout = null; } // Clear grid data this.currentGrid = { bounds: null, grid: [], gridPointsMap: null, pointDistance: null, dx: null, dy: null, nx: null, ny: null }; this.gridsMap = null; this.isUpdating = false; this.lastZoom = null; } debounce(func, delay) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => func.apply(this, args), delay); }; } initializeEventHandlers() {//console.log("######## initializeEventHandlers ########"); this.eventHandlers.moveend = this.debounce(() => {//console.log("Procesando 'moveend'..."); if(this.gridsMap.size === 0) return; const mapBounds = this.map.getBounds(); const gridBounds = this.currentGrid.bounds; const isInside = gridBounds.contains(mapBounds.getNorthEast()) && gridBounds.contains(mapBounds.getSouthWest()); if (!isInside) {//console.log("Hole map is not in the grid"); const { bounds } = GridUtils.adjustAndCount(this.options.maxBounds ?? mapBounds, this.currentGrid.pointDistance, this.options.mapAdjustment); this.currentGrid = GridUtils.gridBuilder(this.map, this.currentGrid.pointDistance, bounds, this.currentGrid.gridPointsMap, this.options); this.forceUpdate(); } }, 300); this.eventHandlers.zoomend = this.debounce(async () => {//console.log("Procesando 'zoomend'..."); if(this.gridsMap.size === 0) return; const mapBounds = this.map.getBounds(); const gridBounds = this.currentGrid.bounds; const isInside = gridBounds.contains(mapBounds.getNorthEast()) && gridBounds.contains(mapBounds.getSouthWest()); var {pointDistance, bounds} = this.getPointDistanceFromBounds(this.options.maxBounds ?? mapBounds); const pointChanged = pointDistance !== this.currentGrid.pointDistance; if (!isInside || pointChanged) {//console.log("Hole map is not in the grid"); pointDistance = this.options.pointDistance ?? pointDistance; this.currentGrid = GridUtils.gridBuilder(this.map, pointDistance, bounds, this.currentGrid.gridPointsMap, this.options); this.forceUpdate(); } }, 300); this.eventHandlers.click = this.debounce((e) => { var lat = e.latlng.lat; var lng = e.latlng.lng; // Call the API to fetch weather data for the clicked point const grid = { grid: [new GridPoint(lat, lng)], }; const options = { randomData: this.options.randomData, dateType: this.options.dateType, }; if(this.options.demoMode) { const pointData = this.fetchWeatherData(grid, options); console.log('API data:\n', pointData); } const weatherData = this.getWeatherDataAt(lat, lng); var popupContent = `<b>Coordinates:</b><br>` + `Lat: ${lat.toFixed(5)}<br>` + `Lng: ${lng.toFixed(5)}<br>`; if (weatherData) { popupContent += `<b>Temperature:</b> ${weatherData.temperature.toFixed(2)}°C<br>` + `<b>Precipitation:</b> ${weatherData.precipitation.toFixed(2)} mm<br>` + `<b>Wind:</b> ${weatherData.wind.speed.toFixed(2)} m/s, ${weatherData.wind.direction.toFixed(0)}°<br>` + `<b>Precipitation Probability:</b> ${weatherData.precipitationProb.toFixed(2)}%`; } else { popupContent += `<b>Warning:</b> The selected point is out of the bounds of the current grid.`; } var popup = L.popup({ closeOnClick: true, className: 'windelsis-popup' // CSS class }) .setLatLng(e.latlng) .setContent(popupContent) .openOn(this.map); }, 300); this.map.on('moveend', this.eventHandlers.moveend); // same handler cuz of calculateOptimalPointDistance implementation this.map.on('zoomend', this.eventHandlers.zoomend); this.map.on('click', this.eventHandlers.click); } pauseHandlers() { if (this.handlersPaused) return; this.map.off('moveend', this.eventHandlers.moveend); this.map.off('zoomend', this.eventHandlers.zoomend); this.handlersPaused = true; console.log('Handlers paused'); } resumeHandlers() { if (!this.handlersPaused) return; this.map.on('moveend', this.eventHandlers.moveend); this.map.on('zoomend', this.eventHandlers.zoomend); this.handlersPaused = false; this.eventHandlers.zoomend(); console.log('Handlers resumed'); } toggleUpdates() { if (this.handlersPaused) this.resumeHandlers(); else this.pauseHandlers(); return this.handlersPaused; } addLayerControlListeners(){ this.map.on("overlayadd", (e) => {//console.log("overlayadd", e.layer); if(e.layer === this.temperatureRenderer.canvasLayer) { if(this.map.hasLayer(this.precipitationRenderer.canvasLayer)){//console.log("removing precipitation layer\n####"); this.map.removeLayer(this.precipitationRenderer.canvasLayer);} this.velocityLayer.setOptions({ colorScale: ["rgb(255, 255, 255)"] }); if (this.map.hasLayer(this.velocityLayer)) { this.velocityLayer.remove(); this.velocityLayer.addTo(this.map); } }else if(e.layer === this.precipitationRenderer.canvasLayer) { if(this.map.hasLayer(this.temperatureRenderer.canvasLayer)){//console.log("removing temperature layer\n####"); this.map.removeLayer(this.temperatureRenderer.canvasLayer);} this.velocityLayer.setOptions({ colorScale: ["rgb(255, 255, 255)"] }); if (this.map.hasLayer(this.velocityLayer)) { this.velocityLayer.remove(); this.velocityLayer.addTo(this.map); } }/*else if (e.layer === this.velocityLayer) { if (!this.map.hasLayer(this.temperatureRenderer.canvasLayer) || !this.map.hasLayer(this.precipitationRenderer.canvasLayer)) { this.setWindyParameters(this.options.windyParameters); } }When adding any of the temp or prec layers, windy is added again and the ‘if’ is read.*/ }); this.map.on("overlayremove", (e) => {//console.log("overlayremove", e.layer); // Cuando se remueve la capa de temperatura if(e.layer === this.temperatureRenderer.canvasLayer || e.layer === this.precipitationRenderer.canvasLayer) { this.setWindyParameters(this.options.windyParameters); if (this.map.hasLayer(this.velocityLayer)) { this.velocityLayer.remove(); this.velocityLayer.addTo(this.map); } } }); } initializeTemperatureLayer() {//console.log("######## initializeTemperatureLayer ########"); this.temperatureRenderer = new DataRenderer(this.map, [], { pixelSize: 5, opacity: 0.3, controlName: 'Temperature Layer', colorScale: this.options.temp_color_scale, layerControl: this.layerControl, demoMode: this.options.demoMode }); this.temperatureRenderer.canvasLayer = this.temperatureRenderer.init(); } initializePrecipitationLayer() {//console.log("######## initializePrecipitationLayer ########"); this.precipitationRenderer = new DataRenderer(this.map, [], { pixelSize: 5, opacity: 0.3, controlName: 'Precipitation Layer', colorScale: this.options.prec_color_scale, layerControl: this.layerControl, demoMode: this.options.demoMode }); this.precipitationRenderer.canvasLayer = this.precipitationRenderer.init(); } initializeWindLayer() {//console.log("######## initializeWindLayer ########"); this.velocityLayer = L.velocityLayer({ displayValues: true, displayOptions: { velocityType: "Global Wind", emptyString: "No velocity data" }, }); this.layerControl.addOverlay(this.velocityLayer, "Wind Layer"); // Add the layer this.velocityLayer.setOptions(this.options.windyParameters); } forceUpdate() { // (to-do) Check if the is new points in this.currentGrid.grid this.updateWeatherData().then(() => { //console.log(this.currentGrid); this.updateTemperatureData(); this.updatePrecipitationData(); this.updateWindData(); this.currentGrid.grid = this.currentGrid.grid.filter(point => point.isStale());; // Reset the grid new GridPoints array for the next update this.gridsMap.set(this.gridKey, this.currentGrid); }); } async updateWeatherData(){ const standardizedData = await this.fetchWeatherData(this.currentGrid, this.options); // Iterate over each point and assign the corresponding weather data. if(!this.options.randomData) standardizedData.forEach((weatherData, index) => this.currentGrid.grid[index].setWeatherData(weatherData)); } updateTemperatureData() {//console.log("######## updateTemperatureData ########"); this.temperatureRenderer.update(GridUtils.tempDataBuilder(this.currentGrid)); } updatePrecipitationData() {//console.log("######## updatePrecipitationData ########"); this.precipitationRenderer.update(GridUtils.precipDataBuilder(this.currentGrid)); } updateWindData() {//console.log("######## updateWindData ########"); this.velocityLayer.setData(GridUtils.windyDataBuilder(this.currentGrid, this.options)); } updateConfig(newConfig = {}) { Object.assign(this.options, { pointDistance: newConfig.pointDistance ?? this.options.pointDistance, maxGridPoints: newConfig.maxGridPoints ?? this.options.maxGridPoints, maxBounds: newConfig.maxBounds ? L.latLngBounds([newConfig.maxBounds[0], newConfig.maxBounds[1]]) : this.options.maxBounds, windyParameters: newConfig.windyParameters ? { ...this.options.windyParameters, ...newConfig.windyParameters } : this.options.windyParameters }); if (newConfig.windyParameters) { this.velocityLayer.setOptions(this.options.windyParameters); if (this.map.hasLayer(this.velocityLayer)) { this.velocityLayer.remove(); this.velocityLayer.addTo(this.map); } } if (newConfig.temperatureOpacity || newConfig.temperatureColorScale){ const tempParams = {}; if (newConfig.temperatureOpacity) tempParams.opacity = newConfig.temperatureOpacity; if (newConfig.temperatureColorScale) tempParams.colorScale = newConfig.temperatureColorScale; this.temperatureRenderer.setOptions(tempParams); if (this.map.hasLayer(this.temperatureRenderer.canvasLayer)) { this.updateTemperatureData(); } } if (newConfig.precipitationOpacity || newConfig.precipitationColorScale){ const precipParams = {}; if (newConfig.precipitationOpacity) precipParams.opacity = newConfig.precipitationOpacity; if (newConfig.precipitationColorScale) precipParams.colorScale = newConfig.precipitationColorScale; this.precipitationRenderer.setOptions(precipParams); if (this.map.hasLayer(this.precipitationRenderer.canvasLayer)) { this.updatePrecipitationData(); } } if (newConfig.pointDistance || newConfig.maxBounds || newConfig.maxGridPoints) { var {pointDistance, bounds} = this.getPointDistanceFromBounds(this.options.maxBounds ?? this.map.getBounds()); this.currentGrid = GridUtils.gridBuilder(this.map, this.options.pointDistance ?? pointDistance, bounds, this.currentGrid.gridPointsMap, this.options); this.forceUpdate(); } } getCurrentData() { const key = 'current'; if (this.gridsMap.has(key)) { this.currentGrid = this.gridsMap.get(key);//console.log("Exists", this.currentGrid); } else { const mapBounds = this.options.maxBounds ?? this.map.getBounds(); var { pointDistance, bounds } = this.getPointDistanceFromBounds(mapBounds); let auxGrid = GridUtils.gridBuilder( this.map, pointDistance, bounds, new Map(), this.options ); this.gridsMap.set(key, auxGrid); this.currentGrid = this.gridsMap.get(key); } return this.setDateType('current', { key }); } getWeatherData(date) { const key = `forecast_${date}`; if (this.gridsMap.has(key)) { this.currentGrid = this.gridsMap.get(key);//console.log("Exists", this.currentGrid); } else { const mapBounds = this.options.maxBounds ?? this.map.getBounds(); var { pointDistance, bounds } = this.getPointDistanceFromBounds(mapBounds); const auxGrid = GridUtils.gridBuilder( this.map, pointDistance, bounds, new Map(), this.options ); this.gridsMap.set(key, auxGrid); this.currentGrid = auxGrid; } return this.setDateType('forecast', { key, date }); } getHourlyWeatherData(date, hour_index) { const key = `forecast_hourly_${date}_${hour_index}`; if (this.gridsMap.has(key)) { this.currentGrid = this.gridsMap.get(key); } else { const mapBounds = this.options.maxBounds ?? this.map.getBounds(); var { pointDistance, bounds } = this.getPointDistanceFromBounds(mapBounds); const auxGrid = GridUtils.gridBuilder( this.map, pointDistance, bounds, new Map(), this.options ); this.gridsMap.set(key, auxGrid); this.currentGrid = auxGrid; } return this.setDateType('forecast_hourly', { key, date, hour_index }); } setDateType(dateType, options = {}) { this.options.dateType = dateType; if (dateType === 'forecast' || dateType === 'forecast_hourly') { this.options.start_date = options.date; this.options.end_date = options.date; this.options.hour_index = options.hour_index || null; } else { this.options.start_date = null; this.options.end_date = null; this.options.hour_index = null; } this.gridKey = options.key; return this.forceUpdate(); } setWindyParameters(parameters) { //console.log("######## setWindyParameters ########",parameters); this.options.windyParameters = { ...this.options.windyParameters, ...parameters }; if (this.velocityLayer) { this.velocityLayer.setOptions(this.options.windyParameters); } } showWeatherPopup(lat, lng) { const pointKey = GridUtils.generatePointKey(lat, lng); const gridPoint = this.currentGrid.gridPointsMap.get(pointKey); if (!gridPoint) { console.error("no gridPoint found for the given coordinates:", lat, lng); return; } const { temperature, wind, timestamp } = gridPoint.weatherData; const popupContent = ` <div> <h4>Datos Meteorológicos</h4> <p><strong>Coordenadas:</strong> Lat: ${lat.toFixed(4)}, Lng: ${lng.toFixed(4)}</p> <p><strong>Temperatura:</strong> ${temperature !== null ? temperature + ' °C' : 'No disponible'}</p> <p><strong>Viento:</strong> ${wind.speed !== null ? wind.speed + ' m/s' : 'No disponible'} ${wind.direction !== null ? ' - ' + wind.direction + '°' : ''}</p> ${timestamp ? `<p><strong>Actualizado:</strong> ${new Date(timestamp).toLocaleString()}</p>` : ''} </div> `; L.popup({ closeOnClick: true, autoClose: false, }).setLatLng([lat, lng]) .setContent(popupContent) .openOn(this.map); } async fetchWeatherData(grid, options) { const points = grid.grid || grid; // Use grid.grid if available; otherwise, use grid directly const apiOptions = { dateType: options.dateType, start_date: options.start_date, end_date: options.end_date, hour_index: options.hour_index, } if (options.randomData) { GridUtils.generateRandomGridData(points); return; } try { let standardizedDataArray = []; if (points && points.length > 0) { standardizedDataArray = await this.apiCaller(points, apiOptions); } return standardizedDataArray; } catch (error) { console.error('Fetching weather data failed:', error); throw error; } } }