src/pyams_gis/resources/js/leaflet-google-mutant.js
changeset 0 c73bb834ccbe
child 75 a430cc4ae715
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_gis/resources/js/leaflet-google-mutant.js	Thu May 18 17:23:48 2017 +0200
@@ -0,0 +1,439 @@
+// Based on https://github.com/shramov/leaflet-plugins
+// GridLayer like https://avinmathew.com/leaflet-and-google-maps/ , but using MutationObserver instead of jQuery
+
+
+// 🍂class GridLayer.GoogleMutant
+// 🍂extends GridLayer
+L.GridLayer.GoogleMutant = L.GridLayer.extend({
+	includes: L.Mixin.Events,
+
+	options: {
+		minZoom: 0,
+		maxZoom: 18,
+		tileSize: 256,
+		subdomains: 'abc',
+		errorTileUrl: '',
+		attribution: '',	// The mutant container will add its own attribution anyways.
+		opacity: 1,
+		continuousWorld: false,
+		noWrap: false,
+		// 🍂option type: String = 'roadmap'
+		// Google's map type. Valid values are 'roadmap', 'satellite' or 'terrain'. 'hybrid' is not really supported.
+		type: 'roadmap',
+		maxNativeZoom: 21
+	},
+
+	initialize: function (options) {
+		L.GridLayer.prototype.initialize.call(this, options);
+
+		this._ready = !!window.google && !!window.google.maps && !!window.google.maps.Map;
+
+		this._GAPIPromise = this._ready ? Promise.resolve(window.google) : new Promise(function (resolve, reject) {
+			var checkCounter = 0;
+			var intervalId = null;
+			intervalId = setInterval(function () {
+				if (checkCounter >= 10) {
+					clearInterval(intervalId);
+					return reject(new Error('window.google not found after 10 attempts'));
+				}
+				if (!!window.google && !!window.google.maps && !!window.google.maps.Map) {
+					clearInterval(intervalId);
+					return resolve(window.google);
+				}
+				checkCounter++;
+			}, 500);
+		});
+
+		// Couple data structures indexed by tile key
+		this._tileCallbacks = {};	// Callbacks for promises for tiles that are expected
+		this._freshTiles = {};	// Tiles from the mutant which haven't been requested yet
+
+		this._imagesPerTile = (this.options.type === 'hybrid') ? 2 : 1;
+	},
+
+	onAdd: function (map) {
+		L.GridLayer.prototype.onAdd.call(this, map);
+		this._initMutantContainer();
+
+		this._GAPIPromise.then(function () {
+			this._ready = true;
+			this._map = map;
+
+			this._initMutant();
+
+			map.on('viewreset', this._reset, this);
+			map.on('move', this._update, this);
+			map.on('zoomend', this._handleZoomAnim, this);
+			map.on('resize', this._resize, this);
+
+			//handle layer being added to a map for which there are no Google tiles at the given zoom
+			google.maps.event.addListenerOnce(this._mutant, 'idle', function () {
+				this._checkZoomLevels();
+				this._mutantIsReady = true;
+			}.bind(this));
+
+			//20px instead of 1em to avoid a slight overlap with google's attribution
+			map._controlCorners.bottomright.style.marginBottom = '20px';
+			map._controlCorners.bottomleft.style.marginBottom = '20px';
+
+			this._reset();
+			this._update();
+
+			if (this._subLayers) {
+				//restore previously added google layers
+				for (var layerName in this._subLayers) {
+					this._subLayers[layerName].setMap(this._mutant);
+				}
+			}
+		}.bind(this));
+	},
+
+	onRemove: function (map) {
+		L.GridLayer.prototype.onRemove.call(this, map);
+		map._container.removeChild(this._mutantContainer);
+		this._mutantContainer = undefined;
+
+		map.off('viewreset', this._reset, this);
+		map.off('move', this._update, this);
+		map.off('zoomend', this._handleZoomAnim, this);
+		map.off('resize', this._resize, this);
+
+		map._controlCorners.bottomright.style.marginBottom = '0em';
+		map._controlCorners.bottomleft.style.marginBottom = '0em';
+	},
+
+	getAttribution: function () {
+		return this.options.attribution;
+	},
+
+	setOpacity: function (opacity) {
+		this.options.opacity = opacity;
+		if (opacity < 1) {
+			L.DomUtil.setOpacity(this._mutantContainer, opacity);
+		}
+	},
+
+	setElementSize: function (e, size) {
+		e.style.width = size.x + 'px';
+		e.style.height = size.y + 'px';
+	},
+
+
+	addGoogleLayer: function (googleLayerName, options) {
+		if (!this._subLayers) this._subLayers = {};
+		return this._GAPIPromise.then(function () {
+			var Constructor = google.maps[googleLayerName];
+			var googleLayer = new Constructor(options);
+			googleLayer.setMap(this._mutant);
+			this._subLayers[googleLayerName] = googleLayer;
+			return googleLayer;
+		}.bind(this));
+	},
+
+	removeGoogleLayer: function (googleLayerName) {
+		var googleLayer = this._subLayers && this._subLayers[googleLayerName];
+		if (!googleLayer) return;
+
+		googleLayer.setMap(null);
+		delete this._subLayers[googleLayerName];
+	},
+
+
+	_initMutantContainer: function () {
+		if (!this._mutantContainer) {
+			this._mutantContainer = L.DomUtil.create('div', 'leaflet-google-mutant leaflet-top leaflet-left');
+			this._mutantContainer.id = '_MutantContainer_' + L.Util.stamp(this._mutantContainer);
+			this._mutantContainer.style.zIndex = '800'; //leaflet map pane at 400, controls at 1000
+			this._mutantContainer.style.pointerEvents = 'none';
+
+			this._map.getContainer().appendChild(this._mutantContainer);
+		}
+
+		this.setOpacity(this.options.opacity);
+		this.setElementSize(this._mutantContainer, this._map.getSize());
+
+		this._attachObserver(this._mutantContainer);
+	},
+
+	_initMutant: function () {
+		if (!this._ready || !this._mutantContainer) return;
+		this._mutantCenter = new google.maps.LatLng(0, 0);
+
+		var map = new google.maps.Map(this._mutantContainer, {
+			center: this._mutantCenter,
+			zoom: 0,
+			tilt: 0,
+			mapTypeId: this.options.type,
+			disableDefaultUI: true,
+			keyboardShortcuts: false,
+			draggable: false,
+			disableDoubleClickZoom: true,
+			scrollwheel: false,
+			streetViewControl: false,
+			styles: this.options.styles || {},
+			backgroundColor: 'transparent'
+		});
+
+		this._mutant = map;
+
+		google.maps.event.addListenerOnce(map, 'idle', function () {
+			var nodes = this._mutantContainer.querySelectorAll('a');
+			for (var i = 0; i < nodes.length; i++) {
+				nodes[i].style.pointerEvents = 'auto';
+			}
+		}.bind(this));
+
+		// 🍂event spawned
+		// Fired when the mutant has been created.
+		this.fire('spawned', {mapObject: map});
+	},
+
+	_attachObserver: function _attachObserver (node) {
+// 		console.log('Gonna observe', node);
+
+		var observer = new MutationObserver(this._onMutations.bind(this));
+
+		// pass in the target node, as well as the observer options
+		observer.observe(node, { childList: true, subtree: true });
+	},
+
+	_onMutations: function _onMutations (mutations) {
+		for (var i = 0; i < mutations.length; ++i) {
+			var mutation = mutations[i];
+			for (var j = 0; j < mutation.addedNodes.length; ++j) {
+				var node = mutation.addedNodes[j];
+
+				if (node instanceof HTMLImageElement) {
+					this._onMutatedImage(node);
+				} else if (node instanceof HTMLElement) {
+					Array.prototype.forEach.call(node.querySelectorAll('img'), this._onMutatedImage.bind(this));
+				}
+			}
+		}
+	},
+
+	// Only images which 'src' attrib match this will be considered for moving around.
+	// Looks like some kind of string-based protobuf, maybe??
+	// Only the roads (and terrain, and vector-based stuff) match this pattern
+	_roadRegexp: /!1i(\d+)!2i(\d+)!3i(\d+)!/,
+
+	// On the other hand, raster imagery matches this other pattern
+	_satRegexp: /x=(\d+)&y=(\d+)&z=(\d+)/,
+
+	// On small viewports, when zooming in/out, a static image is requested
+	// This will not be moved around, just removed from the DOM.
+	_staticRegExp: /StaticMapService\.GetMapImage/,
+
+	_onMutatedImage: function _onMutatedImage (imgNode) {
+// 		if (imgNode.src) {
+// 			console.log('caught mutated image: ', imgNode.src);
+// 		}
+
+		var coords;
+		var match = imgNode.src.match(this._roadRegexp);
+		var sublayer = 0;
+
+		if (match) {
+			coords = {
+				z: match[1],
+				x: match[2],
+				y: match[3]
+			};
+			if (this._imagesPerTile > 1) { 
+				imgNode.style.zIndex = 1;
+				sublayer = 1;
+			}
+		} else {
+			match = imgNode.src.match(this._satRegexp);
+			if (match) {
+				coords = {
+					x: match[1],
+					y: match[2],
+					z: match[3]
+				};
+			}
+// 			imgNode.style.zIndex = 0;
+			sublayer = 0;
+		}
+
+		if (coords) {
+			var tileKey = this._tileCoordsToKey(coords);
+			imgNode.style.position = 'absolute';
+			var cloneImgNode = imgNode.cloneNode(true);
+			cloneImgNode.style.visibility = 'visible';
+			imgNode.style.visibility = 'hidden';
+
+			var key = tileKey + '/' + sublayer;
+			if (key in this._tileCallbacks && this._tileCallbacks[key]) {
+// console.log('Fullfilling callback ', key);
+				this._tileCallbacks[key].shift()(cloneImgNode);
+				if (!this._tileCallbacks[key].length) { delete this._tileCallbacks[key]; }
+			} else {
+// console.log('Caching for later', key);
+
+				if (this._tiles[tileKey]) {
+					//we already have a tile in this position (mutation is probably a google layer being added)
+					//replace it
+					var c = this._tiles[tileKey].el;
+					var oldImg = (sublayer === 0) ? c.firstChild : c.firstChild.nextSibling;
+					c.replaceChild(cloneImgNode, oldImg);
+				} else if (key in this._freshTiles) {
+					this._freshTiles[key].push(cloneImgNode);
+				} else {
+					this._freshTiles[key] = [cloneImgNode];
+				}
+			}
+		} else if (imgNode.src.match(this._staticRegExp)) {
+			imgNode.style.visibility = 'hidden';
+		}
+	},
+
+
+	createTile: function (coords, done) {
+		var key = this._tileCoordsToKey(coords);
+
+		var tileContainer = L.DomUtil.create('div');
+		tileContainer.dataset.pending = this._imagesPerTile;
+		done = done.bind(this, null, tileContainer);
+
+		for (var i = 0; i < this._imagesPerTile; i++) {
+			var key2 = key + '/' + i;
+			if (key2 in this._freshTiles) {
+				tileContainer.appendChild(this._freshTiles[key2].pop());
+				if (!this._freshTiles[key2].length) { delete this._freshTiles[key2]; }
+				tileContainer.dataset.pending--;
+// 				console.log('Got ', key2, ' from _freshTiles');
+			} else {
+				this._tileCallbacks[key2] = this._tileCallbacks[key2] || [];
+				this._tileCallbacks[key2].push( (function (c/*, k2*/) {
+					return function (cloneImgNode) {
+						c.appendChild(cloneImgNode);
+						c.dataset.pending--;
+						if (!parseInt(c.dataset.pending)) { done(); }
+// 						console.log('Sent ', k2, ' to _tileCallbacks, still ', c.dataset.pending, ' images to go');
+					}.bind(this);
+				}.bind(this))(tileContainer/*, key2*/) );
+			}
+		}
+
+		if (!parseInt(tileContainer.dataset.pending)) {
+			L.Util.requestAnimFrame(done);
+		}
+		return tileContainer;
+	},
+
+	_checkZoomLevels: function () {
+		//setting the zoom level on the Google map may result in a different zoom level than the one requested
+		//(it won't go beyond the level for which they have data).
+		var zoomLevel = this._map.getZoom();
+		var gMapZoomLevel = this._mutant.getZoom();
+		if (!zoomLevel || !gMapZoomLevel) return;
+
+
+		if ((gMapZoomLevel !== zoomLevel) || //zoom levels are out of sync, Google doesn't have data
+			(gMapZoomLevel > this.options.maxNativeZoom)) { //at current location, Google does have data (contrary to maxNativeZoom)
+			//Update maxNativeZoom
+			this._setMaxNativeZoom(gMapZoomLevel);
+		}
+	},
+
+	_setMaxNativeZoom: function (zoomLevel) {
+		if (zoomLevel != this.options.maxNativeZoom) {
+			this.options.maxNativeZoom = zoomLevel;
+			this._resetView();
+		}
+	},
+
+	_reset: function () {
+		this._initContainer();
+	},
+
+	_update: function () {
+		// zoom level check needs to happen before super's implementation (tile addition/creation)
+		// otherwise tiles may be missed if maxNativeZoom is not yet correctly determined
+		if (this._mutant) {
+			var center = this._map.getCenter();
+			var _center = new google.maps.LatLng(center.lat, center.lng);
+
+			this._mutant.setCenter(_center);
+			var zoom = this._map.getZoom();
+			var fractionalLevel = zoom !== Math.round(zoom);
+			var mutantZoom = this._mutant.getZoom();
+
+			//ignore fractional zoom levels
+			if (!fractionalLevel && (zoom != mutantZoom)) {
+				this._mutant.setZoom(zoom);
+							
+				if (this._mutantIsReady) this._checkZoomLevels();
+				//else zoom level check will be done later by 'idle' handler
+			}
+		}
+
+		L.GridLayer.prototype._update.call(this);
+	},
+
+	_resize: function () {
+		var size = this._map.getSize();
+		if (this._mutantContainer.style.width === size.x &&
+			this._mutantContainer.style.height === size.y)
+			return;
+		this.setElementSize(this._mutantContainer, size);
+		if (!this._mutant) return;
+		google.maps.event.trigger(this._mutant, 'resize');
+	},
+
+	_handleZoomAnim: function () {
+		if (!this._mutant) return;
+		var center = this._map.getCenter();
+		var _center = new google.maps.LatLng(center.lat, center.lng);
+
+		this._mutant.setCenter(_center);
+		this._mutant.setZoom(Math.round(this._map.getZoom()));
+	},
+
+	// Agressively prune _freshtiles when a tile with the same key is removed,
+	// this prevents a problem where Leaflet keeps a loaded tile longer than
+	// GMaps, so that GMaps makes two requests but Leaflet only consumes one,
+	// polluting _freshTiles with stale data.
+	_removeTile: function (key) {
+		if (!this._mutant) return;
+
+		for (var i=0; i<this._imagesPerTile; i++) {
+			var key2 = key + '/' + i;
+			if (key2 in this._freshTiles) { delete this._freshTiles[key2]; }
+// 				console.log('Pruned spurious hybrid _freshTiles');
+		}
+
+		//if the tile is still visible in the google map, keep it.
+		//In this situation, if the tile is later required, there won't be a mutation event (since tile is already in gMap) 
+		//and there will be no other way to refetch the tile.
+		//this situation where GMaps keeps a tile longer than Leaflet can happen when the map goes past 
+		//self's maxNativeZoom
+		var gZoom = this._mutant.getZoom();
+		var zoom = key.split(':')[2];
+		if (zoom == gZoom && gZoom == this.options.maxNativeZoom) {
+			var imgs = this._tiles[key].el.querySelectorAll('img');
+			if (imgs.length) {
+				for (var j=0; j<this._imagesPerTile;j++) {
+					var keyJ = key + '/' + j;
+					var imgNode = imgs[j];
+					if (keyJ in this._freshTiles) {
+						this._freshTiles[keyJ].push(imgNode);
+					} else {
+						this._freshTiles[keyJ] = [imgNode];
+					}				
+				}
+			}
+		}
+
+
+		return L.GridLayer.prototype._removeTile.call(this, key);
+	}
+});
+
+
+// 🍂factory gridLayer.googleMutant(options)
+// Returns a new `GridLayer.GoogleMutant` given its options
+L.gridLayer.googleMutant = function (options) {
+	return new L.GridLayer.GoogleMutant(options);
+};