emilhe / dash-leaflet

MIT License
213 stars 37 forks source link

Changing the viewport/bounds of a MapContainer via callback #199

Closed sebastian-schweizer closed 7 months ago

sebastian-schweizer commented 1 year ago

Apparently in dash-leaflet version 1.0 there is no longer the viewport property for a MapContainer. In version 0.1 it was possible to set a viewport as a dictionary containing zoom level and center coordinates. Is there a way to change the viewport via a callback with the bounds property?

Thanks!

emilhe commented 1 year ago

The viewport property was removed to align with the new React-Leaflet API. However, I without it, there is no easy way (without writing JS) to set zoom/center after map initialization - but there should be. I am considering a few options,

1) Make the center and zoom properties mutable (+) is simple, intuitive (-) if you want to move the map, you may need to target two properties as output (both zoom and center)

2) Re-introduce the viewport property similar to before (-) is not as intuitive (must users first intuition is to target zoom/center props directly) (+) you only need to set one property as output to move the map

3) Do both (+) best of both worlds, maybe? (-) multiple ways of doing the same thing (-) possibly code complexity

Finally, I don't like that there's currently no way to specify how to move the map, i.e. "jump-to" (as before), "pan-to", or "fly-to". With (1) it would be possible to add a property to specify the how, but then you're suddenly targeting 3 props to move the map, and it's suddenly not as intuitive. With (2), an option would be to add the "how" as part of the viewport spec, i.e. something like

{center: number, zoom: number, animate?: "set" | "pan" | "fly", options?: object}

it could even be extended to include bounds also,

{center?: number, zoom?: number, bounds?: number[][], animate?: "set" | "pan" | "fly", options?: object}

so that the map would fit the bounds if provided. This solution would seem to be the most generic, but may also be less beginner friendly. What are your thoughts, @gisensing-sschweizer ?

sebastian-schweizer commented 1 year ago

I like all three options. But I would prefer (2), because with more complex callbacks the amount of output increases, and it is more convenient to only specify one output viewport. The introduction of a dictionary which also includes the options to pass bounds and the animation type would be great! As you have already pointed out, the greatest disadvantages are that it is not very intuitive with the center and zoom properties and of course that it would no longer be aligned with React-Leaftlet API in terms of this component. Maybe it would even be useful to replace the center and zoom property with an initial viewport property. In my opinion that would make it more intuitive and generic.

I party also like (3) but I don’t like multiple to do the same. What if you (accidentally) target in one callback the center and zoom properties and in another callback the viewport property? As far as I understand that could lead to inconsistent behavior - if it is technically even possible to implement.

Option (1) would be possibly the easiest way to implement and also be the most beginner-friendly option. However, I find it a pity that the properties bounds and animation are not taken into account in this case.

By the way, dash-leaflet and also the new documentation are amazing! Thank you very much :)

emilhe commented 1 year ago

I have made a draft implementation of approach (2) and pushed a release candidate that you should be able to test out,

pip install dash-leaflet==1.0.9rc1

Here is a small example app,

import dash_leaflet as dl
from dash_extensions.enrich import DashProxy, html, Input, Output, MultiplexerTransform

app = DashProxy(prevent_initial_callbacks=True, transforms=[MultiplexerTransform()])
app.layout = html.Div([
    dl.Map([
        dl.TileLayer()
    ], center=[56, 10], zoom=6, style={'height': '50vh'}, id="map"),
    html.Button("Fly", id="fly"),
])

@app.callback(Output("map", "viewport"), Input("fly", "n_clicks"))
def fly_to(_):
    return dict(center=[40, -98], zoom=5, transition="flyTo")

if __name__ == '__main__':
    app.run_server()
lperozzi commented 1 year ago

Hi, in the previous version I was able to get the bounds of the map using a callback function and filtering my data according to the map bounds:

@app.callback(
    Output('histogram', 'figure'),
    Input('map', 'bounds'),
)
def update_histogram(bounds):
    print(bounds)

    # Your existing code to update the histogram based on map bounds
    df_filtered = df[(df['latitude'] >= bounds[0][0]) & (df['latitude'] <= bounds[1][0]) &
                     (df['longitude'] >= bounds[0][1]) & (df['longitude'] <= bounds[1][1])]

    #... (rest of code)
    return fig

How can I achieve the same results with the new version?

emilhe commented 1 year ago

@lperozzi That should work similar to before. I just tested the following code,

from dash import Dash, html, Output, Input
import dash_leaflet as dl
import json

app = Dash()
app.layout = html.Div([
    dl.Map(dl.TileLayer(), center=[56, 10], zoom=6, style={'height': '50vh'}, id="map"),
    html.Div(id="log")
])

@app.callback(Output("log", "children"), Input("map", "bounds"))
def update(bounds):
    return json.dumps(bounds)

if __name__ == '__main__':
    app.run_server()

which seems to work as intended with version 1.0.9rc1. What version are you using?

sebastian-schweizer commented 1 year ago

Thank you very much for implementing approach (2)! Everything works for me with version 1.0.9rc1. I really like how animations like flyTo are now supported.

sebastian-schweizer commented 1 year ago

Despite having set the property trackViewport to True in the MapContainer component the variables current_viewport, current_center and current_zoom are always None in the following example and cannot trigger the callback. Is there a way that the viewport could be used to trigger a callback?

Basically, I try to implement a location search. The geocoder provides a viewport and a location and then the map should fly to this viewport and highlight the location with a marker. But if the user zooms or pans the highlighting marker should disappear. Conceptually, this mechanism should work with the following callback:

import dash_leaflet as dl
from dash_extensions.enrich import DashProxy, html, Input, Output, State, callback, callback_context, MultiplexerTransform

app = DashProxy(prevent_initial_callbacks=True, transforms=[MultiplexerTransform()])
app.layout = html.Div([
    dl.Map([
        dl.TileLayer(),
        dl.Pane(
            id='marker-pane',
            name='marker-pane',
            children=[]
        )
    ], center=[56, 10], zoom=6, style={'height': '50vh'}, id="map", trackViewport=True),
    html.Button("Fly", id="fly")
])

@callback(
    Output('map', 'viewport'),
    Output('marker-pane', 'children'),
    Input('fly', 'n_clicks'),
    Input('map', 'viewport'),
    State('map', 'center'),
    State('map', 'zoom')
)
def track_viewport(click, current_viewport, current_center, current_zoom):
    if callback_context.triggered[0]['prop_id'] == 'fly.n_clicks':
        # Fly to new viewport and show a marker
        return dict(center=[40, -98], zoom=5, transition="flyTo"), dl.Marker(position=[40, -98])
    else:
        # Otherwise remove the marker
        return dash.no_update, []

if __name__ == '__main__':
    app.run_server()
emilhe commented 1 year ago

There was a bug with respect to the zoom/center part of the viewport tracking. It should be fixed in 1.0.9rc2. With this release, you should be able to move the marker with the viewport with code like,

import dash_leaflet as dl
from dash_extensions.enrich import DashProxy, html, Input, Output, callback

app = DashProxy(prevent_initial_callbacks=True)
app.layout = html.Div([
    dl.Map([
        dl.TileLayer(),
        dl.LayerGroup(id='group')
    ], center=[56, 10], zoom=6, style={'height': '50vh'}, id="map"),
    html.Button("Fly", id="fly")
])

@callback(
    Output('map', 'viewport'),
    Input('fly', 'n_clicks'),
)
def change_viewport(n_clicks):
    return dict(center=[40, -98], zoom=5, transition="flyTo")

@callback(
    Output('group', 'children'),
    Input('map', 'center')
)
def track_viewport(center):
    return dl.Marker(position=center)

if __name__ == '__main__':
    app.run_server()
sebastian-schweizer commented 1 year ago

Now, the zoom and center properties trigger a callback when the viewport changes. But it is a pity that the animation flyTo triggers a callback multiple times that has the property zoom or center as input. It would be very useful if the property viewport was also able to trigger a callback. If that was the case, I could set a new viewport with the animation flyTo and the property viewport would hold the target viewport as long as the user does no panning or zooming. If the user carries out some panning or zooming the property viewport would change the center and zoom value. Would that make sense?

Thank you for implementing all these things!

emilhe commented 1 year ago

@gisensing-sschweizer I have pushed a (non-rc) 1.0.9 release with the changes discussed here. I like the current approach of using the viewport prop only to manipulate the map view, while other props (zoom, center, bounds) remain read-only.

I do see the issue though - and I suspect it may be a bug in Leaflet (similar to this one). I'll try to think of a good solution; maybe a debounce would help (i.e. not updating the viewport if it changes too fast; that is, the map is in motion).