Compare commits

...

15 Commits

Author SHA1 Message Date
Matt Walsh
107fa26da8 add daily rain and timestamp #132 2025-11-12 15:31:08 -06:00
Matt Walsh
afad953ed7 Merge branch 'main' into personal-weather 2025-11-12 15:16:42 -06: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
Matt Walsh
410880833b fix include location in build 2025-10-21 15:38:43 -05:00
Matt Walsh
539e7663d6 Format and populate personal weather data 2025-10-21 15:18:01 -05:00
Matt Walsh
5b5b313786 add personal-weather and rearrange pages 2025-10-21 03:08:48 +00:00
35 changed files with 1249 additions and 772 deletions

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:
@@ -347,6 +347,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.
## 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
Not retro enough? Try the [Weatherstar 3000+](https://github.com/netbymatt/ws3kp)

View File

@@ -1,4 +1,4 @@
import 'dotenv/config';
import { config } from 'dotenv';
import {
src, dest, series, parallel,
} from 'gulp';
@@ -14,11 +14,16 @@ 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
import reader from '../src/playlist-reader.mjs';
config({
path: ['gulp/.env', '.env'],
});
const clean = () => deleteAsync(['./dist/**/*', '!./dist/readme.txt']);
const cloudfront = new CloudFrontClient({ region: 'us-east-1' });
@@ -83,6 +88,10 @@ const mjsSources = [
'server/scripts/index.mjs',
];
if (!process.env.DISABLE_PERSONAL) {
mjsSources.push('server/scripts/modules/personal-weather.mjs');
}
const buildJs = () => src(mjsSources)
.pipe(webpack(webpackOptions))
.pipe(dest(RESOURCES_PATH));
@@ -113,6 +122,7 @@ const compressHtml = async () => src(htmlSources)
version,
OVERRIDES,
query: {},
DISABLE_PERSONAL: process.env.DISABLE_PERSONAL === '1',
}))
.pipe(rename({ extname: '.html' }))
.pipe(htmlmin({ collapseWhitespace: true }))
@@ -204,11 +214,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

@@ -9,6 +9,7 @@ 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';
import ambientRelay from "./src/personal-weather.mjs";
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
@@ -59,6 +60,7 @@ const renderIndex = (req, res, production = false) => {
version,
OVERRIDES,
query: req.query,
DISABLE_PERSONAL: process.env.DISABLE_PERSONAL === '1'
});
};
@@ -170,6 +172,7 @@ if (process.env?.DIST === '1') {
app.use('/resources', express.static('./server/scripts/modules'));
app.get('/', index);
app.get('/.well-known/appspecific/com.chrome.devtools.json', devTools);
app.get('/ambient-relay/api/latest', ambientRelay);
app.get('*name', express.static('./server', staticOptions));
}

1372
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.7",
"version": "6.3.1",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",
@@ -34,8 +34,9 @@
"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-awspublish": "^9.0.0",
"gulp-concat": "^2.6.1",
"gulp-ejs": "^5.1.0",
"gulp-file": "^0.4.0",
@@ -45,14 +46,14 @@
"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",
"swiped-events": "^1.1.4",
"terser-webpack-plugin": "^5.3.6",
"webpack": "^5.99.9",
"webpack-stream": "^7.0.0",
"metar-taf-parser": "^9.0.0"
"webpack-stream": "^7.0.0"
},
"dependencies": {
"dotenv": "^17.0.1",

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();
@@ -131,7 +132,7 @@ const init = async () => {
const { lat, lon } = JSON.parse(latLon);
getForecastFromLatLon(lat, lon, true);
} else {
// otherwise use pre-stored data
// otherwise use pre-stored data
loadData(JSON.parse(latLon));
}
}
@@ -177,6 +178,10 @@ 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) => {

View File

@@ -205,7 +205,7 @@ const formatTimesForColumn = (times) => {
};
// register display
const display = new Almanac(9, 'almanac');
const display = new Almanac(10, 'almanac');
registerDisplay(display);
export default display.getSun.bind(display);

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(),
@@ -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

@@ -209,4 +209,4 @@ const shortenExtendedForecastText = (long) => {
};
// register display
registerDisplay(new ExtendedForecast(8, 'extended-forecast'));
registerDisplay(new ExtendedForecast(9, 'extended-forecast'));

View File

@@ -148,4 +148,4 @@ const drawPath = (path, ctx, options) => {
const formatTime = (time) => time.setZone(timeZone()).toFormat('ha').slice(0, -1);
// register display
registerDisplay(new HourlyGraph(4, 'hourly-graph'));
registerDisplay(new HourlyGraph(5, 'hourly-graph'));

View File

@@ -255,7 +255,7 @@ const expand = (data, maxHours = 24) => {
};
// register display
const display = new Hourly(3, 'hourly', false);
const display = new Hourly(4, 'hourly', false);
registerDisplay(display);
export default display.getHourlyData.bind(display);

View File

@@ -205,4 +205,4 @@ const shortenCurrentConditions = (_condition) => {
return condition;
};
// register display
registerDisplay(new LatestObservations(2, 'latest-observations'));
registerDisplay(new LatestObservations(3, 'latest-observations'));

View File

@@ -262,4 +262,4 @@ const parse = (forecast, forecastUrl) => {
}));
};
// register display
registerDisplay(new LocalForecast(7, 'local-forecast'));
registerDisplay(new LocalForecast(8, 'local-forecast'));

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

@@ -0,0 +1,114 @@
// current weather conditions display
import STATUS from './status.mjs';
import { safeJson } from './utils/fetch.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
import {
temperature, pressure, distanceMm, windSpeed,
} from './utils/units.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
class PersonalWeather extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Personal Weather Station', true);
}
async getData(weatherParameters, refresh) {
// always load the data for use in the lower scroll
const superResult = super.getData(weatherParameters, refresh);
const dataUrl = '/ambient-relay/api/latest';
let personalData;
try {
personalData = await safeJson(dataUrl, {
retryCount: 3,
stillWaiting: () => this.stillWaiting(),
});
} catch (error) {
console.error(`Unexpected error getting personal weather station data from: ${dataUrl}: ${error.message}`);
}
// test for data received
if (!personalData) {
if (this.isEnabled) this.setStatus(STATUS.failed);
// send failed to subscribers
this.getDataCallback(undefined);
return;
}
// we only get here if there was no error above
this.data = parseData(personalData);
this.getDataCallback();
// stop here if we're disabled
if (!superResult) return;
// Data is available, ensure we're enabled for display
this.timing.totalScreens = 1;
this.setStatus(STATUS.loaded);
}
async drawCanvas() {
super.drawCanvas();
const fill = {
temp: this.data.Temperature + String.fromCharCode(176),
wind: `${this.data.WindSpeed} ${this.data.WindUnit}`,
deviceName: this.data.device_name,
deviceLocation: this.data.device_location,
humidity: `${this.data.Humidity}%`,
pressure: `${this.data.Pressure} ${this.data.PressureUnit}`,
dailyRain: `${this.data.DailyRain} ${this.data.DailyRainUnit}`,
timestamp: `At ${this.data.timestamp}`,
};
const area = this.elem.querySelector('.main');
area.innerHTML = '';
area.append(this.fillTemplate('weather', fill));
this.finishDraw();
}
// make data available outside this class
// promise allows for data to be requested before it is available
async getCurrentWeather(stillWaiting) {
// an external caller has requested data, set up auto reload
this.setAutoReload();
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
return new Promise((resolve) => {
if (this.data) resolve(this.data);
// data not available, put it into the data callback queue
this.getDataCallbacks.push(() => resolve(this.data));
});
}
}
// format the received data
const parseData = (data) => {
// get the unit converters
const temperatureConverter = temperature('us');
const pressureConverter = pressure('us');
const inConverter = distanceMm('us');
const windConverter = windSpeed('us');
data.Pressure = pressureConverter(data.baromrelin * 10000) / 100;
data.PressureUnit = pressureConverter.units;
data.Humidity = data.humidity;
data.Temperature = temperatureConverter(data.tempf);
data.WindSpeed = windConverter(data.windspeedmph);
data.WindUnit = windConverter.units;
data.DailyRain = inConverter(data.dailyrainin);
data.DailyRainUnit = inConverter.units;
data.timestamp = DateTime.fromISO(data.date).toFormat('H:mm:ss a EEE MMM d').toUpperCase();
// set wind speed of 0 as calm
if (data.WindSpeed === 0) data.WindSpeed = 'Calm';
return data;
};
const display = new PersonalWeather(2, 'personal-weather');
registerDisplay(display);
// export default display.getPersonalWeather.bind(display);

View File

@@ -231,4 +231,4 @@ class Radar extends WeatherDisplay {
}
// register display
registerDisplay(new Radar(11, 'radar'));
registerDisplay(new Radar(12, 'radar'));

View File

@@ -235,4 +235,4 @@ const getAndFormatPoint = async (lat, lon) => {
};
// register display
registerDisplay(new RegionalForecast(6, 'regional-forecast'));
registerDisplay(new RegionalForecast(7, 'regional-forecast'));

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

@@ -146,4 +146,4 @@ class SpcOutlook extends WeatherDisplay {
}
// register display
registerDisplay(new SpcOutlook(10, 'spc-outlook'));
registerDisplay(new SpcOutlook(11, 'spc-outlook'));

View File

@@ -222,4 +222,4 @@ const getTravelCitiesDayName = (cities) => cities.reduce((dayName, city) => {
}, '');
// register display, not active by default
registerDisplay(new TravelForecast(5, 'travel', false));
registerDisplay(new TravelForecast(6, 'travel', false));

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

@@ -13,6 +13,7 @@ const fahrenheitToCelsius = (Fahrenheit) => Math.round((Fahrenheit - 32) * 5 / 9
const kilometersToMiles = (Kilometers) => Math.round(Kilometers / 1.609_34);
const metersToFeet = (Meters) => Math.round(Meters / 0.3048);
const pascalToInHg = (Pascal) => round2(Pascal * 0.000_295_3, 2);
const mmToIn = (mm) => round2(mm / 25.4);
// each module/page/slide creates it's own unit converter as needed by providing the base units available
// the factory function then returns an appropriate converter or pass-thru function for use on the page
@@ -98,6 +99,23 @@ const distanceKilometers = (defaultUnit = 'si') => {
return converter;
};
// millimeters (annoying with camel case)
const distanceMm = (defaultUnit = 'si') => {
// default to passthru
let converter = passthru();
// change the converter if there is a mismatch
if (defaultUnit !== settings.units.value) {
converter = convert((value) => Math.round(mmToIn(value)));
}
// append units
if (settings.units.value === 'si') {
converter.units = ' mm.';
} else {
converter.units = ' in.';
}
return converter;
};
const pressure = (defaultUnit = 'si') => {
// default to passthru (millibar)
let converter = passthru(100);
@@ -121,6 +139,7 @@ export {
distanceMeters,
distanceKilometers,
pressure,
distanceMm,
// formatter
round2,

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

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
// also shared with personal weather
.weather-display .main.current-weather {
&.main {
@@ -92,4 +93,4 @@
text-wrap: nowrap;
}
}
}
}

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,68 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
// also shared with personal weather
.weather-display .main.personal-weather {
&.main {
@include u.text-shadow();
font-family: "Star4000 Large";
font-size: 20px;
font-weight: bold;
line-height: 24px;
top: 20px;
height: 290px;
.row {
margin-bottom: 12px;
.label,
.value {
display: inline-block;
}
.label {
margin-left: 20px;
}
.value {
float: right;
margin-right: 10px;
}
}
.center {
text-align: center;
}
.temp {
font-family: 'Star4000 Large';
font-size: 24pt;
position: absolute;
top: 20px;
right: 0px;
}
.deviceName,
.deviceLocation {
color: c.$title-color;
max-height: 32px;
margin-bottom: 10px;
padding-top: 4px;
overflow: hidden;
text-wrap: nowrap;
}
.timestamp {
position: absolute;
bottom: 15px;
right: 10px;
text-align: right;
font-family: "Star4000 Small";
font-size: 24pt;
font-weight: normal;
}
}
}

View File

@@ -1,6 +1,7 @@
@use 'page';
@use 'weather-display';
@use 'current-weather';
@use 'personal-weather';
@use 'extended-forecast';
@use 'hourly';
@use 'hourly-graph';

20
src/personal-weather.mjs Normal file
View File

@@ -0,0 +1,20 @@
// testing data for use with personal weather stations via
// ambient-relay https://github.com/jasonkonen/ambient-relay
const ambientRelay = (req, res) => {
res.json({
"id": 123,
"mac_address": "00:00:00:00:00:00",
"device_name": "My Weather Station",
"device_location": "Backyard",
"dateutc": 1515436500000,
"date": "2018-01-08T18:35:00.000Z",
"tempf": 66.9,
"humidity": 30,
"windspeedmph": 0.9,
"baromrelin": 30.05,
"dailyrainin": 0,
"raw_data": {}
})
}
export default ambientRelay;

View File

@@ -63,6 +63,9 @@
<script type="module" src="scripts/modules/settings.mjs"></script>
<script type="module" src="scripts/modules/media.mjs"></script>
<script type="module" src="scripts/modules/custom-rss-feed.mjs"></script>
<% if (!DISABLE_PERSONAL) { %>
<script type="module" src="scripts/modules/personal-weather.mjs"></script>
<% } %>
<script type="module" src="scripts/index.mjs"></script>
<% } %>
@@ -109,6 +112,11 @@
<div id="current-weather-html" class="weather-display">
<%- include('partials/current-weather.ejs') %>
</div>
<% if (!DISABLE_PERSONAL) { %>
<div id="personal-weather-html" class="weather-display">
<%- include('partials/personal-weather.ejs') %>
</div>
<% } %>
<div id="local-forecast-html" class="weather-display">
<%- include('partials/local-forecast.ejs') %>
</div>
@@ -147,9 +155,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" />

View File

@@ -0,0 +1,25 @@
<%- include('header.ejs', {titleDual:{ top: 'Personal' , bottom: 'Weather Station' }, noaaLogo: false, hasTime: true}) %>
<div class="main has-scroll has-box personal-weather">
<div class="weather template">
<div class="deviceName value"></div>
<div class="deviceLocation value"></div>
<div class="temp value"></div>
<div class="row">
<div class="label">Humidity:</div>
<div class="humidity value"></div>
</div>
<div class="row">
<div class="label">Wind:</div>
<div class="wind value"></div>
</div>
<div class="row">
<div class="label">Pressure:</div>
<div class="pressure value"></div>
</div>
<div class="row">
<div class="label">Daily Rain:</div>
<div class="dailyRain value"></div>
</div>
<div class="timestamp value">At 12:34:55 PM</div>
</div>
</div>

View File

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