From 2e1c3ec4b9c9f118ce18bd73299ff1e2afa016dc Mon Sep 17 00:00:00 2001 From: Davide Passoni Date: Fri, 23 Feb 2024 16:25:19 +0100 Subject: [PATCH] Added ability to add more map sources --- frontend/server/app.js | 2 +- frontend/server/routes/resources.js | 252 ++++++++++---------- frontend/website/src/constants/constants.ts | 8 +- frontend/website/src/map/map.ts | 25 +- frontend/website/src/olympusapp.ts | 18 ++ olympus.json | 10 +- 6 files changed, 180 insertions(+), 135 deletions(-) diff --git a/frontend/server/app.js b/frontend/server/app.js index 1f43110f..be9c829c 100644 --- a/frontend/server/app.js +++ b/frontend/server/app.js @@ -15,7 +15,7 @@ module.exports = function (configLocation) { var indexRouter = require('./routes/index'); var uikitRouter = require('./routes/uikit'); var usersRouter = require('./routes/users'); - var resourcesRouter = require('./routes/resources'); + var resourcesRouter = require('./routes/resources')(configLocation); var pluginsRouter = require('./routes/plugins'); /* Load the config and create the express app */ diff --git a/frontend/server/routes/resources.js b/frontend/server/routes/resources.js index ebb34ad7..e2460f42 100644 --- a/frontend/server/routes/resources.js +++ b/frontend/server/routes/resources.js @@ -4,140 +4,152 @@ const fs = require('fs'); const pfs = require('fs/promises') const router = express.Router(); -router.get('/theme/*', function (req, res, next) { - var reqTheme = "olympus"; - - /* Yes, this in an easter egg! :D Feel free to ignore it, or activate the parrot theme to check what it does. Why parrots? The story is a bit long, come to the Discord and ask :D */ - if (reqTheme === "parrot" && !req.url.includes(".css")) - res.redirect('/themes/parrot/images/parrot.svg'); - else - res.redirect(req.url.replace("theme", "themes/" + reqTheme)); -}); +module.exports = function (configLocation) { + router.get('/theme/*', function (req, res, next) { + var reqTheme = "olympus"; + + /* Yes, this in an easter egg! :D Feel free to ignore it, or activate the parrot theme to check what it does. Why parrots? The story is a bit long, come to the Discord and ask :D */ + if (reqTheme === "parrot" && !req.url.includes(".css")) + res.redirect('/themes/parrot/images/parrot.svg'); + else + res.redirect(req.url.replace("theme", "themes/" + reqTheme)); + }); -router.put('/theme/:newTheme', function (req, res, next) { - res.end("Ok"); -}); + router.put('/theme/:newTheme', function (req, res, next) { + res.end("Ok"); + }); -router.get('/maps/:map/:z/:x/:y.png', async function (req, res, next) { - let map = req.params.map; - let x = req.params.x; - let y = req.params.y; - let z = req.params.z; - - if (fs.existsSync(`.\\public\\maps\\${map}`)) { - if (!await renderImage(map, z, x, y, res)) { - /* No image was found */ + router.get('/config', function (req, res, next) { + if (fs.existsSync(configLocation)) { + let rawdata = fs.readFileSync(configLocation); + config = JSON.parse(rawdata); + res.send(JSON.stringify(config.frontend)); + res.end() + } else { res.sendStatus(404); } - } else { - /* The requested map does not exist */ - res.sendStatus(404); - } -}); + }); -async function renderImage(map, z, x, y, res, recursionDepth = 0) { - if (recursionDepth == 20) { - console.log("Render image, maximum recursion depth reached") - /* We have reached limit recusion depth, something went wrong */ - return false; - } - /* If the requested image exists, send it straight away */ - if (fs.existsSync(`.\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`)) { - res.sendFile(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); - return true; - } else { - /* If the requested image doesn't exist check if there is a "source" tile at a lower zoom level we can split up */ - let sourceZoom = z - 1; - let sourceX = Math.floor(x / 2); - let sourceY = Math.floor(y / 2); + router.get('/maps/:map/:z/:x/:y.png', async function (req, res, next) { + let map = req.params.map; + let x = req.params.x; + let y = req.params.y; + let z = req.params.z; - /* Keep decreasing the zoom level until we either find an image or reach maximum zoom out and must return false */ - while (sourceZoom >= 0) { - /* We have found a possible source image */ - if (fs.existsSync(`.\\public\\maps\\${map}\\${sourceZoom}\\${sourceX}\\${sourceY}.png`)) { - /* Split the image into four. We can retry up to 10 times to clear any race condition on the files */ - let retries = 10; - while (!await splitTile(map, sourceZoom, sourceX, sourceY) && retries > 0) { - await new Promise(r => setTimeout(r, 1000)); - retries--; - } - /* Check if we exited because we reached the maximum retry value */ - if (retries != 0) { - /* Recursively recall the function now that we have a new "source" image */ - return await renderImage(map, z, x, y, res, recursionDepth + 1); - } else { - return false; - } - } else { - /* Keep searching at a higher level */ - sourceZoom = sourceZoom - 1; - sourceX = Math.floor(sourceX / 2); - sourceY = Math.floor(sourceY / 2); + if (fs.existsSync(`.\\public\\maps\\${map}`)) { + if (!await renderImage(map, z, x, y, res)) { + /* No image was found */ + res.sendStatus(404); } + } else { + /* The requested map does not exist */ + res.sendStatus(404); + } + }); + + async function renderImage(map, z, x, y, res, recursionDepth = 0) { + if (recursionDepth == 20) { + console.log("Render image, maximum recursion depth reached") + /* We have reached limit recusion depth, something went wrong */ + return false; } - return false; - } -} + /* If the requested image exists, send it straight away */ + if (fs.existsSync(`.\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`)) { + res.sendFile(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); + return true; + } else { + /* If the requested image doesn't exist check if there is a "source" tile at a lower zoom level we can split up */ + let sourceZoom = z - 1; + let sourceX = Math.floor(x / 2); + let sourceY = Math.floor(y / 2); -async function splitTile(map, z, x, y) { - try { - /* Load the source image */ - let img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); - - /* Create the necessary folders */ - await pfs.mkdir(`${process.cwd()}\\public\\maps\\${map}\\${z + 1}\\${2*x}`, { recursive: true }) - await pfs.mkdir(`${process.cwd()}\\public\\maps\\${map}\\${z + 1}\\${2*x + 1}`, { recursive: true }) - - /* Split the image into four parts */ - await resizePromise(img, 0, 0, map, z + 1, 2 * x, 2 * y); - img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); - await resizePromise(img, 128, 0, map, z + 1, 2 * x + 1, 2 * y); - img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); - await resizePromise(img, 0, 128, map, z + 1, 2 * x, 2 * y + 1); - img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); - await resizePromise(img, 128, 128, map, z + 1, 2 * x + 1, 2 * y + 1); - return true; - } catch (err) { - if (err.code !== 'EBUSY') { - console.error(err); + /* Keep decreasing the zoom level until we either find an image or reach maximum zoom out and must return false */ + while (sourceZoom >= 0) { + /* We have found a possible source image */ + if (fs.existsSync(`.\\public\\maps\\${map}\\${sourceZoom}\\${sourceX}\\${sourceY}.png`)) { + /* Split the image into four. We can retry up to 10 times to clear any race condition on the files */ + let retries = 10; + while (!await splitTile(map, sourceZoom, sourceX, sourceY) && retries > 0) { + await new Promise(r => setTimeout(r, 1000)); + retries--; + } + /* Check if we exited because we reached the maximum retry value */ + if (retries != 0) { + /* Recursively recall the function now that we have a new "source" image */ + return await renderImage(map, z, x, y, res, recursionDepth + 1); + } else { + return false; + } + } else { + /* Keep searching at a higher level */ + sourceZoom = sourceZoom - 1; + sourceX = Math.floor(sourceX / 2); + sourceY = Math.floor(sourceY / 2); + } + } + return false; } - return false; } -} -/* Returns a promise, extracts a 128x128 pixel chunk from an image, resizes it to 256x256, and saves it to file */ -function resizePromise(img, left, top, map, z, x, y) { - return new Promise((res, rej) => { - if (fs.existsSync(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`)) { - res(true); - } - img.extract({ left: left, top: top, width: 128, height: 128 }).resize(256, 256).toFile(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.temp.png`, function (err) { - if (err) { - rej(err); - } else { - try { - fs.renameSync(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.temp.png`, `${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); - } catch (err) { - if (err.code === 'EBUSY') { - /* The resource is busy, someone else is writing or renaming it. Reject the promise so we can try again */ - rej(err); - } else if (err.code === 'ENOENT') { - /* Someone else renamed the file already so this is not a real error */ - if (fs.existsSync(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`)) { - res(true); - } - /* Something odd happened, reject and try again */ - else { + async function splitTile(map, z, x, y) { + try { + /* Load the source image */ + let img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); + + /* Create the necessary folders */ + await pfs.mkdir(`${process.cwd()}\\public\\maps\\${map}\\${z + 1}\\${2*x}`, { recursive: true }) + await pfs.mkdir(`${process.cwd()}\\public\\maps\\${map}\\${z + 1}\\${2*x + 1}`, { recursive: true }) + + /* Split the image into four parts */ + await resizePromise(img, 0, 0, map, z + 1, 2 * x, 2 * y); + img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); + await resizePromise(img, 128, 0, map, z + 1, 2 * x + 1, 2 * y); + img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); + await resizePromise(img, 0, 128, map, z + 1, 2 * x, 2 * y + 1); + img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); + await resizePromise(img, 128, 128, map, z + 1, 2 * x + 1, 2 * y + 1); + return true; + } catch (err) { + if (err.code !== 'EBUSY') { + console.error(err); + } + return false; + } + } + + /* Returns a promise, extracts a 128x128 pixel chunk from an image, resizes it to 256x256, and saves it to file */ + function resizePromise(img, left, top, map, z, x, y) { + return new Promise((res, rej) => { + if (fs.existsSync(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`)) { + res(true); + } + img.extract({ left: left, top: top, width: 128, height: 128 }).resize(256, 256).toFile(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.temp.png`, function (err) { + if (err) { + rej(err); + } else { + try { + fs.renameSync(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.temp.png`, `${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`); + } catch (err) { + if (err.code === 'EBUSY') { + /* The resource is busy, someone else is writing or renaming it. Reject the promise so we can try again */ + rej(err); + } else if (err.code === 'ENOENT') { + /* Someone else renamed the file already so this is not a real error */ + if (fs.existsSync(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`)) { + res(true); + } + /* Something odd happened, reject and try again */ + else { + rej(err); + } + } else { rej(err); } - } else { - rej(err); } + res(true) } - res(true) - } - }); - }) + }); + }) + } + return router; } - -module.exports = router; diff --git a/frontend/website/src/constants/constants.ts b/frontend/website/src/constants/constants.ts index 52e185cd..fc96f47a 100644 --- a/frontend/website/src/constants/constants.ts +++ b/frontend/website/src/constants/constants.ts @@ -154,7 +154,7 @@ export const mapBounds = { "SinaiMap": { bounds: new LatLngBounds([34.312222, 28.523333], [25.946944, 36.897778]), zoom: 4 }, } -export const mapLayers = { +export const defaultMapLayers = { "ArcGIS Satellite": { urlTemplate: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", minZoom: 1, @@ -190,12 +190,6 @@ export const mapLayers = { minZoom: 1, maxZoom: 20, attribution: 'CyclOSM | Map data: © OpenStreetMap contributors' - }, - "DCS": { - urlTemplate: 'http://localhost:3000/resources/maps/dcs/{z}/{x}/{y}.png', - minZoom: 16, - maxZoom: 20, - attribution: 'Eagle Dynamics' } } diff --git a/frontend/website/src/map/map.ts b/frontend/website/src/map/map.ts index a10afd7d..32eccb44 100644 --- a/frontend/website/src/map/map.ts +++ b/frontend/website/src/map/map.ts @@ -12,7 +12,7 @@ import { DestinationPreviewMarker } from "./markers/destinationpreviewmarker"; import { TemporaryUnitMarker } from "./markers/temporaryunitmarker"; import { ClickableMiniMap } from "./clickableminimap"; import { SVGInjector } from '@tanem/svg-injector' -import { mapLayers, mapBounds, minimapBoundaries, IDLE, COALITIONAREA_DRAW_POLYGON, MOVE_UNIT, SHOW_UNIT_CONTACTS, HIDE_GROUP_MEMBERS, SHOW_UNIT_PATHS, SHOW_UNIT_TARGETS, SHOW_UNIT_LABELS, SHOW_UNITS_ENGAGEMENT_RINGS, SHOW_UNITS_ACQUISITION_RINGS, HIDE_UNITS_SHORT_RANGE_RINGS, FILL_SELECTED_RING, MAP_MARKER_CONTROLS } from "../constants/constants"; +import { defaultMapLayers, mapBounds, minimapBoundaries, IDLE, COALITIONAREA_DRAW_POLYGON, MOVE_UNIT, SHOW_UNIT_CONTACTS, HIDE_GROUP_MEMBERS, SHOW_UNIT_PATHS, SHOW_UNIT_TARGETS, SHOW_UNIT_LABELS, SHOW_UNITS_ENGAGEMENT_RINGS, SHOW_UNITS_ACQUISITION_RINGS, HIDE_UNITS_SHORT_RANGE_RINGS, FILL_SELECTED_RING, MAP_MARKER_CONTROLS } from "../constants/constants"; import { CoalitionArea } from "./coalitionarea/coalitionarea"; import { CoalitionAreaContextMenu } from "../contextmenus/coalitionareacontextmenu"; import { DrawingCursor } from "./coalitionarea/drawingcursor"; @@ -90,6 +90,7 @@ export class Map extends L.Map { #coalitionAreaContextMenu: CoalitionAreaContextMenu = new CoalitionAreaContextMenu("coalition-area-contextmenu"); #mapSourceDropdown: Dropdown; + #mapLayers: any = defaultMapLayers; #mapMarkerVisibilityControls: MapMarkerVisibilityControl[] = MAP_MARKER_CONTROLS; #mapVisibilityOptionsDropdown: Dropdown; #optionButtons: { [key: string]: HTMLButtonElement[] } = {} @@ -120,10 +121,10 @@ export class Map extends L.Map { this.#ID = ID; - this.setLayer(Object.keys(mapLayers)[0]); + this.setLayer(Object.keys(this.#mapLayers)[0]); /* Minimap */ - var minimapLayer = new L.TileLayer(mapLayers[Object.keys(mapLayers)[0] as keyof typeof mapLayers].urlTemplate, { minZoom: 0, maxZoom: 13 }); + var minimapLayer = new L.TileLayer(this.#mapLayers[Object.keys(this.#mapLayers)[0]].urlTemplate, { minZoom: 0, maxZoom: 13 }); this.#miniMapLayerGroup = new L.LayerGroup([minimapLayer]); this.#miniMapPolyline = new L.Polyline([], { color: '#202831' }); this.#miniMapPolyline.addTo(this.#miniMapLayerGroup); @@ -203,6 +204,18 @@ export class Map extends L.Map { this.getContainer().toggleAttribute("data-hide-labels", !this.getVisibilityOptions()[SHOW_UNIT_LABELS]); }); + document.addEventListener("configLoaded", () => { + let config = getApp().getConfig(); + if (config.additionalMaps) { + let additionalMaps = config.additionalMaps; + this.#mapLayers = { + ...this.#mapLayers, + ...additionalMaps + } + this.#mapSourceDropdown.setOptions(this.getLayers()); + } + }) + /* Pan interval */ this.#panInterval = window.setInterval(() => { if (this.#panUp || this.#panDown || this.#panRight || this.#panLeft) @@ -234,8 +247,8 @@ export class Map extends L.Map { if (this.#layer != null) this.removeLayer(this.#layer) - if (layerName in mapLayers) { - const layerData = mapLayers[layerName as keyof typeof mapLayers]; + if (layerName in this.#mapLayers) { + const layerData = this.#mapLayers[layerName]; var options: L.TileLayerOptions = { attribution: layerData.attribution, minZoom: layerData.minZoom, @@ -248,7 +261,7 @@ export class Map extends L.Map { } getLayers() { - return Object.keys(mapLayers); + return Object.keys(this.#mapLayers); } /* State machine */ diff --git a/frontend/website/src/olympusapp.ts b/frontend/website/src/olympusapp.ts index 4d916d11..e2c5dc09 100644 --- a/frontend/website/src/olympusapp.ts +++ b/frontend/website/src/olympusapp.ts @@ -33,6 +33,7 @@ export class OlympusApp { /* Global data */ #activeCoalition: string = "blue"; #latestVersion: string|undefined = undefined; + #config: any = {}; /* Main leaflet map, extended by custom methods */ #map: Map | null = null; @@ -251,6 +252,19 @@ export class OlympusApp { latestVersionSpan.classList.toggle("new-version", this.#latestVersion !== VERSION); } }) + + /* Load the config file from the server */ + const configRequest = new Request(location.href + "resources/config"); + fetch(configRequest).then((response) => { + if (response.status === 200) { + return response.json(); + } else { + throw new Error("Error retrieving config file"); + } + }).then((res) => { + this.#config = res; + document.dispatchEvent(new CustomEvent("configLoaded")); + }) } #setupEvents() { @@ -446,4 +460,8 @@ export class OlympusApp { img.addEventListener("load", () => { SVGInjector(img); }); }) } + + getConfig() { + return this.#config; + } } \ No newline at end of file diff --git a/olympus.json b/olympus.json index e40a4843..54c8916f 100644 --- a/olympus.json +++ b/olympus.json @@ -14,6 +14,14 @@ "provider": "https://srtm.fasma.org/{lat}{lng}.SRTMGL3S.hgt.zip", "username": null, "password": null - } + }, + "additionalMaps": { + "DCS": { + "urlTemplate": "http://localhost:3000/resources/maps/dcs/{z}/{x}/{y}.png", + "minZoom": 8, + "maxZoom": 20, + "attribution": "Eagle Dynamics" + } + } } } \ No newline at end of file