Restore Regional Observations map
This commit is contained in:
parent
628270ac2e
commit
1faa580b18
15 changed files with 1006 additions and 302 deletions
|
|
@ -3,14 +3,17 @@ import { DateTime } from '../vendor/auto/luxon.mjs';
|
|||
import { safeJson } from './utils/fetch.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import {
|
||||
createMap,
|
||||
addBaseLayers,
|
||||
setPrimaryLocationMarker,
|
||||
loadNearbyObservationMarkers,
|
||||
clearMarkers,
|
||||
} from './utils/leaflet-weather-map.mjs';
|
||||
|
||||
class Radar extends WeatherDisplay {
|
||||
static metadataUrl = 'https://api.rainviewer.com/public/weather-maps.json';
|
||||
|
||||
static baseMapUrl = 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}';
|
||||
|
||||
static boundaryMapUrl = 'https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}';
|
||||
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Local Radar');
|
||||
|
||||
|
|
@ -21,6 +24,7 @@ class Radar extends WeatherDisplay {
|
|||
this.baseLayer = null;
|
||||
this.boundaryLayer = null;
|
||||
this.locationMarker = null;
|
||||
this.nearbyMarkers = [];
|
||||
this.radarLayers = [];
|
||||
this.mapFrames = [];
|
||||
this.radarHost = '';
|
||||
|
|
@ -42,6 +46,7 @@ class Radar extends WeatherDisplay {
|
|||
this.map.invalidateSize();
|
||||
this.map.setView([this.weatherParameters.latitude, this.weatherParameters.longitude], 7);
|
||||
this.updateLocationMarker();
|
||||
await this.updateNearbyMarkers();
|
||||
|
||||
const radarMetadata = await safeJson(Radar.metadataUrl, {
|
||||
retryCount: 2,
|
||||
|
|
@ -67,6 +72,7 @@ class Radar extends WeatherDisplay {
|
|||
} catch (error) {
|
||||
console.error(`Failed to initialize radar: ${error.message}`);
|
||||
this.clearRadarLayers();
|
||||
this.clearNearbyMarkers();
|
||||
this.timing.totalScreens = 0;
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
}
|
||||
|
|
@ -80,37 +86,8 @@ class Radar extends WeatherDisplay {
|
|||
throw new Error('Radar map container not found');
|
||||
}
|
||||
|
||||
this.map = window.L.map(mapElement, {
|
||||
zoomControl: false,
|
||||
dragging: false,
|
||||
touchZoom: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
tap: false,
|
||||
attributionControl: false,
|
||||
preferCanvas: true,
|
||||
});
|
||||
|
||||
this.baseLayer = window.L.tileLayer(Radar.baseMapUrl, {
|
||||
maxZoom: 10,
|
||||
minZoom: 1,
|
||||
crossOrigin: true,
|
||||
className: 'radar-base-layer',
|
||||
});
|
||||
|
||||
this.baseLayer.addTo(this.map);
|
||||
|
||||
this.boundaryLayer = window.L.tileLayer(Radar.boundaryMapUrl, {
|
||||
maxZoom: 10,
|
||||
minZoom: 1,
|
||||
opacity: 0.6,
|
||||
crossOrigin: true,
|
||||
className: 'radar-boundary-layer',
|
||||
});
|
||||
|
||||
this.boundaryLayer.addTo(this.map);
|
||||
this.map = createMap(mapElement);
|
||||
({ baseLayer: this.baseLayer, boundaryLayer: this.boundaryLayer } = addBaseLayers(this.map));
|
||||
}
|
||||
|
||||
resetRadarLayers() {
|
||||
|
|
@ -162,23 +139,27 @@ class Radar extends WeatherDisplay {
|
|||
|
||||
updateLocationMarker() {
|
||||
if (!this.map) return;
|
||||
|
||||
if (this.locationMarker && this.map.hasLayer(this.locationMarker)) {
|
||||
this.map.removeLayer(this.locationMarker);
|
||||
}
|
||||
|
||||
this.locationMarker = window.L.circleMarker([
|
||||
this.locationMarker = setPrimaryLocationMarker(
|
||||
this.map,
|
||||
this.locationMarker,
|
||||
this.weatherParameters.latitude,
|
||||
this.weatherParameters.longitude,
|
||||
], {
|
||||
radius: 5,
|
||||
color: '#000',
|
||||
weight: 2,
|
||||
fillColor: '#ff0',
|
||||
fillOpacity: 1,
|
||||
interactive: false,
|
||||
className: 'location-marker',
|
||||
}).addTo(this.map);
|
||||
);
|
||||
}
|
||||
|
||||
clearNearbyMarkers() {
|
||||
this.nearbyMarkers = clearMarkers(this.map, this.nearbyMarkers);
|
||||
}
|
||||
|
||||
async updateNearbyMarkers() {
|
||||
if (!this.map) return;
|
||||
|
||||
this.clearNearbyMarkers();
|
||||
this.nearbyMarkers = await loadNearbyObservationMarkers(this.map, {
|
||||
latitude: this.weatherParameters.latitude,
|
||||
longitude: this.weatherParameters.longitude,
|
||||
});
|
||||
this.nearbyMarkers.forEach((marker) => marker.addTo(this.map));
|
||||
}
|
||||
|
||||
showFrame(screenIndex) {
|
||||
|
|
|
|||
|
|
@ -1,245 +1,115 @@
|
|||
// regional forecast and observations
|
||||
// type 0 = observations, 1 = first forecast, 2 = second forecast
|
||||
// regional observations display
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { distance as calcDistance } from './utils/calc.mjs';
|
||||
import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||
import { getSmallIcon } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import * as utils from './regionalforecast-utils.mjs';
|
||||
import { getPoint } from './utils/weather.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import filterExpiredPeriods from './utils/forecast-utils.mjs';
|
||||
|
||||
// map offset
|
||||
const mapOffsetXY = {
|
||||
x: 240,
|
||||
y: 117,
|
||||
};
|
||||
import {
|
||||
createMap,
|
||||
addBaseLayers,
|
||||
setPrimaryLocationMarker,
|
||||
loadNearbyObservationMarkers,
|
||||
clearMarkers,
|
||||
} from './utils/leaflet-weather-map.mjs';
|
||||
|
||||
class RegionalForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Regional Forecast', true);
|
||||
|
||||
// timings
|
||||
this.timing.totalScreens = 3;
|
||||
super(navId, elemId, 'Regional Observations', true);
|
||||
this.timing.totalScreens = 1;
|
||||
this.map = null;
|
||||
this.baseLayer = null;
|
||||
this.boundaryLayer = null;
|
||||
this.locationMarker = null;
|
||||
this.nearbyMarkers = [];
|
||||
this.nearbyMarkersKey = '';
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
if (!this.weatherParameters?.supportsNoaaDisplays) {
|
||||
this.data = [];
|
||||
this.timing.totalScreens = 0;
|
||||
|
||||
try {
|
||||
if (!window.L) {
|
||||
throw new Error('Leaflet is not available');
|
||||
}
|
||||
|
||||
await this.ensureMap();
|
||||
this.map.invalidateSize();
|
||||
this.map.setView([this.weatherParameters.latitude, this.weatherParameters.longitude], 6);
|
||||
this.locationMarker = setPrimaryLocationMarker(
|
||||
this.map,
|
||||
this.locationMarker,
|
||||
this.weatherParameters.latitude,
|
||||
this.weatherParameters.longitude,
|
||||
);
|
||||
this.nearbyMarkers = clearMarkers(this.map, this.nearbyMarkers);
|
||||
this.nearbyMarkersKey = '';
|
||||
|
||||
this.timing.totalScreens = 1;
|
||||
this.setStatus(STATUS.loaded);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize regional observations: ${error.message}`);
|
||||
this.nearbyMarkers = clearMarkers(this.map, this.nearbyMarkers);
|
||||
this.timing.totalScreens = 0;
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
}
|
||||
this.timing.totalScreens = 3;
|
||||
// regional forecast implements a silent reload
|
||||
// but it will not fall back to previously loaded data if data can not be loaded
|
||||
// there are enough other cities available to populate the map sufficiently even if some do not load
|
||||
}
|
||||
|
||||
// pre-load the base map
|
||||
let baseMap = 'images/maps/basemap.webp';
|
||||
if (weatherParameters.state === 'HI') {
|
||||
baseMap = 'images/maps/radar-hawaii.png';
|
||||
} else if (weatherParameters.state === 'AK') {
|
||||
baseMap = 'images/maps/radar-alaska.png';
|
||||
}
|
||||
this.elem.querySelector('.map img').src = baseMap;
|
||||
async refreshNearbyMarkers() {
|
||||
if (!this.map || !this.active) return;
|
||||
|
||||
// get user's location in x/y
|
||||
const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, mapOffsetXY.x, mapOffsetXY.y, weatherParameters.state);
|
||||
this.map.invalidateSize(false);
|
||||
this.map.setView([this.weatherParameters.latitude, this.weatherParameters.longitude], 6);
|
||||
|
||||
// get latitude and longitude limits
|
||||
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, mapOffsetXY.x, mapOffsetXY.y, this.weatherParameters.state);
|
||||
const bounds = this.map.getBounds();
|
||||
const markerKey = [
|
||||
this.weatherParameters.latitude.toFixed(2),
|
||||
this.weatherParameters.longitude.toFixed(2),
|
||||
bounds.getSouth().toFixed(2),
|
||||
bounds.getWest().toFixed(2),
|
||||
bounds.getNorth().toFixed(2),
|
||||
bounds.getEast().toFixed(2),
|
||||
].join(':');
|
||||
|
||||
// get a target distance
|
||||
let targetDistance = 2.4;
|
||||
if (this.weatherParameters.state === 'HI') targetDistance = 1;
|
||||
if (this.nearbyMarkers.length > 0 && this.nearbyMarkersKey === markerKey) return;
|
||||
|
||||
// make station info into an array
|
||||
const stationInfoArray = Object.values(StationInfo).map((station) => ({ ...station, targetDistance }));
|
||||
// combine regional cities with station info for additional stations
|
||||
// stations are intentionally after cities to allow cities priority when drawing the map
|
||||
const combinedCities = [...RegionalCities, ...stationInfoArray];
|
||||
|
||||
// Determine which cities are within the max/min latitude/longitude.
|
||||
const regionalCities = [];
|
||||
combinedCities.forEach((city) => {
|
||||
if (city.lat > minMaxLatLon.minLat && city.lat < minMaxLatLon.maxLat
|
||||
&& city.lon > minMaxLatLon.minLon && city.lon < minMaxLatLon.maxLon - 1) {
|
||||
// default to 1 for cities loaded from RegionalCities, use value calculate above for remaining stations
|
||||
const targetDist = city.targetDistance || 1;
|
||||
// Only add the city as long as it isn't within set distance degree of any other city already in the array.
|
||||
const okToAddCity = regionalCities.reduce((acc, testCity) => {
|
||||
const distance = calcDistance(city.lon, city.lat, testCity.lon, testCity.lat);
|
||||
return acc && distance >= targetDist;
|
||||
}, true);
|
||||
if (okToAddCity) regionalCities.push(city);
|
||||
}
|
||||
this.nearbyMarkers = clearMarkers(this.map, this.nearbyMarkers);
|
||||
this.nearbyMarkers = await loadNearbyObservationMarkers(this.map, {
|
||||
latitude: this.weatherParameters.latitude,
|
||||
longitude: this.weatherParameters.longitude,
|
||||
});
|
||||
this.nearbyMarkers.forEach((marker) => marker.addTo(this.map));
|
||||
this.nearbyMarkersKey = markerKey;
|
||||
}
|
||||
|
||||
// get a unit converter
|
||||
const temperatureConverter = temperatureUnit();
|
||||
async ensureMap() {
|
||||
if (this.map) return;
|
||||
|
||||
// get regional forecasts and observations using centralized safe Promise handling
|
||||
const regionalDataAll = await safePromiseAll(regionalCities.map(async (city) => {
|
||||
try {
|
||||
const point = city?.point ?? (await getAndFormatPoint(city.lat, city.lon));
|
||||
if (!point) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get Points for '${city.Name ?? city.city}'`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// start off the observation task
|
||||
const observationPromise = utils.getRegionalObservation(point, city);
|
||||
|
||||
const forecast = await safeJson(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/forecast`);
|
||||
if (!forecast) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Regional Forecast request for ${city.Name ?? city.city} failed`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// get XY on map for city
|
||||
const cityXY = utils.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, this.weatherParameters.state);
|
||||
|
||||
// wait for the regional observation if it's not done yet
|
||||
const observation = await observationPromise;
|
||||
|
||||
if (!observation) return false;
|
||||
|
||||
// format the observation the same as the forecast
|
||||
const regionalObservation = {
|
||||
daytime: !!/\/day\//.test(observation.icon),
|
||||
temperature: temperatureConverter(observation.temperature.value),
|
||||
name: utils.formatCity(city.city),
|
||||
icon: observation.icon,
|
||||
x: cityXY.x,
|
||||
y: cityXY.y,
|
||||
};
|
||||
|
||||
// preload the icon
|
||||
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
|
||||
|
||||
// filter out expired periods first, then use the next two periods for forecast
|
||||
const activePeriods = filterExpiredPeriods(forecast.properties.periods);
|
||||
|
||||
// ensure we have enough periods for forecast
|
||||
if (activePeriods.length < 3) {
|
||||
console.warn(`Insufficient active periods for ${city.Name ?? city.city}: only ${activePeriods.length} periods available`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// group together the current observation and next two periods
|
||||
return [
|
||||
regionalObservation,
|
||||
utils.buildForecast(activePeriods[1], city, cityXY),
|
||||
utils.buildForecast(activePeriods[2], city, cityXY),
|
||||
];
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error getting Regional Forecast data for '${city.name ?? city.city}': ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
|
||||
// filter out any false (unavailable data)
|
||||
const regionalData = regionalDataAll.filter((data) => data);
|
||||
|
||||
// test for data present
|
||||
if (regionalData.length === 0) {
|
||||
this.setStatus(STATUS.noData);
|
||||
return;
|
||||
const mapElement = this.elem.querySelector('.leaflet-map');
|
||||
if (!mapElement) {
|
||||
throw new Error('Regional observations map container not found');
|
||||
}
|
||||
|
||||
// return the weather data and offsets
|
||||
this.data = {
|
||||
regionalData,
|
||||
mapOffsetXY,
|
||||
sourceXY,
|
||||
};
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
this.map = createMap(mapElement);
|
||||
({ baseLayer: this.baseLayer, boundaryLayer: this.boundaryLayer } = addBaseLayers(this.map));
|
||||
}
|
||||
|
||||
drawCanvas() {
|
||||
super.drawCanvas();
|
||||
// break up data into useful values
|
||||
const { regionalData: data, sourceXY } = this.data;
|
||||
|
||||
// draw the header graphics
|
||||
|
||||
// draw the appropriate title
|
||||
const titleTop = this.elem.querySelector('.title.dual .top');
|
||||
const titleBottom = this.elem.querySelector('.title.dual .bottom');
|
||||
if (this.screenIndex === 0) {
|
||||
titleTop.innerHTML = 'Regional';
|
||||
titleBottom.innerHTML = 'Observations';
|
||||
} else {
|
||||
const forecastDate = DateTime.fromISO(data[0][this.screenIndex].time);
|
||||
titleTop.innerHTML = 'Regional';
|
||||
titleBottom.innerHTML = 'Observations';
|
||||
|
||||
// get the name of the day
|
||||
const dayName = forecastDate.toLocaleString({ weekday: 'long' });
|
||||
titleTop.innerHTML = 'Forecast for';
|
||||
// draw the title
|
||||
titleBottom.innerHTML = data[0][this.screenIndex].daytime
|
||||
? dayName
|
||||
: `${dayName} Night`;
|
||||
if (this.map) {
|
||||
this.map.invalidateSize(false);
|
||||
}
|
||||
|
||||
// draw the map
|
||||
const scale = 640 / (mapOffsetXY.x * 2);
|
||||
const map = this.elem.querySelector('.map');
|
||||
map.style.transform = `scale(${scale}) translate(-${sourceXY.x}px, -${sourceXY.y}px)`;
|
||||
|
||||
const cities = data.map((city) => {
|
||||
const fill = {};
|
||||
const period = city[this.screenIndex];
|
||||
|
||||
fill.icon = { type: 'img', src: getSmallIcon(period.icon, !period.daytime) };
|
||||
fill.city = period.name;
|
||||
const { temperature } = period;
|
||||
fill.temp = temperature;
|
||||
|
||||
const { x, y } = period;
|
||||
|
||||
const elem = this.fillTemplate('location', fill);
|
||||
elem.style.left = `${x}px`;
|
||||
elem.style.top = `${y}px`;
|
||||
|
||||
return elem;
|
||||
});
|
||||
|
||||
const locationContainer = this.elem.querySelector('.location-container');
|
||||
locationContainer.innerHTML = '';
|
||||
locationContainer.append(...cities);
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
|
||||
async showCanvas(navCmd) {
|
||||
super.showCanvas(navCmd);
|
||||
await this.refreshNearbyMarkers();
|
||||
}
|
||||
}
|
||||
|
||||
const getAndFormatPoint = async (lat, lon) => {
|
||||
try {
|
||||
const point = await getPoint(lat, lon);
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: point.properties.gridX,
|
||||
y: point.properties.gridY,
|
||||
wfo: point.properties.gridId,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Unexpected error getting point for ${lat},${lon}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// register display
|
||||
registerDisplay(new RegionalForecast(6, 'regional-forecast'));
|
||||
|
|
|
|||
155
server/scripts/modules/utils/leaflet-weather-map.mjs
Normal file
155
server/scripts/modules/utils/leaflet-weather-map.mjs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { safePromiseAll } from './fetch.mjs';
|
||||
import { loadData } from './data-loader.mjs';
|
||||
import { getSmallIconFromWmoCode } from '../icons.mjs';
|
||||
import { getOpenMeteoObservationSnapshot } from './weather.mjs';
|
||||
|
||||
const BASE_MAP_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}';
|
||||
const BOUNDARY_MAP_URL = 'https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}';
|
||||
const DEFAULT_MAX_NEARBY_MARKERS = 7;
|
||||
const MIN_CITY_DISTANCE_METERS = 25000;
|
||||
const MIN_MARKER_PIXEL_DISTANCE = 85;
|
||||
|
||||
let radarCitiesCache = null;
|
||||
|
||||
const createMap = (mapElement) => window.L.map(mapElement, {
|
||||
zoomControl: false,
|
||||
dragging: false,
|
||||
touchZoom: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
tap: false,
|
||||
attributionControl: false,
|
||||
preferCanvas: true,
|
||||
});
|
||||
|
||||
const addBaseLayers = (map) => {
|
||||
const baseLayer = window.L.tileLayer(BASE_MAP_URL, {
|
||||
maxZoom: 10,
|
||||
minZoom: 1,
|
||||
crossOrigin: true,
|
||||
className: 'radar-base-layer',
|
||||
}).addTo(map);
|
||||
|
||||
const boundaryLayer = window.L.tileLayer(BOUNDARY_MAP_URL, {
|
||||
maxZoom: 10,
|
||||
minZoom: 1,
|
||||
opacity: 0.6,
|
||||
crossOrigin: true,
|
||||
className: 'radar-boundary-layer',
|
||||
}).addTo(map);
|
||||
|
||||
return { baseLayer, boundaryLayer };
|
||||
};
|
||||
|
||||
const setPrimaryLocationMarker = (map, existingMarker, latitude, longitude) => {
|
||||
if (existingMarker && map.hasLayer(existingMarker)) {
|
||||
map.removeLayer(existingMarker);
|
||||
}
|
||||
|
||||
return window.L.circleMarker([latitude, longitude], {
|
||||
radius: 5,
|
||||
color: '#000',
|
||||
weight: 2,
|
||||
fillColor: '#ff0',
|
||||
fillOpacity: 1,
|
||||
interactive: false,
|
||||
className: 'location-marker',
|
||||
}).addTo(map);
|
||||
};
|
||||
|
||||
const loadRadarCities = async () => {
|
||||
if (!radarCitiesCache) {
|
||||
radarCitiesCache = await loadData('radarcities');
|
||||
}
|
||||
return radarCitiesCache ?? [];
|
||||
};
|
||||
|
||||
const selectNearbyCities = (map, sourceLocation, cities, options = {}) => {
|
||||
const {
|
||||
maxMarkers = DEFAULT_MAX_NEARBY_MARKERS,
|
||||
minCityDistanceMeters = MIN_CITY_DISTANCE_METERS,
|
||||
minMarkerPixelDistance = MIN_MARKER_PIXEL_DISTANCE,
|
||||
} = options;
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const currentLatLng = window.L.latLng(sourceLocation.latitude, sourceLocation.longitude);
|
||||
const visibleCities = cities
|
||||
.filter((city) => bounds.contains([city.lat, city.lon]))
|
||||
.filter((city) => currentLatLng.distanceTo([city.lat, city.lon]) > minCityDistanceMeters)
|
||||
.map((city) => ({
|
||||
...city,
|
||||
distance: currentLatLng.distanceTo([city.lat, city.lon]),
|
||||
point: map.latLngToContainerPoint([city.lat, city.lon]),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
const selected = [];
|
||||
visibleCities.forEach((city) => {
|
||||
if (selected.length >= maxMarkers) return;
|
||||
const overlaps = selected.some((existingCity) => existingCity.point.distanceTo(city.point) < minMarkerPixelDistance);
|
||||
if (!overlaps) selected.push(city);
|
||||
});
|
||||
|
||||
if (selected.length === 0 && visibleCities.length > 0) {
|
||||
selected.push(visibleCities[0]);
|
||||
}
|
||||
|
||||
return selected;
|
||||
};
|
||||
|
||||
const buildNearbyWeatherMarker = (city, observation) => {
|
||||
const icon = getSmallIconFromWmoCode(observation.weatherCode, observation.isDay);
|
||||
const markerHtml = `
|
||||
<div class="nearby-weather-marker-inner">
|
||||
<div class="city">${city.name}</div>
|
||||
<div class="details">
|
||||
<div class="temp">${Math.round(observation.temperature)}</div>
|
||||
<img src="${icon}" alt="${city.name} weather" />
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return window.L.marker([city.lat, city.lon], {
|
||||
icon: window.L.divIcon({
|
||||
html: markerHtml,
|
||||
className: 'nearby-weather-marker',
|
||||
iconSize: [108, 52],
|
||||
iconAnchor: [54, 26],
|
||||
}),
|
||||
interactive: false,
|
||||
zIndexOffset: 500,
|
||||
});
|
||||
};
|
||||
|
||||
const clearMarkers = (map, markers) => {
|
||||
if (!map || !markers?.length) return [];
|
||||
markers.forEach((marker) => {
|
||||
if (map.hasLayer(marker)) map.removeLayer(marker);
|
||||
});
|
||||
return [];
|
||||
};
|
||||
|
||||
const loadNearbyObservationMarkers = async (map, sourceLocation, options = {}) => {
|
||||
const radarCities = await loadRadarCities();
|
||||
const nearbyCities = selectNearbyCities(map, sourceLocation, radarCities, options);
|
||||
if (!nearbyCities.length) return [];
|
||||
|
||||
const nearbyObservations = await safePromiseAll(nearbyCities.map(async (city) => {
|
||||
const observation = await getOpenMeteoObservationSnapshot(city.lat, city.lon);
|
||||
if (!observation || observation.temperature === null) return null;
|
||||
return { city, observation };
|
||||
}));
|
||||
|
||||
return nearbyObservations
|
||||
.filter((entry) => entry)
|
||||
.map(({ city, observation }) => buildNearbyWeatherMarker(city, observation));
|
||||
};
|
||||
|
||||
export {
|
||||
createMap,
|
||||
addBaseLayers,
|
||||
setPrimaryLocationMarker,
|
||||
loadNearbyObservationMarkers,
|
||||
clearMarkers,
|
||||
};
|
||||
|
|
@ -8,6 +8,13 @@ const OPEN_METEO_FORECAST_PARAMETERS = [
|
|||
'models=best_match',
|
||||
].join('&');
|
||||
|
||||
const OPEN_METEO_RADAR_OBSERVATION_PARAMETERS = [
|
||||
'hourly=temperature_2m,weather_code,is_day',
|
||||
'forecast_days=1',
|
||||
'timezone=auto',
|
||||
'models=best_match',
|
||||
].join('&');
|
||||
|
||||
const getPoint = async (lat, lon) => {
|
||||
const point = await safeJson(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
|
||||
if (!point) {
|
||||
|
|
@ -48,6 +55,36 @@ const getAggregatedOpenMeteoForecast = async (lat, lon) => {
|
|||
};
|
||||
};
|
||||
|
||||
const getOpenMeteoObservationSnapshot = async (lat, lon) => {
|
||||
const forecast = await safeJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_RADAR_OBSERVATION_PARAMETERS}`);
|
||||
if (!forecast?.hourly?.time?.length) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get Open-Meteo radar observation snapshot for ${lat},${lon}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let nearestIndex = 0;
|
||||
let nearestDelta = Number.POSITIVE_INFINITY;
|
||||
|
||||
forecast.hourly.time.forEach((time, index) => {
|
||||
const delta = Math.abs(new Date(time).getTime() - now);
|
||||
if (delta < nearestDelta) {
|
||||
nearestDelta = delta;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
time: forecast.hourly.time[nearestIndex],
|
||||
temperature: forecast.hourly.temperature_2m?.[nearestIndex] ?? null,
|
||||
weatherCode: forecast.hourly.weather_code?.[nearestIndex] ?? 0,
|
||||
isDay: Boolean(forecast.hourly.is_day?.[nearestIndex] ?? 1),
|
||||
timezone: forecast.timezone,
|
||||
};
|
||||
};
|
||||
|
||||
const weatherConditions = [
|
||||
{ codes: [0], text: ['Clear sky'] },
|
||||
{ codes: [1, 2, 3], text: ['Mainly clear', 'Partly cloudy', 'Overcast'] },
|
||||
|
|
@ -143,6 +180,7 @@ export {
|
|||
getPoint,
|
||||
getOpenMeteoForecast,
|
||||
getAggregatedOpenMeteoForecast,
|
||||
getOpenMeteoObservationSnapshot,
|
||||
aggregateWeatherForecastData,
|
||||
getConditionText,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue