~ / teaching / InfoVis / practical works / Interactive HorizonGraph

Interactive HorizonGraph with D3.js

© 2024— - Renaud Blanch

The goal of this practical work is to build an interactive visualisation that uses a complex visual mapping: horizon graphs.

You will build a visualisation that allow to explore the NOAA Global Surface Temperature Dataset (NOAAGlobalTemp). The NOAAGlobalTemp data set contains global surface temperatures in gridded (5° × 5°) and monthly resolution time series (from 1850 to present time) data files.

Setup

Get the archive that contains the dataset; the version of D3.js and topojson that you are going to use; and a visualisation template. Unzip it and get used to its content.

The data/noaa-global-temp directory contains the datasets and a README that describes the naming scheme for the files are their structure.

The data/world-atlas directory contains the geographical data to build a world map (last question).

The vendor directory contains the D3.js version 7.8.5 code and the topojson version 3.0.2 code.

The viz directory contains a visualisation template that produces HTML numbered lists of the top-10 hotest and coolest year for the region between latitudes 30N and 60N.

To test this template, you need to start a web server. The simplest way to go is using python in a terminal as shown below:

% tar xzvf anomaly.tgz
x anomaly/data/
[...]
x anomaly/vendor
[...]
x anomaly/viz/
x anomaly/viz/anomaly.html
% cd anomaly/
% python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 ...

You can then open in a new tab your local version of the visualisation that should display the year lists, as the online version does.

Horizon Graph

The first step consists in building a line graph for the annual time series of a given region (e.g., 30N.60N). The sample visualisation shows how load (lines 53–55) and parse (lines 23–30) the data from the fixed width text format used. The visualisation will use a visual mapping that projects the whole tiem series onto a single mark (polyline) with the following encoding:

For the horizontal axis, a time scale is the most appropriate, and its domain can be computed from the years present in the dataset. For the vertical axis, using a fixed domain that goes from -T_MAX to +T_MAX will allow to make visual comparisons between series.

const T_MAX = 5;

To draw the line, add a SVG path element to your plot, and use the line generator as its d attribute to generate the drawing commands from the data that you can pass to the path element using the selection.datum method.

Finally, add an horizontal and a vertical axis; and an horizontal line that show the 0°C baseline to complete the graph.

When this first step is completed, the visualisation should look like this (the graph size is 200×700 px plus margins to put the axis):

The second step consists in slicing the graph in ten 1°C slices. To do so, you can combine two features of SVG: use elements that allow to draw multiple times another element with different attributes each time (here the fill color); and clipPath that allow to cut through an element and display only part of it (here a rectangle with 1°C height for each °C). To achieve the result shown below, follow those steps:

If you transform the clipping rectangles with an appropriate translation computed from the temperature slice it represents, you should get the following graph:

The third step consists in superimposing slices:

You should now have a first horizon graph:

The last step of the visualisation is to go from a single horizon graph representing annual data to small multiples representing monthly data. To do so, you will need to change the following things:

You can use the d3.timeFormat function with the %b specifier to generate the month labels for the small multiples and get a result like this one:

Interactions

The first interaction consists in a vertical cursor that will allow the reading of the exact anomaly values. For each graph, add a group (with class cursor) that have two parts: a group (class reticle) with a vertical line that goes from -1 to 1°C and a circle centered at 0; and a text element (class label) on the right of that group.

To move the cursors, the movement of the mouse cursor over the graphs should be captured. In order to do so, add a filled, but transparent, rectangle on top of the visualisation and register a mousemove event handler for this rectangle using D3's event handling mechanism. This update_year handler can use the pointer function to extract the mouse cursor position in local coordinates, and then the invert method of the horizontal scale to convert that position to a date. This date can then be used to set the current year by looking into the ordered list of years (using d3.least or d3.bisect).

This current year can then be used to: translate the cursor groups to the right abscissa; and, after a lookup in the timeseries, to translate the reticle groups to the right ordinate. The circle of the reticle should match the position of the curve (see image below). The label texts should be updated with the anomaly value, formatted with 3 digits and a sign ('+.3f' format specifier).

Some details can be fine-tuned as shown in the capture below to get a nice-looking result:

The next step consists in making possible to choose the dataset to visualize and to animate the transition between datasets.

To do so, first start with the different types of surfaces:

Now, also add a drop-down for the different latitudes available. You may find convenient to define an array of the existing latitudes and the to use it for the drop-down construction, e.g.:

const latitudes = [
    ['90S', '90N'],
    ['90S', '00N', '90N'],
    ['90S', '20S', '20N', '90N'],
    ['90S', '60S', '30S', '00N', '30N', '60N', '90N'],
    ['60S', '60N'],
];

var dropdown_lats = p.append('select');
dropdown_lats.selectAll('optgroup')
    .data(latitudes.reverse())
    .join('optgroup')
        .attr('class', 'zone')
        .selectAll('.bloc')
            .data(d => d3.zip(d, d.slice(1)))
            .join('option')
                .attr('class', ([s, n]) => `bloc bloc-${s}-${n}`)
                .text(([s, n]) => `${s}${n}`)
                .attr('value', ([s, n]) => `${s}.${n}`);

Finally, register the update_data function to be also triggered by change events on this drop-down; add an animated transition so the horizon graphs morph slowly when the data changes; and start from zeroed data at the loading of the page. The animation and interaction should look somewhat similar to the ones in this video capture:

Bonus

The last part consists in adding a graphical interaction for the selection of latitudes. Propose your own interaction. Below are two exemples: on the left, simple rectangles generated from latitudes are clickable; and on the right a more advanced visualisation with a world map is used.

If you want to experiment with geographical visualisation, the topojson library is vendored in the vendor/topojson/ folder; and so are the world land boundaries in the data/world-atlas/ folder. You can get started on using those with this Observable notebook and the d3-geo documentation. Below is an example of how to load the map and display the globe:

d3.json('../data/world-atlas/land-110m.json')
    .then(topo => topojson.feature(topo, topo.objects.land))
    .then(geo => {

    let margin = {top: 20, right: 10, bottom: 50, left: 120},
        width  = 360 - margin.left - margin.right,
        height = 300 - margin.top - margin.bottom;

    const phi = -5;
    let angle = 0;
    let projection = d3.geoOrthographic()
        .fitSize([width, height], {type: 'Sphere'})
        .rotate([0, phi]);
    let path = d3.geoPath(projection);

    let map = body.insert('svg', '#plot')
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
        .append("g")
            .attr("transform", `translate(${margin.left},${margin.top})`)
            .attr('stroke', 'black')
            .attr('stroke-width', .5)
            .attr('fill', 'white');

    // sphere
    
    map.selectAll('.sphere')
        .data([
            {type: 'Sphere'},
            d3.geoGraticule().stepMinor([30, 30])(),
            geo,
        ])
        .join('path')
            .attr('class', 'sphere')
            .attr('d', path);

last update: jan. 10, 2025