mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-16 08:39:29 -07:00
Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c278928b9 | ||
|
|
130a1bfad3 | ||
|
|
bd6c5430c4 | ||
|
|
cc896bf18d | ||
|
|
f7a15a93c6 | ||
|
|
0baa31a92c | ||
|
|
8fa00b34b4 | ||
|
|
23cc1a1f7a | ||
|
|
b272aa298a | ||
|
|
d810b429c5 | ||
|
|
560b51ccee | ||
|
|
e8f69ce28b | ||
|
|
46fb40058f | ||
|
|
b40fe08465 | ||
|
|
b253988bd3 | ||
|
|
92690e8e97 | ||
|
|
020f0eabb2 | ||
|
|
b812c7b25c | ||
|
|
707144cabd | ||
|
|
f0166ec2df | ||
|
|
1983d025a4 | ||
|
|
372cb0cfab | ||
|
|
64215a117c | ||
|
|
2f0204a689 | ||
|
|
e9164d8c36 | ||
|
|
65444978b7 | ||
|
|
2b4aad3c48 | ||
|
|
1a7d0759c4 | ||
|
|
f0600d92ed | ||
|
|
06b8dbc959 | ||
|
|
dc9a08bdc5 | ||
|
|
0dac24f77d | ||
|
|
c2f0b9bf3f | ||
|
|
cab2da5e62 | ||
|
|
471d322cde | ||
|
|
8f9be046ac | ||
|
|
c34dc1ff25 | ||
|
|
9b4eed7332 | ||
|
|
ef1477f9eb | ||
|
|
e2876df177 | ||
|
|
d6335b2878 | ||
|
|
781128100e | ||
|
|
56261ded4b | ||
|
|
58540ad67b | ||
|
|
af53cca45e | ||
|
|
94470db9a7 | ||
|
|
3c7a77e200 | ||
|
|
d472df2e26 | ||
|
|
fdbf11dcd4 | ||
|
|
b7e9091320 | ||
|
|
d24284d340 | ||
|
|
4e8dc35739 | ||
|
|
779d34a0a8 | ||
|
|
efeb45d3d0 | ||
|
|
e2d7a96971 | ||
|
|
487c83f664 | ||
|
|
88b8b4a82e | ||
|
|
13b77a0070 | ||
|
|
c9307768a4 | ||
|
|
844544c364 | ||
|
|
80a68caa27 | ||
|
|
c7889eaa2c | ||
|
|
019908684b | ||
|
|
e1a58b6548 | ||
|
|
e794976f4d | ||
|
|
193d742aa3 | ||
|
|
b62339af94 | ||
|
|
913dc383f6 | ||
|
|
94249560f2 | ||
|
|
75314d92c9 | ||
|
|
168c0c5caf | ||
|
|
b6cd75ab42 | ||
|
|
934a489340 | ||
|
|
c5f5c101f9 | ||
|
|
933367974f | ||
|
|
0e67eb22dc | ||
|
|
9df6f6888f | ||
|
|
04cc5d4252 | ||
|
|
543d3f5196 | ||
|
|
78ceba9c19 | ||
|
|
763d42061e | ||
|
|
318c55b92d | ||
|
|
84d39101e5 | ||
|
|
fd0e42aa67 | ||
|
|
7be20490e8 | ||
|
|
20b4d22115 | ||
|
|
af4ba1b881 | ||
|
|
9f9dafa30c | ||
|
|
227a959e6a | ||
|
|
63703d1fff | ||
|
|
e87290ed5e | ||
|
|
f4f3720793 | ||
|
|
535b1072d8 | ||
|
|
6d4ec8b958 | ||
|
|
5d0f41f207 | ||
|
|
14367cf71a | ||
|
|
f158b8a138 | ||
|
|
240cc416b2 | ||
|
|
eb69df8b80 | ||
|
|
941bcacfad | ||
|
|
9aa877d6fb | ||
|
|
5d00cf4608 | ||
|
|
53ad8eb317 | ||
|
|
81561f75a1 | ||
|
|
c414b88067 | ||
|
|
0808f1ac0e | ||
|
|
022a10be08 |
@@ -1,3 +1,4 @@
|
||||
.git/
|
||||
Dockerfile
|
||||
.vscode/
|
||||
.vscode/
|
||||
dist/
|
||||
91
.eslintrc
Normal file
91
.eslintrc
Normal 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"
|
||||
]
|
||||
}
|
||||
92
.eslintrc.js
92
.eslintrc.js
@@ -1,92 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
node: true,
|
||||
jquery: true,
|
||||
},
|
||||
extends: [
|
||||
'airbnb-base',
|
||||
'plugin:sonarjs/recommended',
|
||||
],
|
||||
globals: {
|
||||
Atomics: 'readonly',
|
||||
SharedArrayBuffer: 'readonly',
|
||||
StationInfo: 'readonly',
|
||||
RegionalCities: 'readonly',
|
||||
TravelCities: 'readonly',
|
||||
NoSleep: 'readonly',
|
||||
states: 'readonly',
|
||||
SunCalc: 'readonly',
|
||||
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
},
|
||||
plugins: [
|
||||
'unicorn',
|
||||
'sonarjs',
|
||||
],
|
||||
rules: {
|
||||
indent: [
|
||||
'error',
|
||||
'tab',
|
||||
],
|
||||
'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',
|
||||
},
|
||||
],
|
||||
// unicorn
|
||||
'unicorn/numeric-separators-style': 'error',
|
||||
'unicorn/prefer-query-selector': 'error',
|
||||
'unicorn/catch-error-name': 'error',
|
||||
'unicorn/no-negated-condition': 'error',
|
||||
'unicorn/better-regex': 'error',
|
||||
'unicorn/consistent-function-scoping': 'error',
|
||||
'unicorn/prefer-array-flat-map': 'error',
|
||||
'unicorn/prefer-array-find': 'error',
|
||||
'unicorn/prefer-regexp-test': 'error',
|
||||
'unicorn/consistent-destructuring': 'error',
|
||||
'unicorn/prefer-date-now': 'error',
|
||||
'unicorn/prefer-ternary': 'error',
|
||||
'unicorn/prefer-dom-node-append': 'error',
|
||||
'unicorn/explicit-length-check': 'error',
|
||||
'unicorn/prefer-at': 'error',
|
||||
// sonarjs
|
||||
'sonarjs/cognitive-complexity': 0,
|
||||
},
|
||||
ignorePatterns: [
|
||||
'*.min.js',
|
||||
],
|
||||
};
|
||||
13
.github/workflows/build-docker.yaml
vendored
13
.github/workflows/build-docker.yaml
vendored
@@ -10,19 +10,22 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- id: short-sha
|
||||
uses: benjlevesque/short-sha@v1.2
|
||||
uses: actions/checkout@v4
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/netbymatt/ws4kp
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,priority=1000,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=branch
|
||||
${{ steps.short-sha.outputs.sha }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Buildx
|
||||
@@ -44,4 +47,4 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,3 +1,14 @@
|
||||
node_modules
|
||||
**/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
|
||||
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@@ -50,7 +50,7 @@
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
],
|
||||
"program": "${workspaceFolder}/index.js",
|
||||
"program": "${workspaceFolder}/index.mjs",
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
@@ -59,7 +59,7 @@
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
],
|
||||
"program": "${workspaceFolder}/index.js",
|
||||
"program": "${workspaceFolder}/index.mjs",
|
||||
"env": {
|
||||
"DIST": "1"
|
||||
}
|
||||
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -7,8 +7,6 @@
|
||||
"format": "compressed",
|
||||
"extensionName": ".css",
|
||||
"savePath": "/server/styles",
|
||||
"savePathSegmentKeys": null,
|
||||
"savePathReplaceSegmentsWith": null
|
||||
}
|
||||
],
|
||||
"search.exclude": {
|
||||
@@ -18,4 +16,11 @@
|
||||
"**/compiled.css": true,
|
||||
"**/*.min.js": true,
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript"
|
||||
]
|
||||
}
|
||||
@@ -7,4 +7,4 @@ COPY package-lock.json .
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
CMD ["node", "index.js"]
|
||||
CMD ["node", "index.mjs"]
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016-2017 Michael Battaglia
|
||||
Copyright (c) 2020-2024 Matt Walsh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
52
README.md
52
README.md
@@ -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
|
||||
* 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
|
||||
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.
|
||||
|
||||
@@ -77,20 +84,61 @@ I've made several changes to this Weather Star 4000 simulation compared to the o
|
||||
* The nearby cities displayed on screens such as "Latest Observations" and "Regional Forecast" are likely not the same as they were in the 90's. The weather monitoring equipment at these stations move over time for one reason or another, and coming up with a simple formulaic way of finding nearby stations is sufficient to give the same look-and-feel as the original.
|
||||
* "Flavors" are not present in this simulation. Flavors refer to the order of the weather information that was shown on the original units. Instead, the order of the displays has been fixed and a checkboxes can be used to turn on and off individual displays. The travel forecast has been defaulted to off so only local information shows for new users.
|
||||
|
||||
## Sharing a permalink (bookmarking)
|
||||
Selected displays, the forecast city and widescreen setting are sticky from one session to the next. However if you would like to share your exact configuration or bookmark it click the "Copy Permalink" (or get "Get Parmalink") near the bottom of the page. A URL will be copied to your clipboard with all of you selected displays and location (or copy it from the page if your browser doesn't support clipboard transfers directly). You can then share this link or add it to your bookmarks.
|
||||
|
||||
## Kiosk mode
|
||||
Kiosk mode can be activated by a checkbox on the page. Note that there is no way out of kiosk mode (except refresh or closing the browser), and the play/pause and other controls will not be available. This is deliberate as a browser's kiosk mode it intended not to be exited or significantly modified.
|
||||
|
||||
It's also possible to enter kiosk mode using a permalink. First generate a [Permalink](#sharing-a-permalink-bookmarking), then to the end of it add `&kiosk=true`. Opening this link will load all of the selected displays included in the Permalink, enter kiosk mode immediately upon loading and start playing the forecast.
|
||||
|
||||
## Wish list
|
||||
|
||||
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)
|
||||
|
||||
And the following technical fixes.
|
||||
## 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.
|
||||
|
||||
* Caching of the animated gifs, specifically after they are decompressed
|
||||
## 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
|
||||
|
||||
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)
|
||||
* [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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Issue reporting and feature requests
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// pass through api requests
|
||||
|
||||
// http(s) modules
|
||||
const https = require('https');
|
||||
import https from 'https';
|
||||
|
||||
// url parsing
|
||||
const queryString = require('querystring');
|
||||
import queryString from 'querystring';
|
||||
|
||||
// return an express router
|
||||
module.exports = (req, res) => {
|
||||
const cors = (req, res) => {
|
||||
// add out-going headers
|
||||
const headers = {};
|
||||
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
|
||||
@@ -41,3 +41,5 @@ module.exports = (req, res) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
export default cors;
|
||||
@@ -1,13 +1,13 @@
|
||||
// pass through api requests
|
||||
|
||||
// http(s) modules
|
||||
const https = require('https');
|
||||
import https from 'https';
|
||||
|
||||
// url parsing
|
||||
const queryString = require('querystring');
|
||||
import queryString from 'querystring';
|
||||
|
||||
// return an express router
|
||||
module.exports = (req, res) => {
|
||||
const outlook = (req, res) => {
|
||||
// add out-going headers
|
||||
const headers = {};
|
||||
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
|
||||
@@ -42,3 +42,5 @@ module.exports = (req, res) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
export default outlook;
|
||||
@@ -1,13 +1,13 @@
|
||||
// pass through api requests
|
||||
|
||||
// http(s) modules
|
||||
const https = require('https');
|
||||
import https from 'https';
|
||||
|
||||
// url parsing
|
||||
const queryString = require('querystring');
|
||||
import queryString from 'querystring';
|
||||
|
||||
// return an express router
|
||||
module.exports = (req, res) => {
|
||||
const radar = (req, res) => {
|
||||
// add out-going headers
|
||||
const headers = {};
|
||||
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
|
||||
@@ -42,3 +42,5 @@ module.exports = (req, res) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
export default radar;
|
||||
1
dist/index.html
vendored
1
dist/index.html
vendored
File diff suppressed because one or more lines are too long
12
dist/manifest.json
vendored
12
dist/manifest.json
vendored
@@ -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
1
dist/readme.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This folder is a placeholder for static files generated by the gulp task buildDist
|
||||
1
dist/resources/data.min.js
vendored
1
dist/resources/data.min.js
vendored
File diff suppressed because one or more lines are too long
22
dist/resources/vendor.min.js
vendored
22
dist/resources/vendor.min.js
vendored
File diff suppressed because one or more lines are too long
1
dist/resources/ws.min.css
vendored
1
dist/resources/ws.min.css
vendored
File diff suppressed because one or more lines are too long
1
dist/resources/ws.min.js
vendored
1
dist/resources/ws.min.js
vendored
File diff suppressed because one or more lines are too long
0
dist/robots.txt
vendored
0
dist/robots.txt
vendored
@@ -1,23 +1,28 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
const gulp = require('gulp');
|
||||
const concat = require('gulp-concat');
|
||||
const terser = require('gulp-terser');
|
||||
const ejs = require('gulp-ejs');
|
||||
const rename = require('gulp-rename');
|
||||
const htmlmin = require('gulp-htmlmin');
|
||||
const del = require('del');
|
||||
const s3Upload = require('gulp-s3-upload');
|
||||
const webpack = require('webpack-stream');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const path = require('path');
|
||||
|
||||
const clean = () => del(['./dist**']);
|
||||
import {
|
||||
src, dest, series, parallel,
|
||||
} from 'gulp';
|
||||
import concat from 'gulp-concat';
|
||||
import terser from 'gulp-terser';
|
||||
import ejs from 'gulp-ejs';
|
||||
import rename from 'gulp-rename';
|
||||
import htmlmin from 'gulp-htmlmin';
|
||||
import { deleteAsync } from 'del';
|
||||
import s3Upload from 'gulp-s3-upload';
|
||||
import webpack from 'webpack-stream';
|
||||
import TerserPlugin from 'terser-webpack-plugin';
|
||||
import { readFile } from 'fs/promises';
|
||||
import file from 'gulp-file';
|
||||
|
||||
// get cloudfront
|
||||
const AWS = require('aws-sdk');
|
||||
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
|
||||
import reader from '../src/playlist-reader.mjs';
|
||||
|
||||
AWS.config.update({ region: 'us-east-1' });
|
||||
const cloudfront = new AWS.CloudFront({ apiVersion: '2020-01-01' });
|
||||
const clean = () => deleteAsync(['./dist/**/*', '!./dist/readme.txt']);
|
||||
|
||||
const cloudfront = new CloudFrontClient({ region: 'us-east-1' });
|
||||
|
||||
const RESOURCES_PATH = './dist/resources';
|
||||
|
||||
const jsSourcesData = [
|
||||
'server/scripts/data/travelcities.js',
|
||||
@@ -33,7 +38,7 @@ const webpackOptions = {
|
||||
filename: 'ws.min.js',
|
||||
},
|
||||
resolve: {
|
||||
roots: [path.resolve(__dirname, './')],
|
||||
roots: ['./'],
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
@@ -51,10 +56,10 @@ const webpackOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
gulp.task('compress_js_data', () => gulp.src(jsSourcesData)
|
||||
const compressJsData = () => src(jsSourcesData)
|
||||
.pipe(concat('data.min.js'))
|
||||
.pipe(terser())
|
||||
.pipe(gulp.dest('./dist/resources')));
|
||||
.pipe(dest(RESOURCES_PATH));
|
||||
|
||||
const jsVendorSources = [
|
||||
'server/scripts/vendor/auto/jquery.js',
|
||||
@@ -64,10 +69,10 @@ const jsVendorSources = [
|
||||
'server/scripts/vendor/auto/suncalc.js',
|
||||
];
|
||||
|
||||
gulp.task('compress_js_vendor', () => gulp.src(jsVendorSources)
|
||||
const compressJsVendor = () => src(jsVendorSources)
|
||||
.pipe(concat('vendor.min.js'))
|
||||
.pipe(terser())
|
||||
.pipe(gulp.dest('./dist/resources')));
|
||||
.pipe(dest(RESOURCES_PATH));
|
||||
|
||||
const mjsSources = [
|
||||
'server/scripts/modules/currentweatherscroll.mjs',
|
||||
@@ -84,42 +89,45 @@ const mjsSources = [
|
||||
'server/scripts/modules/regionalforecast.mjs',
|
||||
'server/scripts/modules/travelforecast.mjs',
|
||||
'server/scripts/modules/progress.mjs',
|
||||
'server/scripts/modules/media.mjs',
|
||||
'server/scripts/index.mjs',
|
||||
];
|
||||
|
||||
gulp.task('build_js', () => gulp.src(mjsSources)
|
||||
const buildJs = () => src(mjsSources)
|
||||
.pipe(webpack(webpackOptions))
|
||||
.pipe(gulp.dest('dist/resources')));
|
||||
.pipe(dest(RESOURCES_PATH));
|
||||
|
||||
const cssSources = [
|
||||
'server/styles/main.css',
|
||||
];
|
||||
gulp.task('copy_css', () => gulp.src(cssSources)
|
||||
const copyCss = () => src(cssSources)
|
||||
.pipe(concat('ws.min.css'))
|
||||
.pipe(gulp.dest('./dist/resources')));
|
||||
.pipe(dest(RESOURCES_PATH));
|
||||
|
||||
const htmlSources = [
|
||||
'views/*.ejs',
|
||||
];
|
||||
gulp.task('compress_html', () => {
|
||||
// eslint-disable-next-line global-require
|
||||
const { version } = require('../package.json');
|
||||
return gulp.src(htmlSources)
|
||||
const compressHtml = async () => {
|
||||
const packageJson = await readFile('package.json');
|
||||
const { version } = JSON.parse(packageJson);
|
||||
|
||||
return src(htmlSources)
|
||||
.pipe(ejs({
|
||||
production: version,
|
||||
version,
|
||||
}))
|
||||
.pipe(rename({ extname: '.html' }))
|
||||
.pipe(htmlmin({ collapseWhitespace: true }))
|
||||
.pipe(gulp.dest('./dist'));
|
||||
});
|
||||
.pipe(dest('./dist'));
|
||||
};
|
||||
|
||||
const otherFiles = [
|
||||
'server/robots.txt',
|
||||
'server/manifest.json',
|
||||
'server/music/**/*.mp3',
|
||||
];
|
||||
gulp.task('copy_other_files', () => gulp.src(otherFiles, { base: 'server/' })
|
||||
.pipe(gulp.dest('./dist')));
|
||||
const copyOtherFiles = () => src(otherFiles, { base: 'server/', encoding: false })
|
||||
.pipe(dest('./dist'));
|
||||
|
||||
const s3 = s3Upload({
|
||||
useIAM: true,
|
||||
@@ -130,31 +138,32 @@ const uploadSources = [
|
||||
'dist/**',
|
||||
'!dist/**/*.map',
|
||||
];
|
||||
gulp.task('upload', () => gulp.src(uploadSources, { base: './dist' })
|
||||
const upload = () => src(uploadSources, { base: './dist', encoding: false })
|
||||
.pipe(s3({
|
||||
Bucket: 'weatherstar',
|
||||
StorageClass: 'STANDARD',
|
||||
maps: {
|
||||
CacheControl: (keyname) => {
|
||||
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
|
||||
},
|
||||
},
|
||||
})));
|
||||
}));
|
||||
|
||||
const imageSources = [
|
||||
'server/fonts/**',
|
||||
'server/images/**',
|
||||
];
|
||||
gulp.task('upload_images', () => gulp.src(imageSources, { base: './server' })
|
||||
const uploadImages = () => src(imageSources, { base: './server', encoding: false })
|
||||
.pipe(
|
||||
s3({
|
||||
Bucket: 'weatherstar',
|
||||
StorageClass: 'STANDARD',
|
||||
}),
|
||||
));
|
||||
);
|
||||
|
||||
gulp.task('invalidate', async () => cloudfront.createInvalidation({
|
||||
const invalidate = () => cloudfront.send(new CreateInvalidationCommand({
|
||||
DistributionId: 'E9171A4KV8KCW',
|
||||
InvalidationBatch: {
|
||||
CallerReference: (new Date()).toLocaleString(),
|
||||
@@ -163,8 +172,22 @@ gulp.task('invalidate', async () => cloudfront.createInvalidation({
|
||||
Items: ['/*'],
|
||||
},
|
||||
},
|
||||
}).promise());
|
||||
}));
|
||||
|
||||
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
|
||||
// by running upload last the majority of the changes will be at the bottom of the log for easy viewing
|
||||
module.exports = gulp.series(clean, gulp.parallel('build_js', 'compress_js_data', 'compress_js_vendor', 'copy_css', 'compress_html', 'copy_other_files'), 'upload_images', 'upload', 'invalidate');
|
||||
const publishFrontend = series(buildDist, uploadImages, upload, invalidate);
|
||||
|
||||
export default publishFrontend;
|
||||
|
||||
export {
|
||||
buildDist,
|
||||
};
|
||||
@@ -1,12 +1,9 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
const gulp = require('gulp');
|
||||
const del = require('del');
|
||||
const rename = require('gulp-rename');
|
||||
import { src, series, dest } from 'gulp';
|
||||
import { deleteAsync } from 'del';
|
||||
import rename from 'gulp-rename';
|
||||
|
||||
const clean = (cb) => {
|
||||
del(['./server/scripts/vendor/auto/**']);
|
||||
cb();
|
||||
};
|
||||
const clean = () => deleteAsync(['./server/scripts/vendor/auto/**']);
|
||||
|
||||
const vendorFiles = [
|
||||
'./node_modules/luxon/build/es6/luxon.js',
|
||||
@@ -17,13 +14,15 @@ const vendorFiles = [
|
||||
'./node_modules/swiped-events/src/swiped-events.js',
|
||||
];
|
||||
|
||||
const copy = () => gulp.src(vendorFiles)
|
||||
const copy = () => src(vendorFiles)
|
||||
.pipe(rename((path) => {
|
||||
path.dirname = path.dirname.toLowerCase();
|
||||
path.basename = path.basename.toLowerCase();
|
||||
path.extname = path.extname.toLowerCase();
|
||||
if (path.basename === 'luxon') path.extname = '.mjs';
|
||||
}))
|
||||
.pipe(gulp.dest('./server/scripts/vendor/auto'));
|
||||
.pipe(dest('./server/scripts/vendor/auto'));
|
||||
|
||||
module.exports = gulp.series(clean, copy);
|
||||
const updateVendor = series(clean, copy);
|
||||
|
||||
export default updateVendor;
|
||||
@@ -1,4 +0,0 @@
|
||||
const gulp = require('gulp');
|
||||
|
||||
gulp.task('update-vendor', require('./gulp/update-vendor'));
|
||||
gulp.task('publish-frontend', require('./gulp/publish-frontend'));
|
||||
8
gulpfile.mjs
Normal file
8
gulpfile.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
import updateVendor from './gulp/update-vendor.mjs';
|
||||
import publishFrontend, { buildDist } from './gulp/publish-frontend.mjs'
|
||||
|
||||
export {
|
||||
updateVendor,
|
||||
publishFrontend,
|
||||
buildDist,
|
||||
};
|
||||
56
index.js
56
index.js
@@ -1,56 +0,0 @@
|
||||
// express
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
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
53
index.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
10517
package-lock.json
generated
10517
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "5.10.0",
|
||||
"version": "5.16.0",
|
||||
"description": "Welcome to the WeatherStar 4000+ project page!",
|
||||
"main": "index.js",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build:css": "sass --style=compressed ./server/styles/scss/main.scss ./server/styles/main.css",
|
||||
"build": "gulp buildDist",
|
||||
"lint": "eslint ./server/scripts/**/*.mjs",
|
||||
"lint:fix": "eslint --fix ./server/scripts/**/*.mjs"
|
||||
},
|
||||
@@ -20,21 +22,20 @@
|
||||
},
|
||||
"homepage": "https://github.com/netbymatt/ws4kp#readme",
|
||||
"devDependencies": {
|
||||
"del": "^6.0.0",
|
||||
"ejs": "^3.1.5",
|
||||
"eslint": "^8.21.0",
|
||||
"@aws-sdk/client-cloudfront": "^3.609.0",
|
||||
"del": "^8.0.0",
|
||||
"eslint": "^8.2.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-sonarjs": "^0.21.0",
|
||||
"eslint-plugin-unicorn": "^46.0.0",
|
||||
"express": "^4.17.1",
|
||||
"gulp": "^4.0.2",
|
||||
"eslint-plugin-import": "^2.10.0",
|
||||
"gulp": "^5.0.0",
|
||||
"gulp-awspublish": "^8.0.0",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-ejs": "^5.1.0",
|
||||
"gulp-file": "^0.4.0",
|
||||
"gulp-htmlmin": "^5.0.1",
|
||||
"gulp-rename": "^2.0.0",
|
||||
"gulp-s3-upload": "^1.7.3",
|
||||
"gulp-sass": "^5.1.0",
|
||||
"gulp-sass": "^6.0.0",
|
||||
"gulp-terser": "^2.0.0",
|
||||
"jquery": "^3.6.0",
|
||||
"jquery-touchswipe": "^1.6.19",
|
||||
@@ -45,5 +46,9 @@
|
||||
"swiped-events": "^1.1.4",
|
||||
"terser-webpack-plugin": "^5.3.6",
|
||||
"webpack-stream": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ejs": "^3.1.5",
|
||||
"express": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 251 B After Width: | Height: | Size: 251 B |
|
Before Width: | Height: | Size: 455 B After Width: | Height: | Size: 455 B |
BIN
server/images/social/1200x600.png
Normal file
BIN
server/images/social/1200x600.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
BIN
server/music/default/Catch the Sun.mp3
Normal file
BIN
server/music/default/Catch the Sun.mp3
Normal file
Binary file not shown.
BIN
server/music/default/Crisp day.mp3
Normal file
BIN
server/music/default/Crisp day.mp3
Normal file
Binary file not shown.
BIN
server/music/default/Rolling Clouds.mp3
Normal file
BIN
server/music/default/Rolling Clouds.mp3
Normal file
Binary file not shown.
BIN
server/music/default/Strong Breeze.mp3
Normal file
BIN
server/music/default/Strong Breeze.mp3
Normal file
Binary file not shown.
3
server/music/readme.txt
Normal file
3
server/music/readme.txt
Normal 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
|
||||
25
server/scripts/custom.sample.js
Normal file
25
server/scripts/custom.sample.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// this file is loaded by the main html page (when renamed to custom.js)
|
||||
// it is intended to allow for customizations that do not get published back to the git repo
|
||||
// for example, changing the logo
|
||||
|
||||
const customTask = () => {
|
||||
// get all of the logo images
|
||||
const logos = document.querySelectorAll('.logo img');
|
||||
// loop through each logo
|
||||
logos.forEach((elem) => {
|
||||
// change the source
|
||||
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', () => {
|
||||
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import { json } from './modules/utils/fetch.mjs';
|
||||
import noSleep from './modules/utils/nosleep.mjs';
|
||||
import {
|
||||
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived, stopAutoRefreshTimer, registerRefreshData,
|
||||
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived,
|
||||
} from './modules/navigation.mjs';
|
||||
import { round2 } from './modules/utils/units.mjs';
|
||||
import { parseQueryString } from './modules/share.mjs';
|
||||
import settings from './modules/settings.mjs';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
getCustomCode();
|
||||
});
|
||||
|
||||
const categories = [
|
||||
@@ -29,8 +32,6 @@ const init = () => {
|
||||
e.target.select();
|
||||
});
|
||||
|
||||
registerRefreshData(loadData);
|
||||
|
||||
document.querySelector('#NavigateMenu').addEventListener('click', btnNavigateMenuClick);
|
||||
document.querySelector('#NavigateRefresh').addEventListener('click', btnNavigateRefreshClick);
|
||||
document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick);
|
||||
@@ -41,9 +42,12 @@ const init = () => {
|
||||
btnGetGps.addEventListener('click', btnGetGpsClick);
|
||||
if (!navigator.geolocation) btnGetGps.style.display = 'none';
|
||||
|
||||
document.querySelector('#divTwc').addEventListener('click', () => {
|
||||
document.querySelector('#divTwc').addEventListener('mousemove', () => {
|
||||
if (document.fullscreenElement) updateFullScreenNavigate();
|
||||
});
|
||||
// local change detection when exiting full screen via ESC key (or other non button click methods)
|
||||
window.addEventListener('resize', fullScreenResizeCheck);
|
||||
fullScreenResizeCheck.wasFull = false;
|
||||
|
||||
document.querySelector(TXT_ADDRESS_SELECTOR).addEventListener('keydown', (key) => { if (key.code === 'Enter') formSubmit(); });
|
||||
document.querySelector('#btnGetLatLng').addEventListener('click', () => formSubmit());
|
||||
@@ -81,10 +85,15 @@ const init = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Auto load the previous query
|
||||
const query = localStorage.getItem('latLonQuery');
|
||||
const latLon = localStorage.getItem('latLon');
|
||||
const fromGPS = localStorage.getItem('latLonFromGPS');
|
||||
// attempt to parse the url parameters
|
||||
const parsedParameters = parseQueryString();
|
||||
|
||||
const loadFromParsed = parsedParameters.latLonQuery && parsedParameters.latLon;
|
||||
|
||||
// Auto load the parsed parameters and fall back to the previous query
|
||||
const query = parsedParameters.latLonQuery ?? localStorage.getItem('latLonQuery');
|
||||
const latLon = parsedParameters.latLon ?? localStorage.getItem('latLon');
|
||||
const fromGPS = localStorage.getItem('latLonFromGPS') && !loadFromParsed;
|
||||
if (query && latLon && !fromGPS) {
|
||||
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
|
||||
txtAddress.value = query;
|
||||
@@ -94,7 +103,9 @@ const init = () => {
|
||||
btnGetGpsClick();
|
||||
}
|
||||
|
||||
const play = localStorage.getItem('play');
|
||||
// if kiosk mode was set via the query string, also play immediately
|
||||
settings.kiosk.value = parsedParameters['settings-kiosk-checkbox'] === 'true';
|
||||
const play = parsedParameters['settings-kiosk-checkbox'] ?? localStorage.getItem('play');
|
||||
if (play === null || play === 'true') postMessage('navButton', 'play');
|
||||
|
||||
document.querySelector('#btnClearQuery').addEventListener('click', () => {
|
||||
@@ -176,11 +187,11 @@ const enterFullScreen = () => {
|
||||
|
||||
// Supports most browsers and their versions.
|
||||
const requestMethod = element.requestFullScreen || element.webkitRequestFullScreen
|
||||
|| element.mozRequestFullScreen || element.msRequestFullscreen;
|
||||
|| element.mozRequestFullScreen || element.msRequestFullscreen;
|
||||
|
||||
if (requestMethod) {
|
||||
// Native full screen.
|
||||
requestMethod.call(element, { navigationUI: 'hide' }); // https://bugs.chromium.org/p/chromium/issues/detail?id=933436#c7
|
||||
requestMethod.call(element, { navigationUI: 'hide' });
|
||||
} else {
|
||||
// iOS doesn't support FullScreen API.
|
||||
window.scrollTo(0, 0);
|
||||
@@ -208,10 +219,15 @@ const exitFullscreen = () => {
|
||||
document.msExitFullscreen();
|
||||
}
|
||||
resize();
|
||||
exitFullScreenVisibilityChanges();
|
||||
};
|
||||
|
||||
const exitFullScreenVisibilityChanges = () => {
|
||||
// change hover text and image
|
||||
const img = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR);
|
||||
img.src = 'images/nav/ic_fullscreen_white_24dp_2x.png';
|
||||
img.title = 'Enter fullscreen';
|
||||
document.querySelector('#divTwc').classList.remove('no-cursor');
|
||||
const divTwcBottom = document.querySelector('#divTwcBottom');
|
||||
divTwcBottom.classList.remove('hidden');
|
||||
divTwcBottom.classList.add('visible');
|
||||
@@ -219,7 +235,6 @@ const exitFullscreen = () => {
|
||||
|
||||
const btnNavigateMenuClick = () => {
|
||||
postMessage('navButton', 'menu');
|
||||
updateFullScreenNavigate();
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -232,41 +247,37 @@ const loadData = (_latLon, haveDataCallback) => {
|
||||
if (!latLon) return;
|
||||
|
||||
document.querySelector(TXT_ADDRESS_SELECTOR).blur();
|
||||
stopAutoRefreshTimer();
|
||||
latLonReceived(latLon, haveDataCallback);
|
||||
};
|
||||
|
||||
const swipeCallBack = (direction) => {
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
btnNavigateNextClick();
|
||||
break;
|
||||
case 'left':
|
||||
btnNavigateNextClick();
|
||||
break;
|
||||
|
||||
case 'right':
|
||||
default:
|
||||
btnNavigatePreviousClick();
|
||||
break;
|
||||
case 'right':
|
||||
default:
|
||||
btnNavigatePreviousClick();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const btnNavigateRefreshClick = () => {
|
||||
resetStatuses();
|
||||
loadData();
|
||||
updateFullScreenNavigate();
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const btnNavigateNextClick = () => {
|
||||
postMessage('navButton', 'next');
|
||||
updateFullScreenNavigate();
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const btnNavigatePreviousClick = () => {
|
||||
postMessage('navButton', 'previous');
|
||||
updateFullScreenNavigate();
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -278,6 +289,7 @@ const updateFullScreenNavigate = () => {
|
||||
const divTwcBottom = document.querySelector('#divTwcBottom');
|
||||
divTwcBottom.classList.remove('hidden');
|
||||
divTwcBottom.classList.add('visible');
|
||||
document.querySelector('#divTwc').classList.remove('no-cursor');
|
||||
|
||||
if (navigateFadeIntervalId) {
|
||||
clearTimeout(navigateFadeIntervalId);
|
||||
@@ -288,6 +300,7 @@ const updateFullScreenNavigate = () => {
|
||||
if (document.fullscreenElement) {
|
||||
divTwcBottom.classList.remove('visible');
|
||||
divTwcBottom.classList.add('hidden');
|
||||
document.querySelector('#divTwc').classList.add('no-cursor');
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
@@ -297,41 +310,41 @@ const documentKeydown = (e) => {
|
||||
|
||||
if (document.fullscreenElement || document.activeElement === document.body) {
|
||||
switch (key) {
|
||||
case ' ': // Space
|
||||
// don't scroll
|
||||
e.preventDefault();
|
||||
btnNavigatePlayClick();
|
||||
return false;
|
||||
case ' ': // Space
|
||||
// don't scroll
|
||||
e.preventDefault();
|
||||
btnNavigatePlayClick();
|
||||
return false;
|
||||
|
||||
case 'ArrowRight':
|
||||
case 'PageDown':
|
||||
// don't scroll
|
||||
e.preventDefault();
|
||||
btnNavigateNextClick();
|
||||
return false;
|
||||
case 'ArrowRight':
|
||||
case 'PageDown':
|
||||
// don't scroll
|
||||
e.preventDefault();
|
||||
btnNavigateNextClick();
|
||||
return false;
|
||||
|
||||
case 'ArrowLeft':
|
||||
case 'PageUp':
|
||||
// don't scroll
|
||||
e.preventDefault();
|
||||
btnNavigatePreviousClick();
|
||||
return false;
|
||||
case 'ArrowLeft':
|
||||
case 'PageUp':
|
||||
// don't scroll
|
||||
e.preventDefault();
|
||||
btnNavigatePreviousClick();
|
||||
return false;
|
||||
|
||||
case 'ArrowUp': // Home
|
||||
e.preventDefault();
|
||||
btnNavigateMenuClick();
|
||||
return false;
|
||||
case 'ArrowUp': // Home
|
||||
e.preventDefault();
|
||||
btnNavigateMenuClick();
|
||||
return false;
|
||||
|
||||
case '0': // "O" Restart
|
||||
btnNavigateRefreshClick();
|
||||
return false;
|
||||
case '0': // "O" Restart
|
||||
btnNavigateRefreshClick();
|
||||
return false;
|
||||
|
||||
case 'F':
|
||||
case 'f':
|
||||
btnFullScreenClick();
|
||||
return false;
|
||||
case 'F':
|
||||
case 'f':
|
||||
btnFullScreenClick();
|
||||
return false;
|
||||
|
||||
default:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -339,7 +352,6 @@ const documentKeydown = (e) => {
|
||||
|
||||
const btnNavigatePlayClick = () => {
|
||||
postMessage('navButton', 'playToggle');
|
||||
updateFullScreenNavigate();
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -384,3 +396,30 @@ const btnGetGpsClick = async () => {
|
||||
txtAddress.value = `${location.city}, ${location.state}`;
|
||||
});
|
||||
};
|
||||
|
||||
// check for change in full screen triggered by browser and run local functions
|
||||
const fullScreenResizeCheck = () => {
|
||||
if (fullScreenResizeCheck.wasFull && !document.fullscreenElement) {
|
||||
// leaving full screen
|
||||
exitFullScreenVisibilityChanges();
|
||||
}
|
||||
if (!fullScreenResizeCheck.wasFull && document.fullscreenElement) {
|
||||
// entering full screen
|
||||
// can't do much here because a UI interaction is required to change the full screen div element
|
||||
}
|
||||
|
||||
// store state of fullscreen element for next change detection
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { loadImg, preloadImg } from './utils/image.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import STATUS from './status.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
|
||||
class Almanac extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -21,12 +21,11 @@ class Almanac extends WeatherDisplay {
|
||||
this.timing.totalScreens = 1;
|
||||
}
|
||||
|
||||
async getData(_weatherParameters) {
|
||||
const superResponse = super.getData(_weatherParameters);
|
||||
const weatherParameters = _weatherParameters ?? this.weatherParameters;
|
||||
async getData(weatherParameters, refresh) {
|
||||
const superResponse = super.getData(weatherParameters, refresh);
|
||||
|
||||
// get sun/moon data
|
||||
const { sun, moon } = this.calcSunMoonData(weatherParameters);
|
||||
const { sun, moon } = this.calcSunMoonData(this.weatherParameters);
|
||||
|
||||
// store the data
|
||||
this.data = {
|
||||
@@ -94,7 +93,7 @@ class Almanac extends WeatherDisplay {
|
||||
if (iteration % 2 === 0) test = (lastPhase, testPhase) => lastPhase > threshold && testPhase <= threshold;
|
||||
|
||||
do {
|
||||
// store last phase
|
||||
// store last phase
|
||||
const lastPhase = phase;
|
||||
// calculate new phase after step
|
||||
moonDate = moonDate.plus(step);
|
||||
@@ -103,7 +102,7 @@ class Almanac extends WeatherDisplay {
|
||||
if (phase > 0.9) phase -= 1.0;
|
||||
// compare
|
||||
if (test(lastPhase, phase)) {
|
||||
// last iteration is three, return value
|
||||
// last iteration is three, return value
|
||||
if (iteration >= 3) break;
|
||||
// iterate recursively
|
||||
return this.getMoonTransition(threshold, phaseName, moonDate, iteration + 1);
|
||||
@@ -123,10 +122,10 @@ class Almanac extends WeatherDisplay {
|
||||
// sun and moon data
|
||||
this.elem.querySelector('.day-1').innerHTML = Today.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.day-2').innerHTML = Tomorrow.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.rise-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunrise).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('.set-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunset).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('.rise-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
this.elem.querySelector('.rise-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
this.elem.querySelector('.set-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
this.elem.querySelector('.set-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
|
||||
const days = info.moon.map((MoonPhase) => {
|
||||
const fill = {};
|
||||
@@ -160,15 +159,15 @@ class Almanac extends WeatherDisplay {
|
||||
|
||||
const imageName = (type) => {
|
||||
switch (type) {
|
||||
case 'Full':
|
||||
return 'images/2/Full-Moon.gif';
|
||||
case 'Last':
|
||||
return 'images/2/Last-Quarter.gif';
|
||||
case 'New':
|
||||
return 'images/2/New-Moon.gif';
|
||||
case 'First':
|
||||
default:
|
||||
return 'images/2/First-Quarter.gif';
|
||||
case 'Full':
|
||||
return 'images/2/Full-Moon.gif';
|
||||
case 'Last':
|
||||
return 'images/2/Last-Quarter.gif';
|
||||
case 'New':
|
||||
return 'images/2/New-Moon.gif';
|
||||
case 'First':
|
||||
default:
|
||||
return 'images/2/First-Quarter.gif';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getWeatherIconFromIconLink } from './icons.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import {
|
||||
celsiusToFahrenheit, kphToMph, pascalToInHg, metersToFeet, kilometersToMiles,
|
||||
temperature, windSpeed, pressure, distanceMeters, distanceKilometers,
|
||||
} from './utils/units.mjs';
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
async getData(_weatherParameters) {
|
||||
async getData(weatherParameters, refresh) {
|
||||
// always load the data for use in the lower scroll
|
||||
const superResult = super.getData(_weatherParameters);
|
||||
const weatherParameters = _weatherParameters ?? this.weatherParameters;
|
||||
const superResult = super.getData(weatherParameters, refresh);
|
||||
// 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
|
||||
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
|
||||
let observations;
|
||||
@@ -56,7 +57,9 @@ class CurrentWeather extends WeatherDisplay {
|
||||
|| observations.features[0].properties.windSpeed.value === null
|
||||
|| observations.features[0].properties.textDescription === null
|
||||
|| observations.features[0].properties.textDescription === ''
|
||||
|| observations.features[0].properties.icon === null) {
|
||||
|| observations.features[0].properties.icon === null
|
||||
|| observations.features[0].properties.dewpoint.value === null
|
||||
|| observations.features[0].properties.barometricPressure.value === null) {
|
||||
observations = undefined;
|
||||
throw new Error(`Unable to get observations: ${station.properties.stationIdentifier}, trying next station`);
|
||||
}
|
||||
@@ -157,23 +160,32 @@ const shortConditions = (_condition) => {
|
||||
|
||||
// format the received 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;
|
||||
// values from api are provided in metric
|
||||
data.observations = observations;
|
||||
data.Temperature = Math.round(observations.temperature.value);
|
||||
data.TemperatureUnit = 'C';
|
||||
data.DewPoint = Math.round(observations.dewpoint.value);
|
||||
data.Ceiling = Math.round(observations.cloudLayers[0]?.base?.value ?? 0);
|
||||
data.CeilingUnit = 'm.';
|
||||
data.Visibility = Math.round(observations.visibility.value / 1000);
|
||||
data.VisibilityUnit = ' km.';
|
||||
data.WindSpeed = Math.round(observations.windSpeed.value);
|
||||
data.Temperature = temperatureConverter(observations.temperature.value);
|
||||
data.TemperatureUnit = temperatureConverter.units;
|
||||
data.DewPoint = temperatureConverter(observations.dewpoint.value);
|
||||
data.Ceiling = metersConverter(observations.cloudLayers[0]?.base?.value ?? 0);
|
||||
data.CeilingUnit = metersConverter.units;
|
||||
data.Visibility = kilometersConverter(observations.visibility.value);
|
||||
data.VisibilityUnit = kilometersConverter.units;
|
||||
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.Pressure = Math.round(observations.barometricPressure.value);
|
||||
data.HeatIndex = Math.round(observations.heatIndex.value);
|
||||
data.WindChill = Math.round(observations.windChill.value);
|
||||
data.WindGust = Math.round(observations.windGust.value);
|
||||
data.WindUnit = 'KPH';
|
||||
data.WindGust = windConverter(observations.windGust.value);
|
||||
data.WindSpeed = windConverter(data.WindSpeed);
|
||||
data.WindUnit = windConverter.units;
|
||||
data.Humidity = Math.round(observations.relativeHumidity.value);
|
||||
data.Icon = getWeatherIconFromIconLink(observations.icon);
|
||||
data.PressureDirection = '';
|
||||
@@ -184,20 +196,6 @@ const parseData = (data) => {
|
||||
if (pressureDiff > 150) data.PressureDirection = 'R';
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ const screens = [
|
||||
(data) => `Humidity: ${data.Humidity}% Dewpoint: ${data.DewPoint}${degree}${data.TemperatureUnit}`,
|
||||
|
||||
// barometric pressure
|
||||
(data) => `Barometric Pressure: ${data.Pressure} ${data.PressureDirection}`,
|
||||
(data) => `Barometric Pressure: ${data.Pressure} ${data.PressureUnit} ${data.PressureDirection}`,
|
||||
|
||||
// wind
|
||||
(data) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getWeatherIconFromIconLink } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import settings from './settings.mjs';
|
||||
|
||||
class ExtendedForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -17,16 +18,14 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
this.timing.totalScreens = 2;
|
||||
}
|
||||
|
||||
async getData(_weatherParameters) {
|
||||
if (!super.getData(_weatherParameters)) return;
|
||||
const weatherParameters = _weatherParameters ?? this.weatherParameters;
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// request us or si units
|
||||
let forecast;
|
||||
try {
|
||||
forecast = await json(weatherParameters.forecast, {
|
||||
this.data = await json(this.weatherParameters.forecast, {
|
||||
data: {
|
||||
units: 'us',
|
||||
units: settings.units.value,
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
@@ -34,11 +33,13 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
} catch (error) {
|
||||
console.error('Unable to get extended forecast');
|
||||
console.error(error.status, error.responseJSON);
|
||||
this.setStatus(STATUS.failed);
|
||||
return;
|
||||
// if there's no previous data, fail
|
||||
if (!this.data) {
|
||||
this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// we only get here if there was no error above
|
||||
this.data = parse(forecast.properties.periods);
|
||||
this.screenIndex = 0;
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
@@ -48,7 +49,7 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
|
||||
// determine bounds
|
||||
// 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
|
||||
const days = forecast.map((Day) => {
|
||||
@@ -131,7 +132,7 @@ const shortenExtendedForecastText = (long) => {
|
||||
[/dense /gi, ''],
|
||||
[/Thunderstorm/g, 'T\'Storm'],
|
||||
];
|
||||
// run all regexes
|
||||
// run all regexes
|
||||
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);
|
||||
|
||||
let conditions = short.split(' ');
|
||||
|
||||
@@ -10,6 +10,12 @@ const hazardLevels = {
|
||||
Severe: 5,
|
||||
};
|
||||
|
||||
const hazardModifiers = {
|
||||
'Hurricane Warning': 2,
|
||||
'Tornado Warning': 3,
|
||||
'Severe Thunderstorm Warning': 1,
|
||||
};
|
||||
|
||||
class Hazards extends WeatherDisplay {
|
||||
constructor(navId, elemId, defaultActive) {
|
||||
// special height and width for scrolling
|
||||
@@ -20,9 +26,11 @@ class Hazards extends WeatherDisplay {
|
||||
this.timing.totalScreens = 0;
|
||||
}
|
||||
|
||||
async getData(weatherParameters) {
|
||||
async getData(weatherParameters, refresh) {
|
||||
// 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');
|
||||
alert.classList.remove('show');
|
||||
@@ -34,8 +42,9 @@ class Hazards extends WeatherDisplay {
|
||||
url.searchParams.append('limit', 5);
|
||||
const alerts = await json(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
const unsortedAlerts = alerts.features ?? [];
|
||||
const sortedAlerts = unsortedAlerts.sort((a, b) => (hazardLevels[b.properties.severity] ?? 0) - (hazardLevels[a.properties.severity] ?? 0));
|
||||
const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown');
|
||||
const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false);
|
||||
const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event)));
|
||||
const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate')));
|
||||
this.data = filteredAlerts;
|
||||
|
||||
// show alert indicator
|
||||
@@ -66,7 +75,7 @@ class Hazards extends WeatherDisplay {
|
||||
const lines = this.data.map((data) => {
|
||||
const fillValues = {};
|
||||
// text
|
||||
fillValues['hazard-text'] = `${data.properties.event}<br/><br/>${data.properties.description.replace('\n', '<br/><br/>')}`;
|
||||
fillValues['hazard-text'] = `${data.properties.event}<br/><br/>${data.properties.description.replaceAll('\n\n', '<br/><br/>').replaceAll('\n', ' ')}`;
|
||||
|
||||
return this.fillTemplate('hazard', fillValues);
|
||||
});
|
||||
@@ -115,7 +124,7 @@ class Hazards extends WeatherDisplay {
|
||||
// base count change callback
|
||||
baseCountChange(count) {
|
||||
// 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
|
||||
if (offsetY < 0) offsetY = 0;
|
||||
@@ -134,7 +143,26 @@ class Hazards extends WeatherDisplay {
|
||||
this.getDataCallbacks.push(() => resolve(this.data));
|
||||
});
|
||||
}
|
||||
|
||||
// after we roll through the hazards once, don't display again until the next refresh (10 minutes)
|
||||
screenIndexFromBaseCount() {
|
||||
const superValue = super.screenIndexFromBaseCount();
|
||||
// false is returned when we reach the end of the scroll
|
||||
if (superValue === false) {
|
||||
// set total screens to zero to take this out of the rotation
|
||||
this.timing.totalScreens = 0;
|
||||
}
|
||||
// return the value as expected
|
||||
return superValue;
|
||||
}
|
||||
}
|
||||
|
||||
const calcSeverity = (severity, event) => {
|
||||
// base severity plus some modifiers for specific types of warnings
|
||||
const baseSeverity = hazardLevels[severity] ?? 0;
|
||||
const modifiedSeverity = hazardModifiers[event] ?? 0;
|
||||
return baseSeverity + modifiedSeverity;
|
||||
};
|
||||
|
||||
// register display
|
||||
registerDisplay(new Hazards(0, 'hazards', true));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import STATUS from './status.mjs';
|
||||
import getHourlyData from './hourly.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
|
||||
class HourlyGraph extends WeatherDisplay {
|
||||
@@ -23,8 +23,8 @@ class HourlyGraph extends WeatherDisplay {
|
||||
this.elem.querySelector('.header .right').append(header);
|
||||
}
|
||||
|
||||
async getData() {
|
||||
if (!super.getData()) return;
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(undefined, refresh)) return;
|
||||
|
||||
const data = await getHourlyData(() => this.stillWaiting());
|
||||
if (data === undefined) {
|
||||
@@ -38,7 +38,7 @@ class HourlyGraph extends WeatherDisplay {
|
||||
const skyCover = data.map((d) => d.skyCover);
|
||||
|
||||
this.data = {
|
||||
skyCover, temperature, probabilityOfPrecipitation,
|
||||
skyCover, temperature, probabilityOfPrecipitation, temperatureUnit: data[0].temperatureUnit,
|
||||
};
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
@@ -107,6 +107,9 @@ class HourlyGraph extends WeatherDisplay {
|
||||
// set the image source
|
||||
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();
|
||||
this.finishDraw();
|
||||
}
|
||||
@@ -142,7 +145,7 @@ const drawPath = (path, ctx, options) => {
|
||||
};
|
||||
|
||||
// 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
|
||||
registerDisplay(new HourlyGraph(4, 'hourly-graph'));
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import STATUS from './status.mjs';
|
||||
import { DateTime, Interval, Duration } from '../vendor/auto/luxon.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 { directionToNSEW } from './utils/calc.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
import getSun from './almanac.mjs';
|
||||
|
||||
class Hourly extends WeatherDisplay {
|
||||
@@ -27,23 +27,30 @@ class Hourly extends WeatherDisplay {
|
||||
this.timing.delay.push(150);
|
||||
}
|
||||
|
||||
async getData(weatherParameters) {
|
||||
async getData(weatherParameters, refresh) {
|
||||
// super checks for enabled
|
||||
const superResponse = super.getData(weatherParameters);
|
||||
const superResponse = super.getData(weatherParameters, refresh);
|
||||
let forecast;
|
||||
try {
|
||||
// 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) {
|
||||
console.error('Get hourly forecast failed');
|
||||
console.error(error.status, error.responseJSON);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
// return undefined to other subscribers
|
||||
this.getDataCallback(undefined);
|
||||
return;
|
||||
// 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);
|
||||
// return undefined to other subscribers
|
||||
this.getDataCallback(undefined);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.data = await parseForecast(forecast.properties);
|
||||
this.getDataCallback();
|
||||
if (!superResponse) return;
|
||||
|
||||
@@ -56,7 +63,7 @@ class Hourly extends WeatherDisplay {
|
||||
const list = this.elem.querySelector('.hourly-lines');
|
||||
list.innerHTML = '';
|
||||
|
||||
const startingHour = DateTime.local();
|
||||
const startingHour = DateTime.local().setZone(timeZone());
|
||||
|
||||
const lines = this.data.map((data, index) => {
|
||||
const fillValues = {};
|
||||
@@ -66,8 +73,8 @@ class Hourly extends WeatherDisplay {
|
||||
fillValues.hour = formattedHour;
|
||||
|
||||
// temperatures, convert to strings with no decimal
|
||||
const temperature = Math.round(data.temperature).toString().padStart(3);
|
||||
const feelsLike = Math.round(data.apparentTemperature).toString().padStart(3);
|
||||
const temperature = data.temperature.toString().padStart(3);
|
||||
const feelsLike = data.apparentTemperature.toString().padStart(3);
|
||||
fillValues.temp = temperature;
|
||||
// only plot apparent temperature if there is a difference
|
||||
// if (temperature !== feelsLike) line.querySelector('.like').innerHTML = feelsLike;
|
||||
@@ -109,7 +116,7 @@ class Hourly extends WeatherDisplay {
|
||||
// base count change callback
|
||||
baseCountChange(count) {
|
||||
// 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
|
||||
if (offsetY < 0) offsetY = 0;
|
||||
@@ -132,6 +139,11 @@ class Hourly extends WeatherDisplay {
|
||||
|
||||
// extract specific values from forecast and format as an array
|
||||
const parseForecast = async (data) => {
|
||||
// get unit converters
|
||||
const temperatureConverter = temperatureUnit();
|
||||
const distanceConverter = distanceKilometers();
|
||||
|
||||
// parse data
|
||||
const temperature = expand(data.temperature.values);
|
||||
const apparentTemperature = expand(data.apparentTemperature.values);
|
||||
const windSpeed = expand(data.windSpeed.values);
|
||||
@@ -145,9 +157,11 @@ const parseForecast = async (data) => {
|
||||
const icons = await determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed);
|
||||
|
||||
return temperature.map((val, idx) => ({
|
||||
temperature: celsiusToFahrenheit(temperature[idx]),
|
||||
apparentTemperature: celsiusToFahrenheit(apparentTemperature[idx]),
|
||||
windSpeed: kilometersToMiles(windSpeed[idx]),
|
||||
temperature: temperatureConverter(temperature[idx]),
|
||||
temperatureUnit: temperatureConverter.units,
|
||||
apparentTemperature: temperatureConverter(apparentTemperature[idx]),
|
||||
windSpeed: distanceConverter(windSpeed[idx]),
|
||||
windUnit: distanceConverter.units,
|
||||
windDirection: directionToNSEW(windDirection[idx]),
|
||||
probabilityOfPrecipitation: probabilityOfPrecipitation[idx],
|
||||
skyCover: skyCover[idx],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable unicorn/consistent-function-scoping */
|
||||
/* spell-checker: disable */
|
||||
|
||||
const getWeatherRegionalIconFromIconLink = (link, _isNightTime) => {
|
||||
@@ -21,136 +20,138 @@ const getWeatherRegionalIconFromIconLink = (link, _isNightTime) => {
|
||||
|
||||
// find the icon
|
||||
switch (conditionName + (isNightTime ? '-n' : '')) {
|
||||
case 'skc':
|
||||
case 'hot':
|
||||
case 'haze':
|
||||
return addPath('Sunny.gif');
|
||||
case 'skc':
|
||||
case 'hot':
|
||||
case 'haze':
|
||||
return addPath('Sunny.gif');
|
||||
|
||||
case 'skc-n':
|
||||
case 'nskc':
|
||||
case 'nskc-n':
|
||||
case 'cold-n':
|
||||
return addPath('Clear-1992.gif');
|
||||
case 'skc-n':
|
||||
case 'nskc':
|
||||
case 'nskc-n':
|
||||
case 'cold-n':
|
||||
return addPath('Clear-1992.gif');
|
||||
|
||||
case 'bkn':
|
||||
return addPath('Mostly-Cloudy-1994-2.gif');
|
||||
case 'bkn':
|
||||
return addPath('Mostly-Cloudy-1994-2.gif');
|
||||
|
||||
case 'bkn-n':
|
||||
case 'few-n':
|
||||
case 'nfew-n':
|
||||
case 'nfew':
|
||||
return addPath('Partly-Clear-1994-2.gif');
|
||||
case 'bkn-n':
|
||||
case 'few-n':
|
||||
case 'nfew-n':
|
||||
case 'nfew':
|
||||
return addPath('Partly-Clear-1994-2.gif');
|
||||
|
||||
case 'sct':
|
||||
case 'few':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
case 'sct':
|
||||
case 'few':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'sct-n':
|
||||
case 'nsct':
|
||||
case 'nsct-n':
|
||||
return addPath('Mostly-Clear.gif');
|
||||
case 'sct-n':
|
||||
case 'nsct':
|
||||
case 'nsct-n':
|
||||
return addPath('Mostly-Clear.gif');
|
||||
|
||||
case 'ovc':
|
||||
case 'ovc-n':
|
||||
return addPath('Cloudy.gif');
|
||||
case 'ovc':
|
||||
case 'ovc-n':
|
||||
return addPath('Cloudy.gif');
|
||||
|
||||
case 'fog':
|
||||
case 'fog-n':
|
||||
return addPath('Fog.gif');
|
||||
case 'fog':
|
||||
case 'fog-n':
|
||||
return addPath('Fog.gif');
|
||||
|
||||
case 'rain_sleet':
|
||||
return addPath('Sleet.gif');
|
||||
case 'rain_sleet':
|
||||
return addPath('Sleet.gif');
|
||||
|
||||
case 'rain_showers':
|
||||
case 'rain_showers_high':
|
||||
return addPath('Scattered-Showers-1994-2.gif');
|
||||
case 'rain_showers':
|
||||
case 'rain_showers_high':
|
||||
return addPath('Scattered-Showers-1994-2.gif');
|
||||
|
||||
case 'rain_showers-n':
|
||||
case 'rain_showers_high-n':
|
||||
return addPath('Scattered-Showers-Night-1994-2.gif');
|
||||
case 'rain_showers-n':
|
||||
case 'rain_showers_high-n':
|
||||
return addPath('Scattered-Showers-Night-1994-2.gif');
|
||||
|
||||
case 'rain':
|
||||
case 'rain-n':
|
||||
return addPath('Rain-1992.gif');
|
||||
case 'rain':
|
||||
case 'rain-n':
|
||||
return addPath('Rain-1992.gif');
|
||||
|
||||
// case 'snow':
|
||||
// return addPath('Light-Snow.gif');
|
||||
// break;
|
||||
// case 'snow':
|
||||
// return addPath('Light-Snow.gif');
|
||||
// break;
|
||||
|
||||
// case 'cc_snowshowers.gif':
|
||||
// //case "heavy-snow.gif":
|
||||
// return addPath('AM-Snow-1994.gif');
|
||||
// break;
|
||||
// case 'cc_snowshowers.gif':
|
||||
// //case "heavy-snow.gif":
|
||||
// return addPath('AM-Snow-1994.gif');
|
||||
// break;
|
||||
|
||||
case 'snow':
|
||||
case 'snow-n':
|
||||
if (value > 50) return addPath('Heavy-Snow-1994-2.gif');
|
||||
return addPath('Light-Snow.gif');
|
||||
case 'snow':
|
||||
case 'snow-n':
|
||||
if (value > 50) return addPath('Heavy-Snow-1994-2.gif');
|
||||
return addPath('Light-Snow.gif');
|
||||
|
||||
case 'rain_snow':
|
||||
case 'rain_snow-n':
|
||||
return addPath('Rain-Snow-1992.gif');
|
||||
case 'rain_snow':
|
||||
case 'rain_snow-n':
|
||||
return addPath('Rain-Snow-1992.gif');
|
||||
|
||||
case 'snow_fzra':
|
||||
case 'snow_fzra-n':
|
||||
return addPath('Freezing-Rain-Snow-1992.gif');
|
||||
case 'snow_fzra':
|
||||
case 'snow_fzra-n':
|
||||
return addPath('Freezing-Rain-Snow-1992.gif');
|
||||
|
||||
case 'fzra':
|
||||
case 'fzra-n':
|
||||
case 'rain_fzra':
|
||||
case 'rain_fzra-n':
|
||||
return addPath('Freezing-Rain-1992.gif');
|
||||
case 'fzra':
|
||||
case 'fzra-n':
|
||||
case 'rain_fzra':
|
||||
case 'rain_fzra-n':
|
||||
return addPath('Freezing-Rain-1992.gif');
|
||||
|
||||
case 'snow_sleet':
|
||||
case 'snow_sleet-n':
|
||||
return addPath('Snow and Sleet.gif');
|
||||
case 'snow_sleet':
|
||||
case 'snow_sleet-n':
|
||||
return addPath('Snow and Sleet.gif');
|
||||
|
||||
case 'sleet':
|
||||
case 'sleet-n':
|
||||
return addPath('Sleet.gif');
|
||||
case 'sleet':
|
||||
case 'sleet-n':
|
||||
return addPath('Sleet.gif');
|
||||
|
||||
case 'tsra_sct':
|
||||
case 'tsra':
|
||||
return addPath('Scattered-Tstorms-1994-2.gif');
|
||||
case 'tsra_sct':
|
||||
case 'tsra':
|
||||
return addPath('Scattered-Tstorms-1994-2.gif');
|
||||
|
||||
case 'tsra_sct-n':
|
||||
case 'tsra-n':
|
||||
return addPath('Scattered-Tstorms-Night-1994-2.gif');
|
||||
case 'tsra_sct-n':
|
||||
case 'tsra-n':
|
||||
return addPath('Scattered-Tstorms-Night-1994-2.gif');
|
||||
|
||||
case 'tsra_hi':
|
||||
case 'tsra_hi-n':
|
||||
case 'hurricane':
|
||||
case 'tropical_storm':
|
||||
return addPath('Thunderstorm.gif');
|
||||
case 'tsra_hi':
|
||||
case 'tsra_hi-n':
|
||||
case 'hurricane':
|
||||
case 'tropical_storm':
|
||||
case 'hurricane-n':
|
||||
case 'tropical_storm-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind':
|
||||
case 'wind_few':
|
||||
case 'wind_sct':
|
||||
case 'wind_bkn':
|
||||
case 'wind_ovc':
|
||||
case 'wind-n':
|
||||
case 'wind_few-n':
|
||||
case 'wind_bkn-n':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Wind.gif');
|
||||
case 'wind':
|
||||
case 'wind_few':
|
||||
case 'wind_sct':
|
||||
case 'wind_bkn':
|
||||
case 'wind_ovc':
|
||||
case 'wind-n':
|
||||
case 'wind_few-n':
|
||||
case 'wind_bkn-n':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Wind.gif');
|
||||
|
||||
case 'wind_skc':
|
||||
return addPath('Sunny-Wind-1994.gif');
|
||||
case 'wind_skc':
|
||||
return addPath('Sunny-Wind-1994.gif');
|
||||
|
||||
case 'wind_skc-n':
|
||||
case 'wind_sct-n':
|
||||
return addPath('Clear-Wind-1994.gif');
|
||||
case 'wind_skc-n':
|
||||
case 'wind_sct-n':
|
||||
return addPath('Clear-Wind-1994.gif');
|
||||
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing Snow.gif');
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing Snow.gif');
|
||||
|
||||
case 'cold':
|
||||
return addPath('cold.gif');
|
||||
case 'cold':
|
||||
return addPath('cold.gif');
|
||||
|
||||
default:
|
||||
console.log(`Unable to locate regional icon for ${conditionName} ${link} ${isNightTime}`);
|
||||
return false;
|
||||
default:
|
||||
console.log(`Unable to locate regional icon for ${conditionName} ${link} ${isNightTime}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,120 +177,122 @@ const getWeatherIconFromIconLink = (link, _isNightTime) => {
|
||||
|
||||
// find the icon
|
||||
switch (conditionName + (isNightTime ? '-n' : '')) {
|
||||
case 'skc':
|
||||
case 'hot':
|
||||
case 'haze':
|
||||
case 'cold':
|
||||
return addPath('CC_Clear1.gif');
|
||||
case 'skc':
|
||||
case 'hot':
|
||||
case 'haze':
|
||||
case 'cold':
|
||||
return addPath('CC_Clear1.gif');
|
||||
|
||||
case 'skc-n':
|
||||
case 'nskc':
|
||||
case 'nskc-n':
|
||||
case 'cold-n':
|
||||
return addPath('CC_Clear0.gif');
|
||||
case 'skc-n':
|
||||
case 'nskc':
|
||||
case 'nskc-n':
|
||||
case 'cold-n':
|
||||
return addPath('CC_Clear0.gif');
|
||||
|
||||
case 'sct':
|
||||
case 'few':
|
||||
case 'bkn':
|
||||
return addPath('CC_PartlyCloudy1.gif');
|
||||
case 'sct':
|
||||
case 'few':
|
||||
case 'bkn':
|
||||
return addPath('CC_PartlyCloudy1.gif');
|
||||
|
||||
case 'bkn-n':
|
||||
case 'few-n':
|
||||
case 'nfew-n':
|
||||
case 'nfew':
|
||||
case 'sct-n':
|
||||
case 'nsct':
|
||||
case 'nsct-n':
|
||||
return addPath('CC_PartlyCloudy0.gif');
|
||||
case 'bkn-n':
|
||||
case 'few-n':
|
||||
case 'nfew-n':
|
||||
case 'nfew':
|
||||
case 'sct-n':
|
||||
case 'nsct':
|
||||
case 'nsct-n':
|
||||
return addPath('CC_PartlyCloudy0.gif');
|
||||
|
||||
case 'ovc':
|
||||
case 'novc':
|
||||
case 'ovc-n':
|
||||
return addPath('CC_Cloudy.gif');
|
||||
case 'ovc':
|
||||
case 'novc':
|
||||
case 'ovc-n':
|
||||
return addPath('CC_Cloudy.gif');
|
||||
|
||||
case 'fog':
|
||||
case 'fog-n':
|
||||
return addPath('CC_Fog.gif');
|
||||
case 'fog':
|
||||
case 'fog-n':
|
||||
return addPath('CC_Fog.gif');
|
||||
|
||||
case 'rain_sleet':
|
||||
case 'rain_sleet-n':
|
||||
case 'sleet':
|
||||
case 'sleet-n':
|
||||
return addPath('Sleet.gif');
|
||||
case 'rain_sleet':
|
||||
case 'rain_sleet-n':
|
||||
case 'sleet':
|
||||
case 'sleet-n':
|
||||
return addPath('Sleet.gif');
|
||||
|
||||
case 'rain_showers':
|
||||
case 'rain_showers_high':
|
||||
return addPath('CC_Showers.gif');
|
||||
case 'rain_showers':
|
||||
case 'rain_showers_high':
|
||||
return addPath('CC_Showers.gif');
|
||||
|
||||
case 'rain_showers-n':
|
||||
case 'rain_showers_high-n':
|
||||
return addPath('CC_Showers.gif');
|
||||
case 'rain_showers-n':
|
||||
case 'rain_showers_high-n':
|
||||
return addPath('CC_Showers.gif');
|
||||
|
||||
case 'rain':
|
||||
case 'rain-n':
|
||||
return addPath('CC_Rain.gif');
|
||||
case 'rain':
|
||||
case 'rain-n':
|
||||
return addPath('CC_Rain.gif');
|
||||
|
||||
// case 'snow':
|
||||
// return addPath('Light-Snow.gif');
|
||||
// break;
|
||||
// case 'snow':
|
||||
// return addPath('Light-Snow.gif');
|
||||
// break;
|
||||
|
||||
// case 'cc_snowshowers.gif':
|
||||
// //case "heavy-snow.gif":
|
||||
// return addPath('AM-Snow-1994.gif');
|
||||
// break;
|
||||
// case 'cc_snowshowers.gif':
|
||||
// //case "heavy-snow.gif":
|
||||
// return addPath('AM-Snow-1994.gif');
|
||||
// break;
|
||||
|
||||
case 'snow':
|
||||
case 'snow-n':
|
||||
if (value > 50) return addPath('CC_Snow.gif');
|
||||
return addPath('CC_SnowShowers.gif');
|
||||
case 'snow':
|
||||
case 'snow-n':
|
||||
if (value > 50) return addPath('CC_Snow.gif');
|
||||
return addPath('CC_SnowShowers.gif');
|
||||
|
||||
case 'rain_snow':
|
||||
return addPath('CC_RainSnow.gif');
|
||||
case 'rain_snow':
|
||||
return addPath('CC_RainSnow.gif');
|
||||
|
||||
case 'snow_fzra':
|
||||
case 'snow_fzra-n':
|
||||
case 'fzra':
|
||||
case 'fzra-n':
|
||||
case 'rain_fzra':
|
||||
case 'rain_fzra-n':
|
||||
return addPath('CC_FreezingRain.gif');
|
||||
case 'snow_fzra':
|
||||
case 'snow_fzra-n':
|
||||
case 'fzra':
|
||||
case 'fzra-n':
|
||||
case 'rain_fzra':
|
||||
case 'rain_fzra-n':
|
||||
return addPath('CC_FreezingRain.gif');
|
||||
|
||||
case 'snow_sleet':
|
||||
return addPath('Snow-Sleet.gif');
|
||||
case 'snow_sleet':
|
||||
return addPath('Snow-Sleet.gif');
|
||||
|
||||
case 'tsra_sct':
|
||||
case 'tsra':
|
||||
return addPath('EF_ScatTstorms.gif');
|
||||
case 'tsra_sct':
|
||||
case 'tsra':
|
||||
return addPath('EF_ScatTstorms.gif');
|
||||
|
||||
case 'tsra_sct-n':
|
||||
case 'tsra-n':
|
||||
return addPath('CC_TStorm.gif');
|
||||
case 'tsra_sct-n':
|
||||
case 'tsra-n':
|
||||
return addPath('CC_TStorm.gif');
|
||||
|
||||
case 'tsra_hi':
|
||||
case 'tsra_hi-n':
|
||||
case 'hurricane':
|
||||
case 'tropical_storm':
|
||||
return addPath('CC_TStorm.gif');
|
||||
case 'tsra_hi':
|
||||
case 'tsra_hi-n':
|
||||
case 'hurricane':
|
||||
case 'tropical_storm':
|
||||
case 'hurricane-n':
|
||||
case 'tropical_storm-n':
|
||||
return addPath('CC_TStorm.gif');
|
||||
|
||||
case 'wind_few':
|
||||
case 'wind_sct':
|
||||
case 'wind_bkn':
|
||||
case 'wind_ovc':
|
||||
case 'wind_skc':
|
||||
case 'wind_few-n':
|
||||
case 'wind_bkn-n':
|
||||
case 'wind_ovc-n':
|
||||
case 'wind_skc-n':
|
||||
case 'wind_sct-n':
|
||||
return addPath('CC_Windy.gif');
|
||||
case 'wind_few':
|
||||
case 'wind_sct':
|
||||
case 'wind_bkn':
|
||||
case 'wind_ovc':
|
||||
case 'wind_skc':
|
||||
case 'wind_few-n':
|
||||
case 'wind_bkn-n':
|
||||
case 'wind_ovc-n':
|
||||
case 'wind_skc-n':
|
||||
case 'wind_sct-n':
|
||||
return addPath('CC_Windy.gif');
|
||||
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing-Snow.gif');
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing-Snow.gif');
|
||||
|
||||
default:
|
||||
console.log(`Unable to locate icon for ${conditionName} ${link} ${isNightTime}`);
|
||||
return false;
|
||||
default:
|
||||
console.log(`Unable to locate icon for ${conditionName} ${link} ${isNightTime}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { distance as calcDistance, directionToNSEW } from './utils/calc.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import STATUS from './status.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 { registerDisplay } from './navigation.mjs';
|
||||
import settings from './settings.mjs';
|
||||
|
||||
class LatestObservations extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -15,14 +16,15 @@ class LatestObservations extends WeatherDisplay {
|
||||
this.MaximumRegionalStations = 7;
|
||||
}
|
||||
|
||||
async getData(_weatherParameters) {
|
||||
if (!super.getData(_weatherParameters)) return;
|
||||
const weatherParameters = _weatherParameters ?? this.weatherParameters;
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
// 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
|
||||
const stationsByDistance = Object.keys(StationInfo).map((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 };
|
||||
});
|
||||
|
||||
@@ -64,14 +66,22 @@ class LatestObservations extends WeatherDisplay {
|
||||
// sort array by station name
|
||||
const sortedConditions = conditions.sort((a, b) => ((a.Name < b.Name) ? -1 : 1));
|
||||
|
||||
this.elem.querySelector('.column-headers .temp.english').classList.add('show');
|
||||
this.elem.querySelector('.column-headers .temp.metric').classList.remove('show');
|
||||
if (settings.units.value === 'us') {
|
||||
this.elem.querySelector('.column-headers .temp.english').classList.add('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 windDirection = directionToNSEW(condition.windDirection.value);
|
||||
|
||||
const Temperature = Math.round(celsiusToFahrenheit(condition.temperature.value));
|
||||
const WindSpeed = Math.round(kphToMph(condition.windSpeed.value));
|
||||
const Temperature = temperatureConverter(condition.temperature.value);
|
||||
const WindSpeed = windConverter(condition.windSpeed.value);
|
||||
|
||||
const fill = {
|
||||
location: locationCleanup(condition.city).substr(0, 14),
|
||||
@@ -94,6 +104,8 @@ class LatestObservations extends WeatherDisplay {
|
||||
linesContainer.innerHTML = '';
|
||||
linesContainer.append(...lines);
|
||||
|
||||
// update temperature unit header
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
}
|
||||
@@ -122,8 +134,8 @@ const getStations = async (stations) => {
|
||||
const data = await json(`https://api.weather.gov/stations/${station.id}/observations/latest`, { retryCount: 1, stillWaiting: () => this.stillWaiting() });
|
||||
// test for temperature, weather and wind values present
|
||||
if (data.properties.temperature.value === null
|
||||
|| data.properties.textDescription === ''
|
||||
|| data.properties.windSpeed.value === null) return false;
|
||||
|| data.properties.textDescription === ''
|
||||
|| data.properties.windSpeed.value === null) return false;
|
||||
// format the return values
|
||||
return {
|
||||
...data.properties,
|
||||
|
||||
@@ -4,6 +4,7 @@ import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import settings from './settings.mjs';
|
||||
|
||||
class LocalForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -13,19 +14,21 @@ class LocalForecast extends WeatherDisplay {
|
||||
this.timing.baseDelay = 5000;
|
||||
}
|
||||
|
||||
async getData(_weatherParameters) {
|
||||
if (!super.getData(_weatherParameters)) return;
|
||||
const weatherParameters = _weatherParameters ?? this.weatherParameters;
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// get raw data
|
||||
const rawData = await this.getRawData(weatherParameters);
|
||||
// check for data
|
||||
if (!rawData) {
|
||||
const rawData = await this.getRawData(this.weatherParameters);
|
||||
// check for data, or if there's old data available
|
||||
if (!rawData && !this.data) {
|
||||
// fail for no old or new data
|
||||
this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
// store the data
|
||||
this.data = rawData || this.data;
|
||||
// parse raw data
|
||||
const conditions = parse(rawData);
|
||||
const conditions = parse(this.data);
|
||||
|
||||
// read each text
|
||||
this.screenTexts = conditions.map((condition) => {
|
||||
@@ -61,7 +64,7 @@ class LocalForecast extends WeatherDisplay {
|
||||
try {
|
||||
return await json(weatherParameters.forecast, {
|
||||
data: {
|
||||
units: 'us',
|
||||
units: settings.units.value,
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
@@ -69,7 +72,6 @@ class LocalForecast extends WeatherDisplay {
|
||||
} catch (error) {
|
||||
console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`);
|
||||
console.error(error.status, error.responseJSON);
|
||||
this.setStatus(STATUS.failed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
163
server/scripts/modules/media.mjs
Normal file
163
server/scripts/modules/media.mjs
Normal file
@@ -0,0 +1,163 @@
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import Setting from './utils/setting.mjs';
|
||||
|
||||
let playlist;
|
||||
let currentTrack = 0;
|
||||
let player;
|
||||
|
||||
const mediaPlaying = new Setting('mediaPlaying', 'Media Playing', 'boolean', false, null, 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) {
|
||||
randomizePlaylist();
|
||||
currentTrack = 0;
|
||||
}
|
||||
// update the player source
|
||||
player.src = `music/${playlist.availableFiles[currentTrack]}`;
|
||||
};
|
||||
|
||||
export {
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
toggleMedia,
|
||||
};
|
||||
@@ -15,26 +15,11 @@ let playing = false;
|
||||
let progress;
|
||||
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 () => {
|
||||
// set up resize handler
|
||||
window.addEventListener('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();
|
||||
};
|
||||
|
||||
@@ -123,12 +108,6 @@ const updateStatus = (value) => {
|
||||
if (isPlaying() && value.id === firstDisplayIndex && value.status === STATUS.loaded) {
|
||||
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
|
||||
@@ -202,8 +181,6 @@ const loadDisplay = (direction) => {
|
||||
idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
|
||||
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];
|
||||
// hide all displays
|
||||
hideAllCanvases();
|
||||
@@ -238,30 +215,30 @@ const setPlaying = (newValue) => {
|
||||
// handle all navigation buttons
|
||||
const handleNavButton = (button) => {
|
||||
switch (button) {
|
||||
case 'play':
|
||||
setPlaying(true);
|
||||
break;
|
||||
case 'playToggle':
|
||||
setPlaying(!playing);
|
||||
break;
|
||||
case 'stop':
|
||||
setPlaying(false);
|
||||
break;
|
||||
case 'next':
|
||||
setPlaying(false);
|
||||
navTo(msg.command.nextFrame);
|
||||
break;
|
||||
case 'previous':
|
||||
setPlaying(false);
|
||||
navTo(msg.command.previousFrame);
|
||||
break;
|
||||
case 'menu':
|
||||
setPlaying(false);
|
||||
progress.showCanvas();
|
||||
hideAllCanvases();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown navButton ${button}`);
|
||||
case 'play':
|
||||
setPlaying(true);
|
||||
break;
|
||||
case 'playToggle':
|
||||
setPlaying(!playing);
|
||||
break;
|
||||
case 'stop':
|
||||
setPlaying(false);
|
||||
break;
|
||||
case 'next':
|
||||
setPlaying(false);
|
||||
navTo(msg.command.nextFrame);
|
||||
break;
|
||||
case 'previous':
|
||||
setPlaying(false);
|
||||
navTo(msg.command.previousFrame);
|
||||
break;
|
||||
case 'menu':
|
||||
setPlaying(false);
|
||||
progress.showCanvas();
|
||||
hideAllCanvases();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown navButton ${button}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -275,7 +252,7 @@ const resize = () => {
|
||||
const heightZoomPercent = (window.innerHeight) / 480;
|
||||
|
||||
const scale = Math.min(widthZoomPercent, heightZoomPercent);
|
||||
if (scale < 1.0 || document.fullscreenElement) {
|
||||
if (scale < 1.0 || document.fullscreenElement || settings.kiosk) {
|
||||
document.querySelector('#container').style.transform = `scale(${scale})`;
|
||||
} else {
|
||||
document.querySelector('#container').style.transform = 'unset';
|
||||
@@ -320,83 +297,8 @@ const populateWeatherParameters = (params) => {
|
||||
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) => {
|
||||
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;
|
||||
@@ -414,7 +316,5 @@ export {
|
||||
msg,
|
||||
message,
|
||||
latLonReceived,
|
||||
stopAutoRefreshTimer,
|
||||
registerRefreshData,
|
||||
timeZone,
|
||||
};
|
||||
|
||||
@@ -42,19 +42,17 @@ class Radar extends WeatherDisplay {
|
||||
];
|
||||
}
|
||||
|
||||
async getData(_weatherParameters) {
|
||||
if (!super.getData(_weatherParameters)) return;
|
||||
const weatherParameters = _weatherParameters ?? this.weatherParameters;
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
|
||||
// get the base map
|
||||
let src = 'images/4000RadarMap2.jpg';
|
||||
if (weatherParameters.State === 'HI') src = 'images/HawaiiRadarMap2.png';
|
||||
const src = 'images/4000RadarMap2.jpg';
|
||||
this.baseMap = await loadImg(src);
|
||||
|
||||
const baseUrl = 'https://mesonet.agron.iastate.edu/archive/data/';
|
||||
@@ -70,7 +68,7 @@ class Radar extends WeatherDisplay {
|
||||
|
||||
const lists = (await Promise.all(baseUrls.map(async (url) => {
|
||||
try {
|
||||
// get a list of available radars
|
||||
// get a list of available radars
|
||||
return text(url, { cors: true });
|
||||
} catch (error) {
|
||||
console.log('Unable to get list of radars');
|
||||
@@ -91,7 +89,7 @@ class Radar extends WeatherDisplay {
|
||||
const anchors = xmlDoc.querySelectorAll('a');
|
||||
const urls = [];
|
||||
Array.from(anchors).forEach((elem) => {
|
||||
if (elem.innerHTML?.match(/n0r_\d{12}\.png/)) {
|
||||
if (elem.innerHTML?.match(/n0r_\d{12}\.png/)) {
|
||||
urls.push(elem.href);
|
||||
}
|
||||
});
|
||||
@@ -110,19 +108,12 @@ class Radar extends WeatherDisplay {
|
||||
const height = 1600;
|
||||
offsetX *= 2;
|
||||
offsetY *= 2;
|
||||
const sourceXY = utils.getXYFromLatitudeLongitudeMap(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;
|
||||
const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY);
|
||||
|
||||
// calculate radar offsets
|
||||
const radarOffsetX = 120;
|
||||
const radarOffsetY = 70;
|
||||
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(weatherParameters, offsetX, offsetY);
|
||||
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY);
|
||||
const radarSourceX = radarSourceXY.x / 2;
|
||||
const radarSourceY = radarSourceXY.y / 2;
|
||||
|
||||
@@ -135,6 +126,13 @@ class Radar extends WeatherDisplay {
|
||||
const context = canvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
// create working context for manipulation
|
||||
const workingCanvas = document.createElement('canvas');
|
||||
workingCanvas.width = width;
|
||||
workingCanvas.height = height;
|
||||
const workingContext = workingCanvas.getContext('2d');
|
||||
workingContext.imageSmoothingEnabled = false;
|
||||
|
||||
// get the image
|
||||
const response = await fetch(rewriteUrl(url));
|
||||
|
||||
@@ -157,7 +155,7 @@ class Radar extends WeatherDisplay {
|
||||
minute,
|
||||
}, {
|
||||
zone: 'UTC',
|
||||
}).setZone();
|
||||
}).setZone(timeZone());
|
||||
} else {
|
||||
time = DateTime.fromHTTP(response.headers.get('last-modified')).setZone(timeZone());
|
||||
}
|
||||
@@ -170,7 +168,7 @@ class Radar extends WeatherDisplay {
|
||||
workingContext.drawImage(imgBlob, 0, 0, width, 1600);
|
||||
|
||||
// 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
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { getWeatherRegionalIconFromIconLink } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||
|
||||
const buildForecast = (forecast, city, cityXY) => ({
|
||||
daytime: forecast.isDaytime,
|
||||
temperature: forecast.temperature || 0,
|
||||
name: formatCity(city.city),
|
||||
icon: forecast.icon,
|
||||
x: cityXY.x,
|
||||
y: cityXY.y,
|
||||
time: forecast.startTime,
|
||||
});
|
||||
const buildForecast = (forecast, city, cityXY) => {
|
||||
// get a unit converter
|
||||
const temperatureConverter = temperatureUnit('us');
|
||||
return {
|
||||
daytime: forecast.isDaytime,
|
||||
temperature: temperatureConverter(forecast.temperature || 0),
|
||||
name: formatCity(city.city),
|
||||
icon: forecast.icon,
|
||||
x: cityXY.x,
|
||||
y: cityXY.y,
|
||||
time: forecast.startTime,
|
||||
};
|
||||
};
|
||||
|
||||
const getRegionalObservation = async (point, city) => {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import STATUS from './status.mjs';
|
||||
import { distance as calcDistance } from './utils/calc.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 { preloadImg } from './utils/image.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
@@ -21,9 +21,11 @@ class RegionalForecast extends WeatherDisplay {
|
||||
this.timing.totalScreens = 3;
|
||||
}
|
||||
|
||||
async getData(_weatherParameters) {
|
||||
if (!super.getData(_weatherParameters)) return;
|
||||
const weatherParameters = _weatherParameters ?? this.weatherParameters;
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
// regional forecast implements a silent reload
|
||||
// but it will not fall back to previously loaded data if data can not be loaded
|
||||
// there are enough other cities available to populate the map sufficiently even if some do not load
|
||||
|
||||
// pre-load the base map
|
||||
let baseMap = 'images/Basemap2.png';
|
||||
@@ -40,14 +42,14 @@ class RegionalForecast extends WeatherDisplay {
|
||||
y: 117,
|
||||
};
|
||||
// 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
|
||||
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
|
||||
let targetDistance = 2.5;
|
||||
if (weatherParameters.state === 'HI') targetDistance = 1;
|
||||
if (this.weatherParameters.state === 'HI') targetDistance = 1;
|
||||
|
||||
// make station info into an array
|
||||
const stationInfoArray = Object.values(StationInfo).map((value) => ({ ...value, targetDistance }));
|
||||
@@ -59,7 +61,7 @@ class RegionalForecast extends WeatherDisplay {
|
||||
const regionalCities = [];
|
||||
combinedCities.forEach((city) => {
|
||||
if (city.lat > minMaxLatLon.minLat && city.lat < minMaxLatLon.maxLat
|
||||
&& city.lon > minMaxLatLon.minLon && city.lon < minMaxLatLon.maxLon - 1) {
|
||||
&& city.lon > minMaxLatLon.minLon && city.lon < minMaxLatLon.maxLon - 1) {
|
||||
// default to 1 for cities loaded from RegionalCities, use value calculate above for remaining stations
|
||||
const targetDist = city.targetDistance || 1;
|
||||
// Only add the city as long as it isn't within set distance degree of any other city already in the array.
|
||||
@@ -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)
|
||||
const regionalDataAll = await Promise.all(regionalCities.map(async (city) => {
|
||||
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`);
|
||||
|
||||
// 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
|
||||
const observation = await observationPromise;
|
||||
@@ -93,7 +98,7 @@ class RegionalForecast extends WeatherDisplay {
|
||||
// format the observation the same as the forecast
|
||||
const regionalObservation = {
|
||||
daytime: !!/\/day\//.test(observation.icon),
|
||||
temperature: celsiusToFahrenheit(observation.temperature.value),
|
||||
temperature: temperatureConverter(observation.temperature.value),
|
||||
name: utils.formatCity(city.city),
|
||||
icon: observation.icon,
|
||||
x: cityXY.x,
|
||||
|
||||
@@ -4,19 +4,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
});
|
||||
|
||||
const settings = {};
|
||||
// default speed
|
||||
const settings = { speed: { value: 1.0 } };
|
||||
|
||||
const init = () => {
|
||||
// create settings
|
||||
settings.wide = new Setting('wide', 'Widescreen', 'boolean', false, wideScreenChange);
|
||||
settings.wide = new Setting('wide', 'Widescreen', 'checkbox', false, wideScreenChange, true);
|
||||
settings.kiosk = new Setting('kiosk', 'Kiosk', 'checkbox', false, kioskChange, false);
|
||||
settings.speed = new Setting('speed', 'Speed', 'select', 1.0, null, true, [
|
||||
[0.5, 'Very Fast'],
|
||||
[0.75, 'Fast'],
|
||||
[1.0, 'Normal'],
|
||||
[1.25, 'Slow'],
|
||||
[1.5, 'Very Slow'],
|
||||
]);
|
||||
settings.units = new Setting('units', 'Units', 'select', 'us', unitChange, true, [
|
||||
['us', 'US'],
|
||||
['si', 'Metric'],
|
||||
]);
|
||||
settings.refreshTime = new Setting('refreshTime', 'Refresh Time', 'select', 30_000, null, false, [
|
||||
[30_000, 'TESTING'],
|
||||
[300_000, '5 minutes'],
|
||||
[600_000, '10 minutes'],
|
||||
[900_000, '15 minutes'],
|
||||
[1_800_000, '30 minutes'],
|
||||
]);
|
||||
|
||||
// generate checkboxes
|
||||
const checkboxes = Object.values(settings).map((d) => d.generateCheckbox());
|
||||
// generate html objects
|
||||
const settingHtml = Object.values(settings).map((d) => d.generate());
|
||||
|
||||
// write to page
|
||||
const settingsSection = document.querySelector('#settings');
|
||||
settingsSection.innerHTML = '';
|
||||
settingsSection.append(...checkboxes);
|
||||
settingsSection.append(...settingHtml);
|
||||
};
|
||||
|
||||
const wideScreenChange = (value) => {
|
||||
@@ -28,4 +48,23 @@ const wideScreenChange = (value) => {
|
||||
}
|
||||
};
|
||||
|
||||
const kioskChange = (value) => {
|
||||
const body = document.querySelector('body');
|
||||
if (value) {
|
||||
body.classList.add('kiosk');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
} else {
|
||||
body.classList.remove('kiosk');
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
112
server/scripts/modules/share.mjs
Normal file
112
server/scripts/modules/share.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
document.addEventListener('DOMContentLoaded', () => init());
|
||||
|
||||
// shorthand mappings for frequently used values
|
||||
const specialMappings = {
|
||||
kiosk: 'settings-kiosk-checkbox',
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
// add action to existing link
|
||||
const shareLink = document.querySelector('#share-link');
|
||||
shareLink.addEventListener('click', createLink);
|
||||
|
||||
// if navigator.clipboard does not exist, change text
|
||||
if (!navigator?.clipboard) {
|
||||
shareLink.textContent = 'Get Permalink';
|
||||
}
|
||||
};
|
||||
|
||||
const createLink = async (e) => {
|
||||
// cancel default event (click on hyperlink)
|
||||
e.preventDefault();
|
||||
// get all checkboxes on page
|
||||
const checkboxes = document.querySelectorAll('input[type=checkbox]');
|
||||
|
||||
// list to receive checkbox statuses
|
||||
const queryStringElements = {};
|
||||
|
||||
[...checkboxes].forEach((elem) => {
|
||||
if (elem?.id) {
|
||||
queryStringElements[elem.id] = elem?.checked ?? false;
|
||||
}
|
||||
});
|
||||
|
||||
// get all select boxes
|
||||
const selects = document.querySelectorAll('select');
|
||||
[...selects].forEach((elem) => {
|
||||
if (elem?.id) {
|
||||
queryStringElements[elem.id] = elem?.value ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
// add the location string
|
||||
queryStringElements.latLonQuery = localStorage.getItem('latLonQuery');
|
||||
queryStringElements.latLon = localStorage.getItem('latLon');
|
||||
|
||||
const queryString = (new URLSearchParams(queryStringElements)).toString();
|
||||
|
||||
const url = new URL(`?${queryString}`, document.location.href);
|
||||
|
||||
// send to proper function based on availability of clipboard
|
||||
if (navigator?.clipboard) {
|
||||
copyToClipboard(url);
|
||||
} else {
|
||||
writeLinkToPage(url);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (url) => {
|
||||
try {
|
||||
// write to clipboard
|
||||
await navigator.clipboard.writeText(url.toString());
|
||||
// alert user
|
||||
const confirmSpan = document.querySelector('#share-link-copied');
|
||||
confirmSpan.style.display = 'inline';
|
||||
|
||||
// hide confirm text after 5 seconds
|
||||
setTimeout(() => {
|
||||
confirmSpan.style.display = 'none';
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const writeLinkToPage = (url) => {
|
||||
// get elements
|
||||
const shareLinkInstructions = document.querySelector('#share-link-instructions');
|
||||
const shareLinkUrl = shareLinkInstructions.querySelector('#share-link-url');
|
||||
// populate url and display
|
||||
shareLinkUrl.value = url;
|
||||
shareLinkInstructions.style.display = 'inline';
|
||||
// highlight for convenience
|
||||
shareLinkUrl.focus();
|
||||
shareLinkUrl.select();
|
||||
};
|
||||
|
||||
const parseQueryString = () => {
|
||||
// return memoized result
|
||||
if (parseQueryString.params) return parseQueryString.params;
|
||||
const urlSearchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// turn into an array of key-value pairs
|
||||
const paramsArray = [...urlSearchParams];
|
||||
|
||||
// add additional expanded keys
|
||||
paramsArray.forEach((paramPair) => {
|
||||
const expandedKey = specialMappings[paramPair[0]];
|
||||
if (expandedKey) {
|
||||
paramsArray.push([expandedKey, paramPair[1]]);
|
||||
}
|
||||
});
|
||||
|
||||
// memoize result
|
||||
parseQueryString.params = Object.fromEntries(paramsArray);
|
||||
|
||||
return parseQueryString.params;
|
||||
};
|
||||
|
||||
export {
|
||||
createLink,
|
||||
parseQueryString,
|
||||
};
|
||||
@@ -9,20 +9,20 @@ const STATUS = {
|
||||
|
||||
const calcStatusClass = (statusCode) => {
|
||||
switch (statusCode) {
|
||||
case STATUS.loading:
|
||||
return 'loading';
|
||||
case STATUS.loaded:
|
||||
return 'press-here';
|
||||
case STATUS.failed:
|
||||
return 'failed';
|
||||
case STATUS.noData:
|
||||
return 'no-data';
|
||||
case STATUS.disabled:
|
||||
return 'disabled';
|
||||
case STATUS.retrying:
|
||||
return 'retrying';
|
||||
default:
|
||||
return '';
|
||||
case STATUS.loading:
|
||||
return 'loading';
|
||||
case STATUS.loaded:
|
||||
return 'press-here';
|
||||
case STATUS.failed:
|
||||
return 'failed';
|
||||
case STATUS.noData:
|
||||
return 'no-data';
|
||||
case STATUS.disabled:
|
||||
return 'disabled';
|
||||
case STATUS.retrying:
|
||||
return 'retrying';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getWeatherRegionalIconFromIconLink } from './icons.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import settings from './settings.mjs';
|
||||
|
||||
class TravelForecast extends WeatherDisplay {
|
||||
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));
|
||||
// add the final 3 second delay
|
||||
this.timing.delay.push(150);
|
||||
|
||||
// add previous data cache
|
||||
this.previousData = [];
|
||||
}
|
||||
|
||||
async getData() {
|
||||
async getData(weatherParameters, refresh) {
|
||||
// super checks for enabled
|
||||
if (!super.getData()) return;
|
||||
const forecastPromises = TravelCities.map(async (city) => {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// clear stored data if not refresh
|
||||
if (!refresh) {
|
||||
this.previousData = [];
|
||||
}
|
||||
|
||||
const forecastPromises = TravelCities.map(async (city, index) => {
|
||||
try {
|
||||
// get point then forecast
|
||||
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)
|
||||
const todayShift = forecast.properties.periods[0].isDaytime ? 0 : 1;
|
||||
// return a pared-down forecast
|
||||
@@ -131,7 +158,7 @@ class TravelForecast extends WeatherDisplay {
|
||||
// base count change callback
|
||||
baseCountChange(count) {
|
||||
// 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
|
||||
if (offsetY < 0) offsetY = 0;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// rewrite some urls for local server
|
||||
const rewriteUrl = (_url) => {
|
||||
let url = _url;
|
||||
url = url.replace('https://api.weather.gov/', window.location.href);
|
||||
url = url.replace('https://www.cpc.ncep.noaa.gov/', window.location.href);
|
||||
url = url.replace('https://api.weather.gov/', `${window.location.protocol}//${window.location.host}/`);
|
||||
url = url.replace('https://www.cpc.ncep.noaa.gov/', `${window.location.protocol}//${window.location.host}/`);
|
||||
return url;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
if (params.cors === true) corsUrl = rewriteUrl(_url);
|
||||
const url = new URL(corsUrl, `${window.location.origin}/`);
|
||||
// match the security protocol when not on localhost
|
||||
url.protocol = window.location.hostname === 'localhost' ? url.protocol : window.location.protocol;
|
||||
// url.protocol = window.location.hostname === 'localhost' ? url.protocol : window.location.protocol;
|
||||
// add parameters if necessary
|
||||
if (params.data) {
|
||||
Object.keys(params.data).forEach((key) => {
|
||||
@@ -39,14 +39,14 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`);
|
||||
// return the requested response
|
||||
switch (responseType) {
|
||||
case 'json':
|
||||
return response.json();
|
||||
case 'text':
|
||||
return response.text();
|
||||
case 'blob':
|
||||
return response.blob();
|
||||
default:
|
||||
return response;
|
||||
case 'json':
|
||||
return response.json();
|
||||
case 'text':
|
||||
return response.text();
|
||||
case 'blob':
|
||||
return response.blob();
|
||||
default:
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ const doFetch = (url, params) => new Promise((resolve, reject) => {
|
||||
// out of retries
|
||||
return resolve(response);
|
||||
})
|
||||
.catch((error) => reject(error));
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
const delay = (time, func, ...args) => new Promise((resolve) => {
|
||||
@@ -84,11 +84,11 @@ const delay = (time, func, ...args) => new Promise((resolve) => {
|
||||
|
||||
const retryDelay = (retryNumber) => {
|
||||
switch (retryNumber) {
|
||||
case 1: return 1000;
|
||||
case 2: return 2000;
|
||||
case 3: return 5000;
|
||||
case 4: return 10_000;
|
||||
default: return 30_000;
|
||||
case 1: return 1000;
|
||||
case 2: return 2000;
|
||||
case 3: return 5000;
|
||||
case 4: return 10_000;
|
||||
default: return 30_000;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,24 +1,85 @@
|
||||
import { parseQueryString } from '../share.mjs';
|
||||
|
||||
const SETTINGS_KEY = 'Settings';
|
||||
|
||||
class Setting {
|
||||
constructor(shortName, name, type, defaultValue, changeAction) {
|
||||
constructor(shortName, name, type, defaultValue, changeAction, sticky, values) {
|
||||
// store values
|
||||
this.shortName = shortName;
|
||||
this.name = name;
|
||||
this.defaultValue = defaultValue;
|
||||
this.myValue = defaultValue;
|
||||
this.type = type;
|
||||
// a defualt blank change function is provded
|
||||
this.changeAction = changeAction ?? (() => {});
|
||||
this.type = type ?? 'checkbox';
|
||||
this.sticky = sticky;
|
||||
this.values = values;
|
||||
// a default blank change function is provided
|
||||
this.changeAction = changeAction ?? (() => { });
|
||||
|
||||
// get value from url
|
||||
const urlValue = parseQueryString()?.[`settings-${shortName}-${type}`];
|
||||
let urlState;
|
||||
if (type === 'checkbox' && urlValue !== undefined) {
|
||||
urlState = urlValue === 'true';
|
||||
}
|
||||
if (type === 'select' && urlValue !== undefined) {
|
||||
urlState = parseFloat(urlValue);
|
||||
}
|
||||
if (type === 'select' && urlValue !== undefined && Number.isNaN(urlState)) {
|
||||
// couldn't parse as a float, store as a string
|
||||
urlState = urlValue;
|
||||
}
|
||||
|
||||
// get existing value if present
|
||||
const storedValue = this.getFromLocalStorage();
|
||||
if (storedValue !== null) {
|
||||
const storedValue = urlState ?? this.getFromLocalStorage();
|
||||
if (sticky && storedValue !== null) {
|
||||
this.myValue = storedValue;
|
||||
}
|
||||
|
||||
// call the change function on startup
|
||||
this.changeAction(this.myValue);
|
||||
switch (type) {
|
||||
case 'select':
|
||||
this.selectChange({ target: { value: this.myValue } });
|
||||
break;
|
||||
case 'checkbox':
|
||||
default:
|
||||
this.checkboxChange({ target: { checked: this.myValue } });
|
||||
}
|
||||
}
|
||||
|
||||
generateSelect() {
|
||||
// create a radio button set in the selected displays area
|
||||
const label = document.createElement('label');
|
||||
label.for = `settings-${this.shortName}-select`;
|
||||
label.id = `settings-${this.shortName}-label`;
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = `${this.name} `;
|
||||
label.append(span);
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.id = `settings-${this.shortName}-select`;
|
||||
select.name = `settings-${this.shortName}-select`;
|
||||
select.addEventListener('change', (e) => this.selectChange(e));
|
||||
|
||||
this.values.forEach(([value, text]) => {
|
||||
const option = document.createElement('option');
|
||||
if (typeof value === 'number') {
|
||||
option.value = value.toFixed(2);
|
||||
} else {
|
||||
option.value = value;
|
||||
}
|
||||
|
||||
option.innerHTML = text;
|
||||
select.append(option);
|
||||
});
|
||||
label.append(select);
|
||||
|
||||
this.element = label;
|
||||
|
||||
// set the initial value
|
||||
this.selectHighlight(this.myValue);
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
generateCheckbox() {
|
||||
@@ -38,7 +99,7 @@ class Setting {
|
||||
|
||||
label.append(checkbox, span);
|
||||
|
||||
this.checkbox = label;
|
||||
this.element = label;
|
||||
|
||||
return label;
|
||||
}
|
||||
@@ -52,7 +113,21 @@ class Setting {
|
||||
this.changeAction(this.myValue);
|
||||
}
|
||||
|
||||
selectChange(e) {
|
||||
// update the 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);
|
||||
|
||||
// call the change action
|
||||
this.changeAction(this.myValue);
|
||||
}
|
||||
|
||||
storeToLocalStorage(value) {
|
||||
if (!this.sticky) return;
|
||||
const allSettingsString = localStorage?.getItem(SETTINGS_KEY) ?? '{}';
|
||||
const allSettings = JSON.parse(allSettingsString);
|
||||
allSettings[this.shortName] = value;
|
||||
@@ -66,12 +141,12 @@ class Setting {
|
||||
const storedValue = JSON.parse(allSettings)?.[this.shortName];
|
||||
if (storedValue !== undefined) {
|
||||
switch (this.type) {
|
||||
case 'boolean':
|
||||
return storedValue;
|
||||
case 'int':
|
||||
return storedValue;
|
||||
default:
|
||||
return null;
|
||||
case 'boolean':
|
||||
return storedValue;
|
||||
case 'select':
|
||||
return storedValue;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +161,39 @@ class Setting {
|
||||
}
|
||||
|
||||
set value(newValue) {
|
||||
// update the state
|
||||
this.myValue = newValue;
|
||||
switch (this.type) {
|
||||
case 'select':
|
||||
this.selectHighlight(newValue);
|
||||
break;
|
||||
case 'boolean':
|
||||
break;
|
||||
case 'checkbox':
|
||||
default:
|
||||
this.element.checked = newValue;
|
||||
}
|
||||
this.storeToLocalStorage(this.myValue);
|
||||
|
||||
// call change action
|
||||
this.changeAction(this.myValue);
|
||||
}
|
||||
|
||||
selectHighlight(newValue) {
|
||||
// set the dropdown to the provided value
|
||||
this.element.querySelectorAll('option').forEach((elem) => {
|
||||
elem.selected = (newValue?.toFixed?.(2) === elem.value) || (newValue === elem.value);
|
||||
});
|
||||
}
|
||||
|
||||
generate() {
|
||||
switch (this.type) {
|
||||
case 'select':
|
||||
return this.generateSelect();
|
||||
case 'checkbox':
|
||||
default:
|
||||
return this.generateCheckbox();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,113 @@
|
||||
// get the settings for units
|
||||
import settings from '../settings.mjs';
|
||||
// *********************************** unit conversions ***********************
|
||||
|
||||
// round 2 provided for lat/lon formatting
|
||||
const round2 = (value, decimals) => Math.trunc(value * 10 ** decimals) / 10 ** decimals;
|
||||
|
||||
const kphToMph = (Kph) => Math.round(Kph / 1.609_34);
|
||||
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 metersToFeet = (Meters) => Math.round(Meters / 0.3048);
|
||||
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 {
|
||||
kphToMph,
|
||||
celsiusToFahrenheit,
|
||||
kilometersToMiles,
|
||||
metersToFeet,
|
||||
pascalToInHg,
|
||||
// unit conversions
|
||||
windSpeed,
|
||||
temperature,
|
||||
distanceMeters,
|
||||
distanceKilometers,
|
||||
pressure,
|
||||
|
||||
// formatter
|
||||
round2,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import {
|
||||
msg, displayNavMessage, isPlaying, updateStatus, timeZone,
|
||||
} from './navigation.mjs';
|
||||
import { parseQueryString } from './share.mjs';
|
||||
import settings from './settings.mjs';
|
||||
|
||||
class WeatherDisplay {
|
||||
constructor(navId, elemId, name, defaultEnabled) {
|
||||
@@ -20,6 +22,7 @@ class WeatherDisplay {
|
||||
this.okToDrawCurrentConditions = true;
|
||||
this.okToDrawCurrentDateTime = true;
|
||||
this.showOnProgress = true;
|
||||
this.autoRefreshHandle = null;
|
||||
|
||||
// default navigation timing
|
||||
this.timing = {
|
||||
@@ -50,8 +53,15 @@ class WeatherDisplay {
|
||||
// no checkbox if progress
|
||||
if (this.elemId === 'progress') return false;
|
||||
|
||||
// get the saved status of the checkbox
|
||||
let savedStatus = window.localStorage.getItem(`display-enabled: ${this.elemId}`);
|
||||
// get url provided state
|
||||
const urlValue = parseQueryString()?.[`${this.elemId}-checkbox`];
|
||||
let urlState;
|
||||
if (urlValue !== undefined) {
|
||||
urlState = urlValue === 'true';
|
||||
}
|
||||
|
||||
// get the saved status of the checkbox, but defer to a value set in the url
|
||||
let savedStatus = urlState ?? window.localStorage.getItem(`display-enabled: ${this.elemId}`);
|
||||
if (savedStatus === null) savedStatus = defaultEnabled;
|
||||
this.isEnabled = !!((savedStatus === 'true' || savedStatus === true));
|
||||
|
||||
@@ -120,9 +130,14 @@ class WeatherDisplay {
|
||||
}
|
||||
|
||||
// get necessary data for this display
|
||||
getData(weatherParameters) {
|
||||
// clear current data
|
||||
this.data = undefined;
|
||||
getData(weatherParameters, refresh) {
|
||||
// refresh doesn't delete existing data, and is reused if the silent refresh fails
|
||||
if (!refresh) {
|
||||
this.data = undefined;
|
||||
}
|
||||
// clear any refresh timers
|
||||
clearTimeout(this.autoRefreshHandle);
|
||||
this.autoRefreshHandle = null;
|
||||
|
||||
// store weatherParameters locally in case we need them later
|
||||
if (weatherParameters) this.weatherParameters = weatherParameters;
|
||||
@@ -135,6 +150,9 @@ class WeatherDisplay {
|
||||
return false;
|
||||
}
|
||||
|
||||
// set up auto reload
|
||||
this.autoRefreshHandle = setTimeout(() => this.getData(false, true), settings.refreshTime.value);
|
||||
|
||||
// recalculate navigation timing (in case it was modified in the constructor)
|
||||
this.calcNavTiming();
|
||||
return true;
|
||||
@@ -161,7 +179,7 @@ class WeatherDisplay {
|
||||
// auto clock refresh
|
||||
if (!this.dateTimeInterval) {
|
||||
// only draw if canvas is active to conserve battery
|
||||
setInterval(() => this.active && this.drawCurrentDateTime(), 100);
|
||||
this.dateTimeInterval = setInterval(() => this.active && this.drawCurrentDateTime(), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,7 +368,7 @@ class WeatherDisplay {
|
||||
|
||||
// start and stop base counter
|
||||
startNavCount() {
|
||||
if (!this.navInterval) this.navInterval = setInterval(() => this.navBaseTime(), this.timing.baseDelay);
|
||||
if (!this.navInterval) this.navInterval = setInterval(() => this.navBaseTime(), this.timing.baseDelay * settings.speed.value);
|
||||
}
|
||||
|
||||
resetNavBaseCount() {
|
||||
|
||||
2
server/scripts/vendor/auto/luxon.js.map
vendored
2
server/scripts/vendor/auto/luxon.js.map
vendored
File diff suppressed because one or more lines are too long
1440
server/scripts/vendor/auto/luxon.mjs
vendored
1440
server/scripts/vendor/auto/luxon.mjs
vendored
File diff suppressed because it is too large
Load Diff
4
server/scripts/vendor/auto/swiped-events.js
vendored
4
server/scripts/vendor/auto/swiped-events.js
vendored
@@ -35,6 +35,7 @@
|
||||
var yDiff = null;
|
||||
var timeDown = null;
|
||||
var startEl = null;
|
||||
var touchCount = 0;
|
||||
|
||||
/**
|
||||
* Fires swiped event if swipe detected on touchend
|
||||
@@ -84,6 +85,7 @@
|
||||
var eventData = {
|
||||
dir: eventType.replace(/swiped-/, ''),
|
||||
touchType: (changedTouches[0] || {}).touchType || 'direct',
|
||||
fingers: touchCount, // Number of fingers used
|
||||
xStart: parseInt(xDown, 10),
|
||||
xEnd: parseInt((changedTouches[0] || {}).clientX || -1, 10),
|
||||
yStart: parseInt(yDown, 10),
|
||||
@@ -102,7 +104,6 @@
|
||||
yDown = null;
|
||||
timeDown = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records current location on touchstart event
|
||||
* @param {object} e - browser event object
|
||||
@@ -120,6 +121,7 @@
|
||||
yDown = e.touches[0].clientY;
|
||||
xDiff = 0;
|
||||
yDiff = 0;
|
||||
touchCount = e.touches.length;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
34
server/styles/scss/_media.scss
Normal file
34
server/styles/scss/_media.scss
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors'as c;
|
||||
|
||||
@font-face {
|
||||
font-family: "Star4000";
|
||||
@@ -18,6 +19,10 @@ body {
|
||||
color: lightblue;
|
||||
}
|
||||
}
|
||||
|
||||
&.kiosk {
|
||||
margin: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
#divQuery {
|
||||
@@ -301,13 +306,18 @@ body {
|
||||
background: url(../images/BackGround1_1_wide.png)
|
||||
}
|
||||
|
||||
#divTwc:fullscreen #container {
|
||||
#divTwc:fullscreen #container,
|
||||
.kiosk #divTwc #container {
|
||||
// background-image: none;
|
||||
width: unset;
|
||||
height: unset;
|
||||
transform-origin: unset;
|
||||
}
|
||||
|
||||
.kiosk #divTwc #container {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#loading {
|
||||
width: 640px;
|
||||
height: 480px;
|
||||
@@ -343,7 +353,8 @@ body {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#enabledDisplays {
|
||||
#enabledDisplays,
|
||||
#settings {
|
||||
margin-bottom: 15px;
|
||||
@include u.status-colors();
|
||||
|
||||
@@ -395,18 +406,29 @@ body {
|
||||
transform: scale(0.75);
|
||||
}
|
||||
|
||||
#divTwc:fullscreen {
|
||||
#divTwc:fullscreen,
|
||||
.kiosk #divTwc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
|
||||
&.no-cursor {
|
||||
cursor: none;
|
||||
}
|
||||
}
|
||||
|
||||
#divTwc:fullscreen #display {
|
||||
.kiosk #divTwc {
|
||||
justify-content: unset;
|
||||
}
|
||||
|
||||
#divTwc:fullscreen #display,
|
||||
.kiosk #divTwc #display {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#divTwc:fullscreen #divTwcBottom {
|
||||
#divTwc:fullscreen #divTwcBottom,
|
||||
.kiosk #divTwc #divTwcBottom {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: rgb(0 0 0 / 0.5);
|
||||
@@ -416,6 +438,14 @@ body {
|
||||
bottom: 0px;
|
||||
}
|
||||
|
||||
.kiosk {
|
||||
#divTwc #divTwcBottom {
|
||||
>div {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navButton {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -423,10 +453,10 @@ body {
|
||||
.visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 1s linear;
|
||||
transition: opacity 0.1s linear;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
#divTwc:fullscreen .hidden {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: visibility 0s 1s, opacity 1s linear
|
||||
@@ -697,4 +727,25 @@ body {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#share-link-copied {
|
||||
color: c.$title-color;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#share-link-instructions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.kiosk {
|
||||
|
||||
#divQuery,
|
||||
>.info,
|
||||
>.heading,
|
||||
#enabledDisplays,
|
||||
#settings,
|
||||
#divInfo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,5 @@
|
||||
@import 'radar';
|
||||
@import 'regional-forecast';
|
||||
@import 'almanac';
|
||||
@import 'hazards';
|
||||
@import 'hazards';
|
||||
@import 'media';
|
||||
20
src/playlist-reader.mjs
Normal file
20
src/playlist-reader.mjs
Normal 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
17
src/playlist.mjs
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
|
||||
@@ -10,17 +10,20 @@
|
||||
<meta name="author" content="Matt Walsh" />
|
||||
<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="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" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<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) { %>
|
||||
<link rel="stylesheet" type="text/css" href="resources/ws.min.css?_=<%=production%>" />
|
||||
<script type="text/javascript" src="resources/data.min.js?_=<%=production%>"></script>
|
||||
<script type="text/javascript" src="resources/vendor.min.js?_=<%=production%>"></script>
|
||||
<script type="text/javascript" src="resources/ws.min.js?_=<%=production%>"></script>
|
||||
<script type="text/javascript" src="scripts/custom.js?_=<%=production%>"></script>
|
||||
<% } else { %>
|
||||
<link rel="stylesheet" type="text/css" href="styles/main.css" />
|
||||
<script type="text/javascript" src="scripts/vendor/auto/jquery.js"></script>
|
||||
@@ -44,15 +47,12 @@
|
||||
<script type="module" src="scripts/modules/progress.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/media.mjs"></script>
|
||||
<script type="module" src="scripts/index.mjs"></script>
|
||||
|
||||
<!-- data -->
|
||||
<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/stations.js"></script>
|
||||
|
||||
<script type="text/javascript" src="scripts/custom.js"></script>
|
||||
|
||||
<% } %>
|
||||
|
||||
</head>
|
||||
@@ -131,6 +131,10 @@
|
||||
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_2x.png" title="Refresh" />
|
||||
</div>
|
||||
<div id="divTwcBottomRight">
|
||||
<div id="ToggleMedia">
|
||||
<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>
|
||||
@@ -141,6 +145,7 @@
|
||||
<div class="info">
|
||||
<a href="https://github.com/netbymatt/ws4kp#weatherstar-4000">More information</a>
|
||||
</div>
|
||||
<div class="media"></div>
|
||||
|
||||
<div class='heading'>Selected displays</div>
|
||||
<div id='enabledDisplays'>
|
||||
@@ -151,6 +156,16 @@
|
||||
<div id='settings'>
|
||||
</div>
|
||||
|
||||
<div class='heading'>Sharing</div>
|
||||
<div class='info'>
|
||||
<a href='' id='share-link'>Copy Permalink</a> <span id="share-link-copied">Link copied to clipboard!</span>
|
||||
<div id="share-link-instructions">
|
||||
Copy this long URL:
|
||||
<input type='text' id="share-link-url"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='heading'>Forecast Information</div>
|
||||
<div id="divInfo">
|
||||
Location: <span id="spanCity"></span> <span id="spanState"></span><br />
|
||||
Station Id: <span id="spanStationId"></span><br />
|
||||
@@ -158,11 +173,6 @@
|
||||
Zone Id: <span id="spanZoneId"></span><br />
|
||||
</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>
|
||||
|
||||
</html>
|
||||
@@ -22,6 +22,7 @@
|
||||
"devbridge",
|
||||
"gifs",
|
||||
"ltrim",
|
||||
"mbar",
|
||||
"Noaa",
|
||||
"nosleep",
|
||||
"Pngs",
|
||||
@@ -53,5 +54,9 @@
|
||||
},
|
||||
"files.exclude": {},
|
||||
"files.eol": "\n",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user