mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-14 15:49:31 -07:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a161762e | ||
|
|
707b08ee1a | ||
|
|
7900e59aab | ||
|
|
9b422dd697 | ||
|
|
e4ce0b6cc6 | ||
|
|
b0e5018179 | ||
|
|
6422589b5c | ||
|
|
407da90f8a | ||
|
|
3a0e6aa345 | ||
|
|
650dda7b61 | ||
|
|
8f1e8ffb74 | ||
|
|
93af84cbd8 | ||
|
|
117f66e9d0 | ||
|
|
bca9376edc | ||
|
|
8b076db25d | ||
|
|
807932fe3c | ||
|
|
7bb024eff5 | ||
|
|
f4a1a3a1d8 | ||
|
|
9a5efe9d48 | ||
|
|
14b1891efd |
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -12,3 +12,4 @@ Please do not report issues with api.weather.gov being down. It's a new service
|
||||
Please include:
|
||||
* Web browser and OS
|
||||
* Headend Information text block from the very bottom of the web page
|
||||
* How you're running Weatherstar (Node, Dockerfile, Dockerfile.server, etc.)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
1328
package-lock.json
generated
1328
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "6.2.2",
|
||||
"version": "6.2.8",
|
||||
"description": "Welcome to the WeatherStar 4000+ project page!",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
||||
@@ -106,17 +106,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();
|
||||
@@ -162,6 +179,26 @@ const init = async () => {
|
||||
document.querySelector('#container').addEventListener('swiped-right', () => swipeCallBack('right'));
|
||||
};
|
||||
|
||||
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) => {
|
||||
// Note: it's fine that this uses json instead of safeJson since it's infrequent and user-initiated
|
||||
const data = await json('https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/find', {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -84,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 || {};
|
||||
@@ -100,7 +100,7 @@ const drawScreen = async () => {
|
||||
// if we have no current weather and no hazards, there's nothing to display
|
||||
if (!data && (!scrollData.hazards || scrollData.hazards.length === 0)) return;
|
||||
|
||||
const thisScreen = workingScreens[screenIndex](scrollData);
|
||||
const thisScreen = workingScreens[screenIndex](scrollData, parameters);
|
||||
|
||||
// update classes on the scroll area
|
||||
mainScroll.classList.forEach((cls) => { if (cls !== 'scroll') mainScroll.classList.remove(cls); });
|
||||
@@ -298,4 +298,5 @@ export {
|
||||
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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;'
|
||||
|
||||
@@ -73,7 +73,10 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
"cSpell.words": [
|
||||
"hibyehihi"
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"recommendations": [
|
||||
|
||||
Reference in New Issue
Block a user