mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-17 09:09:30 -07:00
reorganize for build system
This commit is contained in:
163
server/scripts/modules/almanac.js
Normal file
163
server/scripts/modules/almanac.js
Normal 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);
|
||||
}
|
||||
}
|
||||
202
server/scripts/modules/currentweather.js
Normal file
202
server/scripts/modules/currentweather.js
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
101
server/scripts/modules/draw.js
Normal file
101
server/scripts/modules/draw.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
177
server/scripts/modules/extendedforecast.js
Normal file
177
server/scripts/modules/extendedforecast.js
Normal 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);
|
||||
}
|
||||
}
|
||||
237
server/scripts/modules/icons.js
Normal file
237
server/scripts/modules/icons.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
129
server/scripts/modules/latestobservations.js
Normal file
129
server/scripts/modules/latestobservations.js
Normal 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;
|
||||
}
|
||||
}
|
||||
145
server/scripts/modules/localforecast.js
Normal file
145
server/scripts/modules/localforecast.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
245
server/scripts/modules/navigation.js
Normal file
245
server/scripts/modules/navigation.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
104
server/scripts/modules/regionalforecast.js
Normal file
104
server/scripts/modules/regionalforecast.js
Normal 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);
|
||||
|
||||
}
|
||||
}
|
||||
319
server/scripts/modules/regionalforecastdata.js
Normal file
319
server/scripts/modules/regionalforecastdata.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
183
server/scripts/modules/travelforecast.js
Normal file
183
server/scripts/modules/travelforecast.js
Normal 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;
|
||||
}
|
||||
}
|
||||
410
server/scripts/modules/utilities.js
Normal file
410
server/scripts/modules/utilities.js
Normal 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);
|
||||
};
|
||||
316
server/scripts/modules/weatherdisplay.js
Normal file
316
server/scripts/modules/weatherdisplay.js
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user