Compare commits

...

79 Commits

Author SHA1 Message Date
Matt Walsh
5adf74feee Merge branch 'main' into screen-enhance 2026-04-15 09:40:02 -05:00
Matt Walsh
6298068f2a use flexbox to put displays and settings side by side 2026-04-14 16:58:17 -05:00
Matt Walsh
62fbe1787f switch local forecast to mixed case for better readability and to match the latest captures from the late '00s 2026-04-14 08:52:36 -05:00
Matt Walsh
994c9240b8 shorten permalinks close #206 2026-04-13 16:19:26 -05:00
Matt Walsh
27d75ba62d current observations don't show like when the same close #207 2026-04-13 14:54:18 -05:00
Matt Walsh
63e089451d restructure wide/enhanced settings close #205 2026-04-13 14:36:15 -05:00
Matt Walsh
c07ebe8bdd 6.5.9 2026-04-09 12:19:03 -05:00
Matt Walsh
a41b0da196 more generalized fix for mapclick enhanced timestamps close #203
moves changes made in 0b47cf79c1 to the mapclick processing for benefit of other mapclick calls
2026-04-09 12:18:56 -05:00
Matt Walsh
30887202c8 6.5.8 2026-04-09 11:30:35 -05:00
Matt Walsh
38d1455a4b fix custom text scroll 2026-04-09 11:29:49 -05:00
Matt Walsh
30ec847ed5 Hide cursor in kiosk
via @iapetusz
2026-04-09 11:22:36 -05:00
Matt Walsh
42f1f66117 squeeze preview format into available space 2026-04-08 23:36:33 -05:00
Matt Walsh
d4f648f244 better 'version' numbers for staging site 2026-04-08 23:34:10 -05:00
Matt Walsh
71d52c0b72 fixe wide-enhanced radar and almanac backgrounds 2026-04-08 23:25:05 -05:00
Matt Walsh
d2bf8f3f99 fix radar tiles 2026-04-08 23:01:42 -05:00
Matt Walsh
f4289e6329 Merge branch 'main' into screen-enahnce/magic-number-code 2026-04-08 22:56:20 -05:00
Matt Walsh
11c54391b2 6.5.7 2026-04-08 22:42:07 -05:00
Matt Walsh
0b47cf79c1 don't overwrite timestamps when enhancing with mapclick 2026-04-08 22:41:42 -05:00
Matt Walsh
ba36904477 6.5.6 2026-04-08 11:39:36 -05:00
Matt Walsh
dae5b20bc6 fix radar round/floor mismatch in calculations close #200 2026-04-08 11:39:25 -05:00
Matt Walsh
ccc936d81a 6.5.5 2026-04-08 09:57:24 -05:00
Matt Walsh
5dc214c6a5 filter station list upon receipt 2026-04-08 09:57:18 -05:00
Matt Walsh
ec1169e07b redraw on entering/leaving enhanced close #198 2026-04-08 09:24:54 -05:00
Matt Walsh
eee4519095 almanac #188 #196 #194 #193 2026-04-07 14:17:26 -05:00
Matt Walsh
38cdb46c85 enhance current weather, latest observations #188 #196 #193 #194 2026-04-07 13:50:37 -05:00
Matt Walsh
e70639d7a6 local forecast enhanced-wide #188 #196 #193 #194 2026-04-07 10:38:12 -05:00
Matt Walsh
63d27d1a26 fix wide-enhanced radar tiles #188 #196 #193 #194 2026-04-06 16:53:11 -05:00
Matt Walsh
97ac0a1656 radar expanded, to do fix number of tiles 2026-04-06 16:41:03 -05:00
Matt Walsh
8158afd039 fix radar crop when wide 2026-04-06 00:48:38 -05:00
Matt Walsh
5fffc495ae regional map enhanced #188 #193 #196 2026-04-06 00:35:24 -05:00
Matt Walsh
b2a424a64f css constants cleanup 2026-04-06 00:06:01 -05:00
Matt Walsh
9f6b90919c hourly enhanced #188 #195 #196 #193 #194 2026-04-05 23:58:50 -05:00
Matt Walsh
778b7f4456 almanac, current weather, extended forecast, hazards #193 2026-04-04 11:36:31 -05:00
Matt Walsh
443114f555 Add new screen enhancement settings close #195 2026-04-04 11:09:21 -05:00
Matt Walsh
2a4dc03cf7 don't build screen-enhance images 2026-04-04 11:02:12 -05:00
Matt Walsh
8c13128005 add screen enhancement template 2026-04-04 10:45:31 -05:00
Matt Walsh
942fa8b817 6.5.4 2026-03-26 14:45:41 -05:00
Matt Walsh
15b68eba2f fix extended forecast day names close #190 2026-03-26 14:44:13 -05:00
Matt Walsh
933a289d03 update dependencies 2026-03-26 14:12:38 -05:00
Matt Walsh
e6121327ce 6.5.3 2026-03-16 12:31:36 -05:00
Matt Walsh
678f04fe42 fix extended forecast preload close #189 2026-03-16 12:31:27 -05:00
Matt Walsh
77592a08a3 update test dependencies 2026-03-10 09:33:04 -05:00
Matt Walsh
dadfcb8a5c readme spelling 2026-03-09 13:50:09 -05:00
Matt Walsh
245e9daf9c update eslint 2026-03-09 13:11:31 -05:00
Matt Walsh
177012317b update dependencies 2026-03-09 13:08:22 -05:00
Matt Walsh
7bd21bcf1d 6.5.2 2026-03-06 15:55:39 -06:00
Matt Walsh
ec65025ae2 search for 4-letter stations on regional maps close #187 2026-03-06 15:55:28 -06:00
Matt Walsh
194e108037 6.5.1 2026-03-01 10:04:02 -06:00
Matt Walsh
d5b7c6630a re-instate custom text scroll close #186 2026-03-01 10:03:52 -06:00
Matt Walsh
39bafae394 Merge pull request #185 from rmitchellscott/music-dist-1
fix: correctly use music mount when DIST=1. Fixes #174
2026-02-17 11:53:04 -06:00
Mitchell Scott
ec8fffbb64 fix: correctly use music mount when DIST=1. Fixes #174 2026-02-17 07:34:12 -07:00
Matt Walsh
0a794eae36 6.5.0 2026-02-16 21:30:23 -06:00
Matt Walsh
8c83736aba remove rss feeds close #184 2026-02-16 21:30:13 -06:00
Matt Walsh
872162080d 6.4.3 2026-02-16 20:40:25 -06:00
Matt Walsh
69d2b0f40b more robust backfilling of current weather properties close #177 2026-02-16 20:40:13 -06:00
Matt Walsh
37193112a7 6.4.2 2026-01-22 22:12:38 -06:00
Matt Walsh
0d9c445919 Fix hourly graph y axis labels close #176 2026-01-22 22:12:32 -06:00
Matt Walsh
6c9fb4cf68 6.4.1 2026-01-17 21:02:33 -06:00
Matt Walsh
59b10ae222 fix icon parsing close #175 2026-01-17 21:02:26 -06:00
Matt Walsh
d18b13821a 6.4.0 2026-01-17 11:42:42 -06:00
Matt Walsh
320d3139c3 add dewpoint to hourly and expand to 36 hours 2026-01-17 11:42:26 -06:00
Matt Walsh
34dedb44c1 6.3.3 2025-12-09 05:03:08 +00:00
Matt Walsh
18633708f9 Slightly denser display of regional cities on map #170 2025-12-09 05:02:58 +00:00
Matt Walsh
9b12255e0a Add image to README 2025-12-08 13:35:13 -06:00
Matt Walsh
f3360772c8 6.3.2 2025-11-30 04:16:09 +00:00
Matt Walsh
767bb8f11d gulp now publishes sourcemaps 2025-11-30 04:16:02 +00:00
Matt Walsh
7586dd7489 update dependencies 2025-11-27 17:32:37 +00:00
Matt Walsh
f37cbd66f7 make kiosk description in readme clearer #165 2025-11-17 17:44:13 -06:00
Matt Walsh
d00262ebbc update dependencies 2025-11-17 00:29:33 +00:00
Matt Walsh
b4646b128a update dependencies 2025-11-10 15:26:35 -06:00
Matt Walsh
9f78761fe8 phone app in readme #163 2025-11-10 13:19:15 -06:00
Matt Walsh
31c060c6d9 6.3.1 2025-11-10 13:01:35 -06:00
Matt Walsh
770f671d45 fix z-index of volume slider 2025-11-10 13:01:30 -06:00
Matt Walsh
da3fe3366c 6.3.0 2025-11-10 12:55:00 -06:00
Matt Walsh
6f97e3d2b9 add volume control slider and overhaul settings close #109 2025-11-10 12:54:54 -06:00
Matt Walsh
8255efd3f7 add version number to publish task 2025-11-10 04:52:13 +00:00
Matt Walsh
1c79b08228 correct environment variable escaping in readme 2025-11-10 04:33:42 +00:00
Matt Walsh
66a161762e 6.2.8 2025-11-10 04:06:31 +00:00
Matt Walsh
707b08ee1a backfill current conditions with the last few observations when needed close #158 2025-11-10 04:06:24 +00:00
79 changed files with 3234 additions and 1948 deletions

View File

@@ -0,0 +1,11 @@
---
name: Screen Enhancement
about: Items and tasks related to the screen enhancement project
title: '[Project]: '
labels: screen-enhance
projects: ['netbymatt/5']
assignees: ''
---
Describe the task, how it affects the overall project and what is considered complete.

View File

@@ -4,6 +4,8 @@ on:
push: push:
branches: branches:
- '**' - '**'
- '!screen-enhance'
- '!screen-enhance/**'
tags: tags:
- 'v*.*.*' - 'v*.*.*'
- 'v*.*' - 'v*.*'

View File

@@ -2,7 +2,7 @@
"liveSassCompile.settings.formats": [ "liveSassCompile.settings.formats": [
{ {
"format": "compressed", "format": "compressed",
"extensionName": ".css", "extensionName": ".min.css",
"savePath": "/server/styles", "savePath": "/server/styles",
} }
], ],
@@ -17,4 +17,4 @@
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
}, },
} }

View File

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

View File

@@ -1,3 +1,5 @@
![Weatherstar 4000+ Current Conditions](https://github.com/netbymatt/ws4kp/blob/main/server/images/social/1200x600.png)
# WeatherStar 4000+ # WeatherStar 4000+
A live version of this project is available at https://weatherstar.netbymatt.com A live version of this project is available at https://weatherstar.netbymatt.com
@@ -32,7 +34,7 @@ From a learning standpoint, this codebase make use of a lot of different methods
* Hand written CSS made easier to mange with SASS * Hand written CSS made easier to mange with SASS
* A linting library to keep code style consistent * A linting library to keep code style consistent
## Quck Start ## Quick Start
Ensure you have Node installed. Ensure you have Node installed.
```bash ```bash
@@ -136,9 +138,9 @@ services:
# Each argument in the permalink URL can become an environment variable on the Docker host by adding WSQS_ # 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 # 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. # more complete list of configuration options.
- WSQS_latLonQuery="Orlando International Airport Orlando FL USA" - WSQS_latLonQuery=Orlando International Airport Orlando FL USA
- WSQS_hazards_checkbox=false - WSQS_hazards=false
- WSQS_current_weather_checkbox=true - WSQS_current_weather=true
ports: ports:
- 8080:8080 # change the first 8080 to meet your local network needs - 8080:8080 # change the first 8080 to meet your local network needs
restart: unless-stopped restart: unless-stopped
@@ -190,17 +192,19 @@ Selected displays, the forecast city and widescreen setting are sticky from one
Your permalink will be very long. Here is an example for the Orlando International Airport: 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 https://weatherstar.netbymatt.com/?hazards=false&current-weather=true&latest-observations=true&hourly=false&hourly-graph=true&travel=false&regional-forecast=true&local-forecast=true&extended-forecast=true&almanac=false&spc-outlook=true&radar=true&wide=false&kiosk=false&scanLines=false&speed-select=1.00&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: 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/?latLonQuery=Orlando+International+Airport
https://weatherstar.netbymatt.com/?kiosk=true https://weatherstar.netbymatt.com/?kiosk=true
https://weatherstar.netbymatt.com/?settings-units-select=metric https://weatherstar.netbymatt.com/?units-select=metric
``` ```
### Kiosk mode ### 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. Kiosk mode can be activated by a checkbox on the page. This will start Weatherstar in a fullscreen-like view without the play/volume/etc toolbar and scaled to fill the entire space. This does not activate the browser's fullscreen or kiosk mode. Those can only be activated by user interaction or by launching the browser with specific parameters such as `--start-fullscreen` or `--kiosk`.
When using kiosk mode (via the checkbox), there will be no way to exit the fullscreen-like view of weatherstar. Reloading the page should remove the kiosk checkbox and return you to the normal view. 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. 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.
@@ -209,7 +213,7 @@ When serving this via the built-in Express server, it's possible to define envir
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 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, its matching environment variable becomes `WSQS_travel_checkbox=true`. 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, its matching environment variable becomes `WSQS_travel=true`.
When using the Docker container, these environment variables are read on container start-up to generate the static redirect HTML. When using the Docker container, these environment variables are read on container start-up to generate the static redirect HTML.
@@ -217,7 +221,13 @@ When using the Docker container, these environment variables are read on contain
**Speed:** Controls the playback speed multiplier of the displays, from "Very Fast" (1.5x) to "Very Slow" (0.5x) with "Normal" being 1x **Speed:** Controls the playback speed multiplier of the displays, from "Very Fast" (1.5x) to "Very Slow" (0.5x) with "Normal" being 1x
**Widescreen:** Stretches the background to 16:9 to avoid "pillarboxing" on modern displays **Display Mode:**
- Standard: Classic 4:3 display with the classic (not enhanced, below) screen layouts.
- Widescreen: Stretches the background to 16:9 to avoid "pillarboxing" on modern displays
- Widescreen Enhanced: Stretches as above, and makes use of the additional space to provide wider maps, more weather data and/or additional days in the forecast
- Portrait Enhanced: (in progress) Rotates the screen to a 16:9 portrait orientation and enhances the original displays by adjusting them to fit the new orientation.
**Kiosk:** Immediately activates kiosk mode, which hides all settings. Exit by refreshing the page or using `Ctrl-K`. (Kiosk mode is similar to clicking the "Fullscreen" icon, but scales to the current browser viewport instead of activating the browser's actual "Fullscreen" mode.) **Kiosk:** Immediately activates kiosk mode, which hides all settings. Exit by refreshing the page or using `Ctrl-K`. (Kiosk mode is similar to clicking the "Fullscreen" icon, but scales to the current browser viewport instead of activating the browser's actual "Fullscreen" mode.)
@@ -332,8 +342,10 @@ When using Docker:
* **Static deployment**: Mount your `custom.js` file to `/usr/share/nginx/html/scripts/custom.js` * **Static deployment**: Mount your `custom.js` file to `/usr/share/nginx/html/scripts/custom.js`
* **Server deployment**: Mount your `custom.js` file to `/app/server/scripts/custom.js` * **Server deployment**: Mount your `custom.js` file to `/app/server/scripts/custom.js`
### RSS feeds and custom scroll ### Custom text scroll
If you would like your Weatherstar to have custom scrolling text in the bottom blue bar, or show headlines from an rss feed turn on the setting for `Enable RSS Feed/Text` and then enter a URL or text in the resulting text box. Then press set. If you would like your Weatherstar to have custom scrolling text in the bottom blue bar, turn on the setting for `Enable RSS Feed/Text` and then enter text in the resulting text box. Then press set.
Tip: You can have Weatherstar select randomly between several text strings on each pass through the current conditions. Use a pipe character to separate string. `Welcome to Weatherstar|Thanks for watching`.
## Issue reporting and feature requests ## Issue reporting and feature requests
@@ -347,6 +359,14 @@ Note: not all units are converted to metric, if selected. Some text-based produc
This is a known problem with the Ws4kp as it ages. It was a problem with the [actual Weatherstar hardware](https://youtu.be/rcUwlZ4pqh0?feature=shared&t=116) as well. This is a known problem with the Ws4kp as it ages. It was a problem with the [actual Weatherstar hardware](https://youtu.be/rcUwlZ4pqh0?feature=shared&t=116) as well.
## Phone App
An Android app is in a closed beta test. It's nothing too special, just a wrapper for displaying the website in a browser.
You can get this functionality without an app on both Andriod and iOS by using the install or add to home screen feature of your browser.
iOS native app? No. I own zero Apple devices and thus have no way to develop, test, compile or verify myself to the app store. That application will have to come from the community.
## Related Projects ## Related Projects
Not retro enough? Try the [Weatherstar 3000+](https://github.com/netbymatt/ws3kp) Not retro enough? Try the [Weatherstar 3000+](https://github.com/netbymatt/ws3kp)

View File

@@ -729,6 +729,16 @@
"wfo": "LMK" "wfo": "LMK"
} }
}, },
{
"city": "Lubbock",
"lat": 33.5836,
"lon": -101.8549,
"point": {
"x": 49,
"y": 34,
"wfo": "LUB"
}
},
{ {
"city": "Manchester", "city": "Manchester",
"lat": 42.9956, "lat": 42.9956,

View File

@@ -364,6 +364,11 @@
"lat": 38.2542, "lat": 38.2542,
"lon": -85.7594 "lon": -85.7594
}, },
{
"city": "Lubbock",
"lat": 33.5836,
"lon": -101.8549
},
{ {
"city": "Manchester", "city": "Manchester",
"lat": 42.9956, "lat": 42.9956,

View File

@@ -8,13 +8,11 @@ import states from './stations-states.mjs';
import chunk from './chunk.mjs'; import chunk from './chunk.mjs';
import overrides from './stations-overrides.mjs'; import overrides from './stations-overrides.mjs';
import postProcessor from './stations-postprocessor.mjs'; import postProcessor from './stations-postprocessor.mjs';
import { stationFilter } from '../server/scripts/modules/utils/string.mjs';
// check for cached flag // check for cached flag
const USE_CACHE = process.argv.includes('--use-cache'); const USE_CACHE = process.argv.includes('--use-cache');
// skip stations starting with these letters
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
// chunk the list of states // chunk the list of states
const chunkStates = chunk(states, 3); const chunkStates = chunk(states, 3);
@@ -41,10 +39,8 @@ if (!USE_CACHE) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const stationsRaw = await https(next); const stationsRaw = await https(next);
stations = JSON.parse(stationsRaw); stations = JSON.parse(stationsRaw);
// filter stations for 4 letter identifiers
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
// filter against starting letter // filter against starting letter
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1))); const stationsFiltered = stations.filter(stationFilter);
// add each resulting station to the output // add each resulting station to the output
stationsFiltered.forEach((station) => { stationsFiltered.forEach((station) => {
const id = station.properties.stationIdentifier; const id = station.properties.stationIdentifier;

View File

@@ -14,11 +14,18 @@ import TerserPlugin from 'terser-webpack-plugin';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import file from 'gulp-file'; import file from 'gulp-file';
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront'; import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
import log from 'fancy-log';
import * as dartSass from 'sass';
import gulpSass from 'gulp-sass';
import sourceMaps from 'gulp-sourcemaps';
import OVERRIDES from '../src/overrides.mjs'; import OVERRIDES from '../src/overrides.mjs';
import { DateTime } from 'luxon';
// get cloudfront // get cloudfront
import reader from '../src/playlist-reader.mjs'; import reader from '../src/playlist-reader.mjs';
const sass = gulpSass(dartSass);
const clean = () => deleteAsync(['./dist/**/*', '!./dist/readme.txt']); const clean = () => deleteAsync(['./dist/**/*', '!./dist/readme.txt']);
const cloudfront = new CloudFrontClient({ region: 'us-east-1' }); const cloudfront = new CloudFrontClient({ region: 'us-east-1' });
@@ -35,6 +42,7 @@ const webpackOptions = {
resolve: { resolve: {
roots: ['./'], roots: ['./'],
}, },
devtool: 'source-map',
optimization: { optimization: {
minimize: true, minimize: true,
minimizer: [ minimizer: [
@@ -79,7 +87,7 @@ const mjsSources = [
'server/scripts/modules/travelforecast.mjs', 'server/scripts/modules/travelforecast.mjs',
'server/scripts/modules/progress.mjs', 'server/scripts/modules/progress.mjs',
'server/scripts/modules/media.mjs', 'server/scripts/modules/media.mjs',
'server/scripts/modules/custom-rss-feed.mjs', 'server/scripts/modules/custom-scroll-text.mjs',
'server/scripts/index.mjs', 'server/scripts/index.mjs',
]; ];
@@ -88,10 +96,13 @@ const buildJs = () => src(mjsSources)
.pipe(dest(RESOURCES_PATH)); .pipe(dest(RESOURCES_PATH));
const cssSources = [ const cssSources = [
'server/styles/main.css', 'server/styles/scss/**/*.scss',
]; ];
const copyCss = () => src(cssSources) const buildCss = () => src(cssSources)
.pipe(concat('ws.min.css')) .pipe(sourceMaps.init())
.pipe(sass({ style: 'compressed' }).on('error', sass.logError))
.pipe(rename({ suffix: '.min' }))
.pipe(sourceMaps.write('./'))
.pipe(dest(RESOURCES_PATH)); .pipe(dest(RESOURCES_PATH));
const htmlSources = [ const htmlSources = [
@@ -100,10 +111,9 @@ const htmlSources = [
const packageJson = await readFile('package.json'); const packageJson = await readFile('package.json');
let { version } = JSON.parse(packageJson); let { version } = JSON.parse(packageJson);
const previewVersion = async () => { const previewVersion = async () => {
// generate a relatively unique timestamp for cache invalidation of the preview site // generate a unique timestamp for cache invalidation of the preview site
const now = new Date(); const now = DateTime.utc();
const msNow = now.getTime() % 1_000_000; version = now.toFormat('yyyyLLddHHmm').substring(3);
version = msNow.toString();
}; };
const compressHtml = async () => src(htmlSources) const compressHtml = async () => src(htmlSources)
@@ -140,7 +150,6 @@ const s3 = s3Upload({
}); });
const uploadSources = [ const uploadSources = [
'dist/**', 'dist/**',
'!dist/**/*.map',
'!dist/images/**/*', '!dist/images/**/*',
'!dist/fonts/**/*', '!dist/fonts/**/*',
]; ];
@@ -204,12 +213,16 @@ const buildPlaylist = async () => {
return file('playlist.json', JSON.stringify(playlist)).pipe(dest('./dist')); return file('playlist.json', JSON.stringify(playlist)).pipe(dest('./dist'));
}; };
const buildDist = series(clean, parallel(buildJs, compressJsVendor, copyCss, compressHtml, copyOtherFiles, copyDataFiles, copyImageSources, buildPlaylist)); const logVersion = async () => {
log(`Version Published: ${version}`);
};
const buildDist = series(clean, parallel(buildJs, compressJsVendor, buildCss, compressHtml, copyOtherFiles, copyDataFiles, copyImageSources, buildPlaylist));
// upload_images could be in parallel with upload, but _images logs a lot and has little changes // upload_images could be in parallel with upload, but _images logs a lot and has little changes
// by running upload last the majority of the changes will be at the bottom of the log for easy viewing // by running upload last the majority of the changes will be at the bottom of the log for easy viewing
const publishFrontend = series(buildDist, uploadImages, upload, invalidate); const publishFrontend = series(buildDist, uploadImages, upload, invalidate, logVersion);
const stageFrontend = series(previewVersion, buildDist, uploadImagesPreview, uploadPreview, invalidatePreview); const stageFrontend = series(previewVersion, buildDist, uploadImagesPreview, uploadPreview, invalidatePreview, logVersion);
export default publishFrontend; export default publishFrontend;

View File

@@ -158,6 +158,7 @@ if (process.env?.DIST === '1') {
// 'npm run build' and then 'DIST=1 npm start' // 'npm run build' and then 'DIST=1 npm start'
app.use('/scripts', express.static('./server/scripts', staticOptions)); app.use('/scripts', express.static('./server/scripts', staticOptions));
app.use('/geoip', geoip); app.use('/geoip', geoip);
app.use('/music', express.static('./server/music', staticOptions));
// render the EJS template in production mode (serve compressed files from dist directory) // render the EJS template in production mode (serve compressed files from dist directory)
app.get('/', (req, res) => { renderIndex(req, res, true); }); app.get('/', (req, res) => { renderIndex(req, res, true); });

3000
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "ws4kp", "name": "ws4kp",
"version": "6.2.7", "version": "6.5.9",
"description": "Welcome to the WeatherStar 4000+ project page!", "description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -31,32 +31,34 @@
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"del": "^8.0.0", "del": "^8.0.0",
"eslint": "^9.0.0", "eslint": "^10.0.3",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-plugin-import": "^2.10.0", "eslint-plugin-import": "^2.10.0",
"fancy-log": "^2.0.0",
"gulp": "^5.0.0", "gulp": "^5.0.0",
"gulp-awspublish": "^8.0.0", "gulp-awspublish": "^9.0.0",
"gulp-concat": "^2.6.1", "gulp-concat": "^2.6.1",
"gulp-ejs": "^5.1.0", "gulp-ejs": "^5.1.0",
"gulp-file": "^0.4.0", "gulp-file": "^0.4.0",
"gulp-html-minifier-terser": "^7.1.0", "gulp-html-minifier-terser": "^8.0.0",
"gulp-rename": "^2.0.0", "gulp-rename": "^2.0.0",
"gulp-s3-uploader": "^1.0.6", "gulp-s3-uploader": "^1.0.6",
"gulp-sass": "^6.0.0", "gulp-sass": "^6.0.0",
"gulp-sourcemaps": "^3.0.0",
"gulp-terser": "^2.0.0", "gulp-terser": "^2.0.0",
"luxon": "^3.0.0", "luxon": "^3.0.0",
"metar-taf-parser": "^9.0.0",
"nosleep.js": "^0.12.0", "nosleep.js": "^0.12.0",
"sass": "^1.54.0", "sass": "^1.54.0",
"suncalc": "^1.8.0", "suncalc": "^1.8.0",
"swiped-events": "^1.1.4", "swiped-events": "^1.1.4",
"terser-webpack-plugin": "^5.3.6", "terser-webpack-plugin": "^5.3.6",
"webpack": "^5.99.9", "webpack": "^5.99.9",
"webpack-stream": "^7.0.0", "webpack-stream": "^7.0.0"
"metar-taf-parser": "^9.0.0"
}, },
"dependencies": { "dependencies": {
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"ejs": "^3.1.5", "ejs": "^5.0.1",
"express": "^5.1.0" "express": "^5.1.0"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Binary file not shown.

BIN
server/images/gimp/1.xcf Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -4,11 +4,12 @@ import {
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived, isIOS, message as navMessage, isPlaying, resize, resetStatuses, latLonReceived, isIOS,
} from './modules/navigation.mjs'; } from './modules/navigation.mjs';
import { round2 } from './modules/utils/units.mjs'; import { round2 } from './modules/utils/units.mjs';
import { parseQueryString } from './modules/share.mjs'; import { registerHiddenSetting } from './modules/share.mjs';
import settings from './modules/settings.mjs'; import settings from './modules/settings.mjs';
import AutoComplete from './modules/autocomplete.mjs'; import AutoComplete from './modules/autocomplete.mjs';
import { loadAllData } from './modules/utils/data-loader.mjs'; import { loadAllData } from './modules/utils/data-loader.mjs';
import { debugFlag } from './modules/utils/debug.mjs'; import { debugFlag } from './modules/utils/debug.mjs';
import { parseQueryString } from './modules/utils/setting.mjs';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
init(); init();
@@ -71,7 +72,7 @@ const init = async () => {
if (!navigator.geolocation) btnGetGps.style.display = 'none'; if (!navigator.geolocation) btnGetGps.style.display = 'none';
document.querySelector('#divTwc').addEventListener('mousemove', () => { document.querySelector('#divTwc').addEventListener('mousemove', () => {
if (document.fullscreenElement) updateFullScreenNavigate(); if (document.fullscreenElement || settings.kiosk?.value) updateFullScreenNavigate();
}); });
document.querySelector('#btnGetLatLng').addEventListener('click', () => autoComplete.directFormSubmit()); document.querySelector('#btnGetLatLng').addEventListener('click', () => autoComplete.directFormSubmit());
@@ -131,7 +132,7 @@ const init = async () => {
const { lat, lon } = JSON.parse(latLon); const { lat, lon } = JSON.parse(latLon);
getForecastFromLatLon(lat, lon, true); getForecastFromLatLon(lat, lon, true);
} else { } else {
// otherwise use pre-stored data // otherwise use pre-stored data
loadData(JSON.parse(latLon)); loadData(JSON.parse(latLon));
} }
} }
@@ -140,7 +141,7 @@ const init = async () => {
} }
// Handle kiosk mode initialization // Handle kiosk mode initialization
const urlKioskCheckbox = parsedParameters['settings-kiosk-checkbox']; const urlKioskCheckbox = parsedParameters?.kiosk ?? parsedParameters['settings-kiosk-checkbox'];
// If kiosk=false is specified, disable kiosk mode and clear any stored value // If kiosk=false is specified, disable kiosk mode and clear any stored value
if (urlKioskCheckbox === 'false') { if (urlKioskCheckbox === 'false') {
@@ -177,6 +178,10 @@ const init = async () => {
// swipe functionality // swipe functionality
document.querySelector('#container').addEventListener('swiped-left', () => swipeCallBack('left')); document.querySelector('#container').addEventListener('swiped-left', () => swipeCallBack('left'));
document.querySelector('#container').addEventListener('swiped-right', () => swipeCallBack('right')); document.querySelector('#container').addEventListener('swiped-right', () => swipeCallBack('right'));
// register hidden settings for search and location query
registerHiddenSetting('latLonQuery', () => localStorage.getItem('latLonQuery'));
registerHiddenSetting('latLon', () => localStorage.getItem('latLon'));
}; };
const geocodeLatLonQuery = async (query) => { const geocodeLatLonQuery = async (query) => {
@@ -379,7 +384,7 @@ const updateFullScreenNavigate = () => {
} }
navigateFadeIntervalId = setTimeout(() => { navigateFadeIntervalId = setTimeout(() => {
if (document.fullscreenElement) { if (document.fullscreenElement || settings.kiosk?.value) {
divTwcBottom.classList.remove('visible'); divTwcBottom.classList.remove('visible');
divTwcBottom.classList.add('hidden'); divTwcBottom.classList.add('hidden');
document.querySelector('#divTwc').classList.add('no-cursor'); document.querySelector('#divTwc').classList.add('no-cursor');

View File

@@ -47,10 +47,7 @@ class Almanac extends WeatherDisplay {
} }
calcSunMoonData(weatherParameters) { calcSunMoonData(weatherParameters) {
const sun = [ const sun = [0, 1, 2, 3, 4, 5, 6].map((days) => SunCalc.getTimes(DateTime.local().plus({ days }).toJSDate(), weatherParameters.latitude, weatherParameters.longitude));
SunCalc.getTimes(new Date(), weatherParameters.latitude, weatherParameters.longitude),
SunCalc.getTimes(DateTime.local().plus({ days: 1 }).toJSDate(), weatherParameters.latitude, weatherParameters.longitude),
];
// brute force the moon phases by scanning the next 30 days // brute force the moon phases by scanning the next 30 days
const moon = []; const moon = [];
@@ -72,7 +69,7 @@ class Almanac extends WeatherDisplay {
// stop after 30 days or 4 moon phases // stop after 30 days or 4 moon phases
iterations += 1; iterations += 1;
} while (iterations <= 30 && moon.length < 4); } while (iterations <= 45 && moon.length < 5);
return { return {
sun, sun,
@@ -126,21 +123,16 @@ class Almanac extends WeatherDisplay {
// Set day names // Set day names
const Today = DateTime.local(); const Today = DateTime.local();
const Tomorrow = Today.plus({ days: 1 }); // fill all three days, even if some are hidden by non-enhanced
this.elem.querySelector('.day-1').textContent = Today.toLocaleString({ weekday: 'long' }); for (let i = 0; i < 3; i += 1) {
this.elem.querySelector('.day-2').textContent = Tomorrow.toLocaleString({ weekday: 'long' }); this.elem.querySelector(`.day-${i}`).textContent = Today.plus({ days: i }).toLocaleString({ weekday: 'long' });
const todaySunrise = DateTime.fromJSDate(info.sun[0].sunrise); const sunrise = DateTime.fromJSDate(info.sun[i].sunrise);
const todaySunset = DateTime.fromJSDate(info.sun[0].sunset); const sunset = DateTime.fromJSDate(info.sun[i].sunset);
const [todaySunriseFormatted, todaySunsetFormatted] = formatTimesForColumn([todaySunrise, todaySunset]); const [sunriseFormatted, sunsetFormatted] = formatTimesForColumn([sunrise, sunset]);
this.elem.querySelector('.rise-1').textContent = todaySunriseFormatted; this.elem.querySelector(`.rise-${i}`).textContent = sunriseFormatted;
this.elem.querySelector('.set-1').textContent = todaySunsetFormatted; this.elem.querySelector(`.set-${i}`).textContent = sunsetFormatted;
}
const tomorrowSunrise = DateTime.fromJSDate(info.sun[1].sunrise);
const tomorrowSunset = DateTime.fromJSDate(info.sun[1].sunset);
const [tomorrowSunriseFormatted, tomorrowSunsetformatted] = formatTimesForColumn([tomorrowSunrise, tomorrowSunset]);
this.elem.querySelector('.rise-2').textContent = tomorrowSunriseFormatted;
this.elem.querySelector('.set-2').textContent = tomorrowSunsetformatted;
// Moon data // Moon data
const days = info.moon.map((MoonPhase) => { const days = info.moon.map((MoonPhase) => {

View File

@@ -13,9 +13,8 @@ import {
} from './utils/units.mjs'; } from './utils/units.mjs';
import { debugFlag } from './utils/debug.mjs'; import { debugFlag } from './utils/debug.mjs';
import { isDataStale, enhanceObservationWithMapClick } from './utils/mapclick.mjs'; import { isDataStale, enhanceObservationWithMapClick } from './utils/mapclick.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
// some stations prefixed do not provide all the necessary data import settings from './settings.mjs';
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
class CurrentWeather extends WeatherDisplay { class CurrentWeather extends WeatherDisplay {
constructor(navId, elemId) { constructor(navId, elemId) {
@@ -28,8 +27,8 @@ class CurrentWeather extends WeatherDisplay {
// note: current weather does not use old data on a silent refresh // note: current weather does not use old data on a silent refresh
// this is deliberate because it can pull data from more than one station in sequence // this is deliberate because it can pull data from more than one station in sequence
// filter for 4-letter observation stations, only those contain sky conditions and thus an icon // get the available stations
const filteredStations = this.weatherParameters.stations.filter((station) => station?.properties?.stationIdentifier?.length === 4 && !skipStations.includes(station.properties.stationIdentifier.slice(0, 1))); const { stations } = this.weatherParameters;
// Load the observations // Load the observations
let observations; let observations;
@@ -37,9 +36,9 @@ class CurrentWeather extends WeatherDisplay {
// station number counter // station number counter
let stationNum = 0; let stationNum = 0;
while (!observations && stationNum < filteredStations.length) { while (!observations && stationNum < stations.length) {
// get the station // get the station
station = filteredStations[stationNum]; station = stations[stationNum];
const stationId = station.properties.stationIdentifier; const stationId = station.properties.stationIdentifier;
stationNum += 1; stationNum += 1;
@@ -49,7 +48,7 @@ class CurrentWeather extends WeatherDisplay {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
candidateObservation = await safeJson(`${station.id}/observations`, { candidateObservation = await safeJson(`${station.id}/observations`, {
data: { data: {
limit: 2, // we need the two most recent observations to calculate pressure direction limit: 5, // we need the two most recent observations to calculate pressure direction, and to back fill any missing data
}, },
retryCount: 3, retryCount: 3,
stillWaiting: () => this.stillWaiting(), stillWaiting: () => this.stillWaiting(),
@@ -103,7 +102,10 @@ class CurrentWeather extends WeatherDisplay {
debugContext: 'currentweather', debugContext: 'currentweather',
}); });
// copy enhanced data and restore the timestamp if it was overwritten by older data from mapclick
candidateObservation.features[0].properties = enhancedResult.data; candidateObservation.features[0].properties = enhancedResult.data;
const { missingFields } = enhancedResult; const { missingFields } = enhancedResult;
const missingRequired = missingFields.filter((fieldName) => { const missingRequired = missingFields.filter((fieldName) => {
const field = requiredFields.find((f) => f.name === fieldName && f.required); const field = requiredFields.find((f) => f.name === fieldName && f.required);
@@ -191,7 +193,9 @@ class CurrentWeather extends WeatherDisplay {
const wind = (typeof this.data.WindSpeed === 'number') ? this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' ') : this.data.WindSpeed; const wind = (typeof this.data.WindSpeed === 'number') ? this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' ') : this.data.WindSpeed;
// get location (city name) from StationInfo if available (allows for overrides) // get location (city name) from StationInfo if available (allows for overrides)
const location = (StationInfo[this.data.station.properties.stationIdentifier]?.city ?? locationCleanup(this.data.station.properties.name)).substr(0, 20); // longer name allowed if in wide-enhanced
const locationLimit = (settings.wide?.value && settings.enhanced?.value) ? 25 : 20;
const location = (StationInfo[this.data.station.properties.stationIdentifier]?.city ?? locationCleanup(this.data.station.properties.name)).substr(0, locationLimit);
const fill = { const fill = {
temp: this.data.Temperature + String.fromCharCode(176), temp: this.data.Temperature + String.fromCharCode(176),
@@ -266,7 +270,7 @@ const parseData = (data) => {
const kilometersConverter = distanceKilometers(); const kilometersConverter = distanceKilometers();
const pressureConverter = pressure(); const pressureConverter = pressure();
const observations = data.features[0].properties; const observations = backfill(data.features);
// values from api are provided in metric // values from api are provided in metric
data.observations = observations; data.observations = observations;
data.Temperature = temperatureConverter(observations.temperature.value); data.Temperature = temperatureConverter(observations.temperature.value);
@@ -306,6 +310,46 @@ const parseData = (data) => {
return data; return data;
}; };
// default to the latest data in the provided observations, but use older data if something is missing
const backfill = (data) => {
// make easy to use timestamps
const sortedData = data.map((observation) => {
observation.timestamp = DateTime.fromISO(observation.properties.timestamp);
return observation;
});
// sort by timestamp with [0] being the earliest
sortedData.sort((a, b) => b.timestamp - a.timestamp);
// create the result data
const result = {};
// backfill each property
Object.keys(sortedData[0].properties).forEach((key) => {
// qualify the key (must have value)
if (Object.hasOwn(sortedData[0].properties?.[key] ?? {}, 'value')) {
// backfill this property
result[key] = backfillProperty(sortedData, key);
} else {
// use the property as is
result[key] = sortedData[0].properties[key];
}
});
return result;
};
// return the property with a value closest to the [0] index
// reduce returns the first non-null value in the array
const backfillProperty = (data, key) => data.reduce(
(prev, cur) => {
const curValue = cur.properties?.[key]?.value;
if (prev.value === null && curValue !== null && curValue !== undefined) return cur.properties[key];
return prev;
},
{ value: null }, // null is the default provided by the api
);
const display = new CurrentWeather(1, 'current-weather'); const display = new CurrentWeather(1, 'current-weather');
registerDisplay(display); registerDisplay(display);

View File

@@ -261,6 +261,7 @@ const parseMessage = (event) => {
if (event?.data?.type === 'current-weather-scroll') { if (event?.data?.type === 'current-weather-scroll') {
if (event.data?.method === 'start') start(); if (event.data?.method === 'start') start();
if (event.data?.method === 'reload') stop(true); if (event.data?.method === 'reload') stop(true);
if (event.data?.method === 'non-display') nonDisplay();
if (event.data?.method === 'show') show(); if (event.data?.method === 'show') show();
if (event.data?.method === 'hide') hide(); if (event.data?.method === 'hide') hide();
} }
@@ -274,6 +275,20 @@ const hide = () => {
mainScroll.style.display = 'none'; mainScroll.style.display = 'none';
}; };
const nonDisplay = () => {
if (interval) {
clearInterval(interval);
interval = null;
stop();
// if greater than default update (typically long scroll) skip to the next weather screen
if (nextUpdate > DEFAULT_UPDATE) {
screenIndex = (screenIndex + 1) % (workingScreens.length);
sinceLastUpdate = 0;
nextUpdate = DEFAULT_UPDATE;
}
}
};
const screenCount = () => workingScreens.length; const screenCount = () => workingScreens.length;
const atDefault = () => defaultScreensLoaded; const atDefault = () => defaultScreensLoaded;

View File

@@ -1,135 +0,0 @@
import Setting from './utils/setting.mjs';
import { reset as resetScroll, addScreen as addScroll, hazards } from './currentweatherscroll.mjs';
import { json } from './utils/fetch.mjs';
let firstRun = true;
const parser = new DOMParser();
// change of enable handler
const changeEnable = (newValue) => {
let newDisplay;
if (newValue) {
// add the feed to the scroll
parseFeed(customFeed.value);
// show the string box
newDisplay = 'block';
} else {
// set scroll back to original
resetScroll();
// hide the string entry
newDisplay = 'none';
}
const stringEntry = document.getElementById('settings-customFeed-label');
if (stringEntry) {
stringEntry.style.display = newDisplay;
}
};
// parse the feed/text provided
const parseFeed = (textInput) => {
// skip getting the feed on first run
if (firstRun) return;
// test validity
if (textInput === undefined || textInput === '') {
resetScroll();
}
// test for url
if (textInput.match(/https?:\/\//)) {
getFeed(textInput);
return;
}
// add single text scroll after hazards if present
resetScroll();
addScroll(hazards);
addScroll(
() => (
{
type: 'scroll',
text: textInput,
}),
// keep the existing scroll
true,
);
};
// get the rss feed and then swap out the current weather scroll
const getFeed = async (url) => {
// get the text as a string
// it needs to be proxied, use a free service
const rssResponse = await json(`https://api.allorigins.win/get?url=${url}`);
// this returns a data url
// a few sanity checks
if (rssResponse.status.content_type.indexOf('xml') < 0) return;
// determine return type
const isBase64 = rssResponse.status.content_type.substring(0, 8) !== 'text/xml';
// base 64 decode everything after the comma
const rss = isBase64 ? atob(rssResponse.contents.split('base64,')[1]) : rssResponse.contents;
// parse the rss
const doc = parser.parseFromString(rss, 'text/xml');
// get the title
const rssTitle = doc.querySelector('channel title').textContent;
// get each item
const titles = [...doc.querySelectorAll('item title')].map((t) => t.textContent);
// reset the scroll, then add the screens
resetScroll();
// add the hazards scroll first
addScroll(hazards);
titles.forEach((title) => {
// data is provided to the screen handler, so we return a function
addScroll(
() => ({
header: rssTitle,
type: 'scroll',
text: title,
}),
// false parameter does not include the default weather scrolls
false,
);
});
};
// change the feed source and re-load if necessary
const changeFeed = (newValue) => {
// first pass through won't have custom feed enable ready
if (firstRun) return;
if (customFeedEnable.value) {
parseFeed(newValue);
}
};
const customFeed = new Setting('customFeed', {
name: 'Custom RSS Feed',
defaultValue: '',
type: 'string',
changeAction: changeFeed,
placeholder: 'Text or URL',
});
const customFeedEnable = new Setting('customFeedEnable', {
name: 'Enable RSS Feed/Text',
defaultValue: false,
changeAction: changeEnable,
});
// initialize the custom feed inputs on the page
document.addEventListener('DOMContentLoaded', () => {
// add the controls to the page
const settingsSection = document.querySelector('#settings');
settingsSection.append(customFeedEnable.generate(), customFeed.generate());
// clear the first run value
firstRun = false;
// call change enable with the current value to show/hide the url box
// and make the call to get the feed if enabled
changeEnable(customFeedEnable.value);
});

View File

@@ -0,0 +1,89 @@
import Setting from './utils/setting.mjs';
import { reset as resetScroll, addScreen as addScroll, hazards } from './currentweatherscroll.mjs';
let firstRun = true;
// change of enable handler
const changeEnable = (newValue) => {
let newDisplay;
if (newValue) {
// add the text to the scroll
parseText(customText.value);
// show the string box
newDisplay = 'block';
} else {
// set scroll back to original
resetScroll();
// hide the string entry
newDisplay = 'none';
}
const stringEntry = document.getElementById('settings-customText-string');
if (stringEntry) {
stringEntry.style.display = newDisplay;
}
};
// parse the text provided
const parseText = (textInput) => {
// skip updating text on first run
if (firstRun) return;
// test validity
if (textInput === undefined || textInput === '') {
resetScroll();
}
// split the text at pipe characters
const texts = textInput.split('|');
// add single text scroll after hazards if present
resetScroll();
addScroll(hazards);
addScroll(
() => {
// pick a random string from the available list
const randInt = Math.floor(Math.random() * texts.length);
return {
type: 'scroll',
text: texts[randInt],
};
},
// keep the existing scroll
true,
);
};
// change the text
const changeText = (newValue) => {
// first pass through won't have custom text enable ready
if (firstRun) return;
if (customTextEnable.value) {
parseText(newValue);
}
};
const customText = new Setting('customText', {
name: 'Custom Text',
defaultValue: '',
type: 'string',
changeAction: changeText,
placeholder: 'Text to scroll',
});
const customTextEnable = new Setting('customTextEnable', {
name: 'Enable Custom Text',
defaultValue: false,
changeAction: changeEnable,
});
// initialize the custom text inputs on the page
document.addEventListener('DOMContentLoaded', () => {
// add the controls to the page
const settingsSection = document.querySelector('#settings');
settingsSection.append(customTextEnable.generate(), customText.generate());
// clear the first run value
firstRun = false;
// call change enable with the current value to show/hide the url box
changeEnable(customTextEnable.value);
});

View File

@@ -17,7 +17,13 @@ class ExtendedForecast extends WeatherDisplay {
super(navId, elemId, 'Extended Forecast', true); super(navId, elemId, 'Extended Forecast', true);
// set timings // set timings
this.timing.totalScreens = 2; if (settings.portrait?.value) {
this.timing.totalScreens = 1;
this.perPage = 4;
} else {
this.timing.totalScreens = 2;
this.perPage = 3;
}
} }
async getData(weatherParameters, refresh) { async getData(weatherParameters, refresh) {
@@ -54,7 +60,7 @@ class ExtendedForecast extends WeatherDisplay {
// determine bounds // determine bounds
// grab the first three or second set of three array elements // grab the first three or second set of three array elements
const forecast = parse(this.data.properties.periods, this.weatherParameters.forecast).slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3); const forecast = parse(this.data.properties.periods, this.weatherParameters.forecast).slice(0 + this.perPage * this.screenIndex, this.perPage + this.screenIndex * this.perPage);
// create each day template // create each day template
const days = forecast.map((Day) => { const days = forecast.map((Day) => {
@@ -97,11 +103,9 @@ const parse = (fullForecast, forecastUrl) => {
// Skip the first period if it's nighttime (like "Tonight") since extended forecast // Skip the first period if it's nighttime (like "Tonight") since extended forecast
// should focus on upcoming full days, not the end of the current day // should focus on upcoming full days, not the end of the current day
let startIndex = 0; let startIndex = 0;
let dateOffset = 0; // offset for date labels when we skip periods
if (activePeriods.length > 0 && !activePeriods[0].isDaytime) { if (activePeriods.length > 0 && !activePeriods[0].isDaytime) {
startIndex = 1; startIndex = 1;
dateOffset = 1; // start date labels from tomorrow since we're skipping tonight
if (debugFlag('extendedforecast')) { if (debugFlag('extendedforecast')) {
console.log(`ExtendedForecast: Skipping first period "${activePeriods[0].name}" because it's nighttime`); console.log(`ExtendedForecast: Skipping first period "${activePeriods[0].name}" because it's nighttime`);
} }
@@ -111,25 +115,14 @@ const parse = (fullForecast, forecastUrl) => {
} }
} }
// create a list of days starting with the appropriate day
const Days = [0, 1, 2, 3, 4, 5, 6];
const dates = Days.map((shift) => {
const date = DateTime.local().startOf('day').plus({ days: shift + dateOffset });
return date.toLocaleString({ weekday: 'short' });
});
if (debugFlag('extendedforecast')) {
console.log(`ExtendedForecast: Generated date labels: [${dates.join(', ')}]`);
}
// track the destination forecast index // track the destination forecast index
let destIndex = 0; let destIndex = 0;
const forecast = []; const forecast = [];
// if the first period is nighttime it is skipped above via startIndex
for (let i = startIndex; i < activePeriods.length; i += 1) { for (let i = startIndex; i < activePeriods.length; i += 1) {
const period = activePeriods[i]; const period = activePeriods[i];
// create the destination object if necessary
if (!forecast[destIndex]) { if (!forecast[destIndex]) {
forecast.push({ forecast.push({
dayName: '', low: undefined, high: undefined, text: undefined, icon: undefined, dayName: '', low: undefined, high: undefined, text: undefined, icon: undefined,
@@ -138,15 +131,14 @@ const parse = (fullForecast, forecastUrl) => {
// get the object to modify/populate // get the object to modify/populate
const fDay = forecast[destIndex]; const fDay = forecast[destIndex];
// preload the icon
preloadImg(fDay.icon);
if (period.isDaytime) { if (period.isDaytime) {
// day time is the high temperature // day time is the high temperature
fDay.high = period.temperature; fDay.high = period.temperature;
fDay.icon = getLargeIcon(period.icon); fDay.icon = getLargeIcon(period.icon);
fDay.text = shortenExtendedForecastText(period.shortForecast); fDay.text = shortenExtendedForecastText(period.shortForecast);
fDay.dayName = dates[destIndex]; fDay.dayName = DateTime.fromISO(period.startTime).startOf('day').toLocaleString({ weekday: 'short' });
// preload the icon
preloadImg(fDay.icon);
// Wait for the corresponding night period to increment // Wait for the corresponding night period to increment
} else { } else {
// low temperature // low temperature

View File

@@ -5,10 +5,30 @@ import getHourlyData from './hourly.mjs';
import WeatherDisplay from './weatherdisplay.mjs'; import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay, timeZone } from './navigation.mjs'; import { registerDisplay, timeZone } from './navigation.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs'; import { DateTime } from '../vendor/auto/luxon.mjs';
import settings from './settings.mjs';
// get available space // set up spacing and scales
const availableWidth = 532; const scaling = () => {
const availableHeight = 285; const available = {
width: 532,
height: 285,
};
const dataLength = {
hours: 36,
xTicks: 4,
};
if (settings.wide?.value && settings.enhanced?.value) {
available.width = available.width + 107 + 107;
available.height = 285;
dataLength.hours = 48;
dataLength.xTicks = 6;
}
return {
available,
dataLength,
};
};
class HourlyGraph extends WeatherDisplay { class HourlyGraph extends WeatherDisplay {
constructor(navId, elemId, defaultActive) { constructor(navId, elemId, defaultActive) {
@@ -40,39 +60,59 @@ class HourlyGraph extends WeatherDisplay {
const temperature = data.map((d) => d.temperature); const temperature = data.map((d) => d.temperature);
const probabilityOfPrecipitation = data.map((d) => d.probabilityOfPrecipitation); const probabilityOfPrecipitation = data.map((d) => d.probabilityOfPrecipitation);
const skyCover = data.map((d) => d.skyCover); const skyCover = data.map((d) => d.skyCover);
const dewpoint = data.map((d) => d.dewpoint);
this.data = { this.data = {
skyCover, temperature, probabilityOfPrecipitation, temperatureUnit: data[0].temperatureUnit, skyCover, temperature, probabilityOfPrecipitation, temperatureUnit: data[0].temperatureUnit, dewpoint,
}; };
// get the data length for current settings
const { dataLength } = scaling();
// clamp down the data to the allowed size
Object.entries(this.data).forEach(([key, value]) => {
if (Array.isArray(value)) {
this.data[key] = value.slice(0, dataLength.hours);
}
});
this.setStatus(STATUS.loaded); this.setStatus(STATUS.loaded);
} }
drawCanvas() { drawCanvas() {
// get scaling parameters
const { dataLength, available } = scaling();
// get the image
if (!this.image) this.image = this.elem.querySelector('.chart img'); if (!this.image) this.image = this.elem.querySelector('.chart img');
this.image.width = availableWidth; // set up image
this.image.height = availableHeight; this.image.width = available.width;
this.image.height = available.height;
// get context // get context
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = availableWidth; canvas.width = available.width;
canvas.height = availableHeight; canvas.height = available.height;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
// calculate time scale // calculate time scale
const timeScale = calcScale(0, 5, this.data.temperature.length - 1, availableWidth); const timeScale = calcScale(0, 5, this.data.temperature.length - 1, available.width);
const timeStep = this.data.temperature.length / (dataLength.xTicks);
const startTime = DateTime.now().startOf('hour'); const startTime = DateTime.now().startOf('hour');
document.querySelector('.x-axis .l-1').innerHTML = formatTime(startTime); let prevTime = startTime;
document.querySelector('.x-axis .l-2').innerHTML = formatTime(startTime.plus({ hour: 6 })); Array(dataLength.xTicks + 1).fill().forEach((val, idx) => {
document.querySelector('.x-axis .l-3').innerHTML = formatTime(startTime.plus({ hour: 12 })); // track the previous label so a day of week can be added when it changes
document.querySelector('.x-axis .l-4').innerHTML = formatTime(startTime.plus({ hour: 18 })); const label = formatTime(startTime.plus({ hour: idx * timeStep }), prevTime);
document.querySelector('.x-axis .l-5').innerHTML = formatTime(startTime.plus({ hour: 24 })); prevTime = label.ts;
// write to page
document.querySelector(`.x-axis .l-${idx + 1}`).innerHTML = label.formatted;
});
// order is important last line drawn is on top // order is important last line drawn is on top
// clouds // clouds
const percentScale = calcScale(0, availableHeight - 10, 100, 10); const percentScale = calcScale(0, available.height - 10, 100, 10);
const cloud = createPath(this.data.skyCover, timeScale, percentScale); const cloud = createPath(this.data.skyCover, timeScale, percentScale);
drawPath(cloud, ctx, { drawPath(cloud, ctx, {
strokeStyle: 'lightgrey', strokeStyle: 'lightgrey',
@@ -86,11 +126,22 @@ class HourlyGraph extends WeatherDisplay {
lineWidth: 3, lineWidth: 3,
}); });
// calculate temperature scale for min and max of dewpoint and temperature
const minScale = Math.min(...this.data.dewpoint, ...this.data.temperature);
const maxScale = Math.max(...this.data.dewpoint, ...this.data.temperature);
const thirdScale = (maxScale - minScale) / 3;
const midScale1 = Math.round(minScale + thirdScale);
const midScale2 = Math.round(minScale + (thirdScale * 2));
const tempScale = calcScale(minScale, available.height - 10, maxScale, 10);
// dewpoint
const dewpointPath = createPath(this.data.dewpoint, timeScale, tempScale);
drawPath(dewpointPath, ctx, {
strokeStyle: 'green',
lineWidth: 3,
});
// temperature // temperature
const minTemp = Math.min(...this.data.temperature);
const maxTemp = Math.max(...this.data.temperature);
const midTemp = Math.round((minTemp + maxTemp) / 2);
const tempScale = calcScale(minTemp, availableHeight - 10, maxTemp, 10);
const tempPath = createPath(this.data.temperature, timeScale, tempScale); const tempPath = createPath(this.data.temperature, timeScale, tempScale);
drawPath(tempPath, ctx, { drawPath(tempPath, ctx, {
strokeStyle: 'red', strokeStyle: 'red',
@@ -100,15 +151,17 @@ class HourlyGraph extends WeatherDisplay {
// temperature axis labels // temperature axis labels
// limited to 3 characters, sacraficing degree character // limited to 3 characters, sacraficing degree character
const degree = String.fromCharCode(176); const degree = String.fromCharCode(176);
this.elem.querySelector('.y-axis .l-1').innerHTML = (maxTemp + degree).substring(0, 3); this.elem.querySelector('.y-axis .l-1').innerHTML = (maxScale + degree).substring(0, 3);
this.elem.querySelector('.y-axis .l-2').innerHTML = (midTemp + degree).substring(0, 3); this.elem.querySelector('.y-axis .l-2').innerHTML = (midScale2 + degree).substring(0, 3);
this.elem.querySelector('.y-axis .l-3').innerHTML = (minTemp + degree).substring(0, 3); this.elem.querySelector('.y-axis .l-3').innerHTML = (midScale1 + degree).substring(0, 3);
this.elem.querySelector('.y-axis .l-4').innerHTML = (minScale + degree).substring(0, 3);
// set the image source // set the image source
this.image.src = canvas.toDataURL(); this.image.src = canvas.toDataURL();
// change the units in the header // change the units in the header
this.elem.querySelector('.temperature').innerHTML = `Temperature ${String.fromCharCode(176)}${this.data.temperatureUnit}`; this.elem.querySelector('.temperature').innerHTML = `Temperature ${String.fromCharCode(176)}${this.data.temperatureUnit}`;
this.elem.querySelector('.dewpoint').innerHTML = `Dewpoint ${String.fromCharCode(176)}${this.data.temperatureUnit}`;
super.drawCanvas(); super.drawCanvas();
this.finishDraw(); this.finishDraw();
@@ -145,7 +198,18 @@ const drawPath = (path, ctx, options) => {
}; };
// format as 1p, 12a, etc. // format as 1p, 12a, etc.
const formatTime = (time) => time.setZone(timeZone()).toFormat('ha').slice(0, -1); const formatTime = (time, prev) => {
// if the day of the week changes, show the day of the week in the label
let format = 'ha';
if (prev.weekday !== time.weekday) format = 'ccc ha';
const ts = time.setZone(timeZone());
return {
ts,
formatted: ts.toFormat(format).slice(0, -1),
};
};
// register display // register display
registerDisplay(new HourlyGraph(4, 'hourly-graph')); registerDisplay(new HourlyGraph(4, 'hourly-graph'));

View File

@@ -75,7 +75,10 @@ class Hourly extends WeatherDisplay {
const startingHour = DateTime.local().setZone(timeZone()); const startingHour = DateTime.local().setZone(timeZone());
const lines = this.data.map((data, index) => { // shorten to 24 hours
const shortData = this.data.slice(0, 24);
const lines = shortData.map((data, index) => {
const fillValues = {}; const fillValues = {};
// hour // hour
const hour = startingHour.plus({ hours: index }); const hour = startingHour.plus({ hours: index });
@@ -102,7 +105,7 @@ class Hourly extends WeatherDisplay {
const filledRow = this.fillTemplate('hourly-row', fillValues); const filledRow = this.fillTemplate('hourly-row', fillValues);
// alter the color of the feels like column to reflect wind chill or heat index // alter the color of the feels like column to reflect wind chill or heat index
if (feelsLike < temperature) { if (data.apparentTemperature < data.temperature) {
filledRow.querySelector('.like').classList.add('wind-chill'); filledRow.querySelector('.like').classList.add('wind-chill');
} else if (feelsLike > temperature) { } else if (feelsLike > temperature) {
filledRow.querySelector('.like').classList.add('heat-index'); filledRow.querySelector('.like').classList.add('heat-index');
@@ -203,6 +206,7 @@ const parseForecast = async (data) => {
const iceAccumulation = expand(data.iceAccumulation.values); // ice icon const iceAccumulation = expand(data.iceAccumulation.values); // ice icon
const probabilityOfPrecipitation = expand(data.probabilityOfPrecipitation.values); // rain icon const probabilityOfPrecipitation = expand(data.probabilityOfPrecipitation.values); // rain icon
const snowfallAmount = expand(data.snowfallAmount.values); // snow icon const snowfallAmount = expand(data.snowfallAmount.values); // snow icon
const dewpoint = expand(data.dewpoint.values);
const icons = await determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed); const icons = await determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed);
@@ -216,6 +220,7 @@ const parseForecast = async (data) => {
probabilityOfPrecipitation: probabilityOfPrecipitation[idx], probabilityOfPrecipitation: probabilityOfPrecipitation[idx],
skyCover: skyCover[idx], skyCover: skyCover[idx],
icon: icons[idx], icon: icons[idx],
dewpoint: temperatureConverter(dewpoint[idx]),
})); }));
}; };
@@ -233,7 +238,7 @@ const determineIcon = async (skyCover, weather, iceAccumulation, probabilityOfPr
}; };
// expand a set of values with durations to an hour-by-hour array // expand a set of values with durations to an hour-by-hour array
const expand = (data, maxHours = 24) => { const expand = (data, maxHours = 48) => {
const startOfHour = DateTime.utc().startOf('hour').toMillis(); const startOfHour = DateTime.utc().startOf('hour').toMillis();
const result = []; // resulting expanded values const result = []; // resulting expanded values
data.forEach((item) => { data.forEach((item) => {

View File

@@ -13,7 +13,7 @@ const largeIcon = (link, _isNightTime) => {
} catch (error) { } catch (error) {
console.warn(`largeIcon: ${error.message}`); console.warn(`largeIcon: ${error.message}`);
// Return a fallback icon to prevent downstream errors // Return a fallback icon to prevent downstream errors
return addPath(`No-Data.gif?${conditionIcon}${isNightTime ? '-n' : ''}`); return addPath(`No-Data-Large.gif?${conditionIcon}${isNightTime ? '-n' : ''}`);
} }
// find the icon // find the icon
@@ -102,6 +102,8 @@ const largeIcon = (link, _isNightTime) => {
case 'snow_fzra': case 'snow_fzra':
case 'snow_fzra-n': case 'snow_fzra-n':
case 'winter_mix':
case 'winter_mix-n':
return addPath('Freezing-Rain-Snow.gif'); return addPath('Freezing-Rain-Snow.gif');
case 'fzra': case 'fzra':
@@ -141,6 +143,8 @@ const largeIcon = (link, _isNightTime) => {
return addPath('Thunderstorm.gif'); return addPath('Thunderstorm.gif');
case 'wind_skc': case 'wind_skc':
case 'wind_':
case 'wind_-n':
return addPath('Windy.gif'); return addPath('Windy.gif');
case 'wind_skc-n': case 'wind_skc-n':
@@ -169,7 +173,7 @@ const largeIcon = (link, _isNightTime) => {
default: { default: {
console.warn(`Unknown weather condition '${conditionIcon}' from ${link}; using fallback icon`); console.warn(`Unknown weather condition '${conditionIcon}' from ${link}; using fallback icon`);
// Return a reasonable fallback instead of false to prevent downstream errors // Return a reasonable fallback instead of false to prevent downstream errors
return addPath(`No-Data.gif?${conditionIcon}${isNightTime ? '-n' : ''}`); return addPath(`No-Data-Large.gif?${conditionIcon}${isNightTime ? '-n' : ''}`);
} }
} }
}; };

View File

@@ -133,6 +133,7 @@ const smallIcon = (link, _isNightTime) => {
case 'wind_few': case 'wind_few':
case 'wind_few-n': case 'wind_few-n':
case 'wind_':
return addPath('Wind.gif'); return addPath('Wind.gif');
case 'wind_sct': case 'wind_sct':
@@ -170,7 +171,7 @@ const smallIcon = (link, _isNightTime) => {
case 'blizzard': case 'blizzard':
case 'blizzard-n': case 'blizzard-n':
return addPath('Blowing Snow.gif'); return addPath('Blowing-Snow.gif');
default: default:
console.warn(`Unknown weather condition '${conditionIcon}' from ${link}; using fallback icon`); console.warn(`Unknown weather condition '${conditionIcon}' from ${link}; using fallback icon`);

View File

@@ -159,12 +159,17 @@ class LatestObservations extends WeatherDisplay {
const windDirection = directionToNSEW(condition.windDirection.value); const windDirection = directionToNSEW(condition.windDirection.value);
const Temperature = temperatureConverter(condition.temperature.value); const Temperature = temperatureConverter(condition.temperature.value);
const Like = likeTemperature(condition.heatIndex?.value, condition.windChill?.value, Temperature, temperatureConverter);
const WindSpeed = windConverter(condition.windSpeed.value); const WindSpeed = windConverter(condition.windSpeed.value);
const locationLimit = (settings.wide?.value && settings.enhanced?.value) ? 20 : 14;
const weatherLimit = (settings.wide?.value && settings.enhanced?.value) ? 10 : 9;
const fill = { const fill = {
location: locationCleanup(condition.city).substr(0, 14), location: locationCleanup(condition.city).substr(0, locationLimit),
temp: Temperature, temp: Temperature,
weather: shortenCurrentConditions(condition.textDescription).substr(0, 9), like: Like.value,
weather: shortenCurrentConditions(condition.textDescription).substr(0, weatherLimit),
}; };
if (WindSpeed > 0) { if (WindSpeed > 0) {
@@ -175,7 +180,12 @@ class LatestObservations extends WeatherDisplay {
fill.wind = 'Calm'; fill.wind = 'Calm';
} }
return this.fillTemplate('observation-row', fill); const filledRow = this.fillTemplate('observation-row', fill);
// add the feels like class
if (Like.cssClass) filledRow.querySelector('.like').classList.add(Like.cssClass);
return filledRow;
}); });
const linesContainer = this.elem.querySelector('.observation-lines'); const linesContainer = this.elem.querySelector('.observation-lines');
@@ -186,6 +196,25 @@ class LatestObservations extends WeatherDisplay {
} }
} }
// generate a "feels like" temperature from heat index and wind chill.
const likeTemperature = (heat, wind, actual, converter) => {
// figure out the feels like value
let value = '';
if (heat) value = converter(heat);
if (wind) value = converter(wind);
// determine if there's a red/blue color class to add
let cssClass;
if (value !== '') {
if (value > actual) cssClass = 'heat-index';
if (value < actual) cssClass = 'wind-chill';
}
return {
value,
cssClass,
};
};
const shortenCurrentConditions = (_condition) => { const shortenCurrentConditions = (_condition) => {
let condition = _condition; let condition = _condition;
condition = condition.replace(/Light/, 'L'); condition = condition.replace(/Light/, 'L');

View File

@@ -37,9 +37,9 @@ class LocalForecast extends WeatherDisplay {
// read each text // read each text
this.screenTexts = conditions.map((condition) => { this.screenTexts = conditions.map((condition) => {
// process the text // process the text
let text = `${condition.DayName.toUpperCase()}...`; let text = `${condition.DayName}...`;
const conditionText = condition.Text; const conditionText = condition.Text;
text += conditionText.toUpperCase().replace('...', ' '); text += conditionText.replace('...', ' ');
return text; return text;
}); });
@@ -257,7 +257,7 @@ const parse = (forecast, forecastUrl) => {
return activePeriods.slice(0, 6).map((text) => ({ return activePeriods.slice(0, 6).map((text) => ({
// format day and text // format day and text
DayName: text.name.toUpperCase(), DayName: text.name,
Text: text.detailedForecast, Text: text.detailedForecast,
})); }));
}; };

View File

@@ -4,6 +4,9 @@ import Setting from './utils/setting.mjs';
let playlist; let playlist;
let currentTrack = 0; let currentTrack = 0;
let player; let player;
let sliderTimeout = null;
let volumeSlider = null;
let volumeSliderInput = null;
const mediaPlaying = new Setting('mediaPlaying', { const mediaPlaying = new Setting('mediaPlaying', {
name: 'Media Playing', name: 'Media Playing',
@@ -14,7 +17,19 @@ const mediaPlaying = new Setting('mediaPlaying', {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// add the event handler to the page // add the event handler to the page
document.getElementById('ToggleMedia').addEventListener('click', toggleMedia); document.getElementById('ToggleMedia').addEventListener('click', handleClick);
// get the slider elements
volumeSlider = document.querySelector('#ToggleMediaContainer .volume-slider');
volumeSliderInput = volumeSlider.querySelector('input');
// catch interactions with the volume slider (timeout handler)
// called on any interaction via 'input' (vs change) for immediate volume response
volumeSlider.addEventListener('input', setSliderTimeout);
volumeSlider.addEventListener('input', sliderChanged);
// add listener for mute (pause) button under the volume slider
volumeSlider.querySelector('img').addEventListener('click', stopMedia);
// get the playlist // get the playlist
getMedia(); getMedia();
}); });
@@ -77,7 +92,7 @@ const enableMediaPlayer = () => {
// randomize the list // randomize the list
randomizePlaylist(); randomizePlaylist();
// enable the icon // enable the icon
const icon = document.getElementById('ToggleMedia'); const icon = document.getElementById('ToggleMediaContainer');
icon.classList.add('available'); icon.classList.add('available');
// set the button type // set the button type
setIcon(); setIcon();
@@ -85,15 +100,12 @@ const enableMediaPlayer = () => {
if (mediaPlaying.value === true) { if (mediaPlaying.value === true) {
startMedia(); startMedia();
} }
// add the volume control to the page
const settingsSection = document.querySelector('#settings');
settingsSection.append(mediaVolume.generate());
} }
}; };
const setIcon = () => { const setIcon = () => {
// get the icon // get the icon
const icon = document.getElementById('ToggleMedia'); const icon = document.getElementById('ToggleMediaContainer');
if (mediaPlaying.value === true) { if (mediaPlaying.value === true) {
icon.classList.add('playing'); icon.classList.add('playing');
} else { } else {
@@ -101,18 +113,54 @@ const setIcon = () => {
} }
}; };
const toggleMedia = (forcedState) => { const handleClick = () => {
// handle forcing // if media is off, start it
if (typeof forcedState === 'boolean') { if (mediaPlaying.value === false) {
mediaPlaying.value = forcedState; mediaPlaying.value = true;
} else {
// toggle the state
mediaPlaying.value = !mediaPlaying.value;
} }
if (mediaPlaying.value === true && !volumeSlider.classList.contains('show')) {
// if media is playing and the slider isn't open, open it
showVolumeSlider();
} else {
// hide the volume slider
hideVolumeSlider();
}
// handle the state change // handle the state change
stateChanged(); stateChanged();
}; };
// set a timeout for the volume slider (called by interactions with the slider)
const setSliderTimeout = () => {
// clear existing timeout
if (sliderTimeout) clearTimeout(sliderTimeout);
// set a new timeout
sliderTimeout = setTimeout(hideVolumeSlider, 5000);
};
// show the volume slider and configure a timeout
const showVolumeSlider = () => {
setSliderTimeout();
// show the slider
if (volumeSlider) {
volumeSlider.classList.add('show');
}
};
// hide the volume slider and clean up the timeout
const hideVolumeSlider = () => {
// clear the timeout handler
if (sliderTimeout) clearTimeout(sliderTimeout);
sliderTimeout = null;
// hide the element
if (volumeSlider) {
volumeSlider.classList.remove('show');
}
};
const startMedia = async () => { const startMedia = async () => {
// if there's not media player yet, enable it // if there's not media player yet, enable it
if (!player) { if (!player) {
@@ -134,9 +182,12 @@ const startMedia = async () => {
}; };
const stopMedia = () => { const stopMedia = () => {
hideVolumeSlider();
if (!player) return; if (!player) return;
player.pause(); player.pause();
mediaPlaying.value = false;
setTrackName('Not playing'); setTrackName('Not playing');
setIcon();
}; };
const stateChanged = () => { const stateChanged = () => {
@@ -170,6 +221,16 @@ const setVolume = (newVolume) => {
} }
}; };
const sliderChanged = () => {
// get the value of the slider
if (volumeSlider) {
const newValue = volumeSliderInput.value;
const cleanValue = parseFloat(newValue) / 100;
setVolume(cleanValue);
mediaVolume.value = cleanValue;
}
};
const mediaVolume = new Setting('mediaVolume', { const mediaVolume = new Setting('mediaVolume', {
name: 'Volume', name: 'Volume',
type: 'select', type: 'select',
@@ -181,6 +242,7 @@ const mediaVolume = new Setting('mediaVolume', {
[0.25, '25%'], [0.25, '25%'],
], ],
changeAction: setVolume, changeAction: setVolume,
visible: false,
}); });
const initializePlayer = () => { const initializePlayer = () => {
@@ -205,7 +267,9 @@ const initializePlayer = () => {
player.src = `music/${playlist.availableFiles[currentTrack]}`; player.src = `music/${playlist.availableFiles[currentTrack]}`;
setTrackName(playlist.availableFiles[currentTrack]); setTrackName(playlist.availableFiles[currentTrack]);
player.type = 'audio/mpeg'; player.type = 'audio/mpeg';
// set volume and slider indicator
setVolume(mediaVolume.value); setVolume(mediaVolume.value);
volumeSliderInput.value = Math.round(mediaVolume.value * 100);
}; };
const playerCanPlay = async () => { const playerCanPlay = async () => {
@@ -238,5 +302,5 @@ const setTrackName = (fileName) => {
export { export {
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
toggleMedia, handleClick,
}; };

View File

@@ -6,6 +6,7 @@ import { safeJson } from './utils/fetch.mjs';
import { getPoint } from './utils/weather.mjs'; import { getPoint } from './utils/weather.mjs';
import { debugFlag } from './utils/debug.mjs'; import { debugFlag } from './utils/debug.mjs';
import settings from './settings.mjs'; import settings from './settings.mjs';
import { stationFilter } from './utils/string.mjs';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
init(); init();
@@ -37,6 +38,11 @@ const init = async () => {
resizeTimeout = setTimeout(() => resize(), 100); resizeTimeout = setTimeout(() => resize(), 100);
}); });
// redraw current screen (typically from enhanced setting change)
window.addEventListener('redraw', () => {
currentDisplay()?.drawCanvas();
});
// Handle orientation changes (Mobile Safari doesn't always fire resize events on orientation change) // Handle orientation changes (Mobile Safari doesn't always fire resize events on orientation change)
window.addEventListener('orientationchange', () => { window.addEventListener('orientationchange', () => {
if (debugFlag('resize')) { if (debugFlag('resize')) {
@@ -85,7 +91,15 @@ const getWeather = async (latLon, haveDataCallback) => {
return; return;
} }
const StationId = stations.features[0].properties.stationIdentifier; // filter stations for proper format
const stationsFiltered = stations.features.filter(stationFilter);
// check for stations available after filtering
if (stationsFiltered.length === 0) {
console.warn('No observation stations left for location after filtering');
return;
}
const StationId = stationsFiltered[0].properties.stationIdentifier;
let { city } = point.properties.relativeLocation.properties; let { city } = point.properties.relativeLocation.properties;
const { state } = point.properties.relativeLocation.properties; const { state } = point.properties.relativeLocation.properties;
@@ -108,7 +122,7 @@ const getWeather = async (latLon, haveDataCallback) => {
weatherParameters.timeZone = point.properties.timeZone; weatherParameters.timeZone = point.properties.timeZone;
weatherParameters.forecast = point.properties.forecast; weatherParameters.forecast = point.properties.forecast;
weatherParameters.forecastGridData = point.properties.forecastGridData; weatherParameters.forecastGridData = point.properties.forecastGridData;
weatherParameters.stations = stations.features; weatherParameters.stations = stationsFiltered;
weatherParameters.relativeLocation = point.properties.relativeLocation.properties; weatherParameters.relativeLocation = point.properties.relativeLocation.properties;
// update the main process for display purposes // update the main process for display purposes

View File

@@ -1,5 +1,56 @@
import settings from './settings.mjs';
const radarFinalSize = () => {
const size = {
width: 640, height: 367,
};
if (settings.wide?.value && settings.enhanced?.value) {
size.width = 854;
}
return size;
};
const radarSourceSize = () => {
const size = {
width: 240,
height: 163,
};
if (settings.wide?.value && settings.enhanced?.value) {
size.width = 240 / 640 * 854; // original size of 640 scaled up to wide at 854
}
return size;
};
const radarOffset = () => {
const offset = {
x: 240,
y: 138,
};
if (settings.wide?.value && settings.enhanced?.value) {
// 107 is the margins shift, 640/854 is the scaling factor normal => wide, /2 is because of the fixed 2:1 scaling between source radar and map tiles
offset.x = 240 + (107 * 640 / 854 / 2); // original size of 640 scaled up to wide at 854;
}
return offset;
};
// shift the base coordinates to align with enhanced radar window sizes
const radarShift = () => {
const shift = {
x: 0,
y: 0,
};
if (settings.wide?.value && settings.enhanced?.value) {
shift.x = 107;
}
return shift;
};
export const TILE_SIZE = { x: 680, y: 387 }; export const TILE_SIZE = { x: 680, y: 387 };
export const TILE_COUNT = { x: 10, y: 11 }; export const TILE_COUNT = { x: 10, y: 11 };
export const TILE_FULL_SIZE = { x: 6800, y: 4255 }; export const TILE_FULL_SIZE = { x: 6800, y: 4255 };
export const RADAR_FULL_SIZE = { width: 2550, height: 1600 }; export const RADAR_FULL_SIZE = { width: 2550, height: 1600 };
export const RADAR_FINAL_SIZE = { width: 640, height: 367 }; export const RADAR_FINAL_SIZE = radarFinalSize;
export const RADAR_SOURCE_SIZE = radarSourceSize;
export const RADAR_OFFSET = radarOffset;
export const RADAR_SHIFT = radarShift;

View File

@@ -1,5 +1,5 @@
import { removeDopplerRadarImageNoise } from './radar-utils.mjs'; import { removeDopplerRadarImageNoise } from './radar-utils.mjs';
import { RADAR_FULL_SIZE, RADAR_FINAL_SIZE } from './radar-constants.mjs'; import { RADAR_FULL_SIZE, RADAR_FINAL_SIZE, RADAR_SOURCE_SIZE } from './radar-constants.mjs';
// process a single radar image and place it on the provided canvas // process a single radar image and place it on the provided canvas
const processRadar = async (data) => { const processRadar = async (data) => {
@@ -13,8 +13,8 @@ const processRadar = async (data) => {
// calculate offsets and sizes // calculate offsets and sizes
const radarSource = { const radarSource = {
width: 240, width: RADAR_SOURCE_SIZE().width,
height: 163, height: RADAR_SOURCE_SIZE().height,
x: Math.round(radarSourceXY.x / 2), x: Math.round(radarSourceXY.x / 2),
y: Math.round(radarSourceXY.y / 2), y: Math.round(radarSourceXY.y / 2),
}; };
@@ -52,11 +52,11 @@ const processRadar = async (data) => {
// stretch the radar image // stretch the radar image
const stretchCanvas = document.createElement('canvas'); const stretchCanvas = document.createElement('canvas');
stretchCanvas.width = RADAR_FINAL_SIZE.width; stretchCanvas.width = RADAR_FINAL_SIZE().width;
stretchCanvas.height = RADAR_FINAL_SIZE.height; stretchCanvas.height = RADAR_FINAL_SIZE().height;
const stretchContext = stretchCanvas.getContext('2d', { willReadFrequently: true }); const stretchContext = stretchCanvas.getContext('2d', { willReadFrequently: true });
stretchContext.imageSmoothingEnabled = false; stretchContext.imageSmoothingEnabled = false;
stretchContext.drawImage(croppedRadarCanvas, 0, 0, radarSource.width, radarSource.height, 0, 0, RADAR_FINAL_SIZE.width, RADAR_FINAL_SIZE.height); stretchContext.drawImage(croppedRadarCanvas, 0, 0, radarSource.width, radarSource.height, 0, 0, RADAR_FINAL_SIZE().width, RADAR_FINAL_SIZE().height);
return stretchCanvas.toDataURL(); return stretchCanvas.toDataURL();
}; };

View File

@@ -9,10 +9,12 @@ const pixelToFile = (xPixel, yPixel) => {
return `${yTile}-${xTile}`; return `${yTile}-${xTile}`;
}; };
// convert a pixel location in the overall map to a pixel location on the tile // convert a pixel location in the overall map to a pixel location on the tile set
const modTile = (xPixel, yPixel) => { const modTile = (xPixel, yPixel) => {
const x = Math.round(xPixel) % TILE_SIZE.x; // adjust for additional 1 tile when odd
const y = Math.round(yPixel) % TILE_SIZE.y; const x = (Math.floor(xPixel) % (TILE_SIZE.x));
const y = (Math.floor(yPixel) % (TILE_SIZE.y));
return { x, y }; return { x, y };
}; };
@@ -29,28 +31,33 @@ const setTiles = (data) => {
// determine the basemap images needed // determine the basemap images needed
const baseMapTiles = [ const baseMapTiles = [
pixelToFile(sourceXY.x, sourceXY.y), pixelToFile(sourceXY.x + TILE_SIZE.x * 0, sourceXY.y),
pixelToFile(sourceXY.x + TILE_SIZE.x, sourceXY.y), pixelToFile(sourceXY.x + TILE_SIZE.x * 1, sourceXY.y),
pixelToFile(sourceXY.x, sourceXY.y + TILE_SIZE.y), pixelToFile(sourceXY.x + TILE_SIZE.x * 2, sourceXY.y),
pixelToFile(sourceXY.x + TILE_SIZE.x, sourceXY.y + TILE_SIZE.y), pixelToFile(sourceXY.x + TILE_SIZE.x * 0, sourceXY.y + TILE_SIZE.y),
pixelToFile(sourceXY.x + TILE_SIZE.x * 1, sourceXY.y + TILE_SIZE.y),
pixelToFile(sourceXY.x + TILE_SIZE.x * 2, sourceXY.y + TILE_SIZE.y),
]; ];
// do some calculations // do some calculations
// the tiles are arranged as follows, with the horizontal axis as x, and correlating with the second set of digits in the image file number // the tiles are arranged as follows, with the horizontal axis as x, and correlating with the second set of digits in the image file number
// T[0] T[1] // T[0] T[1] T[2]
// T[2] T[3] // T[3] T[4] T[5]
// calculate the shift of tile 0 (upper left) // calculate the shift of tile 0 (upper left)
const tileShift = modTile(sourceXY.x, sourceXY.y); const tileShift = modTile(sourceXY.x, sourceXY.y);
// determine which tiles are used // determine which tiles are used
const secondRow = tileShift.y + TILE_SIZE.y > RADAR_FINAL_SIZE().height;
const usedTiles = [ const usedTiles = [
true, true,
TILE_SIZE.x - tileShift.x < RADAR_FINAL_SIZE.width, tileShift.x + TILE_SIZE.x > RADAR_FINAL_SIZE().width,
TILE_SIZE.y - tileShift.y < RADAR_FINAL_SIZE.width, tileShift.x + (TILE_SIZE.x * 2) > RADAR_FINAL_SIZE().width,
secondRow,
]; ];
// if we need t[1] and t[2] then we also need t[3] // second row is a copy of the first row when in use
usedTiles.push(usedTiles[1] && usedTiles[2]); // calculate T[4] and T[5]
usedTiles.push(secondRow && usedTiles[1], secondRow && usedTiles[2]);
// helper function for populating tiles // helper function for populating tiles
const populateTile = (tileName) => (elem, index) => { const populateTile = (tileName) => (elem, index) => {

View File

@@ -1,4 +1,6 @@
import { TILE_SIZE, TILE_FULL_SIZE } from './radar-constants.mjs'; import {
TILE_SIZE, TILE_FULL_SIZE, RADAR_OFFSET, RADAR_SHIFT,
} from './radar-constants.mjs';
// limit a value to within a range // limit a value to within a range
const coerce = (low, value, high) => Math.max(Math.min(value, high), low); const coerce = (low, value, high) => Math.max(Math.min(value, high), low);
@@ -9,16 +11,16 @@ const getXYFromLatitudeLongitudeMap = (pos) => {
// 589 466 -122.3615246 47.63177832 // 589 466 -122.3615246 47.63177832
// 5288 3638 -80.18297384 25.77018996 // 5288 3638 -80.18297384 25.77018996
// map position is calculated as a regresion from the above values (=/- a manual adjustment factor) // map position is calculated as a regresion from the above values (+/- a manual adjustment factor) and shifting for enhanced views
// then shifted by half of the tile size (to center the map) // then shifted by half of the tile size (to center the map)
// then they are limited to values between 0 and the width or height of the map // then they are limited to values between 0 and the width or height of the map
const y = coerce(0, (-145.095 * pos.latitude + 7377.117) - 27 - (TILE_SIZE.y / 2), TILE_FULL_SIZE.y - (TILE_SIZE.y)); const y = coerce(0, (-145.095 * pos.latitude + 7377.117) - 27 - (TILE_SIZE.y / 2) - RADAR_SHIFT().y, TILE_FULL_SIZE.y - (TILE_SIZE.y));
const x = coerce(0, (111.407 * pos.longitude + 14220.972) + 4 - (TILE_SIZE.x / 2), TILE_FULL_SIZE.x - (TILE_SIZE.x)); const x = coerce(0, (111.407 * pos.longitude + 14220.972) + 4 - (TILE_SIZE.x / 2) - RADAR_SHIFT().x, TILE_FULL_SIZE.x - (TILE_SIZE.x));
return { x, y }; return { x, y };
}; };
const getXYFromLatitudeLongitudeDoppler = (pos, offsetX, offsetY) => { const getXYFromLatitudeLongitudeDoppler = (pos) => {
const imgHeight = 6000; const imgHeight = 6000;
const imgWidth = 2800; const imgWidth = 2800;
@@ -26,8 +28,8 @@ const getXYFromLatitudeLongitudeDoppler = (pos, offsetX, offsetY) => {
// then shifted by half of the tile size (to center the map) // then shifted by half of the tile size (to center the map)
// then they are limited to values between 0 and the width or height of the map // then they are limited to values between 0 and the width or height of the map
const y = coerce(0, (51 - pos.latitude) * 61.4481 - offsetY, imgHeight); const y = coerce(0, (51 - pos.latitude) * 61.4481 - RADAR_OFFSET().y, imgHeight);
const x = coerce(0, ((-129.138 - pos.longitude) * 42.1768) * -1 - offsetX, imgWidth); const x = coerce(0, ((-129.138 - pos.longitude) * 42.1768) * -1 - RADAR_OFFSET().x, imgWidth);
return { x: x * 2, y: y * 2 }; return { x: x * 2, y: y * 2 };
}; };

View File

@@ -128,10 +128,8 @@ class Radar extends WeatherDisplay {
const urls = sortedPngs.slice(-(this.dopplerRadarImageMax)); const urls = sortedPngs.slice(-(this.dopplerRadarImageMax));
// calculate offsets and sizes // calculate offsets and sizes
const offsetX = 120 * 2;
const offsetY = 69 * 2;
const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters); const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters);
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY); const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters);
// set up the base map and overlay tiles // set up the base map and overlay tiles
setTiles({ setTiles({

View File

@@ -23,7 +23,7 @@ const buildForecast = (forecast, city, cityXY) => {
const getRegionalObservation = async (point, city) => { const getRegionalObservation = async (point, city) => {
try { try {
// get stations using centralized safe handling // get stations using centralized safe handling
const stations = await safeJson(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations?limit=1`); const stations = await safeJson(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations?limit=10`);
if (!stations || !stations.features || stations.features.length === 0) { if (!stations || !stations.features || stations.features.length === 0) {
if (debugFlag('verbose-failures')) { if (debugFlag('verbose-failures')) {
@@ -32,9 +32,13 @@ const getRegionalObservation = async (point, city) => {
return false; return false;
} }
// get the first station // get the first station with a 4-letter id (generally has appropriate data)
const station = stations.features[0].id; const station4Letter = stations.features.find((station) => {
const stationId = stations.features[0].properties.stationIdentifier; if (station.properties.stationIdentifier.length === 4) return station.properties;
return false;
});
const station = station4Letter.id;
const stationId = station4Letter.properties.stationIdentifier;
// get the observation data using centralized safe handling // get the observation data using centralized safe handling
const observation = await safeJson(`${station}/observations/latest`); const observation = await safeJson(`${station}/observations/latest`);
@@ -205,7 +209,7 @@ const getMinMaxLatitudeLongitudeHI = (X, Y, OffsetX, OffsetY) => {
}; };
}; };
const getXYForCity = (City, MaxLatitude, MinLongitude, state) => { const getXYForCity = (City, MaxLatitude, MinLongitude, state, maxX = 580) => {
if (state === 'AK') getXYForCityAK(City, MaxLatitude, MinLongitude); if (state === 'AK') getXYForCityAK(City, MaxLatitude, MinLongitude);
if (state === 'HI') getXYForCityHI(City, MaxLatitude, MinLongitude); if (state === 'HI') getXYForCityHI(City, MaxLatitude, MinLongitude);
let x = (City.lon - MinLongitude) * 57; let x = (City.lon - MinLongitude) * 57;
@@ -215,7 +219,7 @@ const getXYForCity = (City, MaxLatitude, MinLongitude, state) => {
if (y > 282) y = 282; if (y > 282) y = 282;
if (x < 40) x = 40; if (x < 40) x = 40;
if (x > 580) x = 580; if (x > maxX) x = maxX;
return { x, y }; return { x, y };
}; };

View File

@@ -14,11 +14,29 @@ import * as utils from './regionalforecast-utils.mjs';
import { getPoint } from './utils/weather.mjs'; import { getPoint } from './utils/weather.mjs';
import { debugFlag } from './utils/debug.mjs'; import { debugFlag } from './utils/debug.mjs';
import filterExpiredPeriods from './utils/forecast-utils.mjs'; import filterExpiredPeriods from './utils/forecast-utils.mjs';
import settings from './settings.mjs';
// map offset // set up spacing and scales
const mapOffsetXY = { const scaling = () => {
x: 240, // available space
y: 117, const available = {
x: 640,
};
// map offset
const mapOffsetXY = {
x: 240,
y: 117,
};
if (settings.wide?.value && settings.enhanced?.value) {
mapOffsetXY.x = 320;
available.x = 854;
}
return {
mapOffsetXY,
available,
};
}; };
class RegionalForecast extends WeatherDisplay { class RegionalForecast extends WeatherDisplay {
@@ -45,13 +63,14 @@ class RegionalForecast extends WeatherDisplay {
this.elem.querySelector('.map img').src = baseMap; this.elem.querySelector('.map img').src = baseMap;
// get user's location in x/y // get user's location in x/y
const { available, mapOffsetXY } = scaling();
const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, mapOffsetXY.x, mapOffsetXY.y, weatherParameters.state); const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, mapOffsetXY.x, mapOffsetXY.y, weatherParameters.state);
// get latitude and longitude limits // get latitude and longitude limits
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, mapOffsetXY.x, mapOffsetXY.y, this.weatherParameters.state); const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, mapOffsetXY.x, mapOffsetXY.y, this.weatherParameters.state);
// get a target distance // get a target distance
let targetDistance = 2.5; let targetDistance = 2.4;
if (this.weatherParameters.state === 'HI') targetDistance = 1; if (this.weatherParameters.state === 'HI') targetDistance = 1;
// make station info into an array // make station info into an array
@@ -102,7 +121,7 @@ class RegionalForecast extends WeatherDisplay {
} }
// get XY on map for city // get XY on map for city
const cityXY = utils.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, this.weatherParameters.state); const cityXY = utils.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, this.weatherParameters.state, available - 60);
// wait for the regional observation if it's not done yet // wait for the regional observation if it's not done yet
const observation = await observationPromise; const observation = await observationPromise;
@@ -188,7 +207,8 @@ class RegionalForecast extends WeatherDisplay {
} }
// draw the map // draw the map
const scale = 640 / (mapOffsetXY.x * 2); const { available, mapOffsetXY } = scaling();
const scale = available.x / (mapOffsetXY.x * 2);
const map = this.elem.querySelector('.map'); const map = this.elem.querySelector('.map');
map.style.transform = `scale(${scale}) translate(-${sourceXY.x}px, -${sourceXY.y}px)`; map.style.transform = `scale(${scale}) translate(-${sourceXY.x}px, -${sourceXY.y}px)`;

View File

@@ -1,4 +1,5 @@
import Setting from './utils/setting.mjs'; import Setting from './utils/setting.mjs';
import { registerHiddenSetting } from './share.mjs';
// Initialize settings immediately so other modules can access them // Initialize settings immediately so other modules can access them
const settings = { speed: { value: 1.0 } }; const settings = { speed: { value: 1.0 } };
@@ -6,6 +7,16 @@ const settings = { speed: { value: 1.0 } };
// Track settings that need DOM changes after early initialization // Track settings that need DOM changes after early initialization
const deferredDomSettings = new Set(); const deferredDomSettings = new Set();
// don't show checkboxes for these settings
const hiddenSettings = [
'scanLines',
// wide, portrait and enhanced are handled by a dropdown which sets these individual settings accordingly
'wide',
'portrait',
'enhanced',
];
// Declare change functions first, before they're referenced in init() to avoid the Temporal Dead Zone (TDZ) // Declare change functions first, before they're referenced in init() to avoid the Temporal Dead Zone (TDZ)
const wideScreenChange = (value) => { const wideScreenChange = (value) => {
const container = document.querySelector('#divTwc'); const container = document.querySelector('#divTwc');
@@ -26,6 +37,69 @@ const wideScreenChange = (value) => {
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
}; };
const portraitChange = (value) => {
const container = document.querySelector('#divTwc');
if (!container) {
// DOM not ready; defer enabling if set
if (value) {
deferredDomSettings.add('portrait');
}
return;
}
if (value) {
container.classList.add('portrait');
} else {
container.classList.remove('portrait');
}
// Trigger resize to recalculate scaling for new width
window.dispatchEvent(new Event('resize'));
};
const enhancedChange = (value) => {
const container = document.querySelector('#divTwc');
if (!container) {
// DOM not ready; defer enabling if set
if (value) {
deferredDomSettings.add('enhanced');
}
return;
}
if (value) {
container.classList.add('enhanced');
} else {
container.classList.remove('enhanced');
}
// Trigger resize to recalculate scaling for new width
window.dispatchEvent(new Event('redraw'));
};
const viewModeChange = (value) => {
// set the appropriate mode bits which triggers change actions above
switch (value) {
case 'wide':
settings.wide.value = true;
settings.enhanced.value = false;
settings.portrait.value = false;
break;
case 'wide-enhanced':
settings.wide.value = true;
settings.enhanced.value = true;
settings.portrait.value = false;
break;
case 'portrait-enhanced':
settings.wide.value = false;
settings.enhanced.value = true;
settings.portrait.value = true;
break;
default:
settings.wide.value = false;
settings.enhanced.value = false;
settings.portrait.value = false;
}
};
const kioskChange = (value) => { const kioskChange = (value) => {
const body = document.querySelector('body'); const body = document.querySelector('body');
if (!body) { if (!body) {
@@ -38,9 +112,11 @@ const kioskChange = (value) => {
if (value) { if (value) {
body.classList.add('kiosk'); body.classList.add('kiosk');
document.querySelector('#divTwc')?.classList.add('no-cursor');
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
} else { } else {
body.classList.remove('kiosk'); body.classList.remove('kiosk');
document.querySelector('#divTwc')?.classList.remove('no-cursor');
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
} }
@@ -63,13 +139,19 @@ const scanLineChange = (value) => {
return; return;
} }
const modeSelect = document.getElementById('settings-scanLineMode-label');
if (value) { if (value) {
container.classList.add('scanlines'); container.classList.add('scanlines');
navIcons.classList.add('on'); navIcons.classList.add('on');
modeSelect?.style?.removeProperty('display');
} else { } else {
// Remove all scanline classes // Remove all scanline classes
container.classList.remove('scanlines', 'scanlines-auto', 'scanlines-fine', 'scanlines-normal', 'scanlines-thick', 'scanlines-classic', 'scanlines-retro'); container.classList.remove('scanlines', 'scanlines-auto', 'scanlines-fine', 'scanlines-normal', 'scanlines-thick', 'scanlines-classic', 'scanlines-retro');
navIcons.classList.remove('on'); navIcons.classList.remove('on');
if (modeSelect) {
modeSelect.style.display = 'none';
}
} }
}; };
@@ -118,6 +200,39 @@ const init = () => {
changeAction: wideScreenChange, changeAction: wideScreenChange,
sticky: true, sticky: true,
}); });
settings.portrait = new Setting('portrait', {
name: 'Allow Portrait',
changeAction: portraitChange,
defaultValue: false,
sticky: true,
});
settings.enhanced = new Setting('enhanced', {
name: 'Enhanced Screens',
defaultValue: false,
changeAction: enhancedChange,
sticky: true,
});
// widescreen, portrait and enhanced are handled by a dropdown
// the dropdown change action sets the above bits accordingly
// first, figure out the default value based on other settings
// this also enforces rules on how these can be combined
let viewModeDefault = 'standard';
if (settings.wide.value && !settings.enhanced.value) viewModeDefault = 'wide';
if (settings.wide.value && settings.enhanced.value) viewModeDefault = 'wide-enhanced';
if (settings.portrait.value) viewModeDefault = 'portrait-enhanced';
settings.viewMode = new Setting('viewMode', {
name: 'Display mode',
type: 'select',
defaultValue: viewModeDefault,
changeAction: viewModeChange,
sticky: false, // not sticky because the above 3 settings are sticky and define this item's starting state
values: [
['standard', 'Standard'],
['wide', 'Widescreen'],
['wide-enhanced', 'Widescreen enhanced'],
['portrait-enhanced', 'Portrait enhanced'],
],
});
settings.kiosk = new Setting('kiosk', { settings.kiosk = new Setting('kiosk', {
name: 'Kiosk', name: 'Kiosk',
defaultValue: false, defaultValue: false,
@@ -160,6 +275,7 @@ const init = () => {
['medium', 'Medium (2x)'], ['medium', 'Medium (2x)'],
['thick', 'Thick (3x)'], ['thick', 'Thick (3x)'],
], ],
visible: false,
}); });
settings.units = new Setting('units', { settings.units = new Setting('units', {
name: 'Units', name: 'Units',
@@ -206,10 +322,27 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// Then generate the settings UI // Then generate the settings UI
const settingHtml = Object.values(settings).map((d) => d.generate()); const settingHtml = Object.values(settings).map((setting) => {
if (hiddenSettings.includes(setting.shortName)) {
// setting is hidden, register it
registerHiddenSetting(setting.shortName, setting);
return false;
}
// generate HTML for setting
return setting.generate();
}).filter((d) => d);
const settingsSection = document.querySelector('#settings'); const settingsSection = document.querySelector('#settings');
settingsSection.innerHTML = ''; settingsSection.innerHTML = '';
settingsSection.append(...settingHtml); settingsSection.append(...settingHtml);
// update visibility on some settings
const modeSelect = document.getElementById('settings-scanLineMode-label');
const { value } = settings.scanLines;
if (value) {
modeSelect?.style?.removeProperty('display');
} else if (modeSelect) {
modeSelect.style.display = 'none';
}
}); });
export default settings; export default settings;

View File

@@ -1,11 +1,10 @@
import { elemForEach } from './utils/elem.mjs'; import { elemForEach } from './utils/elem.mjs';
import Setting from './utils/setting.mjs';
document.addEventListener('DOMContentLoaded', () => init()); document.addEventListener('DOMContentLoaded', () => init());
// shorthand mappings for frequently used values // array of settings that are not checkboxes or dropdowns (i.e. volume slider)
const specialMappings = { const hiddenSettings = [];
kiosk: 'settings-kiosk-checkbox',
};
const init = () => { const init = () => {
// add action to existing link // add action to existing link
@@ -26,28 +25,40 @@ const createLink = async (e) => {
const queryStringElements = {}; const queryStringElements = {};
elemForEach('input[type=checkbox]', (elem) => { elemForEach('input[type=checkbox]', (elem) => {
if (elem?.id) { // use name, and fallback to id (older prefix/suffix permalinks)
queryStringElements[elem.id] = elem?.checked ?? false; const key = elem?.name ?? elem?.id;
if (key) {
queryStringElements[key] = elem?.checked ?? false;
} }
}); });
// get all select boxes // get all select boxes
elemForEach('select', (elem) => { elemForEach('select', (elem) => {
if (elem?.id) { // use name, and fallback to id (older prefix/suffix permalinks)
queryStringElements[elem.id] = encodeURIComponent(elem?.value ?? ''); const key = elem?.name ?? elem?.id;
if (key) {
queryStringElements[key] = encodeURIComponent(elem?.value ?? '');
} }
}); });
// get all text boxes // get all text boxes
elemForEach('input[type=text]', ((elem) => { elemForEach('input[type=text]', ((elem) => {
if (elem?.id) { // use name, and fallback to id (older prefix/suffix permalinks)
queryStringElements[elem.id] = elem?.value ?? 0; const key = elem?.name ?? elem?.id;
if (key && key !== '') {
queryStringElements[key] = elem?.value ?? 0;
} }
})); }));
// add the location string // get any hidden settings
queryStringElements.latLonQuery = localStorage.getItem('latLonQuery'); hiddenSettings.forEach((setting) => {
queryStringElements.latLon = localStorage.getItem('latLon'); // determine type
if (setting.value instanceof Setting) {
queryStringElements[setting.name] = setting.value.value;
} else if (typeof setting.value === 'function') {
queryStringElements[setting.name] = setting.value();
}
});
const queryString = (new URLSearchParams(queryStringElements)).toString(); const queryString = (new URLSearchParams(queryStringElements)).toString();
@@ -90,29 +101,17 @@ const writeLinkToPage = (url) => {
shareLinkUrl.select(); shareLinkUrl.select();
}; };
const parseQueryString = () => { const registerHiddenSetting = (name, value) => {
// return memoized result // name is the id of the element
if (parseQueryString.params) return parseQueryString.params; // value can be a function that returns the current value of the setting
const urlSearchParams = new URLSearchParams(window.location.search); // or an instance of Setting
hiddenSettings.push({
// turn into an array of key-value pairs name,
const paramsArray = [...urlSearchParams]; value,
// add additional expanded keys
paramsArray.forEach((paramPair) => {
const expandedKey = specialMappings[paramPair[0]];
if (expandedKey) {
paramsArray.push([expandedKey, paramPair[1]]);
}
}); });
// memoize result
parseQueryString.params = Object.fromEntries(paramsArray);
return parseQueryString.params;
}; };
export { export {
createLink, createLink,
parseQueryString, registerHiddenSetting,
}; };

View File

@@ -650,7 +650,7 @@ export const enhanceObservationWithMapClick = async (observationData, options =
} }
return { return {
data: mapClickProps, data: { ...mapClickProps, timestamp: observationData.timestamp },
wasImproved: true, wasImproved: true,
improvements, improvements,
missingFields: [...mapClickMissingRequired, ...mapClickMissingOptional], missingFields: [...mapClickMissingRequired, ...mapClickMissingOptional],

View File

@@ -1,5 +1,3 @@
import { parseQueryString } from '../share.mjs';
const SETTINGS_KEY = 'Settings'; const SETTINGS_KEY = 'Settings';
const DEFAULTS = { const DEFAULTS = {
@@ -15,6 +13,11 @@ const DEFAULTS = {
placeholder: '', placeholder: '',
}; };
// shorthand mappings for frequently used values
const specialMappings = {
kiosk: 'settings-kiosk-checkbox',
};
class Setting { class Setting {
constructor(shortName, _options) { constructor(shortName, _options) {
if (shortName === undefined) { if (shortName === undefined) {
@@ -35,9 +38,12 @@ class Setting {
this.visible = options.visible; this.visible = options.visible;
this.changeAction = options.changeAction; this.changeAction = options.changeAction;
this.placeholder = options.placeholder; this.placeholder = options.placeholder;
this.elemId = `settings-${shortName}-${this.type}`;
// get value from url // get value from url
const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`]; // includes a fallback to the older prefix/suffix version
const queryString = parseQueryString();
const urlValue = queryString?.[shortName] ?? queryString?.[this.elemId];
let urlState; let urlState;
if (this.type === 'checkbox' && urlValue !== undefined) { if (this.type === 'checkbox' && urlValue !== undefined) {
urlState = urlValue === 'true'; urlState = urlValue === 'true';
@@ -88,7 +94,7 @@ class Setting {
const select = document.createElement('select'); const select = document.createElement('select');
select.id = `settings-${this.shortName}-select`; select.id = `settings-${this.shortName}-select`;
select.name = `settings-${this.shortName}-select`; select.name = this.shortName;
select.addEventListener('change', (e) => this.selectChange(e)); select.addEventListener('change', (e) => this.selectChange(e));
this.values.forEach(([value, text]) => { this.values.forEach(([value, text]) => {
@@ -121,7 +127,7 @@ class Setting {
checkbox.type = 'checkbox'; checkbox.type = 'checkbox';
checkbox.value = true; checkbox.value = true;
checkbox.id = `settings-${this.shortName}-checkbox`; checkbox.id = `settings-${this.shortName}-checkbox`;
checkbox.name = `settings-${this.shortName}-checkbox`; checkbox.name = this.shortName;
checkbox.checked = this.myValue; checkbox.checked = this.myValue;
checkbox.addEventListener('change', (e) => this.checkboxChange(e)); checkbox.addEventListener('change', (e) => this.checkboxChange(e));
const span = document.createElement('span'); const span = document.createElement('span');
@@ -144,14 +150,14 @@ class Setting {
textInput.type = 'text'; textInput.type = 'text';
textInput.value = this.myValue; textInput.value = this.myValue;
textInput.id = `settings-${this.shortName}-string`; textInput.id = `settings-${this.shortName}-string`;
textInput.name = `settings-${this.shortName}-string`; textInput.name = this.shortName;
textInput.placeholder = this.placeholder; textInput.placeholder = this.placeholder;
// set button // set button
const setButton = document.createElement('input'); const setButton = document.createElement('input');
setButton.type = 'button'; setButton.type = 'button';
setButton.value = 'Set'; setButton.value = 'Set';
setButton.id = `settings-${this.shortName}-button`; setButton.id = `settings-${this.shortName}-button`;
setButton.name = `settings-${this.shortName}-button`; setButton.name = this.shortName;
setButton.addEventListener('click', () => { setButton.addEventListener('click', () => {
this.stringChange({ target: { value: textInput.value } }); this.stringChange({ target: { value: textInput.value } });
}); });
@@ -254,7 +260,10 @@ class Setting {
break; break;
case 'checkbox': case 'checkbox':
default: default:
this.element.querySelector('input').checked = newValue; // allow for a hidden checkbox (typically items in the player control bar)
if (this.element) {
this.element.querySelector('input').checked = newValue;
}
} }
this.storeToLocalStorage(this.myValue); this.storeToLocalStorage(this.myValue);
@@ -285,4 +294,30 @@ class Setting {
} }
} }
const parseQueryString = () => {
// return memoized result
if (parseQueryString.params) return parseQueryString.params;
const urlSearchParams = new URLSearchParams(window.location.search);
// turn into an array of key-value pairs
const paramsArray = [...urlSearchParams];
// add additional expanded keys
paramsArray.forEach((paramPair) => {
const expandedKey = specialMappings[paramPair[0]];
if (expandedKey) {
paramsArray.push([expandedKey, paramPair[1]]);
}
});
// memoize result
parseQueryString.params = Object.fromEntries(paramsArray);
return parseQueryString.params;
};
export default Setting; export default Setting;
export {
parseQueryString,
};

View File

@@ -13,7 +13,12 @@ const locationCleanup = (input) => {
return regexes.reduce((value, regex) => value.replace(regex, ''), input); return regexes.reduce((value, regex) => value.replace(regex, ''), input);
}; };
// stations must be 4 alpha characters and not start with the provided list
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
const stationFilter = (station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/) && !skipStations.includes(station.properties.stationIdentifier.slice(0, 1));
export { export {
// eslint-disable-next-line import/prefer-default-export
locationCleanup, locationCleanup,
stationFilter,
}; };

View File

@@ -5,7 +5,7 @@ import { DateTime } from '../vendor/auto/luxon.mjs';
import { import {
msg, displayNavMessage, isPlaying, updateStatus, timeZone, msg, displayNavMessage, isPlaying, updateStatus, timeZone,
} from './navigation.mjs'; } from './navigation.mjs';
import { parseQueryString } from './share.mjs'; import { parseQueryString } from './utils/setting.mjs';
import settings from './settings.mjs'; import settings from './settings.mjs';
import { elemForEach } from './utils/elem.mjs'; import { elemForEach } from './utils/elem.mjs';
import { debugFlag } from './utils/debug.mjs'; import { debugFlag } from './utils/debug.mjs';
@@ -55,8 +55,9 @@ class WeatherDisplay {
// no checkbox if progress // no checkbox if progress
if (this.elemId === 'progress') return false; if (this.elemId === 'progress') return false;
// get url provided state // get url provided state, and fall back to the older suffix naming convention
const urlValue = parseQueryString()?.[`${this.elemId}-checkbox`]; const queryString = parseQueryString();
const urlValue = queryString?.[this.elemId] ?? queryString?.[`${this.elemId}-checkbox`];
let urlState; let urlState;
if (urlValue !== undefined) { if (urlValue !== undefined) {
urlState = urlValue === 'true'; urlState = urlValue === 'true';
@@ -78,7 +79,7 @@ class WeatherDisplay {
checkbox.type = 'checkbox'; checkbox.type = 'checkbox';
checkbox.value = true; checkbox.value = true;
checkbox.id = `${this.elemId}-checkbox`; checkbox.id = `${this.elemId}-checkbox`;
checkbox.name = `${this.elemId}-checkbox`; checkbox.name = this.elemId;
checkbox.checked = this.isEnabled; checkbox.checked = this.isEnabled;
checkbox.addEventListener('change', (e) => this.checkboxChange(e)); checkbox.addEventListener('change', (e) => this.checkboxChange(e));
const span = document.createElement('span'); const span = document.createElement('span');
@@ -172,6 +173,7 @@ class WeatherDisplay {
if (this.screenIndex < 0) this.screenIndex = 0; if (this.screenIndex < 0) this.screenIndex = 0;
if (this.okToDrawCurrentDateTime) this.drawCurrentDateTime(); if (this.okToDrawCurrentDateTime) this.drawCurrentDateTime();
if (this.okToDrawCurrentConditions) postMessage({ type: 'current-weather-scroll', method: 'start' }); if (this.okToDrawCurrentConditions) postMessage({ type: 'current-weather-scroll', method: 'start' });
if (!this.okToDrawCurrentConditions) postMessage({ type: 'current-weather-scroll', method: 'non-display' });
if (this.okToDrawCurrentConditions === false) postMessage({ type: 'current-weather-scroll', method: 'hide' }); if (this.okToDrawCurrentConditions === false) postMessage({ type: 'current-weather-scroll', method: 'hide' });
} }

View File

@@ -346,7 +346,7 @@ var TimeIndicator;
TimeIndicator["TL"] = "TL"; TimeIndicator["TL"] = "TL";
})(TimeIndicator || (TimeIndicator = {})); })(TimeIndicator || (TimeIndicator = {}));
/** /**
* https://www.aviationweather.gov/taf/decoder * https://web.archive.org/web/20230318235549/https://aviationweather.gov/taf/decoder
*/ */
var WeatherChangeType; var WeatherChangeType;
(function (WeatherChangeType) { (function (WeatherChangeType) {
@@ -2535,7 +2535,8 @@ class MetarParser extends AbstractParser {
while (i < trendParts.length && while (i < trendParts.length &&
trendParts[i] !== this.TEMPO && trendParts[i] !== this.TEMPO &&
trendParts[i] !== this.INTER && trendParts[i] !== this.INTER &&
trendParts[i] !== this.BECMG) { trendParts[i] !== this.BECMG &&
trendParts[i] !== this.RMK) {
if (trendParts[i].startsWith(this.FM) || if (trendParts[i].startsWith(this.FM) ||
trendParts[i].startsWith(this.TL) || trendParts[i].startsWith(this.TL) ||
trendParts[i].startsWith(this.AT)) { trendParts[i].startsWith(this.AT)) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,13 @@
@use 'shared/_colors' as c; @use 'shared/_colors'as c;
@use 'shared/_utils' as u; @use 'shared/_utils'as u;
#almanac-html.weather-display { #almanac-html.weather-display {
background-image: url('../images/backgrounds/3.png'); background-image: url('../images/backgrounds/3.png');
// repeat the background if wide-enhanced
.wide.enhanced & {
background-image: url('../images/backgrounds/3-wide-enhanced.png');
}
} }
.weather-display .main.almanac { .weather-display .main.almanac {
@@ -14,13 +19,17 @@
// Use CSS Grid for cross-browser consistency // Use CSS Grid for cross-browser consistency
// Grid is populated in reading order (left-to-right, top-to-bottom): // Grid is populated in reading order (left-to-right, top-to-bottom):
display: grid; display: grid;
grid-template-columns: auto auto auto; grid-template-columns: repeat(3, auto);
grid-template-rows: auto auto auto; grid-template-rows: repeat(3, auto);
gap: 0px 90px; gap: 0px 90px;
margin: 3px auto 5px auto; // align the bottom of the div with the background margin: 3px auto 5px auto; // align the bottom of the div with the background
width: fit-content; width: fit-content;
line-height: 30px; line-height: 30px;
.wide.enhanced & {
grid-template-columns: repeat(4, auto);
}
.grid-item { .grid-item {
// Reset inherited styles that interfere with grid layout // Reset inherited styles that interfere with grid layout
width: auto; width: auto;
@@ -45,6 +54,14 @@
&.time { &.time {
text-align: center; text-align: center;
} }
&.wide-enhanced {
display: none;
.wide.enhanced & {
display: block;
}
}
} }
} }
@@ -58,6 +75,10 @@
padding-left: 13px; padding-left: 13px;
} }
.days {
text-align: center;
}
.day { .day {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
@@ -77,4 +98,4 @@
} }

View File

@@ -1,8 +1,10 @@
@use 'shared/_colors' as c; @use 'shared/_colors'as c;
@use 'shared/_utils' as u; @use 'shared/_utils'as u;
@use 'shared/positions'as p;
.weather-display .main.current-weather { .weather-display .main.current-weather {
&.main { &.main {
width: calc(p.$standard-width - (2 * p.$blue-box-margin));
.col { .col {
height: 50px; height: 50px;
@@ -12,12 +14,17 @@
padding-top: 10px; padding-top: 10px;
position: absolute; position: absolute;
.wide.enhanced & {
width: 300px;
margin-left: 25px;
margin-right: 25px;
}
@include u.text-shadow(); @include u.text-shadow();
&.left { &.left {
font-family: 'Star4000 Extended'; font-family: 'Star4000 Extended';
font-size: 24pt; font-size: 24pt;
} }
&.right { &.right {
@@ -92,4 +99,4 @@
text-wrap: nowrap; text-wrap: nowrap;
} }
} }
} }

View File

@@ -1,5 +1,6 @@
@use 'shared/_colors'as c; @use 'shared/_colors'as c;
@use 'shared/_utils'as u; @use 'shared/_utils'as u;
@use 'shared/positions'as p;
#hazards-html.weather-display { #hazards-html.weather-display {
background-image: url('../images/backgrounds/7.png'); background-image: url('../images/backgrounds/7.png');
@@ -8,7 +9,7 @@
.weather-display .main.hazards { .weather-display .main.hazards {
&.main { &.main {
overflow-y: hidden; overflow-y: hidden;
height: 480px; height: p.$standard-height;
background-color: rgb(112, 35, 35); background-color: rgb(112, 35, 35);

View File

@@ -4,6 +4,12 @@
#hourly-graph-html { #hourly-graph-html {
background-image: url(../images/backgrounds/1-chart.png); background-image: url(../images/backgrounds/1-chart.png);
// change background for wide-enhanced
.wide.enhanced & {
background-image: url(../images/backgrounds/1-chart-wide.png);
background-position-x: 0px;
}
.header { .header {
.right { .right {
position: absolute; position: absolute;
@@ -11,7 +17,7 @@
right: 60px; right: 60px;
width: 360px; width: 360px;
font-family: 'Star4000 Small'; font-family: 'Star4000 Small';
font-size: 32px; font-size: 28px;
@include u.text-shadow(); @include u.text-shadow();
text-align: right; text-align: right;
@@ -23,6 +29,10 @@
color: red; color: red;
} }
.dewpoint {
color: green;
}
.cloud { .cloud {
color: lightgrey; color: lightgrey;
} }
@@ -52,37 +62,79 @@
.x-axis { .x-axis {
bottom: 0px; bottom: 0px;
left: 0px; left: 54px;
width: 640px; width: 532px;
height: 20px; height: 20px;
.label { .label {
text-align: center; text-align: center;
width: 50px; transform: translateX(-50%);
white-space: nowrap;
&.l-1 { &.l-1 {
left: 25px; left: 0px;
} }
&.l-2 { &.l-2 {
left: 158px; left: calc(532px / 4 * 1);
} }
&.l-3 { &.l-3 {
left: 291px; left: calc(532px / 4 * 2);
} }
&.l-4 { &.l-4 {
left: 424px; left: calc(532px / 4 * 3);
} }
&.l-5 { &.l-5 {
left: 557px; left: calc(532px / 4 * 4);
} }
// adjust when enhanced
.wide.enhanced & {
&.l-1 {
left: 0px;
}
&.l-2 {
left: calc(726px / 6 * 1);
}
&.l-3 {
left: calc(726px / 6 * 2);
}
&.l-4 {
left: calc(726px / 6 * 3);
}
&.l-5 {
left: calc(726px / 6 * 4);
}
&.l-6 {
left: calc(726px / 6 * 5);
}
&.l-7 {
left: calc(726px / 6 * 6);
}
}
// only in wide + enhanced
&.l-6,
&.l-7 {
display: none;
.wide.enhanced & {
display: block;
}
}
} }
} }
.chart { .chart {
@@ -92,6 +144,11 @@
img { img {
width: 532px; width: 532px;
height: 285px; height: 285px;
// wide and enhanced
.wide.enhanced & {
width: 746px;
}
} }
} }
@@ -110,41 +167,18 @@
} }
&.l-2 { &.l-2 {
top: 140px; top: calc(280px / 3);
} }
&.l-3 { &.l-3 {
bottom: calc(280px / 3 - 11px);
}
&.l-4 {
bottom: 0px; bottom: 0px;
} }
} }
} }
.column-headers {
background-color: c.$column-header;
height: 20px;
position: absolute;
width: 100%;
}
.column-headers {
position: sticky;
top: 0px;
z-index: 5;
.temp {
left: 355px;
}
.like {
left: 435px;
}
.wind {
left: 535px;
}
}
} }
} }

View File

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

View File

@@ -32,6 +32,12 @@
display: inline-block; display: inline-block;
} }
} }
.like {
display: none;
}
} }
.temp { .temp {
@@ -46,6 +52,41 @@
left: 430px; left: 430px;
} }
.like {
display: none;
}
// wide and enhanced moves the columns and enables the like column
.wide.enhanced & {
.temp {
left: 320px;
}
.like {
left: 380px;
display: block;
&.heat-index {
color: c.$heat-index;
display: block;
}
&.wind-chill {
display: block;
color: c.$wind-chill;
}
}
.weather {
left: 470px;
}
.wind {
left: 630px;
}
}
.observation-lines { .observation-lines {
min-height: 338px; min-height: 338px;
padding-top: 10px; padding-top: 10px;

View File

@@ -1,7 +1,14 @@
@use 'shared/_colors'as c; @use 'shared/_colors'as c;
@use 'shared/_utils'as u; @use 'shared/_utils'as u;
@use 'shared/positions'as p;
.weather-display .local-forecast { .weather-display .local-forecast {
// clamp width to standard
&.main {
width: calc(p.$standard-width - (2 * p.$blue-box-margin));
}
.container { .container {
position: relative; position: relative;
top: 15px; top: 15px;
@@ -18,7 +25,6 @@
.forecast { .forecast {
font-family: 'Star4000'; font-family: 'Star4000';
font-size: 24pt; font-size: 24pt;
text-transform: uppercase;
@include u.text-shadow(); @include u.text-shadow();
min-height: 280px; min-height: 280px;
line-height: 40px; line-height: 40px;

View File

@@ -2,8 +2,9 @@
display: none; display: none;
} }
#ToggleMedia { #ToggleMediaContainer {
display: none; display: none;
position: relative;
&.available { &.available {
display: inline-block; display: inline-block;
@@ -31,4 +32,32 @@
} }
.volume-slider {
display: none;
position: absolute;
top: 0px;
transform: translateY(-100%);
width: 100%;
background-color: #000;
text-align: center;
z-index: 100;
@media (prefers-color-scheme: dark) {
background-color: #303030;
}
input[type="range"] {
writing-mode: vertical-lr;
direction: rtl;
margin-top: 20px;
margin-bottom: 20px;
}
&.show {
display: block;
}
}
} }

View File

@@ -1,5 +1,6 @@
@use 'shared/_utils'as u; @use 'shared/_utils'as u;
@use 'shared/_colors'as c; @use 'shared/_colors'as c;
@use 'shared/positions'as p;
@font-face { @font-face {
font-family: "Star4000"; font-family: "Star4000";
@@ -33,7 +34,7 @@ body {
} }
#divQuery { #divQuery {
max-width: 640px; max-width: p.$standard-width;
padding: 8px; padding: 8px;
.buttons { .buttons {
@@ -146,11 +147,11 @@ body {
background-color: #000000; background-color: #000000;
color: #ffffff; color: #ffffff;
width: 100%; width: 100%;
max-width: 640px; max-width: p.$standard-width;
margin: 0; // Ensure edge-to-edge display margin: 0; // Ensure edge-to-edge display
&.wide { &.wide {
max-width: 854px; max-width: p.$wide-width;
} }
} }
@@ -159,12 +160,12 @@ body {
} }
#divTwcMain { #divTwcMain {
width: 640px; width: p.$standard-width;
height: 480px; height: p.$standard-height;
position: relative; position: relative;
.wide & { .wide & {
width: 854px; width: p.$wide-width;
} }
} }
@@ -209,10 +210,10 @@ body {
background-color: #000000; background-color: #000000;
color: #ffffff; color: #ffffff;
width: 640px; width: p.$standard-width;
.wide & { .wide & {
width: 854px; width: p.$wide-width;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -274,7 +275,7 @@ body {
flex-direction: row; flex-direction: row;
background-color: #000000; background-color: #000000;
color: #ffffff; color: #ffffff;
max-width: 640px; max-width: p.$standard-width;
} }
#divTwcNav>div { #divTwcNav>div {
@@ -336,8 +337,8 @@ body {
#container { #container {
position: relative; position: relative;
width: 640px; width: p.$standard-width;
height: 480px; height: p.$standard-height;
// overflow: hidden; // overflow: hidden;
background-image: url(../images/backgrounds/1.png); background-image: url(../images/backgrounds/1.png);
transform-origin: 0 0; transform-origin: 0 0;
@@ -345,8 +346,7 @@ body {
} }
.wide #container { .wide #container {
padding-left: 107px; width: p.$wide-width;
padding-right: 107px;
background: url(../images/backgrounds/1-wide.png); background: url(../images/backgrounds/1-wide.png);
background-repeat: no-repeat; background-repeat: no-repeat;
} }
@@ -359,8 +359,8 @@ body {
} }
#loading { #loading {
width: 640px; width: p.$standard-width;
height: 480px; height: p.$standard-height;
max-width: 100%; max-width: 100%;
text-shadow: 4px 4px black; text-shadow: 4px 4px black;
display: flex; display: flex;
@@ -368,6 +368,10 @@ body {
text-align: center; text-align: center;
justify-content: center; justify-content: center;
.wide & {
margin-left: p.$wide-margin;
}
.title { .title {
font-family: Star4000 Large; font-family: Star4000 Large;
font-size: 36px; font-size: 36px;
@@ -815,4 +819,25 @@ body.kiosk #loading .instructions {
>*:not(#divTwc) { >*:not(#divTwc) {
display: none !important; display: none !important;
} }
}
#divInfo {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 250px;
}
.lower-flex-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
align-items: flex-start;
align-content: stretch;
gap: 8px;
column-gap: 64px;
.item {
display: inline-block;
}
} }

View File

@@ -1,17 +1,23 @@
@use 'shared/_colors' as c; @use 'shared/_colors'as c;
@use 'shared/_utils' as u; @use 'shared/_utils'as u;
@use 'shared/positions'as p;
.weather-display .progress { .weather-display .progress {
@include u.text-shadow(); @include u.text-shadow();
font-family: 'Star4000 Extended'; font-family: 'Star4000 Extended';
font-size: 19pt; font-size: 19pt;
// clamp width to standard
&.main {
width: calc(p.$standard-width - (2 * p.$blue-box-margin));
}
.container { .container {
position: relative; position: relative;
top: 15px; top: 15px;
margin: 0px 10px; margin: 0px 10px;
box-sizing: border-box; box-sizing: border-box;
height: 310px; height: p.$standard-scroll-height;
overflow: hidden; overflow: hidden;
line-height: 28px; line-height: 28px;
@@ -118,4 +124,4 @@
transition: width 1s steps(6); transition: width 1s steps(6);
} }
} }
} }

View File

@@ -1,9 +1,14 @@
@use 'shared/_colors'as c; @use 'shared/_colors'as c;
@use 'shared/_utils'as u; @use 'shared/_utils'as u;
@use 'shared/positions'as p;
#radar-html.weather-display { #radar-html.weather-display {
background-image: url('../images/backgrounds/4.png'); background-image: url('../images/backgrounds/4.png');
.wide & {
background: url(../images/backgrounds/4-wide.png);
}
.header { .header {
height: 83px; height: 83px;
@@ -104,12 +109,13 @@
.weather-display .main.radar { .weather-display .main.radar {
overflow: hidden; overflow: hidden;
height: 367px; height: 367px;
width: p.$standard-width;
.container { .container {
.tiles { .tiles {
position: absolute; position: absolute;
width: 1400px; width: 2040px;
img { img {
vertical-align: middle; vertical-align: middle;
@@ -120,8 +126,4 @@
position: relative; position: relative;
} }
} }
}
.wide.radar #container {
background: url(../images/backgrounds/4-wide.png);
} }

View File

@@ -1,10 +1,6 @@
@use 'shared/_colors'as c; @use 'shared/_colors'as c;
@use 'shared/_utils'as u; @use 'shared/_utils'as u;
#regional-forecast-html.weather-display {
background-image: url('../images/backgrounds/5.png');
}
.weather-display .main.regional-forecast { .weather-display .main.regional-forecast {

View File

@@ -1,5 +1,6 @@
@use 'shared/_colors'as c; @use 'shared/_colors'as c;
@use 'shared/_utils'as u; @use 'shared/_utils'as u;
@use 'shared/positions'as p;
#spc-outlook-html.weather-display { #spc-outlook-html.weather-display {
background-image: url('../images/backgrounds/6.png'); background-image: url('../images/backgrounds/6.png');

View File

@@ -1,29 +1,50 @@
@use 'shared/_colors'as c; @use 'shared/_colors'as c;
@use 'shared/_utils'as u; @use 'shared/_utils'as u;
@use 'shared/positions'as p;
.weather-display { .weather-display {
width: 640px; width: p.$standard-width;
height: 480px; height: p.$standard-height;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background-image: url(../images/backgrounds/1.png); background-image: url(../images/backgrounds/1.png);
// adjust for wide
.wide & {
width: p.$wide-width;
background-position-x: p.$wide-margin;
background-repeat: no-repeat;
}
.wide.enhanced & {
&:has(.can-enhance) {
background-image: url(../images/backgrounds/1-wide-enhanced.png);
background-position-x: 0px;
}
}
/* this method is required to hide blocks so they can be measured while off screen */ /* this method is required to hide blocks so they can be measured while off screen */
height: 0px; height: 0px;
&.show { &.show {
height: 480px; height: p.$standard-height;
} }
.template { .template {
display: none; display: none;
} }
.header { >.header {
width: 640px; width: p.$standard-width;
height: 60px; height: 60px;
position: relative;
padding-top: 30px; padding-top: 30px;
// adjust for wide
.wide & {
left: p.$wide-margin;
}
.title { .title {
color: c.$title-color; color: c.$title-color;
@include u.text-shadow(3px, 1.5px); @include u.text-shadow(3px, 1.5px);
@@ -92,10 +113,23 @@
.main { .main {
position: relative; position: relative;
// adjust for wide
.wide & {
left: p.$wide-margin;
}
// adjust for enhanced when possible
.wide.enhanced & {
&.can-enhance {
left: 0px;
width: p.$wide-width;
}
}
&.has-scroll { &.has-scroll {
width: 640px; width: p.$standard-width;
margin-top: 0; margin-top: 0;
height: 310px; height: p.$standard-scroll-height;
overflow: hidden; overflow: hidden;
&.no-header { &.no-header {
@@ -105,9 +139,15 @@
} }
&.has-box { &.has-box {
margin-left: 64px; margin-left: p.$blue-box-margin;
margin-right: 64px; margin-right: p.$blue-box-margin;
width: calc(100% - 128px); width: calc(100% - 128px);
.wide.enhanced & {
&.can-enhance {
width: calc(p.$wide-width - p.$blue-box-margin - p.$blue-box-margin)
}
}
} }
} }
@@ -117,7 +157,7 @@
#container>.scroll { #container>.scroll {
display: none; display: none;
@include u.text-shadow(3px, 1.5px); @include u.text-shadow(3px, 1.5px);
width: 640px; width: p.$standard-width;
height: 77px; height: 77px;
overflow: hidden; overflow: hidden;
margin-top: 3px; margin-top: 3px;
@@ -125,12 +165,17 @@
bottom: 0px; bottom: 0px;
z-index: 1; z-index: 1;
// adjust for wide
.wide & {
left: p.$wide-margin;
}
&.hazard { &.hazard {
background-color: rgb(112, 35, 35); background-color: rgb(112, 35, 35);
} }
.scroll-container { .scroll-container {
width: 640px; width: p.$standard-width;
.fixed, .fixed,
.scroll-header { .scroll-header {
@@ -156,7 +201,7 @@
position: relative; position: relative;
// the following added by js code as it is dependent on the content of the element // the following added by js code as it is dependent on the content of the element
// transition: left (x)s; // transition: left (x)s;
// left: calc((elem width) - 640px); // left: calc((elem width) - p.$standard-width);
} }
} }
} }
@@ -166,10 +211,10 @@
} }
.wide #container>.scroll { .wide #container>.scroll {
width: 854px; width: p.$wide-width;
margin-left: -107px; margin-left: -1*p.$wide-margin;
.scroll-container { .scroll-container {
margin-left: 107px; margin-left: p.$wide-margin;
} }
} }

View File

@@ -13,5 +13,7 @@ $gradient-loading-3: #4f99f9;
$gradient-loading-4: #8ffdfa; $gradient-loading-4: #8ffdfa;
$extended-low: #8080FF; $extended-low: #8080FF;
$wind-chill: #8080FF;
$heat-index: #e00;
$blue-box: #26235a; $blue-box: #26235a;

View File

@@ -0,0 +1,14 @@
// standard positioning
$standard-width: 640px;
$standard-height: 480px;
// height with scroll
$standard-scroll-height: 310px;
// blue box size
$blue-box-margin: 64px;
// wide screen positioning
$wide-padding: 107px;
$wide-margin: 107px;
$wide-width: 854px;

1
server/styles/ws.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

327
tests/package-lock.json generated
View File

@@ -14,12 +14,12 @@
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0", "js-tokens": "^4.0.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
@@ -28,26 +28,26 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@puppeteer/browsers": { "node_modules/@puppeteer/browsers": {
"version": "2.10.5", "version": "2.13.0",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.5.tgz", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz",
"integrity": "sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==", "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"debug": "^4.4.1", "debug": "^4.4.3",
"extract-zip": "^2.0.1", "extract-zip": "^2.0.1",
"progress": "^2.0.3", "progress": "^2.0.3",
"proxy-agent": "^6.5.0", "proxy-agent": "^6.5.0",
"semver": "^7.7.2", "semver": "^7.7.4",
"tar-fs": "^3.0.8", "tar-fs": "^3.1.1",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"bin": { "bin": {
@@ -64,13 +64,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.15.29", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/yauzl": { "node_modules/@types/yauzl": {
@@ -84,9 +84,9 @@
} }
}, },
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.3", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
@@ -135,28 +135,44 @@
} }
}, },
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.6.7", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
"integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
"license": "Apache-2.0" "license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
}, },
"node_modules/bare-events": { "node_modules/bare-events": {
"version": "2.5.4", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true "peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
}, },
"node_modules/bare-fs": { "node_modules/bare-fs": {
"version": "4.1.5", "version": "4.5.6",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz",
"integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", "integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"bare-events": "^2.5.4", "bare-events": "^2.5.4",
"bare-path": "^3.0.0", "bare-path": "^3.0.0",
"bare-stream": "^2.6.4" "bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
}, },
"engines": { "engines": {
"bare": ">=1.16.0" "bare": ">=1.16.0"
@@ -171,11 +187,10 @@
} }
}, },
"node_modules/bare-os": { "node_modules/bare-os": {
"version": "3.6.1", "version": "3.8.0",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz",
"integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"bare": ">=1.14.0" "bare": ">=1.14.0"
} }
@@ -185,25 +200,28 @@
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"bare-os": "^3.0.1" "bare-os": "^3.0.1"
} }
}, },
"node_modules/bare-stream": { "node_modules/bare-stream": {
"version": "2.6.5", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.11.0.tgz",
"integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", "integrity": "sha512-Y/+iQ49fL3rIn6w/AVxI/2+BRrpmzJvdWt5Jv8Za6Ngqc6V227c+pYjYYgLdpR3MwQ9ObVXD0ZrqoBztakM0rw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"streamx": "^2.21.0" "streamx": "^2.25.0",
"teex": "^1.0.1"
}, },
"peerDependencies": { "peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*", "bare-buffer": "*",
"bare-events": "*" "bare-events": "*"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": { "bare-buffer": {
"optional": true "optional": true
}, },
@@ -212,10 +230,19 @@
} }
} }
}, },
"node_modules/bare-url": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz",
"integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/basic-ftp": { "node_modules/basic-ftp": {
"version": "5.0.5", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
"integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@@ -240,9 +267,9 @@
} }
}, },
"node_modules/chalk": { "node_modules/chalk": {
"version": "5.4.1", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0" "node": "^12.17.0 || ^14.13 || >=16.0.0"
@@ -252,9 +279,9 @@
} }
}, },
"node_modules/chromium-bidi": { "node_modules/chromium-bidi": {
"version": "5.1.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-5.1.0.tgz", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz",
"integrity": "sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==", "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"mitt": "^3.0.1", "mitt": "^3.0.1",
@@ -297,9 +324,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
"version": "9.0.0", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz",
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"env-paths": "^2.2.1", "env-paths": "^2.2.1",
@@ -332,9 +359,9 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -363,9 +390,9 @@
} }
}, },
"node_modules/devtools-protocol": { "node_modules/devtools-protocol": {
"version": "0.0.1452169", "version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1452169.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-FOFDVMGrAUNp0dDKsAU1TorWJUx2JOU1k9xdgBKKJF3IBh/Uhl2yswG5r3TEAOrCiGY2QRp1e6LVDQrCsTKO4g==", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
@@ -375,9 +402,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
"version": "1.4.4", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"once": "^1.4.0" "once": "^1.4.0"
@@ -393,9 +420,9 @@
} }
}, },
"node_modules/error-ex": { "node_modules/error-ex": {
"version": "1.3.2", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-arrayish": "^0.2.1" "is-arrayish": "^0.2.1"
@@ -462,6 +489,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/extract-zip": { "node_modules/extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -522,9 +558,9 @@
} }
}, },
"node_modules/get-uri": { "node_modules/get-uri": {
"version": "6.0.4", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
"integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"basic-ftp": "^5.0.2", "basic-ftp": "^5.0.2",
@@ -578,14 +614,10 @@
} }
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "9.0.5", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT", "license": "MIT",
"dependencies": {
"jsbn": "1.1.0",
"sprintf-js": "^1.1.3"
},
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
} }
@@ -612,9 +644,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -623,12 +655,6 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/jsbn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": { "node_modules/json-parse-even-better-errors": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -789,9 +815,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.2", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"end-of-stream": "^1.1.0", "end-of-stream": "^1.1.0",
@@ -799,18 +825,18 @@
} }
}, },
"node_modules/puppeteer": { "node_modules/puppeteer": {
"version": "24.10.0", "version": "24.40.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.10.0.tgz", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz",
"integrity": "sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==", "integrity": "sha512-IxQbDq93XHVVLWHrAkFP7F7iHvb9o0mgfsSIMlhHb+JM+JjM1V4v4MNSQfcRWJopx9dsNOr9adYv0U5fm9BJBQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@puppeteer/browsers": "2.10.5", "@puppeteer/browsers": "2.13.0",
"chromium-bidi": "5.1.0", "chromium-bidi": "14.0.0",
"cosmiconfig": "^9.0.0", "cosmiconfig": "^9.0.0",
"devtools-protocol": "0.0.1452169", "devtools-protocol": "0.0.1581282",
"puppeteer-core": "24.10.0", "puppeteer-core": "24.40.0",
"typed-query-selector": "^2.12.0" "typed-query-selector": "^2.12.1"
}, },
"bin": { "bin": {
"puppeteer": "lib/cjs/puppeteer/node/cli.js" "puppeteer": "lib/cjs/puppeteer/node/cli.js"
@@ -820,17 +846,18 @@
} }
}, },
"node_modules/puppeteer-core": { "node_modules/puppeteer-core": {
"version": "24.10.0", "version": "24.40.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.10.0.tgz", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.40.0.tgz",
"integrity": "sha512-xX0QJRc8t19iAwRDsAOR38Q/Zx/W6WVzJCEhKCAwp2XMsaWqfNtQ+rBfQW9PlF+Op24d7c8Zlgq9YNmbnA7hdQ==", "integrity": "sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@puppeteer/browsers": "2.10.5", "@puppeteer/browsers": "2.13.0",
"chromium-bidi": "5.1.0", "chromium-bidi": "14.0.0",
"debug": "^4.4.1", "debug": "^4.4.3",
"devtools-protocol": "0.0.1452169", "devtools-protocol": "0.0.1581282",
"typed-query-selector": "^2.12.0", "typed-query-selector": "^2.12.1",
"ws": "^8.18.2" "webdriver-bidi-protocol": "0.4.1",
"ws": "^8.19.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -855,9 +882,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.2", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -877,12 +904,12 @@
} }
}, },
"node_modules/socks": { "node_modules/socks": {
"version": "2.8.4", "version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ip-address": "^9.0.5", "ip-address": "^10.0.1",
"smart-buffer": "^4.2.0" "smart-buffer": "^4.2.0"
}, },
"engines": { "engines": {
@@ -914,23 +941,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause"
},
"node_modules/streamx": { "node_modules/streamx": {
"version": "2.22.0", "version": "2.25.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
"integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2", "fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0" "text-decoder": "^1.1.0"
},
"optionalDependencies": {
"bare-events": "^2.2.0"
} }
}, },
"node_modules/string-width": { "node_modules/string-width": {
@@ -960,9 +979,9 @@
} }
}, },
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "3.0.9", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
"integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==", "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"pump": "^3.0.0", "pump": "^3.0.0",
@@ -974,20 +993,30 @@
} }
}, },
"node_modules/tar-stream": { "node_modules/tar-stream": {
"version": "3.1.7", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"b4a": "^1.6.4", "b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0", "fast-fifo": "^1.2.0",
"streamx": "^2.15.0" "streamx": "^2.15.0"
} }
}, },
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/text-decoder": { "node_modules/text-decoder": {
"version": "1.2.3", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"b4a": "^1.6.4" "b4a": "^1.6.4"
@@ -1000,18 +1029,24 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/typed-query-selector": { "node_modules/typed-query-selector": {
"version": "2.12.0", "version": "2.12.1",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz",
"integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -1036,9 +1071,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.18.2", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@@ -1103,9 +1138,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.49", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.49.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@@ -36,7 +36,7 @@
const OVERRIDES = <%- JSON.stringify(OVERRIDES ?? {}) %>; const OVERRIDES = <%- JSON.stringify(OVERRIDES ?? {}) %>;
</script> </script>
<% } else { %> <% } else { %>
<link rel="stylesheet" type="text/css" href="styles/main.css" /> <link rel="stylesheet" type="text/css" href="styles/ws.min.css" />
<!--<script type="text/javascript">const OVERRIDES={};</script>--> <!--<script type="text/javascript">const OVERRIDES={};</script>-->
<script type="text/javascript"> <script type="text/javascript">
OVERRIDES = <%- JSON.stringify(OVERRIDES ?? {}) %>; OVERRIDES = <%- JSON.stringify(OVERRIDES ?? {}) %>;
@@ -62,16 +62,16 @@
<script type="module" src="scripts/modules/radar.mjs"></script> <script type="module" src="scripts/modules/radar.mjs"></script>
<script type="module" src="scripts/modules/settings.mjs"></script> <script type="module" src="scripts/modules/settings.mjs"></script>
<script type="module" src="scripts/modules/media.mjs"></script> <script type="module" src="scripts/modules/media.mjs"></script>
<script type="module" src="scripts/modules/custom-rss-feed.mjs"></script> <script type="module" src="scripts/modules/custom-scroll-text.mjs"></script>
<script type="module" src="scripts/index.mjs"></script> <script type="module" src="scripts/index.mjs"></script>
<% } %> <% } %>
</head> </head>
<body <% if (query && query['settings-kiosk-checkbox'] === 'true' ) { %>class="kiosk" <% }%>> <body <% if (query && (query['kiosk'] === true || query['settings-kiosk-checkbox'] === 'true' )) { %>class="kiosk" <% }%>>
<div id="divQuery"> <div id="divQuery">
<input id="txtLocation" type="text" value="" placeholder="ZIP Code or City, State" data-1p-ignore /> <input id="txtLocation" name="txtLocation" type="text" value="" placeholder="ZIP Code or City, State" data-1p-ignore />
<div class="buttons"> <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" /> <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" /> <img src="images/nav/ic_gps_fixed_white_18dp_1x.png" class="dark" />
@@ -147,9 +147,15 @@
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_2x.png" title="Refresh" /> <img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_2x.png" title="Refresh" />
</div> </div>
<div id="divTwcBottomRight"> <div id="divTwcBottomRight">
<div id="ToggleMedia"> <div id="ToggleMediaContainer">
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" /> <div id="ToggleMedia">
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Mute" /> <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="Volume" />
</div>
<div class="volume-slider">
<input type="range" min="1" max="100" value="75" /><br>
<img class="navButton" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Mute" />
</div>
</div> </div>
<div id="ToggleScanlines"> <div id="ToggleScanlines">
<img class="navButton off" src="images/nav/ic_scanlines_off_white_24dp_2x.png" title="Scan lines on" /> <img class="navButton off" src="images/nav/ic_scanlines_off_white_24dp_2x.png" title="Scan lines on" />
@@ -168,21 +174,25 @@
</div> </div>
<div class="media"></div> <div class="media"></div>
<div class='heading'>Selected displays</div> <div class='lower-flex-container'>
<div id='enabledDisplays'> <div class='item'>
<div class='heading'>Selected displays</div>
<div id='enabledDisplays'>
</div> </div>
</div>
<div class='heading'>Settings</div> <div class='item'>
<div id='settings'> <div class='heading'>Settings</div>
</div> <div id='settings'>
<div class='heading'>Sharing</div>
<div class='heading'>Sharing</div> </div>
<div class='info'> <div class='item'>
<a href='' id='share-link'>Copy Permalink</a> <span id="share-link-copied">Link copied to clipboard!</span> <a href='' id='share-link'>Copy Permalink</a> <span id="share-link-copied">Link copied to clipboard!</span>
<div id="share-link-instructions"> <div id="share-link-instructions">
Copy this long URL: Copy this long URL:
<input type='text' id="share-link-url"> <input type='text' id="share-link-url">
</div>
</div>
</div> </div>
</div> </div>
@@ -205,7 +215,6 @@
<div class="header">Ws4kp Version:</div> <div class="header">Ws4kp Version:</div>
<div class="header"><span><%- version %></span></div> <div class="header"><span><%- version %></span></div>
</div> </div>
</div>
</body> </body>

View File

@@ -1,15 +1,18 @@
<%- include('header.ejs', {title:'Almanac', hasTime: true}) %> <%- include('header.ejs', {title:'Almanac', hasTime: true}) %>
<div class="main has-scroll almanac"> <div class="main has-scroll almanac can-enhance">
<div class="sun"> <div class="sun">
<div class="grid-item empty"></div> <div class="grid-item empty"></div>
<div class="grid-item header day-0"></div>
<div class="grid-item header day-1"></div> <div class="grid-item header day-1"></div>
<div class="grid-item header day-2"></div> <div class="grid-item header day-2 wide-enhanced"></div>
<div class="grid-item row-label">Sunrise:</div> <div class="grid-item row-label">Sunrise:</div>
<div class="grid-item time rise-0"></div>
<div class="grid-item time rise-1"></div> <div class="grid-item time rise-1"></div>
<div class="grid-item time rise-2"></div> <div class="grid-item time rise-2 wide-enhanced"></div>
<div class="grid-item row-label">Sunset:</div> <div class="grid-item row-label">Sunset:</div>
<div class="grid-item time set-0"></div>
<div class="grid-item time set-1"></div> <div class="grid-item time set-1"></div>
<div class="grid-item time set-2"></div> <div class="grid-item time set-2 wide-enhanced"></div>
</div> </div>
<div class="moon"> <div class="moon">
<div class="title">Moon Data:</div> <div class="title">Moon Data:</div>

View File

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

View File

@@ -1,7 +1,8 @@
<%- include('header.ejs', {title: 'Hourly Graph' , hasTime: false }) %> <%- include('header.ejs', {title: 'Hourly Graph' , hasTime: false }) %>
<div class="main has-scroll hourly-graph"> <div class="main has-scroll hourly-graph can-enhance">
<div class="top-right template "> <div class="top-right template ">
<div class="temperature">Temperature</div> <div class="temperature">Temperature</div>
<div class="dewpoint">Dewpoint</div>
<div class="cloud">Cloud %</div> <div class="cloud">Cloud %</div>
<div class="rain">Precip %</div> <div class="rain">Precip %</div>
</div> </div>
@@ -9,6 +10,7 @@
<div class="label l-1">75</div> <div class="label l-1">75</div>
<div class="label l-2">65</div> <div class="label l-2">65</div>
<div class="label l-3">55</div> <div class="label l-3">55</div>
<div class="label l-4">45</div>
</div> </div>
<div class="chart"> <div class="chart">
<img id="chart-area"></img> <img id="chart-area"></img>
@@ -19,5 +21,7 @@
<div class="label l-3">12p</div> <div class="label l-3">12p</div>
<div class="label l-4">6p</div> <div class="label l-4">6p</div>
<div class="label l-5">12a</div> <div class="label l-5">12a</div>
<div class="label l-6">6a</div>
<div class="label l-7">12p</div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,10 @@
<%- include('header.ejs', {titleDual:{ top: 'Latest' , bottom: 'Observations' }, noaaLogo: true, hasTime: true }) %> <%- include('header.ejs', {titleDual:{ top: 'Latest' , bottom: 'Observations' }, noaaLogo: true, hasTime: true }) %>
<div class="main has-scroll latest-observations has-box"> <div class="main has-scroll latest-observations has-box can-enhance">
<div class="container"> <div class="container">
<div class="column-headers"> <div class="column-headers">
<div class="temp english">&deg;F</div> <div class="temp english">&deg;F</div>
<div class="temp metric">&deg;C</div> <div class="temp metric">&deg;C</div>
<div class="like">Like</div>
<div class="weather">Weather</div> <div class="weather">Weather</div>
<div class="wind">Wind</div> <div class="wind">Wind</div>
</div> </div>
@@ -11,6 +12,7 @@
<div class="observation-row template"> <div class="observation-row template">
<div class="location"></div> <div class="location"></div>
<div class="temp"></div> <div class="temp"></div>
<div class="like"></div>
<div class="weather"></div> <div class="weather"></div>
<div class="wind"></div> <div class="wind"></div>
</div> </div>

View File

@@ -1,12 +1,12 @@
<%- include('header.ejs', {titleDual:{ top: 'Local' , bottom: 'Forecast' }, hasTime: true, noaaLogo: true}) %> <%- include('header.ejs', {titleDual:{ top: 'Local' , bottom: 'Forecast' }, hasTime: true, noaaLogo: true}) %>
<div class="main has-scroll has-box local-forecast"> <div class="main has-scroll has-box local-forecast can-enhance">
<div class="container"> <div class="container">
<div class="forecasts"> <div class="forecasts">
<div class="forecast template"> <div class="forecast template">
<div class="text"> <div class="text">
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<%- include('scroll.ejs') %> <%- include('scroll.ejs') %>

View File

@@ -1,45 +1,45 @@
<div class="header"> <div class="header">
<div class="logo"><img src="images/logos/logo-corner.png" /></div> <div class="logo"><img src="images/logos/logo-corner.png" /></div>
<div class="title dual"> <div class="title dual">
<div class="top"> <div class="top">
Local Local
</div> </div>
<div class="bottom"> <div class="bottom">
Radar Radar
</div> </div>
</div> </div>
<div class="right"> <div class="right">
<div class="precip"> <div class="precip">
<div class="precip-header">PRECIP</div> <div class="precip-header">PRECIP</div>
<div class="scale"> <div class="scale">
<div class="text">Light</div> <div class="text">Light</div>
<div class="scale-table"> <div class="scale-table">
<div class="box box-1"></div> <div class="box box-1"></div>
<div class="box box-2"></div> <div class="box box-2"></div>
<div class="box box-3"></div> <div class="box box-3"></div>
<div class="box box-4"></div> <div class="box box-4"></div>
<div class="box box-5"></div> <div class="box box-5"></div>
<div class="box box-6"></div> <div class="box box-6"></div>
<div class="box box-7"></div> <div class="box box-7"></div>
<div class="box box-7"></div> <div class="box box-7"></div>
</div> </div>
<div class="text">Heavy</div> <div class="text">Heavy</div>
</div> </div>
<div class="time"></div> <div class="time"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="main radar"> <div class="main radar can-enhance">
<div class="container"> <div class="container">
<div class="map-tiles tiles"><img/><img/><img/><img/></div> <div class="map-tiles tiles"><img /><img /><img /><img /><img /><img /></div>
<div class="scroll-area"> <div class="scroll-area">
<div class="frame template"> <div class="frame template">
<div class="map"> <div class="map">
<img/> <img />
</div> </div>
</div> </div>
</div> </div>
<div class="overlay-tiles tiles"><img/><img/><img/><img/></div> <div class="overlay-tiles tiles"><img /><img /><img /><img /><img /></div>
</div> </div>
</div> </div>

View File

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

View File

@@ -45,7 +45,8 @@
"unmuted", "unmuted",
"dumpio", "dumpio",
"mesonet", "mesonet",
"metar" "metar",
"Unmute"
], ],
"cSpell.ignorePaths": [ "cSpell.ignorePaths": [
"**/package-lock.json", "**/package-lock.json",