-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathindex.html
371 lines (335 loc) · 16.9 KB
/
index.html
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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Map of Cumulative Cases | San Francisco</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1" />
<script src="https://api.mapbox.com/mapbox-gl-js/v1.11.1/mapbox-gl.js"></script>
<script src='https://api.mapbox.com/mapbox.js/plugins/turf/v2.0.2/turf.min.js'></script>
<script src='https://unpkg.com/[email protected]/dist/simple-statistics.min.js'></script>
<script src="https://kit.fontawesome.com/cf7d3a45b6.js" crossorigin="anonymous"></script>
<!-- Geocoder plugin -->
<script
src='https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v4.5.1/mapbox-gl-geocoder.min.js'></script>
<!-- Styles -->
<link href="https://api.mapbox.com/mapbox-gl-js/v1.11.1/mapbox-gl.css" rel="stylesheet" />
<link rel='stylesheet'
href='https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v4.5.1/mapbox-gl-geocoder.css'
type='text/css' />
<link rel="stylesheet" media="all" href="//fonts.googleapis.com/css?family=Rubik:300,400,500,700">
<link rel="stylesheet" media="all" href="index.css">
</head>
<body>
<section id="visual">
<section id="sidebar">
<div class="sidebar__heading sidebar__heading-blue">
<h1 id="dynamic_title">Map of Cumulative Cases</h1>
Click and zoom in on the map for more details.
</div>
<div class="sidebar__content">
<div id="dynamic_content">
<p>The map shows the rate of COVID-19 cases calculated as the total number of cases per 10,000
residents (all cases since testing began on March 2nd). The map initially displays the rates by
neighborhood, zoom in to see rates for smaller areas (census tracts).</p>
<h2>Areas with fewer than twenty cases</h2>
<p>Because of variability in the data, the rate of cases is not calculated for areas with fewer than twenty
cases. These areas are shaded with a grey dotted pattern.</p>
<h2>Privacy protections</h2>
<p>To protect the privacy of residents, the City does not disclose an area's number of
confirmed cases if it is less than ten. In addition, no case data is displayed for areas
with resident populations of fewer than 1,000 people. These areas are dark grey on the map.</p>
<h2>Map updates reflect underlying data</h2>
<p>This map displays cumulative case rates to highlight differences in rates across the City. The map legend and colors are based on the underlying distribution of the data (ck-means method). Because the data changes every day, the map legend for both neighborhood and census tracts may adjust as the data distributions change.</p>
</div>
</div>
</section>
<div id="map"></div>
<div class="sfgov-cta-button__container">
<button id="fit" style="display: none;">Zoom to San Francisco</button>
</div>
<div class="map-overlay" id="legend">
<h2>Cases per 10,000 residents</h2>
<div class='legend-note'>Color ramp changes to fit data <i class="fas fa-info-circle" data-tippy-content="This map displays cumulative case rates to highlight differences in rates across the City. The map legend and colors are based on the underlying distribution of the data (ck-means method). Because the data changes every day, the map legend for both neighborhood and census tracts may adjust as the data distributions change."></i></div>
<div id="legendbins"></div>
<div class='legend-other'>
<div><span class="legend-key legend-key__no-rate"></span><span>Case counts too small (<20)</span></div>
<div id="updated"></div>
</div>
</div>
</section>
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script src="https://unpkg.com/tippy.js@6"></script>
<!-- Script for displaying a map and updating the sidebar with data from map -->
<script>
// specify initial layers and source
var geomLayer = 'neighborhoods-data';
var outlineLayers = ['neighborhoods-outline', 'census-tracts-outline'];
var referenceLayer = 'neighborhoods-reference';
var geomSource = 'covid19_cases-1eby22';
// zoom threshold where first boundary switches to second - should match layer filters
var zoomThreshold = 13;
var initialTitle = document.getElementById('dynamic_title').innerHTML;
var initialText = document.getElementById('dynamic_content').innerHTML;
mapboxgl.accessToken = 'pk.eyJ1IjoiZGF0YXNmIiwiYSI6ImNrOXVzcjQ5czA1Nmkza3BrZTJ4eGg5bmgifQ.wOYqgXQmhOGDhsH3jNyP9A';
var sfbounds = [-122.51517176651764, 37.70717556431859, -122.35761922678324, 37.84150802989059];
var map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/datasf/ckdelmmp41vyg1il9j642lw02',
zoom: 11.78,
center: [-122.43639549665045, 37.774372306273605],
maxBounds: [-122.58758917585335, 37.6769123913688, -122.2965263460861, 37.86740935582834]
});
map.on("style.load", function () {
var dataUrl = "https://data.sfgov.org/resource/tpyr-dvnc.geojson?$select=id,area_type,count,rate,deaths,acs_population,last_updated_at,multipolygon&$where=area_type='Census Tract'+or+area_type='Analysis Neighborhood'";
//get breaks
let censusValues = fetchDataRange("https://data.sfgov.org/resource/tpyr-dvnc.json?$select=rate&$where=area_type='Census Tract'+and+rate+is+not+null&$order=rate")
.then((values) => ss.ckmeans(values,5))
let neighborhoodValues = fetchDataRange("https://data.sfgov.org/resource/tpyr-dvnc.json?$select=rate&$where=area_type='Analysis Neighborhood'+and+rate+is+not+null&$order=rate")
.then((values) => ss.ckmeans(values,5))
let lastUpdated = fetch("https://data.sfgov.org/resource/tpyr-dvnc.json?$select=last_updated_at&$limit=1")
.then(((resp) => resp.json()))
.then((data) => data[0].last_updated_at)
Promise.all([censusValues, neighborhoodValues, lastUpdated]).then(([censusVals, neighborhoodVals, lastUpdated]) => {
map.addSource("geojson", {
type: "geojson",
data: dataUrl
})
// generate breaks
let neighborhoodBreaks = neighborhoodVals.reduce((acc, val) => acc.concat(Math.ceil(val[val.length - 1])), [])
let censusBreaks = censusVals.reduce((acc, val) => acc.concat(Math.ceil(val[val.length - 1])), [])
// set breaks
setDynamicBreaks(neighborhoodBreaks,'neighborhoods-data');
setDynamicBreaks(censusBreaks,'census-tracts-data');
addDynamicLayerStyles();
addInteraction();
let refreshDate = new Date(lastUpdated)
// data lagged by 3 days, so need to subtract from the updated date which reflects when the script last ran which is the night before the morning push to open data
let updatedDate = refreshDate.setDate(refreshDate.getDate() - 2)
updated.innerHTML = "Data through " + Intl.DateTimeFormat('en-US').format(updatedDate) + ", lagged 3 days. <a href='https://data.sfgov.org/stories/s/nudz-9tg2' style='color:white;'>Learn more about how this data is updated and validated daily.</a>";
createLegend(geomLayer);
tippy('[data-tippy-content]', {
placement: 'right'
});
})
/* Remove rotate from control
* Disable drag rotate
* Disable touch zoom rotate
*/
//initialize geocoder
var geocoder = new MapboxGeocoder({
accessToken: mapboxgl.accessToken, // Set the access token
mapboxgl: mapboxgl, // Set the mapbox-gl instance
marker: true, // Use the geocoder's default marker style
placeholder: 'Search for places in SF',
bbox: [-122.6001, 37.6403, -122.2818, 37.8481] // Set the bounding box coordinates
});
// add map controls, navigation, fullscreen, geocoder
map.addControl(new mapboxgl.NavigationControl({
showCompass: false
}));
map.addControl(new mapboxgl.FullscreenControl({ container: document.querySelector('body') }))
map.addControl(geocoder, 'top-left');
// disable map functions: drag rotate and touch zoom rotate
map.dragRotate.disable();
map.touchZoomRotate.disableRotation();
});
function fetchDataRange(url) {
return fetch(url)
.then((resp) => resp.json())
.then(function (data) {
let values = data.map(function (value) {
return value.rate
})
return values
})
}
function createLegend(geomLayer, lastUpdated) {
// create legend, use colors from mapbox style
// BEWARE: array number of color ramp can change based on style ordering, must change if studio style changes
legendbins.innerHTML = ''
var fillRamp = map.getPaintProperty(geomLayer, 'fill-color')[8];
fillRamp = fillRamp.splice(2);
var layers = fillRamp.filter((layer, idx) => idx % 2 == 1);
var colors = fillRamp.filter((layer, idx) => idx % 2 == 0)
for (i = 0; i < layers.length; i++) {
var lowerbound = i == 0 ? "20 " : layers[i - 1];
var layer = lowerbound + " to " + layers[i];
var color = colors[i];
var item = document.createElement('div');
var key = document.createElement('span');
key.className = 'legend-key';
key.style.backgroundColor = color;
var value = document.createElement('span');
value.innerHTML = layer;
item.appendChild(key);
item.appendChild(value);
legendbins.appendChild(item);
}
}
function setDynamicBreaks(breaks, layer) {
// get paint property
let paintProperty = map.getPaintProperty(layer, 'fill-color')
// update paint property
let paintSteps = paintProperty[8];
let j = 0
for (let i = 3; i < paintSteps.length; i+=2) {
paintSteps[i] = breaks[j];
j++;
}
// set paint property
map.setPaintProperty(layer,'fill-color', paintProperty)
}
function addDynamicLayerStyles() {
const style = map.getStyle();
style.sources.composite.promoteId = "id";
const dataLayers = style.layers.filter(item => item['source-layer'] === geomSource)
dataLayers.forEach(layer => {
map.removeLayer(layer.id);
layer.source = "geojson";
delete layer["source-layer"];
map.addLayer(layer);
});
style.sources.geojson.promoteId = "id";
map.setStyle(style);
// set selection state styles
for (let step = 0; step < outlineLayers.length; step++) {
map.setPaintProperty(outlineLayers[step], "line-color",
["case", ["==", ["feature-state", "selected"], true], "#FEB42E", "#FFFFFF"]);
map.setPaintProperty(outlineLayers[step], "line-width",
["case", ["==", ["feature-state", "selected"], true], 3, 1]);
map.setPaintProperty(outlineLayers[step], "line-offset",
["case", ["==", ["feature-state", "selected"], true], 1, 0]);
}
}
/// update map state from latest data
var geomIDToData = {};
function assignFeatureStateFromData(data) {
var features = data.features;
for (var i = 0; i < features.length; i += 1) {
var geom = features[i];
geomIDToData[geom.properties.id] = geom.properties;
}
addInteraction();
}
var selectedGeom = null;
function setSelectedGeometry(feature) {
if (selectedGeom) {
map.removeFeatureState({ source: "geojson", id: selectedGeom.id }, "selected");
}
if (selectedGeom === null || selectedGeom.id !== feature.id) {
map.setFeatureState({ source: "geojson", id: feature.id }, { "selected": true });
selectedGeom = feature;
getContentForLocation(feature);
} else {
document.getElementById('dynamic_title').innerHTML = initialTitle
document.getElementById('dynamic_content').innerHTML = initialText;
selectedGeom = null;
}
}
function formatNumber(num) {
return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,')
}
function formatCensusTract(id) {
var tract_num = parseInt(id.substring(5, 9), 10)
var tract_sub = id.substring(9, 11)
return tract_sub == "00" ? tract_num.toString() : tract_num.toString() + '.' + tract_sub.toString();
}
// Returns text advice based on state status
function getContentForLocation(geom) {
var data = geom.properties
var title = document.getElementById("dynamic_title");
var output = document.getElementById("dynamic_content");
var geomName = geom.id;
if (data.area_type === 'Census Tract') {
geomName = 'Census Tract ' + formatCensusTract(data.id);
}
title.textContent = `${geomName}`;
var lines = "";
// parse data from strings, catch nulls which will return NaN or undefineds where the attribute is missing
var countText = (!isNaN(parseInt(data.count)) && typeof (data.count) !== "undefined") ? parseInt(data.count) : "fewer than 10";
var deathsText = !isNaN(parseInt(data.deaths)) ? parseInt(data.deaths) : "fewer than 10";
var rate = !isNaN(parseFloat(data.rate)) && typeof (data.rate) != "undefined" ? parseFloat(data.rate) : null;
// all acs_populations should be populated, not catching NaN or undefined
var acs_population = parseInt(data.acs_population);
if (acs_population < 1000) {
lines += `<p>Data dropped for ${geomName} because the estimated population is less than 1,000</p>`
} else {
if (typeof (countText) === "string" || countText < 20 || rate === null) {
lines += `<p>Rates are not calculated for ${geomName} because the case counts are fewer than 20.</p>`;
} else {
lines += `<p><strong>Estimated case rate:</strong> ${rate.toFixed(1)} cases per 10,000 residents.</p>`;
}
lines += `<p><strong>Confirmed cases:</strong> ${formatNumber(countText)} cases among an estimated resident population of ${formatNumber(acs_population)}.`;
// enter logic to cast undefined to 0 deaths
lines += `<p><strong>Confirmed deaths:</strong> ${deathsText} individuals have died.`;
}
/*lines += `<p>[OPTIONAL AREA SPECIFIC INFORMATION HERE, for example: This area contains a shelter with a recent outbreak. The City is working to transfer all affected individuals into the appropriate medical care facility based on their needs. More information on the City’s response here: [LINK].]</p>`;*/
lines += `<h2>San Francisco offering help to residents</h2>`;
lines += `<p>San Francisco is offering a variety of resources to support residents during the pandemic. Visit <a href="https://sf.gov/coronavirus">sf.gov/coronavirus</a> or call 311 for more information.</p>`;
lines += `<h3>Getting tested</h3>`;
lines += `<p>There are options for getting tested for COVID-19 in San Francisco for the uninsured and insured. <a href="https://sf.gov/find-out-how-get-tested-coronavirus">Learn more about how you can access testing</a>.</p>`;
assignFeatureStateFromData
output.innerHTML = lines;
};
// Change geomLayer on zoom, show/hide zoom to san francisco button
map.on('zoom', function () {
if (map.getZoom() > 12) {
document.getElementById('fit').style.display = 'block';
} else {
document.getElementById('fit').style.display = 'none';
}
if (map.getZoom() >= zoomThreshold && geomLayer == 'neighborhoods-data') {
removeInteraction();
geomLayer = 'census-tracts-data';
createLegend(geomLayer);
addInteraction();
} else if (map.getZoom() < zoomThreshold && geomLayer == 'census-tracts-data') {
removeInteraction();
geomLayer = 'neighborhoods-data';
createLegend(geomLayer);
addInteraction();
}
})
// Show tooltip popop so user knows they can double click to see more
var popup = new mapboxgl.Popup({
anchor: 'left',
offset: 30,
closeButton: false
})
map.on('mousemove', 'neighborhoods-data', function (e) {
map.getCanvas().style.cursor = 'pointer';
popup
.setLngLat(e.lngLat)
.setHTML('Click to select, double click to zoom to ' + e.features[0].properties.id)
.addTo(map);
});
map.on('mouseleave', 'neighborhoods-data', function (e) {
map.getCanvas().style.cursor = '';
popup.remove();
})
// Zoom polygon extents on double click, disable double click to zoom
map.on('dblclick', 'neighborhoods-data', function (e) {
map.doubleClickZoom.disable();
var bounds = turf.extent(e.features[0].geometry);
map.fitBounds(bounds);
})
// On zoom end re-enable double click to zoom
map.on('zoomend', function (e) {
map.doubleClickZoom.enable();
})
function addInteraction() {
map.on('click', geomLayer, onClickCallback);
}
function removeInteraction() {
map.off('click', geomLayer, onClickCallback);
}
function onClickCallback(event) {
var geometry = event.features[0];
setSelectedGeometry(geometry);
}
document.getElementById('fit').addEventListener('click', function () {
map.fitBounds(sfbounds);
});
</script>
</body>
</html>