mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-14 15:49:31 -07:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b257ace55 | ||
|
|
5dd8f4bd62 | ||
|
|
76fd93e6e1 | ||
|
|
2368a30980 | ||
|
|
8efff1a057 | ||
|
|
ae0d0ef9ec | ||
|
|
f633631532 | ||
|
|
3f5cd4ca70 | ||
|
|
0c8db4f38e | ||
|
|
dc4db67b96 | ||
|
|
52487319fa | ||
|
|
6a1e2da11e | ||
|
|
1cf9f41ca0 | ||
|
|
f7505e3e6f | ||
|
|
705fa9f582 | ||
|
|
5edf5cc947 | ||
|
|
d0382e0de1 | ||
|
|
69d14236f1 | ||
|
|
64fb06d7b4 | ||
|
|
3ea6c0fd55 | ||
|
|
e13582b760 | ||
|
|
1a7734b620 | ||
|
|
0331de8b8a |
@@ -31,7 +31,7 @@ Open your web browser: http://localhost:8080/
|
||||
The change to 5.0 changes from drawing the weather graphics on canvas elements and instead uses HTML and CSS to style all of the weather graphics. A lot of other changes and fixes were implemented at the same time.
|
||||
|
||||
* Replace all canvas elements with HTML and CSS
|
||||
* City and airport names are better parsed to better show location in the available space
|
||||
* City and airport names are better parsed to fit the available space
|
||||
* Remove the dependency on libgif-js
|
||||
* Use browser for text wrapping where necessary
|
||||
* Some new weather icons
|
||||
@@ -61,14 +61,15 @@ The fork is a result of wanting a more manageable, modern code base to work with
|
||||
|
||||
I've made several changes to this Weather Star 4000 simulation compared to the original hardware unit and the code that this was forked from.
|
||||
|
||||
* 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 "Local Forecast" and "Extended Forecast" provide several additional days of information compared to the original format in the 90's.
|
||||
* Narration was removed. In the original code narration made use of the computer's local text-to-speech engine which didn't sound great.
|
||||
* Music was removed. I don't want to deal with copyright issues and hosting MP3s. If you're looking for the music that played during forecasts please visit [TWCClassics](https://twcclassics.com/audio/).
|
||||
* Marine forecast (tides) is not available as it is not part of the new API.
|
||||
* The nearby cities displayed on screens such as "Latest Observations" and "Regional Forecast" are likely not the same as they were in the 90's. The weather monitoring equipment at these stations move over time for one reason or another, and coming up with a simple formulaic way of finding nearby stations is sufficient to give the same look-and-feel as the original.
|
||||
* The "Local Forecast" and "Extended Forecast" provide several additional days of information compared to the original format in the 90's.
|
||||
* "Flavors" are not present in this simulation. Flavors refer to the order of the weather information that was shown on the original units. Instead, the order of the displays has been fixed and a checkboxes can be used to turn on and off individual displays. The travel forecast has been defaulted to off so only local information shows for new users.
|
||||
* Radar displays the timestamp of the image.
|
||||
* A new hourly forecast display for the next 24 hours is available, and is shown in the style of the travel cities forecast.
|
||||
|
||||
## Wish list
|
||||
|
||||
|
||||
2
dist/index.html
vendored
2
dist/index.html
vendored
File diff suppressed because one or more lines are too long
2
dist/resources/ws.min.css
vendored
2
dist/resources/ws.min.css
vendored
File diff suppressed because one or more lines are too long
3
dist/resources/ws.min.js
vendored
3
dist/resources/ws.min.js
vendored
File diff suppressed because one or more lines are too long
1
dist/resources/ws.min.js.map
vendored
1
dist/resources/ws.min.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -77,6 +77,7 @@ const mjsSources = [
|
||||
'server/scripts/modules/icons.mjs',
|
||||
'server/scripts/modules/extendedforecast.mjs',
|
||||
'server/scripts/modules/hourly.mjs',
|
||||
'server/scripts/modules/hourly-graph.mjs',
|
||||
'server/scripts/modules/latestobservations.mjs',
|
||||
'server/scripts/modules/localforecast.mjs',
|
||||
'server/scripts/modules/radar.mjs',
|
||||
@@ -141,6 +142,18 @@ gulp.task('upload', () => gulp.src(uploadSources, { base: './dist' })
|
||||
},
|
||||
})));
|
||||
|
||||
const imageSources = [
|
||||
'server/fonts/**',
|
||||
'server/images/**',
|
||||
];
|
||||
gulp.task('upload_images', () => gulp.src(imageSources, { base: './server' })
|
||||
.pipe(
|
||||
s3({
|
||||
Bucket: 'weatherstar',
|
||||
StorageClass: 'STANDARD',
|
||||
}),
|
||||
));
|
||||
|
||||
gulp.task('invalidate', async () => cloudfront.createInvalidation({
|
||||
DistributionId: 'E9171A4KV8KCW',
|
||||
InvalidationBatch: {
|
||||
@@ -152,4 +165,4 @@ gulp.task('invalidate', async () => cloudfront.createInvalidation({
|
||||
},
|
||||
}).promise());
|
||||
|
||||
module.exports = gulp.series(clean, gulp.parallel('build_js', 'compress_js_data', 'compress_js_vendor', 'copy_css', 'compress_html', 'copy_other_files'), 'upload', 'invalidate');
|
||||
module.exports = gulp.series(clean, gulp.parallel('build_js', 'compress_js_data', 'compress_js_vendor', 'copy_css', 'compress_html', 'copy_other_files'), gulp.parallel('upload', 'upload_images'), 'invalidate');
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "5.2.0",
|
||||
"version": "5.5.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ws4kp",
|
||||
"version": "5.2.0",
|
||||
"version": "5.5.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eslint": "^8.21.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "5.2.0",
|
||||
"version": "5.5.0",
|
||||
"description": "Welcome to the WeatherStar 4000+ project page!",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -40,10 +40,9 @@
|
||||
"suncalc": "^1.8.0",
|
||||
"swiped-events": "^1.1.4",
|
||||
"terser-webpack-plugin": "^5.3.6",
|
||||
"webpack-stream": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"webpack-stream": "^7.0.0",
|
||||
"eslint": "^8.21.0",
|
||||
"eslint-plugin-import": "^2.26.0"
|
||||
}
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
BIN
server/images/BackGround1_1_Chart.png
Normal file
BIN
server/images/BackGround1_1_Chart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
BIN
server/images/nav/ic_gps_fixed_white_18dp_1x.png
Normal file
BIN
server/images/nav/ic_gps_fixed_white_18dp_1x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
server/images/nav/ic_gps_fixed_white_18dp_2x.png
Normal file
BIN
server/images/nav/ic_gps_fixed_white_18dp_2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
server/images/nav/ic_gps_fixed_white_24dp_1x.png
Normal file
BIN
server/images/nav/ic_gps_fixed_white_24dp_1x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
BIN
server/images/nav/ic_gps_fixed_whte_24dp_2x.png
Normal file
BIN
server/images/nav/ic_gps_fixed_whte_24dp_2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
@@ -23,7 +23,7 @@ const categories = [
|
||||
'Airport', 'Ferry', 'Marina', 'Pier', 'Port', 'Resort', // POI/Travel
|
||||
'Postal', 'Populated Place',
|
||||
];
|
||||
const cats = categories.join(',');
|
||||
const category = categories.join(',');
|
||||
|
||||
const init = () => {
|
||||
document.getElementById('txtAddress').addEventListener('focus', (e) => {
|
||||
@@ -54,7 +54,7 @@ const init = () => {
|
||||
params: {
|
||||
f: 'json',
|
||||
countryCode: 'USA', // 'USA,PRI,VIR,GUM,ASM',
|
||||
category: cats,
|
||||
category,
|
||||
maxSuggestions: 10,
|
||||
},
|
||||
dataType: 'json',
|
||||
@@ -192,8 +192,10 @@ const EnterFullScreen = () => {
|
||||
resize();
|
||||
UpdateFullScreenNavigate();
|
||||
|
||||
// change hover text
|
||||
document.getElementById('ToggleFullScreen').title = 'Exit fullscreen';
|
||||
// change hover text and image
|
||||
const img = document.getElementById('ToggleFullScreen');
|
||||
img.src = 'images/nav/ic_fullscreen_exit_white_24dp_1x.png';
|
||||
img.title = 'Exit fullscreen';
|
||||
};
|
||||
|
||||
const ExitFullscreen = () => {
|
||||
@@ -214,8 +216,10 @@ const ExitFullscreen = () => {
|
||||
document.msExitFullscreen();
|
||||
}
|
||||
resize();
|
||||
// change hover text
|
||||
document.getElementById('ToggleFullScreen').title = 'Enter fullscreen';
|
||||
// change hover text and image
|
||||
const img = document.getElementById('ToggleFullScreen');
|
||||
img.src = 'images/nav/ic_fullscreen_white_24dp_1x.png';
|
||||
img.title = 'Enter fullscreen';
|
||||
};
|
||||
|
||||
const btnNavigateMenuClick = () => {
|
||||
|
||||
@@ -133,7 +133,7 @@ class Almanac extends WeatherDisplay {
|
||||
|
||||
fill.date = date;
|
||||
fill.type = MoonPhase.phase;
|
||||
fill.icon = { type: 'img', src: Almanac.imageName(MoonPhase.Phase) };
|
||||
fill.icon = { type: 'img', src: imageName(MoonPhase.phase) };
|
||||
|
||||
return this.fillTemplate('day', fill);
|
||||
});
|
||||
@@ -145,20 +145,6 @@ class Almanac extends WeatherDisplay {
|
||||
this.finishDraw();
|
||||
}
|
||||
|
||||
static imageName(type) {
|
||||
switch (type) {
|
||||
case 'Full':
|
||||
return 'images/2/Full-Moon.gif';
|
||||
case 'Last':
|
||||
return 'images/2/Last-Quarter.gif';
|
||||
case 'New':
|
||||
return 'images/2/New-Moon.gif';
|
||||
case 'First':
|
||||
default:
|
||||
return 'images/2/First-Quarter.gif';
|
||||
}
|
||||
}
|
||||
|
||||
// make sun and moon data available outside this class
|
||||
// promise allows for data to be requested before it is available
|
||||
async getSun() {
|
||||
@@ -170,8 +156,22 @@ class Almanac extends WeatherDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
const imageName = (type) => {
|
||||
switch (type) {
|
||||
case 'Full':
|
||||
return 'images/2/Full-Moon.gif';
|
||||
case 'Last':
|
||||
return 'images/2/Last-Quarter.gif';
|
||||
case 'New':
|
||||
return 'images/2/New-Moon.gif';
|
||||
case 'First':
|
||||
default:
|
||||
return 'images/2/First-Quarter.gif';
|
||||
}
|
||||
};
|
||||
|
||||
// register display
|
||||
const display = new Almanac(7, 'almanac');
|
||||
const display = new Almanac(8, 'almanac');
|
||||
registerDisplay(display);
|
||||
|
||||
export default display.getSun.bind(display);
|
||||
|
||||
@@ -19,7 +19,8 @@ class CurrentWeather extends WeatherDisplay {
|
||||
}
|
||||
|
||||
async getData(_weatherParameters) {
|
||||
if (!super.getData(_weatherParameters)) return;
|
||||
// always load the data for use in the lower scroll
|
||||
const superResult = super.getData(_weatherParameters);
|
||||
const weatherParameters = _weatherParameters ?? this.weatherParameters;
|
||||
|
||||
// Load the observations
|
||||
@@ -39,6 +40,8 @@ class CurrentWeather extends WeatherDisplay {
|
||||
data: {
|
||||
limit: 2,
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
|
||||
// test data quality
|
||||
@@ -55,17 +58,22 @@ class CurrentWeather extends WeatherDisplay {
|
||||
// test for data received
|
||||
if (!observations) {
|
||||
console.error('All current weather stations exhausted');
|
||||
this.setStatus(STATUS.failed);
|
||||
if (this.enabled) this.setStatus(STATUS.failed);
|
||||
// send failed to subscribers
|
||||
this.getDataCallback(undefined);
|
||||
return;
|
||||
}
|
||||
// preload the icon
|
||||
preloadImg(getWeatherIconFromIconLink(observations.features[0].properties.icon));
|
||||
|
||||
// we only get here if there was no error above
|
||||
this.data = { ...observations, station };
|
||||
this.setStatus(STATUS.loaded);
|
||||
|
||||
this.getDataCallback();
|
||||
|
||||
// stop here if we're disabled
|
||||
if (!superResult) return;
|
||||
|
||||
// preload the icon
|
||||
preloadImg(getWeatherIconFromIconLink(observations.features[0].properties.icon));
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
// format the data for use outside this function
|
||||
@@ -126,7 +134,7 @@ class CurrentWeather extends WeatherDisplay {
|
||||
|
||||
let Conditions = data.observations.textDescription;
|
||||
if (Conditions.length > 15) {
|
||||
Conditions = this.shortConditions(Conditions);
|
||||
Conditions = shortConditions(Conditions);
|
||||
}
|
||||
fill.condition = Conditions;
|
||||
|
||||
@@ -161,33 +169,35 @@ class CurrentWeather extends WeatherDisplay {
|
||||
|
||||
// make data available outside this class
|
||||
// promise allows for data to be requested before it is available
|
||||
async getCurrentWeather() {
|
||||
async getCurrentWeather(stillWaiting) {
|
||||
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
|
||||
return new Promise((resolve) => {
|
||||
if (this.data) resolve(this.parseData());
|
||||
// data not available, put it into the data callback queue
|
||||
this.getDataCallbacks.push(() => resolve(this.parseData()));
|
||||
});
|
||||
}
|
||||
|
||||
static shortConditions(_condition) {
|
||||
let condition = _condition;
|
||||
condition = condition.replace(/Light/g, 'L');
|
||||
condition = condition.replace(/Heavy/g, 'H');
|
||||
condition = condition.replace(/Partly/g, 'P');
|
||||
condition = condition.replace(/Mostly/g, 'M');
|
||||
condition = condition.replace(/Few/g, 'F');
|
||||
condition = condition.replace(/Thunderstorm/g, 'T\'storm');
|
||||
condition = condition.replace(/ in /g, '');
|
||||
condition = condition.replace(/Vicinity/g, '');
|
||||
condition = condition.replace(/ and /g, ' ');
|
||||
condition = condition.replace(/Freezing Rain/g, 'Frz Rn');
|
||||
condition = condition.replace(/Freezing/g, 'Frz');
|
||||
condition = condition.replace(/Unknown Precip/g, '');
|
||||
condition = condition.replace(/L Snow Fog/g, 'L Snw/Fog');
|
||||
condition = condition.replace(/ with /g, '/');
|
||||
return condition;
|
||||
}
|
||||
}
|
||||
|
||||
const shortConditions = (_condition) => {
|
||||
let condition = _condition;
|
||||
condition = condition.replace(/Light/g, 'L');
|
||||
condition = condition.replace(/Heavy/g, 'H');
|
||||
condition = condition.replace(/Partly/g, 'P');
|
||||
condition = condition.replace(/Mostly/g, 'M');
|
||||
condition = condition.replace(/Few/g, 'F');
|
||||
condition = condition.replace(/Thunderstorm/g, 'T\'storm');
|
||||
condition = condition.replace(/ in /g, '');
|
||||
condition = condition.replace(/Vicinity/g, '');
|
||||
condition = condition.replace(/ and /g, ' ');
|
||||
condition = condition.replace(/Freezing Rain/g, 'Frz Rn');
|
||||
condition = condition.replace(/Freezing/g, 'Frz');
|
||||
condition = condition.replace(/Unknown Precip/g, '');
|
||||
condition = condition.replace(/L Snow Fog/g, 'L Snw/Fog');
|
||||
condition = condition.replace(/ with /g, '/');
|
||||
return condition;
|
||||
};
|
||||
|
||||
const display = new CurrentWeather(0, 'current-weather');
|
||||
registerDisplay(display);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const incrementInterval = () => {
|
||||
|
||||
const drawScreen = async () => {
|
||||
// get the conditions
|
||||
const data = await getCurrentWeather();
|
||||
const data = await getCurrentWeather(() => this.stillWaiting());
|
||||
|
||||
// nothing to do if there's no data yet
|
||||
if (!data) return;
|
||||
|
||||
@@ -28,6 +28,8 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
data: {
|
||||
units: 'us',
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Unable to get extended forecast');
|
||||
@@ -36,95 +38,11 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
return;
|
||||
}
|
||||
// we only get here if there was no error above
|
||||
this.data = ExtendedForecast.parse(forecast.properties.periods);
|
||||
this.data = parse(forecast.properties.periods);
|
||||
this.screenIndex = 0;
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
// the api provides the forecast in 12 hour increments, flatten to day increments with high and low temperatures
|
||||
static parse(fullForecast) {
|
||||
// create a list of days starting with today
|
||||
const Days = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
const dates = Days.map((shift) => {
|
||||
const date = DateTime.local().startOf('day').plus({ days: shift });
|
||||
return date.toLocaleString({ weekday: 'short' });
|
||||
});
|
||||
|
||||
// track the destination forecast index
|
||||
let destIndex = 0;
|
||||
const forecast = [];
|
||||
fullForecast.forEach((period) => {
|
||||
// create the destination object if necessary
|
||||
if (!forecast[destIndex]) {
|
||||
forecast.push({
|
||||
dayName: '', low: undefined, high: undefined, text: undefined, icon: undefined,
|
||||
});
|
||||
}
|
||||
// get the object to modify/populate
|
||||
const fDay = forecast[destIndex];
|
||||
// high temperature will always be last in the source array so it will overwrite the low values assigned below
|
||||
fDay.icon = getWeatherIconFromIconLink(period.icon);
|
||||
fDay.text = ExtendedForecast.shortenExtendedForecastText(period.shortForecast);
|
||||
fDay.dayName = dates[destIndex];
|
||||
|
||||
// preload the icon
|
||||
preloadImg(fDay.icon);
|
||||
|
||||
if (period.isDaytime) {
|
||||
// day time is the high temperature
|
||||
fDay.high = period.temperature;
|
||||
destIndex += 1;
|
||||
} else {
|
||||
// low temperature
|
||||
fDay.low = period.temperature;
|
||||
}
|
||||
});
|
||||
|
||||
return forecast;
|
||||
}
|
||||
|
||||
static shortenExtendedForecastText(long) {
|
||||
const regexList = [
|
||||
[/ and /ig, ' '],
|
||||
[/Slight /ig, ''],
|
||||
[/Chance /ig, ''],
|
||||
[/Very /ig, ''],
|
||||
[/Patchy /ig, ''],
|
||||
[/Areas /ig, ''],
|
||||
[/Dense /ig, ''],
|
||||
[/Thunderstorm/g, 'T\'Storm'],
|
||||
];
|
||||
// run all regexes
|
||||
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);
|
||||
|
||||
let conditions = short.split(' ');
|
||||
if (short.indexOf('then') !== -1) {
|
||||
conditions = short.split(' then ');
|
||||
conditions = conditions[1].split(' ');
|
||||
}
|
||||
|
||||
let short1 = conditions[0].substr(0, 10);
|
||||
let short2 = '';
|
||||
if (conditions[1]) {
|
||||
if (!short1.endsWith('.')) {
|
||||
short2 = conditions[1].substr(0, 10);
|
||||
} else {
|
||||
short1 = short1.replace(/\./, '');
|
||||
}
|
||||
|
||||
if (short2 === 'Blowing') {
|
||||
short2 = '';
|
||||
}
|
||||
}
|
||||
let result = short1;
|
||||
if (short2 !== '') {
|
||||
result += ` ${short2}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
super.drawCanvas();
|
||||
|
||||
@@ -160,5 +78,89 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
// the api provides the forecast in 12 hour increments, flatten to day increments with high and low temperatures
|
||||
const parse = (fullForecast) => {
|
||||
// create a list of days starting with today
|
||||
const Days = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
const dates = Days.map((shift) => {
|
||||
const date = DateTime.local().startOf('day').plus({ days: shift });
|
||||
return date.toLocaleString({ weekday: 'short' });
|
||||
});
|
||||
|
||||
// track the destination forecast index
|
||||
let destIndex = 0;
|
||||
const forecast = [];
|
||||
fullForecast.forEach((period) => {
|
||||
// create the destination object if necessary
|
||||
if (!forecast[destIndex]) {
|
||||
forecast.push({
|
||||
dayName: '', low: undefined, high: undefined, text: undefined, icon: undefined,
|
||||
});
|
||||
}
|
||||
// get the object to modify/populate
|
||||
const fDay = forecast[destIndex];
|
||||
// high temperature will always be last in the source array so it will overwrite the low values assigned below
|
||||
fDay.icon = getWeatherIconFromIconLink(period.icon);
|
||||
fDay.text = shortenExtendedForecastText(period.shortForecast);
|
||||
fDay.dayName = dates[destIndex];
|
||||
|
||||
// preload the icon
|
||||
preloadImg(fDay.icon);
|
||||
|
||||
if (period.isDaytime) {
|
||||
// day time is the high temperature
|
||||
fDay.high = period.temperature;
|
||||
destIndex += 1;
|
||||
} else {
|
||||
// low temperature
|
||||
fDay.low = period.temperature;
|
||||
}
|
||||
});
|
||||
|
||||
return forecast;
|
||||
};
|
||||
|
||||
const shortenExtendedForecastText = (long) => {
|
||||
const regexList = [
|
||||
[/ and /ig, ' '],
|
||||
[/Slight /ig, ''],
|
||||
[/Chance /ig, ''],
|
||||
[/Very /ig, ''],
|
||||
[/Patchy /ig, ''],
|
||||
[/Areas /ig, ''],
|
||||
[/Dense /ig, ''],
|
||||
[/Thunderstorm/g, 'T\'Storm'],
|
||||
];
|
||||
// run all regexes
|
||||
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);
|
||||
|
||||
let conditions = short.split(' ');
|
||||
if (short.indexOf('then') !== -1) {
|
||||
conditions = short.split(' then ');
|
||||
conditions = conditions[1].split(' ');
|
||||
}
|
||||
|
||||
let short1 = conditions[0].substr(0, 10);
|
||||
let short2 = '';
|
||||
if (conditions[1]) {
|
||||
if (!short1.endsWith('.')) {
|
||||
short2 = conditions[1].substr(0, 10);
|
||||
} else {
|
||||
short1 = short1.replace(/\./, '');
|
||||
}
|
||||
|
||||
if (short2 === 'Blowing') {
|
||||
short2 = '';
|
||||
}
|
||||
}
|
||||
let result = short1;
|
||||
if (short2 !== '') {
|
||||
result += ` ${short2}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// register display
|
||||
registerDisplay(new ExtendedForecast(6, 'extended-forecast'));
|
||||
registerDisplay(new ExtendedForecast(7, 'extended-forecast'));
|
||||
|
||||
149
server/scripts/modules/hourly-graph.mjs
Normal file
149
server/scripts/modules/hourly-graph.mjs
Normal file
@@ -0,0 +1,149 @@
|
||||
// hourly forecast list
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import getHourlyData from './hourly.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
|
||||
class HourlyGraph extends WeatherDisplay {
|
||||
constructor(navId, elemId, defaultActive) {
|
||||
// special height and width for scrolling
|
||||
super(navId, elemId, 'Hourly Graph', defaultActive);
|
||||
|
||||
// move the top right data into the correct location on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.moveHeader();
|
||||
});
|
||||
}
|
||||
|
||||
moveHeader() {
|
||||
// get the header
|
||||
const header = this.fillTemplate('top-right', {});
|
||||
// place the header
|
||||
this.elem.querySelector('.header .right').append(header);
|
||||
}
|
||||
|
||||
async getData() {
|
||||
if (!super.getData()) return;
|
||||
|
||||
const data = await getHourlyData(() => this.stillWaiting());
|
||||
if (data === undefined) {
|
||||
this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
// get interesting data
|
||||
const temperature = data.map((d) => d.temperature);
|
||||
const probabilityOfPrecipitation = data.map((d) => d.probabilityOfPrecipitation);
|
||||
const skyCover = data.map((d) => d.skyCover);
|
||||
|
||||
this.data = {
|
||||
skyCover, temperature, probabilityOfPrecipitation,
|
||||
};
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
drawCanvas() {
|
||||
if (!this.image) this.image = this.elem.querySelector('.chart img');
|
||||
|
||||
// get available space
|
||||
const availableWidth = 532;
|
||||
const availableHeight = 285;
|
||||
|
||||
this.image.width = availableWidth;
|
||||
this.image.height = availableHeight;
|
||||
|
||||
// get context
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = availableWidth;
|
||||
canvas.height = availableHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// calculate time scale
|
||||
const timeScale = calcScale(0, 5, this.data.temperature.length - 1, availableWidth);
|
||||
const startTime = DateTime.now().startOf('hour');
|
||||
document.querySelector('.x-axis .l-1').innerHTML = formatTime(startTime);
|
||||
document.querySelector('.x-axis .l-2').innerHTML = formatTime(startTime.plus({ hour: 6 }));
|
||||
document.querySelector('.x-axis .l-3').innerHTML = formatTime(startTime.plus({ hour: 12 }));
|
||||
document.querySelector('.x-axis .l-4').innerHTML = formatTime(startTime.plus({ hour: 18 }));
|
||||
document.querySelector('.x-axis .l-5').innerHTML = formatTime(startTime.plus({ hour: 24 }));
|
||||
|
||||
// order is important last line drawn is on top
|
||||
// clouds
|
||||
const percentScale = calcScale(0, availableHeight - 10, 100, 10);
|
||||
const cloud = createPath(this.data.skyCover, timeScale, percentScale);
|
||||
drawPath(cloud, ctx, {
|
||||
strokeStyle: 'lightgrey',
|
||||
lineWidth: 3,
|
||||
});
|
||||
|
||||
// precip
|
||||
const precip = createPath(this.data.probabilityOfPrecipitation, timeScale, percentScale);
|
||||
drawPath(precip, ctx, {
|
||||
strokeStyle: 'aqua',
|
||||
lineWidth: 3,
|
||||
});
|
||||
|
||||
// temperature
|
||||
const minTemp = Math.min(...this.data.temperature);
|
||||
const maxTemp = Math.max(...this.data.temperature);
|
||||
const midTemp = Math.round((minTemp + maxTemp) / 2);
|
||||
const tempScale = calcScale(minTemp, availableHeight - 10, maxTemp, 10);
|
||||
const tempPath = createPath(this.data.temperature, timeScale, tempScale);
|
||||
drawPath(tempPath, ctx, {
|
||||
strokeStyle: 'red',
|
||||
lineWidth: 3,
|
||||
});
|
||||
|
||||
// temperature axis labels
|
||||
// limited to 3 characters, sacraficing degree character
|
||||
const degree = String.fromCharCode(176);
|
||||
this.elem.querySelector('.y-axis .l-1').innerHTML = (maxTemp + degree).substring(0, 3);
|
||||
this.elem.querySelector('.y-axis .l-2').innerHTML = (midTemp + degree).substring(0, 3);
|
||||
this.elem.querySelector('.y-axis .l-3').innerHTML = (minTemp + degree).substring(0, 3);
|
||||
|
||||
// set the image source
|
||||
this.image.src = canvas.toDataURL();
|
||||
|
||||
super.drawCanvas();
|
||||
this.finishDraw();
|
||||
}
|
||||
}
|
||||
|
||||
// create a scaling function from two points
|
||||
const calcScale = (x1, y1, x2, y2) => {
|
||||
const m = (y2 - y1) / (x2 - x1);
|
||||
const b = y1 - m * x1;
|
||||
return (x) => m * x + b;
|
||||
};
|
||||
|
||||
// create a path as an array of [x,y]
|
||||
const createPath = (data, xScale, yScale) => data.map((d, i) => [xScale(i), yScale(d)]);
|
||||
|
||||
// draw a path with shadow
|
||||
const drawPath = (path, ctx, options) => {
|
||||
// first shadow
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'black';
|
||||
ctx.lineWidth = (options?.lineWidth ?? 2) + 2;
|
||||
ctx.moveTo(path[0][0], path[0][1]);
|
||||
path.slice(1).forEach((point) => ctx.lineTo(point[0], point[1] + 2));
|
||||
ctx.stroke();
|
||||
|
||||
// then colored line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = options?.strokeStyle ?? 'red';
|
||||
ctx.lineWidth = (options?.lineWidth ?? 2);
|
||||
ctx.moveTo(path[0][0], path[0][1]);
|
||||
path.slice(1).forEach((point) => ctx.lineTo(point[0], point[1]));
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
// format as 1p, 12a, etc.
|
||||
const formatTime = (time) => time.toFormat('ha').slice(0, -1);
|
||||
|
||||
// register display
|
||||
registerDisplay(new HourlyGraph(3, 'hourly-graph'));
|
||||
@@ -29,82 +29,28 @@ class Hourly extends WeatherDisplay {
|
||||
|
||||
async getData(weatherParameters) {
|
||||
// super checks for enabled
|
||||
if (!super.getData(weatherParameters)) return;
|
||||
const superResponse = super.getData(weatherParameters);
|
||||
let forecast;
|
||||
try {
|
||||
// get the forecast
|
||||
forecast = await json(weatherParameters.forecastGridData);
|
||||
forecast = await json(weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
} catch (e) {
|
||||
console.error('Get hourly forecast failed');
|
||||
console.error(e.status, e.responseJSON);
|
||||
this.setStatus(STATUS.failed);
|
||||
if (this.enabled) this.setStatus(STATUS.failed);
|
||||
// return undefined to other subscribers
|
||||
this.getDataCallback(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
this.data = await Hourly.parseForecast(forecast.properties);
|
||||
this.data = await parseForecast(forecast.properties);
|
||||
this.getDataCallback();
|
||||
if (!superResponse) return;
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
this.drawLongCanvas();
|
||||
}
|
||||
|
||||
// extract specific values from forecast and format as an array
|
||||
static async parseForecast(data) {
|
||||
const temperature = Hourly.expand(data.temperature.values);
|
||||
const apparentTemperature = Hourly.expand(data.apparentTemperature.values);
|
||||
const windSpeed = Hourly.expand(data.windSpeed.values);
|
||||
const windDirection = Hourly.expand(data.windDirection.values);
|
||||
const skyCover = Hourly.expand(data.skyCover.values); // cloud icon
|
||||
const weather = Hourly.expand(data.weather.values); // fog icon
|
||||
const iceAccumulation = Hourly.expand(data.iceAccumulation.values); // ice icon
|
||||
const probabilityOfPrecipitation = Hourly.expand(data.probabilityOfPrecipitation.values); // rain icon
|
||||
const snowfallAmount = Hourly.expand(data.snowfallAmount.values); // snow icon
|
||||
|
||||
const icons = await Hourly.determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed);
|
||||
|
||||
return temperature.map((val, idx) => ({
|
||||
temperature: celsiusToFahrenheit(temperature[idx]),
|
||||
apparentTemperature: celsiusToFahrenheit(apparentTemperature[idx]),
|
||||
windSpeed: kilometersToMiles(windSpeed[idx]),
|
||||
windDirection: directionToNSEW(windDirection[idx]),
|
||||
icon: icons[idx],
|
||||
}));
|
||||
}
|
||||
|
||||
// given forecast paramaters determine a suitable icon
|
||||
static async determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed) {
|
||||
const startOfHour = DateTime.local().startOf('hour');
|
||||
const sunTimes = (await getSun()).sun;
|
||||
const overnight = Interval.fromDateTimes(DateTime.fromJSDate(sunTimes[0].sunset), DateTime.fromJSDate(sunTimes[1].sunrise));
|
||||
const tomorrowOvernight = DateTime.fromJSDate(sunTimes[1].sunset);
|
||||
return skyCover.map((val, idx) => {
|
||||
const hour = startOfHour.plus({ hours: idx });
|
||||
const isNight = overnight.contains(hour) || (hour > tomorrowOvernight);
|
||||
return getHourlyIcon(skyCover[idx], weather[idx], iceAccumulation[idx], probabilityOfPrecipitation[idx], snowfallAmount[idx], windSpeed[idx], isNight);
|
||||
});
|
||||
}
|
||||
|
||||
// expand a set of values with durations to an hour-by-hour array
|
||||
static expand(data) {
|
||||
const startOfHour = DateTime.utc().startOf('hour').toMillis();
|
||||
const result = []; // resulting expanded values
|
||||
data.forEach((item) => {
|
||||
let startTime = Date.parse(item.validTime.substr(0, item.validTime.indexOf('/')));
|
||||
const duration = Duration.fromISO(item.validTime.substr(item.validTime.indexOf('/') + 1)).shiftTo('milliseconds').values.milliseconds;
|
||||
const endTime = startTime + duration;
|
||||
// loop through duration at one hour intervals
|
||||
do {
|
||||
// test for timestamp greater than now
|
||||
if (startTime >= startOfHour && result.length < 24) {
|
||||
result.push(item.value); // push data array
|
||||
} // timestamp is after now
|
||||
// increment start time by 1 hour
|
||||
startTime += 3600000;
|
||||
} while (startTime < endTime && result.length < 24);
|
||||
}); // for each value
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async drawLongCanvas() {
|
||||
// get the list element and populate
|
||||
const list = this.elem.querySelector('.hourly-lines');
|
||||
@@ -172,19 +118,80 @@ class Hourly extends WeatherDisplay {
|
||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
||||
}
|
||||
|
||||
static getTravelCitiesDayName(cities) {
|
||||
// effectively returns early on the first found date
|
||||
return cities.reduce((dayName, city) => {
|
||||
if (city && dayName === '') {
|
||||
// today or tomorrow
|
||||
const day = DateTime.local().plus({ days: (city.today) ? 0 : 1 });
|
||||
// return the day
|
||||
return day.toLocaleString({ weekday: 'long' });
|
||||
}
|
||||
return dayName;
|
||||
}, '');
|
||||
// make data available outside this class
|
||||
// promise allows for data to be requested before it is available
|
||||
async getCurrentData(stillWaiting) {
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// extract specific values from forecast and format as an array
|
||||
const parseForecast = async (data) => {
|
||||
const temperature = expand(data.temperature.values);
|
||||
const apparentTemperature = expand(data.apparentTemperature.values);
|
||||
const windSpeed = expand(data.windSpeed.values);
|
||||
const windDirection = expand(data.windDirection.values);
|
||||
const skyCover = expand(data.skyCover.values); // cloud icon
|
||||
const weather = expand(data.weather.values); // fog icon
|
||||
const iceAccumulation = expand(data.iceAccumulation.values); // ice icon
|
||||
const probabilityOfPrecipitation = expand(data.probabilityOfPrecipitation.values); // rain icon
|
||||
const snowfallAmount = expand(data.snowfallAmount.values); // snow icon
|
||||
|
||||
const icons = await determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed);
|
||||
|
||||
return temperature.map((val, idx) => ({
|
||||
temperature: celsiusToFahrenheit(temperature[idx]),
|
||||
apparentTemperature: celsiusToFahrenheit(apparentTemperature[idx]),
|
||||
windSpeed: kilometersToMiles(windSpeed[idx]),
|
||||
windDirection: directionToNSEW(windDirection[idx]),
|
||||
probabilityOfPrecipitation: probabilityOfPrecipitation[idx],
|
||||
skyCover: skyCover[idx],
|
||||
icon: icons[idx],
|
||||
}));
|
||||
};
|
||||
|
||||
// given forecast paramaters determine a suitable icon
|
||||
const determineIcon = async (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed) => {
|
||||
const startOfHour = DateTime.local().startOf('hour');
|
||||
const sunTimes = (await getSun()).sun;
|
||||
const overnight = Interval.fromDateTimes(DateTime.fromJSDate(sunTimes[0].sunset), DateTime.fromJSDate(sunTimes[1].sunrise));
|
||||
const tomorrowOvernight = DateTime.fromJSDate(sunTimes[1].sunset);
|
||||
return skyCover.map((val, idx) => {
|
||||
const hour = startOfHour.plus({ hours: idx });
|
||||
const isNight = overnight.contains(hour) || (hour > tomorrowOvernight);
|
||||
return getHourlyIcon(skyCover[idx], weather[idx], iceAccumulation[idx], probabilityOfPrecipitation[idx], snowfallAmount[idx], windSpeed[idx], isNight);
|
||||
});
|
||||
};
|
||||
|
||||
// expand a set of values with durations to an hour-by-hour array
|
||||
const expand = (data) => {
|
||||
const startOfHour = DateTime.utc().startOf('hour').toMillis();
|
||||
const result = []; // resulting expanded values
|
||||
data.forEach((item) => {
|
||||
let startTime = Date.parse(item.validTime.substr(0, item.validTime.indexOf('/')));
|
||||
const duration = Duration.fromISO(item.validTime.substr(item.validTime.indexOf('/') + 1)).shiftTo('milliseconds').values.milliseconds;
|
||||
const endTime = startTime + duration;
|
||||
// loop through duration at one hour intervals
|
||||
do {
|
||||
// test for timestamp greater than now
|
||||
if (startTime >= startOfHour && result.length < 24) {
|
||||
result.push(item.value); // push data array
|
||||
} // timestamp is after now
|
||||
// increment start time by 1 hour
|
||||
startTime += 3600000;
|
||||
} while (startTime < endTime && result.length < 24);
|
||||
}); // for each value
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// register display
|
||||
registerDisplay(new Hourly(2, 'hourly'));
|
||||
const display = new Hourly(2, 'hourly', false);
|
||||
registerDisplay(display);
|
||||
|
||||
export default display.getCurrentData.bind(display);
|
||||
|
||||
@@ -34,7 +34,7 @@ class LatestObservations extends WeatherDisplay {
|
||||
// get data for regional stations
|
||||
const allConditions = await Promise.all(regionalStations.map(async (station) => {
|
||||
try {
|
||||
const data = await json(`https://api.weather.gov/stations/${station.id}/observations/latest`);
|
||||
const data = await json(`https://api.weather.gov/stations/${station.id}/observations/latest`, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
// test for temperature, weather and wind values present
|
||||
if (data.properties.temperature.value === null
|
||||
|| data.properties.textDescription === ''
|
||||
@@ -82,7 +82,7 @@ class LatestObservations extends WeatherDisplay {
|
||||
const fill = {};
|
||||
fill.location = locationCleanup(condition.city).substr(0, 14);
|
||||
fill.temp = Temperature;
|
||||
fill.weather = LatestObservations.shortenCurrentConditions(condition.textDescription).substr(0, 9);
|
||||
fill.weather = shortenCurrentConditions(condition.textDescription).substr(0, 9);
|
||||
if (WindSpeed > 0) {
|
||||
fill.wind = windDirection + (Array(6 - windDirection.length - WindSpeed.toString().length).join(' ')) + WindSpeed.toString();
|
||||
} else if (WindSpeed === 'NA') {
|
||||
@@ -100,25 +100,24 @@ class LatestObservations extends WeatherDisplay {
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
|
||||
static shortenCurrentConditions(_condition) {
|
||||
let condition = _condition;
|
||||
condition = condition.replace(/Light/, 'L');
|
||||
condition = condition.replace(/Heavy/, 'H');
|
||||
condition = condition.replace(/Partly/, 'P');
|
||||
condition = condition.replace(/Mostly/, 'M');
|
||||
condition = condition.replace(/Few/, 'F');
|
||||
condition = condition.replace(/Thunderstorm/, 'T\'storm');
|
||||
condition = condition.replace(/ in /, '');
|
||||
condition = condition.replace(/Vicinity/, '');
|
||||
condition = condition.replace(/ and /, ' ');
|
||||
condition = condition.replace(/Freezing Rain/, 'Frz Rn');
|
||||
condition = condition.replace(/Freezing/, 'Frz');
|
||||
condition = condition.replace(/Unknown Precip/, '');
|
||||
condition = condition.replace(/L Snow Fog/, 'L Snw/Fog');
|
||||
condition = condition.replace(/ with /, '/');
|
||||
return condition;
|
||||
}
|
||||
}
|
||||
const shortenCurrentConditions = (_condition) => {
|
||||
let condition = _condition;
|
||||
condition = condition.replace(/Light/, 'L');
|
||||
condition = condition.replace(/Heavy/, 'H');
|
||||
condition = condition.replace(/Partly/, 'P');
|
||||
condition = condition.replace(/Mostly/, 'M');
|
||||
condition = condition.replace(/Few/, 'F');
|
||||
condition = condition.replace(/Thunderstorm/, 'T\'storm');
|
||||
condition = condition.replace(/ in /, '');
|
||||
condition = condition.replace(/Vicinity/, '');
|
||||
condition = condition.replace(/ and /, ' ');
|
||||
condition = condition.replace(/Freezing Rain/, 'Frz Rn');
|
||||
condition = condition.replace(/Freezing/, 'Frz');
|
||||
condition = condition.replace(/Unknown Precip/, '');
|
||||
condition = condition.replace(/L Snow Fog/, 'L Snw/Fog');
|
||||
condition = condition.replace(/ with /, '/');
|
||||
return condition;
|
||||
};
|
||||
// register display
|
||||
registerDisplay(new LatestObservations(1, 'latest-observations'));
|
||||
|
||||
@@ -25,7 +25,7 @@ class LocalForecast extends WeatherDisplay {
|
||||
return;
|
||||
}
|
||||
// parse raw data
|
||||
const conditions = LocalForecast.parse(rawData);
|
||||
const conditions = parse(rawData);
|
||||
|
||||
// read each text
|
||||
this.screenTexts = conditions.map((condition) => {
|
||||
@@ -63,6 +63,8 @@ class LocalForecast extends WeatherDisplay {
|
||||
data: {
|
||||
units: 'us',
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`);
|
||||
@@ -80,17 +82,14 @@ class LocalForecast extends WeatherDisplay {
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
|
||||
// format the forecast
|
||||
static parse(forecast) {
|
||||
// only use the first 6 lines
|
||||
return forecast.properties.periods.slice(0, 6).map((text) => ({
|
||||
// format day and text
|
||||
DayName: text.name.toUpperCase(),
|
||||
Text: text.detailedForecast,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// format the forecast
|
||||
// only use the first 6 lines
|
||||
const parse = (forecast) => forecast.properties.periods.slice(0, 6).map((text) => ({
|
||||
// format day and text
|
||||
DayName: text.name.toUpperCase(),
|
||||
Text: text.detailedForecast,
|
||||
}));
|
||||
// register display
|
||||
registerDisplay(new LocalForecast(5, 'local-forecast'));
|
||||
registerDisplay(new LocalForecast(6, 'local-forecast'));
|
||||
|
||||
@@ -23,6 +23,7 @@ let AutoRefreshCountMs = 0;
|
||||
const init = async () => {
|
||||
// set up resize handler
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
// auto refresh
|
||||
const TwcAutoRefresh = localStorage.getItem('TwcAutoRefresh');
|
||||
@@ -97,7 +98,7 @@ const getWeather = async (latLon) => {
|
||||
const updateStatus = (value) => {
|
||||
if (value.id < 0) return;
|
||||
if (!progress) return;
|
||||
progress.drawCanvas(displays, countLoadedCanvases());
|
||||
progress.drawCanvas(displays, countLoadedDisplays());
|
||||
|
||||
// if this is the first display and we're playing, load it up so it starts playing
|
||||
if (isPlaying() && value.id === 0 && value.status === STATUS.loaded) {
|
||||
@@ -105,13 +106,16 @@ const updateStatus = (value) => {
|
||||
}
|
||||
|
||||
// send loaded messaged to parent
|
||||
if (countLoadedCanvases() < displays.length) return;
|
||||
if (countLoadedDisplays() < displays.length) return;
|
||||
|
||||
// everything loaded, set timestamps
|
||||
AssignLastUpdate(new Date());
|
||||
};
|
||||
|
||||
const countLoadedCanvases = () => displays.reduce((acc, display) => {
|
||||
// note: a display that is "still waiting"/"retrying" is considered loaded intentionally
|
||||
// the weather.gov api has long load times for some products when you are the first
|
||||
// requester for the product after the cache expires
|
||||
const countLoadedDisplays = () => displays.reduce((acc, display) => {
|
||||
if (display.status !== STATUS.loading) return acc + 1;
|
||||
return acc;
|
||||
}, 0);
|
||||
@@ -251,11 +255,11 @@ const getDisplay = (index) => displays[index];
|
||||
|
||||
// resize the container on a page resize
|
||||
const resize = () => {
|
||||
const widthZoomPercent = window.innerWidth / 640;
|
||||
const heightZoomPercent = window.innerHeight / 480;
|
||||
const marginOffset = (document.fullscreenElement) ? 0 : 16;
|
||||
const widthZoomPercent = (window.innerWidth - marginOffset) / 640;
|
||||
const heightZoomPercent = (window.innerHeight - marginOffset) / 480;
|
||||
|
||||
const scale = Math.min(widthZoomPercent, heightZoomPercent);
|
||||
|
||||
if (scale < 1.0 || document.fullscreenElement) {
|
||||
document.getElementById('container').style.zoom = scale;
|
||||
} else {
|
||||
@@ -281,7 +285,7 @@ const generateCheckboxes = () => {
|
||||
|
||||
if (!availableDisplays) return;
|
||||
// generate checkboxes
|
||||
const checkboxes = displays.map((d) => d.generateCheckbox()).filter((d) => d);
|
||||
const checkboxes = displays.map((d) => d.generateCheckbox(d.defaultEnabled)).filter((d) => d);
|
||||
|
||||
// write to page
|
||||
availableDisplays.innerHTML = '';
|
||||
|
||||
@@ -55,6 +55,9 @@ class Progress extends WeatherDisplay {
|
||||
case STATUS.disabled:
|
||||
statusClass = 'disabled';
|
||||
break;
|
||||
case STATUS.retrying:
|
||||
statusClass = 'retrying';
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
|
||||
185
server/scripts/modules/radar-utils.mjs
Normal file
185
server/scripts/modules/radar-utils.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
const getXYFromLatitudeLongitudeMap = (pos, offsetX, offsetY) => {
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const imgHeight = 3200;
|
||||
const imgWidth = 5100;
|
||||
|
||||
y = (51.75 - pos.latitude) * 55.2;
|
||||
// center map
|
||||
y -= offsetY;
|
||||
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (imgHeight - (offsetY * 2))) {
|
||||
y = imgHeight - (offsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-130.37 - pos.longitude) * 41.775) * -1;
|
||||
// center map
|
||||
x -= offsetX;
|
||||
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (imgWidth - (offsetX * 2))) {
|
||||
x = imgWidth - (offsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x: x * 2, y: y * 2 };
|
||||
};
|
||||
|
||||
const getXYFromLatitudeLongitudeDoppler = (pos, offsetX, offsetY) => {
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const imgHeight = 6000;
|
||||
const imgWidth = 2800;
|
||||
|
||||
y = (51 - pos.latitude) * 61.4481;
|
||||
// center map
|
||||
y -= offsetY;
|
||||
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (imgHeight - (offsetY * 2))) {
|
||||
y = imgHeight - (offsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-129.138 - pos.longitude) * 42.1768) * -1;
|
||||
// center map
|
||||
x -= offsetX;
|
||||
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (imgWidth - (offsetX * 2))) {
|
||||
x = imgWidth - (offsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x: x * 2, y: y * 2 };
|
||||
};
|
||||
|
||||
const removeDopplerRadarImageNoise = (RadarContext) => {
|
||||
const RadarImageData = RadarContext.getImageData(0, 0, RadarContext.canvas.width, RadarContext.canvas.height);
|
||||
|
||||
// examine every pixel,
|
||||
// change any old rgb to the new-rgb
|
||||
for (let i = 0; i < RadarImageData.data.length; i += 4) {
|
||||
// i + 0 = red
|
||||
// i + 1 = green
|
||||
// i + 2 = blue
|
||||
// i + 3 = alpha (0 = transparent, 255 = opaque)
|
||||
let R = RadarImageData.data[i];
|
||||
let G = RadarImageData.data[i + 1];
|
||||
let B = RadarImageData.data[i + 2];
|
||||
let A = RadarImageData.data[i + 3];
|
||||
|
||||
// is this pixel the old rgb?
|
||||
if ((R === 0 && G === 0 && B === 0)
|
||||
|| (R === 0 && G === 236 && B === 236)
|
||||
|| (R === 1 && G === 160 && B === 246)
|
||||
|| (R === 0 && G === 0 && B === 246)) {
|
||||
// change to your new rgb
|
||||
|
||||
// Transparent
|
||||
R = 0;
|
||||
G = 0;
|
||||
B = 0;
|
||||
A = 0;
|
||||
} else if ((R === 0 && G === 255 && B === 0)) {
|
||||
// Light Green 1
|
||||
R = 49;
|
||||
G = 210;
|
||||
B = 22;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 200 && B === 0)) {
|
||||
// Light Green 2
|
||||
R = 0;
|
||||
G = 142;
|
||||
B = 0;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 144 && B === 0)) {
|
||||
// Dark Green 1
|
||||
R = 20;
|
||||
G = 90;
|
||||
B = 15;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 255 && B === 0)) {
|
||||
// Dark Green 2
|
||||
R = 10;
|
||||
G = 40;
|
||||
B = 10;
|
||||
A = 255;
|
||||
} else if ((R === 231 && G === 192 && B === 0)) {
|
||||
// Yellow
|
||||
R = 196;
|
||||
G = 179;
|
||||
B = 70;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 144 && B === 0)) {
|
||||
// Orange
|
||||
R = 190;
|
||||
G = 72;
|
||||
B = 19;
|
||||
A = 255;
|
||||
} else if ((R === 214 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 0)) {
|
||||
// Red
|
||||
R = 171;
|
||||
G = 14;
|
||||
B = 14;
|
||||
A = 255;
|
||||
} else if ((R === 192 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 255)) {
|
||||
// Brown
|
||||
R = 115;
|
||||
G = 31;
|
||||
B = 4;
|
||||
A = 255;
|
||||
}
|
||||
|
||||
RadarImageData.data[i] = R;
|
||||
RadarImageData.data[i + 1] = G;
|
||||
RadarImageData.data[i + 2] = B;
|
||||
RadarImageData.data[i + 3] = A;
|
||||
}
|
||||
|
||||
RadarContext.putImageData(RadarImageData, 0, 0);
|
||||
};
|
||||
|
||||
const mergeDopplerRadarImage = (mapContext, radarContext) => {
|
||||
const mapImageData = mapContext.getImageData(0, 0, mapContext.canvas.width, mapContext.canvas.height);
|
||||
const radarImageData = radarContext.getImageData(0, 0, radarContext.canvas.width, radarContext.canvas.height);
|
||||
|
||||
// examine every pixel,
|
||||
// change any old rgb to the new-rgb
|
||||
for (let i = 0; i < radarImageData.data.length; i += 4) {
|
||||
// i + 0 = red
|
||||
// i + 1 = green
|
||||
// i + 2 = blue
|
||||
// i + 3 = alpha (0 = transparent, 255 = opaque)
|
||||
|
||||
// is this pixel the old rgb?
|
||||
if ((mapImageData.data[i] < 116 && mapImageData.data[i + 1] < 116 && mapImageData.data[i + 2] < 116)) {
|
||||
// change to your new rgb
|
||||
|
||||
// Transparent
|
||||
radarImageData.data[i] = 0;
|
||||
radarImageData.data[i + 1] = 0;
|
||||
radarImageData.data[i + 2] = 0;
|
||||
radarImageData.data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
radarContext.putImageData(radarImageData, 0, 0);
|
||||
|
||||
mapContext.drawImage(radarContext.canvas, 0, 0);
|
||||
};
|
||||
|
||||
export {
|
||||
getXYFromLatitudeLongitudeDoppler,
|
||||
getXYFromLatitudeLongitudeMap,
|
||||
removeDopplerRadarImageNoise,
|
||||
mergeDopplerRadarImage,
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { text } from './utils/fetch.mjs';
|
||||
import { rewriteUrl } from './utils/cors.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import * as utils from './radar-utils.mjs';
|
||||
|
||||
class Radar extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -109,7 +110,7 @@ class Radar extends WeatherDisplay {
|
||||
const height = 1600;
|
||||
offsetX *= 2;
|
||||
offsetY *= 2;
|
||||
const sourceXY = Radar.getXYFromLatitudeLongitudeMap(weatherParameters, offsetX, offsetY);
|
||||
const sourceXY = utils.getXYFromLatitudeLongitudeMap(weatherParameters, offsetX, offsetY);
|
||||
|
||||
// create working context for manipulation
|
||||
const workingCanvas = document.createElement('canvas');
|
||||
@@ -121,7 +122,7 @@ class Radar extends WeatherDisplay {
|
||||
// calculate radar offsets
|
||||
const radarOffsetX = 120;
|
||||
const radarOffsetY = 70;
|
||||
const radarSourceXY = Radar.getXYFromLatitudeLongitudeDoppler(weatherParameters, offsetX, offsetY);
|
||||
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(weatherParameters, offsetX, offsetY);
|
||||
const radarSourceX = radarSourceXY.x / 2;
|
||||
const radarSourceY = radarSourceXY.y / 2;
|
||||
|
||||
@@ -179,10 +180,10 @@ class Radar extends WeatherDisplay {
|
||||
cropContext.imageSmoothingEnabled = false;
|
||||
cropContext.drawImage(workingCanvas, radarSourceX, radarSourceY, (radarOffsetX * 2), (radarOffsetY * 2.33), 0, 0, 640, 367);
|
||||
// clean the image
|
||||
Radar.removeDopplerRadarImageNoise(cropContext);
|
||||
utils.removeDopplerRadarImageNoise(cropContext);
|
||||
|
||||
// merge the radar and map
|
||||
Radar.mergeDopplerRadarImage(context, cropContext);
|
||||
utils.mergeDopplerRadarImage(context, cropContext);
|
||||
|
||||
const elem = this.fillTemplate('frame', { map: { type: 'img', src: canvas.toDataURL() } });
|
||||
|
||||
@@ -218,188 +219,7 @@ class Radar extends WeatherDisplay {
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
|
||||
static getXYFromLatitudeLongitudeMap(pos, offsetX, offsetY) {
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const imgHeight = 3200;
|
||||
const imgWidth = 5100;
|
||||
|
||||
y = (51.75 - pos.latitude) * 55.2;
|
||||
// center map
|
||||
y -= offsetY;
|
||||
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (imgHeight - (offsetY * 2))) {
|
||||
y = imgHeight - (offsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-130.37 - pos.longitude) * 41.775) * -1;
|
||||
// center map
|
||||
x -= offsetX;
|
||||
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (imgWidth - (offsetX * 2))) {
|
||||
x = imgWidth - (offsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x: x * 2, y: y * 2 };
|
||||
}
|
||||
|
||||
static getXYFromLatitudeLongitudeDoppler(pos, offsetX, offsetY) {
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const imgHeight = 6000;
|
||||
const imgWidth = 2800;
|
||||
|
||||
y = (51 - pos.latitude) * 61.4481;
|
||||
// center map
|
||||
y -= offsetY;
|
||||
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (imgHeight - (offsetY * 2))) {
|
||||
y = imgHeight - (offsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-129.138 - pos.longitude) * 42.1768) * -1;
|
||||
// center map
|
||||
x -= offsetX;
|
||||
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (imgWidth - (offsetX * 2))) {
|
||||
x = imgWidth - (offsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x: x * 2, y: y * 2 };
|
||||
}
|
||||
|
||||
static removeDopplerRadarImageNoise(RadarContext) {
|
||||
const RadarImageData = RadarContext.getImageData(0, 0, RadarContext.canvas.width, RadarContext.canvas.height);
|
||||
|
||||
// examine every pixel,
|
||||
// change any old rgb to the new-rgb
|
||||
for (let i = 0; i < RadarImageData.data.length; i += 4) {
|
||||
// i + 0 = red
|
||||
// i + 1 = green
|
||||
// i + 2 = blue
|
||||
// i + 3 = alpha (0 = transparent, 255 = opaque)
|
||||
let R = RadarImageData.data[i];
|
||||
let G = RadarImageData.data[i + 1];
|
||||
let B = RadarImageData.data[i + 2];
|
||||
let A = RadarImageData.data[i + 3];
|
||||
|
||||
// is this pixel the old rgb?
|
||||
if ((R === 0 && G === 0 && B === 0)
|
||||
|| (R === 0 && G === 236 && B === 236)
|
||||
|| (R === 1 && G === 160 && B === 246)
|
||||
|| (R === 0 && G === 0 && B === 246)) {
|
||||
// change to your new rgb
|
||||
|
||||
// Transparent
|
||||
R = 0;
|
||||
G = 0;
|
||||
B = 0;
|
||||
A = 0;
|
||||
} else if ((R === 0 && G === 255 && B === 0)) {
|
||||
// Light Green 1
|
||||
R = 49;
|
||||
G = 210;
|
||||
B = 22;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 200 && B === 0)) {
|
||||
// Light Green 2
|
||||
R = 0;
|
||||
G = 142;
|
||||
B = 0;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 144 && B === 0)) {
|
||||
// Dark Green 1
|
||||
R = 20;
|
||||
G = 90;
|
||||
B = 15;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 255 && B === 0)) {
|
||||
// Dark Green 2
|
||||
R = 10;
|
||||
G = 40;
|
||||
B = 10;
|
||||
A = 255;
|
||||
} else if ((R === 231 && G === 192 && B === 0)) {
|
||||
// Yellow
|
||||
R = 196;
|
||||
G = 179;
|
||||
B = 70;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 144 && B === 0)) {
|
||||
// Orange
|
||||
R = 190;
|
||||
G = 72;
|
||||
B = 19;
|
||||
A = 255;
|
||||
} else if ((R === 214 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 0)) {
|
||||
// Red
|
||||
R = 171;
|
||||
G = 14;
|
||||
B = 14;
|
||||
A = 255;
|
||||
} else if ((R === 192 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 255)) {
|
||||
// Brown
|
||||
R = 115;
|
||||
G = 31;
|
||||
B = 4;
|
||||
A = 255;
|
||||
}
|
||||
|
||||
RadarImageData.data[i] = R;
|
||||
RadarImageData.data[i + 1] = G;
|
||||
RadarImageData.data[i + 2] = B;
|
||||
RadarImageData.data[i + 3] = A;
|
||||
}
|
||||
|
||||
RadarContext.putImageData(RadarImageData, 0, 0);
|
||||
|
||||
// MapContext.drawImage(RadarContext.canvas, 0, 0);
|
||||
}
|
||||
|
||||
static mergeDopplerRadarImage(mapContext, radarContext) {
|
||||
const mapImageData = mapContext.getImageData(0, 0, mapContext.canvas.width, mapContext.canvas.height);
|
||||
const radarImageData = radarContext.getImageData(0, 0, radarContext.canvas.width, radarContext.canvas.height);
|
||||
|
||||
// examine every pixel,
|
||||
// change any old rgb to the new-rgb
|
||||
for (let i = 0; i < radarImageData.data.length; i += 4) {
|
||||
// i + 0 = red
|
||||
// i + 1 = green
|
||||
// i + 2 = blue
|
||||
// i + 3 = alpha (0 = transparent, 255 = opaque)
|
||||
|
||||
// is this pixel the old rgb?
|
||||
if ((mapImageData.data[i] < 116 && mapImageData.data[i + 1] < 116 && mapImageData.data[i + 2] < 116)) {
|
||||
// change to your new rgb
|
||||
|
||||
// Transparent
|
||||
radarImageData.data[i] = 0;
|
||||
radarImageData.data[i + 1] = 0;
|
||||
radarImageData.data[i + 2] = 0;
|
||||
radarImageData.data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
radarContext.putImageData(radarImageData, 0, 0);
|
||||
|
||||
mapContext.drawImage(radarContext.canvas, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// register display
|
||||
registerDisplay(new Radar(8, 'radar'));
|
||||
registerDisplay(new Radar(9, 'radar'));
|
||||
|
||||
205
server/scripts/modules/regionalforecast-utils.mjs
Normal file
205
server/scripts/modules/regionalforecast-utils.mjs
Normal file
@@ -0,0 +1,205 @@
|
||||
import { getWeatherRegionalIconFromIconLink } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
|
||||
const buildForecast = (forecast, city, cityXY) => ({
|
||||
daytime: forecast.isDaytime,
|
||||
temperature: forecast.temperature || 0,
|
||||
name: formatCity(city.city),
|
||||
icon: forecast.icon,
|
||||
x: cityXY.x,
|
||||
y: cityXY.y,
|
||||
time: forecast.startTime,
|
||||
});
|
||||
|
||||
const getRegionalObservation = async (point, city) => {
|
||||
try {
|
||||
// get stations
|
||||
const stations = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/stations`);
|
||||
|
||||
// get the first station
|
||||
const station = stations.features[0].id;
|
||||
// get the observation data
|
||||
const observation = await json(`${station}/observations/latest`);
|
||||
// preload the image
|
||||
if (!observation.properties.icon) return false;
|
||||
preloadImg(getWeatherRegionalIconFromIconLink(observation.properties.icon, !observation.properties.daytime));
|
||||
// return the observation
|
||||
return observation.properties;
|
||||
} catch (e) {
|
||||
console.log(`Unable to get regional observations for ${city.Name ?? city.city}`);
|
||||
console.error(e.status, e.responseJSON);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// utility latitude/pixel conversions
|
||||
const getXYFromLatitudeLongitude = (Latitude, Longitude, OffsetX, OffsetY, state) => {
|
||||
if (state === 'AK') return getXYFromLatitudeLongitudeAK(Latitude, Longitude, OffsetX, OffsetY);
|
||||
if (state === 'HI') return getXYFromLatitudeLongitudeHI(Latitude, Longitude, OffsetX, OffsetY);
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const ImgHeight = 1600;
|
||||
const ImgWidth = 2550;
|
||||
|
||||
y = (50.5 - Latitude) * 55.2;
|
||||
y -= OffsetY; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (ImgHeight - (OffsetY * 2))) {
|
||||
y = ImgHeight - (OffsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-127.5 - Longitude) * 41.775) * -1;
|
||||
x -= OffsetX; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (ImgWidth - (OffsetX * 2))) {
|
||||
x = ImgWidth - (OffsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const getXYFromLatitudeLongitudeAK = (Latitude, Longitude, OffsetX, OffsetY) => {
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const ImgHeight = 1142;
|
||||
const ImgWidth = 1200;
|
||||
|
||||
y = (73.0 - Latitude) * 56;
|
||||
y -= OffsetY; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (ImgHeight - (OffsetY * 2))) {
|
||||
y = ImgHeight - (OffsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-175.0 - Longitude) * 25.0) * -1;
|
||||
x -= OffsetX; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (ImgWidth - (OffsetX * 2))) {
|
||||
x = ImgWidth - (OffsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const getXYFromLatitudeLongitudeHI = (Latitude, Longitude, OffsetX, OffsetY) => {
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const ImgHeight = 571;
|
||||
const ImgWidth = 600;
|
||||
|
||||
y = (25 - Latitude) * 55.2;
|
||||
y -= OffsetY; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (ImgHeight - (OffsetY * 2))) {
|
||||
y = ImgHeight - (OffsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-164.5 - Longitude) * 41.775) * -1;
|
||||
x -= OffsetX; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (ImgWidth - (OffsetX * 2))) {
|
||||
x = ImgWidth - (OffsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const getMinMaxLatitudeLongitude = (X, Y, OffsetX, OffsetY, state) => {
|
||||
if (state === 'AK') return getMinMaxLatitudeLongitudeAK(X, Y, OffsetX, OffsetY);
|
||||
if (state === 'HI') return getMinMaxLatitudeLongitudeHI(X, Y, OffsetX, OffsetY);
|
||||
const maxLat = ((Y / 55.2) - 50.5) * -1;
|
||||
const minLat = (((Y + (OffsetY * 2)) / 55.2) - 50.5) * -1;
|
||||
const minLon = (((X * -1) / 41.775) + 127.5) * -1;
|
||||
const maxLon = ((((X + (OffsetX * 2)) * -1) / 41.775) + 127.5) * -1;
|
||||
|
||||
return {
|
||||
minLat, maxLat, minLon, maxLon,
|
||||
};
|
||||
};
|
||||
|
||||
const getMinMaxLatitudeLongitudeAK = (X, Y, OffsetX, OffsetY) => {
|
||||
const maxLat = ((Y / 56) - 73.0) * -1;
|
||||
const minLat = (((Y + (OffsetY * 2)) / 56) - 73.0) * -1;
|
||||
const minLon = (((X * -1) / 25) + 175.0) * -1;
|
||||
const maxLon = ((((X + (OffsetX * 2)) * -1) / 25) + 175.0) * -1;
|
||||
|
||||
return {
|
||||
minLat, maxLat, minLon, maxLon,
|
||||
};
|
||||
};
|
||||
|
||||
const getMinMaxLatitudeLongitudeHI = (X, Y, OffsetX, OffsetY) => {
|
||||
const maxLat = ((Y / 55.2) - 25) * -1;
|
||||
const minLat = (((Y + (OffsetY * 2)) / 55.2) - 25) * -1;
|
||||
const minLon = (((X * -1) / 41.775) + 164.5) * -1;
|
||||
const maxLon = ((((X + (OffsetX * 2)) * -1) / 41.775) + 164.5) * -1;
|
||||
|
||||
return {
|
||||
minLat, maxLat, minLon, maxLon,
|
||||
};
|
||||
};
|
||||
|
||||
const getXYForCity = (City, MaxLatitude, MinLongitude, state) => {
|
||||
if (state === 'AK') getXYForCityAK(City, MaxLatitude, MinLongitude);
|
||||
if (state === 'HI') getXYForCityHI(City, MaxLatitude, MinLongitude);
|
||||
let x = (City.lon - MinLongitude) * 57;
|
||||
let y = (MaxLatitude - City.lat) * 70;
|
||||
|
||||
if (y < 30) y = 30;
|
||||
if (y > 282) y = 282;
|
||||
|
||||
if (x < 40) x = 40;
|
||||
if (x > 580) x = 580;
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const getXYForCityAK = (City, MaxLatitude, MinLongitude) => {
|
||||
let x = (City.lon - MinLongitude) * 37;
|
||||
let y = (MaxLatitude - City.lat) * 70;
|
||||
|
||||
if (y < 30) y = 30;
|
||||
if (y > 282) y = 282;
|
||||
|
||||
if (x < 40) x = 40;
|
||||
if (x > 580) x = 580;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const getXYForCityHI = (City, MaxLatitude, MinLongitude) => {
|
||||
let x = (City.lon - MinLongitude) * 57;
|
||||
let y = (MaxLatitude - City.lat) * 70;
|
||||
|
||||
if (y < 30) y = 30;
|
||||
if (y > 282) y = 282;
|
||||
|
||||
if (x < 40) x = 40;
|
||||
if (x > 580) x = 580;
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// to fit on the map, remove anything after punctuation and then limit to 15 characters
|
||||
const formatCity = (city) => city.match(/[^-;/\\,]*/)[0].substr(0, 12);
|
||||
|
||||
export {
|
||||
buildForecast,
|
||||
getRegionalObservation,
|
||||
getXYFromLatitudeLongitude,
|
||||
getMinMaxLatitudeLongitude,
|
||||
getXYForCity,
|
||||
formatCity,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { preloadImg } from './utils/image.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import * as utils from './regionalforecast-utils.mjs';
|
||||
|
||||
class RegionalForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -38,10 +39,10 @@ class RegionalForecast extends WeatherDisplay {
|
||||
y: 117,
|
||||
};
|
||||
// get user's location in x/y
|
||||
const sourceXY = RegionalForecast.getXYFromLatitudeLongitude(weatherParameters.latitude, weatherParameters.longitude, offsetXY.x, offsetXY.y, weatherParameters.state);
|
||||
const sourceXY = utils.getXYFromLatitudeLongitude(weatherParameters.latitude, weatherParameters.longitude, offsetXY.x, offsetXY.y, weatherParameters.state);
|
||||
|
||||
// get latitude and longitude limits
|
||||
const minMaxLatLon = RegionalForecast.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, offsetXY.x, offsetXY.y, weatherParameters.state);
|
||||
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, offsetXY.x, offsetXY.y, weatherParameters.state);
|
||||
|
||||
// get a target distance
|
||||
let targetDistance = 2.5;
|
||||
@@ -75,12 +76,12 @@ class RegionalForecast extends WeatherDisplay {
|
||||
if (!city.point) throw new Error('No pre-loaded point');
|
||||
|
||||
// start off the observation task
|
||||
const observationPromise = RegionalForecast.getRegionalObservation(city.point, city);
|
||||
const observationPromise = utils.getRegionalObservation(city.point, city);
|
||||
|
||||
const forecast = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`);
|
||||
|
||||
// get XY on map for city
|
||||
const cityXY = RegionalForecast.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, weatherParameters.state);
|
||||
const cityXY = utils.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, weatherParameters.state);
|
||||
|
||||
// wait for the regional observation if it's not done yet
|
||||
const observation = await observationPromise;
|
||||
@@ -88,7 +89,7 @@ class RegionalForecast extends WeatherDisplay {
|
||||
const regionalObservation = {
|
||||
daytime: !!observation.icon.match(/\/day\//),
|
||||
temperature: celsiusToFahrenheit(observation.temperature.value),
|
||||
name: RegionalForecast.formatCity(city.city),
|
||||
name: utils.formatCity(city.city),
|
||||
icon: observation.icon,
|
||||
x: cityXY.x,
|
||||
y: cityXY.y,
|
||||
@@ -104,8 +105,8 @@ class RegionalForecast extends WeatherDisplay {
|
||||
// always skip the first forecast index because it's what's going on right now
|
||||
return [
|
||||
regionalObservation,
|
||||
RegionalForecast.buildForecast(forecast.properties.periods[1], city, cityXY),
|
||||
RegionalForecast.buildForecast(forecast.properties.periods[2], city, cityXY),
|
||||
utils.buildForecast(forecast.properties.periods[1], city, cityXY),
|
||||
utils.buildForecast(forecast.properties.periods[2], city, cityXY),
|
||||
];
|
||||
} catch (e) {
|
||||
console.log(`No regional forecast data for '${city.name ?? city.city}'`);
|
||||
@@ -133,203 +134,6 @@ class RegionalForecast extends WeatherDisplay {
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
static buildForecast(forecast, city, cityXY) {
|
||||
return {
|
||||
daytime: forecast.isDaytime,
|
||||
temperature: forecast.temperature || 0,
|
||||
name: RegionalForecast.formatCity(city.city),
|
||||
icon: forecast.icon,
|
||||
x: cityXY.x,
|
||||
y: cityXY.y,
|
||||
time: forecast.startTime,
|
||||
};
|
||||
}
|
||||
|
||||
static async getRegionalObservation(point, city) {
|
||||
try {
|
||||
// get stations
|
||||
const stations = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/stations`);
|
||||
|
||||
// get the first station
|
||||
const station = stations.features[0].id;
|
||||
// get the observation data
|
||||
const observation = await json(`${station}/observations/latest`);
|
||||
// preload the image
|
||||
if (!observation.properties.icon) return false;
|
||||
preloadImg(getWeatherRegionalIconFromIconLink(observation.properties.icon, !observation.properties.daytime));
|
||||
// return the observation
|
||||
return observation.properties;
|
||||
} catch (e) {
|
||||
console.log(`Unable to get regional observations for ${city.Name ?? city.city}`);
|
||||
console.error(e.status, e.responseJSON);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// utility latitude/pixel conversions
|
||||
static getXYFromLatitudeLongitude(Latitude, Longitude, OffsetX, OffsetY, state) {
|
||||
if (state === 'AK') return RegionalForecast.getXYFromLatitudeLongitudeAK(Latitude, Longitude, OffsetX, OffsetY);
|
||||
if (state === 'HI') return RegionalForecast.getXYFromLatitudeLongitudeHI(Latitude, Longitude, OffsetX, OffsetY);
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const ImgHeight = 1600;
|
||||
const ImgWidth = 2550;
|
||||
|
||||
y = (50.5 - Latitude) * 55.2;
|
||||
y -= OffsetY; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (ImgHeight - (OffsetY * 2))) {
|
||||
y = ImgHeight - (OffsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-127.5 - Longitude) * 41.775) * -1;
|
||||
x -= OffsetX; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (ImgWidth - (OffsetX * 2))) {
|
||||
x = ImgWidth - (OffsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
static getXYFromLatitudeLongitudeAK(Latitude, Longitude, OffsetX, OffsetY) {
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const ImgHeight = 1142;
|
||||
const ImgWidth = 1200;
|
||||
|
||||
y = (73.0 - Latitude) * 56;
|
||||
y -= OffsetY; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (ImgHeight - (OffsetY * 2))) {
|
||||
y = ImgHeight - (OffsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-175.0 - Longitude) * 25.0) * -1;
|
||||
x -= OffsetX; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (ImgWidth - (OffsetX * 2))) {
|
||||
x = ImgWidth - (OffsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
static getXYFromLatitudeLongitudeHI(Latitude, Longitude, OffsetX, OffsetY) {
|
||||
let y = 0;
|
||||
let x = 0;
|
||||
const ImgHeight = 571;
|
||||
const ImgWidth = 600;
|
||||
|
||||
y = (25 - Latitude) * 55.2;
|
||||
y -= OffsetY; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (y > (ImgHeight - (OffsetY * 2))) {
|
||||
y = ImgHeight - (OffsetY * 2);
|
||||
} else if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
x = ((-164.5 - Longitude) * 41.775) * -1;
|
||||
x -= OffsetX; // Centers map.
|
||||
// Do not allow the map to exceed the max/min coordinates.
|
||||
if (x > (ImgWidth - (OffsetX * 2))) {
|
||||
x = ImgWidth - (OffsetX * 2);
|
||||
} else if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
static getMinMaxLatitudeLongitude(X, Y, OffsetX, OffsetY, state) {
|
||||
if (state === 'AK') return RegionalForecast.getMinMaxLatitudeLongitudeAK(X, Y, OffsetX, OffsetY);
|
||||
if (state === 'HI') return RegionalForecast.getMinMaxLatitudeLongitudeHI(X, Y, OffsetX, OffsetY);
|
||||
const maxLat = ((Y / 55.2) - 50.5) * -1;
|
||||
const minLat = (((Y + (OffsetY * 2)) / 55.2) - 50.5) * -1;
|
||||
const minLon = (((X * -1) / 41.775) + 127.5) * -1;
|
||||
const maxLon = ((((X + (OffsetX * 2)) * -1) / 41.775) + 127.5) * -1;
|
||||
|
||||
return {
|
||||
minLat, maxLat, minLon, maxLon,
|
||||
};
|
||||
}
|
||||
|
||||
static getMinMaxLatitudeLongitudeAK(X, Y, OffsetX, OffsetY) {
|
||||
const maxLat = ((Y / 56) - 73.0) * -1;
|
||||
const minLat = (((Y + (OffsetY * 2)) / 56) - 73.0) * -1;
|
||||
const minLon = (((X * -1) / 25) + 175.0) * -1;
|
||||
const maxLon = ((((X + (OffsetX * 2)) * -1) / 25) + 175.0) * -1;
|
||||
|
||||
return {
|
||||
minLat, maxLat, minLon, maxLon,
|
||||
};
|
||||
}
|
||||
|
||||
static getMinMaxLatitudeLongitudeHI(X, Y, OffsetX, OffsetY) {
|
||||
const maxLat = ((Y / 55.2) - 25) * -1;
|
||||
const minLat = (((Y + (OffsetY * 2)) / 55.2) - 25) * -1;
|
||||
const minLon = (((X * -1) / 41.775) + 164.5) * -1;
|
||||
const maxLon = ((((X + (OffsetX * 2)) * -1) / 41.775) + 164.5) * -1;
|
||||
|
||||
return {
|
||||
minLat, maxLat, minLon, maxLon,
|
||||
};
|
||||
}
|
||||
|
||||
static getXYForCity(City, MaxLatitude, MinLongitude, state) {
|
||||
if (state === 'AK') RegionalForecast.getXYForCityAK(City, MaxLatitude, MinLongitude);
|
||||
if (state === 'HI') RegionalForecast.getXYForCityHI(City, MaxLatitude, MinLongitude);
|
||||
let x = (City.lon - MinLongitude) * 57;
|
||||
let y = (MaxLatitude - City.lat) * 70;
|
||||
|
||||
if (y < 30) y = 30;
|
||||
if (y > 282) y = 282;
|
||||
|
||||
if (x < 40) x = 40;
|
||||
if (x > 580) x = 580;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
static getXYForCityAK(City, MaxLatitude, MinLongitude) {
|
||||
let x = (City.lon - MinLongitude) * 37;
|
||||
let y = (MaxLatitude - City.lat) * 70;
|
||||
|
||||
if (y < 30) y = 30;
|
||||
if (y > 282) y = 282;
|
||||
|
||||
if (x < 40) x = 40;
|
||||
if (x > 580) x = 580;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
static getXYForCityHI(City, MaxLatitude, MinLongitude) {
|
||||
let x = (City.lon - MinLongitude) * 57;
|
||||
let y = (MaxLatitude - City.lat) * 70;
|
||||
|
||||
if (y < 30) y = 30;
|
||||
if (y > 282) y = 282;
|
||||
|
||||
if (x < 40) x = 40;
|
||||
if (x > 580) x = 580;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// to fit on the map, remove anything after punctuation and then limit to 15 characters
|
||||
static formatCity(city) {
|
||||
return city.match(/[^-;/\\,]*/)[0].substr(0, 12);
|
||||
}
|
||||
|
||||
drawCanvas() {
|
||||
super.drawCanvas();
|
||||
// break up data into useful values
|
||||
@@ -389,4 +193,4 @@ class RegionalForecast extends WeatherDisplay {
|
||||
}
|
||||
|
||||
// register display
|
||||
registerDisplay(new RegionalForecast(4, 'regional-forecast'));
|
||||
registerDisplay(new RegionalForecast(5, 'regional-forecast'));
|
||||
|
||||
@@ -4,6 +4,7 @@ const STATUS = {
|
||||
failed: Symbol('failed'),
|
||||
noData: Symbol('noData'),
|
||||
disabled: Symbol('disabled'),
|
||||
retrying: Symbol('retyring'),
|
||||
};
|
||||
|
||||
export default STATUS;
|
||||
|
||||
@@ -112,7 +112,7 @@ class TravelForecast extends WeatherDisplay {
|
||||
// set up variables
|
||||
const cities = this.data;
|
||||
|
||||
this.elem.querySelector('.header .title.dual .bottom').innerHTML = `For ${TravelForecast.getTravelCitiesDayName(cities)}`;
|
||||
this.elem.querySelector('.header .title.dual .bottom').innerHTML = `For ${getTravelCitiesDayName(cities)}`;
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
@@ -140,24 +140,22 @@ class TravelForecast extends WeatherDisplay {
|
||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
||||
}
|
||||
|
||||
static getTravelCitiesDayName(cities) {
|
||||
// effectively returns early on the first found date
|
||||
return cities.reduce((dayName, city) => {
|
||||
if (city && dayName === '') {
|
||||
// today or tomorrow
|
||||
const day = DateTime.local().plus({ days: (city.today) ? 0 : 1 });
|
||||
// return the day
|
||||
return day.toLocaleString({ weekday: 'long' });
|
||||
}
|
||||
return dayName;
|
||||
}, '');
|
||||
}
|
||||
|
||||
// necessary to get the lastest long canvas when scrolling
|
||||
getLongCanvas() {
|
||||
return this.longCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
// effectively returns early on the first found date
|
||||
const getTravelCitiesDayName = (cities) => cities.reduce((dayName, city) => {
|
||||
if (city && dayName === '') {
|
||||
// today or tomorrow
|
||||
const day = DateTime.local().plus({ days: (city.today) ? 0 : 1 });
|
||||
// return the day
|
||||
return day.toLocaleString({ weekday: 'long' });
|
||||
}
|
||||
return dayName;
|
||||
}, '');
|
||||
|
||||
// register display, not active by default
|
||||
registerDisplay(new TravelForecast(3, 'travel', false));
|
||||
registerDisplay(new TravelForecast(4, 'travel', false));
|
||||
|
||||
@@ -11,9 +11,13 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
type: 'GET',
|
||||
retryCount: 0,
|
||||
..._params,
|
||||
};
|
||||
// build a url, including the rewrite for cors if necessary
|
||||
// store original number of retries
|
||||
params.originalRetries = params.retryCount;
|
||||
|
||||
// build a url, including the rewrite for cors if necessary
|
||||
let corsUrl = _url;
|
||||
if (params.cors === true) corsUrl = rewriteUrl(_url);
|
||||
const url = new URL(corsUrl, `${window.location.origin}/`);
|
||||
@@ -30,7 +34,7 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
}
|
||||
|
||||
// make the request
|
||||
const response = await fetch(url, params);
|
||||
const response = await doFetch(url, params);
|
||||
|
||||
// check for ok response
|
||||
if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`);
|
||||
@@ -47,6 +51,48 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
// fetch with retry and back-off
|
||||
const doFetch = (url, params) => new Promise((resolve, reject) => {
|
||||
fetch(url, params).then((response) => {
|
||||
if (params.retryCount > 0) {
|
||||
// 500 status codes should be retried after a short backoff
|
||||
if (response.status >= 500 && response.status <= 599 && params.retryCount > 0) {
|
||||
// call the "still waiting" function
|
||||
if (typeof params.stillWaiting === 'function' && params.retryCount === params.originalRetries) {
|
||||
params.stillWaiting();
|
||||
}
|
||||
// decrement and retry
|
||||
const newParams = {
|
||||
...params,
|
||||
retryCount: params.retryCount - 1,
|
||||
};
|
||||
return resolve(delay(retryDelay(params.originalRetries - newParams.retryCount), doFetch, url, newParams));
|
||||
}
|
||||
// not 500 status
|
||||
return resolve(response);
|
||||
}
|
||||
// out of retries
|
||||
return resolve(response);
|
||||
})
|
||||
.catch((e) => reject(e));
|
||||
});
|
||||
|
||||
const delay = (time, func, ...args) => new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(func(...args));
|
||||
}, time);
|
||||
});
|
||||
|
||||
const retryDelay = (retryNumber) => {
|
||||
switch (retryNumber) {
|
||||
case 1: return 1000;
|
||||
case 2: return 2000;
|
||||
case 3: return 5000;
|
||||
case 4: return 10000;
|
||||
default: return 30000;
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
json,
|
||||
text,
|
||||
|
||||
@@ -17,6 +17,7 @@ class WeatherDisplay {
|
||||
this.loadingStatus = STATUS.loading;
|
||||
this.name = name ?? elemId;
|
||||
this.getDataCallbacks = [];
|
||||
this.stillWaitingCallbacks = [];
|
||||
this.defaultEnabled = defaultEnabled;
|
||||
this.okToDrawCurrentConditions = true;
|
||||
this.okToDrawCurrentDateTime = true;
|
||||
@@ -392,6 +393,14 @@ class WeatherDisplay {
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
// still waiting for data (retries triggered)
|
||||
stillWaiting() {
|
||||
if (this.enabled) this.setStatus(STATUS.retrying);
|
||||
// handle still waiting callbacks
|
||||
this.stillWaitingCallbacks.forEach((callback) => callback());
|
||||
this.stillWaitingCallbacks = [];
|
||||
}
|
||||
}
|
||||
|
||||
export default WeatherDisplay;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
150
server/styles/scss/_hourly-graph.scss
Normal file
150
server/styles/scss/_hourly-graph.scss
Normal file
@@ -0,0 +1,150 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
|
||||
#hourly-graph-html {
|
||||
background-image: url(../images/BackGround1_1_Chart.png);
|
||||
|
||||
.header {
|
||||
.right {
|
||||
position: absolute;
|
||||
top: 35px;
|
||||
right: 60px;
|
||||
width: 360px;
|
||||
font-family: 'Star4000 Small';
|
||||
font-size: 32px;
|
||||
@include u.text-shadow();
|
||||
text-align: right;
|
||||
|
||||
div {
|
||||
margin-top: -18px;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.cloud {
|
||||
color: lightgrey;
|
||||
}
|
||||
|
||||
.rain {
|
||||
color: aqua;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.weather-display .main.hourly-graph {
|
||||
|
||||
&.main {
|
||||
>div {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'Star4000 Small';
|
||||
font-size: 24pt;
|
||||
color: c.$column-header-text;
|
||||
@include u.text-shadow();
|
||||
margin-top: -15px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.x-axis {
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
width: 640px;
|
||||
height: 20px;
|
||||
|
||||
.label {
|
||||
text-align: center;
|
||||
width: 50px;
|
||||
|
||||
&.l-1 {
|
||||
left: 25px;
|
||||
}
|
||||
|
||||
&.l-2 {
|
||||
left: 158px;
|
||||
}
|
||||
|
||||
&.l-3 {
|
||||
left: 291px;
|
||||
}
|
||||
|
||||
&.l-4 {
|
||||
left: 424px;
|
||||
}
|
||||
|
||||
&.l-5 {
|
||||
left: 557px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
.chart {
|
||||
top: 0px;
|
||||
left: 50px;
|
||||
|
||||
img {
|
||||
width: 532px;
|
||||
height: 285px;
|
||||
}
|
||||
}
|
||||
|
||||
.y-axis {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 50px;
|
||||
height: 285px;
|
||||
|
||||
.label {
|
||||
text-align: right;
|
||||
right: 0px;
|
||||
|
||||
&.l-1 {
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
&.l-2 {
|
||||
top: 140px;
|
||||
}
|
||||
|
||||
&.l-3 {
|
||||
bottom: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.column-headers {
|
||||
background-color: c.$column-header;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.column-headers {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 5;
|
||||
|
||||
|
||||
.temp {
|
||||
left: 355px;
|
||||
}
|
||||
|
||||
.like {
|
||||
left: 435px;
|
||||
}
|
||||
|
||||
.wind {
|
||||
left: 535px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,17 @@
|
||||
|
||||
body {
|
||||
font-family: "Star4000";
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
a {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: lightblue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
@@ -20,18 +31,52 @@ button {
|
||||
#txtAddress {
|
||||
width: 490px;
|
||||
font-size: 16pt;
|
||||
max-width: calc(100% - 8px);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
border: 1px solid darkgray;
|
||||
}
|
||||
}
|
||||
|
||||
#btnGetGps,
|
||||
#btnGetLatLng,
|
||||
#btnClearQuery {
|
||||
font-size: 16pt;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
border: 1px solid darkgray;
|
||||
}
|
||||
|
||||
#btnGetGps img {
|
||||
|
||||
&.dark {
|
||||
display: none;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&.light {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete-suggestions {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
/*overflow: auto;*/
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete-suggestion {
|
||||
@@ -90,6 +135,11 @@ button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #000000;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: rgb(48, 48, 48);
|
||||
}
|
||||
|
||||
color: #ffffff;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -269,12 +319,6 @@ jsgif {
|
||||
font-size: 18pt;
|
||||
}
|
||||
|
||||
#container canvas {
|
||||
/* position: absolute; */
|
||||
width: 100%;
|
||||
/* max-width: 640px; */
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-weight: bold;
|
||||
margin-top: 15px;
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
.loading,
|
||||
.retrying {
|
||||
color: #ffff00;
|
||||
}
|
||||
|
||||
@@ -58,23 +59,12 @@
|
||||
color: #C0C0C0;
|
||||
}
|
||||
|
||||
&.loading .loading {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.press-here .press-here {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.failed .failed {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.no-data .no-data {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.disabled .disabled {
|
||||
&.loading .loading,
|
||||
&.press-here .press-here,
|
||||
&.failed .failed,
|
||||
&.no-data .no-data,
|
||||
&.disabled .disabled,
|
||||
&.retrying .retrying {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@import 'current-weather';
|
||||
@import 'extended-forecast';
|
||||
@import 'hourly';
|
||||
@import 'hourly-graph';
|
||||
@import 'travel';
|
||||
@import 'latest-observations';
|
||||
@import 'local-forecast';
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
<meta name="keywords" content="WeatherStar 4000+" />
|
||||
<meta name="author" content="Matt Walsh" />
|
||||
<meta name="application-name" content="WeatherStar 4000+" />
|
||||
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1;maximum-scale=1;minimum-scale=1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
@@ -34,6 +33,7 @@
|
||||
<script type="module" src="scripts/modules/almanac.mjs"></script>
|
||||
<script type="module" src="scripts/modules/icons.mjs"></script>
|
||||
<script type="module" src="scripts/modules/extendedforecast.mjs"></script>
|
||||
<script type="module" src="scripts/modules/hourly-graph.mjs"></script>
|
||||
<script type="module" src="scripts/modules/hourly.mjs"></script>
|
||||
<script type="module" src="scripts/modules/latestobservations.mjs"></script>
|
||||
<script type="module" src="scripts/modules/localforecast.mjs"></script>
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
<div id="divQuery">
|
||||
<form id="frmGetLatLng">
|
||||
<input id="txtAddress" type="text" value="" placeholder="Zip or City, State" /><button id="btnGetGps" type="button" title="Get GPS Location"><img id="imgGetGps" src="images/nav/ic_gps_fixed_black_18dp_1x.png" /></button>
|
||||
<input id="txtAddress" type="text" value="" placeholder="Zip or City, State" /><button id="btnGetGps" type="button" title="Get GPS Location"><img src="images/nav/ic_gps_fixed_black_18dp_1x.png" class="light"/><img src="images/nav/ic_gps_fixed_white_18dp_1x.png" class="dark"/></button>
|
||||
<input id="btnGetLatLng" type="submit" value="GO" />
|
||||
<input id="btnClearQuery" type="reset" value="Reset" />
|
||||
</form>
|
||||
@@ -90,6 +90,9 @@
|
||||
<div id="hourly-html" class="weather-display">
|
||||
<%- include('partials/hourly.ejs') %>
|
||||
</div>
|
||||
<div id="hourly-graph-html" class="weather-display">
|
||||
<%- include('partials/hourly-graph.ejs') %>
|
||||
</div>
|
||||
<div id="travel-html" class="weather-display">
|
||||
<%- include('partials/travel.ejs') %>
|
||||
</div>
|
||||
@@ -126,7 +129,7 @@
|
||||
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_1x.png" title="Refresh" />
|
||||
</div>
|
||||
<div id="divTwcBottomRight">
|
||||
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_exit_white_24dp_1x.png" title="Enter Fullscreen" />
|
||||
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_1x.png" title="Enter Fullscreen" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
<% if (locals?.hasTime) { %>
|
||||
<div class="date-time date"></div>
|
||||
<div class="date-time time"></div>
|
||||
<% } else if (!locals?.noaaLogo) { %>
|
||||
<div class="right"></div>
|
||||
<% } %>
|
||||
<% if (locals?.noaaLogo) { %>
|
||||
<div class="noaa-logo">
|
||||
|
||||
24
views/partials/hourly-graph.ejs
Normal file
24
views/partials/hourly-graph.ejs
Normal file
@@ -0,0 +1,24 @@
|
||||
<%- 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') %>
|
||||
@@ -9,6 +9,7 @@
|
||||
<div class="failed">Failed</div>
|
||||
<div class="no-data">No Data</div>
|
||||
<div class="disabled">Disabled</div>
|
||||
<div class="retrying">Retrying</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
<div class="header">
|
||||
<div class="logo"><img src="images/Logo3.png" /></div>
|
||||
<div class="title dual">
|
||||
<div class="top">
|
||||
Local
|
||||
</div>
|
||||
<div class="bottom">
|
||||
Radar
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="precip">
|
||||
<div class="precip-header">PRECIP</div>
|
||||
<div class="scale">
|
||||
<div class="text">Light</div>
|
||||
<div class="scale-table">
|
||||
<div class="box box-1"></div>
|
||||
<div class="box box-2"></div>
|
||||
<div class="box box-3"></div>
|
||||
<div class="box box-4"></div>
|
||||
<div class="box box-5"></div>
|
||||
<div class="box box-6"></div>
|
||||
<div class="box box-7"></div>
|
||||
<div class="box box-7"></div>
|
||||
</div>
|
||||
<div class="text">Heavy</div>
|
||||
</div>
|
||||
<div class="time"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logo"><img src="images/Logo3.png" /></div>
|
||||
<div class="title dual">
|
||||
<div class="top">
|
||||
Local
|
||||
</div>
|
||||
<div class="bottom">
|
||||
Radar
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="precip">
|
||||
<div class="precip-header">PRECIP</div>
|
||||
<div class="scale">
|
||||
<div class="text">Light</div>
|
||||
<div class="scale-table">
|
||||
<div class="box box-1"></div>
|
||||
<div class="box box-2"></div>
|
||||
<div class="box box-3"></div>
|
||||
<div class="box box-4"></div>
|
||||
<div class="box box-5"></div>
|
||||
<div class="box box-6"></div>
|
||||
<div class="box box-7"></div>
|
||||
<div class="box box-7"></div>
|
||||
</div>
|
||||
<div class="text">Heavy</div>
|
||||
</div>
|
||||
<div class="time"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main radar">
|
||||
<div class="container">
|
||||
<div class="scroll-area">
|
||||
<div class="frame template">
|
||||
<div class="map">
|
||||
<img src="images/4000RadarMap2.jpg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="scroll-area">
|
||||
<div class="frame template">
|
||||
<div class="map">
|
||||
<img src="images/4000RadarMap2.jpg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user