An Alternative to Choropleth: Contour Density Maps in D3.js

Ellie Frymire
Two-N
Published in
7 min readSep 1, 2020

by Ellie Frymire, Data Visualization Developer, Two-N

Note: In this article, we present an argument for using contour maps to represent graphically continuous data, and follow up with a technical how-to on implementation using d3.js. Although the first section is written towards for all audiences as a discussion of visual theory, the development section is written towards developers, specifically those well versed in d3.js.

Choropleth maps have historically been the default visual representation for data associated with a geographic area. They can be most helpful to expose three valuable data types for region-based data: (1) the location and geography of each feature, (2) area or size, and (3) the associated data values. Because of the clear relationship between region and data value, it is often the first choice for geographic data:

In the choropleth example on the left, the poverty data included is a percent by state — thus a map with each state colored by its value makes sense.

But what if the data itself isn’t constrained to a delineated region, like temperature, or precipitation? Weather events are continuous; rain or temperature doesn’t stop at a border. Often, this data is managed on a regional level, such as climate divisions, but it shouldn’t be visualized as such. In these instances, choropleth maps are much less effective.

The state, county, climate division, or other borders that are inherent in choropleths create artificial boundaries that are not applicable to geographically continuous data. We propose using contour maps to visualize continuous data measured by geographic region.

Contour maps blend the border lines between regions, maintaining the continuity of the data itself. Let’s consider this direction with rate of temperature change data from the US climate divisions, reported by the EPA. Here’s a sample of the data:

Climate Division,Temperature Change
101,-0.294037192
102,-0.234474868
103,-0.661866435
104,-0.220764618
105,-0.242089482
106,-0.432744154
107,-0.521121018
108,0.075600358
201,2.096787133
202,1.463899629
203,2.291893527
204,2.023514559
205,2.793129751
206,2.685887319
207,1.544004314
...

Each climate division represents a geographical area, and the associated value is representative for the entire region. This data translates to the below choropleth (figure 3), also from the EPA:

The choropleth map is still illustrative, but region borders are insignificant in this context. The varying temperatures in neighboring divisions show the spread of temperature over the entire country, but the regions themselves aren’t relevant. The same data, visualized in a contour map, can appear much more effective:

The region borders are no longer limits for the data. This visual, rather, focuses on the geographically continuous data itself, illustrating the varying temperature changes throughout the country.

Development of Contour Maps in D3.js

Constructing a contour map to visualize this type of data is not as simple as it may seem. Contour maps require certain data inputs, and data reported on a geographic level is not prepped for this type of visual. This section will break down three approaches to to build the proposed visual using d3.js. First leveraging d3’s landscape contour generator, then shifting to its contour density estimator, and last, combining the efforts.

Development of contour maps typically requires detailed data from each “pixel” of area. As the name suggests, contour maps have historically been used to illustrate the contours in a natural landscape.

contours of Portland, OR

In a topographic map, the contours join equal points of elevation to illustrate slopes and valleys, such as this screenshot from contours.axismaps.com of Portland, Oregon, and its nearby Mt. Hood.

To make a landscape contour map using d3.js, the data element requires a matrix of values corresponding to the dimensions of the desired map:

The input values must be an array of length n×m where [n, m] is the contour generator’s size; furthermore, each values[i + jn] must represent the value at the position ⟨i, j⟩. (source)

Approach 1: Landscape Contour Generator

(Note: we use the term “landscape contour” here to imply this visual is intended for use with geography and to differentiate it with the density estimator, referenced later. The d3.js documentation just refers to this method as “d3.contours()”)

Consider this approach with the temperature change data referenced above, which doesn’t include any graphical information about its region. To transform this data into an appropriate array of values for a d3.js landscape contour, we could overlay a matrix on a choropleth and “read” the value of the region below it.

geojson matrix of points overlayed on the choropleth, illustrating the data value of its position

This would work fine, but the result ends up very similar to the original choropleth. The values would create a collection of data “plateaus” — it appears to be a contour map, but the slopes between regions are stacked on top of each other. Rather than sloping down from one value to its neighbor (like this: [10, 8, 6, 4, 2, 0]), this map jumps to the new value in one pixel (like this: [10, 10, 10, 0, 0, 0]), as if all mountains were cliffs, not rolling hills. Although cool looking, it preserves the original issue we describe from choropleths — values jump from one region to another, without illustrating the continuity we know exists with this data.

unprojected mosaic of plateaus, created using landscape contours

Our desired output needs each of the regions to blend together. We need some sort of smoothing function to move between regions to create changing “elevation” lines. To accomplish this, we can leverage d3.js contour density estimator (as opposed to its contour generator).

Approach 2: Contour Density Estimator (Centroid)

The contour density estimator will do exactly what its name suggests: estimate the contours based on distances between and density of data points. This density estimator is constructed for a given array of data, returning contour rings as GeoJSON MultiPolygon geometry objects.

d3.js documentation example of contour density estimator from a scatterplot

But of course, this, too, requires some data transformation. We can build a density cloud of points based on the data, almost as if we are placing x and y coordinates on top of our map. Since we’re already working with geography, the easiest transformation of each data point’s location would be to use the centroid of each region. To tie this centroid location (x/y) to the data value in that region (temperature change), we can “stack” a collection of points in the centroid of the region. A single point on the map is representative of one data point. For example, if a climate division’s temperature increased by 10 degrees, we would place 10 points in the centroid of that region.

Note: In order to illustrate negative temperature change values, we used the minimum value of the data domain as 0 points. If the minimum of the domain was -10 degrees, this value of 10 was added as an offset to each region, to bring the minimum up to 0. Our offset was then set as the center of our diverging color scale (white) to preserve the data.

This appears to work well, but unfortunately, our climate divisions aren’t equally spaced. The northeast regions are packed together, while the climate divisions in the west are fairly spaced out. The varying areas of each climate division affects our density function.

contour density function misled by the closeness of climate divisions in the northeast

Approach 3: Contour Density Estimator (Matrix)

To fix this issue, we can merge aspects from both previous attempts. The contour density estimator fixes our original problem of creating contours from this data, but introduced a new one: the geography of the centroid is affecting our contour density function. To correctly space the data, we can use our original overlay of an evenly spaced matrix over the rendered choropleth from our first approach, and apply the density contour function concept of our second approach. Each point in the matrix can “read” its underlying value (we used a voronoi to do this, but can also use d3.geoContains), then create additional points based on the value, to create the density of points based on data.

A matrix of points colored by the value of the nearest climate division

The matrix points will follow the same process we implemented for the centroids. Each matrix position will “stack” points based on the data of the region below to create our fake density cloud. Now the density function, not misdirected by the proximity of neighboring regions, can compute based on the density from this evenly spaced cloud. Adding a color scale to the threshold values illustrates a similar image to the original choropleth, as shown above:

These three approaches, as well as much more information about the development and process of these maps, are included in our Observable notebook: Creating a Contour Density Map from a Choropleth Map.

--

--

Ellie Frymire
Two-N
Writer for

Data Visualization Developer and Designer, living in NYC. Works at @2nfo.