Merge branch 'main' into remove-jquery

This commit is contained in:
Matt Walsh 2025-05-12 13:35:01 -05:00
commit 4cc2312ffd
84 changed files with 31678 additions and 49296 deletions

View file

@ -1,3 +1,4 @@
.git/ .git/
Dockerfile Dockerfile
.vscode/ .vscode/
dist/

91
.eslintrc Normal file
View file

@ -0,0 +1,91 @@
{
"env": {
"browser": true,
"es6": true,
"node": true,
"jquery": true
},
"extends": [
"airbnb-base"
],
"globals": {
"TravelCities": "readonly",
"RegionalCities": "readonly",
"StationInfo": "readonly",
"SunCalc": "readonly"
},
"parserOptions": {
"ecmaVersion": 2023,
"sourceType": "module"
},
"plugins": [],
"rules": {
"indent": [
"error",
"tab",
{
"SwitchCase": 1
}
],
"no-tabs": 0,
"no-console": 0,
"max-len": 0,
"no-use-before-define": [
"error",
{
"variables": false
}
],
"no-param-reassign": [
"error",
{
"props": false
}
],
"no-mixed-operators": [
"error",
{
"groups": [
[
"&",
"|",
"^",
"~",
"<<",
">>",
">>>"
],
[
"==",
"!=",
"===",
"!==",
">",
">=",
"<",
"<="
],
[
"&&",
"||"
],
[
"in",
"instanceof"
]
],
"allowSamePrecedence": true
}
],
"import/extensions": [
"error",
{
"mjs": "always",
"json": "always"
}
]
},
"ignorePatterns": [
"*.min.js"
]
}

View file

@ -1,73 +0,0 @@
module.exports = {
env: {
browser: true,
commonjs: true,
es6: true,
node: true,
},
extends: [
'airbnb-base',
],
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly',
StationInfo: 'readonly',
RegionalCities: 'readonly',
TravelCities: 'readonly',
NoSleep: 'readonly',
states: 'readonly',
SunCalc: 'readonly',
},
parserOptions: {
ecmaVersion: 2023,
},
plugins: [
],
rules: {
indent: [
'error',
'tab',
{
SwitchCase: 1,
},
],
'no-tabs': 0,
'no-console': 0,
'max-len': 0,
'no-use-before-define': [
'error',
{
variables: false,
},
],
'no-param-reassign': [
'error',
{
props: false,
},
],
'no-mixed-operators': [
'error',
{
groups: [
['&', '|', '^', '~', '<<', '>>', '>>>'],
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
['&&', '||'],
['in', 'instanceof'],
],
allowSamePrecedence: true,
},
],
'import/extensions': [
'error',
{
mjs: 'always',
json: 'always',
},
],
},
ignorePatterns: [
'*.min.js',
],
};

11
.gitignore vendored
View file

@ -1,3 +1,14 @@
node_modules node_modules
**/debug.log **/debug.log
server/scripts/custom.js server/scripts/custom.js
#music folder
server/music/*
#except for the readme
!server/music/readme.txt
#and the sample songs
!server/music/default
#dist folder
dist/*
!dist/readme.txt

13
.vscode/launch.json vendored
View file

@ -15,10 +15,13 @@
"**/*.min.js", "**/*.min.js",
"**/vendor/**" "**/vendor/**"
], ],
"runtimeArgs": [
"--autoplay-policy=no-user-gesture-required"
]
}, },
{ {
"name": "Data:stations", "name": "Data:stations",
"program": "${workspaceFolder}/datagenerators/stations.js", "program": "${workspaceFolder}/datagenerators/stations.mjs",
"request": "launch", "request": "launch",
"skipFiles": [ "skipFiles": [
"<node_internals>/**" "<node_internals>/**"
@ -27,7 +30,7 @@
}, },
{ {
"name": "Data:regionalcities", "name": "Data:regionalcities",
"program": "${workspaceFolder}/datagenerators/regionalcities.js", "program": "${workspaceFolder}/datagenerators/regionalcities.mjs",
"request": "launch", "request": "launch",
"skipFiles": [ "skipFiles": [
"<node_internals>/**" "<node_internals>/**"
@ -36,7 +39,7 @@
}, },
{ {
"name": "Data:travelcities", "name": "Data:travelcities",
"program": "${workspaceFolder}/datagenerators/travelcities.js", "program": "${workspaceFolder}/datagenerators/travelcities.mjs",
"request": "launch", "request": "launch",
"skipFiles": [ "skipFiles": [
"<node_internals>/**" "<node_internals>/**"
@ -50,7 +53,7 @@
"skipFiles": [ "skipFiles": [
"<node_internals>/**", "<node_internals>/**",
], ],
"program": "${workspaceFolder}/index.js", "program": "${workspaceFolder}/index.mjs",
}, },
{ {
"type": "node", "type": "node",
@ -59,7 +62,7 @@
"skipFiles": [ "skipFiles": [
"<node_internals>/**", "<node_internals>/**",
], ],
"program": "${workspaceFolder}/index.js", "program": "${workspaceFolder}/index.mjs",
"env": { "env": {
"DIST": "1" "DIST": "1"
} }

View file

@ -22,5 +22,8 @@
}, },
"eslint.validate": [ "eslint.validate": [
"javascript" "javascript"
],
"cSpell.words": [
"Tucsan"
] ]
} }

View file

@ -7,4 +7,4 @@ COPY package-lock.json .
RUN npm ci RUN npm ci
COPY . . COPY . .
CMD ["node", "index.js"] CMD ["node", "index.mjs"]

View file

@ -16,6 +16,13 @@ This project is based on the work of [Mike Battaglia](https://github.com/vbguyny
* [Icon](https://twcclassics.com/downloads.html) sets * [Icon](https://twcclassics.com/downloads.html) sets
* Countless photos and videos of WeatherStar 4000 forecasts used as references. * Countless photos and videos of WeatherStar 4000 forecasts used as references.
## Does WeatherStar 4000+ work outside of the USA?
This project is tightly coupled to [NOAA's Weather API](https://www.weather.gov/documentation/services-web-api), which is exclsuive to the United States. Using NOAA's Weather API is a crucial requirement to provide an authentic WeatherStar 4000+ experience.
If you would like to display weather information for international locations (outside of the USA), please checkout a fork of this project created by [@mwood77](https://github.com/mwood77):
- [`ws4kp-international`](https://github.com/mwood77/ws4kp-international)
## Run Your WeatherStar ## Run Your WeatherStar
There are a lot of CORS considerations and issues with api.weather.gov that are easiest to deal with by running a local server to see this in action (or use the live link above). You'll need Node.js >12.0 to run the local server. There are a lot of CORS considerations and issues with api.weather.gov that are easiest to deal with by running a local server to see this in action (or use the live link above). You'll need Node.js >12.0 to run the local server.
@ -91,11 +98,38 @@ As time allows I will be working on the following enhancements.
* Better error reporting when api.weather.gov is down (happens more often than you would think) * Better error reporting when api.weather.gov is down (happens more often than you would think)
## Serving static files
The app can be served as a static set of files on any web server. Run the provided gulp task to create a set of static distribution files:
```
npm run buildDist
```
The resulting files will be in the /dist folder in the root of the project. These can then be uploaded to a web server for hosting, no server-side scripting is required.
## 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).
### 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`.
If using docker, you must pass a local accessible folder to the container in the `/app/server/music` directory.
```
docker run -p 8080:8080 -v /path/to/local/music:/app/server/music ghcr.io/netbymatt/ws4kp
```
### Music doesn't auto play
Ws4kp is muted by default, but if it was unmuted on the last visit it is coded to try and auto play music on subsequent visits. But, it's considered bad form to have a web site play music automatically on load, and I fully agree with this. [Chrome](https://developer.chrome.com/blog/autoplay/#media_engagement_index) and [Firefox](https://hacks.mozilla.org/2019/02/firefox-66-to-block-automatically-playing-audible-video-and-audio/) have extensive details on how and when auto play is allowed.
Chrome seems to be more lenient on auto play and will eventually let a site auto-play music if you're visited it enough recently and manually clicked to start playing music on each visit. It also has a flag you can add to the command line when launching Chrome: `chrome.exe --autoplay-policy=no-user-gesture-required`. This is the best solution when using Kiosk-style setup.
## Community Notes ## Community Notes
Thanks to the WeatherStar community for providing these discussions to further extend your retro forecasts! Thanks to the WeatherStar community for providing these discussions to further extend your retro forecasts!
* [Stream as FFMPEG](https://github.com/netbymatt/ws4kp/issues/37#issuecomment-2008491948) * [Stream as FFMPEG](https://github.com/netbymatt/ws4kp/issues/37#issuecomment-2008491948)
* [Weather like it's 1999](https://blog.scottlabs.io/2024/02/weather-like-its-1999/) Raspberry pi, streaming, music and CRT all combined into a complete solution.
* [ws4channels](https://github.com/rice9797/ws4channels) A Dockerized Node.js application to stream WeatherStar 4000 data into Channels DVR using Puppeteer and FFmpeg.
## Customization ## Customization
A hook is provided as `/server/scripts/custom.js` to allow customizations to your own fork of this project, without accidentally pushing your customizations back upstream to the git repository. An sample file is provided at `/server/scripts/custom.sample.js` and should be renamed to `custom.js` activate it. A hook is provided as `/server/scripts/custom.js` to allow customizations to your own fork of this project, without accidentally pushing your customizations back upstream to the git repository. An sample file is provided at `/server/scripts/custom.sample.js` and should be renamed to `custom.js` activate it.
@ -104,6 +138,8 @@ A hook is provided as `/server/scripts/custom.js` to allow customizations to you
Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. Before reporting an issue or requesting a feature please consider that this is not intended to be a perfect recreation of the WeatherStar 4000, it's a best effort that fits within what's available from the API and within a web browser. Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. Before reporting an issue or requesting a feature please consider that this is not intended to be a perfect recreation of the WeatherStar 4000, it's a best effort that fits within what's available from the API and within a web browser.
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.
## 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.

View file

@ -1,13 +1,13 @@
// pass through api requests // pass through api requests
// http(s) modules // http(s) modules
const https = require('https'); import https from 'https';
// url parsing // url parsing
const queryString = require('querystring'); import queryString from 'querystring';
// return an express router // return an express router
module.exports = (req, res) => { const cors = (req, res) => {
// add out-going headers // add out-going headers
const headers = {}; const headers = {};
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)'; headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
@ -41,3 +41,5 @@ module.exports = (req, res) => {
console.error(e); console.error(e);
}); });
}; };
export default cors;

View file

@ -1,13 +1,13 @@
// pass through api requests // pass through api requests
// http(s) modules // http(s) modules
const https = require('https'); import https from 'https';
// url parsing // url parsing
const queryString = require('querystring'); import queryString from 'querystring';
// return an express router // return an express router
module.exports = (req, res) => { const outlook = (req, res) => {
// add out-going headers // add out-going headers
const headers = {}; const headers = {};
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)'; headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
@ -42,3 +42,5 @@ module.exports = (req, res) => {
console.error(e); console.error(e);
}); });
}; };
export default outlook;

View file

@ -1,13 +1,13 @@
// pass through api requests // pass through api requests
// http(s) modules // http(s) modules
const https = require('https'); import https from 'https';
// url parsing // url parsing
const queryString = require('querystring'); import queryString from 'querystring';
// return an express router // return an express router
module.exports = (req, res) => { const radar = (req, res) => {
// add out-going headers // add out-going headers
const headers = {}; const headers = {};
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)'; headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
@ -42,3 +42,5 @@ module.exports = (req, res) => {
console.error(e); console.error(e);
}); });
}; };
export default radar;

View file

@ -19,4 +19,4 @@ const chunk = (data, chunkSize = 10) => {
return chunks; return chunks;
}; };
module.exports = chunk; export default chunk;

View file

@ -1,8 +1,7 @@
// async https wrapper // async https wrapper
import https from 'https';
const https = require('https'); const get = (url) => new Promise((resolve, reject) => {
module.exports = (url) => new Promise((resolve, reject) => {
const headers = {}; const headers = {};
headers['user-agent'] = '(WeatherStar 4000+ data generator, ws4000@netbymatt.com)'; headers['user-agent'] = '(WeatherStar 4000+ data generator, ws4000@netbymatt.com)';
@ -22,3 +21,5 @@ module.exports = (url) => new Promise((resolve, reject) => {
reject(e); reject(e);
}); });
}); });
export default get;

View file

@ -4,8 +4,8 @@
"lat": 33.749, "lat": 33.749,
"lon": -84.388, "lon": -84.388,
"point": { "point": {
"x": 50, "x": 51,
"y": 86, "y": 87,
"wfo": "FFC" "wfo": "FFC"
} }
}, },
@ -24,8 +24,8 @@
"lat": 41.9796, "lat": 41.9796,
"lon": -87.9045, "lon": -87.9045,
"point": { "point": {
"x": 65, "x": 66,
"y": 76, "y": 77,
"wfo": "LOT" "wfo": "LOT"
} }
}, },
@ -34,8 +34,8 @@
"lat": 41.4995, "lat": 41.4995,
"lon": -81.6954, "lon": -81.6954,
"point": { "point": {
"x": 82, "x": 83,
"y": 64, "y": 65,
"wfo": "CLE" "wfo": "CLE"
} }
}, },
@ -44,8 +44,8 @@
"lat": 32.8959, "lat": 32.8959,
"lon": -97.0372, "lon": -97.0372,
"point": { "point": {
"x": 79, "x": 80,
"y": 108, "y": 109,
"wfo": "FWD" "wfo": "FWD"
} }
}, },
@ -54,8 +54,8 @@
"lat": 39.7391, "lat": 39.7391,
"lon": -104.9847, "lon": -104.9847,
"point": { "point": {
"x": 62, "x": 63,
"y": 60, "y": 61,
"wfo": "BOU" "wfo": "BOU"
} }
}, },
@ -64,8 +64,8 @@
"lat": 42.3314, "lat": 42.3314,
"lon": -83.0457, "lon": -83.0457,
"point": { "point": {
"x": 65, "x": 66,
"y": 33, "y": 34,
"wfo": "DTX" "wfo": "DTX"
} }
}, },
@ -94,8 +94,8 @@
"lat": 39.7684, "lat": 39.7684,
"lon": -86.158, "lon": -86.158,
"point": { "point": {
"x": 57, "x": 58,
"y": 68, "y": 69,
"wfo": "IND" "wfo": "IND"
} }
}, },
@ -104,8 +104,8 @@
"lat": 34.0522, "lat": 34.0522,
"lon": -118.2437, "lon": -118.2437,
"point": { "point": {
"x": 154, "x": 155,
"y": 44, "y": 45,
"wfo": "LOX" "wfo": "LOX"
} }
}, },
@ -114,8 +114,8 @@
"lat": 25.7743, "lat": 25.7743,
"lon": -80.1937, "lon": -80.1937,
"point": { "point": {
"x": 109, "x": 110,
"y": 50, "y": 51,
"wfo": "MFL" "wfo": "MFL"
} }
}, },
@ -124,8 +124,8 @@
"lat": 44.98, "lat": 44.98,
"lon": -93.2638, "lon": -93.2638,
"point": { "point": {
"x": 107, "x": 108,
"y": 71, "y": 72,
"wfo": "MPX" "wfo": "MPX"
} }
}, },
@ -134,8 +134,8 @@
"lat": 40.78, "lat": 40.78,
"lon": -73.88, "lon": -73.88,
"point": { "point": {
"x": 36, "x": 37,
"y": 38, "y": 39,
"wfo": "OKX" "wfo": "OKX"
} }
}, },
@ -144,8 +144,8 @@
"lat": 36.8468, "lat": 36.8468,
"lon": -76.2852, "lon": -76.2852,
"point": { "point": {
"x": 89, "x": 90,
"y": 51, "y": 52,
"wfo": "AKQ" "wfo": "AKQ"
} }
}, },
@ -164,8 +164,8 @@
"lat": 39.9523, "lat": 39.9523,
"lon": -75.1638, "lon": -75.1638,
"point": { "point": {
"x": 49, "x": 50,
"y": 75, "y": 76,
"wfo": "PHI" "wfo": "PHI"
} }
}, },
@ -174,8 +174,8 @@
"lat": 40.4406, "lat": 40.4406,
"lon": -79.9959, "lon": -79.9959,
"point": { "point": {
"x": 77, "x": 78,
"y": 65, "y": 66,
"wfo": "PBZ" "wfo": "PBZ"
} }
}, },
@ -184,8 +184,8 @@
"lat": 38.6273, "lat": 38.6273,
"lon": -90.1979, "lon": -90.1979,
"point": { "point": {
"x": 94, "x": 95,
"y": 73, "y": 74,
"wfo": "LSX" "wfo": "LSX"
} }
}, },
@ -204,8 +204,8 @@
"lat": 47.6062, "lat": 47.6062,
"lon": -122.3321, "lon": -122.3321,
"point": { "point": {
"x": 124, "x": 125,
"y": 67, "y": 68,
"wfo": "SEW" "wfo": "SEW"
} }
}, },
@ -214,8 +214,8 @@
"lat": 43.0481, "lat": 43.0481,
"lon": -76.1474, "lon": -76.1474,
"point": { "point": {
"x": 51, "x": 52,
"y": 98, "y": 99,
"wfo": "BGM" "wfo": "BGM"
} }
}, },
@ -224,8 +224,8 @@
"lat": 27.9756, "lat": 27.9756,
"lon": -82.5329, "lon": -82.5329,
"point": { "point": {
"x": 67, "x": 68,
"y": 97, "y": 98,
"wfo": "TBW" "wfo": "TBW"
} }
}, },
@ -244,8 +244,8 @@
"lat": 42.6526, "lat": 42.6526,
"lon": -73.7562, "lon": -73.7562,
"point": { "point": {
"x": 58, "x": 72,
"y": 58, "y": 63,
"wfo": "ALY" "wfo": "ALY"
} }
}, },
@ -254,8 +254,8 @@
"lat": 35.0845, "lat": 35.0845,
"lon": -106.6511, "lon": -106.6511,
"point": { "point": {
"x": 97, "x": 98,
"y": 118, "y": 121,
"wfo": "ABQ" "wfo": "ABQ"
} }
}, },
@ -264,8 +264,8 @@
"lat": 35.222, "lat": 35.222,
"lon": -101.8313, "lon": -101.8313,
"point": { "point": {
"x": 47, "x": 48,
"y": 25, "y": 26,
"wfo": "AMA" "wfo": "AMA"
} }
}, },
@ -284,8 +284,8 @@
"lat": 30.2671, "lat": 30.2671,
"lon": -97.7431, "lon": -97.7431,
"point": { "point": {
"x": 155, "x": 156,
"y": 90, "y": 91,
"wfo": "EWX" "wfo": "EWX"
} }
}, },
@ -294,8 +294,8 @@
"lat": 44.7502, "lat": 44.7502,
"lon": -117.6677, "lon": -117.6677,
"point": { "point": {
"x": 93, "x": 94,
"y": 145, "y": 146,
"wfo": "BOI" "wfo": "BOI"
} }
}, },
@ -314,7 +314,7 @@
"lat": 44.8012, "lat": 44.8012,
"lon": -68.7778, "lon": -68.7778,
"point": { "point": {
"x": 72, "x": 66,
"y": 62, "y": 62,
"wfo": "CAR" "wfo": "CAR"
} }
@ -324,8 +324,8 @@
"lat": 33.5207, "lat": 33.5207,
"lon": -86.8025, "lon": -86.8025,
"point": { "point": {
"x": 58, "x": 59,
"y": 83, "y": 84,
"wfo": "BMX" "wfo": "BMX"
} }
}, },
@ -334,8 +334,8 @@
"lat": 46.8083, "lat": 46.8083,
"lon": -100.7837, "lon": -100.7837,
"point": { "point": {
"x": 109, "x": 110,
"y": 46, "y": 47,
"wfo": "BIS" "wfo": "BIS"
} }
}, },
@ -344,8 +344,8 @@
"lat": 43.6135, "lat": 43.6135,
"lon": -116.2034, "lon": -116.2034,
"point": { "point": {
"x": 132, "x": 133,
"y": 85, "y": 86,
"wfo": "BOI" "wfo": "BOI"
} }
}, },
@ -354,8 +354,8 @@
"lat": 42.8864, "lat": 42.8864,
"lon": -78.8784, "lon": -78.8784,
"point": { "point": {
"x": 35, "x": 36,
"y": 46, "y": 47,
"wfo": "BUF" "wfo": "BUF"
} }
}, },
@ -374,8 +374,8 @@
"lat": 32.7766, "lat": 32.7766,
"lon": -79.9309, "lon": -79.9309,
"point": { "point": {
"x": 86, "x": 87,
"y": 76, "y": 77,
"wfo": "CHS" "wfo": "CHS"
} }
}, },
@ -384,8 +384,8 @@
"lat": 38.3498, "lat": 38.3498,
"lon": -81.6326, "lon": -81.6326,
"point": { "point": {
"x": 62, "x": 63,
"y": 66, "y": 67,
"wfo": "RLX" "wfo": "RLX"
} }
}, },
@ -394,8 +394,8 @@
"lat": 35.2271, "lat": 35.2271,
"lon": -80.8431, "lon": -80.8431,
"point": { "point": {
"x": 118, "x": 119,
"y": 64, "y": 65,
"wfo": "GSP" "wfo": "GSP"
} }
}, },
@ -404,8 +404,8 @@
"lat": 41.14, "lat": 41.14,
"lon": -104.8202, "lon": -104.8202,
"point": { "point": {
"x": 109, "x": 110,
"y": 13, "y": 14,
"wfo": "CYS" "wfo": "CYS"
} }
}, },
@ -414,8 +414,8 @@
"lat": 39.162, "lat": 39.162,
"lon": -84.4569, "lon": -84.4569,
"point": { "point": {
"x": 36, "x": 37,
"y": 40, "y": 41,
"wfo": "ILN" "wfo": "ILN"
} }
}, },
@ -434,8 +434,8 @@
"lat": 39.9612, "lat": 39.9612,
"lon": -82.9988, "lon": -82.9988,
"point": { "point": {
"x": 84, "x": 85,
"y": 80, "y": 81,
"wfo": "ILN" "wfo": "ILN"
} }
}, },
@ -444,8 +444,8 @@
"lat": 41.6005, "lat": 41.6005,
"lon": -93.6091, "lon": -93.6091,
"point": { "point": {
"x": 73, "x": 74,
"y": 49, "y": 50,
"wfo": "DMX" "wfo": "DMX"
} }
}, },
@ -454,8 +454,8 @@
"lat": 42.5006, "lat": 42.5006,
"lon": -90.6646, "lon": -90.6646,
"point": { "point": {
"x": 62, "x": 63,
"y": 110, "y": 111,
"wfo": "DVN" "wfo": "DVN"
} }
}, },
@ -474,7 +474,7 @@
"lat": 44.9062, "lat": 44.9062,
"lon": -66.99, "lon": -66.99,
"point": { "point": {
"x": 129, "x": 123,
"y": 79, "y": 79,
"wfo": "CAR" "wfo": "CAR"
} }
@ -484,8 +484,8 @@
"lat": 32.792, "lat": 32.792,
"lon": -115.563, "lon": -115.563,
"point": { "point": {
"x": 26, "x": 27,
"y": 46, "y": 47,
"wfo": "PSR" "wfo": "PSR"
} }
}, },
@ -494,8 +494,8 @@
"lat": 31.7587, "lat": 31.7587,
"lon": -106.4869, "lon": -106.4869,
"point": { "point": {
"x": 99, "x": 100,
"y": 55, "y": 56,
"wfo": "EPZ" "wfo": "EPZ"
} }
}, },
@ -504,8 +504,8 @@
"lat": 44.0521, "lat": 44.0521,
"lon": -123.0867, "lon": -123.0867,
"point": { "point": {
"x": 84, "x": 85,
"y": 38, "y": 39,
"wfo": "PQR" "wfo": "PQR"
} }
}, },
@ -514,8 +514,8 @@
"lat": 46.8772, "lat": 46.8772,
"lon": -96.7898, "lon": -96.7898,
"point": { "point": {
"x": 99, "x": 100,
"y": 56, "y": 57,
"wfo": "FGF" "wfo": "FGF"
} }
}, },
@ -524,8 +524,8 @@
"lat": 35.1981, "lat": 35.1981,
"lon": -111.6513, "lon": -111.6513,
"point": { "point": {
"x": 73, "x": 74,
"y": 88, "y": 89,
"wfo": "FGZ" "wfo": "FGZ"
} }
}, },
@ -544,8 +544,8 @@
"lat": 39.0639, "lat": 39.0639,
"lon": -108.5506, "lon": -108.5506,
"point": { "point": {
"x": 94, "x": 95,
"y": 101, "y": 102,
"wfo": "GJT" "wfo": "GJT"
} }
}, },
@ -554,8 +554,8 @@
"lat": 42.9634, "lat": 42.9634,
"lon": -85.6681, "lon": -85.6681,
"point": { "point": {
"x": 40, "x": 41,
"y": 46, "y": 47,
"wfo": "GRR" "wfo": "GRR"
} }
}, },
@ -564,8 +564,8 @@
"lat": 48.55, "lat": 48.55,
"lon": -109.6841, "lon": -109.6841,
"point": { "point": {
"x": 154, "x": 155,
"y": 187, "y": 188,
"wfo": "TFX" "wfo": "TFX"
} }
}, },
@ -574,8 +574,8 @@
"lat": 46.5927, "lat": 46.5927,
"lon": -112.0361, "lon": -112.0361,
"point": { "point": {
"x": 68, "x": 69,
"y": 103, "y": 104,
"wfo": "TFX" "wfo": "TFX"
} }
}, },
@ -584,8 +584,8 @@
"lat": 21.3069, "lat": 21.3069,
"lon": -157.8583, "lon": -157.8583,
"point": { "point": {
"x": 153, "x": 154,
"y": 144, "y": 145,
"wfo": "HFO" "wfo": "HFO"
} }
}, },
@ -594,8 +594,8 @@
"lat": 34.5037, "lat": 34.5037,
"lon": -93.0552, "lon": -93.0552,
"point": { "point": {
"x": 53, "x": 54,
"y": 60, "y": 61,
"wfo": "LZK" "wfo": "LZK"
} }
}, },
@ -604,8 +604,8 @@
"lat": 43.4666, "lat": 43.4666,
"lon": -112.0341, "lon": -112.0341,
"point": { "point": {
"x": 115, "x": 124,
"y": 72, "y": 73,
"wfo": "PIH" "wfo": "PIH"
} }
}, },
@ -614,8 +614,8 @@
"lat": 32.2988, "lat": 32.2988,
"lon": -90.1848, "lon": -90.1848,
"point": { "point": {
"x": 75, "x": 76,
"y": 62, "y": 63,
"wfo": "JAN" "wfo": "JAN"
} }
}, },
@ -624,8 +624,8 @@
"lat": 30.3322, "lat": 30.3322,
"lon": -81.6556, "lon": -81.6556,
"point": { "point": {
"x": 65, "x": 66,
"y": 64, "y": 65,
"wfo": "JAX" "wfo": "JAX"
} }
}, },
@ -644,8 +644,8 @@
"lat": 39.1142, "lat": 39.1142,
"lon": -94.6275, "lon": -94.6275,
"point": { "point": {
"x": 41, "x": 42,
"y": 50, "y": 51,
"wfo": "EAX" "wfo": "EAX"
} }
}, },
@ -654,8 +654,8 @@
"lat": 24.5557, "lat": 24.5557,
"lon": -81.7826, "lon": -81.7826,
"point": { "point": {
"x": 61, "x": 62,
"y": 47, "y": 48,
"wfo": "KEY" "wfo": "KEY"
} }
}, },
@ -684,8 +684,8 @@
"lat": 36.175, "lat": 36.175,
"lon": -115.1372, "lon": -115.1372,
"point": { "point": {
"x": 122, "x": 123,
"y": 97, "y": 98,
"wfo": "VEF" "wfo": "VEF"
} }
}, },
@ -704,8 +704,8 @@
"lat": 40.8, "lat": 40.8,
"lon": -96.667, "lon": -96.667,
"point": { "point": {
"x": 56, "x": 57,
"y": 38, "y": 39,
"wfo": "OAX" "wfo": "OAX"
} }
}, },
@ -714,8 +714,8 @@
"lat": 33.767, "lat": 33.767,
"lon": -118.1892, "lon": -118.1892,
"point": { "point": {
"x": 154, "x": 155,
"y": 31, "y": 32,
"wfo": "LOX" "wfo": "LOX"
} }
}, },
@ -724,8 +724,8 @@
"lat": 38.2542, "lat": 38.2542,
"lon": -85.7594, "lon": -85.7594,
"point": { "point": {
"x": 49, "x": 50,
"y": 77, "y": 78,
"wfo": "LMK" "wfo": "LMK"
} }
}, },
@ -734,8 +734,8 @@
"lat": 42.9956, "lat": 42.9956,
"lon": -71.4548, "lon": -71.4548,
"point": { "point": {
"x": 41, "x": 42,
"y": 20, "y": 21,
"wfo": "GYX" "wfo": "GYX"
} }
}, },
@ -744,8 +744,8 @@
"lat": 35.1495, "lat": 35.1495,
"lon": -90.049, "lon": -90.049,
"point": { "point": {
"x": 41, "x": 42,
"y": 66, "y": 67,
"wfo": "MEG" "wfo": "MEG"
} }
}, },
@ -754,8 +754,8 @@
"lat": 43.0389, "lat": 43.0389,
"lon": -87.9065, "lon": -87.9065,
"point": { "point": {
"x": 87, "x": 88,
"y": 64, "y": 65,
"wfo": "MKX" "wfo": "MKX"
} }
}, },
@ -764,8 +764,8 @@
"lat": 30.6944, "lat": 30.6944,
"lon": -88.043, "lon": -88.043,
"point": { "point": {
"x": 51, "x": 52,
"y": 66, "y": 67,
"wfo": "MOB" "wfo": "MOB"
} }
}, },
@ -774,8 +774,8 @@
"lat": 32.3668, "lat": 32.3668,
"lon": -86.3, "lon": -86.3,
"point": { "point": {
"x": 80, "x": 81,
"y": 34, "y": 35,
"wfo": "BMX" "wfo": "BMX"
} }
}, },
@ -784,8 +784,8 @@
"lat": 44.2601, "lat": 44.2601,
"lon": -72.5754, "lon": -72.5754,
"point": { "point": {
"x": 110, "x": 111,
"y": 49, "y": 50,
"wfo": "BTV" "wfo": "BTV"
} }
}, },
@ -794,8 +794,8 @@
"lat": 36.1659, "lat": 36.1659,
"lon": -86.7844, "lon": -86.7844,
"point": { "point": {
"x": 49, "x": 50,
"y": 56, "y": 57,
"wfo": "OHX" "wfo": "OHX"
} }
}, },
@ -804,8 +804,8 @@
"lat": 40.7357, "lat": 40.7357,
"lon": -74.1724, "lon": -74.1724,
"point": { "point": {
"x": 26, "x": 27,
"y": 34, "y": 35,
"wfo": "OKX" "wfo": "OKX"
} }
}, },
@ -814,8 +814,8 @@
"lat": 41.3081, "lat": 41.3081,
"lon": -72.9282, "lon": -72.9282,
"point": { "point": {
"x": 65, "x": 66,
"y": 67, "y": 68,
"wfo": "OKX" "wfo": "OKX"
} }
}, },
@ -845,7 +845,7 @@
"lon": -97.5164, "lon": -97.5164,
"point": { "point": {
"x": 97, "x": 97,
"y": 93, "y": 94,
"wfo": "OUN" "wfo": "OUN"
} }
}, },
@ -854,8 +854,8 @@
"lat": 41.2586, "lat": 41.2586,
"lon": -95.9378, "lon": -95.9378,
"point": { "point": {
"x": 82, "x": 83,
"y": 59, "y": 60,
"wfo": "OAX" "wfo": "OAX"
} }
}, },
@ -864,8 +864,8 @@
"lat": 33.4484, "lat": 33.4484,
"lon": -112.074, "lon": -112.074,
"point": { "point": {
"x": 158, "x": 159,
"y": 57, "y": 58,
"wfo": "PSR" "wfo": "PSR"
} }
}, },
@ -874,8 +874,8 @@
"lat": 44.3683, "lat": 44.3683,
"lon": -100.351, "lon": -100.351,
"point": { "point": {
"x": 54, "x": 55,
"y": 43, "y": 44,
"wfo": "ABR" "wfo": "ABR"
} }
}, },
@ -884,8 +884,8 @@
"lat": 43.6615, "lat": 43.6615,
"lon": -70.2553, "lon": -70.2553,
"point": { "point": {
"x": 75, "x": 76,
"y": 58, "y": 59,
"wfo": "GYX" "wfo": "GYX"
} }
}, },
@ -894,8 +894,8 @@
"lat": 45.5234, "lat": 45.5234,
"lon": -122.6762, "lon": -122.6762,
"point": { "point": {
"x": 112, "x": 113,
"y": 103, "y": 104,
"wfo": "PQR" "wfo": "PQR"
} }
}, },
@ -914,8 +914,8 @@
"lat": 35.7721, "lat": 35.7721,
"lon": -78.6386, "lon": -78.6386,
"point": { "point": {
"x": 74, "x": 75,
"y": 56, "y": 57,
"wfo": "RAH" "wfo": "RAH"
} }
}, },
@ -924,8 +924,8 @@
"lat": 39.4986, "lat": 39.4986,
"lon": -119.7681, "lon": -119.7681,
"point": { "point": {
"x": 45, "x": 46,
"y": 104, "y": 105,
"wfo": "REV" "wfo": "REV"
} }
}, },
@ -934,8 +934,8 @@
"lat": 38.7725, "lat": 38.7725,
"lon": -112.0841, "lon": -112.0841,
"point": { "point": {
"x": 81, "x": 82,
"y": 86, "y": 87,
"wfo": "SLC" "wfo": "SLC"
} }
}, },
@ -944,8 +944,8 @@
"lat": 37.5538, "lat": 37.5538,
"lon": -77.4603, "lon": -77.4603,
"point": { "point": {
"x": 44, "x": 45,
"y": 76, "y": 77,
"wfo": "AKQ" "wfo": "AKQ"
} }
}, },
@ -954,8 +954,8 @@
"lat": 37.271, "lat": 37.271,
"lon": -79.9414, "lon": -79.9414,
"point": { "point": {
"x": 73, "x": 74,
"y": 68, "y": 69,
"wfo": "RNK" "wfo": "RNK"
} }
}, },
@ -964,8 +964,8 @@
"lat": 38.5816, "lat": 38.5816,
"lon": -121.4944, "lon": -121.4944,
"point": { "point": {
"x": 40, "x": 41,
"y": 67, "y": 68,
"wfo": "STO" "wfo": "STO"
} }
}, },
@ -974,8 +974,8 @@
"lat": 40.7608, "lat": 40.7608,
"lon": -111.891, "lon": -111.891,
"point": { "point": {
"x": 99, "x": 100,
"y": 174, "y": 175,
"wfo": "SLC" "wfo": "SLC"
} }
}, },
@ -984,8 +984,8 @@
"lat": 29.4241, "lat": 29.4241,
"lon": -98.4936, "lon": -98.4936,
"point": { "point": {
"x": 125, "x": 126,
"y": 53, "y": 54,
"wfo": "EWX" "wfo": "EWX"
} }
}, },
@ -994,8 +994,8 @@
"lat": 32.7153, "lat": 32.7153,
"lon": -117.1573, "lon": -117.1573,
"point": { "point": {
"x": 56, "x": 57,
"y": 13, "y": 14,
"wfo": "SGX" "wfo": "SGX"
} }
}, },
@ -1014,8 +1014,8 @@
"lat": 35.687, "lat": 35.687,
"lon": -105.9378, "lon": -105.9378,
"point": { "point": {
"x": 125, "x": 126,
"y": 143, "y": 146,
"wfo": "ABQ" "wfo": "ABQ"
} }
}, },
@ -1024,8 +1024,8 @@
"lat": 32.0835, "lat": 32.0835,
"lon": -81.0998, "lon": -81.0998,
"point": { "point": {
"x": 46, "x": 47,
"y": 40, "y": 41,
"wfo": "CHS" "wfo": "CHS"
} }
}, },
@ -1034,7 +1034,7 @@
"lat": 32.5251, "lat": 32.5251,
"lon": -93.7502, "lon": -93.7502,
"point": { "point": {
"x": 76, "x": 74,
"y": 69, "y": 69,
"wfo": "SHV" "wfo": "SHV"
} }
@ -1074,8 +1074,8 @@
"lat": 39.8017, "lat": 39.8017,
"lon": -89.6437, "lon": -89.6437,
"point": { "point": {
"x": 47, "x": 48,
"y": 55, "y": 56,
"wfo": "ILX" "wfo": "ILX"
} }
}, },
@ -1094,8 +1094,8 @@
"lat": 37.2153, "lat": 37.2153,
"lon": -93.2982, "lon": -93.2982,
"point": { "point": {
"x": 66, "x": 67,
"y": 34, "y": 35,
"wfo": "SGF" "wfo": "SGF"
} }
}, },
@ -1104,8 +1104,8 @@
"lat": 41.6639, "lat": 41.6639,
"lon": -83.5552, "lon": -83.5552,
"point": { "point": {
"x": 18, "x": 19,
"y": 66, "y": 67,
"wfo": "CLE" "wfo": "CLE"
} }
}, },
@ -1114,8 +1114,8 @@
"lat": 36.154, "lat": 36.154,
"lon": -95.9928, "lon": -95.9928,
"point": { "point": {
"x": 40, "x": 41,
"y": 104, "y": 105,
"wfo": "TSA" "wfo": "TSA"
} }
}, },
@ -1124,8 +1124,8 @@
"lat": 36.8529, "lat": 36.8529,
"lon": -75.978, "lon": -75.978,
"point": { "point": {
"x": 100, "x": 101,
"y": 52, "y": 53,
"wfo": "AKQ" "wfo": "AKQ"
} }
}, },
@ -1134,8 +1134,8 @@
"lat": 37.6922, "lat": 37.6922,
"lon": -97.3375, "lon": -97.3375,
"point": { "point": {
"x": 61, "x": 62,
"y": 33, "y": 34,
"wfo": "ICT" "wfo": "ICT"
} }
}, },
@ -1144,18 +1144,18 @@
"lat": 34.2257, "lat": 34.2257,
"lon": -77.9447, "lon": -77.9447,
"point": { "point": {
"x": 88, "x": 89,
"y": 67, "y": 68,
"wfo": "ILM" "wfo": "ILM"
} }
}, },
{ {
"city": "Tuscan", "city": "Tucson",
"lat": 32.2216, "lat": 32.2216,
"lon": -110.9698, "lon": -110.9698,
"point": { "point": {
"x": 90, "x": 91,
"y": 48, "y": 49,
"wfo": "TWC" "wfo": "TWC"
} }
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,8 +4,8 @@
"Latitude": 33.749, "Latitude": 33.749,
"Longitude": -84.388, "Longitude": -84.388,
"point": { "point": {
"x": 50, "x": 51,
"y": 86, "y": 87,
"wfo": "FFC" "wfo": "FFC"
} }
}, },
@ -24,8 +24,8 @@
"Latitude": 41.9796, "Latitude": 41.9796,
"Longitude": -87.9045, "Longitude": -87.9045,
"point": { "point": {
"x": 65, "x": 66,
"y": 76, "y": 77,
"wfo": "LOT" "wfo": "LOT"
} }
}, },
@ -34,8 +34,8 @@
"Latitude": 41.4995, "Latitude": 41.4995,
"Longitude": -81.6954, "Longitude": -81.6954,
"point": { "point": {
"x": 82, "x": 83,
"y": 64, "y": 65,
"wfo": "CLE" "wfo": "CLE"
} }
}, },
@ -44,8 +44,8 @@
"Latitude": 32.8959, "Latitude": 32.8959,
"Longitude": -97.0372, "Longitude": -97.0372,
"point": { "point": {
"x": 79, "x": 80,
"y": 108, "y": 109,
"wfo": "FWD" "wfo": "FWD"
} }
}, },
@ -54,8 +54,8 @@
"Latitude": 39.7391, "Latitude": 39.7391,
"Longitude": -104.9847, "Longitude": -104.9847,
"point": { "point": {
"x": 62, "x": 63,
"y": 60, "y": 61,
"wfo": "BOU" "wfo": "BOU"
} }
}, },
@ -64,8 +64,8 @@
"Latitude": 42.3314, "Latitude": 42.3314,
"Longitude": -83.0457, "Longitude": -83.0457,
"point": { "point": {
"x": 65, "x": 66,
"y": 33, "y": 34,
"wfo": "DTX" "wfo": "DTX"
} }
}, },
@ -94,8 +94,8 @@
"Latitude": 39.7684, "Latitude": 39.7684,
"Longitude": -86.158, "Longitude": -86.158,
"point": { "point": {
"x": 57, "x": 58,
"y": 68, "y": 69,
"wfo": "IND" "wfo": "IND"
} }
}, },
@ -104,8 +104,8 @@
"Latitude": 34.0522, "Latitude": 34.0522,
"Longitude": -118.2437, "Longitude": -118.2437,
"point": { "point": {
"x": 154, "x": 155,
"y": 44, "y": 45,
"wfo": "LOX" "wfo": "LOX"
} }
}, },
@ -114,8 +114,8 @@
"Latitude": 25.7743, "Latitude": 25.7743,
"Longitude": -80.1937, "Longitude": -80.1937,
"point": { "point": {
"x": 109, "x": 110,
"y": 50, "y": 51,
"wfo": "MFL" "wfo": "MFL"
} }
}, },
@ -124,8 +124,8 @@
"Latitude": 44.98, "Latitude": 44.98,
"Longitude": -93.2638, "Longitude": -93.2638,
"point": { "point": {
"x": 107, "x": 108,
"y": 71, "y": 72,
"wfo": "MPX" "wfo": "MPX"
} }
}, },
@ -134,8 +134,8 @@
"Latitude": 40.7142, "Latitude": 40.7142,
"Longitude": -74.0059, "Longitude": -74.0059,
"point": { "point": {
"x": 32, "x": 33,
"y": 34, "y": 35,
"wfo": "OKX" "wfo": "OKX"
} }
}, },
@ -144,8 +144,8 @@
"Latitude": 36.8468, "Latitude": 36.8468,
"Longitude": -76.2852, "Longitude": -76.2852,
"point": { "point": {
"x": 89, "x": 90,
"y": 51, "y": 52,
"wfo": "AKQ" "wfo": "AKQ"
} }
}, },
@ -164,8 +164,8 @@
"Latitude": 39.9523, "Latitude": 39.9523,
"Longitude": -75.1638, "Longitude": -75.1638,
"point": { "point": {
"x": 49, "x": 50,
"y": 75, "y": 76,
"wfo": "PHI" "wfo": "PHI"
} }
}, },
@ -174,8 +174,8 @@
"Latitude": 40.4406, "Latitude": 40.4406,
"Longitude": -79.9959, "Longitude": -79.9959,
"point": { "point": {
"x": 77, "x": 78,
"y": 65, "y": 66,
"wfo": "PBZ" "wfo": "PBZ"
} }
}, },
@ -184,8 +184,8 @@
"Latitude": 38.6273, "Latitude": 38.6273,
"Longitude": -90.1979, "Longitude": -90.1979,
"point": { "point": {
"x": 94, "x": 95,
"y": 73, "y": 74,
"wfo": "LSX" "wfo": "LSX"
} }
}, },
@ -204,8 +204,8 @@
"Latitude": 47.6062, "Latitude": 47.6062,
"Longitude": -122.3321, "Longitude": -122.3321,
"point": { "point": {
"x": 124, "x": 125,
"y": 67, "y": 68,
"wfo": "SEW" "wfo": "SEW"
} }
}, },
@ -214,8 +214,8 @@
"Latitude": 43.0481, "Latitude": 43.0481,
"Longitude": -76.1474, "Longitude": -76.1474,
"point": { "point": {
"x": 51, "x": 52,
"y": 98, "y": 99,
"wfo": "BGM" "wfo": "BGM"
} }
}, },
@ -224,8 +224,8 @@
"Latitude": 27.9475, "Latitude": 27.9475,
"Longitude": -82.4584, "Longitude": -82.4584,
"point": { "point": {
"x": 70, "x": 71,
"y": 96, "y": 97,
"wfo": "TBW" "wfo": "TBW"
} }
}, },

View file

@ -575,7 +575,7 @@
"lon": -77.9447 "lon": -77.9447
}, },
{ {
"city": "Tuscan", "city": "Tucson",
"lat": 32.2216, "lat": 32.2216,
"lon": -110.9698 "lon": -110.9698
} }

View file

@ -1,41 +0,0 @@
// look up points for each regional city
const fs = require('fs/promises');
const chunk = require('./chunk');
const https = require('./https');
(async () => {
// source data
const regionalCities = JSON.parse(await fs.readFile('./datagenerators/regionalcities-raw.json'));
const result = [];
const dataChunks = chunk(regionalCities, 5);
// for loop intentional for use of await
// this keeps the api from getting overwhelmed
for (let i = 0; i < dataChunks.length; i += 1) {
const cityChunk = dataChunks[i];
// eslint-disable-next-line no-await-in-loop
const chunkResult = await Promise.all(cityChunk.map(async (city) => {
try {
const data = await https(`https://api.weather.gov/points/${city.lat},${city.lon}`);
const point = JSON.parse(data);
return {
...city,
point: {
x: point.properties.gridX,
y: point.properties.gridY,
wfo: point.properties.gridId,
},
};
} catch (e) {
console.error(e);
return city;
}
}));
result.push(...chunkResult);
}
await fs.writeFile('./datagenerators/output/regionalcities.json', JSON.stringify(result, null, ' '));
})();

View file

@ -0,0 +1,40 @@
// look up points for each regional city
import fs from 'fs/promises';
import chunk from './chunk.mjs';
import https from './https.mjs';
// source data
const regionalCities = JSON.parse(await fs.readFile('./datagenerators/regionalcities-raw.json'));
const result = [];
const dataChunks = chunk(regionalCities, 5);
// for loop intentional for use of await
// this keeps the api from getting overwhelmed
for (let i = 0; i < dataChunks.length; i += 1) {
const cityChunk = dataChunks[i];
// eslint-disable-next-line no-await-in-loop
const chunkResult = await Promise.all(cityChunk.map(async (city) => {
try {
const data = await https(`https://api.weather.gov/points/${city.lat},${city.lon}`);
const point = JSON.parse(data);
return {
...city,
point: {
x: point.properties.gridX,
y: point.properties.gridY,
wfo: point.properties.gridId,
},
};
} catch (e) {
console.error(e);
return city;
}
}));
result.push(...chunkResult);
}
await fs.writeFile('./datagenerators/output/regionalcities.json', JSON.stringify(result, null, ' '));

View file

@ -1,4 +1,4 @@
module.exports = [ export default [
'AZ', 'AZ',
'AL', 'AL',
'AK', 'AK',

View file

@ -1,73 +0,0 @@
// list all stations in a single file
// only find stations with 4 letter codes
const fs = require('fs');
const path = require('path');
const https = require('./https');
const states = require('./stations-states');
const chunk = require('./chunk');
// skip stations starting with these letters
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
// immediately invoked function so we can access async/await
const start = async () => {
// chunk the list of states
const chunkStates = chunk(states, 5);
// store output
const output = {};
// process all chunks
for (let i = 0; i < chunkStates.length; i += 1) {
const stateChunk = chunkStates[i];
// loop through states
stateChunk.forEach(async (state) => {
try {
let stations;
let next = `https://api.weather.gov/stations?state=${state}`;
do {
// get list and parse the JSON
// eslint-disable-next-line no-await-in-loop
const stationsRaw = await https(next);
stations = JSON.parse(stationsRaw);
// filter stations for 4 letter identifiers
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
// filter against starting letter
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
// add each resulting station to the output
stationsFiltered.forEach((station) => {
const id = station.properties.stationIdentifier;
if (output[id]) {
console.log(`Duplicate station: ${state}-${id}`);
return;
}
output[id] = {
id,
city: station.properties.name,
state,
lat: station.geometry.coordinates[1],
lon: station.geometry.coordinates[0],
};
});
next = stations?.pagination?.next;
// write the output
// write the output
fs.writeFileSync(path.join(__dirname, 'output/stations.json'), JSON.stringify(output, null, 2));
}
while (next && stations.features.length > 0);
console.log(`Complete: ${state}`);
return true;
} catch (e) {
console.error(`Unable to get state: ${state}`);
return false;
}
});
}
};
// immediately invoked function allows access to async
(async () => {
await start();
})();

View file

@ -0,0 +1,67 @@
// list all stations in a single file
// only find stations with 4 letter codes
import { writeFileSync } from 'fs';
import https from './https.mjs';
import states from './stations-states.mjs';
import chunk from './chunk.mjs';
// skip stations starting with these letters
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
// chunk the list of states
const chunkStates = chunk(states, 1);
// store output
const output = {};
// process all chunks
for (let i = 0; i < chunkStates.length; i += 1) {
const stateChunk = chunkStates[i];
// loop through states
// eslint-disable-next-line no-await-in-loop
await Promise.allSettled(stateChunk.map(async (state) => {
try {
let stations;
let next = `https://api.weather.gov/stations?state=${state}`;
let round = 0;
do {
console.log(`Getting: ${state}-${round}`);
// get list and parse the JSON
// eslint-disable-next-line no-await-in-loop
const stationsRaw = await https(next);
stations = JSON.parse(stationsRaw);
// filter stations for 4 letter identifiers
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
// filter against starting letter
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
// add each resulting station to the output
stationsFiltered.forEach((station) => {
const id = station.properties.stationIdentifier;
if (output[id]) {
console.log(`Duplicate station: ${state}-${id}`);
return;
}
output[id] = {
id,
city: station.properties.name,
state,
lat: station.geometry.coordinates[1],
lon: station.geometry.coordinates[0],
};
});
next = stations?.pagination?.next;
round += 1;
// write the output
writeFileSync('./datagenerators/output/stations.json', JSON.stringify(output, null, 2));
}
while (next && stations.features.length > 0);
console.log(`Complete: ${state}`);
return true;
} catch (e) {
console.error(`Unable to get state: ${state}`);
return false;
}
}));
}

View file

@ -1,41 +0,0 @@
// look up points for each travel city
const fs = require('fs/promises');
const chunk = require('./chunk');
const https = require('./https');
(async () => {
// source data
const travelCities = JSON.parse(await fs.readFile('./datagenerators/travelcities-raw.json'));
const result = [];
const dataChunks = chunk(travelCities, 5);
// for loop intentional for use of await
// this keeps the api from getting overwhelmed
for (let i = 0; i < dataChunks.length; i += 1) {
const cityChunk = dataChunks[i];
// eslint-disable-next-line no-await-in-loop
const chunkResult = await Promise.all(cityChunk.map(async (city) => {
try {
const data = await https(`https://api.weather.gov/points/${city.Latitude},${city.Longitude}`);
const point = JSON.parse(data);
return {
...city,
point: {
x: point.properties.gridX,
y: point.properties.gridY,
wfo: point.properties.gridId,
},
};
} catch (e) {
console.error(e);
return city;
}
}));
result.push(...chunkResult);
}
await fs.writeFile('./datagenerators/output/travelcities.json', JSON.stringify(result, null, ' '));
})();

View file

@ -0,0 +1,39 @@
// look up points for each travel city
import { readFile, writeFile } from 'fs/promises';
import chunk from './chunk.mjs';
import https from './https.mjs';
// source data
const travelCities = JSON.parse(await readFile('./datagenerators/travelcities-raw.json'));
const result = [];
const dataChunks = chunk(travelCities, 5);
// for loop intentional for use of await
// this keeps the api from getting overwhelmed
for (let i = 0; i < dataChunks.length; i += 1) {
const cityChunk = dataChunks[i];
// eslint-disable-next-line no-await-in-loop
const chunkResult = await Promise.all(cityChunk.map(async (city) => {
try {
const data = await https(`https://api.weather.gov/points/${city.Latitude},${city.Longitude}`);
const point = JSON.parse(data);
return {
...city,
point: {
x: point.properties.gridX,
y: point.properties.gridY,
wfo: point.properties.gridId,
},
};
} catch (e) {
console.error(e);
return city;
}
}));
result.push(...chunkResult);
}
await writeFile('./datagenerators/output/travelcities.json', JSON.stringify(result, null, ' '));

1
dist/index.html vendored

File diff suppressed because one or more lines are too long

12
dist/manifest.json vendored
View file

@ -1,12 +0,0 @@
{
"name": "WeatherStar 4000+",
"icons": [
{
"src": "/images/Logo192.png",
"sizes": "192x192",
"type": "images/png"
}
],
"start_url": "/",
"display": "standalone"
}

1
dist/readme.txt vendored Normal file
View file

@ -0,0 +1 @@
This folder is a placeholder for static files generated by the gulp task buildDist

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

0
dist/robots.txt vendored
View file

View file

@ -12,11 +12,13 @@ import s3Upload from 'gulp-s3-upload';
import webpack from 'webpack-stream'; import webpack from 'webpack-stream';
import TerserPlugin from 'terser-webpack-plugin'; import TerserPlugin from 'terser-webpack-plugin';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import file from 'gulp-file';
// get cloudfront // get cloudfront
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront'; import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
import reader from '../src/playlist-reader.mjs';
const clean = () => deleteAsync(['./dist**']); const clean = () => deleteAsync(['./dist/**/*', '!./dist/readme.txt']);
const cloudfront = new CloudFrontClient({ region: 'us-east-1' }); const cloudfront = new CloudFrontClient({ region: 'us-east-1' });
@ -85,6 +87,7 @@ const mjsSources = [
'server/scripts/modules/regionalforecast.mjs', 'server/scripts/modules/regionalforecast.mjs',
'server/scripts/modules/travelforecast.mjs', 'server/scripts/modules/travelforecast.mjs',
'server/scripts/modules/progress.mjs', 'server/scripts/modules/progress.mjs',
'server/scripts/modules/media.mjs',
'server/scripts/index.mjs', 'server/scripts/index.mjs',
]; ];
@ -119,8 +122,9 @@ const compressHtml = async () => {
const otherFiles = [ const otherFiles = [
'server/robots.txt', 'server/robots.txt',
'server/manifest.json', 'server/manifest.json',
'server/music/**/*.mp3',
]; ];
const copyOtherFiles = () => src(otherFiles, { base: 'server/' }) const copyOtherFiles = () => src(otherFiles, { base: 'server/', encoding: false })
.pipe(dest('./dist')); .pipe(dest('./dist'));
const s3 = s3Upload({ const s3 = s3Upload({
@ -132,13 +136,14 @@ const uploadSources = [
'dist/**', 'dist/**',
'!dist/**/*.map', '!dist/**/*.map',
]; ];
const upload = () => src(uploadSources, { base: './dist' }) const upload = () => src(uploadSources, { base: './dist', encoding: false })
.pipe(s3({ .pipe(s3({
Bucket: 'weatherstar', Bucket: 'weatherstar',
StorageClass: 'STANDARD', StorageClass: 'STANDARD',
maps: { maps: {
CacheControl: (keyname) => { CacheControl: (keyname) => {
if (keyname.indexOf('index.html') > -1) return 'max-age=300'; // 10 minutes if (keyname.indexOf('index.html') > -1) return 'max-age=300'; // 10 minutes
if (keyname.indexOf('.mp3') > -1) return 'max-age=31536000'; // 1 year for mp3 files
return 'max-age=2592000'; // 1 month return 'max-age=2592000'; // 1 month
}, },
}, },
@ -167,10 +172,20 @@ const invalidate = () => cloudfront.send(new CreateInvalidationCommand({
}, },
})); }));
const buildDist = series(clean, parallel(buildJs, compressJsData, compressJsVendor, copyCss, compressHtml, copyOtherFiles)); const buildPlaylist = async () => {
const availableFiles = await reader();
const playlist = { availableFiles };
return file('playlist.json', JSON.stringify(playlist)).pipe(dest('./dist'));
};
const buildDist = series(clean, parallel(buildJs, compressJsData, compressJsVendor, copyCss, compressHtml, copyOtherFiles, buildPlaylist));
// upload_images could be in parallel with upload, but _images logs a lot and has little changes // upload_images could be in parallel with upload, but _images logs a lot and has little changes
// by running upload last the majority of the changes will be at the bottom of the log for easy viewing // by running upload last the majority of the changes will be at the bottom of the log for easy viewing
const publishFrontend = series(buildDist, uploadImages, upload, invalidate); const publishFrontend = series(buildDist, uploadImages, upload, invalidate);
export default publishFrontend; export default publishFrontend;
export {
buildDist,
};

View file

@ -1,7 +1,8 @@
import updateVendor from './gulp/update-vendor.mjs'; import updateVendor from './gulp/update-vendor.mjs';
import publishFrontend from './gulp/publish-frontend.mjs'; import publishFrontend, { buildDist } from './gulp/publish-frontend.mjs'
export { export {
updateVendor, updateVendor,
publishFrontend, publishFrontend,
buildDist,
}; };

View file

@ -1,55 +0,0 @@
// express
const express = require('express');
const app = express();
const port = process.env.WS4KP_PORT ?? 8080;
const path = require('path');
// template engine
app.set('view engine', 'ejs');
// cors pass through
const fs = require('fs');
const corsPassThru = require('./cors');
const radarPassThru = require('./cors/radar');
const outlookPassThru = require('./cors/outlook');
// cors pass-thru to api.weather.gov
app.get('/stations/*', corsPassThru);
app.get('/Conus/*', radarPassThru);
app.get('/products/*', outlookPassThru);
// version
const { version } = JSON.parse(fs.readFileSync('package.json'));
const index = (req, res) => {
res.render(path.join(__dirname, 'views/index'), {
production: false,
version,
});
};
// debugging
if (process.env?.DIST === '1') {
// distribution
app.use('/images', express.static(path.join(__dirname, './server/images')));
app.use('/fonts', express.static(path.join(__dirname, './server/fonts')));
app.use('/scripts', express.static(path.join(__dirname, './server/scripts')));
app.use('/', express.static(path.join(__dirname, './dist')));
} else {
// debugging
app.get('/index.html', index);
app.get('/', index);
app.get('*', express.static(path.join(__dirname, './server')));
}
const server = app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
// graceful shutdown
process.on('SIGINT', () => {
server.close(() => {
console.log('Server closed');
});
});

53
index.mjs Normal file
View file

@ -0,0 +1,53 @@
import express from 'express';
import fs from 'fs';
import corsPassThru from './cors/index.mjs';
import radarPassThru from './cors/radar.mjs';
import outlookPassThru from './cors/outlook.mjs';
import playlist from './src/playlist.mjs';
const app = express();
const port = process.env.WS4KP_PORT ?? 8080;
// template engine
app.set('view engine', 'ejs');
// cors pass-thru to api.weather.gov
app.get('/stations/*station', corsPassThru);
app.get('/Conus/*radar', radarPassThru);
app.get('/products/*product', outlookPassThru);
app.get('/playlist.json', playlist);
// version
const { version } = JSON.parse(fs.readFileSync('package.json'));
const index = (req, res) => {
res.render('index', {
production: false,
version,
});
};
// 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('/', express.static('./dist'));
} else {
// debugging
app.get('/index.html', index);
app.get('/', index);
app.get('*name', express.static('./server'));
}
const server = app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
// graceful shutdown
process.on('SIGINT', () => {
server.close(() => {
console.log('Server closed');
});
});

4183
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,13 @@
{ {
"name": "ws4kp", "name": "ws4kp",
"version": "5.13.3", "version": "5.16.6",
"description": "Welcome to the WeatherStar 4000+ project page!", "description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.js", "main": "index.mjs",
"type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"build:css": "sass --style=compressed ./server/styles/scss/main.scss ./server/styles/main.css", "build:css": "sass --style=compressed ./server/styles/scss/main.scss ./server/styles/main.css",
"build": "gulp buildDist",
"lint": "eslint ./server/scripts/**/*.mjs", "lint": "eslint ./server/scripts/**/*.mjs",
"lint:fix": "eslint --fix ./server/scripts/**/*.mjs" "lint:fix": "eslint --fix ./server/scripts/**/*.mjs"
}, },
@ -20,30 +22,33 @@
}, },
"homepage": "https://github.com/netbymatt/ws4kp#readme", "homepage": "https://github.com/netbymatt/ws4kp#readme",
"devDependencies": { "devDependencies": {
"del": "^7.1.0",
"luxon": "^3.0.0",
"nosleep.js": "^0.12.0",
"suncalc": "^1.8.0",
"swiped-events": "^1.1.4",
"@aws-sdk/client-cloudfront": "^3.609.0", "@aws-sdk/client-cloudfront": "^3.609.0",
"gulp-awspublish": "^8.0.0", "del": "^8.0.0",
"gulp-s3-upload": "^1.7.3",
"eslint": "^8.2.0", "eslint": "^8.2.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.10.0", "eslint-plugin-import": "^2.10.0",
"gulp": "^5.0.0", "gulp": "^5.0.0",
"gulp-awspublish": "^8.0.0",
"gulp-concat": "^2.6.1", "gulp-concat": "^2.6.1",
"gulp-ejs": "^5.1.0", "gulp-ejs": "^5.1.0",
"gulp-file": "^0.4.0",
"gulp-htmlmin": "^5.0.1", "gulp-htmlmin": "^5.0.1",
"gulp-rename": "^2.0.0", "gulp-rename": "^2.0.0",
"gulp-sass": "^5.1.0", "gulp-s3-upload": "^1.7.3",
"gulp-sass": "^6.0.0",
"gulp-terser": "^2.0.0", "gulp-terser": "^2.0.0",
"jquery": "^3.6.0",
"jquery-touchswipe": "^1.6.19",
"luxon": "^3.0.0",
"nosleep.js": "^0.12.0",
"sass": "^1.54.0",
"suncalc": "^1.8.0",
"swiped-events": "^1.1.4",
"terser-webpack-plugin": "^5.3.6", "terser-webpack-plugin": "^5.3.6",
"webpack-stream": "^7.0.0", "webpack-stream": "^7.0.0"
"sass": "^1.54.0"
}, },
"dependencies": { "dependencies": {
"express": "^4.17.1", "ejs": "^3.1.5",
"ejs": "^3.1.5" "express": "^5.1.0"
} }
} }

View file

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 251 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 455 B

After

Width:  |  Height:  |  Size: 455 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

3
server/music/readme.txt Normal file
View file

@ -0,0 +1,3 @@
.mp3 files placed in this folder will be available via the un-mute button in the application.
No subdirectories will be scanned, and music will be played in a random order.
The default folder will be used only if no .mp3 files are found in this /server/music folder

View file

@ -2,8 +2,7 @@
// it is intended to allow for customizations that do not get published back to the git repo // it is intended to allow for customizations that do not get published back to the git repo
// for example, changing the logo // for example, changing the logo
// start running after all content is loaded const customTask = () => {
document.addEventListener('DOMContentLoaded', () => {
// get all of the logo images // get all of the logo images
const logos = document.querySelectorAll('.logo img'); const logos = document.querySelectorAll('.logo img');
// loop through each logo // loop through each logo
@ -11,4 +10,16 @@ document.addEventListener('DOMContentLoaded', () => {
// change the source // change the source
elem.src = 'my-custom-logo.gif'; elem.src = 'my-custom-logo.gif';
}); });
};
// start running after all content is loaded, or immediately if page content is already loaded
if (document.readyState === 'loading') {
// Loading hasn't finished yet
document.addEventListener('DOMContentLoaded', customTask);
} else {
// `DOMContentLoaded` has already fired
customTask();
}
document.addEventListener('DOMContentLoaded', () => {
}); });

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,243 +1,243 @@
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const TravelCities = [ const TravelCities = [
{ {
Name: 'Atlanta', "Name": "Atlanta",
Latitude: 33.749, "Latitude": 33.749,
Longitude: -84.388, "Longitude": -84.388,
point: { "point": {
x: 50, "x": 51,
y: 86, "y": 87,
wfo: 'FFC', "wfo": "FFC"
}, }
}, },
{ {
Name: 'Boston', "Name": "Boston",
Latitude: 42.3584, "Latitude": 42.3584,
Longitude: -71.0598, "Longitude": -71.0598,
point: { "point": {
x: 71, "x": 71,
y: 90, "y": 90,
wfo: 'BOX', "wfo": "BOX"
}, }
}, },
{ {
Name: 'Chicago', "Name": "Chicago",
Latitude: 41.9796, "Latitude": 41.9796,
Longitude: -87.9045, "Longitude": -87.9045,
point: { "point": {
x: 65, "x": 66,
y: 76, "y": 77,
wfo: 'LOT', "wfo": "LOT"
}, }
}, },
{ {
Name: 'Cleveland', "Name": "Cleveland",
Latitude: 41.4995, "Latitude": 41.4995,
Longitude: -81.6954, "Longitude": -81.6954,
point: { "point": {
x: 82, "x": 83,
y: 64, "y": 65,
wfo: 'CLE', "wfo": "CLE"
}, }
}, },
{ {
Name: 'Dallas', "Name": "Dallas",
Latitude: 32.8959, "Latitude": 32.8959,
Longitude: -97.0372, "Longitude": -97.0372,
point: { "point": {
x: 79, "x": 80,
y: 108, "y": 109,
wfo: 'FWD', "wfo": "FWD"
}, }
}, },
{ {
Name: 'Denver', "Name": "Denver",
Latitude: 39.7391, "Latitude": 39.7391,
Longitude: -104.9847, "Longitude": -104.9847,
point: { "point": {
x: 62, "x": 63,
y: 60, "y": 61,
wfo: 'BOU', "wfo": "BOU"
}, }
}, },
{ {
Name: 'Detroit', "Name": "Detroit",
Latitude: 42.3314, "Latitude": 42.3314,
Longitude: -83.0457, "Longitude": -83.0457,
point: { "point": {
x: 65, "x": 66,
y: 33, "y": 34,
wfo: 'DTX', "wfo": "DTX"
}, }
}, },
{ {
Name: 'Hartford', "Name": "Hartford",
Latitude: 41.7637, "Latitude": 41.7637,
Longitude: -72.6851, "Longitude": -72.6851,
point: { "point": {
x: 21, "x": 21,
y: 54, "y": 54,
wfo: 'BOX', "wfo": "BOX"
}, }
}, },
{ {
Name: 'Houston', "Name": "Houston",
Latitude: 29.7633, "Latitude": 29.7633,
Longitude: -95.3633, "Longitude": -95.3633,
point: { "point": {
x: 65, "x": 65,
y: 97, "y": 97,
wfo: 'HGX', "wfo": "HGX"
}, }
}, },
{ {
Name: 'Indianapolis', "Name": "Indianapolis",
Latitude: 39.7684, "Latitude": 39.7684,
Longitude: -86.158, "Longitude": -86.158,
point: { "point": {
x: 57, "x": 58,
y: 68, "y": 69,
wfo: 'IND', "wfo": "IND"
}, }
}, },
{ {
Name: 'Los Angeles', "Name": "Los Angeles",
Latitude: 34.0522, "Latitude": 34.0522,
Longitude: -118.2437, "Longitude": -118.2437,
point: { "point": {
x: 154, "x": 155,
y: 44, "y": 45,
wfo: 'LOX', "wfo": "LOX"
}, }
}, },
{ {
Name: 'Miami', "Name": "Miami",
Latitude: 25.7743, "Latitude": 25.7743,
Longitude: -80.1937, "Longitude": -80.1937,
point: { "point": {
x: 109, "x": 110,
y: 50, "y": 51,
wfo: 'MFL', "wfo": "MFL"
}, }
}, },
{ {
Name: 'Minneapolis', "Name": "Minneapolis",
Latitude: 44.98, "Latitude": 44.98,
Longitude: -93.2638, "Longitude": -93.2638,
point: { "point": {
x: 107, "x": 108,
y: 71, "y": 72,
wfo: 'MPX', "wfo": "MPX"
}, }
}, },
{ {
Name: 'New York', "Name": "New York",
Latitude: 40.7142, "Latitude": 40.7142,
Longitude: -74.0059, "Longitude": -74.0059,
point: { "point": {
x: 32, "x": 33,
y: 34, "y": 35,
wfo: 'OKX', "wfo": "OKX"
}, }
}, },
{ {
Name: 'Norfolk', "Name": "Norfolk",
Latitude: 36.8468, "Latitude": 36.8468,
Longitude: -76.2852, "Longitude": -76.2852,
point: { "point": {
x: 89, "x": 90,
y: 51, "y": 52,
wfo: 'AKQ', "wfo": "AKQ"
}, }
}, },
{ {
Name: 'Orlando', "Name": "Orlando",
Latitude: 28.5383, "Latitude": 28.5383,
Longitude: -81.3792, "Longitude": -81.3792,
point: { "point": {
x: 26, "x": 26,
y: 68, "y": 68,
wfo: 'MLB', "wfo": "MLB"
}, }
}, },
{ {
Name: 'Philadelphia', "Name": "Philadelphia",
Latitude: 39.9523, "Latitude": 39.9523,
Longitude: -75.1638, "Longitude": -75.1638,
point: { "point": {
x: 49, "x": 50,
y: 75, "y": 76,
wfo: 'PHI', "wfo": "PHI"
}, }
}, },
{ {
Name: 'Pittsburgh', "Name": "Pittsburgh",
Latitude: 40.4406, "Latitude": 40.4406,
Longitude: -79.9959, "Longitude": -79.9959,
point: { "point": {
x: 77, "x": 78,
y: 65, "y": 66,
wfo: 'PBZ', "wfo": "PBZ"
}, }
}, },
{ {
Name: 'St. Louis', "Name": "St. Louis",
Latitude: 38.6273, "Latitude": 38.6273,
Longitude: -90.1979, "Longitude": -90.1979,
point: { "point": {
x: 94, "x": 95,
y: 73, "y": 74,
wfo: 'LSX', "wfo": "LSX"
}, }
}, },
{ {
Name: 'San Francisco', "Name": "San Francisco",
Latitude: 37.7749, "Latitude": 37.7749,
Longitude: -122.4194, "Longitude": -122.4194,
point: { "point": {
x: 85, "x": 85,
y: 105, "y": 105,
wfo: 'MTR', "wfo": "MTR"
}, }
}, },
{ {
Name: 'Seattle', "Name": "Seattle",
Latitude: 47.6062, "Latitude": 47.6062,
Longitude: -122.3321, "Longitude": -122.3321,
point: { "point": {
x: 124, "x": 125,
y: 67, "y": 68,
wfo: 'SEW', "wfo": "SEW"
}, }
}, },
{ {
Name: 'Syracuse', "Name": "Syracuse",
Latitude: 43.0481, "Latitude": 43.0481,
Longitude: -76.1474, "Longitude": -76.1474,
point: { "point": {
x: 51, "x": 52,
y: 98, "y": 99,
wfo: 'BGM', "wfo": "BGM"
}, }
}, },
{ {
Name: 'Tampa', "Name": "Tampa",
Latitude: 27.9475, "Latitude": 27.9475,
Longitude: -82.4584, "Longitude": -82.4584,
point: { "point": {
x: 70, "x": 71,
y: 96, "y": 97,
wfo: 'TBW', "wfo": "TBW"
}, }
}, },
{ {
Name: 'Washington DC', "Name": "Washington DC",
Latitude: 38.8951, "Latitude": 38.8951,
Longitude: -77.0364, "Longitude": -77.0364,
point: { "point": {
x: 97, "x": 97,
y: 71, "y": 71,
wfo: 'LWX', "wfo": "LWX"
}, }
}, }
]; ]

View file

@ -1,7 +1,7 @@
import { json } from './modules/utils/fetch.mjs'; import { json } from './modules/utils/fetch.mjs';
import noSleep from './modules/utils/nosleep.mjs'; import noSleep from './modules/utils/nosleep.mjs';
import { import {
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived, stopAutoRefreshTimer, registerRefreshData, message as navMessage, isPlaying, resize, resetStatuses, latLonReceived,
} from './modules/navigation.mjs'; } from './modules/navigation.mjs';
import { round2 } from './modules/utils/units.mjs'; import { round2 } from './modules/utils/units.mjs';
import { parseQueryString } from './modules/share.mjs'; import { parseQueryString } from './modules/share.mjs';
@ -10,6 +10,7 @@ import AutoComplete from './modules/autocomplete.mjs';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
init(); init();
getCustomCode();
}); });
const categories = [ const categories = [
@ -32,8 +33,6 @@ const init = () => {
e.target.select(); e.target.select();
}); });
registerRefreshData(loadData);
document.querySelector('#NavigateMenu').addEventListener('click', btnNavigateMenuClick); document.querySelector('#NavigateMenu').addEventListener('click', btnNavigateMenuClick);
document.querySelector('#NavigateRefresh').addEventListener('click', btnNavigateRefreshClick); document.querySelector('#NavigateRefresh').addEventListener('click', btnNavigateRefreshClick);
document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick); document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick);
@ -245,7 +244,6 @@ const loadData = (_latLon, haveDataCallback) => {
if (!latLon) return; if (!latLon) return;
document.querySelector(TXT_ADDRESS_SELECTOR).blur(); document.querySelector(TXT_ADDRESS_SELECTOR).blur();
stopAutoRefreshTimer();
latLonReceived(latLon, haveDataCallback); latLonReceived(latLon, haveDataCallback);
}; };
@ -410,3 +408,15 @@ const fullScreenResizeCheck = () => {
// store state of fullscreen element for next change detection // store state of fullscreen element for next change detection
fullScreenResizeCheck.wasFull = !!document.fullscreenElement; fullScreenResizeCheck.wasFull = !!document.fullscreenElement;
}; };
const getCustomCode = async () => {
// fetch the custom file and see if it returns a 200 status
const response = await fetch('scripts/custom.js', { method: 'HEAD' });
if (response.ok) {
// add the script element to the page
const customElem = document.createElement('script');
customElem.src = 'scripts/custom.js';
customElem.type = 'text/javascript';
document.body.append(customElem);
}
};

View file

@ -3,7 +3,7 @@ import { loadImg, 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';
import { registerDisplay } from './navigation.mjs'; import { registerDisplay, timeZone } from './navigation.mjs';
class Almanac extends WeatherDisplay { class Almanac extends WeatherDisplay {
constructor(navId, elemId) { constructor(navId, elemId) {
@ -21,12 +21,11 @@ class Almanac extends WeatherDisplay {
this.timing.totalScreens = 1; this.timing.totalScreens = 1;
} }
async getData(_weatherParameters) { async getData(weatherParameters, refresh) {
const superResponse = super.getData(_weatherParameters); const superResponse = super.getData(weatherParameters, refresh);
const weatherParameters = _weatherParameters ?? this.weatherParameters;
// get sun/moon data // get sun/moon data
const { sun, moon } = this.calcSunMoonData(weatherParameters); const { sun, moon } = this.calcSunMoonData(this.weatherParameters);
// store the data // store the data
this.data = { this.data = {
@ -123,10 +122,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).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(); 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).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).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).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(); this.elem.querySelector('.set-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
const days = info.moon.map((MoonPhase) => { const days = info.moon.map((MoonPhase) => {
const fill = {}; const fill = {};

View file

@ -8,7 +8,7 @@ import { getWeatherIconFromIconLink } from './icons.mjs';
import WeatherDisplay from './weatherdisplay.mjs'; import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs'; import { registerDisplay } from './navigation.mjs';
import { import {
celsiusToFahrenheit, kphToMph, pascalToInHg, metersToFeet, kilometersToMiles, temperature, windSpeed, pressure, distanceMeters, distanceKilometers,
} from './utils/units.mjs'; } from './utils/units.mjs';
// some stations prefixed do not provide all the necessary data // some stations prefixed do not provide all the necessary data
@ -21,13 +21,14 @@ class CurrentWeather extends WeatherDisplay {
this.backgroundImage = loadImg('images/BackGround1_1.png'); this.backgroundImage = loadImg('images/BackGround1_1.png');
} }
async getData(_weatherParameters) { async getData(weatherParameters, refresh) {
// always load the data for use in the lower scroll // always load the data for use in the lower scroll
const superResult = super.getData(_weatherParameters); const superResult = super.getData(weatherParameters, refresh);
const weatherParameters = _weatherParameters ?? this.weatherParameters; // note: current weather does not use old data on a silent refresh
// this is deliberate because it can pull data from more than one station in sequence
// filter for 4-letter observation stations, only those contain sky conditions and thus an icon // filter for 4-letter observation stations, only those contain sky conditions and thus an icon
const filteredStations = weatherParameters.stations.filter((station) => station?.properties?.stationIdentifier?.length === 4 && !skipStations.includes(station.properties.stationIdentifier.slice(0, 1))); const filteredStations = this.weatherParameters.stations.filter((station) => station?.properties?.stationIdentifier?.length === 4 && !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
// Load the observations // Load the observations
let observations; let observations;
@ -129,6 +130,8 @@ class CurrentWeather extends WeatherDisplay {
// make data available outside this class // make data available outside this class
// promise allows for data to be requested before it is available // promise allows for data to be requested before it is available
async getCurrentWeather(stillWaiting) { async getCurrentWeather(stillWaiting) {
// an external caller has requested data, set up auto reload
this.setAutoReload();
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting); if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.data) resolve(this.data); if (this.data) resolve(this.data);
@ -159,23 +162,32 @@ const shortConditions = (_condition) => {
// format the received data // format the received data
const parseData = (data) => { const parseData = (data) => {
// get the unit converter
const windConverter = windSpeed();
const temperatureConverter = temperature();
const metersConverter = distanceMeters();
const kilometersConverter = distanceKilometers();
const pressureConverter = pressure();
const observations = data.features[0].properties; const observations = data.features[0].properties;
// values from api are provided in metric // values from api are provided in metric
data.observations = observations; data.observations = observations;
data.Temperature = Math.round(observations.temperature.value); data.Temperature = temperatureConverter(observations.temperature.value);
data.TemperatureUnit = 'C'; data.TemperatureUnit = temperatureConverter.units;
data.DewPoint = Math.round(observations.dewpoint.value); data.DewPoint = temperatureConverter(observations.dewpoint.value);
data.Ceiling = Math.round(observations.cloudLayers[0]?.base?.value ?? 0); data.Ceiling = metersConverter(observations.cloudLayers[0]?.base?.value ?? 0);
data.CeilingUnit = 'm.'; data.CeilingUnit = metersConverter.units;
data.Visibility = Math.round(observations.visibility.value / 1000); data.Visibility = kilometersConverter(observations.visibility.value);
data.VisibilityUnit = ' km.'; data.VisibilityUnit = kilometersConverter.units;
data.WindSpeed = Math.round(observations.windSpeed.value); data.Pressure = pressureConverter(observations.barometricPressure.value);
data.PressureUnit = pressureConverter.units;
data.HeatIndex = temperatureConverter(observations.heatIndex.value);
data.WindChill = temperatureConverter(observations.windChill.value);
data.WindSpeed = windConverter(observations.windSpeed.value);
data.WindDirection = directionToNSEW(observations.windDirection.value); data.WindDirection = directionToNSEW(observations.windDirection.value);
data.Pressure = Math.round(observations.barometricPressure.value); data.WindGust = windConverter(observations.windGust.value);
data.HeatIndex = Math.round(observations.heatIndex.value); data.WindSpeed = windConverter(data.WindSpeed);
data.WindChill = Math.round(observations.windChill.value); data.WindUnit = windConverter.units;
data.WindGust = Math.round(observations.windGust.value);
data.WindUnit = 'KPH';
data.Humidity = Math.round(observations.relativeHumidity.value); data.Humidity = Math.round(observations.relativeHumidity.value);
data.Icon = getWeatherIconFromIconLink(observations.icon); data.Icon = getWeatherIconFromIconLink(observations.icon);
data.PressureDirection = ''; data.PressureDirection = '';
@ -186,20 +198,6 @@ const parseData = (data) => {
if (pressureDiff > 150) data.PressureDirection = 'R'; if (pressureDiff > 150) data.PressureDirection = 'R';
if (pressureDiff < -150) data.PressureDirection = 'F'; if (pressureDiff < -150) data.PressureDirection = 'F';
// convert to us units
data.Temperature = celsiusToFahrenheit(data.Temperature);
data.TemperatureUnit = 'F';
data.DewPoint = celsiusToFahrenheit(data.DewPoint);
data.Ceiling = Math.round(metersToFeet(data.Ceiling) / 100) * 100;
data.CeilingUnit = 'ft.';
data.Visibility = kilometersToMiles(observations.visibility.value / 1000);
data.VisibilityUnit = ' mi.';
data.WindSpeed = kphToMph(data.WindSpeed);
data.WindUnit = 'MPH';
data.Pressure = pascalToInHg(data.Pressure).toFixed(2);
data.HeatIndex = celsiusToFahrenheit(data.HeatIndex);
data.WindChill = celsiusToFahrenheit(data.WindChill);
data.WindGust = kphToMph(data.WindGust);
return data; return data;
}; };

View file

@ -71,7 +71,7 @@ const screens = [
(data) => `Humidity: ${data.Humidity}% Dewpoint: ${data.DewPoint}${degree}${data.TemperatureUnit}`, (data) => `Humidity: ${data.Humidity}% Dewpoint: ${data.DewPoint}${degree}${data.TemperatureUnit}`,
// barometric pressure // barometric pressure
(data) => `Barometric Pressure: ${data.Pressure} ${data.PressureDirection}`, (data) => `Barometric Pressure: ${data.Pressure} ${data.PressureUnit} ${data.PressureDirection}`,
// wind // wind
(data) => { (data) => {

View file

@ -8,6 +8,7 @@ import { getWeatherIconFromIconLink } from './icons.mjs';
import { preloadImg } from './utils/image.mjs'; import { preloadImg } from './utils/image.mjs';
import WeatherDisplay from './weatherdisplay.mjs'; import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs'; import { registerDisplay } from './navigation.mjs';
import settings from './settings.mjs';
class ExtendedForecast extends WeatherDisplay { class ExtendedForecast extends WeatherDisplay {
constructor(navId, elemId) { constructor(navId, elemId) {
@ -17,16 +18,14 @@ class ExtendedForecast extends WeatherDisplay {
this.timing.totalScreens = 2; this.timing.totalScreens = 2;
} }
async getData(_weatherParameters) { async getData(weatherParameters, refresh) {
if (!super.getData(_weatherParameters)) return; if (!super.getData(weatherParameters, refresh)) return;
const weatherParameters = _weatherParameters ?? this.weatherParameters;
// request us or si units // request us or si units
let forecast;
try { try {
forecast = await json(weatherParameters.forecast, { this.data = await json(this.weatherParameters.forecast, {
data: { data: {
units: 'us', units: settings.units.value,
}, },
retryCount: 3, retryCount: 3,
stillWaiting: () => this.stillWaiting(), stillWaiting: () => this.stillWaiting(),
@ -34,11 +33,13 @@ class ExtendedForecast extends WeatherDisplay {
} catch (error) { } catch (error) {
console.error('Unable to get extended forecast'); console.error('Unable to get extended forecast');
console.error(error.status, error.responseJSON); console.error(error.status, error.responseJSON);
// if there's no previous data, fail
if (!this.data) {
this.setStatus(STATUS.failed); this.setStatus(STATUS.failed);
return; return;
} }
}
// we only get here if there was no error above // we only get here if there was no error above
this.data = parse(forecast.properties.periods);
this.screenIndex = 0; this.screenIndex = 0;
this.setStatus(STATUS.loaded); this.setStatus(STATUS.loaded);
} }
@ -48,7 +49,7 @@ class ExtendedForecast extends WeatherDisplay {
// determine bounds // determine bounds
// grab the first three or second set of three array elements // grab the first three or second set of three array elements
const forecast = this.data.slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3); const forecast = parse(this.data.properties.periods).slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3);
// create each day template // create each day template
const days = forecast.map((Day) => { const days = forecast.map((Day) => {

View file

@ -26,9 +26,11 @@ class Hazards extends WeatherDisplay {
this.timing.totalScreens = 0; this.timing.totalScreens = 0;
} }
async getData(weatherParameters) { async getData(weatherParameters, refresh) {
// super checks for enabled // super checks for enabled
const superResult = super.getData(weatherParameters); const superResult = super.getData(weatherParameters, refresh);
// hazards performs a silent refresh, but does not fall back to a previous fetch if no data is available
// this is intentional to ensure the latest alerts only are displayed.
const alert = this.checkbox.querySelector('.alert'); const alert = this.checkbox.querySelector('.alert');
alert.classList.remove('show'); alert.classList.remove('show');
@ -122,7 +124,7 @@ class Hazards extends WeatherDisplay {
// base count change callback // base count change callback
baseCountChange(count) { baseCountChange(count) {
// calculate scroll offset and don't go past end // calculate scroll offset and don't go past end
let offsetY = Math.min(this.elem.querySelector('.hazard-lines').getBoundingClientRect().height - 390, (count - 150)); let offsetY = Math.min(this.elem.querySelector('.hazard-lines').offsetHeight - 390, (count - 150));
// don't let offset go negative // don't let offset go negative
if (offsetY < 0) offsetY = 0; if (offsetY < 0) offsetY = 0;

View file

@ -3,7 +3,7 @@
import STATUS from './status.mjs'; import STATUS from './status.mjs';
import getHourlyData from './hourly.mjs'; import getHourlyData from './hourly.mjs';
import WeatherDisplay from './weatherdisplay.mjs'; import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs'; import { registerDisplay, timeZone } from './navigation.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs'; import { DateTime } from '../vendor/auto/luxon.mjs';
class HourlyGraph extends WeatherDisplay { class HourlyGraph extends WeatherDisplay {
@ -23,8 +23,8 @@ class HourlyGraph extends WeatherDisplay {
this.elem.querySelector('.header .right').append(header); this.elem.querySelector('.header .right').append(header);
} }
async getData() { async getData(weatherParameters, refresh) {
if (!super.getData()) return; if (!super.getData(undefined, refresh)) return;
const data = await getHourlyData(() => this.stillWaiting()); const data = await getHourlyData(() => this.stillWaiting());
if (data === undefined) { if (data === undefined) {
@ -38,7 +38,7 @@ class HourlyGraph extends WeatherDisplay {
const skyCover = data.map((d) => d.skyCover); const skyCover = data.map((d) => d.skyCover);
this.data = { this.data = {
skyCover, temperature, probabilityOfPrecipitation, skyCover, temperature, probabilityOfPrecipitation, temperatureUnit: data[0].temperatureUnit,
}; };
this.setStatus(STATUS.loaded); this.setStatus(STATUS.loaded);
@ -107,6 +107,9 @@ class HourlyGraph extends WeatherDisplay {
// set the image source // set the image source
this.image.src = canvas.toDataURL(); this.image.src = canvas.toDataURL();
// change the units in the header
this.elem.querySelector('.temperature').innerHTML = `Temperature ${String.fromCharCode(176)}${this.data.temperatureUnit}`;
super.drawCanvas(); super.drawCanvas();
this.finishDraw(); this.finishDraw();
} }
@ -142,7 +145,7 @@ const drawPath = (path, ctx, options) => {
}; };
// format as 1p, 12a, etc. // format as 1p, 12a, etc.
const formatTime = (time) => time.toFormat('ha').slice(0, -1); const formatTime = (time) => time.setZone(timeZone()).toFormat('ha').slice(0, -1);
// register display // register display
registerDisplay(new HourlyGraph(4, 'hourly-graph')); registerDisplay(new HourlyGraph(4, 'hourly-graph'));

View file

@ -3,11 +3,11 @@
import STATUS from './status.mjs'; import STATUS from './status.mjs';
import { DateTime, Interval, Duration } from '../vendor/auto/luxon.mjs'; import { DateTime, Interval, Duration } from '../vendor/auto/luxon.mjs';
import { json } from './utils/fetch.mjs'; import { json } from './utils/fetch.mjs';
import { celsiusToFahrenheit, kilometersToMiles } from './utils/units.mjs'; import { temperature as temperatureUnit, distanceKilometers } from './utils/units.mjs';
import { getHourlyIcon } from './icons.mjs'; import { getHourlyIcon } from './icons.mjs';
import { directionToNSEW } from './utils/calc.mjs'; import { directionToNSEW } from './utils/calc.mjs';
import WeatherDisplay from './weatherdisplay.mjs'; import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs'; import { registerDisplay, timeZone } from './navigation.mjs';
import getSun from './almanac.mjs'; import getSun from './almanac.mjs';
class Hourly extends WeatherDisplay { class Hourly extends WeatherDisplay {
@ -27,23 +27,30 @@ class Hourly extends WeatherDisplay {
this.timing.delay.push(150); this.timing.delay.push(150);
} }
async getData(weatherParameters) { async getData(weatherParameters, refresh) {
// super checks for enabled // super checks for enabled
const superResponse = super.getData(weatherParameters); const superResponse = super.getData(weatherParameters, refresh);
let forecast; let forecast;
try { try {
// get the forecast // get the forecast
forecast = await json(weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() }); forecast = await json(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
// parse the forecast
this.data = await parseForecast(forecast.properties);
} catch (error) { } catch (error) {
console.error('Get hourly forecast failed'); console.error('Get hourly forecast failed');
console.error(error.status, error.responseJSON); console.error(error.status, error.responseJSON);
// use old data if available
if (this.data) {
console.log('Using previous hourly forecast');
// don't return, this.data is usable from the previous update
} else {
if (this.isEnabled) this.setStatus(STATUS.failed); if (this.isEnabled) this.setStatus(STATUS.failed);
// return undefined to other subscribers // return undefined to other subscribers
this.getDataCallback(undefined); this.getDataCallback(undefined);
return; return;
} }
}
this.data = await parseForecast(forecast.properties);
this.getDataCallback(); this.getDataCallback();
if (!superResponse) return; if (!superResponse) return;
@ -56,7 +63,7 @@ class Hourly extends WeatherDisplay {
const list = this.elem.querySelector('.hourly-lines'); const list = this.elem.querySelector('.hourly-lines');
list.innerHTML = ''; list.innerHTML = '';
const startingHour = DateTime.local(); const startingHour = DateTime.local().setZone(timeZone());
const lines = this.data.map((data, index) => { const lines = this.data.map((data, index) => {
const fillValues = {}; const fillValues = {};
@ -66,12 +73,12 @@ class Hourly extends WeatherDisplay {
fillValues.hour = formattedHour; fillValues.hour = formattedHour;
// temperatures, convert to strings with no decimal // temperatures, convert to strings with no decimal
const temperature = Math.round(data.temperature).toString().padStart(3); const temperature = data.temperature.toString().padStart(3);
const feelsLike = Math.round(data.apparentTemperature).toString().padStart(3); const feelsLike = data.apparentTemperature.toString().padStart(3);
fillValues.temp = temperature; fillValues.temp = temperature;
// only plot apparent temperature if there is a difference
// if (temperature !== feelsLike) line.querySelector('.like').innerHTML = feelsLike; // apparent temperature is color coded if different from actual temperature (after fill is applied)
if (temperature !== feelsLike) fillValues.like = feelsLike; fillValues.like = feelsLike;
// wind // wind
let wind = 'Calm'; let wind = 'Calm';
@ -84,7 +91,17 @@ class Hourly extends WeatherDisplay {
// image // image
fillValues.icon = { type: 'img', src: data.icon }; fillValues.icon = { type: 'img', src: data.icon };
return this.fillTemplate('hourly-row', fillValues); const filledRow = this.fillTemplate('hourly-row', fillValues);
// 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) {
filledRow.querySelector('.like').classList.add('heat-index');
}
return filledRow;
}); });
list.append(...lines); list.append(...lines);
@ -109,7 +126,7 @@ class Hourly extends WeatherDisplay {
// base count change callback // base count change callback
baseCountChange(count) { baseCountChange(count) {
// calculate scroll offset and don't go past end // calculate scroll offset and don't go past end
let offsetY = Math.min(this.elem.querySelector('.hourly-lines').getBoundingClientRect().height - 289, (count - 150)); let offsetY = Math.min(this.elem.querySelector('.hourly-lines').offsetHeight - 289, (count - 150));
// don't let offset go negative // don't let offset go negative
if (offsetY < 0) offsetY = 0; if (offsetY < 0) offsetY = 0;
@ -122,6 +139,8 @@ class Hourly extends WeatherDisplay {
// promise allows for data to be requested before it is available // promise allows for data to be requested before it is available
async getCurrentData(stillWaiting) { async getCurrentData(stillWaiting) {
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting); if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
// an external caller has requested data, set up auto reload
this.setAutoReload();
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.data) resolve(this.data); if (this.data) resolve(this.data);
// data not available, put it into the data callback queue // data not available, put it into the data callback queue
@ -132,6 +151,11 @@ class Hourly extends WeatherDisplay {
// extract specific values from forecast and format as an array // extract specific values from forecast and format as an array
const parseForecast = async (data) => { const parseForecast = async (data) => {
// get unit converters
const temperatureConverter = temperatureUnit();
const distanceConverter = distanceKilometers();
// parse data
const temperature = expand(data.temperature.values); const temperature = expand(data.temperature.values);
const apparentTemperature = expand(data.apparentTemperature.values); const apparentTemperature = expand(data.apparentTemperature.values);
const windSpeed = expand(data.windSpeed.values); const windSpeed = expand(data.windSpeed.values);
@ -145,9 +169,11 @@ const parseForecast = async (data) => {
const icons = await determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed); const icons = await determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed);
return temperature.map((val, idx) => ({ return temperature.map((val, idx) => ({
temperature: celsiusToFahrenheit(temperature[idx]), temperature: temperatureConverter(temperature[idx]),
apparentTemperature: celsiusToFahrenheit(apparentTemperature[idx]), temperatureUnit: temperatureConverter.units,
windSpeed: kilometersToMiles(windSpeed[idx]), apparentTemperature: temperatureConverter(apparentTemperature[idx]),
windSpeed: distanceConverter(windSpeed[idx]),
windUnit: distanceConverter.units,
windDirection: directionToNSEW(windDirection[idx]), windDirection: directionToNSEW(windDirection[idx]),
probabilityOfPrecipitation: probabilityOfPrecipitation[idx], probabilityOfPrecipitation: probabilityOfPrecipitation[idx],
skyCover: skyCover[idx], skyCover: skyCover[idx],

View file

@ -3,9 +3,10 @@ import { distance as calcDistance, directionToNSEW } from './utils/calc.mjs';
import { json } from './utils/fetch.mjs'; import { json } from './utils/fetch.mjs';
import STATUS from './status.mjs'; import STATUS from './status.mjs';
import { locationCleanup } from './utils/string.mjs'; import { locationCleanup } from './utils/string.mjs';
import { celsiusToFahrenheit, kphToMph } from './utils/units.mjs'; import { temperature, windSpeed } from './utils/units.mjs';
import WeatherDisplay from './weatherdisplay.mjs'; import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs'; import { registerDisplay } from './navigation.mjs';
import settings from './settings.mjs';
class LatestObservations extends WeatherDisplay { class LatestObservations extends WeatherDisplay {
constructor(navId, elemId) { constructor(navId, elemId) {
@ -15,14 +16,15 @@ class LatestObservations extends WeatherDisplay {
this.MaximumRegionalStations = 7; this.MaximumRegionalStations = 7;
} }
async getData(_weatherParameters) { async getData(weatherParameters, refresh) {
if (!super.getData(_weatherParameters)) return; if (!super.getData(weatherParameters, refresh)) return;
const weatherParameters = _weatherParameters ?? this.weatherParameters; // latest observations does a silent refresh but will not fall back to previously fetched data
// 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.keys(StationInfo).map((key) => {
const station = StationInfo[key]; const station = StationInfo[key];
const distance = calcDistance(station.lat, station.lon, weatherParameters.latitude, weatherParameters.longitude); const distance = calcDistance(station.lat, station.lon, this.weatherParameters.latitude, this.weatherParameters.longitude);
return { ...station, distance }; return { ...station, distance };
}); });
@ -64,14 +66,22 @@ class LatestObservations extends WeatherDisplay {
// sort array by station name // sort array by station name
const sortedConditions = conditions.sort((a, b) => ((a.Name < b.Name) ? -1 : 1)); const sortedConditions = conditions.sort((a, b) => ((a.Name < b.Name) ? -1 : 1));
if (settings.units.value === 'us') {
this.elem.querySelector('.column-headers .temp.english').classList.add('show'); this.elem.querySelector('.column-headers .temp.english').classList.add('show');
this.elem.querySelector('.column-headers .temp.metric').classList.remove('show'); this.elem.querySelector('.column-headers .temp.metric').classList.remove('show');
} else {
this.elem.querySelector('.column-headers .temp.english').classList.remove('show');
this.elem.querySelector('.column-headers .temp.metric').classList.add('show');
}
// get unit converters
const windConverter = windSpeed();
const temperatureConverter = temperature();
const lines = sortedConditions.map((condition) => { const lines = sortedConditions.map((condition) => {
const windDirection = directionToNSEW(condition.windDirection.value); const windDirection = directionToNSEW(condition.windDirection.value);
const Temperature = Math.round(celsiusToFahrenheit(condition.temperature.value)); const Temperature = temperatureConverter(condition.temperature.value);
const WindSpeed = Math.round(kphToMph(condition.windSpeed.value)); const WindSpeed = windConverter(condition.windSpeed.value);
const fill = { const fill = {
location: locationCleanup(condition.city).substr(0, 14), location: locationCleanup(condition.city).substr(0, 14),
@ -94,6 +104,8 @@ class LatestObservations extends WeatherDisplay {
linesContainer.innerHTML = ''; linesContainer.innerHTML = '';
linesContainer.append(...lines); linesContainer.append(...lines);
// update temperature unit header
this.finishDraw(); this.finishDraw();
} }
} }

View file

@ -4,6 +4,7 @@ import STATUS from './status.mjs';
import { json } from './utils/fetch.mjs'; import { json } from './utils/fetch.mjs';
import WeatherDisplay from './weatherdisplay.mjs'; import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs'; import { registerDisplay } from './navigation.mjs';
import settings from './settings.mjs';
class LocalForecast extends WeatherDisplay { class LocalForecast extends WeatherDisplay {
constructor(navId, elemId) { constructor(navId, elemId) {
@ -13,19 +14,21 @@ class LocalForecast extends WeatherDisplay {
this.timing.baseDelay = 5000; this.timing.baseDelay = 5000;
} }
async getData(_weatherParameters) { async getData(weatherParameters, refresh) {
if (!super.getData(_weatherParameters)) return; if (!super.getData(weatherParameters, refresh)) return;
const weatherParameters = _weatherParameters ?? this.weatherParameters;
// get raw data // get raw data
const rawData = await this.getRawData(weatherParameters); const rawData = await this.getRawData(this.weatherParameters);
// check for data // check for data, or if there's old data available
if (!rawData) { if (!rawData && !this.data) {
// fail for no old or new data
this.setStatus(STATUS.failed); this.setStatus(STATUS.failed);
return; return;
} }
// store the data
this.data = rawData || this.data;
// parse raw data // parse raw data
const conditions = parse(rawData); const conditions = parse(this.data);
// read each text // read each text
this.screenTexts = conditions.map((condition) => { this.screenTexts = conditions.map((condition) => {
@ -61,7 +64,7 @@ class LocalForecast extends WeatherDisplay {
try { try {
return await json(weatherParameters.forecast, { return await json(weatherParameters.forecast, {
data: { data: {
units: 'us', units: settings.units.value,
}, },
retryCount: 3, retryCount: 3,
stillWaiting: () => this.stillWaiting(), stillWaiting: () => this.stillWaiting(),
@ -69,7 +72,6 @@ class LocalForecast extends WeatherDisplay {
} catch (error) { } catch (error) {
console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`); console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`);
console.error(error.status, error.responseJSON); console.error(error.status, error.responseJSON);
this.setStatus(STATUS.failed);
return false; return false;
} }
} }

View file

@ -0,0 +1,168 @@
import { json } from './utils/fetch.mjs';
import Setting from './utils/setting.mjs';
let playlist;
let currentTrack = 0;
let player;
const mediaPlaying = new Setting('mediaPlaying', {
name: 'Media Playing',
type: 'boolean',
defaultValue: false,
sticky: true,
});
document.addEventListener('DOMContentLoaded', () => {
// add the event handler to the page
document.getElementById('ToggleMedia').addEventListener('click', toggleMedia);
// get the playlist
getMedia();
});
const getMedia = async () => {
try {
// fetch the playlist
const rawPlaylist = await json('playlist.json');
// store the playlist
playlist = rawPlaylist;
// enable the media player
enableMediaPlayer();
} catch (e) {
console.error("Couldn't get playlist");
console.error(e);
}
};
const enableMediaPlayer = () => {
// see if files are available
if (playlist?.availableFiles?.length > 0) {
// randomize the list
randomizePlaylist();
// enable the icon
const icon = document.getElementById('ToggleMedia');
icon.classList.add('available');
// set the button type
setIcon();
// if we're already playing (sticky option) then try to start playing
if (mediaPlaying.value === true) {
startMedia();
}
}
};
const setIcon = () => {
// get the icon
const icon = document.getElementById('ToggleMedia');
if (mediaPlaying.value === true) {
icon.classList.add('playing');
} else {
icon.classList.remove('playing');
}
};
const toggleMedia = (forcedState) => {
// handle forcing
if (typeof forcedState === 'boolean') {
mediaPlaying.value = forcedState;
} else {
// toggle the state
mediaPlaying.value = !mediaPlaying.value;
}
// handle the state change
stateChanged();
};
const startMedia = async () => {
// if there's not media player yet, enable it
if (!player) {
initializePlayer();
} else {
try {
await player.play();
} catch (e) {
// report the error
console.error('Couldn\'t play music');
console.error(e);
// set state back to not playing for good UI experience
mediaPlaying.value = false;
stateChanged();
}
}
};
const stopMedia = () => {
if (!player) return;
player.pause();
};
const stateChanged = () => {
// update the icon
setIcon();
// react to the new state
if (mediaPlaying.value) {
startMedia();
} else {
stopMedia();
}
};
const randomizePlaylist = () => {
let availableFiles = [...playlist.availableFiles];
const randomPlaylist = [];
while (availableFiles.length > 0) {
// get a randon item from the available files
const i = Math.floor(Math.random() * availableFiles.length);
// add it to the final list
randomPlaylist.push(availableFiles[i]);
// remove the file from the available files
availableFiles = availableFiles.filter((file, index) => index !== i);
}
playlist.availableFiles = randomPlaylist;
};
const initializePlayer = () => {
// basic sanity checks
if (!playlist.availableFiles || playlist?.availableFiles.length === 0) {
throw new Error('No playlist available');
}
if (player) {
return;
}
// create the player
player = new Audio();
// reset the playlist index
currentTrack = 0;
// add event handlers
player.addEventListener('canplay', playerCanPlay);
player.addEventListener('ended', playerEnded);
// get the first file
player.src = `music/${playlist.availableFiles[currentTrack]}`;
player.type = 'audio/mpeg';
};
const playerCanPlay = async () => {
// check to make sure they user still wants music (protect against slow loading music)
if (!mediaPlaying.value) return;
// start playing
startMedia();
};
const playerEnded = () => {
// next track
currentTrack += 1;
// roll over and re-randomize the tracks
if (currentTrack >= playlist.availableFiles.length) {
randomizePlaylist();
currentTrack = 0;
}
// update the player source
player.src = `music/${playlist.availableFiles[currentTrack]}`;
};
export {
// eslint-disable-next-line import/prefer-default-export
toggleMedia,
};

View file

@ -15,26 +15,11 @@ let playing = false;
let progress; let progress;
const weatherParameters = {}; const weatherParameters = {};
// auto refresh
const AUTO_REFRESH_INTERVAL_MS = 500;
const AUTO_REFRESH_TIME_MS = 600_000; // 10 min.
const CHK_AUTO_REFRESH_SELECTOR = '#chkAutoRefresh';
let AutoRefreshIntervalId = null;
let AutoRefreshCountMs = 0;
const init = async () => { const init = async () => {
// set up resize handler // set up resize handler
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
resize(); resize();
// auto refresh
const autoRefresh = localStorage.getItem('autoRefresh');
if (!autoRefresh || autoRefresh === 'true') {
document.querySelector(CHK_AUTO_REFRESH_SELECTOR).checked = true;
} else {
document.querySelector(CHK_AUTO_REFRESH_SELECTOR).checked = false;
}
document.querySelector(CHK_AUTO_REFRESH_SELECTOR).addEventListener('change', autoRefreshChange);
generateCheckboxes(); generateCheckboxes();
}; };
@ -123,12 +108,6 @@ const updateStatus = (value) => {
if (isPlaying() && value.id === firstDisplayIndex && value.status === STATUS.loaded) { if (isPlaying() && value.id === firstDisplayIndex && value.status === STATUS.loaded) {
navTo(msg.command.firstFrame); navTo(msg.command.firstFrame);
} }
// send loaded messaged to parent
if (countLoadedDisplays() < displays.length) return;
// everything loaded, set timestamps
AssignLastUpdate(new Date());
}; };
// note: a display that is "still waiting"/"retrying" is considered loaded intentionally // note: a display that is "still waiting"/"retrying" is considered loaded intentionally
@ -202,8 +181,6 @@ const loadDisplay = (direction) => {
idx = wrap(curIdx + (i + 1) * direction, totalDisplays); idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) break; if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) break;
} }
// if new display index is less than current display a wrap occurred, test for reload timeout
if (idx <= curIdx && refreshCheck()) return;
const newDisplay = displays[idx]; const newDisplay = displays[idx];
// hide all displays // hide all displays
hideAllCanvases(); hideAllCanvases();
@ -320,83 +297,8 @@ const populateWeatherParameters = (params) => {
document.querySelector('#spanZoneId').innerHTML = params.zoneId; document.querySelector('#spanZoneId').innerHTML = params.zoneId;
}; };
const autoRefreshChange = (e) => {
const { checked } = e.target;
if (checked) {
startAutoRefreshTimer();
} else {
stopAutoRefreshTimer();
}
localStorage.setItem('autoRefresh', checked);
};
const AssignLastUpdate = (date) => {
if (date) {
document.querySelector('#spanLastRefresh').innerHTML = date.toLocaleString('en-US', {
weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'short',
});
if (document.querySelector(CHK_AUTO_REFRESH_SELECTOR).checked) startAutoRefreshTimer();
} else {
document.querySelector('#spanLastRefresh').innerHTML = '(none)';
}
};
const latLonReceived = (data, haveDataCallback) => { const latLonReceived = (data, haveDataCallback) => {
getWeather(data, haveDataCallback); getWeather(data, haveDataCallback);
AssignLastUpdate(null);
};
const startAutoRefreshTimer = () => {
// Ensure that any previous timer has already stopped.
// check if timer is running
if (AutoRefreshIntervalId) return;
// Reset the time elapsed.
AutoRefreshCountMs = 0;
const AutoRefreshTimer = () => {
// Increment the total time elapsed.
AutoRefreshCountMs += AUTO_REFRESH_INTERVAL_MS;
// Display the count down.
let RemainingMs = (AUTO_REFRESH_TIME_MS - AutoRefreshCountMs);
if (RemainingMs < 0) {
RemainingMs = 0;
}
const dt = new Date(RemainingMs);
document.querySelector('#spanRefreshCountDown').innerHTML = `${dt.getMinutes().toString().padStart(2, '0')}:${dt.getSeconds().toString().padStart(2, '0')}`;
// Time has elapsed.
if (AutoRefreshCountMs >= AUTO_REFRESH_TIME_MS && !isPlaying()) loadTwcData();
};
AutoRefreshIntervalId = window.setInterval(AutoRefreshTimer, AUTO_REFRESH_INTERVAL_MS);
AutoRefreshTimer();
};
const stopAutoRefreshTimer = () => {
if (AutoRefreshIntervalId) {
window.clearInterval(AutoRefreshIntervalId);
document.querySelector('#spanRefreshCountDown').innerHTML = '--:--';
AutoRefreshIntervalId = null;
}
};
const refreshCheck = () => {
// Time has elapsed.
if (AutoRefreshCountMs >= AUTO_REFRESH_TIME_MS && isPlaying()) {
loadTwcData();
return true;
}
return false;
};
const loadTwcData = () => {
if (loadTwcData.callback) loadTwcData.callback();
};
const registerRefreshData = (callback) => {
loadTwcData.callback = callback;
}; };
const timeZone = () => weatherParameters.timeZone; const timeZone = () => weatherParameters.timeZone;
@ -414,7 +316,5 @@ export {
msg, msg,
message, message,
latLonReceived, latLonReceived,
stopAutoRefreshTimer,
registerRefreshData,
timeZone, timeZone,
}; };

View file

@ -42,19 +42,17 @@ class Radar extends WeatherDisplay {
]; ];
} }
async getData(_weatherParameters) { async getData(weatherParameters, refresh) {
if (!super.getData(_weatherParameters)) return; if (!super.getData(weatherParameters, refresh)) return;
const weatherParameters = _weatherParameters ?? this.weatherParameters;
// ALASKA AND HAWAII AREN'T SUPPORTED! // ALASKA AND HAWAII AREN'T SUPPORTED!
if (weatherParameters.state === 'AK' || weatherParameters.state === 'HI') { if (this.weatherParameters.state === 'AK' || this.weatherParameters.state === 'HI') {
this.setStatus(STATUS.noData); this.setStatus(STATUS.noData);
return; return;
} }
// get the base map // get the base map
let src = 'images/4000RadarMap2.jpg'; const src = 'images/4000RadarMap2.jpg';
if (weatherParameters.State === 'HI') src = 'images/HawaiiRadarMap2.png';
this.baseMap = await loadImg(src); this.baseMap = await loadImg(src);
const baseUrl = 'https://mesonet.agron.iastate.edu/archive/data/'; const baseUrl = 'https://mesonet.agron.iastate.edu/archive/data/';
@ -110,19 +108,12 @@ class Radar extends WeatherDisplay {
const height = 1600; const height = 1600;
offsetX *= 2; offsetX *= 2;
offsetY *= 2; offsetY *= 2;
const sourceXY = utils.getXYFromLatitudeLongitudeMap(weatherParameters, offsetX, offsetY); const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY);
// create working context for manipulation
const workingCanvas = document.createElement('canvas');
workingCanvas.width = width;
workingCanvas.height = height;
const workingContext = workingCanvas.getContext('2d');
workingContext.imageSmoothingEnabled = false;
// calculate radar offsets // calculate radar offsets
const radarOffsetX = 120; const radarOffsetX = 120;
const radarOffsetY = 70; const radarOffsetY = 70;
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(weatherParameters, offsetX, offsetY); const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY);
const radarSourceX = radarSourceXY.x / 2; const radarSourceX = radarSourceXY.x / 2;
const radarSourceY = radarSourceXY.y / 2; const radarSourceY = radarSourceXY.y / 2;
@ -135,6 +126,13 @@ class Radar extends WeatherDisplay {
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false; 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 // get the image
const response = await fetch(rewriteUrl(url)); const response = await fetch(rewriteUrl(url));
@ -170,7 +168,7 @@ class Radar extends WeatherDisplay {
workingContext.drawImage(imgBlob, 0, 0, width, 1600); workingContext.drawImage(imgBlob, 0, 0, width, 1600);
// get the base map // get the base map
context.drawImage(await this.baseMap, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, 640, 367); context.drawImage(this.baseMap, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, 640, 367);
// crop the radar image // crop the radar image
const cropCanvas = document.createElement('canvas'); const cropCanvas = document.createElement('canvas');

View file

@ -1,16 +1,21 @@
import { getWeatherRegionalIconFromIconLink } from './icons.mjs'; import { getWeatherRegionalIconFromIconLink } from './icons.mjs';
import { preloadImg } from './utils/image.mjs'; import { preloadImg } from './utils/image.mjs';
import { json } from './utils/fetch.mjs'; import { json } from './utils/fetch.mjs';
import { temperature as temperatureUnit } from './utils/units.mjs';
const buildForecast = (forecast, city, cityXY) => ({ const buildForecast = (forecast, city, cityXY) => {
// get a unit converter
const temperatureConverter = temperatureUnit('us');
return {
daytime: forecast.isDaytime, daytime: forecast.isDaytime,
temperature: forecast.temperature || 0, temperature: temperatureConverter(forecast.temperature || 0),
name: formatCity(city.city), name: formatCity(city.city),
icon: forecast.icon, icon: forecast.icon,
x: cityXY.x, x: cityXY.x,
y: cityXY.y, y: cityXY.y,
time: forecast.startTime, time: forecast.startTime,
}); };
};
const getRegionalObservation = async (point, city) => { const getRegionalObservation = async (point, city) => {
try { try {

View file

@ -4,7 +4,7 @@
import STATUS from './status.mjs'; import STATUS from './status.mjs';
import { distance as calcDistance } from './utils/calc.mjs'; import { distance as calcDistance } from './utils/calc.mjs';
import { json } from './utils/fetch.mjs'; import { json } from './utils/fetch.mjs';
import { celsiusToFahrenheit } from './utils/units.mjs'; import { temperature as temperatureUnit } from './utils/units.mjs';
import { getWeatherRegionalIconFromIconLink } from './icons.mjs'; import { getWeatherRegionalIconFromIconLink } from './icons.mjs';
import { 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';
@ -21,9 +21,11 @@ class RegionalForecast extends WeatherDisplay {
this.timing.totalScreens = 3; this.timing.totalScreens = 3;
} }
async getData(_weatherParameters) { async getData(weatherParameters, refresh) {
if (!super.getData(_weatherParameters)) return; if (!super.getData(weatherParameters, refresh)) return;
const weatherParameters = _weatherParameters ?? this.weatherParameters; // regional forecast implements a silent reload
// but it will not fall back to previously loaded data if data can not be loaded
// there are enough other cities available to populate the map sufficiently even if some do not load
// pre-load the base map // pre-load the base map
let baseMap = 'images/Basemap2.png'; let baseMap = 'images/Basemap2.png';
@ -40,14 +42,14 @@ class RegionalForecast extends WeatherDisplay {
y: 117, y: 117,
}; };
// get user's location in x/y // get user's location in x/y
const sourceXY = utils.getXYFromLatitudeLongitude(weatherParameters.latitude, weatherParameters.longitude, offsetXY.x, offsetXY.y, weatherParameters.state); const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, offsetXY.x, offsetXY.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, weatherParameters.state); const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, offsetXY.x, offsetXY.y, this.weatherParameters.state);
// get a target distance // get a target distance
let targetDistance = 2.5; let targetDistance = 2.5;
if (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((value) => ({ ...value, targetDistance }));
@ -71,6 +73,9 @@ class RegionalForecast extends WeatherDisplay {
} }
}); });
// get a unit converter
const temperatureConverter = temperatureUnit();
// 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 {
@ -83,7 +88,7 @@ class RegionalForecast extends WeatherDisplay {
const forecast = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/forecast`); const forecast = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/forecast`);
// get XY on map for city // get XY on map for city
const cityXY = utils.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, weatherParameters.state); const cityXY = utils.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, this.weatherParameters.state);
// wait for the regional observation if it's not done yet // wait for the regional observation if it's not done yet
const observation = await observationPromise; const observation = await observationPromise;
@ -93,7 +98,7 @@ class RegionalForecast extends WeatherDisplay {
// format the observation the same as the forecast // format the observation the same as the forecast
const regionalObservation = { const regionalObservation = {
daytime: !!/\/day\//.test(observation.icon), daytime: !!/\/day\//.test(observation.icon),
temperature: celsiusToFahrenheit(observation.temperature.value), temperature: temperatureConverter(observation.temperature.value),
name: utils.formatCity(city.city), name: utils.formatCity(city.city),
icon: observation.icon, icon: observation.icon,
x: cityXY.x, x: cityXY.x,

View file

@ -8,16 +8,54 @@ document.addEventListener('DOMContentLoaded', () => {
const settings = { speed: { value: 1.0 } }; const settings = { speed: { value: 1.0 } };
const init = () => { const init = () => {
// create settings // create settings see setting.mjs for defaults
settings.wide = new Setting('wide', 'Widescreen', 'boolean', false, wideScreenChange, true); settings.wide = new Setting('wide', {
settings.kiosk = new Setting('kiosk', 'Kiosk', 'boolean', false, kioskChange, false); name: 'Widescreen',
settings.speed = new Setting('speed', 'Speed', 'select', 1.0, null, true, [ defaultValue: false,
changeAction: wideScreenChange,
sticky: true,
});
settings.kiosk = new Setting('kiosk', {
name: 'Kiosk',
defaultValue: false,
changeAction: kioskChange,
sticky: false,
});
settings.speed = new Setting('speed', {
name: 'Speed',
type: 'select',
defaultValue: 1.0,
values: [
[0.5, 'Very Fast'], [0.5, 'Very Fast'],
[0.75, 'Fast'], [0.75, 'Fast'],
[1.0, 'Normal'], [1.0, 'Normal'],
[1.25, 'Slow'], [1.25, 'Slow'],
[1.5, 'Very Slow'], [1.5, 'Very Slow'],
]); ],
});
settings.units = new Setting('units', {
name: 'Units',
type: 'select',
defaultValue: 'us',
changeAction: unitChange,
values: [
['us', 'US'],
['si', 'Metric'],
],
});
settings.refreshTime = new Setting('refreshTime', {
type: 'select',
defaultValue: 600_000,
sticky: false,
values: [
[30_000, 'TESTING'],
[300_000, '5 minutes'],
[600_000, '10 minutes'],
[900_000, '15 minutes'],
[1_800_000, '30 minutes'],
],
visible: false,
});
// generate html objects // generate html objects
const settingHtml = Object.values(settings).map((d) => d.generate()); const settingHtml = Object.values(settings).map((d) => d.generate());
@ -47,4 +85,13 @@ const kioskChange = (value) => {
} }
}; };
const unitChange = () => {
// reload the data at the top level to refresh units
// after the initial load
if (unitChange.firstRunDone) {
window.location.reload();
}
unitChange.firstRunDone = true;
};
export default settings; export default settings;

View file

@ -5,6 +5,7 @@ import { getWeatherRegionalIconFromIconLink } from './icons.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs'; import { DateTime } 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 settings from './settings.mjs';
class TravelForecast extends WeatherDisplay { class TravelForecast extends WeatherDisplay {
constructor(navId, elemId, defaultActive) { constructor(navId, elemId, defaultActive) {
@ -25,16 +26,42 @@ class TravelForecast extends WeatherDisplay {
if (extra !== 0) this.timing.delay.push(Math.round(this.extra * this.cityHeight)); if (extra !== 0) this.timing.delay.push(Math.round(this.extra * this.cityHeight));
// add the final 3 second delay // add the final 3 second delay
this.timing.delay.push(150); this.timing.delay.push(150);
// add previous data cache
this.previousData = [];
} }
async getData() { async getData(weatherParameters, refresh) {
// super checks for enabled // super checks for enabled
if (!super.getData()) return; if (!super.getData(weatherParameters, refresh)) return;
const forecastPromises = TravelCities.map(async (city) => {
// clear stored data if not refresh
if (!refresh) {
this.previousData = [];
}
const forecastPromises = TravelCities.map(async (city, index) => {
try { try {
// get point then forecast // get point then forecast
if (!city.point) throw new Error('No pre-loaded point'); if (!city.point) throw new Error('No pre-loaded point');
const forecast = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`); let forecast;
try {
forecast = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`, {
data: {
units: settings.units.value,
},
});
// store for the next run
this.previousData[index] = forecast;
} catch (e) {
// if there's previous data use it
if (this.previousData?.[index]) {
forecast = this.previousData?.[index];
} else {
// otherwise re-throw for the standard error handling
throw (e);
}
}
// determine today or tomorrow (shift periods by 1 if tomorrow) // determine today or tomorrow (shift periods by 1 if tomorrow)
const todayShift = forecast.properties.periods[0].isDaytime ? 0 : 1; const todayShift = forecast.properties.periods[0].isDaytime ? 0 : 1;
// return a pared-down forecast // return a pared-down forecast
@ -131,7 +158,7 @@ class TravelForecast extends WeatherDisplay {
// base count change callback // base count change callback
baseCountChange(count) { baseCountChange(count) {
// calculate scroll offset and don't go past end // calculate scroll offset and don't go past end
let offsetY = Math.min(this.elem.querySelector('.travel-lines').getBoundingClientRect().height - 289, (count - 150)); let offsetY = Math.min(this.elem.querySelector('.travel-lines').offsetHeight - 289, (count - 150));
// don't let offset go negative // don't let offset go negative
if (offsetY < 0) offsetY = 0; if (offsetY < 0) offsetY = 0;

View file

@ -73,7 +73,7 @@ const doFetch = (url, params) => new Promise((resolve, reject) => {
// out of retries // out of retries
return resolve(response); return resolve(response);
}) })
.catch((error) => reject(error)); .catch(reject);
}); });
const delay = (time, func, ...args) => new Promise((resolve) => { const delay = (time, func, ...args) => new Promise((resolve) => {

View file

@ -2,37 +2,58 @@ import { parseQueryString } from '../share.mjs';
const SETTINGS_KEY = 'Settings'; const SETTINGS_KEY = 'Settings';
const DEFAULTS = {
shortName: undefined,
name: undefined,
type: 'checkbox',
defaultValue: undefined,
changeAction: () => { },
sticky: true,
values: [],
visible: true,
};
class Setting { class Setting {
constructor(shortName, name, type, defaultValue, changeAction, sticky, values) { constructor(shortName, _options) {
// store values if (shortName === undefined) {
throw new Error('No name provided for setting');
}
// merge options with defaults
const options = { ...DEFAULTS, ...(_options ?? {}) };
// store values and combine with defaults
this.shortName = shortName; this.shortName = shortName;
this.name = name; this.name = options.name ?? shortName;
this.defaultValue = defaultValue; this.defaultValue = options.defaultValue;
this.myValue = defaultValue; this.myValue = this.defaultValue;
this.type = type ?? 'checkbox'; this.type = options?.type;
this.sticky = sticky; this.sticky = options.sticky;
this.values = values; this.values = options.values;
// a default blank change function is provided this.visible = options.visible;
this.changeAction = changeAction ?? (() => { }); this.changeAction = options.changeAction;
// get value from url // get value from url
const urlValue = parseQueryString()?.[`settings-${shortName}-${type}`]; const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`];
let urlState; let urlState;
if (type === 'checkbox' && urlValue !== undefined) { if (this.type === 'checkbox' && urlValue !== undefined) {
urlState = urlValue === 'true'; urlState = urlValue === 'true';
} }
if (type === 'select' && urlValue !== undefined) { if (this.type === 'select' && urlValue !== undefined) {
urlState = parseFloat(urlValue); urlState = parseFloat(urlValue);
} }
if (this.type === 'select' && urlValue !== undefined && Number.isNaN(urlState)) {
// couldn't parse as a float, store as a string
urlState = urlValue;
}
// get existing value if present // get existing value if present
const storedValue = urlState ?? this.getFromLocalStorage(); const storedValue = urlState ?? this.getFromLocalStorage();
if (sticky && storedValue !== null) { if ((this.sticky || urlValue !== undefined) && storedValue !== null) {
this.myValue = storedValue; this.myValue = storedValue;
} }
// call the change function on startup // call the change function on startup
switch (type) { switch (this.type) {
case 'select': case 'select':
this.selectChange({ target: { value: this.myValue } }); this.selectChange({ target: { value: this.myValue } });
break; break;
@ -59,7 +80,11 @@ class Setting {
this.values.forEach(([value, text]) => { this.values.forEach(([value, text]) => {
const option = document.createElement('option'); const option = document.createElement('option');
if (typeof value === 'number') {
option.value = value.toFixed(2); option.value = value.toFixed(2);
} else {
option.value = value;
}
option.innerHTML = text; option.innerHTML = text;
select.append(option); select.append(option);
@ -108,6 +133,10 @@ class Setting {
selectChange(e) { selectChange(e) {
// update the value // update the value
this.myValue = parseFloat(e.target.value); this.myValue = parseFloat(e.target.value);
if (Number.isNaN(this.myValue)) {
// was a string, store as such
this.myValue = e.target.value;
}
this.storeToLocalStorage(this.myValue); this.storeToLocalStorage(this.myValue);
// call the change action // call the change action
@ -130,6 +159,7 @@ class Setting {
if (storedValue !== undefined) { if (storedValue !== undefined) {
switch (this.type) { switch (this.type) {
case 'boolean': case 'boolean':
case 'checkbox':
return storedValue; return storedValue;
case 'select': case 'select':
return storedValue; return storedValue;
@ -155,6 +185,8 @@ class Setting {
case 'select': case 'select':
this.selectHighlight(newValue); this.selectHighlight(newValue);
break; break;
case 'boolean':
break;
case 'checkbox': case 'checkbox':
default: default:
this.element.checked = newValue; this.element.checked = newValue;
@ -167,12 +199,15 @@ class Setting {
selectHighlight(newValue) { selectHighlight(newValue) {
// set the dropdown to the provided value // set the dropdown to the provided value
this.element.querySelectorAll('option').forEach((elem) => { this?.element?.querySelectorAll('option')?.forEach?.((elem) => {
elem.selected = newValue.toFixed(2) === elem.value; elem.selected = (newValue?.toFixed?.(2) === elem.value) || (newValue === elem.value);
}); });
} }
generate() { generate() {
// don't generate a control for not visible items
if (!this.visible) return '';
// call the appropriate control generator
switch (this.type) { switch (this.type) {
case 'select': case 'select':
return this.generateSelect(); return this.generateSelect();

View file

@ -1,18 +1,113 @@
// get the settings for units
import settings from '../settings.mjs';
// *********************************** unit conversions *********************** // *********************************** unit conversions ***********************
// round 2 provided for lat/lon formatting
const round2 = (value, decimals) => Math.trunc(value * 10 ** decimals) / 10 ** decimals; const round2 = (value, decimals) => Math.trunc(value * 10 ** decimals) / 10 ** decimals;
const kphToMph = (Kph) => Math.round(Kph / 1.609_34); const kphToMph = (Kph) => Math.round(Kph / 1.609_34);
const celsiusToFahrenheit = (Celsius) => Math.round((Celsius * 9) / 5 + 32); const celsiusToFahrenheit = (Celsius) => Math.round((Celsius * 9) / 5 + 32);
const fahrenheitToCelsius = (Fahrenheit) => Math.round((Fahrenheit - 32) * 5 / 9);
const kilometersToMiles = (Kilometers) => Math.round(Kilometers / 1.609_34); const kilometersToMiles = (Kilometers) => Math.round(Kilometers / 1.609_34);
const metersToFeet = (Meters) => Math.round(Meters / 0.3048); const metersToFeet = (Meters) => Math.round(Meters / 0.3048);
const pascalToInHg = (Pascal) => round2(Pascal * 0.000_295_3, 2); const pascalToInHg = (Pascal) => round2(Pascal * 0.000_295_3, 2);
// each module/page/slide creates it's own unit converter as needed by providing the base units available
// the factory function then returns an appropriate converter or pass-thru function for use on the page
const windSpeed = (defaultUnit = 'si') => {
// default to passthru
let converter = (passthru) => Math.round(passthru);
// change the converter if there is a mismatch
if (defaultUnit !== settings.units.value) {
converter = kphToMph;
}
// append units
if (settings.units.value === 'si') {
converter.units = 'kph';
} else {
converter.units = 'MPH';
}
return converter;
};
const temperature = (defaultUnit = 'si') => {
// default to passthru
let converter = (passthru) => Math.round(passthru);
// change the converter if there is a mismatch
if (defaultUnit !== settings.units.value) {
if (defaultUnit === 'us') {
converter = fahrenheitToCelsius;
} else {
converter = celsiusToFahrenheit;
}
}
// append units
if (settings.units.value === 'si') {
converter.units = 'C';
} else {
converter.units = 'F';
}
return converter;
};
const distanceMeters = (defaultUnit = 'si') => {
// default to passthru
let converter = (passthru) => Math.round(passthru);
// change the converter if there is a mismatch
if (defaultUnit !== settings.units.value) {
// rounded to the nearest 100 (ceiling)
converter = (value) => Math.round(metersToFeet(value) / 100) * 100;
}
// append units
if (settings.units.value === 'si') {
converter.units = 'm.';
} else {
converter.units = 'ft.';
}
return converter;
};
const distanceKilometers = (defaultUnit = 'si') => {
// default to passthru
let converter = (passthru) => Math.round(passthru / 1000);
// change the converter if there is a mismatch
if (defaultUnit !== settings.units.value) {
converter = (value) => Math.round(kilometersToMiles(value) / 1000);
}
// append units
if (settings.units.value === 'si') {
converter.units = ' km.';
} else {
converter.units = ' mi.';
}
return converter;
};
const pressure = (defaultUnit = 'si') => {
// default to passthru (millibar)
let converter = (passthru) => Math.round(passthru / 100);
// change the converter if there is a mismatch
if (defaultUnit !== settings.units.value) {
converter = (value) => pascalToInHg(value).toFixed(2);
}
// append units
if (settings.units.value === 'si') {
converter.units = ' mbar';
} else {
converter.units = ' in.hg';
}
return converter;
};
export { export {
kphToMph, // unit conversions
celsiusToFahrenheit, windSpeed,
kilometersToMiles, temperature,
metersToFeet, distanceMeters,
pascalToInHg, distanceKilometers,
pressure,
// formatter
round2, round2,
}; };

View file

@ -22,6 +22,7 @@ class WeatherDisplay {
this.okToDrawCurrentConditions = true; this.okToDrawCurrentConditions = true;
this.okToDrawCurrentDateTime = true; this.okToDrawCurrentDateTime = true;
this.showOnProgress = true; this.showOnProgress = true;
this.autoRefreshHandle = null;
// default navigation timing // default navigation timing
this.timing = { this.timing = {
@ -129,9 +130,13 @@ class WeatherDisplay {
} }
// get necessary data for this display // get necessary data for this display
getData(weatherParameters) { getData(weatherParameters, refresh) {
// clear current data // refresh doesn't delete existing data, and is reused if the silent refresh fails
if (!refresh) {
this.data = undefined; this.data = undefined;
// clear any refresh timers
this.clearAutoReload();
}
// store weatherParameters locally in case we need them later // store weatherParameters locally in case we need them later
if (weatherParameters) this.weatherParameters = weatherParameters; if (weatherParameters) this.weatherParameters = weatherParameters;
@ -144,6 +149,9 @@ class WeatherDisplay {
return false; return false;
} }
// set up auto reload if necessary
this.setAutoReload();
// recalculate navigation timing (in case it was modified in the constructor) // recalculate navigation timing (in case it was modified in the constructor)
this.calcNavTiming(); this.calcNavTiming();
return true; return true;
@ -426,6 +434,15 @@ class WeatherDisplay {
this.stillWaitingCallbacks.forEach((callback) => callback()); this.stillWaitingCallbacks.forEach((callback) => callback());
this.stillWaitingCallbacks = []; this.stillWaitingCallbacks = [];
} }
clearAutoReload() {
clearInterval(this.autoRefreshHandle);
this.autoRefreshHandle = null;
}
setAutoReload() {
this.autoRefreshHandle = this.autoRefreshHandle ?? setInterval(() => this.getData(false, true), settings.refreshTime.value);
}
} }
export default WeatherDisplay; export default WeatherDisplay;

File diff suppressed because one or more lines are too long

View file

@ -392,12 +392,13 @@ class SystemZone extends Zone {
} }
} }
let dtfCache = {}; const dtfCache = new Map();
function makeDTF(zone) { function makeDTF(zoneName) {
if (!dtfCache[zone]) { let dtf = dtfCache.get(zoneName);
dtfCache[zone] = new Intl.DateTimeFormat("en-US", { if (dtf === undefined) {
dtf = new Intl.DateTimeFormat("en-US", {
hour12: false, hour12: false,
timeZone: zone, timeZone: zoneName,
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
@ -406,8 +407,9 @@ function makeDTF(zone) {
second: "2-digit", second: "2-digit",
era: "short", era: "short",
}); });
dtfCache.set(zoneName, dtf);
} }
return dtfCache[zone]; return dtf;
} }
const typeToPos = { const typeToPos = {
@ -443,7 +445,7 @@ function partsOffset(dtf, date) {
return filled; return filled;
} }
let ianaZoneCache = {}; const ianaZoneCache = new Map();
/** /**
* A zone identified by an IANA identifier, like America/New_York * A zone identified by an IANA identifier, like America/New_York
* @implements {Zone} * @implements {Zone}
@ -454,10 +456,11 @@ class IANAZone extends Zone {
* @return {IANAZone} * @return {IANAZone}
*/ */
static create(name) { static create(name) {
if (!ianaZoneCache[name]) { let zone = ianaZoneCache.get(name);
ianaZoneCache[name] = new IANAZone(name); if (zone === undefined) {
ianaZoneCache.set(name, (zone = new IANAZone(name)));
} }
return ianaZoneCache[name]; return zone;
} }
/** /**
@ -465,8 +468,8 @@ class IANAZone extends Zone {
* @return {void} * @return {void}
*/ */
static resetCache() { static resetCache() {
ianaZoneCache = {}; ianaZoneCache.clear();
dtfCache = {}; dtfCache.clear();
} }
/** /**
@ -569,6 +572,7 @@ class IANAZone extends Zone {
* @return {number} * @return {number}
*/ */
offset(ts) { offset(ts) {
if (!this.valid) return NaN;
const date = new Date(ts); const date = new Date(ts);
if (isNaN(date)) return NaN; if (isNaN(date)) return NaN;
@ -634,36 +638,36 @@ function getCachedLF(locString, opts = {}) {
return dtf; return dtf;
} }
let intlDTCache = {}; const intlDTCache = new Map();
function getCachedDTF(locString, opts = {}) { function getCachedDTF(locString, opts = {}) {
const key = JSON.stringify([locString, opts]); const key = JSON.stringify([locString, opts]);
let dtf = intlDTCache[key]; let dtf = intlDTCache.get(key);
if (!dtf) { if (dtf === undefined) {
dtf = new Intl.DateTimeFormat(locString, opts); dtf = new Intl.DateTimeFormat(locString, opts);
intlDTCache[key] = dtf; intlDTCache.set(key, dtf);
} }
return dtf; return dtf;
} }
let intlNumCache = {}; const intlNumCache = new Map();
function getCachedINF(locString, opts = {}) { function getCachedINF(locString, opts = {}) {
const key = JSON.stringify([locString, opts]); const key = JSON.stringify([locString, opts]);
let inf = intlNumCache[key]; let inf = intlNumCache.get(key);
if (!inf) { if (inf === undefined) {
inf = new Intl.NumberFormat(locString, opts); inf = new Intl.NumberFormat(locString, opts);
intlNumCache[key] = inf; intlNumCache.set(key, inf);
} }
return inf; return inf;
} }
let intlRelCache = {}; const intlRelCache = new Map();
function getCachedRTF(locString, opts = {}) { function getCachedRTF(locString, opts = {}) {
const { base, ...cacheKeyOpts } = opts; // exclude `base` from the options const { base, ...cacheKeyOpts } = opts; // exclude `base` from the options
const key = JSON.stringify([locString, cacheKeyOpts]); const key = JSON.stringify([locString, cacheKeyOpts]);
let inf = intlRelCache[key]; let inf = intlRelCache.get(key);
if (!inf) { if (inf === undefined) {
inf = new Intl.RelativeTimeFormat(locString, opts); inf = new Intl.RelativeTimeFormat(locString, opts);
intlRelCache[key] = inf; intlRelCache.set(key, inf);
} }
return inf; return inf;
} }
@ -678,14 +682,28 @@ function systemLocale() {
} }
} }
let weekInfoCache = {}; const intlResolvedOptionsCache = new Map();
function getCachedIntResolvedOptions(locString) {
let opts = intlResolvedOptionsCache.get(locString);
if (opts === undefined) {
opts = new Intl.DateTimeFormat(locString).resolvedOptions();
intlResolvedOptionsCache.set(locString, opts);
}
return opts;
}
const weekInfoCache = new Map();
function getCachedWeekInfo(locString) { function getCachedWeekInfo(locString) {
let data = weekInfoCache[locString]; let data = weekInfoCache.get(locString);
if (!data) { if (!data) {
const locale = new Intl.Locale(locString); const locale = new Intl.Locale(locString);
// browsers currently implement this as a property, but spec says it should be a getter function // browsers currently implement this as a property, but spec says it should be a getter function
data = "getWeekInfo" in locale ? locale.getWeekInfo() : locale.weekInfo; data = "getWeekInfo" in locale ? locale.getWeekInfo() : locale.weekInfo;
weekInfoCache[locString] = data; // minimalDays was removed from WeekInfo: https://github.com/tc39/proposal-intl-locale-info/issues/86
if (!("minimalDays" in data)) {
data = { ...fallbackWeekSettings, ...data };
}
weekInfoCache.set(locString, data);
} }
return data; return data;
} }
@ -784,7 +802,7 @@ function supportsFastNumbers(loc) {
loc.numberingSystem === "latn" || loc.numberingSystem === "latn" ||
!loc.locale || !loc.locale ||
loc.locale.startsWith("en") || loc.locale.startsWith("en") ||
new Intl.DateTimeFormat(loc.intl).resolvedOptions().numberingSystem === "latn" getCachedIntResolvedOptions(loc.locale).numberingSystem === "latn"
); );
} }
} }
@ -943,7 +961,6 @@ const fallbackWeekSettings = {
/** /**
* @private * @private
*/ */
class Locale { class Locale {
static fromOpts(opts) { static fromOpts(opts) {
return Locale.create( return Locale.create(
@ -967,9 +984,11 @@ class Locale {
static resetCache() { static resetCache() {
sysLocaleCache = null; sysLocaleCache = null;
intlDTCache = {}; intlDTCache.clear();
intlNumCache = {}; intlNumCache.clear();
intlRelCache = {}; intlRelCache.clear();
intlResolvedOptionsCache.clear();
weekInfoCache.clear();
} }
static fromObject({ locale, numberingSystem, outputCalendar, weekSettings } = {}) { static fromObject({ locale, numberingSystem, outputCalendar, weekSettings } = {}) {
@ -1123,7 +1142,7 @@ class Locale {
return ( return (
this.locale === "en" || this.locale === "en" ||
this.locale.toLowerCase() === "en-us" || this.locale.toLowerCase() === "en-us" ||
new Intl.DateTimeFormat(this.intl).resolvedOptions().locale.startsWith("en-us") getCachedIntResolvedOptions(this.intl).locale.startsWith("en-us")
); );
} }
@ -1461,22 +1480,26 @@ function parseDigits(str) {
} }
// cache of {numberingSystem: {append: regex}} // cache of {numberingSystem: {append: regex}}
let digitRegexCache = {}; const digitRegexCache = new Map();
function resetDigitRegexCache() { function resetDigitRegexCache() {
digitRegexCache = {}; digitRegexCache.clear();
} }
function digitRegex({ numberingSystem }, append = "") { function digitRegex({ numberingSystem }, append = "") {
const ns = numberingSystem || "latn"; const ns = numberingSystem || "latn";
if (!digitRegexCache[ns]) { let appendCache = digitRegexCache.get(ns);
digitRegexCache[ns] = {}; if (appendCache === undefined) {
appendCache = new Map();
digitRegexCache.set(ns, appendCache);
} }
if (!digitRegexCache[ns][append]) { let regex = appendCache.get(append);
digitRegexCache[ns][append] = new RegExp(`${numberingSystems[ns]}${append}`); if (regex === undefined) {
regex = new RegExp(`${numberingSystems[ns]}${append}`);
appendCache.set(append, regex);
} }
return digitRegexCache[ns][append]; return regex;
} }
let now = () => Date.now(), let now = () => Date.now(),
@ -4227,6 +4250,14 @@ class Interval {
return this.isValid ? this.e : null; return this.isValid ? this.e : null;
} }
/**
* Returns the last DateTime included in the interval (since end is not part of the interval)
* @type {DateTime}
*/
get lastDateTime() {
return this.isValid ? (this.e ? this.e.minus(1) : null) : null;
}
/** /**
* Returns whether this Interval's end is at least its start, meaning that the Interval isn't 'backwards'. * Returns whether this Interval's end is at least its start, meaning that the Interval isn't 'backwards'.
* @type {boolean} * @type {boolean}
@ -4491,8 +4522,11 @@ class Interval {
} }
/** /**
* Merge an array of Intervals into a equivalent minimal set of Intervals. * Merge an array of Intervals into an equivalent minimal set of Intervals.
* Combines overlapping and adjacent Intervals. * Combines overlapping and adjacent Intervals.
* The resulting array will contain the Intervals in ascending order, that is, starting with the earliest Interval
* and ending with the latest.
*
* @param {Array} intervals * @param {Array} intervals
* @return {Array} * @return {Array}
*/ */
@ -5815,15 +5849,27 @@ function normalizeUnitWithLocalWeeks(unit) {
// This is safe for quickDT (used by local() and utc()) because we don't fill in // This is safe for quickDT (used by local() and utc()) because we don't fill in
// higher-order units from tsNow (as we do in fromObject, this requires that // higher-order units from tsNow (as we do in fromObject, this requires that
// offset is calculated from tsNow). // offset is calculated from tsNow).
/**
* @param {Zone} zone
* @return {number}
*/
function guessOffsetForZone(zone) { function guessOffsetForZone(zone) {
if (!zoneOffsetGuessCache[zone]) {
if (zoneOffsetTs === undefined) { if (zoneOffsetTs === undefined) {
zoneOffsetTs = Settings.now(); zoneOffsetTs = Settings.now();
} }
zoneOffsetGuessCache[zone] = zone.offset(zoneOffsetTs); // Do not cache anything but IANA zones, because it is not safe to do so.
// Guessing an offset which is not present in the zone can cause wrong results from fixOffset
if (zone.type !== "iana") {
return zone.offset(zoneOffsetTs);
} }
return zoneOffsetGuessCache[zone]; const zoneName = zone.name;
let offsetGuess = zoneOffsetGuessCache.get(zoneName);
if (offsetGuess === undefined) {
offsetGuess = zone.offset(zoneOffsetTs);
zoneOffsetGuessCache.set(zoneName, offsetGuess);
}
return offsetGuess;
} }
// this is a dumbed down version of fromObject() that runs about 60% faster // this is a dumbed down version of fromObject() that runs about 60% faster
@ -5913,7 +5959,7 @@ let zoneOffsetTs;
* This optimizes quickDT via guessOffsetForZone to avoid repeated calls of * This optimizes quickDT via guessOffsetForZone to avoid repeated calls of
* zone.offset(). * zone.offset().
*/ */
let zoneOffsetGuessCache = {}; const zoneOffsetGuessCache = new Map();
/** /**
* A DateTime is an immutable data structure representing a specific date and time and accompanying methods. It contains class and instance methods for creating, parsing, interrogating, transforming, and formatting them. * A DateTime is an immutable data structure representing a specific date and time and accompanying methods. It contains class and instance methods for creating, parsing, interrogating, transforming, and formatting them.
@ -6478,7 +6524,7 @@ class DateTime {
static resetCache() { static resetCache() {
zoneOffsetTs = undefined; zoneOffsetTs = undefined;
zoneOffsetGuessCache = {}; zoneOffsetGuessCache.clear();
} }
// INFO // INFO
@ -7247,7 +7293,7 @@ class DateTime {
* @example DateTime.now().toISO() //=> '2017-04-22T20:47:05.335-04:00' * @example DateTime.now().toISO() //=> '2017-04-22T20:47:05.335-04:00'
* @example DateTime.now().toISO({ includeOffset: false }) //=> '2017-04-22T20:47:05.335' * @example DateTime.now().toISO({ includeOffset: false }) //=> '2017-04-22T20:47:05.335'
* @example DateTime.now().toISO({ format: 'basic' }) //=> '20170422T204705.335-0400' * @example DateTime.now().toISO({ format: 'basic' }) //=> '20170422T204705.335-0400'
* @return {string} * @return {string|null}
*/ */
toISO({ toISO({
format = "extended", format = "extended",
@ -7274,7 +7320,7 @@ class DateTime {
* @param {string} [opts.format='extended'] - choose between the basic and extended format * @param {string} [opts.format='extended'] - choose between the basic and extended format
* @example DateTime.utc(1982, 5, 25).toISODate() //=> '1982-05-25' * @example DateTime.utc(1982, 5, 25).toISODate() //=> '1982-05-25'
* @example DateTime.utc(1982, 5, 25).toISODate({ format: 'basic' }) //=> '19820525' * @example DateTime.utc(1982, 5, 25).toISODate({ format: 'basic' }) //=> '19820525'
* @return {string} * @return {string|null}
*/ */
toISODate({ format = "extended" } = {}) { toISODate({ format = "extended" } = {}) {
if (!this.isValid) { if (!this.isValid) {
@ -7359,7 +7405,7 @@ class DateTime {
/** /**
* Returns a string representation of this DateTime appropriate for use in SQL Date * Returns a string representation of this DateTime appropriate for use in SQL Date
* @example DateTime.utc(2014, 7, 13).toSQLDate() //=> '2014-07-13' * @example DateTime.utc(2014, 7, 13).toSQLDate() //=> '2014-07-13'
* @return {string} * @return {string|null}
*/ */
toSQLDate() { toSQLDate() {
if (!this.isValid) { if (!this.isValid) {
@ -7454,7 +7500,7 @@ class DateTime {
} }
/** /**
* Returns the epoch seconds of this DateTime. * Returns the epoch seconds (including milliseconds in the fractional part) of this DateTime.
* @return {number} * @return {number}
*/ */
toSeconds() { toSeconds() {
@ -7561,7 +7607,7 @@ class DateTime {
/** /**
* Return an Interval spanning between this DateTime and another DateTime * Return an Interval spanning between this DateTime and another DateTime
* @param {DateTime} otherDateTime - the other end point of the Interval * @param {DateTime} otherDateTime - the other end point of the Interval
* @return {Interval} * @return {Interval|DateTime}
*/ */
until(otherDateTime) { until(otherDateTime) {
return this.isValid ? Interval.fromDateTimes(this, otherDateTime) : this; return this.isValid ? Interval.fromDateTimes(this, otherDateTime) : this;
@ -7979,7 +8025,7 @@ function friendlyDateTime(dateTimeish) {
} }
} }
const VERSION = "3.5.0"; const VERSION = "3.6.1";
export { DateTime, Duration, FixedOffsetZone, IANAZone, Info, Interval, InvalidZone, Settings, SystemZone, VERSION, Zone }; export { DateTime, Duration, FixedOffsetZone, IANAZone, Info, Interval, InvalidZone, Settings, SystemZone, VERSION, Zone };
//# sourceMappingURL=luxon.js.map //# sourceMappingURL=luxon.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -82,6 +82,14 @@
.like { .like {
left: 425px; left: 425px;
&.heat-index {
color: #e00;
}
&.wind-chill {
color: c.$extended-low;
}
} }
.wind { .wind {

View file

@ -0,0 +1,34 @@
.media {
display: none;
}
#ToggleMedia {
display: none;
&.available {
display: inline-block;
img.on {
display: none;
}
img.off {
display: block;
}
// icon switch is handled by adding/removing the .playing class
&.playing {
img.on {
display: block;
}
img.off {
display: none;
}
}
}
}

View file

@ -748,8 +748,7 @@ body {
>.heading, >.heading,
#enabledDisplays, #enabledDisplays,
#settings, #settings,
#divInfo, #divInfo {
#divRefresh {
display: none; display: none;
} }
} }

View file

@ -12,3 +12,4 @@
@import 'regional-forecast'; @import 'regional-forecast';
@import 'almanac'; @import 'almanac';
@import 'hazards'; @import 'hazards';
@import 'media';

20
src/playlist-reader.mjs Normal file
View file

@ -0,0 +1,20 @@
import fs from 'fs/promises';
const mp3Filter = (file) => file.match(/\.mp3$/);
const reader = async () => {
// get the listing of files in the folder
const rawFiles = await fs.readdir('./server/music');
// filter for mp3 files
const files = rawFiles.filter(mp3Filter);
// if files were found return them
if (files.length > 0) {
return files;
}
// fall back to the default folder
const defaultFiles = await fs.readdir('./server/music/default');
return defaultFiles.map((file) => `default/${file}`).filter(mp3Filter);
};
export default reader;

17
src/playlist.mjs Normal file
View file

@ -0,0 +1,17 @@
import reader from './playlist-reader.mjs';
const playlistGenerator = async (req, res) => {
try {
const availableFiles = await reader();
res.json({
availableFiles,
});
} catch (e) {
console.error(e);
res.json({
availableFiles: [],
});
}
};
export default playlistGenerator;

View file

@ -1,29 +1,33 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml"> <html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>WeatherStar 4000+</title> <title>WeatherStar 4000+</title>
<meta name="description" <meta name="description" content="Web based WeatherStar 4000 simulator that reports current and forecast weather conditions plus a few extras!" />
content="Web based WeatherStar 4000 simulator that reports current and forecast weather conditions plus a few extras!" />
<meta name="keywords" content="WeatherStar 4000+" /> <meta name="keywords" content="WeatherStar 4000+" />
<meta name="author" content="Matt Walsh" /> <meta name="author" content="Matt Walsh" />
<meta name="application-name" content="WeatherStar 4000+" /> <meta name="application-name" content="WeatherStar 4000+" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<link rel="icon" href="images/Logo192.png" /> <link rel="icon" href="images/Logo192.png" />
<link rel="preload" href="playlist.json" as="fetch" crossorigin="anonymous"/>
<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">
<% if (production) { %> <% if (production) { %>
<link rel="stylesheet" type="text/css" href="resources/ws.min.css?_=<%=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/data.min.js?_=<%=production%>"></script>
<script type="text/javascript" src="resources/vendor.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" src="resources/ws.min.js?_=<%=production%>"></script>
<script type="text/javascript" src="scripts/custom.js?_=<%=production%>"></script>
<% } else { %> <% } else { %>
<link rel="stylesheet" type="text/css" href="styles/main.css" /> <link rel="stylesheet" type="text/css" href="styles/main.css" />
<script type="text/javascript" src="scripts/vendor/auto/jquery.js"></script>
<script type="text/javascript" src="scripts/vendor/jquery.autocomplete.min.js"></script>
<script type="text/javascript" src="scripts/vendor/auto/nosleep.js"></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/swiped-events.js"></script>
<script type="text/javascript" src="scripts/vendor/auto/suncalc.js"></script> <script type="text/javascript" src="scripts/vendor/auto/suncalc.js"></script>
@ -43,15 +47,12 @@
<script type="module" src="scripts/modules/progress.mjs"></script> <script type="module" src="scripts/modules/progress.mjs"></script>
<script type="module" src="scripts/modules/radar.mjs"></script> <script type="module" src="scripts/modules/radar.mjs"></script>
<script type="module" src="scripts/modules/settings.mjs"></script> <script type="module" src="scripts/modules/settings.mjs"></script>
<script type="module" src="scripts/modules/media.mjs"></script>
<script type="module" src="scripts/index.mjs"></script> <script type="module" src="scripts/index.mjs"></script>
<script type="text/javascript" src="scripts/custom.js"></script>
<!-- data --> <!-- data -->
<script type="text/javascript" src="scripts/data/travelcities.js"></script> <script type="text/javascript" src="scripts/data/travelcities.js"></script>
<script type="text/javascript" src="scripts/data/regionalcities.js"></script> <script type="text/javascript" src="scripts/data/regionalcities.js"></script>
<script type="text/javascript" src="scripts/data/stations.js"></script> <script type="text/javascript" src="scripts/data/stations.js"></script>
<script type="text/javascript" src="scripts/custom.js"></script>
<% } %> <% } %>
</head> </head>
@ -123,8 +124,7 @@
<div id="divTwcBottom"> <div id="divTwcBottom">
<div id="divTwcBottomLeft"> <div id="divTwcBottomLeft">
<img id="NavigateMenu" class="navButton" src="images/nav/ic_menu_white_24dp_2x.png" title="Menu" /> <img id="NavigateMenu" class="navButton" src="images/nav/ic_menu_white_24dp_2x.png" title="Menu" />
<img id="NavigatePrevious" class="navButton" src="images/nav/ic_skip_previous_white_24dp_2x.png" <img id="NavigatePrevious" class="navButton" src="images/nav/ic_skip_previous_white_24dp_2x.png" title="Previous" />
title="Previous" />
<img id="NavigateNext" class="navButton" src="images/nav/ic_skip_next_white_24dp_2x.png" title="Next" /> <img id="NavigateNext" class="navButton" src="images/nav/ic_skip_next_white_24dp_2x.png" title="Next" />
<img id="NavigatePlay" class="navButton" src="images/nav/ic_play_arrow_white_24dp_2x.png" title="Play" /> <img id="NavigatePlay" class="navButton" src="images/nav/ic_play_arrow_white_24dp_2x.png" title="Play" />
</div> </div>
@ -132,8 +132,11 @@
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_2x.png" title="Refresh" /> <img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_2x.png" title="Refresh" />
</div> </div>
<div id="divTwcBottomRight"> <div id="divTwcBottomRight">
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_2x.png" <div id="ToggleMedia">
title="Enter Fullscreen" /> <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>
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_2x.png" title="Enter Fullscreen" />
</div> </div>
</div> </div>
</div> </div>
@ -143,6 +146,7 @@
<div class="info"> <div class="info">
<a href="https://github.com/netbymatt/ws4kp#weatherstar-4000">More information</a> <a href="https://github.com/netbymatt/ws4kp#weatherstar-4000">More information</a>
</div> </div>
<div class="media"></div>
<div class='heading'>Selected displays</div> <div class='heading'>Selected displays</div>
<div id='enabledDisplays'> <div id='enabledDisplays'>
@ -171,12 +175,6 @@
Zone Id: <span id="spanZoneId"></span><br /> Zone Id: <span id="spanZoneId"></span><br />
</div> </div>
<div id="divRefresh">
Last Update: <span id="spanLastRefresh">(None)</span><br />
<input id="chkAutoRefresh" name="chkAutoRefresh" type="checkbox" /><label id="lblRefreshCountDown"
for="chkAutoRefresh">Auto Refresh: <span id="spanRefreshCountDown">--:--</span></label>
</div>
</body> </body>
</html> </html>

View file

@ -22,6 +22,7 @@
"devbridge", "devbridge",
"gifs", "gifs",
"ltrim", "ltrim",
"mbar",
"Noaa", "Noaa",
"nosleep", "nosleep",
"Pngs", "Pngs",
@ -57,6 +58,5 @@
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
} }
}, },
} }