From 9eb192146afda10a6118e2089ee94ce607599ae3 Mon Sep 17 00:00:00 2001 From: Matt Walsh Date: Mon, 16 Jun 2025 15:30:56 -0500 Subject: [PATCH] layers as pre-stretched tiles #74 #111 --- server/images/maps/radarnotes.txt | 2 +- server/scripts/modules/radar-constants.mjs | 6 + server/scripts/modules/radar-tiles.mjs | 129 ++++++++++++++++ server/scripts/modules/radar-utils.mjs | 64 +++----- server/scripts/modules/radar-worker-bg-fg.mjs | 139 ------------------ server/scripts/modules/radar-worker.mjs | 13 +- server/scripts/modules/radar.mjs | 83 +---------- 7 files changed, 172 insertions(+), 264 deletions(-) create mode 100644 server/scripts/modules/radar-constants.mjs create mode 100644 server/scripts/modules/radar-tiles.mjs delete mode 100644 server/scripts/modules/radar-worker-bg-fg.mjs diff --git a/server/images/maps/radarnotes.txt b/server/images/maps/radarnotes.txt index 7efb477..7cd23eb 100644 --- a/server/images/maps/radarnotes.txt +++ b/server/images/maps/radarnotes.txt @@ -1 +1 @@ -convert radar-stretched.webp --define webp:losless=true -crop 10x11+0+0@ +repage +adjoin -set filename:row "%[fx:floor(t/10)]" -set filename:col "%[fx:t%10]" radar/map-%[filename:row]-%[filename:col].webp \ No newline at end of file +convert radar-stretched.webp -define webp:losless=true -crop 10x11+0+0@ +repage +adjoin -set filename:row "%[fx:floor(t/10)]" -set filename:col "%[fx:t%10]" radar/map-%[filename:row]-%[filename:col].webp \ No newline at end of file diff --git a/server/scripts/modules/radar-constants.mjs b/server/scripts/modules/radar-constants.mjs new file mode 100644 index 0000000..6d38779 --- /dev/null +++ b/server/scripts/modules/radar-constants.mjs @@ -0,0 +1,6 @@ +export const TILE_SIZE = { x: 680, y: 387 }; +export const TILE_COUNT = { x: 10, y: 11 }; +export const TILE_FULL_SIZE = { x: 6800, y: 4255 }; +export const RADAR_FULL_SIZE = { width: 2550, height: 1600 }; +export const RADAR_FINAL_SIZE = { width: 640, height: 367 }; +export const RADAR_SOURCE_SIZE = { width: 480, height: 276 }; diff --git a/server/scripts/modules/radar-tiles.mjs b/server/scripts/modules/radar-tiles.mjs new file mode 100644 index 0000000..f3e21d2 --- /dev/null +++ b/server/scripts/modules/radar-tiles.mjs @@ -0,0 +1,129 @@ +import { + pixelToFile, modTile, +} from './radar-utils.mjs'; +import { RADAR_FINAL_SIZE, TILE_SIZE } from './radar-constants.mjs'; +import { elemForEach } from './utils/elem.mjs'; + +// creates the radar background map image and overlay transparency +// which remain fixed on the page as the radar image changes in layered divs +// it returns 4 ImageBitmaps that represent the base map, and 4 ImageBitmaps that are the overlay +// the main thread pushes these ImageBitmaps into the image placeholders on the page + +const setTiles = (data) => { + const { + sourceXY, + elemId, + } = data; + const elemIdFull = `${elemId}-html`; + + // determine the basemap images needed + const baseMapTiles = [ + pixelToFile(sourceXY.x, sourceXY.y), + pixelToFile(sourceXY.x + TILE_SIZE.x, sourceXY.y), + pixelToFile(sourceXY.x, sourceXY.y + TILE_SIZE.y), + pixelToFile(sourceXY.x + TILE_SIZE.x, sourceXY.y + TILE_SIZE.y), + ]; + + // do some calculations + // 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, + }); + + // 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, + ]; + + // 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`; + elem.width = TILE_SIZE.x; + elem.height = TILE_SIZE.y; + }; + + // populate the map and overlay tiles + // fill the tiles with the map + elemForEach(`#${elemIdFull} .map-tiles img`, populateTile('map')); + elemForEach(`#${elemIdFull} .overlay-tiles img`, populateTile('overlay')); + + // 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`; + 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; diff --git a/server/scripts/modules/radar-utils.mjs b/server/scripts/modules/radar-utils.mjs index 4317d28..5dc039e 100644 --- a/server/scripts/modules/radar-utils.mjs +++ b/server/scripts/modules/radar-utils.mjs @@ -1,32 +1,23 @@ -const getXYFromLatitudeLongitudeMap = (pos, offsetX, offsetY) => { - let y = 0; - let x = 0; - const imgHeight = 3200; - const imgWidth = 5100; +import { + RADAR_FINAL_SIZE, RADAR_SOURCE_SIZE, TILE_SIZE, TILE_COUNT, TILE_FULL_SIZE, +} from './radar-constants.mjs'; - y = (51.75 - pos.latitude) * 55.2; - // center map - y -= offsetY; +// limit a value to within a range +const coerce = (low, value, high) => Math.max(Math.min(value, high), low); - // Do not allow the map to exceed the max/min coordinates. - if (y > (imgHeight - (offsetY * 2))) { - y = imgHeight - (offsetY * 2); - } else if (y < 0) { - y = 0; - } +const getXYFromLatitudeLongitudeMap = (pos) => { + // source values for conversion + // px py lon lat + // 589 466 -122.3615246 47.63177832 + // 5288 3638 -80.18297384 25.77018996 - x = ((-130.37 - pos.longitude) * 41.775) * -1; - // center map - x -= offsetX; + // map position is calculated as a regresion from the above values (=/- a manual adjustment factor) + // then shifted by half of the tile size (to center the map) + // then they are limited to values between 0 and the width or height of the map + const y = coerce(0, (-145.095 * pos.latitude + 7377.117) - 27 - (TILE_SIZE.y / 2), TILE_FULL_SIZE.y - (TILE_SIZE.y)); + const x = coerce(0, (111.407 * pos.longitude + 14220.972) + 4 - (TILE_SIZE.x / 2), TILE_FULL_SIZE.x - (TILE_SIZE.x)); - // Do not allow the map to exceed the max/min coordinates. - if (x > (imgWidth - (offsetX * 2))) { - x = imgWidth - (offsetX * 2); - } else if (x < 0) { - x = 0; - } - - return { x: x * 2, y: y * 2 }; + return { x, y }; }; const getXYFromLatitudeLongitudeDoppler = (pos, offsetX, offsetY) => { @@ -177,27 +168,23 @@ const mergeDopplerRadarImage = (mapContext, radarContext) => { mapContext.drawImage(radarContext.canvas, 0, 0); }; -const tileSize = { x: 510, y: 320 }; -const radarFullSize = { width: 2550, height: 1600 }; -const radarFinalSize = { width: 640, height: 367 }; -const radarSourceSize = { width: 480, height: 276 }; const scaling = { - width: radarFinalSize.width / radarSourceSize.width, - height: radarFinalSize.height / radarSourceSize.height, + width: RADAR_FINAL_SIZE.width / RADAR_SOURCE_SIZE.width, + height: RADAR_FINAL_SIZE.height / RADAR_SOURCE_SIZE.height, }; // convert a pixel location to a file/tile combination const pixelToFile = (xPixel, yPixel) => { - const xTile = Math.floor(xPixel / tileSize.x); - const yTile = Math.floor(yPixel / tileSize.y); - if (xTile < 0 || xTile > 9 || yTile < 0 || yTile > 9) return false; - return `${xTile.toFixed(0).padStart(2, '0')}-${yTile.toFixed(0).padStart(2, '0')}`; + const xTile = Math.floor(xPixel / TILE_SIZE.x); + const yTile = Math.floor(yPixel / TILE_SIZE.y); + if (xTile < 0 || xTile > TILE_COUNT.x || yTile < 0 || yTile > TILE_COUNT.y) return false; + return `${yTile}-${xTile}`; }; // convert a pixel location in the overall map to a pixel location on the tile const modTile = (xPixel, yPixel) => { - const x = Math.round(xPixel) % tileSize.x; - const y = Math.round(yPixel) % tileSize.y; + const x = Math.round(xPixel) % TILE_SIZE.x; + const y = Math.round(yPixel) % TILE_SIZE.y; return { x, y }; }; @@ -219,8 +206,5 @@ export { pixelToFile, modTile, mapSizeToFinalSize, - tileSize, - radarFinalSize, - radarFullSize, fetchAsBlob, }; diff --git a/server/scripts/modules/radar-worker-bg-fg.mjs b/server/scripts/modules/radar-worker-bg-fg.mjs deleted file mode 100644 index 5b774f7..0000000 --- a/server/scripts/modules/radar-worker-bg-fg.mjs +++ /dev/null @@ -1,139 +0,0 @@ -import { - radarFinalSize, pixelToFile, modTile, tileSize, mapSizeToFinalSize, fetchAsBlob, -} from './radar-utils.mjs'; - -// creates the radar background map image and overlay transparency -// which remain fixed on the page as the radar image changes in layered divs -// it returns 4 ImageBitmaps that represent the base map, and 4 ImageBitmaps that are the overlay -// the main thread pushes these ImageBitmaps into the image placeholders on the page - -const baseMapImages = (tile) => new Promise((resolve) => { - if (tile === false) resolve(false); - fetchAsBlob(`/images/maps/radar-tiles/${tile}.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({ - base: imageBitmap, - overlay: canvas.transferToImageBitmap(), - }); - }); - }); -}); - -onmessage = async (e) => { - const { - sourceXY, offsetX, offsetY, - } = e.data; - - // determine the basemap images needed - const baseMapTiles = [ - pixelToFile(sourceXY.x, sourceXY.y), - pixelToFile(sourceXY.x + offsetX * 2, sourceXY.y), - pixelToFile(sourceXY.x, sourceXY.y + offsetY * 2), - pixelToFile(sourceXY.x + offsetX * 2, sourceXY.y + offsetY * 2), - ]; - - // get the base maps - const baseMapsPromise = Promise.allSettled(baseMapTiles.map(baseMapImages)); - - // do some more calculations for assembling the tiles - // 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 = tileSize.x - t0Source.x; - const t0Height = tileSize.y - t0Source.y; - const t0FinalSize = mapSizeToFinalSize(t0Width, 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: tileSize.x - t0Width, - dx: t0FinalSize.x, - dw: mapSizeToFinalSize(tileSize.x - t0Width, 0).x, - - 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: tileSize.y - t0Height, - dy: t0FinalSize.y, - dh: mapSizeToFinalSize(0, tileSize.y - t0Height).y, - }); - // t[3] - mapCoordinates.push({ - sx: 0, - sw: tileSize.x - t0Width, - dx: t0FinalSize.x, - dw: mapSizeToFinalSize(tileSize.x - t0Width, 0).x, - - sy: 0, - sh: tileSize.y - t0Height, - dy: t0FinalSize.y, - dh: mapSizeToFinalSize(0, tileSize.y - t0Height).y, - }); - - // wait for the basemaps to arrive - const baseMaps = (await baseMapsPromise).map((map) => map.value ?? false); - - // build the response - const t0Base = baseMaps[0].base; - const t0Overlay = baseMaps[0].overlay; - let t1Base; let t1Overlay; let t2Base; let t2Overlay; let t3Base; let t3Overlay; - if (mapCoordinates[1].dx < radarFinalSize.width && baseMaps[1]) { - t1Base = baseMaps[1].base; - t1Overlay = baseMaps[1].overlay; - } - if (mapCoordinates[2].dy < radarFinalSize.height && baseMaps[2]) { - t2Base = baseMaps[2].base; - t2Overlay = baseMaps[2].overlay; - if (mapCoordinates[1].dx < radarFinalSize.width && baseMaps[3]) { - t3Base = baseMaps[3].base; - t3Overlay = baseMaps[3].overlay; - } - } - // baseContext.drawImage(baseMaps.fullMap, sourceXY.x, sourceXY.y, offsetX * 2, offsetY * 2, 0, 0, radarFinalSize.width, radarFinalSize.height); - - postMessage({ - t0Base, t0Overlay, t1Base, t1Overlay, t2Base, t2Overlay, t3Base, t3Overlay, - }, [t0Base, t0Overlay, t1Base, t1Overlay, t2Base, t2Overlay, t3Base, t3Overlay]); -}; diff --git a/server/scripts/modules/radar-worker.mjs b/server/scripts/modules/radar-worker.mjs index 023d481..47ef562 100644 --- a/server/scripts/modules/radar-worker.mjs +++ b/server/scripts/modules/radar-worker.mjs @@ -1,6 +1,7 @@ import { - radarFinalSize, radarFullSize, removeDopplerRadarImageNoise, + removeDopplerRadarImageNoise, } from './radar-utils.mjs'; +import { RADAR_FULL_SIZE, RADAR_FINAL_SIZE } from './radar-constants.mjs'; onmessage = async (e) => { const { @@ -20,7 +21,7 @@ onmessage = async (e) => { }; // create radar context for manipulation - const radarCanvas = new OffscreenCanvas(radarFullSize.width, radarFullSize.height); + const radarCanvas = new OffscreenCanvas(RADAR_FULL_SIZE.width, RADAR_FULL_SIZE.height); const radarContext = radarCanvas.getContext('2d'); radarContext.imageSmoothingEnabled = false; @@ -34,8 +35,8 @@ onmessage = async (e) => { // 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); + 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); // crop the radar image without scaling const croppedRadarCanvas = new OffscreenCanvas(radarSource.width, radarSource.height); @@ -47,10 +48,10 @@ onmessage = async (e) => { removeDopplerRadarImageNoise(croppedRadarContext); // stretch the radar image - const stretchCanvas = new OffscreenCanvas(radarFinalSize.width, radarFinalSize.height); + 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, radarFinalSize.width, radarFinalSize.height); + stretchContext.drawImage(croppedRadarCanvas, 0, 0, radarSource.width, radarSource.height, 0, 0, RADAR_FINAL_SIZE.width, RADAR_FINAL_SIZE.height); const stretchedRadar = stretchCanvas.transferToImageBitmap(); diff --git a/server/scripts/modules/radar.mjs b/server/scripts/modules/radar.mjs index 0fd120e..d8d7fb8 100644 --- a/server/scripts/modules/radar.mjs +++ b/server/scripts/modules/radar.mjs @@ -6,7 +6,7 @@ import WeatherDisplay from './weatherdisplay.mjs'; import { registerDisplay, timeZone } from './navigation.mjs'; import * as utils from './radar-utils.mjs'; import { version } from './progress.mjs'; -import { elemForEach } from './utils/elem.mjs'; +import setTiles from './radar-tiles.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 @@ -74,10 +74,6 @@ class Radar extends WeatherDisplay { // get some web workers started this.workers = (new Array(this.dopplerRadarImageMax)).fill(null).map(() => radarWorker()); } - if (!this.fixedWorker) { - // get the fixed background, overlay worker started - this.fixedWorker = fixedRadarWorker(); - } const baseUrl = `https://${RADAR_HOST}/archive/data/`; const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png'; @@ -128,13 +124,13 @@ class Radar extends WeatherDisplay { // calculate offsets and sizes const offsetX = 120 * 2; const offsetY = 69 * 2; - const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters, offsetX, offsetY); + const sourceXY = utils.getXYFromLatitudeLongitudeMap(this.weatherParameters); const radarSourceXY = utils.getXYFromLatitudeLongitudeDoppler(this.weatherParameters, offsetX, offsetY); - const baseAndOverlayPromise = this.fixedWorker.processAssets({ + // set up the base map and overlay tiles + setTiles({ sourceXY, - offsetX, - offsetY, + elemId: this.elemId, }); // Load the most recent doppler radar images. @@ -172,50 +168,6 @@ class Radar extends WeatherDisplay { elem, }; })); - // wait for the base and overlay - const baseAndOverlay = await baseAndOverlayPromise; - - // calculate final tile size - const finalTileSize = utils.mapSizeToFinalSize(utils.tileSize.x, utils.tileSize.y); - // fill the tiles with the map - elemForEach('.map-tiles img', (elem, index) => { - // get the base image - const base = baseAndOverlay[`t${index}Base`]; - // put it on a canvas - const canvas = document.createElement('canvas'); - const context = canvas.getContext('bitmaprenderer'); - context.transferFromImageBitmap(base); - // if it's not there, return (tile not needed) - if (!base) return; - // assign the bitmap to the image - elem.width = finalTileSize.x; - elem.height = finalTileSize.y; - elem.src = canvas.toDataURL(); - }); - elemForEach('.overlay-tiles img', (elem, index) => { - // get the base image - const base = baseAndOverlay[`t${index}Overlay`]; - // put it on a canvas - const canvas = document.createElement('canvas'); - const context = canvas.getContext('bitmaprenderer'); - context.transferFromImageBitmap(base); - // if it's not there, return (tile not needed) - if (!base) return; - // assign the bitmap to the image - elem.width = finalTileSize.x; - elem.height = finalTileSize.y; - elem.src = canvas.toDataURL(); - }); - // fill the tiles with the overlay - // shift the map tile containers - const tileShift = utils.modTile(sourceXY.x, sourceXY.y); - const tileShiftStretched = utils.mapSizeToFinalSize(tileShift.x, tileShift.y); - const mapTileContainer = this.elem.querySelector('.map-tiles'); - mapTileContainer.style.top = `${-tileShiftStretched.y}px`; - mapTileContainer.style.left = `${-tileShiftStretched.x}px`; - const overlayTileContainer = this.elem.querySelector('.overlay-tiles'); - overlayTileContainer.style.top = `${-tileShiftStretched.y}px`; - overlayTileContainer.style.left = `${-tileShiftStretched.x}px`; // put the elements in the container const scrollArea = this.elem.querySelector('.scroll-area'); @@ -271,31 +223,6 @@ const radarWorker = () => { }; }; -// create a radar worker for the fixed background images -const fixedRadarWorker = () => { - // create the worker - const worker = new Worker(`/resources/radar-worker-bg-fg.mjs?_=${version()}`, { type: 'module' }); - - const processAssets = (data) => new Promise((resolve, reject) => { - // prepare for done message - worker.onmessage = (e) => { - if (e?.data instanceof Error) { - reject(e.data); - } else if (e?.data?.t0Base instanceof ImageBitmap) { - resolve(e.data); - } - }; - - // start up the worker - worker.postMessage(data); - }); - - // return the object - return { - processAssets, - }; -}; - // register display // TEMPORARY: except on IOS and bots if (!isIos && !isBot) {