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
5 // 🍂class GridLayer.GoogleMutant
7 L
.GridLayer
.GoogleMutant
= L
.GridLayer
.extend({
8 includes
: L
.Mixin
.Events
,
16 attribution
: '', // The mutant container will add its own attribution anyways.
18 continuousWorld
: false,
20 // 🍂option type: String = 'roadmap'
21 // Google's map type. Valid values are 'roadmap', 'satellite' or 'terrain'. 'hybrid' is not really supported.
26 initialize: function (options
) {
27 L
.GridLayer
.prototype.initialize
.call(this, options
);
29 this._ready
= !!window
.google
&& !!window
.google
.maps
&& !!window
.google
.maps
.Map
;
31 this._GAPIPromise
= this._ready
? Promise
.resolve(window
.google
) : new Promise(function (resolve
, reject
) {
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'));
39 if (!!window
.google
&& !!window
.google
.maps
&& !!window
.google
.maps
.Map
) {
40 clearInterval(intervalId
);
41 return resolve(window
.google
);
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
51 this._imagesPerTile
= (this.options
.type
=== 'hybrid') ? 2 : 1;
52 this.createTile
= (this.options
.type
=== 'hybrid') ? this._createMultiTile
: this._createSingleTile
;
55 onAdd: function (map
) {
56 L
.GridLayer
.prototype.onAdd
.call(this, map
);
57 this._initMutantContainer();
59 this._GAPIPromise
.then(function () {
65 map
.on('viewreset', this._reset
, this);
66 map
.on('move', this._update
, this);
67 map
.on('zoomend', this._handleZoomAnim
, this);
68 map
.on('resize', this._resize
, this);
70 //20px instead of 1em to avoid a slight overlap with google's attribution
71 map
._controlCorners
.bottomright
.style
.marginBottom
= '20px';
78 onRemove: function (map
) {
79 L
.GridLayer
.prototype.onRemove
.call(this, map
);
80 map
._container
.removeChild(this._mutantContainer
);
81 this._mutantContainer
= undefined;
83 map
.off('viewreset', this._reset
, this);
84 map
.off('move', this._update
, this);
85 map
.off('zoomend', this._handleZoomAnim
, this);
86 map
.off('resize', this._resize
, this);
88 map
._controlCorners
.bottomright
.style
.marginBottom
= '0em';
91 getAttribution: function () {
92 return this.options
.attribution
;
95 setOpacity: function (opacity
) {
96 this.options
.opacity
= opacity
;
98 L
.DomUtil
.setOpacity(this._mutantContainer
, opacity
);
102 setElementSize: function (e
, size
) {
103 e
.style
.width
= size
.x
+ 'px';
104 e
.style
.height
= size
.y
+ 'px';
107 _initMutantContainer: function () {
108 if (!this._mutantContainer
) {
109 this._mutantContainer
= L
.DomUtil
.create('div', 'leaflet-google-mutant leaflet-top leaflet-left');
110 this._mutantContainer
.id
= '_MutantContainer_' + L
.Util
.stamp(this._mutantContainer
);
111 // this._mutantContainer.style.zIndex = 'auto';
112 this._mutantContainer
.style
.pointerEvents
= 'none';
114 this._map
.getContainer().appendChild(this._mutantContainer
);
117 this.setOpacity(this.options
.opacity
);
118 this.setElementSize(this._mutantContainer
, this._map
.getSize());
120 this._attachObserver(this._mutantContainer
);
123 _initMutant: function () {
124 if (!this._ready
|| !this._mutantContainer
) return;
125 this._mutantCenter
= new google
.maps
.LatLng(0, 0);
127 var map
= new google
.maps
.Map(this._mutantContainer
, {
128 center
: this._mutantCenter
,
131 mapTypeId
: this.options
.type
,
132 disableDefaultUI
: true,
133 keyboardShortcuts
: false,
135 disableDoubleClickZoom
: true,
137 streetViewControl
: false,
138 styles
: this.options
.styles
|| {},
139 backgroundColor
: 'transparent'
145 // Fired when the mutant has been created.
146 this.fire('spawned', {mapObject
: map
});
149 _attachObserver
: function _attachObserver (node
) {
150 // console.log('Gonna observe', node);
152 var observer
= new MutationObserver(this._onMutations
.bind(this));
154 // pass in the target node, as well as the observer options
155 observer
.observe(node
, { childList
: true, subtree
: true });
158 _onMutations
: function _onMutations (mutations
) {
159 for (var i
= 0; i
< mutations
.length
; ++i
) {
160 var mutation
= mutations
[i
];
161 for (var j
= 0; j
< mutation
.addedNodes
.length
; ++j
) {
162 var node
= mutation
.addedNodes
[j
];
164 if (node
instanceof HTMLImageElement
) {
165 this._onMutatedImage(node
);
166 } else if (node
instanceof HTMLElement
) {
167 Array
.prototype.forEach
.call(node
.querySelectorAll('img'), this._onMutatedImage
.bind(this));
173 // Only images which 'src' attrib match this will be considered for moving around.
174 // Looks like some kind of string-based protobuf, maybe??
175 // Only the roads (and terrain, and vector-based stuff) match this pattern
176 _roadRegexp
: /!1i(\d+)!2i(\d+)!3i(\d+)!/,
178 // On the other hand, raster imagery matches this other pattern
179 _satRegexp
: /x=(\d+)&y=(\d+)&z=(\d+)/,
181 // On small viewports, when zooming in/out, a static image is requested
182 // This will not be moved around, just removed from the DOM.
183 _staticRegExp
: /StaticMapService\.GetMapImage/,
185 _onMutatedImage
: function _onMutatedImage (imgNode
) {
186 // if (imgNode.src) {
187 // console.log('caught mutated image: ', imgNode.src);
191 var match
= imgNode
.src
.match(this._roadRegexp
);
192 var sublayer
, parent
;
200 if (this._imagesPerTile
> 1) { imgNode
.style
.zIndex
= 1; }
203 match
= imgNode
.src
.match(this._satRegexp
);
211 // imgNode.style.zIndex = 0;
216 var key
= this._tileCoordsToKey(coords
);
217 if (this._imagesPerTile
> 1) { key
+= '/' + sublayer
; }
218 if (key
in this._tileCallbacks
&& this._tileCallbacks
[key
]) {
219 // console.log('Fullfilling callback ', key);
220 this._tileCallbacks
[key
].pop()(imgNode
);
221 if (!this._tileCallbacks
[key
].length
) { delete this._tileCallbacks
[key
]; }
223 // console.log('Caching for later', key);
224 parent
= imgNode
.parentNode
;
226 parent
.removeChild(imgNode
);
227 parent
.removeChild
= L
.Util
.falseFn
;
228 // imgNode.parentNode.replaceChild(L.DomUtil.create('img'), imgNode);
230 if (key
in this._freshTiles
) {
231 this._freshTiles
[key
].push(imgNode
);
233 this._freshTiles
[key
] = [imgNode
];
236 } else if (imgNode
.src
.match(this._staticRegExp
)) {
237 parent
= imgNode
.parentNode
;
239 // Remove the image, but don't store it anywhere.
240 // Image needs to be replaced instead of removed, as the container
241 // seems to be reused.
242 imgNode
.parentNode
.replaceChild(L
.DomUtil
.create('img'), imgNode
);
247 // This will be used as this.createTile for 'roadmap', 'sat', 'terrain'
248 _createSingleTile
: function createTile (coords
, done
) {
249 var key
= this._tileCoordsToKey(coords
);
250 // console.log('Need:', key);
252 if (key
in this._freshTiles
) {
253 var tile
= this._freshTiles
[key
].pop();
254 if (!this._freshTiles
[key
].length
) { delete this._freshTiles
[key
]; }
255 L
.Util
.requestAnimFrame(done
);
256 // console.log('Got ', key, ' from _freshTiles');
259 var tileContainer
= L
.DomUtil
.create('div');
260 this._tileCallbacks
[key
] = this._tileCallbacks
[key
] || [];
261 this._tileCallbacks
[key
].push( (function (c
/*, k*/) {
262 return function (imgNode
) {
263 var parent
= imgNode
.parentNode
;
265 parent
.removeChild(imgNode
);
266 parent
.removeChild
= L
.Util
.falseFn
;
267 // imgNode.parentNode.replaceChild(L.DomUtil.create('img'), imgNode);
269 c
.appendChild(imgNode
);
271 // console.log('Sent ', k, ' to _tileCallbacks');
273 }.bind(this))(tileContainer
/*, key*/) );
275 return tileContainer
;
279 // This will be used as this.createTile for 'hybrid'
280 _createMultiTile
: function createTile (coords
, done
) {
281 var key
= this._tileCoordsToKey(coords
);
283 var tileContainer
= L
.DomUtil
.create('div');
284 tileContainer
.dataset
.pending
= this._imagesPerTile
;
286 for (var i
= 0; i
< this._imagesPerTile
; i
++) {
287 var key2
= key
+ '/' + i
;
288 if (key2
in this._freshTiles
) {
289 tileContainer
.appendChild(this._freshTiles
[key2
].pop());
290 if (!this._freshTiles
[key2
].length
) { delete this._freshTiles
[key2
]; }
291 tileContainer
.dataset
.pending
--;
292 // console.log('Got ', key2, ' from _freshTiles');
294 this._tileCallbacks
[key2
] = this._tileCallbacks
[key2
] || [];
295 this._tileCallbacks
[key2
].push( (function (c
/*, k2*/) {
296 return function (imgNode
) {
297 var parent
= imgNode
.parentNode
;
299 parent
.removeChild(imgNode
);
300 parent
.removeChild
= L
.Util
.falseFn
;
301 // imgNode.parentNode.replaceChild(L.DomUtil.create('img'), imgNode);
303 c
.appendChild(imgNode
);
305 if (!parseInt(c
.dataset
.pending
)) { done(); }
306 // console.log('Sent ', k2, ' to _tileCallbacks, still ', c.dataset.pending, ' images to go');
308 }.bind(this))(tileContainer
/*, key2*/) );
312 if (!parseInt(tileContainer
.dataset
.pending
)) {
313 L
.Util
.requestAnimFrame(done
);
315 return tileContainer
;
318 _checkZoomLevels: function () {
319 //setting the zoom level on the Google map may result in a different zoom level than the one requested
320 //(it won't go beyond the level for which they have data).
321 // verify and make sure the zoom levels on both Leaflet and Google maps are consistent
322 if ((this._map
.getZoom() !== undefined) && (this._mutant
.getZoom() !== this._map
.getZoom())) {
323 //zoom levels are out of sync. Set the leaflet zoom level to match the google one
324 this._map
.setZoom(this._mutant
.getZoom());
328 _reset: function () {
329 this._initContainer();
332 _update: function () {
333 L
.GridLayer
.prototype._update
.call(this);
334 if (!this._mutant
) return;
336 var center
= this._map
.getCenter();
337 var _center
= new google
.maps
.LatLng(center
.lat
, center
.lng
);
339 this._mutant
.setCenter(_center
);
340 var zoom
= this._map
.getZoom();
341 if (zoom
!== undefined) {
342 this._mutant
.setZoom(Math
.round(this._map
.getZoom()));
346 _resize: function () {
347 var size
= this._map
.getSize();
348 if (this._mutantContainer
.style
.width
=== size
.x
&&
349 this._mutantContainer
.style
.height
=== size
.y
)
351 this.setElementSize(this._mutantContainer
, size
);
352 if (!this._mutant
) return;
353 google
.maps
.event
.trigger(this._mutant
, 'resize');
356 _handleZoomAnim: function () {
357 var center
= this._map
.getCenter();
358 var _center
= new google
.maps
.LatLng(center
.lat
, center
.lng
);
360 this._mutant
.setCenter(_center
);
361 this._mutant
.setZoom(Math
.round(this._map
.getZoom()));
364 // Agressively prune _freshtiles when a tile with the same key is removed,
365 // this prevents a problem where Leaflet keeps a loaded tile longer than
366 // GMaps, so that GMaps makes two requests but Leaflet only consumes one,
367 // polluting _freshTiles with stale data.
368 _removeTile: function (key
) {
369 if (this._imagesPerTile
> 1) {
370 for (var i
=0; i
<this._imagesPerTile
; i
++) {
371 var key2
= key
+ '/' + i
;
372 if (key2
in this._freshTiles
) { delete this._freshTiles
[key2
]; }
373 // console.log('Pruned spurious hybrid _freshTiles');
376 if (key
in this._freshTiles
) {
377 delete this._freshTiles
[key
];
378 // console.log('Pruned spurious _freshTiles', key);
382 return L
.GridLayer
.prototype._removeTile
.call(this, key
);
387 // 🍂factory gridLayer.googleMutant(options)
388 // Returns a new `GridLayer.GoogleMutant` given its options
389 L
.gridLayer
.googleMutant = function (options
) {
390 return new L
.GridLayer
.GoogleMutant(options
);