randyzwitch / streamlit-folium

Streamlit Component for rendering Folium maps
https://folium.streamlit.app/
MIT License
490 stars 185 forks source link

Programmatically adjusting Draw layer on map. #129

Open rcutler-ursa opened 1 year ago

rcutler-ursa commented 1 year ago

Is there any way to pass in something like the all_drawings structure to st_folium (akin to passing center and zoom) in order to programmatically alter the drawing layer(s) in the Draw plugin?

blackary commented 1 year ago

It looks like you should be able to just add different objects to a LayerGroup, and then when you add Draw, pass that feature group to it to make it editable https://leaflet.github.io/Leaflet.draw/docs/leaflet-draw-latest.html

Let me know if that doesn't work.

rcutler-ursa commented 1 year ago

Hmmm... If I'm understanding what's happening in Leaflet (as is listed in the documentation you pointed me to), and I'm understanding how to convert that to the right Folium calls, and I haven't made a stupid mistake somewhere...

Here's my test code:

import folium
import streamlit as st
from folium.plugins import Draw
from streamlit_folium import st_folium

st.set_page_config(
    layout="wide",
)

my_map = folium.Map(location=[37.47, -75.0], zoom_start=8)

my_feature_group = folium.FeatureGroup(name="my_feature_group")
my_feature_group.add_to(my_map)

my_line = folium.PolyLine([(37.4753864, -75.8641933), (37.2506729, -76.66811018914242)])
my_line.add_to(my_feature_group)

my_draw_control = Draw(
    edit_options={
        "featureGroup": "my_feature_group",
    },
)
my_draw_control.add_to(my_map)

map_output = st_folium(my_map, width=1400, height=1000)
st.write(map_output)

Note that I can't pass the feature group directly as it's not "JSON Serializable", so I'm guessing I should pass the name of the group instead. I've also tried various casing for the "featureGroup" key to no avail.

I get the line drawn properly, but it's not editable. I can, however, create new lines with the editing tools. I'm sure I'm missing something obvious...

rcutler-ursa commented 1 year ago

The drawing layer also gets reset if there's any other change to the map. My use case is that I want the user to be able to draw a polyline on the map and once it's drawn, I want the Streamlit app to draw a polygon that consists of the user's line with a buffer around it. But I still want the user to be able to edit that line.

If I can start the editing in the draw layer with a pre-defined line, that solves the problem as I can just reset the editing layer each time the map is drawn.

Were you able to come up with anything?

rbifulco commented 1 year ago

I am trying to do something similar, however I am afraid that the current folium implementation does not directly support it.

I checked the code in folium.plugings.draw.Draw and it has:


    _template = Template(
        """
        {% macro script(this, kwargs) %}
            var options = {
              position: {{ this.position|tojson }},
              draw: {{ this.draw_options|tojson }},
              edit: {{ this.edit_options|tojson }},
            }
            // FeatureGroup is to store editable layers.
            var drawnItems = new L.featureGroup().addTo(
              {{ this._parent.get_name() }}
            );
            options.edit.featureGroup = drawnItems;
            var {{ this.get_name() }} = new L.Control.Draw(
                options
            ).addTo( {{this._parent.get_name()}} );
...

As you can see, the options.edit.featureGroup is never read and directly assigned to a newly created FeatureGroup.

rbifulco commented 1 year ago

I could use some more time to look into this, but with the big disclaimer that I am new to streamlit-folium, folium and definitely not comfortable with JavaScript and front-end development! Nonetheless, this works at least in a few simple cases, like in this example:

# Define map
m = folium.Map()

# Define FeatureGroup for drawings
draw_group = folium.FeatureGroup(name='Drawings', show=True, overlay=True, control=True)
draw_group.add_to(m)

# Add some drowing, such as a Marker
folium.Marker([0.0, 0.0], popup='Marker', tooltip='Marker', special='rob').add_to(draw_group)

# newdraw is provided in the next snippet ;)
from newdraw import NewDraw

# Pass the draw_group internal folium name in edit_options
NewDraw(
     edit_options={
         'featureGroup': draw_group.get_name(),
     }).add_to(m)

# Optionally, define LayerControl to disable hide the Drawings FeatureGroup
lc = folium.LayerControl(position='bottomleft', collapsed=False)
lc.add_to(m)

# Render the map
st_data = st_folium(m, 
                    width='100%', 
                    returned_objects=['last_object_clicked', 'all_drawings', 'last_active_drawing'],
                    feature_group_to_add=None,)
print(st_data)

As soon you add new drawings or edit/delete the existing ones, all_drawings will be updated to include also those defined in the drawings FeatureGroup.

Here is the code for NewDraw (I placed it in a newdraw.py file in the same dir of my project):

from branca.element import Element, Figure, MacroElement
from jinja2 import Template

from folium.elements import JSCSSMixin

import folium

class NewDraw(JSCSSMixin, MacroElement):
    """
    Extension of vector drawing and editing plugin for Leaflet, which adds support for
    custom defined FeatureGroup to host drawings.

    Parameters
    ----------
    export : bool, default False
        Add a small button that exports the drawn shapes as a geojson file.
    filename : string, default 'data.geojson'
        Name of geojson file
    position : {'topleft', 'toprigth', 'bottomleft', 'bottomright'}
        Position of control.
        See https://leafletjs.com/reference.html#control
    show_geometry_on_click : bool, default True
        When True, opens an alert with the geometry description on click.
    draw_options : dict, optional
        The options used to configure the draw toolbar. See
        http://leaflet.github.io/Leaflet.draw/docs/leaflet-draw-latest.html#drawoptions
    edit_options : dict, optional
        The options used to configure the edit toolbar. See
        https://leaflet.github.io/Leaflet.draw/docs/leaflet-draw-latest.html#editpolyoptions

    Examples
    --------
    >>> m = folium.Map()
    >>> draw_group = folium.FeatureGroup(name='Drawings', control=True)
    >>> draw_group.add_to(m)
    >>> NewDraw(
    ...     edit_options={ 'featureGroup': draw_group.get_name()},
    ... ).add_to(m)

    For more info please check
    https://github.com/randyzwitch/streamlit-folium/issues/129

    """
    _template = Template(
        """
        {% macro script(this, kwargs) %}
            var options = {
                position: {{ this.position|tojson }},
                draw: {{ this.draw_options|tojson }},
                edit: {{ this.edit_options|tojson }},
            }

            var featureGroupName = options.edit.featureGroup;
            // This is the layer number of the FeatureGroup, which is set only if
            // the FeatureGroup has already been created.
            var layerNum = options.edit.layernum
            var drawnItems;

            // Find the layer
            if (layerNum !== undefined) {
                var count = 0;
                {{ this._parent.get_name() }}.eachLayer(function (layer) {

                    if (layer instanceof L.FeatureGroup) {
                        if (count === layerNum) {
                            drawnItems = layer;
                        }
                        count++;
                    }
                });
            }

            // If no existing FeatureGroup was provided or found, create a new one.
            if (!drawnItems) {
                drawnItems = new L.FeatureGroup().addTo({{ this._parent.get_name() }});
                drawnItems.options.name = featureGroupName;
            }

            // Use the found or newly created FeatureGroup.
            options.edit.featureGroup = drawnItems;

            var {{ this.get_name() }} = new L.Control.Draw(
                options
            ).addTo( {{this._parent.get_name()}} );

            {{ this._parent.get_name() }}.on(L.Draw.Event.CREATED, function(e) {
                var layer = e.layer,
                    type = e.layerType;
                var coords = JSON.stringify(layer.toGeoJSON());
                {%- if this.show_geometry_on_click %}
                layer.on('click', function() {
                    alert(coords);
                    console.log(coords);
                });
                {%- endif %}
                drawnItems.addLayer(layer);
            });
            {{ this._parent.get_name() }}.on('draw:created', function(e) {
                drawnItems.addLayer(e.layer);
            });
            {% if this.export %}
            document.getElementById('export').onclick = function(e) {
                var data = drawnItems.toGeoJSON();
                var convertedData = 'text/json;charset=utf-8,'
                    + encodeURIComponent(JSON.stringify(data));
                document.getElementById('export').setAttribute(
                    'href', 'data:' + convertedData
                );
                document.getElementById('export').setAttribute(
                    'download', {{ this.filename|tojson }}
                );
            }
            {% endif %}
        {% endmacro %}
        """
    )

    default_js = [
        (
            "leaflet_draw_js",
            "https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.2/leaflet.draw.js",
        )
    ]
    default_css = [
        (
            "leaflet_draw_css",
            "https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.2/leaflet.draw.css",
        )
    ]

    def __init__(
        self,
        export=False,
        filename="data.geojson",
        position="topleft",
        show_geometry_on_click=True,
        draw_options=None,
        edit_options=None,
    ):
        super().__init__()
        self._name = "DrawControl"
        self.export = export
        self.filename = filename
        self.position = position
        self.show_geometry_on_click = show_geometry_on_click
        self.draw_options = draw_options or {}
        self.edit_options = edit_options or {}

    def render(self, **kwargs):
        # Get all the layers to find the number of the featureGroup whose name
        # is given in edit_options['featureGroup'].
        # The solution is quite hacky, since it uses _children, which is
        # supposed to be private.

        figure = self.get_root()
        assert isinstance(
            figure, Figure
        ), "You cannot render this Element if it is not in a Figure."

        if ('featureGroup' in self.edit_options):
            map = next(iter(figure._children.values()))

            # We count only the FeatureGroups. We do so becasue after rendering 
            # the map will count among the layers also things like Markers.
            # That would make the layer count inconsistent between python and js. 
            layers = [fg for (fg, obj) in map._children.items() if isinstance(obj, folium.FeatureGroup)]
            layer_num = False
            for i, layer in enumerate(layers):
                print(layer)
                if layer == self.edit_options['featureGroup']:
                    layer_num = i
                    break
            if layer_num is not False:
                # We set a new edit_option, which is then used in _template
                print('Setting layer number to ' + str(layer_num))
                self.edit_options['layernum'] = layer_num

        super().render(**kwargs)

        export_style = """
            <style>
                #export {
                    position: absolute;
                    top: 5px;
                    right: 10px;
                    z-index: 999;
                    background: white;
                    color: black;
                    padding: 6px;
                    border-radius: 4px;
                    font-family: 'Helvetica Neue';
                    cursor: pointer;
                    font-size: 12px;
                    text-decoration: none;
                    top: 90px;
                }
            </style>
        """
        export_button = """<a href='#' id='export'>Export</a>"""
        if self.export:
            figure.header.add_child(Element(export_style), name="export")
            figure.html.add_child(Element(export_button), name="export_button")
rcutler-ursa commented 1 year ago

Finally got back to my project needing this feature... Thanks! NewDraw seems to work for what I need. It does appear that the drawing feature group needs to be the first layer added to the map, but that's not a problem for my use case. Again, thank you!

rbifulco commented 1 year ago

I came back working on this for one of my hobby projects, and needed this feature for the actual first time... of course I realized my previous suggestion is not working very well.

The issue is that streamlit-folium uses the hash of the generated component to verify when a map needs re-rendering. Since the initially generated map has the drawnItems FeatureGroup created in python, every change to such FeatureGroup in python will trigger a re-rendering of the map.

Now, this might work well enough if you only do changes to the map on the client side, at the only cost of some performance penality (the creation of drawnItems at each rerun). However, there is an exception: if the user edits an existing drawing, at the next rerun the original drawing will come back into place, since the drawnItem feature groups is always populated with the initial set of drawings in python. If the programmer decides to track the edits and change accordingly the creation of drawnItems, this will trigger a rerendering of the entire map.

In any case, definitely not a good solution. I therefore went ahead and decided to use an approach similar to the one used in st_folium for feature_group_to_add. This however requires a change to the component itself. In the new approach I added a drawn_items_geojson input variable to st_folium, which takes a GeoJSON string. The drawings in the GeoJSON string are then added to the map during the first rendering in Javascript.

# Define your GeoJSON string (could be loaded from a file)
drawn_items = {"type":"FeatureCollection", "features":[ ... ]}
drawn_items_json = json.dumps(drawn_items)

st_data = st_folium(m, width='100%', 
                    returned_objects=['last_object_clicked', 
                                      'all_drawings', 
                                      'last_active_drawing'],
                    drawn_items_geojson=drawn_items_json)

This should be 100% compatible with editing from the client side, and avoids re-rendering of the map. In the current implementation, the drawn items are added only during the first call (on the first rendering of the map). This means that changes to the drawn_items_json do not produce any change in following reruns of the script.

To achieve the above, we need to change streamlit-folium as follows:

In streamlit_folium/init.py:

def st_folium(
    fig: folium.MacroElement,
    key: str | None = None,
    height: int = 700,
    width: int | None = 500,
    returned_objects: Iterable[str] | None = None,
    zoom: int | None = None,
    center: tuple[float, float] | None = None,
    feature_group_to_add: folium.FeatureGroup | None = None,
    drawn_items_geojson: str | None = None, # add the GeoJSON as input
    return_on_hover: bool = False,
    use_container_width: bool = False,
):

...omitting for readibility...

component_value = _component_func(
        script=leaflet,
        html=html,
        id=m_id,
        key=generate_js_hash(leaflet, key, return_on_hover),
        height=height,
        width=width,
        returned_objects=returned_objects,
        default=defaults,
        zoom=zoom,
        center=center,
        feature_group=feature_group_string,
        drawn_items_geojson=drawn_items_geojson, # and pass it as is to the Javascript frontend
        return_on_hover=return_on_hover,
    )

    return component_value

In streamlit_folium/frontend/src/index.tsx:


...omitting for readibility...

// Add this function (mostly taken from https://github.com/Leaflet/Leaflet.draw/issues/253#issuecomment-307265233)
function addDrawnItems(jsonDrawn: string) {
  var json = new L.GeoJSON(JSON.parse(jsonDrawn), {
      pointToLayer: function (feature, latlng) {
          switch (feature.geometry.type) {
              case 'Polygon':
                  return L.polygon(latlng);
              case 'LineString':
                  return L.polyline(latlng);
              case 'Point':
                  return L.marker(latlng);
              default:
                  return;
          }
      },
      onEachFeature: function (feature, layer) {
          layer.addTo(window.drawnItems);
      }
  });
};

...omitting for readibility...

function onRender(event: Event): void {
  ...omitting for readibility...
  if (!window.map) {
    ...omitting for readibility...
        `window.map = map_div; window.initComponent(map_div, ${return_on_hover});`
      document.body.appendChild(render_script)
      const html_div = document.createElement("div")
      html_div.innerHTML = html
      document.body.appendChild(html_div)
    }

   // Add the following if, to add the drawings on first render
    if (
      drawn_items
    ) {
      addDrawnItems(drawn_items)
    }
  }
...omitting for readibility...
rbifulco commented 1 year ago

@blackary @randyzwitch I am not sure how requested is the feature, however, would you consider adding it to streamlit-folium?

I am a novice with streamlit, folium and javascript, but it seems the change is relatively straightforward in this case. Not sure if I missed anything (e.g., the behavior on reload might not be consistent with your expectations...)

randyzwitch commented 1 year ago

As long as it doesn't change any of the other package behaviors, sure, we would accept a pull request. But someone has to do the work of coding and testing it, I (and probably @blackary ) don't understand the subtleties of what you're trying to do.

rbifulco commented 1 year ago

Thanks for the answer, as soon as I find some time I'll clean my implementation up and make a pull request with proper documentation.

PaulRosenfield commented 1 month ago

Just want to add my voice to this feature-request. In my use-case, I've built a Streamlit app where my users draw polygons over satellite imagery to indicate regions where they see notable land-use change. They need to be able to save their work (i.e. the polygons they have already drawn) and then return to edit those polygons later.

hansthen commented 2 weeks ago

If anyone is still interested in this: folium v0.18.0 supports this natively.

      fg = folium.FeatureGroup()
      # add some stuff to the fg  
      fg.add_to(m)
      Draw(export=False, feature_group=fg, show_geometry_on_click=False).add_to(m)

I think this issue can be closed.