Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71da682660 | ||
|
|
1b9a1dcb22 | ||
|
|
095761ee81 | ||
|
|
21e528aaa3 | ||
|
|
a92c632937 | ||
|
|
6073fd1733 | ||
|
|
5da8185633 | ||
|
|
cf5c818ee3 | ||
|
|
97cec114f6 | ||
|
|
7efd2e8db7 | ||
|
|
8c28f41d54 | ||
|
|
e9d603fbfc | ||
|
|
32aa43c5b1 | ||
|
|
dbc56f014a | ||
|
|
3161a03797 | ||
|
|
205fa77f51 | ||
|
|
28bb8f2e2a | ||
|
|
cf9a99a6ca | ||
|
|
a83afa71cd | ||
|
|
74f1abd6f8 | ||
|
|
1bd45bdeeb | ||
|
|
232061b4d8 | ||
|
|
10d10ffbfb | ||
|
|
25ac2059a6 | ||
|
|
25626a98c9 | ||
|
|
002e037bbd | ||
|
|
8d20f7672c | ||
|
|
5567fe37a6 | ||
|
|
2dcc33f210 | ||
|
|
8f86f80eb5 | ||
|
|
1609ab3d38 | ||
|
|
0be23ee988 | ||
|
|
a3ea2c3708 | ||
|
|
09fb698350 | ||
|
|
6f6efe801c | ||
|
|
bc77a1891c | ||
|
|
4666878250 | ||
|
|
5813dd9a92 | ||
|
|
8cb8873760 | ||
|
|
323c175936 | ||
|
|
85e2553cb2 | ||
|
|
101d0ac9ea | ||
|
|
834d68f9e3 | ||
|
|
0ee7fdc9f8 | ||
|
|
d75121e894 | ||
|
|
4cdced3659 | ||
|
|
1a5548d135 | ||
|
|
11c826a2af | ||
|
|
7a129c1cd3 | ||
|
|
867657a965 | ||
|
|
e89dc52541 | ||
|
|
317883fc04 | ||
|
|
a4a601a387 | ||
|
|
375812c024 | ||
|
|
6af8b58f14 | ||
|
|
6287db7483 | ||
|
|
7a196ac64a | ||
|
|
5946ee495a |
@@ -12,7 +12,8 @@
|
||||
"RegionalCities": "readonly",
|
||||
"StationInfo": "readonly",
|
||||
"SunCalc": "readonly",
|
||||
"NoSleep": "readonly"
|
||||
"NoSleep": "readonly",
|
||||
"OVERRIDES": "readonly"
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
|
||||
@@ -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:
|
||||
@@ -109,7 +109,7 @@ The resulting files will be in the /dist folder in the root of the project. Thes
|
||||
## Music
|
||||
The WeatherStar had wonderful background music from the smooth jazz and new age genres by artists of the time. Lists of the music that played are available by searching online, but it's all copyrighted music and would be difficult to provide as part of this repository.
|
||||
|
||||
I've used AI tools to create WeatherStar-inspired music tracks that are unencumbered by copyright and are included in this repo. Too keep the size down, I've only included 4 tracks. Additional tracks will be posted in a companion repository [ws4kp-music](https://github.com/netbymatt/ws4kp-music).
|
||||
I've used AI tools to create WeatherStar-inspired music tracks that are unencumbered by copyright and are included in this repo. Too keep the size down, I've only included 4 tracks. Additional tracks are in a companion repository [ws4kp-music](https://github.com/netbymatt/ws4kp-music).
|
||||
|
||||
### Customizing the music
|
||||
Placing .mp3 files in the `/server/music` folder will override the default music included in the repo. Subdirectories will not be scanned. When weatherstar loads in the browser it will load a list if available files and randomize the order when it starts playing. On each loop through the available tracks the order will again be shuffled. If you're using the static files method to host your WeatherStar music is located in `/music`.
|
||||
@@ -141,6 +141,10 @@ Please do not report issues with api.weather.gov being down. It's a new service
|
||||
|
||||
Note: not all units are converted to metric, if selected. Some text-based products such as warnings are simple text strings provided from the national weather service and thus have baked-in units such as "gusts up to 60 mph." These values will not be converted.
|
||||
|
||||
## Related Projects
|
||||
|
||||
Not retro enough? Try the [Weatherstar 3000+](https://github.com/netbymatt/ws3kp)
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -14,9 +14,10 @@ import webpack from 'webpack-stream';
|
||||
import TerserPlugin from 'terser-webpack-plugin';
|
||||
import { readFile } from 'fs/promises';
|
||||
import file from 'gulp-file';
|
||||
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
|
||||
import OVERRIDES from '../src/overrides.mjs';
|
||||
|
||||
// get cloudfront
|
||||
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
|
||||
import reader from '../src/playlist-reader.mjs';
|
||||
|
||||
const clean = () => deleteAsync(['./dist/**/*', '!./dist/readme.txt']);
|
||||
@@ -76,6 +77,7 @@ const mjsSources = [
|
||||
'server/scripts/modules/hazards.mjs',
|
||||
'server/scripts/modules/currentweather.mjs',
|
||||
'server/scripts/modules/almanac.mjs',
|
||||
'server/scripts/modules/spc-outlook.mjs',
|
||||
'server/scripts/modules/icons.mjs',
|
||||
'server/scripts/modules/extendedforecast.mjs',
|
||||
'server/scripts/modules/hourly.mjs',
|
||||
@@ -112,6 +114,7 @@ const compressHtml = async () => {
|
||||
.pipe(ejs({
|
||||
production: version,
|
||||
version,
|
||||
OVERRIDES,
|
||||
}))
|
||||
.pipe(rename({ extname: '.html' }))
|
||||
.pipe(htmlmin({ collapseWhitespace: true }))
|
||||
@@ -158,6 +161,9 @@ const uploadImages = () => src(imageSources, { base: './server', encoding: false
|
||||
s3({
|
||||
Bucket: process.env.BUCKET,
|
||||
StorageClass: 'STANDARD',
|
||||
maps: {
|
||||
CacheControl: () => 'max-age=31536000',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -188,4 +194,5 @@ export default publishFrontend;
|
||||
|
||||
export {
|
||||
buildDist,
|
||||
invalidate,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import updateVendor from './gulp/update-vendor.mjs';
|
||||
import publishFrontend, { buildDist } from './gulp/publish-frontend.mjs'
|
||||
import publishFrontend, { buildDist, invalidate } from './gulp/publish-frontend.mjs';
|
||||
|
||||
export {
|
||||
updateVendor,
|
||||
publishFrontend,
|
||||
buildDist,
|
||||
invalidate,
|
||||
};
|
||||
|
||||
27
index.mjs
@@ -5,6 +5,7 @@ import corsPassThru from './cors/index.mjs';
|
||||
import radarPassThru from './cors/radar.mjs';
|
||||
import outlookPassThru from './cors/outlook.mjs';
|
||||
import playlist from './src/playlist.mjs';
|
||||
import OVERRIDES from './src/overrides.mjs';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.WS4KP_PORT ?? 8080;
|
||||
@@ -57,19 +58,38 @@ const index = (req, res) => {
|
||||
res.render('index', {
|
||||
production: false,
|
||||
version,
|
||||
OVERRIDES,
|
||||
});
|
||||
};
|
||||
|
||||
const geoip = (req, res) => {
|
||||
res.set({
|
||||
'x-geoip-city': 'Orlando',
|
||||
'x-geoip-country': 'US',
|
||||
'x-geoip-country-name': 'United States',
|
||||
'x-geoip-country-region': 'FL',
|
||||
'x-geoip-country-region-name': 'Florida',
|
||||
'x-geoip-latitude': '28.52135',
|
||||
'x-geoip-longitude': '-81.41079',
|
||||
'x-geoip-postal-code': '32789',
|
||||
'x-geoip-time-zone': 'America/New_York',
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
res.json({});
|
||||
};
|
||||
|
||||
// debugging
|
||||
if (process.env?.DIST === '1') {
|
||||
// distribution
|
||||
app.use('/images', express.static('./server/images'));
|
||||
app.use('/fonts', express.static('./server/fonts'));
|
||||
app.use('/scripts', express.static('./server/scripts'));
|
||||
app.use('/geoip', geoip);
|
||||
app.use('/', express.static('./dist'));
|
||||
} else {
|
||||
// debugging
|
||||
app.get('/index.html', index);
|
||||
app.use('/geoip', geoip);
|
||||
app.get('/', index);
|
||||
app.get('*name', express.static('./server'));
|
||||
}
|
||||
@@ -79,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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "5.18.1",
|
||||
"version": "5.21.6",
|
||||
"description": "Welcome to the WeatherStar 4000+ project page!",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
--Star 3000--
|
||||
|
||||
Star3000.ttf - Standard text style for most screens (and Travel Cities title header)
|
||||
Star3000 Small.ttf - Time/Date and some page headers
|
||||
Star3000 Large.ttf - Travel Cities Forecast (Forecast portion only)
|
||||
Star3000 Extra Large.ttf - Only used on some advertiser text
|
||||
Star3000 Extended.ttf - Only used on some advertiser text
|
||||
"Heavy" style is an emboldened version of the standard font (used on some STARs)
|
||||
|
||||
Star3000 Outline.ttf - A contrast border (stroke) that surrounds the Star3000.ttf base font. When used, must be as a text layer undeneath the base font (and is usually black in color).
|
||||
Star3000 Small Outline.ttf - A contrast border (stroke) that surrounds the Star3000 Small.ttf base font. When used, must be as a text layer undeneath the base font (and is usually black in color).
|
||||
Star3000 Large Outline.ttf - A contrast border (stroke) that surrounds the Star3000 Large.ttf base font. When used, must be as a text layer undeneath the base font (and is usually black in color).
|
||||
|
||||
***Outlines for other font styles are not currently available.
|
||||
|
||||
--Star 4000--
|
||||
|
||||
Star4000.ttf - Standard text style for zone forecast, observation tables, regional map cities, almanac, extended forecast day/weather/temperature headers, Current Conditions right half data and most page header titles (also Travel Cities title header before Nov. 1992)
|
||||
Star4000 Small.ttf - Time/Date, NWS Local Update page header, temperature header for Travel Cities Forecast (after Nov. 1992)
|
||||
Star4000 Large.ttf - City names and temperature data on Travel Cities Forecast (after Nov. 1992), Extended forecast temperature values (after Feb. 1991), Current Conditions temperature value (after Mar. 1991)
|
||||
Star4000 Large Compressed - Travel Cities Forecast (before Nov. 1992), regional map temperatures
|
||||
Star4000 Large Compressed Numbers - Temperature values on regional forecast/observation maps
|
||||
Star4000 Extended - A proportional width font used for the Current Conditions present weather description and wind data
|
||||
Star 4 Radar.ttf - Radar airport I.D.
|
||||
|
||||
--Star Jr.--
|
||||
|
||||
StarJr.ttf - Standard text style for most screens (and Travel Cities title header)
|
||||
StarJr Small.ttf - Time/Date and some page headers
|
||||
StarJr Compressed.ttf - Travel Cities Forecast
|
||||
BIN
server/images/backgrounds/6.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
server/images/gimp/Background 6.xcf
Normal file
|
Before Width: | Height: | Size: 234 KiB |
BIN
server/images/maps/basemap.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
BIN
server/images/maps/radar.webp
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
server/images/nav/ic_scanlines_off_white_24dp_2x.png
Normal file
|
After Width: | Height: | Size: 893 B |
BIN
server/images/nav/ic_scanlines_on_white_24dp_2x.png
Normal file
|
After Width: | Height: | Size: 367 B |
@@ -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,
|
||||
},
|
||||
@@ -78,10 +79,10 @@ const init = () => {
|
||||
onSelect(suggestion) { autocompleteOnSelect(suggestion); },
|
||||
width: 490,
|
||||
});
|
||||
window.autoComplete = autoComplete;
|
||||
|
||||
// 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
|
||||
@@ -109,9 +110,6 @@ const init = () => {
|
||||
document.querySelector('#spanRadarId').innerHTML = '';
|
||||
document.querySelector('#spanZoneId').innerHTML = '';
|
||||
|
||||
document.querySelector('#chkAutoRefresh').checked = true;
|
||||
localStorage.removeItem('autoRefresh');
|
||||
|
||||
localStorage.removeItem('play');
|
||||
postMessage('navButton', 'play');
|
||||
|
||||
@@ -297,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) {
|
||||
@@ -347,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 });
|
||||
@@ -374,6 +379,10 @@ const btnGetGpsClick = async () => {
|
||||
const position = await getPosition();
|
||||
const { latitude, longitude } = position.coords;
|
||||
|
||||
getForecastFromLatLon(latitude, longitude, true);
|
||||
};
|
||||
|
||||
const getForecastFromLatLon = (latitude, longitude, fromGps = false) => {
|
||||
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
|
||||
txtAddress.value = `${round2(latitude, 4)}, ${round2(longitude, 4)}`;
|
||||
|
||||
@@ -383,7 +392,7 @@ const btnGetGpsClick = async () => {
|
||||
const query = `${location.city}, ${location.state}`;
|
||||
localStorage.setItem('latLon', JSON.stringify({ lat: latitude, lon: longitude }));
|
||||
localStorage.setItem('latLonQuery', query);
|
||||
localStorage.setItem('latLonFromGPS', true);
|
||||
localStorage.setItem('latLonFromGPS', fromGps);
|
||||
txtAddress.value = `${location.city}, ${location.state}`;
|
||||
});
|
||||
};
|
||||
@@ -414,3 +423,6 @@ const getCustomCode = async () => {
|
||||
document.body.append(customElem);
|
||||
}
|
||||
};
|
||||
|
||||
// expose functions for external use
|
||||
window.getForecastFromLatLon = getForecastFromLatLon;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '\\$&');
|
||||
@@ -172,6 +153,11 @@ class AutoComplete {
|
||||
}
|
||||
}
|
||||
|
||||
setValue(newValue) {
|
||||
this.currentValue = newValue;
|
||||
this.elem.value = newValue;
|
||||
}
|
||||
|
||||
onValueChange() {
|
||||
clearTimeout(this.onValueChange);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,10 +5,14 @@ import { currentDisplay } from './navigation.mjs';
|
||||
|
||||
// constants
|
||||
const degree = String.fromCharCode(176);
|
||||
const SCROLL_SPEED = 75; // pixels/second
|
||||
const DEFAULT_UPDATE = 8; // 0.5s ticks
|
||||
|
||||
// local variables
|
||||
let interval;
|
||||
let screenIndex = 0;
|
||||
let sinceLastUpdate = 0;
|
||||
let nextUpdate = DEFAULT_UPDATE;
|
||||
|
||||
// start drawing conditions
|
||||
// reset starts from the first item in the text scroll list
|
||||
@@ -17,7 +21,7 @@ const start = () => {
|
||||
|
||||
// set up the interval if needed
|
||||
if (!interval) {
|
||||
interval = setInterval(incrementInterval, 4000);
|
||||
interval = setInterval(incrementInterval, 500);
|
||||
}
|
||||
|
||||
// draw the data
|
||||
@@ -29,14 +33,24 @@ const stop = (reset) => {
|
||||
};
|
||||
|
||||
// increment interval, roll over
|
||||
const incrementInterval = () => {
|
||||
// forcing is used when drawScreen receives an invalid screen and needs to request the next one in line
|
||||
const incrementInterval = (force) => {
|
||||
if (!force) {
|
||||
// test for elapsed time (0.5s ticks);
|
||||
sinceLastUpdate += 1;
|
||||
if (sinceLastUpdate < nextUpdate) return;
|
||||
}
|
||||
// reset flags
|
||||
sinceLastUpdate = 0;
|
||||
nextUpdate = DEFAULT_UPDATE;
|
||||
|
||||
// test current screen
|
||||
const display = currentDisplay();
|
||||
if (!display?.okToDrawCurrentConditions) {
|
||||
stop(display?.elemId === 'progress');
|
||||
return;
|
||||
}
|
||||
screenIndex = (screenIndex + 1) % (screens.length);
|
||||
screenIndex = (screenIndex + 1) % (lastScreen);
|
||||
// draw new text
|
||||
drawScreen();
|
||||
};
|
||||
@@ -48,7 +62,22 @@ const drawScreen = async () => {
|
||||
// nothing to do if there's no data yet
|
||||
if (!data) return;
|
||||
|
||||
drawCondition(screens[screenIndex](data));
|
||||
const thisScreen = screens[screenIndex](data);
|
||||
if (typeof thisScreen === 'string') {
|
||||
// only a string
|
||||
drawCondition(thisScreen);
|
||||
} else if (typeof thisScreen === 'object') {
|
||||
// an object was provided with additional parameters
|
||||
switch (thisScreen.type) {
|
||||
case 'scroll':
|
||||
drawScrollCondition(thisScreen);
|
||||
break;
|
||||
default: drawCondition(thisScreen);
|
||||
}
|
||||
} else {
|
||||
// can't identify screen, get another one
|
||||
incrementInterval(true);
|
||||
}
|
||||
};
|
||||
|
||||
// the "screens" are stored in an array for easy addition and removal
|
||||
@@ -71,7 +100,7 @@ const screens = [
|
||||
(data) => `Humidity: ${data.Humidity}% Dewpoint: ${data.DewPoint}${degree}${data.TemperatureUnit}`,
|
||||
|
||||
// barometric pressure
|
||||
(data) => `Barometric Pressure: ${data.Pressure} ${data.PressureUnit} ${data.PressureDirection}`,
|
||||
(data) => `Barometric Pressure: ${data.Pressure} ${data.PressureDirection}`,
|
||||
|
||||
// wind
|
||||
(data) => {
|
||||
@@ -102,3 +131,56 @@ const drawCondition = (text) => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
start();
|
||||
});
|
||||
|
||||
// store the original number of screens
|
||||
const originalScreens = screens.length;
|
||||
let lastScreen = originalScreens;
|
||||
|
||||
// reset the number of screens
|
||||
const reset = () => {
|
||||
lastScreen = originalScreens;
|
||||
};
|
||||
|
||||
// add screen
|
||||
const addScreen = (screen) => {
|
||||
screens.push(screen);
|
||||
lastScreen += 1;
|
||||
};
|
||||
|
||||
const drawScrollCondition = (screen) => {
|
||||
// create the scroll element
|
||||
const scrollElement = document.createElement('div');
|
||||
scrollElement.classList.add('scroll-area');
|
||||
scrollElement.innerHTML = screen.text;
|
||||
// add it to the page to get the width
|
||||
document.querySelector('.weather-display .scroll .fixed').innerHTML = scrollElement.outerHTML;
|
||||
// grab the width
|
||||
const { scrollWidth, clientWidth } = document.querySelector('.weather-display .scroll .fixed .scroll-area');
|
||||
|
||||
// calculate the scroll distance and set a minimum scroll
|
||||
const scrollDistance = Math.max(scrollWidth - clientWidth, 0);
|
||||
// calculate the scroll time
|
||||
const scrollTime = scrollDistance / SCROLL_SPEED;
|
||||
// calculate a new minimum on-screen time +1.0s at start and end
|
||||
nextUpdate = Math.round(Math.ceil(scrollTime / 0.5) + 4);
|
||||
|
||||
// update the element transition and set initial left position
|
||||
scrollElement.style.left = '0px';
|
||||
scrollElement.style.transition = `left linear ${scrollTime.toFixed(1)}s`;
|
||||
elemForEach('.weather-display .scroll .fixed', (elem) => {
|
||||
elem.innerHTML = '';
|
||||
elem.append(scrollElement.cloneNode(true));
|
||||
});
|
||||
// start the scroll after a short delay
|
||||
setTimeout(() => {
|
||||
// change the left position to trigger the scroll
|
||||
elemForEach('.weather-display .scroll .fixed .scroll-area', (elem) => {
|
||||
elem.style.left = `-${scrollDistance.toFixed(0)}px`;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
window.CurrentWeatherScroll = {
|
||||
addScreen,
|
||||
reset,
|
||||
};
|
||||
|
||||
@@ -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,17 @@ const parse = (fullForecast) => {
|
||||
return forecast;
|
||||
};
|
||||
|
||||
const regexList = [
|
||||
[/ and /gi, ' '],
|
||||
[/slight /gi, ''],
|
||||
[/chance /gi, ''],
|
||||
[/very /gi, ''],
|
||||
[/patchy /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);
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ class Hazards extends WeatherDisplay {
|
||||
// get the forecast
|
||||
const url = new URL('https://api.weather.gov/alerts/active');
|
||||
url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`);
|
||||
url.searchParams.append('limit', 5);
|
||||
const alerts = await json(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
const unsortedAlerts = alerts.features ?? [];
|
||||
const allUnsortedAlerts = alerts.features ?? [];
|
||||
const unsortedAlerts = allUnsortedAlerts.slice(0, 5);
|
||||
const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false);
|
||||
const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event)));
|
||||
const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate')));
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -113,9 +113,11 @@ const smallIcon = (link, _isNightTime) => {
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind':
|
||||
case 'wind_':
|
||||
case 'wind_few':
|
||||
case 'wind_sct':
|
||||
case 'wind-n':
|
||||
case 'wind_-n':
|
||||
case 'wind_few-n':
|
||||
return addPath('Wind.gif');
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ class LocalForecast extends WeatherDisplay {
|
||||
forecastsElem.append(...templates);
|
||||
|
||||
// increase each forecast height to a multiple of container height
|
||||
this.pageHeight = forecastsElem.parentNode.scrollHeight;
|
||||
this.pageHeight = forecastsElem.parentNode.offsetHeight;
|
||||
templates.forEach((forecast) => {
|
||||
const newHeight = Math.ceil(forecast.scrollHeight / this.pageHeight) * this.pageHeight;
|
||||
forecast.style.height = `${newHeight}px`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
106
server/scripts/modules/radar-worker.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as utils from './radar-utils.mjs';
|
||||
|
||||
const radarFullSize = { width: 2550, height: 1600 };
|
||||
const radarFinalSize = { width: 640, height: 367 };
|
||||
|
||||
const fetchAsBlob = async (url) => {
|
||||
const response = await fetch(url);
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
const baseMapImages = new Promise((resolve) => {
|
||||
fetchAsBlob('/images/maps/radar.webp').then((blob) => {
|
||||
createImageBitmap(blob).then((imageBitmap) => {
|
||||
// extract the black pixels to overlay on to the final image (boundaries)
|
||||
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(imageBitmap, 0, 0);
|
||||
const imageData = context.getImageData(0, 0, imageBitmap.width, imageBitmap.height);
|
||||
|
||||
// go through the image data and preserve the black pixels, making the rest transparent
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
if (imageData.data[i + 0] >= 116 || imageData.data[i + 1] >= 116 || imageData.data[i + 2] >= 116) {
|
||||
// make it transparent
|
||||
imageData.data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
// write the image data back
|
||||
context.putImageData(imageData, 0, 0);
|
||||
|
||||
resolve({
|
||||
fullMap: imageBitmap,
|
||||
overlay: canvas,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onmessage = async (e) => {
|
||||
const {
|
||||
url, RADAR_HOST, OVERRIDES, radarSourceXY, sourceXY, offsetX, offsetY,
|
||||
} = e.data;
|
||||
|
||||
// get the image
|
||||
const modifiedRadarUrl = OVERRIDES.RADAR_HOST ? url.replace(RADAR_HOST, OVERRIDES.RADAR_HOST) : url;
|
||||
const radarResponsePromise = fetch(modifiedRadarUrl);
|
||||
|
||||
// calculate offsets and sizes
|
||||
|
||||
const radarSource = {
|
||||
width: 240,
|
||||
height: 163,
|
||||
x: Math.round(radarSourceXY.x / 2),
|
||||
y: Math.round(radarSourceXY.y / 2),
|
||||
};
|
||||
|
||||
// create destination context
|
||||
const baseCanvas = new OffscreenCanvas(radarFinalSize.width, radarFinalSize.height);
|
||||
const baseContext = baseCanvas.getContext('2d');
|
||||
baseContext.imageSmoothingEnabled = false;
|
||||
|
||||
// create working context for manipulation
|
||||
const radarCanvas = new OffscreenCanvas(radarFullSize.width, radarFullSize.height);
|
||||
const radarContext = radarCanvas.getContext('2d');
|
||||
radarContext.imageSmoothingEnabled = false;
|
||||
|
||||
// get the base map
|
||||
const baseMaps = await baseMapImages;
|
||||
baseContext.drawImage(baseMaps.fullMap, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, radarFinalSize.width, radarFinalSize.height);
|
||||
|
||||
// test response
|
||||
const radarResponse = await radarResponsePromise;
|
||||
if (!radarResponse.ok) throw new Error(`Unable to fetch radar error ${radarResponse.status} ${radarResponse.statusText} from ${radarResponse.url}`);
|
||||
|
||||
// get the blob
|
||||
const radarImgBlob = await radarResponse.blob();
|
||||
|
||||
// assign to an html image element
|
||||
const radarImgElement = await createImageBitmap(radarImgBlob);
|
||||
// draw the entire image
|
||||
radarContext.clearRect(0, 0, radarFullSize.width, radarFullSize.height);
|
||||
radarContext.drawImage(radarImgElement, 0, 0, radarFullSize.width, radarFullSize.height);
|
||||
|
||||
// crop the radar image without scaling
|
||||
const croppedRadarCanvas = new OffscreenCanvas(radarSource.width, radarSource.height);
|
||||
const croppedRadarContext = croppedRadarCanvas.getContext('2d');
|
||||
croppedRadarContext.imageSmoothingEnabled = false;
|
||||
croppedRadarContext.drawImage(radarCanvas, radarSource.x, radarSource.y, croppedRadarCanvas.width, croppedRadarCanvas.height, 0, 0, croppedRadarCanvas.width, croppedRadarCanvas.height);
|
||||
|
||||
// clean the image
|
||||
utils.removeDopplerRadarImageNoise(croppedRadarContext);
|
||||
|
||||
// stretch the radar image
|
||||
const stretchCanvas = new OffscreenCanvas(radarFinalSize.width, radarFinalSize.height);
|
||||
const stretchContext = stretchCanvas.getContext('2d', { willReadFrequently: true });
|
||||
stretchContext.imageSmoothingEnabled = false;
|
||||
stretchContext.drawImage(croppedRadarCanvas, 0, 0, radarSource.width, radarSource.height, 0, 0, radarFinalSize.width, radarFinalSize.height);
|
||||
|
||||
// put the radar on the base map
|
||||
baseContext.drawImage(stretchCanvas, 0, 0);
|
||||
// put the road/boundaries overlay on the map
|
||||
baseContext.drawImage(baseMaps.overlay, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, radarFinalSize.width, radarFinalSize.height);
|
||||
|
||||
const processedRadar = baseCanvas.transferToImageBitmap();
|
||||
|
||||
postMessage(processedRadar, [processedRadar]);
|
||||
};
|
||||
@@ -1,13 +1,12 @@
|
||||
// current weather conditions display
|
||||
import STATUS from './status.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import { loadImg } from './utils/image.mjs';
|
||||
import { text } from './utils/fetch.mjs';
|
||||
import { rewriteUrl } from './utils/cors.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
import * as utils from './radar-utils.mjs';
|
||||
|
||||
const RADAR_HOST = 'mesonet.agron.iastate.edu';
|
||||
class Radar extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Local Radar', true);
|
||||
@@ -40,6 +39,9 @@ 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) {
|
||||
@@ -51,12 +53,8 @@ class Radar extends WeatherDisplay {
|
||||
return;
|
||||
}
|
||||
|
||||
// get the base map
|
||||
const src = 'images/maps/radar.jpg';
|
||||
this.baseMap = await loadImg(src);
|
||||
|
||||
const baseUrl = 'https://mesonet.agron.iastate.edu/archive/data/';
|
||||
const baseUrlEnd = '/GIS/uscomp/';
|
||||
const baseUrl = `https://${RADAR_HOST}/archive/data/`;
|
||||
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png';
|
||||
const baseUrls = [];
|
||||
let date = DateTime.utc().minus({ days: 1 }).startOf('day');
|
||||
|
||||
@@ -102,91 +100,45 @@ class Radar extends WeatherDisplay {
|
||||
const urls = sortedPngs.slice(-(this.dopplerRadarImageMax));
|
||||
|
||||
// calculate offsets and sizes
|
||||
let offsetX = 120;
|
||||
let offsetY = 69;
|
||||
const width = 2550;
|
||||
const height = 1600;
|
||||
offsetX *= 2;
|
||||
offsetY *= 2;
|
||||
const offsetX = 120 * 2;
|
||||
const offsetY = 69 * 2;
|
||||
const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY);
|
||||
|
||||
// calculate radar offsets
|
||||
const radarOffsetX = 120;
|
||||
const radarOffsetY = 70;
|
||||
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY);
|
||||
const radarSourceX = radarSourceXY.x / 2;
|
||||
const radarSourceY = radarSourceXY.y / 2;
|
||||
|
||||
// Load the most recent doppler radar images.
|
||||
const radarInfo = await Promise.all(urls.map(async (url) => {
|
||||
// create destination context
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 640;
|
||||
canvas.height = 367;
|
||||
const context = canvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
// create working context for manipulation
|
||||
const workingCanvas = document.createElement('canvas');
|
||||
workingCanvas.width = width;
|
||||
workingCanvas.height = height;
|
||||
const workingContext = workingCanvas.getContext('2d');
|
||||
workingContext.imageSmoothingEnabled = false;
|
||||
|
||||
// get the image
|
||||
const response = await fetch(rewriteUrl(url));
|
||||
|
||||
// test response
|
||||
if (!response.ok) throw new Error(`Unable to fetch radar error ${response.status} ${response.statusText} from ${response.url}`);
|
||||
|
||||
// get the blob
|
||||
const blob = await response.blob();
|
||||
const radarInfo = await Promise.all(urls.map(async (url, index) => {
|
||||
const processedRadar = await this.workers[index].processRadar({
|
||||
url,
|
||||
RADAR_HOST,
|
||||
OVERRIDES,
|
||||
sourceXY,
|
||||
radarSourceXY,
|
||||
offsetX,
|
||||
offsetY,
|
||||
});
|
||||
|
||||
// store the time
|
||||
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
|
||||
let time;
|
||||
if (timeMatch) {
|
||||
const [, year, month, day, hour, minute] = timeMatch;
|
||||
time = DateTime.fromObject({
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
}, {
|
||||
zone: 'UTC',
|
||||
}).setZone(timeZone());
|
||||
} else {
|
||||
time = DateTime.fromHTTP(response.headers.get('last-modified')).setZone(timeZone());
|
||||
}
|
||||
|
||||
// assign to an html image element
|
||||
const imgBlob = await loadImg(blob);
|
||||
const [, year, month, day, hour, minute] = timeMatch;
|
||||
const time = DateTime.fromObject({
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
}, {
|
||||
zone: 'UTC',
|
||||
}).setZone(timeZone());
|
||||
|
||||
// draw the entire image
|
||||
workingContext.clearRect(0, 0, width, 1600);
|
||||
workingContext.drawImage(imgBlob, 0, 0, width, 1600);
|
||||
|
||||
// get the base map
|
||||
context.drawImage(this.baseMap, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, 640, 367);
|
||||
|
||||
// crop the radar image
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
cropCanvas.width = 640;
|
||||
cropCanvas.height = 367;
|
||||
const cropContext = cropCanvas.getContext('2d', { willReadFrequently: true });
|
||||
cropContext.imageSmoothingEnabled = false;
|
||||
cropContext.drawImage(workingCanvas, radarSourceX, radarSourceY, (radarOffsetX * 2), (radarOffsetY * 2.33), 0, 0, 640, 367);
|
||||
// clean the image
|
||||
utils.removeDopplerRadarImageNoise(cropContext);
|
||||
|
||||
// merge the radar and map
|
||||
utils.mergeDopplerRadarImage(context, cropContext);
|
||||
|
||||
const elem = this.fillTemplate('frame', { map: { type: 'img', src: canvas.toDataURL() } });
|
||||
const onscreenCanvas = document.createElement('canvas');
|
||||
onscreenCanvas.width = processedRadar.width;
|
||||
onscreenCanvas.height = processedRadar.height;
|
||||
const onscreenContext = onscreenCanvas.getContext('bitmaprenderer');
|
||||
onscreenContext.transferFromImageBitmap(processedRadar);
|
||||
|
||||
const elem = this.fillTemplate('frame', { map: { type: 'canvas', canvas: onscreenCanvas } });
|
||||
return {
|
||||
canvas,
|
||||
time,
|
||||
elem,
|
||||
};
|
||||
@@ -199,8 +151,6 @@ class Radar extends WeatherDisplay {
|
||||
|
||||
// set max length
|
||||
this.timing.totalScreens = radarInfo.length;
|
||||
// store the images
|
||||
this.data = radarInfo.map((radar) => radar.canvas);
|
||||
|
||||
this.times = radarInfo.map((radar) => radar.time);
|
||||
this.setStatus(STATUS.loaded);
|
||||
@@ -223,5 +173,30 @@ class Radar extends WeatherDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
// create a radar worker with helper functions
|
||||
const radarWorker = () => {
|
||||
// create the worker
|
||||
const worker = new Worker(new URL('./radar-worker.mjs', import.meta.url), { type: 'module' });
|
||||
|
||||
const processRadar = (url) => new Promise((resolve, reject) => {
|
||||
// prepare for done message
|
||||
worker.onmessage = (e) => {
|
||||
if (e?.data instanceof Error) {
|
||||
reject(e.data);
|
||||
} else if (e?.data instanceof ImageBitmap) {
|
||||
resolve(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
// start up the worker
|
||||
worker.postMessage(url);
|
||||
});
|
||||
|
||||
// return the object
|
||||
return {
|
||||
processRadar,
|
||||
};
|
||||
};
|
||||
|
||||
// register display
|
||||
registerDisplay(new Radar(10, 'radar'));
|
||||
registerDisplay(new Radar(11, 'radar'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -28,7 +34,7 @@ class RegionalForecast extends WeatherDisplay {
|
||||
// there are enough other cities available to populate the map sufficiently even if some do not load
|
||||
|
||||
// pre-load the base map
|
||||
let baseMap = 'images/maps/basemap.png';
|
||||
let baseMap = 'images/maps/basemap.webp';
|
||||
if (weatherParameters.state === 'HI') {
|
||||
baseMap = 'images/maps/radar-hawaii.png';
|
||||
} else if (weatherParameters.state === 'AK') {
|
||||
@@ -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)`;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
127
server/scripts/modules/spc-outlook.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
// display spc outlook in a bar graph
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import testPolygon from './utils/polygon.mjs';
|
||||
|
||||
// list of interesting files ordered [0] = today, [1] = tomorrow...
|
||||
const urlPattern = (day) => `https://www.spc.noaa.gov/products/outlook/day${day}otlk_cat.nolyr.geojson`;
|
||||
|
||||
const testAllPoints = (point, data) => {
|
||||
// returns all points where the data matches as an array of days and then matches of the properties of the data
|
||||
|
||||
const result = [];
|
||||
// start with a loop of days
|
||||
data.forEach((day, index) => {
|
||||
// initialize the result
|
||||
result[index] = false;
|
||||
// loop through each category
|
||||
day.features.forEach((feature) => {
|
||||
if (!feature.geometry.coordinates) return;
|
||||
const inPolygon = testPolygon(point, feature.geometry);
|
||||
if (inPolygon) result[index] = feature.properties;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const barSizes = {
|
||||
TSTM: 60,
|
||||
MRGL: 150,
|
||||
SLGT: 210,
|
||||
ENH: 270,
|
||||
MDT: 330,
|
||||
HIGH: 390,
|
||||
};
|
||||
|
||||
class SpcOutlook extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'SPC Outlook', true);
|
||||
// don't display on progress/navigation screen
|
||||
this.showOnProgress = false;
|
||||
|
||||
// calculate file names
|
||||
this.files = [null, null, null].map((v, i) => urlPattern(i + 1));
|
||||
|
||||
// set timings
|
||||
this.timing.totalScreens = 1;
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// initial data does not need to be reloaded on a location change, only during silent refresh
|
||||
if (!this.initialData || refresh) {
|
||||
try {
|
||||
// get the three categorical files to get started
|
||||
const filePromises = await Promise.allSettled(this.files.map((file) => json(file)));
|
||||
// store the data, promise will always be fulfilled
|
||||
this.initialData = filePromises.map((outlookDay) => outlookDay.value);
|
||||
} catch (error) {
|
||||
console.error('Unable to get spc outlook');
|
||||
console.error(error.status, error.responseJSON);
|
||||
// if there's no previous data, fail
|
||||
if (!this.initialData) {
|
||||
this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// do the initial parsing of the data
|
||||
this.data = testAllPoints([weatherParameters.longitude, weatherParameters.latitude], this.initialData);
|
||||
|
||||
// if all the data returns false the there's nothing to do, skip this screen
|
||||
if (this.data.reduce((prev, cur) => prev || !!cur, false)) {
|
||||
this.timing.totalScreens = 1;
|
||||
} else {
|
||||
this.timing.totalScreens = 0;
|
||||
}
|
||||
this.calcNavTiming();
|
||||
|
||||
// we only get here if there was no error above
|
||||
this.screenIndex = 0;
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
super.drawCanvas();
|
||||
|
||||
// analyze each day
|
||||
const days = this.data.map((day, index) => {
|
||||
// get the day name
|
||||
const dayName = DateTime.now().plus({ days: index }).toLocaleString({ weekday: 'long' });
|
||||
|
||||
// fill the name
|
||||
const fill = {};
|
||||
fill['day-name'] = dayName;
|
||||
|
||||
// create the element
|
||||
const elem = this.fillTemplate('day', fill);
|
||||
|
||||
// update the bar length
|
||||
const bar = elem.querySelector('.risk-bar');
|
||||
if (day.LABEL) {
|
||||
bar.style.width = `${barSizes[day.LABEL]}px`;
|
||||
} else {
|
||||
bar.style.display = 'none';
|
||||
}
|
||||
|
||||
return elem;
|
||||
});
|
||||
|
||||
// add the days to the display
|
||||
const dayContainer = this.elem.querySelector('.days');
|
||||
dayContainer.innerHTML = '';
|
||||
dayContainer.append(...days);
|
||||
|
||||
// finish drawing
|
||||
this.finishDraw();
|
||||
}
|
||||
}
|
||||
|
||||
// register display
|
||||
registerDisplay(new SpcOutlook(10, 'spc-outlook'));
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +12,6 @@ const preloadImg = (src) => {
|
||||
};
|
||||
|
||||
export {
|
||||
loadImg,
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
preloadImg,
|
||||
};
|
||||
|
||||
51
server/scripts/modules/utils/polygon.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
// handle multi-polygon and holes
|
||||
const testPolygon = (point, _polygons) => {
|
||||
// turn everything into a multi polygon for ease of processing
|
||||
let polygons = [[..._polygons.coordinates]];
|
||||
if (_polygons.type === 'MultiPolygon') polygons = [..._polygons.coordinates];
|
||||
|
||||
let inArea = false;
|
||||
|
||||
polygons.forEach((_polygon) => {
|
||||
// copy the polygon
|
||||
const polygon = [..._polygon];
|
||||
// if a match has been found don't do anything more
|
||||
if (inArea) return;
|
||||
|
||||
// polygons are defined as [[area], [optional hole 1], [optional hole 2], ...]
|
||||
const area = polygon.shift();
|
||||
// test if inside the initial area
|
||||
inArea = pointInPolygon(point, area);
|
||||
|
||||
// if not in the area return false
|
||||
if (!inArea) return;
|
||||
|
||||
// test the holes, if in any hole return false
|
||||
polygon.forEach((hole) => {
|
||||
if (pointInPolygon(point, hole)) {
|
||||
inArea = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
return inArea;
|
||||
};
|
||||
|
||||
const pointInPolygon = (point, polygon) => {
|
||||
// ray casting method from https://github.com/substack/point-in-polygon
|
||||
const x = point[0];
|
||||
const y = point[1];
|
||||
let inside = false;
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i][0];
|
||||
const yi = polygon[i][1];
|
||||
const xj = polygon[j][0];
|
||||
const yj = polygon[j][1];
|
||||
const intersect = ((yi > y) !== (yj > y))
|
||||
&& (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
export default testPolygon;
|
||||
@@ -189,7 +189,7 @@ class Setting {
|
||||
break;
|
||||
case 'checkbox':
|
||||
default:
|
||||
this.element.checked = newValue;
|
||||
this.element.querySelector('input').checked = newValue;
|
||||
}
|
||||
this.storeToLocalStorage(this.myValue);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
@@ -421,6 +421,8 @@ class WeatherDisplay {
|
||||
} else if (value?.type === 'img') {
|
||||
// fill the image source
|
||||
elem.querySelector('img').src = value.src;
|
||||
} else if (value?.type === 'canvas') {
|
||||
elem.append(value.canvas);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
|
||||
.location {
|
||||
color: c.$title-color;
|
||||
max-height: 32px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
@font-face {
|
||||
font-family: "Star4000";
|
||||
src: url('../fonts/Star4000.woff') format('woff');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -22,6 +23,8 @@ body {
|
||||
|
||||
&.kiosk {
|
||||
margin: 0px;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +143,10 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.kiosk #divTwc {
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
#divTwcLeft {
|
||||
display: none;
|
||||
text-align: right;
|
||||
@@ -188,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 {
|
||||
@@ -251,39 +280,22 @@ body {
|
||||
width: 475px;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Star4000";
|
||||
src: url('../fonts/Star4000.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Star 4 Radar";
|
||||
src: url('../fonts/Star 4 Radar.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Star4000 Extended';
|
||||
src: url('../fonts/Star4000 Extended.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Star4000LCN';
|
||||
src: url('../fonts/Star4000LCN.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Star4000 Large Compressed';
|
||||
src: url('../fonts/Star4000 Large Compressed.woff') format('woff');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Star4000 Large';
|
||||
src: url('../fonts/Star4000 Large.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Star4000 Small';
|
||||
src: url('../fonts/Star4000 Small.woff') format('woff');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
#display {
|
||||
@@ -316,10 +328,6 @@ body {
|
||||
transform-origin: unset;
|
||||
}
|
||||
|
||||
.kiosk #divTwc #container {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#loading {
|
||||
width: 640px;
|
||||
height: 480px;
|
||||
@@ -420,10 +428,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.kiosk #divTwc {
|
||||
justify-content: unset;
|
||||
}
|
||||
|
||||
#divTwc:fullscreen #display,
|
||||
.kiosk #divTwc #display {
|
||||
position: relative;
|
||||
@@ -452,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;
|
||||
@@ -744,6 +772,7 @@ body {
|
||||
|
||||
#divQuery,
|
||||
>.info,
|
||||
>.related-links,
|
||||
>.heading,
|
||||
#enabledDisplays,
|
||||
#settings,
|
||||
|
||||
86
server/styles/scss/_spc-outlook.scss
Normal file
@@ -0,0 +1,86 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
|
||||
#spc-outlook-html.weather-display {
|
||||
background-image: url('../images/backgrounds/6.png');
|
||||
}
|
||||
|
||||
.weather-display .spc-outlook {
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
top: 0px;
|
||||
margin: 0px 10px;
|
||||
box-sizing: border-box;
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.risk-levels {
|
||||
position: absolute;
|
||||
left: 206px;
|
||||
font-family: 'Star4000 Small';
|
||||
font-size: 32px;
|
||||
@include u.text-shadow();
|
||||
|
||||
|
||||
.risk-level {
|
||||
position: relative;
|
||||
top: -14px;
|
||||
height: 20px;
|
||||
|
||||
&:nth-child(1) {
|
||||
left: calc(20px * 5);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
left: calc(20px * 4);
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
left: calc(20px * 3);
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
left: calc(20px * 2);
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
left: calc(20px * 1);
|
||||
}
|
||||
|
||||
&:nth-child(6) {
|
||||
left: calc(20px * 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.days {
|
||||
position: absolute;
|
||||
top: 120px;
|
||||
|
||||
.day {
|
||||
height: 60px;
|
||||
|
||||
.day-name {
|
||||
position: absolute;
|
||||
font-family: 'Star4000';
|
||||
font-size: 24pt;
|
||||
width: 200px;
|
||||
text-align: right;
|
||||
@include u.text-shadow();
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.risk-bar {
|
||||
position: absolute;
|
||||
width: 150px;
|
||||
height: 40px;
|
||||
left: 210px;
|
||||
margin-top: 20px;
|
||||
border: 3px outset hsl(0, 0%, 70%);
|
||||
background: linear-gradient(0deg, hsl(0, 0%, 40%) 0%, hsl(0, 0%, 60%) 50%, hsl(0, 0%, 40%) 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@
|
||||
|
||||
.scroll {
|
||||
@include u.text-shadow(3px, 1.5px);
|
||||
width: 640px;
|
||||
width: calc(640px - 2 * 30px);
|
||||
height: 70px;
|
||||
overflow: hidden;
|
||||
margin-top: 10px;
|
||||
@@ -122,6 +122,15 @@
|
||||
font-family: 'Star4000';
|
||||
font-size: 24pt;
|
||||
margin-left: 55px;
|
||||
overflow: hidden;
|
||||
|
||||
.scroll-area {
|
||||
text-wrap: nowrap;
|
||||
position: relative;
|
||||
// the following added by js code as it is dependent on the content of the element
|
||||
// transition: left (x)s;
|
||||
// left: calc((elem width) - 640px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,6 @@
|
||||
@use 'regional-forecast';
|
||||
@use 'almanac';
|
||||
@use 'hazards';
|
||||
@use 'media';
|
||||
@use 'media';
|
||||
@use 'spc-outlook';
|
||||
@use 'shared/scanlines';
|
||||
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%; }
|
||||
}
|
||||
}
|
||||
10
src/overrides.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
// read overrides from environment variables
|
||||
|
||||
const OVERRIDES = {};
|
||||
Object.entries(process.env).forEach(([key, value]) => {
|
||||
if (key.match(/^OVERRIDE_/)) {
|
||||
OVERRIDES[key.replace('OVERRIDE_', '')] = value;
|
||||
}
|
||||
});
|
||||
|
||||
export default OVERRIDES;
|
||||
@@ -18,21 +18,29 @@
|
||||
<meta property="og:image" content="https://weatherstar.netbymatt.com/images/social/1200x600.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="627">
|
||||
<link rel="prefetch" href="fonts/Star4000.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="prefetch" href="fonts/Star4000 Extended.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="prefetch" href="fonts/Star4000 Large.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="prefetch" href="fonts/Star4000 Small.woff" as="font" type="font/woff" crossorigin>
|
||||
|
||||
<% if (production) { %>
|
||||
<link rel="stylesheet" type="text/css" href="resources/ws.min.css?_=<%=production%>" />
|
||||
<script type="text/javascript" src="resources/data.min.js?_=<%=production%>"></script>
|
||||
<script type="text/javascript" src="resources/vendor.min.js?_=<%=production%>"></script>
|
||||
<script type="text/javascript" src="resources/ws.min.js?_=<%=production%>"></script>
|
||||
<script type="text/javascript">const OVERRIDES=<%-JSON.stringify(OVERRIDES)%>;</script>
|
||||
<% } else { %>
|
||||
<link rel="stylesheet" type="text/css" href="styles/main.css" />
|
||||
<script type="text/javascript" src="scripts/vendor/auto/nosleep.js"></script>
|
||||
<!--<script type="text/javascript">const OVERRIDES={};</script>-->
|
||||
<script type="text/javascript">OVERRIDES=<%-JSON.stringify(OVERRIDES)%>;</script>
|
||||
<script type="text/javascript" src="scripts/vendor/auto/nosleep.js"></script>
|
||||
<script type="text/javascript" src="scripts/vendor/auto/swiped-events.js"></script>
|
||||
<script type="text/javascript" src="scripts/vendor/auto/suncalc.js"></script>
|
||||
<script type="module" src="scripts/modules/hazards.mjs"></script>
|
||||
<script type="module" src="scripts/modules/currentweatherscroll.mjs"></script>
|
||||
<script type="module" src="scripts/modules/currentweather.mjs"></script>
|
||||
<script type="module" src="scripts/modules/almanac.mjs"></script>
|
||||
<script type="module" src="scripts/modules/spc-outlook.mjs"></script>
|
||||
<script type="module" src="scripts/modules/icons.mjs"></script>
|
||||
<script type="module" src="scripts/modules/extendedforecast.mjs"></script>
|
||||
<script type="module" src="scripts/modules/hourly-graph.mjs"></script>
|
||||
@@ -109,6 +117,9 @@
|
||||
<div id="almanac-html" class="weather-display">
|
||||
<%- include('partials/almanac.ejs') %>
|
||||
</div>
|
||||
<div id="spc-outlook-html" class="weather-display">
|
||||
<%- include('partials/spc-outlook.ejs') %>
|
||||
</div>
|
||||
<div id="extended-forecast-html" class="weather-display">
|
||||
<%- include('partials/extended-forecast.ejs') %>
|
||||
</div>
|
||||
@@ -134,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>
|
||||
@@ -171,6 +186,7 @@
|
||||
Station Id: <span id="spanStationId"></span><br />
|
||||
Radar Id: <span id="spanRadarId"></span><br />
|
||||
Zone Id: <span id="spanZoneId"></span><br />
|
||||
Ws4kp Version: <span><%- version %></span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
<div class="scroll-area">
|
||||
<div class="frame template">
|
||||
<div class="map">
|
||||
<img src="images/maps/radar.jpg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Regional' , bottom: 'Observations' }, hasTime: true }) %>
|
||||
<div class="main has-scroll regional-forecast">
|
||||
<div class="map"><img src="images/maps/basemap.png" /></div>
|
||||
<div class="map"><img src="images/maps/basemap.webp" /></div>
|
||||
<div class="location-container">
|
||||
<div class="location template">
|
||||
<div class="icon">
|
||||
|
||||
20
views/partials/spc-outlook.ejs
Normal file
@@ -0,0 +1,20 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Storm Prediction' , bottom: 'Center Outlook' }, hasTime: true}) %>
|
||||
<div class="main has-scroll spc-outlook">
|
||||
<div class="container">
|
||||
<div class="risk-levels">
|
||||
<div class="risk-level">High</div>
|
||||
<div class="risk-level">Moderate</div>
|
||||
<div class="risk-level">Enhanced</div>
|
||||
<div class="risk-level">Slight</div>
|
||||
<div class="risk-level">Marginal</div>
|
||||
<div class="risk-level">T'Storm</div>
|
||||
</div>
|
||||
<div class="days">
|
||||
<div class="day template">
|
||||
<div class="day-name">Monday</div>
|
||||
<div class="risk-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||