block by micahstubbs e756eb0244c651183bce09e5848fd8d9

HTML Annotation + ES2015

Full Screen

an ES2015 fork of the bl.ock HTML Annotation from @armollica


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

index.html

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

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

drawAnnotation.js

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

es6.js

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

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