block by armollica 78894d0b3cbd46d8d8d19d135c6ca34d

HTML Annotation

Full Screen

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.

index.html

<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>

annotation.css

.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;
}

annotations.json

[
  {
    "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
  }
]

draw-annotation.js

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);
	}
}

main.css

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);
}