All files / core/systems/plusCode plusCode.ts

97.72% Statements 86/88
92% Branches 23/25
100% Functions 11/11
98.76% Lines 80/81

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164                  15x 15x 15x 15x 15x 15x 15x 15x 15x         15x 2091x                         135x 134x   1x         2091x 2091x                   15x 15x           2091x 2091x 2091x 2091x 10427x 10427x 10427x 10427x 10427x 10427x 10427x 10427x   2091x           2076x 2076x 2076x 2076x 2076x 2076x 8x 8x 8x 8x 8x 8x 8x   2076x         15x         2076x   2076x   2076x         2080x 2080x           1007x 1007x 1007x 1007x 1007x 1007x 5029x 5029x 5029x 5029x 5029x   1007x 1x 1x 1x 1x 1x   1007x       15x       8032x 8032x 8032x 2013x 2013x 2012x 8032x         1007x 1007x 1007x    
/**
 * Open Location Code (Plus Codes), Google.
 *
 * Five digit pairs (latitude then longitude) at 20°, 1°, 0.05°, 0.0025° and 0.000125°, written
 * with a "+" after the eighth digit, then optional grid digits that split a cell 4 × 5.
 * Only full codes are read: a short code needs a reference location.
 */
import type { LatLon } from '../../geo/ellipsoid'
 
const ALPHABET = '23456789CFGHJMPQRVWX'
const BASE = 20
export const PAIR_RESOLUTIONS = [20, 1, 0.05, 0.0025, 0.000125]
const PAIR_LENGTH = 10
const SEPARATOR_POSITION = 8
const GRID_ROWS = 5
const GRID_COLUMNS = 4
const MAX_LATITUDE = 90
const MAX_LONGITUDE = 180
 
// NOTE: in-range longitudes pass through untouched. The modulo form rounds 179.99999999999994
// up to 180 and wraps it to -180. Unlike geo's wrapLon, 180 itself becomes -180, as in
// Google's reference implementation.
const wrapLon = (lon: number) =>
  lon >= -MAX_LONGITUDE && lon < MAX_LONGITUDE
    ? lon
    : ((((lon + MAX_LONGITUDE) % 360) + 360) % 360) - MAX_LONGITUDE
 
export interface PlusCodeCell {
  west: number
  south: number
  east: number
  north: number
}
 
/** The height of a code's cell, which is how far latitude 90 moves down so it can be decoded. */
function latitudePrecision(length: number): number {
  if (length <= PAIR_LENGTH) {
    return BASE ** Math.floor(length / -2 + 2)
  }
  return BASE ** -3 / GRID_ROWS ** (length - PAIR_LENGTH)
}
 
/** Clip the latitude, wrap the longitude, and move the north pole just inside the top cell. */
function normalise({ lat, lon }: LatLon, length: number): LatLon {
  const clippedLat = Math.min(Math.max(lat, -MAX_LATITUDE), MAX_LATITUDE)
  return {
    lat:
      clippedLat === MAX_LATITUDE
        ? clippedLat - latitudePrecision(length)
        : clippedLat,
    lon: wrapLon(lon),
  }
}
 
/** The first pair covers 180° of latitude and 360° of longitude in 20° steps. */
const FIRST_PAIR_LAT_DIGITS = 9
const FIRST_PAIR_LON_DIGITS = 18
 
// NOTE: near 180° longitude, the division below can round 17.999... up to 18, which is not a
// valid first digit. The npm open-location-code 1.0.3 package has the same bug; the cap keeps
// the point in the easternmost cell.
function encodePairs({ lat, lon }: LatLon, pairCount: number): string {
  let latValue = lat + MAX_LATITUDE
  let lonValue = lon + MAX_LONGITUDE
  let digits = ''
  for (let pair = 0; pair < pairCount; pair++) {
    const resolution = PAIR_RESOLUTIONS[pair]!
    const latLimit = pair === 0 ? FIRST_PAIR_LAT_DIGITS : BASE
    const lonLimit = pair === 0 ? FIRST_PAIR_LON_DIGITS : BASE
    const latIndex = Math.min(latLimit - 1, Math.floor(latValue / resolution))
    const lonIndex = Math.min(lonLimit - 1, Math.floor(lonValue / resolution))
    digits += ALPHABET.charAt(latIndex) + ALPHABET.charAt(lonIndex)
    latValue -= latIndex * resolution
    lonValue -= lonIndex * resolution
  }
  return digits
}
 
// NOTE: this follows Google's reference implementation step for step, including taking the
// remainder of the whole coordinate, so the grid digit matches it at cell edges.
function encodeGrid({ lat, lon }: LatLon, gridDigits: number): string {
  let latPlace = PAIR_RESOLUTIONS[PAIR_RESOLUTIONS.length - 1]!
  let lonPlace = latPlace
  let latValue = (lat + MAX_LATITUDE) % latPlace
  let lonValue = (lon + MAX_LONGITUDE) % lonPlace
  let digits = ''
  for (let index = 0; index < gridDigits; index++) {
    const row = Math.floor(latValue / (latPlace / GRID_ROWS))
    const column = Math.floor(lonValue / (lonPlace / GRID_COLUMNS))
    latPlace /= GRID_ROWS
    lonPlace /= GRID_COLUMNS
    latValue -= row * latPlace
    lonValue -= column * lonPlace
    digits += ALPHABET.charAt(row * GRID_COLUMNS + column)
  }
  return digits
}
 
/** The digit pairs (no separator, no padding) for a point, to `pairCount` pairs. */
export function pairDigits(point: LatLon, pairCount: number): string {
  return encodePairs(normalise(point, pairCount * 2), pairCount)
}
 
/** A full Plus Code: 10 digits, or more with grid refinement digits. */
export function toPlusCode(point: LatLon, length = PAIR_LENGTH): string {
  const normalised = normalise(point, length)
  const digits =
    encodePairs(normalised, PAIR_LENGTH / 2) +
    encodeGrid(normalised, Math.max(0, length - PAIR_LENGTH))
  return withSeparator(digits)
}
 
/** Put the "+" after the eighth digit, padding a shorter code with zeros. */
export function withSeparator(digits: string): string {
  Eif (digits.length >= SEPARATOR_POSITION) {
    return `${digits.slice(0, SEPARATOR_POSITION)}+${digits.slice(SEPARATOR_POSITION)}`
  }
  return `${digits.padEnd(SEPARATOR_POSITION, '0')}+`
}
 
function fromDigits(digits: string): PlusCodeCell {
  let south = -MAX_LATITUDE
  let west = -MAX_LONGITUDE
  let height = 0
  let width = 0
  const pairs = Math.min(digits.length, PAIR_LENGTH)
  for (let index = 0; index < pairs; index += 2) {
    const resolution = PAIR_RESOLUTIONS[index / 2]!
    south += ALPHABET.indexOf(digits.charAt(index)) * resolution
    west += ALPHABET.indexOf(digits.charAt(index + 1)) * resolution
    height = resolution
    width = resolution
  }
  for (let index = PAIR_LENGTH; index < digits.length; index++) {
    const value = ALPHABET.indexOf(digits.charAt(index))
    height /= GRID_ROWS
    width /= GRID_COLUMNS
    south += Math.floor(value / GRID_COLUMNS) * height
    west += (value % GRID_COLUMNS) * width
  }
  return { west, south, east: west + width, north: south + height }
}
 
const FULL_CODE_RE =
  /^([2-9C][2-9CFGHJMPQRV])([2-9CFGHJMPQRVWX]{6}|[2-9CFGHJMPQRVWX]{4}00|[2-9CFGHJMPQRVWX]{2}0000|000000)\+([2-9CFGHJMPQRVWX]{2,7})?$/
 
/** Whether text is a full Plus Code, with or without padding. */
export function isFullPlusCode(text: string): boolean {
  const code = text.toUpperCase()
  const match = FULL_CODE_RE.exec(code)
  if (!match) return false
  const isPadded = code.includes('0')
  if (isPadded && match[3] !== undefined) return false
  const tail = match[3] ?? ''
  return tail.length !== 1
}
 
/** The cell a full Plus Code names. */
export function fromPlusCode(text: string): PlusCodeCell {
  Iif (!isFullPlusCode(text)) throw new Error(`Invalid Plus Code "${text}"`)
  const digits = text.toUpperCase().replace('+', '').replace(/0+$/, '')
  return fromDigits(digits)
}