forked from datamade/code-challenge-v2
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRestaurantPermitMap.js
More file actions
180 lines (157 loc) · 6.02 KB
/
Copy pathRestaurantPermitMap.js
File metadata and controls
180 lines (157 loc) · 6.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import React, { useEffect, useState } from "react"
import { MapContainer, TileLayer, GeoJSON } from "react-leaflet"
import "leaflet/dist/leaflet.css"
import RAW_COMMUNITY_AREAS from "../../../data/raw/community-areas.geojson"
function YearSelect({ filterVal, setFilterVal }) {
// Filter by the permit issue year for each restaurant
const startYear = 2026
const years = [...Array(11).keys()].map((increment) => {
return startYear - increment
})
const options = years.map((year) => {
return (
<option value={year} key={year}>
{year}
</option>
)
})
return (
<>
<label htmlFor="yearSelect" className="fs-3">
Filter by year:{" "}
</label>
<select
id="yearSelect"
className="form-select form-select-lg mb-3"
value={filterVal}
onChange={(e) => setFilterVal(e.target.value)}
>
{options}
</select>
</>
)
}
export default function RestaurantPermitMap() {
// Sequential blue palette (ColorBrewer 4-step) — accessible for color-blind users
const communityAreaColors = ["#eff3ff", "#bdd7e7", "#6baed6", "#2171b5"]
const [currentYearData, setCurrentYearData] = useState([])
const [year, setYear] = useState(2026)
const [loading, setLoading] = useState(false)
const yearlyDataEndpoint = `/map-data/?year=${year}`
// Re-runs whenever yearlyDataEndpoint changes (i.e. when year state updates)
useEffect(() => {
setLoading(true)
fetch(yearlyDataEndpoint)
.then((res) => res.json())
.then((data) => {
setCurrentYearData(data)
})
.catch((err) => console.error("Failed to load map data", err))
.finally(() => setLoading(false))
}, [yearlyDataEndpoint])
const counts = currentYearData.map((area) => area.num_permits)
const totalPermits = counts.reduce((a, b) => a + b, 0)
// Guard against empty array: Math.max() with no args returns -Infinity
const maxNumPermits = Math.max(...counts, 0)
const topArea = currentYearData.find((area) => area.num_permits === maxNumPermits)?.name ?? null
// Pre-build lookup map so setAreaInteraction doesn't call .find() per feature
const permitsByName = Object.fromEntries(
currentYearData.map((area) => [area.name.toUpperCase(), area.num_permits])
)
function getColor(percentageOfPermits) {
// Dividing by maxNumPermits (not total) spreads the color range across
// the busiest area each year, so sparse years still show meaningful variation.
if (percentageOfPermits > 0.75) return communityAreaColors[3]
if (percentageOfPermits > 0.5) return communityAreaColors[2]
if (percentageOfPermits > 0.25) return communityAreaColors[1]
return communityAreaColors[0]
}
function toTitleCase(str) {
return str.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
function setAreaInteraction(feature, layer) {
const areaName = feature.properties.community
// ?? 0 fallback handles areas present in GeoJSON but missing from the API response
const numPermits = permitsByName[areaName.toUpperCase()] ?? 0
const percentage = maxNumPermits > 0 ? numPermits / maxNumPermits : 0
// Share of the year's total permits — shown in the popup for context
const share =
totalPermits > 0 ? ((numPermits / totalPermits) * 100).toFixed(1) : 0
layer.setStyle({ fillColor: getColor(percentage), fillOpacity: 0.7, weight: 1 })
// autoClose: false and closeOnClick: false allow multiple popups to stay open simultaneously
layer.bindPopup(
`<b>${areaName}</b><br>Restaurant Permits: ${numPermits.toLocaleString()} (${share}% of yearly total)<br>Year: ${year}`,
{ autoClose: false, closeOnClick: false }
)
let pinned = false
layer.on("mouseover", () => {
layer.setStyle({ weight: 4, color: "#ff7f00" })
layer.openPopup()
})
layer.on("mouseout", () => {
layer.setStyle({ weight: 1, color: "#3388ff" })
// Only close the popup if the area hasn't been double-clicked to pin it
if (!pinned) layer.closePopup()
})
// Single click on the area pins/unpins the popup
layer.on("click", () => {
pinned = !pinned
if (!pinned) layer.closePopup()
})
// Clicking the popup itself also dismisses it
layer.on("popupopen", (e) => {
e.popup.getElement().addEventListener("click", () => {
pinned = false
layer.closePopup()
})
})
// Reset pinned state if popup is closed via the X button
layer.on("popupclose", () => {
pinned = false
})
}
return (
<>
<YearSelect filterVal={year} setFilterVal={setYear} />
<p className="fs-4">
Restaurant permits issued this year: {totalPermits.toLocaleString()}
</p>
<p className="fs-4">
Maximum number of restaurant permits in a single area:{" "}
{maxNumPermits.toLocaleString()}
</p>
{topArea && (
<p className="fs-4">
Area with the most permits: {topArea ? toTitleCase(topArea) : ""}
</p>
)}
<MapContainer
id="restaurant-map"
center={[41.88, -87.62]}
zoom={10}
>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png"
/>
{/* key change forces a full re-render so onEachFeature re-runs with fresh data */}
{currentYearData.length > 0 ? (
<GeoJSON
data={RAW_COMMUNITY_AREAS}
onEachFeature={setAreaInteraction}
key={`${year}-${maxNumPermits}`}
/>
) : null}
</MapContainer>
{loading && <p className="text-muted">Loading...</p>}
<div className="d-flex justify-content-between mt-2">
<p className="text-muted mb-0">
Map shading: darker blue indicates more restaurant permits issued in that area.
</p>
<p className="text-muted mb-0">
Hover over an area to see details. Click to pin the popup — click again to dismiss.
</p>
</div>
</>
)
}