Compare commits

...

37 Commits

Author SHA1 Message Date
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
Matt Walsh
7900e59aab 6.2.7 2025-11-05 05:24:14 +00:00
Matt Walsh
9b422dd697 expose more data for scroll messages 2025-11-05 05:24:04 +00:00
Matt Walsh
e4ce0b6cc6 update bug template 2025-11-04 05:49:10 +00:00
Matt Walsh
b0e5018179 6.2.6 2025-10-22 00:22:44 +00:00
Matt Walsh
6422589b5c fix wind speed on hourly close #157 2025-10-22 00:22:29 +00:00
Matt Walsh
407da90f8a 6.2.5 2025-10-21 04:15:53 +00:00
Matt Walsh
3a0e6aa345 Merge branch 'geolookup-latlongquery' 2025-10-21 04:15:38 +00:00
Matt Walsh
650dda7b61 allow for latLon only in query string #154 2025-10-21 04:12:21 +00:00
Matt Walsh
8f1e8ffb74 Merge branch 'rmitchellscott-static-redirect-index' 2025-10-21 03:26:25 +00:00
Mitchell Scott
93af84cbd8 fix: geolookup if only latLonQuery is provided 2025-10-20 13:37:18 -06:00
Mitchell Scott
117f66e9d0 fix(static builds): duplicate query params 2025-10-20 13:28:45 -06:00
Mitchell Scott
bca9376edc fix: nginx query parameter redirect works like node.js 2025-10-20 11:42:42 -06:00
Matt Walsh
8b076db25d 6.2.4 2025-10-17 00:51:09 +00:00
Matt Walsh
807932fe3c Merge branch 'ios-regex' close #137 2025-10-17 00:49:59 +00:00
Matt Walsh
7bb024eff5 6.2.3 2025-10-17 00:36:14 +00:00
Matt Walsh
f4a1a3a1d8 add hazards before custom scroll options close #149 2025-10-17 00:35:26 +00:00
Matt Walsh
9a5efe9d48 update dependencies 2025-10-17 00:14:59 +00:00
Matt Walsh
58e0611a46 6.2.2 2025-10-16 19:00:30 -05:00
Matt Walsh
9ed496c892 better formatting for headend info 2025-10-16 19:00:20 -05:00
Matt Walsh
31315d1ace add com.chrome.devtools.json 2025-10-16 18:36:41 -05:00
Matt Walsh
77838e1a81 use locally stored weather parameters in spc outlook close #150 2025-10-15 00:29:23 +00:00
Matt Walsh
64d6484bd8 Merge pull request #151 from bparkin1283/patch-1
Update README.md clarifying displays if you're within one of the high…
2025-10-09 11:17:13 -05:00
bparkin1283
20cab8c25e Update README.md clarifying displays if you're within one of the highlight areas 2025-10-09 10:55:33 -05:00
Matt Walsh
b4de17ccd0 update dependencies 2025-10-02 21:50:28 -05:00
Matt Walsh
0fd90feb7a update community notes 2025-10-02 21:37:36 -05:00
Matt Walsh
8c3b596b69 add build script for travel cities #146 2025-10-02 21:26:45 -05:00
Matt Walsh
e57b9bcb20 6.2.1 2025-09-24 22:33:59 -05:00
Matt Walsh
e27750e915 fix load order on scroll when compiled 2025-09-24 22:33:47 -05:00
Matt Walsh
14b1891efd direct check of regex lookbehind capability 2025-09-11 08:47:16 -05:00
33 changed files with 1342 additions and 3382 deletions

View File

@@ -12,3 +12,4 @@ Please do not report issues with api.weather.gov being down. It's a new service
Please include:
* Web browser and OS
* Headend Information text block from the very bottom of the web page
* How you're running Weatherstar (Node, Dockerfile, Dockerfile.server, etc.)

View File

@@ -136,7 +136,7 @@ services:
# Each argument in the permalink URL can become an environment variable on the Docker host by adding WSQS_
# Following the "Sharing a Permalink" example below, here are a few environment variables defined. Visit that section for a
# more complete list of configuration options.
- WSQS_latLonQuery="Orlando International Airport Orlando FL USA"
- WSQS_latLonQuery=Orlando International Airport Orlando FL USA
- WSQS_hazards_checkbox=false
- WSQS_current_weather_checkbox=true
ports:
@@ -179,7 +179,7 @@ I've made several changes to this Weather Star 4000 simulation compared to the o
* Radar displays the timestamp of the image.
* A new hour-by-hour graph of the temperature, cloud cover and precipitation chances for the next 24 hours.
* A new hourly forecast display for the next 24 hours is available, and is shown in the style of the travel cities forecast. (off by default because it duplicates the hourly graph)
* The SPC Outlook is shown in the style of the old air quality screen. This shows the probability of severe weather over the next 3 days at your location.
* The SPC Outlook is shown in the style of the old air quality screen. This shows the probability of severe weather over the next 3 days at your location. SPC outlook only displays if you're within one of the highlight areas over the next 3 day. You can view the [maps](https://www.weather.gov/crh/outlooks) and pick a location within one of the risk categories to see if the screen is working for you.
* The "Local Forecast" and "Extended Forecast" provide several additional days of information compared to the original format in the 90s.
* The original music has been replaced. More info in [Music](#music).
* Marine forecast (tides) is not available as it is not reliably part of the new API.
@@ -321,6 +321,7 @@ Thanks to the WeatherStar+ community for providing these discussions to further
* [ws4channels](https://github.com/rice9797/ws4channels) A Dockerized Node.js application to stream WeatherStar 4000 data into Channels DVR using Puppeteer and FFmpeg.
* [SSL Certificates](https://github.com/netbymatt/ws4kp/issues/135) Discussion about how to host with an SSL certificate (enables geolocation).
* [Changing playlists](https://github.com/netbymatt/ws4kp/issues/138) Possible ways to automatically change the playlist on a schedule.
* [Customize Travel Forecast Cities](https://github.com/netbymatt/ws4kp/issues/146#issuecomment-3363940202)
## Customization

View File

@@ -84,8 +84,8 @@
"Latitude": 29.7633,
"Longitude": -95.3633,
"point": {
"x": 65,
"y": 97,
"x": 63,
"y": 95,
"wfo": "HGX"
}
},

View File

@@ -14,6 +14,7 @@ import TerserPlugin from 'terser-webpack-plugin';
import { readFile } from 'fs/promises';
import file from 'gulp-file';
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
import log from 'fancy-log';
import OVERRIDES from '../src/overrides.mjs';
// get cloudfront
@@ -204,11 +205,15 @@ const buildPlaylist = async () => {
return file('playlist.json', JSON.stringify(playlist)).pipe(dest('./dist'));
};
const logVersion = async () => {
log(`Version Published: ${version}`);
};
const buildDist = series(clean, parallel(buildJs, compressJsVendor, copyCss, compressHtml, copyOtherFiles, copyDataFiles, copyImageSources, buildPlaylist));
// upload_images could be in parallel with upload, but _images logs a lot and has little changes
// by running upload last the majority of the changes will be at the bottom of the log for easy viewing
const publishFrontend = series(buildDist, uploadImages, upload, invalidate);
const publishFrontend = series(buildDist, uploadImages, upload, invalidate, logVersion);
const stageFrontend = series(previewVersion, buildDist, uploadImagesPreview, uploadPreview, invalidatePreview);
export default publishFrontend;

View File

@@ -5,8 +5,8 @@ import rename from 'gulp-rename';
const clean = () => deleteAsync(['./server/scripts/vendor/auto/**']);
const vendorFiles = [
'./node_modules/luxon/build/es6/luxon.js',
'./node_modules/luxon/build/es6/luxon.js.map',
'./node_modules/luxon/build/es6/luxon.mjs',
'./node_modules/luxon/build/es6/luxon.mjs.map',
'./node_modules/nosleep.js/dist/NoSleep.js',
'./node_modules/suncalc/suncalc.js',
'./node_modules/swiped-events/src/swiped-events.js',
@@ -23,7 +23,6 @@ const copy = () => src(vendorFiles)
path.dirname = path.dirname.toLowerCase();
path.basename = path.basename.toLowerCase();
path.extname = path.extname.toLowerCase();
if (path.basename === 'luxon') path.extname = '.mjs';
}))
.pipe(dest('./server/scripts/vendor/auto'));

View File

@@ -8,6 +8,7 @@ import {
import playlist from './src/playlist.mjs';
import OVERRIDES from './src/overrides.mjs';
import cache from './proxy/cache.mjs';
import devTools from './src/com.chrome.devtools.mjs';
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
@@ -168,6 +169,7 @@ if (process.env?.DIST === '1') {
app.use('/geoip', geoip);
app.use('/resources', express.static('./server/scripts/modules'));
app.get('/', index);
app.get('/.well-known/appspecific/com.chrome.devtools.json', devTools);
app.get('*name', express.static('./server', staticOptions));
}

View File

@@ -10,8 +10,10 @@ server {
add_header X-Weatherstar true always;
include /etc/nginx/includes/wsqs_redirect.conf;
location / {
index redirect.html index.html index.htm;
index index.html index.htm;
try_files $uri $uri/ =404;
}

4151
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ws4kp",
"version": "6.2.0",
"version": "6.3.1",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",
@@ -8,6 +8,7 @@
"start": "node index.mjs",
"stop": "pkill -f 'node index.mjs' || echo 'No process found'",
"test": "echo \"Error: no test specified\" && exit 1",
"build:travelcities": "node datagenerators/travelcities.mjs",
"build:css": "sass --style=compressed ./server/styles/scss/main.scss ./server/styles/main.css",
"build": "gulp buildDist",
"lint": "eslint ./server/scripts/**/*.mjs ./proxy/**/*.mjs ./src/**/*.mjs *.mjs",
@@ -33,6 +34,7 @@
"eslint": "^9.0.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-plugin-import": "^2.10.0",
"fancy-log": "^2.0.0",
"gulp": "^5.0.0",
"gulp-awspublish": "^8.0.0",
"gulp-concat": "^2.6.1",
@@ -44,6 +46,7 @@
"gulp-sass": "^6.0.0",
"gulp-terser": "^2.0.0",
"luxon": "^3.0.0",
"metar-taf-parser": "^9.0.0",
"nosleep.js": "^0.12.0",
"sass": "^1.54.0",
"suncalc": "^1.8.0",
@@ -55,8 +58,6 @@
"dependencies": {
"dotenv": "^17.0.1",
"ejs": "^3.1.5",
"express": "^5.1.0",
"metar-taf-parser": "^9.0.0",
"npm": "^11.6.0"
"express": "^5.1.0"
}
}

View File

@@ -4,11 +4,12 @@ import {
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived, isIOS,
} from './modules/navigation.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 AutoComplete from './modules/autocomplete.mjs';
import { loadAllData } from './modules/utils/data-loader.mjs';
import { debugFlag } from './modules/utils/debug.mjs';
import { parseQueryString } from './modules/utils/setting.mjs';
document.addEventListener('DOMContentLoaded', () => {
init();
@@ -106,17 +107,34 @@ const init = async () => {
// attempt to parse the url parameters
const parsedParameters = parseQueryString();
const loadFromParsed = parsedParameters.latLonQuery && parsedParameters.latLon;
const loadFromParsed = !!parsedParameters.latLon;
// Auto load the parsed parameters and fall back to the previous query
const query = parsedParameters.latLonQuery ?? localStorage.getItem('latLonQuery');
const latLon = parsedParameters.latLon ?? localStorage.getItem('latLon');
const fromGPS = localStorage.getItem('latLonFromGPS') && !loadFromParsed;
if (query && latLon && !fromGPS) {
if (parsedParameters.latLonQuery && !parsedParameters.latLon) {
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
txtAddress.value = query;
loadData(JSON.parse(latLon));
txtAddress.value = parsedParameters.latLonQuery;
const geometry = await geocodeLatLonQuery(parsedParameters.latLonQuery);
if (geometry) {
doRedirectToGeometry(geometry);
}
} else if (latLon && !fromGPS) {
// update in-page search box if using cached data, or parsed parameter
if ((query && !loadFromParsed) || (parsedParameters.latLonQuery && loadFromParsed)) {
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
txtAddress.value = query;
}
// use lat-long lookup if that's all that was provided in the query string
if (loadFromParsed && parsedParameters.latLon && !parsedParameters.latLonQuery) {
const { lat, lon } = JSON.parse(latLon);
getForecastFromLatLon(lat, lon, true);
} else {
// otherwise use pre-stored data
loadData(JSON.parse(latLon));
}
}
if (fromGPS) {
btnGetGpsClick();
@@ -160,6 +178,30 @@ const init = async () => {
// swipe functionality
document.querySelector('#container').addEventListener('swiped-left', () => swipeCallBack('left'));
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) => {
try {
const data = await json('https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/find', {
data: {
text: query,
f: 'json',
},
});
const loc = data.locations?.[0];
if (loc) {
return loc.feature.geometry;
}
return null;
} catch (error) {
console.error('Geocoding failed:', error);
return null;
}
};
const autocompleteOnSelect = async (suggestion) => {

View File

@@ -13,6 +13,7 @@ import {
} from './utils/units.mjs';
import { debugFlag } from './utils/debug.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
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
@@ -49,7 +50,7 @@ class CurrentWeather extends WeatherDisplay {
// eslint-disable-next-line no-await-in-loop
candidateObservation = await safeJson(`${station.id}/observations`, {
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,
stillWaiting: () => this.stillWaiting(),
@@ -231,7 +232,7 @@ class CurrentWeather extends WeatherDisplay {
this.setAutoReload();
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
return new Promise((resolve) => {
if (this.data) resolve(this.data);
if (this.data) resolve({ data: this.data, parameters: this.weatherParameters });
// data not available, put it into the data callback queue
this.getDataCallbacks.push(() => resolve(this.data));
});
@@ -266,7 +267,7 @@ const parseData = (data) => {
const kilometersConverter = distanceKilometers();
const pressureConverter = pressure();
const observations = data.features[0].properties;
const observations = backfill(data.features);
// values from api are provided in metric
data.observations = observations;
data.Temperature = temperatureConverter(observations.temperature.value);
@@ -306,6 +307,46 @@ const parseData = (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');
registerDisplay(display);

View File

@@ -12,9 +12,14 @@ const secondsToTicks = (seconds) => Math.ceil((seconds * 1000) / TICK_INTERVAL_M
const DEFAULT_UPDATE = secondsToTicks(4.0); // 4 second default for each current conditions
// items on page
const mainScroll = document.querySelector('#container>.scroll');
const fixedScroll = document.querySelector('#container>.scroll .fixed');
const header = document.querySelector('#container>.scroll .scroll-header');
let mainScroll;
let fixedScroll;
let header;
document.addEventListener('DOMContentLoaded', () => {
mainScroll = document.querySelector('#container>.scroll');
fixedScroll = document.querySelector('#container>.scroll .fixed');
header = document.querySelector('#container>.scroll .scroll-header');
});
// local variables
let interval;
@@ -79,7 +84,7 @@ const incrementInterval = (force) => {
const drawScreen = async () => {
// get the conditions
const data = await getCurrentWeather();
const { data, parameters } = await getCurrentWeather();
// create a data object (empty if no valid current weather conditions)
const scrollData = data || {};
@@ -95,7 +100,7 @@ const drawScreen = async () => {
// if we have no current weather and no hazards, there's nothing to display
if (!data && (!scrollData.hazards || scrollData.hazards.length === 0)) return;
const thisScreen = workingScreens[screenIndex](scrollData);
const thisScreen = workingScreens[screenIndex](scrollData, parameters);
// update classes on the scroll area
mainScroll.classList.forEach((cls) => { if (cls !== 'scroll') mainScroll.classList.remove(cls); });
@@ -293,4 +298,5 @@ export {
hide,
screenCount,
atDefault,
hazards,
};

View File

@@ -1,5 +1,5 @@
import Setting from './utils/setting.mjs';
import { reset as resetScroll, addScreen as addScroll } from './currentweatherscroll.mjs';
import { reset as resetScroll, addScreen as addScroll, hazards } from './currentweatherscroll.mjs';
import { json } from './utils/fetch.mjs';
let firstRun = true;
@@ -42,8 +42,9 @@ const parseFeed = (textInput) => {
return;
}
// add single text scroll
// add single text scroll after hazards if present
resetScroll();
addScroll(hazards);
addScroll(
() => (
{
@@ -81,6 +82,8 @@ const getFeed = async (url) => {
// 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(

View File

@@ -3,7 +3,7 @@
import STATUS from './status.mjs';
import { DateTime, Interval, Duration } from '../vendor/auto/luxon.mjs';
import { safeJson } from './utils/fetch.mjs';
import { temperature as temperatureUnit, distanceKilometers } from './utils/units.mjs';
import { temperature as temperatureUnit, windSpeed as windUnit } from './utils/units.mjs';
import { getHourlyIcon } from './icons.mjs';
import { directionToNSEW } from './utils/calc.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
@@ -191,7 +191,7 @@ class Hourly extends WeatherDisplay {
const parseForecast = async (data) => {
// get unit converters
const temperatureConverter = temperatureUnit();
const distanceConverter = distanceKilometers();
const windConverter = windUnit();
// parse data
const temperature = expand(data.temperature.values);
@@ -210,8 +210,8 @@ const parseForecast = async (data) => {
temperature: temperatureConverter(temperature[idx]),
temperatureUnit: temperatureConverter.units,
apparentTemperature: temperatureConverter(apparentTemperature[idx]),
windSpeed: distanceConverter(windSpeed[idx]),
windUnit: distanceConverter.units,
windSpeed: windConverter(windSpeed[idx]),
windUnit: windConverter.units,
windDirection: directionToNSEW(windDirection[idx]),
probabilityOfPrecipitation: probabilityOfPrecipitation[idx],
skyCover: skyCover[idx],

View File

@@ -1,9 +1,13 @@
import { text } from './utils/fetch.mjs';
import Setting from './utils/setting.mjs';
import { registerHiddenSetting } from './share.mjs';
let playlist;
let currentTrack = 0;
let player;
let sliderTimeout = null;
let volumeSlider = null;
let volumeSliderInput = null;
const mediaPlaying = new Setting('mediaPlaying', {
name: 'Media Playing',
@@ -14,9 +18,24 @@ const mediaPlaying = new Setting('mediaPlaying', {
document.addEventListener('DOMContentLoaded', () => {
// 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
getMedia();
// register the volume setting
registerHiddenSetting(mediaVolume.elemId, mediaVolume);
});
const scanMusicDirectory = async () => {
@@ -77,7 +96,7 @@ const enableMediaPlayer = () => {
// randomize the list
randomizePlaylist();
// enable the icon
const icon = document.getElementById('ToggleMedia');
const icon = document.getElementById('ToggleMediaContainer');
icon.classList.add('available');
// set the button type
setIcon();
@@ -85,15 +104,12 @@ const enableMediaPlayer = () => {
if (mediaPlaying.value === true) {
startMedia();
}
// add the volume control to the page
const settingsSection = document.querySelector('#settings');
settingsSection.append(mediaVolume.generate());
}
};
const setIcon = () => {
// get the icon
const icon = document.getElementById('ToggleMedia');
const icon = document.getElementById('ToggleMediaContainer');
if (mediaPlaying.value === true) {
icon.classList.add('playing');
} else {
@@ -101,18 +117,54 @@ const setIcon = () => {
}
};
const toggleMedia = (forcedState) => {
// handle forcing
if (typeof forcedState === 'boolean') {
mediaPlaying.value = forcedState;
} else {
// toggle the state
mediaPlaying.value = !mediaPlaying.value;
const handleClick = () => {
// if media is off, start it
if (mediaPlaying.value === false) {
mediaPlaying.value = true;
}
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
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 () => {
// if there's not media player yet, enable it
if (!player) {
@@ -134,9 +186,12 @@ const startMedia = async () => {
};
const stopMedia = () => {
hideVolumeSlider();
if (!player) return;
player.pause();
mediaPlaying.value = false;
setTrackName('Not playing');
setIcon();
};
const stateChanged = () => {
@@ -170,6 +225,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', {
name: 'Volume',
type: 'select',
@@ -205,7 +270,9 @@ const initializePlayer = () => {
player.src = `music/${playlist.availableFiles[currentTrack]}`;
setTrackName(playlist.availableFiles[currentTrack]);
player.type = 'audio/mpeg';
// set volume and slider indicator
setVolume(mediaVolume.value);
volumeSliderInput.value = Math.round(mediaVolume.value * 100);
};
const playerCanPlay = async () => {
@@ -238,5 +305,5 @@ const setTrackName = (fileName) => {
export {
// eslint-disable-next-line import/prefer-default-export
toggleMedia,
handleClick,
};

View File

@@ -109,6 +109,7 @@ const getWeather = async (latLon, haveDataCallback) => {
weatherParameters.forecast = point.properties.forecast;
weatherParameters.forecastGridData = point.properties.forecastGridData;
weatherParameters.stations = stations.features;
weatherParameters.relativeLocation = point.properties.relativeLocation.properties;
// update the main process for display purposes
populateWeatherParameters(weatherParameters, point.properties);

View File

@@ -1,4 +1,5 @@
import Setting from './utils/setting.mjs';
import { registerHiddenSetting } from './share.mjs';
// Initialize settings immediately so other modules can access them
const settings = { speed: { value: 1.0 } };
@@ -6,6 +7,11 @@ const settings = { speed: { value: 1.0 } };
// Track settings that need DOM changes after early initialization
const deferredDomSettings = new Set();
// don't show checkboxes for these settings
const hiddenSettings = [
'scanLines',
];
// Declare change functions first, before they're referenced in init() to avoid the Temporal Dead Zone (TDZ)
const wideScreenChange = (value) => {
const container = document.querySelector('#divTwc');
@@ -63,13 +69,19 @@ const scanLineChange = (value) => {
return;
}
const modeSelect = document.getElementById('settings-scanLineMode-label');
if (value) {
container.classList.add('scanlines');
navIcons.classList.add('on');
modeSelect?.style?.removeProperty('display');
} else {
// Remove all scanline classes
container.classList.remove('scanlines', 'scanlines-auto', 'scanlines-fine', 'scanlines-normal', 'scanlines-thick', 'scanlines-classic', 'scanlines-retro');
navIcons.classList.remove('on');
if (modeSelect) {
modeSelect.style.display = 'none';
}
}
};
@@ -206,10 +218,28 @@ document.addEventListener('DOMContentLoaded', () => {
}
// 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.elemId, setting);
return false;
}
// generate HTML for setting
return setting.generate();
}).filter((d) => d);
const settingsSection = document.querySelector('#settings');
settingsSection.innerHTML = '';
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';
}
registerHiddenSetting('settings-scanLineMode-select', settings.scanLineMode);
});
export default settings;

View File

@@ -1,11 +1,10 @@
import { elemForEach } from './utils/elem.mjs';
import Setting from './utils/setting.mjs';
document.addEventListener('DOMContentLoaded', () => init());
// shorthand mappings for frequently used values
const specialMappings = {
kiosk: 'settings-kiosk-checkbox',
};
// array of settings that are not checkboxes or dropdowns (i.e. volume slider)
const hiddenSettings = [];
const init = () => {
// add action to existing link
@@ -45,9 +44,15 @@ const createLink = async (e) => {
}
}));
// add the location string
queryStringElements.latLonQuery = localStorage.getItem('latLonQuery');
queryStringElements.latLon = localStorage.getItem('latLon');
// get any hidden settings
hiddenSettings.forEach((setting) => {
// 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();
@@ -90,29 +95,17 @@ const writeLinkToPage = (url) => {
shareLinkUrl.select();
};
const parseQueryString = () => {
// return memoized result
if (parseQueryString.params) return parseQueryString.params;
const urlSearchParams = new URLSearchParams(window.location.search);
// turn into an array of key-value pairs
const paramsArray = [...urlSearchParams];
// add additional expanded keys
paramsArray.forEach((paramPair) => {
const expandedKey = specialMappings[paramPair[0]];
if (expandedKey) {
paramsArray.push([expandedKey, paramPair[1]]);
}
const registerHiddenSetting = (name, value) => {
// name is the id of the element
// value can be a function that returns the current value of the setting
// or an instance of Setting
hiddenSettings.push({
name,
value,
});
// memoize result
parseQueryString.params = Object.fromEntries(paramsArray);
return parseQueryString.params;
};
export {
createLink,
parseQueryString,
registerHiddenSetting,
};

View File

@@ -57,6 +57,7 @@ class SpcOutlook extends WeatherDisplay {
}
async getData(weatherParameters, refresh) {
if (weatherParameters) this.weatherParameters = weatherParameters;
if (!super.getData(weatherParameters, refresh)) return;
// SPC outlook data does not need to be reloaded on a location change, only during silent refresh
@@ -93,7 +94,7 @@ class SpcOutlook extends WeatherDisplay {
}
}
// parse the data
this.data = testAllPoints([weatherParameters.longitude, weatherParameters.latitude], this.rawOutlookData);
this.data = testAllPoints([this.weatherParameters.longitude, this.weatherParameters.latitude], this.rawOutlookData);
// check if there's a "risk" for any of the three days, otherwise skip the SPC Outlook screen
if (this.data.reduce((prev, cur) => prev || !!cur, false)) {

View File

@@ -5,21 +5,22 @@ import en from '../../vendor/auto/locale/en.js';
// metar-taf-parser requires regex lookbehind
// this does not work in iOS < 16.4
// this is a detection algorithm for iOS versions
const isIos = /iP(ad|od|hone)/i.test(window.navigator.userAgent);
let iosVersionOk = false;
if (isIos) {
// regex match the version string
const iosVersionRaw = /OS (\d+)_(\d+)/.exec(window.navigator.userAgent);
// check for match
if (iosVersionRaw) {
// break into parts
const iosVersionMajor = parseInt(iosVersionRaw[1], 10);
const iosVersionMinor = parseInt(iosVersionRaw[2], 10);
if (iosVersionMajor > 16) iosVersionOk = true;
if (iosVersionMajor === 16 && iosVersionMinor >= 4) iosVersionOk = true;
// this is a detection algorithm for missing lookbehind support
const supportsRegexLookAheadLookBehindCheck = () => {
try {
return (
// deliberately using RegExp for broader browser support during check
/* eslint-disable prefer-regex-literals */
'hibyehihi'
.replace(new RegExp('(?<=hi)hi', 'g'), 'hello')
.replace(new RegExp('hi(?!bye)', 'g'), 'hey') === 'hibyeheyhello'
/* eslint-enable prefer-regex-literals */
);
} catch {
return false;
}
}
};
const supportsRegexLookAheadLookBehind = supportsRegexLookAheadLookBehindCheck();
/**
* Augment observation data by parsing METAR when API fields are missing
@@ -27,8 +28,8 @@ if (isIos) {
* @returns {Object} - Augmented observation with parsed METAR data filled in
*/
const augmentObservationWithMetar = (observation) => {
// check for a metar message and for unusable ios versions
if (!observation?.rawMessage || (isIos && !iosVersionOk)) {
// check for a metar message and for regex lookbehind support
if (!observation?.rawMessage || (!supportsRegexLookAheadLookBehind)) {
return observation;
}

View File

@@ -1,5 +1,3 @@
import { parseQueryString } from '../share.mjs';
const SETTINGS_KEY = 'Settings';
const DEFAULTS = {
@@ -15,6 +13,11 @@ const DEFAULTS = {
placeholder: '',
};
// shorthand mappings for frequently used values
const specialMappings = {
kiosk: 'settings-kiosk-checkbox',
};
class Setting {
constructor(shortName, _options) {
if (shortName === undefined) {
@@ -35,9 +38,10 @@ class Setting {
this.visible = options.visible;
this.changeAction = options.changeAction;
this.placeholder = options.placeholder;
this.elemId = `settings-${shortName}-${this.type}`;
// get value from url
const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`];
const urlValue = parseQueryString()?.[this.elemId];
let urlState;
if (this.type === 'checkbox' && urlValue !== undefined) {
urlState = urlValue === 'true';
@@ -254,7 +258,10 @@ class Setting {
break;
case 'checkbox':
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);
@@ -285,4 +292,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 {
parseQueryString,
};

View File

@@ -5,7 +5,7 @@ import { DateTime } from '../vendor/auto/luxon.mjs';
import {
msg, displayNavMessage, isPlaying, updateStatus, timeZone,
} from './navigation.mjs';
import { parseQueryString } from './share.mjs';
import { parseQueryString } from './utils/setting.mjs';
import settings from './settings.mjs';
import { elemForEach } from './utils/elem.mjs';
import { debugFlag } from './utils/debug.mjs';

File diff suppressed because one or more lines are too long

View File

@@ -8127,7 +8127,7 @@ function friendlyDateTime(dateTimeish) {
}
}
const VERSION = "3.7.1";
const VERSION = "3.7.2";
export { DateTime, Duration, FixedOffsetZone, IANAZone, Info, Interval, InvalidZone, Settings, SystemZone, VERSION, Zone };
//# sourceMappingURL=luxon.js.map
//# sourceMappingURL=luxon.mjs.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,9 @@
display: none;
}
#ToggleMedia {
#ToggleMediaContainer {
display: none;
position: relative;
&.available {
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

@@ -815,4 +815,10 @@ body.kiosk #loading .instructions {
>*:not(#divTwc) {
display: none !important;
}
}
#divInfo {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 250px;
}

View File

@@ -0,0 +1,25 @@
import path from 'path';
// get values for devtools json
const uuid = 'd2bd1130-560f-4c8e-b2c5-e91073784964';
const root = path.resolve('server');
const DEVTOOLS_CONFIG = {
workspace: {
uuid,
root,
},
};
const devTools = (req, res) => {
// test for localhost
if (['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.ip)) {
console.log(DEVTOOLS_CONFIG);
res.json(DEVTOOLS_CONFIG);
} else {
// not localhost
res.status(404).send('File not found');
}
};
export default devTools;

View File

@@ -12,6 +12,9 @@ url_encode() {
# build query string from WSQS_ env vars
while IFS='=' read -r key val; do
# Skip empty lines
[ -z "$key" ] && continue
# Remove WSQS_ prefix and convert underscores to hyphens
key="${key#WSQS_}"
key="${key//_/-}"
@@ -23,11 +26,16 @@ while IFS='=' read -r key val; do
QS="${key}=${encoded_val}"
fi
done << EOF
$(env | grep '^WSQS_')
$(env | grep '^WSQS_' || true)
EOF
mkdir -p /etc/nginx/includes
if [ -n "$QS" ]; then
# Escape the query string for use in JavaScript (escape backslashes and single quotes)
QS_ESCAPED=$(printf '%s' "$QS" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\'/g")
# Generate redirect.html with JavaScript logic
cat > "$ROOT/redirect.html" <<EOF
<!DOCTYPE html>
<html>
@@ -35,10 +43,36 @@ if [ -n "$QS" ]; then
<meta charset="utf-8" />
<title>Redirecting</title>
<meta http-equiv="refresh" content="0;url=/index.html?$QS" />
<script>
(function() {
var wsqsParams = '$QS_ESCAPED';
var currentParams = window.location.search.substring(1);
var targetParams = currentParams || wsqsParams;
window.location.replace('/index.html?' + targetParams);
})();
</script>
</head>
<body></body>
</html>
EOF
# Generate nginx config for conditional redirects
cat > /etc/nginx/includes/wsqs_redirect.conf <<'EOF'
location = / {
if ($args = '') {
rewrite ^ /redirect.html last;
}
rewrite ^/$ /index.html?$args? redirect;
}
location = /index.html {
if ($args = '') {
rewrite ^ /redirect.html last;
}
}
EOF
else
touch /etc/nginx/includes/wsqs_redirect.conf
fi
exec nginx -g 'daemon off;'

View File

@@ -147,9 +147,15 @@
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_2x.png" title="Refresh" />
</div>
<div id="divTwcBottomRight">
<div id="ToggleMedia">
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Mute" />
<div id="ToggleMediaContainer">
<div id="ToggleMedia">
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="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 id="ToggleScanlines">
<img class="navButton off" src="images/nav/ic_scanlines_off_white_24dp_2x.png" title="Scan lines on" />
@@ -188,14 +194,22 @@
<div class='heading'>Headend Information</div>
<div id="divInfo">
Location: <span id="spanCity"></span> <span id="spanState"></span><br />
Station Id: <span id="spanStationId"></span><br />
Radar Id: <span id="spanRadarId"></span><br />
Zone Id: <span id="spanZoneId"></span><br />
Office Id: <span id="spanOfficeId"></span><br />
Grid X,Y: <span id="spanGridPoint"></span><br />
Music: <span id="musicTrack">Not playing</span><br />
Ws4kp Version: <span><%- version %></span>
<div class="header">Location:</div>
<div class="header"><span id="spanCity"></span> <span id="spanState"></span></div>
<div class="header">Station Id:</div>
<div class="header"><span id="spanStationId"></span></div>
<div class="header">Radar Id:</div>
<div class="header"><span id="spanRadarId"></span></div>
<div class="header">Zone Id:</div>
<div class="header"><span id="spanZoneId"></span></div>
<div class="header">Office Id:</div>
<div class="header"><span id="spanOfficeId"></span></div>
<div class="header">Grid X,Y:</div>
<div class="header"><span id="spanGridPoint"></span></div>
<div class="header">Music:</div>
<div class="header"><span id="musicTrack">Not playing</span></div>
<div class="header">Ws4kp Version:</div>
<div class="header"><span><%- version %></span></div>
</div>
</div>

View File

@@ -45,7 +45,8 @@
"unmuted",
"dumpio",
"mesonet",
"metar"
"metar",
"Unmute"
],
"cSpell.ignorePaths": [
"**/package-lock.json",
@@ -73,7 +74,10 @@
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"cSpell.words": [
"hibyehihi"
]
},
"extensions": {
"recommendations": [