The visualization shows driver license suspensions in California per zip code due to "failure to pay" or "failure to appear". The viz is based on raster map tiles from Mapbox with county and zip code boundaries drawn on top.
See the viz in action here, and fullscreen
See another implementation of the same dataset here, fullscreen. This implementation is using the Mapbox GL API (which uses WebGL) and offers a vastly superior drawing performance.
xxxxxxxxxx
<html lang="en">
<!-- Based on examples by Mike Bostock, MapBox, etc. -->
<!-- Author: Bo Ericsson, Email: bo@boe.net -->
<head>
<title>License Suspensions</title>
<meta charset="UTF-8">
<style>
body {
margin: 0px;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.polygon {
fill: red;
fill-opacity: .5;
stroke: black;
stroke-width: 1px;
}
p, a {
font-size: 12px;
line-height: 12px;
margin: 0px
margin-bottom: 10px;
}
a {
margin-left: 10px;
}
h4 {
margin-top: 10px;
margin-bottom: 5px;
}
circle {
cursor: crosshair;
stroke: gray;
stroke-width: 1px;
stroke-opacity: 0.0;
fill-opacity: 0.4;
}
.cursorLocation {
padding: 3px;
position: absolute;
bottom: 10px;
left: 10px;
min-width: 300px;
min-height: 15px;
opacity: 0.5;
background-color1: white;
}
.cursorLocation label {
font-size: 12px;
font-weight1: bold;
opacity: 1;
}
.attribution {
padding: 3px;
position: absolute;
bottom: 0px;
right: 0px;
opacity: 0.5;
background-color: white;
}
.attribution label {
font-size: 10px;
font-weight: bold;
}
.attribution a {
color: #404040;
text-decoration: none;
}
.attribution a:hover {
text-decoration: underline;
}
.attribution a:link {
-webkit-tap-highlight-color: rgba(0,0,0,0);
}
#viz {
border: 1px solid gray;
overflow: scroll;
}
label {
font-size: 12px;
}
#overlay {
padding: 5px;
position: absolute;
min-width: 200px;
max-width: 400px;
top: 10px;
left: 10px;
border: 1px solid gray;
border-radius: 5px;
background-color: white;
opacity: 0.8;
display: none;
z-index: 10001;
}
#overlay p {
margin: 5px;
}
#controls {
padding1: 5px;
margin-bottom: 15px;
}
#controls p {
margin: 5px;
margin-left: 0px;
}
#main {
position: absolute;
top: 10px;
left: 10px;
opacity: 0.85;
background-color: white;
border-radius: 5px;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
z-index: 10000;
width: 300px;
border: 1px solid gray;
}
ul {
font-size: 12px;
padding-left: 15px;
margin-top: 5px;
margin-bottom: 5px;
}
li {
font-style: italic;
}
#howToUse:hover {
color: red;
}
</style>
<body>
<div style="position: relative; display: block">
<div id="main">
<h4 style="margin-bottom: 0px">Driver License Suspensions in</h4>
<h4 style="margin-top: 0px">California by Zip Code</h4>
<p style="margin-top: 0px; font-weight: bold">Due to "Failure to Pay" or "Failure to Appear"</p>
<div id="controls">
<p> <p> <p> <p> <p> <p> <p>
</div>
<div>
<input type="checkbox" id="overlayCheckbox"><label>Enable popup info overlays</label>
</div>
<p style="margin-top: 10px; margin-bottom: 0px; font-weight: bold">Sources</p>
<ul>
<li>East Bay Community Law Center</li>
<li>CA Deparment of Motor Vehicles</li>
<li>US Census Bureau (for geo data)</li>
</ul>
<p id="howToUse" style="margin-top: 10px; margin-bottom: 0px; font-weight: bold">How to use...</p>
<ul id="instructions"; style="display: none">
<li>Each dot shows the center of a zip code area</li>
<li>Red dot means > 1% suspension rate, gray < 1%</li>
<li>The larger the circle, the higher suspension rate</li>
<li>Move the mouse to see the specifics for the zip code</li>
<li>The zip code boundaries are shown for each county</li>
<li>The darker the zip code areas, the higher suspension rate</li>
<li>Zoom in/out by rolling the mouse wheel</li>
<li>Pan around the map by dragging the mouse</li>
</ul>
</div>
</div>
<div id="viz" style="position: relative"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js" charset="utf-8"></script>
<!--<script src="../_lib/d3.min.js" charset="utf-8" type="text/JavaScript"></script>-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/1.6.19/topojson.js" type="text/JavaScript"></script>
<script>
'use strict';
var mapCenter = [-118.45, 33.95], // LA region
svg,
data,
opacityDefault = 0.0,
opacityHover = 0.1,
zoomLevel = 0,
zipGeo,
topo,
zipTopo,
countyZips,
zipData,
overlayEnabled = false;
// function to load four data sources
function loadData() {
// load zip code data
d3.tsv("cazipgeo.txt", function(_) {
zipGeo = _;
// create object for quick lookup of a county's zip codes (used by code that dynamically injects zip code geometry)
countyZips = {};
zipGeo.forEach(function(d) {
var key = d.County.trim();
if (countyZips[key] == undefined) countyZips[key] = [];
countyZips[key].push(+d.ZipCode)
})
// create data structure used to merge zip code and suspension data
var obj = {};
zipGeo.forEach(function(d) {
var key = d.ZipCode.trim();
obj[key] = d;
delete obj[key].ZipCode;
})
zipGeo = obj; // redefine zipGeo from array to object
// load driver license suspension data
d3.tsv("suspensions.txt", function(suspensions) {
// merge in the zip geo data and create main data structure
data = suspensions.map(function(d, i, a) {
var zipData = zipGeo[d.ZipCode];
Object.keys(zipData).forEach(function(prop) { d[prop] = zipData[prop]; })
return d;
})
// convert to numbers
data.forEach(function(d) {
var props = Object.keys(d);
props.forEach(function(prop) { d[prop] = (isNaN(+d[prop])) ? d[prop] : +d[prop]; })
})
// sort data in ascending order (so highest suspension rates are drawn last and on top of the stack)
data.sort(function(a, b) {
return a.FTAFTPS100 - b.FTAFTPS100;
})
//console.log("data", data);
// create data structure for quick lookup (used by zip code geometry event handler)
zipData = {};
data.forEach(function(d) {
zipData[d.ZipCode] = d;
})
// load county geometry
d3.json("county4.json", function(error, county) {
topo = topojson.feature(county, county.objects.CaliforniaCounty);
// load zip code geometry
d3.json("ziptopo6.json", function(error, zip) {
zipTopo = topojson.feature(zip, zip.objects.zip);
// watch window size changes and then regenerate viz
d3.select(window).on("resize", function() { createViz(); });
d3.select("#howToUse").on("click", function() {
var state = d3.select("#instructions").style("display");
if (state == "none") state = "block";
else state = "none";
d3.select("#instructions").style("display", state);
d3.select(this).text(function() {
return state == "none" ? "How to use..." : "How to use";
})
})
// create the map
createViz();
})
})
})
})
}
loadData();
// create the map
function createViz() {
var inIframe = false;
// are we running in an iframe (bl.ocks.org)?
// https://stackoverflow.com/questions/925039/detect-iframe-embedding-in-javascript
if (window.parent.frames.length > 0) inIframe = true;
//console.log("inIframe", inIframe);
// get current window size
var width = window.innerWidth;
var height = window.innerHeight;
//console.log("window dimension: ", width, height);
// setup tile processing
var tile = d3.geo.tile().size([width, height]);
// define mercator projection and initial zoom level
var projection = d3.geo.mercator()
.scale((1 << 18) / 2 / Math.PI)
.translate([width / 2, height / 2]); // translate to center of svg
// get pixel location of map center
var center = projection(mapCenter);
// define a geo path generator function
var geoPath = d3.geo.path()
.projection(projection)
.pointRadius(2);
// define zoom behavior
var zoom = d3.behavior.zoom()
.scale(projection.scale() * 2 * Math.PI)
.translate([width - center[0], height - center[1]])
.on("zoom", redraw);
// set dimension of container
d3.select("#viz")
.style("max-width", width + "px")
.style("max-height", height + "px")
// remove the svg if exists
d3.select("#viz").selectAll("*").remove();
// create the svg
svg = d3.select("#viz")
.append("svg")
.style("width", width + "px")
.style("height", height + "px")
//.style("border", "1px solid gray")
.call(zoom)
.on("mousemove", function() {
// get current zoom scale
var zoomScale = zoom.scale();
// get current mouse location
var location = projection.invert(d3.mouse(this));
// format location and show
var text = formatLocation(location, zoomScale);
d3.select("#cursorLocation").html(text)
})
// add overlay (tooltip) div
var overlay = d3.select("#viz")
.append("div")
.attr("id", "overlay");
// add div attribution (as per Mapbox' requirements)
d3.select("#viz")
.append("div")
.attr("class", "attribution")
.append("label")
.html("<a href='https://www.mapbox.com/about/maps/' target='_blank'>© MapBox © OpenStreetMap</a> <a href='https://www.mapbox.com/map-feedback/'>Improve this map</a>")
// add div for current cursor location, county and zip code
var cursorLocation = d3.select("#viz")
.append("div")
.attr("class", "cursorLocation")
.append("label")
.attr("id", "cursorLocation");
var fmtPct = d3.format(",.1%");
var fmtInt = d3.format(",d");
var fmtFloat = d3.format(".1f");
// append tile and event groups
var tileGroup = svg.append("g");
var countyGroup = svg.append("g");
var zipGroup = svg.append("g");
var eventGroup = svg.append("g");
// add county boundary to map
countyGroup.selectAll(".countyPath")
.data(topo.features)
.enter().append("path")
.attr("d", geoPath)
.attr("class", "countyPath")
.style("stroke-width", 1)
.style("stroke", "gray")
.style("fill-opacity", opacityDefault)
.on("mouseenter", function(d) {
// reset opacity for all county objects
d3.selectAll(".countyPath").style("fill-opacity", opacityDefault)
// set the opacity of this object
d3.select(this).style("fill-opacity", opacityHover);
// remove any zip objects
zipGroup.selectAll(".zipPath").remove();
// get the zip codes for this county
var zipCodes = countyZips[d.properties.NAME];
// filter the zip code features to include only zip codes in this county
var filteredFeatures = zipTopo.features.filter(function(item) {
return zipCodes.some(function(zipCode) {
if (item.properties.zip == zipCode) return true;
})
})
// inject the filtered features into the svg
zipGroup.selectAll(".zipPath")
.data(filteredFeatures)
.enter().append("path")
.attr("d", geoPath)
.attr("class", "zipPath")
.style("stroke-width", 1)
.style("stroke", "darkred")
.style("fill", "darkred")
.style("fill-opacity", function(d) {
var item = zipData[d.properties.zip];
var opacity = item.FTAFTPS100 / 10;
return opacity;
})
.on("mouseover", function() {
// get the item
var elem = d3.select(this);
// get the associated data
var elemData = elem.data()[0];
// get the related zip code data
var item = zipData[elemData.properties.zip];
// set the text in the side panel
var text = [
"Zip Code: <b>" + item.ZipCode + "</b>",
"Place: <b>" + item.Places + "</b>",
"County: <b>" + item.County + "</b>",
"Suspensions: <b>" + fmtPct(item.FTAFTPS100 / 100) + "</b>",
"Poverty Rate: <b>" + fmtPct(item.povrate / 100) + "</b>",
"Population 15y+: <b>" + fmtInt(item.Pop15Plus) + "</b>",
"Avg Income: <b>" + fmtFloat(""+item.IncK) + "K</b>"
];
manageSidePanel(text);
})
.on("mouseout", function() {
// fill panel with nbsp rows
manageSidePanel(d3.range(7).map(function(d) { return " " }));
})
})
.on("mouseleave", function(d) {
// can't get mouseleave to work properly as the event triggers whenever mouse is moved from county area to topmost circle
//d3.select(this).style("fill-opacity", opacityDefault)
})
// add events to map
var eventElements = eventGroup
.selectAll("circle")
.data(data)
.enter().append("circle")
.attr("cx", function(d) { return projection([d.Long, d.Lat])[0] })
.attr("cy", function(d) { return projection([d.Long, d.Lat])[1] })
.attr("r", function(d) { return Math.max(3, ~~d.FTAFTPS100) })
.style("fill", function(d) {
if (d.FTAFTPS100 > 1) return "darkred";
else return "black";
})
.on("mouseenter", function() {
// get current item
var item = d3.select(this).data()[0];
// set the text in the overlay panel
var text = [
"Zip Code: <b>" + item.ZipCode + "</b>",
"Place: <b>" + item.Places + "</b>",
"County: <b>" + item.County + "</b>",
"Suspensions: <b>" + fmtPct(item.FTAFTPS100 / 100) + "</b>",
"Poverty Rate: <b>" + fmtPct(item.povrate / 100) + "</b>",
"Population 15y+: <b>" + fmtInt(item.Pop15Plus) + "</b>",
"Avg Income: <b>" + fmtFloat(""+item.IncK) + "K</b>"
];
// update side panel
manageSidePanel(text);
// don't show the overlay until zoom level 8
if (zoomLevel > 9 && overlayEnabled) {
// remove current p elements in overlay
overlay.selectAll("p").remove();
// add new p elements
overlay.selectAll("p")
.data(text)
.enter().append("p")
.html(function(d) { return d; })
// get current size of overlay
overlay.style("display", "block");
var overlayWidth = overlay.node().offsetWidth;
var overlayHeight = overlay.node().offsetHeight;
// compute position of overlay
var mouse = d3.mouse(this);
var x = mouse[0] - overlayWidth / 2;
x = (x < 0) ? 10 : (x + overlayWidth) > width ? width - overlayWidth - 10 : x; // adjust x position if needed
var y = mouse[1] - 20 - overlayHeight;
y = (y < 0) ? mouse[1] + 20 : y; // adjust y position if needed
// set position of overlay
overlay.style("left", x + "px")
overlay.style("top", y + "px")
}
})
.on("mouseleave", function() {
// hide the overlay
overlay.style("display", "none")
});
// manages the side panel
function manageSidePanel(data) {
var controls = d3.select("#controls");
// remove current elements
controls.selectAll("p").remove();
// add new p elements
controls.selectAll("p")
.data(data)
.enter().append("p")
.html(function(d) { return d; })
}
// redraws viz after drag or zoom
function redraw() {
// get the tiles for this zoom level
var tiles = tile
.scale(zoom.scale())
.translate(zoom.translate())();
// capture current zoom level (used by zip code event handler to determine whether to show overlay)
zoomLevel = tile.zoomLevel();
// scale and translate tile group and bind new tiles
var image = tileGroup
.attr("transform", "scale(" + tiles.scale + ")translate(" + tiles.translate + ")")
.selectAll("image")
.data(tiles, function(d) { return d; });
// remove prior tiles
image.exit().remove();
// append the new tiles
image.enter().append("image")
.attr("xlink:href", function(d) {
// create url for the map tile fetch
var url = "https://" + ["a", "b", "c", "d"][Math.random() * 4 | 0] + ".tiles.mapbox.com/v4/boeric.naal7ngd/" + d[2] + "/" + d[0] + "/" + d[1] + ".png" + "?access_token=pk.eyJ1IjoiYm9lcmljIiwiYSI6IkZEU3BSTjQifQ.XDXwKy2vBdzFEjndnE4N7Q"; // boeric.mccfpp06, , prior map id: naal7ngd
return url;
})
.attr("width", 1)
.attr("height", 1)
.attr("x", function(d) { return d[0]; })
.attr("y", function(d) { return d[1]; });
// update projection
projection
.scale(zoom.scale() / 2 / Math.PI)
.translate(zoom.translate());
// update object positions
eventElements
.attr("cx", function(d) { return projection([d.Long, d.Lat])[0] })
.attr("cy", function(d) { return projection([d.Long, d.Lat])[1] });
// update all paths
d3.selectAll("path")
.attr("d", geoPath);
}
// initial draw
redraw();
// formats the geo location string (zoom-in generates more decimals)
function formatLocation(p, k) {
var format = d3.format("." + Math.floor(Math.log(k) / 2 - 2) + "f");
return (p[1] < 0 ? format(-p[1]) + "°S" : format(p[1]) + " °N") + " " + (p[0] < 0 ? format(-p[0]) + "°W" : format(p[0]) + "°E");
}
} // end createViz function
// d3 tile generator
// https://github.com/d3/d3-plugins/blob/master/geo/tile/tile.js
d3.geo.tile = function() {
var size = [960, 500],
scale = 256,
translate = [size[0] / 2, size[1] / 2],
zoomDelta = 0;
function tile() {
var z = Math.max(Math.log(scale) / Math.LN2 - 8, 0),
z0 = Math.round(z + zoomDelta),
k = Math.pow(2, z - z0 + 8),
origin = [(translate[0] - scale / 2) / k, (translate[1] - scale / 2) / k],
tiles = [],
cols = d3.range(Math.max(0, Math.floor(-origin[0])), Math.max(0, Math.ceil(size[0] / k - origin[0]))),
rows = d3.range(Math.max(0, Math.floor(-origin[1])), Math.max(0, Math.ceil(size[1] / k - origin[1])));
rows.forEach(function(y) {
cols.forEach(function(x) {
tiles.push([x, y, z0]);
});
});
tiles.translate = origin;
tiles.scale = k;
return tiles;
}
tile.size = function(_) {
if (!arguments.length) return size;
size = _;
return tile;
};
tile.scale = function(_) {
if (!arguments.length) return scale;
scale = _;
return tile;
};
tile.translate = function(_) {
if (!arguments.length) return translate;
translate = _;
return tile;
};
tile.zoomDelta = function(_) {
if (!arguments.length) return zoomDelta;
zoomDelta = +_;
return tile;
};
tile.zoomLevel = function(_) {
var z = Math.max(Math.log(scale) / Math.LN2 - 8, 0),
z0 = Math.round(z + zoomDelta);
return z0;
}
return tile;
} // end tile function
// checkbox handler
d3.select("#overlayCheckbox").on("change", function() {
var state = d3.select(this).property("checked");
overlayEnabled = state;
})
</script>
https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js
https://cdnjs.cloudflare.com/ajax/libs/topojson/1.6.19/topojson.js