/** * Lexicographic fractional indexing for ordering items. * * Generates string keys that sort lexicographically between any two existing keys, * avoiding float precision issues and the need for rebalancing. * * Based on the approach described in: * https://observablehq.com/@dgreensp/implementing-fractional-indexing */ const BASE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const MIDPOINT = 'N'; // roughly middle of uppercase range /** * Generate a position string between `before` and `after`. * - If both are null/undefined, returns a midpoint. * - If only `before` is provided, returns something after it. * - If only `after` is provided, returns something before it. * - If both are provided, returns something between them. */ export function generatePosition(before?: string | null, after?: string | null): string { if (!before && !after) { return MIDPOINT; } if (!before) { // Generate position before `after` return midpointBefore(after!); } if (!after) { // Generate position after `before` return midpointAfter(before); } // Generate position between `before` and `after` return midpointBetween(before, after); } function midpointBefore(pos: string): string { // Find a string that sorts before `pos` for (let i = 0; i < pos.length; i++) { const charIndex = BASE_CHARS.indexOf(pos[i]); if (charIndex > 0) { const midCharIndex = Math.floor(charIndex / 2); return pos.slice(0, i) + BASE_CHARS[midCharIndex]; } } // All chars are 'A' (first char), prepend 'A' and append midpoint return pos + MIDPOINT; } function midpointAfter(pos: string): string { // Find a string that sorts after `pos` for (let i = pos.length - 1; i >= 0; i--) { const charIndex = BASE_CHARS.indexOf(pos[i]); if (charIndex < BASE_CHARS.length - 1) { const midCharIndex = Math.ceil((charIndex + BASE_CHARS.length - 1) / 2); return pos.slice(0, i) + BASE_CHARS[midCharIndex]; } } // All chars are last char, append midpoint return pos + MIDPOINT; } function midpointBetween(before: string, after: string): string { // Pad the shorter string with the first BASE_CHARS character conceptually const maxLen = Math.max(before.length, after.length); for (let i = 0; i < maxLen + 1; i++) { const beforeChar = i < before.length ? BASE_CHARS.indexOf(before[i]) : 0; const afterChar = i < after.length ? BASE_CHARS.indexOf(after[i]) : BASE_CHARS.length - 1; if (beforeChar < afterChar) { if (afterChar - beforeChar > 1) { // There's room between these two characters const midCharIndex = Math.floor((beforeChar + afterChar) / 2); return before.slice(0, i) + BASE_CHARS[midCharIndex]; } else { // Adjacent characters — go one level deeper // Take the before character and find a midpoint in the next level return before.slice(0, i + 1) + midpointAfter(before.slice(i + 1) || 'A'); } } // Characters are equal, continue to next position } // Shouldn't reach here if before < after, but safety net return before + MIDPOINT; } /** * Generate N evenly spaced positions (for initial bulk ordering). */ export function generatePositions(count: number): string[] { if (count === 0) return []; if (count === 1) return [MIDPOINT]; const positions: string[] = []; const step = Math.floor(BASE_CHARS.length / (count + 1)); for (let i = 1; i <= count; i++) { const charIndex = Math.min(step * i, BASE_CHARS.length - 1); positions.push(BASE_CHARS[charIndex]); } return positions; }