Skip to content

Commit 7c28b51

Browse files
committed
GeoJson fix property as identifier
For styling and highlights we need a unique field on each geojson feature. This functionality was broken for geojson data with certain properties layouts. This change makes the functionality work for all properties allowed by the geojson spec.
1 parent 7412a98 commit 7c28b51

File tree

2 files changed

+112
-6
lines changed

2 files changed

+112
-6
lines changed

folium/features.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
none_min,
2626
get_obj_in_upper_tree,
2727
parse_options,
28+
dict_get,
2829
)
2930
from folium.vector_layers import PolyLine, path_options
3031

@@ -521,17 +522,29 @@ def _validate_function(self, func, name):
521522
.format(name))
522523

523524
def find_identifier(self):
524-
"""Find a unique identifier for each feature, create it if needed."""
525+
"""Find a unique identifier for each feature, create it if needed.
526+
527+
According to the GeoJSON specs a feature:
528+
- MAY have an 'id' field with a string or numerical value.
529+
- MUST have a 'properties' field. The content can be any json object
530+
or even null.
531+
532+
"""
525533
features = self.data['features']
526534
n = len(features)
527535
feature = features[0]
536+
# Each feature has an 'id' field with a unique value.
528537
if 'id' in feature and len(set(feat['id'] for feat in features)) == n:
529538
return 'feature.id'
530-
for key in feature.get('properties', []):
531-
if len(set(feat['properties'][key] for feat in features)) == n:
532-
return 'feature.properties.{}'.format(key)
539+
# Each feature has a unique string/float/int property.
540+
# 'properties' may be missing or None:
541+
if isinstance(feature.get('properties', None), dict):
542+
field_name = self._search_properties(features, 'properties')
543+
if field_name is not None:
544+
return field_name
545+
# We add an 'id' field with a unique value to the data.
533546
if self.embed:
534-
for i, feature in enumerate(self.data['features']):
547+
for i, feature in enumerate(features):
535548
feature['id'] = str(i)
536549
return 'feature.id'
537550
raise ValueError(
@@ -540,6 +553,30 @@ def find_identifier(self):
540553
'field to your geojson data or set `embed=True`. '
541554
)
542555

556+
@classmethod
557+
def _search_properties(cls, features, *keys):
558+
"""Find a property for which each feature has a unique str/num value."""
559+
value_first_feature = dict_get(features[0], *keys)
560+
# Recursively look through the dictionary to find a unique value.
561+
if isinstance(value_first_feature, dict):
562+
for key in value_first_feature:
563+
field_name = cls._search_properties(features, *[*keys, key])
564+
if field_name is not None:
565+
return field_name
566+
# Check that all features have these keys and that the values are unique.
567+
unique_values = set()
568+
for feat in features:
569+
try:
570+
value = dict_get(feat, *keys)
571+
except TypeError:
572+
# not all features have the same properties layout
573+
return None
574+
if not isinstance(value, (str, float, int)):
575+
return None
576+
unique_values.add(value)
577+
if len(unique_values) == len(features):
578+
return '.'.join(['feature', *keys])
579+
543580
def _get_self_bounds(self):
544581
"""
545582
Computes the bounds of the object itself (not including it's children)

tests/test_features.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from branca.element import Element
1313

1414
import folium
15-
from folium import Map, Popup
15+
from folium import Map, Popup, GeoJson
1616

1717
import pytest
1818

@@ -213,3 +213,72 @@ def test_geojson_tooltip():
213213
warnings.simplefilter('always')
214214
m._repr_html_()
215215
assert issubclass(w[-1].category, UserWarning), 'GeoJsonTooltip GeometryCollection test failed.'
216+
217+
218+
def test_geojson_search_properties():
219+
features = [{'properties': None} for _ in range(3)]
220+
assert GeoJson._search_properties(features, 'properties') is None
221+
features = [{'properties': {'hi': 'there'}},
222+
{'properties': {'hi': 'what'}}]
223+
assert GeoJson._search_properties(features, 'properties') == 'feature.properties.hi'
224+
features = [{'properties': {'hi': {'more': 'some value'}}},
225+
{'properties': {'hi': {'more': 'another value'}}}]
226+
assert (GeoJson._search_properties(features, 'properties')
227+
== 'feature.properties.hi.more')
228+
features = [{'properties': {'hi': 'there'}},
229+
{'properties': {'hi': 'there'}}]
230+
assert GeoJson._search_properties(features, 'properties') is None
231+
features = [{'properties': {'hi': 'there'}},
232+
{'properties': {'hi': None}}]
233+
assert GeoJson._search_properties(features, 'properties') is None
234+
features = [{'properties': {'hi': 'there'}},
235+
{'properties': 42}]
236+
assert GeoJson._search_properties(features, 'properties') is None
237+
features = [{'properties': [42, 43]},
238+
{'properties': [1, 2]}]
239+
assert GeoJson._search_properties(features, 'properties') is None
240+
241+
242+
def test_geojson_find_identifier():
243+
244+
def _create(properties):
245+
return {"type": "FeatureCollection", "features": [
246+
{"type": "Feature",
247+
"properties": properties}
248+
]}
249+
250+
data_bare = _create(None)
251+
data_with_id = _create(None)
252+
data_with_id['features'][0]['id'] = 'this-is-an-id'
253+
data_with_unique_property = _create({
254+
'property-key': 'some-value',
255+
})
256+
data_with_nested_properties = _create({
257+
"summary": {"distance": 343.2},
258+
"way_points": [3, 5],
259+
})
260+
data_with_incompatible_properties = _create({
261+
"summary": {"distances": [0, 6], "durations": None},
262+
"way_points": [3, 5],
263+
})
264+
265+
geojson = GeoJson(data_with_id)
266+
assert geojson.find_identifier() == 'feature.id'
267+
geojson = GeoJson(data_bare)
268+
assert geojson.find_identifier() == 'feature.id'
269+
assert geojson.data['features'][0]['id'] == '0' # the id got added
270+
geojson = GeoJson(data_with_unique_property)
271+
assert geojson.find_identifier() == 'feature.properties.property-key'
272+
geojson = GeoJson(data_with_nested_properties)
273+
assert geojson.find_identifier() == 'feature.properties.summary.distance'
274+
geojson = GeoJson(data_with_incompatible_properties)
275+
assert geojson.find_identifier() == 'feature.id'
276+
assert geojson.data['features'][0]['id'] == '0' # the id got added
277+
278+
data_loose_geometry = {"type": "LineString", "coordinates": [
279+
[3.961389, 43.583333], [3.968056, 43.580833], [3.974722, 43.578333],
280+
[3.986389, 43.575278], [3.998333, 43.5725], [4.163333, 43.530556],
281+
]}
282+
geojson = GeoJson(data_loose_geometry)
283+
geojson.convert_to_feature_collection()
284+
assert geojson.find_identifier() == 'feature.id' # id got added

0 commit comments

Comments
 (0)