From 37c1766e6fdf88edf7cf99dde5c164ecf1d0a4f5 Mon Sep 17 00:00:00 2001 From: James Lyne Date: Tue, 15 Dec 2020 01:51:19 +0000 Subject: [PATCH] Add tile loading indicator --- src/assets/icons/loading.svg | 29 +++ src/components/Map.vue | 6 + src/leaflet/control/LoadingControl.ts | 244 ++++++++++++++++++++++++++ src/scss/leaflet/_controls.scss | 22 ++- src/scss/style.scss | 20 +++ 5 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 src/assets/icons/loading.svg create mode 100644 src/leaflet/control/LoadingControl.ts diff --git a/src/assets/icons/loading.svg b/src/assets/icons/loading.svg new file mode 100644 index 0000000..e042d1f --- /dev/null +++ b/src/assets/icons/loading.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/Map.vue b/src/components/Map.vue index 079ffe9..0442afc 100644 --- a/src/components/Map.vue +++ b/src/components/Map.vue @@ -26,6 +26,7 @@ import {MutationTypes} from "@/store/mutation-types"; import {Coordinate, DynmapPlayer} from "@/dynmap"; import {ActionTypes} from "@/store/action-types"; import DynmapMap from "@/leaflet/DynmapMap"; +import {LoadingControl} from "@/leaflet/control/LoadingControl"; export default defineComponent({ components: { @@ -145,6 +146,11 @@ export default defineComponent({ // markerZoomAnimation: false, })); + this.leaflet.addControl(new LoadingControl({ + position: 'topleft', + delayIndicator: 500, + })); + this.leaflet.on('moveend', () => { useStore().commit(MutationTypes.SET_CURRENT_LOCATION, this.currentProjection.latLngToLocation(this.leaflet!.getCenter(), 64)); }); diff --git a/src/leaflet/control/LoadingControl.ts b/src/leaflet/control/LoadingControl.ts new file mode 100644 index 0000000..fe21141 --- /dev/null +++ b/src/leaflet/control/LoadingControl.ts @@ -0,0 +1,244 @@ +/* +Portions of this file are taken from Leaflet.loading: + +Copyright (c) 2013 Eric Brelsford + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +import { + Control, + ControlOptions, + DomUtil, + Layer, + LeafletEvent, + Map, TileLayer, +} from 'leaflet'; +import loadingIcon from '@/assets/icons/loading.svg'; + +export interface LoadingControlOptions extends ControlOptions { + delayIndicator?: number; +} + +export class LoadingControl extends Control { + // @ts-ignore + options: LoadingControlOptions; + + _dataLoaders: Set = new Set(); + _loadingIndicator: HTMLDivElement; + _delayIndicatorTimeout?: number; + + constructor(options: LoadingControlOptions) { + super(options); + + this._loadingIndicator = DomUtil.create('div', 'leaflet-control-loading') as HTMLDivElement; + } + + onAdd(map: Map) { + this._loadingIndicator.title = 'Loading...'; + this._loadingIndicator.hidden = true; + this._loadingIndicator.innerHTML = ` + + + `; + + this._addLayerListeners(map); + this._addMapListeners(map); + + return this._loadingIndicator; + } + + onRemove(map: Map) { + this._removeLayerListeners(map); + this._removeMapListeners(map); + } + + addLoader(id: number) { + this._dataLoaders.add(id); + + if (this.options.delayIndicator && !this._delayIndicatorTimeout) { + // If we are delaying showing the indicator and we're not + // already waiting for that delay, set up a timeout. + this._delayIndicatorTimeout = setTimeout(() => { + this.updateIndicator(); + this._delayIndicatorTimeout = undefined; + }, this.options.delayIndicator); + } else { + // Otherwise show the indicator immediately + this.updateIndicator(); + } + } + + removeLoader(id: number) { + this._dataLoaders.delete(id); + this.updateIndicator(); + + // If removing this loader means we're in no danger of loading, + // clear the timeout. This prevents old delays from instantly + // triggering the indicator. + if (this.options.delayIndicator && this._delayIndicatorTimeout && !this.isLoading()) { + clearTimeout(this._delayIndicatorTimeout); + this._delayIndicatorTimeout = undefined; + } + } + + updateIndicator() { + if (this.isLoading()) { + this._showIndicator(); + } + else { + this._hideIndicator(); + } + } + + isLoading() { + return this._countLoaders() > 0; + } + + _countLoaders() { + return this._dataLoaders.size; + } + + _showIndicator() { + this._loadingIndicator.hidden = false; + } + + _hideIndicator() { + this._loadingIndicator.hidden = true; + } + + _handleLoading(e: LeafletEvent) { + this.addLoader(this.getEventId(e)); + } + + _handleBaseLayerChange (e: LeafletEvent) { + // Check for a target 'layer' that contains multiple layers, such as + // L.LayerGroup. This will happen if you have an L.LayerGroup in an + // L.Control.Layers. + if (e.layer && e.layer.eachLayer && typeof e.layer.eachLayer === 'function') { + e.layer.eachLayer((layer: Layer) => { + this._handleBaseLayerChange({ layer: layer } as LeafletEvent); + }); + } + } + + _handleLoad(e: LeafletEvent) { + this.removeLoader(this.getEventId(e)); + } + + getEventId(e: any) { + if (e.id) { + return e.id; + } else if (e.layer) { + return e.layer._leaflet_id; + } + return e.target._leaflet_id; + } + + _layerAdd(e: LeafletEvent) { + if(!(e.layer instanceof TileLayer)) { + return; + } + + try { + if(e.layer.isLoading()) { + this.addLoader((e.layer as any)._leaflet_id); + } + + e.layer.on('loading', this._handleLoading, this); + e.layer.on('load', this._handleLoad, this); + } catch (exception) { + console.warn('L.Control.Loading: Tried and failed to add ' + + ' event handlers to layer', e.layer); + console.warn('L.Control.Loading: Full details', exception); + } + } + + _layerRemove(e: LeafletEvent) { + if(!(e.layer instanceof TileLayer)) { + return; + } + + try { + e.layer.off('loading', this._handleLoading, this); + e.layer.off('load', this._handleLoad, this); + } catch (exception) { + console.warn('L.Control.Loading: Tried and failed to remove ' + + 'event handlers from layer', e.layer); + console.warn('L.Control.Loading: Full details', exception); + } + } + + _addLayerListeners(map: Map) { + // Add listeners for begin and end of load to any layers already + // on the map + map.eachLayer((layer: Layer) => { + if(!(layer instanceof TileLayer)) { + return; + } + + if(layer.isLoading()) { + this.addLoader((layer as any)._leaflet_id); + } + + layer.on('loading', this._handleLoading, this); + layer.on('load', this._handleLoad, this); + }); + + // When a layer is added to the map, add listeners for begin and + // end of load + map.on('layeradd', this._layerAdd, this); + map.on('layerremove', this._layerRemove, this); + } + + _removeLayerListeners(map: Map) { + // Remove listeners for begin and end of load from all layers + map.eachLayer((layer: Layer) => { + if(!(layer instanceof TileLayer)) { + return; + } + + this.removeLoader((layer as any)._leaflet_id); + + layer.off('loading', this._handleLoading, this); + layer.off('load', this._handleLoad, this); + }); + + // Remove layeradd/layerremove listener from map + map.off('layeradd', this._layerAdd, this); + map.off('layerremove', this._layerRemove, this); + } + + _addMapListeners(map: Map) { + // Add listeners to the map for (custom) dataloading and dataload + // events, eg, for AJAX calls that affect the map but will not be + // reflected in the above layer events. + map.on('baselayerchange', this._handleBaseLayerChange, this); + map.on('dataloading', this._handleLoading, this); + map.on('dataload', this._handleLoad, this); + map.on('layerremove', this._handleLoad, this); + } + + _removeMapListeners(map: Map) { + map.off('baselayerchange', this._handleBaseLayerChange, this); + map.off('dataloading', this._handleLoading, this); + map.off('dataload', this._handleLoad, this); + map.off('layerremove', this._handleLoad, this); + } +} diff --git a/src/scss/leaflet/_controls.scss b/src/scss/leaflet/_controls.scss index 85769b5..4ced8af 100644 --- a/src/scss/leaflet/_controls.scss +++ b/src/scss/leaflet/_controls.scss @@ -52,11 +52,11 @@ } } -.leaflet-control-link { +.leaflet-control-link, +.leaflet-control-loading { @extend %button; width: 5rem; height: 5rem; - background-size: 3.2rem 3.2rem; } .leaflet-control-coordinates { @@ -172,4 +172,22 @@ .leaflet-control-layers-toggle { width: 5rem; height: 5rem; +} + +.leaflet-control-loading { + cursor: wait; + animation: fade 0.3s linear; + animation-fill-mode: forwards; + + &:focus, &:hover, &:active { + background-color: $global-background; + } + + &[hidden] { + display: none; + } + + svg { + animation: spin 1s linear infinite; + } } \ No newline at end of file diff --git a/src/scss/style.scss b/src/scss/style.scss index 5051d98..ec5e82a 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -8,6 +8,26 @@ @import "leaflet/controls"; @import "leaflet/popups"; +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +@keyframes fade { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + @font-face { font-family: 'Raleway'; font-style: normal;