310 lines
7.8 KiB
JavaScript
310 lines
7.8 KiB
JavaScript
// License: CC0 (no rights reserved).
|
|
|
|
// This is based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
|
|
|
|
export const BASE_62_DIGITS =
|
|
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
|
|
// `a` may be empty string, `b` is null or non-empty string.
|
|
// `a < b` lexicographically if `b` is non-null.
|
|
// no trailing zeros allowed.
|
|
// digits is a string such as '0123456789' for base 10. Digits must be in
|
|
// ascending character code order!
|
|
/**
|
|
* @param {string} a
|
|
* @param {string | null | undefined} b
|
|
* @param {string} digits
|
|
* @returns {string}
|
|
*/
|
|
function midpoint(a, b, digits) {
|
|
const zero = digits[0];
|
|
if (b != null && a >= b) {
|
|
throw new Error(a + " >= " + b);
|
|
}
|
|
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
|
|
throw new Error("trailing zero");
|
|
}
|
|
if (b) {
|
|
// remove longest common prefix. pad `a` with 0s as we
|
|
// go. note that we don't need to pad `b`, because it can't
|
|
// end before `a` while traversing the common prefix.
|
|
let n = 0;
|
|
while ((a[n] || zero) === b[n]) {
|
|
n++;
|
|
}
|
|
if (n > 0) {
|
|
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits);
|
|
}
|
|
}
|
|
// first digits (or lack of digit) are different
|
|
const digitA = a ? digits.indexOf(a[0]) : 0;
|
|
const digitB = b != null ? digits.indexOf(b[0]) : digits.length;
|
|
if (digitB - digitA > 1) {
|
|
const midDigit = Math.round(0.5 * (digitA + digitB));
|
|
return digits[midDigit];
|
|
} else {
|
|
// first digits are consecutive
|
|
if (b && b.length > 1) {
|
|
return b.slice(0, 1);
|
|
} else {
|
|
// `b` is null or has length 1 (a single digit).
|
|
// the first digit of `a` is the previous digit to `b`,
|
|
// or 9 if `b` is null.
|
|
// given, for example, midpoint('49', '5'), return
|
|
// '4' + midpoint('9', null), which will become
|
|
// '4' + '9' + midpoint('', null), which is '495'
|
|
return digits[digitA] + midpoint(a.slice(1), null, digits);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} int
|
|
* @return {void}
|
|
*/
|
|
|
|
function validateInteger(int) {
|
|
if (int.length !== getIntegerLength(int[0])) {
|
|
throw new Error("invalid integer part of order key: " + int);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} head
|
|
* @return {number}
|
|
*/
|
|
|
|
function getIntegerLength(head) {
|
|
if (head >= "a" && head <= "z") {
|
|
return head.charCodeAt(0) - "a".charCodeAt(0) + 2;
|
|
} else if (head >= "A" && head <= "Z") {
|
|
return "Z".charCodeAt(0) - head.charCodeAt(0) + 2;
|
|
} else {
|
|
throw new Error("invalid order key head: " + head);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} key
|
|
* @return {string}
|
|
*/
|
|
|
|
function getIntegerPart(key) {
|
|
const integerPartLength = getIntegerLength(key[0]);
|
|
if (integerPartLength > key.length) {
|
|
throw new Error("invalid order key: " + key);
|
|
}
|
|
return key.slice(0, integerPartLength);
|
|
}
|
|
|
|
/**
|
|
* @param {string} key
|
|
* @param {string} digits
|
|
* @return {void}
|
|
*/
|
|
|
|
function validateOrderKey(key, digits) {
|
|
if (key === "A" + digits[0].repeat(26)) {
|
|
throw new Error("invalid order key: " + key);
|
|
}
|
|
// getIntegerPart will throw if the first character is bad,
|
|
// or the key is too short. we'd call it to check these things
|
|
// even if we didn't need the result
|
|
const i = getIntegerPart(key);
|
|
const f = key.slice(i.length);
|
|
if (f.slice(-1) === digits[0]) {
|
|
throw new Error("invalid order key: " + key);
|
|
}
|
|
}
|
|
|
|
// note that this may return null, as there is a largest integer
|
|
/**
|
|
* @param {string} x
|
|
* @param {string} digits
|
|
* @return {string | null}
|
|
*/
|
|
function incrementInteger(x, digits) {
|
|
validateInteger(x);
|
|
const [head, ...digs] = x.split("");
|
|
let carry = true;
|
|
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
|
const d = digits.indexOf(digs[i]) + 1;
|
|
if (d === digits.length) {
|
|
digs[i] = digits[0];
|
|
} else {
|
|
digs[i] = digits[d];
|
|
carry = false;
|
|
}
|
|
}
|
|
if (carry) {
|
|
if (head === "Z") {
|
|
return "a" + digits[0];
|
|
}
|
|
if (head === "z") {
|
|
return null;
|
|
}
|
|
const h = String.fromCharCode(head.charCodeAt(0) + 1);
|
|
if (h > "a") {
|
|
digs.push(digits[0]);
|
|
} else {
|
|
digs.pop();
|
|
}
|
|
return h + digs.join("");
|
|
} else {
|
|
return head + digs.join("");
|
|
}
|
|
}
|
|
|
|
// note that this may return null, as there is a smallest integer
|
|
/**
|
|
* @param {string} x
|
|
* @param {string} digits
|
|
* @return {string | null}
|
|
*/
|
|
|
|
function decrementInteger(x, digits) {
|
|
validateInteger(x);
|
|
const [head, ...digs] = x.split("");
|
|
let borrow = true;
|
|
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
|
const d = digits.indexOf(digs[i]) - 1;
|
|
if (d === -1) {
|
|
digs[i] = digits.slice(-1);
|
|
} else {
|
|
digs[i] = digits[d];
|
|
borrow = false;
|
|
}
|
|
}
|
|
if (borrow) {
|
|
if (head === "a") {
|
|
return "Z" + digits.slice(-1);
|
|
}
|
|
if (head === "A") {
|
|
return null;
|
|
}
|
|
const h = String.fromCharCode(head.charCodeAt(0) - 1);
|
|
if (h < "Z") {
|
|
digs.push(digits.slice(-1));
|
|
} else {
|
|
digs.pop();
|
|
}
|
|
return h + digs.join("");
|
|
} else {
|
|
return head + digs.join("");
|
|
}
|
|
}
|
|
|
|
// `a` is an order key or null (START).
|
|
// `b` is an order key or null (END).
|
|
// `a < b` lexicographically if both are non-null.
|
|
// digits is a string such as '0123456789' for base 10. Digits must be in
|
|
// ascending character code order!
|
|
/**
|
|
* @param {string | null | undefined} a
|
|
* @param {string | null | undefined} b
|
|
* @param {string=} digits
|
|
* @return {string}
|
|
*/
|
|
export function generateKeyBetween(a, b, digits = BASE_62_DIGITS) {
|
|
if (a != null) {
|
|
validateOrderKey(a, digits);
|
|
}
|
|
if (b != null) {
|
|
validateOrderKey(b, digits);
|
|
}
|
|
if (a != null && b != null && a >= b) {
|
|
throw new Error(a + " >= " + b);
|
|
}
|
|
if (a == null) {
|
|
if (b == null) {
|
|
return "a" + digits[0];
|
|
}
|
|
|
|
const ib = getIntegerPart(b);
|
|
const fb = b.slice(ib.length);
|
|
if (ib === "A" + digits[0].repeat(26)) {
|
|
return ib + midpoint("", fb, digits);
|
|
}
|
|
if (ib < b) {
|
|
return ib;
|
|
}
|
|
const res = decrementInteger(ib, digits);
|
|
if (res == null) {
|
|
throw new Error("cannot decrement any more");
|
|
}
|
|
return res;
|
|
}
|
|
|
|
if (b == null) {
|
|
const ia = getIntegerPart(a);
|
|
const fa = a.slice(ia.length);
|
|
const i = incrementInteger(ia, digits);
|
|
return i == null ? ia + midpoint(fa, null, digits) : i;
|
|
}
|
|
|
|
const ia = getIntegerPart(a);
|
|
const fa = a.slice(ia.length);
|
|
const ib = getIntegerPart(b);
|
|
const fb = b.slice(ib.length);
|
|
if (ia === ib) {
|
|
return ia + midpoint(fa, fb, digits);
|
|
}
|
|
const i = incrementInteger(ia, digits);
|
|
if (i == null) {
|
|
throw new Error("cannot increment any more");
|
|
}
|
|
if (i < b) {
|
|
return i;
|
|
}
|
|
return ia + midpoint(fa, null, digits);
|
|
}
|
|
|
|
/**
|
|
* same preconditions as generateKeysBetween.
|
|
* n >= 0.
|
|
* Returns an array of n distinct keys in sorted order.
|
|
* If a and b are both null, returns [a0, a1, ...]
|
|
* If one or the other is null, returns consecutive "integer"
|
|
* keys. Otherwise, returns relatively short keys between
|
|
* a and b.
|
|
* @param {string | null | undefined} a
|
|
* @param {string | null | undefined} b
|
|
* @param {number} n
|
|
* @param {string} digits
|
|
* @return {string[]}
|
|
*/
|
|
export function generateNKeysBetween(a, b, n, digits = BASE_62_DIGITS) {
|
|
if (n === 0) {
|
|
return [];
|
|
}
|
|
if (n === 1) {
|
|
return [generateKeyBetween(a, b, digits)];
|
|
}
|
|
if (b == null) {
|
|
let c = generateKeyBetween(a, b, digits);
|
|
const result = [c];
|
|
for (let i = 0; i < n - 1; i++) {
|
|
c = generateKeyBetween(c, b, digits);
|
|
result.push(c);
|
|
}
|
|
return result;
|
|
}
|
|
if (a == null) {
|
|
let c = generateKeyBetween(a, b, digits);
|
|
const result = [c];
|
|
for (let i = 0; i < n - 1; i++) {
|
|
c = generateKeyBetween(a, c, digits);
|
|
result.push(c);
|
|
}
|
|
result.reverse();
|
|
return result;
|
|
}
|
|
const mid = Math.floor(n / 2);
|
|
const c = generateKeyBetween(a, b, digits);
|
|
return [
|
|
...generateNKeysBetween(a, c, mid, digits),
|
|
c,
|
|
...generateNKeysBetween(c, b, n - mid - 1, digits),
|
|
];
|
|
}
|