Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

d3.scale.category10() not behaving as expected

Tags:

d3.js

I'm encountering unexpected behavior when using d3.scale.category10() to generate 10 fixed colors.

Starting out, I note that colors.range() returns an array of properly ordered colors, according to the documentation.

var colors = d3.scale.category10();
console.log(colors.range());
// -> ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"] 

My expectation is that calling colors(0) will always return the zeroth item, colors(1) the first, and so on. However what I'm observing is that if I first call colors(1), the zeroth item is returned instead of the first from that point onward. Subsequently calling colors(0) will return the first item instead of the zeroth. So it appears that the return value is bound to the order that the indices are used, instead of the natural order.

Here's a fiddle: http://jsfiddle.net/LqHst/

To work around this, I'm just stepping through a loop to touch all the colors in the correct order.

for(var i = 0; i < 10; i++) {
  colors(i);
}

Either I'm misunderstanding how this should work, or I'm blind to my incorrect usage. I've used this feature before and remember encountering the expected behavior, so I think I'm just doing something wrong or making an incorrect assumption.

like image 368
t.888 Avatar asked Dec 15 '13 01:12

t.888


2 Answers

You misunderstand the usage of category10.

As the document mentioned: d3.scale.category10() constructs a new ordinal scale with a range of ten categorical colors.

That is to say: var color = d3.scale.category10() will construct a new ordinal scale with empty domain and range with ten colors.

When you use the ordinal scale:

If no domain is set, a range must be set explicitly. Then, each unique value that is passed to the scale function will be assigned a new value from the output range; in other words, the domain will be inferred implicitly from usage. Although domains may thus be constructed implicitly,

https://github.com/mbostock/d3/wiki/Ordinal-Scales#wiki-ordinal_domain you can read the API of ordinal scale for more information.

Update: an ordinal-scale is a map, not an array.

If domain is not set explicit, the domain will be construct implicit with the key sequence you invoke color(key).

  var color = d3.scale.category10();

  console.log(color.domain()); // []

  color("aaa");
  console.log(color.domain()); // ["aaa"]

  color("bbb");
  console.log(color.domain());  // ["aaa", "bbb"]

  color("ccc");
  console.log(color.domain()); // ["aaa", "bbb", "ccc"]

This is useful when you just want to assign a different color for different clusters, and don't have a fixed color mapping. (Think about the situation: when your program support user upload data file as the data source.)

If you want to map each category to specific color, you have to set the domain explicit so that the mapping is not depend on the sequence of keys.

  var color = d3.scale.category10();

  var domain = ["bbb", "ddd", "ccc", "23", "hello"];

  color.domain(domain);

  console.log(color.domain()); // ["bbb", "ddd", "ccc", "23", "hello"] 
  console.log(color.range());  // ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"] 

  color("ddd"); // "#ff7f0e" : d3 will get index of "ddd" and return range[index]
like image 117
qiu-deqing Avatar answered Nov 04 '22 05:11

qiu-deqing


It turns out that setting the scale's domain remedies this issue.

var colors = d3.scale.category10().domain([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);

// Now this will return the first item instead of the zeroth.
console.log(colors(1)); 

Or somewhat more succinctly,

var colors = d3.scale.category10().domain(d3.range(0,10));

Updated fiddle: http://jsfiddle.net/LqHst/2/

When the category10 scale is created, it's created with a range of 10 colors, and an empty domain.

var colors = d3.scale.category10();
console.log(colors.range()); // -> ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"]
console.log(colors.domain()); // -> []

According to the documentation (and the accepted answer), setting the domain on an ordinal scale is optional. When no domain is set, its values are assumed from calls to the scale function.

var colors = d3.scale.category10();
console.log(colors.domain()); // -> []
console.log(colors(1)); // -> #1f77b4
console.log(colors.domain()); // -> [1]
console.log(colors(0), colors(3), colors(7)); // -> #ff7f0e #2ca02c #d62728
console.log(colors.domain()); // -> [1, 0, 3, 7] 

Only if the given index is not already in the domain does it get added.

This is why the workaround, as stated in the original question, produced the expected behavior. Stepping through the scale via a for loop queried the scale in natural order, adding ordered indices to the domain.

var colors = d3.scale.category10();

for (var i = 0; i < 10; i++) {
    colors(i)
}

console.log(colors.domain()); // -> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

The moral of the story is to set the domain explicitly, in order to get more predictable behavior, as shown at the top of this answer.

like image 25
t.888 Avatar answered Nov 04 '22 05:11

t.888