Skip to content

Instantly share code, notes, and snippets.

@SpaceActuary
Last active April 5, 2018 16:23
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 SpaceActuary/f181e0f8d00a4693d1259a89b10ba362 to your computer and use it in GitHub Desktop.
Save SpaceActuary/f181e0f8d00a4693d1259a89b10ba362 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.

forked from borgar's block: Slopegraph

(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 state value
2016 Florida 47.8
2016 California 48.8
2016 Texas 31.8
2016 New York 27.4
2016 New Jersey 17
2016 Pennsylvania 13.2
2016 Louisiana 11.5
2016 Massachusetts 9
2016 Ohio 5.6
2016 Illinois 9.8
2017 Florida 84.5
2017 California 72
2017 Texas 53.5
2017 New York 47.7
2017 New Jersey 28.9
2017 Pennsylvania 18.8
2017 Louisiana 17.9
2017 Massachusetts 15.3
2017 Ohio 14.2
2017 Illinois 14
<!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,
'state': d.state,
'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( 'state' ).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( '.state' )
.data( d => d ).enter()
.append( 'text' )
.attr( 'class', 'state' )
.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.state );
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([ 'Private Flood Written Premium'
, 'in Millions, 2016 and 2017'
]).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