'use strict'; (function (root, factory) { if (typeof define === 'function' && define.amd) { define([], factory); } else if (typeof module === 'object' && module.exports) { module.exports = factory(); } else { root.picker = factory(); } }(typeof self !== 'undefined' ? self : this, function () { /* POLYFILLS */ if (!Array.isArray) { Array.isArray = function(arg) { return Object.prototype.toString.call(arg) === '[object Array]'; }; } /* PICKER STATE OBJECT */ function PickerState(options) { if (!options.items) { console.error("No items specified for PickerState!"); return; } this.options = copyObject(options); }; /* INITIALIZATION AND SERIALIZATION */ PickerState.prototype.getState = function() { /** * Returns a state object corresponding to this PickerState. * We're using deep copies because otherwise the eliminatedBy arrays * may get mutated and undoing/redoing can corrupt the state. */ return { eliminated: copyArray(this.arrays.eliminated), survived: copyArray(this.arrays.survived), current: copyArray(this.arrays.current), evaluating: copyArray(this.arrays.evaluating), favorites: copyArray(this.arrays.favorites), settings: copyObject(this.settings) }; }; PickerState.prototype.initialize = function(settings) { /** * Initializes the PickerState according to the given settings * (or the default settings if no settings are provided). */ this.settings = settings || this.options.defaultSettings || {}; this.items = this.getFilteredItems(); this.arrays = { eliminated: [], survived: [], current: this.items.slice(0), evaluating: [], favorites: [] }; this.batchSize = this.getBatchSize(this.arrays.current.length); shuffle(this.arrays.current); this.nextBatch(); }; PickerState.prototype.restoreState = function(state) { /** * Sets the PickerState to the given dehydrated state. */ this.settings = copyObject(this.options.defaultSettings || {}, state.settings || {}); this.items = this.getFilteredItems(); this.arrays = { eliminated: copyArray(state.eliminated), survived: copyArray(state.survived), current: copyArray(state.current), evaluating: copyArray(state.evaluating), favorites: copyArray(state.favorites) }; this.batchSize = this.arrays.evaluating.length; this.validate(); }; PickerState.prototype.reset = function() { /** * Resets the PickerState to its initial state (leaving the settings * unchanged). */ this.initialize(this.settings); }; /* PUBLIC SETTERS */ PickerState.prototype.setSettings = function(settings) { /** * Sets the settings. */ this.settings = settings; this.items = this.getFilteredItems(); this.validate(); this.resetBatchSize(); }; PickerState.prototype.setFavorites = function(favorites) { /** * Overwrites the found favorites list with the given one. * Since it runs validate, it should be fine if this changes the * actual contents of the list. */ this.arrays.favorites = favorites; this.validate(); }; /* STATE UTILITY FUNCTIONS */ PickerState.prototype.findByIdentifier = function(identifier, array) { /** * Searches for the given item identifier in the given array and * returns the index at which that identifier is found (or -1 if it is * not found). Handles both plain arrays of identifiers and arrays of * objects with an id property (e.g. the eliminated array). */ for (var i = 0; i < array.length; i++) { if (array[i] === identifier || array[i].id === identifier) { return i; } } return -1; }; PickerState.prototype.shouldIncludeItem = function(identifier, settings) { /** * Returns true if this item should be included in the picker * according to the current settings. */ if (this.options.getFilteredItems) { return this.options.getFilteredItems(settings).indexOf(identifier) !== -1; } else if (this.options.shouldIncludeItem) { return this.options.shouldIncludeItem(identifier, settings); } return true; }; PickerState.prototype.getFilteredItems = function() { /** * Returns a list of item identifiers that match the given * settings. */ if (this.options.getFilteredItems) { return this.options.getFilteredItems(this.settings); } var result = []; var i; for (i = 0; i < this.options.items.length; i++) { if (this.shouldIncludeItem(this.options.items[i], this.settings)) { result.push(this.options.items[i]); } } return result; }; PickerState.prototype.findInArray = function(identifier, arrayName) { /** * If the given identifier is found in the given array of the state, * return that entry. Otherwise, return null. */ var index = this.findByIdentifier(identifier, this.arrays[arrayName]); if (index !== -1) { return this.arrays[arrayName][index]; } else { return null; } }; PickerState.prototype.getBatchSize = function(currentSize) { /** * Returns the number of items that should ideally be displayed at a * time, given the whole round is currentSize items. */ if (this.options.getBatchSize) { return this.options.getBatchSize(currentSize, this.settings); } return Math.max(2, this.settings.minBatchSize || 2, Math.min(this.settings.maxBatchSize || 20, Math.ceil(currentSize / 5))); }; PickerState.prototype.resetBatchSize = function() { /** * Resets the current batch size to whatever it ought to be given the * size of the current and survived arrays and adjusts the evaluating * array accordingly. */ Array.prototype.unshift.apply(this.arrays.current, this.arrays.evaluating); this.arrays.evaluating = this.arrays.current.splice(0, this.getBatchSize(this.arrays.current.length + this.arrays.survived.length)); this.batchSize = this.arrays.evaluating.length; }; /* STATE VALIDATION */ PickerState.prototype.validate = function () { /** * Validates and corrects the state. */ var expectedItems = this.getFilteredItems(); var missingItems = []; var extraItems = []; var survived = this.arrays.survived; var eliminated = this.arrays.eliminated; var evaluating = this.arrays.evaluating; var current = this.arrays.current; var favorites = this.arrays.favorites; var arrays = [favorites, survived, eliminated, current, evaluating]; var identifier; var verifyObject = {}; var i, j; for (i = 0; i < expectedItems.length; i++) { verifyObject[expectedItems[i]] = false; } // Go through all the items in each array and: // - correct errors // - mark off the item in the verify object // - make sure that each item appears only once by checking if it's // previously been marked off // - remove any extra items that shouldn't be there // We do this backwards so that we can remove items with splice // without messing up the parts of the array we haven't gone through // yet. for (i = 0; i < arrays.length; i++) { for (j = arrays[i].length - 1; j >= 0; j--) { identifier = arrays[i][j].id || arrays[i][j]; if (identifier in verifyObject) { // This is one of the items we expect if (verifyObject[identifier]) { // We've already found this item - it's a copy. // Remove it from this array and restore any items // eliminated by it, since it might be in error. arrays[i].splice(j, 1); this.removeFromEliminated(identifier); } verifyObject[identifier] = true; } else { // This is an unexpected item - we want to remove it arrays[i].splice(j, 1); extraItems.push(identifier); } } } // Ensure no item is eliminated by itself, fix eliminated items not // being properly ntroduced after their eliminator is found, plus // removing extraneous items from eliminated lists. // We go through both arrays backwards so that splicing the indices // won't mess up subsequent indices. for (i = eliminated.length - 1; i >= 0; i--) { for (j = eliminated[i].eliminatedBy.length - 1; j >= 0; j--) { if (eliminated[i].id === eliminated[i].eliminatedBy[j]) { this.removeEliminatedBy(i, j); } if (favorites.indexOf(eliminated[i].eliminatedBy[j]) !== -1 || extraItems.indexOf(eliminated[i].eliminatedBy[j]) !== -1) { this.removeEliminatedBy(i, j); } } } // Add in any items that we ought to have but weren't in any of the // arrays for (identifier in verifyObject) { if (verifyObject[identifier] === false) { missingItems.push(identifier); current.push(identifier); } } // Store the missing items that we've added, if we want to alert the // user about them later if (missingItems.length > 0) { this.missingItems = missingItems; // Shuffle current: if we've just added some items, we don't want // them all to be dumped at the end of the round shuffle(current); } if (current.length === 0 && evaluating.length === 0 && survived.length > 0) { this.nextRound(); return; } if (evaluating.length < 2) { // Give us an evaluation batch of the size that it should be. this.resetBatchSize(); } else { this.batchSize = evaluating.length; } }; /* MAIN PICKER LOGIC */ PickerState.prototype.pick = function(picked) { /** * Picks the given items from the current evaluating batch, moving * them into the survived array and the others into the eliminated * array. */ var i; var evaluating = this.arrays.evaluating; var survived = this.arrays.survived; var eliminated = this.arrays.eliminated; // Loop through the items we're currently evaluating for (i = 0; i < evaluating.length; i++) { if (!picked.length || this.findByIdentifier(evaluating[i], picked) !== -1) { // This item is one of the ones we picked - add it to // survived survived.push(evaluating[i]); } else { // This item is not one of the ones we picked - add it to // eliminated, with the picked items as the eliminators eliminated.push({id: evaluating[i], eliminatedBy: picked.slice(0)}); } } this.arrays.evaluating = []; this.nextBatch(); }; PickerState.prototype.pass = function() { /** * Passes on this batch of items, equivalent to picking every * item. */ this.pick(this.arrays.evaluating); }; PickerState.prototype.removeEliminatedBy = function(i, j) { /** * Removes the jth item from the eliminatedBy array of the ith * item in the eliminated array, restoring the item to the * survived array if this leaves the eliminatedBy list empty. * * This modifies the arrays in-place; if executed inside a loop, * the loop must run backwards through both arrays. */ var eliminated = this.arrays.eliminated; eliminated[i].eliminatedBy.splice(j, 1); if (eliminated[i].eliminatedBy.length === 0) { this.arrays.survived.push(eliminated.splice(i, 1)[0].id); } }; PickerState.prototype.removeFromEliminated = function(item) { /** * Remove this item from all eliminatedBy lists, restoring any * items left with empty eliminatedBy lists to the survived array. */ var i, idx; var eliminated = this.arrays.eliminated; // Find items that were eliminated by this item. for (i = eliminated.length - 1; i >= 0; i--) { idx = this.findByIdentifier(item, eliminated[i].eliminatedBy); if (idx !== -1) { // This item was (partly) eliminated by the given item; // remove it this.removeEliminatedBy(i, idx); } } }; PickerState.prototype.addToFavorites = function(item) { /** * Add the given item (identifier) to favorites and restore * the items eliminated by it to survived. */ this.arrays.favorites.push(item); this.removeFromEliminated(item); }; PickerState.prototype.nextBatch = function() { /** * Moves on to the next batch of items, adding to favorites if appropriate. */ var current = this.arrays.current; if (current.length < this.batchSize && this.arrays.survived.length > 0) { // Start the next round this.nextRound(); return; } this.arrays.evaluating = current.splice(0, this.batchSize); }; PickerState.prototype.nextRound = function() { /** * Moves on to the next round, shuffling the survived array back into * the current array. */ // If we've only got one item left in survived, then it's our next // favorite - add it to favorites and then start the next round with // the new survivors. if (this.arrays.current.length === 0 && this.arrays.survived.length === 1) { this.addToFavorites(this.arrays.survived.pop()); this.nextRound(); return; } shuffle(this.arrays.survived); // Take the survivors and put them at the end of the current array. this.arrays.current = this.arrays.current.concat(this.arrays.survived.splice(0, this.arrays.survived.length)); // Pick an appropriate batch size for this new round and then show the next batch. this.batchSize = this.getBatchSize(this.arrays.current.length); this.nextBatch(); }; /* PICKER OBJECT */ function Picker(options) { if (!(this instanceof Picker)) { return new Picker(options); } if (!options.items) { console.error("No items specified for picker."); return; } var self = this; this.itemMap = {}; this.options = copyObject({ historyLength: 3, favoritesQueryParam: 'favs' }, options); this.history = []; this.historyPos = -1; var i; // Build the itemMap and catch errors for (i = 0; i < options.items.length; i++) { if (options.items[i].id === undefined) { console.error("You have an item without an ID! An ID is necessary for the picker's functionality to work.", options.items[i]); return; } if (this.itemMap.hasOwnProperty(options.items[i].id)) { console.error("You have more than one item with the same ID (" + options.items[i].id + ")! Please ensure the IDs of your items are unique."); return; } if (options.shortcodeLength && (!options.items[i].shortcode || options.items[i].shortcode.length !== options.shortcodeLength)) { console.error("You have defined a shortcode length of " + options.shortcodeLength + "; however, you have an item with a shortcode that does not match this length (" + options.items[i].shortcode + "). The shortcode functionality only works if the item shortcodes are of a consistent length.", options.items[i]); return; } this.itemMap[options.items[i].id] = options.items[i]; } var defaultSettings = options.defaultSettings || {}; /* PICKER INITIALIZATION */ var pickerStateOptions = { items: map(options.items, function (item) { return item.id; }), getBatchSize: options.getBatchSize, shouldIncludeItem: options.shouldIncludeItem && function (identifier, settings) { return options.shouldIncludeItem(self.itemMap[identifier], settings) }, getFilteredItems: options.getFilteredItems, defaultSettings: defaultSettings }; var savedState = this.loadState(); // Modify the savedState if we have a modifyState function... if (savedState && options.modifyState) { savedState = options.modifyState(savedState); } // ...but if the end result isn't a valid state, throw it away if (savedState && !isState(savedState)) { console.warn("Ignoring invalid saved state"); savedState = null; } this.state = new PickerState(pickerStateOptions); if (savedState) { this.state.restoreState(savedState, defaultSettings); if (options.onLoadState) { options.onLoadState.call( this, this.state.missingItems || [], this.state.extraItems || [] ); } this.pushHistory(); } else { this.state.initialize(defaultSettings); this.pushHistory(); } } /* GETTERS */ Picker.prototype.getArray = function(arrayName) { /** * Gets the full list of items in the given array. */ return this.mapItems(this.state.arrays[arrayName]); }; Picker.prototype.getFavorites = function() { /** * Gets the current favorite list. */ return this.getArray('favorites'); }; Picker.prototype.getEvaluating = function() { /** * Gets the current evaluating list. */ return this.getArray('evaluating'); }; Picker.prototype.getSettings = function() { /** * Gets the state's current settings. */ return this.state.settings; }; Picker.prototype.getSharedFavorites = function() { /** * Gets the shared favorite list. */ var query; if (window.location.search && this.options.favoritesQueryParam && this.options.shortcodeLength) { query = parseQueryString(window.location.search.substring(1)); return this.mapItems(this.parseShortcodeString(query[this.options.favoritesQueryParam]) || []); } return null; }; /* SHORTCODES */ Picker.prototype.getShortcodeString = function() { /** * Gets a shortcode string for the current favorite list. */ return map(this.getFavorites(), function(item) { return item.shortcode; }).join(''); }; Picker.prototype.getShortcodeLink = function() { /** * Gets a shortcode link for the current favorite list. */ return '?' + this.options.favoritesQueryParam + '=' + this.getShortcodeString(); }; Picker.prototype.parseShortcodeString = function(shortcodeString) { /** * Returns the list of favorites given by a shortcode string. */ var self = this; var favorites = []; var i; var shortcode; var shortcodeMap = {}; var favoriteMap = {}; this.forEachItem(function (identifier) { shortcodeMap[self.itemMap[identifier].shortcode] = identifier; }); for (i = 0; i < shortcodeString.length; i += this.options.shortcodeLength) { shortcode = shortcodeString.substring(i, i + this.options.shortcodeLength); if (shortcode in shortcodeMap) { if (!favoriteMap[shortcodeMap[shortcode]]) { favorites.push(shortcodeMap[shortcode]); favoriteMap[shortcodeMap[shortcode]] = true; } } } return favorites; }; /* HISTORY */ Picker.prototype.pushHistory = function() { /** * Adds the current state to the history array. */ this.history.splice(this.historyPos + 1, this.history.length, this.state.getState()); if (this.history.length > this.options.historyLength + 1) { this.history.shift(); } this.historyPos = this.history.length - 1; this.saveState(); }; Picker.prototype.canUndo = function() { /** * Returns true if we can undo. */ return this.historyPos > 0; }; Picker.prototype.canRedo = function() { /** * Returns true if we can redo. */ return this.historyPos < this.history.length - 1; }; Picker.prototype.undo = function() { /** * Reverts to the previous state in the history array. */ if (!this.canUndo()) { return; } this.state.restoreState(this.history[--this.historyPos]); this.saveState(); }; Picker.prototype.redo = function() { /** * Proceeds to the next state in the history array. */ if (!this.canRedo()) { return; } this.state.restoreState(this.history[++this.historyPos]); this.saveState(); }; Picker.prototype.resetToFavorites = function (favorites, useSettings) { /** * Creates a clean state with the items given in favorites (as * identifiers) as found favorites. * * If useSettings is given, then those will be the settings used and * any favorites that don't fit the parameters will be discarded. * Otherwise, the settings will be set by the settingsFromFavorites * option, or set to the default otherwise. */ var finalFavorites = []; var i; for (i = 0; i < favorites.length; i ++) { // Only add the item if it matches the settings (or if we don't have any given settings) if (!useSettings || this.state.shouldIncludeItem(favorites[i], useSettings)) { finalFavorites.push(favorites[i]); } } if (!useSettings) { // If we don't have any given settings, then set the settings according to the favorites instead if (this.options.settingsFromFavorites) { useSettings = copyObject(this.options.defaultSettings, this.options.settingsFromFavorites(this.mapItems(favorites))); } else { useSettings = copyObject(this.options.defaultSettings); } } // This should set the entire state properly. this.state.initialize(useSettings); this.state.setFavorites(finalFavorites); this.initialFavorites = finalFavorites; this.pushHistory(); }; /* STATE */ Picker.prototype.saveState = function() { /** * Saves the given state in localStorage, assuming it is available. */ if (this.options.saveState) { this.options.saveState.call(this, this.state.getState()); } else if (localStorage && JSON && this.options.localStorageKey) { localStorage.setItem(this.options.localStorageKey, JSON.stringify(this.state.getState())); } }; Picker.prototype.loadState = function() { /** * Returns the state stored in localStorage, if there is one. */ var state; if (this.options.loadState) { state = this.options.loadState.call(this); } else if (localStorage && JSON && this.options.localStorageKey) { try { state = JSON.parse(localStorage.getItem(this.options.localStorageKey)); } catch (e) { return null; } } return state; }; Picker.prototype.isUntouched = function() { /** * Returns true if the state has not been touched (either it's a * completely clean state or one that only has found favorites * matching the state's initial favorites). */ var i; var arrays = this.state.arrays; var initialFavorites = this.initialFavorites || []; // If something is in eliminated/survived, it's not untouched if (arrays.eliminated.length > 0 || arrays.survived.length > 0) { return false; } // If we've got nothing in eliminated/survived and nothing in favorites, it is untouched if (arrays.favorites.length === 0) { return true; } // We have found favorites, but nothing eliminated/survived: check if the favorites match the initial favorites, if any // If it's the wrong number of favorites, it's not untouched if (arrays.favorites.length !== initialFavorites.length) { return false; } for (i = 0; i < arrays.favorites.length; i++) { if (initialFavorites[i] !== arrays.favorites[i]) { // This favorite doesn't match, so it's not untouched return false; } } return true; }; Picker.prototype.hasItems = function() { /** * Returns true if the picker has any items (that aren't filtered * out). */ return this.state.items.length > 0; }; /* ACTIONS */ Picker.prototype.pick = function(picked) { this.state.pick(picked); this.pushHistory(); }; Picker.prototype.pass = function() { this.state.pass(); this.pushHistory(); }; Picker.prototype.reset = function() { this.state.reset(); this.pushHistory(); }; Picker.prototype.setSettings = function(settings) { this.state.setSettings(settings); this.pushHistory(); }; Picker.prototype.setFavorites = function(favorites) { this.state.setFavorites(favorites); this.pushHistory(); }; /* PICKER UTILITY FUNCTIONS */ Picker.prototype.forEachItem = function(func) { /** * Executes func for each identifier in the picker's item map. */ var identifier; var result; for (identifier in this.itemMap) { if (this.itemMap.hasOwnProperty(identifier)) { result = func(identifier); if (result) { return result; } } } }; Picker.prototype.mapItems = function(identifiers) { /** * Gets an array of full item objects corresponding to the given * identifiers. */ var self = this; return map(identifiers, function(identifier) { return self.itemMap[identifier]; }); }; /* GENERAL UTILITY FUNCTIONS */ function isState(state) { /** * Returns true if the given state object has all the expected * properties (and can thus safely be passed into restoreState). */ return ( state && typeof state === 'object' && Array.isArray(state.eliminated) && Array.isArray(state.survived) && Array.isArray(state.current) && Array.isArray(state.evaluating) && Array.isArray(state.favorites) && (!state.settings || typeof state.settings === 'object') ); }; function copyArray(array) { /** * Returns a deep copy of the given data array. */ var result = []; var i; for (i = 0; i < array.length; i++) { if (array[i] && typeof array[i] === 'object') { if (Array.isArray(array[i])) { result[i] = copyArray(array[i]); } else { result[i] = copyObject(array[i]); } } else { result[i] = array[i]; } } return result; } function copyObject() { /** * Returns a deep copy of the given object(s), with properties of later * objects overriding those of earlier objects. */ var result = {}; var a, key; for (a = 0; a < arguments.length; a++) { for (key in arguments[a]) { if (arguments[a].hasOwnProperty(key)) { if (arguments[a][key] && typeof arguments[a][key] === 'object') { if (Array.isArray(arguments[a][key])) { result[key] = copyArray(arguments[a][key]); } else { result[key] = copyObject(arguments[a][key]); } } else { result[key] = arguments[a][key]; } } } } return result; } function map(array, func) { /** * Returns an array containing the result of calling func on each item * in the input array. */ var result = []; var i; for (i = 0; i < array.length; i++) { result[i] = func(array[i]); } return result; } function shuffle(array) { /** * Shuffles the given array to be in a random order. */ var currentIndex = array.length, temporaryValue, randomIndex; while (0 !== currentIndex) { randomIndex = Math.floor(Math.random() * currentIndex); currentIndex -= 1; temporaryValue = array[currentIndex]; array[currentIndex] = array[randomIndex]; array[randomIndex] = temporaryValue; } return array; } function parseQueryString(qs) { /** * Parses a query string (a=b&c=d) into an object. */ var query = {}; var split = qs.split('&'); var valueSplit; var i; for (i = 0; i < split.length; i++) { valueSplit = split[i].split('='); query[decodeURIComponent(valueSplit[0])] = valueSplit[1] ? decodeURIComponent(valueSplit[1]) : true; } return query; } return { Picker: Picker, PickerState: PickerState, isState: isState }; }));