Marlau
Marlau

Reputation: 125

Unexpected d3 v4 tree behaviour

The following d3.js (v4) interactive tree layout I've put together as a proof of concept for a user interface project is not behaving as expected. This is my first d3.js visualisation and I'm still getting my head around all the concepts.

Essentially, clicking any yellow node should generate two yellow child nodes (& links). This works fine when following a left to right, top to bottom click sequence, otherwise it displays unexpected behaviour.

It's probably easiest to run you through an example, so here's a snippet:

var data = {
    source: {
        type: 'dataSource',
        name: 'Data Source',
        silos: [
            { name: 'Silo 1', selected: true },
            { name: 'Silo 2', selected: false },
            { name: 'Silo 3', selected: false }
        ],
        union: {
            type: 'union',
            name: 'Union',
            count: null,
            cardinalities: [
                { type: 'cardinality', positive: false, name: 'Falsey', count: 40, cardinalities: [] },
                { type: 'cardinality', positive: true, name: 'Truthy', count: 60, cardinalities: [] }
            ]
        }
    }
}

// global variables
var containerPadding = 20;
var container = d3.select('#container').style('padding', containerPadding + 'px'); // contains the structured search svg
var svg = container.select('svg'); // the canvas that displays the structured search
var group = svg.append('g'); // contains the tree elements (nodes & links)
var nodeWidth = 40, nodeHeight = 30, nodeCornerRadius = 3, verticalNodeSeparation = 150, transitionDuration = 600;
var tree = d3.tree().nodeSize([nodeWidth, nodeHeight]);
var source;

function nodeClicked(d) {
    source = d;
    switch (d.data.type) {
        case 'dataSource':
            // todo: show the data source popup and update the selected values
            d.data.silos[0].selected = !d.data.silos[0].selected;
            break;
        default:
            // todo: show the operation popup and update the selected values
            if (d.data.cardinalities && d.data.cardinalities.length) {
                d.data.cardinalities.splice(-2, 2);
            }
            else {
                d.data.cardinalities.push({ type: 'cardinality', positive: false, name: 'F ' + (new Date()).getSeconds(), count: 40, cardinalities: [] });
                d.data.cardinalities.push({ type: 'cardinality', positive: true, name: 'T ' + (new Date()).getSeconds(), count: 60, cardinalities: [] });
            }
            break;
    }
    render();
}

function renderLink(source, destination) {
    var x = destination.x + nodeWidth / 2;
    var y = destination.y;
    var px = source.x + nodeWidth / 2;
    var py = source.y + nodeHeight;
    return 'M' + x + ',' + y
         + 'C' + x + ',' + (y + py) / 2
         + ' ' + x + ',' + (y + py) / 2
         + ' ' + px + ',' + py;
}

function render() {

    // map the data source to a heirarchy that d3.tree requires
    // d3.tree instance needs the data structured in a specific way to generate the required layout of nodes & links (lines)
    var hierarchy = d3.hierarchy(data.source, function (d) {
        switch (d.type) {
            case 'dataSource':
                return d.silos.some(function (e) { return e.selected; }) ? [d.union] : undefined;
            default:
                return d.cardinalities;
        }
    });

    // set the layout parameters (all required for resizing)
    var containerBoundingRect = container.node().getBoundingClientRect();
    var width = containerBoundingRect.width - containerPadding * 2;
    var height = verticalNodeSeparation * hierarchy.height;
    svg.transition().duration(transitionDuration).attr('width', width).attr('height', height + nodeHeight);
    tree.size([width - nodeWidth, height]);

    // tree() assigns the (x, y) coords, depth, etc, to the nodes in the hierarchy
    tree(hierarchy);

    // get the descendants
    var descendants = hierarchy.descendants();

    // store previous position for transitioning
    descendants.forEach(function (d) {
        d.x0 = d.x;
        d.y0 = d.y;
    });

    // ensure source is set when rendering for the first time (hierarch is the root, same as descendants[0])
    source = source || hierarchy;

    // render nodes
    var nodesUpdate = group.selectAll('.node').data(descendants);

    var nodesEnter = nodesUpdate.enter()
        .append('g')
            .attr('class', 'node')
            .attr('transform', 'translate(' + source.x0 + ',' + source.y0 + ')')
            .style('opacity', 0)
            .on('click', nodeClicked);

    nodesEnter.append('rect')
        .attr('rx', nodeCornerRadius)
        .attr('width', nodeWidth)
        .attr('height', nodeHeight)
        .attr('class', function (d) { return 'box ' + d.data.type; });

    nodesEnter.append('text')
        .attr('dx', nodeWidth / 2 + 5)
        .attr('dy', function (d) { return d.parent ? -5 : nodeHeight + 15; })
        .text(function (d) { return d.data.name; });

    nodesUpdate
        .merge(nodesEnter)
        .transition().duration(transitionDuration)
            .attr('transform', function (d) { return 'translate(' + d.x + ',' + d.y + ')'; })
            .style('opacity', 1);

    nodesUpdate.exit().transition().duration(transitionDuration)
            .attr('transform', function (d) { return 'translate(' + source.x + ',' + source.y + ')'; })
            .style('opacity', 0)
        .remove();

    // render links
    var linksUpdate = group.selectAll('.link').data(descendants.slice(1));

    var linksEnter = linksUpdate.enter()
        .append('path')
            .attr('class', 'link')
            .classed('falsey', function (d) { return d.data.positive === false })
            .classed('truthy', function (d) { return d.data.positive === true })
            .attr('d', function (d) { var o = { x: source.x0, y: source.y0 }; return renderLink(o, o); })
            .style('opacity', 0);

    linksUpdate
        .merge(linksEnter)
        .transition().duration(transitionDuration)
            .attr('d', function (d) { return renderLink({ x: d.parent.x, y: d.parent.y }, d); })
            .style('opacity', 1);

    linksUpdate.exit()
        .transition().duration(transitionDuration)
            .attr('d', function (d) { var o = { x: source.x, y: source.y }; return renderLink(o, o); })
            .style('opacity', 0)
        .remove();
}

window.addEventListener('resize', render); // todo: use requestAnimationFrame (RAF) for this

render();
.link {
  fill:none;
  stroke:#555;
  stroke-opacity:0.4;
  stroke-width:1.5px
}
.truthy {
  stroke:green
}
.falsey {
  stroke:red
}
.box {
  stroke:black;
  stroke-width:1;
  cursor:pointer
}
.dataSource {
  fill:blue
}
.union {
  fill:orange
}
.cardinality {
  fill:yellow
}
<script src="https://d3js.org/d3.v4.min.js"></script>
<div id="container" style="background-color:gray">
		<svg style="background-color:#fff" width="0" height="0"></svg>
</div>

If you click on the Falsey node then the Truthy node, you'll see two child nodes appear beneath each, as expected. However, if you click on the Truthy node first, when you then click the Falsey node, you'll see that the Truthy child nodes move under Falsey, and the Falsey child nodes move under Truthy. Plus, the child nodes beneath Falsey and Truthy are actually the same two nodes, even though the underlying data is different.

I've confirmed that the data object is correctly structured after creating the children. From what I can see, the d3.hierarchy() and d3.tree() methods are working correctly, so I'm assuming that there's an issue with the way I'm constructing the selections.

Hopefully someone can spot the problem.

A second issue that may be related to the first is: Clicking Falsey or Truthy a second time should cause the child nodes (& links) to transition back to the parent node, but it does not track the parent's position. Hopefully someone can spot the issue here too.

Thanks!

Upvotes: 2

Views: 624

Answers (1)

Gerardo Furtado
Gerardo Furtado

Reputation: 102194

It seems to me that you need a key function when you join your data:

If a key function is not specified, then the first datum in data is assigned to the first selected element, the second datum to the second selected element, and so on. A key function may be specified to control which datum is assigned to which element, replacing the default join-by-index.

So, this should be your data binding selection:

var nodesUpdate = group.selectAll('.node')
    .data(descendants, function(d){ return d.data.name});

Check the snippet:

var data = {
    source: {
        type: 'dataSource',
        name: 'Data Source',
        silos: [
            { name: 'Silo 1', selected: true },
            { name: 'Silo 2', selected: false },
            { name: 'Silo 3', selected: false }
        ],
        union: {
            type: 'union',
            name: 'Union',
            count: null,
            cardinalities: [
                { type: 'cardinality', positive: false, name: 'Falsey', count: 40, cardinalities: [] },
                { type: 'cardinality', positive: true, name: 'Truthy', count: 60, cardinalities: [] }
            ]
        }
    }
}

// global variables
var containerPadding = 20;
var container = d3.select('#container').style('padding', containerPadding + 'px'); // contains the structured search svg
var svg = container.select('svg'); // the canvas that displays the structured search
var group = svg.append('g'); // contains the tree elements (nodes & links)
var nodeWidth = 40, nodeHeight = 30, nodeCornerRadius = 3, verticalNodeSeparation = 150, transitionDuration = 600;
var tree = d3.tree().nodeSize([nodeWidth, nodeHeight]);
var source;

function nodeClicked(d) {
    source = d;
    switch (d.data.type) {
        case 'dataSource':
            // todo: show the data source popup and update the selected values
            d.data.silos[0].selected = !d.data.silos[0].selected;
            break;
        default:
            // todo: show the operation popup and update the selected values
            if (d.data.cardinalities && d.data.cardinalities.length) {
                d.data.cardinalities.splice(-2, 2);
            }
            else {
                d.data.cardinalities.push({ type: 'cardinality', positive: false, name: 'F ' + (new Date()).getSeconds(), count: 40, cardinalities: [] });
                d.data.cardinalities.push({ type: 'cardinality', positive: true, name: 'T ' + (new Date()).getSeconds(), count: 60, cardinalities: [] });
            }
            break;
    }
    render();
}

function renderLink(source, destination) {
    var x = destination.x + nodeWidth / 2;
    var y = destination.y;
    var px = source.x + nodeWidth / 2;
    var py = source.y + nodeHeight;
    return 'M' + x + ',' + y
         + 'C' + x + ',' + (y + py) / 2
         + ' ' + x + ',' + (y + py) / 2
         + ' ' + px + ',' + py;
}

function render() {

    // map the data source to a heirarchy that d3.tree requires
    // d3.tree instance needs the data structured in a specific way to generate the required layout of nodes & links (lines)
    var hierarchy = d3.hierarchy(data.source, function (d) {
        switch (d.type) {
            case 'dataSource':
                return d.silos.some(function (e) { return e.selected; }) ? [d.union] : undefined;
            default:
                return d.cardinalities;
        }
    });

    // set the layout parameters (all required for resizing)
    var containerBoundingRect = container.node().getBoundingClientRect();
    var width = containerBoundingRect.width - containerPadding * 2;
    var height = verticalNodeSeparation * hierarchy.height;
    svg.transition().duration(transitionDuration).attr('width', width).attr('height', height + nodeHeight);
    tree.size([width - nodeWidth, height]);

    // tree() assigns the (x, y) coords, depth, etc, to the nodes in the hierarchy
    tree(hierarchy);

    // get the descendants
    var descendants = hierarchy.descendants();

    // store previous position for transitioning
    descendants.forEach(function (d) {
        d.x0 = d.x;
        d.y0 = d.y;
    });

    // ensure source is set when rendering for the first time (hierarch is the root, same as descendants[0])
    source = source || hierarchy;

    // render nodes
    var nodesUpdate = group.selectAll('.node').data(descendants, function(d){ return d.data.name});

    var nodesEnter = nodesUpdate.enter()
        .append('g')
            .attr('class', 'node')
            .attr('transform', 'translate(' + source.x0 + ',' + source.y0 + ')')
            .style('opacity', 0)
            .on('click', nodeClicked);

    nodesEnter.append('rect')
        .attr('rx', nodeCornerRadius)
        .attr('width', nodeWidth)
        .attr('height', nodeHeight)
        .attr('class', function (d) { return 'box ' + d.data.type; });

    nodesEnter.append('text')
        .attr('dx', nodeWidth / 2 + 5)
        .attr('dy', function (d) { return d.parent ? -5 : nodeHeight + 15; })
        .text(function (d) { return d.data.name; });

    nodesUpdate
        .merge(nodesEnter)
        .transition().duration(transitionDuration)
            .attr('transform', function (d) { return 'translate(' + d.x + ',' + d.y + ')'; })
            .style('opacity', 1);

    nodesUpdate.exit().transition().duration(transitionDuration)
            .attr('transform', function (d) { return 'translate(' + source.x + ',' + source.y + ')'; })
            .style('opacity', 0)
        .remove();

    // render links
    var linksUpdate = group.selectAll('.link').data(descendants.slice(1));

    var linksEnter = linksUpdate.enter()
        .append('path')
            .attr('class', 'link')
            .classed('falsey', function (d) { return d.data.positive === false })
            .classed('truthy', function (d) { return d.data.positive === true })
            .attr('d', function (d) { var o = { x: source.x0, y: source.y0 }; return renderLink(o, o); })
            .style('opacity', 0);

    linksUpdate
        .merge(linksEnter)
        .transition().duration(transitionDuration)
            .attr('d', function (d) { return renderLink({ x: d.parent.x, y: d.parent.y }, d); })
            .style('opacity', 1);

    linksUpdate.exit()
        .transition().duration(transitionDuration)
            .attr('d', function (d) { var o = { x: source.x, y: source.y }; return renderLink(o, o); })
            .style('opacity', 0)
        .remove();
}

window.addEventListener('resize', render); // todo: use requestAnimationFrame (RAF) for this

render();
.link {
  fill:none;
  stroke:#555;
  stroke-opacity:0.4;
  stroke-width:1.5px
}
.truthy {
  stroke:green
}
.falsey {
  stroke:red
}
.box {
  stroke:black;
  stroke-width:1;
  cursor:pointer
}
.dataSource {
  fill:blue
}
.union {
  fill:orange
}
.cardinality {
  fill:yellow
}
<script src="https://d3js.org/d3.v4.min.js"></script>
<div id="container" style="background-color:gray">
		<svg style="background-color:#fff" width="0" height="0"></svg>
</div>

Upvotes: 1

Related Questions