From e8fbc0a27ab2e666cbee0a6727ff0087e6e7302f Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Tue, 23 Dec 2025 19:41:16 -0800 Subject: [PATCH] Improved file caching Removed cache-busting query parameters from all file requests to allow browsers to cache files properly. The "update map" button revalidates all content but is now also able to make use of the browser cache, by using Fetch's `cache: "no-cache"` option which allows the browser to make a conditional request to check if the file has changed and then fall back to the cached version if it hasn't. --- common/webapp/.eslintrc.cjs | 3 + common/webapp/src/js/BlueMapApp.js | 26 +- common/webapp/src/js/MapViewer.js | 21 +- common/webapp/src/js/map/LowresTileLoader.js | 12 +- common/webapp/src/js/map/Map.js | 32 +- common/webapp/src/js/map/TileLoader.js | 15 +- common/webapp/src/js/markers/MarkerManager.js | 11 +- .../src/js/util/RevalidatingFileLoader.js | 358 ++++++++++++++++++ .../src/js/util/RevalidatingTextureLoader.js | 90 +++++ common/webapp/src/js/util/Utils.js | 4 - 10 files changed, 516 insertions(+), 56 deletions(-) create mode 100644 common/webapp/src/js/util/RevalidatingFileLoader.js create mode 100644 common/webapp/src/js/util/RevalidatingTextureLoader.js 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