Trying to put something together that makes annotating graphics with HTML easier.
One nice geoprocessing tool I discovered while making this is the mapshaper command-line program The API is easy to figure out and you can do a lot of vector data transformations with it.
Icons from Font Awesome. The map projection is a slight adaptation from the one in this block.
<html>
<head>
<link rel="stylesheet" href="font-awesome.css">
<link rel="stylesheet" href="annotation.css">
<link rel="stylesheet" href="main.css">
</head>
<body>
<script src="//d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/topojson.v2.min.js"></script>
<script src="https://d3js.org/d3-geo-projection.v1.min.js"></script>
<script src="draw-annotation.js"></script>
<script>
var annotationData;
var width = 960,
height = 960;
var container = d3.select("body").append("div")
.attr("class", "container")
.style("position", "relative");
var canvas = container.append("canvas")
.attr("width", width)
.attr("height", height);
var context = canvas.node().getContext("2d");
var legend = container.append("svg")
.attr("class", "legend")
.attr("width", 150)
.attr("height", 300)
.style("position", "absolute")
.style("left", "820px")
.style("top", "300px")
.append("g")
.attr("transform", "translate(30, 30)");
var annotationsContainer = container.append("div")
.attr("class", "annotations");
var projection = d3.geoSatellite()
.distance(1.1)
.scale(5500)
.rotate([80.00, -30.50, 32.12])
.center([-5, 10.2])
.tilt(15)
.clipAngle(Math.acos(1 / 1.1) * 180 / Math.PI - 1e-6)
.precision(.1);
var graticule = d3.geoGraticule()
.extent([[-93, 27], [-47 + 1e-6, 57 + 1e-6]])
.step([3, 3]);
var path = d3.geoPath()
.projection(projection)
.context(context)
.pointRadius(3);
var colorDomain = [200, 375],
color = d3.scaleSequential(d3.interpolatePlasma)
.domain(colorDomain);
d3.queue()
.defer(d3.json, "annotations.json")
.defer(d3.json, "data.json")
.await(ready);
function ready(error, annotations, data) {
if (error) throw error;
// Want to be able to use `copy(annotationData)` in Chrome console
annotationData = annotations;
// Draw annotations
annotationsContainer.call(drawAnnotations, annotations);
// Draw graticule
context.beginPath();
path(graticule());
context.globalAlpha = 0.4;
context.strokeStyle = "#777";
context.stroke();
// Draw voronoi
context.globalAlpha = 0.8;
topojson.feature(data, data.objects.voronoi).features
.forEach(function(feature) {
context.beginPath();
path(feature);
context.fillStyle = color(feature.properties.tmax)
context.fill();
});
// Draw state borders
context.globalAlpha = 0.8;
context.beginPath()
path(topojson.mesh(data, data.objects.states, function(a, b) { return a !== b; }));
context.strokeStyle = "#fff";
context.stroke();
// Draw cities
context.beginPath();
path(data.objects.cities);
context.globalAlpha = 0.5;
context.strokeStyle = "#000";
context.stroke();
context.globalAlpha = 0.3;
context.fillStyle = "#fff";
context.fill();
// Draw legend
var tickWidth = 20,
gapWidth = 1;
var ticks = legend.selectAll(".tick")
.data(d3.ticks(colorDomain[1] + 20, colorDomain[0], 10))
.enter().append("g")
.attr("class", "tick")
.attr("transform", function(d, i) {
return "translate(0," + (i * tickWidth + gapWidth) + ")";
});
ticks.append("line")
.attr("x1", 4)
.attr("transform", "translate(" + (tickWidth - gapWidth) + "," + (tickWidth - gapWidth) / 2 + ")");
ticks.append("text")
.attr("class", "stroke-text")
.attr("dx", tickWidth - gapWidth + 6 + "px")
.attr("dy", 1.2 + "em")
.text(function(d) {
return Math.round(celsiusToFahrenheit(d / 10)) + "°" ;
});
ticks.append("rect")
.attr("width", tickWidth - gapWidth)
.attr("height", tickWidth - gapWidth)
.style("fill", function(d) { return color(d); })
.style("fill-opacity", 0.8);
}
function celsiusToFahrenheit(c) { return c * 9 / 5 + 32; }
function fahrenheitToCelsuis(f) { return (f - 32) * 5 / 9; }
</script>
</body>
</html>
.fa.control,
.annotation .edit-button {
cursor: pointer;
}
.fa.control:hover,
.annotation .edit-button:hover {
color: grey;
}
.annotations {
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
}
.annotation {
position: absolute;
}
.annotation .edit-button {
position: absolute;
right: -3em;
top: -3em;
padding: 2em;
}
.annotation .controls {
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
}
.annotation .control {
position: absolute;
}
.annotation .control.done-button {
right: -1em;
top: -1em;
}
.annotation .control.delete-button {
right: -1em;
top: 0.33em;
}
.annotation .control.move-button {
left: -1em;
top: -1em;
}
.annotation .control.resize-button {
right: -0.5em;
bottom: -0.5em;
}
.annotation textarea.control {
background-color: rgba(255, 255, 255, 0.75);
}
.annotation .hidden {
display: none;
}
[
{
"html": "<div class=\"skewed-x-20\" style=\"width: 70px; height: 440px; border: 1px solid rgba(0,0,0,0.1); box-shadow: 2px 2px 4px 2px rgba(0,0,0,0.1);\">\n\n<div>",
"x": 393,
"y": 261,
"editable": false
},
{
"html": "<div class=\"stroked-text\">\n<h4 class=\"minor-header\">Appalachia stays cool</h4>\n<p class=\"size-12\">\nThe elevation of the Appalachian mountains keeps the heat down. Highs were in the mid-70's in the elevated parts of the region.\n</p>\n</div>",
"x": 429,
"y": 612,
"editable": false,
"width": 155
},
{
"html": "<div class=\"size-16 faded-9 stroked-text\">Louisville</div>",
"x": 187,
"y": 516,
"editable": false
},
{
"html": "<div class=\"size-14 faded-8 stroked-text\">Indianapolis</div>",
"x": 162,
"y": 456,
"editable": false
},
{
"html": "<div class=\"size-12 faded-6 stroked-text\">Detroit</div>",
"x": 240,
"y": 304,
"editable": false
},
{
"html": "<div class=\"size-12 faded-8 stroked-text\">Chicago</div>",
"x": 99,
"y": 433
},
{
"html": "<div class=\"size-12 faded-6 stroked-text\">Milwaukee</div>",
"x": 28,
"y": 395,
"editable": false
},
{
"html": "<div class=\"size-18 stroked-text\">Charlotte</div>",
"x": 521,
"y": 535,
"editable": false
},
{
"html": "<div class=\"size-18 stroked-text\">Jacksonville</div>",
"x": 548,
"y": 928,
"editable": false
},
{
"html": "<div class=\"size-14 faded-8 stroked-text\">Columbus</div>",
"x": 310,
"y": 372,
"editable": false
},
{
"html": "<div class=\"size-16 faded-9 stroked-text\">Washington</div>",
"x": 610,
"y": 308,
"editable": false
},
{
"html": "<div class=\"size-14 faded-8 stroked-text\">Baltimore</div>",
"x": 615,
"y": 267,
"editable": false
},
{
"html": "<div class=\"size-14 faded-7 stroked-text\">Philadelphia</div>",
"x": 656,
"y": 226,
"editable": false
},
{
"html": "<div class=\"size-12 faded-6 stroked-text\">New York</div>",
"x": 678,
"y": 190,
"editable": false
},
{
"html": "<div class=\"size-10 faded-4 stroked-text\">Boston</div>",
"x": 684,
"y": 138,
"editable": false
},
{
"html": "<div class=\"size-18 faded-9 stroked-text\">Raleigh</div>",
"x": 628,
"y": 441,
"editable": false
},
{
"html": "<div class=\"size-18 stroked-text\">Atlanta</div>",
"x": 336,
"y": 775,
"editable": false
},
{
"html": "<div class=\"size-16 faded-9 stroked-text\">Virginia Beach</div>",
"x": 609,
"y": 354,
"editable": false
},
{
"html": "<div class=\"foot-note\">\nWeather data from <a href=\"https://www.ncdc.noaa.gov/cdo-web/datasets\">NOAA</a>\n</div>",
"x": 819,
"y": 940,
"width": 188,
"editable": false
},
{
"html": "<div class=\"text stroked-text\">\n<p>\nAnnotating web graphics with HTML is pretty tedious. The best option right now seems to be Adobe Illustrator coupled with <a href=\"http://ai2html.org/\">ai2html</a>. I should probably just bite the bullet and get Adobe Illustrator. In any case, it would be nice to have a free option, so I've cobbled together something that let's you edit HTML annotations in-place. It's rough but works.\n</p>\n<p>\nHover the annotation on this map and click on the edit button that appears (<i class=\"fa fa-pencil-square-o\"></i>). You edit the inner HTML in the text box. Drag <i class=\"fa fa-arrows\"></i> to re-position and <i class=\"fa fa-arrows-h\"></i> to change the width. Click <i class=\"fa fa-trash-o\"></i> to delete the annotation. Click <i class=\"fa fa-check\"></i> when you're done editing.\n</p>\n</div>",
"x": 720,
"y": 584,
"width": 220,
"editable": false
},
{
"html": "<div class=\"main-text\">\n<p class=\"header\">Summertime Heat</p>\n<p class=\"subheader\">Daily high temperatures, July 22, 2016</p>\n<div class=\"text stroked-text\">\n<p>\nThis July has been brutal here in Washington. I've been stuck indoors and got to thinking about weather data. I found station-level data from <a href=\"https://www.ncdc.noaa.gov/cdo-web/datasets\">NOAA</a> and thought I'd put together a map that shows how hot it got across the eastern US.\n</p>\n<p>\n\n</p>\n</div>\n</div>",
"x": 45,
"y": 37,
"width": 268,
"editable": false
},
{
"html": "<div class=\"size-10 faded-6 stroked-text\">Even the motherland is pretty hot</div>",
"x": 39,
"y": 344,
"editable": false,
"width": 54
},
{
"html": "<div class=\"size-10 faded-4 stroked-text\">\nLet's move to Maine\n</div>",
"x": 635,
"y": 68,
"editable": false,
"width": 61
},
{
"html": "<div class=\"stroked-text\">\n<div class=\"size-16 faded-9\">Daily High Temperature\n</div>\n<div class=\"size-10 faded-7\">Degrees Fahrenheit</div>",
"x": 850,
"y": 274,
"editable": false,
"width": 114
},
{
"html": "<div style=\"text-align: center;\">\n<div class=\"size-12 stroked-text\">Mt. Crest, TN</div>\n<div style=\"color: #ccc;\" class=\"size-22\">↓</div>\n</div>",
"x": 203,
"y": 637,
"editable": false
},
{
"html": "<div style=\"text-align:right;\" class=\"stroked-text\">\n<h4 class=\"minor-header\">Bunk observations</h4>\n<p class=\"size-12\">\nIf the station data is to be trusted, the daily high was just 74°F at the Lafayette, TN station and 70°F at the Mt. Crest, TN station. These seem like a bad readings—the high in these areas hovered around 90°F the previous three days.\n</p>\n</div>",
"x": 28,
"y": 660,
"editable": false,
"width": 175
},
{
"html": "<div style=\"text-align: center;\">\n<div class=\"size-12 stroked-text\">Lafayette, TN</div>\n<div style=\"color: #ccc;\" class=\"size-22\">↓</div>\n</div>",
"x": 151,
"y": 592,
"editable": false,
"width": 76
},
{
"html": "<div class=\"size-12 faded-7 stroked-text\">\n<p>\nArea is partitioned by the closest observation station (see <a href=\"https://github.com/d3/d3-voronoi\">d3-voronoi</a>).\n</p>\n</div>",
"x": 789,
"y": 159,
"editable": false,
"width": 147
},
{
"html": "<div class=\"size-12 faded-9 stroked-text\">\nAlmost 100°F in New York City and its suburbs.\n</div>",
"x": 565,
"y": 160,
"editable": false,
"width": 95
}
]
function drawAnnotations(selection, data) {
// Set default x, y position
data.forEach(function(d) {
d.x = d.x || 500;
d.y = d.y || 250;
});
var annotation = selection.selectAll(".annotation")
.data(data, function(d) { return d; });
annotationEnter = annotation.enter().append("div")
.attr("class", "annotation")
.style("left", function(d) { return d.x + "px"; })
.style("top", function(d) { return d.y + "px"; })
.on("mouseenter", mouseenterAnnotation)
.on("mouseleave", mouseleaveAnnotation);
annotation
.style("left", function(d) { return d.x + "px"; })
.style("top", function(d) { return d.y + "px"; });
annotation.exit()
.remove();
var content = annotationEnter.append("div")
.attr("class", "content")
.style("width", function(d) { return d.width; })
.html(function(d) { return d.html; });
var editButton = annotationEnter.append("span")
.attr("class", "edit-button fa fa-pencil-square-o")
.attr("aria-hidden", "true")
.classed("hidden", true)
.on("click", clickEdit);
var controls = annotationEnter.append("div")
.attr("class", "controls")
.classed("hidden", true);
var doneButton = controls.append("span")
.attr("class", "done-button control fa fa-check")
.attr("aria-hidden", "true")
.on("click", clickDone);
var deleteButton = controls.append("span")
.attr("class", "delete-button control fa fa-trash-o")
.attr("aria-hidden", "true")
.on("click", clickDelete);
var moveButton = controls.append("span")
.attr("class", "move-button control fa fa-arrows")
.attr("aria-hidden", "true")
.call(d3.drag().subject({x: 0, y: 0}).on("drag", dragMove));
var resizeButton = controls.append("span")
.attr("class", "resize-button control fa fa-arrows-h")
.attr("aria-hidden", "true")
.call(d3.drag().on("drag", dragResize));
var textarea = controls.append("textarea")
.datum({ hidden: false })
.attr("class", "control")
.attr("rows", 4)
.attr("cols", 30)
.style("top", function() {
return this.parentNode.clientHeight + 10 + "px";
})
.html(function(d) {
var annotation = d3.select(getAncestor(this, 2));
return annotation.datum().html;
})
.on("input", inputTextarea);
function mouseenterAnnotation() {
var annotation = d3.select(this),
editButton = annotation.select(".edit-button"),
editable = annotation.datum().editable;
if (!editable) {
editButton.classed("hidden", false);
}
}
function mouseleaveAnnotation() {
var annotation = d3.select(this),
editButton = annotation.select(".edit-button");
editButton.classed("hidden", true);
}
function clickEdit() {
var annotation = d3.select(getAncestor(this, 1)),
controls = annotation.select(".controls"),
editButton = d3.select(this);
controls.classed("hidden", false);
editButton.classed("hidden", true);
annotation.datum().editable = true;
annotation.call(resizeAnnotation);
}
function clickDone() {
var annotation = d3.select(getAncestor(this, 2)),
controls = annotation.select(".controls");
controls.classed("hidden", true);
annotation.datum().editable = false;
}
function clickDelete(d) {
data.splice(data.indexOf(d), 1);
selection.call(drawAnnotations, data);
}
function dragMove(d) {
var annotation = d3.select(getAncestor(this, 2));
annotation
.style("left", (d.x += d3.event.x) + "px")
.style("top", (d.y += d3.event.y) + "px");
}
function dragResize(d) {
var annotation = d3.select(getAncestor(this, 2)),
content = annotation.select(".content"),
width = content.node().clientWidth + d3.event.dx;
d.width = width;
content.style("width", function(d) { return d.width; });
annotation.call(resizeAnnotation);
}
function inputTextarea() {
var annotation = d3.select(getAncestor(this, 2)),
content = annotation.select(".content");
annotation.datum().html = this.value;
content.html(function(d) { return d.html; });
annotation.call(resizeAnnotation);
}
function resizeAnnotation(selection) {
// Move the <textarea> a smidge below the annotation content
selection.select("textarea.control")
.style("top", function() {
return this.parentNode.clientHeight + 10 + "px";
});
}
function getAncestor(node, level) {
return level === 0 ? node : getAncestor(node.parentNode, level - 1);
}
}
html { font-family: arial, Helvetica, sans-serif; }
h1, h2, h3, h4, h5, h6 { margin: 0; }
p { margin: 0 0 1em 0; }
a {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted slategrey;
}
.faded-1 { opacity: 0.1; }
.faded-2 { opacity: 0.2; }
.faded-3 { opacity: 0.3; }
.faded-4 { opacity: 0.4; }
.faded-5 { opacity: 0.5; }
.faded-6 { opacity: 0.6; }
.faded-7 { opacity: 0.7; }
.faded-8 { opacity: 0.8; }
.faded-9 { opacity: 0.9; }
.size-8 { font-size: 8px; }
.size-10 { font-size: 10px; }
.size-12 { font-size: 12px; }
.size-14 { font-size: 14px; }
.size-16 { font-size: 16px; }
.size-18 { font-size: 18px; }
.size-20 { font-size: 20px; }
.size-22 { font-size: 22px; }
.stroked-text {
text-shadow:
-1px -1px 2px #fff,
1px -1px 2px #fff,
-1px 1px 2px #fff,
1px 1px 2px #fff;
}
/* Adapted from NYT's website */
.header {
font-size: 24px;
font-size: 1.5rem;
line-height: 28px;
line-height: 1.75rem;
font-weight: 300;
font-style: normal;
margin-bottom: 5px;
color: #666;
}
.subheader {
font-size: 14px;
font-size: 0.875rem;
line-height: 18px;
line-height: 1.125rem;
font-weight: 400;
font-style: normal;
margin-bottom: 10px;
color: #666;
}
.minor-header {
font-size: 14px;
font-size: 0.875rem;
line-height: 18px;
line-height: 1.125rem;
font-weight: 300;
font-style: normal;
margin-bottom: 5px;
}
.text {
font-size: 14px;
font-size: 0.875rem;
color: #444;
}
.foot-note {
font-size: 12px;
color: slategrey;
}
.legend line {
opacity: 0.7;
stroke: #000;
}
.legend text {
font-size: 12px;
}
.skewed-x-20 {
transform: skewX(-20deg);
-webkit-transform: skewX(-20deg);
-ms-transform: skewX(-20deg);
}