diff options
Diffstat (limited to 'src/util/wiki-data.js')
-rw-r--r-- | src/util/wiki-data.js | 422 |
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); +} |