mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-18 17:49:31 -07:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc64e4bd7f | ||
|
|
776148fa6b | ||
|
|
69c050eb8f | ||
|
|
a3e142dade | ||
|
|
28917489bb | ||
|
|
2365a4c0f7 | ||
|
|
8afef77ea5 | ||
|
|
8f70ee87c5 | ||
|
|
4e7429bfba | ||
|
|
c5ffe1542a | ||
|
|
5364855c58 | ||
|
|
18efd810bd | ||
|
|
68a6bae3a7 | ||
|
|
5f0f0d9000 | ||
|
|
9d9cf4b0f3 | ||
|
|
9e500143c0 | ||
|
|
71da682660 | ||
|
|
1b9a1dcb22 | ||
|
|
095761ee81 | ||
|
|
21e528aaa3 | ||
|
|
a92c632937 | ||
|
|
6073fd1733 | ||
|
|
5da8185633 | ||
|
|
cf5c818ee3 | ||
|
|
97cec114f6 | ||
|
|
7efd2e8db7 | ||
|
|
8c28f41d54 | ||
|
|
e9d603fbfc | ||
|
|
32aa43c5b1 | ||
|
|
dbc56f014a | ||
|
|
3161a03797 | ||
|
|
205fa77f51 | ||
|
|
28bb8f2e2a | ||
|
|
cf9a99a6ca | ||
|
|
a83afa71cd | ||
|
|
74f1abd6f8 |
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -31,7 +31,7 @@ To run via Node locally:
|
|||||||
git clone https://github.com/netbymatt/ws4kp.git
|
git clone https://github.com/netbymatt/ws4kp.git
|
||||||
cd ws4kp
|
cd ws4kp
|
||||||
npm i
|
npm i
|
||||||
node index.js
|
node index.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
To run via Docker:
|
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)
|
## 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.
|
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¤t-weather-checkbox=true&latest-observations-checkbox=true&hourly-checkbox=false&hourly-graph-checkbox=true&travel-checkbox=false®ional-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
|
||||||
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.
|
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)
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -99,8 +99,11 @@ const server = app.listen(port, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// graceful shutdown
|
// graceful shutdown
|
||||||
process.on('SIGINT', () => {
|
const gracefulShutdown = () => {
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log('Server closed');
|
console.log('Server closed');
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', gracefulShutdown);
|
||||||
|
process.on('SIGTERM', gracefulShutdown);
|
||||||
|
|||||||
745
package-lock.json
generated
745
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ws4kp",
|
"name": "ws4kp",
|
||||||
"version": "5.21.1",
|
"version": "5.21.13",
|
||||||
"description": "Welcome to the WeatherStar 4000+ project page!",
|
"description": "Welcome to the WeatherStar 4000+ project page!",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
BIN
server/images/icons/current-conditions/Smoke.gif
Normal file
BIN
server/images/icons/current-conditions/Smoke.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
server/images/nav/ic_scanlines_off_white_24dp_2x.png
Normal file
BIN
server/images/nav/ic_scanlines_off_white_24dp_2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 893 B |
BIN
server/images/nav/ic_scanlines_on_white_24dp_2x.png
Normal file
BIN
server/images/nav/ic_scanlines_on_white_24dp_2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 367 B |
@@ -38,6 +38,7 @@ const init = () => {
|
|||||||
document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick);
|
document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick);
|
||||||
document.querySelector('#NavigatePrevious').addEventListener('click', btnNavigatePreviousClick);
|
document.querySelector('#NavigatePrevious').addEventListener('click', btnNavigatePreviousClick);
|
||||||
document.querySelector('#NavigatePlay').addEventListener('click', btnNavigatePlayClick);
|
document.querySelector('#NavigatePlay').addEventListener('click', btnNavigatePlayClick);
|
||||||
|
document.querySelector('#ToggleScanlines').addEventListener('click', btnNavigateToggleScanlines);
|
||||||
document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR).addEventListener('click', btnFullScreenClick);
|
document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR).addEventListener('click', btnFullScreenClick);
|
||||||
const btnGetGps = document.querySelector(BNT_GET_GPS_SELECTOR);
|
const btnGetGps = document.querySelector(BNT_GET_GPS_SELECTOR);
|
||||||
btnGetGps.addEventListener('click', btnGetGpsClick);
|
btnGetGps.addEventListener('click', btnGetGpsClick);
|
||||||
@@ -61,7 +62,7 @@ const init = () => {
|
|||||||
paramName: 'text',
|
paramName: 'text',
|
||||||
params: {
|
params: {
|
||||||
f: 'json',
|
f: 'json',
|
||||||
countryCode: 'USA', // 'USA,PRI,VIR,GUM,ASM',
|
countryCode: 'USA',
|
||||||
category,
|
category,
|
||||||
maxSuggestions: 10,
|
maxSuggestions: 10,
|
||||||
},
|
},
|
||||||
@@ -82,7 +83,6 @@ const init = () => {
|
|||||||
|
|
||||||
// attempt to parse the url parameters
|
// attempt to parse the url parameters
|
||||||
const parsedParameters = parseQueryString();
|
const parsedParameters = parseQueryString();
|
||||||
|
|
||||||
const loadFromParsed = parsedParameters.latLonQuery && parsedParameters.latLon;
|
const loadFromParsed = parsedParameters.latLonQuery && parsedParameters.latLon;
|
||||||
|
|
||||||
// Auto load the parsed parameters and fall back to the previous query
|
// Auto load the parsed parameters and fall back to the previous query
|
||||||
@@ -295,6 +295,8 @@ const updateFullScreenNavigate = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const documentKeydown = (e) => {
|
const documentKeydown = (e) => {
|
||||||
|
// don't trigger on ctrl/alt/shift modified key
|
||||||
|
if (e.altKey || e.ctrlKey || e.shiftKey) return false;
|
||||||
const { key } = e;
|
const { key } = e;
|
||||||
|
|
||||||
if (document.fullscreenElement || document.activeElement === document.body) {
|
if (document.fullscreenElement || document.activeElement === document.body) {
|
||||||
@@ -345,6 +347,11 @@ const btnNavigatePlayClick = () => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const btnNavigateToggleScanlines = () => {
|
||||||
|
settings.scanLines.value = !settings.scanLines.value;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
// post a message to the iframe
|
// post a message to the iframe
|
||||||
const postMessage = (type, myMessage = {}) => {
|
const postMessage = (type, myMessage = {}) => {
|
||||||
navMessage({ type, message: myMessage });
|
navMessage({ type, message: myMessage });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// display sun and moon data
|
// 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 { DateTime } from '../vendor/auto/luxon.mjs';
|
||||||
import STATUS from './status.mjs';
|
import STATUS from './status.mjs';
|
||||||
import WeatherDisplay from './weatherdisplay.mjs';
|
import WeatherDisplay from './weatherdisplay.mjs';
|
||||||
@@ -9,9 +9,6 @@ class Almanac extends WeatherDisplay {
|
|||||||
constructor(navId, elemId) {
|
constructor(navId, elemId) {
|
||||||
super(navId, elemId, 'Almanac', true);
|
super(navId, elemId, 'Almanac', true);
|
||||||
|
|
||||||
// pre-load background images (returns promises)
|
|
||||||
this.backgroundImage0 = loadImg('images/backgrounds/1.png');
|
|
||||||
|
|
||||||
// preload the moon images
|
// preload the moon images
|
||||||
preloadImg(imageName('Full'));
|
preloadImg(imageName('Full'));
|
||||||
preloadImg(imageName('Last'));
|
preloadImg(imageName('Last'));
|
||||||
@@ -122,10 +119,10 @@ class Almanac extends WeatherDisplay {
|
|||||||
// sun and moon data
|
// sun and moon data
|
||||||
this.elem.querySelector('.day-1').innerHTML = Today.toLocaleString({ weekday: 'long' });
|
this.elem.querySelector('.day-1').innerHTML = Today.toLocaleString({ weekday: 'long' });
|
||||||
this.elem.querySelector('.day-2').innerHTML = Tomorrow.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-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunrise));
|
||||||
this.elem.querySelector('.rise-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
this.elem.querySelector('.rise-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunrise));
|
||||||
this.elem.querySelector('.set-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
this.elem.querySelector('.set-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunset));
|
||||||
this.elem.querySelector('.set-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
this.elem.querySelector('.set-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunset));
|
||||||
|
|
||||||
const days = info.moon.map((MoonPhase) => {
|
const days = info.moon.map((MoonPhase) => {
|
||||||
const fill = {};
|
const fill = {};
|
||||||
@@ -171,6 +168,8 @@ const imageName = (type) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const timeFormat = (dt) => dt.setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||||
|
|
||||||
// register display
|
// register display
|
||||||
const display = new Almanac(9, 'almanac');
|
const display = new Almanac(9, 'almanac');
|
||||||
registerDisplay(display);
|
registerDisplay(display);
|
||||||
|
|||||||
@@ -3,43 +3,24 @@ import { json } from './utils/fetch.mjs';
|
|||||||
|
|
||||||
const KEYS = {
|
const KEYS = {
|
||||||
ESC: 27,
|
ESC: 27,
|
||||||
TAB: 9,
|
|
||||||
RETURN: 13,
|
|
||||||
LEFT: 37,
|
|
||||||
UP: 38,
|
UP: 38,
|
||||||
RIGHT: 39,
|
|
||||||
DOWN: 40,
|
DOWN: 40,
|
||||||
ENTER: 13,
|
ENTER: 13,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_OPTIONS = {
|
const DEFAULT_OPTIONS = {
|
||||||
autoSelectFirst: false,
|
|
||||||
serviceUrl: null,
|
serviceUrl: null,
|
||||||
lookup: null,
|
|
||||||
onSelect: () => { },
|
|
||||||
onHint: null,
|
|
||||||
width: 'auto',
|
|
||||||
minChars: 3,
|
minChars: 3,
|
||||||
maxHeight: 300,
|
maxHeight: 300,
|
||||||
deferRequestBy: 0,
|
deferRequestBy: 0,
|
||||||
params: {},
|
params: {},
|
||||||
delimiter: null,
|
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
noCache: false,
|
|
||||||
preserveInput: false,
|
|
||||||
containerClass: 'autocomplete-suggestions',
|
containerClass: 'autocomplete-suggestions',
|
||||||
tabDisabled: false,
|
|
||||||
dataType: 'text',
|
|
||||||
currentRequest: null,
|
|
||||||
triggerSelectOnValidInput: true,
|
|
||||||
preventBadQueries: true,
|
|
||||||
paramName: 'query',
|
paramName: 'query',
|
||||||
transformResult: (a) => a,
|
transformResult: (a) => a,
|
||||||
showNoSuggestionNotice: false,
|
showNoSuggestionNotice: false,
|
||||||
noSuggestionNotice: 'No results',
|
noSuggestionNotice: 'No results',
|
||||||
orientation: 'bottom',
|
|
||||||
forceFixPosition: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const escapeRegExChars = (string) => string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
const escapeRegExChars = (string) => string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// current weather conditions display
|
// current weather conditions display
|
||||||
import STATUS from './status.mjs';
|
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 { json } from './utils/fetch.mjs';
|
||||||
import { directionToNSEW } from './utils/calc.mjs';
|
import { directionToNSEW } from './utils/calc.mjs';
|
||||||
import { locationCleanup } from './utils/string.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 {
|
class CurrentWeather extends WeatherDisplay {
|
||||||
constructor(navId, elemId) {
|
constructor(navId, elemId) {
|
||||||
super(navId, elemId, 'Current Conditions', true);
|
super(navId, elemId, 'Current Conditions', true);
|
||||||
// pre-load background image (returns promise)
|
|
||||||
this.backgroundImage = loadImg('images/backgrounds/1.png');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getData(weatherParameters, refresh) {
|
async getData(weatherParameters, refresh) {
|
||||||
@@ -52,6 +50,8 @@ class CurrentWeather extends WeatherDisplay {
|
|||||||
stillWaiting: () => this.stillWaiting(),
|
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
|
// test data quality
|
||||||
if (observations.features[0].properties.temperature.value === null
|
if (observations.features[0].properties.temperature.value === null
|
||||||
|| observations.features[0].properties.windSpeed.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.dewpoint.value === null
|
||||||
|| observations.features[0].properties.barometricPressure.value === null) {
|
|| observations.features[0].properties.barometricPressure.value === null) {
|
||||||
observations = undefined;
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
observations = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// test for data received
|
// test for data received
|
||||||
|
|||||||
@@ -59,11 +59,10 @@ class ExtendedForecast extends WeatherDisplay {
|
|||||||
date: Day.dayName,
|
date: Day.dayName,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { low } = Day;
|
const { low, high } = Day;
|
||||||
if (low !== undefined) {
|
if (low !== undefined) {
|
||||||
fill['value-lo'] = Math.round(low);
|
fill['value-lo'] = Math.round(low);
|
||||||
}
|
}
|
||||||
const { high } = Day;
|
|
||||||
fill['value-hi'] = Math.round(high);
|
fill['value-hi'] = Math.round(high);
|
||||||
|
|
||||||
// return the filled template
|
// return the filled template
|
||||||
@@ -121,17 +120,18 @@ const parse = (fullForecast) => {
|
|||||||
return forecast;
|
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 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
|
// run all regexes
|
||||||
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);
|
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class Hazards extends WeatherDisplay {
|
|||||||
// show alert indicator
|
// show alert indicator
|
||||||
if (this.data.length > 0) alert.classList.add('show');
|
if (this.data.length > 0) alert.classList.add('show');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get hourly forecast failed');
|
console.error('Get hazards failed');
|
||||||
console.error(error.status, error.responseJSON);
|
console.error(error.status, error.responseJSON);
|
||||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||||
// return undefined to other subscribers
|
// return undefined to other subscribers
|
||||||
@@ -129,7 +129,7 @@ class Hazards extends WeatherDisplay {
|
|||||||
// don't let offset go negative
|
// don't let offset go negative
|
||||||
if (offsetY < 0) offsetY = 0;
|
if (offsetY < 0) offsetY = 0;
|
||||||
|
|
||||||
// copy the scrolled portion of the canvas
|
// move the element
|
||||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import WeatherDisplay from './weatherdisplay.mjs';
|
|||||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||||
|
|
||||||
|
// get available space
|
||||||
|
const availableWidth = 532;
|
||||||
|
const availableHeight = 285;
|
||||||
|
|
||||||
class HourlyGraph extends WeatherDisplay {
|
class HourlyGraph extends WeatherDisplay {
|
||||||
constructor(navId, elemId, defaultActive) {
|
constructor(navId, elemId, defaultActive) {
|
||||||
super(navId, elemId, 'Hourly Graph', defaultActive);
|
super(navId, elemId, 'Hourly Graph', defaultActive);
|
||||||
@@ -47,10 +51,6 @@ class HourlyGraph extends WeatherDisplay {
|
|||||||
drawCanvas() {
|
drawCanvas() {
|
||||||
if (!this.image) this.image = this.elem.querySelector('.chart img');
|
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.width = availableWidth;
|
||||||
this.image.height = availableHeight;
|
this.image.height = availableHeight;
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,7 @@ class Hourly extends WeatherDisplay {
|
|||||||
const fillValues = {};
|
const fillValues = {};
|
||||||
// hour
|
// hour
|
||||||
const hour = startingHour.plus({ hours: index });
|
const hour = startingHour.plus({ hours: index });
|
||||||
const formattedHour = hour.toLocaleString({ weekday: 'short', hour: 'numeric' });
|
fillValues.hour = hour.toLocaleString({ weekday: 'short', hour: 'numeric' });
|
||||||
fillValues.hour = formattedHour;
|
|
||||||
|
|
||||||
// temperatures, convert to strings with no decimal
|
// temperatures, convert to strings with no decimal
|
||||||
const temperature = data.temperature.toString().padStart(3);
|
const temperature = data.temperature.toString().padStart(3);
|
||||||
@@ -81,12 +80,11 @@ class Hourly extends WeatherDisplay {
|
|||||||
fillValues.like = feelsLike;
|
fillValues.like = feelsLike;
|
||||||
|
|
||||||
// wind
|
// wind
|
||||||
let wind = 'Calm';
|
fillValues.wind = 'Calm';
|
||||||
if (data.windSpeed > 0) {
|
if (data.windSpeed > 0) {
|
||||||
const windSpeed = Math.round(data.windSpeed).toString();
|
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
|
// image
|
||||||
fillValues.icon = { type: 'img', src: data.icon };
|
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
|
// alter the color of the feels like column to reflect wind chill or heat index
|
||||||
if (feelsLike < temperature) {
|
if (feelsLike < temperature) {
|
||||||
filledRow.querySelector('.like').classList.add('wind-chill');
|
filledRow.querySelector('.like').classList.add('wind-chill');
|
||||||
}
|
} else if (feelsLike > temperature) {
|
||||||
if (feelsLike > temperature) {
|
|
||||||
filledRow.querySelector('.like').classList.add('heat-index');
|
filledRow.querySelector('.like').classList.add('heat-index');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const hourlyIcon = (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed, isNight = false) => {
|
// internal function to add path to returned icon
|
||||||
// internal function to add path to returned icon
|
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
|
||||||
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
|
|
||||||
|
|
||||||
|
const hourlyIcon = (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed, isNight = false) => {
|
||||||
// possible phenomenon
|
// possible phenomenon
|
||||||
let thunder = false;
|
let thunder = false;
|
||||||
let snow = false;
|
let snow = false;
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ const largeIcon = (link, _isNightTime) => {
|
|||||||
case 'sleet-n':
|
case 'sleet-n':
|
||||||
return addPath('Sleet.gif');
|
return addPath('Sleet.gif');
|
||||||
|
|
||||||
|
case 'smoke':
|
||||||
|
case 'smoke-n':
|
||||||
|
return addPath('Smoke.gif');
|
||||||
|
|
||||||
case 'rain_showers':
|
case 'rain_showers':
|
||||||
case 'rain_showers_high':
|
case 'rain_showers_high':
|
||||||
case 'rain_showers-n':
|
case 'rain_showers-n':
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const smallIcon = (link, _isNightTime) => {
|
|||||||
case 'sct-n':
|
case 'sct-n':
|
||||||
case 'nsct':
|
case 'nsct':
|
||||||
case 'nsct-n':
|
case 'nsct-n':
|
||||||
|
case 'haze-n':
|
||||||
return addPath('Partly-Cloudy-Night.gif');
|
return addPath('Partly-Cloudy-Night.gif');
|
||||||
|
|
||||||
case 'ovc':
|
case 'ovc':
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ class LatestObservations extends WeatherDisplay {
|
|||||||
// this is intentional because up to 30 stations are available to pull data from
|
// this is intentional because up to 30 stations are available to pull data from
|
||||||
|
|
||||||
// calculate distance to each station
|
// calculate distance to each station
|
||||||
const stationsByDistance = Object.keys(StationInfo).map((key) => {
|
const stationsByDistance = Object.values(StationInfo).map((station) => {
|
||||||
const station = StationInfo[key];
|
|
||||||
const distance = calcDistance(station.lat, station.lon, this.weatherParameters.latitude, this.weatherParameters.longitude);
|
const distance = calcDistance(station.lat, station.lon, this.weatherParameters.latitude, this.weatherParameters.longitude);
|
||||||
return { ...station, distance };
|
return { ...station, distance };
|
||||||
});
|
});
|
||||||
@@ -104,8 +103,6 @@ class LatestObservations extends WeatherDisplay {
|
|||||||
linesContainer.innerHTML = '';
|
linesContainer.innerHTML = '';
|
||||||
linesContainer.append(...lines);
|
linesContainer.append(...lines);
|
||||||
|
|
||||||
// update temperature unit header
|
|
||||||
|
|
||||||
this.finishDraw();
|
this.finishDraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,9 +253,9 @@ const resize = () => {
|
|||||||
|
|
||||||
const scale = Math.min(widthZoomPercent, heightZoomPercent);
|
const scale = Math.min(widthZoomPercent, heightZoomPercent);
|
||||||
if (scale < 1.0 || document.fullscreenElement || settings.kiosk) {
|
if (scale < 1.0 || document.fullscreenElement || settings.kiosk) {
|
||||||
document.querySelector('#container').style.transform = `scale(${scale})`;
|
document.querySelector('#container').style.zoom = scale;
|
||||||
} else {
|
} else {
|
||||||
document.querySelector('#container').style.transform = 'unset';
|
document.querySelector('#container').style.zoom = 'unset';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,6 +266,7 @@ const resetStatuses = () => {
|
|||||||
|
|
||||||
// allow displays to register themselves
|
// allow displays to register themselves
|
||||||
const registerDisplay = (display) => {
|
const registerDisplay = (display) => {
|
||||||
|
if (displays[display.navId]) console.warn(`Display nav ID ${display.navId} already in use`);
|
||||||
displays[display.navId] = display;
|
displays[display.navId] = display;
|
||||||
|
|
||||||
// generate checkboxes
|
// generate checkboxes
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// regional forecast and observations
|
// regional forecast and observations
|
||||||
import { loadImg } from './utils/image.mjs';
|
|
||||||
import STATUS, { calcStatusClass, statusClasses } from './status.mjs';
|
import STATUS, { calcStatusClass, statusClasses } from './status.mjs';
|
||||||
import WeatherDisplay from './weatherdisplay.mjs';
|
import WeatherDisplay from './weatherdisplay.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -10,9 +9,6 @@ class Progress extends WeatherDisplay {
|
|||||||
constructor(navId, elemId) {
|
constructor(navId, elemId) {
|
||||||
super(navId, elemId, '', false);
|
super(navId, elemId, '', false);
|
||||||
|
|
||||||
// pre-load background image (returns promise)
|
|
||||||
this.backgroundImage = loadImg('images/backgrounds/1.png');
|
|
||||||
|
|
||||||
// disable any navigation timing
|
// disable any navigation timing
|
||||||
this.timing = false;
|
this.timing = false;
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,27 @@ import WeatherDisplay from './weatherdisplay.mjs';
|
|||||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||||
import * as utils from './radar-utils.mjs';
|
import * as utils from './radar-utils.mjs';
|
||||||
|
|
||||||
|
// TEMPORARY fix to disable radar on ios safari. The same engine (webkit) is
|
||||||
|
// used for all ios browers (chrome, brave, firefox, etc) so it's safe to skip
|
||||||
|
// any subsequent narrowing of the user-agent.
|
||||||
|
const isIos = /iP(ad|od|hone)/i.test(window.navigator.userAgent);
|
||||||
|
// NOTE: iMessages/Messages preview is provided by an Apple scraper that uses a
|
||||||
|
// user-agent similar to: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1)
|
||||||
|
// AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4
|
||||||
|
// facebookexternalhit/1.1 Facebot Twitterbot/1.0`. There is currently a bug in
|
||||||
|
// Messages macos/ios where a constantly crashing website seems to cause an
|
||||||
|
// entire Messages thread to permanently lockup until the individual website
|
||||||
|
// preview is deleted! Messages ios will judder but allows the message to be
|
||||||
|
// deleted eventually. Messages macos beachballs forever and prevents the
|
||||||
|
// successful deletion. See
|
||||||
|
// https://github.com/netbymatt/ws4kp/issues/74#issuecomment-2921154962 for more
|
||||||
|
// context.
|
||||||
|
const isBot = /twitterbot|Facebot/i.test(window.navigator.userAgent);
|
||||||
|
|
||||||
const RADAR_HOST = 'mesonet.agron.iastate.edu';
|
const RADAR_HOST = 'mesonet.agron.iastate.edu';
|
||||||
class Radar extends WeatherDisplay {
|
class Radar extends WeatherDisplay {
|
||||||
constructor(navId, elemId) {
|
constructor(navId, elemId) {
|
||||||
super(navId, elemId, 'Local Radar', true);
|
super(navId, elemId, 'Local Radar', !isIos && !isBot);
|
||||||
|
|
||||||
this.okToDrawCurrentConditions = false;
|
this.okToDrawCurrentConditions = false;
|
||||||
this.okToDrawCurrentDateTime = false;
|
this.okToDrawCurrentDateTime = false;
|
||||||
@@ -39,9 +56,6 @@ class Radar extends WeatherDisplay {
|
|||||||
{ time: 1, si: 4 },
|
{ time: 1, si: 4 },
|
||||||
{ time: 12, si: 5 },
|
{ time: 12, si: 5 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// get some web workers started
|
|
||||||
this.workers = (new Array(this.dopplerRadarImageMax)).fill(null).map(() => radarWorker());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getData(weatherParameters, refresh) {
|
async getData(weatherParameters, refresh) {
|
||||||
@@ -53,6 +67,12 @@ class Radar extends WeatherDisplay {
|
|||||||
return;
|
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 baseUrl = `https://${RADAR_HOST}/archive/data/`;
|
||||||
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png';
|
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png';
|
||||||
const baseUrls = [];
|
const baseUrls = [];
|
||||||
@@ -100,10 +120,8 @@ class Radar extends WeatherDisplay {
|
|||||||
const urls = sortedPngs.slice(-(this.dopplerRadarImageMax));
|
const urls = sortedPngs.slice(-(this.dopplerRadarImageMax));
|
||||||
|
|
||||||
// calculate offsets and sizes
|
// calculate offsets and sizes
|
||||||
let offsetX = 120;
|
const offsetX = 120 * 2;
|
||||||
let offsetY = 69;
|
const offsetY = 69 * 2;
|
||||||
offsetX *= 2;
|
|
||||||
offsetY *= 2;
|
|
||||||
const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY);
|
const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY);
|
||||||
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY);
|
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY);
|
||||||
|
|
||||||
@@ -201,4 +219,7 @@ const radarWorker = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// register display
|
// register display
|
||||||
registerDisplay(new Radar(11, 'radar'));
|
// TEMPORARY: except on IOS and bots
|
||||||
|
if (!isIos && !isBot) {
|
||||||
|
registerDisplay(new Radar(11, 'radar'));
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const buildForecast = (forecast, city, cityXY) => {
|
|||||||
const getRegionalObservation = async (point, city) => {
|
const getRegionalObservation = async (point, city) => {
|
||||||
try {
|
try {
|
||||||
// get stations
|
// 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
|
// get the first station
|
||||||
const station = stations.features[0].id;
|
const station = stations.features[0].id;
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ import { json } from './utils/fetch.mjs';
|
|||||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||||
import { getSmallIcon } from './icons.mjs';
|
import { getSmallIcon } from './icons.mjs';
|
||||||
import { preloadImg } from './utils/image.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 WeatherDisplay from './weatherdisplay.mjs';
|
||||||
import { registerDisplay } from './navigation.mjs';
|
import { registerDisplay } from './navigation.mjs';
|
||||||
import * as utils from './regionalforecast-utils.mjs';
|
import * as utils from './regionalforecast-utils.mjs';
|
||||||
import { getPoint } from './utils/weather.mjs';
|
import { getPoint } from './utils/weather.mjs';
|
||||||
|
|
||||||
|
// map offset
|
||||||
|
const mapOffsetXY = {
|
||||||
|
x: 240,
|
||||||
|
y: 117,
|
||||||
|
};
|
||||||
|
|
||||||
class RegionalForecast extends WeatherDisplay {
|
class RegionalForecast extends WeatherDisplay {
|
||||||
constructor(navId, elemId) {
|
constructor(navId, elemId) {
|
||||||
super(navId, elemId, 'Regional Forecast', true);
|
super(navId, elemId, 'Regional Forecast', true);
|
||||||
@@ -36,23 +42,18 @@ class RegionalForecast extends WeatherDisplay {
|
|||||||
}
|
}
|
||||||
this.elem.querySelector('.map img').src = baseMap;
|
this.elem.querySelector('.map img').src = baseMap;
|
||||||
|
|
||||||
// map offset
|
|
||||||
const offsetXY = {
|
|
||||||
x: 240,
|
|
||||||
y: 117,
|
|
||||||
};
|
|
||||||
// get user's location in x/y
|
// 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
|
// 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
|
// get a target distance
|
||||||
let targetDistance = 2.5;
|
let targetDistance = 2.5;
|
||||||
if (this.weatherParameters.state === 'HI') targetDistance = 1;
|
if (this.weatherParameters.state === 'HI') targetDistance = 1;
|
||||||
|
|
||||||
// make station info into an array
|
// 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
|
// combine regional cities with station info for additional stations
|
||||||
// stations are intentionally after cities to allow cities priority when drawing the map
|
// stations are intentionally after cities to allow cities priority when drawing the map
|
||||||
const combinedCities = [...RegionalCities, ...stationInfoArray];
|
const combinedCities = [...RegionalCities, ...stationInfoArray];
|
||||||
@@ -76,6 +77,9 @@ class RegionalForecast extends WeatherDisplay {
|
|||||||
// get a unit converter
|
// get a unit converter
|
||||||
const temperatureConverter = temperatureUnit();
|
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)
|
// 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) => {
|
const regionalDataAll = await Promise.all(regionalCities.map(async (city) => {
|
||||||
try {
|
try {
|
||||||
@@ -109,14 +113,24 @@ class RegionalForecast extends WeatherDisplay {
|
|||||||
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
|
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
|
||||||
|
|
||||||
// return a pared-down forecast
|
// return a pared-down forecast
|
||||||
// 0th object is the current conditions
|
// 0th object should contain the current conditions, but when WFOs go offline or otherwise don't post
|
||||||
// first object is the next period i.e. if it's daytime then it's the "tonight" forecast
|
// an updated forecast it's possible that the 0th object is in the past.
|
||||||
// second object is the following period
|
// so we go on a search for the current time in the start/end times provided in the forecast periods
|
||||||
// always skip the first forecast index because it's what's going on right now
|
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 [
|
return [
|
||||||
regionalObservation,
|
regionalObservation,
|
||||||
utils.buildForecast(forecast.properties.periods[1], city, cityXY),
|
utils.buildForecast(forecast.properties.periods[currentPeriod + 1], city, cityXY),
|
||||||
utils.buildForecast(forecast.properties.periods[2], city, cityXY),
|
utils.buildForecast(forecast.properties.periods[currentPeriod + 2], city, cityXY),
|
||||||
];
|
];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`No regional forecast data for '${city.name ?? city.city}'`);
|
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
|
// return the weather data and offsets
|
||||||
this.data = {
|
this.data = {
|
||||||
regionalData,
|
regionalData,
|
||||||
offsetXY,
|
mapOffsetXY,
|
||||||
sourceXY,
|
sourceXY,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,7 +161,7 @@ class RegionalForecast extends WeatherDisplay {
|
|||||||
drawCanvas() {
|
drawCanvas() {
|
||||||
super.drawCanvas();
|
super.drawCanvas();
|
||||||
// break up data into useful values
|
// break up data into useful values
|
||||||
const { regionalData: data, sourceXY, offsetXY } = this.data;
|
const { regionalData: data, sourceXY } = this.data;
|
||||||
|
|
||||||
// draw the header graphics
|
// draw the header graphics
|
||||||
|
|
||||||
@@ -170,7 +184,7 @@ class RegionalForecast extends WeatherDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// draw the map
|
// draw the map
|
||||||
const scale = 640 / (offsetXY.x * 2);
|
const scale = 640 / (mapOffsetXY.x * 2);
|
||||||
const map = this.elem.querySelector('.map');
|
const map = this.elem.querySelector('.map');
|
||||||
map.style.transform = `scale(${scale}) translate(-${sourceXY.x}px, -${sourceXY.y}px)`;
|
map.style.transform = `scale(${scale}) translate(-${sourceXY.x}px, -${sourceXY.y}px)`;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ const init = () => {
|
|||||||
[1.5, 'Very Slow'],
|
[1.5, 'Very Slow'],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
settings.scanLines = new Setting('scanLines', {
|
||||||
|
name: 'Scan Lines',
|
||||||
|
defaultValue: false,
|
||||||
|
changeAction: scanLineChange,
|
||||||
|
sticky: true,
|
||||||
|
});
|
||||||
settings.units = new Setting('units', {
|
settings.units = new Setting('units', {
|
||||||
name: 'Units',
|
name: 'Units',
|
||||||
type: 'select',
|
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 = () => {
|
const unitChange = () => {
|
||||||
// reload the data at the top level to refresh units
|
// reload the data at the top level to refresh units
|
||||||
// after the initial load
|
// after the initial load
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { elemForEach } from './utils/elem.mjs';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => init());
|
document.addEventListener('DOMContentLoaded', () => init());
|
||||||
|
|
||||||
// shorthand mappings for frequently used values
|
// shorthand mappings for frequently used values
|
||||||
@@ -19,21 +21,18 @@ const init = () => {
|
|||||||
const createLink = async (e) => {
|
const createLink = async (e) => {
|
||||||
// cancel default event (click on hyperlink)
|
// cancel default event (click on hyperlink)
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// get all checkboxes on page
|
|
||||||
const checkboxes = document.querySelectorAll('input[type=checkbox]');
|
|
||||||
|
|
||||||
// list to receive checkbox statuses
|
// list to receive checkbox statuses
|
||||||
const queryStringElements = {};
|
const queryStringElements = {};
|
||||||
|
|
||||||
[...checkboxes].forEach((elem) => {
|
elemForEach('input[type=checkbox]', (elem) => {
|
||||||
if (elem?.id) {
|
if (elem?.id) {
|
||||||
queryStringElements[elem.id] = elem?.checked ?? false;
|
queryStringElements[elem.id] = elem?.checked ?? false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// get all select boxes
|
// get all select boxes
|
||||||
const selects = document.querySelectorAll('select');
|
elemForEach('select', (elem) => {
|
||||||
[...selects].forEach((elem) => {
|
|
||||||
if (elem?.id) {
|
if (elem?.id) {
|
||||||
queryStringElements[elem.id] = elem?.value ?? 0;
|
queryStringElements[elem.id] = elem?.value ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ const text = (url, params) => fetchAsync(url, 'text', params);
|
|||||||
const blob = (url, params) => fetchAsync(url, 'blob', params);
|
const blob = (url, params) => fetchAsync(url, 'blob', params);
|
||||||
|
|
||||||
const fetchAsync = async (_url, responseType, _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
|
// combine default and provided parameters
|
||||||
const params = {
|
const params = {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -12,6 +17,7 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
|||||||
type: 'GET',
|
type: 'GET',
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
..._params,
|
..._params,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
// store original number of retries
|
// store original number of retries
|
||||||
params.originalRetries = params.retryCount;
|
params.originalRetries = params.retryCount;
|
||||||
|
|||||||
@@ -1,21 +1,4 @@
|
|||||||
import { blob } from './fetch.mjs';
|
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
|
// 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
|
// 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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadImgElement = (url) => new Promise((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
image.onload = () => resolve(image);
|
|
||||||
image.onerror = reject;
|
|
||||||
image.src = url;
|
|
||||||
});
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
loadImg,
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
preloadImg,
|
preloadImg,
|
||||||
loadImgElement,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class Setting {
|
|||||||
break;
|
break;
|
||||||
case 'checkbox':
|
case 'checkbox':
|
||||||
default:
|
default:
|
||||||
this.element.checked = newValue;
|
this.element.querySelector('input').checked = newValue;
|
||||||
}
|
}
|
||||||
this.storeToLocalStorage(this.myValue);
|
this.storeToLocalStorage(this.myValue);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { json } from './fetch.mjs';
|
|||||||
|
|
||||||
const getPoint = async (lat, lon) => {
|
const getPoint = async (lat, lon) => {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.log(`Unable to get point ${lat}, ${lon}`);
|
console.log(`Unable to get point ${lat}, ${lon}`);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from './navigation.mjs';
|
} from './navigation.mjs';
|
||||||
import { parseQueryString } from './share.mjs';
|
import { parseQueryString } from './share.mjs';
|
||||||
import settings from './settings.mjs';
|
import settings from './settings.mjs';
|
||||||
|
import { elemForEach } from './utils/elem.mjs';
|
||||||
|
|
||||||
class WeatherDisplay {
|
class WeatherDisplay {
|
||||||
constructor(navId, elemId, name, defaultEnabled) {
|
constructor(navId, elemId, name, defaultEnabled) {
|
||||||
@@ -391,8 +392,7 @@ class WeatherDisplay {
|
|||||||
this.templates = {};
|
this.templates = {};
|
||||||
this.elem = document.querySelector(`#${this.elemId}-html`);
|
this.elem = document.querySelector(`#${this.elemId}-html`);
|
||||||
if (!this.elem) return;
|
if (!this.elem) return;
|
||||||
const templates = this.elem.querySelectorAll('.template');
|
elemForEach(`#${this.elemId}-html .template`, (template) => {
|
||||||
templates.forEach((template) => {
|
|
||||||
const className = template.classList[0];
|
const className = template.classList[0];
|
||||||
const node = template.cloneNode(true);
|
const node = template.cloneNode(true);
|
||||||
node.classList.remove('template');
|
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
@@ -23,6 +23,8 @@ body {
|
|||||||
|
|
||||||
&.kiosk {
|
&.kiosk {
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +143,10 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kiosk #divTwc {
|
||||||
|
max-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
#divTwcLeft {
|
#divTwcLeft {
|
||||||
display: none;
|
display: none;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -189,11 +195,33 @@ body {
|
|||||||
#divTwcBottom>div {
|
#divTwcBottom>div {
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
padding-right: 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 {
|
#divTwcBottomLeft {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#divTwcBottomMiddle {
|
#divTwcBottomMiddle {
|
||||||
@@ -300,10 +328,6 @@ body {
|
|||||||
transform-origin: unset;
|
transform-origin: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kiosk #divTwc #container {
|
|
||||||
transform-origin: 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#loading {
|
#loading {
|
||||||
width: 640px;
|
width: 640px;
|
||||||
height: 480px;
|
height: 480px;
|
||||||
@@ -404,10 +428,6 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.kiosk #divTwc {
|
|
||||||
justify-content: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
#divTwc:fullscreen #display,
|
#divTwc:fullscreen #display,
|
||||||
.kiosk #divTwc #display {
|
.kiosk #divTwc #display {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -436,6 +456,30 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ToggleScanlines {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
.on {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.off {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&.on {
|
||||||
|
.on {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.off {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.visible {
|
.visible {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -716,7 +760,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#share-link-copied {
|
#share-link-copied {
|
||||||
color: c.$title-color;
|
color: hsl(60, 100%, 30%);
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,6 +772,7 @@ body {
|
|||||||
|
|
||||||
#divQuery,
|
#divQuery,
|
||||||
>.info,
|
>.info,
|
||||||
|
>.related-links,
|
||||||
>.heading,
|
>.heading,
|
||||||
#enabledDisplays,
|
#enabledDisplays,
|
||||||
#settings,
|
#settings,
|
||||||
|
|||||||
@@ -14,3 +14,4 @@
|
|||||||
@use 'hazards';
|
@use 'hazards';
|
||||||
@use 'media';
|
@use 'media';
|
||||||
@use 'spc-outlook';
|
@use 'spc-outlook';
|
||||||
|
@use 'shared/scanlines';
|
||||||
106
server/styles/scss/shared/_scanlines.scss
Normal file
106
server/styles/scss/shared/_scanlines.scss
Normal 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%; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,10 @@
|
|||||||
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
|
<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" />
|
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Mute" />
|
||||||
</div>
|
</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" />
|
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_2x.png" title="Enter Fullscreen" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<div class="scroll-area">
|
<div class="scroll-area">
|
||||||
<div class="frame template">
|
<div class="frame template">
|
||||||
<div class="map">
|
<div class="map">
|
||||||
<!-- <img src="images/maps/radar.webp" /> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user