All files / core/systems/angles angles.ts

100% Statements 79/79
96.38% Branches 80/83
100% Functions 10/10
100% Lines 69/69

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              4118x 4118x       1x       4122x 4122x 4122x 4122x 4122x 329x 329x   4122x 205x 205x   4122x                 8386x 8386x 4211x               4122x 4122x 4122x 4122x 4122x       3x               4120x 4120x 4120x 4120x 189x 189x   4120x 4120x 4120x       2x               16x             6156x 6156x 6156x 6156x 6156x 4138x 4138x   6156x 6066x 6156x 6063x 525x 525x   6063x 6063x   6062x 6156x       5026x         5026x     5026x 2069x 2069x 2957x 1008x 1008x   1949x 1949x   5026x 3078x 3078x 3078x 3015x 3015x 3014x 3014x    
/** Latitude and longitude as decimal degrees, degrees-minutes-seconds and degrees-decimal-minutes. */
import { type LatLon, isValidLatLon } from '../../geo/ellipsoid'
 
export type AngleKind = 'DD' | 'DMS' | 'DDM'
 
/** A value to fixed decimals, without the minus sign when it rounds to zero ("-0.000000"). */
export function toFixedUnsignedZero(value: number, digits: number): string {
  const rounded = value.toFixed(digits)
  return Number(rounded) === 0 ? (0).toFixed(digits) : rounded
}
 
export function formatDD({ lat, lon }: LatLon, digits = 6): string {
  return `${toFixedUnsignedZero(lat, digits)}, ${toFixedUnsignedZero(lon, digits)}`
}
 
function splitDMS(value: number, secDigits: number): [number, number, number] {
  const abs = Math.abs(value)
  let d = Math.floor(abs)
  let m = Math.floor((abs - d) * 60)
  let s = Number(((abs - d - m / 60) * 3600).toFixed(secDigits))
  if (s >= 60) {
    s = 0
    m += 1
  }
  if (m >= 60) {
    m = 0
    d += 1
  }
  return [d, m, s]
}
 
/** The hemisphere letter. A value that rounds to zero is N or E, so the text reads back the same. */
export function hemi(
  value: number,
  axis: 'lat' | 'lon',
  isZero: boolean,
): string {
  const isNegative = value < 0 && !isZero
  if (axis === 'lat') return isNegative ? 'S' : 'N'
  return isNegative ? 'W' : 'E'
}
 
export function formatDMSValue(
  value: number,
  axis: 'lat' | 'lon',
  secDigits = 2,
): string {
  const [d, m, s] = splitDMS(value, secDigits)
  const pad = axis === 'lat' ? 2 : 3
  const sStr = s.toFixed(secDigits).padStart(secDigits ? secDigits + 3 : 2, '0')
  const isZero = d === 0 && m === 0 && s === 0
  return `${String(d).padStart(pad, '0')}° ${String(m).padStart(2, '0')}′ ${sStr}″ ${hemi(value, axis, isZero)}`
}
 
export function formatDMS(p: LatLon, secDigits = 2): string {
  return `${formatDMSValue(p.lat, 'lat', secDigits)}, ${formatDMSValue(p.lon, 'lon', secDigits)}`
}
 
export function formatDDMValue(
  value: number,
  axis: 'lat' | 'lon',
  minDigits = 4,
): string {
  const abs = Math.abs(value)
  let d = Math.floor(abs)
  let m = Number(((abs - d) * 60).toFixed(minDigits))
  if (m >= 60) {
    m = 0
    d += 1
  }
  const pad = axis === 'lat' ? 2 : 3
  const isZero = d === 0 && m === 0
  return `${String(d).padStart(pad, '0')}° ${m.toFixed(minDigits).padStart(minDigits + 3, '0')}′ ${hemi(value, axis, isZero)}`
}
 
export function formatDDM(p: LatLon, minDigits = 4): string {
  return `${formatDDMValue(p.lat, 'lat', minDigits)}, ${formatDDMValue(p.lon, 'lon', minDigits)}`
}
 
export interface LatLonParse {
  kind: AngleKind
  point: LatLon
}
 
const NUM = String.raw`[-+]?\d+(?:\.\d+)?`
 
/** Parse one angle, e.g. `51°30'26.5"N`, `51 30.441 N`, `-0.1278`, `W 0 7 40`. */
function parseAngle(
  raw: string,
  axis: 'lat' | 'lon',
): { value: number; kind: AngleKind } | null {
  let s = raw.trim().toUpperCase()
  let sign = 1
  const hemiRe = axis === 'lat' ? /[NS]/ : /[EW]/
  const hm = s.match(hemiRe)
  if (hm) {
    if (hm[0] === 'S' || hm[0] === 'W') sign = -1
    s = s.replace(hemiRe, ' ')
  }
  if (/[A-Z]/.test(s)) return null
  const nums = s.match(new RegExp(NUM, 'g'))?.map(Number)
  if (!nums || nums.length === 0 || nums.length > 3) return null
  if (nums[0]! < 0) {
    sign = -sign
    nums[0] = -nums[0]!
  }
  const [d, m = 0, sec = 0] = nums
  if (m < 0 || m >= 60 || sec < 0 || sec >= 60) return null
  const kind: AngleKind =
    nums.length === 1 ? 'DD' : nums.length === 2 ? 'DDM' : 'DMS'
  return { value: sign * (d! + m / 60 + sec / 3600), kind }
}
 
export function parseLatLon(input: string): LatLonParse | null {
  const s = input.trim().toUpperCase()
  let latStr: string | undefined
  let lonStr: string | undefined
 
  // "51 30 N 0 7 W" style: split after the first hemisphere letter.
  const hemi = /^[NS]/.test(s)
    ? /^([NS][^EW]*?)\s*[,;]?\s*([EW].*)$/.exec(s)
    : /^(.*?[NS])\s*[,;]?\s*(.*[EW].*)$/.exec(s)
  if (hemi) {
    latStr = hemi[1]
    lonStr = hemi[2]
  } else if (/[,;]/.test(s)) {
    const parts = s.split(/\s*[;,]\s*(?=[-+\dNSEW])/)
    Eif (parts.length === 2) [latStr, lonStr] = parts
  } else {
    const nums = s.split(/\s+/)
    if (nums.length === 2) [latStr, lonStr] = nums
  }
  if (!latStr || !lonStr) return null
  const lat = parseAngle(latStr, 'lat')
  const lon = parseAngle(lonStr, 'lon')
  if (!lat || !lon) return null
  const point = { lat: lat.value, lon: lon.value }
  if (!isValidLatLon(point)) return null
  const rank: Record<AngleKind, number> = { DD: 0, DDM: 1, DMS: 2 }
  return { kind: rank[lat.kind] >= rank[lon.kind] ? lat.kind : lon.kind, point }
}