block by armollica 70afe1b4265425cb6e031b973e6d9811

Map Annotation

Full Screen

Example using the d3.ringNote plugin to add circle annotations to a map. Drag the dashed circles and the text to move the annotation elements. These moveable, dashed-line circles will disappear if ringNote.draggable(false) is called.

This example also shows how to style (or hide) certain elements of the annotation after it has been drawn. In this example one of the circles is hidden.

The map is a fork of mbostock‘s block: Choropleth

index.html

<html>
<head>
<style>

html {
  font-family: sans-serif;
}

.hidden { display: none; }

.annotation circle {
  fill: none;
  stroke: black;
}

.annotation path {
  fill: none;
  stroke: black;
  shape-rendering: crispEdges;
}

.annotation text {
  text-shadow: -2px 0 2px #fff, 
                0 2px 2px #fff,
                2px 0 2px #fff, 
                0 -2px 2px #fff;
}

.counties {
  fill: none;
}

.states {
  fill: none;
  stroke: #fff;
  stroke-linejoin: round;
}

.q0-9 { fill:rgb(247,251,255); }
.q1-9 { fill:rgb(222,235,247); }
.q2-9 { fill:rgb(198,219,239); }
.q3-9 { fill:rgb(158,202,225); }
.q4-9 { fill:rgb(107,174,214); }
.q5-9 { fill:rgb(66,146,198); }
.q6-9 { fill:rgb(33,113,181); }
.q7-9 { fill:rgb(8,81,156); }
.q8-9 { fill:rgb(8,48,107); }

</style>
</head>
<body>     
<script src="//d3js.org/d3.v3.min.js"></script>
<script src="//d3js.org/queue.v1.min.js"></script>
<script src="//d3js.org/topojson.v1.min.js"></script>
<script src="d3-ring-note.js"></script>
<script>

// After position the annotation, run `copy(annotations)` in the browser's
// console and paste over this array:
var annotations = [
  {
    "cx": 675,
    "cy": 180,
    "r": 51,
    "text": "Michigan was hit hard by the last recession",
    "textWidth": 200,
    "textOffset": [
      53,
      -78
    ]
  },
  {
    "cx": 378,
    "cy": 115,
    "r": 25,
    "text": "Less than 2% unemployed in parts of North Dakota",
    "textWidth": 200,
    "textOffset": [
      10,
      -61
    ]
  },
  {
    "cx": 131,
    "cy": 310,
    "r": 89,
    "text": "30% unemployment",
    "textOffset": [
      38,
      112
    ],
    "hideCircle": true  /* used to identify which circles to hide */
  }
];

var width = 960,
    height = 600;

var rateById = d3.map();

var quantize = d3.scale.quantize()
    .domain([0, .15])
    .range(d3.range(9).map(function(i) { return "q" + i + "-9"; }));

var projection = d3.geo.albersUsa()
    .scale(1280)
    .translate([width / 2, height / 2]);

var path = d3.geo.path()
    .projection(projection);

var svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height);

var ringNote = d3.ringNote()
  .draggable(true);

queue()
    .defer(d3.json, "us.json")
    .defer(d3.tsv, "unemployment.tsv", function(d) { rateById.set(d.id, +d.rate); })
    .await(ready);

function ready(error, us) {
  if (error) throw error;

  svg.append("g")
      .attr("class", "counties")
    .selectAll("path")
      .data(topojson.feature(us, us.objects.counties).features)
    .enter().append("path")
      .attr("class", function(d) { return quantize(rateById.get(d.id)); })
      .attr("d", path);

  svg.append("path")
      .datum(topojson.mesh(us, us.objects.states, function(a, b) { return a !== b; }))
      .attr("class", "states")
      .attr("d", path);
  
  var gAnnotations = svg.append("g")
    .attr("class", "annotations")
    .call(ringNote, annotations);
  
  gAnnotations.selectAll(".annotation circle")
    .classed("hidden", function(d) { return d.hideCircle; });
}

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

d3-ring-note.js

d3.ringNote = function() {
  var draggable = false,
      controlRadius = 15;
  
  var dragCenter = d3.behavior.drag()
    .origin(function(d) { return { x: 0, y: 0}; })
    .on("drag", dragmoveCenter);
  
  var dragRadius = d3.behavior.drag()
    .origin(function(d) { return { x: 0, y: 0 }; })
    .on("drag", dragmoveRadius);
  
  var dragText = d3.behavior.drag()
    .origin(function(d) { return { x: 0, y: 0 }; })
    .on("drag", dragmoveText);
  
  var path = d3.svg.line();
  
  function draw(selection, annotation) {
    
    selection.selectAll(".ring-note").remove();
    
    var gRingNote = selection.selectAll(".ring-note")
        .data(annotation)
      .enter().append("g")
        .attr("class", "ring-note")
        .attr("transform", function(d) {
          return "translate(" + d.cx + "," + d.cy + ")";
        });
    
    var gAnnotation = gRingNote.append("g")
      .attr("class", "annotation");
    
    var circle = gAnnotation.append("circle")
      .attr("r", function(d) { return d.r; });
    
    var line = gAnnotation.append("path")
      .call(updateLine);
      
    var text = gAnnotation.append("text")
      .call(updateText);
    
    if (draggable) {
      
      var gControls = gRingNote.append("g")
        .attr("class", "controls");
      
      // Draggable circle that moves the circle's location
      var center = gControls.append("circle")
        .attr("class", "center")
        .call(styleControl)
        .call(dragCenter);
      
      // Draggable circle that changes the circle's radius
      var radius = gControls.append("circle")
        .attr("class", "radius")
        .attr("cx", function(d) { return d.r; })
        .call(styleControl)
        .call(dragRadius); 
       
      // Make text draggble
      text
        .style("cursor", "move")
        .call(dragText);
    }
    
    return selection;
  }
  
  draw.draggable = function(_) {
    if (!arguments.length) return draggable;
    draggable = _;
    return draw;
  };
  
  // Region in relation to circle, e.g., N, NW, W, SW, etc.
  function getRegion(x, y, r) {
    var px = r * Math.cos(Math.PI/4),
        py = r * Math.sin(Math.PI/4);
    
    var distance = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
    
    if (distance < r) {
      return null;
    }
    else {
      if (x > px) {
        // East
        if (y > py) return "SE"; 
        if (y < -py) return "NE";
        if (x > r) return "E";
        return null;
      }
      else if (x < -px) {
        // West
        if (y > py) return "SW";
        if (y < -py) return "NW";
        if (x < -r) return "W";
        return null;
      }
      else {
        // Center
        if (y > r) return "S";
        if (y < -r) return "N";
      }
    }
  }
  
  function dragmoveCenter(d) {
    var gRingNote = d3.select(this.parentNode.parentNode);
        
    d.cx += d3.event.x;
    d.cy += d3.event.y;
    
    gRingNote
      .attr("transform", function(d) {
        return "translate(" + d.cx + "," + d.cy + ")";
      });
  }
  
  function dragmoveRadius(d) {
    var gRingNote = d3.select(this.parentNode.parentNode),
        gAnnotation = gRingNote.select(".annotation"),
        circle = gAnnotation.select("circle"),
        line = gAnnotation.select("path"),
        text = gAnnotation.select("text"),
        radius = d3.select(this);
    
    d.r += d3.event.dx;
    
    circle.attr("r", function(d) { return d.r; });
    radius.attr("cx", function(d) { return d.r; });
    line.call(updateLine);
    text.call(updateText);
  }
  
  function dragmoveText(d) {
    var gAnnotation = d3.select(this.parentNode),
        line = gAnnotation.select("path"),
        text = d3.select(this);
    
    d.textOffset[0] += d3.event.dx;
    d.textOffset[1] += d3.event.dy;
    
    text.call(updateText);
    line.call(updateLine);
  }
  
  function updateLine(selection) {
    return selection.attr("d", function(d) {
      var x = d.textOffset[0],
          y = d.textOffset[1],
          lineData = getLineData(x, y, d.r);
      return path(lineData);
    });
  }
  
  function getLineData(x, y, r) {
    var region = getRegion(x, y, r);
    
    if (region == null) {
      // No line if text is inside the circle
      return [];
    }
    else {
      // Cardinal directions
      if (region == "N") return [[0, -r], [0, y]];
      if (region == "E") return [[r, 0], [x, 0]];
      if (region == "S") return [[0, r], [0, y]];
      if (region == "W") return [[-r, 0],[x, 0]];
      
      var d0 = r * Math.cos(Math.PI/4),
          d1 = Math.min(Math.abs(x), Math.abs(y)) - d0;
          
      // Intermediate directions
      if (region == "NE") return [[ d0, -d0], [ d0 + d1, -d0 - d1], [x, y]];
      if (region == "SE") return [[ d0,  d0], [ d0 + d1,  d0 + d1], [x, y]];
      if (region == "SW") return [[-d0,  d0], [-d0 - d1,  d0 + d1], [x, y]];
      if (region == "NW") return [[-d0, -d0], [-d0 - d1, -d0 - d1], [x, y]];
    }
  }
  
  function updateText(selection) {
    return selection.each(function(d) {
      var x = d.textOffset[0],
          y = d.textOffset[1],
          region = getRegion(x, y, d.r),
          textCoords = getTextCoords(x, y, region);
      
      d3.select(this)
        .attr("x", textCoords.x)
        .attr("y", textCoords.y)
        .text(d.text)
        .each(function(d) {
          var x = d.textOffset[0],
              y = d.textOffset[1],
              textAnchor = getTextAnchor(x, y, region);
          
          var dx = textAnchor == "start" ? "0.33em" :
                  textAnchor == "end" ? "-0.33em" : "0";
          
          var dy = textAnchor !== "middle" ? ".33em" :
            ["NW", "N", "NE"].indexOf(region) !== -1 ? "-.33em" : "1em";
          
          var orientation = textAnchor !== "middle" ? undefined :
            ["NW", "N", "NE"].indexOf(region) !== -1 ? "bottom" : "top";
          
          d3.select(this)
            .style("text-anchor", textAnchor)
            .attr("dx", dx)
            .attr("dy", dy)
            .call(wrapText, d.textWidth || 960, orientation);
        });
    });
  }
  
  function getTextCoords(x, y, region) {
    if (region == "N") return { x: 0, y: y };
    if (region == "E") return { x: x, y: 0 };
    if (region == "S") return { x: 0, y: y };
    if (region == "W") return { x: x, y: 0 };
    return { x: x, y: y };
  }
  
  function getTextAnchor(x, y, region) {
    if (region == null) {
      return "middle";
    }
    else {
      // Cardinal directions
      if (region == "N") return "middle";
      if (region == "E") return "start";
      if (region == "S") return "middle";
      if (region == "W") return "end";
      
      var xLonger = Math.abs(x) > Math.abs(y);
      
      // Intermediate directions`
      if (region == "NE") return xLonger ? "start" : "middle";
      if (region == "SE") return xLonger ? "start" : "middle";
      if (region == "SW") return xLonger ? "end" : "middle";
      if (region == "NW") return xLonger ? "end" : "middle"; 
    }
  }
  
  // Adapted from: https://bl.ocks.org/mbostock/7555321
  function wrapText(text, width, orientation) {
    text.each(function(d) {
      var text = d3.select(this),
          words = text.text().split(/\s+/).reverse(),
          word,
          line = [],
          lineNumber = 1,
          lineHeight = 1.1, // ems
          x = text.attr("x"),
          dx = text.attr("dx"),
          tspan = text.text(null).append("tspan").attr("x", x).attr("dx", dx);
      while (word = words.pop()) {
        line.push(word);
        tspan.text(line.join(" "));
        if (tspan.node().getComputedTextLength() > width) {
          line.pop();
          tspan.text(line.join(" "));
          line = [word];
          tspan = text.append("tspan")
            .attr("x", x)
            .attr("dx", dx)
            .attr("dy", lineHeight + "em")
            .text(word);
          lineNumber++;
        }
      }
      
      var dy;
      if (orientation == "bottom") { 
        dy = -lineHeight * (lineNumber-1) - .33; 
      }
      else if (orientation == "top") { 
        dy = 1;
      }
      else { 
        dy = -lineHeight * ((lineNumber-1) / 2) + .33; 
      }
      text.attr("dy", dy + "em");
      
    });
  }
  
  function styleControl(selection) {
    selection
      .attr("r", controlRadius)
      .style("fill-opacity", "0")
      .style("stroke", "black")
      .style("stroke-dasharray", "3, 3")
      .style("cursor", "move");
  }
  
  return draw;
};