block by HarryStevens aa2c3313b29a4f9c819ee465888e1475

Swarmplot

Full Screen

This is a rebuild of Lazaro Gamio’s What to know about all of the men facing sexual misconduct allegations published in Axios. It’s what seaborn calls a “swarmplot” – a beeswarm faceted by a property of the data.

Just as I was finishing this, I realized that the original chart is made of absolutely positioned HTML divs. My version is SVG. I also didn’t go all out on the tooltips, but the basic idea is there.

I downloaded the data from https://graphics.axios.com/2017-12-12-sexual-misconduct-cases/data/data.json on 4 February, 2018.

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
      
    <style>
      body {
        font-family: "Helvetica Neue", sans-serif;
        margin: 0;
      }
      #wrapper {
        max-width: 900px;
        margin: 0 auto;
      }
      .circle {
        pointer-events: none;
      }
      .circle-bg {
        stroke: steelblue;
        fill: steelblue;
        fill-opacity: .3;
        pointer-events: none;
      }
      .circle-hover {
        opacity: 0;
      }
      .axis .domain {
        display: none;
      }
      .axis text {
        font-size: 1.2em;
      }
      .axis.y.right .tick text {
        fill: steelblue;
      }
      .axis.y .tick line {
        stroke: #eee;
        stroke-width: 10px;
      }
      .axis.x .tick line {
        stroke: #ccc;
      } 
      .tip {
        position: absolute;
        font-size: .8em;
        text-align: center;
        text-shadow: -1px -1px 1px #ffffff, -1px 0px 1px #ffffff, -1px 1px 1px #ffffff, 0px -1px 1px #ffffff, 0px 1px 1px #ffffff, 1px -1px 1px #ffffff, 1px 0px 1px #ffffff, 1px 1px 1px #ffffff;
      }
    </style>
  </head>
  <body>
    <div id="wrapper"></div>
    <script src="https://d3js.org/d3.v4.min.js"></script>
    <script src="https://unpkg.com/d3-moveto@0.0.3/build/d3-moveto.min.js"></script>
    <script src="https://unpkg.com/jeezy@1.12.13/lib/jeezy.min.js"></script>
    <script>
      var radius = 12;

      var margin = {top: radius * 2.5 + 10, left: 90, bottom: radius, right: 30},
        width = +jz.str.keepNumber(d3.select("#wrapper").style("width")) - margin.left - margin.right,
        height = 600 - margin.top - margin.bottom,
        svg = d3.select("#wrapper").append("svg")
            .attr("width", width + margin.left + margin.right)
            .attr("height", height + margin.top + margin.bottom)
          .append("g")
            .attr("transform", "translate(" + margin.left + ", " + margin.top + ")");

      var x = d3.scaleTime()
        .rangeRound([0, width]);

      var y = d3.scaleBand()
        .rangeRound([0, height]);

      var curr_month = "";

      var x_axis = d3.axisBottom(x)
          .tickSizeOuter(0)
          .ticks(d3.timeDay.every(1))
          .tickFormat(function(d){ 
            var s = d.toString().split(" ");
            var m = s[1];
            if (m !== curr_month){
              curr_month = m;
              return m + "."; 
            } else {
              return null;
            }
          });

      var y_axis_left = d3.axisLeft(y)
          .tickSize(0)

      var y_axis_right = d3.axisRight(y)
          .tickSizeOuter(0)
          .tickSizeInner(-width);

      var v = d3.voronoi()
        .extent([[0, 0], [width, height]])
        .x(function(d) { return d.x; })
        .y(function(d) { return d.y; });

      var parseDate = function(x){
        var s = x.split("-");
        var d = [s[2], s[0], s[1]].join("-");
        return new Date(d);
      }

      // defs for images
      var defs = d3.select("svg").append("defs");

      // append the tip
      var tip = d3.select("#wrapper").append("div")
          .attr("class", "tip");

      d3.json("data.json", function(error, data){
        if (error) throw error;

        data = data.include;

        data.forEach(function(d){
          d.accuse_date = parseDate(d.accuse_date);
          d.slug = jz.str.toSlugCase(d.name);
          return d;
        });

        x.domain([new Date(2017, 9, 1), data[data.length - 1].accuse_date]);

        var industries = jz.arr.sortBy(jz.arr.pivot(data, "industry"), "count", "desc");
        y.domain(industries.map(function(d){ return d.value; }));

        x_axis.tickSizeInner(-height + y.bandwidth() / 2 - 3)

        svg.append("g")
            .attr("class", "axis y left")
            .call(y_axis_left)
          .selectAll(".tick text")
            .attr("dx", -radius);

        svg.append("g")
            .attr("class", "axis y right")
            .attr("transform", "translate(" + width + ", 0)")
            .call(y_axis_right.tickFormat(function(d){ return industries.filter(function(c){ return c.value == d })[0].count; }))
          .selectAll(".tick text")
            .attr("dx", radius);

        svg.append("g")
            .attr("class", "axis x")
            .attr("transform", "translate(0, " + height + ")")
            .call(x_axis)
          .selectAll(".tick line")
            .style("display", function(d){
              var s = d.toString().split(" ");
              var m = s[1];
              if (m !== curr_month){
                curr_month = m;
                return "block"; 
              } else {
                return "none";
              }
            });

        // images
        var img = defs.selectAll("pattern")
            .data(data, function(d){ return d.slug; })
          .enter().append("pattern")
            .attr("id", function(d){ return "img-" + d.slug; })
            .attr("x", "0%")
            .attr("y", "0%")
            .attr("height", "100%")
            .attr("width", "100%")
            .attr("viewBox", "0 0 " + radius + " " + radius)
          .append("image")
            .attr("x", "0%")
            .attr("y", "0%")
            .attr("width", radius)
            .attr("height", radius)
            .attr("xlink:href", function(d){ return "https://graphics.axios.com/2017-12-12-sexual-misconduct-cases/img/" + d.image_url; })

        forceSim();

        draw();

        window.addEventListener("resize", function(){ 
          
          // all of these things need to be updated on resize
          width = +jz.str.keepNumber(d3.select("#wrapper").style("width")) - margin.left - margin.right;
          
          d3.select(".axis.y.right").attr("transform", "translate(" + width + ", 0)").call(y_axis_right.tickSizeInner(-width));
          
          x.rangeRound([0, width]);

          forceSim();

          d3.select(".x.axis")
            .call(x_axis);

          draw(); 
        });

        function draw(){

          // circle
          var circle = svg.selectAll(".circle")
              .data(v.polygons(data), function(d){ return d.data.slug; });

          circle.enter().append("circle")
              .attr("class", function(d) { return "circle circle-" + d.data.slug; })
              .attr("r", radius)
              .style("fill", function(d){ return "url(#img-" + d.data.slug + ")"; })
            .merge(circle)
              .attr("cx", function(d) { return d ? d.data.x : null; })
              .attr("cy", function(d) { return d ? d.data.y : null; });

          // background
          var bg_circle = svg.selectAll(".circle-bg")
              .data(v.polygons(data), function(d){ return d.data.slug; });

          bg_circle.enter().append("circle")
              .attr("class", function(d) { return "circle-bg circle-bg-" + d.data.slug; })
              .attr("r", radius)
            .merge(bg_circle)
              .attr("cx", function(d) { return d ? d.data.x : null; })
              .attr("cy", function(d) { return d ? d.data.y : null; });

          // hover
          var hover_circle = svg.selectAll(".circle-hover")
              .data(v.polygons(data), function(d){ return d.data.slug; });

          hover_circle.enter().append("circle")
              .attr("class", function(d) { return "circle-hover circle-hover-" + d.data.slug; })
              .attr("r", radius)
            .merge(hover_circle)
              .attr("cx", function(d) { return d ? d.data.x : null; })
              .attr("cy", function(d) { return d ? d.data.y : null; });

          svg.selectAll(".circle-hover").on("mouseover", function(d){
            
            tip.html(d.data.name);
            
            d3.select(".circle-" + d.data.slug).attr("r", radius * 2.5).moveToFront();
            d3.select(".circle-bg-" + d.data.slug).style("fill-opacity", 0).attr("r", radius * 2.5).style("stroke-width", 3).moveToFront();

            var tip_width = +jz.str.keepNumber(tip.style("width"));
            var tip_height = +jz.str.keepNumber(tip.style("height"));

            var circle_node = d3.select(this).node().getBoundingClientRect();
            var circle_left = circle_node.left;
            var circle_top = circle_node.top;

            var tip_left = circle_left - tip_width / 2 + radius;
            var tip_top = circle_top - radius * 1.5 - tip_height;

            tip
                .style("left", tip_left + "px")
                .style("top", tip_top + "px");

          }).on("mouseout", function(d){
            d3.select(".circle-" + d.data.slug).attr("r", radius);
            d3.select(".circle-bg-" + d.data.slug).style("fill-opacity", .3).attr("r", radius).style("stroke-width", 1);

            tip
              .style("left", "-1000px")
              .style("top", "-1000px");

          });

        }

        function forceSim(){
          var simulation = d3.forceSimulation(data)
              .force("y", d3.forceY(function(d){ return y(d.industry) + y.bandwidth() / 2; }).strength(1))
              .force("x", d3.forceX(function(d){ return x(d.accuse_date); }).strength(1))
              .force("collide", d3.forceCollide(radius + 1))
              .stop();

          for (var i = 0; i < 200; ++i) simulation.tick();
        }

      });

    </script>
  </body>
</html>