import warnings
from typing import Optional, Literal, Tuple
import geopandas as gpd
import matplotlib.cm as cm
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from geopandas import GeoDataFrame
from shapely import MultiPolygon
from shapely.geometry import box
from .logger import logging
from .utils.geohash import geohashes_to_gdf
from .utils.gadm_download import download_gadm_country
from .utils.polygons import build_single_multipolygon
from .adaptative_geohash_coverage import adaptive_geohash_coverage, geohash_coverage
logger = logging.getLogger(__name__)
[docs]
def plot_geohash_coverage(
country_geom: MultiPolygon,
geohash_dict: dict,
tiles_gdf: Optional[gpd.GeoDataFrame] = None,
style: Literal['adaptive', 'single'] = 'adaptive',
figsize: Tuple[float, float] = (12, 14),
save_path: Optional[str] = None,
dpi: int = 200,
draw_bbox: bool = True,
draw_country: bool = True,
label_tiles: bool = False,
color_by_level: bool = True,
cmap: str = 'viridis',
title: Optional[str] = None,
show_stats: bool = True,
alpha: float = 0.6,
edge_color: str = 'navy',
edge_width: float = 0.7,
country_color: str = 'crimson',
country_width: float = 2.5,
show_legend: bool = True
):
"""
Universal plotting function for geohash coverage results.
Works with both adaptive_geohash_coverage and geohash_coverage outputs.
Parameters
----------
country_geom : shapely.geometry
Country boundary geometry (MultiPolygon or Polygon)
geohash_dict : dict
Dictionary with level as keys and list of geohashes as values
Output from adaptive_geohash_coverage or geohash_coverage
tiles_gdf : gpd.GeoDataFrame, optional
GeoDataFrame with 'level' column. If None, will be created from geohash_dict
style : {'adaptive', 'simple', 'heatmap'}
Visualization style:
- 'adaptive': Color by level with legend (best for adaptive coverage)
- 'simple': Single color (good for single-level coverage)
- 'heatmap': Density-based coloring
figsize : tuple, default (12, 14)
Figure size in inches
save_path : str, optional
Path to save the figure
dpi : int, default 200
Resolution for saved figure
draw_bbox : bool, default True
Draw bounding box around country
draw_country : bool, default True
Draw country boundary
label_tiles : bool, default False
Add geohash labels to tiles (only for ≤100 tiles)
color_by_level : bool, default True
Color tiles by geohash level (ignored if style='simple')
cmap : str, default 'viridis'
Matplotlib colormap name
title : str, optional
Custom title. If None, auto-generates title with stats
show_stats : bool, default True
Show statistics in title
alpha : float, default 0.6
Tile transparency (0-1)
edge_color : str, default 'navy'
Tile edge color
edge_width : float, default 0.7
Tile edge width
country_color : str, default 'crimson'
Country boundary color
country_width : float, default 2.5
Country boundary width
show_legend : bool, default True
Show legend for level colors
Returns
-------
fig : matplotlib.figure.Figure
Figure object
ax : matplotlib.axes.Axes
Axes object
"""
# Create GeoDataFrame if not provided
if tiles_gdf is None:
all_geohashes = []
all_levels = []
for level, tiles in geohash_dict.items():
all_geohashes.extend(tiles)
all_levels.extend([level] * len(tiles))
if not all_geohashes:
raise ValueError("No geohashes found in geohash_dict")
tiles_gdf = geohashes_to_gdf(all_geohashes)
tiles_gdf['level'] = all_levels
fig, ax = plt.subplots(1, 1, figsize=figsize)
ax.set_aspect('equal')
lon_min, lat_min, lon_max, lat_max = country_geom.bounds
if draw_bbox:
bbox_geom = box(lon_min, lat_min, lon_max, lat_max)
bbox_gdf = gpd.GeoDataFrame({'geometry': [bbox_geom]}, crs='EPSG:4326')
bbox_gdf.plot(
ax=ax,
facecolor='none',
edgecolor='orange',
linewidth=2,
linestyle='--',
alpha=0.8,
zorder=0.5,
label='Bounding Box'
)
# Plot tiles based on style
if style == 'adaptive' and color_by_level and 'level' in tiles_gdf.columns:
_plot_adaptive_style(ax, tiles_gdf, cmap, alpha, edge_color, edge_width, show_legend)
else:
tiles_gdf.plot(
ax=ax,
facecolor='lightblue',
edgecolor=edge_color,
linewidth=edge_width,
alpha=alpha,
zorder=1,
label='Geohash Tiles'
)
# Draw country boundary
if draw_country:
country_gdf = gpd.GeoDataFrame({'geometry': [country_geom]}, crs='EPSG:4326')
country_gdf.plot(
ax=ax,
facecolor='none',
edgecolor=country_color,
linewidth=country_width,
zorder=2,
label='Country Boundary'
)
if label_tiles and len(tiles_gdf) <= 100:
_add_tile_labels(ax, tiles_gdf)
elif label_tiles and len(tiles_gdf) > 100:
warnings.warn(f"Too many tiles ({len(tiles_gdf)}) to label. Skipping labels.")
if title is None and show_stats:
title = _generate_title(tiles_gdf, geohash_dict)
if title:
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
ax.set_axis_off()
plt.tight_layout()
# Save if path provided
if save_path:
fig.savefig(save_path, dpi=dpi, bbox_inches='tight')
logger.info(f"Saved plot to: {save_path}")
return fig, ax
def _plot_adaptive_style(ax, tiles_gdf, cmap, alpha, edge_color, edge_width, show_legend):
"""
Plot tiles with color-coded levels (adaptive style).
Parameters
----------
ax : matplotlib.axes.Axes
Axes to plot on
tiles_gdf : gpd.GeoDataFrame
GeoDataFrame with 'level' column
cmap : str
Colormap name
alpha : float
Transparency level
edge_color : str
Edge color for tiles
edge_width : float
Edge width for tiles
show_legend : bool
Whether to show legend
"""
unique_levels = sorted(tiles_gdf['level'].unique())
colors = cm.get_cmap(cmap, len(unique_levels))
norm = mcolors.BoundaryNorm(
boundaries=[l - 0.5 for l in unique_levels] + [unique_levels[-1] + 0.5],
ncolors=len(unique_levels)
)
tiles_gdf.plot(
ax=ax,
column='level',
cmap=colors,
norm=norm,
edgecolor=edge_color,
linewidth=edge_width,
alpha=alpha,
zorder=1,
legend=show_legend,
legend_kwds={
'label': 'Geohash Level',
'orientation': 'vertical',
'shrink': 0.8
}
)
def _add_tile_labels(ax, tiles_gdf):
"""
Add geohash labels to tiles.
Parameters
----------
ax : matplotlib.axes.Axes
Axes to plot on
tiles_gdf : gpd.GeoDataFrame
GeoDataFrame with geohash geometries
"""
for _, row in tiles_gdf.iterrows():
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=UserWarning)
centroid = row.geometry.centroid
ax.text(
centroid.x, centroid.y,
row['geohash'],
ha='center',
va='center',
fontsize=7,
fontweight='bold',
zorder=3,
bbox=dict(
boxstyle='round,pad=0.3',
facecolor='white',
alpha=0.7,
edgecolor='gray',
linewidth=0.5
)
)
def _generate_title(tiles_gdf, geohash_dict):
"""
Generate informative title with statistics.
Parameters
----------
tiles_gdf : gpd.GeoDataFrame
GeoDataFrame of tiles
geohash_dict : dict
Dictionary with level as keys and geohashes as values
Returns
-------
str
Formatted title string
"""
total_tiles = len(tiles_gdf)
if 'level' in tiles_gdf.columns:
levels = tiles_gdf['level'].values
min_level = int(levels.min())
max_level = int(levels.max())
if min_level == max_level:
level_info = f"Level {min_level}"
else:
level_info = f"Levels {min_level}–{max_level}"
# Level distribution
level_dist = " | ".join([
f"L{level}: {len(tiles)}"
for level, tiles in sorted(geohash_dict.items())
])
return f"Geohash Coverage: {level_info}\nTotal: {total_tiles:,} tiles ({level_dist})"
else:
return f"Geohash Coverage\nTotal: {total_tiles:,} tiles"
def plot_geohash_comparison(
country_geom: MultiPolygon,
results_list: list,
labels: list,
figsize: Tuple[float, float] = (18, 6),
save_path: Optional[str] = None
):
"""
Plot multiple geohash coverage results side-by-side for comparison.
Parameters
----------
country_geom : shapely.geometry
Country boundary geometry
results_list : list of tuple
List of (geohash_dict, tiles_gdf) tuples to compare
labels : list of str
Labels for each result (e.g., ['L3', 'L4', 'L5'])
figsize : tuple
Figure size
save_path : str, optional
Path to save comparison figure
Returns
-------
fig : matplotlib.figure.Figure
axes : list of matplotlib.axes.Axes
"""
n_plots = len(results_list)
fig, axes = plt.subplots(1, n_plots, figsize=figsize)
if n_plots == 1:
axes = [axes]
for ax, (geohash_dict, tiles_gdf), label in zip(axes, results_list, labels):
# Create temporary figure for each subplot
temp_fig, temp_ax = plt.subplots(1, 1)
# Plot on temporary axis
plot_geohash_coverage(
country_geom,
geohash_dict,
tiles_gdf,
title=label,
show_legend=False
)
plt.close(temp_fig)
# Recreate on actual subplot
if tiles_gdf is None:
all_geohashes = []
all_levels = []
for level, tiles in geohash_dict.items():
all_geohashes.extend(tiles)
all_levels.extend([level] * len(tiles))
tiles_gdf = geohashes_to_gdf(all_geohashes)
tiles_gdf['level'] = all_levels
tiles_gdf.plot(ax=ax, facecolor='lightblue', edgecolor='navy',
linewidth=0.5, alpha=0.6)
country_gdf = gpd.GeoDataFrame({'geometry': [country_geom]}, crs='EPSG:4326')
country_gdf.plot(ax=ax, facecolor='none', edgecolor='crimson', linewidth=2)
ax.set_title(f"{label}\n{len(tiles_gdf)} tiles", fontsize=12, fontweight='bold')
ax.set_axis_off()
plt.tight_layout()
if save_path:
fig.savefig(save_path, dpi=200, bbox_inches='tight')
logger.info(f"Saved comparison to: {save_path}")
return fig, axes
def plot_level_statistics(
geohash_dict: dict,
figsize: Tuple[float, float] = (10, 6),
save_path: Optional[str] = None,
style: Literal['bar', 'pie'] = 'bar'
):
"""
Plot tile distribution statistics across geohash levels.
Parameters
----------
geohash_dict : dict
Dictionary with level as keys and list of geohashes as values
figsize : tuple, default (10, 6)
Figure size in inches
save_path : str, optional
Path to save the figure
style : {'bar', 'pie'}, default 'bar'
Plot style - bar chart or pie chart
Returns
-------
fig : matplotlib.figure.Figure
Figure object
ax : matplotlib.axes.Axes
Axes object
"""
levels = sorted(geohash_dict.keys())
counts = [len(geohash_dict[level]) for level in levels]
total = sum(counts) or 1 # avoid division by zero
fig, ax = plt.subplots(1, 1, figsize=figsize)
# Colormap and colors for consistency with map visuals
cmap = cm.get_cmap('viridis', len(levels))
colors = [cmap(i) for i in range(len(levels))]
labels = [f'Level {l}' for l in levels]
pct = [(count / total) * 100 for count in counts]
if style == 'bar':
bars = ax.bar(levels, counts, color=colors, alpha=0.8, edgecolor='navy')
ax.set_xlabel('Geohash Level', fontsize=12, fontweight='bold')
ax.set_ylabel('Number of Tiles', fontsize=12, fontweight='bold')
ax.set_title(f'Tile Distribution by Level\nTotal: {total:,} tiles',
fontsize=14, fontweight='bold')
ax.grid(axis='y', alpha=0.3, linestyle='--')
# show counts above bars (but percentages only in legend)
for bar, count in zip(bars, counts):
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., height,
f'{count:,}',
ha='center', va='bottom', fontsize=10, fontweight='bold')
# Build legend entries with colored squares and percentages
patches = [
mpatches.Patch(color=colors[i], label=f'{labels[i]} — {pct[i]:.1f}%')
for i in range(len(levels))
]
# place legend outside the plot to the right
ax.legend(handles=patches, title='Levels', bbox_to_anchor=(1.02, 1), loc='upper left', borderaxespad=0.)
else: # pie chart
# no labels/autopct on the slices — legend will contain level + %
wedges, _ = ax.pie(
counts,
labels=None,
colors=colors,
startangle=90,
wedgeprops=dict(edgecolor='white')
)
ax.set_title(f'Tile Distribution by Level\nTotal: {total:,} tiles',
fontsize=14, fontweight='bold')
# Legend with color square, level and percent (and optional count)
patches = [
mpatches.Patch(color=colors[i],
label=f'{labels[i]} — {pct[i]:.1f}% ({counts[i]:,})')
for i in range(len(levels))
]
ax.legend(handles=patches, title='Levels', bbox_to_anchor=(1.02, 0.5), loc='center left', borderaxespad=0.)
plt.tight_layout()
if save_path:
fig.savefig(save_path, dpi=200, bbox_inches='tight')
logger.info(f"Saved statistics to: {save_path}")
return fig, ax
[docs]
def quick_plot(country_geom: MultiPolygon,
geohash_dict: dict[str, list[str]],
tiles_gdf: GeoDataFrame=None):
"""
Quick plot with sensible defaults.
Parameters
----------
country_geom : shapely.geometry
Country boundary geometry
geohash_dict : dict
Dictionary with level as keys and list of geohashes as values
tiles_gdf : gpd.GeoDataFrame, optional
Optional GeoDataFrame with 'level' column
Returns
-------
fig : matplotlib.figure.Figure
Figure object
ax : matplotlib.axes.Axes
Axes object
"""
fig, ax = plot_geohash_coverage(country_geom, geohash_dict, tiles_gdf)
plt.show()
return fig, ax
if __name__ == '__main__':
# Load country
country_gdf = download_gadm_country("BEL", cache_dir='./gadm_cache')
country_geom = build_single_multipolygon(country_gdf)
# Test adaptive coverage
geohash_dict, tiles_gdf = adaptive_geohash_coverage(country_geom, 2, 8)
logger.info(f"Generated coverage: {geohash_dict}")
# Plot with different styles
fig1, ax1 = plot_geohash_coverage(
country_geom, geohash_dict, tiles_gdf,
style='adaptive',
save_path='adaptive_coverage.png'
)
fig2, ax2 = plot_level_statistics(
geohash_dict,
style='pie',
save_path='level_stats.png'
)
plt.show()