├── .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 | """
692 |
693 | # Loop Categories
694 | for label, color in zip(categories, colors):
695 | body += f"""
696 | - {label}
"""
697 |
698 | body += """
699 |
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 |
--------------------------------------------------------------------------------
|