diff --git a/common/webapp/.eslintrc.cjs b/common/webapp/.eslintrc.cjs index 3f4c9faa7..f285f17a3 100644 --- a/common/webapp/.eslintrc.cjs +++ b/common/webapp/.eslintrc.cjs @@ -1,6 +1,9 @@ /* eslint-env node */ module.exports = { root: true, + env: { + es2022: true + }, 'extends': [ 'plugin:vue/vue3-essential', 'eslint:recommended' diff --git a/common/webapp/src/js/BlueMapApp.js b/common/webapp/src/js/BlueMapApp.js index 49beeed46..08a27b8b1 100644 --- a/common/webapp/src/js/BlueMapApp.js +++ b/common/webapp/src/js/BlueMapApp.js @@ -26,13 +26,14 @@ import "./BlueMap"; import {MapViewer} from "./MapViewer"; import {MapControls} from "./controls/map/MapControls"; import {FreeFlightControls} from "./controls/freeflight/FreeFlightControls"; -import {FileLoader, MathUtils, Vector3} from "three"; +import {MathUtils, Vector3} from "three"; import {Map as BlueMapMap} from "./map/Map"; -import {alert, animate, EasingFunctions, generateCacheHash} from "./util/Utils"; +import {alert, animate, EasingFunctions} from "./util/Utils"; import {MainMenu} from "./MainMenu"; import {PopupMarker} from "./PopupMarker"; import {MarkerSet} from "./markers/MarkerSet"; import {getLocalStorage, round, setLocalStorage} from "./Utils"; +import {RevalidatingFileLoader} from "./util/RevalidatingFileLoader"; import {i18n, setLanguage} from "../i18n"; import {PlayerMarkerManager} from "./markers/PlayerMarkerManager"; import {NormalMarkerManager} from "./markers/NormalMarkerManager"; @@ -310,7 +311,7 @@ export class BlueMapApp { let map = new BlueMapMap(mapId, settings.mapDataRoot + "/" + mapId, settings.liveDataRoot + "/" + mapId, this.loadBlocker, this.mapViewer.events); maps.push(map); - return map.loadSettings(this.mapViewer.tileCacheHash) + return map.loadSettings(this.mapViewer.revalidatedUrls) .catch(error => { alert(this.events, `Failed to load settings for map '${map.data.id}':` + error, "warning"); }); @@ -366,9 +367,10 @@ export class BlueMapApp { */ loadSettings() { return new Promise((resolve, reject) => { - let loader = new FileLoader(); + let loader = new RevalidatingFileLoader(); + loader.setRevalidatedUrls(new Set()); // force no-cache requests loader.setResponseType("json"); - loader.load("settings.json?" + generateCacheHash(), + loader.load("settings.json", resolve, () => {}, () => reject("Failed to load the settings.json!") @@ -382,9 +384,10 @@ export class BlueMapApp { */ loadPlayerData(map) { return new Promise((resolve, reject) => { - let loader = new FileLoader(); + let loader = new RevalidatingFileLoader(); + loader.setRevalidatedUrls(new Set()); // force no-cache requests loader.setResponseType("json"); - loader.load(map.data.liveDataRoot + "/live/players.json?" + generateCacheHash(), + loader.load(map.data.liveDataRoot + "/live/players.json", fileData => { if (!fileData) reject(`Failed to parse '${this.fileUrl}'!`); else resolve(fileData); @@ -636,11 +639,11 @@ export class BlueMapApp { return; } - // Only reuse the user's tile cash hash if the current browser navigation event is not a reload. - // If it's a reload, we assume the user is troubleshooting and actually wants to refresh the map. + // If it's a reload, we assume the user is troubleshooting and actually + // wants to fully refresh the map. const [entry] = performance.getEntriesByType("navigation"); - if (entry.type != "reload") { - this.mapViewer.clearTileCache(this.loadUserSetting("tileCacheHash", this.mapViewer.tileCacheHash)); + if (entry.type === "reload") { + this.mapViewer.clearTileCache(); } this.mapViewer.superSampling = this.loadUserSetting("superSampling", this.mapViewer.data.superSampling); @@ -665,7 +668,6 @@ export class BlueMapApp { if (!this.settings.useCookies) return; this.saveUserSetting("resetSettings", false); - this.saveUserSetting("tileCacheHash", this.mapViewer.tileCacheHash); this.saveUserSetting("superSampling", this.mapViewer.data.superSampling); this.saveUserSetting("hiresViewDistance", this.mapViewer.data.loadedHiresViewDistance); diff --git a/common/webapp/src/js/MapViewer.js b/common/webapp/src/js/MapViewer.js index c2b68388e..97d776ed8 100644 --- a/common/webapp/src/js/MapViewer.js +++ b/common/webapp/src/js/MapViewer.js @@ -27,7 +27,7 @@ import {Map} from "./map/Map"; import {SkyboxScene} from "./skybox/SkyboxScene"; import {ControlsManager} from "./controls/ControlsManager"; import Stats from "./util/Stats"; -import {alert, dispatchEvent, elementOffset, generateCacheHash, htmlToElement, softClamp} from "./util/Utils"; +import {alert, dispatchEvent, elementOffset, htmlToElement, softClamp} from "./util/Utils"; import {TileManager} from "./map/TileManager"; import {HIRES_VERTEX_SHADER} from "./map/hires/HiresVertexShader"; import {HIRES_FRAGMENT_SHADER} from "./map/hires/HiresFragmentShader"; @@ -79,7 +79,12 @@ export class MapViewer { loadedLowresViewDistance: 2000, }); - this.tileCacheHash = generateCacheHash(); + /** @import { RevalidatingFileLoader } from "./util/RevalidatingFileLoader" */ + /** + * Used by {@link RevalidatingFileLoader}. + * @type {Set | undefined} + */ + this.revalidatedUrls = undefined; this.stats = new Stats(); this.stats.hide(); @@ -405,7 +410,7 @@ export class MapViewer { this.map = map; if (this.map && this.map.isMap) { - return map.load(HIRES_VERTEX_SHADER, HIRES_FRAGMENT_SHADER, LOWRES_VERTEX_SHADER, LOWRES_FRAGMENT_SHADER, this.data.uniforms, this.tileCacheHash) + return map.load(HIRES_VERTEX_SHADER, HIRES_FRAGMENT_SHADER, LOWRES_VERTEX_SHADER, LOWRES_FRAGMENT_SHADER, this.data.uniforms, this.revalidatedUrls) .then(() => { for (let texture of this.map.loadedTextures){ this.renderer.initTexture(texture); @@ -462,15 +467,13 @@ export class MapViewer { } } - clearTileCache(newTileCacheHash) { - if (!newTileCacheHash) newTileCacheHash = generateCacheHash(); - - this.tileCacheHash = newTileCacheHash; + clearTileCache() { + this.revalidatedUrls = new Set(); if (this.map) { for (let i = 0; i < this.map.lowresTileManager.length; i++) { - this.map.lowresTileManager[i].tileLoader.tileCacheHash = this.tileCacheHash; + this.map.lowresTileManager[i].tileLoader.revalidatedUrls = this.revalidatedUrls; } - this.map.hiresTileManager.tileLoader.tileCacheHash = this.tileCacheHash; + this.map.hiresTileManager.tileLoader.revalidatedUrls = this.revalidatedUrls; } } diff --git a/common/webapp/src/js/map/LowresTileLoader.js b/common/webapp/src/js/map/LowresTileLoader.js index dea3af6fb..632d6052c 100644 --- a/common/webapp/src/js/map/LowresTileLoader.js +++ b/common/webapp/src/js/map/LowresTileLoader.js @@ -24,7 +24,6 @@ */ import {pathFromCoords} from "../util/Utils"; import { - TextureLoader, Mesh, PlaneGeometry, FrontSide, @@ -34,23 +33,25 @@ import { NearestMipMapLinearFilter, Vector2 } from "three"; +import {RevalidatingTextureLoader} from "../util/RevalidatingTextureLoader"; export class LowresTileLoader { - constructor(tilePath, tileSettings, lod, vertexShader, fragmentShader, uniforms, loadBlocker = () => Promise.resolve(), tileCacheHash = 0) { + constructor(tilePath, tileSettings, lod, vertexShader, fragmentShader, uniforms, loadBlocker = () => Promise.resolve(), revalidatedUrls) { Object.defineProperty( this, 'isLowresTileLoader', { value: true } ); this.tilePath = tilePath; this.tileSettings = tileSettings; this.lod = lod; this.loadBlocker = loadBlocker; - this.tileCacheHash = tileCacheHash; + this.revalidatedUrls = revalidatedUrls; this.vertexShader = vertexShader; this.fragmentShader = fragmentShader; this.uniforms = uniforms; - this.textureLoader = new TextureLoader(); + this.textureLoader = new RevalidatingTextureLoader(); + this.textureLoader.setRevalidatedUrls(this.revalidatedUrls); this.geometry = new PlaneGeometry( tileSettings.tileSize.x + 1, tileSettings.tileSize.z + 1, Math.ceil(100 / (lod * 2)), Math.ceil(100 / (lod * 2)) @@ -66,7 +67,8 @@ export class LowresTileLoader { //await this.loadBlocker(); return new Promise((resolve, reject) => { - this.textureLoader.load(tileUrl + '?' + this.tileCacheHash, + this.textureLoader.setRevalidatedUrls(this.revalidatedUrls); + this.textureLoader.load(tileUrl, async texture => { texture.anisotropy = 1; texture.generateMipmaps = false; diff --git a/common/webapp/src/js/map/Map.js b/common/webapp/src/js/map/Map.js index e0fbad7a4..bc9e3aa94 100644 --- a/common/webapp/src/js/map/Map.js +++ b/common/webapp/src/js/map/Map.js @@ -25,7 +25,6 @@ import { ClampToEdgeWrapping, Color, - FileLoader, FrontSide, NearestFilter, NearestMipMapLinearFilter, @@ -34,6 +33,7 @@ import { Texture, Vector3 } from "three"; +import {RevalidatingFileLoader} from "../util/RevalidatingFileLoader"; import {alert, dispatchEvent, getPixel, hashTile, stringToImage, vecArrToObj} from "../util/Utils"; import {TileManager} from "./TileManager"; import {TileLoader} from "./TileLoader"; @@ -110,14 +110,14 @@ export class Map { * @param lowresVertexShader {string} * @param lowresFragmentShader {string} * @param uniforms {object} - * @param tileCacheHash {number} + * @param revalidatedUrls {Set | undefined} * @returns {Promise} */ - load(hiresVertexShader, hiresFragmentShader, lowresVertexShader, lowresFragmentShader, uniforms, tileCacheHash = 0) { + load(hiresVertexShader, hiresFragmentShader, lowresVertexShader, lowresFragmentShader, uniforms, revalidatedUrls) { this.unload() - let settingsPromise = this.loadSettings(tileCacheHash); - let textureFilePromise = this.loadTexturesFile(tileCacheHash); + let settingsPromise = this.loadSettings(revalidatedUrls); + let textureFilePromise = this.loadTexturesFile(revalidatedUrls); this.lowresMaterial = this.createLowresMaterial(lowresVertexShader, lowresFragmentShader, uniforms); @@ -133,7 +133,7 @@ export class Map { this.hiresMaterial, this.data.hires, this.loadBlocker, - tileCacheHash + revalidatedUrls ), this.onTileLoad("hires"), this.onTileUnload("hires"), this.events); this.hiresTileManager.scene.matrixWorldAutoUpdate = false; @@ -147,7 +147,7 @@ export class Map { lowresFragmentShader, uniforms, async () => {}, - tileCacheHash + revalidatedUrls ), this.onTileLoad("lowres"), this.onTileUnload("lowres"), this.events); this.lowresTileManager[i].scene.matrixWorldAutoUpdate = false; } @@ -160,8 +160,8 @@ export class Map { * Loads the settings of this map * @returns {Promise} */ - loadSettings(tileCacheHash) { - return this.loadSettingsFile(tileCacheHash) + loadSettings(revalidatedUrls) { + return this.loadSettingsFile(revalidatedUrls) .then(worldSettings => { this.data.name = worldSettings.name ? worldSettings.name : this.data.name; @@ -259,13 +259,14 @@ export class Map { * Loads the settings.json file for this map * @returns {Promise} */ - loadSettingsFile(tileCacheHash) { + loadSettingsFile(revalidatedUrls) { return new Promise((resolve, reject) => { alert(this.events, `Loading settings for map '${this.data.id}'...`, "fine"); - let loader = new FileLoader(); + let loader = new RevalidatingFileLoader(); + loader.setRevalidatedUrls(revalidatedUrls); loader.setResponseType("json"); - loader.load(this.data.settingsUrl + "?" + tileCacheHash, + loader.load(this.data.settingsUrl, resolve, () => {}, () => reject(`Failed to load the settings.json for map: ${this.data.id}`) @@ -277,13 +278,14 @@ export class Map { * Loads the textures.json file for this map * @returns {Promise} */ - loadTexturesFile(tileCacheHash) { + loadTexturesFile(revalidatedUrls) { return new Promise((resolve, reject) => { alert(this.events, `Loading textures for map '${this.data.id}'...`, "fine"); - let loader = new FileLoader(); + let loader = new RevalidatingFileLoader(); + loader.setRevalidatedUrls(revalidatedUrls); loader.setResponseType("json"); - loader.load(this.data.texturesUrl + "?" + tileCacheHash, + loader.load(this.data.texturesUrl, resolve, () => {}, () => reject(`Failed to load the textures.json for map: ${this.data.id}`) diff --git a/common/webapp/src/js/map/TileLoader.js b/common/webapp/src/js/map/TileLoader.js index cc23d7e30..9aadafada 100644 --- a/common/webapp/src/js/map/TileLoader.js +++ b/common/webapp/src/js/map/TileLoader.js @@ -23,8 +23,9 @@ * THE SOFTWARE. */ import {pathFromCoords} from "../util/Utils"; -import {BufferGeometryLoader, FileLoader, Mesh, Material} from "three"; +import {BufferGeometryLoader, Mesh, Material} from "three"; import {PRBMLoader} from "./hires/PRBMLoader"; +import {RevalidatingFileLoader} from "../util/RevalidatingFileLoader"; export class TileLoader { @@ -37,21 +38,22 @@ export class TileLoader { * translate: {x: number, z: number} * }} * @param loadBlocker {function: Promise} - * @param tileCacheHash {number} + * @param revalidatedUrls {Set | undefined} */ - constructor(tilePath, material, tileSettings, loadBlocker = () => Promise.resolve(), tileCacheHash = 0) { + constructor(tilePath, material, tileSettings, loadBlocker = () => Promise.resolve(), revalidatedUrls) { Object.defineProperty( this, 'isTileLoader', { value: true } ); this.tilePath = tilePath; this.material = material; this.tileSettings = tileSettings; - this.tileCacheHash = tileCacheHash; + this.revalidatedUrls = revalidatedUrls; this.loadBlocker = loadBlocker; - this.fileLoader = new FileLoader(); + this.fileLoader = new RevalidatingFileLoader(); this.fileLoader.setResponseType('arraybuffer'); + this.fileLoader.setRevalidatedUrls(this.revalidatedUrls); this.bufferGeometryLoader = new PRBMLoader(); } @@ -60,7 +62,8 @@ export class TileLoader { let tileUrl = this.tilePath + pathFromCoords(tileX, tileZ) + '.prbm'; return new Promise((resolve, reject) => { - this.fileLoader.load(tileUrl + '?' + this.tileCacheHash, + this.fileLoader.setRevalidatedUrls(this.revalidatedUrls); + this.fileLoader.load(tileUrl, async data => { await this.loadBlocker(); diff --git a/common/webapp/src/js/markers/MarkerManager.js b/common/webapp/src/js/markers/MarkerManager.js index e9dbb12ba..33b31cab1 100644 --- a/common/webapp/src/js/markers/MarkerManager.js +++ b/common/webapp/src/js/markers/MarkerManager.js @@ -22,9 +22,9 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -import {FileLoader} from "three"; import {MarkerSet} from "./MarkerSet"; -import {alert, generateCacheHash} from "../util/Utils"; +import {alert} from "../util/Utils"; +import {RevalidatingFileLoader} from "../util/RevalidatingFileLoader"; /** * A manager for loading and updating markers from a file @@ -116,9 +116,10 @@ export class MarkerManager { */ loadMarkerFile() { return new Promise((resolve, reject) => { - let loader = new FileLoader(); + let loader = new RevalidatingFileLoader(); + loader.setRevalidatedUrls(new Set()); // force no-cache requests loader.setResponseType("json"); - loader.load(this.fileUrl + "?" + generateCacheHash(), + loader.load(this.fileUrl, markerFileData => { if (!markerFileData) reject(`Failed to parse '${this.fileUrl}'!`); else resolve(markerFileData); @@ -129,4 +130,4 @@ export class MarkerManager { }); } -} \ No newline at end of file +} diff --git a/common/webapp/src/js/util/RevalidatingFileLoader.js b/common/webapp/src/js/util/RevalidatingFileLoader.js new file mode 100644 index 000000000..ae64163bb --- /dev/null +++ b/common/webapp/src/js/util/RevalidatingFileLoader.js @@ -0,0 +1,358 @@ +// based on https://github.com/mrdoob/three.js/blob/a58e9ecf225b50e4a28a934442e854878bc2a959/src/loaders/FileLoader.js + +import {Loader, Cache} from "three"; +/** @import {LoadingManager} from "three" */ + +/** @type {Record | undefined, callbacks: Array<{onLoad: function, onProgress: function, onError: function}>>}} */ +const loading = Object.create(null); + +const warn = console.warn; + +class HttpError extends Error { + constructor(message, response) { + super(message); + this.response = response; + } +} + +/** + * A FileLoader that, if passed a Set of URLs, will be put into a mode where it + * revalidates files by setting the Request cache option to "no-cache" for URLs + * that have not previously been revalidated. + * + * This loader supports caching. If you want to use it, add `THREE.Cache.enabled = true;` + * once to your application. + * + * ```js + * const loader = new THREE.FileLoader(); + * const data = await loader.loadAsync( 'example.txt' ); + * ``` + * + * @augments Loader + */ +export class RevalidatingFileLoader extends Loader { + /** + * Constructs a new file loader. + * + * @param {LoadingManager} [manager] - The loading manager. + */ + constructor(manager) { + super(manager); + + /** + * The expected mime type. Valid values can be found + * [here](hhttps://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString#mimetype) + * + * @type {string} + */ + this.mimeType = ""; + + /** + * The expected response type. + * + * @type {('arraybuffer'|'blob'|'document'|'json'|'')} + * @default '' + */ + this.responseType = ""; + + /** + * Used for aborting requests. + * + * @private + * @type {AbortController} + */ + this._abortController = new AbortController(); + + /** + * If set to a Set, this loader will revalidate URLs by setting the + * Request cache option to "no-cache" for URLs not in the Set, adding + * them to the Set once loaded. + * + * @type {Set | undefined} + */ + this._revalidatedUrls = undefined; + } + + /** + * @param {Set | undefined} revalidatedUrls - If set to a Set, this + * loader will revalidate URLs by setting the Request cache option to + * "no-cache" for URLs not in the Set, adding them to the Set once loaded. + */ + setRevalidatedUrls(revalidatedUrls) { + this._revalidatedUrls = revalidatedUrls; + return this; + } + + /** + * Starts loading from the given URL and pass the loaded response to the `onLoad()` callback. + * + * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI. + * @param {function(any)} onLoad - Executed when the loading process has been finished. + * @param {onProgressCallback} [onProgress] - Executed while the loading is in progress. + * @param {onErrorCallback} [onError] - Executed when errors occur. + * @return {any|undefined} The cached resource if available. + */ + load(url, onLoad, onProgress, onError) { + if (url === undefined) url = ""; + + if (this.path !== undefined) url = this.path + url; + + url = this.manager.resolveURL(url); + + // copy reference at start of method in case it is changed while loading + const revalidatedUrls = this._revalidatedUrls; + const forceNoCacheRequest = revalidatedUrls + ? !revalidatedUrls.has(url) + : false; + + if (!forceNoCacheRequest) { + const cached = Cache.get(`file:${url}`); + + if (cached !== undefined) { + this.manager.itemStart(url); + + setTimeout(() => { + if (onLoad) onLoad(cached); + this.manager.itemEnd(url); + }, 0); + + return cached; + } + } + + // Check if request is duplicate + + let loadingEntry = loading[url]; + + if ( + loadingEntry !== undefined && + (!revalidatedUrls || + loadingEntry.revalidatedUrls === revalidatedUrls) + ) { + loadingEntry.callbacks.push({onLoad, onProgress, onError}); + return; + } + + // Create new loading entry (replacing if duplicate with different revalidatedUrls) + loadingEntry = loading[url] = { + revalidatedUrls, + callbacks: [{onLoad, onProgress, onError}], + }; + + // create request + const req = new Request(url, { + headers: new Headers(this.requestHeader), + cache: forceNoCacheRequest ? "no-cache" : undefined, + credentials: this.withCredentials ? "include" : "same-origin", + signal: + // future versions of LoadingManager have an abortController property + typeof AbortSignal.any === "function" && + this.manager.abortController?.signal + ? AbortSignal.any([ + this._abortController.signal, + this.manager.abortController.signal, + ]) + : this._abortController.signal, + }); + + // record states ( avoid data race ) + const mimeType = this.mimeType; + const responseType = this.responseType; + + // start the fetch + fetch(req) + .then((response) => { + if (response.status === 200 || response.status === 0) { + // Some browsers return HTTP Status 0 when using non-http protocol + // e.g. 'file://' or 'data://'. Handle as success. + + if (response.status === 0) { + warn("FileLoader: HTTP Status 0 received."); + } + + // Workaround: Checking if response.body === undefined for Alipay browser #23548 + + if ( + typeof ReadableStream === "undefined" || + response.body === undefined || + response.body.getReader === undefined + ) { + return response; + } + + const reader = response.body.getReader(); + + // Nginx needs X-File-Size check + // https://serverfault.com/questions/482875/why-does-nginx-remove-content-length-header-for-chunked-content + const contentLength = + response.headers.get("X-File-Size") || + response.headers.get("Content-Length"); + const total = contentLength ? parseInt(contentLength) : 0; + const lengthComputable = total !== 0; + let loaded = 0; + + // periodically read data into the new stream tracking while download progress + const stream = new ReadableStream({ + start(controller) { + readData(); + + function readData() { + reader.read().then( + ({done, value}) => { + if (done) { + controller.close(); + } else { + loaded += value.byteLength; + + const event = new ProgressEvent( + "progress", + { + lengthComputable, + loaded, + total, + } + ); + for ( + let i = 0, + il = + loadingEntry.callbacks + .length; + i < il; + i++ + ) { + const callback = + loadingEntry.callbacks[i]; + if (callback.onProgress) + callback.onProgress(event); + } + + controller.enqueue(value); + readData(); + } + }, + (e) => { + controller.error(e); + } + ); + } + }, + }); + + return new Response(stream); + } else { + throw new HttpError( + `fetch for "${response.url}" responded with ${response.status}: ${response.statusText}`, + response + ); + } + }) + .then((response) => { + switch (responseType) { + case "arraybuffer": + return response.arrayBuffer(); + + case "blob": + return response.blob(); + + case "document": + return response.text().then((text) => { + const parser = new DOMParser(); + return parser.parseFromString(text, mimeType); + }); + + case "json": + return response.json(); + + default: + if (mimeType === "") { + return response.text(); + } else { + // sniff encoding + const re = /charset="?([^;"\s]*)"?/i; + const exec = re.exec(mimeType); + const label = + exec && exec[1] + ? exec[1].toLowerCase() + : undefined; + const decoder = new TextDecoder(label); + return response + .arrayBuffer() + .then((ab) => decoder.decode(ab)); + } + } + }) + .then((data) => { + // Add to cache only on HTTP success, so that we do not cache + // error response bodies as proper responses to requests. + Cache.add(`file:${url}`, data); + + if (loading[url] === loadingEntry) { + delete loading[url]; + } + + for ( + let i = 0, il = loadingEntry.callbacks.length; + i < il; + i++ + ) { + const callback = loadingEntry.callbacks[i]; + if (callback.onLoad) callback.onLoad(data); + } + }) + .catch((err) => { + // Abort errors and other errors are handled the same + + if (loading[url] === loadingEntry) { + delete loading[url]; + } + + for ( + let i = 0, il = loadingEntry.callbacks.length; + i < il; + i++ + ) { + const callback = loadingEntry.callbacks[i]; + if (callback.onError) callback.onError(err); + } + this.manager.itemError(url); + }) + .finally(() => { + this.manager.itemEnd(url); + }); + this.manager.itemStart(url); + } + + /** + * Sets the expected response type. + * + * @param {('arraybuffer'|'blob'|'document'|'json'|'')} value - The response type. + * @return {FileLoader} A reference to this file loader. + */ + setResponseType(value) { + this.responseType = value; + return this; + } + + /** + * Sets the expected mime type of the loaded file. + * + * @param {string} value - The mime type. + * @return {FileLoader} A reference to this file loader. + */ + setMimeType(value) { + this.mimeType = value; + return this; + } + + /** + * Aborts ongoing fetch requests. + * + * @return {FileLoader} A reference to this instance. + */ + abort() { + this._abortController.abort(); + this._abortController = new AbortController(); + + return this; + } +} diff --git a/common/webapp/src/js/util/RevalidatingTextureLoader.js b/common/webapp/src/js/util/RevalidatingTextureLoader.js new file mode 100644 index 000000000..e500894a9 --- /dev/null +++ b/common/webapp/src/js/util/RevalidatingTextureLoader.js @@ -0,0 +1,90 @@ +import {Loader, ImageLoader, Texture} from "three"; +import {RevalidatingFileLoader} from "./RevalidatingFileLoader"; + +/** + * @import {TextureLoader} from "three" + */ + +/** + * An alternative to {@link TextureLoader} for loading textures with support for + * forcing revalidation like {@link RevalidatingFileLoader}. + * + * Images are internally loaded via {@link ImageLoader} or + * {@link RevalidatingFileLoader} if an uncached request is made. + * + * ```js + * const loader = new RevalidatingTextureLoader(); + * const texture = await loader.loadAsync( 'textures/land_ocean_ice_cloud_2048.jpg' ); + * + * const material = new THREE.MeshBasicMaterial( { map:texture } ); + * ``` + */ +export class RevalidatingTextureLoader extends Loader { + /** @type {Set | undefined} */ + #revalidatedUrls; + #revalidatingFileLoader = new RevalidatingFileLoader(this.manager); + #imageLoader = new ImageLoader(this.manager); + + /** + * @param {Set | undefined} revalidatedUrls - If set to a Set, this + * loader will revalidate URLs by setting the Request cache option to + * "no-cache" for URLs not in the Set, adding them to the Set once loaded. + */ + setRevalidatedUrls(revalidatedUrls) { + this.#revalidatedUrls = revalidatedUrls; + this.#revalidatingFileLoader.setRevalidatedUrls(revalidatedUrls); + return this; + } + + /** + * Starts loading from the given URL and pass the fully loaded texture + * to the `onLoad()` callback. The method also returns a new texture object which can + * directly be used for material creation. If you do it this way, the texture + * may pop up in your scene once the respective loading process is finished. + * + * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI. + * @param {function(Texture)} onLoad - Executed when the loading process has been finished. + * @param {onProgressCallback} onProgress - Unsupported in this loader. + * @param {onErrorCallback} onError - Executed when errors occur. + * @return {Texture} The texture. + */ + load(url, onLoad, onProgress, onError) { + // copy reference at start of method in case it is changed while loading + const revalidatedUrls = this.#revalidatedUrls; + + const texture = new Texture(); + + if (revalidatedUrls && !revalidatedUrls.has(url)) { + const loader = this.#revalidatingFileLoader; + loader.setResponseType('blob'); + loader.setWithCredentials(this.withCredentials); + loader.setCrossOrigin(this.crossOrigin); + loader.setPath(this.path); + + loader.loadAsync(url, onProgress) + .then(async blob => { + revalidatedUrls.add(url); + + const imageBitmap = await createImageBitmap(blob, {colorSpaceConversion: 'none'}); + texture.image = imageBitmap; + texture.needsUpdate = true; + return texture; + }) + .then(onLoad, onError) + } else { + const loader = this.#imageLoader; + loader.setCrossOrigin(this.crossOrigin); + loader.setPath(this.path); + + loader.loadAsync(url, onProgress) + .then(image => { + texture.image = image; + texture.needsUpdate = true; + return texture; + }) + .then(onLoad, onError); + } + + return texture; + } +} diff --git a/common/webapp/src/js/util/Utils.js b/common/webapp/src/js/util/Utils.js index 287518cd4..3b4c808c6 100644 --- a/common/webapp/src/js/util/Utils.js +++ b/common/webapp/src/js/util/Utils.js @@ -89,10 +89,6 @@ const splitNumberToPath = num => { */ export const hashTile = (x, z) => `x${x}z${z}`; -export const generateCacheHash = () => { - return Math.round(Math.random() * 1000000); -} - /** * Dispatches an event to the element of this map-viewer * @param element {EventTarget} the element on that the event is dispatched