reorganize for build system

This commit is contained in:
Matt Walsh
2020-09-04 13:02:20 -05:00
commit 8bc7a7dd95
477 changed files with 49411 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
// display sun and moon data
/* globals WeatherDisplay, utils, STATUS, UNITS, draw, navigation, SunCalc, luxon */
// eslint-disable-next-line no-unused-vars
class Almanac extends WeatherDisplay {
constructor(navId,elemId,weatherParameters) {
super(navId,elemId);
// pre-load background image (returns promise)
this.backgroundImage = utils.image.load('images/BackGround1_1.png');
// load all images in parallel (returns promises)
this.moonImages = [
utils.image.load('images/2/Full-Moon.gif'),
utils.image.load('images/2/Last-Quarter.gif'),
utils.image.load('images/2/New-Moon.gif'),
utils.image.load('images/2/First-Quarter.gif'),
];
this.backgroundImage = utils.image.load('images/BackGround3_1.png');
// get the data
this.getData(weatherParameters);
}
getData(weatherParameters) {
super.getData();
const {DateTime} = luxon;
const sun = [
SunCalc.getTimes(new Date(), weatherParameters.latitude, weatherParameters.longitude),
SunCalc.getTimes(DateTime.local().plus({days:1}).toJSDate(), weatherParameters.latitude, weatherParameters.longitude),
];
// brute force the moon phases by scanning the next 30 days
const moon = [];
// start with yesterday
let moonDate = DateTime.local().minus({days:1});
let phase = SunCalc.getMoonIllumination(moonDate.toJSDate()).phase;
let iterations = 0;
do {
// get yesterday's moon info
const lastPhase = phase;
// calculate new values
moonDate = moonDate.plus({days:1});
phase = SunCalc.getMoonIllumination(moonDate.toJSDate()).phase;
// check for 4 cases
if (lastPhase < 0.25 && phase >= 0.25) moon.push(this.getMoonTransition(0.25, 'First', moonDate));
if (lastPhase < 0.50 && phase >= 0.50) moon.push(this.getMoonTransition(0.50, 'Full', moonDate));
if (lastPhase < 0.75 && phase >= 0.75) moon.push(this.getMoonTransition(0.75, 'Last', moonDate));
if (lastPhase > phase) moon.push(this.getMoonTransition(0.00, 'New', moonDate));
// stop after 30 days or 4 moon phases
iterations++;
} while (iterations <= 30 && moon.length < 4);
// store the data
this.data = {
sun,
moon,
};
// draw the canvas
this.drawCanvas();
}
// get moon transition from one phase to the next by drilling down by hours, minutes and seconds
getMoonTransition(threshold, phaseName, start, iteration = 0) {
let moonDate = start;
let phase = SunCalc.getMoonIllumination(moonDate.toJSDate()).phase;
let iterations = 0;
const step = {
hours: iteration === 0 ? -1:0,
minutes: iteration === 1 ? 1:0,
seconds: iteration === 2 ? -1:0,
milliseconds: iteration === 3 ? 1:0,
};
// increasing test
let test = (lastPhase,phase,threshold) => lastPhase < threshold && phase >= threshold;
// decreasing test
if (iteration%2===0) test = (lastPhase,phase,threshold) => lastPhase > threshold && phase <= threshold;
do {
// store last phase
const lastPhase = phase;
// calculate new phase after step
moonDate = moonDate.plus(step);
phase = SunCalc.getMoonIllumination(moonDate.toJSDate()).phase;
// wrap phases > 0.9 to -0.1 for ease of detection
if (phase > 0.9) phase -= 1.0;
// compare
if (test(lastPhase, phase, threshold)) {
// last iteration is three, return value
if (iteration >= 3) break;
// iterate recursively
return this.getMoonTransition(threshold, phaseName, moonDate, iteration+1);
}
iterations++;
} while (iterations < 1000);
return {phase: phaseName, date: moonDate};
}
async drawCanvas() {
super.drawCanvas();
const info = this.data;
const {DateTime} = luxon;
// extract moon images
const [FullMoonImage, LastMoonImage, NewMoonImage, FirstMoonImage] = await Promise.all(this.moonImages);
this.context.drawImage(await this.backgroundImage, 0, 0);
draw.horizontalGradientSingle(this.context, 0, 30, 500, 90, draw.topColor1, draw.topColor2);
draw.triangle(this.context, 'rgb(28, 10, 87)', 500, 30, 450, 90, 500, 90);
draw.horizontalGradientSingle(this.context, 0, 90, 640, 190, draw.sideColor1, draw.sideColor2);
draw.titleText(this.context, 'Almanac', 'Astronomical');
const Today = DateTime.local();
const Tomorrow = Today.plus({days: 1});
draw.text(this.context, 'Star4000', '24pt', '#FFFF00', 320, 120, Today.toLocaleString({weekday: 'long'}), 2, 'center');
draw.text(this.context, 'Star4000', '24pt', '#FFFF00', 500, 120, Tomorrow.toLocaleString({weekday: 'long'}), 2, 'center');
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 70, 150, 'Sunrise:', 2);
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 270, 150, DateTime.fromJSDate(info.sun[0].sunrise).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(), 2);
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 450, 150, DateTime.fromJSDate(info.sun[1].sunrise).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(), 2);
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 70, 180, ' Sunset:', 2);
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 270, 180, DateTime.fromJSDate(info.sun[0].sunset).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(), 2);
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 450, 180, DateTime.fromJSDate(info.sun[1].sunset).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(), 2);
draw.text(this.context, 'Star4000', '24pt', '#FFFF00', 70, 220, 'Moon Data:', 2);
info.moon.forEach((MoonPhase, Index) => {
const date = MoonPhase.date.toLocaleString({month: 'short', day: 'numeric'});
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 120+Index*130, 260, MoonPhase.phase, 2, 'center');
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 120+Index*130, 390, date, 2, 'center');
const image = (() => {
switch (MoonPhase.phase) {
case 'Full':
return FullMoonImage;
case 'Last':
return LastMoonImage;
case 'New':
return NewMoonImage;
case 'First':
default:
return FirstMoonImage;
}
})();
this.context.drawImage(image, 75+Index*130, 270);
});
this.finishDraw();
this.setStatus(STATUS.loaded);
}
}

View File

@@ -0,0 +1,202 @@
// current weather conditions display
/* globals WeatherDisplay, utils, STATUS, icons, UNITS, draw, navigation */
// eslint-disable-next-line no-unused-vars
class CurrentWeather extends WeatherDisplay {
constructor(navId,elemId,weatherParameters) {
super(navId,elemId);
// pre-load background image (returns promise)
this.backgroundImage = utils.image.load('images/BackGround1_1.png');
// get the data
this.getData(weatherParameters);
}
async getData(weatherParameters) {
super.getData();
// Load the observations
let observations, station;
try {
// station observations
const observationsPromise = $.ajaxCORS({
type: 'GET',
url: `https://api.weather.gov/stations/${weatherParameters.stationId}/observations`,
data: {
limit: 2,
},
dataType: 'json',
crossDomain: true,
});
// station info
const stationPromise = $.ajax({
type: 'GET',
url: `https://api.weather.gov/stations/${weatherParameters.stationId}`,
dataType: 'json',
crossDomain: true,
});
// wait for the promises to resolve
[observations, station] = await Promise.all([observationsPromise, stationPromise]);
// TODO: add retry for further stations if observations are unavailable
} catch (e) {
console.error('Unable to get current observations');
console.error(e);
this.setStatus(STATUS.error);
return;
}
// we only get here if there was no error above
this.data = Object.assign({}, observations, {station: station});
this.drawCanvas();
}
async drawCanvas () {
super.drawCanvas();
const observations = this.data.features[0].properties;
// values from api are provided in metric
let Temperature = Math.round(observations.temperature.value);
let DewPoint = Math.round(observations.dewpoint.value);
let Ceiling = Math.round(observations.cloudLayers[0].base.value);
let CeilingUnit = 'm.';
let Visibility = Math.round(observations.visibility.value/1000);
let VisibilityUnit = ' km.';
let WindSpeed = Math.round(observations.windSpeed.value);
const WindDirection = utils.calc.directionToNSEW(observations.windDirection.value);
let Pressure = Math.round(observations.barometricPressure.value);
let HeatIndex = Math.round(observations.heatIndex.value);
let WindChill = Math.round(observations.windChill.value);
let WindGust = Math.round(observations.windGust.value);
let Humidity = Math.round(observations.relativeHumidity.value);
// TODO: switch to larger icon
const Icon = icons.getWeatherRegionalIconFromIconLink(observations.icon);
let PressureDirection = '';
const TextConditions = observations.textDescription;
// difference since last measurement (pascals, looking for difference of more than 150)
const pressureDiff = (observations.barometricPressure.value - this.data.features[1].properties.barometricPressure.value);
if (pressureDiff > 150) PressureDirection = 'R';
if (pressureDiff < -150) PressureDirection = 'F';
if (navigation.units() === UNITS.english) {
Temperature = utils.units.celsiusToFahrenheit(Temperature);
DewPoint = utils.units.celsiusToFahrenheit(DewPoint);
Ceiling = Math.round(utils.units.metersToFeet(Ceiling)/100)*100;
CeilingUnit = 'ft.';
Visibility = utils.units.kilometersToMiles(observations.visibility.value/1000);
VisibilityUnit = ' mi.';
WindSpeed = utils.units.kphToMph(WindSpeed);
Pressure = utils.units.pascalToInHg(Pressure);
HeatIndex = utils.units.celsiusToFahrenheit(HeatIndex);
WindChill = utils.units.celsiusToFahrenheit(WindChill);
WindGust = utils.units.kphToMph(WindGust);
}
// get main icon
this.gifs.push(await utils.image.superGifAsync({
src: Icon,
loop_delay: 100,
auto_play: true,
canvas: this.canvas,
x: 140,
y: 175,
max_width: 126,
}));
this.context.drawImage(await this.backgroundImage, 0, 0);
draw.horizontalGradientSingle(this.context, 0, 30, 500, 90, draw.topColor1, draw.topColor2);
draw.triangle(this.context, 'rgb(28, 10, 87)', 500, 30, 450, 90, 500, 90);
draw.horizontalGradientSingle(this.context, 0, 90, 52, 399, draw.sideColor1, draw.sideColor2);
draw.horizontalGradientSingle(this.context, 584, 90, 640, 399, draw.sideColor1, draw.sideColor2);
draw.titleText(this.context, 'Current', 'Conditions');
draw.text(this.context, 'Star4000 Large', '24pt', '#FFFFFF', 170, 135, Temperature + String.fromCharCode(176), 2);
let Conditions = observations.textDescription;
if (TextConditions.length > 15) {
Conditions = this.shortConditions(Conditions);
}
draw.text(this.context, 'Star4000 Extended', '24pt', '#FFFFFF', 195, 170, Conditions, 2, 'center');
draw.text(this.context, 'Star4000 Extended', '24pt', '#FFFFFF', 80, 330, 'Wind:', 2);
draw.text(this.context, 'Star4000 Extended', '24pt', '#FFFFFF', 300, 330, WindDirection + ' ' + WindSpeed, 2, 'right');
if (WindGust) draw.text(this.context, 'Star4000 Extended', '24pt', '#FFFFFF', 80, 375, 'Gusts to ' + WindGust, 2);
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFF00', 315, 120, this.data.station.properties.name.substr(0, 20), 2);
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 340, 165, 'Humidity:', 2);
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 560, 165, Humidity + '%', 2, 'right');
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 340, 205, 'Dewpoint:', 2);
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 560, 205, DewPoint + String.fromCharCode(176), 2, 'right');
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 340, 245, 'Ceiling:', 2);
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 560, 245, (Ceiling === '' ? 'Unlimited' : Ceiling + CeilingUnit), 2, 'right');
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 340, 285, 'Visibility:', 2);
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 560, 285, Visibility + VisibilityUnit, 2, 'right');
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 340, 325, 'Pressure:', 2);
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 535, 325, Pressure, 2, 'right');
switch (PressureDirection) {
case 'R':
// Shadow
draw.triangle(this.context, '#000000', 552, 302, 542, 312, 562, 312);
draw.box(this.context, '#000000', 549, 312, 6, 15);
// Border
draw.triangle(this.context, '#000000', 550, 300, 540, 310, 560, 310);
draw.box(this.context, '#000000', 547, 310, 6, 15);
// Fill
draw.triangle(this.context, '#FFFF00', 550, 301, 541, 309, 559, 309);
draw.box(this.context, '#FFFF00', 548, 309, 4, 15);
break;
case 'F':
// Shadow
draw.triangle(this.context, '#000000', 552, 327, 542, 317, 562, 317);
draw.box(this.context, '#000000', 549, 302, 6, 15);
// Border
draw.triangle(this.context, '#000000', 550, 325, 540, 315, 560, 315);
draw.box(this.context, '#000000', 547, 300, 6, 15);
// Fill
draw.triangle(this.context, '#FFFF00', 550, 324, 541, 314, 559, 314);
draw.box(this.context, '#FFFF00', 548, 301, 4, 15);
break;
default:
}
if (observations.heatIndex.value && HeatIndex !== Temperature) {
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 340, 365, 'Heat Index:', 2);
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 560, 365, HeatIndex + String.fromCharCode(176), 2, 'right');
} else if (observations.windChill.value && WindChill !== '' && WindChill < Temperature) {
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 340, 365, 'Wind Chill:', 2);
draw.text(this.context, 'Star4000 Large', 'bold 16pt', '#FFFFFF', 560, 365, WindChill + String.fromCharCode(176), 2, 'right');
}
this.finishDraw();
this.setStatus(STATUS.loaded);
}
shortConditions(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;
}
}

View File

@@ -0,0 +1,101 @@
// drawing functionality and constants
// eslint-disable-next-line no-unused-vars
const draw = (() => {
const horizontalGradient = (context, x1, y1, x2, y2, color1, color2) => {
const linearGradient = context.createLinearGradient(0, y1, 0, y2);
linearGradient.addColorStop(0, color1);
linearGradient.addColorStop(0.4, color2);
linearGradient.addColorStop(0.6, color2);
linearGradient.addColorStop(1, color1);
context.fillStyle = linearGradient;
context.fillRect(x1, y1, x2 - x1, y2 - y1);
};
const horizontalGradientSingle = (context, x1, y1, x2, y2, color1, color2) => {
const linearGradient = context.createLinearGradient(0, y1, 0, y2);
linearGradient.addColorStop(0, color1);
linearGradient.addColorStop(1, color2);
context.fillStyle = linearGradient;
context.fillRect(x1, y1, x2 - x1, y2 - y1);
};
const triangle = (context, color, x1, y1, x2, y2, x3, y3) => {
context.fillStyle = color;
context.beginPath();
context.moveTo(x1, y1);
context.lineTo(x2, y2);
context.lineTo(x3, y3);
context.fill();
};
const titleText = (context, title1, title2) => {
const font = 'Star4000';
const size = '24pt';
const color = '#ffff00';
const shadow = 3;
const x = 170;
let y = 55;
if (title2) {
text(context, font, size, color, x, y, title1, shadow); y += 30;
text(context, font, size, color, x, y, title2, shadow); y += 30;
} else {
y += 15;
text(context, font, size, color, x, y, title1, shadow); y += 30;
}
};
const text = (context, font, size, color, x, y, text, shadow = 0, align = 'start') => {
context.textAlign = align;
context.font = size + ` '${font}'`;
context.shadowColor = '#000000';
context.shadowOffsetX = shadow;
context.shadowOffsetY = shadow;
context.strokeStyle = '#000000';
context.lineWidth = 2;
context.strokeText(text, x, y);
context.fillStyle = color;
context.fillText(text, x, y);
context.fillStyle = '';
context.strokeStyle = '';
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
};
const box = (context, color, x, y, width, height) => {
context.fillStyle = color;
context.fillRect(x, y, width, height);
};
const border = (context, color, lineWith, x, y, width, height) => {
context.strokeStyle = color;
context.lineWidth = lineWith;
context.strokeRect(x, y, width, height);
};
// TODO: implement full themes support
const theme = 1; // classic
const topColor1 = 'rgb(192, 91, 2)';
const topColor2 = 'rgb(72, 34, 64)';
const sideColor1 = 'rgb(46, 18, 80)';
const sideColor2 = 'rgb(192, 91, 2)';
return {
// methods
horizontalGradient,
horizontalGradientSingle,
triangle,
titleText,
text,
box,
border,
// constant-ish
theme,
topColor1,
topColor2,
sideColor1,
sideColor2,
};
})();

View File

@@ -0,0 +1,177 @@
// display extended forecast graphically
// technically uses the same data as the local forecast, we'll let the browser do the caching of that
/* globals WeatherDisplay, utils, STATUS, UNITS, draw, icons, navigation, luxon */
// eslint-disable-next-line no-unused-vars
class ExtendedForecast extends WeatherDisplay {
constructor(navId,elemId,weatherParameters) {
super(navId,elemId);
// set timings
this.timing.totalScreens = 2;
// pre-load background image (returns promise)
this.backgroundImage = utils.image.load('images/BackGround2_1.png');
// get the data
this.getData(weatherParameters);
}
async getData(weatherParameters) {
super.getData();
// request us or si units
let units = 'us';
if (navigation.units() === UNITS.metric) units = 'si';
let forecast;
try {
forecast = await $.ajax({
type: 'GET',
url: weatherParameters.forecast,
data: {
units,
},
dataType: 'json',
crossDomain: true,
});
} catch (e) {
console.error('Unable to get extended forecast');
console.error(e);
this.setStatus(STATUS.error);
return;
}
// we only get here if there was no error above
this.data = this.parseExtendedForecast(forecast.properties.periods);
this.screnIndex = 0;
this.drawCanvas();
}
// the api provides the forecast in 12 hour increments, flatten to day increments with high and low temperatures
parseExtendedForecast(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 = luxon.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
// TODO: change to commented line when incons are matched up
// fDay.icon = icons.GetWeatherIconFromIconLink(period.icon);
fDay.icon = icons.getWeatherRegionalIconFromIconLink(period.icon);
fDay.text = this.shortenExtendedForecastText(period.shortForecast);
fDay.dayName = dates[destIndex];
if (period.isDaytime) {
// day time is the high temperature
fDay.high = period.temperature;
destIndex++;
} else {
// low temperature
fDay.low = period.temperature;
}
});
return forecast;
}
shortenExtendedForecastText(long) {
let short = long;
short = short.replace(/ and /g, ' ');
short = short.replace(/Slight /g, '');
short = short.replace(/Chance /g, '');
short = short.replace(/Very /g, '');
short = short.replace(/Patchy /g, '');
short = short.replace(/Areas /g, '');
short = short.replace(/Dense /g, '');
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 = '';
}
}
short = short1;
if (short2 !== '') {
short += ' ' + short2;
}
return [short, short1, short2];
}
async drawCanvas() {
super.drawCanvas();
// determine bounds
// grab the first three or second set of three array elements
const forecast = this.data.slice(0+3*this.screenIndex, 3+this.screenIndex*3);
const backgroundImage = await this.backgroundImage;
this.context.drawImage(backgroundImage, 0, 0);
draw.horizontalGradientSingle(this.context, 0, 30, 500, 90, draw.topColor1, draw.topColor2);
draw.triangle(this.context, 'rgb(28, 10, 87)', 500, 30, 450, 90, 500, 90);
draw.horizontalGradientSingle(this.context, 0, 90, 640, 399, draw.sideColor1, draw.sideColor2);
this.context.drawImage(backgroundImage, 38, 100, 174, 297, 38, 100, 174, 297);
this.context.drawImage(backgroundImage, 232, 100, 174, 297, 232, 100, 174, 297);
this.context.drawImage(backgroundImage, 426, 100, 174, 297, 426, 100, 174, 297);
draw.titleText(this.context, 'Extended', 'Forecast');
await Promise.all(forecast.map(async (Day, Index) => {
const offset = Index*195;
draw.text(this.context, 'Star4000', '24pt', '#FFFF00', 100+offset, 135, Day.dayName.toUpperCase(), 2);
draw.text(this.context, 'Star4000', '24pt', '#8080FF', 85+offset, 345, 'Lo', 2, 'center');
draw.text(this.context, 'Star4000', '24pt', '#FFFF00', 165+offset, 345, 'Hi', 2, 'center');
let low = Day.low;
if (low !== undefined) {
if (navigation.units() === UNITS.metric) low = utils.units.rahrenheitToCelsius(low);
draw.text(this.context, 'Star4000 Large', '24pt', '#FFFFFF', 85+offset, 385, low, 2, 'center');
}
let high = Day.high;
if (navigation.units() === UNITS.metric) high = utils.units.rahrenheitToCelsius(high);
draw.text(this.context, 'Star4000 Large', '24pt', '#FFFFFF', 165+offset, 385, high, 2, 'center');
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 120+offset, 270, Day.text[1], 2, 'center');
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 120+offset, 310, Day.text[2], 2, 'center');
// draw the icon
this.gifs.push(await utils.image.superGifAsync({
src: Day.icon,
loop_delay: 100,
auto_play: true,
canvas: this.canvas,
x: 70 + Index*195,
y: 150,
max_height: 75,
}));
}));
this.finishDraw();
this.setStatus(STATUS.loaded);
}
}

View File

@@ -0,0 +1,237 @@
'use strict';
// eslint-disable-next-line no-unused-vars
const icons = (() => {
// internal function to add path to returned icon
const addPath = (icon) => `images/r/${icon}`;
const getWeatherRegionalIconFromIconLink = (link, isNightTime) => {
// extract day or night if not provided
if (isNightTime === undefined) isNightTime = link.indexOf('/night/') >=0;
// grab everything after the last slash ending at any of these: ?&,
const afterLastSlash = link.toLowerCase().match(/[^/]+$/)[0];
let conditionName = afterLastSlash.match(/(.*?)[,?&.]/)[1];
// if a 'DualImage' is captured, adjust to just the j parameter
if (conditionName === 'dualimage') {
const match = link.match(/&j=(.*)&/);
conditionName = match[1];
}
// find the icon
switch (conditionName + (isNightTime?'-n':'')) {
case 'skc':
case 'hot':
case 'haze':
return addPath('Sunny.gif');
case 'skc-n':
case 'nskc':
case 'nskc-n':
return addPath('Clear-1992.gif');
case 'bkn':
return addPath('Mostly-Cloudy-1994-2.gif');
case 'bkn-n':
case 'few-n':
case 'nfew-n':
case 'nfew':
return addPath('Partly-Clear-1994-2.gif');
case 'sct':
case 'few':
return addPath('Partly-Cloudy.gif');
case 'sct-n':
case 'nsct':
case 'nsct-n':
return addPath('Mostly-Clear.gif');
case 'ovc':
return addPath('Cloudy.gif');
case 'fog':
return addPath('Fog.gif');
case 'rain_sleet':
return addPath('Sleet.gif');
case 'rain_showers':
case 'rain_showers_high':
return addPath('Scattered-Showers-1994-2.gif');
case 'rain_showers-n':
case 'rain_showers_high-n':
return addPath('Scattered-Showers-Night-1994-2.gif');
case 'rain':
return addPath('Rain-1992.gif');
// case 'snow':
// return addPath('Light-Snow.gif');
// break;
// case 'cc_snowshowers.gif':
// //case "heavy-snow.gif":
// return addPath('AM-Snow-1994.gif');
// break;
case 'snow':
return addPath('Heavy-Snow-1994-2.gif');
case 'rain_snow':
return addPath('Rain-Snow-1992.gif');
case 'snow_fzra':
return addPath('Freezing-Rain-Snow-1992.gif');
case 'fzra':
return addPath('Freezing-Rain-1992.gif');
case 'snow_sleet':
return addPath('Wintry-Mix-1992.gif');
case 'tsra_sct':
case 'tsra':
return addPath('Scattered-Tstorms-1994-2.gif');
case 'tsra_sct-n':
case 'tsra-n':
return addPath('Scattered-Tstorms-Night-1994-2.gif');
case 'tsra_hi':
case 'tsra_hi-n':
case 'hurricane':
return addPath('Thunderstorm.gif');
case 'wind_few':
case 'wind_sct':
case 'wind_bkn':
case 'wind_ovc':
return addPath('Wind.gif');
case 'wind_skc':
return addPath('Sunny-Wind-1994.gif');
case 'wind_skc-n':
return addPath('Clear-Wind-1994.gif');
case 'blizzard':
return addPath('Blowing Snow.gif');
default:
console.log(`Unable to locate regional icon for ${link} ${isNightTime}`);
return false;
}
};
const getWeatherIconFromIconLink = function (link, OverrideIsDay = true) {
// grab everything after the last slash ending at any of these: ?&,
const afterLastSlash = link.toLowerCase().match(/[^/]+$/)[0];
let conditionName = afterLastSlash.match(/(.*?)[,?&.]/)[1];
// if a 'DualImage' is captured, adjust to just the j parameter
if (conditionName === 'dualimage') {
const match = link.match(/&j=(.*)&/);
conditionName = match[1];
}
// find the icon
switch (conditionName + (!OverrideIsDay?'-n':'')) {
case 'skc':
return addPath('Sunny.gif');
case 'skc-n':
return addPath('Clear.gif');
case 'cc_mostlycloudy1.gif':
return addPath('Mostly-Cloudy.gif');
case 'cc_mostlycloudy0.gif':
return addPath('Partly-Clear.gif');
case 'cc_partlycloudy1.gif':
return addPath('Partly-Cloudy.gif');
case 'cc_partlycloudy0.gif':
return addPath('Mostly-Clear.gif');
case 'cc_cloudy.gif':
return addPath('Cloudy.gif');
case 'cc_fog.gif':
return addPath('Fog.gif');
case 'sleet.gif':
return addPath('Sleet.gif');
case 'ef_scatshowers.gif':
return addPath('Scattered-Showers.gif');
case 'cc_showers.gif':
return addPath('Shower.gif');
case 'cc_rain.gif':
return addPath('Rain.gif');
//case "ef_scatsnowshowers.gif":
case 'light-snow.gif':
return addPath('Light-Snow.gif');
case 'cc_snowshowers.gif':
return addPath('Heavy-Snow.gif');
case 'cc_snow.gif':
case 'heavy-snow.gif':
return addPath('Heavy-Snow.gif');
case 'cc_rainsnow.gif':
//return addPath("Ice-Snow.gif");
return addPath('Rain-Snow.gif');
case 'cc_freezingrain.gif':
return addPath('Freezing-Rain.gif');
case 'cc_mix.gif':
return addPath('Wintry-Mix.gif');
case 'freezing-rain-sleet.gif':
return addPath('Freezing-Rain-Sleet.gif');
case 'snow-sleet.gif':
return addPath('Snow-Sleet.gif');
case 'ef_scattstorms.gif':
return addPath('Scattered-Tstorms.gif');
case 'ef_scatsnowshowers.gif':
return addPath('Scattered-Snow-Showers.gif');
case 'cc_tstorm.gif':
case 'ef_isolatedtstorms.gif':
return addPath('Thunderstorm.gif');
case 'cc_windy.gif':
case 'cc_windy2.gif':
return addPath('Windy.gif');
case 'blowing-snow.gif':
return addPath('Blowing-Snow.gif');
default:
console.error('Unable to locate icon for \'' + link + '\'');
return false;
}
};
return {
getWeatherIconFromIconLink,
getWeatherRegionalIconFromIconLink,
};
})();

View File

@@ -0,0 +1,129 @@
// current weather conditions display
/* globals WeatherDisplay, utils, STATUS, UNITS, draw, navigation, _StationInfo */
// eslint-disable-next-line no-unused-vars
class LatestObservations extends WeatherDisplay {
constructor(navId,elemId, weatherParameters) {
super(navId,elemId);
// pre-load background image (returns promise)
this.backgroundImage = utils.image.load('images/BackGround1_1.png');
// constants
this.MaximumRegionalStations = 7;
// get the data
this.getData(weatherParameters);
}
async getData(weatherParameters) {
// calculate distance to each station
const stationsByDistance = Object.keys(_StationInfo).map(key => {
const station = _StationInfo[key];
const distance = utils.calc.distance(station.Latitude, station.Longitude, weatherParameters.latitude, weatherParameters.longitude);
return Object.assign({}, station, {distance});
});
// sort the stations by distance
const sortedStations = stationsByDistance.sort((a,b) => a.distance - b.distance);
// try up to 30 regional stations
const regionalStations = sortedStations.slice(0,30);
// get data for regional stations
const allConditions = await Promise.all(regionalStations.map(async station => {
try {
const data = await $.ajax({
type: 'GET',
url: `https://api.weather.gov/stations/${station.StationId}/observations/latest`,
dataType: 'json',
crossDomain: true,
});
// format the return values
return Object.assign({}, data.properties, {
StationId: station.StationId,
City: station.City,
});
} catch (e) {
console.log(`Unable to get latest observations for ${station.StationId}`);
return;
}
}));
// remove and stations that did not return data
const actualConditions = allConditions.filter(condition => condition);
// cut down to the maximum of 7
this.data = actualConditions.slice(0,this.MaximumRegionalStations);
this.drawCanvas();
}
async drawCanvas() {
super.drawCanvas();
const conditions = this.data;
// sort array by station name
const sortedConditions = conditions.sort((a,b) => ((a.Name < b.Name) ? -1 : ((a.Name > b.Name) ? 1 : 0)));
this.context.drawImage(await this.backgroundImage, 0, 0);
draw.horizontalGradientSingle(this.context, 0, 30, 500, 90, draw.topColor1, draw.topColor2);
draw.triangle(this.context, 'rgb(28, 10, 87)', 500, 30, 450, 90, 500, 90);
draw.horizontalGradientSingle(this.context, 0, 90, 52, 399, draw.sideColor1, draw.sideColor2);
draw.horizontalGradientSingle(this.context, 584, 90, 640, 399, draw.sideColor1, draw.sideColor2);
draw.titleText(this.context, 'Latest', 'Observations');
if (navigation.units() === UNITS.english) {
draw.text(this.context, 'Star4000 Small', '24pt', '#FFFFFF', 295, 105, String.fromCharCode(176) + 'F', 2);
} else {
draw.text(this.context, 'Star4000 Small', '24pt', '#FFFFFF', 295, 105, String.fromCharCode(176) + 'C', 2);
}
draw.text(this.context, 'Star4000 Small', '24pt', '#FFFFFF', 345, 105, 'WEATHER', 2);
draw.text(this.context, 'Star4000 Small', '24pt', '#FFFFFF', 495, 105, 'WIND', 2);
let y = 140;
sortedConditions.forEach((condition) => {
let Temperature = condition.temperature.value;
let WindSpeed = condition.windSpeed.value;
const windDirection = utils.calc.directionToNSEW(condition.windDirection.value);
if (navigation.units() === UNITS.english) {
Temperature = utils.units.celsiusToFahrenheit(Temperature);
WindSpeed = utils.units.kphToMph(WindSpeed);
}
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 65, y, condition.City.substr(0, 14), 2);
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 345, y, this.shortenCurrentConditions(condition.textDescription).substr(0, 9), 2);
if (WindSpeed > 0) {
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 495, y, windDirection + (Array(6 - windDirection.length - WindSpeed.toString().length).join(' ')) + WindSpeed.toString(), 2);
} else if (WindSpeed === 'NA') {
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 495, y, 'NA', 2);
} else {
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 495, y, 'Calm', 2);
}
const x = (325 - (Temperature.toString().length * 15));
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', x, y, Temperature, 2);
y += 40;
});
this.finishDraw();
this.setStatus(STATUS.loaded);
}
shortenCurrentConditions(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;
}
}

View File

@@ -0,0 +1,145 @@
// display text based local forecast
/* globals WeatherDisplay, utils, STATUS, UNITS, draw, navigation*/
// eslint-disable-next-line no-unused-vars
class LocalForecast extends WeatherDisplay {
constructor(navId,elemId,weatherParameters) {
super(navId,elemId);
// set timings
this.timing.baseDelay= 3000;
// pre-load background image (returns promise)
this.backgroundImage = utils.image.load('images/BackGround1_1.png');
// get the data
this.getData(weatherParameters);
}
async getData(weatherParameters) {
super.getData();
// get raw data
const rawData = await this.getRawData(weatherParameters);
// parse raw data
const conditions = this.parseLocalForecast(rawData);
// split this forecast into the correct number of screens
const MaxRows = 7;
const MaxCols = 32;
this.screenTexts = [];
// read each text
conditions.forEach(condition => {
// process the text
let text = condition.DayName.toUpperCase() + '...';
let conditionText = condition.Text;
if (navigation.units() === UNITS.metric) {
conditionText = condition.TextC;
}
text += conditionText.toUpperCase().replace('...', ' ');
text = text.wordWrap(MaxCols, '\n');
const Lines = text.split('\n');
const LineCount = Lines.length;
let ScreenText = '';
const MaxRowCount = MaxRows;
let RowCount = 0;
// if (PrependAlert) {
// ScreenText = LocalForecastScreenTexts[LocalForecastScreenTexts.length - 1];
// RowCount = ScreenText.split('\n').length - 1;
// }
for (let i = 0; i <= LineCount - 1; i++) {
if (Lines[i] === '') continue;
if (RowCount > MaxRowCount - 1) {
// if (PrependAlert) {
// LocalForecastScreenTexts[LocalForecastScreenTexts.length - 1] = ScreenText;
// PrependAlert = false;
// } else {
this.screenTexts.push(ScreenText);
// }
ScreenText = '';
RowCount = 0;
}
ScreenText += Lines[i] + '\n';
RowCount++;
}
// if (PrependAlert) {
// this.screenTexts[this.screenTexts.length - 1] = ScreenText;
// PrependAlert = false;
// } else {
this.screenTexts.push(ScreenText);
// }
});
this.currentScreen = 0;
this.timing.totalScreens = this.screenTexts.length;
this.drawCanvas();
}
// get the unformatted data (also used by extended forecast)
async getRawData(weatherParameters) {
// request us or si units
let units = 'us';
if (navigation.units() === UNITS.metric) units = 'si';
try {
return await $.ajax({
type: 'GET',
url: weatherParameters.forecast,
data: {
units,
},
dataType: 'json',
crossDomain: true,
});
} catch (e) {
console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`);
console.error(e);
return false;
}
}
// TODO: alerts needs a cleanup
// TODO: second page of screenTexts when needed
async drawCanvas() {
super.drawCanvas();
this.context.drawImage(await this.backgroundImage, 0, 0);
draw.horizontalGradientSingle(this.context, 0, 30, 500, 90, draw.topColor1, draw.topColor2);
draw.triangle(this.context, 'rgb(28, 10, 87)', 500, 30, 450, 90, 500, 90);
draw.horizontalGradientSingle(this.context, 0, 90, 52, 399, draw.sideColor1, draw.sideColor2);
draw.horizontalGradientSingle(this.context, 584, 90, 640, 399, draw.sideColor1, draw.sideColor2);
draw.titleText(this.context, 'Local ', 'Forecast');
// clear existing text
draw.box(this.context, 'rgb(33, 40, 90)', 65, 105, 505, 280);
// Draw the text.
this.screenTexts[this.screenIndex].split('\n').forEach((text, index) => {
draw.text(this.context, 'Star4000', '24pt', '#FFFFFF', 75, 140+40*index, text, 2);
});
this.finishDraw();
this.setStatus(STATUS.loaded);
}
// format the forecast
parseLocalForecast (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,
}));
}
}

View File

@@ -0,0 +1,245 @@
'use strict';
// navigation handles progress, next/previous and initial load messages from the parent frame
/* globals utils, _StationInfo, STATUS */
/* globals CurrentWeather, LatestObservations, TravelForecast, RegionalForecast, LocalForecast, ExtendedForecast, Almanac */
document.addEventListener('DOMContentLoaded', () => {
navigation.init();
});
const UNITS = {
english: Symbol('english'),
metric: Symbol('metric'),
};
const navigation = (() => {
let weatherParameters = {};
let displays = [];
let initialLoadDone = false;
let currentUnits = UNITS.english;
let playing = false;
const init = () => {
// set up message receive and dispatch accordingly
window.addEventListener('message', (event) => {
// test for trust
if (!event.isTrusted) return;
// get the data
const data = JSON.parse(event.data);
// dispatch event
if (!data.type) return;
switch (data.type) {
case 'latLon':
getWeather(data.message);
break;
case 'units':
setUnits(data.message);
break;
case 'navButton':
handleNavButton(data.message);
break;
default:
console.error(`Unknown event ${data.type}`);
}
}, false);
};
const postMessage = (type, message = {}) => {
const parent = window.parent;
parent.postMessage(JSON.stringify({type, message}, window.location.origin));
};
const getWeather = async (latLon) => {
// reset statuses
initialLoadDone = false;
// get initial weather data
const point = await utils.weather.getPoint(latLon.lat, latLon.lon);
// get stations
const stations = await $.ajax({
type: 'GET',
url: point.properties.observationStations,
dataType: 'json',
crossDomain: true,
});
const StationId = stations.features[0].properties.stationIdentifier;
let city = point.properties.relativeLocation.properties.city;
if (StationId in _StationInfo) {
city = _StationInfo[StationId].City;
city = city.split('/')[0];
}
// populate the weather parameters
weatherParameters.latitude = latLon.lat;
weatherParameters.longitude = latLon.lon;
weatherParameters.zoneId = point.properties.forecastZone.substr(-6);
weatherParameters.radarId = point.properties.radarStation.substr(-3);
weatherParameters.stationId = StationId;
weatherParameters.weatherOffice = point.properties.cwa;
weatherParameters.city = city;
weatherParameters.state = point.properties.relativeLocation.properties.state;
weatherParameters.timeZone = point.properties.relativeLocation.properties.timeZone;
weatherParameters.forecast = point.properties.forecast;
weatherParameters.stations = stations.features;
// update the main process for display purposes
postMessage('weatherParameters', weatherParameters);
// start loading canvases if necessary
if (displays.length === 0) {
displays = [
new CurrentWeather(0,'currentWeather', weatherParameters),
new LatestObservations(1, 'latestObservations', weatherParameters),
new TravelForecast(2, 'travelForecast', weatherParameters),
// Regional Forecast: 0 = regional conditions, 1 = today, 2 = tomorrow
new RegionalForecast(3, 'regionalForecast0', weatherParameters, 0),
new RegionalForecast(4, 'regionalForecast1', weatherParameters, 1),
new RegionalForecast(5, 'regionalForecast2', weatherParameters, 2),
new LocalForecast(6, 'localForecast', weatherParameters),
new ExtendedForecast(7, 'extendedForecast', weatherParameters),
new Almanac(8, 'alamanac', weatherParameters),
];
} else {
// or just call for new data if the canvases already exist
displays.forEach(display => display.getData(weatherParameters));
}
// GetMonthPrecipitation(this.weatherParameters);
// GetAirQuality3(this.weatherParameters);
// ShowDopplerMap(this.weatherParameters);
// GetWeatherHazards3(this.weatherParameters);
};
// receive a status update from a module {id, value}
const updateStatus = (value) => {
// skip if initial load
if (initialLoadDone) return;
// test for loaded status
if (value.status !== STATUS.loaded) return;
// display the first canvas loaded on the next scan (allows display constructors to finish loading)
initialLoadDone = true;
setTimeout(() => {
hideAllCanvases();
displays[value.id].showCanvas();
}, 1);
// send loaded messaged to parent
postMessage('loaded');
// store the display number
};
const hideAllCanvases = () => {
displays.forEach(display => display.hideCanvas());
};
const units = () => currentUnits;
const setUnits = (_unit) => {
const unit = _unit.toLowerCase();
if (unit === 'english') {
currentUnits = UNITS.english;
} else {
currentUnits = UNITS.metric;
}
// TODO: refresh current screen
};
// is playing interface
const isPlaying = () => playing;
// navigation message constants
const msg = {
response: { // display to navigation
previous: Symbol('previous'), // already at first frame, calling function should switch to previous canvas
inProgress: Symbol('inProgress'), // have data to display, calling function should do nothing
next: Symbol('next'), // end of frames reached, calling function should switch to next canvas
},
command: { // navigation to display
firstFrame: Symbol('firstFrame'),
previousFrame: Symbol('previousFrame'),
nextFrame: Symbol('nextFrame'),
lastFrame: Symbol('lastFrame'), // used when navigating backwards from the begining of the next canvas
},
};
// receive naivgation messages from displays
const displayNavMessage = (message) => {
if (message.type === msg.response.previous) loadDisplay(-1);
if (message.type === msg.response.next) loadDisplay(1);
};
// navigate to next or previous
const navTo = (direction) => {
if (direction === msg.command.nextFrame) currentDisplay().navNext();
if (direction === msg.command.previousFrame) currentDisplay().navPrev();
};
// find the next or previous available display
const loadDisplay = (direction) => {
const totalDisplays = displays.length;
const curIdx = currentDisplayIndex();
let idx;
for (let i = 0; i < totalDisplays; i++) {
// convert form simple 0-10 to start at current display index +/-1 and wrap
idx = utils.calc.wrap(curIdx+(i+1)*direction,totalDisplays);
if (displays[idx].status === STATUS.loaded) break;
}
const newDisplay = displays[idx];
// hide all displays
hideAllCanvases();
// show the new display and navigate to an appropriate display
if (direction < 0) newDisplay.showCanvas(msg.command.lastFrame);
if (direction > 0) newDisplay.showCanvas(msg.command.firstFrame);
};
// get the current display index or value
const currentDisplayIndex = () => {
const index = displays.findIndex(display=>display.isActive());
if (index === undefined) console.error('No active display');
return index;
};
const currentDisplay = () => {
return displays[currentDisplayIndex()];
};
const setPlaying = (newValue) => {
playing = newValue;
postMessage('isPlaying', playing);
};
// handle all navigation buttons
const handleNavButton = (button) => {
switch (button) {
case 'playToggle':
setPlaying(!playing);
break;
case 'next':
setPlaying(false);
navTo(msg.command.nextFrame);
break;
case 'previous':
setPlaying(false);
navTo(msg.command.previousFrame);
break;
default:
console.error(`Unknown navButton ${button}`);
}
};
return {
init,
updateStatus,
units,
isPlaying,
displayNavMessage,
msg,
};
})();

View File

@@ -0,0 +1,104 @@
// regional forecast and observations
// type 0 = observations, 1 = first forecast, 2 = second forecast
// makes use of global data retrevial through RegionalForecastData
/* globals WeatherDisplay, utils, STATUS, icons, UNITS, draw, navigation, luxon, RegionalForecastData */
// eslint-disable-next-line no-unused-vars
class RegionalForecast extends WeatherDisplay {
constructor(navId,elemId, weatherParameters, period) {
super(navId,elemId);
// store the period, see above
this.period = period;
// pre-load background image (returns promise)
this.backgroundImage = utils.image.load('images/BackGround5_1.png');
// get the data and update the promise
this.getData(weatherParameters);
}
// get the data from the globally shared object
async getData(weatherParameters) {
super.getData();
// pre-load the base map (returns promise)
let src = 'images/Basemap2.png';
if (weatherParameters.State === 'HI') {
src = 'images/HawaiiRadarMap4.png';
} else if (weatherParameters.State === 'AK') {
src = 'images/AlaskaRadarMap6.png';
}
this.baseMap = utils.image.load(src);
this.data = await RegionalForecastData.updateData(weatherParameters);
this.drawCanvas();
}
async drawCanvas() {
super.drawCanvas();
// break up data into useful values
const {regionalData: data, sourceXY, offsetXY} = this.data;
// fixed offset for all y values when drawing to the map
const mapYOff = 90;
const {DateTime} = luxon;
// draw the header graphics
this.context.drawImage(await this.backgroundImage, 0, 0);
draw.horizontalGradientSingle(this.context, 0, 30, 500, 90, draw.topColor1, draw.topColor2);
draw.triangle(this.context, 'rgb(28, 10, 87)', 500, 30, 450, 90, 500, 90);
// draw the appropriate title
if (this.period === 0) {
draw.titleText(this.context, 'Regional', 'Observations');
} else {
let forecastDate = DateTime.local();
// four conditions to evaluate based on whether the first forecast is for daytime and the requested period
const firstIsDay = data[0][1].daytime;
if (firstIsDay && this.period === 1) forecastDate = forecastDate.plus({days: 1});
if (firstIsDay && this.period === 2); // no change, shown for consistency
if (!firstIsDay && this.period === 1); // no change, shown for consistency
if (!firstIsDay && this.period === 2) forecastDate = forecastDate.plus({days: 1});
// get the name of the day
const dayName = forecastDate.toLocaleString({weekday: 'long'});
// draw the title
if (data[0][this.period].daytime) {
draw.titleText(this.context, 'Forecast for', dayName);
} else {
draw.titleText(this.context, 'Forecast for', dayName + ' Night');
}
}
// draw the map
this.context.drawImage(await this.baseMap, sourceXY.x, sourceXY.y, (offsetXY.x * 2), (offsetXY.y * 2), 0, mapYOff, 640, 312);
await Promise.all(data.map(async city => {
const period = city[this.period];
// draw the icon if possible
const icon = icons.getWeatherRegionalIconFromIconLink(period.icon, !period.daytime);
if (icon) {
this.gifs.push(await utils.image.superGifAsync({
src: icon,
max_width: 42,
loop_delay: 100,
auto_play: true,
canvas: this.canvas,
x: period.x,
y: period.y - 15+mapYOff,
}));
}
// City Name
draw.text(this.context, 'Star4000', '20px', '#ffffff', period.x - 40, period.y - 15+mapYOff, period.name, 2);
// Temperature
let temperature = period.temperature;
if (navigation.units() === UNITS.metric) temperature = Math.round(utils.units.fahrenheitToCelsius(temperature));
draw.text(this.context, 'Star4000 Large Compressed', '28px', '#ffff00', period.x - (temperature.toString().length * 15), period.y + 20+mapYOff, temperature, 2);
}));
this.finishDraw();
this.setStatus(STATUS.loaded);
}
}

View File

@@ -0,0 +1,319 @@
// provide regional forecast and regional observations on a map
// this is a two stage process because the data is shared between both
// and allows for three instances of RegionalForecast to use the same data
/* globals utils, _StationInfo, _RegionalCities */
// a shared global object is used to handle the data for all instances of regional weather
// eslint-disable-next-line no-unused-vars
const RegionalForecastData = (() => {
let dataPromise;
let lastWeatherParameters;
// update the data by providing weatherParamaters
const updateData = (weatherParameters) => {
// test for new data comparing weather paramaters
if (utils.object.shallowEqual(lastWeatherParameters, weatherParameters)) return dataPromise;
// update the promise by calling get data
lastWeatherParameters = weatherParameters;
dataPromise = getData(weatherParameters);
return dataPromise;
};
// return an array of cities each containing an array of 3 weather paramaters 0 = current observation, 1,2 = next forecast periods
const getData = async (weatherParameters) => {
// map offset
const offsetXY = {
x: 240,
y: 117,
};
// get user's location in x/y
const sourceXY = getXYFromLatitudeLongitude(weatherParameters.latitude, weatherParameters.longitude, offsetXY.x, offsetXY.y, weatherParameters.state);
// get latitude and longitude limits
const minMaxLatLon = getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, offsetXY.x, offsetXY.y, weatherParameters.state);
// get a target distance
let targetDistance = 2.5;
if (weatherParameters.State === 'HI') targetDistance = 1;
// make station info into an array
const stationInfoArray = Object.keys(_StationInfo).map(key => Object.assign({}, _StationInfo[key], {Name: _StationInfo[key].City, targetDistance}));
// combine regional cities with station info for additional stations
// stations are intentionally after cities to allow cities priority when drawing the map
const combinedCities = [..._RegionalCities, ...stationInfoArray];
// Determine which cities are within the max/min latitude/longitude.
const regionalCities = [];
combinedCities.forEach(city => {
if (city.Latitude > minMaxLatLon.minLat && city.Latitude < minMaxLatLon.maxLat &&
city.Longitude > minMaxLatLon.minLon && city.Longitude < minMaxLatLon.maxLon - 1) {
// default to 1 for cities loaded from _RegionalCities, use value calculate above for remaining stations
const targetDistance = city.targetDistance || 1;
// Only add the city as long as it isn't within set distance degree of any other city already in the array.
const okToAddCity = regionalCities.reduce((acc, testCity) => {
const distance = utils.calc.distance(city.Longitude, city.Latitude, testCity.Longitude, testCity.Latitude);
return acc && distance >= targetDistance;
}, true);
if (okToAddCity) regionalCities.push(city);
}
});
// get regional forecasts and observations (the two are intertwined due to the design of api.weather.gov)
const regionalForecastPromises = regionalCities.map(async city => {
try {
// get the point first, then break down into forecast and observations
const point = await utils.weather.getPoint(city.Latitude, city.Longitude);
// start off the observation task
const observationPromise = getRegionalObservation(point, city);
const forecast = await $.ajax({
url: point.properties.forecast,
dataType: 'json',
crossDomain: true,
});
// get XY on map for city
const cityXY = getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, weatherParameters.state);
// wait for the regional observation if it's not done yet
const observation = await observationPromise;
// format the observation the same as the forecast
const regionalObservation = {
daytime: !!observation.icon.match(/\/day\//),
temperature: utils.units.celsiusToFahrenheit(observation.temperature.value),
name: city.Name,
icon: observation.icon,
x: cityXY.x,
y: cityXY.y,
};
// return a pared-down forecast
// 0th object is the current conditions
// first object is the next period i.e. if it's daytime then it's the "tonight" forecast
// second object is the following period
// always skip the first forecast index because it's what's going on right now
return [
regionalObservation,
buildForecast(forecast.properties.periods[1], city, cityXY),
buildForecast(forecast.properties.periods[2], city, cityXY),
];
} catch (e) {
console.log(`No regional forecast data for '${city.Name}'`);
console.error(e);
return false;
}
});
// wait for the forecasts
const regionalDataAll = await Promise.all(regionalForecastPromises);
// filter out any false (unavailable data)
const regionalData = regionalDataAll.filter(data => data);
// return the weather data and offsets
return {
regionalData,
offsetXY,
sourceXY,
};
};
const buildForecast = (forecast, city, cityXY) => ({
daytime: forecast.isDaytime,
temperature: forecast.temperature||0,
name: city.Name,
icon: forecast.icon,
x: cityXY.x,
y: cityXY.y,
});
const getRegionalObservation = async (point, city) => {
try {
// get stations
const stations = await $.ajax({
type: 'GET',
url: point.properties.observationStations,
dataType: 'json',
crossDomain: true,
});
// get the first station
const station = stations.features[0].id;
// get the observation data
const observation = await $.ajax({
type: 'GET',
url: `${station}/observations/latest`,
dataType: 'json',
crossDomain: true,
});
// return the observation
return observation.properties;
} catch (e) {
console.log(`Unable to get regional observations for ${city.Name}`);
console.error(e);
return false;
}
};
// return the data promise so everyone gets the same thing at the same time
const getDataPromise = () => dataPromise;
// utility latitude/pixel conversions
const getXYFromLatitudeLongitude = (Latitude, Longitude, OffsetX, OffsetY, state) => {
if (state === 'AK') return getXYFromLatitudeLongitudeAK(...arguments);
if (state === 'HI') return getXYFromLatitudeLongitudeHI(...arguments);
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 = function (X, Y, OffsetX, OffsetY, state) {
if (state === 'AK') return getMinMaxLatitudeLongitudeAK(...arguments);
if (state === 'HI') return getMinMaxLatitudeLongitudeHI(...arguments);
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(...arguments);
if (state === 'HI') getXYForCityHI(...arguments);
let x = (City.Longitude - MinLongitude) * 57;
let y = (MaxLatitude - City.Latitude) * 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.Longitude - MinLongitude) * 37;
let y = (MaxLatitude - City.Latitude) * 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.Longitude - MinLongitude) * 57;
let y = (MaxLatitude - City.Latitude) * 70;
if (y < 30) y = 30;
if (y > 282) y = 282;
if (x < 40) x = 40;
if (x > 580) x = 580;
return { x, y };
};
return {
updateData,
getDataPromise,
};
})();

View File

@@ -0,0 +1,183 @@
// travel forecast display
/* globals WeatherDisplay, utils, STATUS, UNITS, draw, navigation, icons, luxon, _TravelCities */
// eslint-disable-next-line no-unused-vars
class TravelForecast extends WeatherDisplay {
constructor(navId, elemId, weatherParameters) {
// special height and width for scrolling
super(navId, elemId);
// pre-load background image (returns promise)
this.backgroundImage = utils.image.load('images/BackGround6_1.png');
// get the data
this.getData(weatherParameters);
// scrolling tracking
this.scrollCount = 0;
this.endDelay = 0;
}
async getData() {
const forecastPromises = _TravelCities.map(async city => {
try {
// get point then forecast
const point = await utils.weather.getPoint(city.Latitude, city.Longitude);
const forecast = await $.ajax({
url: point.properties.forecast,
dataType: 'json',
crossDomain: true,
});
// determine today or tomorrow (shift periods by 1 if tomorrow)
const todayShift = forecast.properties.periods[0].isDaytime? 0:1;
// return a pared-down forecast
return {
today: todayShift === 0,
high: forecast.properties.periods[todayShift].temperature,
low: forecast.properties.periods[todayShift+1].temperature,
name: city.Name,
icon: icons.getWeatherRegionalIconFromIconLink(forecast.properties.periods[todayShift].icon),
};
} catch (e) {
console.error(`GetTravelWeather for ${city.Name} failed`);
console.error(e);
return {name: city.Name};
}
});
// wait for all forecasts
const forecasts = await Promise.all(forecastPromises);
this.data = forecasts;
this.drawCanvas(true);
}
async drawCanvas(newData) {
// there are technically 2 canvases: the standard canvas and the extra-long canvas that contains the complete
// list of cities. The second canvas is copied into the standard canvas to create the scroll
super.drawCanvas();
// create the "long" canvas if necessary
if (!this.longCanvas) {
this.longCanvas = document.createElement('canvas');
this.longCanvas.width = 640;
this.longCanvas.height = 1728;
this.longContext = this.longCanvas.getContext('2d');
}
// set up variables
const cities = this.data;
// draw the long canvas only if there is new data
if (newData) {
this.longContext.clearRect(0,0,this.longCanvas.width,this.longCanvas.height);
// draw the "long" canvas with all cities
draw.box(this.longContext, 'rgb(35, 50, 112)', 0, 0, 640, 1728);
for (let i = 0; i <= 4; i++) {
const y = i * 346;
draw.horizontalGradient(this.longContext, 0, y, 640, y + 346, '#102080', '#001040');
}
await Promise.all(cities.map(async (city, index) => {
// calculate base y value
const y = 50+72*index;
// city name
draw.text(this.longContext, 'Star4000 Large Compressed', '24pt', '#FFFF00', 80, y, city.name, 2);
// check for forecast data
if (city.icon) {
// get temperatures and convert if necessary
let {low, high} = city;
if (navigation.units() === UNITS.metric) {
low = utils.units.fahrenheitToCelsius(low);
high = utils.units.fahrenheitToCelsius(high);
}
// convert to strings with no decimal
const lowString = Math.round(low).toString();
const highString = Math.round(high).toString();
const xLow = (500 - (lowString.length * 20));
draw.text(this.longContext, 'Star4000 Large', '24pt', '#FFFF00', xLow, y, lowString, 2);
const xHigh = (560 - (highString.length * 20));
draw.text(this.longContext, 'Star4000 Large', '24pt', '#FFFF00', xHigh, y, highString, 2);
this.gifs.push(await utils.image.superGifAsync({
src: city.icon,
loop_delay: 100,
auto_play: true,
canvas: this.longCanvas,
x: 330,
y: y - 35,
max_width: 47,
}));
} else {
draw.text(this.longContext, 'Star4000 Small', '24pt', '#FFFFFF', 400, y - 18, 'NO TRAVEL', 2);
draw.text(this.longContext, 'Star4000 Small', '24pt', '#FFFFFF', 400, y, 'DATA AVAILABLE', 2);
}
}));
}
// draw the standard context
this.context.drawImage(await this.backgroundImage, 0, 0);
draw.horizontalGradientSingle(this.context, 0, 30, 500, 90, draw.topColor1, draw.topColor2);
draw.triangle(this.context, 'rgb(28, 10, 87)', 500, 30, 450, 90, 500, 90);
draw.titleText(this.context, 'Travel Forecast', 'For ' + this.getTravelCitiesDayName(cities));
draw.text(this.context, 'Star4000 Small', '24pt', '#FFFF00', 455, 105, 'LOW', 2);
draw.text(this.context, 'Star4000 Small', '24pt', '#FFFF00', 510, 105, 'HIGH', 2);
// copy the scrolled portion of the canvas for the initial run before the scrolling starts
this.context.drawImage(this.longCanvas, 0, 0, 640, 289, 0, 110, 640, 289);
// set up scrolling one time
if (!this.scrollInterval) {
this.scrollInterval = window.setInterval(() => {
if (this.isActive()) {
// get a fresh canvas
const longCanvas = this.getLongCanvas();
// increment scrolling
this.scrollCount++;
// wait 3 seconds at begining
if (this.scrollCount < 150) return;
// calculate scroll offset and don't go past end of canvas
const offsetY = Math.min(longCanvas.height-289, (this.scrollCount-150));
// copy the scrolled portion of the canvas
this.context.drawImage(longCanvas, 0, offsetY, 640, 289, 0, 110, 640, 289);
// track end of scrolling for 3 seconds
if (offsetY >= longCanvas.height-289) this.endDelay++;
// TODO: report playback done
} else {
// reset scroll to top of image
this.scrollCount = 0;
this.endDelay = 0;
}
}, 20);
}
this.finishDraw();
this.setStatus(STATUS.loaded);
}
getTravelCitiesDayName(cities) {
const {DateTime} = luxon;
// 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;
}
}

View File

@@ -0,0 +1,410 @@
'use strict';
// radar utilities
/* globals _Units, Units, SuperGif */
// eslint-disable-next-line no-unused-vars
const utils = (() => {
// ****************************** weather data ********************************
const getPoint = async (lat, lon) => {
try {
return await $.ajax({
type: 'GET',
url: `https://api.weather.gov/points/${lat},${lon}`,
dataType: 'json',
crossDomain: true,
});
} catch (e) {
console.error('Unable to get point');
console.error(lat,lon);
console.error(e);
return false;
}
};
// ****************************** load images *********************************
// load an image from a blob or url
const loadImg = (imgData) => {
return new Promise(resolve => {
const img = new Image();
img.onload = (e) => {
resolve(e.target);
};
if (imgData instanceof Blob) {
img.src = window.URL.createObjectURL(imgData);
} else {
img.src = imgData;
}
});
};
// async version of SuperGif
const superGifAsync = (e) => {
return new Promise(resolve => {
const gif = new SuperGif(e);
gif.load(() => resolve(gif));
});
};
// *********************************** unit conversions ***********************
Math.round2 = (value, decimals) => Number(Math.round(value + 'e' + decimals) + 'e-' + decimals);
const mphToKph = (Mph) => Math.round(Mph * 1.60934);
const kphToMph = (Kph) => Math.round(Kph / 1.60934);
const celsiusToFahrenheit = (Celsius) => Math.round(Celsius * 9 / 5 + 32);
const fahrenheitToCelsius = (Fahrenheit) => Math.round2(((Fahrenheit) - 32) * 5 / 9, 1);
const milesToKilometers = (Miles) => Math.round(Miles * 1.60934);
const kilometersToMiles = (Kilometers) => Math.round(Kilometers / 1.60934);
const feetToMeters = (Feet) => Math.round(Feet * 0.3048);
const metersToFeet = (Meters) => Math.round(Meters / 0.3048);
const inchesToCentimeters = (Inches) => Math.round2(Inches * 2.54, 2);
const pascalToInHg = (Pascal) => Math.round2(Pascal*0.0002953,2);
// ***************************** calculations **********************************
const relativeHumidity = (Temperature, DewPoint) => {
const T = Temperature;
const TD = DewPoint;
return Math.round(100 * (Math.exp((17.625 * TD) / (243.04 + TD)) / Math.exp((17.625 * T) / (243.04 + T))));
};
const heatIndex = (Temperature, RelativeHumidity) => {
const T = Temperature;
const RH = RelativeHumidity;
let HI = 0.5 * (T + 61.0 + ((T - 68.0) * 1.2) + (RH * 0.094));
let ADJUSTMENT;
if (T >= 80) {
HI = -42.379 + 2.04901523 * T + 10.14333127 * RH - 0.22475541 * T * RH - 0.00683783 * T * T - 0.05481717 * RH * RH + 0.00122874 * T * T * RH + 0.00085282 * T * RH * RH - 0.00000199 * T * T * RH * RH;
if (RH < 13 && (T > 80 && T < 112)) {
ADJUSTMENT = ((13 - RH) / 4) * Math.sqrt((17 - Math.abs(T - 95)) / 17);
HI -= ADJUSTMENT;
} else if (RH > 85 && (T > 80 && T < 87)) {
ADJUSTMENT = ((RH - 85) / 10) * ((87 - T) / 5);
HI += ADJUSTMENT;
}
}
if (HI < Temperature) {
HI = Temperature;
}
return Math.round(HI);
};
const windChill = (Temperature, WindSpeed) => {
if (WindSpeed === '0' || WindSpeed === 'Calm' || WindSpeed === 'NA') {
return '';
}
const T = Temperature;
const V = WindSpeed;
return Math.round(35.74 + (0.6215 * T) - (35.75 * Math.pow(V, 0.16)) + (0.4275 * T * Math.pow(V, 0.16)));
};
// wind direction
const directionToNSEW = (Direction) => {
const val = Math.floor((Direction / 22.5) + 0.5);
const arr = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
return arr[(val % 16)];
};
const distance = (x1 ,y1, x2, y2) => Math.sqrt((x2-=x1)*x2 + (y2-=y1)*y2);
// wrap a number to 0-m
const wrap = (x,m) => (x%m + m)%m;
// ********************************* date functions ***************************
const getDateFromUTC = (date, utc) => {
const time = utc.split(':');
return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), time[0], time[1], 0));
};
const getTimeZoneOffsetFromUTC = (timezone) => {
switch (timezone) {
case 'EST':
return -5;
case 'EDT':
return -4;
case 'CST':
return -6;
case 'CDT':
return -5;
case 'MST':
return -7;
case 'MDT':
return -6;
case 'PST':
return -8;
case 'PDT':
return -7;
case 'AST':
case 'AKST':
return -9;
case 'ADT':
case 'AKDT':
return -8;
case 'HST':
return -10;
case 'HDT':
return -9;
default:
return null;
}
};
Date.prototype.getTimeZone = function () {
const tz = this.toLocaleTimeString('en-us', { timeZoneName: 'short' }).split(' ')[2];
if (tz === null){
switch (this.toTimeString().split(' ')[2]) {
case '(Eastern':
return 'EST';
case '(Central':
return 'CST';
case '(Mountain':
return 'MST';
case '(Pacific':
return 'PST';
case '(Alaskan':
return 'AST';
case '(Hawaiian':
return 'HST';
default:
}
} else if (tz.length === 4) {
// Fix weird bug in Edge where it returns the timezone with a null character in the first position.
return tz.substr(1);
}
return tz;
};
Date.prototype.addHours = function (hours) {
var dat = new Date(this.valueOf());
dat.setHours(dat.getHours() + hours);
return dat;
};
Date.prototype.getDayShortName = function () {
var days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return days[this.getDay()];
};
Date.prototype.getMonthShortName = function () {
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return months[this.getMonth()];
};
const dateToTimeZone = (date, timezone) => {
const OldOffset = getTimeZoneOffsetFromUTC(date.getTimeZone());
const NewOffset = getTimeZoneOffsetFromUTC(timezone);
let dt = new Date(date);
dt = dt.addHours(OldOffset * -1);
dt = dt.addHours(NewOffset);
return dt;
};
const getDateFromTime = (date, time, timezone) => {
const Time = time.split(':');
if (timezone) {
const Offset = getTimeZoneOffsetFromUTC(timezone) * -1;
const newDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), Time[0], Time[1], 0));
return newDate.addHours(Offset);
} else {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), Time[0], Time[1], 0);
}
};
Date.prototype.getFormattedTime = function () {
let hours;
let minutes;
let ampm;
switch (_Units) {
case Units.English:
hours = this.getHours() === 0 ? '12' : this.getHours() > 12 ? this.getHours() - 12 : this.getHours();
minutes = (this.getMinutes() < 10 ? '0' : '') + this.getMinutes();
ampm = this.getHours() < 12 ? 'am' : 'pm';
return hours + ':' + minutes + ' ' + ampm;
default:
hours = (this.getHours() < 10 ? ' ' : '') + this.getHours();
minutes = (this.getMinutes() < 10 ? '0' : '') + this.getMinutes();
return hours + ':' + minutes;
}
};
Date.prototype.toTimeAMPM = function () {
const date = this;
let hours = date.getHours();
let minutes = date.getMinutes();
let ampm = hours >= 12 ? 'pm' : 'am';
hours = hours % 12;
hours = hours ? hours : 12; // the hour '0' should be '12'
minutes = minutes < 10 ? '0' + minutes : minutes;
return hours + ':' + minutes + ' ' + ampm;
};
const xmlDateToJsDate = (XmlDate) => {
let bits = XmlDate.split(/[-T:+]/g);
if (bits[5] === undefined) {
console.log('bit[5] is undefined');
}
bits[5] = bits[5].replace('Z', '');
const d = new Date(bits[0], bits[1] - 1, bits[2]);
d.setHours(bits[3], bits[4], bits[5]);
// Case for when no time zone offset if specified
if (bits.length < 8) {
bits.push('00');
bits.push('00');
}
// Get supplied time zone offset in minutes
const sign = /\d\d-\d\d:\d\d$/.test(XmlDate) ? '-' : '+';
const offsetMinutes = (sign==='-'?-1:1)*(bits[6] * 60 + Number(bits[7]));
// Apply offset and local timezone
// d is now a local time equivalent to the supplied time
return d.setMinutes(d.getMinutes() - offsetMinutes - d.getTimezoneOffset());
};
const timeTo24Hour = (Time) => {
const AMPM = Time.substr(Time.length - 2);
const MM = Time.split(':')[1].substr(0, 2);
let HH = Time.split(':')[0];
switch (AMPM.toLowerCase()) {
case 'am':
if (HH === '12') HH = '0';
break;
case 'pm':
if (HH !== '12') HH = (parseInt(HH) + 12).toString();
break;
default:
}
return HH + ':' + MM;
};
// compare objects on shallow equality (nested objects ignored)
const shallowEqual= (obj1, obj2) => {
if (typeof obj1 !== 'object') return false;
if (typeof obj2 !== 'object') return false;
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (typeof obj1[key] !== 'object' && obj1[key] !== obj2[key]) return false;
}
return true;
};
// ********************************* strings *********************************************
if (!String.prototype.startsWith) {
String.prototype.startsWith = function (searchString, position) {
position = position || 0;
return this.substr(position, searchString.length) === searchString;
};
}
if (!String.prototype.endsWith) {
String.prototype.endsWith = function(searchString, position) {
var subjectString = this.toString();
if (typeof position !== 'number' || !isFinite(position) || Math.floor(position) !== position || position > subjectString.length) {
position = subjectString.length;
}
position -= searchString.length;
var lastIndex = subjectString.lastIndexOf(searchString, position);
return lastIndex !== -1 && lastIndex === position;
};
}
String.prototype.wordWrap = function () {
let str = this;
let m = ((arguments.length >= 1) ? arguments[0] : 75);
let b = ((arguments.length >= 2) ? arguments[1] : '\n');
let c = ((arguments.length >= 3) ? arguments[2] : false);
let i, j, l, s, r;
str += '';
if (m < 1) {
return str;
}
for (i = -1, l = (r = str.split(/\r\n|\n|\r/)).length; ++i < l; r[i] += s) {
// @todo: Split this up over many more lines and more semantic variable names
// so it becomes readable
for (s = r[i], r[i] = '';
s.length > m;
r[i] += s.slice(0, j) + ((s = s.slice(j)).length ? b : '')) {
j = c === 2 || (j = s.slice(0, m + 1).match(/\S*(\s)?$/))[1]
? m
: j.input.length - j[0].length || c === true && m ||
j.input.length + (j = s.slice(m).match(/^\S*/))[0].length;
}
}
return r.join('\n').replace(/\n /g, '\n');
};
// return an orderly object
return {
image: {
load: loadImg,
superGifAsync,
},
weather: {
getPoint,
},
units: {
mphToKph,
kphToMph,
celsiusToFahrenheit,
fahrenheitToCelsius,
milesToKilometers,
kilometersToMiles,
feetToMeters,
metersToFeet,
inchesToCentimeters,
pascalToInHg,
},
calc: {
relativeHumidity,
heatIndex,
windChill,
directionToNSEW,
distance,
wrap,
},
dateTime: {
getDateFromUTC,
getTimeZoneOffsetFromUTC,
dateToTimeZone,
getDateFromTime,
xmlDateToJsDate,
timeTo24Hour,
},
object: {
shallowEqual,
},
};
})();
// pass data through local server as CORS workaround
$.ajaxCORS = function (e) {
// modify the URL
e.url = e.url.replace('https://api.weather.gov/', '');
// call the ajax function
return $.ajax(e);
};

View File

@@ -0,0 +1,316 @@
// base weather display class
/* globals navigation, utils, draw, UNITS, luxon */
const STATUS = {
loading: Symbol('loading'),
loaded: Symbol('loaded'),
failed: Symbol('failed'),
noData: Symbol('noData'),
};
// eslint-disable-next-line no-unused-vars
class WeatherDisplay {
constructor(navId, elemId, canvasWidth, canvasHeight) {
// navId is used in messaging
this.navId = navId;
this.elemId = undefined;
this.gifs = [];
this.data = undefined;
this.loadingStatus = STATUS.loading;
// default navigation timing
this.timing = {
totalScreens: 1,
baseDelay: 5000, // 5 seconds
delay: 1, // 1*1second = 1 second total display time
};
this.navBaseCount = 0;
this.screenIndex = 0;
this.setStatus(STATUS.loading);
this.createCanvas(elemId, canvasWidth, canvasHeight);
}
// set data status and send update to navigation module
setStatus(value) {
this.status = value;
navigation.updateStatus({
id: this.navId,
status: this.status,
});
}
get status() {
return this.loadingStatus;
}
set status(state) {
this.loadingStatus = state;
}
createCanvas(elemId, width = 640, height = 480) {
// only create it once
if (this.elemId) return;
this.elemId = elemId;
const container = document.getElementById('container');
container.innerHTML += `<canvas id='${elemId+'Canvas'}' width='${width}' height='${height}'/ style='display: none;'>`;
}
// get necessary data for this display
getData() {
// clear current data
this.data = undefined;
// set status
this.setStatus(STATUS.loading);
}
drawCanvas() {
// stop all gifs
this.gifs.forEach(gif => gif.pause());
// delete the gifs
this.gifs.length = 0;
// refresh the canvas
this.canvas = document.getElementById(this.elemId+'Canvas');
this.context = this.canvas.getContext('2d');
// clear the canvas
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
finishDraw() {
let OkToDrawCurrentConditions = true;
let OkToDrawNoaaImage = true;
let OkToDrawCurrentDateTime = true;
let OkToDrawLogoImage = true;
// let OkToDrawCustomScrollText = false;
let bottom = undefined;
// visibility tests
// if (_ScrollText !== '') OkToDrawCustomScrollText = true;
if (this.elemId === 'almanac') OkToDrawNoaaImage = false;
if (this.elemId === 'almanacTides') OkToDrawNoaaImage = false;
if (this.elemId === 'outlook') OkToDrawNoaaImage = false;
if (this.elemId === 'marineForecast')OkToDrawNoaaImage = false;
if (this.elemId === 'airQuailty') OkToDrawNoaaImage = false;
if (this.elemId === 'travelForecast') OkToDrawNoaaImage = false;
if (this.elemId === 'regionalForecast1')OkToDrawNoaaImage = false;
if (this.elemId === 'regionalForecast2') OkToDrawNoaaImage = false;
if (this.elemId === 'regionalObservations') OkToDrawNoaaImage = false;
if (this.elemId === 'localRadar') {
OkToDrawCurrentConditions = false;
OkToDrawCurrentDateTime = false;
OkToDrawNoaaImage = false;
// OkToDrawCustomScrollText = false;
}
if (this.elemId === 'hazards') {
OkToDrawNoaaImage = false;
bottom = true;
OkToDrawLogoImage = false;
}
// draw functions
if (OkToDrawCurrentDateTime) {
this.drawCurrentDateTime(bottom);
// auto clock refresh
if (!this.dateTimeInterval) {
setInterval(() => this.drawCurrentDateTime(bottom), 100);
}
}
if (OkToDrawLogoImage) this.drawLogoImage();
if (OkToDrawNoaaImage) this.drawNoaaImage();
// TODO: fix current conditions scroll
// if (OkToDrawCurrentConditions) DrawCurrentConditions(WeatherParameters, this.context);
// TODO: add custom scroll text
// if (OkToDrawCustomScrollText) DrawCustomScrollText(WeatherParameters, context);
}
// TODO: update clock automatically
drawCurrentDateTime(bottom) {
// only draw if canvas is active to conserve battery
if (!this.isActive()) return;
const {DateTime} = luxon;
const font = 'Star4000 Small';
const size = '24pt';
const color = '#ffffff';
const shadow = 2;
// on the first pass store the background for the date and time
if (!this.dateTimeBackground) {
this.dateTimeBackground = this.context.getImageData(410, 30, 175, 60);
}
// Clear the date and time area.
if (bottom) {
draw.box(this.context, 'rgb(25, 50, 112)', 0, 389, 640, 16);
} else {
this.context.putImageData(this.dateTimeBackground, 410, 30);
}
// Get the current date and time.
const now = DateTime.local();
//time = "11:35:08 PM";
const time = now.toLocaleString(DateTime.TIME_WITH_SECONDS).padStart(11,' ');
let x,y;
if (bottom) {
x = 400;
y = 402;
} else {
x = 410;
y = 65;
}
if (navigation.units() === UNITS.metric) {
x += 45;
}
draw.text(this.context, font, size, color, x, y, time.toUpperCase(), shadow); //y += 20;
const date = now.toFormat(' ccc LLL ') + now.day.toString().padStart(2,' ');
if (bottom) {
x = 55;
y = 402;
} else {
x = 410;
y = 85;
}
draw.text(this.context, font, size, color, x, y, date.toUpperCase(), shadow);
}
async drawNoaaImage () {
// load the image and store locally
if (!this.drawNoaaImage.image) {
this.drawNoaaImage.image = utils.image.load('images/noaa5.gif');
}
// wait for the image to load completely
const img = await this.drawNoaaImage.image;
this.context.drawImage(img, 356, 39);
}
async drawLogoImage () {
// load the image and store locally
if (!this.drawLogoImage.image) {
this.drawLogoImage.image = utils.image.load('images/Logo3.png');
}
// wait for the image load completely
const img = await this.drawLogoImage.image;
this.context.drawImage(img, 50, 30, 85, 67);
}
// show/hide the canvas and start/stop the navigation timer
showCanvas(navCmd) {
// if a nav command is present call it to set the screen index
if (navCmd === navigation.msg.command.firstFrame) this.navNext(navCmd);
if (navCmd === navigation.msg.command.lastFrame) this.navPrev(navCmd);
// see if the canvas is already showing
if (this.canvas.style.display === 'block') return false;
// show the canvas
this.canvas.style.display = 'block';
// reset timing
this.startNavCount(navigation.isPlaying());
// refresh the canvas (incase the screen index chagned)
if (navCmd) this.drawCanvas();
}
hideCanvas() {
this.stopNavBaseCount(true);
if (!this.canvas) return;
this.canvas.style.display = 'none';
}
isActive() {
return document.getElementById(this.elemId+'Canvas').offsetParent !== null;
}
// navigation timings
// totalScreens = total number of screens that are available
// baseDelay = ms to delay before re-evaluating screenIndex
// delay: three options
// integer = each screen will display for this number of baseDelays
// [integer, integer, ...] = screenIndex 0 displays for integer[0]*baseDelay, etc.
// [{time, si}, ...] = time as above, si is specific screen index to display during this interval
// if the array forms are used totalScreens is overwritten by the size of the array
navBaseTime() {
// see if play is active and screen is active
if (!navigation.isPlaying() || !this.isActive()) return;
// increment the base count
this.navBaseCount++;
// update total screens
if (Array.isArray(this.timing.delay)) this.timing.totalScreens = this.timing.delay.length;
// determine type of timing
// simple delay
if (typeof this.timing.delay === 'number') {
this.navNext();
return;
}
}
// navigate to next screen
navNext(command) {
// check for special 'first frame' command
if (command === navigation.msg.command.firstFrame) {
this.resetNavBaseCount();
this.drawCanvas();
return;
}
// increment screen index
this.screenIndex++;
// test for end reached
if (this.screenIndex >= this.timing.totalScreens) {
this.sendNavDisplayMessage(navigation.msg.response.next);
this.stopNavBaseCount();
return;
}
// if the end was not reached, update the canvas
this.drawCanvas();
}
// navigate to previous screen
navPrev(command) {
// check for special 'last frame' command
if (command === navigation.msg.command.lastFrame) {
this.screenIndex = this.timing.totalScreens-1;
this.drawCanvas();
return;
}
// decrement screen index
this.screenIndex--;
// test for end reached
if (this.screenIndex < 0) {
this.sendNavDisplayMessage(navigation.msg.response.previous);
return;
}
// if the end was not reached, update the canvas
this.drawCanvas();
}
// start and stop base counter
startNavCount(reset) {
if (reset) this.resetNavBaseCount();
if (!this.navInterval) this.navInterval = setInterval(()=>this.navBaseTime(), this.timing.baseDelay);
}
stopNavBaseCount(reset) {
clearInterval(this.navInterval);
this.navInterval = undefined;
if (reset) this.resetNavBaseCount();
}
resetNavBaseCount() {
this.navBaseCount = 0;
this.screenIndex = 0;
}
sendNavDisplayMessage(message) {
navigation.displayNavMessage({
id: this.navId,
type: message,
});
}
}