Compare commits

..

84 Commits

Author SHA1 Message Date
Matt Walsh
cab4219740 5.21.15 2025-06-04 23:39:42 -05:00
Matt Walsh
9252275436 star large font change to woff for size 2025-06-04 23:39:34 -05:00
Matt Walsh
9d1c21d8ef Merge pull request #103 from rmitchellscott/node24
chore: update dockerfile to Node 24 (LTS)
2025-06-04 21:13:22 -05:00
Matt Walsh
6473f167a8 Merge pull request #102 from rmitchellscott/docker-version-tags
chore: update docker build action, also run on tag action
2025-06-04 21:12:35 -05:00
Mitchell Scott
d280a5b3a9 chore: update dockerfile to Node 24 (LTS) 2025-06-04 14:29:16 -06:00
Mitchell Scott
b195ce042b chore: update docker build action, also run on tag action 2025-06-04 14:13:53 -06:00
Matt Walsh
39e8879697 5.21.14 2025-06-03 21:10:19 -05:00
Matt Walsh
5e3b917023 add music track names to info block close #100 2025-06-03 21:09:55 -05:00
Matt Walsh
a813ee19a7 Merge branch 'gordonthree-main' 2025-06-03 13:39:09 -05:00
Matt Walsh
e01edc6972 Merge gordonthree-main to main 2025-06-03 13:38:27 -05:00
gordonthree
ab0249e6eb forgot to replace hypen with underscore 2025-06-03 12:46:52 -04:00
gordonthree
c4c85b3b7b shortened example content, revised comments to align with existing permalink example 2025-06-03 12:01:59 -04:00
Matt Walsh
e954033979 reorganize and update readme 2025-06-03 09:39:57 -05:00
gordonthree
ba39af9126 added ports info to the compose example 2025-06-03 09:42:51 -04:00
gordonthree
a814fde5b5 Added docker-compose example 2025-06-03 09:35:38 -04:00
Matt Walsh
3f5f78eddf update dependencies 2025-06-03 07:36:17 -05:00
Matt Walsh
dc64e4bd7f 5.21.13 2025-06-02 20:42:13 -05:00
Matt Walsh
776148fa6b handle haze-n regional map icon 2025-06-02 20:42:03 -05:00
Matt Walsh
69c050eb8f 5.21.12 2025-06-01 21:09:15 -05:00
Matt Walsh
a3e142dade Merge pull request #95 from kirbysayshi/ksh/disable-radar-for-bots
Expand radar feature disabling to cover all iOS browsers and the Messages crawler
2025-06-01 21:03:42 -05:00
Andrew Petersen
28917489bb simplify check to cover other ios browsers too 2025-06-01 17:14:45 -04:00
Andrew Petersen
2365a4c0f7 prevent loading radar for iMessages web preview bot 2025-06-01 17:05:18 -04:00
Matt Walsh
8afef77ea5 5.21.11 2025-06-01 13:38:21 -05:00
Matt Walsh
8f70ee87c5 add smoke to large icon set close #91 2025-06-01 13:37:32 -05:00
Matt Walsh
4e7429bfba 5.21.10 2025-05-31 13:30:30 -05:00
Matt Walsh
c5ffe1542a TEMPORARY don't allow radar on safari on ios 2025-05-31 13:30:21 -05:00
Matt Walsh
5364855c58 5.21.9 2025-05-31 13:20:45 -05:00
Matt Walsh
18efd810bd permalink coloring, readme additions close #88 2025-05-31 13:20:35 -05:00
Matt Walsh
68a6bae3a7 5.21.8 2025-05-30 09:06:45 -05:00
Matt Walsh
5f0f0d9000 Correct smoke forecast text on extended forecast #91 2025-05-30 09:06:40 -05:00
Matt Walsh
9d9cf4b0f3 5.21.7 2025-05-30 07:58:59 -05:00
Matt Walsh
9e500143c0 load radar workers later in the startup process 2025-05-30 07:57:28 -05:00
Matt Walsh
71da682660 5.21.6 2025-05-29 23:08:11 -05:00
Matt Walsh
1b9a1dcb22 don't clobber browser alt-left/right shortcuts 2025-05-29 23:08:04 -05:00
Matt Walsh
095761ee81 5.21.5 2025-05-29 21:04:51 -05:00
Matt Walsh
21e528aaa3 fix for kiosk mode scrollbars #86 2025-05-29 21:03:48 -05:00
Matt Walsh
a92c632937 5.21.4 2025-05-29 20:24:08 -05:00
Matt Walsh
6073fd1733 better missing data handling for current conditions #87 2025-05-29 20:23:55 -05:00
Matt Walsh
5da8185633 Merge pull request #89 from kevinastone/patch-2
Gracefully shutdown on both SIGINT + SIGTERM
2025-05-29 20:02:43 -05:00
Kevin Stone
cf5c818ee3 Gracefully shutdown on both SIGINT + SIGTERM
Most service managers (systemd, docker, etc) use SIGTERM as the shutdown signal by default rather than SIGINT (which is used for interactive CTRL-C).
2025-05-29 17:37:40 -07:00
Matt Walsh
97cec114f6 Merge branch 'main' of github.com:netbymatt/ws4kp 2025-05-29 17:03:55 -05:00
Matt Walsh
7efd2e8db7 add scanlines 2025-05-29 17:03:50 -05:00
Matt Walsh
8c28f41d54 Merge pull request #85 from dylan-park/readme-patch-1
update local run instructions in README
2025-05-29 14:45:07 -05:00
Dylan Park
e9d603fbfc update local run instructions in README 2025-05-29 14:24:05 -05:00
Matt Walsh
32aa43c5b1 update user-agent header, now allowed in some browsers 2025-05-29 14:18:49 -05:00
Matt Walsh
dbc56f014a 5.21.3 2025-05-29 13:34:22 -05:00
Matt Walsh
3161a03797 fix for stale forecast data on regional forecasts #84 2025-05-29 13:32:47 -05:00
Matt Walsh
205fa77f51 5.21.2 2025-05-29 08:36:21 -05:00
Matt Walsh
28bb8f2e2a scale nav buttons on narrow screens 2025-05-29 08:36:13 -05:00
Matt Walsh
cf9a99a6ca update dependencies 2025-05-29 08:31:03 -05:00
Matt Walsh
a83afa71cd code cleanup 2025-05-29 08:30:01 -05:00
Matt Walsh
74f1abd6f8 switch to zoom scaling 2025-05-27 16:33:03 -05:00
Matt Walsh
1bd45bdeeb 5.21.1 2025-05-25 19:39:40 -05:00
Matt Walsh
232061b4d8 revert to ttf font to fix layout problems close #82 2025-05-25 19:39:25 -05:00
Matt Walsh
10d10ffbfb Merge branch 'radar-rendering' 2025-05-25 19:18:32 -05:00
Matt Walsh
25ac2059a6 remove debug timing 2025-05-25 19:11:52 -05:00
Matt Walsh
25626a98c9 5.21.0 2025-05-25 15:18:32 -05:00
Matt Walsh
002e037bbd optimized radar image merging 2025-05-25 15:17:56 -05:00
Matt Walsh
8d20f7672c radar processed in web worker 2025-05-24 16:36:41 -05:00
Matt Walsh
5567fe37a6 add instrumentation 2025-05-24 09:22:23 -05:00
Matt Walsh
2dcc33f210 change to offscreen canvas 2025-05-23 23:13:50 -05:00
Matt Walsh
8f86f80eb5 5.20.5 2025-05-23 22:15:02 -05:00
Matt Walsh
1609ab3d38 radar host overrides 2025-05-23 22:14:48 -05:00
Matt Walsh
0be23ee988 radar speed improvements 2025-05-23 21:18:54 -05:00
Matt Walsh
a3ea2c3708 5.20.4 2025-05-23 16:08:19 -05:00
Matt Walsh
09fb698350 locally limit the number of alerts/hazards 2025-05-23 16:08:11 -05:00
Matt Walsh
6f6efe801c 5.20.3 2025-05-23 15:47:27 -05:00
Matt Walsh
bc77a1891c remove limit for alert endpoint due to recent api change 2025-05-23 15:43:58 -05:00
Matt Walsh
4666878250 5.20.2 2025-05-21 13:55:39 -05:00
Matt Walsh
5813dd9a92 title overflow cleanup 2025-05-21 13:55:27 -05:00
Matt Walsh
8cb8873760 add hooks for geoip lookup 2025-05-21 13:49:49 -05:00
Matt Walsh
323c175936 css cleanup 2025-05-20 22:19:46 -05:00
Matt Walsh
85e2553cb2 5.20.1 2025-05-20 22:10:26 -05:00
Matt Walsh
101d0ac9ea page delivery tweaks 2025-05-20 22:10:13 -05:00
Matt Walsh
834d68f9e3 expose invalidate gulp taks 2025-05-20 18:26:50 -05:00
Matt Walsh
0ee7fdc9f8 Update README.md with ws3kp link 2025-05-20 18:02:25 -05:00
Matt Walsh
d75121e894 5.20.0 2025-05-20 16:29:16 -05:00
Matt Walsh
4cdced3659 prep for additional bottom line displays 2025-05-20 16:28:56 -05:00
Matt Walsh
1a5548d135 5.19.3 2025-05-16 14:42:19 -05:00
Matt Walsh
11c826a2af fix local forecast paging 2025-05-16 14:42:11 -05:00
Matt Walsh
7a129c1cd3 autocomplete cleanup 2025-05-16 11:17:35 -05:00
Matt Walsh
867657a965 5.19.2 2025-05-16 09:39:54 -05:00
Matt Walsh
e89dc52541 fix spc changing locations close #80 2025-05-16 09:39:48 -05:00
Matt Walsh
317883fc04 Add version number to bottom of page 2025-05-16 09:21:04 -05:00
73 changed files with 1517 additions and 1053 deletions

View File

@@ -12,7 +12,8 @@
"RegionalCities": "readonly",
"StationInfo": "readonly",
"SunCalc": "readonly",
"NoSleep": "readonly"
"NoSleep": "readonly",
"OVERRIDES": "readonly"
},
"parserOptions": {
"ecmaVersion": "latest",

View File

@@ -1,5 +1,12 @@
name: build-docker
on: push
on:
push:
branches:
- main
tags:
- 'v*.*.*'
- 'v*.*'
jobs:
build:
@@ -13,7 +20,7 @@ jobs:
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/netbymatt/ws4kp
@@ -27,18 +34,18 @@ jobs:
type=semver,pattern={{major}}
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
uses: docker/build-push-action@v6
with:
context: .
pull: true

View File

@@ -1,4 +1,4 @@
FROM node:18-alpine
FROM node:24-alpine
WORKDIR /app
COPY package.json .

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2024 Matt Walsh
Copyright (c) 2020-2025 Matt Walsh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

146
README.md
View File

@@ -6,15 +6,31 @@ A live version of this project is available at https://weatherstar.netbymatt.com
This project aims to bring back the feel of the 90's with a weather forecast that has the look and feel of The Weather Channel at that time but available in a modern way. This is by no means intended to be a perfect emulation of the WeatherStar 4000, the hardware that produced those wonderful blue and orange graphics you saw during the local forecast on The Weather Channel. If you would like a much more accurate project please see the [WS4000 Simulator](http://www.taiganet.com/). Instead, this project intends to create a simple to use interface with minimal configuration fuss. Some changes have been made to the screens available because either more or less forecast information is available today than was in the 90's. Most of these changes are captured in sections below.
## Acknowledgements
## What's your motivation
This project is based on the work of [Mike Battaglia](https://github.com/vbguyny/ws4kp). It was forked from his work in August 2020.
Nostalgia. And I enjoy following the weather, especially severe storms.
* Mike Battaglia for the original project and all of the code which draws the weather displays. This code remains largely intact and was a huge amount of work to get exactly right. He's also responsible for all of the background graphics including the maps used in the application.
* The team at [TWCClassics](https://twcclassics.com/) for several resources.
* A [font](https://twcclassics.com/downloads.html) set used on the original WeatherStar 4000
* [Icon](https://twcclassics.com/downloads.html) sets
* Countless photos and videos of WeatherStar 4000 forecasts used as references.
It's also a creative outlet for me and keeps my programming skills honed for when I need them for my day job.
### Included technology
I've kept this open source, well commented, and made it as library-free as possible to help others interested in programming be able to jump right in and start working with the code.
From a learning standpoint, this codebase make use of a lot of different methods and technologies common on the internet including:
* The https://api.weather.gov REST API. ([documentation](https://www.weather.gov/documentation/services-web-api)).
* ES 6 functionality
* Arrow functions
* Promises
* Async/await and parallel loading of all forecast resources
* Classes and extensions
* Javascript modules
* Separation between API code and user interface code
* Use of a modern date parsing library [luxon](https://moment.github.io/luxon/)
* Practical API rates and static asset caching
* Very straight-forward hand written HTML
* Build system integration (Gulp, Webpack) to reduce the number of scripts that need to be loaded
* Hand written CSS made easier to mange with SASS
* A linting library to keep code style consistent
## Does WeatherStar 4000+ work outside of the USA?
@@ -24,14 +40,12 @@ If you would like to display weather information for international locations (ou
- [`ws4kp-international`](https://github.com/mwood77/ws4kp-international)
## Run Your WeatherStar
There are a lot of CORS considerations and issues with api.weather.gov that are easiest to deal with by running a local server to see this in action (or use the live link above). You'll need Node.js >12.0 to run the local server.
To run via Node locally:
```
git clone https://github.com/netbymatt/ws4kp.git
cd ws4kp
npm i
node index.js
node index.mjs
```
To run via Docker:
@@ -40,35 +54,31 @@ docker run -p 8080:8080 ghcr.io/netbymatt/ws4kp
```
Open your web browser: http://localhost:8080/
## Updates in 5.0
The change to 5.0 changes from drawing the weather graphics on canvas elements and instead uses HTML and CSS to style all of the weather graphics. A lot of other changes and fixes were implemented at the same time.
To run via Docker Compose (docker-compose.yaml):
```
---
services:
ws4kp:
image: ghcr.io/netbymatt/ws4kp
container_name: ws4kp
environment:
# Each argument in the permalink URL can become an environment variable on the Docker host by adding WSQS_
# Following the "Sharing a Permalink" example below, here are a few environment variables defined. Visit that section for a
# more complete list of configuration options.
- WSQS_latLonQuery="Orlando International Airport Orlando FL USA"
- WSQS_hazards_checkbox=false
- WSQS_current_weather_checkbox=true
ports:
- 8080:8080 # change the first 8080 to meet your local network needs
restart: unless-stopped
```
* Replace all canvas elements with HTML and CSS
* City and airport names are better parsed to fit the available space
* Remove the dependency on libgif-js
* Use browser for text wrapping where necessary
* Some new weather icons
* Refresh only on slideshow repeat
* Removed Almanac 30-day outlook
* Fixed startup issue when current conditions are unavailable
## Why the fork?
The fork is a result of wanting a more manageable, modern code base to work with. Part of it is an exercise in my education in JavaScript. There are several technical changes that were made behind the scenes.
* Make use of the new API available at https://api.weather.gov ([documentation](https://www.weather.gov/documentation/services-web-api)). This caused the removal of some of the original WeatherStar 4000 displays, and allowed for new displays to be created.
* Changed code to make extensive use of ES6 functionality including:
* Arrow functions
* Promises
* Async/await and parallel loading of all forecast resources
* Classes
* Common code base for each display through use of classes
* Separation between weather display code and user interface
* Use of a modern date parsing library [luxon](https://moment.github.io/luxon/)
* Attempt to remove the need for a local server to bypass [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) issues with the various APIs used. This is almost workable but there are still some minor CORS issues with https://api.weather.gov.
* The necessary CORS pass through URLs have been rewritten so they can be deployed on Node.js using the included server or through S3/Cloudfront in a serverless environment.
* Proper settings for static resource caching
* Build system integration to reduce the number of scripts that need to be loaded
### Serving static files
The app can be served as a static set of files on any web server. Run the provided gulp task to create a set of static distribution files:
```
npm run buildDist
```
The resulting files will be in the /dist folder in the root of the project. These can then be uploaded to a web server for hosting, no server-side scripting is required.
## What's different
@@ -77,39 +87,44 @@ I've made several changes to this Weather Star 4000 simulation compared to the o
* Radar displays the timestamp of the image.
* A new hour-by-hour graph of the temperature, cloud cover and precipitation chances for the next 24 hours.
* A new hourly forecast display for the next 24 hours is available, and is shown in the style of the travel cities forecast. (off by default because it duplicates the hourly graph)
* The SPC Outlook is shown in the style of the old air quality screen. This shows the probability of severe weather over the next 3 days at your location.
* The "Local Forecast" and "Extended Forecast" provide several additional days of information compared to the original format in the 90's.
* Narration was removed. In the original code narration made use of the computer's local text-to-speech engine which didn't sound great.
* Music was removed. I don't want to deal with copyright issues and hosting MP3s. If you're looking for the music that played during forecasts please visit [TWCClassics](https://twcclassics.com/audio/).
* Marine forecast (tides) is not available as it is not part of the new API.
* The nearby cities displayed on screens such as "Latest Observations" and "Regional Forecast" are likely not the same as they were in the 90's. The weather monitoring equipment at these stations move over time for one reason or another, and coming up with a simple formulaic way of finding nearby stations is sufficient to give the same look-and-feel as the original.
* The original music has been replaced. More info in [Music](#music).
* Marine forecast (tides) is not available as it is not reliably part of the new API.
* "Flavors" are not present in this simulation. Flavors refer to the order of the weather information that was shown on the original units. Instead, the order of the displays has been fixed and a checkboxes can be used to turn on and off individual displays. The travel forecast has been defaulted to off so only local information shows for new users.
## Sharing a permalink (bookmarking)
Selected displays, the forecast city and widescreen setting are sticky from one session to the next. However if you would like to share your exact configuration or bookmark it click the "Copy Permalink" (or get "Get Parmalink") near the bottom of the page. A URL will be copied to your clipboard with all of you selected displays and location (or copy it from the page if your browser doesn't support clipboard transfers directly). You can then share this link or add it to your bookmarks.
Selected displays, the forecast city and widescreen setting are sticky from one session to the next. However if you would like to share your exact configuration or bookmark it, click the "Copy Permalink" (or get "Get Parmalink") near the bottom of the page. A URL will be copied to your clipboard with all of you selected displays and location (or copy it from the page if your browser doesn't support clipboard transfers directly). You can then share this link or add it to your bookmarks.
## Kiosk mode
Kiosk mode can be activated by a checkbox on the page. Note that there is no way out of kiosk mode (except refresh or closing the browser), and the play/pause and other controls will not be available. This is deliberate as a browser's kiosk mode it intended not to be exited or significantly modified.
Your permalink will be very long. Here is an example for the Orlando International Airport:
```
https://weatherstar.netbymatt.com/?hazards-checkbox=false&current-weather-checkbox=true&latest-observations-checkbox=true&hourly-checkbox=false&hourly-graph-checkbox=true&travel-checkbox=false&regional-forecast-checkbox=true&local-forecast-checkbox=true&extended-forecast-checkbox=true&almanac-checkbox=false&spc-outlook-checkbox=true&radar-checkbox=true&settings-wide-checkbox=false&settings-kiosk-checkbox=false&settings-scanLines-checkbox=false&settings-speed-select=1.00&settings-units-select=us&latLonQuery=Orlando+International+Airport%2C+Orlando%2C+FL%2C+USA&latLon=%7B%22lat%22%3A28.431%2C%22lon%22%3A-81.3076%7D
```
You can also build your own permalink. Any omitted settings will be filled with defaults. Here are a few examples:
```
https://weatherstar.netbymatt.com/?latLonQuery=Orlando+International+Airport
https://weatherstar.netbymatt.com/?kiosk=true
https://weatherstar.netbymatt.com/?settings-units-select=metric
```
### Kiosk mode
Kiosk mode can be activated by a checkbox on the page. Note that there is no way out of kiosk mode (except refresh or closing the browser), and the play/pause and other controls will not be available. This is deliberate as a browser's kiosk mode it intended not to be exited or significantly modified. A separate full-screen icon is available in the tool bar to go full-screen on a laptop or mobile browser.
It's also possible to enter kiosk mode using a permalink. First generate a [Permalink](#sharing-a-permalink-bookmarking), then to the end of it add `&kiosk=true`. Opening this link will load all of the selected displays included in the Permalink, enter kiosk mode immediately upon loading and start playing the forecast.
## Default query string paramaters (environment variables)
### Default query string paramaters (environment variables)
When serving this via the built-in Express server, it's possible to define environment variables that direct the user to a default set of paramaters (like the [Permalink](#sharing-a-permalink-bookmarking) above). If a user requests the root page at `http://localhost:8080/` the query string provided by environment variables will be appended to the url thus providing a default configuration.
Environment variables can be added to the command line as usual, or via a .env file which is parsed with [dotenv](https://github.com/motdotla/dotenv). Both methods have the same effect.
Environment variables that are to be added to the default query string are prefixed with `WSQS_` and then use the same key/value pairs generated by the [Permalink](#sharing-a-permalink-bookmarking) above, with the `- (dash)` character replaced by an `_ (underscore)`. For example, if you wanted to turn the travel forecast on, you would find `travel-checkbox=true` in the permalink, it's matching environment variable becomes `WSQS_travel_checkbox=true`.
## Serving static files
The app can be served as a static set of files on any web server. Run the provided gulp task to create a set of static distribution files:
```
npm run buildDist
```
The resulting files will be in the /dist folder in the root of the project. These can then be uploaded to a web server for hosting, no server-side scripting is required.
## Music
The WeatherStar had wonderful background music from the smooth jazz and new age genres by artists of the time. Lists of the music that played are available by searching online, but it's all copyrighted music and would be difficult to provide as part of this repository.
I've used AI tools to create WeatherStar-inspired music tracks that are unencumbered by copyright and are included in this repo. Too keep the size down, I've only included 4 tracks. Additional tracks will be posted in a companion repository [ws4kp-music](https://github.com/netbymatt/ws4kp-music).
I've used AI tools to create WeatherStar-inspired music tracks that are unencumbered by copyright and are included in this repo. Too keep the size down, I've only included 4 tracks. Additional tracks are in a companion repository [ws4kp-music](https://github.com/netbymatt/ws4kp-music).
If you're looking for the original music that played during forecasts [TWCClassics](https://twcclassics.com/audio/) has thurough documentation of playlists.
### Customizing the music
Placing .mp3 files in the `/server/music` folder will override the default music included in the repo. Subdirectories will not be scanned. When weatherstar loads in the browser it will load a list if available files and randomize the order when it starts playing. On each loop through the available tracks the order will again be shuffled. If you're using the static files method to host your WeatherStar music is located in `/music`.
@@ -137,10 +152,31 @@ A hook is provided as `/server/scripts/custom.js` to allow customizations to you
## Issue reporting and feature requests
Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. Before reporting an issue or requesting a feature please consider that this is not intended to be a perfect recreation of the WeatherStar 4000, it's a best effort that fits within what's available from the API and within a web browser.
Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. I've also observed that the API can go down on a regional basis (based on NWS office locations). This means that you may have problems getting data for, say, Chicago right now, but Dallas and others are working just fine.
Before reporting an issue or requesting a feature please consider that this is not intended to be a perfect recreation of the WeatherStar 4000, it's a best effort that fits within what's available from the API and within a web browser.
Note: not all units are converted to metric, if selected. Some text-based products such as warnings are simple text strings provided from the national weather service and thus have baked-in units such as "gusts up to 60 mph." These values will not be converted.
## Related Projects
Not retro enough? Try the [Weatherstar 3000+](https://github.com/netbymatt/ws3kp)
## Use
Linking directly to the live web site at https://weatherstar.netbymatt.com is encouraged. As is using the live site for digital signage, home dashboards, streaming and public display. Please note the disclaimer below.
## Acknowledgements
This project is based on the work of [Mike Battaglia](https://github.com/vbguyny/ws4kp). It was forked from his work in August 2020.
* Mike Battaglia for the original project and all of the code which draws the weather displays. This code remains largely intact and was a huge amount of work to get exactly right. He's also responsible for all of the background graphics including the maps used in the application.
* The team at [TWCClassics](https://twcclassics.com/) for several resources.
* A [font](https://twcclassics.com/downloads.html) set used on the original WeatherStar 4000
* [Icon](https://twcclassics.com/downloads.html) sets
* Countless photos and videos of WeatherStar 4000 forecasts used as references.
* The growing list of contributors to this repository
## Disclaimer
This web site should NOT be used in life threatening weather situations, or be relied on to inform the public of such situations. The Internet is an unreliable network subject to server and network outages and by nature is not suitable for such mission critical use. If you require such access to NWS data, please consider one of their subscription services. The authors of this web site shall not be held liable in the event of injury, death or property damage that occur as a result of disregarding this warning.

View File

@@ -14,9 +14,10 @@ import webpack from 'webpack-stream';
import TerserPlugin from 'terser-webpack-plugin';
import { readFile } from 'fs/promises';
import file from 'gulp-file';
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
import OVERRIDES from '../src/overrides.mjs';
// get cloudfront
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
import reader from '../src/playlist-reader.mjs';
const clean = () => deleteAsync(['./dist/**/*', '!./dist/readme.txt']);
@@ -113,6 +114,7 @@ const compressHtml = async () => {
.pipe(ejs({
production: version,
version,
OVERRIDES,
}))
.pipe(rename({ extname: '.html' }))
.pipe(htmlmin({ collapseWhitespace: true }))
@@ -159,6 +161,9 @@ const uploadImages = () => src(imageSources, { base: './server', encoding: false
s3({
Bucket: process.env.BUCKET,
StorageClass: 'STANDARD',
maps: {
CacheControl: () => 'max-age=31536000',
},
}),
);
@@ -189,4 +194,5 @@ export default publishFrontend;
export {
buildDist,
invalidate,
};

View File

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

View File

@@ -5,6 +5,7 @@ import corsPassThru from './cors/index.mjs';
import radarPassThru from './cors/radar.mjs';
import outlookPassThru from './cors/outlook.mjs';
import playlist from './src/playlist.mjs';
import OVERRIDES from './src/overrides.mjs';
const app = express();
const port = process.env.WS4KP_PORT ?? 8080;
@@ -57,19 +58,38 @@ const index = (req, res) => {
res.render('index', {
production: false,
version,
OVERRIDES,
});
};
const geoip = (req, res) => {
res.set({
'x-geoip-city': 'Orlando',
'x-geoip-country': 'US',
'x-geoip-country-name': 'United States',
'x-geoip-country-region': 'FL',
'x-geoip-country-region-name': 'Florida',
'x-geoip-latitude': '28.52135',
'x-geoip-longitude': '-81.41079',
'x-geoip-postal-code': '32789',
'x-geoip-time-zone': 'America/New_York',
'content-type': 'application/json',
});
res.json({});
};
// debugging
if (process.env?.DIST === '1') {
// distribution
app.use('/images', express.static('./server/images'));
app.use('/fonts', express.static('./server/fonts'));
app.use('/scripts', express.static('./server/scripts'));
app.use('/geoip', geoip);
app.use('/', express.static('./dist'));
} else {
// debugging
app.get('/index.html', index);
app.use('/geoip', geoip);
app.get('/', index);
app.get('*name', express.static('./server'));
}
@@ -79,8 +99,11 @@ const server = app.listen(port, () => {
});
// graceful shutdown
process.on('SIGINT', () => {
const gracefulShutdown = () => {
server.close(() => {
console.log('Server closed');
});
});
};
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);

1349
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ws4kp",
"version": "5.19.1",
"version": "5.21.15",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",

View File

@@ -1,30 +0,0 @@
--Star 3000--
Star3000.ttf - Standard text style for most screens (and Travel Cities title header)
Star3000 Small.ttf - Time/Date and some page headers
Star3000 Large.ttf - Travel Cities Forecast (Forecast portion only)
Star3000 Extra Large.ttf - Only used on some advertiser text
Star3000 Extended.ttf - Only used on some advertiser text
"Heavy" style is an emboldened version of the standard font (used on some STARs)
Star3000 Outline.ttf - A contrast border (stroke) that surrounds the Star3000.ttf base font. When used, must be as a text layer undeneath the base font (and is usually black in color).
Star3000 Small Outline.ttf - A contrast border (stroke) that surrounds the Star3000 Small.ttf base font. When used, must be as a text layer undeneath the base font (and is usually black in color).
Star3000 Large Outline.ttf - A contrast border (stroke) that surrounds the Star3000 Large.ttf base font. When used, must be as a text layer undeneath the base font (and is usually black in color).
***Outlines for other font styles are not currently available.
--Star 4000--
Star4000.ttf - Standard text style for zone forecast, observation tables, regional map cities, almanac, extended forecast day/weather/temperature headers, Current Conditions right half data and most page header titles (also Travel Cities title header before Nov. 1992)
Star4000 Small.ttf - Time/Date, NWS Local Update page header, temperature header for Travel Cities Forecast (after Nov. 1992)
Star4000 Large.ttf - City names and temperature data on Travel Cities Forecast (after Nov. 1992), Extended forecast temperature values (after Feb. 1991), Current Conditions temperature value (after Mar. 1991)
Star4000 Large Compressed - Travel Cities Forecast (before Nov. 1992), regional map temperatures
Star4000 Large Compressed Numbers - Temperature values on regional forecast/observation maps
Star4000 Extended - A proportional width font used for the Current Conditions present weather description and wind data
Star 4 Radar.ttf - Radar airport I.D.
--Star Jr.--
StarJr.ttf - Standard text style for most screens (and Travel Cities title header)
StarJr Small.ttf - Time/Date and some page headers
StarJr Compressed.ttf - Travel Cities Forecast

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -38,6 +38,7 @@ const init = () => {
document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick);
document.querySelector('#NavigatePrevious').addEventListener('click', btnNavigatePreviousClick);
document.querySelector('#NavigatePlay').addEventListener('click', btnNavigatePlayClick);
document.querySelector('#ToggleScanlines').addEventListener('click', btnNavigateToggleScanlines);
document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR).addEventListener('click', btnFullScreenClick);
const btnGetGps = document.querySelector(BNT_GET_GPS_SELECTOR);
btnGetGps.addEventListener('click', btnGetGpsClick);
@@ -61,7 +62,7 @@ const init = () => {
paramName: 'text',
params: {
f: 'json',
countryCode: 'USA', // 'USA,PRI,VIR,GUM,ASM',
countryCode: 'USA',
category,
maxSuggestions: 10,
},
@@ -78,10 +79,10 @@ const init = () => {
onSelect(suggestion) { autocompleteOnSelect(suggestion); },
width: 490,
});
window.autoComplete = autoComplete;
// attempt to parse the url parameters
const parsedParameters = parseQueryString();
const loadFromParsed = parsedParameters.latLonQuery && parsedParameters.latLon;
// Auto load the parsed parameters and fall back to the previous query
@@ -109,9 +110,6 @@ const init = () => {
document.querySelector('#spanRadarId').innerHTML = '';
document.querySelector('#spanZoneId').innerHTML = '';
document.querySelector('#chkAutoRefresh').checked = true;
localStorage.removeItem('autoRefresh');
localStorage.removeItem('play');
postMessage('navButton', 'play');
@@ -297,6 +295,8 @@ const updateFullScreenNavigate = () => {
};
const documentKeydown = (e) => {
// don't trigger on ctrl/alt/shift modified key
if (e.altKey || e.ctrlKey || e.shiftKey) return false;
const { key } = e;
if (document.fullscreenElement || document.activeElement === document.body) {
@@ -347,6 +347,11 @@ const btnNavigatePlayClick = () => {
return false;
};
const btnNavigateToggleScanlines = () => {
settings.scanLines.value = !settings.scanLines.value;
return false;
};
// post a message to the iframe
const postMessage = (type, myMessage = {}) => {
navMessage({ type, message: myMessage });
@@ -374,6 +379,10 @@ const btnGetGpsClick = async () => {
const position = await getPosition();
const { latitude, longitude } = position.coords;
getForecastFromLatLon(latitude, longitude, true);
};
const getForecastFromLatLon = (latitude, longitude, fromGps = false) => {
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
txtAddress.value = `${round2(latitude, 4)}, ${round2(longitude, 4)}`;
@@ -383,7 +392,7 @@ const btnGetGpsClick = async () => {
const query = `${location.city}, ${location.state}`;
localStorage.setItem('latLon', JSON.stringify({ lat: latitude, lon: longitude }));
localStorage.setItem('latLonQuery', query);
localStorage.setItem('latLonFromGPS', true);
localStorage.setItem('latLonFromGPS', fromGps);
txtAddress.value = `${location.city}, ${location.state}`;
});
};
@@ -414,3 +423,6 @@ const getCustomCode = async () => {
document.body.append(customElem);
}
};
// expose functions for external use
window.getForecastFromLatLon = getForecastFromLatLon;

View File

@@ -1,5 +1,5 @@
// display sun and moon data
import { loadImg, preloadImg } from './utils/image.mjs';
import { preloadImg } from './utils/image.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
import STATUS from './status.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
@@ -9,9 +9,6 @@ class Almanac extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Almanac', true);
// pre-load background images (returns promises)
this.backgroundImage0 = loadImg('images/backgrounds/1.png');
// preload the moon images
preloadImg(imageName('Full'));
preloadImg(imageName('Last'));
@@ -122,10 +119,10 @@ class Almanac extends WeatherDisplay {
// sun and moon data
this.elem.querySelector('.day-1').innerHTML = Today.toLocaleString({ weekday: 'long' });
this.elem.querySelector('.day-2').innerHTML = Tomorrow.toLocaleString({ weekday: 'long' });
this.elem.querySelector('.rise-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
this.elem.querySelector('.rise-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
this.elem.querySelector('.set-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
this.elem.querySelector('.set-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
this.elem.querySelector('.rise-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunrise));
this.elem.querySelector('.rise-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunrise));
this.elem.querySelector('.set-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunset));
this.elem.querySelector('.set-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunset));
const days = info.moon.map((MoonPhase) => {
const fill = {};
@@ -171,6 +168,8 @@ const imageName = (type) => {
}
};
const timeFormat = (dt) => dt.setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
// register display
const display = new Almanac(9, 'almanac');
registerDisplay(display);

View File

@@ -3,43 +3,24 @@ import { json } from './utils/fetch.mjs';
const KEYS = {
ESC: 27,
TAB: 9,
RETURN: 13,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
ENTER: 13,
};
const DEFAULT_OPTIONS = {
autoSelectFirst: false,
serviceUrl: null,
lookup: null,
onSelect: () => { },
onHint: null,
width: 'auto',
minChars: 3,
maxHeight: 300,
deferRequestBy: 0,
params: {},
delimiter: null,
zIndex: 9999,
type: 'GET',
noCache: false,
preserveInput: false,
containerClass: 'autocomplete-suggestions',
tabDisabled: false,
dataType: 'text',
currentRequest: null,
triggerSelectOnValidInput: true,
preventBadQueries: true,
paramName: 'query',
transformResult: (a) => a,
showNoSuggestionNotice: false,
noSuggestionNotice: 'No results',
orientation: 'bottom',
forceFixPosition: false,
};
const escapeRegExChars = (string) => string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
@@ -172,6 +153,11 @@ class AutoComplete {
}
}
setValue(newValue) {
this.currentValue = newValue;
this.elem.value = newValue;
}
onValueChange() {
clearTimeout(this.onValueChange);

View File

@@ -1,6 +1,6 @@
// current weather conditions display
import STATUS from './status.mjs';
import { loadImg, preloadImg } from './utils/image.mjs';
import { preloadImg } from './utils/image.mjs';
import { json } from './utils/fetch.mjs';
import { directionToNSEW } from './utils/calc.mjs';
import { locationCleanup } from './utils/string.mjs';
@@ -17,8 +17,6 @@ const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F'
class CurrentWeather extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Current Conditions', true);
// pre-load background image (returns promise)
this.backgroundImage = loadImg('images/backgrounds/1.png');
}
async getData(weatherParameters, refresh) {
@@ -52,6 +50,8 @@ class CurrentWeather extends WeatherDisplay {
stillWaiting: () => this.stillWaiting(),
});
if (observations.features.length === 0) throw new Error(`No features returned for station: ${station.properties.stationIdentifier}, trying next station`);
// test data quality
if (observations.features[0].properties.temperature.value === null
|| observations.features[0].properties.windSpeed.value === null
@@ -61,10 +61,11 @@ class CurrentWeather extends WeatherDisplay {
|| observations.features[0].properties.dewpoint.value === null
|| observations.features[0].properties.barometricPressure.value === null) {
observations = undefined;
throw new Error(`Unable to get observations: ${station.properties.stationIdentifier}, trying next station`);
throw new Error(`Incomplete data set for: ${station.properties.stationIdentifier}, trying next station`);
}
} catch (error) {
console.error(error);
observations = undefined;
}
}
// test for data received

View File

@@ -5,10 +5,14 @@ import { currentDisplay } from './navigation.mjs';
// constants
const degree = String.fromCharCode(176);
const SCROLL_SPEED = 75; // pixels/second
const DEFAULT_UPDATE = 8; // 0.5s ticks
// local variables
let interval;
let screenIndex = 0;
let sinceLastUpdate = 0;
let nextUpdate = DEFAULT_UPDATE;
// start drawing conditions
// reset starts from the first item in the text scroll list
@@ -17,7 +21,7 @@ const start = () => {
// set up the interval if needed
if (!interval) {
interval = setInterval(incrementInterval, 4000);
interval = setInterval(incrementInterval, 500);
}
// draw the data
@@ -29,14 +33,24 @@ const stop = (reset) => {
};
// increment interval, roll over
const incrementInterval = () => {
// forcing is used when drawScreen receives an invalid screen and needs to request the next one in line
const incrementInterval = (force) => {
if (!force) {
// test for elapsed time (0.5s ticks);
sinceLastUpdate += 1;
if (sinceLastUpdate < nextUpdate) return;
}
// reset flags
sinceLastUpdate = 0;
nextUpdate = DEFAULT_UPDATE;
// test current screen
const display = currentDisplay();
if (!display?.okToDrawCurrentConditions) {
stop(display?.elemId === 'progress');
return;
}
screenIndex = (screenIndex + 1) % (screens.length);
screenIndex = (screenIndex + 1) % (lastScreen);
// draw new text
drawScreen();
};
@@ -48,7 +62,22 @@ const drawScreen = async () => {
// nothing to do if there's no data yet
if (!data) return;
drawCondition(screens[screenIndex](data));
const thisScreen = screens[screenIndex](data);
if (typeof thisScreen === 'string') {
// only a string
drawCondition(thisScreen);
} else if (typeof thisScreen === 'object') {
// an object was provided with additional parameters
switch (thisScreen.type) {
case 'scroll':
drawScrollCondition(thisScreen);
break;
default: drawCondition(thisScreen);
}
} else {
// can't identify screen, get another one
incrementInterval(true);
}
};
// the "screens" are stored in an array for easy addition and removal
@@ -71,7 +100,7 @@ const screens = [
(data) => `Humidity: ${data.Humidity}% Dewpoint: ${data.DewPoint}${degree}${data.TemperatureUnit}`,
// barometric pressure
(data) => `Barometric Pressure: ${data.Pressure} ${data.PressureUnit} ${data.PressureDirection}`,
(data) => `Barometric Pressure: ${data.Pressure} ${data.PressureDirection}`,
// wind
(data) => {
@@ -102,3 +131,56 @@ const drawCondition = (text) => {
document.addEventListener('DOMContentLoaded', () => {
start();
});
// store the original number of screens
const originalScreens = screens.length;
let lastScreen = originalScreens;
// reset the number of screens
const reset = () => {
lastScreen = originalScreens;
};
// add screen
const addScreen = (screen) => {
screens.push(screen);
lastScreen += 1;
};
const drawScrollCondition = (screen) => {
// create the scroll element
const scrollElement = document.createElement('div');
scrollElement.classList.add('scroll-area');
scrollElement.innerHTML = screen.text;
// add it to the page to get the width
document.querySelector('.weather-display .scroll .fixed').innerHTML = scrollElement.outerHTML;
// grab the width
const { scrollWidth, clientWidth } = document.querySelector('.weather-display .scroll .fixed .scroll-area');
// calculate the scroll distance and set a minimum scroll
const scrollDistance = Math.max(scrollWidth - clientWidth, 0);
// calculate the scroll time
const scrollTime = scrollDistance / SCROLL_SPEED;
// calculate a new minimum on-screen time +1.0s at start and end
nextUpdate = Math.round(Math.ceil(scrollTime / 0.5) + 4);
// update the element transition and set initial left position
scrollElement.style.left = '0px';
scrollElement.style.transition = `left linear ${scrollTime.toFixed(1)}s`;
elemForEach('.weather-display .scroll .fixed', (elem) => {
elem.innerHTML = '';
elem.append(scrollElement.cloneNode(true));
});
// start the scroll after a short delay
setTimeout(() => {
// change the left position to trigger the scroll
elemForEach('.weather-display .scroll .fixed .scroll-area', (elem) => {
elem.style.left = `-${scrollDistance.toFixed(0)}px`;
});
}, 1000);
};
window.CurrentWeatherScroll = {
addScreen,
reset,
};

View File

@@ -59,11 +59,10 @@ class ExtendedForecast extends WeatherDisplay {
date: Day.dayName,
};
const { low } = Day;
const { low, high } = Day;
if (low !== undefined) {
fill['value-lo'] = Math.round(low);
}
const { high } = Day;
fill['value-hi'] = Math.round(high);
// return the filled template
@@ -121,17 +120,18 @@ const parse = (fullForecast) => {
return forecast;
};
const regexList = [
[/ and /gi, ' '],
[/slight /gi, ''],
[/chance /gi, ''],
[/very /gi, ''],
[/patchy /gi, ''],
[/Areas Of /gi, ''],
[/areas /gi, ''],
[/dense /gi, ''],
[/Thunderstorm/g, 'T\'Storm'],
];
const shortenExtendedForecastText = (long) => {
const regexList = [
[/ and /gi, ' '],
[/slight /gi, ''],
[/chance /gi, ''],
[/very /gi, ''],
[/patchy /gi, ''],
[/areas /gi, ''],
[/dense /gi, ''],
[/Thunderstorm/g, 'T\'Storm'],
];
// run all regexes
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);

View File

@@ -39,9 +39,9 @@ class Hazards extends WeatherDisplay {
// get the forecast
const url = new URL('https://api.weather.gov/alerts/active');
url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`);
url.searchParams.append('limit', 5);
const alerts = await json(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
const unsortedAlerts = alerts.features ?? [];
const allUnsortedAlerts = alerts.features ?? [];
const unsortedAlerts = allUnsortedAlerts.slice(0, 5);
const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false);
const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event)));
const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate')));
@@ -50,7 +50,7 @@ class Hazards extends WeatherDisplay {
// show alert indicator
if (this.data.length > 0) alert.classList.add('show');
} catch (error) {
console.error('Get hourly forecast failed');
console.error('Get hazards failed');
console.error(error.status, error.responseJSON);
if (this.isEnabled) this.setStatus(STATUS.failed);
// return undefined to other subscribers
@@ -129,7 +129,7 @@ class Hazards extends WeatherDisplay {
// don't let offset go negative
if (offsetY < 0) offsetY = 0;
// copy the scrolled portion of the canvas
// move the element
this.elem.querySelector('.main').scrollTo(0, offsetY);
}

View File

@@ -6,6 +6,10 @@ import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay, timeZone } from './navigation.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
// get available space
const availableWidth = 532;
const availableHeight = 285;
class HourlyGraph extends WeatherDisplay {
constructor(navId, elemId, defaultActive) {
super(navId, elemId, 'Hourly Graph', defaultActive);
@@ -47,10 +51,6 @@ class HourlyGraph extends WeatherDisplay {
drawCanvas() {
if (!this.image) this.image = this.elem.querySelector('.chart img');
// get available space
const availableWidth = 532;
const availableHeight = 285;
this.image.width = availableWidth;
this.image.height = availableHeight;

View File

@@ -69,8 +69,7 @@ class Hourly extends WeatherDisplay {
const fillValues = {};
// hour
const hour = startingHour.plus({ hours: index });
const formattedHour = hour.toLocaleString({ weekday: 'short', hour: 'numeric' });
fillValues.hour = formattedHour;
fillValues.hour = hour.toLocaleString({ weekday: 'short', hour: 'numeric' });
// temperatures, convert to strings with no decimal
const temperature = data.temperature.toString().padStart(3);
@@ -81,12 +80,11 @@ class Hourly extends WeatherDisplay {
fillValues.like = feelsLike;
// wind
let wind = 'Calm';
fillValues.wind = 'Calm';
if (data.windSpeed > 0) {
const windSpeed = Math.round(data.windSpeed).toString();
wind = data.windDirection + (Array(6 - data.windDirection.length - windSpeed.length).join(' ')) + windSpeed;
fillValues.wind = data.windDirection + (Array(6 - data.windDirection.length - windSpeed.length).join(' ')) + windSpeed;
}
fillValues.wind = wind;
// image
fillValues.icon = { type: 'img', src: data.icon };
@@ -96,8 +94,7 @@ class Hourly extends WeatherDisplay {
// alter the color of the feels like column to reflect wind chill or heat index
if (feelsLike < temperature) {
filledRow.querySelector('.like').classList.add('wind-chill');
}
if (feelsLike > temperature) {
} else if (feelsLike > temperature) {
filledRow.querySelector('.like').classList.add('heat-index');
}

View File

@@ -1,7 +1,7 @@
const hourlyIcon = (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed, isNight = false) => {
// internal function to add path to returned icon
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
// internal function to add path to returned icon
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
const hourlyIcon = (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed, isNight = false) => {
// possible phenomenon
let thunder = false;
let snow = false;

View File

@@ -65,6 +65,10 @@ const largeIcon = (link, _isNightTime) => {
case 'sleet-n':
return addPath('Sleet.gif');
case 'smoke':
case 'smoke-n':
return addPath('Smoke.gif');
case 'rain_showers':
case 'rain_showers_high':
case 'rain_showers-n':

View File

@@ -44,6 +44,7 @@ const smallIcon = (link, _isNightTime) => {
case 'sct-n':
case 'nsct':
case 'nsct-n':
case 'haze-n':
return addPath('Partly-Cloudy-Night.gif');
case 'ovc':

View File

@@ -22,8 +22,7 @@ class LatestObservations extends WeatherDisplay {
// this is intentional because up to 30 stations are available to pull data from
// calculate distance to each station
const stationsByDistance = Object.keys(StationInfo).map((key) => {
const station = StationInfo[key];
const stationsByDistance = Object.values(StationInfo).map((station) => {
const distance = calcDistance(station.lat, station.lon, this.weatherParameters.latitude, this.weatherParameters.longitude);
return { ...station, distance };
});
@@ -104,8 +103,6 @@ class LatestObservations extends WeatherDisplay {
linesContainer.innerHTML = '';
linesContainer.append(...lines);
// update temperature unit header
this.finishDraw();
}
}

View File

@@ -47,7 +47,7 @@ class LocalForecast extends WeatherDisplay {
forecastsElem.append(...templates);
// increase each forecast height to a multiple of container height
this.pageHeight = forecastsElem.parentNode.scrollHeight;
this.pageHeight = forecastsElem.parentNode.offsetHeight;
templates.forEach((forecast) => {
const newHeight = Math.ceil(forecast.scrollHeight / this.pageHeight) * this.pageHeight;
forecast.style.height = `${newHeight}px`;

View File

@@ -79,6 +79,7 @@ const startMedia = async () => {
} else {
try {
await player.play();
setTrackName(playlist.availableFiles[currentTrack]);
} catch (e) {
// report the error
console.error('Couldn\'t play music');
@@ -86,6 +87,7 @@ const startMedia = async () => {
// set state back to not playing for good UI experience
mediaPlaying.value = false;
stateChanged();
setTrackName('Not playing');
}
}
};
@@ -93,6 +95,7 @@ const startMedia = async () => {
const stopMedia = () => {
if (!player) return;
player.pause();
setTrackName('Not playing');
};
const stateChanged = () => {
@@ -140,6 +143,7 @@ const initializePlayer = () => {
// get the first file
player.src = `music/${playlist.availableFiles[currentTrack]}`;
setTrackName(playlist.availableFiles[currentTrack]);
player.type = 'audio/mpeg';
};
@@ -160,6 +164,12 @@ const playerEnded = () => {
}
// update the player source
player.src = `music/${playlist.availableFiles[currentTrack]}`;
setTrackName(playlist.availableFiles[currentTrack]);
};
const setTrackName = (fileName) => {
const trackName = fileName.replace(/\.mp3/gi, '').replace(/(_-)/gi, '');
document.getElementById('musicTrack').innerHTML = trackName;
};
export {

View File

@@ -253,9 +253,9 @@ const resize = () => {
const scale = Math.min(widthZoomPercent, heightZoomPercent);
if (scale < 1.0 || document.fullscreenElement || settings.kiosk) {
document.querySelector('#container').style.transform = `scale(${scale})`;
document.querySelector('#container').style.zoom = scale;
} else {
document.querySelector('#container').style.transform = 'unset';
document.querySelector('#container').style.zoom = 'unset';
}
};
@@ -266,6 +266,7 @@ const resetStatuses = () => {
// allow displays to register themselves
const registerDisplay = (display) => {
if (displays[display.navId]) console.warn(`Display nav ID ${display.navId} already in use`);
displays[display.navId] = display;
// generate checkboxes

View File

@@ -1,5 +1,4 @@
// regional forecast and observations
import { loadImg } from './utils/image.mjs';
import STATUS, { calcStatusClass, statusClasses } from './status.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import {
@@ -10,9 +9,6 @@ class Progress extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, '', false);
// pre-load background image (returns promise)
this.backgroundImage = loadImg('images/backgrounds/1.png');
// disable any navigation timing
this.timing = false;

View File

@@ -0,0 +1,106 @@
import * as utils from './radar-utils.mjs';
const radarFullSize = { width: 2550, height: 1600 };
const radarFinalSize = { width: 640, height: 367 };
const fetchAsBlob = async (url) => {
const response = await fetch(url);
return response.blob();
};
const baseMapImages = new Promise((resolve) => {
fetchAsBlob('/images/maps/radar.webp').then((blob) => {
createImageBitmap(blob).then((imageBitmap) => {
// extract the black pixels to overlay on to the final image (boundaries)
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
const context = canvas.getContext('2d');
context.drawImage(imageBitmap, 0, 0);
const imageData = context.getImageData(0, 0, imageBitmap.width, imageBitmap.height);
// go through the image data and preserve the black pixels, making the rest transparent
for (let i = 0; i < imageData.data.length; i += 4) {
if (imageData.data[i + 0] >= 116 || imageData.data[i + 1] >= 116 || imageData.data[i + 2] >= 116) {
// make it transparent
imageData.data[i + 3] = 0;
}
}
// write the image data back
context.putImageData(imageData, 0, 0);
resolve({
fullMap: imageBitmap,
overlay: canvas,
});
});
});
});
onmessage = async (e) => {
const {
url, RADAR_HOST, OVERRIDES, radarSourceXY, sourceXY, offsetX, offsetY,
} = e.data;
// get the image
const modifiedRadarUrl = OVERRIDES.RADAR_HOST ? url.replace(RADAR_HOST, OVERRIDES.RADAR_HOST) : url;
const radarResponsePromise = fetch(modifiedRadarUrl);
// calculate offsets and sizes
const radarSource = {
width: 240,
height: 163,
x: Math.round(radarSourceXY.x / 2),
y: Math.round(radarSourceXY.y / 2),
};
// create destination context
const baseCanvas = new OffscreenCanvas(radarFinalSize.width, radarFinalSize.height);
const baseContext = baseCanvas.getContext('2d');
baseContext.imageSmoothingEnabled = false;
// create working context for manipulation
const radarCanvas = new OffscreenCanvas(radarFullSize.width, radarFullSize.height);
const radarContext = radarCanvas.getContext('2d');
radarContext.imageSmoothingEnabled = false;
// get the base map
const baseMaps = await baseMapImages;
baseContext.drawImage(baseMaps.fullMap, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, radarFinalSize.width, radarFinalSize.height);
// test response
const radarResponse = await radarResponsePromise;
if (!radarResponse.ok) throw new Error(`Unable to fetch radar error ${radarResponse.status} ${radarResponse.statusText} from ${radarResponse.url}`);
// get the blob
const radarImgBlob = await radarResponse.blob();
// assign to an html image element
const radarImgElement = await createImageBitmap(radarImgBlob);
// draw the entire image
radarContext.clearRect(0, 0, radarFullSize.width, radarFullSize.height);
radarContext.drawImage(radarImgElement, 0, 0, radarFullSize.width, radarFullSize.height);
// crop the radar image without scaling
const croppedRadarCanvas = new OffscreenCanvas(radarSource.width, radarSource.height);
const croppedRadarContext = croppedRadarCanvas.getContext('2d');
croppedRadarContext.imageSmoothingEnabled = false;
croppedRadarContext.drawImage(radarCanvas, radarSource.x, radarSource.y, croppedRadarCanvas.width, croppedRadarCanvas.height, 0, 0, croppedRadarCanvas.width, croppedRadarCanvas.height);
// clean the image
utils.removeDopplerRadarImageNoise(croppedRadarContext);
// stretch the radar image
const stretchCanvas = new OffscreenCanvas(radarFinalSize.width, radarFinalSize.height);
const stretchContext = stretchCanvas.getContext('2d', { willReadFrequently: true });
stretchContext.imageSmoothingEnabled = false;
stretchContext.drawImage(croppedRadarCanvas, 0, 0, radarSource.width, radarSource.height, 0, 0, radarFinalSize.width, radarFinalSize.height);
// put the radar on the base map
baseContext.drawImage(stretchCanvas, 0, 0);
// put the road/boundaries overlay on the map
baseContext.drawImage(baseMaps.overlay, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, radarFinalSize.width, radarFinalSize.height);
const processedRadar = baseCanvas.transferToImageBitmap();
postMessage(processedRadar, [processedRadar]);
};

View File

@@ -1,16 +1,32 @@
// current weather conditions display
import STATUS from './status.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
import { loadImg } from './utils/image.mjs';
import { text } from './utils/fetch.mjs';
import { rewriteUrl } from './utils/cors.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay, timeZone } from './navigation.mjs';
import * as utils from './radar-utils.mjs';
// TEMPORARY fix to disable radar on ios safari. The same engine (webkit) is
// used for all ios browers (chrome, brave, firefox, etc) so it's safe to skip
// any subsequent narrowing of the user-agent.
const isIos = /iP(ad|od|hone)/i.test(window.navigator.userAgent);
// NOTE: iMessages/Messages preview is provided by an Apple scraper that uses a
// user-agent similar to: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1)
// AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4
// facebookexternalhit/1.1 Facebot Twitterbot/1.0`. There is currently a bug in
// Messages macos/ios where a constantly crashing website seems to cause an
// entire Messages thread to permanently lockup until the individual website
// preview is deleted! Messages ios will judder but allows the message to be
// deleted eventually. Messages macos beachballs forever and prevents the
// successful deletion. See
// https://github.com/netbymatt/ws4kp/issues/74#issuecomment-2921154962 for more
// context.
const isBot = /twitterbot|Facebot/i.test(window.navigator.userAgent);
const RADAR_HOST = 'mesonet.agron.iastate.edu';
class Radar extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Local Radar', true);
super(navId, elemId, 'Local Radar', !isIos && !isBot);
this.okToDrawCurrentConditions = false;
this.okToDrawCurrentDateTime = false;
@@ -51,12 +67,14 @@ class Radar extends WeatherDisplay {
return;
}
// get the base map
const src = 'images/maps/radar.jpg';
this.baseMap = await loadImg(src);
// get the workers started
if (!this.workers) {
// get some web workers started
this.workers = (new Array(this.dopplerRadarImageMax)).fill(null).map(() => radarWorker());
}
const baseUrl = 'https://mesonet.agron.iastate.edu/archive/data/';
const baseUrlEnd = '/GIS/uscomp/';
const baseUrl = `https://${RADAR_HOST}/archive/data/`;
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png';
const baseUrls = [];
let date = DateTime.utc().minus({ days: 1 }).startOf('day');
@@ -102,91 +120,45 @@ class Radar extends WeatherDisplay {
const urls = sortedPngs.slice(-(this.dopplerRadarImageMax));
// calculate offsets and sizes
let offsetX = 120;
let offsetY = 69;
const width = 2550;
const height = 1600;
offsetX *= 2;
offsetY *= 2;
const offsetX = 120 * 2;
const offsetY = 69 * 2;
const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY);
// calculate radar offsets
const radarOffsetX = 120;
const radarOffsetY = 70;
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY);
const radarSourceX = radarSourceXY.x / 2;
const radarSourceY = radarSourceXY.y / 2;
// Load the most recent doppler radar images.
const radarInfo = await Promise.all(urls.map(async (url) => {
// create destination context
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 367;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;
// create working context for manipulation
const workingCanvas = document.createElement('canvas');
workingCanvas.width = width;
workingCanvas.height = height;
const workingContext = workingCanvas.getContext('2d');
workingContext.imageSmoothingEnabled = false;
// get the image
const response = await fetch(rewriteUrl(url));
// test response
if (!response.ok) throw new Error(`Unable to fetch radar error ${response.status} ${response.statusText} from ${response.url}`);
// get the blob
const blob = await response.blob();
const radarInfo = await Promise.all(urls.map(async (url, index) => {
const processedRadar = await this.workers[index].processRadar({
url,
RADAR_HOST,
OVERRIDES,
sourceXY,
radarSourceXY,
offsetX,
offsetY,
});
// store the time
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
let time;
if (timeMatch) {
const [, year, month, day, hour, minute] = timeMatch;
time = DateTime.fromObject({
year,
month,
day,
hour,
minute,
}, {
zone: 'UTC',
}).setZone(timeZone());
} else {
time = DateTime.fromHTTP(response.headers.get('last-modified')).setZone(timeZone());
}
// assign to an html image element
const imgBlob = await loadImg(blob);
const [, year, month, day, hour, minute] = timeMatch;
const time = DateTime.fromObject({
year,
month,
day,
hour,
minute,
}, {
zone: 'UTC',
}).setZone(timeZone());
// draw the entire image
workingContext.clearRect(0, 0, width, 1600);
workingContext.drawImage(imgBlob, 0, 0, width, 1600);
// get the base map
context.drawImage(this.baseMap, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, 640, 367);
// crop the radar image
const cropCanvas = document.createElement('canvas');
cropCanvas.width = 640;
cropCanvas.height = 367;
const cropContext = cropCanvas.getContext('2d', { willReadFrequently: true });
cropContext.imageSmoothingEnabled = false;
cropContext.drawImage(workingCanvas, radarSourceX, radarSourceY, (radarOffsetX * 2), (radarOffsetY * 2.33), 0, 0, 640, 367);
// clean the image
utils.removeDopplerRadarImageNoise(cropContext);
// merge the radar and map
utils.mergeDopplerRadarImage(context, cropContext);
const elem = this.fillTemplate('frame', { map: { type: 'img', src: canvas.toDataURL() } });
const onscreenCanvas = document.createElement('canvas');
onscreenCanvas.width = processedRadar.width;
onscreenCanvas.height = processedRadar.height;
const onscreenContext = onscreenCanvas.getContext('bitmaprenderer');
onscreenContext.transferFromImageBitmap(processedRadar);
const elem = this.fillTemplate('frame', { map: { type: 'canvas', canvas: onscreenCanvas } });
return {
canvas,
time,
elem,
};
@@ -199,8 +171,6 @@ class Radar extends WeatherDisplay {
// set max length
this.timing.totalScreens = radarInfo.length;
// store the images
this.data = radarInfo.map((radar) => radar.canvas);
this.times = radarInfo.map((radar) => radar.time);
this.setStatus(STATUS.loaded);
@@ -223,5 +193,33 @@ class Radar extends WeatherDisplay {
}
}
// create a radar worker with helper functions
const radarWorker = () => {
// create the worker
const worker = new Worker(new URL('./radar-worker.mjs', import.meta.url), { type: 'module' });
const processRadar = (url) => new Promise((resolve, reject) => {
// prepare for done message
worker.onmessage = (e) => {
if (e?.data instanceof Error) {
reject(e.data);
} else if (e?.data instanceof ImageBitmap) {
resolve(e.data);
}
};
// start up the worker
worker.postMessage(url);
});
// return the object
return {
processRadar,
};
};
// register display
registerDisplay(new Radar(11, 'radar'));
// TEMPORARY: except on IOS and bots
if (!isIos && !isBot) {
registerDisplay(new Radar(11, 'radar'));
}

View File

@@ -20,7 +20,7 @@ const buildForecast = (forecast, city, cityXY) => {
const getRegionalObservation = async (point, city) => {
try {
// get stations
const stations = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations`);
const stations = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations?limit=1`);
// get the first station
const station = stations.features[0].id;

View File

@@ -7,12 +7,18 @@ import { json } from './utils/fetch.mjs';
import { temperature as temperatureUnit } from './utils/units.mjs';
import { getSmallIcon } from './icons.mjs';
import { preloadImg } from './utils/image.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
import { DateTime, Interval } from '../vendor/auto/luxon.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
import * as utils from './regionalforecast-utils.mjs';
import { getPoint } from './utils/weather.mjs';
// map offset
const mapOffsetXY = {
x: 240,
y: 117,
};
class RegionalForecast extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Regional Forecast', true);
@@ -28,7 +34,7 @@ class RegionalForecast extends WeatherDisplay {
// there are enough other cities available to populate the map sufficiently even if some do not load
// pre-load the base map
let baseMap = 'images/maps/basemap.png';
let baseMap = 'images/maps/basemap.webp';
if (weatherParameters.state === 'HI') {
baseMap = 'images/maps/radar-hawaii.png';
} else if (weatherParameters.state === 'AK') {
@@ -36,23 +42,18 @@ class RegionalForecast extends WeatherDisplay {
}
this.elem.querySelector('.map img').src = baseMap;
// map offset
const offsetXY = {
x: 240,
y: 117,
};
// get user's location in x/y
const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, offsetXY.x, offsetXY.y, weatherParameters.state);
const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, mapOffsetXY.x, mapOffsetXY.y, weatherParameters.state);
// get latitude and longitude limits
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, offsetXY.x, offsetXY.y, this.weatherParameters.state);
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, mapOffsetXY.x, mapOffsetXY.y, this.weatherParameters.state);
// get a target distance
let targetDistance = 2.5;
if (this.weatherParameters.state === 'HI') targetDistance = 1;
// make station info into an array
const stationInfoArray = Object.values(StationInfo).map((value) => ({ ...value, targetDistance }));
const stationInfoArray = Object.values(StationInfo).map((station) => ({ ...station, targetDistance }));
// combine regional cities with station info for additional stations
// stations are intentionally after cities to allow cities priority when drawing the map
const combinedCities = [...RegionalCities, ...stationInfoArray];
@@ -76,6 +77,9 @@ class RegionalForecast extends WeatherDisplay {
// get a unit converter
const temperatureConverter = temperatureUnit();
// get now as DateTime for calculations below
const now = DateTime.now();
// get regional forecasts and observations (the two are intertwined due to the design of api.weather.gov)
const regionalDataAll = await Promise.all(regionalCities.map(async (city) => {
try {
@@ -109,14 +113,24 @@ class RegionalForecast extends WeatherDisplay {
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
// return a pared-down forecast
// 0th object is the current conditions
// first object is the next period i.e. if it's daytime then it's the "tonight" forecast
// second object is the following period
// always skip the first forecast index because it's what's going on right now
// 0th object should contain the current conditions, but when WFOs go offline or otherwise don't post
// an updated forecast it's possible that the 0th object is in the past.
// so we go on a search for the current time in the start/end times provided in the forecast periods
const { periods } = forecast.properties;
const currentPeriod = periods.reduce((prev, period, index) => {
const start = DateTime.fromISO(period.startTime);
const end = DateTime.fromISO(period.endTime);
const interval = Interval.fromDateTimes(start, end);
if (interval.contains(now)) {
return index;
}
return prev;
}, 0);
// group together the current observation and next two periods
return [
regionalObservation,
utils.buildForecast(forecast.properties.periods[1], city, cityXY),
utils.buildForecast(forecast.properties.periods[2], city, cityXY),
utils.buildForecast(forecast.properties.periods[currentPeriod + 1], city, cityXY),
utils.buildForecast(forecast.properties.periods[currentPeriod + 2], city, cityXY),
];
} catch (error) {
console.log(`No regional forecast data for '${city.name ?? city.city}'`);
@@ -137,7 +151,7 @@ class RegionalForecast extends WeatherDisplay {
// return the weather data and offsets
this.data = {
regionalData,
offsetXY,
mapOffsetXY,
sourceXY,
};
@@ -147,7 +161,7 @@ class RegionalForecast extends WeatherDisplay {
drawCanvas() {
super.drawCanvas();
// break up data into useful values
const { regionalData: data, sourceXY, offsetXY } = this.data;
const { regionalData: data, sourceXY } = this.data;
// draw the header graphics
@@ -170,7 +184,7 @@ class RegionalForecast extends WeatherDisplay {
}
// draw the map
const scale = 640 / (offsetXY.x * 2);
const scale = 640 / (mapOffsetXY.x * 2);
const map = this.elem.querySelector('.map');
map.style.transform = `scale(${scale}) translate(-${sourceXY.x}px, -${sourceXY.y}px)`;

View File

@@ -33,6 +33,12 @@ const init = () => {
[1.5, 'Very Slow'],
],
});
settings.scanLines = new Setting('scanLines', {
name: 'Scan Lines',
defaultValue: false,
changeAction: scanLineChange,
sticky: true,
});
settings.units = new Setting('units', {
name: 'Units',
type: 'select',
@@ -85,6 +91,18 @@ const kioskChange = (value) => {
}
};
const scanLineChange = (value) => {
const container = document.getElementById('container');
const navIcons = document.getElementById('ToggleScanlines');
if (value) {
container.classList.add('scanlines');
navIcons.classList.add('on');
} else {
container.classList.remove('scanlines');
navIcons.classList.remove('on');
}
};
const unitChange = () => {
// reload the data at the top level to refresh units
// after the initial load

View File

@@ -1,3 +1,5 @@
import { elemForEach } from './utils/elem.mjs';
document.addEventListener('DOMContentLoaded', () => init());
// shorthand mappings for frequently used values
@@ -19,21 +21,18 @@ const init = () => {
const createLink = async (e) => {
// cancel default event (click on hyperlink)
e.preventDefault();
// get all checkboxes on page
const checkboxes = document.querySelectorAll('input[type=checkbox]');
// list to receive checkbox statuses
const queryStringElements = {};
[...checkboxes].forEach((elem) => {
elemForEach('input[type=checkbox]', (elem) => {
if (elem?.id) {
queryStringElements[elem.id] = elem?.checked ?? false;
}
});
// get all select boxes
const selects = document.querySelectorAll('select');
[...selects].forEach((elem) => {
elemForEach('select', (elem) => {
if (elem?.id) {
queryStringElements[elem.id] = elem?.value ?? 0;
}

View File

@@ -54,23 +54,25 @@ class SpcOutlook extends WeatherDisplay {
async getData(weatherParameters, refresh) {
if (!super.getData(weatherParameters, refresh)) return;
let initialData;
try {
// get the three categorical files to get started
const filePromises = await Promise.allSettled(this.files.map((file) => json(file)));
// store the data, promise will always be fulfilled
initialData = filePromises.map((outlookDay) => outlookDay.value);
} catch (error) {
console.error('Unable to get spc outlook');
console.error(error.status, error.responseJSON);
// if there's no previous data, fail
if (!this.data) {
this.setStatus(STATUS.failed);
return;
// initial data does not need to be reloaded on a location change, only during silent refresh
if (!this.initialData || refresh) {
try {
// get the three categorical files to get started
const filePromises = await Promise.allSettled(this.files.map((file) => json(file)));
// store the data, promise will always be fulfilled
this.initialData = filePromises.map((outlookDay) => outlookDay.value);
} catch (error) {
console.error('Unable to get spc outlook');
console.error(error.status, error.responseJSON);
// if there's no previous data, fail
if (!this.initialData) {
this.setStatus(STATUS.failed);
return;
}
}
}
// do the initial parsing of the data
this.data = testAllPoints([weatherParameters.longitude, weatherParameters.latitude], initialData);
this.data = testAllPoints([weatherParameters.longitude, weatherParameters.latitude], this.initialData);
// if all the data returns false the there's nothing to do, skip this screen
if (this.data.reduce((prev, cur) => prev || !!cur, false)) {

View File

@@ -5,6 +5,11 @@ const text = (url, params) => fetchAsync(url, 'text', params);
const blob = (url, params) => fetchAsync(url, 'blob', params);
const fetchAsync = async (_url, responseType, _params = {}) => {
// add user agent header to json request at api.weather.gov
const headers = {};
if (_url.toString().match(/api\.weather\.gov/)) {
headers['user-agent'] = 'Weatherstar 4000+; weatherstar@netbymatt.com';
}
// combine default and provided parameters
const params = {
method: 'GET',
@@ -12,6 +17,7 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
type: 'GET',
retryCount: 0,
..._params,
headers,
};
// store original number of retries
params.originalRetries = params.retryCount;

View File

@@ -1,21 +1,4 @@
import { blob } from './fetch.mjs';
import { rewriteUrl } from './cors.mjs';
// ****************************** load images *********************************
// load an image from a blob or url
const loadImg = (imgData, cors = false) => new Promise((resolve) => {
const img = new Image();
img.onload = (e) => {
resolve(e.target);
};
if (imgData instanceof Blob) {
img.src = window.URL.createObjectURL(imgData);
} else {
let url = imgData;
if (cors) url = rewriteUrl(imgData);
img.src = url;
}
});
// preload an image
// the goal is to get it in the browser's cache so it is available more quickly when the browser needs it
@@ -29,6 +12,6 @@ const preloadImg = (src) => {
};
export {
loadImg,
// eslint-disable-next-line import/prefer-default-export
preloadImg,
};

View File

@@ -189,7 +189,7 @@ class Setting {
break;
case 'checkbox':
default:
this.element.checked = newValue;
this.element.querySelector('input').checked = newValue;
}
this.storeToLocalStorage(this.myValue);

View File

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

View File

@@ -7,6 +7,7 @@ import {
} from './navigation.mjs';
import { parseQueryString } from './share.mjs';
import settings from './settings.mjs';
import { elemForEach } from './utils/elem.mjs';
class WeatherDisplay {
constructor(navId, elemId, name, defaultEnabled) {
@@ -391,8 +392,7 @@ class WeatherDisplay {
this.templates = {};
this.elem = document.querySelector(`#${this.elemId}-html`);
if (!this.elem) return;
const templates = this.elem.querySelectorAll('.template');
templates.forEach((template) => {
elemForEach(`#${this.elemId}-html .template`, (template) => {
const className = template.classList[0];
const node = template.cloneNode(true);
node.classList.remove('template');
@@ -421,6 +421,8 @@ class WeatherDisplay {
} else if (value?.type === 'img') {
// fill the image source
elem.querySelector('img').src = value.src;
} else if (value?.type === 'canvas') {
elem.append(value.canvas);
}
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@
width: 255px;
display: inline-block;
margin-top: 10px;
padding-top: 10px;
position: absolute;
@include u.text-shadow();
@@ -90,7 +91,11 @@
.location {
color: c.$title-color;
max-height: 32px;
margin-bottom: 10px;
padding-top: 4px;
overflow: hidden;
text-wrap: nowrap;
}
}
}

View File

@@ -4,6 +4,7 @@
@font-face {
font-family: "Star4000";
src: url('../fonts/Star4000.woff') format('woff');
font-display: swap;
}
body {
@@ -22,6 +23,8 @@ body {
&.kiosk {
margin: 0px;
overflow: hidden;
width: 100vw;
}
}
@@ -140,6 +143,10 @@ body {
}
}
.kiosk #divTwc {
max-width: unset;
}
#divTwcLeft {
display: none;
text-align: right;
@@ -188,11 +195,33 @@ body {
#divTwcBottom>div {
padding-left: 6px;
padding-right: 6px;
// scale down the buttons on narrower screens
@media (max-width: 550px) {
zoom: 0.90;
}
@media (max-width: 500px) {
zoom: 0.80;
}
@media (max-width: 450px) {
zoom: 0.70;
}
@media (max-width: 400px) {
zoom: 0.60;
}
@media (max-width: 350px) {
zoom: 0.50;
}
}
#divTwcBottomLeft {
flex: 1;
text-align: left;
}
#divTwcBottomMiddle {
@@ -251,39 +280,22 @@ body {
width: 475px;
}
@font-face {
font-family: "Star4000";
src: url('../fonts/Star4000.woff') format('woff');
}
@font-face {
font-family: "Star 4 Radar";
src: url('../fonts/Star 4 Radar.woff') format('woff');
}
@font-face {
font-family: 'Star4000 Extended';
src: url('../fonts/Star4000 Extended.woff') format('woff');
}
@font-face {
font-family: 'Star4000LCN';
src: url('../fonts/Star4000LCN.woff') format('woff');
}
@font-face {
font-family: 'Star4000 Large Compressed';
src: url('../fonts/Star4000 Large Compressed.woff') format('woff');
font-display: swap;
}
@font-face {
font-family: 'Star4000 Large';
src: url('../fonts/Star4000 Large.ttf') format('truetype');
src: url('../fonts/Star4000 Large.woff') format('woff');
font-display: swap;
}
@font-face {
font-family: 'Star4000 Small';
src: url('../fonts/Star4000 Small.woff') format('woff');
font-display: swap;
}
#display {
@@ -316,10 +328,6 @@ body {
transform-origin: unset;
}
.kiosk #divTwc #container {
transform-origin: 0 0;
}
#loading {
width: 640px;
height: 480px;
@@ -420,10 +428,6 @@ body {
}
}
.kiosk #divTwc {
justify-content: unset;
}
#divTwc:fullscreen #display,
.kiosk #divTwc #display {
position: relative;
@@ -452,6 +456,30 @@ body {
cursor: pointer;
}
#ToggleScanlines {
display: inline-block;
.on {
display: none;
}
.off {
display: inline-block;
}
&.on {
.on {
display: inline-block;
}
.off {
display: none;
}
}
}
.visible {
visibility: visible;
opacity: 1;
@@ -732,7 +760,7 @@ body {
}
#share-link-copied {
color: c.$title-color;
color: hsl(60, 100%, 30%);
display: none;
}
@@ -744,6 +772,7 @@ body {
#divQuery,
>.info,
>.related-links,
>.heading,
#enabledDisplays,
#settings,

View File

@@ -38,6 +38,7 @@
.temp {
font-family: 'Star4000 Large';
font-size: 28px;
padding-top: 2px;
color: c.$title-color;
top: 28px;
text-align: right;

View File

@@ -113,7 +113,7 @@
.scroll {
@include u.text-shadow(3px, 1.5px);
width: 640px;
width: calc(640px - 2 * 30px);
height: 70px;
overflow: hidden;
margin-top: 10px;
@@ -122,6 +122,15 @@
font-family: 'Star4000';
font-size: 24pt;
margin-left: 55px;
overflow: hidden;
.scroll-area {
text-wrap: nowrap;
position: relative;
// the following added by js code as it is dependent on the content of the element
// transition: left (x)s;
// left: calc((elem width) - 640px);
}
}
}
}

View File

@@ -13,4 +13,5 @@
@use 'almanac';
@use 'hazards';
@use 'media';
@use 'spc-outlook';
@use 'spc-outlook';
@use 'shared/scanlines';

View File

@@ -0,0 +1,106 @@
/* REGULAR SCANLINES SETTINGS */
// width of 1 scanline (min.: 1px)
$scan-width: 1px;
// emulates a damage-your-eyes bad pre-2000 CRT screen ♥ (true, false)
$scan-crt: false;
// frames-per-second (should be > 1), only applies if $scan-crt: true;
$scan-fps: 20;
// scanline-color (rgba)
$scan-color: rgba(#000, .3);
// set z-index on 8, like in ♥ 8-bits ♥, or…
// set z-index on 2147483648 or more to enable scanlines on Chrome fullscreen (doesn't work in Firefox or IE);
$scan-z-index: 2147483648;
/* MOVING SCANLINE SETTINGS */
// moving scanline (true, false)
$scan-moving-line: true;
// opacity of the moving scanline
$scan-opacity: .75;
/* MIXINS */
// apply CRT animation: @include scan-crt($scan-crt);
@mixin scan-crt($scan-crt) {
@if $scan-crt==true {
animation: scanlines 1s steps($scan-fps) infinite;
}
@else {
animation: none;
}
}
// apply CRT animation: @include scan-crt($scan-crt);
@mixin scan-moving($scan-moving-line) {
@if $scan-moving-line==true {
animation: scanline 6s linear infinite;
}
@else {
animation: none;
}
}
/* CSS .scanlines CLASS */
.scanlines {
position: relative;
overflow: hidden; // only to animate the unique scanline
&:before,
&:after {
display: block;
pointer-events: none;
content: '';
position: absolute;
}
// unique scanline travelling on the screen
&:before {
// position: absolute;
// bottom: 100%;
width: 100%;
height: $scan-width * 1;
z-index: $scan-z-index + 1;
background: $scan-color;
opacity: $scan-opacity;
// animation: scanline 6s linear infinite;
@include scan-moving($scan-moving-line);
}
// the scanlines, so!
&:after {
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: $scan-z-index;
background: linear-gradient(to bottom,
transparent 50%,
$scan-color 51%);
background-size: 100% $scan-width*2;
@include scan-crt($scan-crt);
}
}
/* ANIMATE UNIQUE SCANLINE */
@keyframes scanline {
0% {
transform: translate3d(0, 200000%, 0);
// bottom: 0%; // to have a continuous scanline move, use this line (here in 0% step) instead of transform and write, in &:before, { position: absolute; bottom: 100%; }
}
}
@keyframes scanlines {
0% {
background-position: 0 50%;
// bottom: 0%; // to have a continuous scanline move, use this line (here in 0% step) instead of transform and write, in &:before, { position: absolute; bottom: 100%; }
}
}

10
src/overrides.mjs Normal file
View File

@@ -0,0 +1,10 @@
// read overrides from environment variables
const OVERRIDES = {};
Object.entries(process.env).forEach(([key, value]) => {
if (key.match(/^OVERRIDE_/)) {
OVERRIDES[key.replace('OVERRIDE_', '')] = value;
}
});
export default OVERRIDES;

View File

@@ -37,16 +37,16 @@
}
},
"node_modules/@puppeteer/browsers": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.4.tgz",
"integrity": "sha512-9DxbZx+XGMNdjBynIs4BRSz+M3iRDeB7qRcAr6UORFLphCIM2x3DXgOucvADiifcqCE4XePFUKcnaAMyGbrDlQ==",
"version": "2.10.5",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.5.tgz",
"integrity": "sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.4.0",
"debug": "^4.4.1",
"extract-zip": "^2.0.1",
"progress": "^2.0.3",
"proxy-agent": "^6.5.0",
"semver": "^7.7.1",
"semver": "^7.7.2",
"tar-fs": "^3.0.8",
"yargs": "^17.7.2"
},
@@ -64,9 +64,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.18.tgz",
"integrity": "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==",
"version": "22.15.29",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz",
"integrity": "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -363,9 +363,9 @@
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1439962",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1439962.tgz",
"integrity": "sha512-jJF48UdryzKiWhJ1bLKr7BFWUQCEIT5uCNbDLqkQJBtkFxYzILJH44WN0PDKMIlGDN7Utb8vyUY85C3w4R/t2g==",
"version": "0.0.1452169",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1452169.tgz",
"integrity": "sha512-FOFDVMGrAUNp0dDKsAU1TorWJUx2JOU1k9xdgBKKJF3IBh/Uhl2yswG5r3TEAOrCiGY2QRp1e6LVDQrCsTKO4g==",
"license": "BSD-3-Clause"
},
"node_modules/emoji-regex": {
@@ -799,17 +799,17 @@
}
},
"node_modules/puppeteer": {
"version": "24.8.2",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.8.2.tgz",
"integrity": "sha512-Sn6SBPwJ6ASFvQ7knQkR+yG7pcmr4LfXzmoVp3NR0xXyBbPhJa8a8ybtb6fnw1g/DD/2t34//yirubVczko37w==",
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.10.0.tgz",
"integrity": "sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.10.4",
"@puppeteer/browsers": "2.10.5",
"chromium-bidi": "5.1.0",
"cosmiconfig": "^9.0.0",
"devtools-protocol": "0.0.1439962",
"puppeteer-core": "24.8.2",
"devtools-protocol": "0.0.1452169",
"puppeteer-core": "24.10.0",
"typed-query-selector": "^2.12.0"
},
"bin": {
@@ -820,15 +820,15 @@
}
},
"node_modules/puppeteer-core": {
"version": "24.8.2",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.8.2.tgz",
"integrity": "sha512-wNw5cRZOHiFibWc0vdYCYO92QuKTbJ8frXiUfOq/UGJWMqhPoBThTKkV+dJ99YyWfzJ2CfQQ4T1nhhR0h8FlVw==",
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.10.0.tgz",
"integrity": "sha512-xX0QJRc8t19iAwRDsAOR38Q/Zx/W6WVzJCEhKCAwp2XMsaWqfNtQ+rBfQW9PlF+Op24d7c8Zlgq9YNmbnA7hdQ==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.10.4",
"@puppeteer/browsers": "2.10.5",
"chromium-bidi": "5.1.0",
"debug": "^4.4.0",
"devtools-protocol": "0.0.1439962",
"debug": "^4.4.1",
"devtools-protocol": "0.0.1452169",
"typed-query-selector": "^2.12.0",
"ws": "^8.18.2"
},
@@ -960,9 +960,9 @@
}
},
"node_modules/tar-fs": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz",
"integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
@@ -1103,9 +1103,9 @@
}
},
"node_modules/zod": {
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
"version": "3.25.49",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.49.tgz",
"integrity": "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -18,15 +18,22 @@
<meta property="og:image" content="https://weatherstar.netbymatt.com/images/social/1200x600.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="627">
<link rel="prefetch" href="fonts/Star4000.woff" as="font" type="font/woff" crossorigin>
<link rel="prefetch" href="fonts/Star4000 Extended.woff" as="font" type="font/woff" crossorigin>
<link rel="prefetch" href="fonts/Star4000 Large.woff" as="font" type="font/woff" crossorigin>
<link rel="prefetch" href="fonts/Star4000 Small.woff" as="font" type="font/woff" crossorigin>
<% if (production) { %>
<link rel="stylesheet" type="text/css" href="resources/ws.min.css?_=<%=production%>" />
<script type="text/javascript" src="resources/data.min.js?_=<%=production%>"></script>
<script type="text/javascript" src="resources/vendor.min.js?_=<%=production%>"></script>
<script type="text/javascript" src="resources/ws.min.js?_=<%=production%>"></script>
<script type="text/javascript">const OVERRIDES=<%-JSON.stringify(OVERRIDES)%>;</script>
<% } else { %>
<link rel="stylesheet" type="text/css" href="styles/main.css" />
<script type="text/javascript" src="scripts/vendor/auto/nosleep.js"></script>
<!--<script type="text/javascript">const OVERRIDES={};</script>-->
<script type="text/javascript">OVERRIDES=<%-JSON.stringify(OVERRIDES)%>;</script>
<script type="text/javascript" src="scripts/vendor/auto/nosleep.js"></script>
<script type="text/javascript" src="scripts/vendor/auto/swiped-events.js"></script>
<script type="text/javascript" src="scripts/vendor/auto/suncalc.js"></script>
<script type="module" src="scripts/modules/hazards.mjs"></script>
@@ -138,6 +145,10 @@
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Mute" />
</div>
<div id="ToggleScanlines">
<img class="navButton off" src="images/nav/ic_scanlines_off_white_24dp_2x.png" title="Scan lines on" />
<img class="navButton on" src="images/nav/ic_scanlines_on_white_24dp_2x.png" title="Scan lines off" />
</div>
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_2x.png" title="Enter Fullscreen" />
</div>
</div>
@@ -175,6 +186,8 @@
Station Id: <span id="spanStationId"></span><br />
Radar Id: <span id="spanRadarId"></span><br />
Zone Id: <span id="spanZoneId"></span><br />
Music: <span id="musicTrack">Not playing</span><br />
Ws4kp Version: <span><%- version %></span>
</div>
</body>

View File

@@ -35,7 +35,6 @@
<div class="scroll-area">
<div class="frame template">
<div class="map">
<img src="images/maps/radar.jpg" />
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<%- include('header.ejs', {titleDual:{ top: 'Regional' , bottom: 'Observations' }, hasTime: true }) %>
<div class="main has-scroll regional-forecast">
<div class="map"><img src="images/maps/basemap.png" /></div>
<div class="map"><img src="images/maps/basemap.webp" /></div>
<div class="location-container">
<div class="location template">
<div class="icon">

View File

@@ -1,4 +1,4 @@
<%- include('header.ejs', {title: 'SPC Outlook', hasTime: true, noaaLogo: true}) %>
<%- include('header.ejs', {titleDual:{ top: 'Storm Prediction' , bottom: 'Center Outlook' }, hasTime: true}) %>
<div class="main has-scroll spc-outlook">
<div class="container">
<div class="risk-levels">