Monday, 10 June 2019

Level up your .sort game

Sorting is a super handy JavaScript method that can display the values of an array in a certain order. Whether that’s real estate listings by price, burger joints by distance, or best nearby happy hours by rating, sorting arrays of information is a common need.

If you’re already doing this with JavaScript on a project, you are will likely using the built-in array .sort method, which is in the same family of array methods that includes .filter, .map and .reduce.

Let's take a look at how to do that!

A quick note about side effects

Before going into the details of how to use .sort, there is a very important detail that needs to be addressed. While many of the ES5 array methods such as .filter, .map, and .reduce will return a new array and leave the original untouched, .sort will sort the array in place. If this is unwanted, an ES6 technique to avoid this is using the spread operator to concisely create a new array.

const foo = ['c','b','a'];
const bar = ['x','z','y'];
const fooSorted = foo.sort();
const barSorted = [...bar].sort();

console.log({foo, fooSorted, bar, barSorted});

/*
{
  "foo":       [ "a", "b", "c" ],
  "fooSorted": [ "a", "b", "c" ],
  "bar":       [ "x", "z", "y" ],
  "barSorted": [ "x", "y", "z" ]
}
*/

foo and fooSorted both reference the same array, but bar and barSorted are now individual arrays.

General overview

The only parameter of the .sort method is a function. The spec refers to this as the compareFn — I will refer to it as the "comparison function" for the rest of the post. This comparison function accepts two parameters, which I will refer to as a and b. a and b are the two elements that we will be comparing. If you do not provide a comparison function, the array will coerce each element into a string and sort according to Unicode points.

If you would like the a to be ordered first in the array, the comparison function should return a negative integer; for b, a positive integer. If you would like the two to maintain their current order, return a 0.

If you don't understand, don't worry! Hopefully it will become much more clear with a few examples.

Comparing numbers

One of the simplest callbacks to write is a number comparison.

const numbers = [13,8,2,21,5,1,3,1];
const byValue = (a,b) => a - b;
const sorted = [...numbers].sort(byValue);
console.log(sorted); // [1,1,2,3,5,8,13,21]

If a is greater than b, a - b will return a positive number, so b will be sorted first.

Comparing strings

When comparing strings, the > and < operators will compare values based on each string’s Unicode value. This means that all uppercase letters will be “less” than all lowercase letters, which can lead to unexpected behavior.

JavaScript does have a method to help with comparing strings: the String.prototype.localeCompare method. This method accepts a comparison string, a locale, and an options object. The options object accepts a few properties (all of which you can view here), but I find that the most useful is "sensitivity." This will affect how comparisons work between letter variations such as case and accent.

const strings = ['Über', 'alpha', 'Zeal', 'über', 'uber', 'Uber', 'Alpha', 'zeal'];

const sortBySensitivity = sensitivity => (a, b) => a.localeCompare(
  b,
  undefined, // locale string -- undefined means to use browser default
  { sensitivity }
);

const byAccent  = sortBySensitivity('accent');
const byBase    = sortBySensitivity('base');
const byCase    = sortBySensitivity('case');
const byVariant = sortBySensitivity('variant'); // default

const accentSorted  = [...strings].sort(byAccent);
const baseSorted    = [...strings].sort(byBase);
const caseSorted    = [...strings].sort(byCase);
const variantSorted = [...strings].sort(byVariant);

console.log({accentSorted, baseSorted, caseSorted, variantSorted});

/*
{
  "accentSorted":  [ "alpha", "Alpha", "uber", "Uber", "Über", "über", "Zeal", "zeal" ],
  "baseSorted":    [ "alpha", "Alpha", "Über", "über", "uber", "Uber", "Zeal", "zeal" ],
  "caseSorted":    [ "alpha", "Alpha", "über", "uber", "Über", "Uber", "zeal", "Zeal" ],
  "variantSorted": [ "alpha", "Alpha", "uber", "Uber", "über", "Über", "zeal", "Zeal" ]
}
*/

To me, baseSorted seems to be the most logical for most alphabetical sorting — ‘ü’, ‘u’, ‘Ü’, and ‘U’ are equivalent, so they remain in the order of the original array.

Running functions before comparing values

You may want to run a comparison function on a value that is derived from each array’s element. First, let’s write a comparison function factory that will "map" over the element before calling the comparison function.

const sortByMapped = (map,compareFn) => (a,b) => compareFn(map(a),map(b));

One use case for this is sorting based on the attribute of an object.

const purchases = [
  { name: 'Popcorn', price: 5.75 }, 
  { name: 'Movie Ticket', price: 12 },
  { name: 'Soda', price: 3.75 },
  { name: 'Candy', price: 5 },
];

const sortByMapped = (map,compareFn) => (a,b) => compareFn(map(a),map(b));
const byValue = (a,b) => a - b;
const toPrice = e => e.price;
const byPrice = sortByMapped(toPrice,byValue);

console.log([...purchases].sort(byPrice));

/*
[
  { name: "Soda", price: 3.75 },
  { name: "Candy", price: 5 },
  { name: "Popcorn", price: 5.75 },
  { name: "Movie Ticket", price: 12 }
]
*/

Another case might be to compare an array of dates.

const dates  = ['2018-12-10', '1991-02-10', '2015-10-07', '1990-01-11'];
const sortByMapped = (map,compareFn) => (a,b) => compareFn(map(a),map(b));
const toDate = e => new Date(e).getTime();
const byValue = (a,b) => a - b;
const byDate = sortByMapped(toDate,byValue);

console.log([...dates].sort(byDate));
// ["1990-01-11", "1991-02-10", "2015-10-07", "2018-12-10"]

Reversing a sort

There are some cases where you may want to reverse the outcome of a comparison function. This is subtly different than doing a sort and then reversing the result in the way ties are handled: if you reverse the outcome, ties will also be reversed in order.

To write a higher order function that accepts a comparison function and returns a new one, you will need to flip the sign of the comparison’s return value.

const flipComparison = fn => (a,b) => -fn(a,b);
const byAlpha = (a,b) => a.localeCompare(b, null, { sensitivity: 'base' });
const byReverseAlpha = flipComparison(byAlpha);

console.log(['A', 'B', 'C'].sort(byReverseAlpha)); // ['C','B','A']

Running a tiebreaker sort

There are times when you may want to have a "tie-breaker" sort — that is, another comparison function that is used in the case of a tie.

By using [].reduce, you can flatten an array of comparison functions into a single one.

const sortByMapped = map => compareFn => (a,b) => compareFn(map(a),map(b));
const flipComparison = fn => (a,b) => -fn(a,b);
const byValue = (a,b) => a - b;

const byPrice = sortByMapped(e => e.price)(byValue);
const byRating = sortByMapped(e => e.rating)(flipComparison(byValue));

const sortByFlattened = fns => (a,b) => 
  fns.reduce((acc, fn) => acc || fn(a,b), 0);

const byPriceRating = sortByFlattened([byPrice,byRating]);

const restaurants = [
  { name: "Foo's Burger Stand", price: 1, rating: 3 },
  { name: "The Tapas Bar", price: 3, rating: 4 },
  { name: "Baz Pizza", price: 3, rating: 2 },
  { name: "Amazing Deal", price: 1, rating: 5 },
  { name: "Overpriced", price: 5, rating: 1 }, 
];

console.log(restaurants.sort(byPriceRating));

/*
{name: "Amazing Deal", price: 1, rating: 5}
{name: "Foo's Burger Stand", price: 1, rating: 3}
{name: "The Tapas Bar", price: 3, rating: 4}
{name: "Baz Pizza", price: 3, rating: 2}
{name: "Overpriced", price: 5, rating: 1}
*/

Writing a random sort

You might want to sort an array "randomly." One technique that I have seen is to use the following function as the comparison function.

const byRandom = () => Math.random() - .5;

Since Math.random() returns a "random" number between 0 and 1, the byRandom function should return a positive number half of the time and a negative number the other half. This seems like it would be a good solution, but unfortunately, since the comparison function is not "consistent" — meaning it may not return the same value when called multiple times with the same values — it may result in some unexpected results.

For example, let's take an array of numbers between 0 and 4. If this byRandom function was truly random, it would be expected that the new index of each number would be spread out equally over enough iterations. The original 0 value would be just as likely to be in index 4 as index 0 in the new array. However, in practice, this function will bias each number to its original position.

See the Pen
Array.sort() Random 👎
by Adam Giese (@AdamGiese)
on CodePen.

The "diagonal" from the top-left will statistically have the greatest value. In an ideal and truly random sort, each table cell would hover around 20%.

The fix for this is to find a way to ensure that the comparison function remains consistent. One way to do this is to map the random value to each array element before the comparison, then map it away after.

const sortByMapped = map => compareFn => (a,b) => compareFn(map(a),map(b));
const values = [0,1,2,3,4,5,6,7,8,9];
const withRandom = (e) => ({ random: Math.random(), original: e });
const toOriginal = ({original}) => original;
const toRandom = ({random}) => random;
const byValue = (a,b) => a - b;
const byRandom = sortByMapped(toRandom)(byValue);

const shuffleArray = array => array
  .map(withRandom)
  .sort(byRandom)
  .map(toOriginal);

This ensures that each element has a single random value that is only calculated once per element rather than once per comparison. This removes the sorting bias towards the original position.

See the Pen
Array.sort() Random 👍
by Adam Giese (@AdamGiese)
on CodePen.

The post Level up your .sort game appeared first on CSS-Tricks.



from CSS-Tricks http://bit.ly/31k67nI
via IFTTT

No comments:

Post a Comment

Passkeys: What the Heck and Why?

These things called  passkeys  sure are making the rounds these days. They were a main attraction at  W3C TPAC 2022 , gained support in  Saf...