Compare commits

...

30 Commits

Author SHA1 Message Date
Matt Walsh
8afef77ea5 5.21.11 2025-06-01 13:38:21 -05:00
Matt Walsh
8f70ee87c5 add smoke to large icon set close #91 2025-06-01 13:37:32 -05:00
Matt Walsh
4e7429bfba 5.21.10 2025-05-31 13:30:30 -05:00
Matt Walsh
c5ffe1542a TEMPORARY don't allow radar on safari on ios 2025-05-31 13:30:21 -05:00
Matt Walsh
5364855c58 5.21.9 2025-05-31 13:20:45 -05:00
Matt Walsh
18efd810bd permalink coloring, readme additions close #88 2025-05-31 13:20:35 -05:00
Matt Walsh
68a6bae3a7 5.21.8 2025-05-30 09:06:45 -05:00
Matt Walsh
5f0f0d9000 Correct smoke forecast text on extended forecast #91 2025-05-30 09:06:40 -05:00
Matt Walsh
9d9cf4b0f3 5.21.7 2025-05-30 07:58:59 -05:00
Matt Walsh
9e500143c0 load radar workers later in the startup process 2025-05-30 07:57:28 -05:00
Matt Walsh
71da682660 5.21.6 2025-05-29 23:08:11 -05:00
Matt Walsh
1b9a1dcb22 don't clobber browser alt-left/right shortcuts 2025-05-29 23:08:04 -05:00
Matt Walsh
095761ee81 5.21.5 2025-05-29 21:04:51 -05:00
Matt Walsh
21e528aaa3 fix for kiosk mode scrollbars #86 2025-05-29 21:03:48 -05:00
Matt Walsh
a92c632937 5.21.4 2025-05-29 20:24:08 -05:00
Matt Walsh
6073fd1733 better missing data handling for current conditions #87 2025-05-29 20:23:55 -05:00
Matt Walsh
5da8185633 Merge pull request #89 from kevinastone/patch-2
Gracefully shutdown on both SIGINT + SIGTERM
2025-05-29 20:02:43 -05:00
Kevin Stone
cf5c818ee3 Gracefully shutdown on both SIGINT + SIGTERM
Most service managers (systemd, docker, etc) use SIGTERM as the shutdown signal by default rather than SIGINT (which is used for interactive CTRL-C).
2025-05-29 17:37:40 -07:00
Matt Walsh
97cec114f6 Merge branch 'main' of github.com:netbymatt/ws4kp 2025-05-29 17:03:55 -05:00
Matt Walsh
7efd2e8db7 add scanlines 2025-05-29 17:03:50 -05:00
Matt Walsh
8c28f41d54 Merge pull request #85 from dylan-park/readme-patch-1
update local run instructions in README
2025-05-29 14:45:07 -05:00
Dylan Park
e9d603fbfc update local run instructions in README 2025-05-29 14:24:05 -05:00
Matt Walsh
32aa43c5b1 update user-agent header, now allowed in some browsers 2025-05-29 14:18:49 -05:00
Matt Walsh
dbc56f014a 5.21.3 2025-05-29 13:34:22 -05:00
Matt Walsh
3161a03797 fix for stale forecast data on regional forecasts #84 2025-05-29 13:32:47 -05:00
Matt Walsh
205fa77f51 5.21.2 2025-05-29 08:36:21 -05:00
Matt Walsh
28bb8f2e2a scale nav buttons on narrow screens 2025-05-29 08:36:13 -05:00
Matt Walsh
cf9a99a6ca update dependencies 2025-05-29 08:31:03 -05:00
Matt Walsh
a83afa71cd code cleanup 2025-05-29 08:30:01 -05:00
Matt Walsh
74f1abd6f8 switch to zoom scaling 2025-05-27 16:33:03 -05:00
38 changed files with 717 additions and 509 deletions

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2024 Matt Walsh
Copyright (c) 2020-2025 Matt Walsh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -31,7 +31,7 @@ To run via Node locally:
git clone https://github.com/netbymatt/ws4kp.git
cd ws4kp
npm i
node index.js
node index.mjs
```
To run via Docker:
@@ -87,6 +87,17 @@ I've made several changes to this Weather Star 4000 simulation compared to the o
## Sharing a permalink (bookmarking)
Selected displays, the forecast city and widescreen setting are sticky from one session to the next. However if you would like to share your exact configuration or bookmark it click the "Copy Permalink" (or get "Get Parmalink") near the bottom of the page. A URL will be copied to your clipboard with all of you selected displays and location (or copy it from the page if your browser doesn't support clipboard transfers directly). You can then share this link or add it to your bookmarks.
Your permalink will be very long. Here is an example for the Orlando Internation Airport:
```
https://weatherstar.netbymatt.com/?hazards-checkbox=false&current-weather-checkbox=true&latest-observations-checkbox=true&hourly-checkbox=false&hourly-graph-checkbox=true&travel-checkbox=false&regional-forecast-checkbox=true&local-forecast-checkbox=true&extended-forecast-checkbox=true&almanac-checkbox=false&spc-outlook-checkbox=true&radar-checkbox=true&settings-wide-checkbox=false&settings-kiosk-checkbox=false&settings-scanLines-checkbox=false&settings-speed-select=1.00&settings-units-select=us&latLonQuery=Orlando+International+Airport%2C+Orlando%2C+FL%2C+USA&latLon=%7B%22lat%22%3A28.431%2C%22lon%22%3A-81.3076%7D
```
You can also build your own permalink. Any omitted settings will be filled with defaults. Here are a few examples:
```
https://weatherstar.netbymatt.com/?latLonQuery=Orlando+International+Airport
https://weatherstar.netbymatt.com/?kiosk=true
https://weatherstar.netbymatt.com/?settings-units-select=metric
```
## Kiosk mode
Kiosk mode can be activated by a checkbox on the page. Note that there is no way out of kiosk mode (except refresh or closing the browser), and the play/pause and other controls will not be available. This is deliberate as a browser's kiosk mode it intended not to be exited or significantly modified.
@@ -145,6 +156,10 @@ Note: not all units are converted to metric, if selected. Some text-based produc
Not retro enough? Try the [Weatherstar 3000+](https://github.com/netbymatt/ws3kp)
## Use
Linking directly to the live web site at https://weatherstar.netbymatt.com is encouraged. As is using the live site for digital signage, home dashboards, streaming and public display. Please note the disclaimer below.
## Disclaimer
This web site should NOT be used in life threatening weather situations, or be relied on to inform the public of such situations. The Internet is an unreliable network subject to server and network outages and by nature is not suitable for such mission critical use. If you require such access to NWS data, please consider one of their subscription services. The authors of this web site shall not be held liable in the event of injury, death or property damage that occur as a result of disregarding this warning.

View File

@@ -99,8 +99,11 @@ const server = app.listen(port, () => {
});
// graceful shutdown
process.on('SIGINT', () => {
const gracefulShutdown = () => {
server.close(() => {
console.log('Server closed');
});
});
};
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);

745
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ws4kp",
"version": "5.21.1",
"version": "5.21.11",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -38,6 +38,7 @@ const init = () => {
document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick);
document.querySelector('#NavigatePrevious').addEventListener('click', btnNavigatePreviousClick);
document.querySelector('#NavigatePlay').addEventListener('click', btnNavigatePlayClick);
document.querySelector('#ToggleScanlines').addEventListener('click', btnNavigateToggleScanlines);
document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR).addEventListener('click', btnFullScreenClick);
const btnGetGps = document.querySelector(BNT_GET_GPS_SELECTOR);
btnGetGps.addEventListener('click', btnGetGpsClick);
@@ -61,7 +62,7 @@ const init = () => {
paramName: 'text',
params: {
f: 'json',
countryCode: 'USA', // 'USA,PRI,VIR,GUM,ASM',
countryCode: 'USA',
category,
maxSuggestions: 10,
},
@@ -82,7 +83,6 @@ const init = () => {
// attempt to parse the url parameters
const parsedParameters = parseQueryString();
const loadFromParsed = parsedParameters.latLonQuery && parsedParameters.latLon;
// Auto load the parsed parameters and fall back to the previous query
@@ -295,6 +295,8 @@ const updateFullScreenNavigate = () => {
};
const documentKeydown = (e) => {
// don't trigger on ctrl/alt/shift modified key
if (e.altKey || e.ctrlKey || e.shiftKey) return false;
const { key } = e;
if (document.fullscreenElement || document.activeElement === document.body) {
@@ -345,6 +347,11 @@ const btnNavigatePlayClick = () => {
return false;
};
const btnNavigateToggleScanlines = () => {
settings.scanLines.value = !settings.scanLines.value;
return false;
};
// post a message to the iframe
const postMessage = (type, myMessage = {}) => {
navMessage({ type, message: myMessage });

View File

@@ -1,5 +1,5 @@
// display sun and moon data
import { loadImg, preloadImg } from './utils/image.mjs';
import { preloadImg } from './utils/image.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
import STATUS from './status.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
@@ -9,9 +9,6 @@ class Almanac extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Almanac', true);
// pre-load background images (returns promises)
this.backgroundImage0 = loadImg('images/backgrounds/1.png');
// preload the moon images
preloadImg(imageName('Full'));
preloadImg(imageName('Last'));
@@ -122,10 +119,10 @@ class Almanac extends WeatherDisplay {
// sun and moon data
this.elem.querySelector('.day-1').innerHTML = Today.toLocaleString({ weekday: 'long' });
this.elem.querySelector('.day-2').innerHTML = Tomorrow.toLocaleString({ weekday: 'long' });
this.elem.querySelector('.rise-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
this.elem.querySelector('.rise-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
this.elem.querySelector('.set-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
this.elem.querySelector('.set-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
this.elem.querySelector('.rise-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunrise));
this.elem.querySelector('.rise-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunrise));
this.elem.querySelector('.set-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunset));
this.elem.querySelector('.set-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunset));
const days = info.moon.map((MoonPhase) => {
const fill = {};
@@ -171,6 +168,8 @@ const imageName = (type) => {
}
};
const timeFormat = (dt) => dt.setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
// register display
const display = new Almanac(9, 'almanac');
registerDisplay(display);

View File

@@ -3,43 +3,24 @@ import { json } from './utils/fetch.mjs';
const KEYS = {
ESC: 27,
TAB: 9,
RETURN: 13,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
ENTER: 13,
};
const DEFAULT_OPTIONS = {
autoSelectFirst: false,
serviceUrl: null,
lookup: null,
onSelect: () => { },
onHint: null,
width: 'auto',
minChars: 3,
maxHeight: 300,
deferRequestBy: 0,
params: {},
delimiter: null,
zIndex: 9999,
type: 'GET',
noCache: false,
preserveInput: false,
containerClass: 'autocomplete-suggestions',
tabDisabled: false,
dataType: 'text',
currentRequest: null,
triggerSelectOnValidInput: true,
preventBadQueries: true,
paramName: 'query',
transformResult: (a) => a,
showNoSuggestionNotice: false,
noSuggestionNotice: 'No results',
orientation: 'bottom',
forceFixPosition: false,
};
const escapeRegExChars = (string) => string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');

View File

@@ -1,6 +1,6 @@
// current weather conditions display
import STATUS from './status.mjs';
import { loadImg, preloadImg } from './utils/image.mjs';
import { preloadImg } from './utils/image.mjs';
import { json } from './utils/fetch.mjs';
import { directionToNSEW } from './utils/calc.mjs';
import { locationCleanup } from './utils/string.mjs';
@@ -17,8 +17,6 @@ const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F'
class CurrentWeather extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Current Conditions', true);
// pre-load background image (returns promise)
this.backgroundImage = loadImg('images/backgrounds/1.png');
}
async getData(weatherParameters, refresh) {
@@ -52,6 +50,8 @@ class CurrentWeather extends WeatherDisplay {
stillWaiting: () => this.stillWaiting(),
});
if (observations.features.length === 0) throw new Error(`No features returned for station: ${station.properties.stationIdentifier}, trying next station`);
// test data quality
if (observations.features[0].properties.temperature.value === null
|| observations.features[0].properties.windSpeed.value === null
@@ -61,10 +61,11 @@ class CurrentWeather extends WeatherDisplay {
|| observations.features[0].properties.dewpoint.value === null
|| observations.features[0].properties.barometricPressure.value === null) {
observations = undefined;
throw new Error(`Unable to get observations: ${station.properties.stationIdentifier}, trying next station`);
throw new Error(`Incomplete data set for: ${station.properties.stationIdentifier}, trying next station`);
}
} catch (error) {
console.error(error);
observations = undefined;
}
}
// test for data received

View File

@@ -59,11 +59,10 @@ class ExtendedForecast extends WeatherDisplay {
date: Day.dayName,
};
const { low } = Day;
const { low, high } = Day;
if (low !== undefined) {
fill['value-lo'] = Math.round(low);
}
const { high } = Day;
fill['value-hi'] = Math.round(high);
// return the filled template
@@ -121,17 +120,18 @@ const parse = (fullForecast) => {
return forecast;
};
const regexList = [
[/ and /gi, ' '],
[/slight /gi, ''],
[/chance /gi, ''],
[/very /gi, ''],
[/patchy /gi, ''],
[/Areas Of /gi, ''],
[/areas /gi, ''],
[/dense /gi, ''],
[/Thunderstorm/g, 'T\'Storm'],
];
const shortenExtendedForecastText = (long) => {
const regexList = [
[/ and /gi, ' '],
[/slight /gi, ''],
[/chance /gi, ''],
[/very /gi, ''],
[/patchy /gi, ''],
[/areas /gi, ''],
[/dense /gi, ''],
[/Thunderstorm/g, 'T\'Storm'],
];
// run all regexes
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);

View File

@@ -50,7 +50,7 @@ class Hazards extends WeatherDisplay {
// show alert indicator
if (this.data.length > 0) alert.classList.add('show');
} catch (error) {
console.error('Get hourly forecast failed');
console.error('Get hazards failed');
console.error(error.status, error.responseJSON);
if (this.isEnabled) this.setStatus(STATUS.failed);
// return undefined to other subscribers
@@ -129,7 +129,7 @@ class Hazards extends WeatherDisplay {
// don't let offset go negative
if (offsetY < 0) offsetY = 0;
// copy the scrolled portion of the canvas
// move the element
this.elem.querySelector('.main').scrollTo(0, offsetY);
}

View File

@@ -6,6 +6,10 @@ import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay, timeZone } from './navigation.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
// get available space
const availableWidth = 532;
const availableHeight = 285;
class HourlyGraph extends WeatherDisplay {
constructor(navId, elemId, defaultActive) {
super(navId, elemId, 'Hourly Graph', defaultActive);
@@ -47,10 +51,6 @@ class HourlyGraph extends WeatherDisplay {
drawCanvas() {
if (!this.image) this.image = this.elem.querySelector('.chart img');
// get available space
const availableWidth = 532;
const availableHeight = 285;
this.image.width = availableWidth;
this.image.height = availableHeight;

View File

@@ -69,8 +69,7 @@ class Hourly extends WeatherDisplay {
const fillValues = {};
// hour
const hour = startingHour.plus({ hours: index });
const formattedHour = hour.toLocaleString({ weekday: 'short', hour: 'numeric' });
fillValues.hour = formattedHour;
fillValues.hour = hour.toLocaleString({ weekday: 'short', hour: 'numeric' });
// temperatures, convert to strings with no decimal
const temperature = data.temperature.toString().padStart(3);
@@ -81,12 +80,11 @@ class Hourly extends WeatherDisplay {
fillValues.like = feelsLike;
// wind
let wind = 'Calm';
fillValues.wind = 'Calm';
if (data.windSpeed > 0) {
const windSpeed = Math.round(data.windSpeed).toString();
wind = data.windDirection + (Array(6 - data.windDirection.length - windSpeed.length).join(' ')) + windSpeed;
fillValues.wind = data.windDirection + (Array(6 - data.windDirection.length - windSpeed.length).join(' ')) + windSpeed;
}
fillValues.wind = wind;
// image
fillValues.icon = { type: 'img', src: data.icon };
@@ -96,8 +94,7 @@ class Hourly extends WeatherDisplay {
// alter the color of the feels like column to reflect wind chill or heat index
if (feelsLike < temperature) {
filledRow.querySelector('.like').classList.add('wind-chill');
}
if (feelsLike > temperature) {
} else if (feelsLike > temperature) {
filledRow.querySelector('.like').classList.add('heat-index');
}

View File

@@ -1,7 +1,7 @@
const hourlyIcon = (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed, isNight = false) => {
// internal function to add path to returned icon
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
// internal function to add path to returned icon
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
const hourlyIcon = (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed, isNight = false) => {
// possible phenomenon
let thunder = false;
let snow = false;

View File

@@ -65,6 +65,10 @@ const largeIcon = (link, _isNightTime) => {
case 'sleet-n':
return addPath('Sleet.gif');
case 'smoke':
case 'smoke-n':
return addPath('Smoke.gif');
case 'rain_showers':
case 'rain_showers_high':
case 'rain_showers-n':

View File

@@ -22,8 +22,7 @@ class LatestObservations extends WeatherDisplay {
// this is intentional because up to 30 stations are available to pull data from
// calculate distance to each station
const stationsByDistance = Object.keys(StationInfo).map((key) => {
const station = StationInfo[key];
const stationsByDistance = Object.values(StationInfo).map((station) => {
const distance = calcDistance(station.lat, station.lon, this.weatherParameters.latitude, this.weatherParameters.longitude);
return { ...station, distance };
});
@@ -104,8 +103,6 @@ class LatestObservations extends WeatherDisplay {
linesContainer.innerHTML = '';
linesContainer.append(...lines);
// update temperature unit header
this.finishDraw();
}
}

View File

@@ -253,9 +253,9 @@ const resize = () => {
const scale = Math.min(widthZoomPercent, heightZoomPercent);
if (scale < 1.0 || document.fullscreenElement || settings.kiosk) {
document.querySelector('#container').style.transform = `scale(${scale})`;
document.querySelector('#container').style.zoom = scale;
} else {
document.querySelector('#container').style.transform = 'unset';
document.querySelector('#container').style.zoom = 'unset';
}
};
@@ -266,6 +266,7 @@ const resetStatuses = () => {
// allow displays to register themselves
const registerDisplay = (display) => {
if (displays[display.navId]) console.warn(`Display nav ID ${display.navId} already in use`);
displays[display.navId] = display;
// generate checkboxes

View File

@@ -1,5 +1,4 @@
// regional forecast and observations
import { loadImg } from './utils/image.mjs';
import STATUS, { calcStatusClass, statusClasses } from './status.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import {
@@ -10,9 +9,6 @@ class Progress extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, '', false);
// pre-load background image (returns promise)
this.backgroundImage = loadImg('images/backgrounds/1.png');
// disable any navigation timing
this.timing = false;

View File

@@ -6,10 +6,15 @@ import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay, timeZone } from './navigation.mjs';
import * as utils from './radar-utils.mjs';
// TEMPORARY fix to disable radar on ios safari
const isIos = /iP(ad|od|hone)/i.test(window.navigator.userAgent);
const isSafari = !!navigator.userAgent.match(/Version\/[\d.]+.*Safari/);
const safariIos = isIos && isSafari;
const RADAR_HOST = 'mesonet.agron.iastate.edu';
class Radar extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Local Radar', true);
super(navId, elemId, 'Local Radar', !safariIos);
this.okToDrawCurrentConditions = false;
this.okToDrawCurrentDateTime = false;
@@ -39,9 +44,6 @@ class Radar extends WeatherDisplay {
{ time: 1, si: 4 },
{ time: 12, si: 5 },
];
// get some web workers started
this.workers = (new Array(this.dopplerRadarImageMax)).fill(null).map(() => radarWorker());
}
async getData(weatherParameters, refresh) {
@@ -53,6 +55,12 @@ class Radar extends WeatherDisplay {
return;
}
// get the workers started
if (!this.workers) {
// get some web workers started
this.workers = (new Array(this.dopplerRadarImageMax)).fill(null).map(() => radarWorker());
}
const baseUrl = `https://${RADAR_HOST}/archive/data/`;
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png';
const baseUrls = [];
@@ -100,10 +108,8 @@ class Radar extends WeatherDisplay {
const urls = sortedPngs.slice(-(this.dopplerRadarImageMax));
// calculate offsets and sizes
let offsetX = 120;
let offsetY = 69;
offsetX *= 2;
offsetY *= 2;
const offsetX = 120 * 2;
const offsetY = 69 * 2;
const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY);
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY);
@@ -201,4 +207,7 @@ const radarWorker = () => {
};
// register display
registerDisplay(new Radar(11, 'radar'));
// TEMPORARY: except on safari on IOS
if (!safariIos) {
registerDisplay(new Radar(11, 'radar'));
}

View File

@@ -20,7 +20,7 @@ const buildForecast = (forecast, city, cityXY) => {
const getRegionalObservation = async (point, city) => {
try {
// get stations
const stations = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations`);
const stations = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations?limit=1`);
// get the first station
const station = stations.features[0].id;

View File

@@ -7,12 +7,18 @@ import { json } 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 { DateTime, Interval } 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';
// map offset
const mapOffsetXY = {
x: 240,
y: 117,
};
class RegionalForecast extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Regional Forecast', true);
@@ -36,23 +42,18 @@ class RegionalForecast extends WeatherDisplay {
}
this.elem.querySelector('.map img').src = baseMap;
// map offset
const offsetXY = {
x: 240,
y: 117,
};
// get user's location in x/y
const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, offsetXY.x, offsetXY.y, weatherParameters.state);
const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, mapOffsetXY.x, mapOffsetXY.y, weatherParameters.state);
// get latitude and longitude limits
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, offsetXY.x, offsetXY.y, this.weatherParameters.state);
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, mapOffsetXY.x, mapOffsetXY.y, this.weatherParameters.state);
// get a target distance
let targetDistance = 2.5;
if (this.weatherParameters.state === 'HI') targetDistance = 1;
// make station info into an array
const stationInfoArray = Object.values(StationInfo).map((value) => ({ ...value, targetDistance }));
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];
@@ -76,6 +77,9 @@ class RegionalForecast extends WeatherDisplay {
// get a unit converter
const temperatureConverter = temperatureUnit();
// get now as DateTime for calculations below
const now = DateTime.now();
// get regional forecasts and observations (the two are intertwined due to the design of api.weather.gov)
const regionalDataAll = await Promise.all(regionalCities.map(async (city) => {
try {
@@ -109,14 +113,24 @@ class RegionalForecast extends WeatherDisplay {
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
// return a pared-down forecast
// 0th object is the current conditions
// first object is the next period i.e. if it's daytime then it's the "tonight" forecast
// second object is the following period
// always skip the first forecast index because it's what's going on right now
// 0th object should contain the current conditions, but when WFOs go offline or otherwise don't post
// an updated forecast it's possible that the 0th object is in the past.
// so we go on a search for the current time in the start/end times provided in the forecast periods
const { periods } = forecast.properties;
const currentPeriod = periods.reduce((prev, period, index) => {
const start = DateTime.fromISO(period.startTime);
const end = DateTime.fromISO(period.endTime);
const interval = Interval.fromDateTimes(start, end);
if (interval.contains(now)) {
return index;
}
return prev;
}, 0);
// group together the current observation and next two periods
return [
regionalObservation,
utils.buildForecast(forecast.properties.periods[1], city, cityXY),
utils.buildForecast(forecast.properties.periods[2], city, cityXY),
utils.buildForecast(forecast.properties.periods[currentPeriod + 1], city, cityXY),
utils.buildForecast(forecast.properties.periods[currentPeriod + 2], city, cityXY),
];
} catch (error) {
console.log(`No regional forecast data for '${city.name ?? city.city}'`);
@@ -137,7 +151,7 @@ class RegionalForecast extends WeatherDisplay {
// return the weather data and offsets
this.data = {
regionalData,
offsetXY,
mapOffsetXY,
sourceXY,
};
@@ -147,7 +161,7 @@ class RegionalForecast extends WeatherDisplay {
drawCanvas() {
super.drawCanvas();
// break up data into useful values
const { regionalData: data, sourceXY, offsetXY } = this.data;
const { regionalData: data, sourceXY } = this.data;
// draw the header graphics
@@ -170,7 +184,7 @@ class RegionalForecast extends WeatherDisplay {
}
// draw the map
const scale = 640 / (offsetXY.x * 2);
const scale = 640 / (mapOffsetXY.x * 2);
const map = this.elem.querySelector('.map');
map.style.transform = `scale(${scale}) translate(-${sourceXY.x}px, -${sourceXY.y}px)`;

View File

@@ -33,6 +33,12 @@ const init = () => {
[1.5, 'Very Slow'],
],
});
settings.scanLines = new Setting('scanLines', {
name: 'Scan Lines',
defaultValue: false,
changeAction: scanLineChange,
sticky: true,
});
settings.units = new Setting('units', {
name: 'Units',
type: 'select',
@@ -85,6 +91,18 @@ const kioskChange = (value) => {
}
};
const scanLineChange = (value) => {
const container = document.getElementById('container');
const navIcons = document.getElementById('ToggleScanlines');
if (value) {
container.classList.add('scanlines');
navIcons.classList.add('on');
} else {
container.classList.remove('scanlines');
navIcons.classList.remove('on');
}
};
const unitChange = () => {
// reload the data at the top level to refresh units
// after the initial load

View File

@@ -1,3 +1,5 @@
import { elemForEach } from './utils/elem.mjs';
document.addEventListener('DOMContentLoaded', () => init());
// shorthand mappings for frequently used values
@@ -19,21 +21,18 @@ const init = () => {
const createLink = async (e) => {
// cancel default event (click on hyperlink)
e.preventDefault();
// get all checkboxes on page
const checkboxes = document.querySelectorAll('input[type=checkbox]');
// list to receive checkbox statuses
const queryStringElements = {};
[...checkboxes].forEach((elem) => {
elemForEach('input[type=checkbox]', (elem) => {
if (elem?.id) {
queryStringElements[elem.id] = elem?.checked ?? false;
}
});
// get all select boxes
const selects = document.querySelectorAll('select');
[...selects].forEach((elem) => {
elemForEach('select', (elem) => {
if (elem?.id) {
queryStringElements[elem.id] = elem?.value ?? 0;
}

View File

@@ -5,6 +5,11 @@ const text = (url, params) => fetchAsync(url, 'text', params);
const blob = (url, params) => fetchAsync(url, 'blob', params);
const fetchAsync = async (_url, responseType, _params = {}) => {
// add user agent header to json request at api.weather.gov
const headers = {};
if (_url.toString().match(/api\.weather\.gov/)) {
headers['user-agent'] = 'Weatherstar 4000+; weatherstar@netbymatt.com';
}
// combine default and provided parameters
const params = {
method: 'GET',
@@ -12,6 +17,7 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
type: 'GET',
retryCount: 0,
..._params,
headers,
};
// store original number of retries
params.originalRetries = params.retryCount;

View File

@@ -1,21 +1,4 @@
import { blob } from './fetch.mjs';
import { rewriteUrl } from './cors.mjs';
// ****************************** load images *********************************
// load an image from a blob or url
const loadImg = (imgData, cors = false) => new Promise((resolve) => {
const img = new Image();
img.onload = (e) => {
resolve(e.target);
};
if (imgData instanceof Blob) {
img.src = window.URL.createObjectURL(imgData);
} else {
let url = imgData;
if (cors) url = rewriteUrl(imgData);
img.src = url;
}
});
// preload an image
// the goal is to get it in the browser's cache so it is available more quickly when the browser needs it
@@ -28,15 +11,7 @@ const preloadImg = (src) => {
return true;
};
const loadImgElement = (url) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = url;
});
export {
loadImg,
// eslint-disable-next-line import/prefer-default-export
preloadImg,
loadImgElement,
};

View File

@@ -189,7 +189,7 @@ class Setting {
break;
case 'checkbox':
default:
this.element.checked = newValue;
this.element.querySelector('input').checked = newValue;
}
this.storeToLocalStorage(this.myValue);

View File

@@ -2,7 +2,7 @@ import { json } from './fetch.mjs';
const getPoint = async (lat, lon) => {
try {
return await json(`https://api.weather.gov/points/${lat},${lon}`);
return await json(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
} catch (error) {
console.log(`Unable to get point ${lat}, ${lon}`);
console.error(error);

View File

@@ -7,6 +7,7 @@ import {
} from './navigation.mjs';
import { parseQueryString } from './share.mjs';
import settings from './settings.mjs';
import { elemForEach } from './utils/elem.mjs';
class WeatherDisplay {
constructor(navId, elemId, name, defaultEnabled) {
@@ -391,8 +392,7 @@ class WeatherDisplay {
this.templates = {};
this.elem = document.querySelector(`#${this.elemId}-html`);
if (!this.elem) return;
const templates = this.elem.querySelectorAll('.template');
templates.forEach((template) => {
elemForEach(`#${this.elemId}-html .template`, (template) => {
const className = template.classList[0];
const node = template.cloneNode(true);
node.classList.remove('template');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,8 @@ body {
&.kiosk {
margin: 0px;
overflow: hidden;
width: 100vw;
}
}
@@ -141,6 +143,10 @@ body {
}
}
.kiosk #divTwc {
max-width: unset;
}
#divTwcLeft {
display: none;
text-align: right;
@@ -189,11 +195,33 @@ body {
#divTwcBottom>div {
padding-left: 6px;
padding-right: 6px;
// scale down the buttons on narrower screens
@media (max-width: 550px) {
zoom: 0.90;
}
@media (max-width: 500px) {
zoom: 0.80;
}
@media (max-width: 450px) {
zoom: 0.70;
}
@media (max-width: 400px) {
zoom: 0.60;
}
@media (max-width: 350px) {
zoom: 0.50;
}
}
#divTwcBottomLeft {
flex: 1;
text-align: left;
}
#divTwcBottomMiddle {
@@ -300,10 +328,6 @@ body {
transform-origin: unset;
}
.kiosk #divTwc #container {
transform-origin: 0 0;
}
#loading {
width: 640px;
height: 480px;
@@ -404,10 +428,6 @@ body {
}
}
.kiosk #divTwc {
justify-content: unset;
}
#divTwc:fullscreen #display,
.kiosk #divTwc #display {
position: relative;
@@ -436,6 +456,30 @@ body {
cursor: pointer;
}
#ToggleScanlines {
display: inline-block;
.on {
display: none;
}
.off {
display: inline-block;
}
&.on {
.on {
display: inline-block;
}
.off {
display: none;
}
}
}
.visible {
visibility: visible;
opacity: 1;
@@ -716,7 +760,7 @@ body {
}
#share-link-copied {
color: c.$title-color;
color: hsl(60, 100%, 30%);
display: none;
}
@@ -728,6 +772,7 @@ body {
#divQuery,
>.info,
>.related-links,
>.heading,
#enabledDisplays,
#settings,

View File

@@ -13,4 +13,5 @@
@use 'almanac';
@use 'hazards';
@use 'media';
@use 'spc-outlook';
@use 'spc-outlook';
@use 'shared/scanlines';

View File

@@ -0,0 +1,106 @@
/* REGULAR SCANLINES SETTINGS */
// width of 1 scanline (min.: 1px)
$scan-width: 1px;
// emulates a damage-your-eyes bad pre-2000 CRT screen ♥ (true, false)
$scan-crt: false;
// frames-per-second (should be > 1), only applies if $scan-crt: true;
$scan-fps: 20;
// scanline-color (rgba)
$scan-color: rgba(#000, .3);
// set z-index on 8, like in ♥ 8-bits ♥, or…
// set z-index on 2147483648 or more to enable scanlines on Chrome fullscreen (doesn't work in Firefox or IE);
$scan-z-index: 2147483648;
/* MOVING SCANLINE SETTINGS */
// moving scanline (true, false)
$scan-moving-line: true;
// opacity of the moving scanline
$scan-opacity: .75;
/* MIXINS */
// apply CRT animation: @include scan-crt($scan-crt);
@mixin scan-crt($scan-crt) {
@if $scan-crt==true {
animation: scanlines 1s steps($scan-fps) infinite;
}
@else {
animation: none;
}
}
// apply CRT animation: @include scan-crt($scan-crt);
@mixin scan-moving($scan-moving-line) {
@if $scan-moving-line==true {
animation: scanline 6s linear infinite;
}
@else {
animation: none;
}
}
/* CSS .scanlines CLASS */
.scanlines {
position: relative;
overflow: hidden; // only to animate the unique scanline
&:before,
&:after {
display: block;
pointer-events: none;
content: '';
position: absolute;
}
// unique scanline travelling on the screen
&:before {
// position: absolute;
// bottom: 100%;
width: 100%;
height: $scan-width * 1;
z-index: $scan-z-index + 1;
background: $scan-color;
opacity: $scan-opacity;
// animation: scanline 6s linear infinite;
@include scan-moving($scan-moving-line);
}
// the scanlines, so!
&:after {
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: $scan-z-index;
background: linear-gradient(to bottom,
transparent 50%,
$scan-color 51%);
background-size: 100% $scan-width*2;
@include scan-crt($scan-crt);
}
}
/* ANIMATE UNIQUE SCANLINE */
@keyframes scanline {
0% {
transform: translate3d(0, 200000%, 0);
// bottom: 0%; // to have a continuous scanline move, use this line (here in 0% step) instead of transform and write, in &:before, { position: absolute; bottom: 100%; }
}
}
@keyframes scanlines {
0% {
background-position: 0 50%;
// bottom: 0%; // to have a continuous scanline move, use this line (here in 0% step) instead of transform and write, in &:before, { position: absolute; bottom: 100%; }
}
}

View File

@@ -145,6 +145,10 @@
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Mute" />
</div>
<div id="ToggleScanlines">
<img class="navButton off" src="images/nav/ic_scanlines_off_white_24dp_2x.png" title="Scan lines on" />
<img class="navButton on" src="images/nav/ic_scanlines_on_white_24dp_2x.png" title="Scan lines off" />
</div>
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_2x.png" title="Enter Fullscreen" />
</div>
</div>

View File

@@ -35,7 +35,6 @@
<div class="scroll-area">
<div class="frame template">
<div class="map">
<!-- <img src="images/maps/radar.webp" /> -->
</div>
</div>
</div>