From f316c0dd503f485b932eda64e5dd3662f3e46c3e Mon Sep 17 00:00:00 2001 From: James Lyne Date: Sat, 14 Aug 2021 00:08:01 +0100 Subject: [PATCH] Use fetch for tile loads, move loading logic to LiveAtlasTileLayer --- src/index.d.ts | 19 ++- src/leaflet/tileLayer/DynmapTileLayer.ts | 111 ++------------ src/leaflet/tileLayer/LiveAtlasTileLayer.ts | 152 +++++++++++++++++++- 3 files changed, 181 insertions(+), 101 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index d547694..99e22f1 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -17,7 +17,7 @@ import {State} from "@/store"; import {DynmapUrlConfig} from "@/dynmap"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; -import {PathOptions, PointTuple, PolylineOptions} from "leaflet"; +import {Coords, DoneCallback, PathOptions, PointTuple, PolylineOptions} from "leaflet"; import {CoordinatesControlOptions} from "@/leaflet/control/CoordinatesControl"; import {ClockControlOptions} from "@/leaflet/control/ClockControl"; import {LogoControlOptions} from "@/leaflet/control/LogoControl"; @@ -313,3 +313,20 @@ interface LiveAtlasChat { source?: string; timestamp: number; } + +export interface LiveAtlasTile { + active?: boolean; + coords: Coords; + current: boolean; + el: LiveAtlasTileElement; + loaded?: Date; + retain?: boolean; + complete: boolean; +} + +export interface LiveAtlasTileElement extends HTMLImageElement { + tileName?: string; + url: string; + callback: DoneCallback; + abortController: AbortController; +} diff --git a/src/leaflet/tileLayer/DynmapTileLayer.ts b/src/leaflet/tileLayer/DynmapTileLayer.ts index e1a152b..affb5b2 100644 --- a/src/leaflet/tileLayer/DynmapTileLayer.ts +++ b/src/leaflet/tileLayer/DynmapTileLayer.ts @@ -17,29 +17,15 @@ * limitations under the License. */ -import {Coords, DoneCallback, DomUtil} from 'leaflet'; +import {Coords, DoneCallback} from 'leaflet'; import {useStore} from "@/store"; -import {Coordinate} from "@/index"; -import {LiveAtlasTileLayerOptions, LiveAtlasTileLayer} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; +import {Coordinate, LiveAtlasTile} from "@/index"; +import {LiveAtlasTileLayer, LiveAtlasTileLayerOptions} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; import {computed, watch} from "@vue/runtime-core"; import {ComputedRef} from "@vue/reactivity"; import {WatchStopHandle} from "vue"; import {ActionTypes} from "@/store/action-types"; -export interface DynmapTile { - active?: boolean; - coords: Coords; - current: boolean; - el: DynmapTileElement; - loaded?: Date; - retain?: boolean; - complete: boolean; -} - -export interface DynmapTileElement extends HTMLImageElement { - tileName: string; -} - export interface TileInfo { prefix: string; nightday: string; @@ -56,11 +42,7 @@ const store = useStore(); // noinspection JSUnusedGlobalSymbols export class DynmapTileLayer extends LiveAtlasTileLayer { - private readonly _cachedTileUrls: Map = Object.seal(new Map()); private readonly _namedTiles: Map = Object.seal(new Map()); - private readonly _loadQueue: DynmapTileElement[] = []; - private readonly _loadingTiles: Set = Object.seal(new Set()); - private readonly _tileTemplate: DynmapTileElement; private readonly _baseUrl: string; private readonly _night: ComputedRef; @@ -69,26 +51,12 @@ export class DynmapTileLayer extends LiveAtlasTileLayer { private readonly _updateUnwatch: WatchStopHandle; private _updateFrame: number = 0; - // @ts-ignore - declare options: DynmapTileLayerOptions; - constructor(options: LiveAtlasTileLayerOptions) { super('', options); this._mapSettings = options.mapSettings; - this._tileTemplate = DomUtil.create('img', 'leaflet-tile') as DynmapTileElement; - this._tileTemplate.style.width = this._tileTemplate.style.height = this.options.tileSize + 'px'; - this._tileTemplate.alt = ''; - this._tileTemplate.tileName = ''; - this._tileTemplate.setAttribute('role', 'presentation'); this._baseUrl = store.state.currentMapProvider!.getTilesUrl(); - Object.seal(this._tileTemplate); - - if(this.options.crossOrigin || this.options.crossOrigin === '') { - this._tileTemplate.crossOrigin = this.options.crossOrigin === true ? '' : this.options.crossOrigin; - } - this._pendingUpdates = computed(() => !!store.state.pendingTileUpdates.length); this._updateUnwatch = watch(this._pendingUpdates, (newValue, oldValue) => { if(newValue && !oldValue && !this._updateFrame) { @@ -117,17 +85,11 @@ export class DynmapTileLayer extends LiveAtlasTileLayer { } private getTileUrlFromName(name: string, timestamp?: number) { - let url = this._cachedTileUrls.get(name); + const path = escape(`${this._mapSettings.world.name}/${name}`); + let url = `${this._baseUrl}${path}`; - if (!url) { - const path = escape(`${this._mapSettings.world.name}/${name}`); - url = `${this._baseUrl}${path}`; - - if(typeof timestamp !== 'undefined') { - url += (url.indexOf('?') === -1 ? `?timestamp=${timestamp}` : `×tamp=${timestamp}`); - } - - this._cachedTileUrls.set(name, url); + if(typeof timestamp !== 'undefined') { + url += (url.indexOf('?') === -1 ? `?timestamp=${timestamp}` : `×tamp=${timestamp}`); } return url; @@ -135,57 +97,23 @@ export class DynmapTileLayer extends LiveAtlasTileLayer { private updateNamedTile(name: string, timestamp: number) { const tile = this._namedTiles.get(name); - this._cachedTileUrls.delete(name); if (tile) { tile.dataset.src = this.getTileUrlFromName(name, timestamp); - this._loadQueue.push(tile); - this._tickLoadQueue(); + this.loadQueue.push(tile); + this.tickLoadQueue(); } } createTile(coords: Coords, done: DoneCallback) { - //Clone template image instead of creating a new one - const tile = this._tileTemplate.cloneNode(false) as DynmapTileElement; + const tile = super.createTile(coords, done); tile.tileName = this.getTileName(coords); - tile.dataset.src = this.getTileUrl(coords); - this._namedTiles.set(tile.tileName, tile); - this._loadQueue.push(tile); - - //Use addEventListener here - tile.onload = () => { - this._tileOnLoad(done, tile); - this._loadingTiles.delete(tile); - this._tickLoadQueue(); - }; - tile.onerror = () => { - this._tileOnError(done, tile, {name: 'Error', message: 'Error'}); - this._loadingTiles.delete(tile); - this._tickLoadQueue(); - }; - - this._tickLoadQueue(); return tile; } - _tickLoadQueue() { - if (this._loadingTiles.size > 6) { - return; - } - - const tile = this._loadQueue.shift(); - - if (!tile) { - return; - } - - this._loadingTiles.add(tile); - tile.src = tile.dataset.src as string; - } - // stops loading all tiles in the background layer _abortLoading() { let tile; @@ -195,18 +123,12 @@ export class DynmapTileLayer extends LiveAtlasTileLayer { continue; } - tile = this._tiles[i] as DynmapTile; + tile = this._tiles[i] as LiveAtlasTile; if (tile.coords.z !== this._tileZoom) { if (tile.loaded && tile.el && tile.el.tileName) { this._namedTiles.delete(tile.el.tileName); } - - if(this._loadQueue.includes(tile.el)) { - this._loadQueue.splice(this._loadQueue.indexOf(tile.el), 1); - } - - this._loadingTiles.delete(tile.el); } } @@ -214,7 +136,7 @@ export class DynmapTileLayer extends LiveAtlasTileLayer { } _removeTile(key: string) { - const tile = this._tiles[key] as DynmapTile; + const tile = this._tiles[key] as LiveAtlasTile; if (!tile) { return; @@ -224,15 +146,6 @@ export class DynmapTileLayer extends LiveAtlasTileLayer { if (tileName) { this._namedTiles.delete(tileName); - this._cachedTileUrls.delete(tileName); - this._loadingTiles.delete(tile.el); - - if(this._loadQueue.includes(tile.el)) { - this._loadQueue.splice(this._loadQueue.indexOf(tile.el), 1); - } - - tile.el.onerror = null; - tile.el.onload = null; } // @ts-ignore diff --git a/src/leaflet/tileLayer/LiveAtlasTileLayer.ts b/src/leaflet/tileLayer/LiveAtlasTileLayer.ts index 429ae58..6a48b61 100644 --- a/src/leaflet/tileLayer/LiveAtlasTileLayer.ts +++ b/src/leaflet/tileLayer/LiveAtlasTileLayer.ts @@ -14,8 +14,10 @@ * limitations under the License. */ -import {TileLayer, TileLayerOptions, Util} from 'leaflet'; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; +import {Coords, DomUtil, DoneCallback, TileLayer, TileLayerOptions, Util} from "leaflet"; +import {LiveAtlasTile, LiveAtlasTileElement} from "@/index"; +import falseFn = Util.falseFn; export interface LiveAtlasTileLayerOptions extends TileLayerOptions { mapSettings: LiveAtlasMapDefinition; @@ -27,10 +29,29 @@ export abstract class LiveAtlasTileLayer extends TileLayer { protected _mapSettings: LiveAtlasMapDefinition; declare options: LiveAtlasTileLayerOptions; + private readonly tileTemplate: LiveAtlasTileElement; + protected readonly loadQueue: LiveAtlasTileElement[] = []; + private readonly loadingTiles: Set = Object.seal(new Set()); + + private static genericLoadError = new Error('Tile failed to load'); + protected constructor(url: string, options: LiveAtlasTileLayerOptions) { super(url, options); this._mapSettings = options.mapSettings; + this.tileTemplate = DomUtil.create('img', 'leaflet-tile') as LiveAtlasTileElement; + this.tileTemplate.style.width = this.tileTemplate.style.height = this.options.tileSize + 'px'; + this.tileTemplate.alt = ''; + this.tileTemplate.tileName = ''; + this.tileTemplate.callback = falseFn; + this.tileTemplate.setAttribute('role', 'presentation'); + + if(this.options.crossOrigin || this.options.crossOrigin === '') { + this.tileTemplate.crossOrigin = this.options.crossOrigin === true ? '' : this.options.crossOrigin; + } + + Object.seal(this.tileTemplate); + options.maxZoom = this._mapSettings.nativeZoomLevels + this._mapSettings.extraZoomLevels; options.maxNativeZoom = this._mapSettings.nativeZoomLevels; options.zoomReverse = true; @@ -43,4 +64,133 @@ export abstract class LiveAtlasTileLayer extends TileLayer { throw new TypeError("mapSettings missing"); } } + + // @method createTile(coords: Object, done?: Function): HTMLElement + // Called only internally, overrides GridLayer's [`createTile()`](#gridlayer-createtile) + // to return an `` HTML element with the appropriate image URL given `coords`. The `done` + // callback is called when the tile has been loaded. + createTile(coords: Coords, done: DoneCallback) { + const tile = this.tileTemplate.cloneNode(false) as LiveAtlasTileElement; + this.loadQueue.push(tile); + + tile.onload = () => { + URL.revokeObjectURL(tile.src); //Revoke the object URL as we don't need it anymore + + this._tileOnLoad(done, tile); + this.loadingTiles.delete(tile); + this.tickLoadQueue(); + }; + + tile.onerror = () => { + this._tileOnError(done, tile, LiveAtlasTileLayer.genericLoadError); + this.loadingTiles.delete(tile); + this.tickLoadQueue(); + }; + + tile.url = this.getTileUrl(coords); + tile.callback = done; + + this.tickLoadQueue(); + + return tile; + } + + private async fetchTile(tile: LiveAtlasTileElement) { + if(tile.abortController && !tile.abortController.signal.aborted) { + tile.abortController.abort(); + } + + tile.abortController = new AbortController(); + + try { + //Retrieve image via a fetch instead of just setting the src + //This works around the fact that browsers usually don't make a request for an image that was previously loaded, + //without resorting to changing the URL (which would break caching). + const response = await fetch(tile.url, {signal: tile.abortController.signal}); + + //Call leaflet's error handler if request fails for some reason + if (!response.ok) { + this._tileOnError(tile.callback, tile, new Error('Response was not ok')); + return; + } + + //Get image data and convert into object URL so it can be used as a src + //The tile onload listener will take it from here + const blob = await response.blob(); + tile.src = URL.createObjectURL(blob); + } catch(e) { + if (e instanceof DOMException && e.name === 'AbortError') { + return; + } + + console.error(e); + + this._tileOnError(tile.callback, tile, e); + } + } + + protected tickLoadQueue() { + if (this.loadingTiles.size > 6) { + return; + } + + const tile = this.loadQueue.shift(); + + if (!tile) { + return; + } + + this.loadingTiles.add(tile); + this.fetchTile(tile); + } + + _abortLoading() { + let tile; + + for (const i in this._tiles) { + if (!Object.prototype.hasOwnProperty.call(this._tiles, i)) { + continue; + } + + tile = this._tiles[i] as LiveAtlasTile; + + if (tile.coords.z !== this._tileZoom) { + if (!tile.loaded && tile.el && tile.el.abortController) { + tile.el.abortController.abort(); + } + + if(this.loadQueue.includes(tile.el)) { + this.loadQueue.splice(this.loadQueue.indexOf(tile.el), 1); + } + + this.loadingTiles.delete(tile.el); + } + } + + super._abortLoading.call(this); + } + + _removeTile(key: string) { + const tile = this._tiles[key] as LiveAtlasTile; + + if (!tile) { + return; + } + + this.loadingTiles.delete(tile.el); + + if(this.loadQueue.includes(tile.el)) { + this.loadQueue.splice(this.loadQueue.indexOf(tile.el), 1); + } + + tile.el.onerror = null; + tile.el.onload = null; + + if(!tile.loaded && tile.el.abortController) { + tile.el.abortController.abort(); + } + + // @ts-ignore + super._removeTile(key); + } }