Compare commits

...

61 Commits

Author SHA1 Message Date
Matt Walsh
9c0d42c5c4 5.9.9 2023-05-31 23:11:30 -05:00
Matt Walsh
07ad5141a4 fix radar timestamp sorting 2023-05-31 23:11:12 -05:00
Matt Walsh
2c010a9a32 switch from css zoom to transform-scale 2023-05-31 22:57:36 -05:00
Matt Walsh
3b050073ed Merge pull request #26 from N7KnightOne/patch-1
Fixed Copy Commands
2023-05-31 21:11:19 -05:00
N7KnightOne
2953dc993c Fixed copy commands
In a Dockerfile, when using COPY with more than one source file, the destination must be a directory and end with a /. Or, you can specify each file that needs to be copied.
2023-05-31 15:53:50 -07:00
Matt Walsh
f481c5cfeb Merge pull request #23 from rmitchellscott/docker
Add Docker build
2023-04-22 21:26:41 -05:00
Matt Walsh
e4672d12d7 5.9.8 2023-04-22 21:24:17 -05:00
Matt Walsh
04ed3e0a52 fix npm lint script 2023-04-22 21:23:31 -05:00
Matt Walsh
58a337efbf fix hazards - current conditions race condition close #24 2023-04-22 21:16:30 -05:00
Mitchell Scott
5da4a50a96 Revert "Pin Node to v16."
This reverts commit d850165752.
2023-04-19 16:04:05 -06:00
Mitchell Scott
d5cce14fc2 Add workflow permissions for image build 2023-04-17 09:31:53 -06:00
Mitchell Scott
d850165752 Pin Node to v16. Workaround for netbymatt/ws4kp#24 2023-04-16 14:20:59 -06:00
Mitchell Scott
2d0af7a143 Swap to netbymatt GHCR 2023-04-13 16:35:54 -06:00
Mitchell Scott
146a3fda76 Tweak Dockerfile for better caching 2023-04-13 15:46:27 -06:00
Mitchell Scott
e1083c83ae Add dockerfile and build 2023-04-13 11:43:52 -06:00
Matt Walsh
249cbb93e6 add test via multiple locations 2023-01-17 16:10:06 -06:00
Matt Walsh
888b35ea73 code cleanup 2023-01-17 14:13:51 -06:00
Matt Walsh
2a9e5b370e don't re-parse current conditions 2023-01-17 11:26:57 -06:00
Matt Walsh
87d4155d71 capture dist 2023-01-10 14:13:30 -06:00
Matt Walsh
3166dfad16 5.9.7 2023-01-10 14:12:32 -06:00
Matt Walsh
b6cceb5acf radar scroll height 2023-01-10 14:12:22 -06:00
Matt Walsh
1303c55851 freezing rain icon 2023-01-10 14:07:48 -06:00
Matt Walsh
339d391110 code cleanup 2023-01-06 16:26:19 -06:00
Matt Walsh
b6e57e8a19 reduse use of .reduce 2023-01-06 16:18:33 -06:00
Matt Walsh
3743c45de6 additional eslint rules 2023-01-06 14:39:39 -06:00
Matt Walsh
b890b4e53d update dependencies 2023-01-05 14:48:21 -06:00
Matt Walsh
b07478f7ff capture dist 2023-01-05 14:26:58 -06:00
Matt Walsh
8a25881d5b 5.9.6 2023-01-05 14:19:43 -06:00
Matt Walsh
0743b9e2bb filter current conditions for missing icon or current conditions 2023-01-05 14:19:33 -06:00
Matt Walsh
784c074e32 capture dist 2022-12-22 14:48:05 -06:00
Matt Walsh
4840909098 5.9.5 2022-12-22 14:47:08 -06:00
Matt Walsh
03dfbc462b better latest observation get data routine 2022-12-22 14:46:58 -06:00
Matt Walsh
25291efff5 capture dist 2022-12-21 16:20:54 -06:00
Matt Walsh
dc77ba835c 5.9.4 2022-12-21 16:20:37 -06:00
Matt Walsh
a440990696 update top form html and css 2022-12-21 16:20:31 -06:00
Matt Walsh
fc4cbc1415 fix time zones close #21 2022-12-21 15:17:50 -06:00
Matt Walsh
20ba3ddaac capture dist 2022-12-21 14:52:03 -06:00
Matt Walsh
4205155f96 5.9.3 2022-12-21 14:51:04 -06:00
Matt Walsh
f4101f06cc fix hazards failed to load auto play issue 2022-12-21 14:50:56 -06:00
Matt Walsh
4c3fcfc358 correct hazard scroll time 2022-12-21 14:44:36 -06:00
Matt Walsh
366f527aee auto play when hazard is present 2022-12-21 14:05:14 -06:00
Matt Walsh
797b4d32fa capture dist 2022-12-19 15:24:34 -06:00
Matt Walsh
5092076050 5.9.2 2022-12-19 15:24:12 -06:00
Matt Walsh
5d891fb38f switch to 2x image sizes 2022-12-19 15:21:38 -06:00
Matt Walsh
97e0fda709 key navigation 2022-12-19 11:48:59 -06:00
Matt Walsh
7cf9dd6466 almanac delivers data when disabled 2022-12-19 11:27:02 -06:00
Matt Walsh
a44bd866ed regional forecast icon blizzard 2022-12-19 11:17:30 -06:00
Matt Walsh
21ef7f476a auto refresh fix 2022-12-19 11:15:48 -06:00
Matt Walsh
c5b715d631 checkbox label colors 2022-12-19 10:17:12 -06:00
Matt Walsh
dfd9facc79 Merge branch 'main' of github.com:netbymatt/ws4kp 2022-12-19 10:14:37 -06:00
Matt Walsh
5b926a358e no hazards, blizzard 2022-12-19 10:14:33 -06:00
Matt Walsh
ba1fbd7088 capture dist 2022-12-14 21:49:07 -06:00
Matt Walsh
f82980ed09 5.9.1 2022-12-14 21:47:35 -06:00
Matt Walsh
af17b3c690 progress light mode colors 2022-12-14 21:47:27 -06:00
Matt Walsh
2c394c2e4a correct gulp build 2022-12-14 16:31:11 -06:00
Matt Walsh
97f8eda236 capture dist 2022-12-14 16:29:09 -06:00
Matt Walsh
deb107e4ec 5.9.0 2022-12-14 16:28:47 -06:00
Matt Walsh
111f077e20 add hazards 2022-12-14 16:28:33 -06:00
Matt Walsh
806ef91000 fullscreen element fix 2022-12-14 13:32:55 -06:00
Matt Walsh
2a577aaea7 code cleanup 2022-12-14 13:08:49 -06:00
Matt Walsh
49296e53f0 make gulp output easier to read 2022-12-14 11:22:55 -06:00
58 changed files with 3695 additions and 7621 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.git/
Dockerfile
.vscode/

View File

@@ -6,7 +6,10 @@ module.exports = {
node: true,
jquery: true,
},
extends: 'airbnb-base',
extends: [
'airbnb-base',
'plugin:sonarjs/recommended',
],
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly',
@@ -21,6 +24,10 @@ module.exports = {
parserOptions: {
ecmaVersion: 2021,
},
plugins: [
'unicorn',
'sonarjs',
],
rules: {
indent: [
'error',
@@ -60,6 +67,24 @@ module.exports = {
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',

47
.github/workflows/build-docker.yaml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: build-docker
on: push
jobs:
build:
name: Build Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v3
- id: short-sha
uses: benjlevesque/short-sha@v1.2
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/netbymatt/ws4kp
tags: |
type=raw,priority=1000,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=branch
${{ steps.short-sha.outputs.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: .
pull: true
push: ${{ github.ref == 'refs/heads/main' }}
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

12
.vscode/launch.json vendored
View File

@@ -63,7 +63,17 @@
"env": {
"DIST": "1"
}
}
},
{
"name": "Test",
"program": "${workspaceFolder}/tests/index.js",
"request": "launch",
"skipFiles": [
"<node_internals>/**"
],
"type": "node",
"outputCapture": "std"
},
],
"compounds": [
{

14
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "lint",
"problemMatcher": [
"$eslint-stylish"
],
"label": "npm: lint",
"detail": "eslint ./server/scripts/**"
}
]
}

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:18-alpine
WORKDIR /app
COPY package.json .
COPY package-lock.json .
RUN npm ci
COPY . .
CMD ["node", "index.js"]

View File

@@ -19,12 +19,18 @@ This project is based on the work of [Mike Battaglia](https://github.com/vbguyny
## 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.
To run via Node locally:
```
git clone https://github.com/netbymatt/ws4kp.git
cd ws4kp
npm i
node index.js
```
To run via Docker:
```
docker run -p 8080:8080 ghcr.io/netbymatt/ws4kp
```
Open your web browser: http://localhost:8080/
## Updates in 5.0

View File

@@ -11,7 +11,7 @@ module.exports = (req, res) => {
// add out-going headers
const headers = {};
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
headers['accept'] = req.headers.accept;
headers.accept = req.headers.accept;
// get query paramaters if the exist
const queryParams = Object.keys(req.query).reduce((acc, key) => {
@@ -20,16 +20,16 @@ module.exports = (req, res) => {
// add the paramter to the resulting object
acc[key] = req.query[key];
return acc;
},{});
}, {});
let query = queryString.encode(queryParams);
if (query.length > 0) query = '?' + query;
if (query.length > 0) query = `?${query}`;
// get the page
https.get('https://www.cpc.ncep.noaa.gov/' + req.path + query, {
https.get(`https://www.cpc.ncep.noaa.gov/${req.path}${query}`, {
headers,
}, getRes => {
}, (getRes) => {
// pull some info
const {statusCode} = getRes;
const { statusCode } = getRes;
// pass the status code through
res.status(statusCode);
@@ -38,8 +38,7 @@ module.exports = (req, res) => {
res.header('last-modified', getRes.headers['last-modified']);
// pipe to response
getRes.pipe(res);
}).on('error', e=>{
}).on('error', (e) => {
console.error(e);
});
};
};

View File

@@ -11,7 +11,7 @@ module.exports = (req, res) => {
// add out-going headers
const headers = {};
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
headers['accept'] = req.headers.accept;
headers.accept = req.headers.accept;
// get query paramaters if the exist
const queryParams = Object.keys(req.query).reduce((acc, key) => {
@@ -20,16 +20,16 @@ module.exports = (req, res) => {
// add the paramter to the resulting object
acc[key] = req.query[key];
return acc;
},{});
}, {});
let query = queryString.encode(queryParams);
if (query.length > 0) query = '?' + query;
if (query.length > 0) query = `?${query}`;
// get the page
https.get('https://radar.weather.gov' + req.path + query, {
https.get(`https://radar.weather.gov${req.path}${query}`, {
headers,
}, getRes => {
}, (getRes) => {
// pull some info
const {statusCode} = getRes;
const { statusCode } = getRes;
// pass the status code through
res.status(statusCode);
@@ -38,8 +38,7 @@ module.exports = (req, res) => {
res.header('last-modified', getRes.headers['last-modified']);
// pipe to response
getRes.pipe(res);
}).on('error', e=>{
}).on('error', (e) => {
console.error(e);
});
};
};

2
dist/index.html vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -71,6 +71,7 @@ gulp.task('compress_js_vendor', () => gulp.src(jsVendorSources)
const mjsSources = [
'server/scripts/modules/currentweatherscroll.mjs',
'server/scripts/modules/hazards.mjs',
'server/scripts/modules/currentweather.mjs',
'server/scripts/modules/almanac.mjs',
'server/scripts/modules/icons.mjs',
@@ -164,4 +165,6 @@ gulp.task('invalidate', async () => cloudfront.createInvalidation({
},
}).promise());
module.exports = gulp.series(clean, gulp.parallel('build_js', 'compress_js_data', 'compress_js_vendor', 'copy_css', 'compress_html', 'copy_other_files'), gulp.parallel('upload', 'upload_images'), 'invalidate');
// 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');

View File

@@ -31,17 +31,17 @@ const index = (req, res) => {
};
// debugging
if (process.env?.DIST !== '1') {
// debugging
app.get('/index.html', index);
app.get('/', index);
app.get('*', express.static(path.join(__dirname, './server')));
} else {
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, () => {

8357
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
{
"name": "ws4kp",
"version": "5.8.0",
"version": "5.9.9",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build:css": "sass ./server/styles/scss/style.scss ./server/styles/compiled.css",
"lint": "eslint ./server/scripts/**",
"lint:fix": "eslint --fix ./server/scripts/**"
"lint": "eslint ./server/scripts/**/*.mjs",
"lint:fix": "eslint --fix ./server/scripts/**/*.mjs"
},
"repository": {
"type": "git",
@@ -22,7 +22,11 @@
"devDependencies": {
"del": "^6.0.0",
"ejs": "^3.1.5",
"eslint": "^8.21.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-sonarjs": "^0.19.0",
"eslint-plugin-unicorn": "^46.0.0",
"express": "^4.17.1",
"gulp": "^4.0.2",
"gulp-concat": "^2.6.1",
@@ -40,9 +44,6 @@
"suncalc": "^1.8.0",
"swiped-events": "^1.1.4",
"terser-webpack-plugin": "^5.3.6",
"webpack-stream": "^7.0.0",
"eslint": "^8.21.0",
"eslint-plugin-import": "^2.26.0"
},
"dependencies": {}
"webpack-stream": "^7.0.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
rules: {
// unicorn
'unicorn/numeric-separators-style': 0,
},
};

View File

@@ -22,32 +22,38 @@ const categories = [
'Postal', 'Populated Place',
];
const category = categories.join(',');
const TXT_ADDRESS_SELECTOR = '#txtAddress';
const TOGGLE_FULL_SCREEN_SELECTOR = '#ToggleFullScreen';
const BNT_GET_GPS_SELECTOR = '#btnGetGps';
const init = () => {
document.getElementById('txtAddress').addEventListener('focus', (e) => {
document.querySelector(TXT_ADDRESS_SELECTOR).addEventListener('focus', (e) => {
e.target.select();
});
registerRefreshData(loadData);
document.getElementById('NavigateMenu').addEventListener('click', btnNavigateMenuClick);
document.getElementById('NavigateRefresh').addEventListener('click', btnNavigateRefreshClick);
document.getElementById('NavigateNext').addEventListener('click', btnNavigateNextClick);
document.getElementById('NavigatePrevious').addEventListener('click', btnNavigatePreviousClick);
document.getElementById('NavigatePlay').addEventListener('click', btnNavigatePlayClick);
document.getElementById('ToggleFullScreen').addEventListener('click', btnFullScreenClick);
const btnGetGps = document.getElementById('btnGetGps');
document.querySelector('#NavigateMenu').addEventListener('click', btnNavigateMenuClick);
document.querySelector('#NavigateRefresh').addEventListener('click', btnNavigateRefreshClick);
document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick);
document.querySelector('#NavigatePrevious').addEventListener('click', btnNavigatePreviousClick);
document.querySelector('#NavigatePlay').addEventListener('click', btnNavigatePlayClick);
document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR).addEventListener('click', btnFullScreenClick);
const btnGetGps = document.querySelector(BNT_GET_GPS_SELECTOR);
btnGetGps.addEventListener('click', btnGetGpsClick);
if (!navigator.geolocation) btnGetGps.style.display = 'none';
document.getElementById('divTwc').addEventListener('click', () => {
document.querySelector('#divTwc').addEventListener('click', () => {
if (document.fullscreenElement) updateFullScreenNavigate();
});
document.querySelector(TXT_ADDRESS_SELECTOR).addEventListener('keydown', (key) => { if (key.code === 'Enter') formSubmit(); });
document.querySelector('#btnGetLatLng').addEventListener('click', () => formSubmit());
document.addEventListener('keydown', documentKeydown);
document.addEventListener('touchmove', (e) => { if (fullScreenOverride) e.preventDefault(); });
$('#frmGetLatLng #txtAddress').devbridgeAutocomplete({
$(TXT_ADDRESS_SELECTOR).devbridgeAutocomplete({
serviceUrl: 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/suggest',
deferRequestBy: 300,
paramName: 'text',
@@ -71,18 +77,18 @@ const init = () => {
width: 490,
});
$('#frmGetLatLng').on('submit', () => {
const ac = $('#frmGetLatLng #txtAddress').devbridgeAutocomplete();
const formSubmit = () => {
const ac = $(TXT_ADDRESS_SELECTOR).devbridgeAutocomplete();
if (ac.suggestions[0]) $(ac.suggestionsContainer.children[0]).trigger('click');
return false;
});
};
// Auto load the previous query
const query = localStorage.getItem('latLonQuery');
const latLon = localStorage.getItem('latLon');
const fromGPS = localStorage.getItem('latLonFromGPS');
if (query && latLon && !fromGPS) {
const txtAddress = document.getElementById('txtAddress');
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
txtAddress.value = query;
loadData(JSON.parse(latLon));
}
@@ -90,17 +96,17 @@ const init = () => {
btnGetGpsClick();
}
const twcPlay = localStorage.getItem('play');
if (twcPlay === null || twcPlay === 'true') postMessage('navButton', 'play');
const play = localStorage.getItem('play');
if (play === null || play === 'true') postMessage('navButton', 'play');
document.getElementById('btnClearQuery').addEventListener('click', () => {
document.getElementById('spanCity').innerHTML = '';
document.getElementById('spanState').innerHTML = '';
document.getElementById('spanStationId').innerHTML = '';
document.getElementById('spanRadarId').innerHTML = '';
document.getElementById('spanZoneId').innerHTML = '';
document.querySelector('#btnClearQuery').addEventListener('click', () => {
document.querySelector('#spanCity').innerHTML = '';
document.querySelector('#spanState').innerHTML = '';
document.querySelector('#spanStationId').innerHTML = '';
document.querySelector('#spanRadarId').innerHTML = '';
document.querySelector('#spanZoneId').innerHTML = '';
document.getElementById('chkAutoRefresh').checked = true;
document.querySelector('#chkAutoRefresh').checked = true;
localStorage.removeItem('autoRefresh');
localStorage.removeItem('play');
@@ -109,12 +115,12 @@ const init = () => {
localStorage.removeItem('latLonQuery');
localStorage.removeItem('latLon');
localStorage.removeItem('latLonFromGPS');
document.getElementById('btnGetGps').classList.remove('active');
document.querySelector(BNT_GET_GPS_SELECTOR).classList.remove('active');
});
// swipe functionality
document.getElementById('container').addEventListener('swiped-left', () => swipeCallBack('left'));
document.getElementById('container').addEventListener('swiped-right', () => swipeCallBack('right'));
document.querySelector('#container').addEventListener('swiped-left', () => swipeCallBack('left'));
document.querySelector('#container').addEventListener('swiped-right', () => swipeCallBack('right'));
};
const autocompleteOnSelect = async (suggestion, elem) => {
@@ -132,7 +138,7 @@ const autocompleteOnSelect = async (suggestion, elem) => {
const loc = data.locations[0];
if (loc) {
localStorage.removeItem('latLonFromGPS');
document.getElementById('btnGetGps').classList.remove('active');
document.querySelector(BNT_GET_GPS_SELECTOR).classList.remove('active');
doRedirectToGeometry(loc.feature.geometry);
} else {
console.error('An unexpected error occurred. Please try a different search string.');
@@ -142,7 +148,7 @@ const autocompleteOnSelect = async (suggestion, elem) => {
const doRedirectToGeometry = (geom, haveDataCallback) => {
const latLon = { lat: round2(geom.y, 4), lon: round2(geom.x, 4) };
// Save the query
localStorage.setItem('latLonQuery', document.getElementById('txtAddress').value);
localStorage.setItem('latLonQuery', document.querySelector(TXT_ADDRESS_SELECTOR).value);
localStorage.setItem('latLon', JSON.stringify(latLon));
// get the data
@@ -150,10 +156,10 @@ const doRedirectToGeometry = (geom, haveDataCallback) => {
};
const btnFullScreenClick = () => {
if (!document.fullscreenElement) {
enterFullScreen();
} else {
if (document.fullscreenElement) {
exitFullscreen();
} else {
enterFullScreen();
}
if (isPlaying()) {
@@ -168,7 +174,7 @@ const btnFullScreenClick = () => {
};
const enterFullScreen = () => {
const element = document.getElementById('divTwc');
const element = document.querySelector('#divTwc');
// Supports most browsers and their versions.
const requestMethod = element.requestFullScreen || element.webkitRequestFullScreen
@@ -186,8 +192,8 @@ const enterFullScreen = () => {
updateFullScreenNavigate();
// change hover text and image
const img = document.getElementById('ToggleFullScreen');
img.src = 'images/nav/ic_fullscreen_exit_white_24dp_1x.png';
const img = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR);
img.src = 'images/nav/ic_fullscreen_exit_white_24dp_2x.png';
img.title = 'Exit fullscreen';
};
@@ -210,8 +216,8 @@ const exitFullscreen = () => {
}
resize();
// change hover text and image
const img = document.getElementById('ToggleFullScreen');
img.src = 'images/nav/ic_fullscreen_white_24dp_1x.png';
const img = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR);
img.src = 'images/nav/ic_fullscreen_white_24dp_2x.png';
img.title = 'Enter fullscreen';
};
@@ -229,7 +235,7 @@ const loadData = (_latLon, haveDataCallback) => {
// if there's no data stop
if (!latLon) return;
document.getElementById('txtAddress').blur();
document.querySelector(TXT_ADDRESS_SELECTOR).blur();
stopAutoRefreshTimer();
latLonReceived(latLon, haveDataCallback);
};
@@ -273,8 +279,9 @@ let navigateFadeIntervalId = null;
const updateFullScreenNavigate = () => {
document.activeElement.blur();
document.getElementById('divTwcBottom').classList.remove('hidden');
document.getElementById('divTwcBottom').classList.add('visible');
const divTwcBottom = document.querySelector('#divTwcBottom');
divTwcBottom.classList.remove('hidden');
divTwcBottom.classList.add('visible');
if (navigateFadeIntervalId) {
clearTimeout(navigateFadeIntervalId);
@@ -283,44 +290,48 @@ const updateFullScreenNavigate = () => {
navigateFadeIntervalId = setTimeout(() => {
if (document.fullscreenElement) {
document.getElementById('divTwcBottom').classList.remove('visible');
document.getElementById('divTwcBottom').classList.add('hidden');
divTwcBottom.classList.remove('visible');
divTwcBottom.classList.add('hidden');
}
}, 2000);
};
const documentKeydown = (e) => {
const code = (e.keyCode || e.which);
// 200ms repeat
if ((Date.now() - documentKeydown.lastButton ?? 0) < 200) return false;
documentKeydown.lastButton = Date.now();
const { key } = e;
if (document.fullscreenElement || document.activeElement === document.body) {
switch (code) {
case 32: // Space
switch (key) {
case ' ': // Space
// don't scroll
e.preventDefault();
btnNavigatePlayClick();
return false;
case 39: // Right Arrow
case 34: // Page Down
case 'ArrowRight':
case 'PageDown':
// don't scroll
e.preventDefault();
btnNavigateNextClick();
return false;
case 37: // Left Arrow
case 33: // Page Up
case 'ArrowLeft':
case 'PageUp':
// don't scroll
e.preventDefault();
btnNavigatePreviousClick();
return false;
case 36: // Home
case 'ArrowUp': // Home
e.preventDefault();
btnNavigateMenuClick();
return false;
case 48: // Restart
case '0': // "O" Restart
btnNavigateRefreshClick();
return false;
case 70: // F
case 'F':
case 'f':
btnFullScreenClick();
return false;
@@ -348,7 +359,7 @@ const getPosition = async () => new Promise((resolve) => {
const btnGetGpsClick = async () => {
if (!navigator.geolocation) return;
const btn = document.getElementById('btnGetGps');
const btn = document.querySelector(BNT_GET_GPS_SELECTOR);
// toggle first
if (btn.classList.contains('active')) {
@@ -364,11 +375,10 @@ const btnGetGpsClick = async () => {
const position = await getPosition();
const { latitude, longitude } = position.coords;
const txtAddress = document.getElementById('txtAddress');
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
txtAddress.value = `${round2(latitude, 4)}, ${round2(longitude, 4)}`;
doRedirectToGeometry({ y: latitude, x: longitude }, (point) => {
console.log(point);
const location = point.properties.relativeLocation.properties;
// Save the query
const query = `${location.city}, ${location.state}`;

View File

@@ -22,7 +22,7 @@ class Almanac extends WeatherDisplay {
}
async getData(_weatherParameters) {
if (!super.getData(_weatherParameters)) return;
const superResponse = super.getData(_weatherParameters);
const weatherParameters = _weatherParameters ?? this.weatherParameters;
// get sun/moon data
@@ -33,11 +33,13 @@ class Almanac extends WeatherDisplay {
sun,
moon,
};
// update status
this.setStatus(STATUS.loaded);
// share data
this.getDataCallback();
if (!superResponse) return;
// update status
this.setStatus(STATUS.loaded);
}
calcSunMoonData(weatherParameters) {
@@ -171,7 +173,7 @@ const imageName = (type) => {
};
// register display
const display = new Almanac(8, 'almanac');
const display = new Almanac(9, 'almanac');
registerDisplay(display);
export default display.getSun.bind(display);

View File

@@ -30,8 +30,9 @@ class CurrentWeather extends WeatherDisplay {
const filteredStations = weatherParameters.stations.filter((station) => station?.properties?.stationIdentifier?.length === 4 && !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
// Load the observations
let observations; let
station;
let observations;
let station;
// station number counter
let stationNum = 0;
while (!observations && stationNum < filteredStations.length) {
@@ -53,25 +54,27 @@ class CurrentWeather extends WeatherDisplay {
// test data quality
if (observations.features[0].properties.temperature.value === null
|| observations.features[0].properties.windSpeed.value === null
|| observations.features[0].properties.textDescription === null) {
|| observations.features[0].properties.textDescription === null
|| observations.features[0].properties.textDescription === ''
|| observations.features[0].properties.icon === null) {
observations = undefined;
throw new Error(`Unable to get observations: ${station.properties.stationIdentifier}, trying next station`);
}
} catch (e) {
console.error(e);
} catch (error) {
console.error(error);
}
}
// test for data received
if (!observations) {
console.error('All current weather stations exhausted');
if (this.enabled) this.setStatus(STATUS.failed);
if (this.isEnabled) this.setStatus(STATUS.failed);
// send failed to subscribers
this.getDataCallback(undefined);
return;
}
// we only get here if there was no error above
this.data = { ...observations, station };
this.data = parseData({ ...observations, station });
this.getDataCallback();
// stop here if we're disabled
@@ -82,89 +85,37 @@ class CurrentWeather extends WeatherDisplay {
this.setStatus(STATUS.loaded);
}
// format the data for use outside this function
parseData() {
if (!this.data) return false;
const data = {};
const observations = this.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.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.Humidity = Math.round(observations.relativeHumidity.value);
data.Icon = getWeatherIconFromIconLink(observations.icon);
data.PressureDirection = '';
data.TextConditions = observations.textDescription;
data.station = this.data.station;
// difference since last measurement (pascals, looking for difference of more than 150)
const pressureDiff = (observations.barometricPressure.value - this.data.features[1].properties.barometricPressure.value);
if (pressureDiff > 150) data.PressureDirection = 'R';
if (pressureDiff < -150) data.PressureDirection = 'F';
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;
}
async drawCanvas() {
super.drawCanvas();
const fill = {};
// parse each time to deal with a change in units if necessary
const data = this.parseData();
fill.temp = data.Temperature + String.fromCharCode(176);
let Conditions = data.observations.textDescription;
if (Conditions.length > 15) {
Conditions = shortConditions(Conditions);
let condition = this.data.observations.textDescription;
if (condition.length > 15) {
condition = shortConditions(condition);
}
fill.condition = Conditions;
fill.wind = data.WindDirection.padEnd(3, '') + data.WindSpeed.toString().padStart(3, ' ');
if (data.WindGust) fill['wind-gusts'] = `Gusts to ${data.WindGust}`;
const fill = {
temp: this.data.Temperature + String.fromCharCode(176),
condition,
wind: this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' '),
location: locationCleanup(this.data.station.properties.name).substr(0, 20),
humidity: `${this.data.Humidity}%`,
dewpoint: this.data.DewPoint + String.fromCharCode(176),
ceiling: (this.data.Ceiling === 0 ? 'Unlimited' : this.data.Ceiling + this.data.CeilingUnit),
visibility: this.data.Visibility + this.data.VisibilityUnit,
pressure: `${this.data.Pressure} ${this.data.PressureDirection}`,
icon: { type: 'img', src: this.data.Icon },
};
fill.location = locationCleanup(this.data.station.properties.name).substr(0, 20);
if (this.data.WindGust) fill['wind-gusts'] = `Gusts to ${this.data.WindGust}`;
fill.humidity = `${data.Humidity}%`;
fill.dewpoint = data.DewPoint + String.fromCharCode(176);
fill.ceiling = (data.Ceiling === 0 ? 'Unlimited' : data.Ceiling + data.CeilingUnit);
fill.visibility = data.Visibility + data.VisibilityUnit;
fill.pressure = `${data.Pressure} ${data.PressureDirection}`;
if (data.observations.heatIndex.value && data.HeatIndex !== data.Temperature) {
if (this.data.observations.heatIndex.value && this.data.HeatIndex !== this.data.Temperature) {
fill['heat-index-label'] = 'Heat Index:';
fill['heat-index'] = data.HeatIndex + String.fromCharCode(176);
} else if (data.observations.windChill.value && data.WindChill !== '' && data.WindChill < data.Temperature) {
fill['heat-index'] = this.data.HeatIndex + String.fromCharCode(176);
} else if (this.data.observations.windChill.value && this.data.WindChill !== '' && this.data.WindChill < this.data.Temperature) {
fill['heat-index-label'] = 'Wind Chill:';
fill['heat-index'] = data.WindChill + String.fromCharCode(176);
fill['heat-index'] = this.data.WindChill + String.fromCharCode(176);
}
fill.icon = { type: 'img', src: data.Icon };
const area = this.elem.querySelector('.main');
area.innerHTML = '';
@@ -178,9 +129,9 @@ class CurrentWeather extends WeatherDisplay {
async getCurrentWeather(stillWaiting) {
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
return new Promise((resolve) => {
if (this.data) resolve(this.parseData());
if (this.data) resolve(this.data);
// data not available, put it into the data callback queue
this.getDataCallbacks.push(() => resolve(this.parseData()));
this.getDataCallbacks.push(() => resolve(this.data));
});
}
}
@@ -204,7 +155,53 @@ const shortConditions = (_condition) => {
return condition;
};
const display = new CurrentWeather(0, 'current-weather');
// format the received data
const parseData = (data) => {
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.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.Humidity = Math.round(observations.relativeHumidity.value);
data.Icon = getWeatherIconFromIconLink(observations.icon);
data.PressureDirection = '';
data.TextConditions = observations.textDescription;
// difference since last measurement (pascals, looking for difference of more than 150)
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
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;
};
const display = new CurrentWeather(1, 'current-weather');
registerDisplay(display);
export default display.getCurrentWeather.bind(display);

View File

@@ -43,7 +43,7 @@ const incrementInterval = () => {
const drawScreen = async () => {
// get the conditions
const data = await getCurrentWeather(() => this.stillWaiting());
const data = await getCurrentWeather();
// nothing to do if there's no data yet
if (!data) return;
@@ -75,12 +75,10 @@ const screens = [
// wind
(data) => {
let text = '';
if (data.WindSpeed > 0) {
text = `Wind: ${data.WindDirection} ${data.WindSpeed} ${data.WindUnit}`;
} else {
text = 'Wind: Calm';
}
let text = data.WindSpeed > 0
? `Wind: ${data.WindDirection} ${data.WindSpeed} ${data.WindUnit}`
: 'Wind: Calm';
if (data.WindGust > 0) {
text += ` Gusts to ${data.WindGust}`;
}
@@ -88,7 +86,10 @@ const screens = [
},
// visibility
(data) => `Visib: ${data.Visibility} ${data.VisibilityUnit} Ceiling: ${data.Ceiling === 0 ? 'Unlimited' : `${data.Ceiling} ${data.CeilingUnit}`}`,
(data) => {
const distance = `${data.Ceiling} ${data.CeilingUnit}`;
return `Visib: ${data.Visibility} ${data.VisibilityUnit} Ceiling: ${data.Ceiling === 0 ? 'Unlimited' : distance}`;
},
];
// internal draw function with preset parameters

View File

@@ -31,9 +31,9 @@ class ExtendedForecast extends WeatherDisplay {
retryCount: 3,
stillWaiting: () => this.stillWaiting(),
});
} catch (e) {
} catch (error) {
console.error('Unable to get extended forecast');
console.error(e.status, e.responseJSON);
console.error(error.status, error.responseJSON);
this.setStatus(STATUS.failed);
return;
}
@@ -52,8 +52,11 @@ class ExtendedForecast extends WeatherDisplay {
// create each day template
const days = forecast.map((Day) => {
const fill = {};
fill.date = Day.dayName;
const fill = {
icon: { type: 'img', src: Day.icon },
condition: Day.text,
date: Day.dayName,
};
const { low } = Day;
if (low !== undefined) {
@@ -61,10 +64,6 @@ class ExtendedForecast extends WeatherDisplay {
}
const { high } = Day;
fill['value-hi'] = Math.round(high);
fill.condition = Day.text;
// draw the icon
fill.icon = { type: 'img', src: Day.icon };
// return the filled template
return this.fillTemplate('day', fill);
@@ -123,13 +122,13 @@ const parse = (fullForecast) => {
const shortenExtendedForecastText = (long) => {
const regexList = [
[/ and /ig, ' '],
[/Slight /ig, ''],
[/Chance /ig, ''],
[/Very /ig, ''],
[/Patchy /ig, ''],
[/Areas /ig, ''],
[/Dense /ig, ''],
[/ and /gi, ' '],
[/slight /gi, ''],
[/chance /gi, ''],
[/very /gi, ''],
[/patchy /gi, ''],
[/areas /gi, ''],
[/dense /gi, ''],
[/Thunderstorm/g, 'T\'Storm'],
];
// run all regexes
@@ -144,10 +143,10 @@ const shortenExtendedForecastText = (long) => {
let short1 = conditions[0].substr(0, 10);
let short2 = '';
if (conditions[1]) {
if (!short1.endsWith('.')) {
short2 = conditions[1].substr(0, 10);
} else {
if (short1.endsWith('.')) {
short1 = short1.replace(/\./, '');
} else {
short2 = conditions[1].substr(0, 10);
}
if (short2 === 'Blowing') {
@@ -163,4 +162,4 @@ const shortenExtendedForecastText = (long) => {
};
// register display
registerDisplay(new ExtendedForecast(7, 'extended-forecast'));
registerDisplay(new ExtendedForecast(8, 'extended-forecast'));

View File

@@ -0,0 +1,140 @@
// hourly forecast list
import STATUS from './status.mjs';
import { json } from './utils/fetch.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
const hazardLevels = {
Extreme: 10,
Severe: 5,
};
class Hazards extends WeatherDisplay {
constructor(navId, elemId, defaultActive) {
// special height and width for scrolling
super(navId, elemId, 'Hazards', defaultActive);
this.showOnProgress = false;
// 0 screens skips this during "play"
this.timing.totalScreens = 0;
}
async getData(weatherParameters) {
// super checks for enabled
const superResult = super.getData(weatherParameters);
const alert = this.checkbox.querySelector('.alert');
alert.classList.remove('show');
try {
// get the forecast
const url = new URL('https://api.weather.gov/alerts/active');
url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`);
url.searchParams.append('limit', 5);
const alerts = await json(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
const unsortedAlerts = alerts.features ?? [];
const sortedAlerts = unsortedAlerts.sort((a, b) => (hazardLevels[b.properties.severity] ?? 0) - (hazardLevels[a.properties.severity] ?? 0));
const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown');
this.data = filteredAlerts;
// show alert indicator
if (this.data.length > 0) alert.classList.add('show');
} 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;
}
this.getDataCallback();
if (!superResult) {
this.setStatus(STATUS.loaded);
return;
}
this.drawLongCanvas();
}
async drawLongCanvas() {
// get the list element and populate
const list = this.elem.querySelector('.hazard-lines');
list.innerHTML = '';
const lines = this.data.map((data) => {
const fillValues = {};
// text
fillValues['hazard-text'] = `${data.properties.event}<br/><br/>${data.properties.description.replace('\n', '<br/><br/>')}`;
return this.fillTemplate('hazard', fillValues);
});
list.append(...lines);
// no alerts, skip this display by setting timing to zero
if (lines.length === 0) {
this.setStatus(STATUS.loaded);
this.timing.totalScreens = 0;
this.setStatus(STATUS.loaded);
return;
}
// update timing
// set up the timing
this.timing.baseDelay = 20;
// 24 hours = 6 pages
const pages = Math.max(Math.ceil(list.scrollHeight / 400) - 3, 1);
const timingStep = 400;
this.timing.delay = [150 + timingStep];
// add additional pages
for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep);
// add the final 3 second delay
this.timing.delay.push(250);
this.calcNavTiming();
this.setStatus(STATUS.loaded);
}
drawCanvas() {
super.drawCanvas();
this.finishDraw();
}
showCanvas() {
// special to hourly to draw the remainder of the canvas
this.drawCanvas();
super.showCanvas();
}
// screen index change callback just runs the base count callback
screenIndexChange() {
this.baseCountChange(this.navBaseCount);
}
// 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));
// don't let offset go negative
if (offsetY < 0) offsetY = 0;
// copy the scrolled portion of the canvas
this.elem.querySelector('.main').scrollTo(0, offsetY);
}
// make data available outside this class
// promise allows for data to be requested before it is available
async getCurrentData(stillWaiting) {
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
return new Promise((resolve) => {
if (this.data) resolve(this.data);
// data not available, put it into the data callback queue
this.getDataCallbacks.push(() => resolve(this.data));
});
}
}
// register display
registerDisplay(new Hazards(0, 'hazards', true));

View File

@@ -8,7 +8,6 @@ import { DateTime } from '../vendor/auto/luxon.mjs';
class HourlyGraph extends WeatherDisplay {
constructor(navId, elemId, defaultActive) {
// special height and width for scrolling
super(navId, elemId, 'Hourly Graph', defaultActive);
// move the top right data into the correct location on load
@@ -146,4 +145,4 @@ const drawPath = (path, ctx, options) => {
const formatTime = (time) => time.toFormat('ha').slice(0, -1);
// register display
registerDisplay(new HourlyGraph(3, 'hourly-graph'));
registerDisplay(new HourlyGraph(4, 'hourly-graph'));

View File

@@ -34,10 +34,10 @@ class Hourly extends WeatherDisplay {
try {
// get the forecast
forecast = await json(weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
} catch (e) {
} catch (error) {
console.error('Get hourly forecast failed');
console.error(e.status, e.responseJSON);
if (this.enabled) this.setStatus(STATUS.failed);
console.error(error.status, error.responseJSON);
if (this.isEnabled) this.setStatus(STATUS.failed);
// return undefined to other subscribers
this.getDataCallback(undefined);
return;
@@ -183,7 +183,7 @@ const expand = (data) => {
result.push(item.value); // push data array
} // timestamp is after now
// increment start time by 1 hour
startTime += 3600000;
startTime += 3_600_000;
} while (startTime < endTime && result.length < 24);
}); // for each value
@@ -191,7 +191,7 @@ const expand = (data) => {
};
// register display
const display = new Hourly(2, 'hourly', false);
const display = new Hourly(3, 'hourly', false);
registerDisplay(display);
export default display.getCurrentData.bind(display);

View File

@@ -1,3 +1,4 @@
/* eslint-disable unicorn/consistent-function-scoping */
/* spell-checker: disable */
const getWeatherRegionalIconFromIconLink = (link, _isNightTime) => {
@@ -8,7 +9,7 @@ const getWeatherRegionalIconFromIconLink = (link, _isNightTime) => {
// grab everything after the last slash ending at any of these: ?&,
const afterLastSlash = link.toLowerCase().match(/[^/]+$/)[0];
let conditionName = afterLastSlash.match(/(.*?)[,?&.]/)[1];
let conditionName = afterLastSlash.match(/(.*?)[&,.?]/)[1];
// using probability as a crude heavy/light indication where possible
const value = +(link.match(/,(\d{2,3})/) ?? [0, 100])[1];
@@ -87,6 +88,7 @@ const getWeatherRegionalIconFromIconLink = (link, _isNightTime) => {
return addPath('Light-Snow.gif');
case 'rain_snow':
case 'rain_snow-n':
return addPath('Rain-Snow-1992.gif');
case 'snow_fzra':
@@ -95,6 +97,8 @@ const getWeatherRegionalIconFromIconLink = (link, _isNightTime) => {
case 'fzra':
case 'fzra-n':
case 'rain_fzra':
case 'rain_fzra-n':
return addPath('Freezing-Rain-1992.gif');
case 'snow_sleet':
@@ -119,10 +123,15 @@ const getWeatherRegionalIconFromIconLink = (link, _isNightTime) => {
case 'tropical_storm':
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_skc':
@@ -133,6 +142,7 @@ const getWeatherRegionalIconFromIconLink = (link, _isNightTime) => {
return addPath('Clear-Wind-1994.gif');
case 'blizzard':
case 'blizzard-n':
return addPath('Blowing Snow.gif');
case 'cold':
@@ -154,7 +164,7 @@ const getWeatherIconFromIconLink = (link, _isNightTime) => {
// grab everything after the last slash ending at any of these: ?&,
const afterLastSlash = link.toLowerCase().match(/[^/]+$/)[0];
let conditionName = afterLastSlash.match(/(.*?)[,?&.]/)[1];
let conditionName = afterLastSlash.match(/(.*?)[&,.?]/)[1];
// using probability as a crude heavy/light indication where possible
const value = +(link.match(/,(\d{2,3})/) ?? [0, 100])[1];
@@ -202,6 +212,9 @@ const getWeatherIconFromIconLink = (link, _isNightTime) => {
return addPath('CC_Fog.gif');
case 'rain_sleet':
case 'rain_sleet-n':
case 'sleet':
case 'sleet-n':
return addPath('Sleet.gif');
case 'rain_showers':
@@ -237,6 +250,8 @@ const getWeatherIconFromIconLink = (link, _isNightTime) => {
case 'snow_fzra-n':
case 'fzra':
case 'fzra-n':
case 'rain_fzra':
case 'rain_fzra-n':
return addPath('CC_FreezingRain.gif');
case 'snow_sleet':
@@ -260,14 +275,16 @@ const getWeatherIconFromIconLink = (link, _isNightTime) => {
case 'wind_sct':
case 'wind_bkn':
case 'wind_ovc':
return addPath('CC_Windy.gif');
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');
default:

View File

@@ -32,31 +32,25 @@ class LatestObservations extends WeatherDisplay {
const regionalStations = sortedStations.slice(0, 30);
// get data for regional stations
const allConditions = await Promise.all(regionalStations.map(async (station) => {
try {
const data = await json(`https://api.weather.gov/stations/${station.id}/observations/latest`, { retryCount: 3, 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;
// format the return values
return {
...data.properties,
StationId: station.id,
city: station.city,
};
} catch (e) {
console.log(`Unable to get latest observations for ${station.id}`);
return false;
}
}));
// remove and stations that did not return data
const actualConditions = allConditions.filter((condition) => condition);
// get first 7 stations
const actualConditions = [];
let lastStation = Math.min(regionalStations.length, 7);
let firstStation = 0;
while (actualConditions.length < 7 && (lastStation) <= regionalStations.length) {
// eslint-disable-next-line no-await-in-loop
const someStations = await getStations(regionalStations.slice(firstStation, lastStation));
actualConditions.push(...someStations);
// update counters
firstStation += lastStation;
lastStation = Math.min(regionalStations.length + 1, firstStation + 7 - actualConditions.length);
}
// cut down to the maximum of 7
this.data = actualConditions.slice(0, this.MaximumRegionalStations);
// test for at least one station
if (this.data.length < 1) {
if (this.data.length === 0) {
this.setStatus(STATUS.noData);
return;
}
@@ -79,10 +73,12 @@ class LatestObservations extends WeatherDisplay {
const Temperature = Math.round(celsiusToFahrenheit(condition.temperature.value));
const WindSpeed = Math.round(kphToMph(condition.windSpeed.value));
const fill = {};
fill.location = locationCleanup(condition.city).substr(0, 14);
fill.temp = Temperature;
fill.weather = shortenCurrentConditions(condition.textDescription).substr(0, 9);
const fill = {
location: locationCleanup(condition.city).substr(0, 14),
temp: Temperature,
weather: shortenCurrentConditions(condition.textDescription).substr(0, 9),
};
if (WindSpeed > 0) {
fill.wind = windDirection + (Array(6 - windDirection.length - WindSpeed.toString().length).join(' ')) + WindSpeed.toString();
} else if (WindSpeed === 'NA') {
@@ -119,5 +115,28 @@ const shortenCurrentConditions = (_condition) => {
condition = condition.replace(/ with /, '/');
return condition;
};
const getStations = async (stations) => {
const stationData = await Promise.all(stations.map(async (station) => {
try {
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;
// format the return values
return {
...data.properties,
StationId: station.id,
city: station.city,
};
} catch (error) {
console.log(`Unable to get latest observations for ${station.id}`);
return false;
}
}));
// filter false (no data or other error)
return stationData.filter((d) => d);
};
// register display
registerDisplay(new LatestObservations(1, 'latest-observations'));
registerDisplay(new LatestObservations(2, 'latest-observations'));

View File

@@ -66,9 +66,9 @@ class LocalForecast extends WeatherDisplay {
retryCount: 3,
stillWaiting: () => this.stillWaiting(),
});
} catch (e) {
} catch (error) {
console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`);
console.error(e.status, e.responseJSON);
console.error(error.status, error.responseJSON);
this.setStatus(STATUS.failed);
return false;
}
@@ -92,4 +92,4 @@ const parse = (forecast) => forecast.properties.periods.slice(0, 6).map((text) =
Text: text.detailedForecast,
}));
// register display
registerDisplay(new LocalForecast(6, 'local-forecast'));
registerDisplay(new LocalForecast(7, 'local-forecast'));

View File

@@ -16,7 +16,8 @@ const weatherParameters = {};
// auto refresh
const AUTO_REFRESH_INTERVAL_MS = 500;
const AUTO_REFRESH_TIME_MS = 600000; // 10 min.
const AUTO_REFRESH_TIME_MS = 600_000; // 10 min.
const CHK_AUTO_REFRESH_SELECTOR = '#chkAutoRefresh';
let AutoRefreshIntervalId = null;
let AutoRefreshCountMs = 0;
@@ -26,27 +27,21 @@ const init = async () => {
resize();
// auto refresh
const TwcAutoRefresh = localStorage.getItem('TwcAutoRefresh');
if (!TwcAutoRefresh || TwcAutoRefresh === 'true') {
document.getElementById('chkAutoRefresh').checked = true;
const autoRefresh = localStorage.getItem('autoRefresh');
if (!autoRefresh || autoRefresh === 'true') {
document.querySelector(CHK_AUTO_REFRESH_SELECTOR).checked = true;
} else {
document.getElementById('chkAutoRefresh').checked = false;
document.querySelector(CHK_AUTO_REFRESH_SELECTOR).checked = false;
}
document.getElementById('chkAutoRefresh').addEventListener('change', autoRefreshChange);
document.querySelector(CHK_AUTO_REFRESH_SELECTOR).addEventListener('change', autoRefreshChange);
generateCheckboxes();
};
const message = (data) => {
// dispatch event
if (!data.type) return;
switch (data.type) {
case 'navButton':
handleNavButton(data.message);
break;
default:
console.error(`Unknown event ${data.type}`);
}
if (!data.type) return false;
if (data.type === 'navButton') return handleNavButton(data.message);
return console.error(`Unknown event ${data.type}`);
};
const getWeather = async (latLon, haveDataCallback) => {
@@ -61,10 +56,12 @@ const getWeather = async (latLon, haveDataCallback) => {
const StationId = stations.features[0].properties.stationIdentifier;
let { city } = point.properties.relativeLocation.properties;
const { state } = point.properties.relativeLocation.properties;
if (StationId in StationInfo) {
city = StationInfo[StationId].city;
[city] = city.split('/');
city = city.replace(/\s+$/, '');
}
// populate the weather parameters
@@ -75,8 +72,8 @@ const getWeather = async (latLon, haveDataCallback) => {
weatherParameters.stationId = StationId;
weatherParameters.weatherOffice = point.properties.cwa;
weatherParameters.city = city;
weatherParameters.state = point.properties.relativeLocation.properties.state;
weatherParameters.timeZone = point.properties.relativeLocation.properties.timeZone;
weatherParameters.state = state;
weatherParameters.timeZone = point.properties.timeZone;
weatherParameters.forecast = point.properties.forecast;
weatherParameters.forecastGridData = point.properties.forecastGridData;
weatherParameters.stations = stations.features;
@@ -86,7 +83,7 @@ const getWeather = async (latLon, haveDataCallback) => {
// draw the progress canvas and hide others
hideAllCanvases();
document.getElementById('loading').style.display = 'none';
document.querySelector('#loading').style.display = 'none';
if (progress) {
await progress.drawCanvas();
progress.showCanvas();
@@ -102,8 +99,24 @@ const updateStatus = (value) => {
if (!progress) return;
progress.drawCanvas(displays, countLoadedDisplays());
// first display is hazards and it must load before evaluating the first display
if (displays[0].status === STATUS.loading) return;
// calculate first enabled display
const firstDisplayIndex = displays.findIndex((display) => display.enabled);
const firstDisplayIndex = displays.findIndex((display) => display.enabled && display.timing.totalScreens > 0);
// value.id = 0 is hazards, if they fail to load hot-wire a new value.id to the current display to see if it needs to be loaded
// typically this plays out as current conditions loads, then hazards fails.
if (value.id === 0 && (value.status === STATUS.failed || value.status === STATUS.retrying)) {
value.id = firstDisplayIndex;
value.status = displays[firstDisplayIndex].status;
}
// if hazards data arrives after the firstDisplayIndex loads, then we need to hot wire this to the first display
if (value.id === 0 && value.status === STATUS.loaded && displays[0].timing.totalScreens === 0) {
value.id = firstDisplayIndex;
value.status = displays[firstDisplayIndex].status;
}
// if this is the first display and we're playing, load it up so it starts playing
if (isPlaying() && value.id === firstDisplayIndex && value.status === STATUS.loaded) {
@@ -164,13 +177,14 @@ const navTo = (direction) => {
let firstDisplay;
let displayCount = 0;
do {
if (displays[displayCount].status === STATUS.loaded) firstDisplay = displays[displayCount];
if (displays[displayCount].status === STATUS.loaded && displays[displayCount].timing.totalScreens > 0) firstDisplay = displays[displayCount];
displayCount += 1;
} while (!firstDisplay && displayCount < displays.length);
if (!firstDisplay) return;
firstDisplay.navNext(msg.command.firstFrame);
firstDisplay.showCanvas();
return;
}
if (direction === msg.command.nextFrame) currentDisplay().navNext();
@@ -185,12 +199,10 @@ const loadDisplay = (direction) => {
for (let i = 0; i < totalDisplays; i += 1) {
// convert form simple 0-10 to start at current display index +/-1 and wrap
idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
if (displays[idx].status === STATUS.loaded) break;
if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) break;
}
// if new display index is less than current display a wrap occurred, test for reload timeout
if (idx <= curIdx) {
if (refreshCheck()) return;
}
if (idx <= curIdx && refreshCheck()) return;
const newDisplay = displays[idx];
// hide all displays
hideAllCanvases();
@@ -200,25 +212,22 @@ const loadDisplay = (direction) => {
};
// get the current display index or value
const currentDisplayIndex = () => {
const index = displays.findIndex((display) => display.isActive());
return index;
};
const currentDisplayIndex = () => displays.findIndex((display) => display.active);
const currentDisplay = () => displays[currentDisplayIndex()];
const setPlaying = (newValue) => {
playing = newValue;
const playButton = document.getElementById('NavigatePlay');
localStorage.setItem('TwcPlay', playing);
const playButton = document.querySelector('#NavigatePlay');
localStorage.setItem('play', playing);
if (playing) {
noSleep(true);
playButton.title = 'Pause';
playButton.src = 'images/nav/ic_pause_white_24dp_1x.png';
playButton.src = 'images/nav/ic_pause_white_24dp_2x.png';
} else {
noSleep(false);
playButton.title = 'Play';
playButton.src = 'images/nav/ic_play_arrow_white_24dp_1x.png';
playButton.src = 'images/nav/ic_play_arrow_white_24dp_2x.png';
}
// if we're playing and on the progress screen jump to the next screen
if (!progress) return;
@@ -260,15 +269,14 @@ const getDisplay = (index) => displays[index];
// resize the container on a page resize
const resize = () => {
const marginOffset = (document.fullscreenElement) ? 0 : 16;
const widthZoomPercent = (window.innerWidth - marginOffset) / 640;
const heightZoomPercent = (window.innerHeight - marginOffset) / 480;
const widthZoomPercent = (document.querySelector('#divTwcBottom').getBoundingClientRect().width) / 640;
const heightZoomPercent = (window.innerHeight) / 480;
const scale = Math.min(widthZoomPercent, heightZoomPercent);
if (scale < 1.0 || document.fullscreenElement) {
document.getElementById('container').style.zoom = scale;
document.querySelector('#container').style.transform = `scale(${scale})`;
} else {
document.getElementById('container').style.zoom = 1;
document.querySelector('#container').style.transform = 'unset';
}
};
@@ -286,7 +294,7 @@ const registerDisplay = (display) => {
};
const generateCheckboxes = () => {
const availableDisplays = document.getElementById('enabledDisplays');
const availableDisplays = document.querySelector('#enabledDisplays');
if (!availableDisplays) return;
// generate checkboxes
@@ -303,11 +311,11 @@ const registerProgress = (_progress) => {
};
const populateWeatherParameters = (params) => {
document.getElementById('spanCity').innerHTML = `${params.city}, `;
document.getElementById('spanState').innerHTML = params.state;
document.getElementById('spanStationId').innerHTML = params.stationId;
document.getElementById('spanRadarId').innerHTML = params.radarId;
document.getElementById('spanZoneId').innerHTML = params.zoneId;
document.querySelector('#spanCity').innerHTML = `${params.city}, `;
document.querySelector('#spanState').innerHTML = params.state;
document.querySelector('#spanStationId').innerHTML = params.stationId;
document.querySelector('#spanRadarId').innerHTML = params.radarId;
document.querySelector('#spanZoneId').innerHTML = params.zoneId;
};
const autoRefreshChange = (e) => {
@@ -319,17 +327,17 @@ const autoRefreshChange = (e) => {
stopAutoRefreshTimer();
}
localStorage.setItem('TwcAutoRefresh', checked);
localStorage.setItem('autoRefresh', checked);
};
const AssignLastUpdate = (date) => {
if (date) {
document.getElementById('spanLastRefresh').innerHTML = date.toLocaleString('en-US', {
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.getElementById('chkAutoRefresh').checked) startAutoRefreshTimer();
if (document.querySelector(CHK_AUTO_REFRESH_SELECTOR).checked) startAutoRefreshTimer();
} else {
document.getElementById('spanLastRefresh').innerHTML = '(none)';
document.querySelector('#spanLastRefresh').innerHTML = '(none)';
}
};
@@ -356,7 +364,7 @@ const startAutoRefreshTimer = () => {
RemainingMs = 0;
}
const dt = new Date(RemainingMs);
document.getElementById('spanRefreshCountDown').innerHTML = `${dt.getMinutes() < 10 ? `0${dt.getMinutes()}` : dt.getMinutes()}:${dt.getSeconds() < 10 ? `0${dt.getSeconds()}` : dt.getSeconds()}`;
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();
@@ -367,14 +375,14 @@ const startAutoRefreshTimer = () => {
const stopAutoRefreshTimer = () => {
if (AutoRefreshIntervalId) {
window.clearInterval(AutoRefreshIntervalId);
document.getElementById('spanRefreshCountDown').innerHTML = '--:--';
document.querySelector('#spanRefreshCountDown').innerHTML = '--:--';
AutoRefreshIntervalId = null;
}
};
const refreshCheck = () => {
// Time has elapsed.
if (AutoRefreshCountMs >= AUTO_REFRESH_TIME_MS) {
if (AutoRefreshCountMs >= AUTO_REFRESH_TIME_MS && isPlaying()) {
loadTwcData();
return true;
}
@@ -389,6 +397,8 @@ const registerRefreshData = (callback) => {
loadTwcData.callback = callback;
};
const timeZone = () => weatherParameters.timeZone;
export {
updateStatus,
displayNavMessage,
@@ -404,4 +414,5 @@ export {
latLonReceived,
stopAutoRefreshTimer,
registerRefreshData,
timeZone,
};

View File

@@ -18,7 +18,7 @@ class Progress extends WeatherDisplay {
// setup event listener for dom-required initialization
document.addEventListener('DOMContentLoaded', () => {
this.version = document.getElementById('version').innerHTML;
this.version = document.querySelector('#version').innerHTML;
this.elem.querySelector('.container').addEventListener('click', this.lineClick.bind(this));
});
@@ -26,6 +26,7 @@ class Progress extends WeatherDisplay {
}
async drawCanvas(displays, loadedCount) {
if (!this.elem) return;
super.drawCanvas();
// get the progress bar cover (makes percentage)
@@ -34,9 +35,10 @@ class Progress extends WeatherDisplay {
// if no displays provided just draw the backgrounds (above)
if (!displays) return;
const lines = displays.map((display, index) => {
const fill = {};
fill.name = display.name;
if (display.showOnProgress === false) return false;
const fill = {
name: display.name,
};
const statusClass = calcStatusClass(display.status);

View File

@@ -5,7 +5,7 @@ import { loadImg } from './utils/image.mjs';
import { text } from './utils/fetch.mjs';
import { rewriteUrl } from './utils/cors.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
import { registerDisplay, timeZone } from './navigation.mjs';
import * as utils from './radar-utils.mjs';
class Radar extends WeatherDisplay {
@@ -71,36 +71,36 @@ class Radar extends WeatherDisplay {
const lists = (await Promise.all(baseUrls.map(async (url) => {
try {
// get a list of available radars
const radarHtml = await text(url, { cors: true });
return radarHtml;
} catch (e) {
return text(url, { cors: true });
} catch (error) {
console.log('Unable to get list of radars');
console.error(e);
console.error(error);
this.setStatus(STATUS.failed);
return false;
}
}))).filter((d) => d);
// convert to an array of gif urls
const pngs = lists.map((html, htmlIdx) => {
const pngs = lists.flatMap((html, htmlIdx) => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(html, 'text/html');
// add the base url
const base = xmlDoc.createElement('base');
base.href = baseUrls[htmlIdx];
xmlDoc.head.append(base);
const anchors = xmlDoc.getElementsByTagName('a');
const anchors = xmlDoc.querySelectorAll('a');
const urls = [];
Array.from(anchors).forEach((elem) => {
if (elem.innerHTML?.includes('.png') && elem.innerHTML?.includes('n0r_')) {
if (elem.innerHTML?.match(/n0r_\d{12}\.png/)) {
urls.push(elem.href);
}
});
return urls;
}).flat();
});
// get the last few images
const sortedPngs = pngs.sort((a, b) => (Date(a) < Date(b) ? -1 : 1));
const timestampRegex = /_(\d{12})\.png/;
const sortedPngs = pngs.sort((a, b) => (a.match(timestampRegex)[1] < b.match(timestampRegex)[1] ? -1 : 1));
const urls = sortedPngs.slice(-(this.dopplerRadarImageMax));
// calculate offsets and sizes
@@ -159,7 +159,7 @@ class Radar extends WeatherDisplay {
zone: 'UTC',
}).setZone();
} else {
time = DateTime.fromHTTP(response.headers.get('last-modified')).setZone();
time = DateTime.fromHTTP(response.headers.get('last-modified')).setZone(timeZone());
}
// assign to an html image element
@@ -214,12 +214,16 @@ class Radar extends WeatherDisplay {
const timePadded = time.length >= 8 ? time : `&nbsp;${time}`;
this.elem.querySelector('.header .right .time').innerHTML = timePadded;
// get image offset calculation
// is slides slightly because of scaling so we have to take a measurement from the rendered page
const actualFrameHeight = this.elem.querySelector('.frame').getBoundingClientRect().height;
// scroll to image
this.elem.querySelector('.scroll-area').style.top = `${-this.screenIndex * 371}px`;
this.elem.querySelector('.scroll-area').style.top = `${-this.screenIndex * actualFrameHeight}px`;
this.finishDraw();
}
}
// register display
registerDisplay(new Radar(9, 'radar'));
registerDisplay(new Radar(10, 'radar'));

View File

@@ -23,12 +23,14 @@ const getRegionalObservation = async (point, city) => {
const observation = await json(`${station}/observations/latest`);
// preload the image
if (!observation.properties.icon) return false;
preloadImg(getWeatherRegionalIconFromIconLink(observation.properties.icon, !observation.properties.daytime));
const icon = getWeatherRegionalIconFromIconLink(observation.properties.icon, !observation.properties.daytime);
if (!icon) return false;
preloadImg(icon);
// return the observation
return observation.properties;
} catch (e) {
} catch (error) {
console.log(`Unable to get regional observations for ${city.Name ?? city.city}`);
console.error(e.status, e.responseJSON);
console.error(error.status, error.responseJSON);
return false;
}
};
@@ -193,7 +195,7 @@ const getXYForCityHI = (City, MaxLatitude, MinLongitude) => {
};
// to fit on the map, remove anything after punctuation and then limit to 15 characters
const formatCity = (city) => city.match(/[^-;/\\,]*/)[0].substr(0, 12);
const formatCity = (city) => city.match(/[^,/;\\-]*/)[0].substr(0, 12);
export {
buildForecast,

View File

@@ -87,9 +87,12 @@ class RegionalForecast extends WeatherDisplay {
// wait for the regional observation if it's not done yet
const observation = await observationPromise;
if (!observation) return false;
// format the observation the same as the forecast
const regionalObservation = {
daytime: !!observation.icon.match(/\/day\//),
daytime: !!/\/day\//.test(observation.icon),
temperature: celsiusToFahrenheit(observation.temperature.value),
name: utils.formatCity(city.city),
icon: observation.icon,
@@ -110,9 +113,9 @@ class RegionalForecast extends WeatherDisplay {
utils.buildForecast(forecast.properties.periods[1], city, cityXY),
utils.buildForecast(forecast.properties.periods[2], city, cityXY),
];
} catch (e) {
} catch (error) {
console.log(`No regional forecast data for '${city.name ?? city.city}'`);
console.log(e);
console.log(error);
return false;
}
}));
@@ -156,19 +159,15 @@ class RegionalForecast extends WeatherDisplay {
const dayName = forecastDate.toLocaleString({ weekday: 'long' });
titleTop.innerHTML = 'Forecast for';
// draw the title
if (data[0][this.screenIndex].daytime) {
titleBottom.innerHTML = dayName;
} else {
titleBottom.innerHTML = `${dayName} Night`;
}
titleBottom.innerHTML = data[0][this.screenIndex].daytime
? dayName
: `${dayName} Night`;
}
// draw the map
const scale = 640 / (offsetXY.x * 2);
const map = this.elem.querySelector('.map');
map.style.zoom = scale;
map.style.top = `-${sourceXY.y}px`;
map.style.left = `-${sourceXY.x}px`;
map.style.transform = `scale(${scale}) translate(-${sourceXY.x}px, -${sourceXY.y}px)`;
const cities = data.map((city) => {
const fill = {};
@@ -179,9 +178,11 @@ class RegionalForecast extends WeatherDisplay {
const { temperature } = period;
fill.temp = temperature;
const { x, y } = period;
const elem = this.fillTemplate('location', fill);
elem.style.left = `${period.x}px`;
elem.style.top = `${period.y}px`;
elem.style.left = `${x}px`;
elem.style.top = `${y}px`;
return elem;
});
@@ -204,4 +205,4 @@ const getAndFormatPoint = async (lat, lon) => {
};
// register display
registerDisplay(new RegionalForecast(5, 'regional-forecast'));
registerDisplay(new RegionalForecast(6, 'regional-forecast'));

View File

@@ -45,9 +45,9 @@ class TravelForecast extends WeatherDisplay {
name: city.Name,
icon: getWeatherRegionalIconFromIconLink(forecast.properties.periods[todayShift].icon),
};
} catch (e) {
} catch (error) {
console.error(`GetTravelWeather for ${city.Name} failed`);
console.error(e.status, e.responseJSON);
console.error(error.status, error.responseJSON);
return { name: city.Name, error: true };
}
});
@@ -57,7 +57,7 @@ class TravelForecast extends WeatherDisplay {
this.data = forecasts;
// test for some data available in at least one forecast
const hasData = this.data.reduce((acc, forecast) => acc || forecast.high, false);
const hasData = this.data.some((forecast) => forecast.high);
if (!hasData) {
this.setStatus(STATUS.noData);
return;
@@ -77,10 +77,9 @@ class TravelForecast extends WeatherDisplay {
const lines = cities.map((city) => {
if (city.error) return false;
const fillValues = {};
// city name
fillValues.city = city;
const fillValues = {
city,
};
// check for forecast data
if (city.icon) {
@@ -94,8 +93,9 @@ class TravelForecast extends WeatherDisplay {
fillValues.low = lowString;
fillValues.high = highString;
const { icon } = city;
fillValues.icon = { type: 'img', src: city.icon };
fillValues.icon = { type: 'img', src: icon };
} else {
fillValues.error = 'NO TRAVEL DATA AVAILABLE';
}
@@ -158,4 +158,4 @@ const getTravelCitiesDayName = (cities) => cities.reduce((dayName, city) => {
}, '');
// register display, not active by default
registerDisplay(new TravelForecast(4, 'travel', false));
registerDisplay(new TravelForecast(5, 'travel', false));

View File

@@ -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' ? window.location.protocol : url.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) => {
@@ -73,7 +73,7 @@ const doFetch = (url, params) => new Promise((resolve, reject) => {
// out of retries
return resolve(response);
})
.catch((e) => reject(e));
.catch((error) => reject(error));
});
const delay = (time, func, ...args) => new Promise((resolve) => {
@@ -87,8 +87,8 @@ const retryDelay = (retryNumber) => {
case 1: return 1000;
case 2: return 2000;
case 3: return 5000;
case 4: return 10000;
default: return 30000;
case 4: return 10_000;
default: return 30_000;
}
};

View File

@@ -2,11 +2,11 @@ const locationCleanup = (input) => {
// regexes to run
const regexes = [
// "Chicago / West Chicago", removes before slash
/^[A-Za-z ]+ \/ /,
/^[ A-Za-z]+ \/ /,
// "Chicago/Waukegan" removes before slash
/^[A-Za-z ]+\//,
/^[ A-Za-z]+\//,
// "Chicago, Chicago O'hare" removes before comma
/^[A-Za-z ]+, /,
/^[ A-Za-z]+, /,
];
// run all regexes

View File

@@ -2,11 +2,11 @@
const round2 = (value, decimals) => Math.trunc(value * 10 ** decimals) / 10 ** decimals;
const kphToMph = (Kph) => Math.round(Kph / 1.60934);
const kphToMph = (Kph) => Math.round(Kph / 1.609_34);
const celsiusToFahrenheit = (Celsius) => Math.round((Celsius * 9) / 5 + 32);
const kilometersToMiles = (Kilometers) => Math.round(Kilometers / 1.60934);
const kilometersToMiles = (Kilometers) => Math.round(Kilometers / 1.609_34);
const metersToFeet = (Meters) => Math.round(Meters / 0.3048);
const pascalToInHg = (Pascal) => round2(Pascal * 0.0002953, 2);
const pascalToInHg = (Pascal) => round2(Pascal * 0.000_295_3, 2);
export {
kphToMph,

View File

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

View File

@@ -2,9 +2,8 @@
import STATUS, { calcStatusClass, statusClasses } from './status.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
import { elemForEach } from './utils/elem.mjs';
import {
msg, displayNavMessage, isPlaying, updateStatus,
msg, displayNavMessage, isPlaying, updateStatus, timeZone,
} from './navigation.mjs';
class WeatherDisplay {
@@ -12,7 +11,6 @@ class WeatherDisplay {
// navId is used in messaging and sort order
this.navId = navId;
this.elemId = undefined;
this.gifs = [];
this.data = undefined;
this.loadingStatus = STATUS.loading;
this.name = name ?? elemId;
@@ -21,6 +19,7 @@ class WeatherDisplay {
this.defaultEnabled = defaultEnabled;
this.okToDrawCurrentConditions = true;
this.okToDrawCurrentDateTime = true;
this.showOnProgress = true;
// default navigation timing
this.timing = {
@@ -34,7 +33,7 @@ class WeatherDisplay {
// store elemId once
this.storeElemId(elemId);
if (this.enabled) {
if (this.isEnabled) {
this.setStatus(STATUS.loading);
} else {
this.setStatus(STATUS.disabled);
@@ -54,14 +53,10 @@ class WeatherDisplay {
// get the saved status of the checkbox
let savedStatus = window.localStorage.getItem(`display-enabled: ${this.elemId}`);
if (savedStatus === null) savedStatus = defaultEnabled;
if (savedStatus === 'true' || savedStatus === true) {
this.enabled = true;
} else {
this.enabled = false;
}
this.isEnabled = !!((savedStatus === 'true' || savedStatus === true));
// refresh (or initially store the state of the checkbox)
window.localStorage.setItem(`display-enabled: ${this.elemId}`, this.enabled);
window.localStorage.setItem(`display-enabled: ${this.elemId}`, this.isEnabled);
// create a checkbox in the selected displays area
const label = document.createElement('label');
@@ -72,12 +67,15 @@ class WeatherDisplay {
checkbox.value = true;
checkbox.id = `${this.elemId}-checkbox`;
checkbox.name = `${this.elemId}-checkbox`;
checkbox.checked = this.enabled;
checkbox.checked = this.isEnabled;
checkbox.addEventListener('change', (e) => this.checkboxChange(e));
const span = document.createElement('span');
span.innerHTML = this.name;
const alert = document.createElement('span');
alert.innerHTML = '!!!';
alert.classList.add('alert');
label.append(checkbox, span);
label.append(checkbox, span, alert);
this.checkbox = label;
@@ -86,9 +84,9 @@ class WeatherDisplay {
checkboxChange(e) {
// update the state
this.enabled = e.target.checked;
this.isEnabled = e.target.checked;
// store the value for the next load
window.localStorage.setItem(`display-enabled: ${this.elemId}`, this.enabled);
window.localStorage.setItem(`display-enabled: ${this.elemId}`, this.isEnabled);
// calling get data will update the status and actually get the data if we're set to enabled
this.getData();
}
@@ -130,7 +128,7 @@ class WeatherDisplay {
if (weatherParameters) this.weatherParameters = weatherParameters;
// set status
if (this.enabled) {
if (this.isEnabled) {
this.setStatus(STATUS.loading);
} else {
this.setStatus(STATUS.disabled);
@@ -168,22 +166,24 @@ class WeatherDisplay {
drawCurrentDateTime() {
// only draw if canvas is active to conserve battery
if (!this.isActive()) return;
if (!this.active) return;
// Get the current date and time.
const now = DateTime.local();
const now = DateTime.local().setZone(timeZone());
// time = "11:35:08 PM";
const time = now.toLocaleString(DateTime.TIME_WITH_SECONDS).padStart(11, ' ');
const date = now.toFormat(' ccc LLL ') + now.day.toString().padStart(2, ' ');
if (this.lastTime !== time) {
elemForEach('.date-time.time', (elem) => { elem.innerHTML = time.toUpperCase(); });
const dateElem = this.elem.querySelector('.date-time.date');
const timeElem = this.elem.querySelector('.date-time.time');
if (timeElem && this.lastTime !== time) {
timeElem.innerHTML = time.toUpperCase();
}
this.lastTime = time;
const date = now.toFormat(' ccc LLL ') + now.day.toString().padStart(2, ' ');
if (this.lastDate !== date) {
elemForEach('.date-time.date', (elem) => { elem.innerHTML = date.toUpperCase(); });
if (dateElem && this.lastDate !== date) {
dateElem.innerHTML = date.toUpperCase();
}
this.lastDate = date;
}
@@ -205,12 +205,12 @@ class WeatherDisplay {
this.elem.classList.remove('show');
}
isActive() {
get active() {
return this.elem.offsetHeight !== 0;
}
isEnabled() {
return this.enabled;
get enabled() {
return this.isEnabled;
}
// navigation timings
@@ -223,7 +223,7 @@ class WeatherDisplay {
// if the array forms are used totalScreens is overwritten by the size of the array
navBaseTime() {
// see if play is active and screen is active
if (!isPlaying() || !this.isActive()) return;
if (!isPlaying() || !this.active) return;
// increment the base count
this.navBaseCount += 1;
@@ -249,19 +249,15 @@ class WeatherDisplay {
if (nextScreenIndex === this.screenIndex) return;
// test for -1 (no screen displayed yet)
if (nextScreenIndex === -1) {
this.screenIndex = 0;
} else {
this.screenIndex = nextScreenIndex;
}
this.screenIndex = nextScreenIndex === -1 ? 0 : nextScreenIndex;
// call the appropriate screen index change method
if (!this.screenIndexChange) {
await this.drawCanvas();
this.showCanvas();
} else {
if (this.screenIndexChange) {
this.screenIndexChange(this.screenIndex);
} else {
await this.drawCanvas();
}
this.showCanvas();
}
// take the three timing formats shown above and break them into arrays for consistent usage in navigation functions
@@ -341,6 +337,7 @@ class WeatherDisplay {
screenIndexFromBaseCount() {
// test for timing enabled
if (!this.timing) return 0;
if (this.timing.totalScreens === 0) return false;
// find the first timing in the timing array that is greater than the base count
if (this.timing && !this.timing.fullDelay) this.calcNavTiming();
const timingIndex = this.timing.fullDelay.findIndex((delay) => delay > this.navBaseCount);
@@ -372,7 +369,7 @@ class WeatherDisplay {
loadTemplates() {
this.templates = {};
this.elem = document.getElementById(`${this.elemId}-html`);
this.elem = document.querySelector(`#${this.elemId}-html`);
if (!this.elem) return;
const templates = this.elem.querySelectorAll('.template');
templates.forEach((template) => {
@@ -412,7 +409,7 @@ class WeatherDisplay {
// still waiting for data (retries triggered)
stillWaiting() {
if (this.enabled) this.setStatus(STATUS.retrying);
if (this.isEnabled) this.setStatus(STATUS.retrying);
// handle still waiting callbacks
this.stillWaitingCallbacks.forEach((callback) => callback());
this.stillWaitingCallbacks = [];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
.weather-display .main.hazards {
&.main {
overflow-y: hidden;
.hazard-lines {
min-height: 400px;
padding-top: 10px;
background-color: rgb(112, 35, 35);
.hazard {
font-family: 'Star4000';
font-size: 24pt;
color: white;
@include u.text-shadow(0px);
position: relative;
text-transform: uppercase;
margin-top: 110px;
margin-left: 80px;
margin-right: 80px;
}
}
}
}

View File

@@ -20,70 +20,82 @@ body {
}
}
input,
button {
font-family: "Star4000";
}
#divQuery {
max-width: 640px;
#imgGetGps {
height: 13px;
vertical-align: middle;
}
.buttons {
display: inline-block;
width: 150px;
text-align: right;
#txtAddress {
width: 490px;
font-size: 16pt;
max-width: calc(100% - 8px);
@media (prefers-color-scheme: dark) {
background-color: #000000;
color: white;
border: 1px solid darkgray;
}
}
#btnGetGps,
#btnGetLatLng,
#btnClearQuery {
font-size: 16pt;
@media (prefers-color-scheme: dark) {
background-color: #000000;
color: white;
}
border: 1px solid darkgray;
}
#btnGetGps {
img {
&.dark {
display: none;
@media (prefers-color-scheme: dark) {
display: inline-block;
}
#imgGetGps {
height: 13px;
vertical-align: middle;
}
&.light {
button {
font-size: 16pt;
@media (prefers-color-scheme: dark) {
display: none;
background-color: #000000;
color: white;
}
border: 1px solid darkgray;
}
#btnGetGps {
img {
&.dark {
display: none;
@media (prefers-color-scheme: dark) {
display: inline-block;
}
}
&.light {
@media (prefers-color-scheme: dark) {
display: none;
}
}
}
&.active {
background-color: black;
@media (prefers-color-scheme: dark) {
background-color: white;
}
img {
filter: invert(1);
}
}
}
}
&.active {
background-color: black;
input,
button {
font-family: "Star4000";
}
#txtAddress {
width: calc(100% - 170px);
max-width: 490px;
font-size: 16pt;
min-width: 200px;
display: inline-block;
@media (prefers-color-scheme: dark) {
background-color: white;
}
img {
filter: invert(1);
background-color: #000000;
color: white;
border: 1px solid darkgray;
}
}
}
.autocomplete-suggestions {
@@ -93,19 +105,19 @@ button {
@media (prefers-color-scheme: dark) {
background-color: #000000;
}
}
.autocomplete-suggestion {
/*padding: 2px 5px;*/
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16pt;
}
.autocomplete-suggestion {
/*padding: 2px 5px;*/
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16pt;
}
.autocomplete-selected {
background-color: #0000ff;
color: #ffffff;
.autocomplete-selected {
background-color: #0000ff;
color: #ffffff;
}
}
#divTwc {
@@ -213,8 +225,7 @@ button {
text-align: right;
}
#imgPause1x,
#imgPause2x {
#imgPause1x {
visibility: hidden;
position: absolute;
}
@@ -270,15 +281,19 @@ button {
#container {
position: relative;
width: 100%;
height: 100%;
width: 640px;
height: 480px;
overflow: hidden;
background-image: url(../images/BackGround1_1.png);
transform-origin: 0 0;
}
#divTwc:fullscreen #container {
background-image: none;
width: unset;
height: unset;
transform-origin: unset;
}
#loading {
@@ -320,15 +335,48 @@ button {
color: white;
}
@media (prefers-color-scheme: light) {
.loading,
.retrying {
color: hsl(60, 100%, 30%);
}
.press-here {
color: black;
cursor: pointer;
}
.failed {
color: hsl(0, 100%, 30%);
}
.no-data {
color: hsl(0, 0%, 30%);
}
.disabled {
color: hsl(0, 0%, 30%);
}
}
label {
display: block;
max-width: 300px;
.alert {
display: none;
&.show {
display: inline;
color: red;
}
}
}
}
#divTwcBottom img {
zoom: 150%;
transform: scale(0.75);
}
#divTwc:fullscreen {

View File

@@ -12,6 +12,7 @@
.map {
position: absolute;
transform-origin: 0 0;
}
.location {

View File

@@ -96,6 +96,10 @@
width: 640px;
height: 310px;
overflow: hidden;
&.no-header {
height: 400px;
}
}
&.has-box {

View File

@@ -10,4 +10,5 @@
@import 'progress';
@import 'radar';
@import 'regional-forecast';
@import 'almanac';
@import 'almanac';
@import 'hazards';

1
tests/README.md Normal file
View File

@@ -0,0 +1 @@
Currently, tests take a different approach from typical unit testing. The test methodology loads several forecasts for different locations and logs them all to one logger so errors can be found such as missing icons, locations that do not have all of the necessary data or other changes that may occur between geographical locations.

42
tests/index.js Normal file
View File

@@ -0,0 +1,42 @@
const puppeteer = require('puppeteer');
const { setTimeout } = require('node:timers/promises');
const { readFile } = require('fs/promises');
const messageFormatter = require('./messageformatter');
(async () => {
const browser = await puppeteer.launch({
// headless: false,
slowMo: 10,
timeout: 10_000,
dumpio: true,
});
// get the list of locations
const LOCATIONS = JSON.parse(await readFile('./tests/locations.json'));
// get the page
const page = (await browser.pages())[0];
await page.goto('http://localhost:8080');
page.on('console', messageFormatter);
// run all the locations
for (let i = 0; i < LOCATIONS.length; i += 1) {
const location = LOCATIONS[i];
console.log(location);
// eslint-disable-next-line no-await-in-loop
await tester(location, page);
}
browser.close();
})();
const tester = async (location, page) => {
// Set the address
await page.type('#txtAddress', location);
await setTimeout(500);
// get the page
await page.click('#btnGetLatLng');
// wait for errors
await setTimeout(5000);
};

52
tests/locations.json Normal file
View File

@@ -0,0 +1,52 @@
[
"New York, New York",
"Los Angeles, California",
"Chicago, Illinois",
"Houston, Texas",
"Phoenix, Arizona",
"Philadelphia, Pennsylvania",
"San Antonio, Texas",
"San Diego, California",
"Dallas, Texas",
"San Jose, California",
"Austin, Texas",
"Jacksonville, Florida",
"Fort Worth, Texas",
"Columbus, Ohio",
"Charlotte, North Carolina",
"Indianapolis, Indiana",
"San Francisco, California",
"Seattle, Washington",
"Denver, Colorado",
"Nashville, Tennessee",
"Washington, District of Columbia",
"Oklahoma City, Oklahoma",
"Boston, Massachusetts",
"El Paso, Texas",
"Portland, Oregon",
"Las Vegas, Nevada",
"Memphis, Tennessee",
"Detroit, Michigan",
"Baltimore, Maryland",
"Milwaukee, Wisconsin",
"Albuquerque, New Mexico",
"Fresno, California",
"Tucson, Arizona",
"Sacramento, California",
"Mesa, Arizona",
"Kansas City, Missouri",
"Atlanta, Georgia",
"Omaha, Nebraska",
"Colorado Springs, Colorado",
"Raleigh, North Carolina",
"Long Beach, California",
"Virginia Beach, Virginia",
"Oakland, California",
"Miami, Florida",
"Minneapolis, Minnesota",
"Bakersfield, California",
"Tulsa, Oklahoma",
"Aurora, Colorado",
"Arlington, Texas",
"Wichita, Kansas"
]

28
tests/messageformatter.js Normal file
View File

@@ -0,0 +1,28 @@
const chalk = require('chalk');
const describe = (jsHandle) => jsHandle.executionContext().evaluate(
// serialize |obj| however you want
(obj) => `OBJ: ${typeof obj}, ${obj}`,
jsHandle,
);
const colors = {
LOG: chalk.grey,
ERR: chalk.red,
WAR: chalk.yellow,
INF: chalk.cyan,
};
const formatter = async (message) => {
const args = await Promise.all(message.args().map((arg) => describe(arg)));
// make ability to paint different console[types]
const type = message.type().substr(0, 3).toUpperCase();
const color = colors[type] || chalk.blue;
let text = '';
for (let i = 0; i < args.length; i += 1) {
text += `[${i}] ${args[i]} `;
}
console.log(color(`CONSOLE.${type}: ${message.text()}\n${text} `));
};
module.exports = formatter;

1436
tests/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
tests/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "ws4kp-tests",
"version": "1.0.0",
"description": "Currently, tests take a different approach from typical unit testing. The test methodology loads several forecasts for different locations and logs them all to one logger so errors can be found such as missing icons, locations that do not have all of the necessary data or other changes that may occur between geographical locations.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "MIT",
"dependencies": {
"chalk": "^4.0.0",
"puppeteer": "^19.5.2"
}
}

View File

@@ -28,6 +28,7 @@
<script type="text/javascript" src="scripts/vendor/auto/nosleep.js"></script>
<script type="text/javascript" src="scripts/vendor/auto/swiped-events.js"></script>
<script type="text/javascript" src="scripts/vendor/auto/suncalc.js"></script>
<script type="module" src="scripts/modules/hazards.mjs"></script>
<script type="module" src="scripts/modules/currentweatherscroll.mjs"></script>
<script type="module" src="scripts/modules/currentweather.mjs"></script>
<script type="module" src="scripts/modules/almanac.mjs"></script>
@@ -41,6 +42,7 @@
<script type="module" src="scripts/modules/regionalforecast.mjs"></script>
<script type="module" src="scripts/modules/travelforecast.mjs"></script>
<script type="module" src="scripts/modules/progress.mjs"></script>
<script type="module" src="scripts/modules/radar.mjs"></script>
<script type="module" src="scripts/index.mjs"></script>
<!-- data -->
@@ -58,19 +60,15 @@
<div id="divQuery">
<form id="frmGetLatLng">
<input id="txtAddress" type="text" value="" placeholder="Zip or City, State" /><button id="btnGetGps" type="button" title="Get GPS Location"><img src="images/nav/ic_gps_fixed_black_18dp_1x.png" class="light"/><img src="images/nav/ic_gps_fixed_white_18dp_1x.png" class="dark"/></button>
<input id="btnGetLatLng" type="submit" value="GO" />
<input id="btnClearQuery" type="reset" value="Reset" />
</form>
<div id="divLat"></div>
<div id="divLng"></div>
<input id="txtAddress" type="text" value="" placeholder="Zip or City, State" />
<div class="buttons">
<button id="btnGetGps" type="button" title="Get GPS Location"><img src="images/nav/ic_gps_fixed_black_18dp_1x.png" class="light"/>
<img src="images/nav/ic_gps_fixed_white_18dp_1x.png" class="dark"/>
</button>
<button id="btnGetLatLng" type="submit">GO</button>
<button id="btnClearQuery" type="reset">Reset</button>
</div>
</div>
<br />
<img id="imgPause1x" src="images/nav/ic_pause_white_24dp_1x.png" />
<img id="imgPause2x" src="images/nav/ic_pause_white_24dp_2x.png" />
<div id="version" style="display:none">
<%- version %>
</div>
@@ -117,19 +115,22 @@
<div id="radar-html" class="weather-display">
<%- include('partials/radar.ejs') %>
</div>
<div id="hazards-html" class="weather-display show">
<%- include('partials/hazards.ejs') %>
</div>
</div>
<div id="divTwcBottom">
<div id="divTwcBottomLeft">
<img id="NavigateMenu" class="navButton" src="images/nav/ic_menu_white_24dp_1x.png" title="Menu" />
<img id="NavigatePrevious" class="navButton" src="images/nav/ic_skip_previous_white_24dp_1x.png" title="Previous" />
<img id="NavigateNext" class="navButton" src="images/nav/ic_skip_next_white_24dp_1x.png" title="Next" />
<img id="NavigatePlay" class="navButton" src="images/nav/ic_play_arrow_white_24dp_1x.png" title="Play" />
<img id="NavigateMenu" class="navButton" src="images/nav/ic_menu_white_24dp_2x.png" title="Menu" />
<img id="NavigatePrevious" class="navButton" src="images/nav/ic_skip_previous_white_24dp_2x.png" title="Previous" />
<img id="NavigateNext" class="navButton" src="images/nav/ic_skip_next_white_24dp_2x.png" title="Next" />
<img id="NavigatePlay" class="navButton" src="images/nav/ic_play_arrow_white_24dp_2x.png" title="Play" />
</div>
<div id="divTwcBottomMiddle">
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_1x.png" title="Refresh" />
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_2x.png" title="Refresh" />
</div>
<div id="divTwcBottomRight">
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_1x.png" title="Enter Fullscreen" />
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_2x.png" title="Enter Fullscreen" />
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<%- include('header.ejs', {titleDual:{ top: 'Current' , bottom: 'Conditions' }, noaaLogo: true}) %>
<%- include('header.ejs', {titleDual:{ top: 'Current' , bottom: 'Conditions' }, noaaLogo: true, hasTime: true}) %>
<div class="main has-scroll has-box current-weather">
<div class="weather template">
<div class="left col">

View File

@@ -0,0 +1,8 @@
<div class="main has-scroll hazards no-header">
<div class="hazard-lines">
<div class="hazard template">
<div class="hazard-text"></div>
</div>
</div>
</div>
<%- include('scroll.ejs') %>

View File

@@ -1,4 +1,4 @@
<%- include('header.ejs', {titleDual:{ top: 'Latest' , bottom: 'Observations' }, noaaLogo: true }) %>
<%- include('header.ejs', {titleDual:{ top: 'Latest' , bottom: 'Observations' }, noaaLogo: true, hasTime: true }) %>
<div class="main has-scroll latest-observations has-box">
<div class="container">
<div class="column-headers">

View File

@@ -27,6 +27,7 @@
"Pngs",
"PRECIP",
"rtrim",
"sonarjs",
"T",
"T'storm",
"uscomp",