Skip to content

Commit 00f4993

Browse files
author
Nathaniel Henry
committed
Add place_pois(), a close_map() interactive-map helper, and the places state column (1.3.0)
1 parent 06a45c6 commit 00f4993

6 files changed

Lines changed: 158 additions & 3 deletions

File tree

CHANGELOG.md

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

3+
## 1.3.0
4+
5+
- `Client.place_pois(geoid, ...)` — every point of interest within a census
6+
place (city or town), by place GEOID. The place analog of `pois_search`; pass
7+
`type` to get, e.g., all supermarkets in a city.
8+
- `close_map(gdf, ...)` — a one-line interactive map (CARTO Positron basemap,
9+
hoverable points, or filled blocks that highlight the features meeting a
10+
criterion) for the GeoDataFrames the client returns. Built on plotly (the
11+
`closecity[maps]` extra); GDAL-free.
12+
- `places()` results now carry a `state` column (two-letter USPS abbreviation),
13+
so same-named places are distinguishable.
14+
315
## 1.2.0
416

517
- `Client()` reads the `CLOSECITY_KEY` environment variable when no `api_key` is

pyproject.toml

Lines changed: 4 additions & 2 deletions
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.2.0"
7+
version = "1.3.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"
@@ -32,10 +32,12 @@ API = "https://api.close.city"
3232
dev = ["pytest>=8", "ruff>=0.6"]
3333
# Only needed to auto-download census-block boundaries (blocks map by default).
3434
tiger = ["pygris"]
35+
# Interactive maps via close_map() (GDAL-free).
36+
maps = ["plotly>=5"]
3537
# Building the Sphinx documentation site. myst-nb runs the tutorial notebooks;
3638
# matplotlib draws the maps; pygris downloads block boundaries during execution.
3739
docs = ["sphinx>=7", "furo", "sphinx-copybutton", "myst-nb", "jupytext",
38-
"matplotlib", "pygris"]
40+
"matplotlib", "pygris", "plotly>=5"]
3941

4042
[tool.setuptools.packages.find]
4143
where = ["src"]

src/closecity/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@
1919
ServiceUnavailableError,
2020
TokensExhaustedError,
2121
)
22+
from .map import close_map
2223
from .spatial import to_geopandas
2324
from .tabular import to_pandas
2425

25-
__version__ = "1.1.0"
26+
__version__ = "1.3.0"
2627

2728
__all__ = [
2829
"Client",
2930
"Paginator",
3031
"Reply",
32+
"close_map",
3133
"to_geopandas",
3234
"to_pandas",
3335
"CloseError",

src/closecity/client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,20 @@ def place_blocks(
457457
"include_population": include_population, "limit": limit}),
458458
), geometry = True, output = output)
459459

460+
def place_pois(
461+
self, geoid: str, *, type = None, q = None, limit = None,
462+
output: str | None = None,
463+
):
464+
"""Every point of interest within a census place (city or town), by
465+
place GEOID. The place analog of `pois_search`; pass `type` to get,
466+
e.g., all supermarkets in a city. Spatial only — no travel times. A
467+
GeoDataFrame of points (a plain DataFrame under `output="tabular"`, a
468+
`Paginator` under `output="raw"`)."""
469+
return self._deliver(Paginator(
470+
self, "GET", f"/v1/places/{geoid}/pois",
471+
_clean({"type": type, "q": q, "limit": limit}),
472+
), geometry = True, output = output)
473+
460474
# -- isochrone ----------------------------------------------------------
461475

462476
def isochrone(

src/closecity/map.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""A one-line interactive map for the GeoDataFrames the client returns.
2+
3+
Built on plotly over a CARTO Positron basemap: points become bright hoverable
4+
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
6+
the GeoJSON directly, so no extra system dependency.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any
12+
13+
14+
def close_map(
15+
gdf,
16+
*,
17+
color: str = "#e8590c",
18+
highlight: Any = None,
19+
label: str = "name",
20+
size: int = 9,
21+
zoom: float = 10,
22+
opacity: float = 0.65,
23+
):
24+
"""Draw a :class:`geopandas.GeoDataFrame` from a client method as an
25+
interactive map on a CARTO Positron basemap.
26+
27+
Points (POIs, places) render as bright hoverable markers; polygons (census
28+
blocks) are filled, optionally greying the features that do not meet
29+
``highlight`` so the ones that matter stand out.
30+
31+
``highlight`` is either a boolean sequence (length ``len(gdf)``) or the name
32+
of a boolean/0-1 column. ``label`` names the hover column. Returns a plotly
33+
``Figure``.
34+
"""
35+
try:
36+
import plotly.graph_objects as go
37+
except ImportError as exc: # pragma: no cover
38+
raise ImportError(
39+
"close_map needs plotly: pip install 'closecity[maps]' or plotly"
40+
) from exc
41+
42+
g = gdf.to_crs(4326)
43+
hl = None
44+
if highlight is not None:
45+
col = g[highlight] if isinstance(highlight, str) else highlight
46+
hl = [bool(v) for v in col]
47+
48+
minx, miny, maxx, maxy = g.total_bounds
49+
center = {"lon": (minx + maxx) / 2, "lat": (miny + maxy) / 2}
50+
hover = g[label].astype(str).tolist() if label in g.columns else None
51+
geom_type = g.geom_type.iloc[0] if len(g) else "Point"
52+
53+
if "Point" in geom_type:
54+
marker_color = color if hl is None else [
55+
color if h else "#888888" for h in hl
56+
]
57+
fig = go.Figure(go.Scattermapbox(
58+
lat = g.geometry.y.tolist(), lon = g.geometry.x.tolist(),
59+
mode = "markers",
60+
marker = dict(size = size, color = marker_color),
61+
text = hover, hoverinfo = "text" if hover else "none",
62+
))
63+
else:
64+
import json
65+
66+
g = g.reset_index(drop = True)
67+
g["_id"] = [str(i) for i in range(len(g))]
68+
z = [1] * len(g) if hl is None else [int(h) for h in hl]
69+
geojson = json.loads(g[["_id", "geometry"]].to_json())
70+
colorscale = (
71+
[[0, color], [1, color]] if hl is None
72+
else [[0, "#888888"], [1, color]]
73+
)
74+
fig = go.Figure(go.Choroplethmapbox(
75+
geojson = geojson, locations = g["_id"].tolist(), z = z,
76+
featureidkey = "properties._id", colorscale = colorscale,
77+
showscale = False,
78+
marker = dict(opacity = opacity, line = dict(width = 0)),
79+
text = hover, hoverinfo = "text" if hover else "none",
80+
))
81+
82+
fig.update_layout(
83+
mapbox = dict(style = "carto-positron", zoom = zoom, center = center),
84+
margin = dict(l = 0, r = 0, t = 0, b = 0),
85+
)
86+
return fig

tests/test_map.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""close_map builds a plotly Figure from a GeoDataFrame; no network. Skipped
2+
when the optional plotting deps are absent."""
3+
4+
import pytest
5+
6+
gpd = pytest.importorskip("geopandas")
7+
pytest.importorskip("plotly")
8+
from shapely.geometry import Point, Polygon # noqa: E402
9+
10+
from closecity import close_map # noqa: E402
11+
12+
13+
def _points():
14+
return gpd.GeoDataFrame(
15+
{"name": ["A", "B"]},
16+
geometry = [Point(-71.41, 41.82), Point(-71.42, 41.83)],
17+
crs = 4326,
18+
)
19+
20+
21+
def _polys():
22+
def sq(x, y):
23+
return Polygon([(x, y), (x + 0.01, y), (x + 0.01, y + 0.01),
24+
(x, y + 0.01), (x, y)])
25+
return gpd.GeoDataFrame(
26+
{"name": ["g1", "g2"], "near": [True, False]},
27+
geometry = [sq(-71.42, 41.82), sq(-71.40, 41.82)], crs = 4326,
28+
)
29+
30+
31+
def test_close_map_points():
32+
import plotly.graph_objects as go
33+
assert isinstance(close_map(_points(), color = "#e8590c"), go.Figure)
34+
35+
36+
def test_close_map_polygons_with_highlight():
37+
import plotly.graph_objects as go
38+
fig = close_map(_polys(), color = "#2f9e44", highlight = "near")
39+
assert isinstance(fig, go.Figure)

0 commit comments

Comments
 (0)