mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-16 00:29:34 -07:00
Compare commits
46 Commits
v6.1.11
...
personal-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
107fa26da8 | ||
|
|
afad953ed7 | ||
|
|
b4646b128a | ||
|
|
9f78761fe8 | ||
|
|
31c060c6d9 | ||
|
|
770f671d45 | ||
|
|
da3fe3366c | ||
|
|
6f97e3d2b9 | ||
|
|
8255efd3f7 | ||
|
|
1c79b08228 | ||
|
|
66a161762e | ||
|
|
707b08ee1a | ||
|
|
7900e59aab | ||
|
|
9b422dd697 | ||
|
|
e4ce0b6cc6 | ||
|
|
b0e5018179 | ||
|
|
6422589b5c | ||
|
|
410880833b | ||
|
|
539e7663d6 | ||
|
|
407da90f8a | ||
|
|
3a0e6aa345 | ||
|
|
650dda7b61 | ||
|
|
8f1e8ffb74 | ||
|
|
5b5b313786 | ||
|
|
93af84cbd8 | ||
|
|
117f66e9d0 | ||
|
|
bca9376edc | ||
|
|
8b076db25d | ||
|
|
807932fe3c | ||
|
|
7bb024eff5 | ||
|
|
f4a1a3a1d8 | ||
|
|
9a5efe9d48 | ||
|
|
58e0611a46 | ||
|
|
9ed496c892 | ||
|
|
31315d1ace | ||
|
|
77838e1a81 | ||
|
|
64d6484bd8 | ||
|
|
20cab8c25e | ||
|
|
b4de17ccd0 | ||
|
|
0fd90feb7a | ||
|
|
8c3b596b69 | ||
|
|
e57b9bcb20 | ||
|
|
e27750e915 | ||
|
|
f5431a04c7 | ||
|
|
5117a9d475 | ||
|
|
14b1891efd |
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -11,4 +11,5 @@ Please do not report issues with api.weather.gov being down. It's a new service
|
||||
|
||||
Please include:
|
||||
* Web browser and OS
|
||||
* Forecast 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.)
|
||||
|
||||
13
README.md
13
README.md
@@ -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
|
||||
|
||||
@@ -346,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)
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
"Latitude": 29.7633,
|
||||
"Longitude": -95.3633,
|
||||
"point": {
|
||||
"x": 65,
|
||||
"y": 97,
|
||||
"x": 63,
|
||||
"y": 95,
|
||||
"wfo": "HGX"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ 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';
|
||||
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'));
|
||||
@@ -58,6 +60,7 @@ const renderIndex = (req, res, production = false) => {
|
||||
version,
|
||||
OVERRIDES,
|
||||
query: req.query,
|
||||
DISABLE_PERSONAL: process.env.DISABLE_PERSONAL === '1'
|
||||
});
|
||||
};
|
||||
|
||||
@@ -168,6 +171,8 @@ 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('/ambient-relay/api/latest', ambientRelay);
|
||||
app.get('*name', express.static('./server', staticOptions));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
4250
package-lock.json
generated
4250
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "6.1.11",
|
||||
"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,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",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { locationCleanup } from './utils/string.mjs';
|
||||
import { elemForEach } from './utils/elem.mjs';
|
||||
import getCurrentWeather from './currentweather.mjs';
|
||||
import { currentDisplay } from './navigation.mjs';
|
||||
import getHazards from './hazards.mjs';
|
||||
@@ -12,6 +11,16 @@ const TICK_INTERVAL_MS = 500; // milliseconds per tick
|
||||
const secondsToTicks = (seconds) => Math.ceil((seconds * 1000) / TICK_INTERVAL_MS);
|
||||
const DEFAULT_UPDATE = secondsToTicks(4.0); // 4 second default for each current conditions
|
||||
|
||||
// items on page
|
||||
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;
|
||||
let screenIndex = 0;
|
||||
@@ -23,6 +32,8 @@ let defaultScreensLoaded = true;
|
||||
// start drawing conditions
|
||||
// reset starts from the first item in the text scroll list
|
||||
const start = () => {
|
||||
// show the block
|
||||
show();
|
||||
// if already started, draw the screen on a reset flag and return
|
||||
if (interval) {
|
||||
if (resetFlag) drawScreen();
|
||||
@@ -62,6 +73,7 @@ const incrementInterval = (force) => {
|
||||
const display = currentDisplay();
|
||||
if (!display?.okToDrawCurrentConditions) {
|
||||
stop(display?.elemId === 'progress');
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
screenIndex = (screenIndex + 1) % (workingScreens.length);
|
||||
@@ -72,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 || {};
|
||||
@@ -88,21 +100,11 @@ 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
|
||||
elemForEach('.weather-display .scroll', (elem) => {
|
||||
elem.classList.forEach((cls) => { if (cls !== 'scroll') elem.classList.remove(cls); });
|
||||
// no scroll on progress
|
||||
if (elem.parentElement.id === 'progress-html') return;
|
||||
thisScreen?.classes?.forEach((cls) => elem.classList.add(cls));
|
||||
});
|
||||
// special case for red background on hazard scroll
|
||||
const mainScrollBg = document.getElementById('scroll-bg');
|
||||
mainScrollBg.className = '';
|
||||
if (thisScreen?.classes?.includes('hazard')) {
|
||||
mainScrollBg.classList.add('hazard');
|
||||
}
|
||||
mainScroll.classList.forEach((cls) => { if (cls !== 'scroll') mainScroll.classList.remove(cls); });
|
||||
thisScreen?.classes?.forEach((cls) => mainScroll.classList.add(cls));
|
||||
|
||||
if (typeof thisScreen === 'string') {
|
||||
// only a string
|
||||
@@ -131,9 +133,7 @@ const hazards = (data) => {
|
||||
// test for data
|
||||
if (!data.hazards || data.hazards.length === 0) return false;
|
||||
|
||||
// since the hazard scroll element has no left/right margins, pad the beginning and end with non-breaking spaces
|
||||
const padding = ' '.repeat(4);
|
||||
const hazard = `${padding}${data.hazards[0].properties.event} ${data.hazards[0].properties.description}${padding}`;
|
||||
const hazard = `${data.hazards[0].properties.event} ${data.hazards[0].properties.description}`;
|
||||
|
||||
return {
|
||||
text: hazard,
|
||||
@@ -196,17 +196,12 @@ let workingScreens = [...baseScreens, ...additionalScreens];
|
||||
|
||||
// internal draw function with preset parameters
|
||||
const drawCondition = (text) => {
|
||||
// update all html scroll elements
|
||||
elemForEach('.weather-display .scroll .fixed', (elem) => {
|
||||
elem.innerHTML = text;
|
||||
});
|
||||
fixedScroll.innerHTML = text;
|
||||
setHeader('');
|
||||
};
|
||||
|
||||
const setHeader = (text) => {
|
||||
elemForEach('.weather-display .scroll .scroll-header', (elem) => {
|
||||
elem.innerHTML = text ?? '';
|
||||
});
|
||||
header.innerHTML = text ?? '';
|
||||
};
|
||||
|
||||
// reset the screens back to the original set
|
||||
@@ -229,14 +224,14 @@ const drawScrollCondition = (screen) => {
|
||||
scrollElement.classList.add('scroll-area');
|
||||
scrollElement.innerHTML = screen.text;
|
||||
// add it to the page to get the width
|
||||
document.querySelector('.weather-display .scroll .fixed').innerHTML = scrollElement.outerHTML;
|
||||
fixedScroll.innerHTML = scrollElement.outerHTML;
|
||||
// grab the width
|
||||
const { scrollWidth, clientWidth } = document.querySelector('.weather-display .scroll .fixed .scroll-area');
|
||||
const { scrollWidth, clientWidth } = document.querySelector('#container>.scroll .fixed .scroll-area');
|
||||
|
||||
// calculate the scroll distance and set a minimum scroll
|
||||
const scrollDistance = Math.max(scrollWidth - clientWidth, 0);
|
||||
// calculate the scroll time (scaled by global speed setting)
|
||||
const scrollTime = scrollDistance / SCROLL_SPEED * settings.speed.value;
|
||||
// calculate the scroll time (scaled by global speed setting), minimum 2s (4s when added to start and end delays)
|
||||
const scrollTime = Math.max(scrollDistance / SCROLL_SPEED * settings.speed.value, 2);
|
||||
// add 1 second pause at the end of the scroll animation
|
||||
const endPauseTime = 1.0;
|
||||
const totalAnimationTime = scrollTime + endPauseTime;
|
||||
@@ -252,17 +247,13 @@ const drawScrollCondition = (screen) => {
|
||||
scrollElement.style.backfaceVisibility = 'hidden'; // Force hardware acceleration
|
||||
scrollElement.style.perspective = '1000px'; // Enable 3D rendering context
|
||||
|
||||
elemForEach('.weather-display .scroll .fixed', (elem) => {
|
||||
elem.innerHTML = '';
|
||||
elem.append(scrollElement.cloneNode(true));
|
||||
});
|
||||
fixedScroll.innerHTML = '';
|
||||
fixedScroll.append(scrollElement.cloneNode(true));
|
||||
|
||||
// start the scroll after the specified delay
|
||||
setTimeout(() => {
|
||||
// change the transform to trigger the scroll
|
||||
elemForEach('.weather-display .scroll .fixed .scroll-area', (elem) => {
|
||||
elem.style.transform = `translateX(-${scrollDistance.toFixed(0)}px)`;
|
||||
});
|
||||
document.querySelector('#container>.scroll .fixed .scroll-area').style.transform = `translateX(-${scrollDistance.toFixed(0)}px)`;
|
||||
}, startDelayTime * 1000);
|
||||
};
|
||||
|
||||
@@ -270,9 +261,19 @@ const parseMessage = (event) => {
|
||||
if (event?.data?.type === 'current-weather-scroll') {
|
||||
if (event.data?.method === 'start') start();
|
||||
if (event.data?.method === 'reload') stop(true);
|
||||
if (event.data?.method === 'show') show();
|
||||
if (event.data?.method === 'hide') hide();
|
||||
}
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
mainScroll.style.display = 'block';
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
mainScroll.style.display = 'none';
|
||||
};
|
||||
|
||||
const screenCount = () => workingScreens.length;
|
||||
const atDefault = () => defaultScreensLoaded;
|
||||
|
||||
@@ -283,6 +284,8 @@ window.CurrentWeatherScroll = {
|
||||
addScreen,
|
||||
reset,
|
||||
start,
|
||||
show,
|
||||
hide,
|
||||
screenCount,
|
||||
atDefault,
|
||||
};
|
||||
@@ -291,6 +294,9 @@ export {
|
||||
addScreen,
|
||||
reset,
|
||||
start,
|
||||
show,
|
||||
hide,
|
||||
screenCount,
|
||||
atDefault,
|
||||
hazards,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -209,4 +209,4 @@ const shortenExtendedForecastText = (long) => {
|
||||
};
|
||||
|
||||
// register display
|
||||
registerDisplay(new ExtendedForecast(8, 'extended-forecast'));
|
||||
registerDisplay(new ExtendedForecast(9, 'extended-forecast'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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],
|
||||
@@ -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);
|
||||
|
||||
@@ -205,4 +205,4 @@ const shortenCurrentConditions = (_condition) => {
|
||||
return condition;
|
||||
};
|
||||
// register display
|
||||
registerDisplay(new LatestObservations(2, 'latest-observations'));
|
||||
registerDisplay(new LatestObservations(3, 'latest-observations'));
|
||||
|
||||
@@ -262,4 +262,4 @@ const parse = (forecast, forecastUrl) => {
|
||||
}));
|
||||
};
|
||||
// register display
|
||||
registerDisplay(new LocalForecast(7, 'local-forecast'));
|
||||
registerDisplay(new LocalForecast(8, 'local-forecast'));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -330,6 +331,7 @@ const handleNavButton = (button) => {
|
||||
break;
|
||||
case 'menu':
|
||||
setPlaying(false);
|
||||
postMessage({ type: 'current-weather-scroll', method: 'hide' });
|
||||
if (progress) {
|
||||
progress.showCanvas();
|
||||
} else if (settings?.kiosk?.value) {
|
||||
|
||||
114
server/scripts/modules/personal-weather.mjs
Normal file
114
server/scripts/modules/personal-weather.mjs
Normal 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);
|
||||
@@ -22,7 +22,9 @@ class Progress extends WeatherDisplay {
|
||||
}
|
||||
|
||||
async drawCanvas(displays, loadedCount) {
|
||||
// skip drawing if not displayed, or not yet available
|
||||
if (!this.elem) return;
|
||||
if (this.elem.classList.contains('show') === false) return;
|
||||
super.drawCanvas();
|
||||
|
||||
// get the progress bar cover (makes percentage)
|
||||
|
||||
@@ -231,4 +231,4 @@ class Radar extends WeatherDisplay {
|
||||
}
|
||||
|
||||
// register display
|
||||
registerDisplay(new Radar(11, 'radar'));
|
||||
registerDisplay(new Radar(12, 'radar'));
|
||||
|
||||
@@ -235,4 +235,4 @@ const getAndFormatPoint = async (lat, lon) => {
|
||||
};
|
||||
|
||||
// register display
|
||||
registerDisplay(new RegionalForecast(6, 'regional-forecast'));
|
||||
registerDisplay(new RegionalForecast(7, 'regional-forecast'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
@@ -145,4 +146,4 @@ class SpcOutlook extends WeatherDisplay {
|
||||
}
|
||||
|
||||
// register display
|
||||
registerDisplay(new SpcOutlook(10, 'spc-outlook'));
|
||||
registerDisplay(new SpcOutlook(11, 'spc-outlook'));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -172,6 +172,7 @@ class WeatherDisplay {
|
||||
if (this.screenIndex < 0) this.screenIndex = 0;
|
||||
if (this.okToDrawCurrentDateTime) this.drawCurrentDateTime();
|
||||
if (this.okToDrawCurrentConditions) postMessage({ type: 'current-weather-scroll', method: 'start' });
|
||||
if (this.okToDrawCurrentConditions === false) postMessage({ type: 'current-weather-scroll', method: 'hide' });
|
||||
}
|
||||
|
||||
finishDraw() {
|
||||
|
||||
1
server/scripts/vendor/auto/luxon.js.map
vendored
1
server/scripts/vendor/auto/luxon.js.map
vendored
File diff suppressed because one or more lines are too long
4
server/scripts/vendor/auto/luxon.mjs
vendored
4
server/scripts/vendor/auto/luxon.mjs
vendored
@@ -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
|
||||
|
||||
1
server/scripts/vendor/auto/luxon.mjs.map
vendored
Normal file
1
server/scripts/vendor/auto/luxon.mjs.map
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -815,4 +815,10 @@ body.kiosk #loading .instructions {
|
||||
>*:not(#divTwc) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
#divInfo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
max-width: 250px;
|
||||
}
|
||||
68
server/styles/scss/_personal-weather.scss
Normal file
68
server/styles/scss/_personal-weather.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,31 +112,32 @@
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.scroll {
|
||||
@include u.text-shadow(3px, 1.5px);
|
||||
#container>.scroll {
|
||||
display: none;
|
||||
@include u.text-shadow(3px, 1.5px);
|
||||
width: 640px;
|
||||
height: 77px;
|
||||
overflow: hidden;
|
||||
margin-top: 3px;
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
z-index: 1;
|
||||
|
||||
&.hazard {
|
||||
background-color: rgb(112, 35, 35);
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
width: 640px;
|
||||
height: 77px;
|
||||
overflow: hidden;
|
||||
margin-top: 3px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&.hazard {
|
||||
background-color: rgb(112, 35, 35);
|
||||
}
|
||||
|
||||
.fixed,
|
||||
.scroll-header {
|
||||
margin-left: 55px;
|
||||
margin-right: 55px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Remove margins for hazard scrolls to maximize text space
|
||||
&.hazard .fixed {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scroll-header {
|
||||
@@ -158,21 +159,17 @@
|
||||
// left: calc((elem width) - 640px);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#scroll-bg {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
height: 77px;
|
||||
width: 640px;
|
||||
|
||||
&.hazard {
|
||||
background-color: rgb(112, 35, 35);
|
||||
}
|
||||
}
|
||||
|
||||
.wide #scroll-bg {
|
||||
.wide #container>.scroll {
|
||||
width: 854px;
|
||||
margin-left: -107px;
|
||||
|
||||
.scroll-container {
|
||||
margin-left: 107px;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@use 'page';
|
||||
@use 'weather-display';
|
||||
@use 'current-weather';
|
||||
@use 'personal-weather';
|
||||
@use 'extended-forecast';
|
||||
@use 'hourly';
|
||||
@use 'hourly-graph';
|
||||
|
||||
25
src/com.chrome.devtools.mjs
Normal file
25
src/com.chrome.devtools.mjs
Normal 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;
|
||||
20
src/personal-weather.mjs
Normal file
20
src/personal-weather.mjs
Normal 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;
|
||||
@@ -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;'
|
||||
|
||||
@@ -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>
|
||||
@@ -133,8 +141,8 @@
|
||||
<div id="hazards-html" class="weather-display">
|
||||
<%- include('partials/hazards.ejs') %>
|
||||
</div>
|
||||
<%- include('partials/scroll.ejs') %>
|
||||
</div>
|
||||
<div id="scroll-bg"></div>
|
||||
</div>
|
||||
<div id="divTwcBottom">
|
||||
<div id="divTwcBottomLeft">
|
||||
@@ -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" />
|
||||
@@ -186,16 +200,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='heading'>Forecast Information</div>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -21,5 +21,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
</div>
|
||||
@@ -1,43 +1,42 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Current' , bottom: 'Conditions' }, noaaLogo: true, hasTime: true}) %>
|
||||
<div class="main has-scroll has-box current-weather">
|
||||
<div class="weather template">
|
||||
<div class="left col">
|
||||
<div class="temp center"></div>
|
||||
<div class="condition center"></div>
|
||||
<div class="icon center"><img src="" /></div>
|
||||
<div class="wind-container">
|
||||
<div class="wind-label">Wind:</div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
<div class="wind-gusts"></div>
|
||||
</div>
|
||||
<div class="right col">
|
||||
<div class="location"></div>
|
||||
<div class="row">
|
||||
<div class="label">Humidity:</div>
|
||||
<div class="humidity value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Dewpoint:</div>
|
||||
<div class="dewpoint value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Ceiling:</div>
|
||||
<div class="ceiling value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Visibility:</div>
|
||||
<div class="visibility value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Pressure:</div>
|
||||
<div class="pressure value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="heat-index-label label"></div>
|
||||
<div class="heat-index value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll has-box current-weather">
|
||||
<div class="weather template">
|
||||
<div class="left col">
|
||||
<div class="temp center"></div>
|
||||
<div class="condition center"></div>
|
||||
<div class="icon center"><img src="" /></div>
|
||||
<div class="wind-container">
|
||||
<div class="wind-label">Wind:</div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
<div class="wind-gusts"></div>
|
||||
</div>
|
||||
<div class="right col">
|
||||
<div class="location"></div>
|
||||
<div class="row">
|
||||
<div class="label">Humidity:</div>
|
||||
<div class="humidity value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Dewpoint:</div>
|
||||
<div class="dewpoint value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Ceiling:</div>
|
||||
<div class="ceiling value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Visibility:</div>
|
||||
<div class="visibility value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Pressure:</div>
|
||||
<div class="pressure value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="heat-index-label label"></div>
|
||||
<div class="heat-index value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,5 +19,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
</div>
|
||||
@@ -1,24 +1,23 @@
|
||||
<%- include('header.ejs', {title: 'Hourly Graph' , hasTime: false }) %>
|
||||
<div class="main has-scroll hourly-graph">
|
||||
<div class="top-right template ">
|
||||
<div class="temperature">Temperature</div>
|
||||
<div class="cloud">Cloud %</div>
|
||||
<div class="rain">Precip %</div>
|
||||
</div>
|
||||
<div class="y-axis">
|
||||
<div class="label l-1">75</div>
|
||||
<div class="label l-2">65</div>
|
||||
<div class="label l-3">55</div>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<img id="chart-area"></img>
|
||||
</div>
|
||||
<div class="x-axis">
|
||||
<div class="label l-1">12a</div>
|
||||
<div class="label l-2">6a</div>
|
||||
<div class="label l-3">12p</div>
|
||||
<div class="label l-4">6p</div>
|
||||
<div class="label l-5">12a</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="top-right template ">
|
||||
<div class="temperature">Temperature</div>
|
||||
<div class="cloud">Cloud %</div>
|
||||
<div class="rain">Precip %</div>
|
||||
</div>
|
||||
<div class="y-axis">
|
||||
<div class="label l-1">75</div>
|
||||
<div class="label l-2">65</div>
|
||||
<div class="label l-3">55</div>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<img id="chart-area"></img>
|
||||
</div>
|
||||
<div class="x-axis">
|
||||
<div class="label l-1">12a</div>
|
||||
<div class="label l-2">6a</div>
|
||||
<div class="label l-3">12p</div>
|
||||
<div class="label l-4">6p</div>
|
||||
<div class="label l-5">12a</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,18 +1,17 @@
|
||||
<%- include('header.ejs', {title: 'Hourly Forecast' , hasTime: true }) %>
|
||||
<div class="main has-scroll hourly">
|
||||
<div class="column-headers">
|
||||
<div class="temp">TEMP</div>
|
||||
<div class="like">LIKE</div>
|
||||
<div class="wind">WIND</div>
|
||||
</div>
|
||||
<div class="hourly-lines">
|
||||
<div class="hourly-row template">
|
||||
<div class="hour"></div>
|
||||
<div class="icon"><img /></div>
|
||||
<div class="temp"></div>
|
||||
<div class="like"></div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll hourly">
|
||||
<div class="column-headers">
|
||||
<div class="temp">TEMP</div>
|
||||
<div class="like">LIKE</div>
|
||||
<div class="wind">WIND</div>
|
||||
</div>
|
||||
<div class="hourly-lines">
|
||||
<div class="hourly-row template">
|
||||
<div class="hour"></div>
|
||||
<div class="icon"><img /></div>
|
||||
<div class="temp"></div>
|
||||
<div class="like"></div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +1,19 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Latest' , bottom: 'Observations' }, noaaLogo: true, hasTime: true }) %>
|
||||
<div class="main has-scroll latest-observations has-box">
|
||||
<div class="container">
|
||||
<div class="column-headers">
|
||||
<div class="temp english">°F</div>
|
||||
<div class="temp metric">°C</div>
|
||||
<div class="weather">Weather</div>
|
||||
<div class="wind">Wind</div>
|
||||
</div>
|
||||
<div class="observation-lines">
|
||||
<div class="observation-row template">
|
||||
<div class="location"></div>
|
||||
<div class="temp"></div>
|
||||
<div class="weather"></div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll latest-observations has-box">
|
||||
<div class="container">
|
||||
<div class="column-headers">
|
||||
<div class="temp english">°F</div>
|
||||
<div class="temp metric">°C</div>
|
||||
<div class="weather">Weather</div>
|
||||
<div class="wind">Wind</div>
|
||||
</div>
|
||||
<div class="observation-lines">
|
||||
<div class="observation-row template">
|
||||
<div class="location"></div>
|
||||
<div class="temp"></div>
|
||||
<div class="weather"></div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
25
views/partials/personal-weather.ejs
Normal file
25
views/partials/personal-weather.ejs
Normal 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>
|
||||
@@ -1,14 +1,13 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Regional' , bottom: 'Observations' }, hasTime: true }) %>
|
||||
<div class="main has-scroll regional-forecast">
|
||||
<div class="map"><img src="images/maps/basemap.webp" /></div>
|
||||
<div class="location-container">
|
||||
<div class="location template">
|
||||
<div class="icon">
|
||||
<img src="" />
|
||||
</div>
|
||||
<div class="city"></div>
|
||||
<div class="temp"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll regional-forecast">
|
||||
<div class="map"><img src="images/maps/basemap.webp" /></div>
|
||||
<div class="location-container">
|
||||
<div class="location template">
|
||||
<div class="icon">
|
||||
<img src="" />
|
||||
</div>
|
||||
<div class="city"></div>
|
||||
<div class="temp"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<div class="scroll">
|
||||
<div class="scrolling template"></div>
|
||||
<div class="scroll-header"></div>
|
||||
<div class="fixed"></div>
|
||||
<div class="scroll-container">
|
||||
<div class="scroll-header"></div>
|
||||
<div class="fixed"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +1,19 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Storm Prediction' , bottom: 'Center Outlook' }, hasTime: true}) %>
|
||||
<div class="main has-scroll spc-outlook">
|
||||
<div class="container">
|
||||
<div class="risk-levels">
|
||||
<div class="risk-level">High</div>
|
||||
<div class="risk-level">Moderate</div>
|
||||
<div class="risk-level">Enhanced</div>
|
||||
<div class="risk-level">Slight</div>
|
||||
<div class="risk-level">Marginal</div>
|
||||
<div class="risk-level">T'Storm</div>
|
||||
</div>
|
||||
<div class="days">
|
||||
<div class="day template">
|
||||
<div class="day-name">Monday</div>
|
||||
<div class="risk-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll spc-outlook">
|
||||
<div class="container">
|
||||
<div class="risk-levels">
|
||||
<div class="risk-level">High</div>
|
||||
<div class="risk-level">Moderate</div>
|
||||
<div class="risk-level">Enhanced</div>
|
||||
<div class="risk-level">Slight</div>
|
||||
<div class="risk-level">Marginal</div>
|
||||
<div class="risk-level">T'Storm</div>
|
||||
</div>
|
||||
<div class="days">
|
||||
<div class="day template">
|
||||
<div class="day-name">Monday</div>
|
||||
<div class="risk-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,5 +12,4 @@
|
||||
<div class="temp high"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
</div>
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user