diff --git a/package-lock.json b/package-lock.json index 3781b6c..de46792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ws4kp", - "version": "5.20.5", + "version": "5.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ws4kp", - "version": "5.20.5", + "version": "5.21.0", "license": "MIT", "dependencies": { "dotenv": "^16.5.0", diff --git a/package.json b/package.json index b0febb5..02506bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws4kp", - "version": "5.20.5", + "version": "5.21.0", "description": "Welcome to the WeatherStar 4000+ project page!", "main": "index.mjs", "type": "module", diff --git a/server/scripts/modules/radar-worker.mjs b/server/scripts/modules/radar-worker.mjs new file mode 100644 index 0000000..75cc6e9 --- /dev/null +++ b/server/scripts/modules/radar-worker.mjs @@ -0,0 +1,106 @@ +import * as utils from './radar-utils.mjs'; + +const radarFullSize = { width: 2550, height: 1600 }; +const radarFinalSize = { width: 640, height: 367 }; + +const fetchAsBlob = async (url) => { + const response = await fetch(url); + return response.blob(); +}; + +const baseMapImages = new Promise((resolve) => { + fetchAsBlob('/images/maps/radar.webp').then((blob) => { + createImageBitmap(blob).then((imageBitmap) => { + // extract the black pixels to overlay on to the final image (boundaries) + const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height); + const context = canvas.getContext('2d'); + context.drawImage(imageBitmap, 0, 0); + const imageData = context.getImageData(0, 0, imageBitmap.width, imageBitmap.height); + + // go through the image data and preserve the black pixels, making the rest transparent + for (let i = 0; i < imageData.data.length; i += 4) { + if (imageData.data[i + 0] >= 116 || imageData.data[i + 1] >= 116 || imageData.data[i + 2] >= 116) { + // make it transparent + imageData.data[i + 3] = 0; + } + } + // write the image data back + context.putImageData(imageData, 0, 0); + + resolve({ + fullMap: imageBitmap, + overlay: canvas, + }); + }); + }); +}); + +onmessage = async (e) => { + const { + url, RADAR_HOST, OVERRIDES, radarSourceXY, sourceXY, offsetX, offsetY, + } = e.data; + + // get the image + const modifiedRadarUrl = OVERRIDES.RADAR_HOST ? url.replace(RADAR_HOST, OVERRIDES.RADAR_HOST) : url; + const radarResponsePromise = fetch(modifiedRadarUrl); + + // calculate offsets and sizes + + const radarSource = { + width: 240, + height: 163, + x: Math.round(radarSourceXY.x / 2), + y: Math.round(radarSourceXY.y / 2), + }; + + // create destination context + const baseCanvas = new OffscreenCanvas(radarFinalSize.width, radarFinalSize.height); + const baseContext = baseCanvas.getContext('2d'); + baseContext.imageSmoothingEnabled = false; + + // create working context for manipulation + const radarCanvas = new OffscreenCanvas(radarFullSize.width, radarFullSize.height); + const radarContext = radarCanvas.getContext('2d'); + radarContext.imageSmoothingEnabled = false; + + // get the base map + const baseMaps = await baseMapImages; + baseContext.drawImage(baseMaps.fullMap, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, radarFinalSize.width, radarFinalSize.height); + + // test response + const radarResponse = await radarResponsePromise; + if (!radarResponse.ok) throw new Error(`Unable to fetch radar error ${radarResponse.status} ${radarResponse.statusText} from ${radarResponse.url}`); + + // get the blob + const radarImgBlob = await radarResponse.blob(); + + // assign to an html image element + const radarImgElement = await createImageBitmap(radarImgBlob); + // draw the entire image + radarContext.clearRect(0, 0, radarFullSize.width, radarFullSize.height); + radarContext.drawImage(radarImgElement, 0, 0, radarFullSize.width, radarFullSize.height); + + // crop the radar image without scaling + const croppedRadarCanvas = new OffscreenCanvas(radarSource.width, 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); + + // clean the image + utils.removeDopplerRadarImageNoise(croppedRadarContext); + + // stretch the radar image + const stretchCanvas = new OffscreenCanvas(radarFinalSize.width, radarFinalSize.height); + const stretchContext = stretchCanvas.getContext('2d', { willReadFrequently: true }); + stretchContext.imageSmoothingEnabled = false; + stretchContext.drawImage(croppedRadarCanvas, 0, 0, radarSource.width, radarSource.height, 0, 0, radarFinalSize.width, radarFinalSize.height); + + // put the radar on the base map + baseContext.drawImage(stretchCanvas, 0, 0); + // put the road/boundaries overlay on the map + baseContext.drawImage(baseMaps.overlay, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, radarFinalSize.width, radarFinalSize.height); + + const processedRadar = baseCanvas.transferToImageBitmap(); + + postMessage(processedRadar, [processedRadar]); +}; diff --git a/server/scripts/modules/radar.mjs b/server/scripts/modules/radar.mjs index dda0e53..cca8cdd 100644 --- a/server/scripts/modules/radar.mjs +++ b/server/scripts/modules/radar.mjs @@ -1,9 +1,7 @@ // current weather conditions display import STATUS from './status.mjs'; import { DateTime } from '../vendor/auto/luxon.mjs'; -import { loadImg } from './utils/image.mjs'; import { text } from './utils/fetch.mjs'; -import { rewriteUrl } from './utils/cors.mjs'; import WeatherDisplay from './weatherdisplay.mjs'; import { registerDisplay, timeZone } from './navigation.mjs'; import * as utils from './radar-utils.mjs'; @@ -41,6 +39,9 @@ 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) { @@ -52,10 +53,6 @@ class Radar extends WeatherDisplay { return; } - // get the base map - const src = 'images/maps/radar.webp'; - this.baseMap = await loadImg(src); - const baseUrl = `https://${RADAR_HOST}/archive/data/`; const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png'; const baseUrls = []; @@ -105,90 +102,45 @@ class Radar extends WeatherDisplay { // calculate offsets and sizes let offsetX = 120; let offsetY = 69; - const width = 2550; - const height = 1600; offsetX *= 2; offsetY *= 2; const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY); - - // calculate radar offsets - const radarOffsetX = 120; - const radarOffsetY = 70; const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY); - const radarSourceX = radarSourceXY.x / 2; - const radarSourceY = radarSourceXY.y / 2; // Load the most recent doppler radar images. - const radarInfo = await Promise.all(urls.map(async (url) => { - // create destination context - const canvas = document.createElement('canvas'); - canvas.width = 640; - canvas.height = 367; - const context = canvas.getContext('2d'); - context.imageSmoothingEnabled = false; - - // create working context for manipulation - const workingCanvas = document.createElement('canvas'); - workingCanvas.width = width; - workingCanvas.height = height; - const workingContext = workingCanvas.getContext('2d'); - workingContext.imageSmoothingEnabled = false; - - // get the image - const modifiedUrl = OVERRIDES.RADAR_HOST ? url.replace(RADAR_HOST, OVERRIDES.RADAR_HOST) : url; - const response = await fetch(rewriteUrl(modifiedUrl)); - - // test response - if (!response.ok) throw new Error(`Unable to fetch radar error ${response.status} ${response.statusText} from ${response.url}`); - - // get the blob - const blob = await response.blob(); + const radarInfo = await Promise.all(urls.map(async (url, index) => { + const processedRadar = await this.workers[index].processRadar({ + url, + RADAR_HOST, + OVERRIDES, + sourceXY, + radarSourceXY, + offsetX, + offsetY, + }); // store the time const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./); - let time; - if (timeMatch) { - const [, year, month, day, hour, minute] = timeMatch; - time = DateTime.fromObject({ - year, - month, - day, - hour, - minute, - }, { - zone: 'UTC', - }).setZone(timeZone()); - } else { - time = DateTime.fromHTTP(response.headers.get('last-modified')).setZone(timeZone()); - } - // assign to an html image element - const imgBlob = await loadImg(blob); + const [, year, month, day, hour, minute] = timeMatch; + const time = DateTime.fromObject({ + year, + month, + day, + hour, + minute, + }, { + zone: 'UTC', + }).setZone(timeZone()); - // draw the entire image - workingContext.clearRect(0, 0, width, 1600); - workingContext.drawImage(imgBlob, 0, 0, width, 1600); - - // get the base map - context.drawImage(this.baseMap, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, 640, 367); - - // crop the radar image - const cropCanvas = document.createElement('canvas'); - cropCanvas.width = 640; - cropCanvas.height = 367; - const cropContext = cropCanvas.getContext('2d', { willReadFrequently: true }); - cropContext.imageSmoothingEnabled = false; - cropContext.drawImage(workingCanvas, radarSourceX, radarSourceY, (radarOffsetX * 2), (radarOffsetY * 2.33), 0, 0, 640, 367); - // clean the image - utils.removeDopplerRadarImageNoise(cropContext); - - // merge the radar and map - utils.mergeDopplerRadarImage(context, cropContext); - - const elem = this.fillTemplate('frame', { map: { type: 'img', src: canvas.toDataURL() } }); + const onscreenCanvas = document.createElement('canvas'); + onscreenCanvas.width = processedRadar.width; + onscreenCanvas.height = processedRadar.height; + const onscreenContext = onscreenCanvas.getContext('bitmaprenderer'); + onscreenContext.transferFromImageBitmap(processedRadar); + const elem = this.fillTemplate('frame', { map: { type: 'canvas', canvas: onscreenCanvas } }); return { - canvas, time, elem, }; @@ -201,8 +153,6 @@ class Radar extends WeatherDisplay { // set max length this.timing.totalScreens = radarInfo.length; - // store the images - this.data = radarInfo.map((radar) => radar.canvas); this.times = radarInfo.map((radar) => radar.time); this.setStatus(STATUS.loaded); @@ -225,5 +175,30 @@ class Radar extends WeatherDisplay { } } +// create a radar worker with helper functions +const radarWorker = () => { + // create the worker + const worker = new Worker(new URL('./radar-worker.mjs', import.meta.url), { type: 'module' }); + + const processRadar = (url) => 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(url); + }); + + // return the object + return { + processRadar, + }; +}; + // register display registerDisplay(new Radar(11, 'radar')); diff --git a/server/scripts/modules/utils/image.mjs b/server/scripts/modules/utils/image.mjs index 1a7cabe..5d62363 100644 --- a/server/scripts/modules/utils/image.mjs +++ b/server/scripts/modules/utils/image.mjs @@ -28,7 +28,15 @@ 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, preloadImg, + loadImgElement, }; diff --git a/server/scripts/modules/weatherdisplay.mjs b/server/scripts/modules/weatherdisplay.mjs index d9e3a07..34a5b75 100644 --- a/server/scripts/modules/weatherdisplay.mjs +++ b/server/scripts/modules/weatherdisplay.mjs @@ -421,6 +421,8 @@ class WeatherDisplay { } else if (value?.type === 'img') { // fill the image source elem.querySelector('img').src = value.src; + } else if (value?.type === 'canvas') { + elem.append(value.canvas); } }); diff --git a/views/partials/radar.ejs b/views/partials/radar.ejs index 5952f81..7b2c42d 100644 --- a/views/partials/radar.ejs +++ b/views/partials/radar.ejs @@ -35,7 +35,7 @@
+