Compare commits

...

24 Commits

Author SHA1 Message Date
Matt Walsh
c5c01e5450 5.27.0 2025-06-30 23:29:46 -05:00
Matt Walsh
0a65221905 update readme for custom rss close #57 2025-06-30 23:29:39 -05:00
Matt Walsh
9f9667c895 add single text scroll option #57 2025-06-28 09:20:36 -05:00
Matt Walsh
fda44e95fc rss feeds scroll, needs additional testing #57 2025-06-28 00:59:40 -05:00
Matt Walsh
945c12e6c6 parse rss feed #57 2025-06-28 00:29:55 -05:00
Matt Walsh
0fde88cd8f restructure current weather scroll to allow add/remove of rss feed #57 2025-06-28 00:29:47 -05:00
Matt Walsh
c6af9a2913 5.26.2 2025-06-27 22:30:05 -05:00
Matt Walsh
11eba84cdb fix for calm/0mph wind close #121 2025-06-27 22:29:56 -05:00
Matt Walsh
b9ead38015 5.26.1 2025-06-27 22:17:00 -05:00
Matt Walsh
3d0178faa1 radar scrolling fix for ios 2025-06-27 22:16:51 -05:00
Matt Walsh
8a2907e02c fix display of null wind speed 2025-06-27 15:35:15 -05:00
Matt Walsh
b870ce1c01 store already processed radar images for reuse on silent reload 2025-06-27 15:29:20 -05:00
Matt Walsh
15107ffe1c 5.26.0 2025-06-27 08:56:14 -05:00
Matt Walsh
efd4e0c66d Remove workers from build processes 2025-06-27 08:56:04 -05:00
Matt Walsh
652d7c5fb0 Merge remote-tracking branch 'origin/radar-no-worker' #74 #140 2025-06-27 08:54:50 -05:00
Matt Walsh
5a80f43f30 add staging gulp tasks 2025-06-26 22:30:42 -05:00
Matt Walsh
6d090cb1c7 streamline radar tile layout calculation #74 2025-06-26 21:23:44 -05:00
Matt Walsh
b5fa3e49d6 remove radar-worker and offscreen canvas to make things easier for ios #74 2025-06-20 22:04:00 -05:00
Matt Walsh
ef0b60a0b8 Merge pull request #117 from rmitchellscott/fix-radar-mime-chrome
fix: radar mime-type in chrome in Docker
2025-06-20 21:12:40 -05:00
Mitchell Scott
dc13140cc4 fix: radar mime-type in chrome 2025-06-20 08:40:52 -06:00
Matt Walsh
5414b1f5bc Merge pull request #116 from arazilsongweaver/bugfix-docker-nginx-config-add-mime-type
Docker: Add mime.types To nginx Configuration
2025-06-20 09:20:49 -05:00
Arazil
1fdc3635e6 Docker: Add mime.types To nginx Configuration
Explicit configuration of nginx MIME types is required for the proper operation the radar viewer.
2025-06-20 08:06:32 -05:00
Matt Walsh
e2cc86cddd 5.25.3 2025-06-19 23:30:56 -05:00
Matt Walsh
92181c716d clean up linking to radar worker 2025-06-19 23:30:44 -05:00
16 changed files with 354 additions and 236 deletions

View File

@@ -176,6 +176,9 @@ A hook is provided as `/server/scripts/custom.js` to allow customizations to you
When using Docker, mount your `custom.js` file to `/usr/share/nginx/html/scripts/custom.js` to customize the static build.
### RSS feeds and custom scroll
If you would like your Weatherstar to have custom scrolling text in the bottom blue bar, or show headlines from an rss feed turn on the setting for `Enable RSS Feed/Text` and then enter a URL or text in the resulting text box. Then press set.
## Issue reporting and feature requests
Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. I've also observed that the API can go down on a regional basis (based on NWS office locations). This means that you may have problems getting data for, say, Chicago right now, but Dallas and others are working just fine.

View File

@@ -96,29 +96,6 @@ const buildJs = () => src(mjsSources)
.pipe(webpack(webpackOptions))
.pipe(dest(RESOURCES_PATH));
const workerSources = [
'./server/scripts/modules/radar-worker.mjs',
];
const buildWorkers = () => {
// update the file name in the webpack options
const output = {
chunkFilename: '[id].mjs',
chunkFormat: 'module',
filename: '[name].mjs',
};
const workerWebpackOptions = {
...webpackOptions,
output,
entry: {
'radar-worker': workerSources[0],
},
};
return src(workerSources)
.pipe(webpack(workerWebpackOptions))
.pipe(dest(RESOURCES_PATH));
};
const cssSources = [
'server/styles/main.css',
];
@@ -163,9 +140,10 @@ const uploadSources = [
'!dist/images/**/*',
'!dist/fonts/**/*',
];
const upload = () => src(uploadSources, { base: './dist', encoding: false })
const uploadCreator = (bucket) => () => src(uploadSources, { base: './dist', encoding: false })
.pipe(s3({
Bucket: process.env.BUCKET,
Bucket: bucket,
StorageClass: 'STANDARD',
maps: {
CacheControl: (keyname) => {
@@ -181,10 +159,14 @@ const imageSources = [
'server/images/**',
'!server/images/gimp/**',
];
const uploadImages = () => src(imageSources, { base: './server', encoding: false })
const upload = uploadCreator(process.env.BUCKET);
const uploadPreview = uploadCreator(process.env.BUCKET_PREVIEW);
const uploadImagesCreator = (bucket) => () => src(imageSources, { base: './server', encoding: false })
.pipe(
s3({
Bucket: process.env.BUCKET,
Bucket: bucket,
StorageClass: 'STANDARD',
maps: {
CacheControl: () => 'max-age=31536000',
@@ -192,11 +174,14 @@ const uploadImages = () => src(imageSources, { base: './server', encoding: false
}),
);
const uploadImages = uploadImagesCreator(process.env.BUCKET);
const uploadImagesPreview = uploadImagesCreator(process.env.BUCKET_PREVIEW);
const copyImageSources = () => src(imageSources, { base: './server', encoding: false })
.pipe(dest('./dist'));
const invalidate = () => cloudfront.send(new CreateInvalidationCommand({
DistributionId: process.env.DISTRIBUTION_ID,
const invalidateCreator = (distributionId) => () => cloudfront.send(new CreateInvalidationCommand({
DistributionId: distributionId,
InvalidationBatch: {
CallerReference: (new Date()).toLocaleString(),
Paths: {
@@ -206,21 +191,26 @@ const invalidate = () => cloudfront.send(new CreateInvalidationCommand({
},
}));
const invalidate = invalidateCreator(process.env.DISTRIBUTION_ID);
const invalidatePreview = invalidateCreator(process.env.DISTRIBUTION_ID_PREVIEW);
const buildPlaylist = async () => {
const availableFiles = await reader();
const playlist = { availableFiles };
return file('playlist.json', JSON.stringify(playlist)).pipe(dest('./dist'));
};
const buildDist = series(clean, parallel(buildJs, buildWorkers, compressJsData, compressJsVendor, copyCss, compressHtml, copyOtherFiles, copyImageSources, buildPlaylist));
const buildDist = series(clean, parallel(buildJs, compressJsData, compressJsVendor, copyCss, compressHtml, copyOtherFiles, copyImageSources, buildPlaylist));
// upload_images could be in parallel with upload, but _images logs a lot and has little changes
// by running upload last the majority of the changes will be at the bottom of the log for easy viewing
const publishFrontend = series(buildDist, uploadImages, upload, invalidate);
const stageFrontend = series(buildDist, uploadImagesPreview, uploadPreview, invalidatePreview);
export default publishFrontend;
export {
buildDist,
invalidate,
stageFrontend,
};

View File

@@ -1,9 +1,10 @@
import updateVendor from './gulp/update-vendor.mjs';
import publishFrontend, { buildDist, invalidate } from './gulp/publish-frontend.mjs';
import publishFrontend, { buildDist, invalidate, stageFrontend } from './gulp/publish-frontend.mjs';
export {
updateVendor,
publishFrontend,
buildDist,
invalidate,
stageFrontend,
};

View File

@@ -1,6 +1,10 @@
server {
listen 8080;
server_name localhost;
include mime.types;
types {
text/javascript mjs;
}
root /usr/share/nginx/html;

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ws4kp",
"version": "5.25.2",
"version": "5.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ws4kp",
"version": "5.25.2",
"version": "5.27.0",
"license": "MIT",
"dependencies": {
"dotenv": "^16.5.0",

View File

@@ -1,6 +1,6 @@
{
"name": "ws4kp",
"version": "5.25.2",
"version": "5.27.0",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",

View File

@@ -112,10 +112,12 @@ class CurrentWeather extends WeatherDisplay {
condition = shortConditions(condition);
}
const wind = (typeof this.data.WindSpeed === 'number') ? this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' ') : this.data.WindSpeed;
const fill = {
temp: this.data.Temperature + String.fromCharCode(176),
condition,
wind: this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' '),
wind,
location: locationCleanup(this.data.station.properties.name).substr(0, 20),
humidity: `${this.data.Humidity}%`,
dewpoint: this.data.DewPoint + String.fromCharCode(176),
@@ -202,13 +204,15 @@ const parseData = (data) => {
data.WindSpeed = windConverter(observations.windSpeed.value);
data.WindDirection = directionToNSEW(observations.windDirection.value);
data.WindGust = windConverter(observations.windGust.value);
data.WindSpeed = windConverter(data.WindSpeed);
data.WindUnit = windConverter.units;
data.Humidity = Math.round(observations.relativeHumidity.value);
data.Icon = getLargeIcon(observations.icon);
data.PressureDirection = '';
data.TextConditions = observations.textDescription;
// set wind speed of 0 as calm
if (data.WindSpeed === 0) data.WindSpeed = 'Calm';
// difference since last measurement (pascals, looking for difference of more than 150)
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
if (pressureDiff > 150) data.PressureDirection = 'R';

View File

@@ -15,6 +15,7 @@ let screenIndex = 0;
let sinceLastUpdate = 0;
let nextUpdate = DEFAULT_UPDATE;
let resetFlag;
let defaultScreensLoaded = true;
// start drawing conditions
// reset starts from the first item in the text scroll list
@@ -60,7 +61,7 @@ const incrementInterval = (force) => {
stop(display?.elemId === 'progress');
return;
}
screenIndex = (screenIndex + 1) % (lastScreen);
screenIndex = (screenIndex + 1) % (workingScreens.length);
// draw new text
drawScreen();
@@ -78,7 +79,7 @@ const drawScreen = async () => {
// nothing to do if there's no data yet
if (!data) return;
const thisScreen = screens[screenIndex](data);
const thisScreen = workingScreens[screenIndex](data);
// update classes on the scroll area
elemForEach('.weather-display .scroll', (elem) => {
@@ -125,8 +126,10 @@ const hazards = (data) => {
};
};
// additional screens are stored in a separate for simple clearing/resettings
let additionalScreens = [];
// the "screens" are stored in an array for easy addition and removal
const screens = [
const baseScreens = [
// hazards
hazards,
// station name
@@ -168,6 +171,9 @@ const screens = [
},
];
// working screens are the combination of base screens (when active) and additional screens
let workingScreens = [...baseScreens, ...additionalScreens];
// internal draw function with preset parameters
const drawCondition = (text) => {
// update all html scroll elements
@@ -183,19 +189,18 @@ const setHeader = (text) => {
});
};
// store the original number of screens
const originalScreens = screens.length;
let lastScreen = originalScreens;
// reset the number of screens
// reset the screens back to the original set
const reset = () => {
lastScreen = originalScreens;
workingScreens = [...baseScreens];
additionalScreens = [];
defaultScreensLoaded = true;
};
// add screen
const addScreen = (screen) => {
screens.push(screen);
lastScreen += 1;
// add screen, keepBase keeps the regular weather crawl
const addScreen = (screen, keepBase = true) => {
defaultScreensLoaded = false;
additionalScreens.push(screen);
workingScreens = [...(keepBase ? baseScreens : []), ...additionalScreens];
};
const drawScrollCondition = (screen) => {
@@ -238,6 +243,9 @@ const parseMessage = (event) => {
}
};
const screenCount = () => workingScreens.length;
const atDefault = () => defaultScreensLoaded;
// add event listener for start message
window.addEventListener('message', parseMessage);
@@ -245,10 +253,14 @@ window.CurrentWeatherScroll = {
addScreen,
reset,
start,
screenCount,
atDefault,
};
export {
addScreen,
reset,
start,
screenCount,
atDefault,
};

View File

@@ -0,0 +1,130 @@
import Setting from './utils/setting.mjs';
import { reset as resetScroll, addScreen as addScroll } from './currentweatherscroll.mjs';
import { json } from './utils/fetch.mjs';
let firstRun = true;
const parser = new DOMParser();
// change of enable handler
const changeEnable = (newValue) => {
let newDisplay;
if (newValue) {
// add the feed to the scroll
parseFeed(customFeed.value);
// show the string box
newDisplay = 'block';
} else {
// set scroll back to original
resetScroll();
// hide the string entry
newDisplay = 'none';
}
const stringEntry = document.getElementById('settings-customFeed-label');
if (stringEntry) {
stringEntry.style.display = newDisplay;
}
};
// parse the feed/text provided
const parseFeed = (textInput) => {
// skip getting the feed on first run
if (firstRun) return;
// test validity
if (textInput === undefined || textInput === '') {
resetScroll();
}
// test for url
if (textInput.match(/https?:\/\//)) {
getFeed(textInput);
return;
}
// add single text scroll
resetScroll();
addScroll(
() => (
{
type: 'scroll',
text: textInput,
}),
// keep the existing scroll
true,
);
};
// get the rss feed and then swap out the current weather scroll
const getFeed = async (url) => {
// get the text as a string
// it needs to be proxied, use a free service
const rssResponse = await json(`https://api.allorigins.win/get?url=${url}`);
// this returns a data url
// a few sanity checks
if (rssResponse.status.content_type.indexOf('xml') < 0) return;
if (rssResponse.contents.indexOf('base64') > 100) return;
// base 64 decode everything after the comma
const rss = atob(rssResponse.contents.split('base64,')[1]);
// parse the rss
const doc = parser.parseFromString(rss, 'text/xml');
// get the title
const rssTitle = doc.querySelector('channel title').textContent;
// get each item
const titles = [...doc.querySelectorAll('item title')].map((t) => t.textContent);
// reset the scroll, then add the screens
resetScroll();
titles.forEach((title) => {
// data is provided to the screen handler, so we return a function
addScroll(
() => ({
header: rssTitle,
type: 'scroll',
text: title,
}),
// false parameter does not include the default weather scrolls
false,
);
});
};
// change the feed source and re-load if necessary
const changeFeed = (newValue) => {
// first pass through won't have custom feed enable ready
if (firstRun) return;
if (customFeedEnable.value) {
parseFeed(newValue);
}
};
const customFeed = new Setting('customFeed', {
name: 'Custom RSS Feed',
defaultValue: '',
type: 'string',
changeAction: changeFeed,
placeholder: 'Text or URL',
});
const customFeedEnable = new Setting('customFeedEnable', {
name: 'Enable RSS Feed/Text',
defaultValue: false,
changeAction: changeEnable,
});
// initialize the custom feed inputs on the page
document.addEventListener('DOMContentLoaded', () => {
// add the controls to the page
const settingsSection = document.querySelector('#settings');
settingsSection.append(customFeedEnable.generate(), customFeed.generate());
// clear the first run value
firstRun = false;
// call change enable with the current value to show/hide the url box
// and make the call to get the feed if enabled
changeEnable(customFeedEnable.value);
});

View File

@@ -20,45 +20,44 @@ document.addEventListener('DOMContentLoaded', () => {
});
const scanMusicDirectory = async () => {
const parseDirectory = async (path, prefix = "") => {
const listing = await text(path);
const matches = [...listing.matchAll(/href="([^\"]+\.mp3)"/gi)];
return matches.map((m) => `${prefix}${m[1]}`);
};
const parseDirectory = async (path, prefix = '') => {
const listing = await text(path);
const matches = [...listing.matchAll(/href="([^"]+\.mp3)"/gi)];
return matches.map((m) => `${prefix}${m[1]}`);
};
try {
let files = await parseDirectory("music/");
if (files.length === 0) {
files = await parseDirectory("music/default/", "default/");
}
return { availableFiles: files };
} catch (e) {
console.error("Unable to scan music directory");
console.error(e);
return { availableFiles: [] };
}
try {
let files = await parseDirectory('music/');
if (files.length === 0) {
files = await parseDirectory('music/default/', 'default/');
}
return { availableFiles: files };
} catch (e) {
console.error('Unable to scan music directory');
console.error(e);
return { availableFiles: [] };
}
};
const getMedia = async () => {
try {
const response = await fetch('playlist.json');
if (response.ok) {
playlist = await response.json();
} else if (response.status === 404
&& response.headers.get('X-Weatherstar') === 'true') {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
} else {
console.warn(`Couldn't get playlist.json: ${response.status} ${response.statusText}`);
playlist = { availableFiles: [] };
}
} catch (e) {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
}
try {
const response = await fetch('playlist.json');
if (response.ok) {
playlist = await response.json();
} else if (response.status === 404
&& response.headers.get('X-Weatherstar') === 'true') {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
} else {
console.warn(`Couldn't get playlist.json: ${response.status} ${response.statusText}`);
playlist = { availableFiles: [] };
}
} catch (e) {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
}
enableMediaPlayer();
enableMediaPlayer();
};
const enableMediaPlayer = () => {
@@ -219,11 +218,11 @@ const playerEnded = () => {
};
const setTrackName = (fileName) => {
const baseName = fileName.split('/').pop();
const trackName = decodeURIComponent(
baseName.replace(/\.mp3/gi, '').replace(/(_-)/gi, '')
);
document.getElementById('musicTrack').innerHTML = trackName;
const baseName = fileName.split('/').pop();
const trackName = decodeURIComponent(
baseName.replace(/\.mp3/gi, '').replace(/(_-)/gi, ''),
);
document.getElementById('musicTrack').innerHTML = trackName;
};
export {

View File

@@ -1,10 +1,11 @@
import { removeDopplerRadarImageNoise } from './radar-utils.mjs';
import { RADAR_FULL_SIZE, RADAR_FINAL_SIZE } from './radar-constants.mjs';
onmessage = async (e) => {
// process a single radar image and place it on the provided canvas
const processRadar = async (data) => {
const {
url, RADAR_HOST, OVERRIDES, radarSourceXY,
} = e.data;
} = data;
// get the image
const modifiedRadarUrl = OVERRIDES.RADAR_HOST ? url.replace(RADAR_HOST, OVERRIDES.RADAR_HOST) : url;
@@ -19,7 +20,9 @@ onmessage = async (e) => {
};
// create radar context for manipulation
const radarCanvas = new OffscreenCanvas(RADAR_FULL_SIZE.width, RADAR_FULL_SIZE.height);
const radarCanvas = document.createElement('canvas');
radarCanvas.width = RADAR_FULL_SIZE.width;
radarCanvas.height = RADAR_FULL_SIZE.height;
const radarContext = radarCanvas.getContext('2d');
radarContext.imageSmoothingEnabled = false;
@@ -37,7 +40,9 @@ onmessage = async (e) => {
radarContext.drawImage(radarImgElement, 0, 0, RADAR_FULL_SIZE.width, RADAR_FULL_SIZE.height);
// crop the radar image without scaling
const croppedRadarCanvas = new OffscreenCanvas(radarSource.width, radarSource.height);
const croppedRadarCanvas = document.createElement('canvas');
croppedRadarCanvas.width = radarSource.width;
croppedRadarCanvas.height = radarSource.height;
const croppedRadarContext = croppedRadarCanvas.getContext('2d');
croppedRadarContext.imageSmoothingEnabled = false;
croppedRadarContext.drawImage(radarCanvas, radarSource.x, radarSource.y, croppedRadarCanvas.width, croppedRadarCanvas.height, 0, 0, croppedRadarCanvas.width, croppedRadarCanvas.height);
@@ -46,12 +51,14 @@ onmessage = async (e) => {
removeDopplerRadarImageNoise(croppedRadarContext);
// stretch the radar image
const stretchCanvas = new OffscreenCanvas(RADAR_FINAL_SIZE.width, RADAR_FINAL_SIZE.height);
const stretchCanvas = document.createElement('canvas');
stretchCanvas.width = RADAR_FINAL_SIZE.width;
stretchCanvas.height = RADAR_FINAL_SIZE.height;
const stretchContext = stretchCanvas.getContext('2d', { willReadFrequently: true });
stretchContext.imageSmoothingEnabled = false;
stretchContext.drawImage(croppedRadarCanvas, 0, 0, radarSource.width, radarSource.height, 0, 0, RADAR_FINAL_SIZE.width, RADAR_FINAL_SIZE.height);
const stretchedRadar = stretchCanvas.transferToImageBitmap();
postMessage(stretchedRadar, [stretchedRadar]);
return stretchCanvas.toDataURL();
};
export default processRadar;

View File

@@ -39,80 +39,35 @@ const setTiles = (data) => {
// the tiles are arranged as follows, with the horizontal axis as x, and correlating with the second set of digits in the image file number
// T[0] T[1]
// T[2] T[3]
// tile 0 gets special treatment, it's placement is the basis for all downstream calculations
const t0Source = modTile(sourceXY.x, sourceXY.y);
const t0Width = TILE_SIZE.x - t0Source.x;
const t0Height = TILE_SIZE.y - t0Source.y;
const t0FinalSize = { x: t0Width, y: t0Height };
// these will all be used again for the overlay, calculate them once here
const mapCoordinates = [];
// t[0]
mapCoordinates.push({
sx: t0Source.x,
sw: t0Width,
dx: 0,
dw: t0FinalSize.x,
sy: t0Source.y,
sh: t0Height,
dy: 0,
dh: t0FinalSize.y,
});
// t[1]
mapCoordinates.push({
sx: 0,
sw: TILE_SIZE.x - t0Width,
dx: t0FinalSize.x,
dw: TILE_SIZE.x - t0Width,
sy: t0Source.y,
sh: t0Height,
dy: 0,
dh: t0FinalSize.y,
});
// t[2]
mapCoordinates.push({
sx: t0Source.x,
sw: t0Width,
dx: 0,
dw: t0FinalSize.x,
sy: 0,
sh: TILE_SIZE.y - t0Height,
dy: t0FinalSize.y,
dh: TILE_SIZE.y - t0Height,
});
// t[3]
mapCoordinates.push({
sx: 0,
sw: TILE_SIZE.x - t0Width,
dx: t0FinalSize.x,
dw: TILE_SIZE.x - t0Width,
sy: 0,
sh: TILE_SIZE.y - t0Height,
dy: t0FinalSize.y,
dh: TILE_SIZE.y - t0Height,
});
// calculate the shift of tile 0 (upper left)
const tileShift = modTile(sourceXY.x, sourceXY.y);
// determine which tiles are used
const usedTiles = [
true,
mapCoordinates[1].dx < RADAR_FINAL_SIZE.width,
mapCoordinates[2].dy < RADAR_FINAL_SIZE.height,
mapCoordinates[2].dy < RADAR_FINAL_SIZE.height && mapCoordinates[1].dx < RADAR_FINAL_SIZE.width,
TILE_SIZE.x - tileShift.x < RADAR_FINAL_SIZE.width,
TILE_SIZE.y - tileShift.y < RADAR_FINAL_SIZE.width,
];
// if we need t[1] and t[2] then we also need t[3]
usedTiles.push(usedTiles[1] && usedTiles[2]);
// helper function for populating tiles
const populateTile = (tileName) => (elem, index) => {
// check if the tile is used
if (!usedTiles[index]) return;
// set the image source and size
elem.src = `/images/maps/radar/${tileName}-${baseMapTiles[index]}.webp`;
// always set the size to flow the images correctly
elem.width = TILE_SIZE.x;
elem.height = TILE_SIZE.y;
// check if the tile is used
if (!usedTiles[index]) {
elem.src = '';
return;
}
// set the image source and size
const newSource = `/images/maps/radar/${tileName}-${baseMapTiles[index]}.webp`;
if (elem.src === newSource) return;
elem.src = newSource;
};
// populate the map and overlay tiles
@@ -122,7 +77,6 @@ const setTiles = (data) => {
// fill the tiles with the overlay
// shift the map tile containers
const tileShift = modTile(sourceXY.x, sourceXY.y);
const mapTileContainer = document.querySelector(`#${elemIdFull} .map-tiles`);
mapTileContainer.style.top = `${-tileShift.y}px`;
mapTileContainer.style.left = `${-tileShift.x}px`;
@@ -130,12 +84,6 @@ const setTiles = (data) => {
const overlayTileContainer = document.querySelector(`#${elemIdFull} .overlay-tiles`);
overlayTileContainer.style.top = `${-tileShift.y}px`;
overlayTileContainer.style.left = `${-tileShift.x}px`;
// return some useful data
return {
usedTiles,
baseMapTiles,
};
};
export default setTiles;

View File

@@ -1 +0,0 @@
import './radar-worker.mjs';

View File

@@ -5,30 +5,17 @@ import { text } from './utils/fetch.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay, timeZone } from './navigation.mjs';
import * as utils from './radar-utils.mjs';
import { version } from './progress.mjs';
import setTiles from './radar-tiles.mjs';
import processRadar from './radar-processor.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);
// store processed radar as dataURLs to avoid re-processing frames as they slide backwards in time
// this is cleared upon changing the location displayed
let processedRadars = [];
const RADAR_HOST = 'mesonet.agron.iastate.edu';
class Radar extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Local Radar', !isIos && !isBot);
super(navId, elemId, 'Local Radar');
this.okToDrawCurrentConditions = false;
this.okToDrawCurrentDateTime = false;
@@ -69,12 +56,6 @@ 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 = [];
@@ -133,19 +114,44 @@ class Radar extends WeatherDisplay {
elemId: this.elemId,
});
const radarKey = `${radarSourceXY.x.toFixed(0)}-${radarSourceXY.y.toFixed(0)}`;
// reset the "used" flag on pre-processed radars
// items that were not used during this process are deleted (either expired via time or change of location)
processedRadars.forEach((radar) => { radar.used = false; });
// remove any radars that aren't
// Load the most recent doppler radar images.
const radarInfo = await Promise.all(urls.map(async (url, index) => {
const processedRadar = await this.workers[index].processRadar({
const radarInfo = await Promise.all(urls.map(async (url) => {
// store the time
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
const [, year, month, day, hour, minute] = timeMatch;
const radarKeyedTimestamp = `${radarKey}:${year}${month}${day}${hour}${minute}`;
// check for a pre-processed radar
const preProcessed = processedRadars.find((radar) => radar.key === radarKeyedTimestamp);
// use the pre-processed radar, or get a new one
const processedRadar = preProcessed?.dataURL ?? await processRadar({
url,
RADAR_HOST,
OVERRIDES,
radarSourceXY,
});
// store the time
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
// store the radar
if (!preProcessed) {
processedRadars.push({
key: radarKeyedTimestamp,
dataURL: processedRadar,
used: true,
});
} else {
// set used flag
preProcessed.used = true;
}
const [, year, month, day, hour, minute] = timeMatch;
const time = DateTime.fromObject({
year,
month,
@@ -156,15 +162,7 @@ class Radar extends WeatherDisplay {
zone: 'UTC',
}).setZone(timeZone());
const onscreenCanvas = document.createElement('canvas');
onscreenCanvas.width = processedRadar.width;
onscreenCanvas.height = processedRadar.height;
const onscreenContext = onscreenCanvas.getContext('bitmaprenderer');
onscreenContext.transferFromImageBitmap(processedRadar);
const dataUrl = onscreenCanvas.toDataURL();
const elem = this.fillTemplate('frame', { map: { type: 'img', src: dataUrl } });
const elem = this.fillTemplate('frame', { map: { type: 'img', src: processedRadar } });
return {
time,
elem,
@@ -181,12 +179,15 @@ class Radar extends WeatherDisplay {
this.times = radarInfo.map((radar) => radar.time);
this.setStatus(STATUS.loaded);
// clean up any unused stored radars
processedRadars = processedRadars.filter((radar) => radar.used);
}
async drawCanvas() {
super.drawCanvas();
const time = this.times[this.screenIndex].toLocaleString(DateTime.TIME_SIMPLE);
const timePadded = time.length >= 8 ? time : `&nbsp;${time}`;
const timePadded = time.length >= 8 ? time : `&nbsp;${time} `;
this.elem.querySelector('.header .right .time').innerHTML = timePadded;
// get image offset calculation
@@ -200,33 +201,5 @@ class Radar extends WeatherDisplay {
}
}
// create a radar worker with helper functions
const radarWorker = () => {
// create the worker
const worker = new Worker(`/resources/radar-worker.js?_=${version()}`, { type: 'module' });
const processRadar = (data) => new Promise((resolve, reject) => {
// prepare for done message
worker.onmessage = (e) => {
if (e?.data instanceof Error) {
reject(e.data);
} else if (e?.data instanceof ImageBitmap) {
resolve(e.data);
}
};
// start up the worker
worker.postMessage(data);
});
// return the object
return {
processRadar,
};
};
// register display
// TEMPORARY: except on IOS and bots
if (!isIos && !isBot) {
registerDisplay(new Radar(11, 'radar'));
}
registerDisplay(new Radar(11, 'radar'));

View File

@@ -11,6 +11,7 @@ const DEFAULTS = {
sticky: true,
values: [],
visible: true,
placeholder: '',
};
class Setting {
@@ -31,6 +32,7 @@ class Setting {
this.values = options.values;
this.visible = options.visible;
this.changeAction = options.changeAction;
this.placeholder = options.placeholder;
// get value from url
const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`];
@@ -48,6 +50,9 @@ class Setting {
// couldn't parse as a float, store as a string
urlState = urlValue;
}
if (this.type === 'string' && urlValue !== undefined) {
urlState = urlValue;
}
// get existing value if present
const storedValue = urlState ?? this.getFromLocalStorage();
@@ -60,6 +65,9 @@ class Setting {
case 'select':
this.selectChange({ target: { value: this.myValue } });
break;
case 'string':
this.stringChange({ target: { value: this.myValue } });
break;
case 'checkbox':
default:
this.checkboxChange({ target: { checked: this.myValue } });
@@ -124,6 +132,34 @@ class Setting {
return label;
}
generateString() {
// create a string input and accompanying set button
const label = document.createElement('label');
label.for = `settings-${this.shortName}-string`;
label.id = `settings-${this.shortName}-label`;
// text input box
const textInput = document.createElement('input');
textInput.type = 'text';
textInput.value = this.myValue;
textInput.id = `settings-${this.shortName}-string`;
textInput.name = `settings-${this.shortName}-string`;
textInput.placeholder = this.placeholder;
// set button
const setButton = document.createElement('input');
setButton.type = 'button';
setButton.value = 'Set';
setButton.id = `settings-${this.shortName}-button`;
setButton.name = `settings-${this.shortName}-button`;
setButton.addEventListener('click', () => {
this.stringChange({ target: { value: textInput.value } });
});
// assemble
label.append(textInput, setButton);
this.element = label;
return label;
}
checkboxChange(e) {
// update the state
this.myValue = e.target.checked;
@@ -146,6 +182,15 @@ class Setting {
this.changeAction(this.myValue);
}
stringChange(e) {
// update the value
this.myValue = e.target.value;
this.storeToLocalStorage(this.myValue);
// call the change action
this.changeAction(this.myValue);
}
storeToLocalStorage(value) {
if (!this.sticky) return;
const allSettingsString = localStorage?.getItem(SETTINGS_KEY) ?? '{}';
@@ -163,8 +208,8 @@ class Setting {
switch (this.type) {
case 'boolean':
case 'checkbox':
return storedValue;
case 'select':
case 'string':
return storedValue;
default:
return null;
@@ -214,6 +259,8 @@ class Setting {
switch (this.type) {
case 'select':
return this.generateSelect();
case 'string':
return this.generateString();
case 'checkbox':
default:
return this.generateCheckbox();

View File

@@ -54,6 +54,7 @@
<script type="module" src="scripts/modules/radar.mjs"></script>
<script type="module" src="scripts/modules/settings.mjs"></script>
<script type="module" src="scripts/modules/media.mjs"></script>
<script type="module" src="scripts/modules/custom-rss-feed.mjs"></script>
<script type="module" src="scripts/index.mjs"></script>
<!-- data -->
<script type="text/javascript" src="scripts/data/travelcities.js"></script>