Skip to content

Commit 7bf43cd

Browse files
author
Nathaniel Henry
committed
Add place_boundary(), layered close_map(), and full-attribute hover (1.4.0).
1 parent c204105 commit 7bf43cd

7 files changed

Lines changed: 203 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## 1.4.0
4+
5+
- `Client.place_boundary(geoid)` — the boundary polygon of a census place, as a
6+
one-row GeoDataFrame. Free (no API key); handy as a `boundary` layer for
7+
`close_map`.
8+
- `close_map` gains layered context and smarter defaults: it auto-zooms to the
9+
data, shows every attribute on hover, defaults to the ColorBrewer `YlGnBu`
10+
scale (blue = most accessible), and takes a `boundary` outline and
11+
semi-transparent `background` layers (e.g. a city boundary, commute isochrones,
12+
or a walkshed under its POIs).
13+
314
## 1.3.0
415

516
- `Client.place_pois(geoid, ...)` — every point of interest within a census

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "closecity"
7-
version = "1.3.0"
7+
version = "1.4.0"
88
description = "Python client for the Close API (api.close.city). Travel times to points of interest for every US census block."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/closecity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from .spatial import to_geopandas
2424
from .tabular import to_pandas
2525

26-
__version__ = "1.3.0"
26+
__version__ = "1.4.0"
2727

2828
__all__ = [
2929
"Client",

src/closecity/client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,17 @@ def place_pois(
471471
_clean({"type": type, "q": q, "limit": limit}),
472472
), geometry = True, output = output)
473473

474+
def place_boundary(self, geoid: str, *, output: str | None = None):
475+
"""The boundary polygon of a census place (city or town), by place GEOID.
476+
Handy as a `boundary` layer for `close_map` when mapping a city's blocks
477+
or POIs. Free (no API key). A one-row polygon GeoDataFrame (a `Reply`
478+
under `output="raw"`)."""
479+
reply = self._get(f"/v1/places/{geoid}/boundary")
480+
if self._resolve_output(output) == "raw":
481+
return reply
482+
from . import spatial
483+
return spatial.to_geopandas(reply)
484+
474485
# -- isochrone ----------------------------------------------------------
475486

476487
def isochrone(

src/closecity/map.py

Lines changed: 148 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,130 @@
22
33
Built on plotly over a CARTO Positron basemap: points become bright hoverable
44
markers, 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
68
the GeoJSON directly, so no extra system dependency.
79
"""
810

911
from __future__ import annotations
1012

13+
import math
1114
from 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+
1492
def 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

src/closecity/spatial.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ def _isochrone_gdf(data: dict, gpd, shape, crs):
4141
return gpd.GeoDataFrame(records, geometry = geoms, crs = crs)
4242

4343

44+
def _feature_gdf(data: dict, gpd, shape, crs):
45+
"""A single GeoJSON Feature (e.g. a place boundary) as a one-row GeoDataFrame,
46+
its ``properties`` folded into columns."""
47+
props = dict(data.get("properties") or {})
48+
return gpd.GeoDataFrame([props], geometry = [shape(data["geometry"])], crs = crs)
49+
50+
4451
def _points_gdf(rows: list[dict], gpd, Point, crs):
4552
geoms = [
4653
Point(r["lon"], r["lat"])
@@ -120,6 +127,11 @@ def to_geopandas(
120127
tabular._stamp_attrs(gdf, data, meta)
121128
return gdf
122129

130+
if isinstance(data, dict) and data.get("type") == "Feature" and data.get("geometry"):
131+
gdf = _feature_gdf(data, gpd, shape, crs)
132+
tabular._stamp_attrs(gdf, data, meta)
133+
return gdf
134+
123135
rows, envelope = tabular._rows_and_envelope(data)
124136
first = rows[0] if rows else None
125137
if first is not None and "lat" in first and "lon" in first:

tests/test_map.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,22 @@ def test_close_map_polygons_with_highlight():
3737
import plotly.graph_objects as go
3838
fig = close_map(_polys(), color = "#2f9e44", highlight = "near")
3939
assert isinstance(fig, go.Figure)
40+
41+
42+
def test_close_map_shades_by_fill():
43+
import plotly.graph_objects as go
44+
polys = _polys()
45+
polys["score"] = [2, 5]
46+
fig = close_map(polys, fill = "score", palette = "YlGnBu")
47+
assert isinstance(fig, go.Figure)
48+
49+
50+
def test_close_map_boundary_and_background_layers():
51+
import plotly.graph_objects as go
52+
polys = _polys()
53+
boundary = polys.geometry.union_all() # a bare shapely geometry
54+
fig = close_map(_points(), boundary = boundary,
55+
background = [polys], background_color = "#3b6fb0")
56+
# one background fill + one boundary outline + the point layer
57+
assert isinstance(fig, go.Figure)
58+
assert len(fig.data) == 3

0 commit comments

Comments
 (0)