|
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 }; |