Skip to content

Commit

Permalink
Allow to use Layer control for dynamically added layers (#166)
Browse files Browse the repository at this point in the history
* fix: actually remove the feature group when needed

* feat: possibility to provide the feature group as a list

* fix: changed expected type and documentation

* fix title

* added test to check that the feature groups are appearing and disappearing as requested

* fix: make test pass

* feat: make possible to use layer contorl for dynamically added layers

* to do some tests

* added page to test dynamic layer control

* added test for dynamic layer control, not complete yet

* release to True

* linting

* Update dynamic_updates.py - remove code in double

* removed main()

* added layer control string to debug
  • Loading branch information
patrontheo authored Dec 22, 2023
1 parent 51f3346 commit 8097e78
Show file tree
Hide file tree
Showing 4 changed files with 183 additions and 6 deletions.
86 changes: 86 additions & 0 deletions examples/pages/dynamic_layer_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from __future__ import annotations

import folium
import folium.features
import geopandas as gpd
import shapely
import streamlit as st

from streamlit_folium import st_folium

st.set_page_config(layout="wide")

st.write("## Dynamic layer control updates")

START_LOCATION = [37.7944347109497, -122.398077892527]
START_ZOOM = 17

if "feature_group" not in st.session_state:
st.session_state["feature_group"] = None

wkt1 = (
"POLYGON ((-122.399077892527 37.7934347109497, -122.398922660838 "
"37.7934544916178, -122.398980265018 37.7937266504805, -122.399133972495 "
"37.7937070646238, -122.399077892527 37.7934347109497))"
)
wkt2 = (
"POLYGON ((-122.397416 37.795017, -122.397137 37.794712, -122.396332 37.794983,"
" -122.396171 37.795483, -122.396858 37.795695, -122.397652 37.795466, "
"-122.397759 37.79511, -122.397416 37.795017))"
)

polygon_1 = shapely.wkt.loads(wkt1)
polygon_2 = shapely.wkt.loads(wkt2)

gdf1 = gpd.GeoDataFrame(geometry=[polygon_1]).set_crs(epsg=4326)
gdf2 = gpd.GeoDataFrame(geometry=[polygon_2]).set_crs(epsg=4326)

style_parcels = {
"fillColor": "#1100f8",
"color": "#1100f8",
"fillOpacity": 0.13,
"weight": 2,
}
style_buildings = {
"color": "#ff3939",
"fillOpacity": 0,
"weight": 3,
"opacity": 1,
"dashArray": "5, 5",
}

polygon_folium1 = folium.GeoJson(data=gdf1, style_function=lambda x: style_parcels)
polygon_folium2 = folium.GeoJson(data=gdf2, style_function=lambda x: style_buildings)

map = folium.Map(
location=START_LOCATION,
zoom_start=START_ZOOM,
tiles="OpenStreetMap",
max_zoom=21,
)

fg1 = folium.FeatureGroup(name="Parcels")
fg1.add_child(polygon_folium1)

fg2 = folium.FeatureGroup(name="Buildings")
fg2.add_child(polygon_folium2)

fg_dict = {"Parcels": fg1, "Buildings": fg2, "None": None, "Both": [fg1, fg2]}

control = folium.LayerControl(collapsed=False)

fg = st.radio("Feature Group", ["Parcels", "Buildings", "None", "Both"])

layer = st.radio("Layer Control", ["yes", "no"])

layer_dict = {"yes": control, "no": None}

st_folium(
map,
width=800,
height=450,
returned_objects=[],
feature_group_to_add=fg_dict[fg],
debug=True,
layer_control=layer_dict[layer],
)
33 changes: 33 additions & 0 deletions streamlit_folium/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,26 @@ def _get_feature_group_string(
return feature_group_string


def _get_layer_control_string(
control: folium.LayerControl,
map: folium.Map,
) -> str:
control._id = "layer_control"
control.add_to(map)
control.render()
control_string = generate_leaflet_string(control, base_id="layer_control")
m_id = get_full_id(map)
control_string = control_string.replace(m_id, "map_div")
control_string = dedent(control_string)
control_string += dedent(
"""
window.layer_control = layer_control_layer_control;
"""
)

return control_string


def st_folium(
fig: folium.MacroElement,
key: str | None = None,
Expand All @@ -188,6 +208,7 @@ def st_folium(
feature_group_to_add: list[folium.FeatureGroup] | folium.FeatureGroup | None = None,
return_on_hover: bool = False,
use_container_width: bool = False,
layer_control: folium.LayerControl | None = None,
debug: bool = False,
):
"""Display a Folium object in Streamlit, returning data as user interacts
Expand Down Expand Up @@ -227,6 +248,9 @@ def st_folium(
use_container_width: bool
If True, set the width of the map to the width of the current container.
This overrides the `width` parameter.
layer_control: folium.LayerControl or None
If you want to have layer control for dynamically added layers, you can
pass the layer control here.
debug: bool
If True, print out the html and javascript code used to render the map with
st.code
Expand Down Expand Up @@ -315,6 +339,10 @@ def bounds_to_dict(bounds_list: list[list[float]]) -> dict[str, dict[str, float]
idx=idx,
)

layer_control_string = None
if layer_control is not None:
layer_control_string = _get_layer_control_string(layer_control, folium_map)

if debug:
with st.expander("Show generated code"):
st.info("HTML:")
Expand All @@ -326,6 +354,10 @@ def bounds_to_dict(bounds_list: list[list[float]]) -> dict[str, dict[str, float]
st.info("Feature group js:")
st.code(feature_group_string)

if layer_control_string is not None:
st.info("Layer control js:")
st.code(layer_control_string)

component_value = _component_func(
script=leaflet,
html=html,
Expand All @@ -339,6 +371,7 @@ def bounds_to_dict(bounds_list: list[list[float]]) -> dict[str, dict[str, float]
center=center,
feature_group=feature_group_string,
return_on_hover=return_on_hover,
layer_control=layer_control_string,
)

return component_value
Expand Down
27 changes: 21 additions & 6 deletions streamlit_folium/frontend/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Layer } from "leaflet"
import { RenderData, Streamlit } from "streamlit-component-lib"
import { debounce } from "underscore"
import { circleToPolygon } from "./circle-to-polygon"
import { Layer } from "leaflet"

type GlobalData = {
lat_lng_clicked: any
Expand All @@ -18,6 +18,7 @@ type GlobalData = {
last_zoom: any
last_center: any
last_feature_group: any
last_layer_control: any
}

declare global {
Expand All @@ -27,6 +28,7 @@ declare global {
map: any
drawnItems: any
feature_group: any
layer_control: any
}
}

Expand Down Expand Up @@ -179,6 +181,7 @@ function onRender(event: Event): void {
const center: any = data.args["center"]
const feature_group: string = data.args["feature_group"]
const return_on_hover: boolean = data.args["return_on_hover"]
const layer_control: string = data.args["layer_control"]

if (!window.map) {
// Only run this if the map hasn't already been created (and thus the global
Expand Down Expand Up @@ -222,6 +225,7 @@ function onRender(event: Event): void {
last_zoom: null,
last_center: null,
last_feature_group: null,
last_layer_control: null,
}
if (script.indexOf("map_div2") !== -1) {
parent_div?.classList.remove("single")
Expand All @@ -240,23 +244,31 @@ function onRender(event: Event): void {
}
}

if (feature_group !== window.__GLOBAL_DATA__.last_feature_group) {
if (
feature_group !== window.__GLOBAL_DATA__.last_feature_group ||
layer_control !== window.__GLOBAL_DATA__.last_layer_control
) {
if (window.feature_group && window.feature_group.length > 0) {
window.feature_group.forEach((layer: Layer) => {
window.map.removeLayer(layer)
})
window.map.removeLayer(layer);
});
}

if (window.layer_control) {
window.map.removeControl(window.layer_control)
}

window.__GLOBAL_DATA__.last_feature_group = feature_group
window.__GLOBAL_DATA__.last_layer_control = layer_control

if (feature_group) {
if (feature_group){
// Though using `eval` is generally a bad idea, we're using it here
// because we're evaluating code that we've generated ourselves on the
// Python side. This is safe because we're not evaluating user input, so this
// couldn't be used to execute arbitrary code.

// eslint-disable-next-line
eval(feature_group)
eval(feature_group + layer_control)
for (let key in window.map._layers) {
let layer = window.map._layers[key]
layer.off("click", onLayerClick)
Expand All @@ -266,6 +278,9 @@ function onRender(event: Event): void {
layer.on("mouseover", onLayerClick)
}
}
} else {
// eslint-disable-next-line
eval(layer_control)
}
}

Expand Down
43 changes: 43 additions & 0 deletions tests/test_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,46 @@ def test_dynamic_feature_group_update(page: Page):
).to_be_visible()
expect(page.get_by_text("fillColor")).to_be_visible()
expect(page.get_by_text("dashArray")).to_be_visible()


def test_layer_control_dynamic_update(page: Page):
page.get_by_role("link", name="dynamic layer control").click()
page.get_by_text("Show generated code").click()

Check failure on line 339 in tests/test_frontend.py

View workflow job for this annotation

GitHub Actions / build (3.10)

test_layer_control_dynamic_update[chromium] playwright._impl._errors.TimeoutError: Timeout 30000ms exceeded.

page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator(
"label"
).filter(has_text="Parcels").locator("div").click()
expect(
page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]')
.locator("label")
.filter(has_text="Parcels")
.locator("div")
).not_to_be_checked()
expect(
page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]')
.locator("path")
.first
).to_be_hidden()
expect(page.get_by_text("dashArray")).to_be_hidden()

page.get_by_test_id("stRadio").get_by_text("Both").click()
page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_label(
"Parcels"
).uncheck()
expect(

Check failure on line 361 in tests/test_frontend.py

View workflow job for this annotation

GitHub Actions / build (3.8)

test_layer_control_dynamic_update[chromium] AssertionError: Locator expected not to be checked Actual value: True Call log: LocatorAssertions.not_to_be_checked with timeout 5000ms - waiting for frame_locator("iframe[title=\"streamlit_folium\\.st_folium\"]").locator("label").filter(has_text="Parcels").locator("div") - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked"
page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]')
.locator("label")
.filter(has_text="Parcels")
.locator("div")
).not_to_be_checked()

Check failure on line 366 in tests/test_frontend.py

View workflow job for this annotation

GitHub Actions / build (3.11)

test_layer_control_dynamic_update[chromium] AssertionError: Locator expected not to be checked Actual value: True Call log: LocatorAssertions.not_to_be_checked with timeout 5000ms - waiting for frame_locator("iframe[title=\"streamlit_folium\\.st_folium\"]").locator("label").filter(has_text="Parcels").locator("div") - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked" - locator resolved to <div>…</div> - unexpected value "checked"
expect(
page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]')
.locator("label")
.filter(has_text="Buildings")
.locator("div")
).to_be_checked()
expect(
page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]')
.locator("path")
.first
).to_be_visible()

0 comments on commit 8097e78

Please sign in to comment.