score:3

Accepted answer

With d3 v7 released, there is now a better way to do this using the new d3.flatRollup.

const data = [
    { a: 10, b: 20, c: 30, d: 40 },
    { a: 10, b: 20, c: 31, d: 41 },
    { a: 12, b: 22, c: 32, d: 42 }
];

const result = d3.flatRollup(
    data,
    x => ({
      c: x.map(d => d.c),
      d: x.map(d => d.d)
    }),
    d => d.a,
    d => d.b
  );
console.log(result);

const flattened = result.map(([a, b, values]) => ({a, b, ...values}));
console.log(flattened);
<script src="https://cdn.jsdelivr.net/npm/d3-array@3.0.2/dist/d3-array.min.js"></script>

score:1

The approach below allows you to remove the split, but does not prevent the need to create a string for the compound key. In this case, using JSON.stringify({a: d.a, b: d.b}) instead of ${d.a} ${d.b}, allows for the map to return an object where the c and d properties can be assigned to the parse of the key.

This preserves some of the 'd3-ishness' of your question and the utility of rollups to deal with the creation of the arrays for c and d.

const data = [
  { a: 10, b: 20, c: 30, d: 40 },
  { a: 10, b: 20, c: 31, d: 41 },
  { a: 12, b: 22, c: 32, d: 42 }
];

const groups = d3.rollups(
  data,
  x => ({
    c: x.map(d => d.c),
    d: x.map(d => d.d)
  }),
  d => JSON.stringify({a: d.a, b: d.b}) // compare with `${d.a} ${d.b}`
).map(arr => Object.assign(JSON.parse(arr[0]), arr[1]));

console.log(groups);
.as-console-wrapper { max-height: 100% !important; top: 0; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.5.0/d3.min.js"></script>

The approach can accommodate the extensibility of @Gerado Furtado's answer, but I fear it's getting a little hectic:

const data = [
  {a: 10, b: 20, c: 30, d: 40, e: 5, f: 19},
  {a: 10, b: 55, c: 37, d: 40, e: 5, f: 19},
  {a: 10, b: 20, c: 31, d: 48, e: 5, f: 18},
  {a: 80, b: 20, c: 31, d: 48, e: 5, f: 18},
  {a: 1, b: 2, c: 3, d: 8, e: 5, f: 9},
  {a: 10, b: 88, c: 44, d: 33, e: 5, f: 19}
];
const keys = ["a", "e", "f"];

const groupedRollup = (data, keys) => {
  const others = Object.keys(data[0])
    .filter(k => !keys.includes(k)); // finds b, c, d as not part of compound key
    
  return d3.rollups(
    data,
    x => Object.assign(
      {}, 
      ...others.map(k => {
        return {[k]: x.map(d => d[k])} // dynamically create reducer
      })
    ),
    d => JSON.stringify(
      Object.assign(
        {}, 
        ...keys.map(k => {
          return {[k]: d[k]} // dynamically add keys
        })
      )
    ) // and stringify for compound key
  ).map(arr => Object.fromEntries( // sorting the output object
    Object.entries( // keys in alpha order
      Object.assign(JSON.parse(arr[0]), arr[1])).sort() // same approach
    )
  );
}

console.log(groupedRollup(data, keys));
.as-console-wrapper { max-height: 100% !important; top: 0; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.5.0/d3.min.js"></script>

There's some interesting talk about the introduction of use of InternMap in rollups and the associated functions - but I don't see either that it's ready, or that it's useful for what you are trying to do.

score:2

As you already know d3.rollups() will create nested arrays if you have more than one key:

If more than one key is specified, a nested Map [or array] is returned.

Therefore, as d3.rollups doesn't fit your needs, I believe it's easier to create a plain JavaScript function (I'm aware of "using D3" in your title, but even in a D3 code nothing forbids us of writing plain JS solutions where D3 has none).

In the following example I'm purposefully writing a verbose function (with comments) so each part of it is clear, avoiding more complex features which could make it substantially short (but more cryptic). In this function I'm using reduce, so the data array is looped only once. myKeys is the array of keys you'll use to rollup.

Here is the function and the comments:

function groupedRollup(myArray, myKeys) {
  return myArray.reduce((a, c) => {
    //Find the object in the acc with all 'myKeys' equivalent to the current
    const foundObject = a.find(e => myKeys.every(f => e[f] === c[f]));
    //if found, push the value for each key which is not in 'myKeys'
    if (foundObject) {
      for (let key in foundObject) {
        if (!keys.includes(key)) foundObject[key].push(c[key]);
      };
    //if not found, push the current object with all non 'myKeys' keys as arrays
    } else {
      const copiedObject = Object.assign({}, c);//avoids mutation
      for (let key in copiedObject) {
        if (!keys.includes(key)) copiedObject[key] = [copiedObject[key]];
      };
      a.push(copiedObject);
    };
    return a;
  }, [])
};

Here is the demo:

const data = [{
    a: 10,
    b: 20,
    c: 30,
    d: 40
  },
  {
    a: 10,
    b: 20,
    c: 31,
    d: 41
  },
  {
    a: 12,
    b: 22,
    c: 32,
    d: 42
  }
];
const keys = ["a", "b"];

console.log(groupedRollup(data, keys))

function groupedRollup(myArray, myKeys) {
  return myArray.reduce((a, c) => {
    const foundObject = a.find(e => myKeys.every(f => e[f] === c[f]));
    if (foundObject) {
      for (let key in foundObject) {
        if (!keys.includes(key)) foundObject[key].push(c[key]);
      };
    } else {
      const copiedObject = Object.assign({}, c);
      for (let key in copiedObject) {
        if (!keys.includes(key)) copiedObject[key] = [copiedObject[key]];
      };
      a.push(copiedObject);
    };
    return a;
  }, [])
};

And here is a demo with a more complex data:

const data = [{
    a: 10,
    b: 20,
    c: 30,
    d: 40,
    e: 5,
    f: 19
  },
  {
    a: 10,
    b: 55,
    c: 37,
    d: 40,
    e: 5,
    f: 19
  },
  {
    a: 10,
    b: 20,
    c: 31,
    d: 48,
    e: 5,
    f: 18
  },
  {
    a: 80,
    b: 20,
    c: 31,
    d: 48,
    e: 5,
    f: 18
  },
  {
    a: 1,
    b: 2,
    c: 3,
    d: 8,
    e: 5,
    f: 9
  },
  {
    a: 10,
    b: 88,
    c: 44,
    d: 33,
    e: 5,
    f: 19
  }
];
const keys = ["a", "e", "f"];

console.log(groupedRollup(data, keys))

function groupedRollup(myArray, myKeys) {
  return myArray.reduce((a, c) => {
    const foundObject = a.find(e => myKeys.every(f => e[f] === c[f]));
    if (foundObject) {
      for (let key in foundObject) {
        if (!keys.includes(key)) foundObject[key].push(c[key]);
      };
    } else {
      const copiedObject = Object.assign({}, c);
      for (let key in copiedObject) {
        if (!keys.includes(key)) copiedObject[key] = [copiedObject[key]];
      };
      a.push(copiedObject);
    };
    return a;
  }, [])
};

Finally, pay attention that this function will push duplicated values (in the above example d: [40, 40, 33]). If that's not what you want then just check for duplicates.


Related Query

More Query from same tag