1

I am using the d3.js treemap in a an application with backbone.js. The treemap renders correctly with the first JSOn data, but subsequent calls with different JSON data do not cause the treemap to re-render.

My HTML looks like this:

<html>
<head>
    <title>Jenkins analytics</title>

    <!-- stylesheets -->
    <link rel="stylesheet" href="css/spa.css" type="text/css"/>
    <link rel="stylesheet" href="css/treemap.css" type="text/css"/>
</head>

<body>
    <nav>
        <form>
            <fieldset>
                <label for="chart">Chart:</label>
                <select id="chart" name="chart">
                    <option value="treemap" selected>Treemap</option>
                    <option value="motion">MotionChart</option>
                </select>
            </fieldset>
        </form>
        <form>
            <fieldset>
                <label for="period">Period:</label>
                <select id="period" name="period">
                    <option value="lastday" selected>Day</option>
                    <option value="lastweek">Week</option>
                    <option value="lastmonth">Month</option>
                </select>

                <label for="team">Team:</label>
                <select id="team" name="team">
                    <option value="all" selected>all</option>
                    <option value="spg">spg</option>
                    <option value="beacon">beacon</option>
                    <option value="disco">disco</option>
                </select>

                <label for="status">Status:</label>
                <select id="status" name="status">
                    <option value="" selected>all</option>
                    <option value="SUCCESS">success</option>
                    <option value="FAILURE">failure</option>
                    <option value="ABORTED">aborted</option>
                </select>
            </fieldset>
        </form>
        <form>
            <fieldset>
                <label for="duration">Duration</label>
                <input id="duration" type="radio" name="mode" value="size" checked />

                <label for="count">Count</label>
                <input id="count" type="radio" name="mode" value="count" />

                <label for="average">Average</label>
                <input id="average" type="radio" name="mode" value="avg" />
            </fieldset>
        </form>
    </nav>

    <div id="container" />

    <!-- Third party javascript -->
    <script type="text/javascript" src="http://code.jquery.com/jquery-2.0.3.min.js" charset="utf-8"></script>
    <script type="text/javascript" src="script/underscore/underscore.js" charset="utf-8"></script>
    <script type="text/javascript" src="script/backbone/backbone.js" charset="utf-8"></script>
    <script type="text/javascript" src="script/d3/d3.v3.js" charset="utf-8"></script>
    <script type="text/javascript" src="https://www.google.com/jsapi"></script>

    <!-- Application javascript -->
    <script type="text/javascript" src="script/module.js"></script>

    <!-- Startup -->
    <script>
        var navview = new NavViewer();
    </script>
</body>
</html>

script/module.js looks like this:

var NavViewer = Backbone.View.extend({
    el: 'nav',

    events: {
        "change #chart": "change_chart",
        "change #period": "change_period",
        "change #team": "change_team",
        "change #status": "change_status"
    },

    initialize: function() {
        console.log("NavViewer.initialize");
        this.d3view = new D3Viewer();
        this.active_view = this.d3view;
    },

    change_chart: function(e) {
        console.log("NavViewer.change_chart");
    },
    change_period: function(e) {
        var _period = $('#period').val();
        console.log("NavViewer.change_period to " + _period);
        this.active_view.load();
    },
    change_team: function(e) {
        var _team = $('#team').val();
        console.log("NavViewer.change_team to "+ _team);
        this.active_view.load();
    },
    change_status: function(e) {
        var _status = $('#status').val();
        console.log("NavViewer.change_status to " + _status);
        this.active_view.load();
    }
});

var JenkinsViewer = Backbone.View.extend({
    el: '#container',
    server: "http://192.168.1.100:5000",
    url_fragment: function() {
        var _period = $('#period').val();
        var _team = $('#team').val();
        var _status = $('#status').val();
        return "when=" + _period +
            (_team == "all" ? "" : ("&" + "team=" + _team)) +
            (_status == "" ? "" : ("&" + "status=" + _status));
    }
});

var D3Viewer = JenkinsViewer.extend({
    initialize: function() {
        this.margin = {top: 8, right: 0, bottom: 0, left: 0};
        this.width = 1200 - this.margin.left - this.margin.right;
        this.height = 800 - this.margin.top - this.margin.bottom - 60;
        this.container = d3.select(this.el);
        this.color = d3.scale.category20c();
        this.base_url = this.server + "/team_build";

        this.treemap = d3.layout.treemap()
            .size([this.width, this.height])
            .sticky(true)
            .value(function(d) { return d.size; });

        this.position = function() {
            this.style("left", function(d) { return d.x + "px"; })
                .style("top", function(d) { return d.y + "px"; })
                .style("width", function(d) { return Math.max(0, d.dx - 1) + "px"; })
                .style("height", function(d) { return Math.max(0, d.dy - 1) + "px"; });
        };

        /* style the container */
        this.container
            .style("position", "relative")
            .style("width", this.width + "px")
            .style("height", this.height + "px")
            .style("left", this.margin.left + "px")
            .style("top", this.margin.top + "px")
            .style("border", "1px solid black");

        /* tootlip is appended to container */
        this.tooltip = this.container.append("div")
            .attr('class', 'tooltip')
            .style("visibility", "hidden")
            .style("background-color", "#ffffff");

        this.load();
    },

    load: function() {
        var $container = this.container;
        var color = this.color;
        var treemap = this.treemap;
        var position = this.position;
        var tooltip = this.tooltip;
        var url = this.base_url + "?" + this.url_fragment();

        console.log("D3View.load: " + url);

        d3.json(url, function(error, root) {
            /* 'root' actually means the data retrieved by the xhr call */
            var node = $container.datum(root)
                .selectAll(".node")
                .data(treemap.nodes);

            node.enter().append("div")
                .attr("class", "node")
                .call(position)
                .style("background", function(d) { return d.children ? color(d.name) : null; })
                .text(function(d) { return d.children ? null : d.name; })
                .on("mouseover", function(d) {
                    tooltip.html(d.name + ": " + Math.floor(d.value))
                      .style("visibility", "visible");
                    this.style.cursor = "hand";
                })
                .on("mouseout", function(){
                    tooltip.style("visibility", "hidden");
                });

            d3.selectAll("input").on("change", function change() {
                var functions = {
                    count: function(d) { return d.count; },
                    size: function(d) { return d.size; },
                    avg: function(d) { return d.size / d.count; }
                };
                var value = functions[this.value];

                node
                    .data(treemap.value(value).nodes)
                .transition()
                    .duration(1500)
                    .call(position);
            });
        });

        return true;
    }
});

Here are things I've done:

I think the problem might relate to stickiness or the absence of a call node.exit().remove(). I've tried modifying both of those without success. However, to get the interactive clientside UI, I think I need to use treemap.sticky(true).

I've confirmed that I get different JSON each time I hit the REST API service. I've confirmed that container.datum().children changes in size between calls, confirming for me that it is a question of treemap not re-rendering.

D3.js: How to handle dynamic JSON Data looks highly relevant:

  • "It's worth mentioning that if you already have data with the same key, d3.js will store the data in the node but will still use the original data."
  • "When I started playing with d3.js, I thought that rendered data would be evented and react to my changing of scales, domains and whatnot. It's not. You have to tell d3.js to update the current stuff or it will stay there, unsynced."
  • "I assign the result of data() to a variable because enter() only affects new data that are not bound to nodes."
hughdbrown
  • 47,733
  • 20
  • 85
  • 108
  • 1
    To be clear: Are you using the same `load()` function for both the initial and subsequent data calls? Because as it is, it only sets the attributes on new elements, not on existing ones with changed data. Read this answer for a more step-by-step explanation: http://stackoverflow.com/a/20754006/3128209 – AmeliaBR Jan 10 '14 at 02:16
  • @AmeliaBR: Yes, I am using the same `load()`. I'll fix this Saturday and post corrected code. Since this is so obviously cut-and-pasted code, though, it would have been nice if this D3 example had used best practices, too. Thanks for your help. – hughdbrown Jan 10 '14 at 17:00
  • 1
    Unfortunately, most of the examples and tutorials out there are only designed to demonstrate one aspect of the code, and tend to skim over other aspects. So they show how to lay-out a treemap, but not how to update it, or something like that. If you start with the really simple stuff and work your way up, then you're going to know what you need to adapt. But most people want to jump in to the complicated visualization examples, and then don't know where in the code something is going wrong. – AmeliaBR Jan 10 '14 at 17:19
  • @hughdbrown, yeah, really, why didn't this guy, from whom you copied the code, write exactly what you need? Email him, give him a $#$%, and tell him next time he writes an example he should know better. For everything you do, there should be an example on the net so that you can just copy/paste, right?... – VividD Jan 11 '14 at 12:57
  • @VividD: Thanks for taking the time to post your taunts. Let me be clear: I am truly grateful for OS developers and the work they put into their libraries and documentation. I know I am more productive for it. Since you have answered 50-ish questions on d3.js, you are as well-placed as I am to know it was not just "some guy". And the D3 API is pretty irregular in how it approaches rendering new data. Compare it to Google's data visualizations, for example. It's the kind of thing I would highlight if I were writing sample code. So for a lot of reasons, I find myself not agreeing with you. – hughdbrown Jan 13 '14 at 18:41
  • @hughdbrown, my comment was meant to be wry humor. Sorry if it perhaps hurt your feelings, or similar. As far documentation is concerned, I agree with you that it should be more user-oriented. – VividD Jan 13 '14 at 18:52

1 Answers1

2

Here is what I ended up doing in my load method:

load: function() {
    var $container = this.container;
    var color = this.color;
    var treemap = this.treemap;
    var position = this.position;
    var tooltip = this.tooltip;
    var url = this.base_url + "?" + this.url_fragment();

    console.log("D3View.load: " + url);

    d3.json(url, function(error, root) {
        var tooltip_mouseover = function(d) {
            tooltip.html(d.name + ": " + Math.floor(d.value))
              .style("visibility", "visible");
            this.style.cursor = "hand";
        };
        var tooltip_mouseout = function(){
            tooltip.style("visibility", "hidden");
        };
        var background_color = function(d) { return d.children ? color(d.name) : null; };
        var text_format = function(d) { return d.children ? null : d.name; };

        /*
         * Refresh sticky bit
         * https://github.com/mbostock/d3/wiki/Treemap-Layout
         * "Implementation note: sticky treemaps cache the array of nodes internally; therefore, it
         * is not possible to reuse the same layout instance on multiple datasets. To reset the
         * cached state when switching datasets with a sticky layout, call sticky(true) again."
         */
        treemap.sticky(true);

        /* 'root' actually means the data retrieved by the xhr call */
        var nodes = $container.datum(root)
            .selectAll(".node")
            .data(treemap.nodes);

        var enter = nodes.enter().append("div")
            .attr("class", "node")
            .call(position)
            .style("background", background_color)
            .text(text_format)
            .on("mouseover", tooltip_mouseover)
            .on("mouseout", tooltip_mouseout);

        var exit = nodes.exit().remove();

        var update = nodes.style("background", background_color)
            .call(position)
            .text(text_format);

        d3.selectAll("input").on("change", function change() {
            var functions = {
                count: function(d) { return d.count; },
                size: function(d) { return d.size; },
                avg: function(d) { return d.size / d.count; }
            };
            var value = functions[this.value];

            $container.selectAll(".node")
                .data(treemap.value(value).nodes)
            .transition()
                .duration(1500)
                .call(position);
        });
    });

There are two important changes:

  1. Reset treemap sticky bit
  2. Use enter/exit/update selections for new/missing/changed nodes/data

Notice also that enter nodes have the side effect of adding a div of class node and the exit and update nodes do not reference that div-class -- except they do, in the creation of nodes. If add a further selection on node-class in those places, your selection will be empty and the exit and update code will be no-ops.

Thanks to AmeliaBR for posting a really helpful comment with a link to an SO answer she composed.

Other helpful reads:

hughdbrown
  • 47,733
  • 20
  • 85
  • 108
  • 1
    Glad you got it figured out, and thanks for coming back to post your solutions and good references for others. The update process is one that it is easy to get confused with, but once you understand it fully it becomes automatic and it's easy to forget how confusing it was at the start! – AmeliaBR Jan 16 '14 at 01:12