Compare commits

...

20 Commits

Author SHA1 Message Date
Matt Walsh
2368a30980 5.4.3 2022-12-12 10:52:31 -06:00
Matt Walsh
8efff1a057 fix alamanc graphics 2022-12-12 10:48:54 -06:00
Matt Walsh
ae0d0ef9ec distribution 2022-12-09 14:15:16 -06:00
Matt Walsh
f633631532 class static code cleanup 2022-12-09 13:51:51 -06:00
Matt Walsh
3f5cd4ca70 class static code cleanup 2022-12-09 13:50:17 -06:00
Matt Walsh
0c8db4f38e full screen scaling 2022-12-09 13:26:13 -06:00
Matt Walsh
dc4db67b96 capture dist 2022-12-09 13:12:53 -06:00
Matt Walsh
52487319fa 5.4.1 2022-12-09 13:12:10 -06:00
Matt Walsh
6a1e2da11e mobile scaling and rotation 2022-12-09 13:11:53 -06:00
Matt Walsh
1cf9f41ca0 gulp upload_images 2022-12-09 11:58:47 -06:00
Matt Walsh
f7505e3e6f 5.4.0 2022-12-08 16:25:24 -06:00
Matt Walsh
705fa9f582 dark mode, page only 2022-12-08 16:25:12 -06:00
Matt Walsh
5edf5cc947 cleanup 2022-12-08 15:05:51 -06:00
Matt Walsh
d0382e0de1 better error handlig of shared data 2022-12-08 14:41:15 -06:00
Matt Walsh
69d14236f1 hourly graph uses image with internal canvas for drawing 2022-12-08 09:23:26 -06:00
Matt Walsh
64fb06d7b4 capture distribution files 2022-12-07 15:39:34 -06:00
Matt Walsh
3ea6c0fd55 5.3.0 2022-12-07 15:39:05 -06:00
Matt Walsh
e13582b760 readme cleanup 2022-12-07 15:38:34 -06:00
Matt Walsh
1a7734b620 add hourly graph 2022-12-07 15:36:02 -06:00
Matt Walsh
0331de8b8a distribution 2022-12-07 11:06:51 -06:00
36 changed files with 1118 additions and 712 deletions

View File

@@ -31,7 +31,7 @@ Open your web browser: http://localhost:8080/
The change to 5.0 changes from drawing the weather graphics on canvas elements and instead uses HTML and CSS to style all of the weather graphics. A lot of other changes and fixes were implemented at the same time.
* Replace all canvas elements with HTML and CSS
* City and airport names are better parsed to better show location in the available space
* City and airport names are better parsed to fit the available space
* Remove the dependency on libgif-js
* Use browser for text wrapping where necessary
* Some new weather icons
@@ -61,14 +61,15 @@ The fork is a result of wanting a more manageable, modern code base to work with
I've made several changes to this Weather Star 4000 simulation compared to the original hardware unit and the code that this was forked from.
* Radar displays the timestamp of the image.
* A new hour-by-hour graph of the temperature, cloud cover and precipitation chances for the next 24 hours.
* A new hourly forecast display for the next 24 hours is available, and is shown in the style of the travel cities forecast. (off by default because it duplicates the hourly graph)
* The "Local Forecast" and "Extended Forecast" provide several additional days of information compared to the original format in the 90's.
* Narration was removed. In the original code narration made use of the computer's local text-to-speech engine which didn't sound great.
* Music was removed. I don't want to deal with copyright issues and hosting MP3s. If you're looking for the music that played during forecasts please visit [TWCClassics](https://twcclassics.com/audio/).
* Marine forecast (tides) is not available as it is not part of the new API.
* The nearby cities displayed on screens such as "Latest Observations" and "Regional Forecast" are likely not the same as they were in the 90's. The weather monitoring equipment at these stations move over time for one reason or another, and coming up with a simple formulaic way of finding nearby stations is sufficient to give the same look-and-feel as the original.
* The "Local Forecast" and "Extended Forecast" provide several additional days of information compared to the original format in the 90's.
* "Flavors" are not present in this simulation. Flavors refer to the order of the weather information that was shown on the original units. Instead, the order of the displays has been fixed and a checkboxes can be used to turn on and off individual displays. The travel forecast has been defaulted to off so only local information shows for new users.
* Radar displays the timestamp of the image.
* A new hourly forecast display for the next 24 hours is available, and is shown in the style of the travel cities forecast.
## Wish list

2
dist/index.html vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -77,6 +77,7 @@ const mjsSources = [
'server/scripts/modules/icons.mjs',
'server/scripts/modules/extendedforecast.mjs',
'server/scripts/modules/hourly.mjs',
'server/scripts/modules/hourly-graph.mjs',
'server/scripts/modules/latestobservations.mjs',
'server/scripts/modules/localforecast.mjs',
'server/scripts/modules/radar.mjs',
@@ -141,6 +142,18 @@ gulp.task('upload', () => gulp.src(uploadSources, { base: './dist' })
},
})));
const imageSources = [
'server/fonts/**',
'server/images/**',
];
gulp.task('upload_images', () => gulp.src(imageSources, { base: './server' })
.pipe(
s3({
Bucket: 'weatherstar',
StorageClass: 'STANDARD',
}),
));
gulp.task('invalidate', async () => cloudfront.createInvalidation({
DistributionId: 'E9171A4KV8KCW',
InvalidationBatch: {
@@ -152,4 +165,4 @@ gulp.task('invalidate', async () => cloudfront.createInvalidation({
},
}).promise());
module.exports = gulp.series(clean, gulp.parallel('build_js', 'compress_js_data', 'compress_js_vendor', 'copy_css', 'compress_html', 'copy_other_files'), 'upload', 'invalidate');
module.exports = gulp.series(clean, gulp.parallel('build_js', 'compress_js_data', 'compress_js_vendor', 'copy_css', 'compress_html', 'copy_other_files'), gulp.parallel('upload', 'upload_images'), 'invalidate');

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ws4kp",
"version": "5.2.0",
"version": "5.4.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "ws4kp",
"version": "5.2.0",
"version": "5.4.3",
"license": "MIT",
"dependencies": {
"eslint": "^8.21.0",

View File

@@ -1,6 +1,6 @@
{
"name": "ws4kp",
"version": "5.2.0",
"version": "5.4.3",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.js",
"scripts": {
@@ -40,10 +40,9 @@
"suncalc": "^1.8.0",
"swiped-events": "^1.1.4",
"terser-webpack-plugin": "^5.3.6",
"webpack-stream": "^7.0.0"
},
"dependencies": {
"webpack-stream": "^7.0.0",
"eslint": "^8.21.0",
"eslint-plugin-import": "^2.26.0"
}
},
"dependencies": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -23,7 +23,7 @@ const categories = [
'Airport', 'Ferry', 'Marina', 'Pier', 'Port', 'Resort', // POI/Travel
'Postal', 'Populated Place',
];
const cats = categories.join(',');
const category = categories.join(',');
const init = () => {
document.getElementById('txtAddress').addEventListener('focus', (e) => {
@@ -54,7 +54,7 @@ const init = () => {
params: {
f: 'json',
countryCode: 'USA', // 'USA,PRI,VIR,GUM,ASM',
category: cats,
category,
maxSuggestions: 10,
},
dataType: 'json',
@@ -192,8 +192,10 @@ const EnterFullScreen = () => {
resize();
UpdateFullScreenNavigate();
// change hover text
document.getElementById('ToggleFullScreen').title = 'Exit fullscreen';
// change hover text and image
const img = document.getElementById('ToggleFullScreen');
img.src = 'images/nav/ic_fullscreen_exit_white_24dp_1x.png';
img.title = 'Exit fullscreen';
};
const ExitFullscreen = () => {
@@ -214,8 +216,10 @@ const ExitFullscreen = () => {
document.msExitFullscreen();
}
resize();
// change hover text
document.getElementById('ToggleFullScreen').title = 'Enter fullscreen';
// change hover text and image
const img = document.getElementById('ToggleFullScreen');
img.src = 'images/nav/ic_fullscreen_white_24dp_1x.png';
img.title = 'Enter fullscreen';
};
const btnNavigateMenuClick = () => {

View File

@@ -133,7 +133,7 @@ class Almanac extends WeatherDisplay {
fill.date = date;
fill.type = MoonPhase.phase;
fill.icon = { type: 'img', src: Almanac.imageName(MoonPhase.Phase) };
fill.icon = { type: 'img', src: imageName(MoonPhase.phase) };
return this.fillTemplate('day', fill);
});
@@ -145,20 +145,6 @@ class Almanac extends WeatherDisplay {
this.finishDraw();
}
static imageName(type) {
switch (type) {
case 'Full':
return 'images/2/Full-Moon.gif';
case 'Last':
return 'images/2/Last-Quarter.gif';
case 'New':
return 'images/2/New-Moon.gif';
case 'First':
default:
return 'images/2/First-Quarter.gif';
}
}
// make sun and moon data available outside this class
// promise allows for data to be requested before it is available
async getSun() {
@@ -170,8 +156,22 @@ class Almanac extends WeatherDisplay {
}
}
const imageName = (type) => {
switch (type) {
case 'Full':
return 'images/2/Full-Moon.gif';
case 'Last':
return 'images/2/Last-Quarter.gif';
case 'New':
return 'images/2/New-Moon.gif';
case 'First':
default:
return 'images/2/First-Quarter.gif';
}
};
// register display
const display = new Almanac(7, 'almanac');
const display = new Almanac(8, 'almanac');
registerDisplay(display);
export default display.getSun.bind(display);

View File

@@ -55,7 +55,9 @@ class CurrentWeather extends WeatherDisplay {
// test for data received
if (!observations) {
console.error('All current weather stations exhausted');
this.setStatus(STATUS.failed);
if (this.enabled) this.setStatus(STATUS.failed);
// send failed to subscribers
this.getDataCallback(undefined);
return;
}
// preload the icon
@@ -126,7 +128,7 @@ class CurrentWeather extends WeatherDisplay {
let Conditions = data.observations.textDescription;
if (Conditions.length > 15) {
Conditions = this.shortConditions(Conditions);
Conditions = shortConditions(Conditions);
}
fill.condition = Conditions;
@@ -168,26 +170,27 @@ class CurrentWeather extends WeatherDisplay {
this.getDataCallbacks.push(() => resolve(this.parseData()));
});
}
static shortConditions(_condition) {
let condition = _condition;
condition = condition.replace(/Light/g, 'L');
condition = condition.replace(/Heavy/g, 'H');
condition = condition.replace(/Partly/g, 'P');
condition = condition.replace(/Mostly/g, 'M');
condition = condition.replace(/Few/g, 'F');
condition = condition.replace(/Thunderstorm/g, 'T\'storm');
condition = condition.replace(/ in /g, '');
condition = condition.replace(/Vicinity/g, '');
condition = condition.replace(/ and /g, ' ');
condition = condition.replace(/Freezing Rain/g, 'Frz Rn');
condition = condition.replace(/Freezing/g, 'Frz');
condition = condition.replace(/Unknown Precip/g, '');
condition = condition.replace(/L Snow Fog/g, 'L Snw/Fog');
condition = condition.replace(/ with /g, '/');
return condition;
}
}
const shortConditions = (_condition) => {
let condition = _condition;
condition = condition.replace(/Light/g, 'L');
condition = condition.replace(/Heavy/g, 'H');
condition = condition.replace(/Partly/g, 'P');
condition = condition.replace(/Mostly/g, 'M');
condition = condition.replace(/Few/g, 'F');
condition = condition.replace(/Thunderstorm/g, 'T\'storm');
condition = condition.replace(/ in /g, '');
condition = condition.replace(/Vicinity/g, '');
condition = condition.replace(/ and /g, ' ');
condition = condition.replace(/Freezing Rain/g, 'Frz Rn');
condition = condition.replace(/Freezing/g, 'Frz');
condition = condition.replace(/Unknown Precip/g, '');
condition = condition.replace(/L Snow Fog/g, 'L Snw/Fog');
condition = condition.replace(/ with /g, '/');
return condition;
};
const display = new CurrentWeather(0, 'current-weather');
registerDisplay(display);

View File

@@ -36,95 +36,11 @@ class ExtendedForecast extends WeatherDisplay {
return;
}
// we only get here if there was no error above
this.data = ExtendedForecast.parse(forecast.properties.periods);
this.data = parse(forecast.properties.periods);
this.screenIndex = 0;
this.setStatus(STATUS.loaded);
}
// the api provides the forecast in 12 hour increments, flatten to day increments with high and low temperatures
static parse(fullForecast) {
// create a list of days starting with today
const Days = [0, 1, 2, 3, 4, 5, 6];
const dates = Days.map((shift) => {
const date = DateTime.local().startOf('day').plus({ days: shift });
return date.toLocaleString({ weekday: 'short' });
});
// track the destination forecast index
let destIndex = 0;
const forecast = [];
fullForecast.forEach((period) => {
// create the destination object if necessary
if (!forecast[destIndex]) {
forecast.push({
dayName: '', low: undefined, high: undefined, text: undefined, icon: undefined,
});
}
// get the object to modify/populate
const fDay = forecast[destIndex];
// high temperature will always be last in the source array so it will overwrite the low values assigned below
fDay.icon = getWeatherIconFromIconLink(period.icon);
fDay.text = ExtendedForecast.shortenExtendedForecastText(period.shortForecast);
fDay.dayName = dates[destIndex];
// preload the icon
preloadImg(fDay.icon);
if (period.isDaytime) {
// day time is the high temperature
fDay.high = period.temperature;
destIndex += 1;
} else {
// low temperature
fDay.low = period.temperature;
}
});
return forecast;
}
static shortenExtendedForecastText(long) {
const regexList = [
[/ and /ig, ' '],
[/Slight /ig, ''],
[/Chance /ig, ''],
[/Very /ig, ''],
[/Patchy /ig, ''],
[/Areas /ig, ''],
[/Dense /ig, ''],
[/Thunderstorm/g, 'T\'Storm'],
];
// run all regexes
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);
let conditions = short.split(' ');
if (short.indexOf('then') !== -1) {
conditions = short.split(' then ');
conditions = conditions[1].split(' ');
}
let short1 = conditions[0].substr(0, 10);
let short2 = '';
if (conditions[1]) {
if (!short1.endsWith('.')) {
short2 = conditions[1].substr(0, 10);
} else {
short1 = short1.replace(/\./, '');
}
if (short2 === 'Blowing') {
short2 = '';
}
}
let result = short1;
if (short2 !== '') {
result += ` ${short2}`;
}
return result;
}
async drawCanvas() {
super.drawCanvas();
@@ -160,5 +76,89 @@ class ExtendedForecast extends WeatherDisplay {
}
}
// the api provides the forecast in 12 hour increments, flatten to day increments with high and low temperatures
const parse = (fullForecast) => {
// create a list of days starting with today
const Days = [0, 1, 2, 3, 4, 5, 6];
const dates = Days.map((shift) => {
const date = DateTime.local().startOf('day').plus({ days: shift });
return date.toLocaleString({ weekday: 'short' });
});
// track the destination forecast index
let destIndex = 0;
const forecast = [];
fullForecast.forEach((period) => {
// create the destination object if necessary
if (!forecast[destIndex]) {
forecast.push({
dayName: '', low: undefined, high: undefined, text: undefined, icon: undefined,
});
}
// get the object to modify/populate
const fDay = forecast[destIndex];
// high temperature will always be last in the source array so it will overwrite the low values assigned below
fDay.icon = getWeatherIconFromIconLink(period.icon);
fDay.text = shortenExtendedForecastText(period.shortForecast);
fDay.dayName = dates[destIndex];
// preload the icon
preloadImg(fDay.icon);
if (period.isDaytime) {
// day time is the high temperature
fDay.high = period.temperature;
destIndex += 1;
} else {
// low temperature
fDay.low = period.temperature;
}
});
return forecast;
};
const shortenExtendedForecastText = (long) => {
const regexList = [
[/ and /ig, ' '],
[/Slight /ig, ''],
[/Chance /ig, ''],
[/Very /ig, ''],
[/Patchy /ig, ''],
[/Areas /ig, ''],
[/Dense /ig, ''],
[/Thunderstorm/g, 'T\'Storm'],
];
// run all regexes
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);
let conditions = short.split(' ');
if (short.indexOf('then') !== -1) {
conditions = short.split(' then ');
conditions = conditions[1].split(' ');
}
let short1 = conditions[0].substr(0, 10);
let short2 = '';
if (conditions[1]) {
if (!short1.endsWith('.')) {
short2 = conditions[1].substr(0, 10);
} else {
short1 = short1.replace(/\./, '');
}
if (short2 === 'Blowing') {
short2 = '';
}
}
let result = short1;
if (short2 !== '') {
result += ` ${short2}`;
}
return result;
};
// register display
registerDisplay(new ExtendedForecast(6, 'extended-forecast'));
registerDisplay(new ExtendedForecast(7, 'extended-forecast'));

View File

@@ -0,0 +1,149 @@
// hourly forecast list
import STATUS from './status.mjs';
import getHourlyData from './hourly.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
class HourlyGraph extends WeatherDisplay {
constructor(navId, elemId, defaultActive) {
// special height and width for scrolling
super(navId, elemId, 'Hourly Graph', defaultActive);
// move the top right data into the correct location on load
document.addEventListener('DOMContentLoaded', () => {
this.moveHeader();
});
}
moveHeader() {
// get the header
const header = this.fillTemplate('top-right', {});
// place the header
this.elem.querySelector('.header .right').append(header);
}
async getData() {
if (!super.getData()) return;
const data = await getHourlyData();
if (data === undefined) {
this.setStatus(STATUS.failed);
return;
}
// get interesting data
const temperature = data.map((d) => d.temperature);
const probabilityOfPrecipitation = data.map((d) => d.probabilityOfPrecipitation);
const skyCover = data.map((d) => d.skyCover);
this.data = {
skyCover, temperature, probabilityOfPrecipitation,
};
this.setStatus(STATUS.loaded);
}
drawCanvas() {
if (!this.image) this.image = this.elem.querySelector('.chart img');
// get available space
const availableWidth = 532;
const availableHeight = 285;
this.image.width = availableWidth;
this.image.height = availableHeight;
// get context
const canvas = document.createElement('canvas');
canvas.width = availableWidth;
canvas.height = availableHeight;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
// calculate time scale
const timeScale = calcScale(0, 5, this.data.temperature.length - 1, availableWidth);
const startTime = DateTime.now().startOf('hour');
document.querySelector('.x-axis .l-1').innerHTML = formatTime(startTime);
document.querySelector('.x-axis .l-2').innerHTML = formatTime(startTime.plus({ hour: 6 }));
document.querySelector('.x-axis .l-3').innerHTML = formatTime(startTime.plus({ hour: 12 }));
document.querySelector('.x-axis .l-4').innerHTML = formatTime(startTime.plus({ hour: 18 }));
document.querySelector('.x-axis .l-5').innerHTML = formatTime(startTime.plus({ hour: 24 }));
// order is important last line drawn is on top
// clouds
const percentScale = calcScale(0, availableHeight - 10, 100, 10);
const cloud = createPath(this.data.skyCover, timeScale, percentScale);
drawPath(cloud, ctx, {
strokeStyle: 'lightgrey',
lineWidth: 3,
});
// precip
const precip = createPath(this.data.probabilityOfPrecipitation, timeScale, percentScale);
drawPath(precip, ctx, {
strokeStyle: 'aqua',
lineWidth: 3,
});
// temperature
const minTemp = Math.min(...this.data.temperature);
const maxTemp = Math.max(...this.data.temperature);
const midTemp = Math.round((minTemp + maxTemp) / 2);
const tempScale = calcScale(minTemp, availableHeight - 10, maxTemp, 10);
const tempPath = createPath(this.data.temperature, timeScale, tempScale);
drawPath(tempPath, ctx, {
strokeStyle: 'red',
lineWidth: 3,
});
// temperature axis labels
// limited to 3 characters, sacraficing degree character
const degree = String.fromCharCode(176);
this.elem.querySelector('.y-axis .l-1').innerHTML = (maxTemp + degree).substring(0, 3);
this.elem.querySelector('.y-axis .l-2').innerHTML = (midTemp + degree).substring(0, 3);
this.elem.querySelector('.y-axis .l-3').innerHTML = (minTemp + degree).substring(0, 3);
// set the image source
this.image.src = canvas.toDataURL();
super.drawCanvas();
this.finishDraw();
}
}
// create a scaling function from two points
const calcScale = (x1, y1, x2, y2) => {
const m = (y2 - y1) / (x2 - x1);
const b = y1 - m * x1;
return (x) => m * x + b;
};
// create a path as an array of [x,y]
const createPath = (data, xScale, yScale) => data.map((d, i) => [xScale(i), yScale(d)]);
// draw a path with shadow
const drawPath = (path, ctx, options) => {
// first shadow
ctx.beginPath();
ctx.strokeStyle = 'black';
ctx.lineWidth = (options?.lineWidth ?? 2) + 2;
ctx.moveTo(path[0][0], path[0][1]);
path.slice(1).forEach((point) => ctx.lineTo(point[0], point[1] + 2));
ctx.stroke();
// then colored line
ctx.beginPath();
ctx.strokeStyle = options?.strokeStyle ?? 'red';
ctx.lineWidth = (options?.lineWidth ?? 2);
ctx.moveTo(path[0][0], path[0][1]);
path.slice(1).forEach((point) => ctx.lineTo(point[0], point[1]));
ctx.stroke();
};
// format as 1p, 12a, etc.
const formatTime = (time) => time.toFormat('ha').slice(0, -1);
// register display
registerDisplay(new HourlyGraph(3, 'hourly-graph'));

View File

@@ -29,7 +29,7 @@ class Hourly extends WeatherDisplay {
async getData(weatherParameters) {
// super checks for enabled
if (!super.getData(weatherParameters)) return;
const superResponse = super.getData(weatherParameters);
let forecast;
try {
// get the forecast
@@ -37,74 +37,20 @@ class Hourly extends WeatherDisplay {
} catch (e) {
console.error('Get hourly forecast failed');
console.error(e.status, e.responseJSON);
this.setStatus(STATUS.failed);
if (this.enabled) this.setStatus(STATUS.failed);
// return undefined to other subscribers
this.getDataCallback(undefined);
return;
}
this.data = await Hourly.parseForecast(forecast.properties);
this.data = await parseForecast(forecast.properties);
this.getDataCallback();
if (!superResponse) return;
this.setStatus(STATUS.loaded);
this.drawLongCanvas();
}
// extract specific values from forecast and format as an array
static async parseForecast(data) {
const temperature = Hourly.expand(data.temperature.values);
const apparentTemperature = Hourly.expand(data.apparentTemperature.values);
const windSpeed = Hourly.expand(data.windSpeed.values);
const windDirection = Hourly.expand(data.windDirection.values);
const skyCover = Hourly.expand(data.skyCover.values); // cloud icon
const weather = Hourly.expand(data.weather.values); // fog icon
const iceAccumulation = Hourly.expand(data.iceAccumulation.values); // ice icon
const probabilityOfPrecipitation = Hourly.expand(data.probabilityOfPrecipitation.values); // rain icon
const snowfallAmount = Hourly.expand(data.snowfallAmount.values); // snow icon
const icons = await Hourly.determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed);
return temperature.map((val, idx) => ({
temperature: celsiusToFahrenheit(temperature[idx]),
apparentTemperature: celsiusToFahrenheit(apparentTemperature[idx]),
windSpeed: kilometersToMiles(windSpeed[idx]),
windDirection: directionToNSEW(windDirection[idx]),
icon: icons[idx],
}));
}
// given forecast paramaters determine a suitable icon
static async determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed) {
const startOfHour = DateTime.local().startOf('hour');
const sunTimes = (await getSun()).sun;
const overnight = Interval.fromDateTimes(DateTime.fromJSDate(sunTimes[0].sunset), DateTime.fromJSDate(sunTimes[1].sunrise));
const tomorrowOvernight = DateTime.fromJSDate(sunTimes[1].sunset);
return skyCover.map((val, idx) => {
const hour = startOfHour.plus({ hours: idx });
const isNight = overnight.contains(hour) || (hour > tomorrowOvernight);
return getHourlyIcon(skyCover[idx], weather[idx], iceAccumulation[idx], probabilityOfPrecipitation[idx], snowfallAmount[idx], windSpeed[idx], isNight);
});
}
// expand a set of values with durations to an hour-by-hour array
static expand(data) {
const startOfHour = DateTime.utc().startOf('hour').toMillis();
const result = []; // resulting expanded values
data.forEach((item) => {
let startTime = Date.parse(item.validTime.substr(0, item.validTime.indexOf('/')));
const duration = Duration.fromISO(item.validTime.substr(item.validTime.indexOf('/') + 1)).shiftTo('milliseconds').values.milliseconds;
const endTime = startTime + duration;
// loop through duration at one hour intervals
do {
// test for timestamp greater than now
if (startTime >= startOfHour && result.length < 24) {
result.push(item.value); // push data array
} // timestamp is after now
// increment start time by 1 hour
startTime += 3600000;
} while (startTime < endTime && result.length < 24);
}); // for each value
return result;
}
async drawLongCanvas() {
// get the list element and populate
const list = this.elem.querySelector('.hourly-lines');
@@ -172,19 +118,79 @@ class Hourly extends WeatherDisplay {
this.elem.querySelector('.main').scrollTo(0, offsetY);
}
static getTravelCitiesDayName(cities) {
// effectively returns early on the first found date
return cities.reduce((dayName, city) => {
if (city && dayName === '') {
// today or tomorrow
const day = DateTime.local().plus({ days: (city.today) ? 0 : 1 });
// return the day
return day.toLocaleString({ weekday: 'long' });
}
return dayName;
}, '');
// make data available outside this class
// promise allows for data to be requested before it is available
async getCurrentData() {
return new Promise((resolve) => {
if (this.data) resolve(this.data);
// data not available, put it into the data callback queue
this.getDataCallbacks.push(() => resolve(this.data));
});
}
}
// extract specific values from forecast and format as an array
const parseForecast = async (data) => {
const temperature = expand(data.temperature.values);
const apparentTemperature = expand(data.apparentTemperature.values);
const windSpeed = expand(data.windSpeed.values);
const windDirection = expand(data.windDirection.values);
const skyCover = expand(data.skyCover.values); // cloud icon
const weather = expand(data.weather.values); // fog icon
const iceAccumulation = expand(data.iceAccumulation.values); // ice icon
const probabilityOfPrecipitation = expand(data.probabilityOfPrecipitation.values); // rain icon
const snowfallAmount = expand(data.snowfallAmount.values); // snow icon
const icons = await determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed);
return temperature.map((val, idx) => ({
temperature: celsiusToFahrenheit(temperature[idx]),
apparentTemperature: celsiusToFahrenheit(apparentTemperature[idx]),
windSpeed: kilometersToMiles(windSpeed[idx]),
windDirection: directionToNSEW(windDirection[idx]),
probabilityOfPrecipitation: probabilityOfPrecipitation[idx],
skyCover: skyCover[idx],
icon: icons[idx],
}));
};
// given forecast paramaters determine a suitable icon
const determineIcon = async (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed) => {
const startOfHour = DateTime.local().startOf('hour');
const sunTimes = (await getSun()).sun;
const overnight = Interval.fromDateTimes(DateTime.fromJSDate(sunTimes[0].sunset), DateTime.fromJSDate(sunTimes[1].sunrise));
const tomorrowOvernight = DateTime.fromJSDate(sunTimes[1].sunset);
return skyCover.map((val, idx) => {
const hour = startOfHour.plus({ hours: idx });
const isNight = overnight.contains(hour) || (hour > tomorrowOvernight);
return getHourlyIcon(skyCover[idx], weather[idx], iceAccumulation[idx], probabilityOfPrecipitation[idx], snowfallAmount[idx], windSpeed[idx], isNight);
});
};
// expand a set of values with durations to an hour-by-hour array
const expand = (data) => {
const startOfHour = DateTime.utc().startOf('hour').toMillis();
const result = []; // resulting expanded values
data.forEach((item) => {
let startTime = Date.parse(item.validTime.substr(0, item.validTime.indexOf('/')));
const duration = Duration.fromISO(item.validTime.substr(item.validTime.indexOf('/') + 1)).shiftTo('milliseconds').values.milliseconds;
const endTime = startTime + duration;
// loop through duration at one hour intervals
do {
// test for timestamp greater than now
if (startTime >= startOfHour && result.length < 24) {
result.push(item.value); // push data array
} // timestamp is after now
// increment start time by 1 hour
startTime += 3600000;
} while (startTime < endTime && result.length < 24);
}); // for each value
return result;
};
// register display
registerDisplay(new Hourly(2, 'hourly'));
const display = new Hourly(2, 'hourly', false);
registerDisplay(display);
export default display.getCurrentData.bind(display);

View File

@@ -82,7 +82,7 @@ class LatestObservations extends WeatherDisplay {
const fill = {};
fill.location = locationCleanup(condition.city).substr(0, 14);
fill.temp = Temperature;
fill.weather = LatestObservations.shortenCurrentConditions(condition.textDescription).substr(0, 9);
fill.weather = shortenCurrentConditions(condition.textDescription).substr(0, 9);
if (WindSpeed > 0) {
fill.wind = windDirection + (Array(6 - windDirection.length - WindSpeed.toString().length).join(' ')) + WindSpeed.toString();
} else if (WindSpeed === 'NA') {
@@ -100,25 +100,24 @@ class LatestObservations extends WeatherDisplay {
this.finishDraw();
}
static shortenCurrentConditions(_condition) {
let condition = _condition;
condition = condition.replace(/Light/, 'L');
condition = condition.replace(/Heavy/, 'H');
condition = condition.replace(/Partly/, 'P');
condition = condition.replace(/Mostly/, 'M');
condition = condition.replace(/Few/, 'F');
condition = condition.replace(/Thunderstorm/, 'T\'storm');
condition = condition.replace(/ in /, '');
condition = condition.replace(/Vicinity/, '');
condition = condition.replace(/ and /, ' ');
condition = condition.replace(/Freezing Rain/, 'Frz Rn');
condition = condition.replace(/Freezing/, 'Frz');
condition = condition.replace(/Unknown Precip/, '');
condition = condition.replace(/L Snow Fog/, 'L Snw/Fog');
condition = condition.replace(/ with /, '/');
return condition;
}
}
const shortenCurrentConditions = (_condition) => {
let condition = _condition;
condition = condition.replace(/Light/, 'L');
condition = condition.replace(/Heavy/, 'H');
condition = condition.replace(/Partly/, 'P');
condition = condition.replace(/Mostly/, 'M');
condition = condition.replace(/Few/, 'F');
condition = condition.replace(/Thunderstorm/, 'T\'storm');
condition = condition.replace(/ in /, '');
condition = condition.replace(/Vicinity/, '');
condition = condition.replace(/ and /, ' ');
condition = condition.replace(/Freezing Rain/, 'Frz Rn');
condition = condition.replace(/Freezing/, 'Frz');
condition = condition.replace(/Unknown Precip/, '');
condition = condition.replace(/L Snow Fog/, 'L Snw/Fog');
condition = condition.replace(/ with /, '/');
return condition;
};
// register display
registerDisplay(new LatestObservations(1, 'latest-observations'));

View File

@@ -25,7 +25,7 @@ class LocalForecast extends WeatherDisplay {
return;
}
// parse raw data
const conditions = LocalForecast.parse(rawData);
const conditions = parse(rawData);
// read each text
this.screenTexts = conditions.map((condition) => {
@@ -80,17 +80,14 @@ class LocalForecast extends WeatherDisplay {
this.finishDraw();
}
// format the forecast
static parse(forecast) {
// only use the first 6 lines
return forecast.properties.periods.slice(0, 6).map((text) => ({
// format day and text
DayName: text.name.toUpperCase(),
Text: text.detailedForecast,
}));
}
}
// format the forecast
// only use the first 6 lines
const parse = (forecast) => forecast.properties.periods.slice(0, 6).map((text) => ({
// format day and text
DayName: text.name.toUpperCase(),
Text: text.detailedForecast,
}));
// register display
registerDisplay(new LocalForecast(5, 'local-forecast'));
registerDisplay(new LocalForecast(6, 'local-forecast'));

View File

@@ -23,6 +23,7 @@ let AutoRefreshCountMs = 0;
const init = async () => {
// set up resize handler
window.addEventListener('resize', resize);
resize();
// auto refresh
const TwcAutoRefresh = localStorage.getItem('TwcAutoRefresh');
@@ -251,11 +252,11 @@ const getDisplay = (index) => displays[index];
// resize the container on a page resize
const resize = () => {
const widthZoomPercent = window.innerWidth / 640;
const heightZoomPercent = window.innerHeight / 480;
const marginOffset = (document.fullscreenElement) ? 0 : 16;
const widthZoomPercent = (window.innerWidth - marginOffset) / 640;
const heightZoomPercent = (window.innerHeight - marginOffset) / 480;
const scale = Math.min(widthZoomPercent, heightZoomPercent);
if (scale < 1.0 || document.fullscreenElement) {
document.getElementById('container').style.zoom = scale;
} else {
@@ -281,7 +282,7 @@ const generateCheckboxes = () => {
if (!availableDisplays) return;
// generate checkboxes
const checkboxes = displays.map((d) => d.generateCheckbox()).filter((d) => d);
const checkboxes = displays.map((d) => d.generateCheckbox(d.defaultEnabled)).filter((d) => d);
// write to page
availableDisplays.innerHTML = '';

View File

@@ -0,0 +1,185 @@
const getXYFromLatitudeLongitudeMap = (pos, offsetX, offsetY) => {
let y = 0;
let x = 0;
const imgHeight = 3200;
const imgWidth = 5100;
y = (51.75 - pos.latitude) * 55.2;
// center map
y -= offsetY;
// Do not allow the map to exceed the max/min coordinates.
if (y > (imgHeight - (offsetY * 2))) {
y = imgHeight - (offsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-130.37 - pos.longitude) * 41.775) * -1;
// center map
x -= offsetX;
// Do not allow the map to exceed the max/min coordinates.
if (x > (imgWidth - (offsetX * 2))) {
x = imgWidth - (offsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x: x * 2, y: y * 2 };
};
const getXYFromLatitudeLongitudeDoppler = (pos, offsetX, offsetY) => {
let y = 0;
let x = 0;
const imgHeight = 6000;
const imgWidth = 2800;
y = (51 - pos.latitude) * 61.4481;
// center map
y -= offsetY;
// Do not allow the map to exceed the max/min coordinates.
if (y > (imgHeight - (offsetY * 2))) {
y = imgHeight - (offsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-129.138 - pos.longitude) * 42.1768) * -1;
// center map
x -= offsetX;
// Do not allow the map to exceed the max/min coordinates.
if (x > (imgWidth - (offsetX * 2))) {
x = imgWidth - (offsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x: x * 2, y: y * 2 };
};
const removeDopplerRadarImageNoise = (RadarContext) => {
const RadarImageData = RadarContext.getImageData(0, 0, RadarContext.canvas.width, RadarContext.canvas.height);
// examine every pixel,
// change any old rgb to the new-rgb
for (let i = 0; i < RadarImageData.data.length; i += 4) {
// i + 0 = red
// i + 1 = green
// i + 2 = blue
// i + 3 = alpha (0 = transparent, 255 = opaque)
let R = RadarImageData.data[i];
let G = RadarImageData.data[i + 1];
let B = RadarImageData.data[i + 2];
let A = RadarImageData.data[i + 3];
// is this pixel the old rgb?
if ((R === 0 && G === 0 && B === 0)
|| (R === 0 && G === 236 && B === 236)
|| (R === 1 && G === 160 && B === 246)
|| (R === 0 && G === 0 && B === 246)) {
// change to your new rgb
// Transparent
R = 0;
G = 0;
B = 0;
A = 0;
} else if ((R === 0 && G === 255 && B === 0)) {
// Light Green 1
R = 49;
G = 210;
B = 22;
A = 255;
} else if ((R === 0 && G === 200 && B === 0)) {
// Light Green 2
R = 0;
G = 142;
B = 0;
A = 255;
} else if ((R === 0 && G === 144 && B === 0)) {
// Dark Green 1
R = 20;
G = 90;
B = 15;
A = 255;
} else if ((R === 255 && G === 255 && B === 0)) {
// Dark Green 2
R = 10;
G = 40;
B = 10;
A = 255;
} else if ((R === 231 && G === 192 && B === 0)) {
// Yellow
R = 196;
G = 179;
B = 70;
A = 255;
} else if ((R === 255 && G === 144 && B === 0)) {
// Orange
R = 190;
G = 72;
B = 19;
A = 255;
} else if ((R === 214 && G === 0 && B === 0)
|| (R === 255 && G === 0 && B === 0)) {
// Red
R = 171;
G = 14;
B = 14;
A = 255;
} else if ((R === 192 && G === 0 && B === 0)
|| (R === 255 && G === 0 && B === 255)) {
// Brown
R = 115;
G = 31;
B = 4;
A = 255;
}
RadarImageData.data[i] = R;
RadarImageData.data[i + 1] = G;
RadarImageData.data[i + 2] = B;
RadarImageData.data[i + 3] = A;
}
RadarContext.putImageData(RadarImageData, 0, 0);
};
const mergeDopplerRadarImage = (mapContext, radarContext) => {
const mapImageData = mapContext.getImageData(0, 0, mapContext.canvas.width, mapContext.canvas.height);
const radarImageData = radarContext.getImageData(0, 0, radarContext.canvas.width, radarContext.canvas.height);
// examine every pixel,
// change any old rgb to the new-rgb
for (let i = 0; i < radarImageData.data.length; i += 4) {
// i + 0 = red
// i + 1 = green
// i + 2 = blue
// i + 3 = alpha (0 = transparent, 255 = opaque)
// is this pixel the old rgb?
if ((mapImageData.data[i] < 116 && mapImageData.data[i + 1] < 116 && mapImageData.data[i + 2] < 116)) {
// change to your new rgb
// Transparent
radarImageData.data[i] = 0;
radarImageData.data[i + 1] = 0;
radarImageData.data[i + 2] = 0;
radarImageData.data[i + 3] = 0;
}
}
radarContext.putImageData(radarImageData, 0, 0);
mapContext.drawImage(radarContext.canvas, 0, 0);
};
export {
getXYFromLatitudeLongitudeDoppler,
getXYFromLatitudeLongitudeMap,
removeDopplerRadarImageNoise,
mergeDopplerRadarImage,
};

View File

@@ -6,6 +6,7 @@ import { text } from './utils/fetch.mjs';
import { rewriteUrl } from './utils/cors.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
import * as utils from './radar-utils.mjs';
class Radar extends WeatherDisplay {
constructor(navId, elemId) {
@@ -109,7 +110,7 @@ class Radar extends WeatherDisplay {
const height = 1600;
offsetX *= 2;
offsetY *= 2;
const sourceXY = Radar.getXYFromLatitudeLongitudeMap(weatherParameters, offsetX, offsetY);
const sourceXY = utils.getXYFromLatitudeLongitudeMap(weatherParameters, offsetX, offsetY);
// create working context for manipulation
const workingCanvas = document.createElement('canvas');
@@ -121,7 +122,7 @@ class Radar extends WeatherDisplay {
// calculate radar offsets
const radarOffsetX = 120;
const radarOffsetY = 70;
const radarSourceXY = Radar.getXYFromLatitudeLongitudeDoppler(weatherParameters, offsetX, offsetY);
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(weatherParameters, offsetX, offsetY);
const radarSourceX = radarSourceXY.x / 2;
const radarSourceY = radarSourceXY.y / 2;
@@ -179,10 +180,10 @@ class Radar extends WeatherDisplay {
cropContext.imageSmoothingEnabled = false;
cropContext.drawImage(workingCanvas, radarSourceX, radarSourceY, (radarOffsetX * 2), (radarOffsetY * 2.33), 0, 0, 640, 367);
// clean the image
Radar.removeDopplerRadarImageNoise(cropContext);
utils.removeDopplerRadarImageNoise(cropContext);
// merge the radar and map
Radar.mergeDopplerRadarImage(context, cropContext);
utils.mergeDopplerRadarImage(context, cropContext);
const elem = this.fillTemplate('frame', { map: { type: 'img', src: canvas.toDataURL() } });
@@ -218,188 +219,7 @@ class Radar extends WeatherDisplay {
this.finishDraw();
}
static getXYFromLatitudeLongitudeMap(pos, offsetX, offsetY) {
let y = 0;
let x = 0;
const imgHeight = 3200;
const imgWidth = 5100;
y = (51.75 - pos.latitude) * 55.2;
// center map
y -= offsetY;
// Do not allow the map to exceed the max/min coordinates.
if (y > (imgHeight - (offsetY * 2))) {
y = imgHeight - (offsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-130.37 - pos.longitude) * 41.775) * -1;
// center map
x -= offsetX;
// Do not allow the map to exceed the max/min coordinates.
if (x > (imgWidth - (offsetX * 2))) {
x = imgWidth - (offsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x: x * 2, y: y * 2 };
}
static getXYFromLatitudeLongitudeDoppler(pos, offsetX, offsetY) {
let y = 0;
let x = 0;
const imgHeight = 6000;
const imgWidth = 2800;
y = (51 - pos.latitude) * 61.4481;
// center map
y -= offsetY;
// Do not allow the map to exceed the max/min coordinates.
if (y > (imgHeight - (offsetY * 2))) {
y = imgHeight - (offsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-129.138 - pos.longitude) * 42.1768) * -1;
// center map
x -= offsetX;
// Do not allow the map to exceed the max/min coordinates.
if (x > (imgWidth - (offsetX * 2))) {
x = imgWidth - (offsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x: x * 2, y: y * 2 };
}
static removeDopplerRadarImageNoise(RadarContext) {
const RadarImageData = RadarContext.getImageData(0, 0, RadarContext.canvas.width, RadarContext.canvas.height);
// examine every pixel,
// change any old rgb to the new-rgb
for (let i = 0; i < RadarImageData.data.length; i += 4) {
// i + 0 = red
// i + 1 = green
// i + 2 = blue
// i + 3 = alpha (0 = transparent, 255 = opaque)
let R = RadarImageData.data[i];
let G = RadarImageData.data[i + 1];
let B = RadarImageData.data[i + 2];
let A = RadarImageData.data[i + 3];
// is this pixel the old rgb?
if ((R === 0 && G === 0 && B === 0)
|| (R === 0 && G === 236 && B === 236)
|| (R === 1 && G === 160 && B === 246)
|| (R === 0 && G === 0 && B === 246)) {
// change to your new rgb
// Transparent
R = 0;
G = 0;
B = 0;
A = 0;
} else if ((R === 0 && G === 255 && B === 0)) {
// Light Green 1
R = 49;
G = 210;
B = 22;
A = 255;
} else if ((R === 0 && G === 200 && B === 0)) {
// Light Green 2
R = 0;
G = 142;
B = 0;
A = 255;
} else if ((R === 0 && G === 144 && B === 0)) {
// Dark Green 1
R = 20;
G = 90;
B = 15;
A = 255;
} else if ((R === 255 && G === 255 && B === 0)) {
// Dark Green 2
R = 10;
G = 40;
B = 10;
A = 255;
} else if ((R === 231 && G === 192 && B === 0)) {
// Yellow
R = 196;
G = 179;
B = 70;
A = 255;
} else if ((R === 255 && G === 144 && B === 0)) {
// Orange
R = 190;
G = 72;
B = 19;
A = 255;
} else if ((R === 214 && G === 0 && B === 0)
|| (R === 255 && G === 0 && B === 0)) {
// Red
R = 171;
G = 14;
B = 14;
A = 255;
} else if ((R === 192 && G === 0 && B === 0)
|| (R === 255 && G === 0 && B === 255)) {
// Brown
R = 115;
G = 31;
B = 4;
A = 255;
}
RadarImageData.data[i] = R;
RadarImageData.data[i + 1] = G;
RadarImageData.data[i + 2] = B;
RadarImageData.data[i + 3] = A;
}
RadarContext.putImageData(RadarImageData, 0, 0);
// MapContext.drawImage(RadarContext.canvas, 0, 0);
}
static mergeDopplerRadarImage(mapContext, radarContext) {
const mapImageData = mapContext.getImageData(0, 0, mapContext.canvas.width, mapContext.canvas.height);
const radarImageData = radarContext.getImageData(0, 0, radarContext.canvas.width, radarContext.canvas.height);
// examine every pixel,
// change any old rgb to the new-rgb
for (let i = 0; i < radarImageData.data.length; i += 4) {
// i + 0 = red
// i + 1 = green
// i + 2 = blue
// i + 3 = alpha (0 = transparent, 255 = opaque)
// is this pixel the old rgb?
if ((mapImageData.data[i] < 116 && mapImageData.data[i + 1] < 116 && mapImageData.data[i + 2] < 116)) {
// change to your new rgb
// Transparent
radarImageData.data[i] = 0;
radarImageData.data[i + 1] = 0;
radarImageData.data[i + 2] = 0;
radarImageData.data[i + 3] = 0;
}
}
radarContext.putImageData(radarImageData, 0, 0);
mapContext.drawImage(radarContext.canvas, 0, 0);
}
}
// register display
registerDisplay(new Radar(8, 'radar'));
registerDisplay(new Radar(9, 'radar'));

View File

@@ -0,0 +1,205 @@
import { getWeatherRegionalIconFromIconLink } from './icons.mjs';
import { preloadImg } from './utils/image.mjs';
import { json } from './utils/fetch.mjs';
const buildForecast = (forecast, city, cityXY) => ({
daytime: forecast.isDaytime,
temperature: forecast.temperature || 0,
name: formatCity(city.city),
icon: forecast.icon,
x: cityXY.x,
y: cityXY.y,
time: forecast.startTime,
});
const getRegionalObservation = async (point, city) => {
try {
// get stations
const stations = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/stations`);
// get the first station
const station = stations.features[0].id;
// get the observation data
const observation = await json(`${station}/observations/latest`);
// preload the image
if (!observation.properties.icon) return false;
preloadImg(getWeatherRegionalIconFromIconLink(observation.properties.icon, !observation.properties.daytime));
// return the observation
return observation.properties;
} catch (e) {
console.log(`Unable to get regional observations for ${city.Name ?? city.city}`);
console.error(e.status, e.responseJSON);
return false;
}
};
// utility latitude/pixel conversions
const getXYFromLatitudeLongitude = (Latitude, Longitude, OffsetX, OffsetY, state) => {
if (state === 'AK') return getXYFromLatitudeLongitudeAK(Latitude, Longitude, OffsetX, OffsetY);
if (state === 'HI') return getXYFromLatitudeLongitudeHI(Latitude, Longitude, OffsetX, OffsetY);
let y = 0;
let x = 0;
const ImgHeight = 1600;
const ImgWidth = 2550;
y = (50.5 - Latitude) * 55.2;
y -= OffsetY; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (y > (ImgHeight - (OffsetY * 2))) {
y = ImgHeight - (OffsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-127.5 - Longitude) * 41.775) * -1;
x -= OffsetX; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (x > (ImgWidth - (OffsetX * 2))) {
x = ImgWidth - (OffsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x, y };
};
const getXYFromLatitudeLongitudeAK = (Latitude, Longitude, OffsetX, OffsetY) => {
let y = 0;
let x = 0;
const ImgHeight = 1142;
const ImgWidth = 1200;
y = (73.0 - Latitude) * 56;
y -= OffsetY; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (y > (ImgHeight - (OffsetY * 2))) {
y = ImgHeight - (OffsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-175.0 - Longitude) * 25.0) * -1;
x -= OffsetX; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (x > (ImgWidth - (OffsetX * 2))) {
x = ImgWidth - (OffsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x, y };
};
const getXYFromLatitudeLongitudeHI = (Latitude, Longitude, OffsetX, OffsetY) => {
let y = 0;
let x = 0;
const ImgHeight = 571;
const ImgWidth = 600;
y = (25 - Latitude) * 55.2;
y -= OffsetY; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (y > (ImgHeight - (OffsetY * 2))) {
y = ImgHeight - (OffsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-164.5 - Longitude) * 41.775) * -1;
x -= OffsetX; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (x > (ImgWidth - (OffsetX * 2))) {
x = ImgWidth - (OffsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x, y };
};
const getMinMaxLatitudeLongitude = (X, Y, OffsetX, OffsetY, state) => {
if (state === 'AK') return getMinMaxLatitudeLongitudeAK(X, Y, OffsetX, OffsetY);
if (state === 'HI') return getMinMaxLatitudeLongitudeHI(X, Y, OffsetX, OffsetY);
const maxLat = ((Y / 55.2) - 50.5) * -1;
const minLat = (((Y + (OffsetY * 2)) / 55.2) - 50.5) * -1;
const minLon = (((X * -1) / 41.775) + 127.5) * -1;
const maxLon = ((((X + (OffsetX * 2)) * -1) / 41.775) + 127.5) * -1;
return {
minLat, maxLat, minLon, maxLon,
};
};
const getMinMaxLatitudeLongitudeAK = (X, Y, OffsetX, OffsetY) => {
const maxLat = ((Y / 56) - 73.0) * -1;
const minLat = (((Y + (OffsetY * 2)) / 56) - 73.0) * -1;
const minLon = (((X * -1) / 25) + 175.0) * -1;
const maxLon = ((((X + (OffsetX * 2)) * -1) / 25) + 175.0) * -1;
return {
minLat, maxLat, minLon, maxLon,
};
};
const getMinMaxLatitudeLongitudeHI = (X, Y, OffsetX, OffsetY) => {
const maxLat = ((Y / 55.2) - 25) * -1;
const minLat = (((Y + (OffsetY * 2)) / 55.2) - 25) * -1;
const minLon = (((X * -1) / 41.775) + 164.5) * -1;
const maxLon = ((((X + (OffsetX * 2)) * -1) / 41.775) + 164.5) * -1;
return {
minLat, maxLat, minLon, maxLon,
};
};
const getXYForCity = (City, MaxLatitude, MinLongitude, state) => {
if (state === 'AK') getXYForCityAK(City, MaxLatitude, MinLongitude);
if (state === 'HI') getXYForCityHI(City, MaxLatitude, MinLongitude);
let x = (City.lon - MinLongitude) * 57;
let y = (MaxLatitude - City.lat) * 70;
if (y < 30) y = 30;
if (y > 282) y = 282;
if (x < 40) x = 40;
if (x > 580) x = 580;
return { x, y };
};
const getXYForCityAK = (City, MaxLatitude, MinLongitude) => {
let x = (City.lon - MinLongitude) * 37;
let y = (MaxLatitude - City.lat) * 70;
if (y < 30) y = 30;
if (y > 282) y = 282;
if (x < 40) x = 40;
if (x > 580) x = 580;
return { x, y };
};
const getXYForCityHI = (City, MaxLatitude, MinLongitude) => {
let x = (City.lon - MinLongitude) * 57;
let y = (MaxLatitude - City.lat) * 70;
if (y < 30) y = 30;
if (y > 282) y = 282;
if (x < 40) x = 40;
if (x > 580) x = 580;
return { x, y };
};
// to fit on the map, remove anything after punctuation and then limit to 15 characters
const formatCity = (city) => city.match(/[^-;/\\,]*/)[0].substr(0, 12);
export {
buildForecast,
getRegionalObservation,
getXYFromLatitudeLongitude,
getMinMaxLatitudeLongitude,
getXYForCity,
formatCity,
};

View File

@@ -10,6 +10,7 @@ import { preloadImg } from './utils/image.mjs';
import { DateTime } from '../vendor/auto/luxon.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
import * as utils from './regionalforecast-utils.mjs';
class RegionalForecast extends WeatherDisplay {
constructor(navId, elemId) {
@@ -38,10 +39,10 @@ class RegionalForecast extends WeatherDisplay {
y: 117,
};
// get user's location in x/y
const sourceXY = RegionalForecast.getXYFromLatitudeLongitude(weatherParameters.latitude, weatherParameters.longitude, offsetXY.x, offsetXY.y, weatherParameters.state);
const sourceXY = utils.getXYFromLatitudeLongitude(weatherParameters.latitude, weatherParameters.longitude, offsetXY.x, offsetXY.y, weatherParameters.state);
// get latitude and longitude limits
const minMaxLatLon = RegionalForecast.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, offsetXY.x, offsetXY.y, weatherParameters.state);
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, offsetXY.x, offsetXY.y, weatherParameters.state);
// get a target distance
let targetDistance = 2.5;
@@ -75,12 +76,12 @@ class RegionalForecast extends WeatherDisplay {
if (!city.point) throw new Error('No pre-loaded point');
// start off the observation task
const observationPromise = RegionalForecast.getRegionalObservation(city.point, city);
const observationPromise = utils.getRegionalObservation(city.point, city);
const forecast = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`);
// get XY on map for city
const cityXY = RegionalForecast.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, weatherParameters.state);
const cityXY = utils.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, weatherParameters.state);
// wait for the regional observation if it's not done yet
const observation = await observationPromise;
@@ -88,7 +89,7 @@ class RegionalForecast extends WeatherDisplay {
const regionalObservation = {
daytime: !!observation.icon.match(/\/day\//),
temperature: celsiusToFahrenheit(observation.temperature.value),
name: RegionalForecast.formatCity(city.city),
name: utils.formatCity(city.city),
icon: observation.icon,
x: cityXY.x,
y: cityXY.y,
@@ -104,8 +105,8 @@ class RegionalForecast extends WeatherDisplay {
// always skip the first forecast index because it's what's going on right now
return [
regionalObservation,
RegionalForecast.buildForecast(forecast.properties.periods[1], city, cityXY),
RegionalForecast.buildForecast(forecast.properties.periods[2], city, cityXY),
utils.buildForecast(forecast.properties.periods[1], city, cityXY),
utils.buildForecast(forecast.properties.periods[2], city, cityXY),
];
} catch (e) {
console.log(`No regional forecast data for '${city.name ?? city.city}'`);
@@ -133,203 +134,6 @@ class RegionalForecast extends WeatherDisplay {
this.setStatus(STATUS.loaded);
}
static buildForecast(forecast, city, cityXY) {
return {
daytime: forecast.isDaytime,
temperature: forecast.temperature || 0,
name: RegionalForecast.formatCity(city.city),
icon: forecast.icon,
x: cityXY.x,
y: cityXY.y,
time: forecast.startTime,
};
}
static async getRegionalObservation(point, city) {
try {
// get stations
const stations = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/stations`);
// get the first station
const station = stations.features[0].id;
// get the observation data
const observation = await json(`${station}/observations/latest`);
// preload the image
if (!observation.properties.icon) return false;
preloadImg(getWeatherRegionalIconFromIconLink(observation.properties.icon, !observation.properties.daytime));
// return the observation
return observation.properties;
} catch (e) {
console.log(`Unable to get regional observations for ${city.Name ?? city.city}`);
console.error(e.status, e.responseJSON);
return false;
}
}
// utility latitude/pixel conversions
static getXYFromLatitudeLongitude(Latitude, Longitude, OffsetX, OffsetY, state) {
if (state === 'AK') return RegionalForecast.getXYFromLatitudeLongitudeAK(Latitude, Longitude, OffsetX, OffsetY);
if (state === 'HI') return RegionalForecast.getXYFromLatitudeLongitudeHI(Latitude, Longitude, OffsetX, OffsetY);
let y = 0;
let x = 0;
const ImgHeight = 1600;
const ImgWidth = 2550;
y = (50.5 - Latitude) * 55.2;
y -= OffsetY; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (y > (ImgHeight - (OffsetY * 2))) {
y = ImgHeight - (OffsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-127.5 - Longitude) * 41.775) * -1;
x -= OffsetX; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (x > (ImgWidth - (OffsetX * 2))) {
x = ImgWidth - (OffsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x, y };
}
static getXYFromLatitudeLongitudeAK(Latitude, Longitude, OffsetX, OffsetY) {
let y = 0;
let x = 0;
const ImgHeight = 1142;
const ImgWidth = 1200;
y = (73.0 - Latitude) * 56;
y -= OffsetY; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (y > (ImgHeight - (OffsetY * 2))) {
y = ImgHeight - (OffsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-175.0 - Longitude) * 25.0) * -1;
x -= OffsetX; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (x > (ImgWidth - (OffsetX * 2))) {
x = ImgWidth - (OffsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x, y };
}
static getXYFromLatitudeLongitudeHI(Latitude, Longitude, OffsetX, OffsetY) {
let y = 0;
let x = 0;
const ImgHeight = 571;
const ImgWidth = 600;
y = (25 - Latitude) * 55.2;
y -= OffsetY; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (y > (ImgHeight - (OffsetY * 2))) {
y = ImgHeight - (OffsetY * 2);
} else if (y < 0) {
y = 0;
}
x = ((-164.5 - Longitude) * 41.775) * -1;
x -= OffsetX; // Centers map.
// Do not allow the map to exceed the max/min coordinates.
if (x > (ImgWidth - (OffsetX * 2))) {
x = ImgWidth - (OffsetX * 2);
} else if (x < 0) {
x = 0;
}
return { x, y };
}
static getMinMaxLatitudeLongitude(X, Y, OffsetX, OffsetY, state) {
if (state === 'AK') return RegionalForecast.getMinMaxLatitudeLongitudeAK(X, Y, OffsetX, OffsetY);
if (state === 'HI') return RegionalForecast.getMinMaxLatitudeLongitudeHI(X, Y, OffsetX, OffsetY);
const maxLat = ((Y / 55.2) - 50.5) * -1;
const minLat = (((Y + (OffsetY * 2)) / 55.2) - 50.5) * -1;
const minLon = (((X * -1) / 41.775) + 127.5) * -1;
const maxLon = ((((X + (OffsetX * 2)) * -1) / 41.775) + 127.5) * -1;
return {
minLat, maxLat, minLon, maxLon,
};
}
static getMinMaxLatitudeLongitudeAK(X, Y, OffsetX, OffsetY) {
const maxLat = ((Y / 56) - 73.0) * -1;
const minLat = (((Y + (OffsetY * 2)) / 56) - 73.0) * -1;
const minLon = (((X * -1) / 25) + 175.0) * -1;
const maxLon = ((((X + (OffsetX * 2)) * -1) / 25) + 175.0) * -1;
return {
minLat, maxLat, minLon, maxLon,
};
}
static getMinMaxLatitudeLongitudeHI(X, Y, OffsetX, OffsetY) {
const maxLat = ((Y / 55.2) - 25) * -1;
const minLat = (((Y + (OffsetY * 2)) / 55.2) - 25) * -1;
const minLon = (((X * -1) / 41.775) + 164.5) * -1;
const maxLon = ((((X + (OffsetX * 2)) * -1) / 41.775) + 164.5) * -1;
return {
minLat, maxLat, minLon, maxLon,
};
}
static getXYForCity(City, MaxLatitude, MinLongitude, state) {
if (state === 'AK') RegionalForecast.getXYForCityAK(City, MaxLatitude, MinLongitude);
if (state === 'HI') RegionalForecast.getXYForCityHI(City, MaxLatitude, MinLongitude);
let x = (City.lon - MinLongitude) * 57;
let y = (MaxLatitude - City.lat) * 70;
if (y < 30) y = 30;
if (y > 282) y = 282;
if (x < 40) x = 40;
if (x > 580) x = 580;
return { x, y };
}
static getXYForCityAK(City, MaxLatitude, MinLongitude) {
let x = (City.lon - MinLongitude) * 37;
let y = (MaxLatitude - City.lat) * 70;
if (y < 30) y = 30;
if (y > 282) y = 282;
if (x < 40) x = 40;
if (x > 580) x = 580;
return { x, y };
}
static getXYForCityHI(City, MaxLatitude, MinLongitude) {
let x = (City.lon - MinLongitude) * 57;
let y = (MaxLatitude - City.lat) * 70;
if (y < 30) y = 30;
if (y > 282) y = 282;
if (x < 40) x = 40;
if (x > 580) x = 580;
return { x, y };
}
// to fit on the map, remove anything after punctuation and then limit to 15 characters
static formatCity(city) {
return city.match(/[^-;/\\,]*/)[0].substr(0, 12);
}
drawCanvas() {
super.drawCanvas();
// break up data into useful values
@@ -389,4 +193,4 @@ class RegionalForecast extends WeatherDisplay {
}
// register display
registerDisplay(new RegionalForecast(4, 'regional-forecast'));
registerDisplay(new RegionalForecast(5, 'regional-forecast'));

View File

@@ -112,7 +112,7 @@ class TravelForecast extends WeatherDisplay {
// set up variables
const cities = this.data;
this.elem.querySelector('.header .title.dual .bottom').innerHTML = `For ${TravelForecast.getTravelCitiesDayName(cities)}`;
this.elem.querySelector('.header .title.dual .bottom').innerHTML = `For ${getTravelCitiesDayName(cities)}`;
this.finishDraw();
}
@@ -140,24 +140,22 @@ class TravelForecast extends WeatherDisplay {
this.elem.querySelector('.main').scrollTo(0, offsetY);
}
static getTravelCitiesDayName(cities) {
// effectively returns early on the first found date
return cities.reduce((dayName, city) => {
if (city && dayName === '') {
// today or tomorrow
const day = DateTime.local().plus({ days: (city.today) ? 0 : 1 });
// return the day
return day.toLocaleString({ weekday: 'long' });
}
return dayName;
}, '');
}
// necessary to get the lastest long canvas when scrolling
getLongCanvas() {
return this.longCanvas;
}
}
// effectively returns early on the first found date
const getTravelCitiesDayName = (cities) => cities.reduce((dayName, city) => {
if (city && dayName === '') {
// today or tomorrow
const day = DateTime.local().plus({ days: (city.today) ? 0 : 1 });
// return the day
return day.toLocaleString({ weekday: 'long' });
}
return dayName;
}, '');
// register display, not active by default
registerDisplay(new TravelForecast(3, 'travel', false));
registerDisplay(new TravelForecast(4, 'travel', false));

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,150 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
#hourly-graph-html {
background-image: url(../images/BackGround1_1_Chart.png);
.header {
.right {
position: absolute;
top: 35px;
right: 60px;
width: 360px;
font-family: 'Star4000 Small';
font-size: 32px;
@include u.text-shadow();
text-align: right;
div {
margin-top: -18px;
}
.temperature {
color: red;
}
.cloud {
color: lightgrey;
}
.rain {
color: aqua;
}
}
}
}
.weather-display .main.hourly-graph {
&.main {
>div {
position: absolute;
}
.label {
font-family: 'Star4000 Small';
font-size: 24pt;
color: c.$column-header-text;
@include u.text-shadow();
margin-top: -15px;
position: absolute;
}
.x-axis {
bottom: 0px;
left: 0px;
width: 640px;
height: 20px;
.label {
text-align: center;
width: 50px;
&.l-1 {
left: 25px;
}
&.l-2 {
left: 158px;
}
&.l-3 {
left: 291px;
}
&.l-4 {
left: 424px;
}
&.l-5 {
left: 557px;
}
}
}
.chart {
top: 0px;
left: 50px;
img {
width: 532px;
height: 285px;
}
}
.y-axis {
top: 0px;
left: 0px;
width: 50px;
height: 285px;
.label {
text-align: right;
right: 0px;
&.l-1 {
top: 0px;
}
&.l-2 {
top: 140px;
}
&.l-3 {
bottom: 0px;
}
}
}
.column-headers {
background-color: c.$column-header;
height: 20px;
position: absolute;
width: 100%;
}
.column-headers {
position: sticky;
top: 0px;
z-index: 5;
.temp {
left: 355px;
}
.like {
left: 435px;
}
.wind {
left: 535px;
}
}
}
}

View File

@@ -5,6 +5,17 @@
body {
font-family: "Star4000";
@media (prefers-color-scheme: dark) {
background-color: #000000;
color: white;
}
a {
@media (prefers-color-scheme: dark) {
color: lightblue;
}
}
}
input,
@@ -20,18 +31,52 @@ button {
#txtAddress {
width: 490px;
font-size: 16pt;
max-width: calc(100% - 8px);
@media (prefers-color-scheme: dark) {
background-color: #000000;
color: white;
border: 1px solid darkgray;
}
}
#btnGetGps,
#btnGetLatLng,
#btnClearQuery {
font-size: 16pt;
@media (prefers-color-scheme: dark) {
background-color: #000000;
color: white;
}
border: 1px solid darkgray;
}
#btnGetGps img {
&.dark {
display: none;
@media (prefers-color-scheme: dark) {
display: inline-block;
}
}
&.light {
@media (prefers-color-scheme: dark) {
display: none;
}
}
}
.autocomplete-suggestions {
background-color: #ffffff;
border: 1px solid #000000;
/*overflow: auto;*/
@media (prefers-color-scheme: dark) {
background-color: #000000;
}
}
.autocomplete-suggestion {
@@ -90,6 +135,11 @@ button {
display: flex;
flex-direction: row;
background-color: #000000;
@media (prefers-color-scheme: dark) {
background-color: rgb(48, 48, 48);
}
color: #ffffff;
width: 100%;
}
@@ -269,12 +319,6 @@ jsgif {
font-size: 18pt;
}
#container canvas {
/* position: absolute; */
width: 100%;
/* max-width: 640px; */
}
.heading {
font-weight: bold;
margin-top: 15px;

View File

@@ -3,6 +3,7 @@
@import 'current-weather';
@import 'extended-forecast';
@import 'hourly';
@import 'hourly-graph';
@import 'travel';
@import 'latest-observations';
@import 'local-forecast';

View File

@@ -9,8 +9,7 @@
<meta name="keywords" content="WeatherStar 4000+" />
<meta name="author" content="Matt Walsh" />
<meta name="application-name" content="WeatherStar 4000+" />
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="viewport" content="width=device-width,initial-scale=1;maximum-scale=1;minimum-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="manifest.json" />
@@ -34,6 +33,7 @@
<script type="module" src="scripts/modules/almanac.mjs"></script>
<script type="module" src="scripts/modules/icons.mjs"></script>
<script type="module" src="scripts/modules/extendedforecast.mjs"></script>
<script type="module" src="scripts/modules/hourly-graph.mjs"></script>
<script type="module" src="scripts/modules/hourly.mjs"></script>
<script type="module" src="scripts/modules/latestobservations.mjs"></script>
<script type="module" src="scripts/modules/localforecast.mjs"></script>
@@ -60,7 +60,7 @@
<div id="divQuery">
<form id="frmGetLatLng">
<input id="txtAddress" type="text" value="" placeholder="Zip or City, State" /><button id="btnGetGps" type="button" title="Get GPS Location"><img id="imgGetGps" src="images/nav/ic_gps_fixed_black_18dp_1x.png" /></button>
<input id="txtAddress" type="text" value="" placeholder="Zip or City, State" /><button id="btnGetGps" type="button" title="Get GPS Location"><img src="images/nav/ic_gps_fixed_black_18dp_1x.png" class="light"/><img src="images/nav/ic_gps_fixed_white_18dp_1x.png" class="dark"/></button>
<input id="btnGetLatLng" type="submit" value="GO" />
<input id="btnClearQuery" type="reset" value="Reset" />
</form>
@@ -90,6 +90,9 @@
<div id="hourly-html" class="weather-display">
<%- include('partials/hourly.ejs') %>
</div>
<div id="hourly-graph-html" class="weather-display">
<%- include('partials/hourly-graph.ejs') %>
</div>
<div id="travel-html" class="weather-display">
<%- include('partials/travel.ejs') %>
</div>
@@ -126,7 +129,7 @@
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_1x.png" title="Refresh" />
</div>
<div id="divTwcBottomRight">
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_exit_white_24dp_1x.png" title="Enter Fullscreen" />
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_1x.png" title="Enter Fullscreen" />
</div>
</div>
</div>

View File

@@ -17,6 +17,8 @@
<% if (locals?.hasTime) { %>
<div class="date-time date"></div>
<div class="date-time time"></div>
<% } else if (!locals?.noaaLogo) { %>
<div class="right"></div>
<% } %>
<% if (locals?.noaaLogo) { %>
<div class="noaa-logo">

View File

@@ -0,0 +1,24 @@
<%- include('header.ejs', {title: 'Hourly Graph' , hasTime: false }) %>
<div class="main has-scroll hourly-graph">
<div class="top-right template ">
<div class="temperature">Temperature</div>
<div class="cloud">Cloud %</div>
<div class="rain">Precip %</div>
</div>
<div class="y-axis">
<div class="label l-1">75</div>
<div class="label l-2">65</div>
<div class="label l-3">55</div>
</div>
<div class="chart">
<img id="chart-area"></img>
</div>
<div class="x-axis">
<div class="label l-1">12a</div>
<div class="label l-2">6a</div>
<div class="label l-3">12p</div>
<div class="label l-4">6p</div>
<div class="label l-5">12a</div>
</div>
</div>
<%- include('scroll.ejs') %>

View File

@@ -1,43 +1,43 @@
<div class="header">
<div class="logo"><img src="images/Logo3.png" /></div>
<div class="title dual">
<div class="top">
Local
</div>
<div class="bottom">
Radar
</div>
</div>
<div class="right">
<div class="precip">
<div class="precip-header">PRECIP</div>
<div class="scale">
<div class="text">Light</div>
<div class="scale-table">
<div class="box box-1"></div>
<div class="box box-2"></div>
<div class="box box-3"></div>
<div class="box box-4"></div>
<div class="box box-5"></div>
<div class="box box-6"></div>
<div class="box box-7"></div>
<div class="box box-7"></div>
</div>
<div class="text">Heavy</div>
</div>
<div class="time"></div>
</div>
</div>
<div class="logo"><img src="images/Logo3.png" /></div>
<div class="title dual">
<div class="top">
Local
</div>
<div class="bottom">
Radar
</div>
</div>
<div class="right">
<div class="precip">
<div class="precip-header">PRECIP</div>
<div class="scale">
<div class="text">Light</div>
<div class="scale-table">
<div class="box box-1"></div>
<div class="box box-2"></div>
<div class="box box-3"></div>
<div class="box box-4"></div>
<div class="box box-5"></div>
<div class="box box-6"></div>
<div class="box box-7"></div>
<div class="box box-7"></div>
</div>
<div class="text">Heavy</div>
</div>
<div class="time"></div>
</div>
</div>
</div>
<div class="main radar">
<div class="container">
<div class="scroll-area">
<div class="frame template">
<div class="map">
<img src="images/4000RadarMap2.jpg" />
</div>
</div>
</div>
</div>
<div class="container">
<div class="scroll-area">
<div class="frame template">
<div class="map">
<img src="images/4000RadarMap2.jpg" />
</div>
</div>
</div>
</div>
</div>