Skip to content

Instantly share code, notes, and snippets.

@schutt
Last active June 13, 2023 17:50
Show Gist options
  • Save schutt/91bf0ff0cba3908bf243 to your computer and use it in GitHub Desktop.
Save schutt/91bf0ff0cba3908bf243 to your computer and use it in GitHub Desktop.
A tree with links between branches.
// A data building function to generate a random cross-linked tree. The tree
// has a maximum depth and maximum child node count.
function generateData( maxDepth, maxChildren ) {
var tree = {};
var name = 0;
var nodes = [];
// Recursive builder function to build the tree.
function innerBuilder( depth ) {
var number_of_children = Math.floor(
Math.random() * ( maxChildren + 1 ) );
// Force there to be a child of the zeroth node.
if ( depth === 0 && number_of_children === 0 ) {
number_of_children = 2;
}
// Build the node.
var node = {
name: name,
children: []
};
// Push the node onto the node array for cross-edge building.
nodes.push( node );
// Increment the name.
name++;
// Add children based on the position and parameters.
if ( depth < maxDepth && number_of_children > 0 ) {
depth++
for ( var i = 0; i < number_of_children; i++ ) {
var child = innerBuilder( depth );
node.children.push( child );
}
}
return node;
}
var root = innerBuilder( 0 );
// Build random links.
var crosslink_data = [];
var a = null;
var b = null;
// Repeat the link building process sever times to make a more dense set of
// links than a single pass would.
for ( var i = 0; i < 4; i++ ) {
// Shuffle a copy of the nodes array.
var shuffled = d3.shuffle( nodes.slice() );
// Pop two nodes off the shuffled nodes array and link them.
while ( a = shuffled.pop(), b = shuffled.pop() ) {
if ( a !== b ) {
var entry = { source: a, target: b };
if ( crosslink_data.indexOf( entry ) === -1 ) {
crosslink_data.push( entry );
}
}
}
}
return {
root: root,
links: crosslink_data
};
}
// This is a modified version of `d3.svg.diagonal` to draw edges that cross
// tree branches. The edges are shifted so that they do not lie over the curves
// drawn by the normal D3 diagonal. The shift is increased as the distance
// between the nodes increases so that multiple cross edges connecting to the
// same node diverge for clarity.
//
// See https://github.com/mbostock/d3/wiki/SVG-Shapes#diagonal
var crossDiagonal = function() {
var source = function( d ) { return d.source; };
var target = function( d ) { return d.target; };
var projection = function( d ) { return [d.x, d.y]; };
var distance_factor = 10;
var shift_factor = 2;
function diagonal( d, i ) {
var p0 = source.call( this, d, i );
var p3 = target.call( this, d, i );
var l = ( p0.x + p3.x ) / 2;
var m = ( p0.x + p3.x ) / 2;
var x_shift = 0;
var y_shift = 0;
if ( p0.x === p3.x ) {
x_shift = ( p0.depth > p3.depth ? -1 : 1 ) *
shift_factor + ( p3.y - p0.y ) / distance_factor;
}
if ( p0.y === p3.y ) {
y_shift = ( p0.x > p3.x ? -1 : 1 ) *
shift_factor + ( p3.x - p0.x ) / distance_factor;
}
var p = [
p0,
{ x: m + x_shift, y: p0.y + y_shift },
{ x: l + x_shift, y: p3.y + y_shift },
p3
];
p = p.map( projection );
return 'M' + p[0] + 'C' + p[1] + ' ' + p[2] + ' ' + p[3];
}
diagonal.source = function( x ) {
if ( !arguments.length ) return source;
source = d3.functor( x );
return diagonal;
};
diagonal.target = function( x ) {
if ( !arguments.length ) return target;
target = d3.functor( x );
return diagonal;
};
diagonal.projection = function( x ) {
if ( !arguments.length ) return projection;
projection = x;
return diagonal;
};
return diagonal;
};
// The chart builder function to build a tree with cross links.
// Configuration is excluded for the simplicity of the example.
var linkedTreeChartBuilder = function( parentElement ) {
var data;
// Set up the boundaries.
var margin = { top: 20, right: 20, bottom: 20, left: 20 };
var width = 600 - margin.left - margin.right;
var height = 400 - margin.top - margin.bottom;
// Set up display variables. These would usually be merged with a
// configuration object.
var colors = {
inLink: 'indigo',
outLink: 'orange'
};
var opacity = 0.7;
var link_highlight_duration = 750;
// Create the chart SVG.
var svg = parentElement.append( 'svg' );
var chart_group = svg.append( 'g' )
.attr( 'class', 'chart' );
// Create the helper functions.
// Create the D3 diagonal for drawing graph edges.
var diagonal = d3.svg.diagonal()
.projection( function( d ) { return [d.x, d.y]; } );
// Create the custom diagonal for drawing graph edges across tree branches.
var link_diagonal = crossDiagonal();
// Create the tree layout.
var tree = d3.layout.tree();
// The transition used to highlight edges on mouse-over.
var edgeTransitionIn = function( path ) {
path.transition()
.duration( link_highlight_duration )
.attr( 'opacity', 1 )
.attrTween( 'stroke-dasharray', tweenDash );
};
// The dash tween to make the highlighted edges animate from the start node
// to the end node.
var tweenDash = function() {
var l = this.getTotalLength();
var i = d3.interpolateString( '0,' + l, l + ',' + l );
return function( t ) { return i( t ); };
};
// A simple transition to un-highlight edges on mouse-out.
var edgeTransitionOut = function( path ) {
path.transition()
.duration( link_highlight_duration / 2 )
.attr( 'opacity', 1e-6 );
};
// Handler builder for mouse-over and mouse-out. Accepts a transition
// handler to alter the edges.
var mouseHandler = function( transition ) {
return function( d ) {
var crosslinks = chart_group.selectAll( '.crosslink-highlighted' );
// Filter the edges to those connected to the current node.
crosslinks.filter( function( dd ) {
var source_matches = d.x == dd.source.x &&
d.y == dd.source.y;
var target_matches = d.x == dd.target.x &&
d.y == dd.target.y;
return source_matches || target_matches;
} )
// Set the line color based on the edge direction compared to
// the active node.
.style( 'stroke', function( dd, i ) {
return d.x == dd.source.x && d.y == dd.source.y ?
colors.outLink :
colors.inLink;
} )
.call( transition );
};
};
// The chart update function that this builder returns. To redraw the
// chart, call this function. If no data object is supplied, the latest
// data object will be used.
var update = function( _ ) {
data = !arguments.length ? update.data() : update.data( _ );
// Apply the chart size and position.
svg.attr( 'width', width + margin.left + margin.right )
.attr( 'height', height + margin.top + margin.bottom );
chart_group.attr( 'transform',
'translate(' + margin.left + ',' + margin.top + ')' );
// Update the tree.
tree.size( [width, height] );
// Process the tree.
var nodes = tree.nodes( data.root );
// Build the parent-child edges.
var links = tree.links( nodes );
// Select the parent-child edges.
var link = chart_group.selectAll( '.link' )
.data( links );
link.attr( 'class', 'link' )
.attr( 'd', diagonal );
link.enter().append( 'path' )
.attr( 'class', 'link' )
.attr( 'd', diagonal );
link.exit()
.style( 'fill-opacity', 1e-6 )
.remove();
// The dashed edge lines for the cross-links.
var cross_link = chart_group.selectAll( '.crosslink' )
.data( data.links );
cross_link.attr( 'class', 'crosslink' )
.attr( 'd', link_diagonal );
cross_link.enter().append( 'path' )
.attr( 'class', 'crosslink' )
.attr( 'opacity', opacity )
.style( 'stroke', 'lightgrey' )
.style( 'stroke-dasharray', '4,8' )
.attr( 'd', link_diagonal );
cross_link.exit()
.style( 'fill-opacity', 1e-6 )
.remove();
// The solid edge lines fro the cross-links. A second set of paths is
// used so that the dashed lines can stay in place while the link is
// animated from the start node to the end node.
var cross_link_highlighted = chart_group.selectAll(
'.crosslink-highlighted' )
.data( data.links );
cross_link_highlighted.attr( 'class', 'crosslink-highlighted' )
.attr( 'd', link_diagonal );
cross_link_highlighted.enter().append( 'path' )
.attr( 'class', 'crosslink-highlighted' )
.attr( 'opacity', 1e-6 )
.attr( 'd', link_diagonal );
cross_link_highlighted.exit()
.style('fill-opacity', 1e-6)
.remove();
// The nodes themselves.
var node = chart_group.selectAll( '.node' )
.data( nodes );
node.attr( 'class', 'node' )
.attr( 'transform', function( d ) {
return 'translate(' + d.x + ',' + d.y + ')';
} );
var entered_nodes = node.enter().append( 'g' )
.attr( 'class', 'node' )
.attr( 'transform', function( d ) {
return 'translate(' + d.x + ',' + d.y + ')';
} )
.on( 'mouseover', mouseHandler( edgeTransitionIn ) )
.on( 'mouseout', mouseHandler( edgeTransitionOut ) );
entered_nodes.append( 'circle' )
.attr( 'r', 4.5 );
entered_nodes.append( 'text' );
node.selectAll( 'text' )
.attr( 'dx', function( d ) { return d.children ? -8 : 8; } )
.attr( 'dy', 3 )
.style( 'text-anchor', function( d ) {
return d.children ? 'end' : 'start';
} )
.text( function( d ) { return d.name; } );
node.exit()
.style( 'fill-opacity', 1e-6 )
.remove();
};
// Data getter/setter.
update.data = function( _ ) {
if ( !arguments.length ) return data;
data = _;
return data;
};
return update;
};
// Build the chart.
var chart = linkedTreeChartBuilder( d3.select( 'div.chart' ) );
// Build a tree and update the chart.
chart( generateData( 3, 4 ) );
// Add a chart regeneration handler.
d3.select( 'button#regenerate' )
.on( 'click', function() {
// Generate new data and update the chart. Note that the chart does not
// need to be recreated.
chart( generateData( 3, 4 ) );
} );
<!DOCTYPE html>
<meta charset="utf-8">
<style>
.node circle {
fill: #fff;
stroke: steelblue;
stroke-width: 1.5px;
}
.node {
font: 10px sans-serif;
}
.link {
fill: none;
stroke: #ccc;
stroke-width: 1.5px;
}
path {
fill: none;
stroke: #000;
}
</style>
<body>
<div class="chart"></div>
<div>
<button type="submit" id="regenerate">Regenerate data</button>
</div>
</body>
<script src="https://d3js.org/d3.v3.min.js"></script>
<script src="chart.js"></script>
@universvm
Copy link

Nice! However, if you actually used a JSON file rather than a random function for your data, it would have been much more useful :/

@lindseyhewett
Copy link

I'm really excited by this example! Any advice how to update the crossDiagonal() function to d3 version 5? After updating the tree generation functions to the new syntax, the main part of the tree does appear (the nodes and solid links) but not the cross diagonal links. I'm getting an error with the path created for the cross diagonal links (path undefined) because the objects p0 and p3 represent inside the diagonal() function do not have "x", "y" and "depth" attributes on them. What am I missing? Why did this work in v3 but not now? How can I add those attributes to each object?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment