-
Notifications
You must be signed in to change notification settings - Fork 39
start geopandas extension #728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
knaaptime
wants to merge
33
commits into
developmentseed:main
Choose a base branch
from
knaaptime:explore
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+4,061
−0
Open
Changes from all commits
Commits
Show all changes
33 commits
Select commit
Hold shift + click to select a range
f4cd78b
add geopandas explore accessor
52ddf09
add explore notebook
89f3097
update notebook w/ versions
d29e3c3
move conditional imports, rename classification_kwds
3bbd198
warn on scheme
d1bbfef
rm mapclassify from optional deps
0ab2680
fix classifier check; expose highlight
f8e5092
add type hints; use google docstrings; allow vmin and vmax
79cfbba
add mapclassify back to optional deps
9895c03
Merge branch 'main' into explore
kylebarron 218c3c1
Remove mapclassify extra
kylebarron f8487b4
lint
kylebarron 4ae5f0c
errant import; fix docstrings
33f6957
precommit
90a49f5
Update lonboard/geopandas.py
knaaptime de92d47
Merge branch 'main' of github.com:developmentseed/lonboard into explore
ea5eb3b
update from code review
69e3254
skip bool check because its a kwarg in the private function
8f915cf
lint notebook
a6cc193
underscore cell
3f22c25
Merge branch 'main' into explore
knaaptime e380c11
Merge branch 'main' into explore
knaaptime 51ecfa0
Merge branch 'main' into explore
knaaptime 8d4f02c
Merge branch 'main' of github.com:developmentseed/lonboard into explore
cf25ca3
use dict fromkeys
36b754f
Merge branch 'explore' of github.com:knaaptime/lonboard into explore
2624f97
Merge branch 'main' into explore
knaaptime ad60f81
manual ruff
79bb722
Merge branch 'main' of github.com:developmentseed/lonboard into explore
2ced17e
Merge branch 'explore' of github.com:knaaptime/lonboard into explore
f23a3e3
revert notebook
3e2b739
Merge branch 'main' into explore
knaaptime 872db8f
Merge branch 'main' into explore
knaaptime File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,340 @@ | ||
from __future__ import annotations | ||
|
||
from typing import Any | ||
|
||
import geopandas as gpd | ||
import numpy as np | ||
import pandas as pd | ||
|
||
from . import Map, basemap, viz | ||
from .colormap import apply_categorical_cmap, apply_continuous_cmap | ||
|
||
__all__ = ["LonboardAccessor"] | ||
|
||
QUERY_NAME_TRANSLATION = str.maketrans(dict.fromkeys("., -_/", "")) | ||
|
||
|
||
@pd.api.extensions.register_dataframe_accessor("lb") | ||
class LonboardAccessor: | ||
"""Geopandas Extension class to provide the `explore` method.""" | ||
|
||
def __init__(self, pandas_obj) -> None: # noqa: ANN001, D107 | ||
self._validate(pandas_obj) | ||
self._obj = pandas_obj | ||
|
||
@staticmethod | ||
def _validate(obj) -> None: # noqa: ANN001 | ||
if not isinstance(obj, gpd.GeoDataFrame): | ||
raise TypeError("must be a geodataframe") | ||
|
||
def explore( # noqa: PLR0913 | ||
self, | ||
column: str | None = None, | ||
cmap: str | None = None, | ||
scheme: str | None = None, | ||
k: int | None = 6, | ||
categorical: bool = False, # noqa: FBT001, FBT002 | ||
elevation: str | np.ndarray = None, | ||
elevation_scale: float | None = 1, | ||
alpha: float | None = 1, | ||
layer_kwargs: dict[str, Any] | None = None, | ||
map_kwargs: dict[str, Any] | None = None, | ||
classification_kwds: dict[str, Any] | None = None, | ||
nan_color: list[int] | np.ndarray[int] | None = None, | ||
color: str | None = None, | ||
vmin: float | None = None, | ||
vmax: float | None = None, | ||
wireframe: bool = False, # noqa: FBT001, FBT002 | ||
tiles: str | None = None, | ||
highlight: bool = False, # noqa: FBT001, FBT002 | ||
m: Map | None = None, | ||
) -> Map: | ||
"""Explore a dataframe using lonboard and deckgl. | ||
|
||
Keyword Args: | ||
column : Name of column on dataframe to visualize on map. | ||
cmap : Name of matplotlib colormap to use. | ||
scheme : Name of a classification scheme defined by mapclassify.Classifier. | ||
k : Number of classes to generate. Defaults to 6. | ||
categorical : Whether the data should be treated as categorical or | ||
continuous. | ||
elevation : Name of column on the dataframe used to extrude each geometry or | ||
an array-like in the same order as observations. Defaults to None. | ||
elevation_scale : Constant scaler multiplied by elevation value. | ||
alpha : Alpha (opacity) parameter in the range (0,1) passed to | ||
mapclassify.util.get_color_array. | ||
layer_kwargs : Additional keyword arguments passed to lonboard.viz layer | ||
arguments (either polygon_kwargs, scatterplot_kwargs, or path_kwargs, | ||
depending on input geometry type). | ||
map_kwargs : Additional keyword arguments passed to lonboard.viz map_kwargs. | ||
classification_kwds : Additional keyword arguments passed to | ||
`mapclassify.classify`. | ||
nan_color : Color used to shade NaN observations formatted as an RGBA list. | ||
Defaults to [255, 255, 255, 255]. If no alpha channel is passed it is | ||
assumed to be 255. | ||
color : single or array of colors passed to Layer.get_fill_color | ||
or a lonboard.basemap object, or a string to a maplibre style basemap. | ||
vmin : Minimum value for color mapping. | ||
vmax : Maximum value for color mapping. | ||
wireframe : Whether to use wireframe styling in deckgl. | ||
tiles : Either a known string {"CartoDB Positron", | ||
"CartoDB Positron No Label", "CartoDB Darkmatter", | ||
"CartoDB Darkmatter No Label", "CartoDB Voyager", | ||
"CartoDB Voyager No Label"} | ||
highlight : Whether to highlight each feature on mouseover (passed to | ||
lonboard.Layer's auto_highlight). Defaults to False. | ||
m: An existing Map object to plot onto. | ||
|
||
Returns: | ||
lonboard.Map | ||
a lonboard map with geodataframe included as a Layer object. | ||
|
||
""" | ||
return _dexplore( | ||
self._obj, | ||
column=column, | ||
cmap=cmap, | ||
scheme=scheme, | ||
k=k, | ||
categorical=categorical, | ||
elevation=elevation, | ||
elevation_scale=elevation_scale, | ||
alpha=alpha, | ||
layer_kwargs=layer_kwargs, | ||
map_kwargs=map_kwargs, | ||
classification_kwds=classification_kwds, | ||
nan_color=nan_color, | ||
color=color, | ||
vmin=vmin, | ||
vmax=vmax, | ||
wireframe=wireframe, | ||
tiles=tiles, | ||
highlight=highlight, | ||
m=m, | ||
) | ||
|
||
|
||
def _dexplore( # noqa: C901, PLR0912, PLR0913, PLR0915 | ||
gdf, # noqa: ANN001 | ||
*, | ||
column, # noqa: ANN001 | ||
cmap, # noqa: ANN001 | ||
scheme, # noqa: ANN001 | ||
k, # noqa: ANN001 | ||
categorical, # noqa: ANN001 | ||
elevation, # noqa: ANN001 | ||
elevation_scale, # noqa: ANN001 | ||
alpha, # noqa: ANN001 | ||
layer_kwargs, # noqa: ANN001 | ||
map_kwargs, # noqa: ANN001 | ||
classification_kwds, # noqa: ANN001 | ||
nan_color, # noqa: ANN001 | ||
color, # noqa: ANN001 | ||
vmin, # noqa: ANN001 | ||
vmax, # noqa: ANN001 | ||
wireframe, # noqa: ANN001 | ||
tiles, # noqa: ANN001 | ||
highlight, # noqa: ANN001 | ||
m, # noqa: ANN001 | ||
) -> Map: | ||
"""Explore a dataframe using lonboard and deckgl. | ||
|
||
See the public docstring for detailed parameter information | ||
|
||
Returns | ||
------- | ||
lonboard.Map | ||
a lonboard map with geodataframe included as a Layer object. | ||
|
||
""" | ||
if map_kwargs is None: | ||
map_kwargs = {} | ||
if classification_kwds is None: | ||
classification_kwds = {} | ||
if layer_kwargs is None: | ||
layer_kwargs = {} | ||
if isinstance(elevation, str): | ||
if elevation in gdf.columns: | ||
elevation = gdf[elevation] | ||
else: | ||
raise ValueError( | ||
f"the designated height column {elevation} is not in the dataframe", | ||
) | ||
if not pd.api.types.is_numeric_dtype(elevation): | ||
raise ValueError("elevation must be a numeric data type") | ||
if elevation is not None: | ||
layer_kwargs["extruded"] = True | ||
if nan_color is None: | ||
nan_color = [255, 255, 255, 255] | ||
if not pd.api.types.is_list_like(nan_color): | ||
raise ValueError("nan_color must be an iterable of 3 or 4 values") | ||
if len(nan_color) != 4: | ||
if len(nan_color) == 3: | ||
nan_color = np.append(nan_color, [255]) | ||
else: | ||
raise ValueError("nan_color must be an iterable of 3 or 4 values") | ||
|
||
# only polygons have z | ||
if ["Polygon", "MultiPolygon"] in gdf.geometry.geom_type.unique(): | ||
layer_kwargs["get_elevation"] = elevation | ||
layer_kwargs["elevation_scale"] = elevation_scale | ||
layer_kwargs["wireframe"] = wireframe | ||
layer_kwargs["auto_highlight"] = highlight | ||
|
||
line = False # set color of lines, not fill_color | ||
if ["LineString", "MultiLineString"] in gdf.geometry.geom_type.unique(): | ||
line = True | ||
if color: | ||
if line: | ||
layer_kwargs["get_color"] = color | ||
else: | ||
layer_kwargs["get_fill_color"] = color | ||
if column is not None: | ||
try: | ||
from matplotlib import colormaps | ||
except ImportError as e: | ||
raise ImportError( | ||
"you must have matplotlib installed to style by a column", | ||
) from e | ||
|
||
if column not in gdf.columns: | ||
raise ValueError(f"the designated column {column} is not in the dataframe") | ||
if gdf[column].dtype in ["O", "category"]: | ||
categorical = True | ||
if cmap is not None and cmap not in colormaps: | ||
raise ValueError( | ||
f"`cmap` must be one of {list(colormaps.keys())} but {cmap} was passed", | ||
) | ||
if cmap is None: | ||
cmap = "tab20" if categorical else "viridis" | ||
if categorical: | ||
color_array = _get_categorical_cmap(gdf[column], cmap, nan_color, alpha) | ||
elif scheme is None: | ||
if vmin is None: | ||
vmin = np.nanmin(gdf[column]) | ||
if vmax is None: | ||
vmax = np.nanmax(gdf[column]) | ||
# minmax scale the column first, matplotlib needs 0-1 | ||
transformed = (gdf[column] - vmin) / (vmax - vmin) | ||
color_array = apply_continuous_cmap( | ||
values=transformed, | ||
cmap=colormaps[cmap], | ||
alpha=alpha, | ||
) | ||
else: | ||
try: | ||
from mapclassify._classify_API import _classifiers | ||
from mapclassify.util import get_color_array | ||
|
||
_klasses = list(_classifiers.keys()) | ||
_klasses.append("userdefined") | ||
except ImportError as e: | ||
raise ImportError( | ||
"you must have the `mapclassify` package installed to use the " | ||
"`scheme` keyword", | ||
) from e | ||
if scheme.replace("_", "") not in _klasses: | ||
raise ValueError( | ||
"the classification scheme must be a valid mapclassify" | ||
f"classifier in {_klasses}," | ||
f"but {scheme} was passed instead", | ||
) | ||
if k is not None and "k" in classification_kwds: | ||
# k passed directly takes precedence | ||
classification_kwds.pop("k") | ||
color_array = get_color_array( | ||
gdf[column], | ||
scheme=scheme, | ||
k=k, | ||
cmap=cmap, | ||
alpha=alpha, | ||
nan_color=nan_color, | ||
**classification_kwds, | ||
) | ||
|
||
if line: | ||
layer_kwargs["get_color"] = color_array | ||
|
||
else: | ||
layer_kwargs["get_fill_color"] = color_array | ||
if tiles: | ||
map_kwargs["basemap_style"] = _query_name(tiles) | ||
new_m = viz( | ||
gdf, | ||
polygon_kwargs=layer_kwargs, | ||
scatterplot_kwargs=layer_kwargs, | ||
path_kwargs=layer_kwargs, | ||
map_kwargs=map_kwargs, | ||
) | ||
if m is not None: | ||
new_m = m.add_layer(new_m) | ||
|
||
return new_m | ||
|
||
|
||
def _get_categorical_cmap(categories, cmap, nan_color, alpha): # noqa: ANN001, ANN202 | ||
try: | ||
from matplotlib import colormaps | ||
except ImportError as e: | ||
raise ImportError( | ||
"this function requires the `lonboard` package to be installed", | ||
) from e | ||
|
||
cat_codes = pd.Series(pd.Categorical(categories).codes, dtype="category") | ||
# nans are encoded as -1 OR largest category depending on input type | ||
# re-encode to always be last category | ||
cat_codes = cat_codes.cat.rename_categories({-1: len(cat_codes.unique()) - 1}) | ||
unique_cats = categories.dropna().unique() | ||
n_cats = len(unique_cats) | ||
colors = colormaps[cmap].resampled(n_cats)(list(range(n_cats)), alpha, bytes=True) | ||
colors = np.vstack([colors, nan_color]) | ||
temp_cmap = dict(zip(range(n_cats + 1), colors)) | ||
return apply_categorical_cmap(cat_codes, temp_cmap) | ||
|
||
def _query_name(name: str) -> basemap: | ||
"""Return basemap URL based on the name query (mimicking behavior from xyzservices). | ||
|
||
Returns a matching basemap from name contains the same letters in the same | ||
order as the provider's name irrespective of the letter case, spaces, dashes | ||
and other characters. See examples for details. | ||
|
||
Parameters | ||
---------- | ||
name : str | ||
Name of the tile provider. Formatting does not matter. | ||
|
||
Returns | ||
------- | ||
match: lonboard.basemap | ||
|
||
Examples | ||
-------- | ||
>>> import xyzservices.providers as xyz | ||
|
||
All these queries return the same ``CartoDB.Positron`` TileProvider: | ||
|
||
>>> xyz._query_name("CartoDB Positron") | ||
>>> xyz._query_name("cartodbpositron") | ||
>>> xyz._query_name("cartodb-positron") | ||
>>> xyz._query_name("carto db/positron") | ||
>>> xyz._query_name("CARTO_DB_POSITRON") | ||
>>> xyz._query_name("CartoDB.Positron") | ||
|
||
""" | ||
providers = { | ||
"CartoDB Positron": basemap.CartoBasemap.Positron, | ||
"CartoDB Positron No Label": basemap.CartoBasemap.PositronNoLabels, | ||
"CartoDB Darkmatter": basemap.CartoBasemap.DarkMatter, | ||
"CartoDB Darkmatter No Label": basemap.CartoBasemap.DarkMatterNoLabels, | ||
"CartoDB Voyager": basemap.CartoBasemap.Voyager, | ||
"CartoDB Voyager No Label": basemap.CartoBasemap.VoyagerNoLabels, | ||
} | ||
xyz_flat_lower = { | ||
k.translate(QUERY_NAME_TRANSLATION).lower(): v | ||
for k, v in providers.items() | ||
} | ||
name_clean = name.translate(QUERY_NAME_TRANSLATION).lower() | ||
if name_clean in xyz_flat_lower: | ||
return xyz_flat_lower[name_clean] | ||
|
||
raise ValueError(f"No matching provider found for the query '{name}'.") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.