Skip to content

Instantly share code, notes, and snippets.

@armollica
Last active May 24, 2016 23:52
Show Gist options
  • Save armollica/853c9344a7808619d863c8d7410062e6 to your computer and use it in GitHub Desktop.
Save armollica/853c9344a7808619d863c8d7410062e6 to your computer and use it in GitHub Desktop.
Chart Annotation

Example using the d3.ringNote plugin to add circle annotations to a chart. 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.

The chart shows the eruption duration and waiting time between eruptions for the Old Faithful geyser in Yellowstone National Park. This is from the datasets R package.

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;
};
[{"eruptions":3.6,"waiting":79},{"eruptions":1.8,"waiting":54},{"eruptions":3.333,"waiting":74},{"eruptions":2.283,"waiting":62},{"eruptions":4.533,"waiting":85},{"eruptions":2.883,"waiting":55},{"eruptions":4.7,"waiting":88},{"eruptions":3.6,"waiting":85},{"eruptions":1.95,"waiting":51},{"eruptions":4.35,"waiting":85},{"eruptions":1.833,"waiting":54},{"eruptions":3.917,"waiting":84},{"eruptions":4.2,"waiting":78},{"eruptions":1.75,"waiting":47},{"eruptions":4.7,"waiting":83},{"eruptions":2.167,"waiting":52},{"eruptions":1.75,"waiting":62},{"eruptions":4.8,"waiting":84},{"eruptions":1.6,"waiting":52},{"eruptions":4.25,"waiting":79},{"eruptions":1.8,"waiting":51},{"eruptions":1.75,"waiting":47},{"eruptions":3.45,"waiting":78},{"eruptions":3.067,"waiting":69},{"eruptions":4.533,"waiting":74},{"eruptions":3.6,"waiting":83},{"eruptions":1.967,"waiting":55},{"eruptions":4.083,"waiting":76},{"eruptions":3.85,"waiting":78},{"eruptions":4.433,"waiting":79},{"eruptions":4.3,"waiting":73},{"eruptions":4.467,"waiting":77},{"eruptions":3.367,"waiting":66},{"eruptions":4.033,"waiting":80},{"eruptions":3.833,"waiting":74},{"eruptions":2.017,"waiting":52},{"eruptions":1.867,"waiting":48},{"eruptions":4.833,"waiting":80},{"eruptions":1.833,"waiting":59},{"eruptions":4.783,"waiting":90},{"eruptions":4.35,"waiting":80},{"eruptions":1.883,"waiting":58},{"eruptions":4.567,"waiting":84},{"eruptions":1.75,"waiting":58},{"eruptions":4.533,"waiting":73},{"eruptions":3.317,"waiting":83},{"eruptions":3.833,"waiting":64},{"eruptions":2.1,"waiting":53},{"eruptions":4.633,"waiting":82},{"eruptions":2,"waiting":59},{"eruptions":4.8,"waiting":75},{"eruptions":4.716,"waiting":90},{"eruptions":1.833,"waiting":54},{"eruptions":4.833,"waiting":80},{"eruptions":1.733,"waiting":54},{"eruptions":4.883,"waiting":83},{"eruptions":3.717,"waiting":71},{"eruptions":1.667,"waiting":64},{"eruptions":4.567,"waiting":77},{"eruptions":4.317,"waiting":81},{"eruptions":2.233,"waiting":59},{"eruptions":4.5,"waiting":84},{"eruptions":1.75,"waiting":48},{"eruptions":4.8,"waiting":82},{"eruptions":1.817,"waiting":60},{"eruptions":4.4,"waiting":92},{"eruptions":4.167,"waiting":78},{"eruptions":4.7,"waiting":78},{"eruptions":2.067,"waiting":65},{"eruptions":4.7,"waiting":73},{"eruptions":4.033,"waiting":82},{"eruptions":1.967,"waiting":56},{"eruptions":4.5,"waiting":79},{"eruptions":4,"waiting":71},{"eruptions":1.983,"waiting":62},{"eruptions":5.067,"waiting":76},{"eruptions":2.017,"waiting":60},{"eruptions":4.567,"waiting":78},{"eruptions":3.883,"waiting":76},{"eruptions":3.6,"waiting":83},{"eruptions":4.133,"waiting":75},{"eruptions":4.333,"waiting":82},{"eruptions":4.1,"waiting":70},{"eruptions":2.633,"waiting":65},{"eruptions":4.067,"waiting":73},{"eruptions":4.933,"waiting":88},{"eruptions":3.95,"waiting":76},{"eruptions":4.517,"waiting":80},{"eruptions":2.167,"waiting":48},{"eruptions":4,"waiting":86},{"eruptions":2.2,"waiting":60},{"eruptions":4.333,"waiting":90},{"eruptions":1.867,"waiting":50},{"eruptions":4.817,"waiting":78},{"eruptions":1.833,"waiting":63},{"eruptions":4.3,"waiting":72},{"eruptions":4.667,"waiting":84},{"eruptions":3.75,"waiting":75},{"eruptions":1.867,"waiting":51},{"eruptions":4.9,"waiting":82},{"eruptions":2.483,"waiting":62},{"eruptions":4.367,"waiting":88},{"eruptions":2.1,"waiting":49},{"eruptions":4.5,"waiting":83},{"eruptions":4.05,"waiting":81},{"eruptions":1.867,"waiting":47},{"eruptions":4.7,"waiting":84},{"eruptions":1.783,"waiting":52},{"eruptions":4.85,"waiting":86},{"eruptions":3.683,"waiting":81},{"eruptions":4.733,"waiting":75},{"eruptions":2.3,"waiting":59},{"eruptions":4.9,"waiting":89},{"eruptions":4.417,"waiting":79},{"eruptions":1.7,"waiting":59},{"eruptions":4.633,"waiting":81},{"eruptions":2.317,"waiting":50},{"eruptions":4.6,"waiting":85},{"eruptions":1.817,"waiting":59},{"eruptions":4.417,"waiting":87},{"eruptions":2.617,"waiting":53},{"eruptions":4.067,"waiting":69},{"eruptions":4.25,"waiting":77},{"eruptions":1.967,"waiting":56},{"eruptions":4.6,"waiting":88},{"eruptions":3.767,"waiting":81},{"eruptions":1.917,"waiting":45},{"eruptions":4.5,"waiting":82},{"eruptions":2.267,"waiting":55},{"eruptions":4.65,"waiting":90},{"eruptions":1.867,"waiting":45},{"eruptions":4.167,"waiting":83},{"eruptions":2.8,"waiting":56},{"eruptions":4.333,"waiting":89},{"eruptions":1.833,"waiting":46},{"eruptions":4.383,"waiting":82},{"eruptions":1.883,"waiting":51},{"eruptions":4.933,"waiting":86},{"eruptions":2.033,"waiting":53},{"eruptions":3.733,"waiting":79},{"eruptions":4.233,"waiting":81},{"eruptions":2.233,"waiting":60},{"eruptions":4.533,"waiting":82},{"eruptions":4.817,"waiting":77},{"eruptions":4.333,"waiting":76},{"eruptions":1.983,"waiting":59},{"eruptions":4.633,"waiting":80},{"eruptions":2.017,"waiting":49},{"eruptions":5.1,"waiting":96},{"eruptions":1.8,"waiting":53},{"eruptions":5.033,"waiting":77},{"eruptions":4,"waiting":77},{"eruptions":2.4,"waiting":65},{"eruptions":4.6,"waiting":81},{"eruptions":3.567,"waiting":71},{"eruptions":4,"waiting":70},{"eruptions":4.5,"waiting":81},{"eruptions":4.083,"waiting":93},{"eruptions":1.8,"waiting":53},{"eruptions":3.967,"waiting":89},{"eruptions":2.2,"waiting":45},{"eruptions":4.15,"waiting":86},{"eruptions":2,"waiting":58},{"eruptions":3.833,"waiting":78},{"eruptions":3.5,"waiting":66},{"eruptions":4.583,"waiting":76},{"eruptions":2.367,"waiting":63},{"eruptions":5,"waiting":88},{"eruptions":1.933,"waiting":52},{"eruptions":4.617,"waiting":93},{"eruptions":1.917,"waiting":49},{"eruptions":2.083,"waiting":57},{"eruptions":4.583,"waiting":77},{"eruptions":3.333,"waiting":68},{"eruptions":4.167,"waiting":81},{"eruptions":4.333,"waiting":81},{"eruptions":4.5,"waiting":73},{"eruptions":2.417,"waiting":50},{"eruptions":4,"waiting":85},{"eruptions":4.167,"waiting":74},{"eruptions":1.883,"waiting":55},{"eruptions":4.583,"waiting":77},{"eruptions":4.25,"waiting":83},{"eruptions":3.767,"waiting":83},{"eruptions":2.033,"waiting":51},{"eruptions":4.433,"waiting":78},{"eruptions":4.083,"waiting":84},{"eruptions":1.833,"waiting":46},{"eruptions":4.417,"waiting":83},{"eruptions":2.183,"waiting":55},{"eruptions":4.8,"waiting":81},{"eruptions":1.833,"waiting":57},{"eruptions":4.8,"waiting":76},{"eruptions":4.1,"waiting":84},{"eruptions":3.966,"waiting":77},{"eruptions":4.233,"waiting":81},{"eruptions":3.5,"waiting":87},{"eruptions":4.366,"waiting":77},{"eruptions":2.25,"waiting":51},{"eruptions":4.667,"waiting":78},{"eruptions":2.1,"waiting":60},{"eruptions":4.35,"waiting":82},{"eruptions":4.133,"waiting":91},{"eruptions":1.867,"waiting":53},{"eruptions":4.6,"waiting":78},{"eruptions":1.783,"waiting":46},{"eruptions":4.367,"waiting":77},{"eruptions":3.85,"waiting":84},{"eruptions":1.933,"waiting":49},{"eruptions":4.5,"waiting":83},{"eruptions":2.383,"waiting":71},{"eruptions":4.7,"waiting":80},{"eruptions":1.867,"waiting":49},{"eruptions":3.833,"waiting":75},{"eruptions":3.417,"waiting":64},{"eruptions":4.233,"waiting":76},{"eruptions":2.4,"waiting":53},{"eruptions":4.8,"waiting":94},{"eruptions":2,"waiting":55},{"eruptions":4.15,"waiting":76},{"eruptions":1.867,"waiting":50},{"eruptions":4.267,"waiting":82},{"eruptions":1.75,"waiting":54},{"eruptions":4.483,"waiting":75},{"eruptions":4,"waiting":78},{"eruptions":4.117,"waiting":79},{"eruptions":4.083,"waiting":78},{"eruptions":4.267,"waiting":78},{"eruptions":3.917,"waiting":70},{"eruptions":4.55,"waiting":79},{"eruptions":4.083,"waiting":70},{"eruptions":2.417,"waiting":54},{"eruptions":4.183,"waiting":86},{"eruptions":2.217,"waiting":50},{"eruptions":4.45,"waiting":90},{"eruptions":1.883,"waiting":54},{"eruptions":1.85,"waiting":54},{"eruptions":4.283,"waiting":77},{"eruptions":3.95,"waiting":79},{"eruptions":2.333,"waiting":64},{"eruptions":4.15,"waiting":75},{"eruptions":2.35,"waiting":47},{"eruptions":4.933,"waiting":86},{"eruptions":2.9,"waiting":63},{"eruptions":4.583,"waiting":85},{"eruptions":3.833,"waiting":82},{"eruptions":2.083,"waiting":57},{"eruptions":4.367,"waiting":82},{"eruptions":2.133,"waiting":67},{"eruptions":4.35,"waiting":74},{"eruptions":2.2,"waiting":54},{"eruptions":4.45,"waiting":83},{"eruptions":3.567,"waiting":73},{"eruptions":4.5,"waiting":73},{"eruptions":4.15,"waiting":88},{"eruptions":3.817,"waiting":80},{"eruptions":3.917,"waiting":71},{"eruptions":4.45,"waiting":83},{"eruptions":2,"waiting":56},{"eruptions":4.283,"waiting":79},{"eruptions":4.767,"waiting":78},{"eruptions":4.533,"waiting":84},{"eruptions":1.85,"waiting":58},{"eruptions":4.25,"waiting":83},{"eruptions":1.983,"waiting":43},{"eruptions":2.25,"waiting":60},{"eruptions":4.75,"waiting":75},{"eruptions":4.117,"waiting":81},{"eruptions":2.15,"waiting":46},{"eruptions":4.417,"waiting":90},{"eruptions":1.817,"waiting":46},{"eruptions":4.467,"waiting":74}]
<html>
<head>
<style>
body {
font: 12px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.annotation circle {
fill: none;
stroke: darkslategrey;
}
.annotation path {
fill: none;
stroke: darkslategrey;
shape-rendering: crispEdges;
}
.annotation text {
text-shadow: -2px 0 2px #fff,
0 2px 2px #fff,
2px 0 2px #fff,
0 -2px 2px #fff;
}
</style>
</head>
<body>
<script src="//d3js.org/d3.v3.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": 625,
"cy": 111,
"r": 109,
"text": "The longer Old Faithful lays dormant, the longer the eruption last",
"textWidth": 140,
"textOffset": [
121,
186
]
}
];
var margin = {top: 20, right: 20, bottom: 30, left: 40},
width = 960 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var x = function(d) { return d.waiting; },
y = function(d) { return d.eruptions; }
var xScale = d3.scale.linear().range([0, width]),
yScale = d3.scale.linear().range([height, 0]);
var xValue = function(d) { return xScale(x(d)); },
yValue = function(d) { return yScale(y(d)); };
var xAxis = d3.svg.axis().scale(xScale).orient("bottom"),
yAxis = d3.svg.axis().scale(yScale).orient("left");
var svg = d3.select("body").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 ringNote = d3.ringNote()
.draggable(true);
d3.json("faithful.json", function(error, data) {
if (error) throw error;
xScale.domain(d3.extent(data, x));
yScale.domain(d3.extent(data, y));
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis)
.append("text")
.attr("class", "label")
.attr("x", width)
.attr("y", -6)
.style("text-anchor", "end")
.text("Time Between Eruptions (minutes)");
svg.append("g")
.attr("class", "y axis")
.call(yAxis)
.append("text")
.attr("class", "label")
.attr("transform", "rotate(-90)")
.attr("y", 6)
.attr("dy", ".71em")
.style("text-anchor", "end")
.text("Eruption Duration (minutes)");
svg.selectAll(".dot")
.data(data)
.enter().append("circle")
.attr("class", "dot")
.attr("r", 3)
.attr("cx", xValue)
.attr("cy", yValue);
svg.append("g")
.attr("class", "annotations")
.call(ringNote, annotations);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment