« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util/wiki-data.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/util/wiki-data.js')
-rw-r--r--src/util/wiki-data.js422
1 files changed, 374 insertions, 48 deletions
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index 89c621c5..a3133748 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -3,6 +3,8 @@
 import {
   accumulateSum,
   empty,
+  stitchArrays,
+  unique,
 } from './sugar.js';
 
 // Generic value operations
@@ -70,6 +72,36 @@ export function chunkByProperties(array, properties) {
   }));
 }
 
+export function chunkMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const newChunk = index => arrays.map(array => [array[index]]);
+  const results = [newChunk(0)];
+
+  for (let i = 1; i < arrays[0].length; i++) {
+    const current = results.at(-1);
+
+    const args = [];
+    for (let j = 0; j < arrays.length; j++) {
+      const item = arrays[j][i];
+      const previous = current[j].at(-1);
+      args.push(item, previous);
+    }
+
+    if (fn(...args)) {
+      results.push(newChunk(i));
+      continue;
+    }
+
+    for (let j = 0; j < arrays.length; j++) {
+      current[j].push(arrays[j][i]);
+    }
+  }
+
+  return results;
+}
+
 // Sorting functions - all utils here are mutating, so make sure to initially
 // slice/filter/somehow generate a new array from input data if retaining the
 // initial sort matters! (Spoilers: If what you're doing involves any kind of
@@ -117,6 +149,123 @@ export function normalizeName(s) {
   return s;
 }
 
+// Sorts multiple arrays by an arbitrary function (which is the last argument).
+// Paired values from each array are provided to the callback sequentially:
+//
+//   (a_fromFirstArray, b_fromFirstArray,
+//    a_fromSecondArray, b_fromSecondArray,
+//    a_fromThirdArray, b_fromThirdArray) =>
+//     relative positioning (negative, positive, or zero)
+//
+// Like native single-array sort, this is a mutating function.
+export function sortMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const length = arrays[0].length;
+  const symbols = new Array(length).fill(null).map(() => Symbol());
+  const indexes = Object.fromEntries(symbols.map((symbol, index) => [symbol, index]));
+
+  symbols.sort((a, b) => {
+    const indexA = indexes[a];
+    const indexB = indexes[b];
+
+    const args = [];
+    for (let i = 0; i < arrays.length; i++) {
+      args.push(arrays[i][indexA]);
+      args.push(arrays[i][indexB]);
+    }
+
+    return fn(...args);
+  });
+
+  for (const array of arrays) {
+    // Note: We're mutating this array pulling values from itself, but only all
+    // at once after all those values have been pulled.
+    array.splice(0, array.length, ...symbols.map(symbol => array[indexes[symbol]]));
+  }
+
+  return arrays;
+}
+
+// Filters multiple arrays by an arbitrary function (which is the last argument).
+// Values from each array are provided to the callback sequentially:
+//
+//   (value_fromFirstArray,
+//    value_fromSecondArray,
+//    value_fromThirdArray,
+//    index,
+//    [firstArray, secondArray, thirdArray]) =>
+//      true or false
+//
+// Please be aware that this is a mutating function, unlike native single-array
+// filter. The mutated arrays are returned. Also attached under `.removed` are
+// corresponding arrays of items filtered out.
+export function filterMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const removed = new Array(arrays.length).fill(null).map(() => []);
+
+  for (let i = arrays[0].length - 1; i >= 0; i--) {
+    const args = arrays.map(array => array[i]);
+    args.push(i, arrays);
+
+    if (!fn(...args)) {
+      for (let j = 0; j < arrays.length; j++) {
+        const item = arrays[j][i];
+        arrays[j].splice(i, 1);
+        removed[j].unshift(item);
+      }
+    }
+  }
+
+  Object.assign(arrays, {removed});
+  return arrays;
+}
+
+// Reduces multiple arrays with an arbitrary function (which is the last
+// argument). Note that this reduces into multiple accumulators, one for
+// each input array, not just a single value. That's reflected in both the
+// callback parameters:
+//
+//   (accumulator1,
+//    accumulator2,
+//    value_fromFirstArray,
+//    value_fromSecondArray,
+//    index,
+//    [firstArray, secondArray]) =>
+//      [newAccumulator1, newAccumulator2]
+//
+// As well as the final return value of reduceMultipleArrays:
+//
+//   [finalAccumulator1, finalAccumulator2]
+//
+// This is not a mutating function.
+export function reduceMultipleArrays(...args) {
+  const [arrays, fn, initialAccumulators] =
+    (typeof args.at(-1) === 'function'
+      ? [args.slice(0, -1), args.at(-1), null]
+      : [args.slice(0, -2), args.at(-2), args.at(-1)]);
+
+  if (empty(arrays[0])) {
+    throw new TypeError(`Reduce of empty arrays with no initial value`);
+  }
+
+  let [accumulators, i] =
+    (initialAccumulators
+      ? [initialAccumulators, 0]
+      : [arrays.map(array => array[0]), 1]);
+
+  for (; i < arrays[0].length; i++) {
+    const args = [...accumulators, ...arrays.map(array => array[i])];
+    args.push(i, arrays);
+    accumulators = fn(...args);
+  }
+
+  return accumulators;
+}
+
 // Component sort functions - these sort by one particular property, applying
 // unique particulars where appropriate. Usually you don't want to use these
 // directly, but if you're making a custom sort they can come in handy.
@@ -146,65 +295,126 @@ export function normalizeName(s) {
 // sortByDirectory will handle the rest, given all directories are unique
 // except when album and track directories overlap with each other.
 export function sortByDirectory(data, {
-  getDirectory = (o) => o.directory,
+  getDirectory = object => object.directory,
 } = {}) {
-  return data.sort((a, b) => {
-    const ad = getDirectory(a);
-    const bd = getDirectory(b);
-    return compareCaseLessSensitive(ad, bd);
-  });
+  const directories = data.map(getDirectory);
+
+  sortMultipleArrays(data, directories,
+    (a, b, directoryA, directoryB) =>
+      compareCaseLessSensitive(directoryA, directoryB));
+
+  return data;
 }
 
 export function sortByName(data, {
-  getName = (o) => o.name,
+  getName = object => object.name,
 } = {}) {
-  const nameMap = new Map();
-  const normalizedNameMap = new Map();
-  for (const o of data) {
-    const name = getName(o);
-    const normalizedName = normalizeName(name);
-    nameMap.set(o, name);
-    normalizedNameMap.set(o, normalizedName);
-  }
+  const names = data.map(getName);
+  const normalizedNames = names.map(normalizeName);
+
+  sortMultipleArrays(data, normalizedNames, names,
+    (
+      a, b,
+      normalizedA, normalizedB,
+      nonNormalizedA, nonNormalizedB,
+    ) =>
+      compareNormalizedNames(
+        normalizedA, normalizedB,
+        nonNormalizedA, nonNormalizedB,
+      ));
 
-  return data.sort((a, b) => {
-    const ann = normalizedNameMap.get(a);
-    const bnn = normalizedNameMap.get(b);
-    const comparison = compareCaseLessSensitive(ann, bnn);
-    if (comparison !== 0)
-      return comparison;
-
-    const an = nameMap.get(a);
-    const bn = nameMap.get(b);
-    return compareCaseLessSensitive(an, bn);
-  });
+  return data;
+}
+
+export function compareNormalizedNames(
+  normalizedA, normalizedB,
+  nonNormalizedA, nonNormalizedB,
+) {
+  const comparison = compareCaseLessSensitive(normalizedA, normalizedB);
+  return (
+    (comparison === 0
+      ? compareCaseLessSensitive(nonNormalizedA, nonNormalizedB)
+      : comparison));
 }
 
 export function sortByDate(data, {
+  getDate = object => object.date,
   latestFirst = false,
-  getDate = (o) => o.date,
 } = {}) {
-  return data.sort((a, b) => {
-    const ad = getDate(a);
-    const bd = getDate(b);
-
-    // It's possible for objects with and without dates to be mixed
-    // together in the same array. If that's the case, we put all items
-    // without dates at the end.
-    if (ad && bd) {
-      return (latestFirst ? bd - ad : ad - bd);
-    } else if (ad) {
-      return -1;
-    } else if (bd) {
-      return 1;
-    } else {
-      // If neither of the items being compared have a date, don't move
-      // them relative to each other. This is basically the same as
-      // filtering out all non-date items and then pushing them at the
-      // end after sorting the rest.
-      return 0;
-    }
-  });
+  const dates = data.map(getDate);
+
+  sortMultipleArrays(data, dates,
+    (a, b, dateA, dateB) =>
+      compareDates(dateA, dateB, {latestFirst}));
+
+  return data;
+}
+
+export function compareDates(a, b, {
+  latestFirst = false,
+} = {}) {
+  if (a && b) {
+    return (latestFirst ? b - a : a - b);
+  }
+
+  // It's possible for objects with and without dates to be mixed
+  // together in the same array. If that's the case, we put all items
+  // without dates at the end.
+  if (a) return -1;
+  if (b) return 1;
+
+  // If neither of the items being compared have a date, don't move
+  // them relative to each other. This is basically the same as
+  // filtering out all non-date items and then pushing them at the
+  // end after sorting the rest.
+  return 0;
+}
+
+export function getLatestDate(dates) {
+  const filtered = dates.filter(Boolean);
+  if (empty(filtered)) return null;
+
+  return filtered
+    .reduce(
+      (accumulator, date) =>
+        date > accumulator ? date : accumulator,
+      -Infinity);
+}
+
+export function getEarliestDate(dates) {
+  const filtered = dates.filter(Boolean);
+  if (empty(filtered)) return null;
+
+  return filtered
+    .reduce(
+      (accumulator, date) =>
+        date < accumulator ? date : accumulator,
+      Infinity);
+}
+
+// Funky sort which takes a data set and a corresponding list of "counts",
+// which are really arbitrary numbers representing some property of each data
+// object defined by the caller. It sorts and mutates *both* of these, so the
+// sorted data will still correspond to the same indexed count.
+export function sortByCount(data, counts, {
+  greatestFirst = false,
+} = {}) {
+  sortMultipleArrays(data, counts, (data1, data2, count1, count2) =>
+    (greatestFirst
+      ? count2 - count1
+      : count1 - count2));
+
+  return data;
+}
+
+// Corresponding filter function for the above sort. By default, items whose
+// corresponding count is zero will be removed.
+export function filterByCount(data, counts, {
+  min = 1,
+  max = Infinity,
+} = {}) {
+  filterMultipleArrays(data, counts, (data, count) =>
+    count >= min && count <= max);
 }
 
 export function sortByPositionInParent(data, {
@@ -315,6 +525,60 @@ export function sortChronologically(data, {
   return data;
 }
 
+// This one's a little odd! Sorts an array of {entry, thing} pairs using
+// the provided sortFunction, which will operate on each item's `thing`, not
+// its entry (or the item as a whole). If multiple entries are associated
+// with the same thing, they'll end up bunched together in the output,
+// retaining their original relative positioning.
+export function sortEntryThingPairs(data, sortFunction) {
+  const things = unique(data.map(item => item.thing));
+  sortFunction(things);
+
+  const outputArrays = [];
+  const thingToOutputArray = new Map();
+
+  for (const thing of things) {
+    const array = [];
+    thingToOutputArray.set(thing, array);
+    outputArrays.push(array);
+  }
+
+  for (const item of data) {
+    thingToOutputArray.get(item.thing).push(item);
+  }
+
+  data.splice(0, data.length, ...outputArrays.flat());
+
+  return data;
+}
+
+/*
+// Alternate draft version of sortEntryThingPairs.
+// See: https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607412168
+
+// Maps the provided "preparation" function across a list of arbitrary values,
+// building up a list of sortable values; sorts these with the provided sorting
+// function; and reorders the sources to match their corresponding prepared
+// values. As usual, if multiple source items correspond to the same sorting
+// data, this retains the source relative positioning.
+export function prepareAndSort(sources, prepareForSort, sortFunction) {
+  const prepared = [];
+  const preparedToSource = new Map();
+
+  for (const original of originals) {
+    const prep = prepareForSort(source);
+    prepared.push(prep);
+    preparedToSource.set(prep, source);
+  }
+
+  sortFunction(prepared);
+
+  sources.splice(0, ...sources.length, prepared.map(prep => preparedToSource.get(prep)));
+
+  return sources;
+}
+*/
+
 // Highly contextual sort functions - these are only for very specific types
 // of Things, and have appropriately hard-coded behavior.
 
@@ -554,3 +818,65 @@ export function getNewReleases(numReleases, {wikiData}) {
     .slice(0, numReleases)
     .map((album) => ({item: album}));
 }
+
+// Carousel layout and utilities
+
+// Layout constants:
+//
+// Carousels support fitting 4-18 items, with a few "dead" zones to watch out
+// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles.
+//
+// Carousels are limited to 1-3 rows and 4-6 columns.
+// Lower edge case: 1-3 items are treated as 4 items (with blank space).
+// Upper edge case: all items past 18 are dropped (treated as 18 items).
+//
+// This is all done through JS instead of CSS because it's just... ANNOYING...
+// to write a mapping like this in CSS lol.
+const carouselLayoutMap = [
+  // 0-3
+  null, null, null, null,
+
+  // 4-6
+  {rows: 1, columns: 4}, //  4: 1x4, drop 0
+  {rows: 1, columns: 5}, //  5: 1x5, drop 0
+  {rows: 1, columns: 6}, //  6: 1x6, drop 0
+
+  // 7-12
+  {rows: 1, columns: 6}, //  7: 1x6, drop 1
+  {rows: 2, columns: 4}, //  8: 2x4, drop 0
+  {rows: 2, columns: 4}, //  9: 2x4, drop 1
+  {rows: 2, columns: 5}, // 10: 2x5, drop 0
+  {rows: 2, columns: 5}, // 11: 2x5, drop 1
+  {rows: 2, columns: 6}, // 12: 2x6, drop 0
+
+  // 13-18
+  {rows: 2, columns: 6}, // 13: 2x6, drop 1
+  {rows: 2, columns: 6}, // 14: 2x6, drop 2
+  {rows: 3, columns: 5}, // 15: 3x5, drop 0
+  {rows: 3, columns: 5}, // 16: 3x5, drop 1
+  {rows: 3, columns: 5}, // 17: 3x5, drop 2
+  {rows: 3, columns: 6}, // 18: 3x6, drop 0
+];
+
+const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null);
+const maxCarouselLayoutItems = carouselLayoutMap.length - 1;
+const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems];
+const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems];
+
+export function getCarouselLayoutForNumberOfItems(numItems) {
+  return (
+    numItems < minCarouselLayoutItems ? shortestCarouselLayout :
+    numItems > maxCarouselLayoutItems ? longestCarouselLayout :
+    carouselLayoutMap[numItems]);
+}
+
+export function filterItemsForCarousel(items) {
+  if (empty(items)) {
+    return [];
+  }
+
+  return items
+    .filter(item => item.hasCoverArt)
+    .filter(item => item.artTags.every(tag => !tag.isContentWarning))
+    .slice(0, maxCarouselLayoutItems + 1);
+}