diff --git a/fabfile/__init__.py b/fabfile/__init__.py
index 6321868b..7984c448 100644
--- a/fabfile/__init__.py
+++ b/fabfile/__init__.py
@@ -373,6 +373,13 @@ def add_animated_photo(slug):
"""
_add_graphic(slug, 'animated_photo')
+@task
+def add_annotated_line_chart(slug):
+ """
+ Create an annotated line chart.
+ """
+ _add_graphic(slug, 'annotated_line_chart')
+
@task
def add_archive_graphic(slug):
"""
diff --git a/graphic_templates/annotated_line_chart/child_template.html b/graphic_templates/annotated_line_chart/child_template.html
new file mode 100644
index 00000000..2c5f28a0
--- /dev/null
+++ b/graphic_templates/annotated_line_chart/child_template.html
@@ -0,0 +1,32 @@
+{% extends 'base_template.html' %}
+
+{% block content %}
+
+ {% if COPY.labels.headline %}
{{ COPY.labels.headline|smarty }}
{% endif %}
+ {% if COPY.labels.subhed %}{{ render(COPY.labels.subhed)|smarty }}
{% endif %}
+
+
+

+
+
+ {% if COPY.labels.footnote %}
+
+ {% endif %}
+
+
+
+
+
+{% endblock content %}
diff --git a/graphic_templates/annotated_line_chart/css/graphic.less b/graphic_templates/annotated_line_chart/css/graphic.less
new file mode 100644
index 00000000..3c19294d
--- /dev/null
+++ b/graphic_templates/annotated_line_chart/css/graphic.less
@@ -0,0 +1,38 @@
+@import "base";
+
+.lines {
+ fill: none;
+ stroke-width: 3px;
+ stroke: #ccc;
+}
+
+.value text {
+ font-size: 12px;
+ font-weight: bold;
+ fill: #999;
+}
+
+.annotations .dots {
+ stroke: #fff;
+ stroke-width: 1px;
+}
+
+.annotations text {
+ font-size: 12px;
+ fill: #999;
+}
+
+/*
+ * larger-than-mobile-screen styles
+ */
+@media @screen-mobile-above {
+ .key { display: none; }
+}
+
+/*
+ * small-screen styles
+ */
+@media @screen-mobile {
+ .value text { font-size: 10px; }
+ .annotations text { font-size: 10px; }
+}
diff --git a/graphic_templates/annotated_line_chart/graphic_config.py b/graphic_templates/annotated_line_chart/graphic_config.py
new file mode 100644
index 00000000..d46dbda0
--- /dev/null
+++ b/graphic_templates/annotated_line_chart/graphic_config.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python
+
+import base_filters
+
+COPY_GOOGLE_DOC_KEY = '1wq0oi5HfgfYBdDs32-Qs77xmnI9VMXPUgRHA8lKIRmM'
+
+USE_ASSETS = False
+
+# Use these variables to override the default cache timeouts for this graphic
+# DEFAULT_MAX_AGE = 20
+# ASSETS_MAX_AGE = 300
+
+JINJA_FILTER_FUNCTIONS = base_filters.FILTERS
diff --git a/graphic_templates/annotated_line_chart/js/graphic.js b/graphic_templates/annotated_line_chart/js/graphic.js
new file mode 100644
index 00000000..17f52c49
--- /dev/null
+++ b/graphic_templates/annotated_line_chart/js/graphic.js
@@ -0,0 +1,422 @@
+// Global vars
+var pymChild = null;
+var isMobile = false;
+var dataSeries = [];
+var annotations = [];
+var skipLabels = ["date", "annotate", "x_offset", "y_offset"];
+
+/*
+ * Initialize graphic
+ */
+var onWindowLoaded = function() {
+ formatData();
+
+ pymChild = new pym.Child({
+ renderCallback: render
+ });
+
+ pymChild.onMessage("on-screen", function(bucket) {
+ ANALYTICS.trackEvent("on-screen", bucket);
+ });
+ pymChild.onMessage("scroll-depth", function(data) {
+ data = JSON.parse(data);
+ ANALYTICS.trackEvent("scroll-depth", data.percent, data.seconds);
+ });
+};
+
+/*
+ * Format graphic data for processing by D3.
+ */
+var formatData = function() {
+ DATA.forEach(function(d) {
+ d["date"] = d3.time.format("%m/%d/%y").parse(d["date"]);
+
+ for (var key in d) {
+ if (!skipLabels.includes(key) && d[key] != null && d[key].length > 0) {
+ d[key] = Number(d[key]);
+
+ // Annotations
+ var hasAnnotation = !!d["annotate"];
+ if (hasAnnotation) {
+
+ var hasCustomLabel = d["annotate"].toLowerCase() != "true";
+ var label = hasCustomLabel ? d["annotate"] : null;
+
+ var xOffset = Number(d["x_offset"]) || 0;
+ var yOffset = Number(d["y_offset"]) || 0;
+
+ annotations.push({
+ date: d["date"],
+ amt: d[key],
+ series: key,
+ xOffset: xOffset,
+ yOffset: yOffset,
+ label: label
+ });
+ }
+ }
+ }
+ });
+
+ /*
+ * Restructure tabular data for easier charting.
+ */
+ for (var column in DATA[0]) {
+ if (skipLabels.includes(column)) {
+ continue;
+ }
+
+ dataSeries.push({
+ name: column,
+ values: DATA.map(function(d) {
+ return {
+ date: d["date"],
+ amt: d[column]
+ };
+ // filter out empty data. uncomment this if you have inconsistent data.
+ // }).filter(function(d) {
+ // return d['amt'] != null;
+ })
+ });
+ }
+};
+
+/*
+ * Render the graphic(s). Called by pym with the container width.
+ */
+var render = function(containerWidth) {
+ if (!containerWidth) {
+ containerWidth = DEFAULT_WIDTH;
+ }
+
+ if (containerWidth <= MOBILE_THRESHOLD) {
+ isMobile = true;
+ } else {
+ isMobile = false;
+ }
+
+ // Render the chart!
+ renderLineChart({
+ container: "#annotated-line-chart",
+ width: containerWidth,
+ data: dataSeries,
+ annotations: annotations
+ });
+
+ // Update iframe
+ if (pymChild) {
+ pymChild.sendHeight();
+ }
+};
+
+/*
+ * Render a line chart.
+ */
+var renderLineChart = function(config) {
+ /*
+ * Setup
+ */
+ var dateColumn = "date";
+ var valueColumn = "amt";
+
+ var aspectWidth = 16;
+ var aspectHeight = 9;
+
+ var margins = {
+ top: 5,
+ right: 75,
+ bottom: 20,
+ left: 30
+ };
+
+ var ticksX = 10;
+ var ticksY = 10;
+ var roundTicksFactor = 5;
+
+ var annotationXOffset = -4;
+ var annotationYOffset = -24;
+ var annotationWidth = 80;
+ var annotationLineHeight = 14;
+
+ // Mobile
+ if (isMobile) {
+ aspectWidth = 4;
+ aspectHeight = 3;
+ ticksX = 5;
+ ticksY = 5;
+ margins["right"] = 25;
+ annotationXOffset = -6;
+ annotationYOffset = -20;
+ annotationWidth = 72;
+ annotationLineHeight = 12;
+ }
+
+ // Calculate actual chart dimensions
+ var chartWidth = config["width"] - margins["left"] - margins["right"];
+ var chartHeight =
+ Math.ceil((config["width"] * aspectHeight) / aspectWidth) -
+ margins["top"] -
+ margins["bottom"];
+
+ // Clear existing graphic (for redraw)
+ var containerElement = d3.select(config["container"]);
+ containerElement.html("");
+
+ /*
+ * Create D3 scale objects.
+ */
+ var xScale = d3.time
+ .scale()
+ .domain(
+ d3.extent(config["data"][0]["values"], function(d) {
+ return d["date"];
+ })
+ )
+ .range([0, chartWidth]);
+
+ var min = d3.min(config["data"], function(d) {
+ return d3.min(d["values"], function(v) {
+ return Math.floor(v[valueColumn] / roundTicksFactor) * roundTicksFactor;
+ });
+ });
+
+ if (min > 0) {
+ min = 0;
+ }
+
+ var max = d3.max(config["data"], function(d) {
+ return d3.max(d["values"], function(v) {
+ return Math.ceil(v[valueColumn] / roundTicksFactor) * roundTicksFactor;
+ });
+ });
+
+ var yScale = d3.scale
+ .linear()
+ .domain([min, max])
+ .range([chartHeight, 0]);
+
+ var colorScale = d3.scale
+ .ordinal()
+ .domain(
+ config.data.map(function(d) {
+ return d.name;
+ })
+ )
+ .range([
+ COLORS["red3"],
+ COLORS["yellow3"],
+ COLORS["blue3"],
+ COLORS["orange3"],
+ COLORS["teal3"]
+ ]);
+
+ /*
+ * Render the HTML legend.
+ */
+ // var legend = containerElement
+ // .append("ul")
+ // .attr("class", "key")
+ // .selectAll("g")
+ // .data(config["data"])
+ // .enter()
+ // .append("li")
+ // .attr("class", function(d, i) {
+ // return "key-item " + classify(d["name"]);
+ // });
+
+ // legend.append("b").style("background-color", function(d) {
+ // return colorScale(d["name"]);
+ // });
+
+ // legend.append("label").text(function(d) {
+ // return d["name"];
+ // });
+
+ /*
+ * Create the root SVG element.
+ */
+ var chartWrapper = containerElement
+ .append("div")
+ .attr("class", "graphic-wrapper");
+
+ var chartElement = chartWrapper
+ .append("svg")
+ .attr("width", chartWidth + margins["left"] + margins["right"])
+ .attr("height", chartHeight + margins["top"] + margins["bottom"])
+ .append("g")
+ .attr(
+ "transform",
+ "translate(" + margins["left"] + "," + margins["top"] + ")"
+ );
+
+ /*
+ * Create D3 axes.
+ */
+ var xAxis = d3.svg
+ .axis()
+ .scale(xScale)
+ .orient("bottom")
+ .ticks(ticksX)
+ .tickFormat(function(d, i) {
+ if (isMobile) {
+ return "\u2019" + fmtYearAbbrev(d);
+ } else {
+ return fmtYearFull(d);
+ }
+ });
+
+ var yAxis = d3.svg
+ .axis()
+ .scale(yScale)
+ .orient("left")
+ .ticks(ticksY);
+
+ /*
+ * Render axes to chart.
+ */
+ chartElement
+ .append("g")
+ .attr("class", "x axis")
+ .attr("transform", makeTranslate(0, chartHeight))
+ .call(xAxis);
+
+ chartElement
+ .append("g")
+ .attr("class", "y axis")
+ .call(yAxis);
+
+ /*
+ * Render grid to chart.
+ */
+ var xAxisGrid = function() {
+ return xAxis;
+ };
+
+ var yAxisGrid = function() {
+ return yAxis;
+ };
+
+ chartElement
+ .append("g")
+ .attr("class", "x grid")
+ .attr("transform", makeTranslate(0, chartHeight))
+ .call(
+ xAxisGrid()
+ .tickSize(-chartHeight, 0, 0)
+ .tickFormat("")
+ );
+
+ chartElement
+ .append("g")
+ .attr("class", "y grid")
+ .call(
+ yAxisGrid()
+ .tickSize(-chartWidth, 0, 0)
+ .tickFormat("")
+ );
+
+ /*
+ * Render 0 value line.
+ */
+ if (min < 0) {
+ chartElement
+ .append("line")
+ .attr("class", "zero-line")
+ .attr("x1", 0)
+ .attr("x2", chartWidth)
+ .attr("y1", yScale(0))
+ .attr("y2", yScale(0));
+ }
+
+ /*
+ * Render lines to chart.
+ */
+ var line = d3.svg
+ .line()
+ .interpolate("monotone")
+ .x(function(d) {
+ return xScale(d[dateColumn]);
+ })
+ .y(function(d) {
+ return yScale(d[valueColumn]);
+ });
+
+ chartElement
+ .append("g")
+ .attr("class", "lines")
+ .selectAll("path")
+ .data(config["data"])
+ .enter()
+ .append("path")
+ .attr("class", function(d, i) {
+ return "line " + classify(d["name"]);
+ })
+ .attr("stroke", function(d) {
+ return colorScale(d["name"]);
+ })
+ .attr("d", function(d) {
+ return line(d["values"]);
+ });
+
+ chartElement
+ .append("g")
+ .attr("class", "value")
+ .selectAll("text")
+ .data(config["data"])
+ .enter()
+ .append("text")
+ .attr("x", function(d, i) {
+ var last = d["values"][d["values"].length - 1];
+ return xScale(last[dateColumn]) + 5;
+ })
+ .attr("y", function(d) {
+ var last = d["values"][d["values"].length - 1];
+ return yScale(last[valueColumn]) + 3;
+ });
+
+ /*
+ * Render annotations.
+ */
+ var annotation = chartElement
+ .append("g")
+ .attr("class", "annotations")
+ .selectAll("circle")
+ .data(config.annotations)
+ .enter();
+
+ annotation
+ .append("circle")
+ .attr("class", "dots")
+ .attr("cx", function(d) {
+ return xScale(d[dateColumn]);
+ })
+ .attr("cy", function(d) {
+ return yScale(d[valueColumn]);
+ })
+ .attr("fill", function(d) {
+ return colorScale(d["series"]);
+ })
+ .attr("r", 3);
+
+ annotation
+ .append("text")
+ .html(function(d) {
+ var hasCustomLabel = d["label"] != null && d["label"].length > 0;
+ var text = hasCustomLabel ? d["label"] : formatFullDate(d[dateColumn]);
+ var value = d[valueColumn].toFixed(2);
+ return text + " " + value;
+ })
+ .attr("x", function(d, i) {
+ return xScale(d[dateColumn]) + d["xOffset"] + annotationXOffset;
+ })
+ .attr("y", function(d, i) {
+ return yScale(d[valueColumn]) + d["yOffset"] + annotationYOffset;
+ })
+ .call(wrapText, annotationWidth, annotationLineHeight);
+};
+
+/*
+ * Initially load the graphic
+ * (NB: Use window.load to ensure all images have loaded)
+ */
+window.onload = onWindowLoaded;