an ES2015 fork of the bl.ock HTML Annotation from @armollica
README.md
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 bl.ock.
<html>
<head>
<link rel='stylesheet' href='fontAwesome.css'>
<link rel='stylesheet' href='annotation.css'>
<link rel='stylesheet' href='main.css'>
</head>
<body>
<script src='https://cdnjs.cloudflare.com/ajax/libs/d3/4.2.0/d3.min.js'></script>
<script src='https://d3js.org/topojson.v1.min.js'></script>
<script src='https://d3js.org/d3-geo-projection.v1.min.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.10.3/babel.min.js'></script>
<script src='drawAnnotation.js' lang='babel' type='text/babel'></script>
<script lang='babel' type='text/babel'>
let annotationData;
const width = 960;
const height = 960;
const container = d3.select('body').append('div')
.attr('class', 'container')
.style('position', 'relative');
const canvas = container.append('canvas')
.attr('width', width)
.attr('height', height);
const context = canvas.node().getContext('2d');
const 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)');
const annotationsContainer = container.append('div')
.attr('class', 'annotations');
const 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(0.1);
const graticule = d3.geoGraticule()
.extent([
[-93, 27],
[-47 + 1e-6, 57 + 1e-6]
])
.step([3, 3]);
const path = d3.geoPath()
.projection(projection)
.context(context)
.pointRadius(3);
const colorDomain = [200, 375];
const color = d3.scaleSequential(d3.interpolatePlasma)
.domain(colorDomain);
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 paths
context.globalAlpha = 0.8;
topojson.feature(data, data.objects.voronoi).features
.forEach(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, (a, b) => 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
function celsiusToFahrenheit(c) { return c * 9 / 5 + 32; }
// function fahrenheitToCelsuis(f) { return (f - 32) * 5 / 9; }
const tickWidth = 20;
const gapWidth = 1;
const ticks = legend.selectAll('.tick')
.data(d3.ticks(colorDomain[1] + 20, colorDomain[0], 10))
.enter().append('g')
.attr('class', 'tick')
.attr('transform', (d, i) => `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(d => `${Math.round(celsiusToFahrenheit(d / 10))}°`);
ticks.append('rect')
.attr('width', tickWidth - gapWidth)
.attr('height', tickWidth - gapWidth)
.style('fill', d => color(d))
.style('fill-opacity', 0.8);
}
d3.queue()
.defer(d3.json, 'annotations.json')
.defer(d3.json, 'data.json')
.await(ready);
</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 it works.\n</p>\n<p>\nHover over any annotation on this map, then click on the edit button that appears (<i class=\"fa fa-pencil-square-o\"></i>). You can then 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 nearest 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(d => {
d.x = d.x || 500;
d.y = d.y || 250;
});
const annotation = selection.selectAll('.annotation')
.data(data, d => d);
const annotationEnter = annotation.enter().append('div')
.attr('class', 'annotation')
.style('left', d => `${d.x}px`)
.style('top', d => `${d.y}px`)
.on('mouseenter', mouseenterAnnotation)
.on('mouseleave', mouseleaveAnnotation);
annotation
.style('left', d => `${d.x}px`)
.style('top', d => `${d.y}px`);
annotation.exit()
.remove();
const content = annotationEnter.append('div')
.attr('class', 'content')
.style('width', d => d.width)
.html(d => d.html);
const editButton = annotationEnter.append('span')
.attr('class', 'edit-button fa fa-pencil-square-o')
.attr('aria-hidden', 'true')
.classed('hidden', true)
.on('click', clickEdit);
const controls = annotationEnter.append('div')
.attr('class', 'controls')
.classed('hidden', true);
const doneButton = controls.append('span')
.attr('class', 'done-button control fa fa-check')
.attr('aria-hidden', 'true')
.on('click', clickDone);
const deleteButton = controls.append('span')
.attr('class', 'delete-button control fa fa-trash-o')
.attr('aria-hidden', 'true')
.on('click', clickDelete);
const 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));
const resizeButton = controls.append('span')
.attr('class', 'resize-button control fa fa-arrows-h')
.attr('aria-hidden', 'true')
.call(d3.drag().on('drag', dragResize));
const 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) {
const annotation = d3.select(getAncestor(this, 2));
return annotation.datum().html;
})
.on('input', inputTextarea);
function mouseenterAnnotation() {
const annotation = d3.select(this);
const editButton = annotation.select('.edit-button');
const editable = annotation.datum().editable;
if (!editable) {
editButton.classed('hidden', false);
}
}
function mouseleaveAnnotation() {
const annotation = d3.select(this);
const editButton = annotation.select('.edit-button');
editButton.classed('hidden', true);
}
function clickEdit() {
const annotation = d3.select(getAncestor(this, 1));
const controls = annotation.select('.controls');
const editButton = d3.select(this);
controls.classed('hidden', false);
editButton.classed('hidden', true);
annotation.datum().editable = true;
annotation.call(resizeAnnotation);
}
function clickDone() {
const annotation = d3.select(getAncestor(this, 2));
const 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) {
const 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) {
const annotation = d3.select(getAncestor(this, 2));
const content = annotation.select('.content');
const width = content.node().clientWidth + d3.event.dx;
d.width = width;
content.style('width', d => d.width);
annotation.call(resizeAnnotation);
}
function inputTextarea() {
const annotation = d3.select(getAncestor(this, 2));
const content = annotation.select('.content');
annotation.datum().html = this.value;
content.html(d => 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);
}
}
function drawAnnotations(selection, data) {
// Set default x, y position
data.forEach(d => {
d.x = d.x || 500;
d.y = d.y || 250;
});
const annotation = selection.selectAll('.annotation')
.data(data, d => d);
annotationEnter = annotation.enter().append('div')
.attr('class', 'annotation')
.style('left', d => `${d.x}px`)
.style('top', d => `${d.y}px`)
.on('mouseenter', mouseenterAnnotation)
.on('mouseleave', mouseleaveAnnotation);
annotation
.style('left', d => `${d.x}px`)
.style('top', d => `${d.y}px`);
annotation.exit()
.remove();
const content = annotationEnter.append('div')
.attr('class', 'content')
.style('width', d => d.width)
.html(d => d.html);
const editButton = annotationEnter.append('span')
.attr('class', 'edit-button fa fa-pencil-square-o')
.attr('aria-hidden', 'true')
.classed('hidden', true)
.on('click', clickEdit);
const controls = annotationEnter.append('div')
.attr('class', 'controls')
.classed('hidden', true);
const doneButton = controls.append('span')
.attr('class', 'done-button control fa fa-check')
.attr('aria-hidden', 'true')
.on('click', clickDone);
const deleteButton = controls.append('span')
.attr('class', 'delete-button control fa fa-trash-o')
.attr('aria-hidden', 'true')
.on('click', clickDelete);
const 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));
const resizeButton = controls.append('span')
.attr('class', 'resize-button control fa fa-arrows-h')
.attr('aria-hidden', 'true')
.call(d3.drag().on('drag', dragResize));
const 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) {
const annotation = d3.select(getAncestor(this, 2));
return annotation.datum().html;
})
.on('input', inputTextarea);
function mouseenterAnnotation() {
const annotation = d3.select(this);
const editButton = annotation.select('.edit-button');
const editable = annotation.datum().editable;
if (!editable) {
editButton.classed('hidden', false);
}
}
function mouseleaveAnnotation() {
const annotation = d3.select(this);
const editButton = annotation.select('.edit-button');
editButton.classed('hidden', true);
}
function clickEdit() {
const annotation = d3.select(getAncestor(this, 1));
const controls = annotation.select('.controls');
const editButton = d3.select(this);
controls.classed('hidden', false);
editButton.classed('hidden', true);
annotation.datum().editable = true;
annotation.call(resizeAnnotation);
}
function clickDone() {
const annotation = d3.select(getAncestor(this, 2));
const 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) {
const 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) {
const annotation = d3.select(getAncestor(this, 2));
const content = annotation.select('.content');
const width = content.node().clientWidth + d3.event.dx;
d.width = width;
content.style('width', d => d.width);
annotation.call(resizeAnnotation);
}
function inputTextarea() {
const annotation = d3.select(getAncestor(this, 2));
const content = annotation.select('.content');
annotation.datum().html = this.value;
content.html(d => 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);
}