diff --git a/src/L.Map.Sync.js b/src/L.Map.Sync.js new file mode 100644 index 0000000..a4795da --- /dev/null +++ b/src/L.Map.Sync.js @@ -0,0 +1,269 @@ +/* + * Extends L.Map to synchronize the interaction on one map to one or more other maps. + */ + +(function () { + var NO_ANIMATION = { + animate: false, + reset: true, + disableViewprereset: true + }; + + L.Sync = function () {}; + /* + * Helper function to compute the offset easily. + * + * The arguments are relative positions with respect to reference and target maps of + * the point to sync. If you provide ratioRef=[0, 1], ratioTarget=[1, 0] will sync the + * bottom left corner of the reference map with the top right corner of the target map. + * The values can be less than 0 or greater than 1. It will sync points out of the map. + */ + L.Sync.offsetHelper = function (ratioRef, ratioTarget) { + var or = L.Util.isArray(ratioRef) ? ratioRef : [0.5, 0.5]; + var ot = L.Util.isArray(ratioTarget) ? ratioTarget : [0.5, 0.5]; + return function (center, zoom, refMap, targetMap) { + var rs = refMap.getSize(); + var ts = targetMap.getSize(); + var pt = refMap.project(center, zoom) + .subtract([(0.5 - or[0]) * rs.x, (0.5 - or[1]) * rs.y]) + .add([(0.5 - ot[0]) * ts.x, (0.5 - ot[1]) * ts.y]); + return refMap.unproject(pt, zoom); + }; + }; + + + L.Map.include({ + sync: function (map, options) { + this._initSync(); + options = L.extend({ + noInitialSync: false, + syncCursor: false, + syncCursorMarkerOptions: { + radius: 10, + fillOpacity: 0.3, + color: '#da291c', + fillColor: '#fff' + }, + offsetFn: function (center, zoom, refMap, targetMap) { + // no transformation at all + return center; + } + }, options); + + // prevent double-syncing the map: + if (this._syncMaps.indexOf(map) === -1) { + this._syncMaps.push(map); + this._syncOffsetFns[L.Util.stamp(map)] = options.offsetFn; + } + + if (!options.noInitialSync) { + map.setView( + options.offsetFn(this.getCenter(), this.getZoom(), this, map), + this.getZoom(), NO_ANIMATION); + } + if (options.syncCursor) { + if (typeof map.cursor === 'undefined') { + map.cursor = L.circleMarker([0, 0], options.syncCursorMarkerOptions).addTo(map); + } + + this._cursors.push(map.cursor); + + this.on('mousemove', this._cursorSyncMove, this); + this.on('mouseout', this._cursorSyncOut, this); + } + + // on these events, we should reset the view on every synced map + // dragstart is due to inertia + this.on('resize zoomend', this._selfSetView); + this.on('moveend', this._syncOnMoveend); + this.on('dragend', this._syncOnDragend); + return this; + }, + + + // unsync maps from each other + unsync: function (map) { + var self = this; + + if (this._cursors) { + this._cursors.forEach(function (cursor, indx, _cursors) { + if (cursor === map.cursor) { + _cursors.splice(indx, 1); + } + }); + } + + // TODO: hide cursor in stead of moving to 0, 0 + if (map.cursor) { + map.cursor.setLatLng([0, 0]); + } + + if (this._syncMaps) { + this._syncMaps.forEach(function (synced, id) { + if (map === synced) { + delete self._syncOffsetFns[L.Util.stamp(map)]; + self._syncMaps.splice(id, 1); + } + }); + } + + if (!this._syncMaps || this._syncMaps.length == 0) { + // no more synced maps, so these events are not needed. + this.off('resize zoomend', this._selfSetView); + this.off('moveend', this._syncOnMoveend); + this.off('dragend', this._syncOnDragend); + } + + return this; + }, + + // Checks if the map is synced with anything or a specifyc map + isSynced: function (otherMap) { + var has = (this.hasOwnProperty('_syncMaps') && Object.keys(this._syncMaps).length > 0); + if (has && otherMap) { + // Look for this specific map + has = false; + this._syncMaps.forEach(function (synced) { + if (otherMap == synced) { has = true; } + }); + } + return has; + }, + + + // Callbacks for events... + _cursorSyncMove: function (e) { + this._cursors.forEach(function (cursor) { + cursor.setLatLng(e.latlng); + }); + }, + + _cursorSyncOut: function (e) { + this._cursors.forEach(function (cursor) { + // TODO: hide cursor in stead of moving to 0, 0 + cursor.setLatLng([0, 0]); + }); + }, + + _selfSetView: function (e) { + // reset the map, and let setView synchronize the others. + this.setView(this.getCenter(), this.getZoom(), NO_ANIMATION); + }, + + _syncOnMoveend: function (e) { + if (this._syncDragend) { + // This is 'the moveend' after the dragend. + // Without inertia, it will be right after, + // but when inertia is on, we need this to detect that. + this._syncDragend = false; // before calling setView! + this._selfSetView(e); + this._syncMaps.forEach(function (toSync) { + toSync.fire('moveend'); + }); + } + }, + + _syncOnDragend: function (e) { + // It is ugly to have state, but we need it in case of inertia. + this._syncDragend = true; + }, + + + // overload methods on originalMap to replay interactions on _syncMaps; + _initSync: function () { + if (this._syncMaps) { + return; + } + var originalMap = this; + + this._syncMaps = []; + this._cursors = []; + this._syncOffsetFns = {}; + + L.extend(originalMap, { + setView: function (center, zoom, options, sync) { + // Use this sandwich to disable and enable viewprereset + // around setView call + function sandwich (obj, fn) { + var viewpreresets = []; + var doit = options && options.disableViewprereset && obj && obj._events; + if (doit) { + // The event viewpreresets does an invalidateAll, + // that reloads all the tiles. + // That causes an annoying flicker. + viewpreresets = obj._events.viewprereset; + obj._events.viewprereset = []; + } + var ret = fn(obj); + if (doit) { + // restore viewpreresets event to its previous values + obj._events.viewprereset = viewpreresets; + } + return ret; + } + + // Looks better if the other maps 'follow' the active one, + // so call this before _syncMaps + var ret = sandwich(this, function (obj) { + return L.Map.prototype.setView.call(obj, center, zoom, options); + }); + + if (!sync) { + originalMap._syncMaps.forEach(function (toSync) { + sandwich(toSync, function (obj) { + return toSync.setView( + originalMap._syncOffsetFns[L.Util.stamp(toSync)](center, zoom, originalMap, toSync), + zoom, options, true); + }); + }); + } + + return ret; + }, + + panBy: function (offset, options, sync) { + if (!sync) { + originalMap._syncMaps.forEach(function (toSync) { + toSync.panBy(offset, options, true); + }); + } + return L.Map.prototype.panBy.call(this, offset, options); + }, + + _onResize: function (event, sync) { + if (!sync) { + originalMap._syncMaps.forEach(function (toSync) { + toSync._onResize(event, true); + }); + } + return L.Map.prototype._onResize.call(this, event); + }, + + _stop: function (sync) { + L.Map.prototype._stop.call(this); + if (!sync) { + originalMap._syncMaps.forEach(function (toSync) { + toSync._stop(true); + }); + } + } + }); + + originalMap.dragging._draggable._updatePosition = function () { + L.Draggable.prototype._updatePosition.call(this); + var self = this; + originalMap._syncMaps.forEach(function (toSync) { + L.DomUtil.setPosition(toSync.dragging._draggable._element, self._newPos); + toSync.eachLayer(function (layer) { + if (layer._google !== undefined) { + var offsetFn = originalMap._syncOffsetFns[L.Util.stamp(toSync)]; + var center = offsetFn(originalMap.getCenter(), originalMap.getZoom(), originalMap, toSync); + layer._google.setCenter(center); + } + }); + toSync.fire('move'); + }); + }; + } + }); +})();