1
votes

I have this dc.js dataTable with only two columns, one of which is a number. And I need to add a third column, with that same number but expressed as a percentage over the total:

VENDOR   SCORE      PERCENT
-------  -----      -------
Charles  5          50.0 %  
Sarah    4          40.0 %
John     1          10.0 %

If the table was static, I would just calculate the percentages and add them to the data array before passing it to Crossfilter:

let sum = data.reduce((accum, d) => accum + d.score, 0);
data.forEach(d => { d.percent = 100 * d.score / sum; });

But the data can be filtered (using dc.js charts and selectMenus), for example the user could choose to display only female vendors, and then the table would show only Sarah... but with her old percentage, 40% instead of recalculating it to 100%.

Is there any way I can work around this? I guess there is no way to automatically calculate the percentage. But maybe I can add a listener to some event which triggers after the data is filtered, but before dc.js redraws the table?


By the way, in case it makes any difference... to make things worse, I'm not working directly with the data dimension, instead I'm passing a fake group to the table (code borrowed from here):

function filteredGroup(originalGroup, filterFunction) {
  return {
    all: () => originalGroup.all().filter(filterFunction),
    top: n => originalGroup.top(Infinity).filter(filterFunction).slice(0, n),
    bottom: n => originalGroup.top(Infinity).filter(filterFunction).slice(-n).reverse()
  };
}

let ndx = crossfilter(data);
let dim = ndx.dimension(d => d.vendor);
let grp = filteredGroup(dim.group().reduceCount(), d => d.value > 0);

dc.dataTable('#id')
  .dimension(grp)
  .group(d => d.someOtherField)
  .showGroups(true)
  .columns([d => d.vendor, d => d.score])
  ...

(My actual data array would contain 5 entries for Charles, 4 for Sarah and 1 for John. Each includes more information, such as date and time, which is used in the selectMenus to allow filtering. But I need aggregated data on the table. That's why I'm using a group instead of the dimension.)

1
Sure you can. The group is calculated every time the table is displayed (that's why all/top/bottom are functions). So you can calculate the total/score sums in your original group, and then calculate percentages in your filtered group, or in your column accessors. - Gordon
Someone else asked almost the same question today, maybe this will help: stackoverflow.com/questions/52871245/… - Gordon
Thank you Gordon :). It was extremely easy to do, I can't believe I spent so much time trying to figure this out! I was thinking of groups and "fake groups" like some magical thing... when they're actually quite simple, just an object with 3 functions that return a data array. - AJPerez
Great, thanks for following up! That's just it, no magic to it! - Gordon

1 Answers

2
votes

It's actually very easy to do it. I had lost perspective, a "group" isn't a mysterious thing that works magically. It's just an object with 3 functions: all, which returns an array with all the data, and top and bottom, which do the same but returning only the first N or last N elements. And as Gordon pointed out in his comment, these functions are called every time a table is displayed.

So, I just needed to calculate my percentages inside the fake group definition:

function filteredGroup(originalGroup, filterFunction) {
  return {
    all: () => calc(originalGroup.all().filter(filterFunction)),
    top: n => calc(originalGroup.top(Infinity).filter(filterFunction)).slice(0, n),
    bottom: n => calc(originalGroup.top(Infinity).filter(filterFunction)).slice(-n).reverse()
  };
}

function calc(data) {
  let sum = data.reduce((accum, d) => accum + d.score, 0);
  data.forEach(d => { d.percent = 100 * d.score / sum; });
  return data;
}