Restore Regional Observations map

This commit is contained in:
mrkmntal 2026-04-07 18:08:38 -04:00
commit 1faa580b18
15 changed files with 1006 additions and 302 deletions

View 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,
};

View file

@ -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,
};