d3.js stacked bar with toggleable series
Asked Answered
B

2

6

this time I am trying to create a stacked bar with toggleable series- based on Mike Bostock's example (thanks once more Mike!) I have already succeeded into making it responsive and zoomable, and the toggleable series through a legend is the last thing remaining.

I created the legend items, and applied the correct color by using keys:

var legendItem = d3.select(".legend")
  .selectAll("li")
  .data(keys)
  .enter()
  .append("li")
  .on('click', function(d) {
    keys.forEach(function(c) {
      if (c != d) tKeys.push(c)
    });
    fKeys = tKeys;
    tKeys = [];
    redraw();
  });

legendItem
  .append("span")
  .attr("class", "color-square")
  .style("color", function(d) {
    return colorScale5(d);
  });

legendItem
  .append("span")
  .text(function(d) {
    return (d)
  });

Based on the structure, in order to create the toggleable item, I came to the conclusion that I somehow have to be able to toggle it from the keys AND the dataset - or is there another way to do it? I have managed to remove a specific key from the keys, but not from the dataset, I have no idea how to map it properly.

The second issue is that I can't figure of a way to toggle a key, but just remove it. This is the original dataset:

var data = [{
  "country": "Greece",
  "Vodafone": 57,
  "Wind": 12,
  "Cosmote": 20
}, {
  "country": "Italy",
  "Vodafone": 40,
  "Wind": 24,
  "Cosmote": 35
}, {
  "country": "France",
  "Vodafone": 22,
  "Wind": 9,
  "Cosmote": 9
}]

In the values were provided from a nested dataset, I could attach a key named 'enabled' to each object and could easily filter the dataset, but can't figure out how to attach a key to help in the filtering proccess.

edit3 Removed useless information from the question:

Here is a working fiddle: https://jsfiddle.net/fgseaxoy/2/

Baluchi answered 30/3, 2017 at 9:32 Comment(3)
...if anyone could point me to the right direction would be great!Baluchi
Are you still looking for answer?Widdershins
@blackmiaool, if someone can provide a more elegant / clearer solution (no offence @SergGr) it would be more than welcomeBaluchi
W
2

SergGr's code works well, but some parts can be cleaner.

onclick

var fKeys = keys.slice();

//a helper object to record the state of keys 
var fKeyReference = fKeys.map(function () {
    return true; //used to indicate if the corresponding key is active
});

function getActiveKeys(reference) {
    return reference.map(function (state, index) {
        if (state) {
            return keys[index]; //just keep keys whoes state is true
        }
        return false; //return false to be filered
    }).filter(function (name) {
        return name
    });
}

...
.on('click', function (d) {
    if (fKeys.length === 1 && fKeys[0] === d) {
        return;
    }

    var index = keys.indexOf(d);
    fKeyReference[index] = !fKeyReference[index]; // toggle state of fKeyReference
    fKeys = getActiveKeys(fKeyReference);
    redraw();
});

.stack()

g.selectAll(".d3-group").remove();//remove all groups and draw them all again
stackedBars = g
    .selectAll(".d3-group")
    .data(d3.stack().keys(fKeys)(dataset));

update the axis (y.domain)

y.domain([
    0,
    1.2 * d3.max(dataset, function (d) {
        return fKeys.reduce(function (pre, key) {//calculate the sum of values of fKeys
            return pre + d[key];
        }, 0);
    })
]);

And finally, jsfiddle

Widdershins answered 3/4, 2017 at 13:57 Comment(19)
your code does not allow more than one series to be toggled. For example if you click once on "Vodafone" and once on "Wind", instead of hiding them both, the second click toggles the first one.Baluchi
@Baluchi OK l'll fix itWiddershins
also if possible, please analyze your comments a bit more; for example why do you have to copy the keys array again?Baluchi
@Baluchi Alright. Due to your first comment, it's a bug. I'll remove it.Widdershins
@blackmiaool, I agree that using bitmask for fKeys might be a good idea, but I don't like your last suggestion regarding y.domain because 1) It means duplication of logic from stack method 2) If you deselect all components, diagram looks ugly (note how my code has two clauses in if (autoScaleY && stackedData.length > 0) ). The second clause allows to show original max scale in case nothing is selected.Butterbur
@Butterbur Showing original max scale or not depends on OP.Widdershins
And it saves a variable stackedData .Widdershins
@blackmiaool, I'm not sure if showng the original scale is the best solution, but showing nothing looks quite ugly to me. And I definitely prefer to have an explicit variable for stackedData to duplication of the logic of the d3.stack() method.Butterbur
@Butterbur If OP think showing original axis is better, he can use (fKeys.length?fKeys:keys).reduce(function (pre, key) { instead of fKeys.reduce(function (pre, key) {. The duplication is useful here.Widdershins
@blackmiaool, from your answers and ignoring another point, I infer that DRYness of the code doesn't bother you as a part of what makes a "clean solution". Then this is probably time to agree to disagree.Butterbur
@Butterbur So you think have two 1.2 * d3.max in the code is better?Widdershins
@blackmiaool, I can trivially move it to a named constant, of course. And even move multiplication out of the if clauseButterbur
@blackmiaool, well I can change my code in a way that will not be affected by a resonable change in implementation of d3.stack method (see my updated answer). Can you do so? ;) Yes, I know that I still have call of d3.stack() twice in my code but I strongly believe that calling twice the same library method is much much better than duplciating logic of a library method in your own code.Butterbur
@blackmiaool, well if it is "DRY enough" for you, as I already said, it is time to agree to disagree.Butterbur
@blackmiaool, although I find your code easier to digest and much simpler, it does not work on modern browsers; I suspect it has something ti do with the => syntax (which I wasn't aware of) that is very poorly supported; I can't validate that it is the only problem though; not working on ie11.Baluchi
@Butterbur Also, I think it would be common sense to remove series down to one and not allow ALL series to be removed completely. Also, this debate between the two of you is bound to improve the final result. :)Baluchi
@scooterlord, not letting user to remove the final choice might be an option as well, but in this case I think some explicit feedback is required and thus it goes in an area of UX which is not my strong suiteButterbur
@SergGr, sorry, I thought since it was obvious to me, it was common sense.. however I am more of a UX kind of guy :)Baluchi
@Baluchi It works in IE11 now. The reason it didn't work is that I used es6 in it. There're many discussions on meta about it. There're many answers on SO use es6, if you want to make them work in IE11, you can use babelWiddershins
B
6

There are a few things that needed fixing:

First, JavaScript assignes objects by reference. It means that after

var fKeys = keys;

both fKeys and keys point to the same array. This is not what you want. You want something copying such as:

var fKeys = keys.slice();

Then your legendItem "click" handler was wrong because it doesn't really toggle the selected item. What you want is something like

        .on('click', function (keyToToggle) {
            // Go through both keys and fKeys to find out proper
            // position to insert keyToToggle if it is to be inserted
            var i, j;
            for (i = 0, j = 0; i < keys.length; i++) {
                // If we hit the end of fKeys, keyToToggle
                // should be last
                if (j >= fKeys.length) {
                    fKeys.push(keyToToggle);
                    break;
                }
                // if we found keyToToggle in fKeys - remove it
                if (fKeys[j] == keyToToggle) {
                    // remove it
                    fKeys.splice(j, 1);
                    break;
                }

                // we found keyToToggle in the original collection
                // AND it was not found at fKeys[j]. It means
                // it should be inserted to fKeys at position "j"
                if (keys[i] == keyToToggle) {
                    // add it
                    fKeys.splice(j, 0, keyToToggle);
                    break;
                }

                if (keys[i] == fKeys[j])
                    j++;
            }

            redraw();
        });

Next you want to povide key fuction when you call data to get stackedBars. This is important because otherwise data would be bound by index and always the last piece of data would be removed.

    var stackedData = d3.stack().keys(fKeys)(dataset);
    var stackedBars = g
            .selectAll(".d3-group")
            .data(stackedData , function (__data__, i, group) {
                return __data__.key;
            });

And finally, when you update '.d3-rect' you want to call data once again as child nodes cache data from the last draw and you want to override it with new data

        stackedBars.selectAll('.d3-rect')
                .data(function (d) {
                    return d; // force override with updated parent's data
                })
                .attr("x", function (d) {
                    return xz(d.data.country);
                })
                ...

Without such call, hiding first piece of data ("Vodafone") would not move other stacked pieces down.

Also there are a few too many global vairables (i.e. too few vars) and a few unnecessary variables.

Update (auto-scale y)

If you also want your Y-scale to be updated, you move var stackedData higher in the code of the redraw so you can use it to calculate your y as following

    var stackedData = d3.stack().keys(fKeys)(dataset);
    var autoScaleY = true; // scale Y according to selected data or always use original range
    var stackedDataForMax;
    if (autoScaleY && stackedData.length > 0) {
        // only selected data
        stackedDataForMax = stackedData;
    }
    else {
        // full range
        stackedDataForMax = d3.stack().keys(keys)(dataset);
    }
    var maxDataY = 1.2 * d3.max(stackedDataForMax.map(function (d) {
                return d3.max(d, function (innerD) {
                    return innerD[1];
                });
            }));
    y.domain([0, maxDataY]).rangeRound([height, 0]);

You can find whole code in the fork of your original fiddle.

Butterbur answered 2/4, 2017 at 0:57 Comment(7)
Hello there, sorry it took me so long to respond. Thanks for your effort! I have to say though, that there must be a more elegant way to do it. You provided some great ideas - like copying the original keys dataset and comparing if we want a new one to be added, but still I think it's too much effort. Also, the y scale should adapt to the changes, so I guess the dataset also needs some alteration.Baluchi
@scooterlord, I didn't get that auto-scaling Y was among your goals but it is easy to do (see update in the answer and updated fiddle). As for "more elegant way to do it" you might be right, I just started with your code and fixed bugs instead of creating something new from scratch.Butterbur
thanks for your reply. If it's not too much to ask and you don't mind spending the extra time, I would rather learn something right, than just getting an answer that works. Although there isn't one right way to do things in development, i would appreciate a better way to do things!Baluchi
@scooterlord, I'm not a D3 expert. I'm just reasonably good at JS and debugging. So if some real D3 expert comes up with a much better solution I'm quite OK with that. Still, out of curiosity, in which area(s) you expect the "ideal solution" to be much better?Butterbur
first of all I think that the data re-binding is unnecessary. During the weekend I realized that I had the order of the update and merge selection wrong. Reversing them updated the data properly. Moreover, in your first answer I don't get this part: // If we hit the end of fKeys, keyToToggle // should be lastBaluchi
@scooterlord, The goal of that code is to modify fKeys so that the order of (non-removed) keys there is the same as in the original keys. The idea is that I have two "synchronized" indices i and j that should point to identical values in keys and fKeys. More specifically j points to a place in fKeys where the value equal to keys[i] is now or should be insereted. There are 2 reasons to stop the algorithm: 1) we found keyToToggle in the keys - simple one. 2) keyToToggle was one of the last element of keys and it (and all elements after it) were removed.Butterbur
Then j will hit end of fKeys before i hits position where keyToToggle is in keys and thus attempt to do (fKeys[j] == keyToToggle) check willl potential be incorrect. But in this case, it is obvious that we a) need to add keyToToggle to fKeys and b) need to add it to the last position. Example: keys = [Vodafone, Wind, Cosmote], fKeys = [Vodafone] and keyToToggle = Cosmote. On the first iteration of the loop keys[i] === fKeys[j] so j will be incremented and we are already beyond end of fKeys. @Widdershins has another interesting approach using bitmasksButterbur
W
2

SergGr's code works well, but some parts can be cleaner.

onclick

var fKeys = keys.slice();

//a helper object to record the state of keys 
var fKeyReference = fKeys.map(function () {
    return true; //used to indicate if the corresponding key is active
});

function getActiveKeys(reference) {
    return reference.map(function (state, index) {
        if (state) {
            return keys[index]; //just keep keys whoes state is true
        }
        return false; //return false to be filered
    }).filter(function (name) {
        return name
    });
}

...
.on('click', function (d) {
    if (fKeys.length === 1 && fKeys[0] === d) {
        return;
    }

    var index = keys.indexOf(d);
    fKeyReference[index] = !fKeyReference[index]; // toggle state of fKeyReference
    fKeys = getActiveKeys(fKeyReference);
    redraw();
});

.stack()

g.selectAll(".d3-group").remove();//remove all groups and draw them all again
stackedBars = g
    .selectAll(".d3-group")
    .data(d3.stack().keys(fKeys)(dataset));

update the axis (y.domain)

y.domain([
    0,
    1.2 * d3.max(dataset, function (d) {
        return fKeys.reduce(function (pre, key) {//calculate the sum of values of fKeys
            return pre + d[key];
        }, 0);
    })
]);

And finally, jsfiddle

Widdershins answered 3/4, 2017 at 13:57 Comment(19)
your code does not allow more than one series to be toggled. For example if you click once on "Vodafone" and once on "Wind", instead of hiding them both, the second click toggles the first one.Baluchi
@Baluchi OK l'll fix itWiddershins
also if possible, please analyze your comments a bit more; for example why do you have to copy the keys array again?Baluchi
@Baluchi Alright. Due to your first comment, it's a bug. I'll remove it.Widdershins
@blackmiaool, I agree that using bitmask for fKeys might be a good idea, but I don't like your last suggestion regarding y.domain because 1) It means duplication of logic from stack method 2) If you deselect all components, diagram looks ugly (note how my code has two clauses in if (autoScaleY && stackedData.length > 0) ). The second clause allows to show original max scale in case nothing is selected.Butterbur
@Butterbur Showing original max scale or not depends on OP.Widdershins
And it saves a variable stackedData .Widdershins
@blackmiaool, I'm not sure if showng the original scale is the best solution, but showing nothing looks quite ugly to me. And I definitely prefer to have an explicit variable for stackedData to duplication of the logic of the d3.stack() method.Butterbur
@Butterbur If OP think showing original axis is better, he can use (fKeys.length?fKeys:keys).reduce(function (pre, key) { instead of fKeys.reduce(function (pre, key) {. The duplication is useful here.Widdershins
@blackmiaool, from your answers and ignoring another point, I infer that DRYness of the code doesn't bother you as a part of what makes a "clean solution". Then this is probably time to agree to disagree.Butterbur
@Butterbur So you think have two 1.2 * d3.max in the code is better?Widdershins
@blackmiaool, I can trivially move it to a named constant, of course. And even move multiplication out of the if clauseButterbur
@blackmiaool, well I can change my code in a way that will not be affected by a resonable change in implementation of d3.stack method (see my updated answer). Can you do so? ;) Yes, I know that I still have call of d3.stack() twice in my code but I strongly believe that calling twice the same library method is much much better than duplciating logic of a library method in your own code.Butterbur
@blackmiaool, well if it is "DRY enough" for you, as I already said, it is time to agree to disagree.Butterbur
@blackmiaool, although I find your code easier to digest and much simpler, it does not work on modern browsers; I suspect it has something ti do with the => syntax (which I wasn't aware of) that is very poorly supported; I can't validate that it is the only problem though; not working on ie11.Baluchi
@Butterbur Also, I think it would be common sense to remove series down to one and not allow ALL series to be removed completely. Also, this debate between the two of you is bound to improve the final result. :)Baluchi
@scooterlord, not letting user to remove the final choice might be an option as well, but in this case I think some explicit feedback is required and thus it goes in an area of UX which is not my strong suiteButterbur
@SergGr, sorry, I thought since it was obvious to me, it was common sense.. however I am more of a UX kind of guy :)Baluchi
@Baluchi It works in IE11 now. The reason it didn't work is that I used es6 in it. There're many discussions on meta about it. There're many answers on SO use es6, if you want to make them work in IE11, you can use babelWiddershins

© 2022 - 2024 — McMap. All rights reserved.