├── .coveragerc ├── .gitattributes ├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── LICENSE ├── README.md ├── ci └── envs │ └── latest.yaml ├── examples.ipynb ├── geopandas_view ├── __init__.py ├── test_view.py └── view.py ├── requirements-dev.txt ├── requirements.txt └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | geopandas_view/test_view.py -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | - cron: '0 0 * * 5' 10 | 11 | jobs: 12 | Test: 13 | name: Test geopandas-view 14 | runs-on: [ubuntu-latest] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Install micromamba 19 | run: | 20 | wget -qO- https://micromamba.snakepit.net/api/micromamba/linux-64/latest | tar -xvj bin/micromamba --strip-components=1 21 | ./micromamba shell init -s bash -p ~/micromamba 22 | mkdir -p ~/micromamba/pkgs/ 23 | 24 | - name: Install dependencies 25 | shell: bash -l {0} 26 | run: | 27 | export MAMBA_ROOT_PREFIX=~/micromamba 28 | export MAMBA_EXE=$(pwd)/micromamba 29 | . $MAMBA_ROOT_PREFIX/etc/profile.d/mamba.sh 30 | ./micromamba create -f ci/envs/latest.yaml -y 31 | 32 | - name: Test geopandas-view 33 | shell: bash -l {0} 34 | run: | 35 | export MAMBA_ROOT_PREFIX=~/micromamba 36 | export MAMBA_EXE=$(pwd)/micromamba 37 | . $MAMBA_ROOT_PREFIX/etc/profile.d/mamba.sh 38 | micromamba activate test 39 | python setup.py install 40 | pytest -v --color=yes --cov-config .coveragerc --cov=geopandas_view --cov-report term-missing --cov-report xml . 41 | 42 | - uses: codecov/codecov-action@v1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | .hypothesis 128 | *.py[cod] 129 | __pycache__/ 130 | *.egg-info 131 | dask-worker-space/ 132 | docs/build 133 | build/ 134 | dist/ 135 | .idea/ 136 | log.* 137 | log 138 | .pytest_cache/ 139 | .coverage 140 | .DS_Store 141 | *.swp 142 | *.swo 143 | .cache/ 144 | .ipynb_checkpoints 145 | .vscode/ 146 | 147 | _build 148 | 149 | coverage.xml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Martin Fleischmann 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # geopandas-view 2 | 3 | **DISCLAIMER: THIS HAS BECOME AN INTERNAL MODULE OF GEOPANDAS:** 4 | 5 | ```py 6 | gdf.explore() 7 | ``` 8 | 9 | Interactive exploration of GeoPandas GeoDataFrames 10 | 11 | Proof-of-a-concept based on folium, closely mimcking API of `GeoDataFrame.plot()`. 12 | 13 | For details see RFC document: https://github.com/martinfleis/geopandas-view/issues/1 14 | 15 | ## Installation 16 | 17 | ``` 18 | pip install git+https://github.com/martinfleis/geopandas-view.git 19 | ``` 20 | 21 | Requires `geopandas`, `folium` and `mapclassify`. 22 | 23 | 24 | ## Usage 25 | 26 | ```python 27 | 28 | import geopandas 29 | from geopandas_view import view 30 | 31 | df = geopandas.read_file(geopandas.datasets.get_path('nybb')) 32 | view(df) 33 | ``` 34 | -------------------------------------------------------------------------------- /ci/envs/latest.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - geopandas 6 | - folium 7 | - mapclassify 8 | - contextily 9 | - pytest 10 | - pytest-cov 11 | - codecov 12 | - matplotlib 13 | -------------------------------------------------------------------------------- /geopandas_view/__init__.py: -------------------------------------------------------------------------------- 1 | from .view import view 2 | 3 | __author__ = "Martin Fleischmann" 4 | __author_email__ = "martin@martinfleischmann.net" 5 | -------------------------------------------------------------------------------- /geopandas_view/test_view.py: -------------------------------------------------------------------------------- 1 | import folium 2 | import geopandas as gpd 3 | import matplotlib.cm as cm 4 | import matplotlib.colors as colors 5 | from branca.colormap import StepColormap 6 | import contextily 7 | import numpy as np 8 | import pandas as pd 9 | import pytest 10 | 11 | from geopandas_view import view 12 | 13 | nybb = gpd.read_file(gpd.datasets.get_path("nybb")) 14 | world = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres")) 15 | cities = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres")) 16 | world["range"] = range(len(world)) 17 | missing = world.copy() 18 | np.random.seed(42) 19 | missing.loc[np.random.choice(missing.index, 40), "continent"] = np.nan 20 | missing.loc[np.random.choice(missing.index, 40), "pop_est"] = np.nan 21 | 22 | 23 | def _fetch_map_string(m): 24 | out = m._parent.render() 25 | out_str = "".join(out.split()) 26 | return out_str 27 | 28 | 29 | def test_simple_pass(): 30 | """Make sure default pass""" 31 | m = view(nybb) 32 | m = view(world) 33 | m = view(cities) 34 | m = view(world.geometry) 35 | 36 | 37 | def test_choropleth_pass(): 38 | """Make sure default choropleth pass""" 39 | m = view(world, column="pop_est") 40 | 41 | 42 | def test_map_settings_default(): 43 | """Check default map settings""" 44 | m = view(world) 45 | assert m.location == [ 46 | pytest.approx(-3.1774349999999956, rel=1e-6), 47 | pytest.approx(2.842170943040401e-14, rel=1e-6), 48 | ] 49 | assert m.options["zoom"] == 10 50 | assert m.options["zoomControl"] == True 51 | assert m.position == "relative" 52 | assert m.height == (100.0, "%") 53 | assert m.width == (100.0, "%") 54 | assert m.left == (0, "%") 55 | assert m.top == (0, "%") 56 | assert m.global_switches.no_touch is False 57 | assert m.global_switches.disable_3d is False 58 | assert "openstreetmap" in m.to_dict()["children"].keys() 59 | 60 | 61 | def test_map_settings_custom(): 62 | """Check custom map settins""" 63 | m = view(nybb, zoom_control=False, width=200, height=200, tiles="CartoDB positron") 64 | assert m.location == [ 65 | pytest.approx(40.70582377450201, rel=1e-6), 66 | pytest.approx(-73.9778006856748, rel=1e-6), 67 | ] 68 | assert m.options["zoom"] == 10 69 | assert m.options["zoomControl"] == False 70 | assert m.height == (200.0, "px") 71 | assert m.width == (200.0, "px") 72 | assert "cartodbpositron" in m.to_dict()["children"].keys() 73 | 74 | # custom XYZ tiles 75 | m = view( 76 | nybb, 77 | zoom_control=False, 78 | width=200, 79 | height=200, 80 | tiles="https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}", 81 | attr="Google", 82 | ) 83 | 84 | out_str = _fetch_map_string(m) 85 | assert ( 86 | 'tileLayer("https://mt1.google.com/vt/lyrs=m\\u0026x={x}\\u0026y={y}\\u0026z={z}",{"attribution":"Google"' 87 | in out_str 88 | ) 89 | 90 | m = view(nybb, location=(40, 5)) 91 | assert m.location == [40, 5] 92 | assert m.options["zoom"] == 10 93 | 94 | m = view(nybb, zoom_start=8) 95 | assert m.location == [ 96 | pytest.approx(40.70582377450201, rel=1e-6), 97 | pytest.approx(-73.9778006856748, rel=1e-6), 98 | ] 99 | assert m.options["zoom"] == 8 100 | 101 | m = view(nybb, location=(40, 5), zoom_start=8) 102 | assert m.location == [40, 5] 103 | assert m.options["zoom"] == 8 104 | 105 | 106 | def test_simple_color(): 107 | """Check color settings""" 108 | # single named color 109 | m = view(nybb, color="red") 110 | out_str = _fetch_map_string(m) 111 | assert '"fillColor":"red"' in out_str 112 | 113 | # list of colors 114 | colors = ["#333333", "#367324", "#95824f", "#fcaa00", "#ffcc33"] 115 | m2 = view(nybb, color=colors) 116 | out_str = _fetch_map_string(m2) 117 | for c in colors: 118 | assert f'"fillColor":"{c}"' in out_str 119 | 120 | # column of colors 121 | df = nybb.copy() 122 | df["colors"] = colors 123 | m3 = view(df, color="colors") 124 | out_str = _fetch_map_string(m3) 125 | for c in colors: 126 | assert f'"fillColor":"{c}"' in out_str 127 | 128 | # line GeoSeries 129 | m4 = view(nybb.boundary, color="red") 130 | out_str = _fetch_map_string(m4) 131 | assert '"fillColor":"red"' in out_str 132 | 133 | 134 | def test_choropleth_linear(): 135 | """Check choropleth colors""" 136 | # default cmap 137 | m = view(nybb, column="Shape_Leng") 138 | out_str = _fetch_map_string(m) 139 | assert 'color":"#440154"' in out_str 140 | assert 'color":"#fde725"' in out_str 141 | assert 'color":"#50c46a"' in out_str 142 | assert 'color":"#481467"' in out_str 143 | assert 'color":"#3d4e8a"' in out_str 144 | 145 | # named cmap 146 | m = view(nybb, column="Shape_Leng", cmap="PuRd") 147 | out_str = _fetch_map_string(m) 148 | assert 'color":"#f7f4f9"' in out_str 149 | assert 'color":"#67001f"' in out_str 150 | assert 'color":"#d31760"' in out_str 151 | assert 'color":"#f0ecf5"' in out_str 152 | assert 'color":"#d6bedc"' in out_str 153 | 154 | 155 | def test_choropleth_mapclassify(): 156 | """Mapclassify bins""" 157 | # quantiles 158 | m = view(nybb, column="Shape_Leng", scheme="quantiles") 159 | out_str = _fetch_map_string(m) 160 | assert 'color":"#21918c"' in out_str 161 | assert 'color":"#3b528b"' in out_str 162 | assert 'color":"#5ec962"' in out_str 163 | assert 'color":"#fde725"' in out_str 164 | assert 'color":"#440154"' in out_str 165 | 166 | # headtail 167 | m = view(world, column="pop_est", scheme="headtailbreaks") 168 | out_str = _fetch_map_string(m) 169 | assert '"fillColor":"#3b528b"' in out_str 170 | assert '"fillColor":"#21918c"' in out_str 171 | assert '"fillColor":"#5ec962"' in out_str 172 | assert '"fillColor":"#fde725"' in out_str 173 | assert '"fillColor":"#440154"' in out_str 174 | # custom k 175 | m = view(world, column="pop_est", scheme="naturalbreaks", k=3) 176 | out_str = _fetch_map_string(m) 177 | assert '"fillColor":"#21918c"' in out_str 178 | assert '"fillColor":"#fde725"' in out_str 179 | assert '"fillColor":"#440154"' in out_str 180 | 181 | 182 | def test_categorical(): 183 | """Categorical maps""" 184 | # auto detection 185 | m = view(world, column="continent") 186 | out_str = _fetch_map_string(m) 187 | assert 'color":"#9467bd","continent":"Europe"' in out_str 188 | assert 'color":"#c49c94","continent":"NorthAmerica"' in out_str 189 | assert 'color":"#1f77b4","continent":"Africa"' in out_str 190 | assert 'color":"#98df8a","continent":"Asia"' in out_str 191 | assert 'color":"#ff7f0e","continent":"Antarctica"' in out_str 192 | assert 'color":"#9edae5","continent":"SouthAmerica"' in out_str 193 | assert 'color":"#7f7f7f","continent":"Oceania"' in out_str 194 | assert 'color":"#dbdb8d","continent":"Sevenseas(openocean)"' in out_str 195 | 196 | # forced categorical 197 | m = view(nybb, column="BoroCode", categorical=True) 198 | out_str = _fetch_map_string(m) 199 | assert 'color":"#9edae5"' in out_str 200 | assert 'color":"#c7c7c7"' in out_str 201 | assert 'color":"#8c564b"' in out_str 202 | assert 'color":"#1f77b4"' in out_str 203 | assert 'color":"#98df8a"' in out_str 204 | 205 | # pandas.Categorical 206 | df = world.copy() 207 | df["categorical"] = pd.Categorical(df["name"]) 208 | m = view(df, column="categorical") 209 | out_str = _fetch_map_string(m) 210 | for c in np.apply_along_axis(colors.to_hex, 1, cm.tab20(range(20))): 211 | assert f'"fillColor":"{c}"' in out_str 212 | 213 | # custom cmap 214 | m = view(nybb, column="BoroName", cmap="Set1") 215 | out_str = _fetch_map_string(m) 216 | assert 'color":"#999999"' in out_str 217 | assert 'color":"#a65628"' in out_str 218 | assert 'color":"#4daf4a"' in out_str 219 | assert 'color":"#e41a1c"' in out_str 220 | assert 'color":"#ff7f00"' in out_str 221 | 222 | # custom list of colors 223 | cmap = ["#333432", "#3b6e8c", "#bc5b4f", "#8fa37e", "#efc758"] 224 | m = view(nybb, column="BoroName", cmap=cmap) 225 | out_str = _fetch_map_string(m) 226 | for c in cmap: 227 | assert f'"fillColor":"{c}"' in out_str 228 | 229 | # shorter list (to make it repeat) 230 | cmap = ["#333432", "#3b6e8c"] 231 | m = view(nybb, column="BoroName", cmap=cmap) 232 | out_str = _fetch_map_string(m) 233 | for c in cmap: 234 | assert f'"fillColor":"{c}"' in out_str 235 | 236 | with pytest.raises(ValueError, match="'cmap' is invalid."): 237 | view(nybb, column="BoroName", cmap="nonsense") 238 | 239 | 240 | def test_categories(): 241 | m = view( 242 | nybb[["BoroName", "geometry"]], 243 | column="BoroName", 244 | categories=["Brooklyn", "Staten Island", "Queens", "Bronx", "Manhattan"], 245 | ) 246 | out_str = _fetch_map_string(m) 247 | assert '"Bronx","__folium_color":"#c7c7c7"' in out_str 248 | assert '"Manhattan","__folium_color":"#9edae5"' in out_str 249 | assert '"Brooklyn","__folium_color":"#1f77b4"' in out_str 250 | assert '"StatenIsland","__folium_color":"#98df8a"' in out_str 251 | assert '"Queens","__folium_color":"#8c564b"' in out_str 252 | 253 | df = nybb.copy() 254 | df["categorical"] = pd.Categorical(df["BoroName"]) 255 | with pytest.raises(ValueError, match="Cannot specify 'categories'"): 256 | view(df, "categorical", categories=["Brooklyn", "Staten Island"]) 257 | 258 | 259 | def test_column_values(): 260 | """ 261 | Check that the dataframe plot method returns same values with an 262 | input string (column in df), pd.Series, or np.array 263 | """ 264 | column_array = np.array(world["pop_est"]) 265 | m1 = view(world, column="pop_est") # column name 266 | m2 = view(world, column=column_array) # np.array 267 | m3 = view(world, column=world["pop_est"]) # pd.Series 268 | assert m1.location == m2.location == m3.location 269 | 270 | m1_fields = view(world, column=column_array, tooltip=True, popup=True) 271 | out1_fields_str = _fetch_map_string(m1_fields) 272 | assert ( 273 | 'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' 274 | in out1_fields_str 275 | ) 276 | assert ( 277 | 'aliases=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' 278 | in out1_fields_str 279 | ) 280 | 281 | m2_fields = view(world, column=world["pop_est"], tooltip=True, popup=True) 282 | out2_fields_str = _fetch_map_string(m2_fields) 283 | assert ( 284 | 'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' 285 | in out2_fields_str 286 | ) 287 | assert ( 288 | 'aliases=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' 289 | in out2_fields_str 290 | ) 291 | 292 | # GeoDataframe and the given list have different number of rows 293 | with pytest.raises(ValueError, match="different number of rows"): 294 | view(world, column=np.array([1, 2, 3])) 295 | 296 | 297 | def test_no_crs(): 298 | """Naive geometry get no tiles""" 299 | df = world.copy() 300 | df.crs = None 301 | m = view(df) 302 | assert "openstreetmap" not in m.to_dict()["children"].keys() 303 | 304 | 305 | def test_style_kwds(): 306 | """Style keywords""" 307 | m = view(world, style_kwds=dict(fillOpacity=0.1, weight=0.5, fillColor="orange")) 308 | out_str = _fetch_map_string(m) 309 | assert '"fillColor":"orange","fillOpacity":0.1,"weight":0.5' in out_str 310 | m = view(world, column="pop_est", style_kwds=dict(color="black")) 311 | assert '"color":"black"' in _fetch_map_string(m) 312 | 313 | 314 | def test_tooltip(): 315 | """Test tooltip""" 316 | # default with no tooltip or popup 317 | m = view(world) 318 | assert "GeoJsonTooltip" in str(m.to_dict()) 319 | assert "GeoJsonPopup" not in str(m.to_dict()) 320 | 321 | # True 322 | m = view(world, tooltip=True, popup=True) 323 | assert "GeoJsonTooltip" in str(m.to_dict()) 324 | assert "GeoJsonPopup" in str(m.to_dict()) 325 | out_str = _fetch_map_string(m) 326 | assert ( 327 | 'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' in out_str 328 | ) 329 | assert ( 330 | 'aliases=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' 331 | in out_str 332 | ) 333 | 334 | # True choropleth 335 | m = view(world, column="pop_est", tooltip=True, popup=True) 336 | assert "GeoJsonTooltip" in str(m.to_dict()) 337 | assert "GeoJsonPopup" in str(m.to_dict()) 338 | out_str = _fetch_map_string(m) 339 | assert ( 340 | 'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' in out_str 341 | ) 342 | assert ( 343 | 'aliases=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' 344 | in out_str 345 | ) 346 | 347 | # single column 348 | m = view(world, tooltip="pop_est", popup="iso_a3") 349 | out_str = _fetch_map_string(m) 350 | assert 'fields=["pop_est"]' in out_str 351 | assert 'aliases=["pop_est"]' in out_str 352 | assert 'fields=["iso_a3"]' in out_str 353 | assert 'aliases=["iso_a3"]' in out_str 354 | 355 | # list 356 | m = view(world, tooltip=["pop_est", "continent"], popup=["iso_a3", "gdp_md_est"]) 357 | out_str = _fetch_map_string(m) 358 | assert 'fields=["pop_est","continent"]' in out_str 359 | assert 'aliases=["pop_est","continent"]' in out_str 360 | assert 'fields=["iso_a3","gdp_md_est"' in out_str 361 | assert 'aliases=["iso_a3","gdp_md_est"]' in out_str 362 | 363 | # number 364 | m = view(world, tooltip=2, popup=2) 365 | out_str = _fetch_map_string(m) 366 | assert 'fields=["pop_est","continent"]' in out_str 367 | assert 'aliases=["pop_est","continent"]' in out_str 368 | 369 | # keywords tooltip 370 | m = view( 371 | world, 372 | tooltip=True, 373 | popup=False, 374 | tooltip_kwds=dict(aliases=[0, 1, 2, 3, 4, 5], sticky=False), 375 | ) 376 | out_str = _fetch_map_string(m) 377 | assert ( 378 | 'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' in out_str 379 | ) 380 | assert "aliases=[0,1,2,3,4,5]" in out_str 381 | assert '"sticky":false' in out_str 382 | 383 | # keywords popup 384 | m = view( 385 | world, 386 | tooltip=False, 387 | popup=True, 388 | popup_kwds=dict(aliases=[0, 1, 2, 3, 4, 5]), 389 | ) 390 | out_str = _fetch_map_string(m) 391 | assert ( 392 | 'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]' in out_str 393 | ) 394 | assert "aliases=[0,1,2,3,4,5]" in out_str 395 | assert "${aliases[i]" in out_str 396 | 397 | # no labels 398 | m = view( 399 | world, 400 | tooltip=True, 401 | popup=True, 402 | tooltip_kwds=dict(labels=False), 403 | popup_kwds=dict(labels=False), 404 | ) 405 | out_str = _fetch_map_string(m) 406 | assert "${aliases[i]" not in out_str 407 | 408 | 409 | def test_custom_markers(): 410 | # Markers 411 | m = view( 412 | cities, 413 | marker_type="marker", 414 | marker_kwds={"icon": folium.Icon(icon="star")}, 415 | ) 416 | assert ""","icon":"star",""" in _fetch_map_string(m) 417 | 418 | # Circle Markers 419 | m = view(cities, marker_type="circle", marker_kwds={"fill_color": "red"}) 420 | assert ""","fillColor":"red",""" in _fetch_map_string(m) 421 | 422 | # Folium Markers 423 | m = view( 424 | cities, 425 | marker_type=folium.Circle( 426 | radius=4, fill_color="orange", fill_opacity=0.4, color="black", weight=1 427 | ), 428 | ) 429 | assert ""","color":"black",""" in _fetch_map_string(m) 430 | 431 | # Circle 432 | m = view(cities, marker_type="circle_marker", marker_kwds={"radius": 10}) 433 | assert ""","radius":10,""" in _fetch_map_string(m) 434 | 435 | # Unsupported Markers 436 | with pytest.raises( 437 | ValueError, match="Only 'marker', 'circle', and 'circle_marker' are supported" 438 | ): 439 | view(cities, marker_type="dummy") 440 | 441 | 442 | def test_categorical_legend(): 443 | m = view(world, column="continent", legend=True) 444 | out = m.get_root().render() 445 | out_str = "".join(out.split()) 446 | 447 | assert "#1f77b4'>Africa" in out_str 448 | assert "#ff7f0e'>Antarctica" in out_str 449 | assert "#98df8a'>Asia" in out_str 450 | assert "#9467bd'>Europe" in out_str 451 | assert "#c49c94'>NorthAmerica" in out_str 452 | assert "#7f7f7f'>Oceania" in out_str 453 | assert "#dbdb8d'>Sevenseas(openocean)" in out_str 454 | assert "#9edae5'>SouthAmerica" in out_str 455 | 456 | 457 | def test_vmin_vmax(): 458 | df = world.copy() 459 | df["range"] = range(len(df)) 460 | m = view(df, "range", vmin=-100, vmax=1000) 461 | out_str = _fetch_map_string(m) 462 | assert 'case"176":return{"color":"#3b528b","fillColor":"#3b528b"' in out_str 463 | assert 'case"119":return{"color":"#414287","fillColor":"#414287"' in out_str 464 | assert 'case"3":return{"color":"#482173","fillColor":"#482173"' in out_str 465 | 466 | with pytest.warns(UserWarning, match="vmin' cannot be higher than minimum value"): 467 | m = view(df, "range", vmin=100000) 468 | 469 | with pytest.warns(UserWarning, match="'vmax' cannot be lower than maximum value"): 470 | m = view(df, "range", vmax=10) 471 | 472 | 473 | def test_missing_vals(): 474 | m = view(missing, "continent") 475 | assert '"fillColor":null' in _fetch_map_string(m) 476 | 477 | m = view(missing, "pop_est") 478 | assert '"fillColor":null' in _fetch_map_string(m) 479 | 480 | m = view(missing, "pop_est", missing_kwds=dict(color="red")) 481 | assert '"fillColor":"red"' in _fetch_map_string(m) 482 | 483 | m = view(missing, "continent", missing_kwds=dict(color="red")) 484 | assert '"fillColor":"red"' in _fetch_map_string(m) 485 | 486 | 487 | def test_categorical_legend(): 488 | m = view(world, "continent", legend=True) 489 | out_str = _fetch_map_string(m) 490 | assert "#1f77b4'>Africa" in out_str 491 | assert "#ff7f0e'>Antarctica" in out_str 492 | assert "#98df8a'>Asia" in out_str 493 | assert "#9467bd'>Europe" in out_str 494 | assert "#c49c94'>NorthAmerica" in out_str 495 | assert "#7f7f7f'>Oceania" in out_str 496 | assert "#dbdb8d'>Sevenseas(openocean)" in out_str 497 | assert "#9edae5'>SouthAmerica" in out_str 498 | 499 | m = view(missing, "continent", legend=True, missing_kwds={"color": "red"}) 500 | out_str = _fetch_map_string(m) 501 | assert "red'>NaN" in out_str 502 | 503 | 504 | def test_colorbar(): 505 | m = view(world, "range", legend=True) 506 | out_str = _fetch_map_string(m) 507 | assert "attr(\"id\",'legend')" in out_str 508 | assert "text('range')" in out_str 509 | 510 | m = view(world, "range", legend=True, legend_kwds=dict(caption="my_caption")) 511 | out_str = _fetch_map_string(m) 512 | assert "attr(\"id\",'legend')" in out_str 513 | assert "text('my_caption')" in out_str 514 | 515 | m = view(missing, "pop_est", legend=True, missing_kwds=dict(color="red")) 516 | out_str = _fetch_map_string(m) 517 | assert "red'>NaN" in out_str 518 | 519 | # do not scale legend 520 | m = view( 521 | world, 522 | "pop_est", 523 | legend=True, 524 | legend_kwds=dict(scale=False), 525 | scheme="Headtailbreaks", 526 | ) 527 | out_str = _fetch_map_string(m) 528 | assert out_str.count("#440154ff") == 100 529 | assert out_str.count("#3b528bff") == 100 530 | assert out_str.count("#21918cff") == 100 531 | assert out_str.count("#5ec962ff") == 100 532 | assert out_str.count("#fde725ff") == 100 533 | 534 | # scale legend accorrdingly 535 | m = view( 536 | world, 537 | "pop_est", 538 | legend=True, 539 | scheme="Headtailbreaks", 540 | ) 541 | out_str = _fetch_map_string(m) 542 | assert out_str.count("#440154ff") == 16 543 | assert out_str.count("#3b528bff") == 51 544 | assert out_str.count("#21918cff") == 133 545 | assert out_str.count("#5ec962ff") == 282 546 | assert out_str.count("#fde725ff") == 18 547 | 548 | # discrete cmap 549 | m = view(world, "pop_est", legend=True, cmap="Pastel2") 550 | out_str = _fetch_map_string(m) 551 | 552 | assert out_str.count("b3e2cdff") == 63 553 | assert out_str.count("fdcdacff") == 62 554 | assert out_str.count("cbd5e8ff") == 63 555 | assert out_str.count("f4cae4ff") == 62 556 | assert out_str.count("e6f5c9ff") == 62 557 | assert out_str.count("fff2aeff") == 63 558 | assert out_str.count("f1e2ccff") == 62 559 | assert out_str.count("ccccccff") == 63 560 | 561 | 562 | def test_providers(): 563 | m = view(nybb, tiles=contextily.providers.CartoDB.PositronNoLabels) 564 | out_str = _fetch_map_string(m) 565 | 566 | assert ( 567 | '"https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png"' in out_str 568 | ) 569 | assert '"attribution":"(C)OpenStreetMapcontributors(C)CARTO"' in out_str 570 | assert '"maxNativeZoom":19,"maxZoom":19,"minZoom":0' in out_str 571 | 572 | 573 | def test_linearrings(): 574 | rings = nybb.explode().exterior 575 | m = view(rings) 576 | out_str = _fetch_map_string(m) 577 | 578 | assert out_str.count("LineString") == len(rings) 579 | 580 | 581 | def test_mapclassify_categorical_legend(): 582 | m = view( 583 | missing, 584 | column="pop_est", 585 | legend=True, 586 | scheme="naturalbreaks", 587 | missing_kwds=dict(color="red", label="Missing"), 588 | legend_kwds=dict(colorbar=False, interval=True), 589 | ) 590 | out_str = _fetch_map_string(m) 591 | 592 | strings = [ 593 | "[140.00,33986655.00]", 594 | "(33986655.00,105350020.00]", 595 | "(105350020.00,207353391.00]", 596 | "(207353391.00,326625791.00]", 597 | "(326625791.00,1379302771.00]", 598 | "Missing", 599 | ] 600 | for s in strings: 601 | assert s in out_str 602 | 603 | # interval=False 604 | m = view( 605 | missing, 606 | column="pop_est", 607 | legend=True, 608 | scheme="naturalbreaks", 609 | missing_kwds=dict(color="red", label="Missing"), 610 | legend_kwds=dict(colorbar=False, interval=False), 611 | ) 612 | out_str = _fetch_map_string(m) 613 | 614 | strings = [ 615 | ">140.00,33986655.00", 616 | ">33986655.00,105350020.00", 617 | ">105350020.00,207353391.00", 618 | ">207353391.00,326625791.00", 619 | ">326625791.00,1379302771.00", 620 | "Missing", 621 | ] 622 | for s in strings: 623 | assert s in out_str 624 | 625 | # custom labels 626 | m = view( 627 | world, 628 | column="pop_est", 629 | legend=True, 630 | scheme="naturalbreaks", 631 | k=5, 632 | legend_kwds=dict(colorbar=False, labels=["s", "m", "l", "xl", "xxl"]), 633 | ) 634 | out_str = _fetch_map_string(m) 635 | 636 | strings = [">s<", ">m<", ">l<", ">xl<", ">xxl<"] 637 | for s in strings: 638 | assert s in out_str 639 | 640 | # fmt 641 | m = view( 642 | missing, 643 | column="pop_est", 644 | legend=True, 645 | scheme="naturalbreaks", 646 | missing_kwds=dict(color="red", label="Missing"), 647 | legend_kwds=dict(colorbar=False, fmt="{:.0f}"), 648 | ) 649 | out_str = _fetch_map_string(m) 650 | 651 | strings = [ 652 | ">140,33986655", 653 | ">33986655,105350020", 654 | ">105350020,207353391", 655 | ">207353391,326625791", 656 | ">326625791,1379302771", 657 | "Missing", 658 | ] 659 | for s in strings: 660 | assert s in out_str 661 | 662 | 663 | def test_given_m(): 664 | "Check that geometry is mapped onto a given folium.Map" 665 | m = folium.Map() 666 | view(nybb, m=m, tooltip=False, highlight=False) 667 | 668 | out_str = _fetch_map_string(m) 669 | 670 | assert out_str.count("BoroCode") == 5 671 | # should not change map settings 672 | assert m.options["zoom"] == 1 673 | 674 | 675 | def test_highlight(): 676 | m = view(nybb, highlight=True) 677 | out_str = _fetch_map_string(m) 678 | 679 | assert '"fillOpacity":0.75' in out_str 680 | 681 | m = view(nybb, highlight=True, highlight_kwds=dict(fillOpacity=1, color="red")) 682 | out_str = _fetch_map_string(m) 683 | 684 | assert '{"color":"red","fillOpacity":1}' in out_str 685 | 686 | 687 | def test_custom_colormaps(): 688 | 689 | step = StepColormap(["green", "yellow", "red"], vmin=0, vmax=100000000) 690 | 691 | m = view(world, "pop_est", cmap=step, tooltip=["name"], legend=True) 692 | 693 | strings = [ 694 | 'fillColor":"#008000ff"', # Green 695 | '"fillColor":"#ffff00ff"', # Yellow 696 | '"fillColor":"#ff0000ff"', # Red 697 | ] 698 | 699 | out_str = _fetch_map_string(m) 700 | for s in strings: 701 | assert s in out_str 702 | 703 | assert out_str.count("008000ff") == 306 704 | assert out_str.count("ffff00ff") == 187 705 | assert out_str.count("ff0000ff") == 190 706 | 707 | # Using custom function colormap 708 | def my_color_function(field): 709 | """Maps low values to green and high values to red.""" 710 | if field > 100000000: 711 | return "#ff0000" 712 | else: 713 | return "#008000" 714 | 715 | m = view(world, "pop_est", cmap=my_color_function, legend=False) 716 | 717 | strings = [ 718 | '"color":"#ff0000","fillColor":"#ff0000"', 719 | '"color":"#008000","fillColor":"#008000"', 720 | ] 721 | 722 | for s in strings: 723 | assert s in _fetch_map_string(m) 724 | 725 | # matplotlib.Colormap 726 | cmap = colors.ListedColormap(["red", "green", "blue", "white", "black"]) 727 | 728 | m = view(nybb, "BoroName", cmap=cmap) 729 | strings = [ 730 | '"fillColor":"#ff0000"', # Red 731 | '"fillColor":"#008000"', # Green 732 | '"fillColor":"#0000ff"', # Blue 733 | '"fillColor":"#ffffff"', # White 734 | '"fillColor":"#000000"', # Black 735 | ] 736 | 737 | out_str = _fetch_map_string(m) 738 | for s in strings: 739 | assert s in out_str 740 | -------------------------------------------------------------------------------- /geopandas_view/view.py: -------------------------------------------------------------------------------- 1 | from statistics import mean 2 | from warnings import warn 3 | 4 | import branca as bc 5 | import folium 6 | import geopandas as gpd 7 | from shapely.geometry import LineString 8 | import mapclassify 9 | import matplotlib.cm as cm 10 | import matplotlib.colors as colors 11 | import matplotlib.pyplot as plt 12 | import numpy as np 13 | import pandas as pd 14 | 15 | _MAP_KWARGS = [ 16 | "location", 17 | "prefer_canvas", 18 | "no_touch", 19 | "disable_3d", 20 | "png_enabled", 21 | "zoom_control", 22 | "crs", 23 | "zoom_start", 24 | "left", 25 | "top", 26 | "position", 27 | "min_zoom", 28 | "max_zoom", 29 | "min_lat", 30 | "max_lat", 31 | "min_lon", 32 | "max_lon", 33 | "max_bounds", 34 | ] 35 | 36 | 37 | def view( 38 | df, 39 | column=None, 40 | cmap=None, 41 | color=None, 42 | m=None, 43 | tiles="OpenStreetMap", 44 | attr=None, 45 | tooltip=True, 46 | popup=False, 47 | highlight=True, 48 | categorical=False, 49 | legend=True, 50 | scheme=None, 51 | k=5, 52 | vmin=None, 53 | vmax=None, 54 | width="100%", 55 | height="100%", 56 | categories=None, 57 | classification_kwds=None, 58 | control_scale=True, 59 | marker_type=None, 60 | marker_kwds={}, 61 | style_kwds={}, 62 | highlight_kwds={}, 63 | missing_kwds={}, 64 | tooltip_kwds={}, 65 | popup_kwds={}, 66 | legend_kwds={}, 67 | **kwargs, 68 | ): 69 | """Interactive map based on GeoPandas and folium/leaflet.js 70 | 71 | Generate an interactive leaflet map based on GeoDataFrame or GeoSeries 72 | 73 | Parameters 74 | ---------- 75 | df : GeoDataFrame 76 | The GeoDataFrame to be plotted. 77 | column : str, np.array, pd.Series (default None) 78 | The name of the dataframe column, np.array, or pd.Series to be plotted. 79 | If np.array or pd.Series are used then it must have same length as dataframe. 80 | cmap : str, matplotlib.Colormap, branca.colormap, self-defined function fun(column)->str (default None) 81 | The name of a colormap recognized by matplotlib, a list-like of colors, matplotlib.Colormap, 82 | a branca colormap or function that returns a named color or hex based on the column 83 | value, e.g.: 84 | def my_colormap(value): # scalar value defined in 'column' 85 | if value > 1: 86 | return "green" 87 | return "red" 88 | color : str, array-like (default None) 89 | Named color or a list-like of colors (named or hex). 90 | m : folium.Map (default None) 91 | Existing map instance on which to draw the plot. 92 | tiles : str, contextily.providers.TileProvider (default 'OpenStreetMap') 93 | Map tileset to use. Can choose from this list of built-in tiles or pass 94 | ``contextily.providers.TileProvider``: 95 | 96 | ``["OpenStreetMap", "Stamen Terrain", “Stamen Toner", “Stamen Watercolor" 97 | "CartoDB positron", “CartoDB dark_matter"]`` 98 | 99 | You can pass a custom tileset to Folium by passing a Leaflet-style URL 100 | to the tiles parameter: http://{s}.yourtiles.com/{z}/{x}/{y}.png. 101 | You can find a list of free tile providers here: 102 | http://leaflet-extras.github.io/leaflet-providers/preview/. Be sure 103 | to check their terms and conditions and to provide attribution with the attr keyword. 104 | attr : str (default None) 105 | Map tile attribution; only required if passing custom tile URL. 106 | tooltip : bool, str, int, list (default True) 107 | Display GeoDataFrame attributes when hovering over the object. 108 | Integer specifies first n columns to be included, ``True`` includes all 109 | columns. ``False`` removes tooltip. Pass string or list of strings to specify a 110 | column(s). Defaults to ``True``. 111 | popup : bool, str, int, list (default False) 112 | Input GeoDataFrame attributes for object displayed when clicking. 113 | Integer specifies first n columns to be included, ``True`` includes all 114 | columns. ``False`` removes tooltip. Pass string or list of strings to specify a 115 | column(s). Defaults to ``False``. 116 | highlight : bool (default True) 117 | Enable highlight functionality when hovering over a geometry. 118 | categorical : bool (default False) 119 | If False, cmap will reflect numerical values of the 120 | column being plotted. For non-numerical columns, this 121 | will be set to True. 122 | legend : bool (default True) 123 | Plot a legend in choropleth plots. 124 | Ignored if no `column` is given. 125 | scheme : str (default None) 126 | Name of a choropleth classification scheme (requires mapclassify). 127 | A mapclassify.MapClassifier object will be used 128 | under the hood. Supported are all schemes provided by mapclassify (e.g. 129 | 'BoxPlot', 'EqualInterval', 'FisherJenks', 'FisherJenksSampled', 130 | 'HeadTailBreaks', 'JenksCaspall', 'JenksCaspallForced', 131 | 'JenksCaspallSampled', 'MaxP', 'MaximumBreaks', 132 | 'NaturalBreaks', 'Quantiles', 'Percentiles', 'StdMean', 133 | 'UserDefined'). Arguments can be passed in classification_kwds. 134 | k : int (default 5) 135 | Number of classes 136 | vmin : None or float (default None) 137 | Minimum value of cmap. If None, the minimum data value 138 | in the column to be plotted is used. Cannot be higher than minimum data value. 139 | vmax : None or float (default None) 140 | Maximum value of cmap. If None, the maximum data value 141 | in the column to be plotted is used. Cannot be lower than maximum data value. 142 | width : pixel int or percentage string (default: '100%') 143 | Width of the folium.Map. If the argument 144 | m is given explicitly, width is ignored. 145 | height : pixel int or percentage string (default: '100%') 146 | Height of the folium.Map. If the argument 147 | m is given explicitly, height is ignored. 148 | categories : list-like 149 | Ordered list-like object of categories to be used for categorical plot. 150 | classification_kwds : dict (default None) 151 | Keyword arguments to pass to mapclassify 152 | control_scale : bool, (default True) 153 | Whether to add a control scale on the map. 154 | marker_type : str, folium.Circle, folium.CircleMarker, folium.Marker (default None) 155 | Allowed string options are ('marker', 'circle', 'circle_marker') 156 | marker_kwds: dict (default {}) 157 | Additional keywords to be passed to the selected ``marker_type``, e.g.: 158 | 159 | radius : float 160 | Radius of the circle, in meters (for ``'circle'``) or pixels (for ``circle_marker``). 161 | icon : folium.map.Icon 162 | the Icon object to use to render the marker. 163 | See https://python-visualization.github.io/folium/modules.html#folium.map.Icon. 164 | draggable : bool (default False) 165 | Set to True to be able to drag the marker around the map. 166 | 167 | style_kwds : dict (default {}) 168 | Additional style to be passed to folium style_function: 169 | 170 | stroke : bool (default True) 171 | Whether to draw stroke along the path. Set it to False to 172 | disable borders on polygons or circles. 173 | color : str 174 | Stroke color 175 | weight : int 176 | Stroke width in pixels 177 | opacity : float (default 1.0) 178 | Stroke opacity 179 | fill : boolean (default True) 180 | Whether to fill the path with color. Set it to False to 181 | disable filling on polygons or circles. 182 | fillColor : str 183 | Fill color. Defaults to the value of the color option 184 | fillOpacity : float (default 0.5) 185 | Fill opacity. 186 | 187 | Plus all supported by folium.Path object. 188 | See ``folium.vector_layers.path_options()`` for the Path options. 189 | 190 | highlight_kwds : dict (default {}) 191 | Style to be passed to folium highlight_function. Uses the same keywords 192 | as ``style_kwds``. When empty, defaults to ``{"fillOpacity": 0.75}``. 193 | tooltip_kwds : dict (default {}) 194 | Additional keywords to be passed to folium.features.GeoJsonTooltip, 195 | e.g. ``aliases``, ``labels``, or ``sticky``. See the folium 196 | documentation for details: 197 | https://python-visualization.github.io/folium/modules.html#folium.features.GeoJsonTooltip 198 | popup_kwds : dict (default {}) 199 | Additional keywords to be passed to folium.features.GeoJsonPopup, 200 | e.g. ``aliases`` or ``labels``. See the folium 201 | documentation for details: 202 | https://python-visualization.github.io/folium/modules.html#folium.features.GeoJsonPopup 203 | legend_kwds : dict (default {}) 204 | Additional keywords to be passed to the legend. 205 | 206 | Currently supported customisation: 207 | 208 | caption : string 209 | Custom caption of the legend. Defaults to the column name. 210 | 211 | Additional accepted keywords when `scheme` is specified: 212 | 213 | colorbar : bool (default True) 214 | An option to control the style of the legend. If True, continuous 215 | colorbar will be used. If False, categorical legend will be used for bins. 216 | scale : bool (default True) 217 | Scale bins along the colorbar axis according to the bin edges (True) 218 | or use the equal length for each bin (False) 219 | fmt : string (default "{:.2f}") 220 | A formatting specification for the bin edges of the classes in the 221 | legend. For example, to have no decimals: ``{"fmt": "{:.0f}"}``. Applies 222 | if ``colorbar=False``. 223 | labels : list-like 224 | A list of legend labels to override the auto-generated labels. 225 | Needs to have the same number of elements as the number of 226 | classes (`k`). Applies if ``colorbar=False``. 227 | interval : boolean (default False) 228 | An option to control brackets from mapclassify legend. 229 | If True, open/closed interval brackets are shown in the legend. 230 | Applies if ``colorbar=False``. 231 | 232 | **kwargs : dict 233 | Additional options to be passed on to the folium.Map or folium.GeoJson. 234 | 235 | Returns 236 | ------- 237 | m : folium.Map 238 | Folium map instance 239 | 240 | """ 241 | gdf = df.copy() 242 | 243 | # convert LinearRing to LineString 244 | rings_mask = df.geom_type == "LinearRing" 245 | if rings_mask.any(): 246 | gdf.geometry[rings_mask] = gdf.geometry[rings_mask].apply( 247 | lambda g: LineString(g) 248 | ) 249 | 250 | if gdf.crs is None: 251 | crs = "Simple" 252 | tiles = None 253 | elif not gdf.crs.equals(4326): 254 | gdf = gdf.to_crs(4326) 255 | 256 | # create folium.Map object 257 | if m is None: 258 | # Get bounds to specify location and map extent 259 | bounds = gdf.total_bounds 260 | location = kwargs.pop("location", None) 261 | if location is None: 262 | x = mean([bounds[0], bounds[2]]) 263 | y = mean([bounds[1], bounds[3]]) 264 | location = (y, x) 265 | if "zoom_start" in kwargs.keys(): 266 | fit = False 267 | else: 268 | fit = True 269 | else: 270 | fit = False 271 | 272 | # get a subset of kwargs to be passed to folium.Map 273 | map_kwds = {i: kwargs[i] for i in kwargs.keys() if i in _MAP_KWARGS} 274 | 275 | # contextily.providers object 276 | if hasattr(tiles, "url") and hasattr(tiles, "attribution"): 277 | attr = attr if attr else tiles["attribution"] 278 | map_kwds["min_zoom"] = tiles.get("min_zoom", 0) 279 | map_kwds["max_zoom"] = tiles.get("max_zoom", 18) 280 | tiles = tiles["url"].format( 281 | x="{x}", y="{y}", z="{z}", s="{s}", r=tiles.get("r", ""), **tiles 282 | ) 283 | 284 | m = folium.Map( 285 | location=location, 286 | control_scale=control_scale, 287 | tiles=tiles, 288 | attr=attr, 289 | width=width, 290 | height=height, 291 | **map_kwds, 292 | ) 293 | 294 | # fit bounds to get a proper zoom level 295 | if fit: 296 | m.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) 297 | 298 | for map_kwd in _MAP_KWARGS: 299 | kwargs.pop(map_kwd, None) 300 | 301 | nan_idx = None 302 | 303 | if column is not None: 304 | if pd.api.types.is_list_like(column): 305 | if len(column) != gdf.shape[0]: 306 | raise ValueError( 307 | "The GeoDataframe and given column have different number of rows." 308 | ) 309 | else: 310 | column_name = "__plottable_column" 311 | gdf[column_name] = column 312 | column = column_name 313 | elif pd.api.types.is_categorical_dtype(gdf[column]): 314 | if categories is not None: 315 | raise ValueError( 316 | "Cannot specify 'categories' when column has categorical dtype" 317 | ) 318 | categorical = True 319 | elif gdf[column].dtype is np.dtype("O") or categories: 320 | categorical = True 321 | 322 | nan_idx = pd.isna(gdf[column]) 323 | 324 | if categorical: 325 | cat = pd.Categorical(gdf[column][~nan_idx], categories=categories) 326 | N = len(cat.categories) 327 | cmap = cmap if cmap else "tab20" 328 | 329 | # colormap exists in matplotlib 330 | if cmap in plt.colormaps(): 331 | 332 | color = np.apply_along_axis( 333 | colors.to_hex, 1, cm.get_cmap(cmap, N)(cat.codes) 334 | ) 335 | legend_colors = np.apply_along_axis( 336 | colors.to_hex, 1, cm.get_cmap(cmap, N)(range(N)) 337 | ) 338 | 339 | # colormap is matplotlib.Colormap 340 | elif isinstance(cmap, colors.Colormap): 341 | color = np.apply_along_axis(colors.to_hex, 1, cmap(cat.codes)) 342 | legend_colors = np.apply_along_axis(colors.to_hex, 1, cmap(range(N))) 343 | 344 | # custom list of colors 345 | elif pd.api.types.is_list_like(cmap): 346 | if N > len(cmap): 347 | cmap = cmap * (N // len(cmap) + 1) 348 | color = np.take(cmap, cat.codes) 349 | legend_colors = np.take(cmap, range(N)) 350 | 351 | else: 352 | raise ValueError( 353 | "'cmap' is invalid. For categorical plots, pass either valid " 354 | "named matplotlib colormap or a list-like of colors." 355 | ) 356 | 357 | elif callable(cmap): 358 | # List of colors based on Branca colormaps or self-defined functions 359 | color = list(map(lambda x: cmap(x), df[column])) 360 | 361 | else: 362 | vmin = gdf[column].min() if not vmin else vmin 363 | vmax = gdf[column].max() if not vmax else vmax 364 | 365 | if vmin > gdf[column].min(): 366 | warn( 367 | "'vmin' cannot be higher than minimum value. Setting vmin to minimum.", 368 | UserWarning, 369 | stacklevel=3, 370 | ) 371 | vmin = gdf[column].min() 372 | if vmax < gdf[column].max(): 373 | warn( 374 | "'vmax' cannot be lower than maximum value. Setting vmax to maximum.", 375 | UserWarning, 376 | stacklevel=3, 377 | ) 378 | vmax = gdf[column].max() 379 | 380 | # get bins 381 | if scheme is not None: 382 | 383 | if classification_kwds is None: 384 | classification_kwds = {} 385 | if "k" not in classification_kwds: 386 | classification_kwds["k"] = k 387 | 388 | binning = mapclassify.classify( 389 | np.asarray(gdf[column][~nan_idx]), scheme, **classification_kwds 390 | ) 391 | color = np.apply_along_axis( 392 | colors.to_hex, 1, cm.get_cmap(cmap, k)(binning.yb) 393 | ) 394 | 395 | else: 396 | 397 | bins = np.linspace(vmin, vmax, 257)[1:] 398 | binning = mapclassify.classify( 399 | np.asarray(gdf[column][~nan_idx]), "UserDefined", bins=bins 400 | ) 401 | 402 | color = np.apply_along_axis( 403 | colors.to_hex, 1, cm.get_cmap(cmap, 256)(binning.yb) 404 | ) 405 | 406 | # we cannot color default 'marker' 407 | if marker_type is None: 408 | marker_type = "circle" 409 | 410 | # set default style 411 | if "fillOpacity" not in style_kwds: 412 | style_kwds["fillOpacity"] = 0.5 413 | if "weight" not in style_kwds: 414 | style_kwds["weight"] = 2 415 | 416 | # specify color 417 | if color is not None: 418 | if ( 419 | isinstance(color, str) 420 | and isinstance(gdf, gpd.GeoDataFrame) 421 | and color in gdf.columns 422 | ): # use existing column 423 | style_function = lambda x: { 424 | "fillColor": x["properties"][color], 425 | **style_kwds, 426 | } 427 | else: # assign new column 428 | if isinstance(gdf, gpd.GeoSeries): 429 | gdf = gpd.GeoDataFrame(geometry=gdf) 430 | 431 | if nan_idx is not None and nan_idx.any(): 432 | nan_color = missing_kwds.pop("color", None) 433 | 434 | gdf["__folium_color"] = nan_color 435 | gdf.loc[~nan_idx, "__folium_color"] = color 436 | else: 437 | gdf["__folium_color"] = color 438 | 439 | stroke_color = style_kwds.pop("color", None) 440 | if not stroke_color: 441 | style_function = lambda x: { 442 | "fillColor": x["properties"]["__folium_color"], 443 | "color": x["properties"]["__folium_color"], 444 | **style_kwds, 445 | } 446 | else: 447 | style_function = lambda x: { 448 | "fillColor": x["properties"]["__folium_color"], 449 | "color": stroke_color, 450 | **style_kwds, 451 | } 452 | else: # use folium default 453 | style_function = lambda x: {**style_kwds} 454 | 455 | if highlight: 456 | if not "fillOpacity" in highlight_kwds: 457 | highlight_kwds["fillOpacity"] = 0.75 458 | highlight_function = lambda x: {**highlight_kwds} 459 | else: 460 | highlight_function = None 461 | 462 | marker = marker_type 463 | if marker_type is not None and isinstance(marker_type, str): 464 | if marker_type == "marker": 465 | marker = folium.Marker(**marker_kwds) 466 | elif marker_type == "circle": 467 | marker = folium.Circle(**marker_kwds) 468 | elif marker_type == "circle_marker": 469 | marker = folium.CircleMarker(**marker_kwds) 470 | else: 471 | raise ValueError( 472 | "Only 'marker', 'circle', and 'circle_marker' are supported as marker values" 473 | ) 474 | 475 | # preprare tooltip and popup 476 | if isinstance(gdf, gpd.GeoDataFrame): 477 | # specify fields to show in the tooltip 478 | tooltip = _tooltip_popup("tooltip", tooltip, gdf, **tooltip_kwds) 479 | popup = _tooltip_popup("popup", popup, gdf, **popup_kwds) 480 | else: 481 | tooltip = None 482 | popup = None 483 | 484 | # add dataframe to map 485 | folium.GeoJson( 486 | gdf.__geo_interface__, 487 | tooltip=tooltip, 488 | popup=popup, 489 | marker=marker, 490 | style_function=style_function, 491 | highlight_function=highlight_function, 492 | **kwargs, 493 | ).add_to(m) 494 | 495 | if legend: 496 | # NOTE: overlaps should be resolved in branca https://github.com/python-visualization/branca/issues/88 497 | caption = column if not column == "__plottable_column" else "" 498 | caption = legend_kwds.pop("caption", caption) 499 | if categorical: 500 | categories = cat.categories.to_list() 501 | legend_colors = legend_colors.tolist() 502 | 503 | if nan_idx.any() and nan_color: 504 | categories.append(missing_kwds.pop("label", "NaN")) 505 | legend_colors.append(nan_color) 506 | 507 | _categorical_legend(m, caption, categories, legend_colors) 508 | elif column is not None: 509 | 510 | cbar = legend_kwds.pop("colorbar", True) 511 | if scheme: 512 | cb_colors = np.apply_along_axis( 513 | colors.to_hex, 1, cm.get_cmap(cmap, binning.k)(range(binning.k)) 514 | ) 515 | if cbar: 516 | if legend_kwds.pop("scale", True): 517 | index = [vmin] + binning.bins.tolist() 518 | else: 519 | index = None 520 | colorbar = bc.colormap.StepColormap( 521 | cb_colors, vmin=vmin, vmax=vmax, caption=caption, index=index 522 | ) 523 | else: 524 | fmt = legend_kwds.pop("fmt", "{:.2f}") 525 | if "labels" in legend_kwds: 526 | categories = legend_kwds["labels"] 527 | else: 528 | categories = binning.get_legend_classes(fmt) 529 | show_interval = legend_kwds.pop("interval", False) 530 | if not show_interval: 531 | categories = [c[1:-1] for c in categories] 532 | 533 | if nan_idx.any() and nan_color: 534 | categories.append(missing_kwds.pop("label", "NaN")) 535 | cb_colors = np.append(cb_colors, nan_color) 536 | _categorical_legend(m, caption, categories, cb_colors) 537 | 538 | else: 539 | if isinstance(cmap, bc.colormap.ColorMap): 540 | colorbar = cmap 541 | else: 542 | 543 | mp_cmap = cm.get_cmap(cmap) 544 | cb_colors = np.apply_along_axis( 545 | colors.to_hex, 1, mp_cmap(range(mp_cmap.N)) 546 | ) 547 | # linear legend 548 | if mp_cmap.N > 20: 549 | colorbar = bc.colormap.LinearColormap( 550 | cb_colors, vmin=vmin, vmax=vmax, caption=caption 551 | ) 552 | 553 | # steps 554 | else: 555 | colorbar = bc.colormap.StepColormap( 556 | cb_colors, vmin=vmin, vmax=vmax, caption=caption 557 | ) 558 | 559 | if cbar: 560 | if nan_idx.any() and nan_color: 561 | _categorical_legend( 562 | m, "", [missing_kwds.pop("label", "NaN")], [nan_color] 563 | ) 564 | m.add_child(colorbar) 565 | 566 | return m 567 | 568 | 569 | def _tooltip_popup(type, fields, gdf, **kwds): 570 | """get tooltip or popup""" 571 | # specify fields to show in the tooltip 572 | if fields is False or fields is None or fields == 0: 573 | return None 574 | else: 575 | if fields is True: 576 | fields = gdf.columns.drop(gdf.geometry.name).to_list() 577 | elif isinstance(fields, int): 578 | fields = gdf.columns.drop(gdf.geometry.name).to_list()[:fields] 579 | elif isinstance(fields, str): 580 | fields = [fields] 581 | 582 | for field in ["__plottable_column", "__folium_color"]: 583 | if field in fields: 584 | fields.remove(field) 585 | 586 | # Cast fields to str 587 | fields = list(map(str, fields)) 588 | if type == "tooltip": 589 | return folium.GeoJsonTooltip(fields, **kwds) 590 | elif type == "popup": 591 | return folium.GeoJsonPopup(fields, **kwds) 592 | 593 | 594 | def _categorical_legend(m, title, categories, colors): 595 | """ 596 | Add categorical legend to a map 597 | 598 | The implementation is using the code originally written by Michel Metran 599 | (@michelmetran) and released on GitHub 600 | (https://github.com/michelmetran/package_folium) under MIT license. 601 | 602 | Copyright (c) 2020 Michel Metran 603 | 604 | Parameters 605 | ---------- 606 | m : folium.Map 607 | Existing map instance on which to draw the plot 608 | title : str 609 | title of the legend (e.g. column name) 610 | categories : list-like 611 | list of categories 612 | colors : list-like 613 | list of colors (in the same order as categories) 614 | """ 615 | 616 | # Header to Add 617 | head = """ 618 | {% macro header(this, kwargs) %} 619 | 620 | 632 | 679 | {% endmacro %} 680 | """ 681 | 682 | # Add CSS (on Header) 683 | macro = bc.element.MacroElement() 684 | macro._template = bc.element.Template(head) 685 | m.get_root().add_child(macro) 686 | 687 | body = f""" 688 |
689 |
{title}
690 |
691 | 700 |
701 |
702 | """ 703 | 704 | # Add Body 705 | body = bc.element.Element(body, "legend") 706 | m.get_root().html.add_child(body) 707 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | geopandas 2 | folium 3 | mapclassify 4 | matplotlib 5 | contextily 6 | pytest 7 | pytest-cov 8 | codecov -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | geopandas 2 | folium 3 | mapclassify 4 | matplotlib -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | name="geopandas_view", 5 | version="0.0.1", 6 | author="Martin Fleischmann", 7 | author_email="martin@martinfleischmann.net", 8 | python_requires=">=3.6", 9 | install_requires=["geopandas", "folium", "mapclassify", "matplotlib"], 10 | packages=setuptools.find_packages(), 11 | ) 12 | --------------------------------------------------------------------------------