Show and Tell - Dash Leaflet

@Emil , Thanks again for this great package and your amazing responses over the years.

I’m currently working on an app where I use the assign function to do a bindTooltip to apply labelling to a dl.GeoJSON layer. I’ve managed to use that to great effect, but I’m wondering if there is a way to make a tooltip or Popup of a GeoJSON layer draggable or moveable.


tooltip_crosstab=assign("""function(feature, layer) {
    layer.bindTooltip(
        function (layer) {
            let rows = [],
            myFeature = layer.feature;
            for (const property in myFeature.properties) {
                if (property != 'cluster' && property != 'id' && property != 'x_coord' && property != 'y_coord' && property != 'sys_sample_code'){
                    rows.push (`<tr><th>${property}</th><td>${myFeature.properties[property]}</td></tr>`);
                    }
            }
            let body = rows.join();
            return(`<table><tr><th>${myFeature.properties.sys_loc_code}</th><th>Value</th></tr>${body}</table>`);
            
        }, //end formatting function
        {
            permanent: true,
            interactive: true
        }
    );
}""")


equis_geojson_query_layer_cross = dl.GeoJSON(id="equis_gj_query_layer_cross", zoomToBounds=True,cluster=True, options=dict({"onEachFeature": tooltip_crosstab}))

Please let me know if you have any guidance on something like this. Again, thanks for all the support for this package, its been invaluable.

Lance

Thanks for the MWE! That helped me narrow down the bug quickly. I have pushed a new version 1.0.12rc2 that contains a bugfix. Could you try it out to confirm that is solves the issue on your side? For reference, here is the code I tested with (slightly modified version of yours, to be independent of the content of the assets folder),

"""Example of Dash Leaflet Bug."""
import dash_extensions.javascript as djs
import dash_leaflet as dl
import dash_leaflet.express as dlx
from dash import Dash, html
from dash.dependencies import Input, Output, State

icon_red = "https://leafletjs.com/examples/custom-icons/leaf-red.png"
icon_green = "https://leafletjs.com/examples/custom-icons/leaf-green.png"
point_to_layer = djs.assign(
    """function(feature, latlng){
    const flag = L.icon({
        iconUrl: `${feature.properties.icon}`,
        iconSize: [40, 40],
    });
    return L.marker(latlng, {icon: flag});
}"""
)
geojson = dl.GeoJSON(
    data=dlx.dicts_to_geojson(
        [{"lat": lat, "lon": 10, "icon": icon_green} for lat in [56, 57]]
    ),
    id="geojson_id",
    options=dict(pointToLayer=point_to_layer),
    cluster=True,
)

app = Dash()
app.layout = html.Div(
    [
        dl.Map(
            [dl.TileLayer(), geojson], center=[56, 10], zoom=6, style={"height": "50vh"}
        ),
        cluster := html.Button("Toggle Cluster", id="toggle_cluster", n_clicks=0),
        icons := html.Button("Update Icons", id="change_icons_btn", n_clicks=0),
    ]
)


@app.callback(
    Output(geojson, "cluster"),
    Input(cluster, "n_clicks"),
    prevent_inital_call=True,
)
def toggle_cluster_on_off(n_clicks):
    return n_clicks % 2 == 0


@app.callback(
    Output(geojson, "data"),
    Input(icons, "n_clicks"),
    State(geojson, "data"),
    prevent_inital_call=True,
)
def change_map_icons(n_clicks, geojson_data):
    icon = icon_green if n_clicks % 2 == 0 else icon_red
    for point in geojson_data["features"]:
        point["properties"]["icon"] = icon
    return geojson_data


if __name__ == "__main__":
    app.run_server()
1 Like

In newer versions of React Leaflet, map controls are considered immutable, i.e. they cannot be changed after construction. In most cases I think that makes sense - but this component may be an exception. When I have some time, I’ll reconsider, if I should make an exception for it :slight_smile:

Hi @Emil

Thanks for the update. I just tried 1.1.12rc2 and can confirm the strange behaviour of the icons is resolved :slight_smile: awesome work!

Regarding map controls being immutable, this makes sense now and thanks for explaining. A use case for modifying the EditControl could be an app that switches modes, requiring a change from point selection to different draw tool functionalities or disabling point selection altogether once data has already been selected or loaded.

However I found a workaround that can serve this purpose; you overwrite the EditControl with a new instance with a new dash id which can then be swapped on and off the map as below:

Thanks again for all the work with dash leaflet!

import dash_leaflet as dl
from dash import Dash, html
from dash.dependencies import Input, Output

markers = ["circle", "marker", "polyline", "polygon", "rectangle", "circlemarker"]
all_tools_off = {key: False for key in markers}
all_tools_on = {key: True for key in markers}

app = Dash()
app.layout = html.Div(
    [
        dl.Map(
            [
                dl.TileLayer(),
                dl.FeatureGroup(
                    id="feature_group",
                    children=dl.EditControl(id="edit_control", draw=all_tools_off),
                ),
            ],
            center=[56, 10],
            zoom=6,
            style={"height": "50vh"},
        ),
        html.Button("Change Draw Toolbar", id="button", n_clicks=0),
    ]
)


@app.callback(
    Output("feature_group", "children"),
    Input("button", "n_clicks"),
    prevent_initial_call=True,
)
def change_draw_options(n_clicks):
    if n_clicks % 2 == 0:
        return dl.EditControl(id="edit_control_off", draw=all_tools_off)
    else:
        return dl.EditControl(id="edit_control_on", draw=all_tools_on)


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

@Emil - Been using this library in production for ~6 months or so now, really love it.

Two questions (that are related):

I want to add/remove layers via zoomlevel. I see I can write some custom event handlers, but seems to me it might be better to just use a clientside callback with a hideout? Could have a hideout prop that is something like ‘isVisible’? That seems like a much quick solution than the event handler. Looks like I can grab the zoomlevel via the map container in a callback. That said, it seems like the downside to this approach would be the user would be downloading all the layers in the browser, then just turned on and off, where the event handler would be dynamically adding/removing the layer.

Second question… as our dataset grows, the amount of data I’m needing to add to the map is also growing. Right now, I’ve probably got ~5,000 or so entires into a few different GeoJSONs (served in PostGIS - which I have cached via Redis), but I have about 50-100x more data I could add to the map. Would it be worthwhile to swap over to flatgeobuf? The data is mostly static (can be refreshed nightly and dumped into S3). Feels like something I should just go ahead and pull the trigger on now.

Thanks!

For anyone else reading this and want to incorporate maps, I strongly suggest using this package AND becoming an expert on how the hideouts work. I’ve saved a ton of time by forcing myself to write some rather basic javascript and using clientside callbacks with the hideout. It is incredibly powerful.

1 Like

Hi @Emil , getting access to all the event handlers is fantastic and I have now used it for a few, but I am struggling to catch the events emitted from the LayersControl. When I try the measureControl one from the examples, it all works as expected but the layersControl always only returns None. Are you able to mock up an example that interacts with the layersControl which returns the name of the overlay which is toggled on/off please? What I have is as follows:

event_handlers = dict(
overlayadd=assign(“function(e, ctx){ctx.setProps({layerAdd: e.name})}”),
)

Any help will be greatly appreciated! Thanks.

Custom Cluster and Icons on GeoJSON:

# dash_extentions.javascript import assign
point_to_layer_js = assign("""function(feature, latlng){
    const flag = L.icon({iconUrl: `https://cdn.discordapp.com/attachments/419291925322006528/1178069819707359242/mining_site1.png?ex=6574ce04&is=65625904&hm=8c23df4651c5a131cbfb37ee155aa1edd0e7c9608a33f9bcc862df09875396b9&`, iconSize: [64, 48]});
        return L.marker(latlng, {icon: flag});
}
""")
cluster_to_layer = assign("""function(feature, latlng, index, context){
function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

function ringSVG(opt) {
  function describeArc(opt) {
    const innerStart = polarToCartesian(opt.x, opt.y, opt.radius, opt.endAngle);
    const innerEnd = polarToCartesian(opt.x, opt.y, opt.radius, opt.startAngle);
    const outerStart = polarToCartesian(opt.x, opt.y, opt.radius + opt.ringThickness, opt.endAngle);
    const outerEnd = polarToCartesian(opt.x, opt.y, opt.radius + opt.ringThickness, opt.startAngle);
    const largeArcFlag = opt.endAngle - opt.startAngle <= 180 ? "0" : "1";
    return [ "M", outerStart.x, outerStart.y,
             "A", opt.radius + opt.ringThickness, opt.radius + opt.ringThickness, 0, largeArcFlag, 0, outerEnd.x, outerEnd.y,
             "L", innerEnd.x, innerEnd.y,
             "A", opt.radius, opt.radius, 0, largeArcFlag, 1, innerStart.x, innerStart.y,
             "L", outerStart.x, outerStart.y, "Z"].join(" ");
  }

  const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
      return { x: centerX + (radius * Math.cos((angleInDegrees - 90) * Math.PI / 180.0)),
               y: centerY + (radius * Math.sin((angleInDegrees - 90) * Math.PI / 180.0)) };
  }

  opt = opt || {};
  const defaults = { width: 60, height: 60, radius: 20, gapDeg: 5, goodPerc: 75, fontSize: 17, text: `test`,
                     ringThickness: 7, goodColor: 'green', badColor: 'red'};
  opt = {...defaults, ...opt};

  const badPercDeg = 360 * (100 - opt['goodPerc']) / 100;
  const stdOpt = {x: opt['width']/2, y: opt['height']/2, radius: opt['radius'], ringThickness: opt['ringThickness']};
  const dGreen = describeArc({...stdOpt, startAngle: 90, endAngle: 450 - badPercDeg - opt['gapDeg']});
  const dRed   = describeArc({...stdOpt, startAngle: 450 - badPercDeg, endAngle: 450 - opt['gapDeg']});
  const path1 = `<path class="path1" fill="${opt['goodColor']}" d="${dGreen}"></path>`
  const path2 = opt['goodPerc'] < 100 ? `<path class="path2" fill="${opt['badColor']}" d="${dRed}"></path>` : '';
  return `<svg id="svg" width="${opt['width']}" height="${opt['height']}">
            ${path1} ${path2}
            <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle" font-size="${opt['fontSize']}"
                  fill="black"> ${opt['text'] || opt['goodPerc']}
            </text>
          </svg>`;
}
/*********************/

    const {min, max, colorscale, circleOptions, colorProp} = context.hideout;
    const csc = chroma.scale(colorscale).domain([min, max]);

    // Set color based on mean value of leaves.
    const leaves = index.getLeaves(feature.properties.cluster_id);
    let valueSum = 0;
    for (let i = 0; i < leaves.length; ++i) {
        valueSum += leaves[i].properties[colorProp]
    }
    const valueMean = valueSum / leaves.length;

    // icon background color
    const scatterIcon = L.DivIcon.extend({
        createIcon: function(oldIcon) {
               let icon = L.DivIcon.prototype.createIcon.call(this, oldIcon);
               return icon;
        }
    });

    // Render a circle with the number of leaves written in the center.
    const total = feature.properties.point_count_abbreviated;
    const numOffline = getRandomInt(0, total);
    const numOnline = total - numOffline;
    const goodPerc = numOnline / total * 100; 

    const icon = new scatterIcon({
        html: ringSVG({
                text:`${total-numOffline}/${total}`,
                goodPerc,
                width: 70,
                height: 70,
                radius: 15,
                fontSize: 12,
                ringThickness: 5,
            }),
        className: "marker-cluster",
        iconSize: L.point(40, 40),
        className: "marker-cluster",
        iconSize: L.point(40, 40),
        color: csc(valueMean)
    });
    return L.marker(latlng, {icon : icon})
}
""")

GeoJson & map:

cities = dl.GeoJSON(
    data=geobuf,
    format='geobuf',
    cluster=True,
    zoomToBoundsOnClick=True,
    superClusterOptions=dict(radius=40),
    hideout=dict(
        circleOptions=dict(fillOpacity=1, stroke=False, radius=3),
        min=0,
    ),
    pointToLayer=point_to_layer_js,
    clusterToLayer=cluster_to_layer,
)

dl.Map([
                    dl.TileLayer(),
                    cities
                ],
                    style={'width': '100%', 'height': '100vh'},
                    center=[40, -74],  # y, x
                    zoom=7
                )
2 Likes

Hello,

many thanks for your contribution to the community for creating the package. I was wondering if it is possible to “unspiderify” a cluster after it is clicked on - when e.g. 10 points are on the same lat and longitude and you use clustering then after clicking on the cluster they show in a spiderwebb manner on the map, but after I click elsewhere they do not go back into the cluster. Can I somehow make them go back? This is the standard behaviour in the standard Leaflet JS library.

Thank you.

@Dano I never noticed this feature, and thus didn’t implement it. I think it makes sense though - and as I generally strive towards keeping the dash-leaflet components in sync with their Leaflet counterparts, I have drafted an implementation and pushed an rc (1.0.16rc1) release,

Could you test if it works as intended? :slight_smile:

1 Like

Hi, I will definitely test it tommorow, thank you very much for your fast response. While working on my project I also noticed that when zooming into clusters, the spread out of the cluster into smaller ones is not animated like it is in classic Leaflet. This animation looks very nice and clean. Even though I can see the marker-cluster.css file with the animation “transition: transform 0.3s ease-out, opacity 0.3s ease-in” inside it, it does not propagate/execute. I have tried to fix this myself but without succes.

Thank you again.

EDIT: The click feature works as intented, thanks.

@Emil Hi, Im sorry to bother you, I was wondering if you could help me do the animanation I was describing above myself to work in my dash app. I suspect you dont animate the clusters cause you follow the supercluster library and not the leaflet.markercluster. Im a quite a noob in this area of programming but I suspect I could make my own JS code to “execute” the animation that I would put in the assets folder and it would work (if I checked the markercluster code correctly its just applying the classname to the cluster div when zooming is triggered and hiding the classname when the animation ends). Is this a right approach?

Thanks again.

One thing I really miss from dl.MarkerClusterGroup is the convex hull shading that appeared when you hovered over a cluster:

https://leaflet.github.io/Leaflet.markercluster/example/marker-clustering-realworld.388.html

Is it possible to replicate that behavior with dl.GeoJSON instead?

1 Like

@Emil sorry to bother you about this item again (see post above: Show and Tell - Dash Leaflet - #377 by troynh). I am really struggling to create an event handler which listens to the LayersControl. I have tried both the overlayadd and baselayerchange keys but neither seem to fire. Is there any chance you could please help me out here?
(I have tried all the examples in the docs and can get them to all work correctly)
Thanks!

Hi Leaflet gang,

When using Dash Leaflet I have encountered an issue where the map does not render correctly when its parent container is toggled from being hidden (initially display: none ) to visible. I have provided some example code below to demonstrate this effect.

  • Is this behaviour expected? A known limitation?
  • Is there anything that can be done to resolve this (either ‘properly’ to trigger re-rendering or a workaround to achieve a similar effect).
import dash
import dash_leaflet as dl
from dash import Input, Output, html


app = dash.Dash(__name__)
app.layout = html.Div(
    [
        # Button to toggle the map visibility
        html.Button("Show/Hide Map", id="toggle-map-btn", n_clicks=0),
        # Div containing the map, initially hidden
        html.Div(
            [
                dl.Map(
                    [dl.TileLayer(), dl.Marker(position=(56, 10))],
                    center=(56, 10),
                    zoom=8,
                    style={"width": "1000px", "height": "500px"},
                ),
            ],
            id="map-container",
            style={"display": "none"},
        ),
    ]
)


# Callback to toggle the map's visibility
@app.callback(Output("map-container", "style"), Input("toggle-map-btn", "n_clicks"))
def toggle_map_visibility(n):
    if n % 2 == 0:
        return {"display": "none"}
    return {"display": "block"}


if __name__ == "__main__":
    app.run_server(debug=True)

Thank you

edit: Was able to get a workaround for my use case - you allow the leaflet map to load at its target location / dimensions but place a white DIV covering it which you can then make transparent to create the ‘unhiding’ effect via a callback.

Is their a way to make tooltip direction top? I was looking through the documentation and I noticed the prop position doesn’t work, I attempted a few others like sticky, direction, interactive but I couldn’t get any change in a dl.tooltip component.

dl.Marker(position=[lat, lon],
 children=dl.Tooltip(children=html.Img(src=pop_up, style={'width': '20vw'}),
                     direction='top'),
 icon=icon, id='initial_marker')
1 Like

Hi @Emil, thank you so much for creating this leaflet wrapper. using this for months and love it. Been trying to put plotly graph in the popup but no luck. Any help would be appreciated. Thanks in advance.

import dash_leaflet as dl
from dash import Dash
import plotly.express as px

df = px.data.iris()
fig = px.bar(df.head(), x='sepal_width', y='petal_width')
fig_in_html = fig.to_html(full_html=True, default_width='400px',default_height='100px', include_plotlyjs='cdn', div_id='', include_mathjax=False)

center = [56, 10]
app = Dash()
app.layout = dl.Map([
   dl.TileLayer(),
   dl.Marker(position=[55, 10], children=[dl.Popup(content=fig_in_html)])
], center=center, zoom=6, style={'height': '50vh'})

if __name__ == '__main__':
   app.run_server(debug=True,)

@ttylo
Came to the forums to post the exact same question - I’ll play around with it today and see what I can do. I’ve looked at this in the past and wasn’t able to get anything working.

Does that method give you an error?

@ttylo

Need to format some of the sizes… but this worked for me:

df = px.data.iris()
fig = px.bar(df.head(), x='sepal_width', y='petal_width')
map = dl.Map([
dl.TileLayer(),
dl.Marker(position=[55, 10], children=dl.Popup(dcc.Graph(figure=fig,style={'height':'300px','width':'700px'})))
], center=[55, 10], zoom=6, style={'height': '50vh'})

@Emil Is there anyway to do the above except with a geojson? Think it has something to do with the ‘onEachFeature’ method?

Thx so much @bgivens33, this is exactly what i was looking for.