mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-14 15:49:31 -07:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c050eb8f | ||
|
|
a3e142dade | ||
|
|
28917489bb | ||
|
|
2365a4c0f7 | ||
|
|
8afef77ea5 | ||
|
|
8f70ee87c5 | ||
|
|
4e7429bfba | ||
|
|
c5ffe1542a | ||
|
|
5364855c58 | ||
|
|
18efd810bd | ||
|
|
68a6bae3a7 | ||
|
|
5f0f0d9000 | ||
|
|
9d9cf4b0f3 | ||
|
|
9e500143c0 | ||
|
|
71da682660 | ||
|
|
1b9a1dcb22 | ||
|
|
095761ee81 | ||
|
|
21e528aaa3 | ||
|
|
a92c632937 | ||
|
|
6073fd1733 | ||
|
|
5da8185633 | ||
|
|
cf5c818ee3 | ||
|
|
97cec114f6 | ||
|
|
7efd2e8db7 | ||
|
|
8c28f41d54 | ||
|
|
e9d603fbfc | ||
|
|
32aa43c5b1 | ||
|
|
dbc56f014a | ||
|
|
3161a03797 | ||
|
|
205fa77f51 | ||
|
|
28bb8f2e2a | ||
|
|
cf9a99a6ca | ||
|
|
a83afa71cd | ||
|
|
74f1abd6f8 |
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2024 Matt Walsh
|
||||
Copyright (c) 2020-2025 Matt Walsh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
17
README.md
17
README.md
@@ -31,7 +31,7 @@ To run via Node locally:
|
||||
git clone https://github.com/netbymatt/ws4kp.git
|
||||
cd ws4kp
|
||||
npm i
|
||||
node index.js
|
||||
node index.mjs
|
||||
```
|
||||
|
||||
To run via Docker:
|
||||
@@ -87,6 +87,17 @@ I've made several changes to this Weather Star 4000 simulation compared to the o
|
||||
## Sharing a permalink (bookmarking)
|
||||
Selected displays, the forecast city and widescreen setting are sticky from one session to the next. However if you would like to share your exact configuration or bookmark it click the "Copy Permalink" (or get "Get Parmalink") near the bottom of the page. A URL will be copied to your clipboard with all of you selected displays and location (or copy it from the page if your browser doesn't support clipboard transfers directly). You can then share this link or add it to your bookmarks.
|
||||
|
||||
Your permalink will be very long. Here is an example for the Orlando Internation Airport:
|
||||
```
|
||||
https://weatherstar.netbymatt.com/?hazards-checkbox=false¤t-weather-checkbox=true&latest-observations-checkbox=true&hourly-checkbox=false&hourly-graph-checkbox=true&travel-checkbox=false®ional-forecast-checkbox=true&local-forecast-checkbox=true&extended-forecast-checkbox=true&almanac-checkbox=false&spc-outlook-checkbox=true&radar-checkbox=true&settings-wide-checkbox=false&settings-kiosk-checkbox=false&settings-scanLines-checkbox=false&settings-speed-select=1.00&settings-units-select=us&latLonQuery=Orlando+International+Airport%2C+Orlando%2C+FL%2C+USA&latLon=%7B%22lat%22%3A28.431%2C%22lon%22%3A-81.3076%7D
|
||||
```
|
||||
You can also build your own permalink. Any omitted settings will be filled with defaults. Here are a few examples:
|
||||
```
|
||||
https://weatherstar.netbymatt.com/?latLonQuery=Orlando+International+Airport
|
||||
https://weatherstar.netbymatt.com/?kiosk=true
|
||||
https://weatherstar.netbymatt.com/?settings-units-select=metric
|
||||
```
|
||||
|
||||
## Kiosk mode
|
||||
Kiosk mode can be activated by a checkbox on the page. Note that there is no way out of kiosk mode (except refresh or closing the browser), and the play/pause and other controls will not be available. This is deliberate as a browser's kiosk mode it intended not to be exited or significantly modified.
|
||||
|
||||
@@ -145,6 +156,10 @@ Note: not all units are converted to metric, if selected. Some text-based produc
|
||||
|
||||
Not retro enough? Try the [Weatherstar 3000+](https://github.com/netbymatt/ws3kp)
|
||||
|
||||
## Use
|
||||
|
||||
Linking directly to the live web site at https://weatherstar.netbymatt.com is encouraged. As is using the live site for digital signage, home dashboards, streaming and public display. Please note the disclaimer below.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This web site should NOT be used in life threatening weather situations, or be relied on to inform the public of such situations. The Internet is an unreliable network subject to server and network outages and by nature is not suitable for such mission critical use. If you require such access to NWS data, please consider one of their subscription services. The authors of this web site shall not be held liable in the event of injury, death or property damage that occur as a result of disregarding this warning.
|
||||
|
||||
@@ -99,8 +99,11 @@ const server = app.listen(port, () => {
|
||||
});
|
||||
|
||||
// graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
const gracefulShutdown = () => {
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', gracefulShutdown);
|
||||
process.on('SIGTERM', gracefulShutdown);
|
||||
|
||||
745
package-lock.json
generated
745
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "5.21.1",
|
||||
"version": "5.21.12",
|
||||
"description": "Welcome to the WeatherStar 4000+ project page!",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
||||
BIN
server/images/icons/current-conditions/Smoke.gif
Normal file
BIN
server/images/icons/current-conditions/Smoke.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
server/images/nav/ic_scanlines_off_white_24dp_2x.png
Normal file
BIN
server/images/nav/ic_scanlines_off_white_24dp_2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 893 B |
BIN
server/images/nav/ic_scanlines_on_white_24dp_2x.png
Normal file
BIN
server/images/nav/ic_scanlines_on_white_24dp_2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 367 B |
@@ -38,6 +38,7 @@ const init = () => {
|
||||
document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick);
|
||||
document.querySelector('#NavigatePrevious').addEventListener('click', btnNavigatePreviousClick);
|
||||
document.querySelector('#NavigatePlay').addEventListener('click', btnNavigatePlayClick);
|
||||
document.querySelector('#ToggleScanlines').addEventListener('click', btnNavigateToggleScanlines);
|
||||
document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR).addEventListener('click', btnFullScreenClick);
|
||||
const btnGetGps = document.querySelector(BNT_GET_GPS_SELECTOR);
|
||||
btnGetGps.addEventListener('click', btnGetGpsClick);
|
||||
@@ -61,7 +62,7 @@ const init = () => {
|
||||
paramName: 'text',
|
||||
params: {
|
||||
f: 'json',
|
||||
countryCode: 'USA', // 'USA,PRI,VIR,GUM,ASM',
|
||||
countryCode: 'USA',
|
||||
category,
|
||||
maxSuggestions: 10,
|
||||
},
|
||||
@@ -82,7 +83,6 @@ const init = () => {
|
||||
|
||||
// attempt to parse the url parameters
|
||||
const parsedParameters = parseQueryString();
|
||||
|
||||
const loadFromParsed = parsedParameters.latLonQuery && parsedParameters.latLon;
|
||||
|
||||
// Auto load the parsed parameters and fall back to the previous query
|
||||
@@ -295,6 +295,8 @@ const updateFullScreenNavigate = () => {
|
||||
};
|
||||
|
||||
const documentKeydown = (e) => {
|
||||
// don't trigger on ctrl/alt/shift modified key
|
||||
if (e.altKey || e.ctrlKey || e.shiftKey) return false;
|
||||
const { key } = e;
|
||||
|
||||
if (document.fullscreenElement || document.activeElement === document.body) {
|
||||
@@ -345,6 +347,11 @@ const btnNavigatePlayClick = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const btnNavigateToggleScanlines = () => {
|
||||
settings.scanLines.value = !settings.scanLines.value;
|
||||
return false;
|
||||
};
|
||||
|
||||
// post a message to the iframe
|
||||
const postMessage = (type, myMessage = {}) => {
|
||||
navMessage({ type, message: myMessage });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// display sun and moon data
|
||||
import { loadImg, preloadImg } from './utils/image.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import STATUS from './status.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
@@ -9,9 +9,6 @@ class Almanac extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Almanac', true);
|
||||
|
||||
// pre-load background images (returns promises)
|
||||
this.backgroundImage0 = loadImg('images/backgrounds/1.png');
|
||||
|
||||
// preload the moon images
|
||||
preloadImg(imageName('Full'));
|
||||
preloadImg(imageName('Last'));
|
||||
@@ -122,10 +119,10 @@ class Almanac extends WeatherDisplay {
|
||||
// sun and moon data
|
||||
this.elem.querySelector('.day-1').innerHTML = Today.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.day-2').innerHTML = Tomorrow.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.rise-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
this.elem.querySelector('.rise-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
this.elem.querySelector('.set-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
this.elem.querySelector('.set-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
this.elem.querySelector('.rise-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunrise));
|
||||
this.elem.querySelector('.rise-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunrise));
|
||||
this.elem.querySelector('.set-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunset));
|
||||
this.elem.querySelector('.set-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunset));
|
||||
|
||||
const days = info.moon.map((MoonPhase) => {
|
||||
const fill = {};
|
||||
@@ -171,6 +168,8 @@ const imageName = (type) => {
|
||||
}
|
||||
};
|
||||
|
||||
const timeFormat = (dt) => dt.setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
|
||||
// register display
|
||||
const display = new Almanac(9, 'almanac');
|
||||
registerDisplay(display);
|
||||
|
||||
@@ -3,43 +3,24 @@ import { json } from './utils/fetch.mjs';
|
||||
|
||||
const KEYS = {
|
||||
ESC: 27,
|
||||
TAB: 9,
|
||||
RETURN: 13,
|
||||
LEFT: 37,
|
||||
UP: 38,
|
||||
RIGHT: 39,
|
||||
DOWN: 40,
|
||||
ENTER: 13,
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
autoSelectFirst: false,
|
||||
serviceUrl: null,
|
||||
lookup: null,
|
||||
onSelect: () => { },
|
||||
onHint: null,
|
||||
width: 'auto',
|
||||
minChars: 3,
|
||||
maxHeight: 300,
|
||||
deferRequestBy: 0,
|
||||
params: {},
|
||||
delimiter: null,
|
||||
zIndex: 9999,
|
||||
type: 'GET',
|
||||
noCache: false,
|
||||
preserveInput: false,
|
||||
containerClass: 'autocomplete-suggestions',
|
||||
tabDisabled: false,
|
||||
dataType: 'text',
|
||||
currentRequest: null,
|
||||
triggerSelectOnValidInput: true,
|
||||
preventBadQueries: true,
|
||||
paramName: 'query',
|
||||
transformResult: (a) => a,
|
||||
showNoSuggestionNotice: false,
|
||||
noSuggestionNotice: 'No results',
|
||||
orientation: 'bottom',
|
||||
forceFixPosition: false,
|
||||
};
|
||||
|
||||
const escapeRegExChars = (string) => string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// current weather conditions display
|
||||
import STATUS from './status.mjs';
|
||||
import { loadImg, preloadImg } from './utils/image.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { directionToNSEW } from './utils/calc.mjs';
|
||||
import { locationCleanup } from './utils/string.mjs';
|
||||
@@ -17,8 +17,6 @@ const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F'
|
||||
class CurrentWeather extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Current Conditions', true);
|
||||
// pre-load background image (returns promise)
|
||||
this.backgroundImage = loadImg('images/backgrounds/1.png');
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
@@ -52,6 +50,8 @@ class CurrentWeather extends WeatherDisplay {
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
|
||||
if (observations.features.length === 0) throw new Error(`No features returned for station: ${station.properties.stationIdentifier}, trying next station`);
|
||||
|
||||
// test data quality
|
||||
if (observations.features[0].properties.temperature.value === null
|
||||
|| observations.features[0].properties.windSpeed.value === null
|
||||
@@ -61,10 +61,11 @@ class CurrentWeather extends WeatherDisplay {
|
||||
|| observations.features[0].properties.dewpoint.value === null
|
||||
|| observations.features[0].properties.barometricPressure.value === null) {
|
||||
observations = undefined;
|
||||
throw new Error(`Unable to get observations: ${station.properties.stationIdentifier}, trying next station`);
|
||||
throw new Error(`Incomplete data set for: ${station.properties.stationIdentifier}, trying next station`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
observations = undefined;
|
||||
}
|
||||
}
|
||||
// test for data received
|
||||
|
||||
@@ -59,11 +59,10 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
date: Day.dayName,
|
||||
};
|
||||
|
||||
const { low } = Day;
|
||||
const { low, high } = Day;
|
||||
if (low !== undefined) {
|
||||
fill['value-lo'] = Math.round(low);
|
||||
}
|
||||
const { high } = Day;
|
||||
fill['value-hi'] = Math.round(high);
|
||||
|
||||
// return the filled template
|
||||
@@ -121,17 +120,18 @@ const parse = (fullForecast) => {
|
||||
return forecast;
|
||||
};
|
||||
|
||||
const regexList = [
|
||||
[/ and /gi, ' '],
|
||||
[/slight /gi, ''],
|
||||
[/chance /gi, ''],
|
||||
[/very /gi, ''],
|
||||
[/patchy /gi, ''],
|
||||
[/Areas Of /gi, ''],
|
||||
[/areas /gi, ''],
|
||||
[/dense /gi, ''],
|
||||
[/Thunderstorm/g, 'T\'Storm'],
|
||||
];
|
||||
const shortenExtendedForecastText = (long) => {
|
||||
const regexList = [
|
||||
[/ and /gi, ' '],
|
||||
[/slight /gi, ''],
|
||||
[/chance /gi, ''],
|
||||
[/very /gi, ''],
|
||||
[/patchy /gi, ''],
|
||||
[/areas /gi, ''],
|
||||
[/dense /gi, ''],
|
||||
[/Thunderstorm/g, 'T\'Storm'],
|
||||
];
|
||||
// run all regexes
|
||||
const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class Hazards extends WeatherDisplay {
|
||||
// show alert indicator
|
||||
if (this.data.length > 0) alert.classList.add('show');
|
||||
} catch (error) {
|
||||
console.error('Get hourly forecast failed');
|
||||
console.error('Get hazards failed');
|
||||
console.error(error.status, error.responseJSON);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
// return undefined to other subscribers
|
||||
@@ -129,7 +129,7 @@ class Hazards extends WeatherDisplay {
|
||||
// don't let offset go negative
|
||||
if (offsetY < 0) offsetY = 0;
|
||||
|
||||
// copy the scrolled portion of the canvas
|
||||
// move the element
|
||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
|
||||
// get available space
|
||||
const availableWidth = 532;
|
||||
const availableHeight = 285;
|
||||
|
||||
class HourlyGraph extends WeatherDisplay {
|
||||
constructor(navId, elemId, defaultActive) {
|
||||
super(navId, elemId, 'Hourly Graph', defaultActive);
|
||||
@@ -47,10 +51,6 @@ class HourlyGraph extends WeatherDisplay {
|
||||
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;
|
||||
|
||||
|
||||
@@ -69,8 +69,7 @@ class Hourly extends WeatherDisplay {
|
||||
const fillValues = {};
|
||||
// hour
|
||||
const hour = startingHour.plus({ hours: index });
|
||||
const formattedHour = hour.toLocaleString({ weekday: 'short', hour: 'numeric' });
|
||||
fillValues.hour = formattedHour;
|
||||
fillValues.hour = hour.toLocaleString({ weekday: 'short', hour: 'numeric' });
|
||||
|
||||
// temperatures, convert to strings with no decimal
|
||||
const temperature = data.temperature.toString().padStart(3);
|
||||
@@ -81,12 +80,11 @@ class Hourly extends WeatherDisplay {
|
||||
fillValues.like = feelsLike;
|
||||
|
||||
// wind
|
||||
let wind = 'Calm';
|
||||
fillValues.wind = 'Calm';
|
||||
if (data.windSpeed > 0) {
|
||||
const windSpeed = Math.round(data.windSpeed).toString();
|
||||
wind = data.windDirection + (Array(6 - data.windDirection.length - windSpeed.length).join(' ')) + windSpeed;
|
||||
fillValues.wind = data.windDirection + (Array(6 - data.windDirection.length - windSpeed.length).join(' ')) + windSpeed;
|
||||
}
|
||||
fillValues.wind = wind;
|
||||
|
||||
// image
|
||||
fillValues.icon = { type: 'img', src: data.icon };
|
||||
@@ -96,8 +94,7 @@ class Hourly extends WeatherDisplay {
|
||||
// alter the color of the feels like column to reflect wind chill or heat index
|
||||
if (feelsLike < temperature) {
|
||||
filledRow.querySelector('.like').classList.add('wind-chill');
|
||||
}
|
||||
if (feelsLike > temperature) {
|
||||
} else if (feelsLike > temperature) {
|
||||
filledRow.querySelector('.like').classList.add('heat-index');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const hourlyIcon = (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed, isNight = false) => {
|
||||
// internal function to add path to returned icon
|
||||
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
|
||||
// internal function to add path to returned icon
|
||||
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
|
||||
|
||||
const hourlyIcon = (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed, isNight = false) => {
|
||||
// possible phenomenon
|
||||
let thunder = false;
|
||||
let snow = false;
|
||||
|
||||
@@ -65,6 +65,10 @@ const largeIcon = (link, _isNightTime) => {
|
||||
case 'sleet-n':
|
||||
return addPath('Sleet.gif');
|
||||
|
||||
case 'smoke':
|
||||
case 'smoke-n':
|
||||
return addPath('Smoke.gif');
|
||||
|
||||
case 'rain_showers':
|
||||
case 'rain_showers_high':
|
||||
case 'rain_showers-n':
|
||||
|
||||
@@ -22,8 +22,7 @@ class LatestObservations extends WeatherDisplay {
|
||||
// this is intentional because up to 30 stations are available to pull data from
|
||||
|
||||
// calculate distance to each station
|
||||
const stationsByDistance = Object.keys(StationInfo).map((key) => {
|
||||
const station = StationInfo[key];
|
||||
const stationsByDistance = Object.values(StationInfo).map((station) => {
|
||||
const distance = calcDistance(station.lat, station.lon, this.weatherParameters.latitude, this.weatherParameters.longitude);
|
||||
return { ...station, distance };
|
||||
});
|
||||
@@ -104,8 +103,6 @@ class LatestObservations extends WeatherDisplay {
|
||||
linesContainer.innerHTML = '';
|
||||
linesContainer.append(...lines);
|
||||
|
||||
// update temperature unit header
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,9 +253,9 @@ const resize = () => {
|
||||
|
||||
const scale = Math.min(widthZoomPercent, heightZoomPercent);
|
||||
if (scale < 1.0 || document.fullscreenElement || settings.kiosk) {
|
||||
document.querySelector('#container').style.transform = `scale(${scale})`;
|
||||
document.querySelector('#container').style.zoom = scale;
|
||||
} else {
|
||||
document.querySelector('#container').style.transform = 'unset';
|
||||
document.querySelector('#container').style.zoom = 'unset';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -266,6 +266,7 @@ const resetStatuses = () => {
|
||||
|
||||
// allow displays to register themselves
|
||||
const registerDisplay = (display) => {
|
||||
if (displays[display.navId]) console.warn(`Display nav ID ${display.navId} already in use`);
|
||||
displays[display.navId] = display;
|
||||
|
||||
// generate checkboxes
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// regional forecast and observations
|
||||
import { loadImg } from './utils/image.mjs';
|
||||
import STATUS, { calcStatusClass, statusClasses } from './status.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import {
|
||||
@@ -10,9 +9,6 @@ class Progress extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, '', false);
|
||||
|
||||
// pre-load background image (returns promise)
|
||||
this.backgroundImage = loadImg('images/backgrounds/1.png');
|
||||
|
||||
// disable any navigation timing
|
||||
this.timing = false;
|
||||
|
||||
|
||||
@@ -6,10 +6,27 @@ import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
import * as utils from './radar-utils.mjs';
|
||||
|
||||
// TEMPORARY fix to disable radar on ios safari. The same engine (webkit) is
|
||||
// used for all ios browers (chrome, brave, firefox, etc) so it's safe to skip
|
||||
// any subsequent narrowing of the user-agent.
|
||||
const isIos = /iP(ad|od|hone)/i.test(window.navigator.userAgent);
|
||||
// NOTE: iMessages/Messages preview is provided by an Apple scraper that uses a
|
||||
// user-agent similar to: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1)
|
||||
// AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4
|
||||
// facebookexternalhit/1.1 Facebot Twitterbot/1.0`. There is currently a bug in
|
||||
// Messages macos/ios where a constantly crashing website seems to cause an
|
||||
// entire Messages thread to permanently lockup until the individual website
|
||||
// preview is deleted! Messages ios will judder but allows the message to be
|
||||
// deleted eventually. Messages macos beachballs forever and prevents the
|
||||
// successful deletion. See
|
||||
// https://github.com/netbymatt/ws4kp/issues/74#issuecomment-2921154962 for more
|
||||
// context.
|
||||
const isBot = /twitterbot|Facebot/i.test(window.navigator.userAgent);
|
||||
|
||||
const RADAR_HOST = 'mesonet.agron.iastate.edu';
|
||||
class Radar extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Local Radar', true);
|
||||
super(navId, elemId, 'Local Radar', !isIos && !isBot);
|
||||
|
||||
this.okToDrawCurrentConditions = false;
|
||||
this.okToDrawCurrentDateTime = false;
|
||||
@@ -39,9 +56,6 @@ class Radar extends WeatherDisplay {
|
||||
{ time: 1, si: 4 },
|
||||
{ time: 12, si: 5 },
|
||||
];
|
||||
|
||||
// get some web workers started
|
||||
this.workers = (new Array(this.dopplerRadarImageMax)).fill(null).map(() => radarWorker());
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
@@ -53,6 +67,12 @@ class Radar extends WeatherDisplay {
|
||||
return;
|
||||
}
|
||||
|
||||
// get the workers started
|
||||
if (!this.workers) {
|
||||
// get some web workers started
|
||||
this.workers = (new Array(this.dopplerRadarImageMax)).fill(null).map(() => radarWorker());
|
||||
}
|
||||
|
||||
const baseUrl = `https://${RADAR_HOST}/archive/data/`;
|
||||
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png';
|
||||
const baseUrls = [];
|
||||
@@ -100,10 +120,8 @@ class Radar extends WeatherDisplay {
|
||||
const urls = sortedPngs.slice(-(this.dopplerRadarImageMax));
|
||||
|
||||
// calculate offsets and sizes
|
||||
let offsetX = 120;
|
||||
let offsetY = 69;
|
||||
offsetX *= 2;
|
||||
offsetY *= 2;
|
||||
const offsetX = 120 * 2;
|
||||
const offsetY = 69 * 2;
|
||||
const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY);
|
||||
const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY);
|
||||
|
||||
@@ -201,4 +219,7 @@ const radarWorker = () => {
|
||||
};
|
||||
|
||||
// register display
|
||||
registerDisplay(new Radar(11, 'radar'));
|
||||
// TEMPORARY: except on IOS and bots
|
||||
if (!isIos && !isBot) {
|
||||
registerDisplay(new Radar(11, 'radar'));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const buildForecast = (forecast, city, cityXY) => {
|
||||
const getRegionalObservation = async (point, city) => {
|
||||
try {
|
||||
// get stations
|
||||
const stations = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations`);
|
||||
const stations = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations?limit=1`);
|
||||
|
||||
// get the first station
|
||||
const station = stations.features[0].id;
|
||||
|
||||
@@ -7,12 +7,18 @@ import { json } from './utils/fetch.mjs';
|
||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||
import { getSmallIcon } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import { DateTime, Interval } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import * as utils from './regionalforecast-utils.mjs';
|
||||
import { getPoint } from './utils/weather.mjs';
|
||||
|
||||
// map offset
|
||||
const mapOffsetXY = {
|
||||
x: 240,
|
||||
y: 117,
|
||||
};
|
||||
|
||||
class RegionalForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Regional Forecast', true);
|
||||
@@ -36,23 +42,18 @@ class RegionalForecast extends WeatherDisplay {
|
||||
}
|
||||
this.elem.querySelector('.map img').src = baseMap;
|
||||
|
||||
// map offset
|
||||
const offsetXY = {
|
||||
x: 240,
|
||||
y: 117,
|
||||
};
|
||||
// get user's location in x/y
|
||||
const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, offsetXY.x, offsetXY.y, weatherParameters.state);
|
||||
const sourceXY = utils.getXYFromLatitudeLongitude(this.weatherParameters.latitude, this.weatherParameters.longitude, mapOffsetXY.x, mapOffsetXY.y, weatherParameters.state);
|
||||
|
||||
// get latitude and longitude limits
|
||||
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, offsetXY.x, offsetXY.y, this.weatherParameters.state);
|
||||
const minMaxLatLon = utils.getMinMaxLatitudeLongitude(sourceXY.x, sourceXY.y, mapOffsetXY.x, mapOffsetXY.y, this.weatherParameters.state);
|
||||
|
||||
// get a target distance
|
||||
let targetDistance = 2.5;
|
||||
if (this.weatherParameters.state === 'HI') targetDistance = 1;
|
||||
|
||||
// make station info into an array
|
||||
const stationInfoArray = Object.values(StationInfo).map((value) => ({ ...value, targetDistance }));
|
||||
const stationInfoArray = Object.values(StationInfo).map((station) => ({ ...station, 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];
|
||||
@@ -76,6 +77,9 @@ class RegionalForecast extends WeatherDisplay {
|
||||
// get a unit converter
|
||||
const temperatureConverter = temperatureUnit();
|
||||
|
||||
// get now as DateTime for calculations below
|
||||
const now = DateTime.now();
|
||||
|
||||
// get regional forecasts and observations (the two are intertwined due to the design of api.weather.gov)
|
||||
const regionalDataAll = await Promise.all(regionalCities.map(async (city) => {
|
||||
try {
|
||||
@@ -109,14 +113,24 @@ class RegionalForecast extends WeatherDisplay {
|
||||
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
|
||||
|
||||
// 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
|
||||
// 0th object should contain the current conditions, but when WFOs go offline or otherwise don't post
|
||||
// an updated forecast it's possible that the 0th object is in the past.
|
||||
// so we go on a search for the current time in the start/end times provided in the forecast periods
|
||||
const { periods } = forecast.properties;
|
||||
const currentPeriod = periods.reduce((prev, period, index) => {
|
||||
const start = DateTime.fromISO(period.startTime);
|
||||
const end = DateTime.fromISO(period.endTime);
|
||||
const interval = Interval.fromDateTimes(start, end);
|
||||
if (interval.contains(now)) {
|
||||
return index;
|
||||
}
|
||||
return prev;
|
||||
}, 0);
|
||||
// group together the current observation and next two periods
|
||||
return [
|
||||
regionalObservation,
|
||||
utils.buildForecast(forecast.properties.periods[1], city, cityXY),
|
||||
utils.buildForecast(forecast.properties.periods[2], city, cityXY),
|
||||
utils.buildForecast(forecast.properties.periods[currentPeriod + 1], city, cityXY),
|
||||
utils.buildForecast(forecast.properties.periods[currentPeriod + 2], city, cityXY),
|
||||
];
|
||||
} catch (error) {
|
||||
console.log(`No regional forecast data for '${city.name ?? city.city}'`);
|
||||
@@ -137,7 +151,7 @@ class RegionalForecast extends WeatherDisplay {
|
||||
// return the weather data and offsets
|
||||
this.data = {
|
||||
regionalData,
|
||||
offsetXY,
|
||||
mapOffsetXY,
|
||||
sourceXY,
|
||||
};
|
||||
|
||||
@@ -147,7 +161,7 @@ class RegionalForecast extends WeatherDisplay {
|
||||
drawCanvas() {
|
||||
super.drawCanvas();
|
||||
// break up data into useful values
|
||||
const { regionalData: data, sourceXY, offsetXY } = this.data;
|
||||
const { regionalData: data, sourceXY } = this.data;
|
||||
|
||||
// draw the header graphics
|
||||
|
||||
@@ -170,7 +184,7 @@ class RegionalForecast extends WeatherDisplay {
|
||||
}
|
||||
|
||||
// draw the map
|
||||
const scale = 640 / (offsetXY.x * 2);
|
||||
const scale = 640 / (mapOffsetXY.x * 2);
|
||||
const map = this.elem.querySelector('.map');
|
||||
map.style.transform = `scale(${scale}) translate(-${sourceXY.x}px, -${sourceXY.y}px)`;
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ const init = () => {
|
||||
[1.5, 'Very Slow'],
|
||||
],
|
||||
});
|
||||
settings.scanLines = new Setting('scanLines', {
|
||||
name: 'Scan Lines',
|
||||
defaultValue: false,
|
||||
changeAction: scanLineChange,
|
||||
sticky: true,
|
||||
});
|
||||
settings.units = new Setting('units', {
|
||||
name: 'Units',
|
||||
type: 'select',
|
||||
@@ -85,6 +91,18 @@ const kioskChange = (value) => {
|
||||
}
|
||||
};
|
||||
|
||||
const scanLineChange = (value) => {
|
||||
const container = document.getElementById('container');
|
||||
const navIcons = document.getElementById('ToggleScanlines');
|
||||
if (value) {
|
||||
container.classList.add('scanlines');
|
||||
navIcons.classList.add('on');
|
||||
} else {
|
||||
container.classList.remove('scanlines');
|
||||
navIcons.classList.remove('on');
|
||||
}
|
||||
};
|
||||
|
||||
const unitChange = () => {
|
||||
// reload the data at the top level to refresh units
|
||||
// after the initial load
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { elemForEach } from './utils/elem.mjs';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => init());
|
||||
|
||||
// shorthand mappings for frequently used values
|
||||
@@ -19,21 +21,18 @@ const init = () => {
|
||||
const createLink = async (e) => {
|
||||
// cancel default event (click on hyperlink)
|
||||
e.preventDefault();
|
||||
// get all checkboxes on page
|
||||
const checkboxes = document.querySelectorAll('input[type=checkbox]');
|
||||
|
||||
// list to receive checkbox statuses
|
||||
const queryStringElements = {};
|
||||
|
||||
[...checkboxes].forEach((elem) => {
|
||||
elemForEach('input[type=checkbox]', (elem) => {
|
||||
if (elem?.id) {
|
||||
queryStringElements[elem.id] = elem?.checked ?? false;
|
||||
}
|
||||
});
|
||||
|
||||
// get all select boxes
|
||||
const selects = document.querySelectorAll('select');
|
||||
[...selects].forEach((elem) => {
|
||||
elemForEach('select', (elem) => {
|
||||
if (elem?.id) {
|
||||
queryStringElements[elem.id] = elem?.value ?? 0;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ const text = (url, params) => fetchAsync(url, 'text', params);
|
||||
const blob = (url, params) => fetchAsync(url, 'blob', params);
|
||||
|
||||
const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
// add user agent header to json request at api.weather.gov
|
||||
const headers = {};
|
||||
if (_url.toString().match(/api\.weather\.gov/)) {
|
||||
headers['user-agent'] = 'Weatherstar 4000+; weatherstar@netbymatt.com';
|
||||
}
|
||||
// combine default and provided parameters
|
||||
const params = {
|
||||
method: 'GET',
|
||||
@@ -12,6 +17,7 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
type: 'GET',
|
||||
retryCount: 0,
|
||||
..._params,
|
||||
headers,
|
||||
};
|
||||
// store original number of retries
|
||||
params.originalRetries = params.retryCount;
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
import { blob } from './fetch.mjs';
|
||||
import { rewriteUrl } from './cors.mjs';
|
||||
|
||||
// ****************************** load images *********************************
|
||||
// load an image from a blob or url
|
||||
const loadImg = (imgData, cors = false) => new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = (e) => {
|
||||
resolve(e.target);
|
||||
};
|
||||
if (imgData instanceof Blob) {
|
||||
img.src = window.URL.createObjectURL(imgData);
|
||||
} else {
|
||||
let url = imgData;
|
||||
if (cors) url = rewriteUrl(imgData);
|
||||
img.src = url;
|
||||
}
|
||||
});
|
||||
|
||||
// preload an image
|
||||
// the goal is to get it in the browser's cache so it is available more quickly when the browser needs it
|
||||
@@ -28,15 +11,7 @@ const preloadImg = (src) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadImgElement = (url) => new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = reject;
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
export {
|
||||
loadImg,
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
preloadImg,
|
||||
loadImgElement,
|
||||
};
|
||||
|
||||
@@ -189,7 +189,7 @@ class Setting {
|
||||
break;
|
||||
case 'checkbox':
|
||||
default:
|
||||
this.element.checked = newValue;
|
||||
this.element.querySelector('input').checked = newValue;
|
||||
}
|
||||
this.storeToLocalStorage(this.myValue);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { json } from './fetch.mjs';
|
||||
|
||||
const getPoint = async (lat, lon) => {
|
||||
try {
|
||||
return await json(`https://api.weather.gov/points/${lat},${lon}`);
|
||||
return await json(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
|
||||
} catch (error) {
|
||||
console.log(`Unable to get point ${lat}, ${lon}`);
|
||||
console.error(error);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from './navigation.mjs';
|
||||
import { parseQueryString } from './share.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import { elemForEach } from './utils/elem.mjs';
|
||||
|
||||
class WeatherDisplay {
|
||||
constructor(navId, elemId, name, defaultEnabled) {
|
||||
@@ -391,8 +392,7 @@ class WeatherDisplay {
|
||||
this.templates = {};
|
||||
this.elem = document.querySelector(`#${this.elemId}-html`);
|
||||
if (!this.elem) return;
|
||||
const templates = this.elem.querySelectorAll('.template');
|
||||
templates.forEach((template) => {
|
||||
elemForEach(`#${this.elemId}-html .template`, (template) => {
|
||||
const className = template.classList[0];
|
||||
const node = template.cloneNode(true);
|
||||
node.classList.remove('template');
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -23,6 +23,8 @@ body {
|
||||
|
||||
&.kiosk {
|
||||
margin: 0px;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +143,10 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.kiosk #divTwc {
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
#divTwcLeft {
|
||||
display: none;
|
||||
text-align: right;
|
||||
@@ -189,11 +195,33 @@ body {
|
||||
#divTwcBottom>div {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
|
||||
// scale down the buttons on narrower screens
|
||||
@media (max-width: 550px) {
|
||||
zoom: 0.90;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
zoom: 0.80;
|
||||
}
|
||||
|
||||
@media (max-width: 450px) {
|
||||
zoom: 0.70;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
zoom: 0.60;
|
||||
}
|
||||
|
||||
@media (max-width: 350px) {
|
||||
zoom: 0.50;
|
||||
}
|
||||
}
|
||||
|
||||
#divTwcBottomLeft {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
|
||||
}
|
||||
|
||||
#divTwcBottomMiddle {
|
||||
@@ -300,10 +328,6 @@ body {
|
||||
transform-origin: unset;
|
||||
}
|
||||
|
||||
.kiosk #divTwc #container {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#loading {
|
||||
width: 640px;
|
||||
height: 480px;
|
||||
@@ -404,10 +428,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.kiosk #divTwc {
|
||||
justify-content: unset;
|
||||
}
|
||||
|
||||
#divTwc:fullscreen #display,
|
||||
.kiosk #divTwc #display {
|
||||
position: relative;
|
||||
@@ -436,6 +456,30 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#ToggleScanlines {
|
||||
display: inline-block;
|
||||
|
||||
.on {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.off {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
&.on {
|
||||
.on {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.off {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
@@ -716,7 +760,7 @@ body {
|
||||
}
|
||||
|
||||
#share-link-copied {
|
||||
color: c.$title-color;
|
||||
color: hsl(60, 100%, 30%);
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -728,6 +772,7 @@ body {
|
||||
|
||||
#divQuery,
|
||||
>.info,
|
||||
>.related-links,
|
||||
>.heading,
|
||||
#enabledDisplays,
|
||||
#settings,
|
||||
|
||||
@@ -13,4 +13,5 @@
|
||||
@use 'almanac';
|
||||
@use 'hazards';
|
||||
@use 'media';
|
||||
@use 'spc-outlook';
|
||||
@use 'spc-outlook';
|
||||
@use 'shared/scanlines';
|
||||
106
server/styles/scss/shared/_scanlines.scss
Normal file
106
server/styles/scss/shared/_scanlines.scss
Normal file
@@ -0,0 +1,106 @@
|
||||
/* REGULAR SCANLINES SETTINGS */
|
||||
|
||||
// width of 1 scanline (min.: 1px)
|
||||
$scan-width: 1px;
|
||||
|
||||
// emulates a damage-your-eyes bad pre-2000 CRT screen ♥ (true, false)
|
||||
$scan-crt: false;
|
||||
|
||||
// frames-per-second (should be > 1), only applies if $scan-crt: true;
|
||||
$scan-fps: 20;
|
||||
|
||||
// scanline-color (rgba)
|
||||
$scan-color: rgba(#000, .3);
|
||||
|
||||
// set z-index on 8, like in ♥ 8-bits ♥, or…
|
||||
// set z-index on 2147483648 or more to enable scanlines on Chrome fullscreen (doesn't work in Firefox or IE);
|
||||
$scan-z-index: 2147483648;
|
||||
|
||||
/* MOVING SCANLINE SETTINGS */
|
||||
|
||||
// moving scanline (true, false)
|
||||
$scan-moving-line: true;
|
||||
|
||||
// opacity of the moving scanline
|
||||
$scan-opacity: .75;
|
||||
|
||||
/* MIXINS */
|
||||
|
||||
// apply CRT animation: @include scan-crt($scan-crt);
|
||||
@mixin scan-crt($scan-crt) {
|
||||
@if $scan-crt==true {
|
||||
animation: scanlines 1s steps($scan-fps) infinite;
|
||||
}
|
||||
|
||||
@else {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
// apply CRT animation: @include scan-crt($scan-crt);
|
||||
@mixin scan-moving($scan-moving-line) {
|
||||
@if $scan-moving-line==true {
|
||||
animation: scanline 6s linear infinite;
|
||||
}
|
||||
|
||||
@else {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* CSS .scanlines CLASS */
|
||||
|
||||
.scanlines {
|
||||
position: relative;
|
||||
overflow: hidden; // only to animate the unique scanline
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
// unique scanline travelling on the screen
|
||||
&:before {
|
||||
// position: absolute;
|
||||
// bottom: 100%;
|
||||
width: 100%;
|
||||
height: $scan-width * 1;
|
||||
z-index: $scan-z-index + 1;
|
||||
background: $scan-color;
|
||||
opacity: $scan-opacity;
|
||||
// animation: scanline 6s linear infinite;
|
||||
@include scan-moving($scan-moving-line);
|
||||
}
|
||||
|
||||
// the scanlines, so!
|
||||
&:after {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: $scan-z-index;
|
||||
background: linear-gradient(to bottom,
|
||||
transparent 50%,
|
||||
$scan-color 51%);
|
||||
background-size: 100% $scan-width*2;
|
||||
@include scan-crt($scan-crt);
|
||||
}
|
||||
}
|
||||
|
||||
/* ANIMATE UNIQUE SCANLINE */
|
||||
@keyframes scanline {
|
||||
0% {
|
||||
transform: translate3d(0, 200000%, 0);
|
||||
// bottom: 0%; // to have a continuous scanline move, use this line (here in 0% step) instead of transform and write, in &:before, { position: absolute; bottom: 100%; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanlines {
|
||||
0% {
|
||||
background-position: 0 50%;
|
||||
// bottom: 0%; // to have a continuous scanline move, use this line (here in 0% step) instead of transform and write, in &:before, { position: absolute; bottom: 100%; }
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,10 @@
|
||||
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
|
||||
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Mute" />
|
||||
</div>
|
||||
<div id="ToggleScanlines">
|
||||
<img class="navButton off" src="images/nav/ic_scanlines_off_white_24dp_2x.png" title="Scan lines on" />
|
||||
<img class="navButton on" src="images/nav/ic_scanlines_on_white_24dp_2x.png" title="Scan lines off" />
|
||||
</div>
|
||||
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_2x.png" title="Enter Fullscreen" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
<div class="scroll-area">
|
||||
<div class="frame template">
|
||||
<div class="map">
|
||||
<!-- <img src="images/maps/radar.webp" /> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user