UNPKG

groupster-engine

Version:

Randomly group objects using do-group and don't-group rules.

159 lines (147 loc) 5.95 kB
import _ from 'lodash'; import Joi from 'joi-browser'; import SafeJoi from './SafeJoi'; import buildRuleMap from './buildRuleMap'; export const determineGroupSizes = (idCount, desiredGroupSize) => { const errors = [ SafeJoi( idCount, Joi.number().min(1), `idCount must be a number greater than 0 (it was ${idCount})`, ), SafeJoi( desiredGroupSize, Joi.number().min(2).max(idCount), `desiredGroupSize must be a number greater than 1 and less than or equal to idCount (it was ${idCount})`, ), ].filter(Boolean); const groups = []; if (!errors.length) { while (idCount >= desiredGroupSize) { groups.push(desiredGroupSize); idCount -= desiredGroupSize; } // If some are remaining then the end goal is to have max # of groups of desired size, with minimal # of groups off by 1. // If not possible just drop remaining into group of their own. // Universal rule is that you should never have a group of size 1. if (idCount > 0) { // See if possible to distribute into other groups let canDistribute = idCount <= groups.length; // See if possible to skim other groups into this group let canSkim = idCount + groups.length >= desiredGroupSize - 1; if ((idCount < desiredGroupSize - 1 && (canSkim || canDistribute)) || idCount === 1) { // See how many groups would be affected by distributing remaining into other groups const groupsAffectedByDistribute = idCount; // See how many groups would be affected by skimming other groups by 1 until remainder is almost a full group const groupsAffectedBySkim = desiredGroupSize - idCount; if (canSkim && canDistribute) { if (groupsAffectedBySkim <= groupsAffectedByDistribute && idCount > 1) { // Prefer skimming -> this leads to smaller groups canDistribute = false; } else { canSkim = false; } } if (canSkim) { // Skim groups, starting at end, until remaining is enough for its own group for (let i = groups.length - 1; i >= 0; i -= 1) { groups[i] -= 1; idCount += 1; if (idCount >= desiredGroupSize - 1) { groups.push(idCount); break; } } } else { // Distribute remaining among groups, starting at end for (let i = groups.length - 1; i >= 0; i -= 1) { groups[i] += 1; idCount -= 1; if (idCount === 0) { break; } } } } // If neither are possible to get all groups within 1 of the desiredGroupSize then give up and just use remainder as group else { groups.push(idCount); } } } return { groups, errors, }; }; /** * Given a array of IDs and an array of the group sizes desired, along with * an optional set of rules to govern how they should be grouped, returns * an array of arrays containing grouped IDs. * * @export * @param { array } IDs - Array containing [unique] ids to be grouped. * @param { number[] } groupSizes - * Array containing sizes of groups desired. The sum of its values must equal the length of 'IDs' param. * @param { array } [rules] - * Array of rule objects. A rule object must contain a boolean 'shouldGroup' property and an 'IDs' property * containing an array of IDs for which the rule applies. If the 'shouldGroup' property is false these IDs * will be placed into separate groups (to the extent possible). If true then they will be placed into * the same group (to the extent possible). For now 'shouldGroup: false' rules will take precedence. * * @returns Array of groups. Each group is an array containing ids. * @throws ValidationError if any of the parameters don't conform to their schemas. * @throws Error if any IDs are duplicates, or rules reference IDs not present in 'IDs' param. */ export default function Group(IDs, groupSizes, rules = []) { // The keys of ruleMap are deleted as each id is placed const ruleMap = buildRuleMap(IDs, groupSizes, rules); const groups = groupSizes.map(size => { const list = []; return { get size() { return size; }, get list() { return list; }, get roomLeft() { return size - list.length; }, add(id) { list.push(id); }, fitRating(id) { // If room is left if (size - list.length > 0) { return list.reduce((acc, otherID) => { const rating = ruleMap[id][otherID]; if (rating === undefined) { return acc + 0; } return acc + (rating ? 1 : -1); }, 0); } // If no room return -Infinity; }, }; }); const addToBestGroup = id => { // Add to the best group (preferring one with the most room) const groupRatings = groups.map((group, index) => ({ index, roomLeft: group.roomLeft, rating: group.fitRating(id) })); const bestGroupIndex = _.sortBy(groupRatings, ['rating', 'roomLeft'])[groupRatings.length - 1].index; groups[bestGroupIndex].add(id); // Remove from ruleMap delete ruleMap[id]; }; const dontGroups = rules.filter(rule => !rule.shouldGroup).map(rule => _.shuffle(rule.IDs)).sort((a, b) => b.length - a.length); dontGroups.forEach(dontGroup => { const remaining = dontGroup.filter(id => _.has(ruleMap, id)); remaining.forEach(addToBestGroup); }); const doGroups = rules.filter(rule => rule.shouldGroup).map(rule => _.shuffle(rule.IDs)).sort((a, b) => b.length - a.length); doGroups.forEach(doGroup => { const remaining = doGroup.filter(id => _.has(ruleMap, id)); remaining.forEach(addToBestGroup); }); // Place remaining IDs _.shuffle(Object.keys(ruleMap)).forEach(addToBestGroup); return groups.map(group => group.list); }