0

I have an array of objects where I have sorted on a key (group below) such that all of the objects wit hthe same value for group are adjacent to each other in the indices of data. For example:

var data = [{foo: "cat", group:"house"},
            {foo: "cat", group: "house"},
            {foo: "cat", group: "tree"},
            {foo: "dog", group: "tree"},
            {foo: "dog", group: "car"}];

I am trying to shuffle the order of the objects in the data array while preserving the ordering within the values of the key group. In other words, I am trying to shuffle groups of objects in data and not the individual objects. While I know how to shuffle objects in an array, I don't know how to shuffle groups of object in an array.

My thoughts were that there might be a way to use the fact that the values of group change only when the group changes.

socialscientist
  • 3,759
  • 5
  • 23
  • 58

3 Answers3

1

You have a fun question here. I just wrote about this recently so follow that link if you're interested in the ideas presented in this answer -

const randInt = (n = 0) =>
  Math.floor(Math.random() * n)

const { empty, map, concat } =
  Comparison

const sortByGroup =
  map(empty, x => x.group)

const sortByRand =
  map(empty, _ => randInt(3) - 1) // -1, 0, 1

Intuitively, we use map(empty, ...) to make a new comparison (sorter). concat is what we use to combine one comparison with another -

// sort by .group then sort by rand
const mySorter =
  concat(sortByGroup, sortByRand) 

Our comparison plugs directly into Array.prototype.sort -

const data =
  [ { name: "Alice", group: "staff" }
  , { name: "Monty", group: "client" }
  , { name: "Cooper", group: "client" }
  , { name: "Jason", group: "staff" }
  , { name: "Farrah", group: "staff" }
  , { name: "Celeste", group: "guest" }
  , { name: "Briana", group: "staff" }
  ]

console.log("first", data.sort(mySorter)) // shuffle once
console.log("second", data.sort(mySorter)) // shuffle again

In the output, we see items grouped by group and then randomised -

// first
[ { name: "Cooper", group: "client" }
, { name: "Monty", group: "client" }
, { name: "Celeste", group: "guest" }
, { name: "Alice", group: "staff" }
, { name: "Jason", group: "staff" }
, { name: "Farrah", group: "staff" }
, { name: "Briana", group: "staff" }
]

// second
[ { name: "Monty", group: "client" }
, { name: "Cooper", group: "client" }
, { name: "Celeste", group: "guest" }
, { name: "Farrah", group: "staff" }
, { name: "Alice", group: "staff" }
, { name: "Jason", group: "staff" }
, { name: "Briana", group: "staff" }
]

Finally, we implement Comparison -

const Comparison =
  { empty: (a, b) =>
      a < b ? -1
        : a > b ? 1
          : 0
  , map: (m, f) =>
      (a, b) => m(f(a), f(b))
  , concat: (m, n) =>
      (a, b) => Ordered.concat(m(a, b), n(a, b))
  }

const Ordered =
  { empty: 0
  , concat: (a, b) =>
      a === 0 ? b : a
  }

Expand the snippet below to verify the results in your own browser. Run the program multiple times to see the results are always ordered by group and then randomised -

const Comparison =
  { empty: (a, b) =>
      a < b ? -1
        : a > b ? 1
          : 0
  , map: (m, f) =>
      (a, b) => m(f(a), f(b))
  , concat: (m, n) =>
      (a, b) => Ordered.concat(m(a, b), n(a, b))
  }

const Ordered =
  { empty: 0
  , concat: (a, b) =>
      a === 0 ? b : a
  }

const randInt = (n = 0) =>
  Math.floor(Math.random() * n)

const { empty, map, concat } =
  Comparison

const sortByGroup =
  map(empty, x => x.group)

const sortByRand =
  map(empty, _ => randInt(3) - 1) // -1, 0, 1

const mySorter =
  concat(sortByGroup, sortByRand) // sort by .group then sort by rand

const data =
  [ { name: "Alice", group: "staff" }
  , { name: "Monty", group: "client" }
  , { name: "Cooper", group: "client" }
  , { name: "Jason", group: "staff" }
  , { name: "Farrah", group: "staff" }
  , { name: "Celeste", group: "guest" }
  , { name: "Briana", group: "staff" }
  ]
   
console.log(JSON.stringify(data.sort(mySorter))) // shuffle once
console.log(JSON.stringify(data.sort(mySorter))) // shuffle again

small improvement

Instead of hard-coding sorters like sortByGroup, we can make a parameterised comparison, sortByProp -

const sortByProp = (prop = "") =>
  map(empty, (o = {}) => o[prop])

const sortByFullName =
  concat
    ( sortByProp("lastName")  // primary: sort by obj.lastName
    , sortByProp("firstName") // secondary: sort by obj.firstName
    )

data.sort(sortByFullName) // ...

why a module?

The benefits of defining a separate Comparison module are numerous but I won't repeat them here. The module allows us to model complex sorting logic with ease -

const sortByName =
  map(empty, x => x.name)

const sortByAge =
  map(empty, x => x.age)

const data =
  [ { name: 'Alicia', age: 10 }
  , { name: 'Alice', age: 15 }
  , { name: 'Alice', age: 10 }
  , { name: 'Alice', age: 16 }
  ]

Sort by name then sort by age -

data.sort(concat(sortByName, sortByAge))
// [ { name: 'Alice', age: 10 }
// , { name: 'Alice', age: 15 }
// , { name: 'Alice', age: 16 }
// , { name: 'Alicia', age: 10 }
// ]

Sort by age then sort by name -

data.sort(concat(sortByAge, sortByName))
// [ { name: 'Alice', age: 10 }
// , { name: 'Alicia', age: 10 }
// , { name: 'Alice', age: 15 }
// , { name: 'Alice', age: 16 }
// ]

And effortlessly reverse any sorter. Here we sort by name then reverse sort by age -

const Comparison =
  { // ...
  , reverse: (m) =>
      (a, b) => m(b, a)
  }

data.sort(concat(sortByName, reverse(sortByAge)))
// [ { name: 'Alice', age: 16 }
// , { name: 'Alice', age: 15 }
// , { name: 'Alice', age: 10 }
// , { name: 'Alicia', age: 10 }
// ]

functional principles

Our Comparison module is flexible yet reliable. This allows us to write our sorters in a formula-like way -

// this...
concat(reverse(sortByName), reverse(sortByAge))

// is the same as...
reverse(concat(sortByName, sortByAge))

And similarly with concat expressions -

// this...
concat(sortByYear, concat(sortByMonth, sortByDay))

// is the same as...
concat(concat(sortByYear, sortByMonth), sortByDay)

// is the same as...
nsort(sortByYear, sortByMonth, sortByDay)

multi-sort

Because our comparisons can be combined to create more sophisticated comparisons, we can effectively sort by an arbitrary number of factors. For example, sorting date objects requires three comparisons: year, month, and day. Thanks to functional principles, our concat and empty do all the hard work -

const Comparison =
  { // ...
  , nsort: (...m) =>
      m.reduce(Comparison.concat, Comparison.empty)
  }

const { empty, map, reverse, nsort } =
  Comparison

const data =
  [ { year: 2020, month: 4, day: 5 }
  , { year: 2018, month: 1, day: 20 }
  , { year: 2019, month: 3, day: 14 }
  ]

const sortByDate =
  nsort
    ( map(empty, x => x.year)  // primary: sort by year
    , map(empty, x => x.month) // secondary: sort by month
    , map(empty, x => x.day)   // tertiary: sort by day
    )

Now we can sort by year, month, day -

data.sort(sortByDate)
// [ { year: 2019, month: 11, day: 14 }
// , { year: 2020, month: 4, day: 3 }
// , { year: 2020, month: 4, day: 5 }
// ]

And just as easily reverse sort by year, month, day -

data.sort(reverse(sortByDate))
// [ { year: 2020, month: 4, day: 5 }
// , { year: 2020, month: 4, day: 3 }
// , { year: 2019, month: 11, day: 14 }
// ]

To run the reverse and nsort examples, follow along to the original post


complex sort

You are certainly looking for a nuanced sorter, but worry not, our module is capable of handling it -

const { empty, map } =
  Comparison

const randParitionBy = (prop = "", m = new Map) =>
  map
    ( empty
    , ({ [prop]: value }) =>
        m.has(value)
          ? m.get(value)
          : ( m.set(value, Math.random())
            , m.get(value)
            )
    )

console.log(data)                               // presort...
console.log(data.sort(randParitionBy("group"))) // first...
console.log(data.sort(randParitionBy("group"))) // again...

Output -

// pre-sort
[ {name:"Alice",group:"staff"}
, {name:"Monty",group:"client"}
, {name:"Cooper",group:"client"}
, {name:"Jason",group:"staff"}
, {name:"Farrah",group:"staff"}
, {name:"Celeste",group:"guest"}
, {name:"Briana",group:"staff"}
]

// first run (elements keep order, but sorted by groups, groups are sorted randomly)
[ {name:"Celeste",group:"guest"}
, {name:"Alice",group:"staff"}
, {name:"Jason",group:"staff"}
, {name:"Farrah",group:"staff"}
, {name:"Briana",group:"staff"}
, {name:"Monty",group:"client"}
, {name:"Cooper",group:"client"}
]

// second run (elements keep order and still sorted by groups, but groups are sorted differently)
[ {name:"Alice",group:"staff"}
, {name:"Jason",group:"staff"}
, {name:"Farrah",group:"staff"}
, {name:"Briana",group:"staff"}
, {name:"Monty",group:"client"}
, {name:"Cooper",group:"client"}
, {name:"Celeste",group:"guest"}
]

const Comparison =
  { empty: (a, b) =>
      a < b ? -1
        : a > b ? 1
          : 0
  , map: (m, f) =>
      (a, b) => m(f(a), f(b))
  }
  
const { empty, map } =
  Comparison

const data =
  [ { name: "Alice", group: "staff" }
  , { name: "Monty", group: "client" }
  , { name: "Cooper", group: "client" }
  , { name: "Jason", group: "staff" }
  , { name: "Farrah", group: "staff" }
  , { name: "Celeste", group: "guest" }
  , { name: "Briana", group: "staff" }
  ]

const randParitionBy = (prop = "", m = new Map) =>
  map
    ( empty
    , ({ [prop]: value }) =>
        m.has(value)
          ? m.get(value)
          : ( m.set(value, Math.random())
            , m.get(value)
            )
    )

console.log(JSON.stringify(data.sort(randParitionBy("group")))) // run multiple times!
Mulan
  • 129,518
  • 31
  • 228
  • 259
  • So I tried to adapt this to my own code and it appears to be sorting all of the observations on group and then within the group it is randomizing. This is actually the opposite of the intended behavior, where the groups themselves change ordering but within groups the objects do not. My expectations were that if I sorted on the random number then on group would solve the problem, but it does not. Thoughts? – socialscientist Apr 12 '20 at 21:43
  • @user3614648 I misunderstood the explanation in your original post. Does the edit at the bottom of my post answer your question? – Mulan Apr 12 '20 at 22:04
1

Simply create a random property to sort with at the group level and assign the property to each respective object in the array:

var data = [{foo: "cat", group: "house"},
            {foo: "cat", group: "house"},
            {foo: "cat", group: "tree"},
            {foo: "dog", group: "tree"},
            {foo: "dog", group: "car"}];

//get random sorting at the group level (via a hashtable)
let randomGroupSortKey = {}
data.forEach(d => randomGroupSortKey[d.group] = Math.random())
console.log("Group sort keys:", randomGroupSortKey)

//add the sortKey property to the individual array entries
let dataSortable = data.map(x => {
  return {
    ...x, 
    sortKey: randomGroupSortKey[x.group]
  }
})

dataSortable.sort((a, b) => a.sortKey - b.sortKey) //sort the groups!

console.log("Result:", dataSortable)
console.log("Result without sortKey:", dataSortable.map(({ sortKey, ...x }) => x))
Adi H
  • 668
  • 7
  • 9
  • 1
    This makes the most sense, works, and is well-done. – socialscientist Apr 12 '20 at 21:53
  • 2
    Since you add the sort key using `x => ({...x, sortKey: "..."})` I would remove it in the same way `({sortKey, ...x}) => x` instead of `x => ({foo: x.foo, group: x.group})`, which only extracts the "foo" and "group" property. – 3limin4t0r Apr 12 '20 at 22:15
  • 1
    @3limin4t0r thank you! I didn't even know we could use rest syntax here. For those interested here's a good SO explanation: https://stackoverflow.com/a/56156100/2328210 – Adi H Apr 12 '20 at 22:24
  • For some reason I am getting an error that the ... is an unrecognized token for both instances of it. Any advice? – socialscientist Apr 12 '20 at 22:34
  • Alright, no idea why the "..." rest syntax doesn't work for me. I edited it in my own application to explicitly state what to return. – socialscientist Apr 12 '20 at 22:43
  • I'm guessing you probably don't have ES6 support? I'd look into using Babel to transpile or convert the code to ES5 equivalent by manually creating an object from the properties – Adi H Apr 12 '20 at 22:45
  • @user3614648 If you are talking about the `"..."` string in my comment, that's only there to shorten the comment and should be replaced with `randomGroupSortKey[x.group]`. I used `"..."` since the value was not relevant for the comment. – 3limin4t0r Apr 12 '20 at 23:01
1

You could first group the objects by their group property, then shuffle the groups, lastly unnest the groups.

function groupBy(iterable, keyFn = obj => obj) {
  const groups = new Map();
  for (const item of iterable) {
    const key = keyFn(item);
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(item);
  }
  return groups;
}

function shuffle(array) {
  array = array.slice(0);
  for (let limit = array.length; limit > 0; --limit) {
    const index = Math.floor(Math.random() * limit);
    array.push(...array.splice(index, 1));
  }
  return array;
}

var data = [{foo:"cat",group:"house"},{foo:"cat",group:"house"},{foo:"cat",group:"tree"},{foo:"dog",group:"tree"},{foo:"dog",group:"car"}];

data = groupBy(data, obj => obj.group);
data = Array.from(data.values());
data = shuffle(data);
data = data.flat();

console.log(data);
3limin4t0r
  • 19,353
  • 2
  • 31
  • 52