UNPKG

@alvarosilva/hex-address

Version:

Convert GPS coordinates to memorable hex addresses using H3

1 lines 112 kB
{"version":3,"sources":["../src/h3-syllable-system.ts","../src/types.ts","../src/configs/ascii-elomr.json","../src/config-loader.ts","../src/index.ts"],"sourcesContent":["import { latLngToCell, cellToLatLng, cellToParent, cellToChildren, getBaseCellNumber } from 'h3-js';\nimport { \n SyllableConfig, \n Coordinates, \n RoundTripResult, \n SystemInfo, \n ConversionError,\n GeographicBounds,\n PartialLocationEstimate,\n AddressAnalysis,\n PhoneticAlternative,\n ValidationResult,\n ValidationError\n} from './types';\nimport { getConfig } from './config-loader';\n\n/**\n * Comprehensive phonetic confusion database\n * Maps characters to their phonetically similar alternatives across different languages\n */\nconst PHONETIC_CONFUSIONS: Record<string, string[]> = {\n // Voiced/Unvoiced consonant pairs (most common confusions)\n 'd': ['t'],\n 't': ['d'], \n 'f': ['v', 'p'], // f often confused with v and p\n 'v': ['f', 'b'], // v often confused with f and b\n 's': ['z', 'c'], // s often confused with z and soft c\n 'z': ['s'],\n 'p': ['b', 'f'], // p often confused with b and f\n 'b': ['p', 'v'], // b often confused with p and v\n 'k': ['c', 'g'], // k often confused with hard c and g\n 'c': ['k', 's'], // c can sound like k or s\n 'g': ['k', 'j'], // g often confused with k and j\n 'j': ['g', 'y'], // j often confused with g and y\n \n // Liquid consonants (often confused)\n 'l': ['r', 'n'], // l/r confusion common in many languages\n 'r': ['l'],\n 'n': ['m', 'l'], // n often confused with m and l\n 'm': ['n'],\n \n // Sibilants and fricatives\n 'h': ['f'], // h often confused with f\n 'w': ['v', 'u'], // w often confused with v and u\n 'y': ['j', 'i'], // y often confused with j and i\n \n // Vowel confusions (very common in speech)\n 'a': ['e', 'o'], // a often confused with e and o\n 'e': ['i', 'a'], // e often confused with i and a\n 'i': ['e', 'y'], // i often confused with e and y\n 'o': ['u', 'a'], // o often confused with u and a\n 'u': ['o', 'w'] // u often confused with o and w\n};\n\n\n/**\n * H3 Syllable Address System\n * \n * Converts GPS coordinates to memorable syllable addresses using H3 Level 15 cells.\n * \n * Standard Process:\n * 1. GPS Coordinates → H3 Cell ID (H3 hexagonal identifier)\n * 2. H3 Cell ID → Hierarchical Array (path through H3 tree structure) \n * 3. Hierarchical Array → Integer Index (unique mathematical index)\n * 4. Integer Index → Syllable Address (human-readable syllables)\n */\nexport class H3SyllableSystem {\n private config: SyllableConfig;\n private configName: string;\n private syllableToIndex: Map<string, number> = new Map();\n private indexToSyllable: Map<number, string> = new Map();\n private hamiltonianPath: number[] = [];\n \n // Pre-computed values for faster operations\n private readonly consonantCount: number;\n private readonly vowelCount: number;\n private readonly totalSyllables: number;\n\n constructor(configName: string = 'ascii-elomr') {\n this.configName = configName;\n this.config = getConfig(configName);\n \n // Pre-compute frequently used values\n this.consonantCount = this.config.consonants.length;\n this.vowelCount = this.config.vowels.length;\n this.totalSyllables = this.consonantCount * this.vowelCount;\n \n this.initializeSyllableTables();\n this.initializeHamiltonianPath();\n }\n\n /**\n * Initialize syllable lookup tables for fast conversion\n */\n private initializeSyllableTables(): void {\n this.syllableToIndex.clear();\n this.indexToSyllable.clear();\n\n let index = 0;\n for (const consonant of this.config.consonants) {\n for (const vowel of this.config.vowels) {\n const syllable = consonant + vowel;\n this.syllableToIndex.set(syllable, index);\n this.indexToSyllable.set(index, syllable);\n index++;\n }\n }\n }\n\n /**\n * Initialize Hamiltonian path for level 0 cells (optimized array-based approach)\n */\n private initializeHamiltonianPath(): void {\n // Pre-computed Hamiltonian path for perfect spatial adjacency (100%)\n // Array where index = original_base_cell, value = hamiltonian_position\n this.hamiltonianPath = [\n 1, 2, 3, 8, 0, 4, 12, 9, 5, 10,\n 14, 13, 7, 22, 11, 6, 17, 39, 16, 42,\n 41, 23, 18, 37, 15, 38, 21, 40, 20, 25,\n 34, 19, 35, 33, 43, 47, 44, 36, 24, 69,\n 45, 31, 27, 26, 29, 48, 46, 57, 65, 32,\n 66, 56, 67, 30, 55, 54, 50, 68, 28, 70,\n 52, 63, 59, 49, 58, 61, 64, 75, 51, 93,\n 74, 92, 53, 91, 72, 62, 60, 87, 71, 86,\n 89, 77, 107, 73, 94, 76, 109, 82, 90, 96,\n 88, 97, 84, 121, 78, 85, 108, 95, 106, 100,\n 83, 80, 81, 98, 110, 99, 101, 79, 119, 120,\n 111, 105, 113, 103, 114, 112, 104, 102, 118, 116,\n 115, 117\n ];\n }\n\n /**\n * Convert geographic coordinates to syllable address\n */\n coordinateToAddress(latitude: number, longitude: number): string {\n try {\n this.validateCoordinates(latitude, longitude);\n\n // Step 1: Convert GPS Coordinates to H3 Cell ID\n const h3CellId = latLngToCell(latitude, longitude, this.config.h3_resolution);\n\n // Step 2: Convert H3 Cell ID to Hierarchical Array\n const hierarchicalArray = this.h3CellIdToHierarchicalArray(h3CellId);\n\n // Step 3: Convert Hierarchical Array to Integer Index\n const integerIndex = this.hierarchicalArrayToIntegerIndex(hierarchicalArray);\n\n // Step 4: Convert Integer Index to Syllable Address\n const syllableAddress = this.integerIndexToSyllableAddress(integerIndex);\n\n return syllableAddress;\n } catch (error) {\n if (error instanceof ConversionError) {\n throw error;\n }\n throw new ConversionError(`Coordinate conversion failed`);\n }\n }\n\n /**\n * Convert syllable address to geographic coordinates\n */\n addressToCoordinate(syllableAddress: string): Coordinates {\n try {\n // Step 1: Convert Syllable Address to Integer Index\n const integerIndex = this.syllableAddressToIntegerIndex(syllableAddress);\n\n // Step 2: Convert Integer Index to Hierarchical Array\n const hierarchicalArray = this.integerIndexToHierarchicalArray(integerIndex);\n\n // Step 3: Convert Hierarchical Array to H3 Cell ID\n const h3CellId = this.hierarchicalArrayToH3CellId(hierarchicalArray);\n\n // Step 4: Convert H3 Cell ID to GPS Coordinates\n const [latitude, longitude] = cellToLatLng(h3CellId);\n\n return [latitude, longitude];\n } catch (error) {\n if (error instanceof ConversionError) {\n throw error;\n }\n console.error('Syllable conversion error:', error);\n throw new ConversionError(`Syllable conversion failed: ${error instanceof Error ? error.message : String(error)}`);\n }\n }\n\n /**\n * Check if a syllable address maps to a real H3 location\n * \n * @param syllableAddress - The address to validate\n * @param detailed - If true, returns ValidationResult with detailed errors and phonetic suggestions\n * @returns boolean or ValidationResult based on detailed parameter\n * \n * @example\n * ```typescript\n * // Simple validation\n * system.isValidAddress(\"dinenunukiwufeme\") // → true\n * system.isValidAddress(\"invalid\") // → false\n * \n * // Detailed validation with phonetic suggestions\n * const result = system.isValidAddress(\"helloworld\", true);\n * console.log(result.errors[0].suggestions); // → ['fello', 'jello', 'mello']\n * ```\n */\n isValidAddress(syllableAddress: string): boolean;\n isValidAddress(syllableAddress: string, detailed: true): ValidationResult;\n isValidAddress(syllableAddress: string, detailed: false): boolean;\n isValidAddress(syllableAddress: string, detailed?: boolean): boolean | ValidationResult {\n if (detailed) {\n return this.validateAddress(syllableAddress);\n }\n \n try {\n this.addressToCoordinate(syllableAddress);\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Comprehensive validation with detailed error reporting\n * @internal\n */\n private validateAddress(syllableAddress: string): ValidationResult {\n const errors: ValidationError[] = [];\n const validParts: string[] = [];\n let suggestions: string[] = [];\n\n // Step 1: Format validation\n const formatResult = this.validateFormat(syllableAddress);\n if (!formatResult.isValid) {\n errors.push(...formatResult.errors);\n return { isValid: false, errors, validParts, suggestions };\n }\n\n // Step 2: Syllable validation\n const syllableResult = this.validateSyllables(syllableAddress);\n errors.push(...syllableResult.errors);\n validParts.push(...syllableResult.validParts);\n suggestions.push(...(syllableResult.suggestions || []));\n\n // Step 3: Geographic validation (only if syllables are valid)\n if (syllableResult.isValid) {\n const geoResult = this.validateGeographic(syllableAddress);\n errors.push(...geoResult.errors);\n if (!geoResult.isValid) {\n suggestions.push(...(geoResult.suggestions || []));\n }\n }\n\n return {\n isValid: errors.length === 0,\n errors,\n validParts,\n suggestions: suggestions.length > 0 ? suggestions : undefined\n };\n }\n\n /**\n * Validate address format (length, structure)\n */\n private validateFormat(address: string): ValidationResult {\n const errors: ValidationError[] = [];\n \n if (!address || address.trim() === '') {\n errors.push({\n type: 'format',\n message: 'Address cannot be empty',\n suggestions: ['Enter a valid syllable address like \"dinenunukiwufeme\"']\n });\n return { isValid: false, errors, validParts: [] };\n }\n\n const cleanAddress = address.toLowerCase().trim();\n \n // Check if length is even (syllables are 2 characters each)\n if (cleanAddress.length % 2 !== 0) {\n errors.push({\n type: 'format',\n message: `Address length must be even (syllables are 2 characters each). Got ${cleanAddress.length} characters`,\n received: cleanAddress,\n suggestions: ['Each syllable must be exactly 2 characters (consonant + vowel)']\n });\n }\n\n // Check expected length\n const expectedLength = this.config.address_length * 2;\n if (cleanAddress.length !== expectedLength) {\n errors.push({\n type: 'length',\n message: `Address must be exactly ${expectedLength} characters (${this.config.address_length} syllables). Got ${cleanAddress.length} characters`,\n received: cleanAddress,\n expected: [`${expectedLength} characters`]\n });\n }\n\n return { isValid: errors.length === 0, errors, validParts: [] };\n }\n\n /**\n * Validate individual syllables\n */\n private validateSyllables(address: string): ValidationResult {\n const errors: ValidationError[] = [];\n const validParts: string[] = [];\n const suggestions: string[] = [];\n \n const cleanAddress = address.toLowerCase().trim();\n \n // Parse syllables\n const syllables: string[] = [];\n for (let i = 0; i < cleanAddress.length; i += 2) {\n syllables.push(cleanAddress.substring(i, i + 2));\n }\n\n // Validate each syllable\n for (let i = 0; i < syllables.length; i++) {\n const syllable = syllables[i];\n \n if (syllable.length !== 2) {\n errors.push({\n type: 'syllable',\n message: `Syllable at position ${i + 1} must be exactly 2 characters. Got \"${syllable}\" (${syllable.length} characters)`,\n position: i,\n received: syllable\n });\n continue;\n }\n\n if (!this.syllableToIndex.has(syllable)) {\n const [consonant, vowel] = syllable;\n const validConsonants = this.config.consonants;\n const validVowels = this.config.vowels;\n \n let errorMsg = `Invalid syllable \"${syllable}\" at position ${i + 1}`;\n const syllableSuggestions: string[] = [];\n \n // Check if consonant is valid\n if (!validConsonants.includes(consonant)) {\n errorMsg += `. Invalid consonant \"${consonant}\"`;\n // Suggest phonetically similar consonants\n const similarConsonants = this.getCharacterSuggestions(consonant, 'consonant');\n \n if (similarConsonants.length > 0) {\n syllableSuggestions.push(...similarConsonants.map(c => c + vowel));\n }\n }\n \n // Check if vowel is valid\n if (!validVowels.includes(vowel)) {\n errorMsg += `. Invalid vowel \"${vowel}\"`;\n // Suggest phonetically similar vowels\n const similarVowels = this.getCharacterSuggestions(vowel, 'vowel');\n \n if (similarVowels.length > 0 && validConsonants.includes(consonant)) {\n syllableSuggestions.push(...similarVowels.map(v => consonant + v));\n }\n }\n \n errors.push({\n type: 'syllable',\n message: errorMsg,\n position: i,\n received: syllable,\n expected: validConsonants.includes(consonant) ? \n validVowels.map(v => consonant + v) : \n validConsonants.map(c => c + vowel).slice(0, 5),\n suggestions: syllableSuggestions.length > 0 ? syllableSuggestions : undefined\n });\n } else {\n validParts.push(syllable);\n }\n }\n\n // Add general suggestions for valid syllables\n if (errors.length > 0) {\n const allSyllables = Array.from(this.syllableToIndex.keys()).sort();\n suggestions.push(\n `Valid syllables: ${allSyllables.slice(0, 10).join(', ')}...`,\n `Consonants: ${this.config.consonants.join(', ')}`,\n `Vowels: ${this.config.vowels.join(', ')}`\n );\n }\n\n return { \n isValid: errors.length === 0, \n errors, \n validParts,\n suggestions: suggestions.length > 0 ? suggestions : undefined\n };\n }\n\n /**\n * Validate geographic existence\n */\n private validateGeographic(address: string): ValidationResult {\n const errors: ValidationError[] = [];\n \n try {\n this.addressToCoordinate(address);\n return { isValid: true, errors, validParts: [] };\n } catch (error) {\n const suggestions = [\n 'This address combination doesn\\'t correspond to a real geographic location',\n 'Try modifying the last few syllables',\n 'Use estimateLocationFromPartial() to explore valid addresses in this area'\n ];\n\n // Try to provide similar valid addresses\n const similarAddresses = this.findSimilarValidAddresses(address);\n if (similarAddresses.length > 0) {\n suggestions.unshift(`Similar valid addresses: ${similarAddresses.slice(0, 3).join(', ')}`);\n }\n\n errors.push({\n type: 'geographic',\n message: `Address \"${address}\" doesn't correspond to a real geographic location. This is normal - not all syllable combinations map to valid H3 cells.`,\n suggestions\n });\n }\n\n return { isValid: false, errors, validParts: [] };\n }\n\n /**\n * Find similar valid addresses by modifying the last few syllables\n */\n private findSimilarValidAddresses(address: string, maxAttempts: number = 50): string[] {\n const validAddresses: string[] = [];\n const cleanAddress = address.toLowerCase().trim();\n \n // Parse syllables\n const syllables: string[] = [];\n for (let i = 0; i < cleanAddress.length; i += 2) {\n syllables.push(cleanAddress.substring(i, i + 2));\n }\n \n const allSyllables = Array.from(this.syllableToIndex.keys());\n let attempts = 0;\n \n // Try modifying the last 2 syllables\n for (let i = Math.max(0, syllables.length - 2); i < syllables.length && attempts < maxAttempts; i++) {\n for (const replacement of allSyllables) {\n if (attempts >= maxAttempts) break;\n \n const testSyllables = [...syllables];\n testSyllables[i] = replacement;\n const testAddress = testSyllables.join('');\n \n if (this.isValidAddress(testAddress)) {\n validAddresses.push(testAddress);\n attempts++;\n }\n }\n }\n \n return validAddresses;\n }\n\n /**\n * Get valid phonetic substitutions for a character based on current config\n */\n private getValidPhoneticSubstitutions(char: string): string[] {\n const allSubstitutions = PHONETIC_CONFUSIONS[char] || [];\n const validChars = [...this.config.consonants, ...this.config.vowels];\n \n // Only return substitutions that exist in current config\n return allSubstitutions.filter(sub => validChars.includes(sub));\n }\n\n /**\n * Get phonetic suggestions for an invalid character\n */\n private getCharacterSuggestions(char: string, type: 'consonant' | 'vowel'): string[] {\n const suggestions = new Set<string>();\n \n // 1. Phonetic similarities (highest priority)\n const phoneticSubs = this.getValidPhoneticSubstitutions(char);\n phoneticSubs.forEach(sub => suggestions.add(sub));\n \n // 2. If no phonetic suggestions, provide first few valid characters\n if (suggestions.size === 0) {\n const validChars = type === 'consonant' ? this.config.consonants : this.config.vowels;\n validChars.slice(0, 3).forEach(c => suggestions.add(c));\n }\n \n return Array.from(suggestions).slice(0, 5); // Limit to top 5 suggestions\n }\n\n /**\n * Calculate distance between two coordinates in kilometers\n */\n private calculateDistanceKm(coord1: Coordinates, coord2: Coordinates): number {\n const [lat1, lng1] = coord1;\n const [lat2, lng2] = coord2;\n \n const R = 6371; // Earth's radius in kilometers\n const dLat = (lat2 - lat1) * Math.PI / 180;\n const dLng = (lng2 - lng1) * Math.PI / 180;\n const a = Math.sin(dLat/2) * Math.sin(dLat/2) +\n Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *\n Math.sin(dLng/2) * Math.sin(dLng/2);\n const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));\n return R * c;\n }\n\n /**\n * Analyze a syllable address and provide phonetic alternatives\n */\n analyzeAddress(syllableAddress: string): AddressAnalysis {\n // Validate the original address\n const isValid = this.isValidAddress(syllableAddress);\n let coordinates: Coordinates | undefined;\n \n if (isValid) {\n try {\n coordinates = this.addressToCoordinate(syllableAddress);\n } catch {\n // This shouldn't happen if isValid is true, but being safe\n }\n }\n\n const phoneticAlternatives: PhoneticAlternative[] = [];\n\n // Only generate alternatives if we have valid coordinates to compare distance\n if (coordinates) {\n // Remove separators for character-by-character analysis\n const cleanAddress = syllableAddress.replace(/[-|]/g, '');\n \n // For each character position, try phonetic substitutions\n for (let i = 0; i < cleanAddress.length; i++) {\n const char = cleanAddress[i];\n const substitutions = this.getValidPhoneticSubstitutions(char);\n \n for (const substitution of substitutions) {\n // Create alternative address\n const altChars = cleanAddress.split('');\n altChars[i] = substitution;\n \n // Reconstruct address with original formatting\n const altAddress = this.reconstructAddressFormat(altChars.join(''), syllableAddress);\n \n // Check if alternative is valid\n if (this.isValidAddress(altAddress)) {\n try {\n const altCoordinates = this.addressToCoordinate(altAddress);\n const distance = this.calculateDistanceKm(coordinates, altCoordinates);\n \n phoneticAlternatives.push({\n address: altAddress,\n coordinates: altCoordinates,\n distanceKm: Math.round(distance * 100) / 100, // Round to 2 decimal places\n change: {\n position: i,\n from: char,\n to: substitution\n }\n });\n } catch {\n // Alternative address is not convertible, skip it\n }\n }\n }\n }\n }\n\n // Sort alternatives by distance (closest first)\n phoneticAlternatives.sort((a, b) => a.distanceKm - b.distanceKm);\n\n return {\n isValid,\n address: syllableAddress,\n coordinates,\n phoneticAlternatives\n };\n }\n\n /**\n * Reconstruct address format (with separators) from clean character string\n */\n private reconstructAddressFormat(cleanChars: string, originalFormat: string): string {\n let result = '';\n let cleanIndex = 0;\n \n for (let i = 0; i < originalFormat.length; i++) {\n const char = originalFormat[i];\n if (char === '-' || char === '|') {\n result += char;\n } else {\n result += cleanChars[cleanIndex];\n cleanIndex++;\n }\n }\n \n return result;\n }\n\n /**\n * Estimate location and bounds from a partial syllable address\n */\n estimateLocationFromPartial(partialAddress: string, comprehensive: boolean = false): PartialLocationEstimate {\n try {\n // Parse partial address and validate format\n const parsed = this.parsePartialAddress(partialAddress);\n \n let samplePoints: Coordinates[] = [];\n let bounds: GeographicBounds;\n let center: Coordinates;\n let areaKm2: number;\n\n if (comprehensive) {\n // Generate sample addresses using comprehensive sampling for the next level\n const sampleAddresses = this.generateComprehensiveSamples(parsed);\n \n // Convert all sample addresses to coordinates\n samplePoints = sampleAddresses.map(addr => this.addressToCoordinate(addr));\n \n // Calculate bounds from all sample points\n bounds = this.calculateBoundsFromPoints(samplePoints);\n center = this.calculateCenterFromPoints(samplePoints);\n areaKm2 = this.calculateAreaKm2(bounds);\n } else {\n // Original approach: Calculate address range (min and max complete addresses)\n const addressRange = this.calculateAddressRange(parsed);\n \n // Find valid addresses within the range, with smart fallback if initial addresses are invalid\n const validRange = this.findValidAddressRange(addressRange.minAddress, addressRange.maxAddress, parsed.completeSyllables);\n \n // Convert both addresses to coordinates\n const minCoords = this.addressToCoordinate(validRange.minAddress);\n const maxCoords = this.addressToCoordinate(validRange.maxAddress);\n samplePoints = [minCoords, maxCoords];\n \n // Calculate geographic bounds and metrics\n bounds = this.calculateGeographicBounds(minCoords, maxCoords);\n center = this.calculateCenter(minCoords, maxCoords);\n areaKm2 = this.calculateAreaKm2(bounds);\n }\n const confidence = this.calculateConfidence(parsed);\n \n // Get suggested refinements (next possible syllables)\n const suggestedRefinements = this.getSuggestedRefinements(parsed);\n \n const completenessLevel = parsed.completeSyllables.length + (parsed.partialConsonant ? 0.5 : 0);\n \n return {\n centerCoordinate: center,\n bounds,\n confidence,\n estimatedAreaKm2: areaKm2,\n completenessLevel,\n suggestedRefinements,\n samplePoints: comprehensive ? samplePoints : undefined,\n comprehensiveMode: comprehensive\n };\n } catch (error) {\n if (error instanceof ConversionError) {\n throw error;\n }\n throw new ConversionError(`Partial address estimation failed: ${error instanceof Error ? error.message : String(error)}`);\n }\n }\n\n /**\n * Test round-trip conversion accuracy\n */\n testRoundTrip(latitude: number, longitude: number): RoundTripResult {\n try {\n const syllableAddress = this.coordinateToAddress(latitude, longitude);\n const [resultLat, resultLon] = this.addressToCoordinate(syllableAddress);\n\n // Calculate precision\n const latDiff = Math.abs(resultLat - latitude);\n const lonDiff = Math.abs(resultLon - longitude);\n\n const latRad = (latitude * Math.PI) / 180;\n const metersPerDegreeLat = 111320;\n const metersPerDegreeLon = 111320 * Math.cos(latRad);\n\n const distanceErrorM = Math.sqrt(\n (latDiff * metersPerDegreeLat) ** 2 + \n (lonDiff * metersPerDegreeLon) ** 2\n );\n\n return {\n success: true,\n originalCoordinates: [latitude, longitude],\n syllableAddress,\n resultCoordinates: [resultLat, resultLon],\n distanceErrorMeters: distanceErrorM,\n precise: distanceErrorM < 1.0\n };\n } catch (error) {\n throw new ConversionError(`Round-trip test failed: ${error}`);\n }\n }\n\n /**\n * Get system information and statistics\n */\n getSystemInfo(): SystemInfo {\n const totalSyllables = this.totalSyllables;\n const addressSpace = totalSyllables ** this.config.address_length;\n const h3Target = 122 * (7 ** 15); // H3 Level 15 cells: 122 base cells × 7^15 hierarchical positions\n\n return {\n h3Resolution: this.config.h3_resolution,\n totalH3Cells: h3Target,\n consonants: [...this.config.consonants],\n vowels: [...this.config.vowels],\n totalSyllables,\n addressLength: this.config.address_length,\n addressSpace,\n coveragePercentage: (addressSpace / h3Target) * 100,\n precisionMeters: 0.5\n };\n }\n\n /**\n * Clear internal cache (no-op - cache removed for performance)\n */\n clearCache(): void {\n // No-op - cache has been removed as it's not beneficial for this use case\n }\n\n /**\n * Get current configuration\n */\n getConfig(): SyllableConfig {\n return { ...this.config };\n }\n\n /**\n * Get current configuration name\n */\n getConfigName(): string {\n return this.configName;\n }\n\n /**\n * Create H3 system from a list of letters\n */\n static fromLetters(_letters: string[]): H3SyllableSystem {\n // For now, use default config since we don't have dynamic config generation\n // This would need to be implemented with the config generation system\n throw new Error('Dynamic config generation not implemented. Use existing configurations.');\n }\n\n /**\n * Create H3 system with language-optimized configuration\n */\n static suggestForLanguage(language: string = 'international', _precisionMeters: number = 0.5): H3SyllableSystem {\n // Select configuration based on language preference\n // Note: All are ASCII character sets, optimized for different use cases\n let configName: string;\n \n switch (language) {\n case 'english':\n configName = 'ascii-jaxqt'; // Common typing letters\n break;\n case 'spanish':\n configName = 'ascii-fqsmnn'; // No Q confusion\n break;\n case 'japanese':\n configName = 'ascii-fqwclj'; // No L (avoid L/R confusion)\n break;\n default:\n configName = 'ascii-elomr'; // Default to international config (11 consonants, 5 vowels, 8 syllables)\n }\n \n return new H3SyllableSystem(configName);\n }\n\n /**\n * Validate coordinates are within valid ranges\n */\n private validateCoordinates(latitude: number, longitude: number): void {\n // Check for invalid numbers\n if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {\n throw new Error(`Invalid coordinate values: latitude=${latitude}, longitude=${longitude}`);\n }\n \n if (latitude < -90 || latitude > 90) {\n throw new Error(`Latitude must be between -90 and 90, got ${latitude}`);\n }\n if (longitude < -180 || longitude > 180) {\n throw new Error(`Longitude must be between -180 and 180, got ${longitude}`);\n }\n }\n\n /**\n * Convert H3 Cell ID to Hierarchical Array\n */\n private h3CellIdToHierarchicalArray(h3CellId: string): number[] {\n const hierarchicalArray = new Array(16).fill(-1);\n \n // Get the complete parent chain from target resolution to base\n let current = h3CellId;\n const parentChain = [current];\n \n // Walk up the hierarchy to get all ancestors\n for (let res = this.config.h3_resolution - 1; res >= 0; res--) {\n const parent = cellToParent(current, res);\n parentChain.push(parent);\n current = parent;\n }\n \n // Extract base cell number\n const baseCell = getBaseCellNumber(parentChain[parentChain.length - 1]);\n hierarchicalArray[0] = baseCell;\n \n // Extract child positions at each resolution level\n for (let res = 1; res <= this.config.h3_resolution; res++) {\n const parent = parentChain[parentChain.length - res];\n const child = parentChain[parentChain.length - res - 1];\n \n const children = cellToChildren(parent, res);\n const childPosition = children.indexOf(child);\n \n if (childPosition === -1) {\n throw new Error(`Could not find child position for resolution ${res}`);\n }\n \n hierarchicalArray[res] = childPosition;\n }\n \n return hierarchicalArray;\n }\n\n /**\n * Convert Hierarchical Array to H3 Cell ID\n */\n private hierarchicalArrayToH3CellId(hierarchicalArray: number[]): string {\n const baseCellNumber = hierarchicalArray[0];\n \n // Pre-computed mapping of base cell numbers to H3 indices\n const BASE_CELL_H3_INDICES: Record<number, string> = {\n 0: \"8001fffffffffff\", 1: \"8003fffffffffff\", 2: \"8005fffffffffff\", 3: \"8007fffffffffff\",\n 4: \"8009fffffffffff\", 5: \"800bfffffffffff\", 6: \"800dfffffffffff\", 7: \"800ffffffffffff\",\n 8: \"8011fffffffffff\", 9: \"8013fffffffffff\", 10: \"8015fffffffffff\", 11: \"8017fffffffffff\",\n 12: \"8019fffffffffff\", 13: \"801bfffffffffff\", 14: \"801dfffffffffff\", 15: \"801ffffffffffff\",\n 16: \"8021fffffffffff\", 17: \"8023fffffffffff\", 18: \"8025fffffffffff\", 19: \"8027fffffffffff\",\n 20: \"8029fffffffffff\", 21: \"802bfffffffffff\", 22: \"802dfffffffffff\", 23: \"802ffffffffffff\",\n 24: \"8031fffffffffff\", 25: \"8033fffffffffff\", 26: \"8035fffffffffff\", 27: \"8037fffffffffff\",\n 28: \"8039fffffffffff\", 29: \"803bfffffffffff\", 30: \"803dfffffffffff\", 31: \"803ffffffffffff\",\n 32: \"8041fffffffffff\", 33: \"8043fffffffffff\", 34: \"8045fffffffffff\", 35: \"8047fffffffffff\",\n 36: \"8049fffffffffff\", 37: \"804bfffffffffff\", 38: \"804dfffffffffff\", 39: \"804ffffffffffff\",\n 40: \"8051fffffffffff\", 41: \"8053fffffffffff\", 42: \"8055fffffffffff\", 43: \"8057fffffffffff\",\n 44: \"8059fffffffffff\", 45: \"805bfffffffffff\", 46: \"805dfffffffffff\", 47: \"805ffffffffffff\",\n 48: \"8061fffffffffff\", 49: \"8063fffffffffff\", 50: \"8065fffffffffff\", 51: \"8067fffffffffff\",\n 52: \"8069fffffffffff\", 53: \"806bfffffffffff\", 54: \"806dfffffffffff\", 55: \"806ffffffffffff\",\n 56: \"8071fffffffffff\", 57: \"8073fffffffffff\", 58: \"8075fffffffffff\", 59: \"8077fffffffffff\",\n 60: \"8079fffffffffff\", 61: \"807bfffffffffff\", 62: \"807dfffffffffff\", 63: \"807ffffffffffff\",\n 64: \"8081fffffffffff\", 65: \"8083fffffffffff\", 66: \"8085fffffffffff\", 67: \"8087fffffffffff\",\n 68: \"8089fffffffffff\", 69: \"808bfffffffffff\", 70: \"808dfffffffffff\", 71: \"808ffffffffffff\",\n 72: \"8091fffffffffff\", 73: \"8093fffffffffff\", 74: \"8095fffffffffff\", 75: \"8097fffffffffff\",\n 76: \"8099fffffffffff\", 77: \"809bfffffffffff\", 78: \"809dfffffffffff\", 79: \"809ffffffffffff\",\n 80: \"80a1fffffffffff\", 81: \"80a3fffffffffff\", 82: \"80a5fffffffffff\", 83: \"80a7fffffffffff\",\n 84: \"80a9fffffffffff\", 85: \"80abfffffffffff\", 86: \"80adfffffffffff\", 87: \"80affffffffffff\",\n 88: \"80b1fffffffffff\", 89: \"80b3fffffffffff\", 90: \"80b5fffffffffff\", 91: \"80b7fffffffffff\",\n 92: \"80b9fffffffffff\", 93: \"80bbfffffffffff\", 94: \"80bdfffffffffff\", 95: \"80bffffffffffff\",\n 96: \"80c1fffffffffff\", 97: \"80c3fffffffffff\", 98: \"80c5fffffffffff\", 99: \"80c7fffffffffff\",\n 100: \"80c9fffffffffff\", 101: \"80cbfffffffffff\", 102: \"80cdfffffffffff\", 103: \"80cffffffffffff\",\n 104: \"80d1fffffffffff\", 105: \"80d3fffffffffff\", 106: \"80d5fffffffffff\", 107: \"80d7fffffffffff\",\n 108: \"80d9fffffffffff\", 109: \"80dbfffffffffff\", 110: \"80ddfffffffffff\", 111: \"80dffffffffffff\",\n 112: \"80e1fffffffffff\", 113: \"80e3fffffffffff\", 114: \"80e5fffffffffff\", 115: \"80e7fffffffffff\",\n 116: \"80e9fffffffffff\", 117: \"80ebfffffffffff\", 118: \"80edfffffffffff\", 119: \"80effffffffffff\",\n 120: \"80f1fffffffffff\", 121: \"80f3fffffffffff\"\n };\n \n const baseCell = BASE_CELL_H3_INDICES[baseCellNumber];\n if (!baseCell) {\n throw new Error(`Invalid base cell number: ${baseCellNumber}. Must be 0-121.`);\n }\n \n let currentH3 = baseCell;\n \n // Navigate through child positions\n for (let res = 1; res <= this.config.h3_resolution; res++) {\n const childPosition = hierarchicalArray[res];\n const children = cellToChildren(currentH3, res);\n \n if (childPosition >= children.length) {\n throw new Error(`Child position ${childPosition} out of range for resolution ${res}`);\n }\n \n currentH3 = children[childPosition];\n }\n \n return currentH3;\n }\n\n /**\n * Convert Hierarchical Array to Integer Index using mixed-radix encoding\n */\n private hierarchicalArrayToIntegerIndex(hierarchicalArray: number[]): number {\n let result = 0;\n let multiplier = 1;\n \n // Process from fine to coarse (right to left, least significant first)\n for (let pos = this.config.h3_resolution; pos >= 1; pos--) {\n const childPos = hierarchicalArray[pos];\n if (childPos !== -1) {\n result += childPos * multiplier;\n multiplier *= 7; // 7 possible child positions\n } else {\n multiplier *= 7;\n }\n }\n \n // Apply Hamiltonian path ordering to base cell (most significant)\n const originalBaseCell = hierarchicalArray[0];\n const hamiltonianBaseCell = this.hamiltonianPath[originalBaseCell];\n result += hamiltonianBaseCell * multiplier;\n \n return result;\n }\n\n /**\n * Convert Integer Index to Hierarchical Array\n */\n private integerIndexToHierarchicalArray(integerIndex: number): number[] {\n const hierarchicalArray = new Array(16).fill(-1);\n let remaining = integerIndex;\n \n // Calculate base multiplier\n const baseMultiplier = 7 ** this.config.h3_resolution;\n \n // Extract Hamiltonian base cell and convert back to original\n const hamiltonianBaseCell = Math.floor(remaining / baseMultiplier);\n const originalBaseCell = this.hamiltonianPath.indexOf(hamiltonianBaseCell);\n hierarchicalArray[0] = originalBaseCell;\n remaining = remaining % baseMultiplier;\n \n // Extract child positions from fine to coarse (right to left)\n for (let pos = this.config.h3_resolution; pos >= 1; pos--) {\n const childPos = remaining % 7;\n hierarchicalArray[pos] = childPos;\n remaining = Math.floor(remaining / 7);\n }\n \n return hierarchicalArray;\n }\n\n /**\n * Convert Integer Index to Syllable Address\n * Orders syllables from coarse to fine geography (most significant first)\n */\n private integerIndexToSyllableAddress(integerIndex: number): string {\n const totalSyllables = this.totalSyllables;\n const addressSpace = totalSyllables ** this.config.address_length;\n \n if (integerIndex < 0 || integerIndex >= addressSpace) {\n throw new Error(`Integer Index ${integerIndex} out of range [0, ${addressSpace})`);\n }\n \n const syllables: string[] = [];\n let remaining = integerIndex;\n \n // Base conversion with geographic ordering (most significant first)\n for (let pos = 0; pos < this.config.address_length; pos++) {\n const syllableIdx = remaining % totalSyllables;\n const syllable = this.indexToSyllable.get(syllableIdx);\n if (!syllable) {\n throw new Error(`Invalid syllable index: ${syllableIdx}`);\n }\n // Add to front so coarse geography appears first\n syllables.unshift(syllable);\n remaining = Math.floor(remaining / totalSyllables);\n }\n \n return this.formatSyllableAddress(syllables);\n }\n\n /**\n * Format syllable address as concatenated string\n */\n private formatSyllableAddress(syllables: string[]): string {\n return syllables.join('');\n }\n\n /**\n * Convert Syllable Address to Integer Index\n * Processes syllables from coarse to fine geography (most significant first)\n */\n private syllableAddressToIntegerIndex(syllableAddress: string): number {\n const cleanAddress = syllableAddress.toLowerCase();\n \n // Parse 2-character syllables from concatenated string\n const syllables: string[] = [];\n for (let i = 0; i < cleanAddress.length; i += 2) {\n syllables.push(cleanAddress.substring(i, i + 2));\n }\n \n if (syllables.length !== this.config.address_length) {\n throw new Error(`Address must have ${this.config.address_length} syllables`);\n }\n \n const totalSyllables = this.totalSyllables;\n let integerValue = 0;\n \n // Process syllables from right to left (fine to coarse) to match the reversed ordering\n for (let pos = 0; pos < syllables.length; pos++) {\n const syllable = syllables[syllables.length - 1 - pos]; // Process from right to left\n const syllableIndex = this.syllableToIndex.get(syllable);\n if (syllableIndex === undefined) {\n throw new Error(`Unknown syllable: ${syllable}`);\n }\n \n // Use the same base conversion logic as forward direction\n integerValue += syllableIndex * (totalSyllables ** pos);\n }\n \n return integerValue;\n }\n\n /**\n * Parse partial address into syllables array\n */\n private parsePartialAddress(partialAddress: string): { completeSyllables: string[]; partialConsonant?: string } {\n if (!partialAddress || partialAddress.trim() === '') {\n throw new ConversionError('Partial address cannot be empty');\n }\n\n const cleanAddress = partialAddress.toLowerCase().trim();\n \n // Parse 2-character syllables from concatenated string\n const syllables: string[] = [];\n for (let i = 0; i < cleanAddress.length; i += 2) {\n syllables.push(cleanAddress.substring(i, i + 2));\n }\n \n if (syllables.length === 0) {\n throw new ConversionError('No valid syllables found in partial address');\n }\n\n // Check if we have a partial syllable (single character at the end)\n let partialConsonant: string | undefined;\n let completeSyllables = syllables;\n \n const lastSyllable = syllables[syllables.length - 1];\n if (lastSyllable.length === 1) {\n // We have a partial syllable - validate it's a consonant\n if (!this.config.consonants.includes(lastSyllable)) {\n throw new ConversionError(`Invalid partial consonant: ${lastSyllable}. Must be one of: ${this.config.consonants.join(', ')}`);\n }\n partialConsonant = lastSyllable;\n completeSyllables = syllables.slice(0, -1); // Remove the partial syllable from complete ones\n \n // Special case: if only a single consonant was provided with no complete syllables\n if (completeSyllables.length === 0) {\n throw new ConversionError(`Partial address must contain at least one complete syllable. '${partialAddress}' only contains a partial consonant.`);\n }\n }\n\n if (completeSyllables.length + (partialConsonant ? 1 : 0) >= this.config.address_length) {\n throw new ConversionError(`Partial address cannot have ${completeSyllables.length + (partialConsonant ? 1 : 0)} or more syllables (max: ${this.config.address_length - 1})`);\n }\n\n // Validate each complete syllable\n for (const syllable of completeSyllables) {\n if (!this.syllableToIndex.has(syllable)) {\n throw new ConversionError(`Invalid syllable: ${syllable}`);\n }\n }\n\n return { completeSyllables, partialConsonant };\n }\n\n /**\n * Calculate the range of complete addresses for a partial address\n */\n private calculateAddressRange(parsed: { completeSyllables: string[]; partialConsonant?: string }): { minAddress: string; maxAddress: string } {\n const totalSyllables = parsed.completeSyllables.length + (parsed.partialConsonant ? 1 : 0);\n const remainingSyllables = this.config.address_length - totalSyllables;\n \n if (remainingSyllables < 0) {\n throw new ConversionError('Partial address is already complete or too long');\n }\n\n // Get min and max syllables for padding\n const { minSyllable, maxSyllable } = this.getMinMaxSyllables();\n \n let minSyllables: string[];\n let maxSyllables: string[];\n \n if (parsed.partialConsonant) {\n // Handle partial consonant: create range from consonant+firstVowel to consonant+lastVowel\n const firstVowel = this.config.vowels[0]; // 'a'\n const lastVowel = this.config.vowels[this.config.vowels.length - 1]; // 'u'\n \n const minPartialSyllable = parsed.partialConsonant + firstVowel;\n const maxPartialSyllable = parsed.partialConsonant + lastVowel;\n \n // Create min address: complete syllables + min partial syllable + padding\n minSyllables = [...parsed.completeSyllables, minPartialSyllable];\n for (let i = 0; i < remainingSyllables; i++) {\n minSyllables.push(minSyllable);\n }\n \n // Create max address: complete syllables + max partial syllable + padding\n maxSyllables = [...parsed.completeSyllables, maxPartialSyllable];\n for (let i = 0; i < remainingSyllables; i++) {\n maxSyllables.push(maxSyllable);\n }\n } else {\n // No partial consonant, handle normally\n minSyllables = [...parsed.completeSyllables];\n for (let i = 0; i < remainingSyllables; i++) {\n minSyllables.push(minSyllable);\n }\n \n maxSyllables = [...parsed.completeSyllables];\n for (let i = 0; i < remainingSyllables; i++) {\n maxSyllables.push(maxSyllable);\n }\n }\n \n return {\n minAddress: this.formatSyllableAddress(minSyllables),\n maxAddress: this.formatSyllableAddress(maxSyllables)\n };\n }\n\n /**\n * Get the minimum and maximum syllables for the current config\n */\n private getMinMaxSyllables(): { minSyllable: string; maxSyllable: string } {\n const syllables = Array.from(this.syllableToIndex.keys()).sort();\n return {\n minSyllable: syllables[0],\n maxSyllable: syllables[syllables.length - 1]\n };\n }\n\n /**\n * Calculate geographic bounds from min and max coordinates\n */\n private calculateGeographicBounds(minCoords: Coordinates, maxCoords: Coordinates): GeographicBounds {\n const [minLat, minLon] = minCoords;\n const [maxLat, maxLon] = maxCoords;\n \n return {\n north: Math.max(minLat, maxLat),\n south: Math.min(minLat, maxLat),\n east: Math.max(minLon, maxLon),\n west: Math.min(minLon, maxLon)\n };\n }\n\n /**\n * Calculate center point from min and max coordinates\n */\n private calculateCenter(minCoords: Coordinates, maxCoords: Coordinates): Coordinates {\n const [minLat, minLon] = minCoords;\n const [maxLat, maxLon] = maxCoords;\n \n return [\n (minLat + maxLat) / 2,\n (minLon + maxLon) / 2\n ];\n }\n\n /**\n * Calculate area in square kilometers from geographic bounds\n */\n private calculateAreaKm2(bounds: GeographicBounds): number {\n const latDiff = bounds.north - bounds.south;\n const lonDiff = bounds.east - bounds.west;\n \n // Convert to approximate distance in kilometers\n const avgLat = (bounds.north + bounds.south) / 2;\n const latKm = latDiff * 111.32; // ~111.32 km per degree latitude\n const lonKm = lonDiff * 111.32 * Math.cos(avgLat * Math.PI / 180); // Adjust for longitude at this latitude\n \n return latKm * lonKm;\n }\n\n /**\n * Calculate confidence score based on completeness level\n */\n private calculateConfidence(parsed: { completeSyllables: string[]; partialConsonant?: string }): number {\n // Calculate effective completeness level\n // Complete syllables count as 1.0, partial consonants as 0.5\n const completenessLevel = parsed.completeSyllables.length + (parsed.partialConsonant ? 0.5 : 0);\n \n // Higher completeness = higher confidence\n // Scale from 0.1 (1 syllable) to 0.95 (7 syllables for 8-syllable addresses)\n const maxLevel = this.config.address_length - 1;\n const confidence = 0.1 + (completenessLevel / maxLevel) * 0.85;\n return Math.min(0.95, Math.max(0.1, confidence));\n }\n\n /**\n * Get suggested refinements (next possible syllables or vowels)\n */\n private getSuggestedRefinements(parsed: { completeSyllables: string[]; partialConsonant?: string }): string[] {\n const totalSyllables = parsed.completeSyllables.length + (parsed.partialConsonant ? 1 : 0);\n \n if (totalSyllables >= this.config.address_length - 1) {\n return []; // Already almost complete, no meaningful refinements\n }\n \n if (parsed.partialConsonant) {\n // For partial consonants, suggest possible vowels to complete the syllable\n return this.config.vowels.map(vowel => parsed.partialConsonant + vowel).sort();\n } else {\n // For complete syllables, suggest all available syllables as potential next options\n return Array.from(this.syllableToIndex.keys()).sort();\n }\n }\n\n /**\n * Find valid address range with smart fallback when min/max addresses are invalid\n */\n private findValidAddressRange(minAddress: string, maxAddress: string, partialSyllables: string[]): { minAddress: string; maxAddress: string } {\n // First, try the exact range\n const minValid = this.isValidAddress(minAddress);\n const maxValid = this.isValidAddress(maxAddress);\n \n if (minValid && maxValid) {\n // Perfect! Both addresses are valid\n return { minAddress, maxAddress };\n }\n \n // If either is invalid, try limited search (10 attempts max to avoid infinite loops)\n const maxAttempts = 10;\n let validMinAddress = minAddress;\n let validMaxAddress = maxAddress;\n \n if (!minValid) {\n let attempts = 0;\n while (!this.isValidAddress(validMinAddress) && attempts < maxAttempts) {\n validMinAddress = this.incrementAddress(validMinAddress, partialSyllables);\n attempts++;\n }\n }\n \n if (!maxValid) {\n let attempts = 0;\n while (!this.isValidAddress(validMaxAddress) && attempts < maxAttempts) {\n validMaxAddress = this.decrementAddress(validMaxAddress, partialSyllables);\n attempts++;\n }\n }\n \n // Check if we found valid addresses\n if (this.isValidAddress(validMinAddress) && this.isValidAddress(validMaxAddress)) {\n return { minAddress: validMinAddress, maxAddress: validMaxAddress };\n }\n \n // If still no luck, try fallback to shorter prefix\n if (partialSyllables.length > 1) {\n console.warn(`Address range for '${partialSyllables.join('')}' is unmappable, falling back to shorter prefix`);\n const shorterPartial = partialSyllables.slice(0, -1);\n const fallbackRange = this.calculateAddressRange({ completeSyllables: shorterPartial });\n return this.findValidAddressRange(fallbackRange.minAddress, fallbackRange.maxAddress, shorterPartial);\n }\n \n // Last resort: throw error with helpful message\n throw new ConversionError(\n `The partial address '${partialSyllables.join('')}' maps to an unmappable region of the H3 address space. ` +\n `This occurs when syllable combinations don't correspond to valid geographic locations. ` +\n `Try a different partial address or use a shorter prefix.`\n );\n }\n\n /**\n * Increment address intelligently from left to right with carry-over\n */\n private incrementAddress(address: string, partialSyllables: string[]): string {\n const cleanAddress = address.toLowerCase();\n \n // Parse 2-character syllables from concatenated string\n const syllables: string[] = [];\n for (let i = 0; i < cleanAddress.length; i += 2) {\n syllables.push(cleanAddress.substring(i, i + 2));\n }\n \n const allSyllables = Array.from(this.syllableToIndex.keys()).sort();\n const partialLength = partialSyllables.length;\n \n // Start incrementing from the first syllable after the partial prefix\n for (let i = partialLength; i < syllables.length; i++) {\n const currentSyllable = syllables[i];\n const currentIndex = allSyllables.indexOf(currentSyllable);\n \n if (currentIndex < allSyllables.length - 1) {\n // Can increment this syllable\n syllables[i] = allSyllables[currentIndex + 1];\n // Reset all syllables after this one to min values\n for (let j = i + 1; j < syllables.length; j++) {\n syllables[j] = allSyllables[0];\n }\n break;\n } else {\n // This syllable is at max, continue to next position\n syllables[i] = allSyllables[0];\n }\n }\n \n return this