fractional-indexing-base-26
Version:
Provides functions for generating ordering strings
219 lines (199 loc) • 6.57 kB
text/typescript
// License: CC0 (no rights reserved).
// This is based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
export const digits = 'abcdefghijklmnopqrstuvwxyz';
const zero = digits[0] as 'a';
const integerZero = 'na';
const smallestInteger = 'a'.repeat(14);
// `a` may be an empty string, `b` is null or a non-empty string.
// `a < b` lexicographically if `b` is non-null.
// No trailing zeros are allowed.
function midpoint(a: string, b: string | null): string {
if (b !== null && a >= b) {
throw new Error(a + ' >= ' + b);
}
if (a.slice(-1) === zero || b?.slice(-1) === zero) {
throw new Error('trailing zero');
}
if (b) {
// Remove longest common prefix. Pad `a` with zeroes 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));
}
}
// The first digits (or lack of digit) are different.
const digitA = a ? digits.indexOf(a[0] as string) : 0;
const digitB = b ? digits.indexOf(b[0] as string) : digits.length;
if (digitB - digitA > 1) {
return digits[Math.round((digitA + digitB) / 2)] as string;
}
// The first digits are consecutive.
if (b && b.length > 1) {
return b.slice(0, 1);
}
// `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);
}
const getIntegerLength = (head: string) => {
if (head >= 'a' && head <= 'm') {
// We add 2 because there's the header and there's always at least one digit after the header.
return 'm'.charCodeAt(0) - head.charCodeAt(0) + 2;
}
if (head >= 'n' && head <= 'z') {
return head.charCodeAt(0) - 'n'.charCodeAt(0) + 2;
}
throw new Error('invalid order key head: ' + head);
};
const getIntegerPart = (key: string) => key.slice(0, getIntegerLength(key[0] as string));
// Note that this may return null, as there is a largest integer.
const incrementInteger = (x: string) => {
const [head, ...digs] = x.split('') as [string, ...string[]];
let carry = true;
for (let i = digs.length - 1; carry && i >= 0; i--) {
const d = digits.indexOf(digs[i] as string) + 1;
if (d === digits.length) {
digs[i] = zero;
} else {
digs[i] = digits[d] as string;
carry = false;
}
}
if (carry) {
if (head === 'm') {
return 'n' + zero;
}
if (head === 'z') {
return null;
}
const newHead = String.fromCharCode(head.charCodeAt(0) + 1);
if (newHead > 'n') {
return newHead + digs.join('') + zero;
}
digs.pop();
return newHead + digs.join('');
}
return head + digs.join('');
};
const z = digits.slice(-1);
// Note that this may return null, as there is a smallest integer.
const decrementInteger = (x: string) => {
const [head, ...digs] = x.split('') as [string, ...string[]];
let borrow = true;
for (let i = digs.length - 1; borrow && i >= 0; i--) {
const d = digits.indexOf(digs[i] as string) - 1;
if (d === -1) {
digs[i] = z;
} else {
digs[i] = digits[d] as string;
borrow = false;
}
}
if (borrow) {
if (head === 'n') {
return 'm' + z;
}
if (head === 'a') {
return null;
}
const newHead = String.fromCharCode(head.charCodeAt(0) - 1);
if (newHead < 'm') {
return newHead + digs.join('') + z;
}
digs.pop();
return newHead + digs.join('');
}
return head + digs.join('');
};
/**
* Returns the key that is halfway between the two given keys, which are expected to have the format that this library produces.
* If both a and b are non-null, a must sort lexicographically before b.
*/
export function generateKeyBetween(a: string | null, b: string | null): string {
if (a !== null && b !== null && a >= b) {
throw new Error(`expected ${a} >= ${b}`);
}
if (a === null) {
if (b === null) {
return integerZero;
}
const bIntegerPart = getIntegerPart(b);
const bFractionPart = b.slice(bIntegerPart.length);
if (bIntegerPart === smallestInteger) {
return bIntegerPart + midpoint('', bFractionPart);
}
if (bIntegerPart < b) {
return bIntegerPart;
}
const res = decrementInteger(bIntegerPart);
if (res == null) {
throw new Error('cannot decrement any more');
}
return res;
}
const aIntegerPart = getIntegerPart(a);
const aFractionPart = a.slice(aIntegerPart.length);
if (b === null) {
const nextInteger = incrementInteger(aIntegerPart);
return nextInteger === null ? aIntegerPart + midpoint(aFractionPart, null) : nextInteger;
}
const bIntegerPart = getIntegerPart(b);
const bFractionPart = b.slice(bIntegerPart.length);
if (aIntegerPart === bIntegerPart) {
return aIntegerPart + midpoint(aFractionPart, bFractionPart);
}
const nextInteger = incrementInteger(aIntegerPart);
if (nextInteger === null) {
throw new Error('cannot increment any more');
}
if (nextInteger < b) {
return nextInteger;
}
return aIntegerPart + midpoint(aFractionPart, null);
}
/**
* Returns an array of n distinct keys in sorted order.
* If a and b are both null, keys start at the middle of the possible key range, so it returns [na, nb, ...].
* If only a is null, returns the "integer" key immediately before b.
* If only b is null, returns the "integer" key immediately after a.
* Otherwise, returns (short) keys strictly between a and b.
*/
export function generateKeysBetween(a: string | null, b: string | null, n: number): string[] {
if (n === 0) {
return [];
}
if (n === 1) {
return [generateKeyBetween(a, b)];
}
if (b == null) {
let c = generateKeyBetween(a, b);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(c, b);
result.push(c);
}
return result;
}
if (a === null) {
let c = generateKeyBetween(a, b);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(a, c);
result.push(c);
}
result.reverse();
return result;
}
const mid = Math.floor(n / 2);
const c = generateKeyBetween(a, b);
return [...generateKeysBetween(a, c, mid), c, ...generateKeysBetween(c, b, n - mid - 1)];
}