Open rcutler-ursa opened 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.
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...
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?
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.
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")
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!
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...
@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...)
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.
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.
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.
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.
Is there any way to pass in something like the
all_drawings
structure tost_folium
(akin to passingcenter
andzoom
) in order to programmatically alter the drawing layer(s) in theDraw
plugin?