22
33Built on plotly over a CARTO Positron basemap: points become bright hoverable
44markers, block polygons are filled and can highlight the features meeting a
5- criterion. plotly is GDAL-free (unlike leaflet/mapgl in R), and geopandas emits
5+ criterion. The view auto-zooms to the data, hover shows every attribute, and a
6+ city ``boundary`` outline or semi-transparent ``background`` layers can sit
7+ underneath. plotly is GDAL-free (unlike leaflet/mapgl in R), and geopandas emits
68the GeoJSON directly, so no extra system dependency.
79"""
810
911from __future__ import annotations
1012
13+ import math
1114from typing import Any
1215
1316
17+ def _rgba (hexcolor : str , alpha : float ) -> str :
18+ h = hexcolor .lstrip ("#" )
19+ r , g , b = int (h [0 :2 ], 16 ), int (h [2 :4 ], 16 ), int (h [4 :6 ], 16 )
20+ return f"rgba({ r } , { g } , { b } , { alpha } )"
21+
22+
23+ def _as_layer (obj ):
24+ """Normalise a boundary/background argument to a GeoDataFrame, accepting a
25+ GeoDataFrame, a GeoSeries, or a bare shapely geometry (assumed WGS84)."""
26+ import geopandas as gpd
27+
28+ if isinstance (obj , gpd .GeoDataFrame ):
29+ return obj
30+ if isinstance (obj , gpd .GeoSeries ):
31+ return gpd .GeoDataFrame (geometry = obj )
32+ return gpd .GeoDataFrame (geometry = [obj ], crs = "EPSG:4326" )
33+
34+
35+ def _bounds (g ):
36+ minx , miny , maxx , maxy = g .total_bounds
37+ return [minx , miny , maxx , maxy ]
38+
39+
40+ def _center_zoom (bounds_list , buffer ):
41+ """A CARTO-Positron centre and zoom that frame every layer, plus a margin."""
42+ minx = min (b [0 ] for b in bounds_list )
43+ miny = min (b [1 ] for b in bounds_list )
44+ maxx = max (b [2 ] for b in bounds_list )
45+ maxy = max (b [3 ] for b in bounds_list )
46+ center = {"lon" : (minx + maxx ) / 2 , "lat" : (miny + maxy ) / 2 }
47+ span = max (maxx - minx , maxy - miny , 0.005 ) * (1 + 2 * buffer )
48+ zoom = max (1.0 , min (16.0 , math .log2 (360 / span ) - 0.5 ))
49+ return center , zoom
50+
51+
52+ def _polygon_lines (g ):
53+ """Exterior rings of every (multi)polygon as one lon/lat path, ``None``-split."""
54+ lons : list = []
55+ lats : list = []
56+ for geom in g .geometry :
57+ if geom is None or geom .is_empty :
58+ continue
59+ polys = list (geom .geoms ) if geom .geom_type .startswith ("Multi" ) else [geom ]
60+ for poly in polys :
61+ x , y = poly .exterior .xy
62+ lons += list (x ) + [None ]
63+ lats += list (y ) + [None ]
64+ return lons , lats
65+
66+
67+ def _fmt (v ):
68+ if isinstance (v , float ):
69+ return f"{ v :.5f} " .rstrip ("0" ).rstrip ("." )
70+ if isinstance (v , dict ):
71+ return ", " .join (str (x ) for x in v .values () if x is not None )
72+ if isinstance (v , (list , tuple )):
73+ return ", " .join (str (x ) for x in v )
74+ return str (v )
75+
76+
77+ def _hover (g , label ):
78+ """One hover string per row: every non-geometry attribute, ``label`` first."""
79+ cols = [c for c in g .columns if c != g .geometry .name ]
80+ lead = [label ] if label and label in cols else []
81+ ordered = lead + [c for c in cols if c not in lead ]
82+ out = []
83+ for _ , row in g .iterrows ():
84+ parts = [
85+ (f"<b>{ _fmt (row [c ])} </b>" if c == label else f"{ c } : { _fmt (row [c ])} " )
86+ for c in ordered
87+ ]
88+ out .append ("<br>" .join (parts ))
89+ return out
90+
91+
1492def close_map (
1593 gdf ,
1694 * ,
1795 color : str = "#e8590c" ,
1896 highlight : Any = None ,
1997 fill : Any = None ,
20- palette : str = "Viridis " ,
21- reverse : bool = True ,
22- label : str = "name" ,
98+ palette : str = "YlGnBu " ,
99+ reverse : bool = False ,
100+ label : str | None = None ,
23101 size : int = 9 ,
24- zoom : float = 10 ,
25102 opacity : float = 0.65 ,
103+ boundary = None ,
104+ background = None ,
105+ background_color : Any = "#3b6fb0" ,
106+ background_opacity : float = 0.3 ,
107+ buffer : float = 0.15 ,
26108):
27109 """Draw a :class:`geopandas.GeoDataFrame` from a client method as an
28110 interactive map on a CARTO Positron basemap.
29111
30112 Points (POIs, places) render as bright hoverable markers; polygons (census
31113 blocks) are filled, optionally greying the features that do not meet
32- ``highlight`` so the ones that matter stand out.
114+ ``highlight`` so the ones that matter stand out. The view auto-zooms to fit
115+ every layer with a ``buffer`` margin, and hover shows all attributes.
116+
117+ ``highlight`` is a boolean sequence or the name of a boolean/0-1 column.
118+ ``fill`` is the name of a numeric column to shade features by, on a
119+ continuous ColorBrewer scale with a legend (use it OR ``highlight``);
120+ ``palette`` (default ``"YlGnBu"``) and ``reverse`` control that scale.
121+ ``reverse=False`` puts the blue end at the high values; pass ``reverse=True``
122+ when high values mean *less* access, e.g. travel time.
33123
34- ``highlight `` is either a boolean sequence (length ``len(gdf)``) or the name
35- of a boolean/0-1 column . ``fill `` is the name of a numeric column to shade
36- features by, on a continuous scale with a legend (use it OR ``highlight``);
37- ``palette`` and ``reverse`` control that scale. ``label `` names the hover
38- column . Returns a plotly ``Figure``.
124+ ``boundary `` is a polygon GeoDataFrame drawn as a grey outline underneath
125+ (e.g. a city boundary from ``place_boundary``) . ``background `` is one polygon
126+ GeoDataFrame, or a list of them, drawn as semi-transparent fills underneath
127+ (e.g. commute isochrones or a walkshed); ``background_color `` recycles across
128+ them . Returns a plotly ``Figure``.
39129 """
40130 try :
41131 import plotly .graph_objects as go
@@ -45,6 +135,39 @@ def close_map(
45135 ) from exc
46136
47137 g = gdf .to_crs (4326 )
138+ traces = []
139+ bounds = [_bounds (g )]
140+
141+ # Semi-transparent background fills, drawn first (underneath everything).
142+ if background is not None :
143+ layers = background if isinstance (background , (list , tuple )) else [background ]
144+ colors = (background_color if isinstance (background_color , (list , tuple ))
145+ else [background_color ])
146+ for i , layer in enumerate (layers ):
147+ lg = _as_layer (layer ).to_crs (4326 )
148+ bounds .append (_bounds (lg ))
149+ lons , lats = _polygon_lines (lg )
150+ col = colors [i % len (colors )]
151+ traces .append (go .Scattermapbox (
152+ lon = lons , lat = lats , mode = "lines" , fill = "toself" ,
153+ fillcolor = _rgba (col , background_opacity ),
154+ line = {"color" : _rgba (col , min (1.0 , background_opacity + 0.3 )),
155+ "width" : 1 },
156+ hoverinfo = "skip" , showlegend = False ,
157+ ))
158+
159+ # City-boundary outline (no fill), above the background fills.
160+ if boundary is not None :
161+ bg = _as_layer (boundary ).to_crs (4326 )
162+ bounds .append (_bounds (bg ))
163+ lons , lats = _polygon_lines (bg )
164+ traces .append (go .Scattermapbox (
165+ lon = lons , lat = lats , mode = "lines" ,
166+ line = {"color" : "#666666" , "width" : 1.5 },
167+ hoverinfo = "skip" , showlegend = False ,
168+ ))
169+
170+ hover = _hover (g , label )
48171 hl = None
49172 if highlight is not None :
50173 col = g [highlight ] if isinstance (highlight , str ) else highlight
@@ -53,11 +176,7 @@ def close_map(
53176 if fill is not None and isinstance (fill , str ) and fill in g .columns :
54177 fv = g [fill ].astype (float ).tolist ()
55178
56- minx , miny , maxx , maxy = g .total_bounds
57- center = {"lon" : (minx + maxx ) / 2 , "lat" : (miny + maxy ) / 2 }
58- hover = g [label ].astype (str ).tolist () if label in g .columns else None
59179 geom_type = g .geom_type .iloc [0 ] if len (g ) else "Point"
60-
61180 if "Point" in geom_type :
62181 if fv is not None :
63182 marker = {"size" : size , "color" : fv , "colorscale" : palette ,
@@ -68,37 +187,39 @@ def close_map(
68187 color if h else "#888888" for h in hl
69188 ]
70189 marker = {"size" : size , "color" : marker_color }
71- fig = go . Figure (go .Scattermapbox (
72- lat = g .geometry .y .tolist (), lon = g .geometry .x .tolist (),
73- mode = "markers" , marker = marker ,
74- text = hover , hoverinfo = "text" if hover else "none" ,
190+ traces . append (go .Scattermapbox (
191+ lat = g .geometry .y .tolist (), lon = g .geometry .x .tolist (),
192+ mode = "markers" , marker = marker ,
193+ text = hover , hoverinfo = "text" , showlegend = False ,
75194 ))
76195 else :
77196 import json
78197
79- g = g .reset_index (drop = True )
198+ g = g .reset_index (drop = True )
80199 g ["_id" ] = [str (i ) for i in range (len (g ))]
81200 geojson = json .loads (g [["_id" , "geometry" ]].to_json ())
82201 common = {
83202 "geojson" : geojson , "locations" : g ["_id" ].tolist (),
84203 "featureidkey" : "properties._id" ,
85204 "marker" : {"opacity" : opacity , "line" : {"width" : 0 }},
86- "text" : hover , "hoverinfo" : "text" if hover else "none" ,
205+ "text" : hover , "hoverinfo" : "text" , "showlegend" : False ,
87206 }
88207 if fv is not None :
89208 trace = go .Choroplethmapbox (
90- z = fv , colorscale = palette , reversescale = reverse ,
91- showscale = True , colorbar = {"title" : fill }, ** common )
209+ z = fv , colorscale = palette , reversescale = reverse ,
210+ showscale = True , colorbar = {"title" : fill }, ** common )
92211 else :
93212 z = [1 ] * len (g ) if hl is None else [int (h ) for h in hl ]
94213 colorscale = ([[0 , color ], [1 , color ]] if hl is None
95214 else [[0 , "#888888" ], [1 , color ]])
96215 trace = go .Choroplethmapbox (
97- z = z , colorscale = colorscale , showscale = False , ** common )
98- fig = go . Figure (trace )
216+ z = z , colorscale = colorscale , showscale = False , ** common )
217+ traces . append (trace )
99218
219+ center , zoom = _center_zoom (bounds , buffer )
220+ fig = go .Figure (traces )
100221 fig .update_layout (
101- mapbox = {"style" : "carto-positron" , "zoom" : zoom , "center" : center },
102- margin = {"l" : 0 , "r" : 0 , "t" : 0 , "b" : 0 },
222+ mapbox = {"style" : "carto-positron" , "zoom" : zoom , "center" : center },
223+ margin = {"l" : 0 , "r" : 0 , "t" : 0 , "b" : 0 },
103224 )
104225 return fig
0 commit comments