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,462 @@
[
{
"name": "Anchorage",
"lat": 61.2181,
"lon": -149.9003
},
{
"name": "Vancouver",
"lat": 49.2827,
"lon": -123.1207
},
{
"name": "Seattle",
"lat": 47.6062,
"lon": -122.3321
},
{
"name": "San Francisco",
"lat": 37.7749,
"lon": -122.4194
},
{
"name": "Los Angeles",
"lat": 34.0522,
"lon": -118.2437
},
{
"name": "Phoenix",
"lat": 33.4484,
"lon": -112.074
},
{
"name": "Denver",
"lat": 39.7392,
"lon": -104.9903
},
{
"name": "Dallas",
"lat": 32.7767,
"lon": -96.797
},
{
"name": "Houston",
"lat": 29.7604,
"lon": -95.3698
},
{
"name": "Minneapolis",
"lat": 44.9778,
"lon": -93.265
},
{
"name": "Chicago",
"lat": 41.8781,
"lon": -87.6298
},
{
"name": "Atlanta",
"lat": 33.749,
"lon": -84.388
},
{
"name": "Miami",
"lat": 25.7617,
"lon": -80.1918
},
{
"name": "Toronto",
"lat": 43.6532,
"lon": -79.3832
},
{
"name": "Montreal",
"lat": 45.5019,
"lon": -73.5674
},
{
"name": "New York",
"lat": 40.7128,
"lon": -74.006
},
{
"name": "Boston",
"lat": 42.3601,
"lon": -71.0589
},
{
"name": "Reykjavik",
"lat": 64.1466,
"lon": -21.9426
},
{
"name": "Mexico City",
"lat": 19.4326,
"lon": -99.1332
},
{
"name": "Guatemala City",
"lat": 14.6349,
"lon": -90.5069
},
{
"name": "Havana",
"lat": 23.1136,
"lon": -82.3666
},
{
"name": "Santo Domingo",
"lat": 18.4861,
"lon": -69.9312
},
{
"name": "San Juan",
"lat": 18.4655,
"lon": -66.1057
},
{
"name": "Bogota",
"lat": 4.711,
"lon": -74.0721
},
{
"name": "Quito",
"lat": -0.1807,
"lon": -78.4678
},
{
"name": "Lima",
"lat": -12.0464,
"lon": -77.0428
},
{
"name": "La Paz",
"lat": -16.4897,
"lon": -68.1193
},
{
"name": "Santiago",
"lat": -33.4489,
"lon": -70.6693
},
{
"name": "Buenos Aires",
"lat": -34.6037,
"lon": -58.3816
},
{
"name": "Montevideo",
"lat": -34.9011,
"lon": -56.1645
},
{
"name": "Sao Paulo",
"lat": -23.5558,
"lon": -46.6396
},
{
"name": "Rio",
"lat": -22.9068,
"lon": -43.1729
},
{
"name": "Recife",
"lat": -8.0476,
"lon": -34.877
},
{
"name": "London",
"lat": 51.5072,
"lon": -0.1276
},
{
"name": "Dublin",
"lat": 53.3498,
"lon": -6.2603
},
{
"name": "Paris",
"lat": 48.8566,
"lon": 2.3522
},
{
"name": "Amsterdam",
"lat": 52.3676,
"lon": 4.9041
},
{
"name": "Brussels",
"lat": 50.8503,
"lon": 4.3517
},
{
"name": "Berlin",
"lat": 52.52,
"lon": 13.405
},
{
"name": "Hamburg",
"lat": 53.5511,
"lon": 9.9937
},
{
"name": "Madrid",
"lat": 40.4168,
"lon": -3.7038
},
{
"name": "Lisbon",
"lat": 38.7223,
"lon": -9.1393
},
{
"name": "Rome",
"lat": 41.9028,
"lon": 12.4964
},
{
"name": "Milan",
"lat": 45.4642,
"lon": 9.19
},
{
"name": "Vienna",
"lat": 48.2082,
"lon": 16.3738
},
{
"name": "Prague",
"lat": 50.0755,
"lon": 14.4378
},
{
"name": "Warsaw",
"lat": 52.2297,
"lon": 21.0122
},
{
"name": "Stockholm",
"lat": 59.3293,
"lon": 18.0686
},
{
"name": "Oslo",
"lat": 59.9139,
"lon": 10.7522
},
{
"name": "Helsinki",
"lat": 60.1699,
"lon": 24.9384
},
{
"name": "Athens",
"lat": 37.9838,
"lon": 23.7275
},
{
"name": "Istanbul",
"lat": 41.0082,
"lon": 28.9784
},
{
"name": "Kyiv",
"lat": 50.4501,
"lon": 30.5234
},
{
"name": "Cairo",
"lat": 30.0444,
"lon": 31.2357
},
{
"name": "Casablanca",
"lat": 33.5731,
"lon": -7.5898
},
{
"name": "Lagos",
"lat": 6.5244,
"lon": 3.3792
},
{
"name": "Accra",
"lat": 5.6037,
"lon": -0.187
},
{
"name": "Nairobi",
"lat": -1.2864,
"lon": 36.8172
},
{
"name": "Addis Ababa",
"lat": 8.9806,
"lon": 38.7578
},
{
"name": "Johannesburg",
"lat": -26.2041,
"lon": 28.0473
},
{
"name": "Cape Town",
"lat": -33.9249,
"lon": 18.4241
},
{
"name": "Dubai",
"lat": 25.2048,
"lon": 55.2708
},
{
"name": "Abu Dhabi",
"lat": 24.4539,
"lon": 54.3773
},
{
"name": "Riyadh",
"lat": 24.7136,
"lon": 46.6753
},
{
"name": "Doha",
"lat": 25.2854,
"lon": 51.531
},
{
"name": "Kuwait City",
"lat": 29.3759,
"lon": 47.9774
},
{
"name": "Jerusalem",
"lat": 31.7683,
"lon": 35.2137
},
{
"name": "Amman",
"lat": 31.9539,
"lon": 35.9106
},
{
"name": "Karachi",
"lat": 24.8607,
"lon": 67.0011
},
{
"name": "Mumbai",
"lat": 19.076,
"lon": 72.8777
},
{
"name": "Delhi",
"lat": 28.6139,
"lon": 77.209
},
{
"name": "Dhaka",
"lat": 23.8103,
"lon": 90.4125
},
{
"name": "Bangkok",
"lat": 13.7563,
"lon": 100.5018
},
{
"name": "Hanoi",
"lat": 21.0278,
"lon": 105.8342
},
{
"name": "Ho Chi Minh City",
"lat": 10.8231,
"lon": 106.6297
},
{
"name": "Singapore",
"lat": 1.3521,
"lon": 103.8198
},
{
"name": "Jakarta",
"lat": -6.2088,
"lon": 106.8456
},
{
"name": "Manila",
"lat": 14.5995,
"lon": 120.9842
},
{
"name": "Hong Kong",
"lat": 22.3193,
"lon": 114.1694
},
{
"name": "Taipei",
"lat": 25.033,
"lon": 121.5654
},
{
"name": "Shanghai",
"lat": 31.2304,
"lon": 121.4737
},
{
"name": "Beijing",
"lat": 39.9042,
"lon": 116.4074
},
{
"name": "Seoul",
"lat": 37.5665,
"lon": 126.978
},
{
"name": "Tokyo",
"lat": 35.6762,
"lon": 139.6503
},
{
"name": "Osaka",
"lat": 34.6937,
"lon": 135.5023
},
{
"name": "Sapporo",
"lat": 43.0618,
"lon": 141.3545
},
{
"name": "Sydney",
"lat": -33.8688,
"lon": 151.2093
},
{
"name": "Melbourne",
"lat": -37.8136,
"lon": 144.9631
},
{
"name": "Brisbane",
"lat": -27.4698,
"lon": 153.0251
},
{
"name": "Perth",
"lat": -31.9523,
"lon": 115.8613
},
{
"name": "Auckland",
"lat": -36.8509,
"lon": 174.7645
},
{
"name": "Wellington",
"lat": -41.2866,
"lon": 174.7756
}
]

View file

@ -0,0 +1,94 @@
[
{ "name": "Anchorage", "lat": 61.2181, "lon": -149.9003 },
{ "name": "Vancouver", "lat": 49.2827, "lon": -123.1207 },
{ "name": "Seattle", "lat": 47.6062, "lon": -122.3321 },
{ "name": "San Francisco", "lat": 37.7749, "lon": -122.4194 },
{ "name": "Los Angeles", "lat": 34.0522, "lon": -118.2437 },
{ "name": "Phoenix", "lat": 33.4484, "lon": -112.074 },
{ "name": "Denver", "lat": 39.7392, "lon": -104.9903 },
{ "name": "Dallas", "lat": 32.7767, "lon": -96.797 },
{ "name": "Houston", "lat": 29.7604, "lon": -95.3698 },
{ "name": "Minneapolis", "lat": 44.9778, "lon": -93.265 },
{ "name": "Chicago", "lat": 41.8781, "lon": -87.6298 },
{ "name": "Atlanta", "lat": 33.749, "lon": -84.388 },
{ "name": "Miami", "lat": 25.7617, "lon": -80.1918 },
{ "name": "Toronto", "lat": 43.6532, "lon": -79.3832 },
{ "name": "Montreal", "lat": 45.5019, "lon": -73.5674 },
{ "name": "New York", "lat": 40.7128, "lon": -74.006 },
{ "name": "Boston", "lat": 42.3601, "lon": -71.0589 },
{ "name": "Reykjavik", "lat": 64.1466, "lon": -21.9426 },
{ "name": "Mexico City", "lat": 19.4326, "lon": -99.1332 },
{ "name": "Guatemala City", "lat": 14.6349, "lon": -90.5069 },
{ "name": "Havana", "lat": 23.1136, "lon": -82.3666 },
{ "name": "Santo Domingo", "lat": 18.4861, "lon": -69.9312 },
{ "name": "San Juan", "lat": 18.4655, "lon": -66.1057 },
{ "name": "Bogota", "lat": 4.711, "lon": -74.0721 },
{ "name": "Quito", "lat": -0.1807, "lon": -78.4678 },
{ "name": "Lima", "lat": -12.0464, "lon": -77.0428 },
{ "name": "La Paz", "lat": -16.4897, "lon": -68.1193 },
{ "name": "Santiago", "lat": -33.4489, "lon": -70.6693 },
{ "name": "Buenos Aires", "lat": -34.6037, "lon": -58.3816 },
{ "name": "Montevideo", "lat": -34.9011, "lon": -56.1645 },
{ "name": "Sao Paulo", "lat": -23.5558, "lon": -46.6396 },
{ "name": "Rio", "lat": -22.9068, "lon": -43.1729 },
{ "name": "Recife", "lat": -8.0476, "lon": -34.877 },
{ "name": "London", "lat": 51.5072, "lon": -0.1276 },
{ "name": "Dublin", "lat": 53.3498, "lon": -6.2603 },
{ "name": "Paris", "lat": 48.8566, "lon": 2.3522 },
{ "name": "Amsterdam", "lat": 52.3676, "lon": 4.9041 },
{ "name": "Brussels", "lat": 50.8503, "lon": 4.3517 },
{ "name": "Berlin", "lat": 52.52, "lon": 13.405 },
{ "name": "Hamburg", "lat": 53.5511, "lon": 9.9937 },
{ "name": "Madrid", "lat": 40.4168, "lon": -3.7038 },
{ "name": "Lisbon", "lat": 38.7223, "lon": -9.1393 },
{ "name": "Rome", "lat": 41.9028, "lon": 12.4964 },
{ "name": "Milan", "lat": 45.4642, "lon": 9.19 },
{ "name": "Vienna", "lat": 48.2082, "lon": 16.3738 },
{ "name": "Prague", "lat": 50.0755, "lon": 14.4378 },
{ "name": "Warsaw", "lat": 52.2297, "lon": 21.0122 },
{ "name": "Stockholm", "lat": 59.3293, "lon": 18.0686 },
{ "name": "Oslo", "lat": 59.9139, "lon": 10.7522 },
{ "name": "Helsinki", "lat": 60.1699, "lon": 24.9384 },
{ "name": "Athens", "lat": 37.9838, "lon": 23.7275 },
{ "name": "Istanbul", "lat": 41.0082, "lon": 28.9784 },
{ "name": "Kyiv", "lat": 50.4501, "lon": 30.5234 },
{ "name": "Cairo", "lat": 30.0444, "lon": 31.2357 },
{ "name": "Casablanca", "lat": 33.5731, "lon": -7.5898 },
{ "name": "Lagos", "lat": 6.5244, "lon": 3.3792 },
{ "name": "Accra", "lat": 5.6037, "lon": -0.187 },
{ "name": "Nairobi", "lat": -1.2864, "lon": 36.8172 },
{ "name": "Addis Ababa", "lat": 8.9806, "lon": 38.7578 },
{ "name": "Johannesburg", "lat": -26.2041, "lon": 28.0473 },
{ "name": "Cape Town", "lat": -33.9249, "lon": 18.4241 },
{ "name": "Dubai", "lat": 25.2048, "lon": 55.2708 },
{ "name": "Abu Dhabi", "lat": 24.4539, "lon": 54.3773 },
{ "name": "Riyadh", "lat": 24.7136, "lon": 46.6753 },
{ "name": "Doha", "lat": 25.2854, "lon": 51.531 },
{ "name": "Kuwait City", "lat": 29.3759, "lon": 47.9774 },
{ "name": "Jerusalem", "lat": 31.7683, "lon": 35.2137 },
{ "name": "Amman", "lat": 31.9539, "lon": 35.9106 },
{ "name": "Karachi", "lat": 24.8607, "lon": 67.0011 },
{ "name": "Mumbai", "lat": 19.076, "lon": 72.8777 },
{ "name": "Delhi", "lat": 28.6139, "lon": 77.209 },
{ "name": "Dhaka", "lat": 23.8103, "lon": 90.4125 },
{ "name": "Bangkok", "lat": 13.7563, "lon": 100.5018 },
{ "name": "Hanoi", "lat": 21.0278, "lon": 105.8342 },
{ "name": "Ho Chi Minh City", "lat": 10.8231, "lon": 106.6297 },
{ "name": "Singapore", "lat": 1.3521, "lon": 103.8198 },
{ "name": "Jakarta", "lat": -6.2088, "lon": 106.8456 },
{ "name": "Manila", "lat": 14.5995, "lon": 120.9842 },
{ "name": "Hong Kong", "lat": 22.3193, "lon": 114.1694 },
{ "name": "Taipei", "lat": 25.033, "lon": 121.5654 },
{ "name": "Shanghai", "lat": 31.2304, "lon": 121.4737 },
{ "name": "Beijing", "lat": 39.9042, "lon": 116.4074 },
{ "name": "Seoul", "lat": 37.5665, "lon": 126.978 },
{ "name": "Tokyo", "lat": 35.6762, "lon": 139.6503 },
{ "name": "Osaka", "lat": 34.6937, "lon": 135.5023 },
{ "name": "Sapporo", "lat": 43.0618, "lon": 141.3545 },
{ "name": "Sydney", "lat": -33.8688, "lon": 151.2093 },
{ "name": "Melbourne", "lat": -37.8136, "lon": 144.9631 },
{ "name": "Brisbane", "lat": -27.4698, "lon": 153.0251 },
{ "name": "Perth", "lat": -31.9523, "lon": 115.8613 },
{ "name": "Auckland", "lat": -36.8509, "lon": 174.7645 },
{ "name": "Wellington", "lat": -41.2866, "lon": 174.7756 }
]

View file

@ -0,0 +1,17 @@
import { readFile, writeFile } from 'fs/promises';
const radarCities = JSON.parse(await readFile('./datagenerators/radarcities-raw.json'));
const result = radarCities.map((city) => {
if (!city?.name || typeof city.lat !== 'number' || typeof city.lon !== 'number') {
throw new Error(`Invalid radar city: ${JSON.stringify(city)}`);
}
return {
name: city.name,
lat: city.lat,
lon: city.lon,
};
});
await writeFile('./datagenerators/output/radarcities.json', JSON.stringify(result, null, '\t'));

View file

@ -144,6 +144,7 @@ const copyDataFiles = () => src([
'datagenerators/output/travelcities.json',
'datagenerators/output/regionalcities.json',
'datagenerators/output/stations.json',
'datagenerators/output/radarcities.json',
]).pipe(dest('./dist/data'));
const s3 = s3Upload({

View file

@ -81,6 +81,7 @@ const parseLwnStories = (html) => {
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
const stationInfo = JSON.parse(await readFile('./datagenerators/output/stations.json'));
const radarCities = JSON.parse(await readFile('./datagenerators/output/radarcities.json'));
const app = express();
const port = process.env.WS4KP_PORT ?? 8080;
@ -265,6 +266,7 @@ const dataEndpoints = {
travelcities: travelCities,
regionalcities: regionalCities,
stations: stationInfo,
radarcities: radarCities,
};
Object.entries(dataEndpoints).forEach(([name, data]) => {

View file

@ -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) {

View file

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

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

View file

@ -157,6 +157,52 @@
border: 2px solid #000;
border-radius: 50%;
}
.nearby-weather-marker {
background: transparent;
border: 0;
.nearby-weather-marker-inner {
display: flex;
flex-direction: column;
align-items: center;
min-width: 72px;
padding: 2px 4px;
background: rgba(18, 34, 61, 0.88);
border: 1px solid #000;
box-shadow: 1px 1px 0 #000;
color: #fff;
text-align: center;
}
.city {
font-family: 'Star4000 Small';
font-size: 11pt;
line-height: 1;
white-space: nowrap;
margin-bottom: 1px;
text-shadow: 1px 1px 0 #000;
}
.details {
display: flex;
align-items: center;
gap: 2px;
}
.temp {
font-family: 'Star4000';
font-size: 18pt;
line-height: 1;
color: #ff0;
text-shadow: 1px 1px 0 #000;
}
img {
width: auto;
height: 20px;
}
}
}
}

View file

@ -6,48 +6,91 @@
}
.weather-display .main.regional-forecast {
position: relative;
overflow: hidden;
z-index: 0;
.map {
position: absolute;
transform-origin: 0 0;
inset: 0;
}
.location {
position: absolute;
width: 140px;
margin-left: -40px;
margin-top: -35px;
.leaflet-map {
height: 100%;
width: 100%;
background: #061f3e;
}
>div {
position: absolute;
@include u.text-shadow();
}
.leaflet-container {
background: #061f3e;
font-family: inherit;
}
.icon {
top: 26px;
left: 44px;
.radar-base-layer,
.radar-base-layer .leaflet-tile {
filter: grayscale(0.35) brightness(0.58) contrast(1.1) saturate(0.2);
}
img {
max-height: 32px;
}
}
.radar-boundary-layer,
.radar-boundary-layer .leaflet-tile {
filter: grayscale(0.8) brightness(0.7) contrast(1.3) saturate(0.1);
}
.temp {
font-family: 'Star4000 Large';
font-size: 28px;
padding-top: 2px;
color: c.$title-color;
top: 28px;
text-align: right;
width: 40px;
.leaflet-control-container,
.leaflet-control-attribution,
.leaflet-control-zoom {
display: none;
}
.location-marker {
background: #ff0;
border: 2px solid #000;
border-radius: 50%;
}
.nearby-weather-marker {
background: transparent;
border: 0;
.nearby-weather-marker-inner {
display: flex;
flex-direction: column;
align-items: center;
min-width: 72px;
padding: 2px 4px;
background: rgba(18, 34, 61, 0.88);
border: 1px solid #000;
box-shadow: 1px 1px 0 #000;
color: #fff;
text-align: center;
}
.city {
font-family: Star4000;
font-size: 20px;
font-family: 'Star4000 Small';
font-size: 11pt;
line-height: 1;
white-space: nowrap;
margin-bottom: 1px;
@include u.text-shadow();
}
.details {
display: flex;
align-items: center;
gap: 2px;
}
.temp {
font-family: 'Star4000';
font-size: 18pt;
line-height: 1;
color: c.$title-color;
@include u.text-shadow();
}
img {
width: auto;
height: 20px;
}
}
}
}

View file

@ -23,6 +23,8 @@
width: 640px;
height: 60px;
padding-top: 30px;
position: relative;
z-index: 20;
.title {
color: c.$title-color;
@ -172,4 +174,4 @@
.scroll-container {
margin-left: 107px;
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,6 @@
<%- include('header.ejs', {titleDual:{ top: 'Regional' , bottom: 'Observations' }, hasTime: true }) %>
<div class="main has-scroll regional-forecast">
<div class="map"><img src="images/maps/basemap.webp" /></div>
<div class="location-container">
<div class="location template">
<div class="icon">
<img src="" />
</div>
<div class="city"></div>
<div class="temp"></div>
</div>
</div>
</div>
<div class="map">
<div class="leaflet-map"></div>
</div>
</div>