Leaflet: Allow for offset on center of map.

Created on 31 Jul 2012  ·  31Comments  ·  Source: Leaflet/Leaflet

Hi. It would be super awesome to be able to offset the center of the map relative to the viewport. My main use case is that I have a large box overlaying the map on the left, and would like the center of the map to be more to the right. This means that when I do a pan or center, it would actually be offset a certain amount of pixels or percentage of viewport. This would be a map level setting.

I have wanted this feature in many JS mapping libraries but have not found it yet. Unfortunately, it needs to be pretty low level in the API.

Sorry, I don't have any code, was just thinking about and wanted to get it down.

Thanks for the awesome project!

feature

Most helpful comment

Here's how I've currently implemented a panTo with offset in our project at https://github.com/codeforamerica/lv-trucks-map (live site at http://clvfoodtrucks.com/). It modifies the L.map prototype object so that this method is "natively" available on map objects, and it should be added to your JavaScript code before creating a map:

L.Map.prototype.panToOffset = function (latlng, offset, options) {
    var x = this.latLngToContainerPoint(latlng).x - offset[0]
    var y = this.latLngToContainerPoint(latlng).y - offset[1]
    var point = this.containerPointToLatLng([x, y])
    return this.setView(point, this._zoom, { pan: options })
}

It works the same as panTo() but with an additional parameter, offset, which is an array in form of [xOffset, yOffset]. You could modify this to create a setViewOffset() method as well.

All 31 comments

It's actually pretty easy in Leaflet. What you need is map.containerPointToLatLng method. It converts some pixel coordinate relative to top left corner of the map into geographical location. So you do something like this:

var centerPoint = map.getSize().divideBy(2),
    targetPoint = centerPoint.subtract([overlayWidth, 0]),
    targetLatLng = map.containerPointToLatLng(centerPoint);

map.panTo(targetLatLng);

Does this solve your use case?

Hey. It sort of does, and is probably good enough. But, the issue here is that it assumes that the viewport is where you want to center, then offset it, so it would be a two step movement. As opposed to taking in a lat-lng, offseting, then panning; a single movement.

Oh, my calculations are not correct. You need to set actual map center in such way that the offset center is in the target point, and the offset will equal overlayWidth / 2. So here's the working code for a single movement:

var targetPoint = map.project(targetLatLng, targetZoom).subtract([overlayWidth / 2, 0]),
    targetLatLng = map.unproject(targetPoint, targetZoom);

map.setView(targetLatLng, targetZoom);

This is the same case that we had too - a slightly opaque sidebar overlayed on top of the map. Whenever we called fitBounds(), it would be a little off and we would need to do another movement in order to get it right. We were considering a pull request for this but ended up just moving the sidebar to be next to the map as we figured it might be a bit of work and didn't think it was a common enough use case.

@mourner That makes a lot of sense. I guess I missed the project() methods. This does handle most of my use case. Many thanks.

Like @ajbeaven, an offset fitBounds for a (GeoJSON) layer would be super awesome, but this seems like it would be a lot more complicated. I don't think just offsetting is good enough, as the bounds of the viewport are effectively changing, nor would I know how to do it in one call.

Either way, feel free to close this out if it doesn't make sense to add in the library itself. I think I can manage my specific project with the your provided code. Thanks.

fitBounds is a bit more verbose but easy too. Just copy-paste this https://github.com/CloudMade/Leaflet/blob/master/src/map/Map.js#L275 method, modifying what size it uses, and then use

var targetZoom = altGetBoundsZoom(map, bounds);
// ... calculate targetLatLng as above
map.setView(targetLatLng, targetZoom);

Hi. I am using fitBounds with LatLng[] as a parameter and I tried to use code snippets you provided above, but with no luck.
My web has a sidebar overlay where I want the map to be visible underneath, but I'd like to prevent markers to appear under this overlay. (It would be great to offset the map center too, but that can be done manually.)

Could you please post a full code snippet for fitBounds on _offset map_?
I'm quite confused by what you wrote above. The problem is that I don't have any targetLatLng nor targetPoint sice I use array of points.

Btw. it would be really awesome to have an option to define amount of semi-overlayed space on each side of the container, where you'd still draw the map, but wouldn't place any controls on markers there.

You can see the code I implemented here. (it is by no means a great instance of code, but the idea is there) It is also built into an object that holds a map property that is the Leaflet map. It has the following methods:

appSetView
appSetBounds
appGetBoundsZoom

https://github.com/MinnPost/minnpost-my-boundaries/blob/master/visualizations/index.html#L556

Reopening, as it would be useful to add this functionality built in.

Came across this discussion as I had to have a list at the left side of the screen of 550px with the map behind it. I decided it would probably be easier to override some base functions of L.Map. Thanks for all your ideas.

Hope I've got them all covered, but I'm not 100% sure.

MapCenterOffsetMixin = {
    UIOffset: [550, 0], // x, y
    getBounds: function(){
        var a=this.getPixelBounds(),
            b=this.unproject(new L.Point(a.min.x+this.UIOffset[0],a.max.y+this.UIOffset[1]), this._zoom,!0),
            c=this.unproject(new L.Point(a.max.x,a.min.y),this._zoom,!0);
            return new L.LatLngBounds(b,c)
    },
    _latLngToNewLayerPoint: function (latlng, newZoom, newCenter) {
        var targetPoint = this.project(newCenter, newCenter).subtract([this.UIOffset[0]/2, this.UIOffset[1]/2]),
            newCenter = this.unproject(targetPoint, newZoom);
        var topLeft = this._getNewTopLeftPoint(newCenter, newZoom).add(this._getMapPanePos());
        return this.project(latlng, newZoom)._subtract(topLeft);
    },
    _getCenterLayerPoint: function () {
        return this.containerPointToLayerPoint(this.getSize().divideBy(2).add([this.UIOffset[0]/2, this.UIOffset[1]/2]));
    },
    _resetView: function (a, b, c, d) {
        var e = this._zoom !== b;
        // Change the center
        var targetPoint = this.project(a, b).subtract([this.UIOffset[0] / 2, this.UIOffset[1]/2]),
            a = this.unproject(targetPoint, b);
        d || (this.fire("movestart"), e && this.fire("zoomstart")), this._zoom = b, this._initialTopLeftPoint = this._getNewTopLeftPoint(a);
        if (!c) L.DomUtil.setPosition(this._mapPane, new L.Point(0, 0));
        else {
            var f = L.DomUtil.getPosition(this._mapPane);
            this._initialTopLeftPoint._add(f)
        }
        this._tileLayersToLoad = this._tileLayersNum, this.fire("viewreset", {
            hard: !c
        }), this.fire("move"), (e || d) && this.fire("zoomend"), this.fire("moveend"), this._loaded || (this._loaded = !0, this.fire("load"))
    }
}

L.Map.include(MapCenterOffsetMixin);

@averrips Thanks for posting that. Your solution is working well for me currently... except that the zoom controls are broken in Firefox now :( I'm getting the sort of "catchall" error: TypeError: t is undefined

Here is an implementation of fitBoundsPadded, which should live in map:
Use it like fitBounds, but pass in the padding you want, so use it like:

map.fitBoundsPadded(myBounds, new L.Point(0, 0), new L.Point(0, 150));

And the code:

    fitBoundsPadded: function (bounds, paddingTopLeft, paddingBottomRight) { //(LatLngBounds, Point, Point)

        var zoom = this.getBoundsZoom(bounds);
        var zoomToTry = zoom;

        while (true) {
            var newTopLeft = this.unproject(this.project(bounds.getNorthEast(), zoomToTry).add(paddingTopLeft), zoomToTry);
            var newBottomRight = this.unproject(this.project(bounds.getSouthWest(), zoomToTry).add(paddingBottomRight), zoomToTry);

            var paddedBounds = new L.LatLngBounds([bounds.getSouthWest(), bounds.getNorthEast(), newTopLeft, newBottomRight]);

            var zoom2 = this.getBoundsZoom(paddedBounds);

            if (zoom2 == zoomToTry) {
                return this.fitBounds(paddedBounds);
            } else {
                zoomToTry--;
            }
        }
    },

Implemented in master! Use it like this:

map.fitBounds(bounds, [20, 30], [40, 50]); // bounds, topLeftPadding, bottomRightPadding

Or pass just the second argument and bottom-right padding will be defaulted to equal top-left.

The signature changed a bit. Now you use it like this:

map.fitBounds(bounds, {
    padding: [20, 30]
});

map.fitBounds(bounds, {
    paddingTopLeft: [20, 30],
    paddingbottomRight: [40, 50]
});

Is there any planned work on adding padding to other functions?

Possibly, yeah. For setView and panTo at least.

There should be a shortcut for applying the same padding on all sides. Something like this.

map.fitBounds(bounds, { padding: 20 });

Instead of the current...

map.fitBounds(bounds, { padding: [20, 20] });

@averrips That Mixin was very helpful, thank you.

I couldn't find the solution yet, so here you go (real easy):

function recenter(map,latlng,offsetx,offsety) {
      var center = map.project(latlng);
      center = new L.point(center.x+offsetx,center.y+offsety);
      var target = map.unproject(center);
      map.panTo(target);
}

Please add the same functionality also for "setView" and "panTo".
Thanks!

Also, is there a way for padding also the left|right controls?

Here's how I've currently implemented a panTo with offset in our project at https://github.com/codeforamerica/lv-trucks-map (live site at http://clvfoodtrucks.com/). It modifies the L.map prototype object so that this method is "natively" available on map objects, and it should be added to your JavaScript code before creating a map:

L.Map.prototype.panToOffset = function (latlng, offset, options) {
    var x = this.latLngToContainerPoint(latlng).x - offset[0]
    var y = this.latLngToContainerPoint(latlng).y - offset[1]
    var point = this.containerPointToLatLng([x, y])
    return this.setView(point, this._zoom, { pan: options })
}

It works the same as panTo() but with an additional parameter, offset, which is an array in form of [xOffset, yOffset]. You could modify this to create a setViewOffset() method as well.

Thanks, louh!

For reference, this config works for me to emulate the horizontal offset as seen on the foursquare homepage.
https://gist.github.com/missinglink/7620340

Thank you @louh!

Thanks @missinglink , your code works!

Is there a solution also for the zoom in/out actions?

I'm using something like this:

        var MapCenterOffsetMixin = {
            getMapOffset: function() {
                console.log('getMapOffset');
                return [$('#left-panel').offset().left, 0];
            },
            getBounds: function(){
                console.log('getBounds');
                var offset = this.getMapOffset(),
                    bounds = this.getPixelBounds(),
                    sw = this.unproject(new L.Point(bounds.min.x + offset[0], bounds.max.y + offset[1]), this._zoom, !0),
                    ne = this.unproject(new L.Point(bounds.max.x, bounds.min.y), this._zoom, !0);

                return new L.LatLngBounds(sw, ne)
            },
            _oldLatLngToNewLayerPoint: L.Map.prototype._latLngToNewLayerPoint,
            _latLngToNewLayerPoint: function (latlng, newZoom, newCenter) {
                console.log('_latLngToNewLayerPoint');
                var offset = this.getMapOffset(),
                    targetPoint = this.project(newCenter, newCenter).subtract([offset[0] / 2, offset[1] / 2]);
                newCenter = this.unproject(targetPoint, newZoom);

                return this._oldLatLngToNewLayerPoint(latlng, newZoom, newCenter);
            },
            _getCenterLayerPoint: function () {
                console.log('_getCenterLayerPoint');
                var offset = this.getMapOffset();
                return this.containerPointToLayerPoint(this.getSize().divideBy(2).add([offset[0] / 2, offset[1] / 2]));
            },
            _oldResetView: L.Map.prototype._resetView,
            _resetView: function (center, zoom, preserveMapOffset, afterZoomAnim) {
                console.log('_resetView');
                var offset = this.getMapOffset(),
                    targetPoint = this.project(center, zoom).subtract([offset[0] / 2, offset[1] / 2]);
                center = this.unproject(targetPoint, zoom);

                this._oldResetView(center, zoom, preserveMapOffset, afterZoomAnim);
            }
        };
        L.Map.include(MapCenterOffsetMixin);

but it fails on zooming.

Here is the solution: https://github.com/Mappy/Leaflet-active-area. Brilliant! :)

The 'map.fitBounds(bounds, topLeftPadding, bottomRightPadding)' function seems to be using values to adjust lat long, not pixels. So different levels of zoom will have different results on how far the map is adjusted. I'm sure there is a use case for this, but it seems like for the issue everyone is bringing up, a set size side-bar or top menu, that the adjustment shouldn't depend on zoom level. Is there a built-in leaflet function that adds padding based on pixels rather than coordinates?

topLeftPadding and bottomRightPadding expect values in pixels: http://leafletjs.com/reference.html#map-fitboundsoptions

For some reason that doesn't seem to be the case for me. To test this out I center a marker at a zoom level of 15 and then run this command...
map.fitBounds(pointsArray, {paddingTopLeft: [0,0], paddingBottomRight: [800,0], maxZoom: map.getZoom()});
Then I recenter the marker at a zoom level of 19 and run it again. At level 15 it is almost unnoticeable the amount it shifts. However, at level 19, it shift over about half the page. Am I doing something wrong in my function call?

Was this page helpful?
0 / 5 - 0 ratings