src/pyams_gis/resources/js/leaflet-google-mutant.js
changeset 0 c73bb834ccbe
child 75 a430cc4ae715
equal deleted inserted replaced
-1:000000000000 0:c73bb834ccbe
       
     1 // Based on https://github.com/shramov/leaflet-plugins
       
     2 // GridLayer like https://avinmathew.com/leaflet-and-google-maps/ , but using MutationObserver instead of jQuery
       
     3 
       
     4 
       
     5 // 🍂class GridLayer.GoogleMutant
       
     6 // 🍂extends GridLayer
       
     7 L.GridLayer.GoogleMutant = L.GridLayer.extend({
       
     8 	includes: L.Mixin.Events,
       
     9 
       
    10 	options: {
       
    11 		minZoom: 0,
       
    12 		maxZoom: 18,
       
    13 		tileSize: 256,
       
    14 		subdomains: 'abc',
       
    15 		errorTileUrl: '',
       
    16 		attribution: '',	// The mutant container will add its own attribution anyways.
       
    17 		opacity: 1,
       
    18 		continuousWorld: false,
       
    19 		noWrap: false,
       
    20 		// 🍂option type: String = 'roadmap'
       
    21 		// Google's map type. Valid values are 'roadmap', 'satellite' or 'terrain'. 'hybrid' is not really supported.
       
    22 		type: 'roadmap',
       
    23 		maxNativeZoom: 21
       
    24 	},
       
    25 
       
    26 	initialize: function (options) {
       
    27 		L.GridLayer.prototype.initialize.call(this, options);
       
    28 
       
    29 		this._ready = !!window.google && !!window.google.maps && !!window.google.maps.Map;
       
    30 
       
    31 		this._GAPIPromise = this._ready ? Promise.resolve(window.google) : new Promise(function (resolve, reject) {
       
    32 			var checkCounter = 0;
       
    33 			var intervalId = null;
       
    34 			intervalId = setInterval(function () {
       
    35 				if (checkCounter >= 10) {
       
    36 					clearInterval(intervalId);
       
    37 					return reject(new Error('window.google not found after 10 attempts'));
       
    38 				}
       
    39 				if (!!window.google && !!window.google.maps && !!window.google.maps.Map) {
       
    40 					clearInterval(intervalId);
       
    41 					return resolve(window.google);
       
    42 				}
       
    43 				checkCounter++;
       
    44 			}, 500);
       
    45 		});
       
    46 
       
    47 		// Couple data structures indexed by tile key
       
    48 		this._tileCallbacks = {};	// Callbacks for promises for tiles that are expected
       
    49 		this._freshTiles = {};	// Tiles from the mutant which haven't been requested yet
       
    50 
       
    51 		this._imagesPerTile = (this.options.type === 'hybrid') ? 2 : 1;
       
    52 	},
       
    53 
       
    54 	onAdd: function (map) {
       
    55 		L.GridLayer.prototype.onAdd.call(this, map);
       
    56 		this._initMutantContainer();
       
    57 
       
    58 		this._GAPIPromise.then(function () {
       
    59 			this._ready = true;
       
    60 			this._map = map;
       
    61 
       
    62 			this._initMutant();
       
    63 
       
    64 			map.on('viewreset', this._reset, this);
       
    65 			map.on('move', this._update, this);
       
    66 			map.on('zoomend', this._handleZoomAnim, this);
       
    67 			map.on('resize', this._resize, this);
       
    68 
       
    69 			//handle layer being added to a map for which there are no Google tiles at the given zoom
       
    70 			google.maps.event.addListenerOnce(this._mutant, 'idle', function () {
       
    71 				this._checkZoomLevels();
       
    72 				this._mutantIsReady = true;
       
    73 			}.bind(this));
       
    74 
       
    75 			//20px instead of 1em to avoid a slight overlap with google's attribution
       
    76 			map._controlCorners.bottomright.style.marginBottom = '20px';
       
    77 			map._controlCorners.bottomleft.style.marginBottom = '20px';
       
    78 
       
    79 			this._reset();
       
    80 			this._update();
       
    81 
       
    82 			if (this._subLayers) {
       
    83 				//restore previously added google layers
       
    84 				for (var layerName in this._subLayers) {
       
    85 					this._subLayers[layerName].setMap(this._mutant);
       
    86 				}
       
    87 			}
       
    88 		}.bind(this));
       
    89 	},
       
    90 
       
    91 	onRemove: function (map) {
       
    92 		L.GridLayer.prototype.onRemove.call(this, map);
       
    93 		map._container.removeChild(this._mutantContainer);
       
    94 		this._mutantContainer = undefined;
       
    95 
       
    96 		map.off('viewreset', this._reset, this);
       
    97 		map.off('move', this._update, this);
       
    98 		map.off('zoomend', this._handleZoomAnim, this);
       
    99 		map.off('resize', this._resize, this);
       
   100 
       
   101 		map._controlCorners.bottomright.style.marginBottom = '0em';
       
   102 		map._controlCorners.bottomleft.style.marginBottom = '0em';
       
   103 	},
       
   104 
       
   105 	getAttribution: function () {
       
   106 		return this.options.attribution;
       
   107 	},
       
   108 
       
   109 	setOpacity: function (opacity) {
       
   110 		this.options.opacity = opacity;
       
   111 		if (opacity < 1) {
       
   112 			L.DomUtil.setOpacity(this._mutantContainer, opacity);
       
   113 		}
       
   114 	},
       
   115 
       
   116 	setElementSize: function (e, size) {
       
   117 		e.style.width = size.x + 'px';
       
   118 		e.style.height = size.y + 'px';
       
   119 	},
       
   120 
       
   121 
       
   122 	addGoogleLayer: function (googleLayerName, options) {
       
   123 		if (!this._subLayers) this._subLayers = {};
       
   124 		return this._GAPIPromise.then(function () {
       
   125 			var Constructor = google.maps[googleLayerName];
       
   126 			var googleLayer = new Constructor(options);
       
   127 			googleLayer.setMap(this._mutant);
       
   128 			this._subLayers[googleLayerName] = googleLayer;
       
   129 			return googleLayer;
       
   130 		}.bind(this));
       
   131 	},
       
   132 
       
   133 	removeGoogleLayer: function (googleLayerName) {
       
   134 		var googleLayer = this._subLayers && this._subLayers[googleLayerName];
       
   135 		if (!googleLayer) return;
       
   136 
       
   137 		googleLayer.setMap(null);
       
   138 		delete this._subLayers[googleLayerName];
       
   139 	},
       
   140 
       
   141 
       
   142 	_initMutantContainer: function () {
       
   143 		if (!this._mutantContainer) {
       
   144 			this._mutantContainer = L.DomUtil.create('div', 'leaflet-google-mutant leaflet-top leaflet-left');
       
   145 			this._mutantContainer.id = '_MutantContainer_' + L.Util.stamp(this._mutantContainer);
       
   146 			this._mutantContainer.style.zIndex = '800'; //leaflet map pane at 400, controls at 1000
       
   147 			this._mutantContainer.style.pointerEvents = 'none';
       
   148 
       
   149 			this._map.getContainer().appendChild(this._mutantContainer);
       
   150 		}
       
   151 
       
   152 		this.setOpacity(this.options.opacity);
       
   153 		this.setElementSize(this._mutantContainer, this._map.getSize());
       
   154 
       
   155 		this._attachObserver(this._mutantContainer);
       
   156 	},
       
   157 
       
   158 	_initMutant: function () {
       
   159 		if (!this._ready || !this._mutantContainer) return;
       
   160 		this._mutantCenter = new google.maps.LatLng(0, 0);
       
   161 
       
   162 		var map = new google.maps.Map(this._mutantContainer, {
       
   163 			center: this._mutantCenter,
       
   164 			zoom: 0,
       
   165 			tilt: 0,
       
   166 			mapTypeId: this.options.type,
       
   167 			disableDefaultUI: true,
       
   168 			keyboardShortcuts: false,
       
   169 			draggable: false,
       
   170 			disableDoubleClickZoom: true,
       
   171 			scrollwheel: false,
       
   172 			streetViewControl: false,
       
   173 			styles: this.options.styles || {},
       
   174 			backgroundColor: 'transparent'
       
   175 		});
       
   176 
       
   177 		this._mutant = map;
       
   178 
       
   179 		google.maps.event.addListenerOnce(map, 'idle', function () {
       
   180 			var nodes = this._mutantContainer.querySelectorAll('a');
       
   181 			for (var i = 0; i < nodes.length; i++) {
       
   182 				nodes[i].style.pointerEvents = 'auto';
       
   183 			}
       
   184 		}.bind(this));
       
   185 
       
   186 		// 🍂event spawned
       
   187 		// Fired when the mutant has been created.
       
   188 		this.fire('spawned', {mapObject: map});
       
   189 	},
       
   190 
       
   191 	_attachObserver: function _attachObserver (node) {
       
   192 // 		console.log('Gonna observe', node);
       
   193 
       
   194 		var observer = new MutationObserver(this._onMutations.bind(this));
       
   195 
       
   196 		// pass in the target node, as well as the observer options
       
   197 		observer.observe(node, { childList: true, subtree: true });
       
   198 	},
       
   199 
       
   200 	_onMutations: function _onMutations (mutations) {
       
   201 		for (var i = 0; i < mutations.length; ++i) {
       
   202 			var mutation = mutations[i];
       
   203 			for (var j = 0; j < mutation.addedNodes.length; ++j) {
       
   204 				var node = mutation.addedNodes[j];
       
   205 
       
   206 				if (node instanceof HTMLImageElement) {
       
   207 					this._onMutatedImage(node);
       
   208 				} else if (node instanceof HTMLElement) {
       
   209 					Array.prototype.forEach.call(node.querySelectorAll('img'), this._onMutatedImage.bind(this));
       
   210 				}
       
   211 			}
       
   212 		}
       
   213 	},
       
   214 
       
   215 	// Only images which 'src' attrib match this will be considered for moving around.
       
   216 	// Looks like some kind of string-based protobuf, maybe??
       
   217 	// Only the roads (and terrain, and vector-based stuff) match this pattern
       
   218 	_roadRegexp: /!1i(\d+)!2i(\d+)!3i(\d+)!/,
       
   219 
       
   220 	// On the other hand, raster imagery matches this other pattern
       
   221 	_satRegexp: /x=(\d+)&y=(\d+)&z=(\d+)/,
       
   222 
       
   223 	// On small viewports, when zooming in/out, a static image is requested
       
   224 	// This will not be moved around, just removed from the DOM.
       
   225 	_staticRegExp: /StaticMapService\.GetMapImage/,
       
   226 
       
   227 	_onMutatedImage: function _onMutatedImage (imgNode) {
       
   228 // 		if (imgNode.src) {
       
   229 // 			console.log('caught mutated image: ', imgNode.src);
       
   230 // 		}
       
   231 
       
   232 		var coords;
       
   233 		var match = imgNode.src.match(this._roadRegexp);
       
   234 		var sublayer = 0;
       
   235 
       
   236 		if (match) {
       
   237 			coords = {
       
   238 				z: match[1],
       
   239 				x: match[2],
       
   240 				y: match[3]
       
   241 			};
       
   242 			if (this._imagesPerTile > 1) { 
       
   243 				imgNode.style.zIndex = 1;
       
   244 				sublayer = 1;
       
   245 			}
       
   246 		} else {
       
   247 			match = imgNode.src.match(this._satRegexp);
       
   248 			if (match) {
       
   249 				coords = {
       
   250 					x: match[1],
       
   251 					y: match[2],
       
   252 					z: match[3]
       
   253 				};
       
   254 			}
       
   255 // 			imgNode.style.zIndex = 0;
       
   256 			sublayer = 0;
       
   257 		}
       
   258 
       
   259 		if (coords) {
       
   260 			var tileKey = this._tileCoordsToKey(coords);
       
   261 			imgNode.style.position = 'absolute';
       
   262 			var cloneImgNode = imgNode.cloneNode(true);
       
   263 			cloneImgNode.style.visibility = 'visible';
       
   264 			imgNode.style.visibility = 'hidden';
       
   265 
       
   266 			var key = tileKey + '/' + sublayer;
       
   267 			if (key in this._tileCallbacks && this._tileCallbacks[key]) {
       
   268 // console.log('Fullfilling callback ', key);
       
   269 				this._tileCallbacks[key].shift()(cloneImgNode);
       
   270 				if (!this._tileCallbacks[key].length) { delete this._tileCallbacks[key]; }
       
   271 			} else {
       
   272 // console.log('Caching for later', key);
       
   273 
       
   274 				if (this._tiles[tileKey]) {
       
   275 					//we already have a tile in this position (mutation is probably a google layer being added)
       
   276 					//replace it
       
   277 					var c = this._tiles[tileKey].el;
       
   278 					var oldImg = (sublayer === 0) ? c.firstChild : c.firstChild.nextSibling;
       
   279 					c.replaceChild(cloneImgNode, oldImg);
       
   280 				} else if (key in this._freshTiles) {
       
   281 					this._freshTiles[key].push(cloneImgNode);
       
   282 				} else {
       
   283 					this._freshTiles[key] = [cloneImgNode];
       
   284 				}
       
   285 			}
       
   286 		} else if (imgNode.src.match(this._staticRegExp)) {
       
   287 			imgNode.style.visibility = 'hidden';
       
   288 		}
       
   289 	},
       
   290 
       
   291 
       
   292 	createTile: function (coords, done) {
       
   293 		var key = this._tileCoordsToKey(coords);
       
   294 
       
   295 		var tileContainer = L.DomUtil.create('div');
       
   296 		tileContainer.dataset.pending = this._imagesPerTile;
       
   297 		done = done.bind(this, null, tileContainer);
       
   298 
       
   299 		for (var i = 0; i < this._imagesPerTile; i++) {
       
   300 			var key2 = key + '/' + i;
       
   301 			if (key2 in this._freshTiles) {
       
   302 				tileContainer.appendChild(this._freshTiles[key2].pop());
       
   303 				if (!this._freshTiles[key2].length) { delete this._freshTiles[key2]; }
       
   304 				tileContainer.dataset.pending--;
       
   305 // 				console.log('Got ', key2, ' from _freshTiles');
       
   306 			} else {
       
   307 				this._tileCallbacks[key2] = this._tileCallbacks[key2] || [];
       
   308 				this._tileCallbacks[key2].push( (function (c/*, k2*/) {
       
   309 					return function (cloneImgNode) {
       
   310 						c.appendChild(cloneImgNode);
       
   311 						c.dataset.pending--;
       
   312 						if (!parseInt(c.dataset.pending)) { done(); }
       
   313 // 						console.log('Sent ', k2, ' to _tileCallbacks, still ', c.dataset.pending, ' images to go');
       
   314 					}.bind(this);
       
   315 				}.bind(this))(tileContainer/*, key2*/) );
       
   316 			}
       
   317 		}
       
   318 
       
   319 		if (!parseInt(tileContainer.dataset.pending)) {
       
   320 			L.Util.requestAnimFrame(done);
       
   321 		}
       
   322 		return tileContainer;
       
   323 	},
       
   324 
       
   325 	_checkZoomLevels: function () {
       
   326 		//setting the zoom level on the Google map may result in a different zoom level than the one requested
       
   327 		//(it won't go beyond the level for which they have data).
       
   328 		var zoomLevel = this._map.getZoom();
       
   329 		var gMapZoomLevel = this._mutant.getZoom();
       
   330 		if (!zoomLevel || !gMapZoomLevel) return;
       
   331 
       
   332 
       
   333 		if ((gMapZoomLevel !== zoomLevel) || //zoom levels are out of sync, Google doesn't have data
       
   334 			(gMapZoomLevel > this.options.maxNativeZoom)) { //at current location, Google does have data (contrary to maxNativeZoom)
       
   335 			//Update maxNativeZoom
       
   336 			this._setMaxNativeZoom(gMapZoomLevel);
       
   337 		}
       
   338 	},
       
   339 
       
   340 	_setMaxNativeZoom: function (zoomLevel) {
       
   341 		if (zoomLevel != this.options.maxNativeZoom) {
       
   342 			this.options.maxNativeZoom = zoomLevel;
       
   343 			this._resetView();
       
   344 		}
       
   345 	},
       
   346 
       
   347 	_reset: function () {
       
   348 		this._initContainer();
       
   349 	},
       
   350 
       
   351 	_update: function () {
       
   352 		// zoom level check needs to happen before super's implementation (tile addition/creation)
       
   353 		// otherwise tiles may be missed if maxNativeZoom is not yet correctly determined
       
   354 		if (this._mutant) {
       
   355 			var center = this._map.getCenter();
       
   356 			var _center = new google.maps.LatLng(center.lat, center.lng);
       
   357 
       
   358 			this._mutant.setCenter(_center);
       
   359 			var zoom = this._map.getZoom();
       
   360 			var fractionalLevel = zoom !== Math.round(zoom);
       
   361 			var mutantZoom = this._mutant.getZoom();
       
   362 
       
   363 			//ignore fractional zoom levels
       
   364 			if (!fractionalLevel && (zoom != mutantZoom)) {
       
   365 				this._mutant.setZoom(zoom);
       
   366 							
       
   367 				if (this._mutantIsReady) this._checkZoomLevels();
       
   368 				//else zoom level check will be done later by 'idle' handler
       
   369 			}
       
   370 		}
       
   371 
       
   372 		L.GridLayer.prototype._update.call(this);
       
   373 	},
       
   374 
       
   375 	_resize: function () {
       
   376 		var size = this._map.getSize();
       
   377 		if (this._mutantContainer.style.width === size.x &&
       
   378 			this._mutantContainer.style.height === size.y)
       
   379 			return;
       
   380 		this.setElementSize(this._mutantContainer, size);
       
   381 		if (!this._mutant) return;
       
   382 		google.maps.event.trigger(this._mutant, 'resize');
       
   383 	},
       
   384 
       
   385 	_handleZoomAnim: function () {
       
   386 		if (!this._mutant) return;
       
   387 		var center = this._map.getCenter();
       
   388 		var _center = new google.maps.LatLng(center.lat, center.lng);
       
   389 
       
   390 		this._mutant.setCenter(_center);
       
   391 		this._mutant.setZoom(Math.round(this._map.getZoom()));
       
   392 	},
       
   393 
       
   394 	// Agressively prune _freshtiles when a tile with the same key is removed,
       
   395 	// this prevents a problem where Leaflet keeps a loaded tile longer than
       
   396 	// GMaps, so that GMaps makes two requests but Leaflet only consumes one,
       
   397 	// polluting _freshTiles with stale data.
       
   398 	_removeTile: function (key) {
       
   399 		if (!this._mutant) return;
       
   400 
       
   401 		for (var i=0; i<this._imagesPerTile; i++) {
       
   402 			var key2 = key + '/' + i;
       
   403 			if (key2 in this._freshTiles) { delete this._freshTiles[key2]; }
       
   404 // 				console.log('Pruned spurious hybrid _freshTiles');
       
   405 		}
       
   406 
       
   407 		//if the tile is still visible in the google map, keep it.
       
   408 		//In this situation, if the tile is later required, there won't be a mutation event (since tile is already in gMap) 
       
   409 		//and there will be no other way to refetch the tile.
       
   410 		//this situation where GMaps keeps a tile longer than Leaflet can happen when the map goes past 
       
   411 		//self's maxNativeZoom
       
   412 		var gZoom = this._mutant.getZoom();
       
   413 		var zoom = key.split(':')[2];
       
   414 		if (zoom == gZoom && gZoom == this.options.maxNativeZoom) {
       
   415 			var imgs = this._tiles[key].el.querySelectorAll('img');
       
   416 			if (imgs.length) {
       
   417 				for (var j=0; j<this._imagesPerTile;j++) {
       
   418 					var keyJ = key + '/' + j;
       
   419 					var imgNode = imgs[j];
       
   420 					if (keyJ in this._freshTiles) {
       
   421 						this._freshTiles[keyJ].push(imgNode);
       
   422 					} else {
       
   423 						this._freshTiles[keyJ] = [imgNode];
       
   424 					}				
       
   425 				}
       
   426 			}
       
   427 		}
       
   428 
       
   429 
       
   430 		return L.GridLayer.prototype._removeTile.call(this, key);
       
   431 	}
       
   432 });
       
   433 
       
   434 
       
   435 // 🍂factory gridLayer.googleMutant(options)
       
   436 // Returns a new `GridLayer.GoogleMutant` given its options
       
   437 L.gridLayer.googleMutant = function (options) {
       
   438 	return new L.GridLayer.GoogleMutant(options);
       
   439 };