Compare commits

...

20 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
23 changed files with 370 additions and 77 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: Please include:
* Web browser and OS * Web browser and OS
* Headend Information text block from the very bottom of the web page * 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_ # 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_checkbox=false
- WSQS_current_weather_checkbox=true - WSQS_current_weather_checkbox=true
ports: ports:

View File

@@ -14,6 +14,7 @@ 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 OVERRIDES from '../src/overrides.mjs'; import OVERRIDES from '../src/overrides.mjs';
// get cloudfront // get cloudfront
@@ -204,11 +205,15 @@ const buildPlaylist = async () => {
return file('playlist.json', JSON.stringify(playlist)).pipe(dest('./dist')); 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)); 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 // 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);
export default publishFrontend; export default publishFrontend;

View File

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

5
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ws4kp", "name": "ws4kp",
"version": "6.2.4", "version": "6.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ws4kp", "name": "ws4kp",
"version": "6.2.4", "version": "6.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
@@ -21,6 +21,7 @@
"eslint": "^9.0.0", "eslint": "^9.0.0",
"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": "^8.0.0",
"gulp-concat": "^2.6.1", "gulp-concat": "^2.6.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ws4kp", "name": "ws4kp",
"version": "6.2.4", "version": "6.3.1",
"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",
@@ -34,6 +34,7 @@
"eslint": "^9.0.0", "eslint": "^9.0.0",
"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": "^8.0.0",
"gulp-concat": "^2.6.1", "gulp-concat": "^2.6.1",
@@ -45,14 +46,14 @@
"gulp-sass": "^6.0.0", "gulp-sass": "^6.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",

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();
@@ -106,17 +107,34 @@ const init = async () => {
// attempt to parse the url parameters // attempt to parse the url parameters
const parsedParameters = parseQueryString(); 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 // Auto load the parsed parameters and fall back to the previous query
const query = parsedParameters.latLonQuery ?? localStorage.getItem('latLonQuery'); const query = parsedParameters.latLonQuery ?? localStorage.getItem('latLonQuery');
const latLon = parsedParameters.latLon ?? localStorage.getItem('latLon'); const latLon = parsedParameters.latLon ?? localStorage.getItem('latLon');
const fromGPS = localStorage.getItem('latLonFromGPS') && !loadFromParsed; const fromGPS = localStorage.getItem('latLonFromGPS') && !loadFromParsed;
if (query && latLon && !fromGPS) { if (parsedParameters.latLonQuery && !parsedParameters.latLon) {
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR); const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
txtAddress.value = query; txtAddress.value = parsedParameters.latLonQuery;
loadData(JSON.parse(latLon)); 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) { if (fromGPS) {
btnGetGpsClick(); btnGetGpsClick();
@@ -160,6 +178,30 @@ 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) => {
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) => { const autocompleteOnSelect = async (suggestion) => {

View File

@@ -13,6 +13,7 @@ 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 // 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']; 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 // 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(),
@@ -231,7 +232,7 @@ class CurrentWeather extends WeatherDisplay {
this.setAutoReload(); this.setAutoReload();
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting); if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
return new Promise((resolve) => { 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 // data not available, put it into the data callback queue
this.getDataCallbacks.push(() => resolve(this.data)); this.getDataCallbacks.push(() => resolve(this.data));
}); });
@@ -266,7 +267,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 +307,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

@@ -84,7 +84,7 @@ const incrementInterval = (force) => {
const drawScreen = async () => { const drawScreen = async () => {
// get the conditions // get the conditions
const data = await getCurrentWeather(); const { data, parameters } = await getCurrentWeather();
// create a data object (empty if no valid current weather conditions) // create a data object (empty if no valid current weather conditions)
const scrollData = data || {}; const scrollData = data || {};
@@ -100,7 +100,7 @@ const drawScreen = async () => {
// if we have no current weather and no hazards, there's nothing to display // if we have no current weather and no hazards, there's nothing to display
if (!data && (!scrollData.hazards || scrollData.hazards.length === 0)) return; 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 // update classes on the scroll area
mainScroll.classList.forEach((cls) => { if (cls !== 'scroll') mainScroll.classList.remove(cls); }); mainScroll.classList.forEach((cls) => { if (cls !== 'scroll') mainScroll.classList.remove(cls); });

View File

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

View File

@@ -1,9 +1,13 @@
import { text } from './utils/fetch.mjs'; import { text } from './utils/fetch.mjs';
import Setting from './utils/setting.mjs'; import Setting from './utils/setting.mjs';
import { registerHiddenSetting } from './share.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,9 +18,24 @@ 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();
// register the volume setting
registerHiddenSetting(mediaVolume.elemId, mediaVolume);
}); });
const scanMusicDirectory = async () => { const scanMusicDirectory = async () => {
@@ -77,7 +96,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 +104,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 +117,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 +186,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 +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', { const mediaVolume = new Setting('mediaVolume', {
name: 'Volume', name: 'Volume',
type: 'select', type: 'select',
@@ -205,7 +270,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 +305,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

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

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,11 @@ 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',
];
// 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');
@@ -63,13 +69,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';
}
} }
}; };
@@ -206,10 +218,28 @@ 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.elemId, 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';
}
registerHiddenSetting('settings-scanLineMode-select', settings.scanLineMode);
}); });
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
@@ -45,9 +44,15 @@ const createLink = async (e) => {
} }
})); }));
// 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 +95,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

@@ -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,10 @@ 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}`]; const urlValue = parseQueryString()?.[this.elemId];
let urlState; let urlState;
if (this.type === 'checkbox' && urlValue !== undefined) { if (this.type === 'checkbox' && urlValue !== undefined) {
urlState = urlValue === 'true'; urlState = urlValue === 'true';
@@ -254,7 +258,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 +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 default Setting;
export {
parseQueryString,
};

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';

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; 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

@@ -815,4 +815,10 @@ 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;
} }

View File

@@ -12,6 +12,9 @@ url_encode() {
# build query string from WSQS_ env vars # build query string from WSQS_ env vars
while IFS='=' read -r key val; do while IFS='=' read -r key val; do
# Skip empty lines
[ -z "$key" ] && continue
# Remove WSQS_ prefix and convert underscores to hyphens # Remove WSQS_ prefix and convert underscores to hyphens
key="${key#WSQS_}" key="${key#WSQS_}"
key="${key//_/-}" key="${key//_/-}"
@@ -23,11 +26,16 @@ while IFS='=' read -r key val; do
QS="${key}=${encoded_val}" QS="${key}=${encoded_val}"
fi fi
done << EOF done << EOF
$(env | grep '^WSQS_') $(env | grep '^WSQS_' || true)
EOF EOF
mkdir -p /etc/nginx/includes
if [ -n "$QS" ]; then 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 cat > "$ROOT/redirect.html" <<EOF
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -35,10 +43,36 @@ if [ -n "$QS" ]; then
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Redirecting</title> <title>Redirecting</title>
<meta http-equiv="refresh" content="0;url=/index.html?$QS" /> <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> </head>
<body></body> <body></body>
</html> </html>
EOF 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 fi
exec nginx -g 'daemon off;' 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" /> <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" />

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",