riboseinc/id_pack

View on GitHub
app/assets/javascripts/lib/id-packer.js

Summary

Maintainability
D
2 days
Test Coverage
/** vim: et:ts=2:sw=2:sts=2
 * @license (c) 2017 Ribose Inc.
 *
 * IdPacker is a library provides encoding support for Number collections (Arrays or Objects).
 *
 *
 * Usage:
 *
 * The encoding algorithm encodes a Number collection (in form of Array or Object) into another String.
 *
 * @param collection - Number collection (in form of Array or Object)
 * @param windowSize - Maximunm length of the binary encodedString in binary form (default: 10)
 * @param isExcludeNull - Exclude the object keys with null value (default: true)
 * @param outputCharset - Control how the output look like. CANNOT CONTAIN DUPLICATED characters (default: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-")
 *
 * IdPacker.encodeHashKeys(collection, windowSize, isExcludeNull, outputCharset);
 *
 *
 * Examples:
 *
 * // encode the items in an Array
 * var collection = [5, 6, 21, 23, 25];
 * IdPacker.encodeHashKeys(collection);
 * => "_E~C_O.V"
 *
 * // encode the keys of an Object
 * var collection = {
 *   5: "a",
 *   6: "b",
 *   21: "c",
 *   23: "d",
 *   25: "e"
 * };
 * IdPacker.encodeHashKeys(collection);
 * => "_E~C_O.V"
 *
 *
 * The encoding algorithm:
 *
 * The algorithm first sorts the collection in ascending order. Then, it decomposes
 * the sorted collection into multiple parts starting from 1. Finally, each part is
 * encoded into one of the following three encodedString forms:
 *
 * 1) Spaces encodedString
 * A encoded text with prefix '_', represents continuous space.
 *
 * 2) Range encodedString
 * A encoded text with prefix '~', represents continuous number.
 *
 * 3) Binary encodedString
 * A encoded text with prefix '.', represents arbitrary distributed numbers.
 *
 * In each part, the encodedString after the prefix is a value from a base-X number system, where
 * X is the length of IdPacker.outputCharset (overridable).
 *
 * Take the encoded text "_E~C_O.V" (original collection: [5, 6, 21, 23, 25]) as an example.
 * It consists of 4 parts:
 *
 * 1)_E
 * 4 continuous space (0-4)
 *
 * 2) ~C
 * 2 continuous number (5,6)
 *
 * 3) _O
 * 14 continuous space (7-20)
 *
 * 4) .V
 * Convert 'V' from the base-X number system to base-10 number system gives '21',
 * convert '21' from the base-10 number system to base-2 (binary) number system gives '101'.
 * '1' and '0' represent 'number' and 'space', respectively. Therefore, '101' denotes 21, 23, 25.
 */
(function (factory) {
  'use strict';

  /* AMD. Register as an anonymous module. */
  if (typeof define === 'function' && define.amd) {
    define(factory);
  }

  /* Node/CommonJS */
  else if (typeof exports === 'object') {
    module.exports = factory();
  }

  /* Browser globals */
  else {
    window.IdPacker = factory();
  }

}(function() {
  'use strict';

  return {
    spacesEncodedStringPrefix: '_',
    binaryEncodedStringPrefix: '.',
    rangeEncodedStringPrefix:  '~',
    windowSize:                10,
    isExcludeNull:             true,
    outputCharset:             "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-",

    range: function(start, length) {

      /** convert arguments to numbers */
      if (typeof start != 'number') {
        start     = parseInt(start, 10);
      }
      if (typeof length != 'number') {
        length    = parseInt(length, 10);
      }
      this.start  = !$.isNumeric(start)  ? 0 : start;
      this.length = !$.isNumeric(length) ? 0 : length;

      // range functions

      /**
       * var range = IdPacker.range(0, 5);
       * range.clone();
       * => IdPacker.range(0, 5)
       */
      this.clone = function() {
        return IdPacker.range(this.start, this.length);
      };

      /**
       * var range = IdPacker.range(0, 5);
       * range.end();
       * => 4
       */
      this.end = function() {
        return this.start + this.length - 1;
      };

      /**
       * var range = IdPacker.range(3, 5);
       * range.roundToWindow(5);
       * => IdPacker.range(0, 10)
       *
       * range.roundToWindow(2);
       * => IdPacker.range(2, 6)
       */
      this.roundToWindow = function(window_size) {
        var start  = Math.floor(this.start / window_size) * window_size;
        var length = Math.ceil((this.start + this.length - start) / window_size) * window_size;
        return IdPacker.range(start, length);
      };

      /**
       * var range = IdPacker.range(0, 5);
       * range.contain(3);
       * => true
       *
       * range.contain(8);
       * => false
       */
      this.contain = function(n) {
        return n >= this.start && n <= this.end();
      };

      // range-to-range functions

      /**
       * var range1 = IdPacker.range(0, 2);
       * var range2 = IdPacker.range(2, 2);
       * var range3 = IdPacker.range(3, 2);
       * range1.touch(range2);
       * => true
       *
       * range1.touch(range3);
       * => false
       *
       * range2.touch(range3);
       * => true
       */
      this.touch = function(range) {
        var start  = Math.max(this.start, range.start);
        var end    = Math.min(this.end(), range.end());
        var length = end - start + 1;
        return length >= 0;
      };

      /**
       * var range1 = IdPacker.range(2, 2);
       * var range2 = IdPacker.range(3, 2);
       * range1.intersect(range2);
       * => IdPacker.range(3, 1)
       */
      this.intersect = function(range) {
        var start  = Math.max(this.start, range.start);
        var end    = Math.min(this.end(), range.end());
        var length = end - start + 1;
        return (length > 0) ? IdPacker.range(start, length) : null;
      };

      /**
       * var range1 = IdPacker.range(2, 2);
       * var range2 = IdPacker.range(2, 5);
       * var range3 = IdPacker.range(0, 5);
       * range1.isSubsetOf(range3);
       * => true
       *
       * range2.isSubsetOf(range3);
       * => false
       */
      this.isSubsetOf = function(range) {
        return range.contain(this.start) && range.contain(this.end());
      };

      /**
       * var range1 = IdPacker.range(0, 5);
       * var range2 = IdPacker.range(2, 5);
       * range1.union(range2);
       * => IdPacker.range(0, 7)
       */
      this.union = function(range) {
        var start = Math.min(this.start, range.start);
        var end = Math.max(this.end(), range.end());
        var length = end - start + 1;
        return IdPacker.range(start, length);
      };

      /**
       * var range1 = IdPacker.range(0, 5);
       * var range2 = IdPacker.range(2, 5);
       * range1.minus(range2);
       * => IdPacker.range(0, 2)
       */
      this.minus = function(range) {
        var intersectedRange = this.intersect(range);
        if (this.equal(intersectedRange)) {
          return [];
        } else if (this.start < intersectedRange.start && this.end() > intersectedRange.end()) {
          return [(IdPacker.range(this.start, intersectedRange.start - this.start)),
            (IdPacker.range(intersectedRange.end() + 1, this.end() - intersectedRange.end()))];
        } else if (this.start < intersectedRange.start) {
          return [IdPacker.range(this.start, intersectedRange.start - this.start)];
        } else {
          return [IdPacker.range(intersectedRange.end() + 1, this.end() - intersectedRange.end())];
        }
      };

      /**
       * var range1 = IdPacker.range(0, 2);
       * var range2 = IdPacker.range(0, 2);
       * var range3 = IdPacker.range(2, 2);
       * range1.equal(range2);
       * => true
       *
       * range1.equal(range3);
       * => false
       */
      this.equal = function (range) {
        return this.start == range.start && this.length == range.length;
      };

      // range-to-ranges functions

      /**
       * var range1 = IdPacker.range(0, 2);
       * var range2 = IdPacker.range(2, 2);
       * var ranges = [IdPacker.range(0, 3), IdPacker.range(5, 3)];
       * range1.hasFullRange(ranges);
       * => true
       *
       * range2.hasFullRange(ranges);
       * => false
       */
      this.hasFullRange = function(ranges) {
        for(var i = 0, l = ranges.length; i < l; i++) {
          if (this.isSubsetOf(ranges[i])) {
            return true;
          }
        }
        return false;
      };

      /**
       * var range = IdPacker.range(2, 5);
       * var ranges = [IdPacker.range(0, 3), IdPacker.range(5, 3)];
       * range.getOverlapRanges(ranges);
       * => [IdPacker.range(2, 1), IdPacker.range(5, 2)]
       */
      this.getOverlapRanges = function(ranges) {
        var overlapRanges = [];
        for(var i = 0, l = ranges.length; i < l; i++) {
          var intersectedRange = this.intersect(ranges[i]);
          if (intersectedRange !== null) {
            overlapRanges.push(intersectedRange);
          }
        }
        return overlapRanges;
      };

      /**
       * var range = IdPacker.range(2, 5);
       * var ranges = [IdPacker.range(0, 3), IdPacker.range(5, 3)];
       * range.getMissingRanges(ranges);
       * => [IdPacker.range(3, 2)]
       */
      this.getMissingRanges = function(ranges) {
        var overlapRanges = this.getOverlapRanges(ranges);
        var missingRanges = [];
        var missingRange  = this;

        for(var i = 0, ol = overlapRanges.length; i < ol; i++) {
          var currentMissingRanges = missingRange.minus(overlapRanges[i]);
          for (var j = 0, cl = currentMissingRanges.length-1; j < cl; j++) {
            missingRanges.push(currentMissingRanges[j]);
          }
          missingRange = currentMissingRanges[currentMissingRanges.length-1];
        }

        if (missingRange) {
          missingRanges.push(missingRange);
        }
        return missingRanges;
      };

      /**
       * var range = IdPacker.range(2, 5);
       * var ranges = [IdPacker.range(0, 3), IdPacker.range(5, 3)];
       * range.addToRanges(ranges);
       * => [IdPacker.range(0, 8)]
       */
      this.addToRanges = function(ranges){
        if (! ranges.length) {
          ranges.push(this);
          return ranges;
        }
        var i, l;
        var overlapRanges = [];
        var accumulate;

        for (i = 0, l = ranges.length; i < l; i++) {
          if (this.touch(ranges[i])) {
            overlapRanges.push(i);
          }
        }
        if (overlapRanges.length === 0) {
          for (i = 0, l = ranges.length; i < l; i++) {
            if (this.start < ranges[i].start) {
              Array.prototype.splice.apply(ranges, [i, 0].concat(this));
              return ranges;
            }
          }
          ranges.push(this);
          return ranges;
        }

        accumulate = this;
        for (i = 0, l = overlapRanges.length; i < l; i++) {
          accumulate = accumulate.union(ranges[overlapRanges[i]]);
        }

        Array.prototype.splice.apply(
          ranges, [overlapRanges[0], overlapRanges.length].concat(accumulate)
        );
        return ranges;
      };

      /**
       * var range = IdPacker.range(2, 5);
       * var ranges = [IdPacker.range(0, 3), IdPacker.range(5, 3)];
       * range.deleteFromRanges(ranges);
       * => [IdPacker.range(0, 2), IdPacker.range(7, 1)]
       *
       * @param {Array(Range)} ranges
       * @return {Array(Range)}
       */
      this.deleteFromRanges = function(ranges) {
        var i, l;
        var overlapRanges = [];
        var accumulate    = [];

        for (i = 0, l = ranges.length; i < l; i++) {
          if (this.intersect(ranges[i]) !== null) {
            overlapRanges.push(i);
          }
        }
        if (overlapRanges.length === 0) {
          return ranges;
        }

        accumulate = [];

        for (i = 0, l = overlapRanges.length; i < l; i++) {
          accumulate = accumulate.concat(ranges[overlapRanges[i]].minus(this));
        }

        Array.prototype.splice.apply(
          ranges, [overlapRanges[0], overlapRanges.length].concat(accumulate)
        );
        return ranges;
      };
    },

    /**
     * [1,2,3,6,7,8]
     * => [IdPacker.range(start: 1 length: 3), IdPacker.range(start: 6 length: 3)]
     */
    convertNumbersToRanges: function (numbers) {
      var ranges = [];
      if (numbers.length > 0) {
        var range = IdPacker.range(numbers[0], 1);
        for (var i = 1; i < numbers.length; i++) {
          if (numbers[i] == numbers[i-1]+1) {
            range.length = range.length + 1;
          } else {
            ranges.push(range);
            range = IdPacker.range(numbers[i], 1);
          }
        }
        ranges.push(range);
      }
      return ranges;
    },

    /**
     * [IdPacker.range(start: 1 length: 3), IdPacker.range(start: 6 length: 3)]
     * => "11100111"
     */
    convertRangesToBinaryNumber: function (ranges) {
      var binaryNumber = '';
      var i, j;
      for (i = 0; i < ranges.length; i++) {
        if (i > 0) {
          for (j = ranges[i].start; j > ranges[i-1].end() + 1; j--) {
            binaryNumber += '0';
          }
        }
        for (j = 0; j < ranges[i].length; j++) {
          binaryNumber += '1';
        }
      }
      return binaryNumber;
    },

    /**
     * [5, 6, 21, 23, 25]
     * => "_E~C_O.V"
     */
    encodeHashKeys: function(hash, windowSize, isExcludeNull, outputCharset) {
      // set default values
      windowSize    = typeof  windowSize   === "number"  ? windowSize    : this.windowSize;
      isExcludeNull = typeof isExcludeNull === "boolean" ? isExcludeNull : this.isExcludeNull;
      outputCharset = typeof outputCharset === "string"  ? outputCharset : this.outputCharset;

      var encodedHashKeys = '';
      var hashKeys = hash instanceof Array ? hash : [];

      // convert keys in Object into array if collection is an Object
      if (! (hash instanceof Array)) {
        var hashKey;
        for (hashKey in hash) {
          if (!isExcludeNull || isExcludeNull && hash[hashKey]) {
            hashKeys.push(parseInt(hashKey, 10));
          }
        }

        if (! hashKeys.length) {
          return '';
        }
      }

      // sort the collection in ascending order
      hashKeys.sort(function(a,b) {
        return a - b;
      });

      var ranges        = this.convertNumbersToRanges(hashKeys);
      var prevEnd       = 0;
      var currStart     = 1;
      var spaces        = 0;
      var groupWithPrev = false;
      var rangesToGroup = [];
      var binaryNumber  = '';
      var decimalNumber = 0;
      var encodedString = '';

      for (var i = 0; i < ranges.length; i++) {

        spaces = ranges[i].start - prevEnd;

        if (groupWithPrev) {

          if (ranges[i].end() - currStart + 1 == windowSize) {
            rangesToGroup.push(ranges[i]);
            binaryNumber     = this.convertRangesToBinaryNumber(rangesToGroup);
            decimalNumber    = this.convertBinaryNumberToDecimalNumber(binaryNumber);
            encodedString    = this.binaryPrefix + this.encodeDecimalNumber(decimalNumber, outputCharset);
            encodedHashKeys += encodedString;
            rangesToGroup    = [];
            groupWithPrev    = false;
          } else if (ranges[i].end() - currStart + 1 >= windowSize) {

            if (rangesToGroup.length == 1) {
              encodedString    = this.rangePrefix + this.encodeDecimalNumber(rangesToGroup[0].length, outputCharset);
              encodedHashKeys += encodedString;
            } else {
              binaryNumber     = this.convertRangesToBinaryNumber(rangesToGroup);
              decimalNumber    = this.convertBinaryNumberToDecimalNumber(binaryNumber);
              encodedString    = this.binaryPrefix + this.encodeDecimalNumber(decimalNumber, outputCharset);
              encodedHashKeys += encodedString;
            }

            rangesToGroup    = [];
            encodedString    = this.spacesPrefix + this.encodeDecimalNumber(spaces, outputCharset);
            encodedHashKeys += encodedString;

            if (ranges[i].length >= windowSize) {
              encodedString    = this.rangePrefix + this.encodeDecimalNumber(ranges[i].length, outputCharset);
              encodedHashKeys += encodedString;
              groupWithPrev    = false;
            } else {
              rangesToGroup.push(ranges[i]);
              currStart     = ranges[i].start;
              groupWithPrev = true;
            }
          } else {
            rangesToGroup.push(ranges[i]);
          }
        } else {
          if (spaces >= 0) {
            encodedString    = this.spacesPrefix + this.encodeDecimalNumber(spaces, outputCharset);
            encodedHashKeys += encodedString;
          }
          if (ranges[i].length >= windowSize) {
            encodedString    = this.rangePrefix + this.encodeDecimalNumber(ranges[i].length, outputCharset);
            encodedHashKeys += encodedString;
          } else {
            rangesToGroup.push(ranges[i]);
            currStart     = ranges[i].start;
            groupWithPrev = true;
          }
        }
        prevEnd = ranges[i].end();
      }

      if (rangesToGroup.length == 1) {
        encodedString    = this.rangePrefix + this.encodeDecimalNumber(rangesToGroup[0].length, outputCharset);
        encodedHashKeys += encodedString;
      } else if (rangesToGroup.length > 0) {
        binaryNumber     = this.convertRangesToBinaryNumber(rangesToGroup);
        decimalNumber    = this.convertBinaryNumberToDecimalNumber(binaryNumber);
        encodedString    = this.binaryPrefix + this.encodeDecimalNumber(decimalNumber, outputCharset);
        encodedHashKeys += encodedString;
      }
      return encodedHashKeys;
    },

    encodeInteger: function (n) {
      return this.encodeDecimalNumber(n);
    },

    /**
     * "10101"
     * => 21
     */
    convertBinaryNumberToDecimalNumber: function(binaryNumber) {
      var decimalNumber = 0;
      for (var i = 0; i < binaryNumber.length; i++) {
        decimalNumber = decimalNumber + Math.pow(2, binaryNumber.length - i - 1) * parseInt(binaryNumber.charAt(i), 10);
      }
      return decimalNumber;
    },

    /**
     * 5
     * => F"
     */
    encodeDecimalNumber: function(decimalNumber, outputCharset) {
      if (typeof outputCharset == 'undefined' || outputCharset == null) {
        outputCharset = this.outputCharset;
      }
      var encodedNumber = "";
      var base          = outputCharset.length;
      var quotient      = decimalNumber;
      var remainder;
      while (true) {
        remainder = quotient % base;
        encodedNumber = outputCharset.charAt(remainder) + encodedNumber;
        quotient = (quotient - remainder) / base;
        if (quotient == 0) {
          break;
        }
      }
      return encodedNumber;
    }
  };

}));