Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@borgar
Last active March 13, 2016 21:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save borgar/67a2173ef40f08129201 to your computer and use it in GitHub Desktop.
Save borgar/67a2173ef40f08129201 to your computer and use it in GitHub Desktop.
Slopegraph
license: gpl-3.0
height: 940
border: no

Edward Tufte introduced Slopegraphs in his 1983 book The Visual Display of Quantitative Information. He describes them as "compar[ing] changes over time for a list of nouns located on an ordinal or interval scale".

This example recreates Tufte's original chart. Getting the labeling correct is a little tricky so it makes a perfect example of something that belongs in a layout.

(function () {
function _accessor ( s ) {
return ( typeof s === 'function' ) ? s : function ( d ) { return d[ s ]; };
}
function crunch ( data, min_needed_dist, get_x, get_y, get_j ) {
min_needed_dist = min_needed_dist || 0.5;
// step 1: group the values
var rollup_rows = {},
rollup_cols = {},
xs = [],
js = [],
values = [];
for ( var di=0,dl=data.length; di<dl; di++ ) {
var item = data[ di ],
x = get_x( item ),
j = get_j( item );
item.y = get_y( item );
rollup_rows[ j ] = rollup_rows[ j ] || {};
rollup_rows[ j ][ x ] = item;
rollup_cols[ x ] = rollup_cols[ x ] || [];
rollup_cols[ x ].push( item );
xs.push( x );
js.push( j );
}
var xs = d3.set( xs ).values().sort( d3.ascending );
var facts = d3.set( js ).values().map(function ( j ) {
return xs.map(function ( x ) {
return rollup_rows[ j ][ x ];
});
});
// step 2: "fan out" label positions to remove overlaps
for ( var col_id in rollup_cols ) {
var column = rollup_cols[ col_id ].sort(function ( a, b ) {
var r = get_y( a ) - get_y( b );
if ( r === 0 ) {
if ( get_j( a ) > get_j( b ) ) {
return -1;
}
if ( get_j( a ) < get_j( b ) ) {
return 1;
}
}
return r;
});
var improoving = true,
steps = 0;
while ( improoving ) {
var last_gap_size = null,
smallest_gap = null,
smallest_gap_size = -Infinity;
// compute distances
for ( var i=0,l=column.length; i<l; i++ ) {
var item = column[ i ],
prev = column[ i - 1 ],
next = column[ i + 1 ];
// space above
if ( !prev ) {
item._top = Infinity;
}
else {
item._top = item.y - prev.y;
// remember it if it was important
if ( !smallest_gap || smallest_gap_size > item._top ) {
smallest_gap = [ item, prev ];
smallest_gap_size = item._top;
}
}
// space below
if ( !next ) {
item._bottom = Infinity;
}
else {
item._bottom = next.y - item.y;
// remember it if it was important
if ( !smallest_gap || smallest_gap_size > item._bottom ) {
smallest_gap = [ next, item ];
smallest_gap_size = item._bottom;
}
}
}
// find the smallest gap
if ( smallest_gap_size >= min_needed_dist || /* no overlaps present */
steps > 1000 /* we're going nowhere fast */ ) {
break;
}
steps++;
// push items apart, aiming toward empty spaces
var t = smallest_gap[ 0 ]._bottom + smallest_gap[ 1 ]._top,
a = isFinite( t ) ? smallest_gap[ 0 ]._bottom / t : 1,
b = isFinite( t ) ? smallest_gap[ 1 ]._top / t : 1,
force = min_needed_dist / 4;
smallest_gap[ 0 ].y += force * a;
smallest_gap[ 1 ].y -= force * b;
// stop doing this when labels stop overlapping
improoving = ( smallest_gap_size >= ( last_gap_size || 0 ) );
last_gap_size = smallest_gap_size;
}
}
return facts;
}
d3.layout.slopegraph = function () {
var get_x = _accessor( 'x' ),
get_y = _accessor( 'y' ),
get_j = _accessor( Number ),
data = [],
cached,
min_needed_dist;
var layout = function ( d ) {
if ( arguments.length ) {
cached = undefined;
data = d;
return layout;
}
return data;
};
layout.data = layout;
layout.j = function ( d ) {
if ( arguments.length ) {
get_j = _accessor( d );
return this;
}
return get_j;
};
layout.x = function ( d ) {
if ( arguments.length ) {
get_x = _accessor( d );
return this;
}
return get_x;
};
layout.y = function ( d ) {
if ( arguments.length ) {
get_y = _accessor( d );
return this;
}
return get_y;
};
layout.textHeight = function ( h ) {
if ( arguments.length ) {
min_needed_dist = h;
cached = undefined;
return this;
}
return min_needed_dist;
};
layout.left = function ( d ) {
return [];
};
layout.right = function ( d ) {
return [];
};
layout.pairs = function ( d ) {
return cached || (cached = crunch( data, min_needed_dist, get_x, get_y, get_j ));
};
return layout;
};
})();
year country value
1970 Sweden 46.9
1970 Netherlands 44
1970 Norway 43.5
1970 Britain 40.7
1970 France 39
1970 Germany 37.5
1970 Belgium 35.2
1970 Canada 35.2
1970 Finland 34.9
1970 Italy 30.4
1970 United States 30.3
1970 Greece 26.8
1970 Switzerland 26.5
1970 Spain 22.5
1970 Japan 20.7
1979 Sweden 57.4
1979 Netherlands 55.8
1979 Norway 52.2
1979 France 43.4
1979 Belgium 43.2
1979 Germany 42.9
1979 Britain 39
1979 Finland 38.2
1979 Canada 35.8
1979 Italy 35.7
1979 Switzerland 33.2
1979 United States 32.5
1979 Greece 30.6
1979 Spain 27.2
1979 Japan 26.6
<!DOCTYPE html>
<meta charset='utf-8'>
<style>
svg {
font: 13px serif;
}
.years {
font-size: 15px;
}
.desc {
font-size: 16px;
}
.lines path {
fill: none;
stroke: black;
stroke-width: 1.1;
}
</style>
<body>
<script src="//d3js.org/d3.v3.min.js"></script>
<script src="d3.slopegraph.js"></script>
<script>
d3.csv( 'data.csv', parseRow, ready );
function parseRow ( d ) {
return { 'year': +d.year,
'country': d.country,
'value': +d.value };
}
function ready ( data ) {
var margin = { top: 70, right: 0, bottom: 40, left: 0 },
width = 710 - margin.left - margin.right,
height = 940 - margin.top - margin.bottom,
y_dom = d3.extent( data, d => d.value ).reverse()
x_dom = d3.extent( data, d => d.year )
y = d3.scale.linear()
.domain( y_dom )
.range([ 0, height ]),
x = d3.scale.linear()
.domain( x_dom )
.range([ 390, 570 ]),
layout = d3.layout.slopegraph()( data )
.j( 'country' ).y( 'value' ).x( 'year' )
.textHeight( (y_dom[0] - y_dom[1]) / height * 14 ),
textAlign = m => {
return (d, i) => i ? 'start' : 'end';
},
textMargin = m => {
return (d, i) => i ? m * 1 : m * -1;
};
var svg = d3.select( 'body' ).append( 'svg' )
.attr( 'width', width + margin.left + margin.right )
.attr( 'height', height + margin.top + margin.bottom )
.append( 'g' )
.attr( 'transform', `translate(${margin.left},${margin.top})` );
svg.append( 'g' )
.attr( 'class', 'years' )
.selectAll( 'text' ).data( x_dom ).enter()
.append( 'text' )
.attr( 'x', x )
.attr( 'dx', (d, i) => i ? 10 : -10 )
.attr( 'y', -40 )
.style( 'text-anchor', textAlign() )
.text( String );
var line = d3.svg.line()
.x( d => x( d.year ) )
.y( d => y( d.y ) );
var pairs = svg.append( 'g' )
.attr( 'class', 'lines' )
.selectAll( 'g' )
.data( layout.pairs() ).enter()
.append( 'g' );
pairs.append( 'path' )
.attr( 'd', line );
pairs.selectAll( '.country' )
.data( d => d ).enter()
.append( 'text' )
.attr( 'class', 'country' )
.attr( 'x', d => x( d.year ) )
.attr( 'dx', textMargin( 48 ) )
.attr( 'dy', '.32em' )
.attr( 'y', d => y( d.y ) )
.style( 'text-anchor', textAlign() )
.text( d => d.country );
pairs.selectAll( '.value' )
.data( d => d ).enter()
.append( 'text' )
.attr( 'class', 'value' )
.attr( 'x', d => x( d.year ) )
.attr( 'dy', '.32em' )
.attr( 'dx', textMargin( 10 ) )
.attr( 'y', d => y( d.y ) )
.style( 'text-anchor', textAlign() )
.text( d => d.value.toFixed( 1 ) );
svg.append( 'g' )
.attr( 'class', 'desc' )
.selectAll( 'text' )
.data([ 'Current Receipts of Government as a'
, 'Percentage of Gross Domestic'
, 'Product, 1970 and 1979'
]).enter()
.append( 'text' )
.attr( 'y', (d,i) => i * 20 )
.attr( 'dy', '-.32em' )
.attr( 'x', 13 )
.text( String );
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment