librenms/html/js/Leaflet.GoogleMutant.js
Tony Murray 957ecc2441
Locations UI and editing (#9480)
* Better handling of errors
Mapquest seems to return the center of the US on error.......

* Editable locations WIP

* Change to bootgrid ajax table WIP

* Graphs working, using handlebars
update js libs
add current location button

* remove sql query, change icon

* Add the map to the device view, only when gps is expanded.
Allow edit on device page, share js code

* fix chevron rotation, improve click area

* extra warning

* fix overview layout (remove containers)

* fix style

* fix html divs, change collapse ui a bit
move css, update css/js versions

* start zoomed out on new locations

* don't double load scripts, zoom to 17

* fix php-md errors, remove unused use statement

* improve non-admin experience

* Move locations page to Laravel
More functions in Url and Html util classes
reduce code duplication

* translation buttons too

* fix whitespace

* move formatters to the frontend

* small changes

* disable traffic for locations with no devices

* change down 0 to green from gray

* missing "

* Fix paginate all

* working fix for paginate all

* allow sort by counts

* fix down sort

* a little safety

* Don't call the function twice

* btn-xs
2018-11-29 19:23:40 -06:00

458 lines
14 KiB
JavaScript

// 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({
options: {
minZoom: 0,
maxZoom: 23,
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;
this._boundOnMutatedImage = this._onMutatedImage.bind(this);
},
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;
google.maps.event.clearListeners(map, 'idle');
google.maps.event.clearListeners(this._mutant, 'idle');
map.off('viewreset', this._reset, this);
map.off('move', this._update, this);
map.off('zoomend', this._handleZoomAnim, this);
map.off('resize', this._resize, this);
if (map._controlCorners) {
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._boundOnMutatedImage
);
// Check for, and remove, the "Sorry, we have no imagery here"
// empty <div>s. The [style*="text-align: center"] selector
// avoids matching the attribution notice.
// This empty div doesn't have a reference to the tile
// coordinates, so it's not possible to mark the tile as
// failed.
Array.prototype.forEach.call(
node.querySelectorAll('div[draggable=false][style*="text-align: center"]'),
L.DomUtil.remove
)
}
}
}
},
// 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';
imgNode.style.visibility = 'hidden';
var key = tileKey + '/' + sublayer;
// console.log('mutation for tile', key)
//store img so it can also be used in subsequent tile requests
this._freshTiles[key] = imgNode;
if (key in this._tileCallbacks && this._tileCallbacks[key]) {
// console.log('Fullfilling callback ', key);
//fullfill most recent tileCallback because there maybe callbacks that will never get a
//corresponding mutation (because map moved to quickly...)
this._tileCallbacks[key].pop()(imgNode);
if (!this._tileCallbacks[key].length) { delete this._tileCallbacks[key]; }
} else {
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;
var cloneImgNode = this._clone(imgNode);
c.replaceChild(cloneImgNode, oldImg);
}
}
} 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) {
var imgNode = this._freshTiles[key2];
tileContainer.appendChild(this._clone(imgNode));
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 (imgNode) {
c.appendChild(this._clone(imgNode));
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;
},
_clone: function (imgNode) {
var clonedImgNode = imgNode.cloneNode(true);
clonedImgNode.style.visibility = 'visible';
return clonedImgNode;
},
_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;
//give time for animations to finish before checking it tile should be pruned
setTimeout(this._pruneTile.bind(this, key), 1000);
return L.GridLayer.prototype._removeTile.call(this, key);
},
_pruneTile: function (key) {
var gZoom = this._mutant.getZoom();
var tileZoom = key.split(':')[2];
var googleBounds = this._mutant.getBounds();
var sw = googleBounds.getSouthWest();
var ne = googleBounds.getNorthEast();
var gMapBounds = L.latLngBounds([[sw.lat(), sw.lng()], [ne.lat(), ne.lng()]]);
for (var i=0; i<this._imagesPerTile; i++) {
var key2 = key + '/' + i;
if (key2 in this._freshTiles) {
var tileBounds = this._map && this._keyToBounds(key);
var stillVisible = this._map && tileBounds.overlaps(gMapBounds) && (tileZoom == gZoom);
if (!stillVisible) delete this._freshTiles[key2];
// console.log('Prunning of ', key, (!stillVisible))
}
}
}
});
// 🍂factory gridLayer.googleMutant(options)
// Returns a new `GridLayer.GoogleMutant` given its options
L.gridLayer.googleMutant = function (options) {
return new L.GridLayer.GoogleMutant(options);
};