diff --git a/assets/geohash-layer.jpeg b/assets/geohash-layer.jpeg
new file mode 100644
index 00000000..78dff7e6
Binary files /dev/null and b/assets/geohash-layer.jpeg differ
diff --git a/examples/geohash-layer.ipynb b/examples/geohash-layer.ipynb
new file mode 100644
index 00000000..50dda09b
--- /dev/null
+++ b/examples/geohash-layer.ipynb
@@ -0,0 +1,307 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "9740523b",
+ "metadata": {},
+ "source": [
+ "This example aggregates public NYC 311 requests into geohash cells and renders their density with `GeohashLayer`. Brighter cells received fewer reports; darker red cells received more.\n",
+ "\n",
+ "The data comes from [NYC Open Data's 311 Service Requests dataset](https://data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2020-to-Present/erm2-nwe9/about_data).\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7cb2e3c6",
+ "metadata": {},
+ "source": [
+ "## Dependencies\n",
+ "\n",
+ "Install `uv` and then launch this notebook with:\n",
+ "\n",
+ "```bash\n",
+ "uvx juv run examples/geohash-layer.ipynb"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c2375ded",
+ "metadata": {
+ "jupyter": {
+ "source_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "# /// script\n",
+ "# requires-python = \">=3.12\"\n",
+ "# dependencies = [\n",
+ "# \"geohash2>=1.1\",\n",
+ "# \"lonboard>=0.16.0\",\n",
+ "# \"matplotlib>=3.11.1\",\n",
+ "# \"palettable>=3.3.3\",\n",
+ "# \"pandas>=3.0.5\",\n",
+ "# \"pyarrow>=25.0.1\",\n",
+ "# \"pygeohash>=3.3.1\",\n",
+ "# \"requests>=2.34.2\",\n",
+ "# ]\n",
+ "# ///"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b81dd458",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import pygeohash\n",
+ "import requests\n",
+ "from matplotlib.colors import LogNorm\n",
+ "from palettable.colorbrewer.sequential import YlOrRd_9\n",
+ "\n",
+ "from lonboard import GeohashLayer, Map\n",
+ "from lonboard.basemap import CartoStyle, MaplibreBasemap\n",
+ "from lonboard.colormap import apply_continuous_cmap\n",
+ "from lonboard.view_state import MapViewState"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5d4db92c",
+ "metadata": {},
+ "source": [
+ "## Download and aggregate the data"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "fdc67d5d",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " geohash | \n",
+ " request_count | \n",
+ " primary_borough | \n",
+ " top_complaint | \n",
+ " top_complaint_count | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " dr5nqr | \n",
+ " 1 | \n",
+ " STATEN ISLAND | \n",
+ " Noise - Commercial | \n",
+ " 1 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " dr5nqw | \n",
+ " 1 | \n",
+ " STATEN ISLAND | \n",
+ " Noise - Residential | \n",
+ " 1 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " dr5nqx | \n",
+ " 1 | \n",
+ " STATEN ISLAND | \n",
+ " Illegal Parking | \n",
+ " 1 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " dr5nqz | \n",
+ " 1 | \n",
+ " STATEN ISLAND | \n",
+ " Damaged Tree | \n",
+ " 1 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " dr5nw9 | \n",
+ " 1 | \n",
+ " STATEN ISLAND | \n",
+ " Building/Use | \n",
+ " 1 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " geohash request_count primary_borough top_complaint \\\n",
+ "0 dr5nqr 1 STATEN ISLAND Noise - Commercial \n",
+ "1 dr5nqw 1 STATEN ISLAND Noise - Residential \n",
+ "2 dr5nqx 1 STATEN ISLAND Illegal Parking \n",
+ "3 dr5nqz 1 STATEN ISLAND Damaged Tree \n",
+ "4 dr5nw9 1 STATEN ISLAND Building/Use \n",
+ "\n",
+ " top_complaint_count \n",
+ "0 1 \n",
+ "1 1 \n",
+ "2 1 \n",
+ "3 1 \n",
+ "4 1 "
+ ]
+ },
+ "execution_count": 24,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "API_URL = \"https://data.cityofnewyork.us/resource/erm2-nwe9.json\"\n",
+ "PARAMS = {\n",
+ " \"$select\": \"latitude, longitude, complaint_type, borough\",\n",
+ " \"$where\": (\n",
+ " \"created_date >= '2025-01-01T00:00:00' AND \"\n",
+ " \"created_date < '2025-01-08T00:00:00' AND \"\n",
+ " \"latitude IS NOT NULL AND longitude IS NOT NULL\"\n",
+ " ),\n",
+ " \"$order\": \"created_date ASC\",\n",
+ " \"$limit\": 10_000,\n",
+ "}\n",
+ "\n",
+ "try:\n",
+ " response = requests.get(API_URL, params=PARAMS, timeout=30)\n",
+ " response.raise_for_status()\n",
+ "except requests.RequestException as exc:\n",
+ " raise RuntimeError(\n",
+ " \"Unable to download NYC 311 data. Please try again later.\",\n",
+ " ) from exc\n",
+ "\n",
+ "requests_df = pd.DataFrame(response.json())\n",
+ "if requests_df.empty:\n",
+ " raise RuntimeError(\"The NYC 311 query returned no requests.\")\n",
+ "\n",
+ "requests_df = requests_df.astype({\"latitude\": \"float64\", \"longitude\": \"float64\"})\n",
+ "requests_df[\"geohash\"] = [\n",
+ " pygeohash.encode(latitude, longitude, precision=6)\n",
+ " for latitude, longitude in zip(\n",
+ " requests_df[\"latitude\"],\n",
+ " requests_df[\"longitude\"],\n",
+ " strict=True,\n",
+ " )\n",
+ "]\n",
+ "\n",
+ "\n",
+ "def aggregate_cell_data(df: pd.DataFrame) -> pd.Series:\n",
+ " top_complaint = (\n",
+ " df[\"complaint_type\"].mode()[0] if not df[\"complaint_type\"].empty else \"N/A\"\n",
+ " )\n",
+ " return pd.Series(\n",
+ " {\n",
+ " \"request_count\": len(df),\n",
+ " \"primary_borough\": df[\"borough\"].mode()[0]\n",
+ " if \"borough\" in df.columns\n",
+ " else \"Unspecified\",\n",
+ " \"top_complaint\": top_complaint,\n",
+ " \"top_complaint_count\": (df[\"complaint_type\"] == top_complaint).sum(),\n",
+ " },\n",
+ " )\n",
+ "\n",
+ "\n",
+ "cells = requests_df.groupby(\"geohash\", as_index=False).apply(aggregate_cell_data)\n",
+ "cells.head()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d00c9dc4",
+ "metadata": {},
+ "source": [
+ "## Explore neighborhood-scale demand\n",
+ "Hover over a cell to inspect its geohash and request count."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7be0a8a8",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "6c77d7957c374aa6a283c2abd76b9eaf",
+ "version_major": 2,
+ "version_minor": 0
+ },
+ "text/plain": [
+ "VBox(children=(, VBox(children=(ErrorOutput(), ErrorOutput(), ErrorOu…"
+ ]
+ },
+ "execution_count": 25,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "color_scale = LogNorm(\n",
+ " vmin=cells[\"request_count\"].min(),\n",
+ " vmax=cells[\"request_count\"].max(),\n",
+ ")\n",
+ "\n",
+ "layer = GeohashLayer.from_pandas(\n",
+ " cells,\n",
+ " get_geohash=cells[\"geohash\"],\n",
+ " get_fill_color=apply_continuous_cmap(\n",
+ " color_scale(cells[\"request_count\"]),\n",
+ " YlOrRd_9,\n",
+ " alpha=0.88,\n",
+ " ),\n",
+ " get_line_color=[35, 12, 8, 160],\n",
+ " line_width_min_pixels=0.75,\n",
+ " pickable=True,\n",
+ ")\n",
+ "\n",
+ "map_ = Map(\n",
+ " layer,\n",
+ " basemap=MaplibreBasemap(style=CartoStyle.DarkMatter),\n",
+ " view_state=MapViewState(longitude=-73.96, latitude=40.73, zoom=10),\n",
+ " height=700,\n",
+ " show_tooltip=True,\n",
+ " show_side_panel=False,\n",
+ " picking_radius=5,\n",
+ ")\n",
+ "map_"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/index.md b/examples/index.md
index fa0cec8d..85f3889c 100644
--- a/examples/index.md
+++ b/examples/index.md
@@ -12,6 +12,7 @@
- [Global boundaries ](../examples/global-boundaries) using [`PolygonLayer`][lonboard.PolygonLayer]
- [Raster PMTiles ](../examples/raster-pmtiles) using [`RasterLayer`][lonboard.RasterLayer]
- [H3 Population Data ](../examples/kontur_pop) using [`H3HexagonLayer`][lonboard.H3HexagonLayer]
+- [NYC 311 Data ](../examples/geohash-layer) using [`GeohashLayer`][lonboard.GeohashLayer]
- [U.S. County-to-County Migration ](../examples/migration) using [`ArcLayer`][lonboard.ArcLayer] and [`BrushingExtension`][lonboard.layer_extension.BrushingExtension]
- [Scatterplot with GPU data filtering ](../examples/data-filter-extension) using [`ScatterplotLayer`][lonboard.ScatterplotLayer] and [`DataFilterExtension`][lonboard.layer_extension.DataFilterExtension]
- [Categorical Filtering ](../examples/data-filter-extension-categorical) using [`ScatterplotLayer`][lonboard.ScatterplotLayer] and [`DataFilterExtension`][lonboard.layer_extension.DataFilterExtension]
diff --git a/mkdocs.yml b/mkdocs.yml
index e730ed66..4516bcae 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -55,6 +55,7 @@ nav:
- examples/interleaved-labels.ipynb
- examples/linked-maps.ipynb
- examples/clicked-point.ipynb
+ - examples/geohash-layer.ipynb
- NYC Taxi Trips: examples/marimo/nyc_taxi_trips.md
- Integrations:
- examples/duckdb.ipynb