block by boeric 2dca5c7c84eb5d3c72a8

Driver License Suspensions

Full Screen

Driver License Suspensions I

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.

index.html

<!doctype html>
<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>&nbsp;<p>&nbsp;<p>&nbsp;<p>&nbsp;<p>&nbsp;<p>&nbsp;<p>&nbsp;
    </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 &gt; 1% suspension rate, gray &lt; 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)?
  // //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>&nbsp;<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 "&nbsp" }));
          })
      })
      .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 = "//" + ["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>