From 8ead95c04162ea3e767920dbbd09a4f45e5ff8f8 Mon Sep 17 00:00:00 2001 From: Eddy G Date: Tue, 24 Jun 2025 23:35:41 -0400 Subject: [PATCH] Improve error handling and API efficiency - Switch to safe*() methods for centralized error handling - Add error handling and validation - Optimize radar API usage by only fetching yesterday's data when needed - Use centralized URL rewriting for caching proxy support - Add debug logging throughout radar processing pipeline - Improve canvas context validation and error recovery - Handle worker errors gracefully by setting totalScreens = 0 to skip in animation - Remove unused OVERRIDES parameter passing to workers --- server/scripts/modules/radar-utils.mjs | 183 +++++++++++++----------- server/scripts/modules/radar-worker.mjs | 108 ++++++++------ server/scripts/modules/radar.mjs | 178 ++++++++++++++--------- 3 files changed, 281 insertions(+), 188 deletions(-) diff --git a/server/scripts/modules/radar-utils.mjs b/server/scripts/modules/radar-utils.mjs index 15a15ba..e95a2d2 100644 --- a/server/scripts/modules/radar-utils.mjs +++ b/server/scripts/modules/radar-utils.mjs @@ -33,91 +33,108 @@ const getXYFromLatitudeLongitudeDoppler = (pos, offsetX, offsetY) => { }; 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; + // Validate canvas context and dimensions before calling getImageData + if (!RadarContext || !RadarContext.canvas) { + console.error('Invalid radar context provided to removeDopplerRadarImageNoise'); + return; } - RadarContext.putImageData(RadarImageData, 0, 0); + const { canvas } = RadarContext; + if (canvas.width <= 0 || canvas.height <= 0) { + console.error(`Invalid canvas dimensions in removeDopplerRadarImageNoise: ${canvas.width}x${canvas.height}`); + return; + } + + try { + const RadarImageData = RadarContext.getImageData(0, 0, canvas.width, 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); + } catch (error) { + console.error(`Error in removeDopplerRadarImageNoise: ${error.message}. Canvas size: ${canvas.width}x${canvas.height}`); + // Don't re-throw the error, just log it and continue processing + } }; export { diff --git a/server/scripts/modules/radar-worker.mjs b/server/scripts/modules/radar-worker.mjs index c81aba4..2325644 100644 --- a/server/scripts/modules/radar-worker.mjs +++ b/server/scripts/modules/radar-worker.mjs @@ -2,56 +2,82 @@ import { removeDopplerRadarImageNoise } from './radar-utils.mjs'; import { RADAR_FULL_SIZE, RADAR_FINAL_SIZE } from './radar-constants.mjs'; onmessage = async (e) => { - const { - url, RADAR_HOST, OVERRIDES, radarSourceXY, - } = e.data; + try { + const { + url, radarSourceXY, debug, + } = e.data; - // get the image - const modifiedRadarUrl = OVERRIDES.RADAR_HOST ? url.replace(RADAR_HOST, OVERRIDES.RADAR_HOST) : url; - const radarResponsePromise = fetch(modifiedRadarUrl); + if (debug) { + console.log('[RADAR-WORKER] Message received at:', new Date().toISOString(), 'File:', url.split('/').pop()); + } - // calculate offsets and sizes - const radarSource = { - width: 240, - height: 163, - x: Math.round(radarSourceXY.x / 2), - y: Math.round(radarSourceXY.y / 2), - }; + // get the image (URL is already rewritten for caching by radar.mjs) + const radarResponsePromise = fetch(url); - // create radar context for manipulation - const radarCanvas = new OffscreenCanvas(RADAR_FULL_SIZE.width, RADAR_FULL_SIZE.height); - const radarContext = radarCanvas.getContext('2d'); - radarContext.imageSmoothingEnabled = false; + // calculate offsets and sizes + const radarSource = { + width: 240, + height: 163, + x: Math.round(radarSourceXY.x / 2), + y: Math.round(radarSourceXY.y / 2), + }; - // test response - const radarResponse = await radarResponsePromise; - if (!radarResponse.ok) throw new Error(`Unable to fetch radar error ${radarResponse.status} ${radarResponse.statusText} from ${radarResponse.url}`); + // create radar context for manipulation + const radarCanvas = new OffscreenCanvas(RADAR_FULL_SIZE.width, RADAR_FULL_SIZE.height); + const radarContext = radarCanvas.getContext('2d'); + if (!radarContext) { + throw new Error('Failed to get radar canvas context'); + } - // get the blob - const radarImgBlob = await radarResponse.blob(); + radarContext.imageSmoothingEnabled = false; - // assign to an html image element - const radarImgElement = await createImageBitmap(radarImgBlob); - // draw the entire image - radarContext.clearRect(0, 0, RADAR_FULL_SIZE.width, RADAR_FULL_SIZE.height); - radarContext.drawImage(radarImgElement, 0, 0, RADAR_FULL_SIZE.width, RADAR_FULL_SIZE.height); + // test response + const radarResponse = await radarResponsePromise; + if (!radarResponse.ok) throw new Error(`Unable to fetch radar image: got ${radarResponse.status} ${radarResponse.statusText} from ${radarResponse.url}`); - // 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); + // get the blob + const radarImgBlob = await radarResponse.blob(); - // clean the image - removeDopplerRadarImageNoise(croppedRadarContext); + // assign to an html image element + const radarImgElement = await createImageBitmap(radarImgBlob); + // draw the entire image + radarContext.clearRect(0, 0, RADAR_FULL_SIZE.width, RADAR_FULL_SIZE.height); + radarContext.drawImage(radarImgElement, 0, 0, RADAR_FULL_SIZE.width, RADAR_FULL_SIZE.height); - // stretch the radar image - const stretchCanvas = new OffscreenCanvas(RADAR_FINAL_SIZE.width, 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); + // crop the radar image without scaling + const croppedRadarCanvas = new OffscreenCanvas(radarSource.width, radarSource.height); + const croppedRadarContext = croppedRadarCanvas.getContext('2d'); + if (!croppedRadarContext) { + throw new Error('Failed to get cropped radar canvas context'); + } - const stretchedRadar = stretchCanvas.transferToImageBitmap(); + croppedRadarContext.imageSmoothingEnabled = false; + croppedRadarContext.drawImage(radarCanvas, radarSource.x, radarSource.y, croppedRadarCanvas.width, croppedRadarCanvas.height, 0, 0, croppedRadarCanvas.width, croppedRadarCanvas.height); - postMessage(stretchedRadar, [stretchedRadar]); + // clean the image + removeDopplerRadarImageNoise(croppedRadarContext); + + // stretch the radar image + const stretchCanvas = new OffscreenCanvas(RADAR_FINAL_SIZE.width, RADAR_FINAL_SIZE.height); + const stretchContext = stretchCanvas.getContext('2d', { willReadFrequently: true }); + if (!stretchContext) { + throw new Error('Failed to get stretch canvas context'); + } + + 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(); + + if (debug) { + console.log('[RADAR-WORKER] Sending processed radar at:', new Date().toISOString(), 'Canvas size:', stretchCanvas.width, 'x', stretchCanvas.height, 'File:', url.split('/').pop()); + } + + postMessage(stretchedRadar, [stretchedRadar]); + } catch (error) { + console.warn('[RADAR-WORKER] Error at:', new Date().toISOString(), 'Error:', error.message); + // Handle radar fetch errors by indicating failure to the main thread + // This allows the radar display to set totalScreens = 0 and skip in animation + postMessage({ error: true, message: error.message }); + } }; diff --git a/server/scripts/modules/radar.mjs b/server/scripts/modules/radar.mjs index 3e22a13..1723cfe 100644 --- a/server/scripts/modules/radar.mjs +++ b/server/scripts/modules/radar.mjs @@ -1,10 +1,12 @@ // current weather conditions display import STATUS from './status.mjs'; import { DateTime } from '../vendor/auto/luxon.mjs'; -import { text } from './utils/fetch.mjs'; +import { safeText } from './utils/fetch.mjs'; import WeatherDisplay from './weatherdisplay.mjs'; import { registerDisplay, timeZone } from './navigation.mjs'; import * as utils from './radar-utils.mjs'; +import { rewriteUrl } from './utils/url-rewrite.mjs'; +import { debugFlag } from './utils/debug.mjs'; import { version } from './progress.mjs'; import setTiles from './radar-tiles.mjs'; @@ -25,7 +27,8 @@ const isIos = /iP(ad|od|hone)/i.test(window.navigator.userAgent); // context. const isBot = /twitterbot|Facebot/i.test(window.navigator.userAgent); -const RADAR_HOST = 'mesonet.agron.iastate.edu'; +// Use OVERRIDE_RADAR_HOST if provided, otherwise default to mesonet +const RADAR_HOST = (typeof OVERRIDES !== 'undefined' ? OVERRIDES?.RADAR_HOST : undefined) || 'mesonet.agron.iastate.edu'; class Radar extends WeatherDisplay { constructor(navId, elemId) { super(navId, elemId, 'Local Radar', !isIos && !isBot); @@ -76,35 +79,60 @@ class Radar extends WeatherDisplay { } const baseUrl = `https://${RADAR_HOST}/archive/data/`; - const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png'; - const baseUrls = []; - let date = DateTime.utc().minus({ days: 1 }).startOf('day'); + const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png'; // This URL returns an index of .png files for the given date - // make urls for yesterday and today - while (date <= DateTime.utc().startOf('day')) { - baseUrls.push(`${baseUrl}${date.toFormat('yyyy/LL/dd')}${baseUrlEnd}`); - date = date.plus({ days: 1 }); + // Always get today's data + const today = DateTime.utc().startOf('day'); + const todayStr = today.toFormat('yyyy/LL/dd'); + const yesterday = today.minus({ days: 1 }); + const yesterdayStr = yesterday.toFormat('yyyy/LL/dd'); + const todayUrl = `${baseUrl}${todayStr}${baseUrlEnd}`; + + // Get today's data, then we'll see if we need yesterday's + const todayList = await safeText(todayUrl); + + // Count available images from today + let todayImageCount = 0; + if (todayList) { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(todayList, 'text/html'); + const anchors = xmlDoc.querySelectorAll('a'); + todayImageCount = Array.from(anchors).filter((elem) => elem.innerHTML?.match(/n0r_\d{12}\.png/)).length; } - const lists = (await Promise.all(baseUrls.map(async (url) => { - try { - // get a list of available radars - return text(url); - } catch (error) { - console.log('Unable to get list of radars'); - console.error(error); - this.setStatus(STATUS.failed); - return false; - } - }))).filter((d) => d); + // Only fetch yesterday's data if we don't have enough images from today + // or if it's very early in the day when recent images might still be from yesterday + const currentTimeUTC = DateTime.utc(); + const minutesSinceMidnight = currentTimeUTC.hour * 60 + currentTimeUTC.minute; + const requiredTimeWindow = this.dopplerRadarImageMax * 5; // 5 minutes per image + const needYesterday = todayImageCount < this.dopplerRadarImageMax || minutesSinceMidnight < requiredTimeWindow; - // convert to an array of gif urls + // Build the final lists array + const lists = []; + if (needYesterday) { + const yesterdayUrl = `${baseUrl}${yesterdayStr}${baseUrlEnd}`; + const yesterdayList = await safeText(yesterdayUrl); + if (yesterdayList) { + lists.push(yesterdayList); // Add yesterday's data first + } + } + if (todayList) { + lists.push(todayList); // Add today's data + } + + // convert to an array of png urls const pngs = lists.flatMap((html, htmlIdx) => { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(html, 'text/html'); - // add the base url + // add the base url - reconstruct the URL for each list const base = xmlDoc.createElement('base'); - base.href = baseUrls[htmlIdx]; + if (htmlIdx === 0 && needYesterday) { + // First item is yesterday's data when we fetched it + base.href = `${baseUrl}${yesterdayStr}${baseUrlEnd}`; + } else { + // This is today's data (or the only data if yesterday wasn't fetched) + base.href = `${baseUrl}${todayStr}${baseUrlEnd}`; + } xmlDoc.head.append(base); const anchors = xmlDoc.querySelectorAll('a'); const urls = []; @@ -134,53 +162,58 @@ class Radar extends WeatherDisplay { }); // Load the most recent doppler radar images. - const radarInfo = await Promise.all(urls.map(async (url, index) => { - const processedRadar = await this.workers[index].processRadar({ - url, - RADAR_HOST, - OVERRIDES, - radarSourceXY, - }); + try { + const radarInfo = await Promise.all(urls.map(async (url, index) => { + const processedRadar = await this.workers[index].processRadar({ + url: rewriteUrl(url).toString(), // Apply URL rewriting for caching; convert to string for worker + radarSourceXY, + debug: debugFlag('radar'), + }); - // store the time - const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./); + // 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 time = DateTime.fromObject({ - year, - month, - day, - hour, - minute, - }, { - zone: 'UTC', - }).setZone(timeZone()); + const [, year, month, day, hour, minute] = timeMatch; + const time = DateTime.fromObject({ + year, + month, + day, + hour, + minute, + }, { + 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 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 dataUrl = onscreenCanvas.toDataURL(); - const elem = this.fillTemplate('frame', { map: { type: 'img', src: dataUrl } }); - return { - time, - elem, - }; - })); + const elem = this.fillTemplate('frame', { map: { type: 'img', src: dataUrl } }); + return { + time, + elem, + }; + })); - // put the elements in the container - const scrollArea = this.elem.querySelector('.scroll-area'); - scrollArea.innerHTML = ''; - scrollArea.append(...radarInfo.map((r) => r.elem)); + // put the elements in the container + const scrollArea = this.elem.querySelector('.scroll-area'); + scrollArea.innerHTML = ''; + scrollArea.append(...radarInfo.map((r) => r.elem)); - // set max length - this.timing.totalScreens = radarInfo.length; + // set max length + this.timing.totalScreens = radarInfo.length; - this.times = radarInfo.map((radar) => radar.time); - this.setStatus(STATUS.loaded); + this.times = radarInfo.map((radar) => radar.time); + this.setStatus(STATUS.loaded); + } catch (_error) { + // Radar fetch failed - skip this display in animation by setting totalScreens = 0 + this.timing.totalScreens = 0; + if (this.isEnabled) this.setStatus(STATUS.failed); + } } async drawCanvas() { @@ -206,17 +239,34 @@ const radarWorker = () => { const worker = new Worker(`/resources/radar-worker.mjs?_=${version()}`, { type: 'module' }); const processRadar = (data) => new Promise((resolve, reject) => { + if (debugFlag('radar')) { + console.log('[RADAR-MAIN] Posting to worker at:', new Date().toISOString(), 'File:', data.url.split('/').pop()); + } // prepare for done message worker.onmessage = (e) => { - if (e?.data instanceof Error) { + if (debugFlag('radar')) { + console.log('[RADAR-MAIN] Received from worker at:', new Date().toISOString(), 'Data type:', e?.data?.constructor?.name); + } + if (e?.data?.error) { + console.warn('[RADAR-MAIN] Worker error:', e.data.message); + // Worker encountered an error + reject(new Error(e.data.message)); + } else if (e?.data instanceof Error) { + console.warn('[RADAR-MAIN] Worker exception:', e.data); reject(e.data); } else if (e?.data instanceof ImageBitmap) { + if (debugFlag('radar')) { + console.log('[RADAR-MAIN] Successfully received ImageBitmap, size:', e.data.width, 'x', e.data.height); + } resolve(e.data); } }; // start up the worker - worker.postMessage(data); + worker.postMessage({ + ...data, + debug: debugFlag('radar'), + }); }); // return the object