Search code examples
pythongeopandasfoliumchoropleth

Folium plot GeoJson fill color in polygon based on custom values


I have polygons with lat/long values associated with identifiers in a GeoDataFrame as shown below. Consider an example with two identifiers A and B, polygon A has three points and B has four points, their lat/long values are as shown below. Corresponding to each point (lat/long), I also have an associated numeric value as shown in the last column.

id    geometry                                                                         values
A   POLYGON((lat_A_1 long_A_1, lat_A_2 long_A_2, lat_A_3 long_A_3))                    10,12,13
B   POLYGON((lat_B_1 long_B_1, lat_B_2 long_B_2, lat_B_3 long_B_3, lat_B_4 long_B_4))  4,8,16,20

I iterate over the GeoDataFrame and plot these polygons on the map using this code

    geo_j = folium.GeoJson(data=geo_j,
                           style_function={ 
                               'fillColor': 'blue'
                           })

Is there a way that I can fill the polygon with a custom colormap based on the column values in the GeoDataFrame, such as red for 0-5, blue for 6-10 and green for 11-20. How can this be done?


Solution

    • started by getting some polygons and defining a value per point (generate MWE sample data set)
    • this means you have as many values associated with polygon as there are points in the polygon. You request a solution using folium that fills the polygon with a custom color map value. This means you need to have a function that will assimilates all these values into a single value for the polygon (a color). I have used mode, most common value. This could be mean, median or any other function.
    • solution then become simple, it's folium.GeoJson() using and appropriately structured style_function
    • extended answer. You can split polygon into smaller polygons and associate color of sub-polygon with a point. folium production is unchanged (have include iso_a3) just to make it simpler to view
    • shapley provides two ways to split a polygon https://shapely.readthedocs.io/en/stable/manual.html#shapely.ops.triangulate. Have found that voronoi is more effective

    generate MWE data

    # some polygons
    gdf = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres")).loc[lambda d: d["iso_a3"].isin(["BEL", "LUX", "NLD", "DEU", "AUT"]), ["geometry"]]
    
    # comma separated values column... between 0 and 20...
    gdf["values"] = gdf.geometry.apply(lambda p: ",".join([str(int(sum(xy)) % 20) for xy in p.exterior.coords]))
    # id column
    gdf["id"] = list("ABCDEFGHIJ")[0 : len(gdf)]
    gdf = gdf.set_index("id", drop=False)
    

    data

                                       geometry                                   values id
    id                                                                                     
    A   POLYGON ((16.97967 48.12350, 16.9037...  5,4,4,4,3,2,1,1,0,19,19,18,17,17,16,...  A
    B   POLYGON ((14.11969 53.75703, 14.3533...  7,7,7,7,6,6,6,5,5,4,4,3,2,2,2,2,2,1,...  B
    C   POLYGON ((6.04307 50.12805, 6.24275 ...                     16,16,15,15,15,15,16  C
    D   POLYGON ((6.15666 50.80372, 6.04307 ...  16,16,15,15,14,14,13,13,13,13,14,14,...  D
    E   POLYGON ((6.90514 53.48216, 7.09205 ...  0,0,19,18,17,16,16,16,15,14,14,15,17...  E
    

    solution

    import statistics as st
    import branca.colormap
    import geopandas as gpd
    import folium
    
    m = folium.Map(
        location=[
            sum(gdf.geometry.total_bounds[[1, 3]]) / 2,
            sum(gdf.geometry.total_bounds[[0, 2]]) / 2,
        ],
        zoom_start=5,
        control_scale=True,
    )
    
    # style the polygons based on "values" property
    def style_fn(feature):
        cm = branca.colormap.LinearColormap(["mistyrose", "tomato", "red"], vmin=0, vmax=20)
        most_common = st.mode([int(v) for v in feature["properties"]["values"].split(",")])
        ss = {
            "fillColor": cm(most_common),
            "fillOpacity": 0.8,
            "weight": 0.8,
            "color": cm(most_common),
        }
        return ss
    
    
    folium.GeoJson(
        gdf.__geo_interface__,
        style_function=style_fn,
        tooltip=folium.features.GeoJsonTooltip(["id", "values"]),
    ).add_to(m)
    
    m
    

    enter image description here

    split polygons into parts

    import statistics as st
    import branca.colormap
    import geopandas as gpd
    import folium
    import shapely.geometry
    import shapely.ops
    import pandas as pd
    
    # some polygons
    # fmt: off
    gdf = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres")).loc[lambda d: d["iso_a3"].isin(["BEL", "LUX", "NLD", "DEU", "AUT","POL"]), ["geometry", "iso_a3"]]
    
    # comma separated values column... between 0 and 20...
    gdf["values"] = gdf.geometry.apply(lambda p: ",".join([str(int(sum(xy)) % 20) for xy in p.exterior.coords]))
    # id column
    gdf["id"] = list("ABCDEFGHIJ")[0 : len(gdf)]
    gdf = gdf.set_index("id", drop=False)
    # fmt: on
    
    
    def sub_polygons(r, method="voronoi"):
        g = r["geometry"]
        # split into sub-polygons
        if method == "voronoi":
            geoms = shapely.ops.voronoi_diagram(g).geoms
        elif method == "triangulate":
            geoms = [
                p
                for p in shapely.ops.triangulate(g)
                if isinstance(p.intersection(g), shapely.geometry.Polygon)
            ]
        else:
            raise "invalid polygon ops method"
            
        # clip sub-geometries
        geoms = [p.intersection(g) for p in geoms]
        vs = r["values"].split(",")
        vr = []
        # order or sub-polygons and points are differenct.  use value from point
        # in sub-polygon
        for vg in geoms:
            for i, xy in enumerate(g.exterior.coords):
                if not shapely.geometry.Point(xy).intersection(vg).is_empty:
                    break
            vr.append(vs[i])
        return [{**r.to_dict(), **{"geometry": g, "values": v}} for g, v in zip(geoms, vr)]
    
    
    gdf2 = gpd.GeoDataFrame(
        gdf.apply(sub_polygons, axis=1, method="voronoi").explode().apply(pd.Series)
    )
    
    
    m = folium.Map(
        location=[
            sum(gdf.geometry.total_bounds[[1, 3]]) / 2,
            sum(gdf.geometry.total_bounds[[0, 2]]) / 2,
        ],
        zoom_start=5,
        control_scale=True,
    )
    
    # style the polygons based on "values" property
    def style_fn(feature):
        cm = branca.colormap.LinearColormap(["mistyrose", "tomato", "red"], vmin=0, vmax=20)
        most_common = st.mode([int(v) for v in feature["properties"]["values"].split(",")])
        ss = {
            "fillColor": cm(most_common),
            "fillOpacity": 0.8,
            "weight": 0.8,
            "color": cm(most_common),
        }
        return ss
    
    
    folium.GeoJson(
        gdf2.__geo_interface__,
        style_function=style_fn,
        tooltip=folium.features.GeoJsonTooltip(["id", "values", "iso_a3"]),
    ).add_to(m)
    
    m
    

    enter image description here

    with FeatureGroup

    m = folium.Map(
        location=[
            sum(gdf.geometry.total_bounds[[1, 3]]) / 2,
            sum(gdf.geometry.total_bounds[[0, 2]]) / 2,
        ],
        zoom_start=5,
        control_scale=True,
    )
    
    for g, d in gdf2.groupby(level=0):
        fg = folium.map.FeatureGroup(name=g)
        folium.GeoJson(
            d.__geo_interface__,
            style_function=style_fn,
            tooltip=folium.features.GeoJsonTooltip(["id", "values", "iso_a3"]),
        ).add_to(fg)
        fg.add_to(m)
        
    folium.LayerControl().add_to(m)    
    m