All files / core/systems/mgrs grid.ts

98.93% Statements 185/187
93.75% Branches 75/80
100% Functions 13/13
99.37% Lines 159/160

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 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327                                    30x 10x 4x 3x                             1016x 1016x 102524x 102524x   1016x 1660x 49800x 49800x 25854x   1660x   1016x 1016x 1016x 1016x 51064x 51064x 51064x 51064x 830x 830x 830x 830x     51064x 830x 830x 830x 830x   51064x 51064x 51064x   1016x 1016x             15x     15x       104x 104x 104x 197x 197x 197x 197x 197x 102x 102x 102x   95x 95x   2x         51x 51x                 30x 30x 30x 30x   10x 10x 10x 10x 150x                 10x           830x 755x 755x   75x 75x 150x 150x 104x 104x   104x     150x 51x                 10x 10x 12010x 11970x 11970x 32x 32x     12010x 12010x 12010x 12010x 12010x     1160x 1160x 1160x 1160x 1160x 1160x   32x 32x 32x 32x 32x 32x 32x 32x 24x   1016x 24x 408x 408x     38706x       312x     408x 608x 608x     63818x       518x         608x 2x 2x 38x 1014x 616x                   1232x 1232x 1232x                             24x 12010x 18x 18x 166x 2388x 2388x 1580x 1580x 1580x 1568x 1568x       1568x       1568x   1580x         10x         7x 7x 7x 6x 6x 6x 6x   6x 6x 6x             6x 6x 20x 20x 20x 160x           160x     5x 5x   1x      
/** MGRS grid lines and 100 km square labels, clipped to each grid zone. */
import type { LatLon } from '../../geo/ellipsoid'
import {
  type Box,
  type GridGeometry,
  type GridLabel,
  type GridPath,
  type Position,
  boxPath,
  inBox,
  intersectBoxes,
} from '../../geo/geometry'
import { GZD_CELLS, cellBox } from '../utm/gzd'
import { type Hemisphere, fromUtm, toUtm } from '../utm/utm'
import { fromMgrs, utmSquareId } from './mgrs'
 
/** MGRS grid spacing in metres for a zoom level, or 0 for none. */
export function mgrsSpacing(zoom: number): number {
  if (zoom < 5) return 0
  if (zoom < 8.5) return 100_000
  if (zoom < 11.5) return 10_000
  Eif (zoom < 14.5) return 1_000
  return 100
}
 
/**
 * Trace a straight grid line in UTM space and clip it to `clip` in geographic space.
 * Boundary crossings are found by bisection so lines end on the zone edge.
 */
function traceClipped(
  zone: number,
  hemi: Hemisphere,
  at: (t: number) => [number, number],
  steps: number,
  clip: Box,
): Position[][] {
  const runs: Position[][] = []
  const geo = (t: number) => {
    const [e, n] = at(t)
    return fromUtm(zone, hemi, e, n)
  }
  const edge = (tIn: number, tOut: number): LatLon => {
    for (let i = 0; i < 30; i++) {
      const mid = (tIn + tOut) / 2
      if (inBox(geo(mid), clip)) tIn = mid
      else tOut = mid
    }
    return geo(tIn)
  }
  let run: Position[] | null = null
  let prevT = 0
  let prevIn = false
  for (let i = 0; i <= steps; i++) {
    const t = i / steps
    const p = geo(t)
    const inside = inBox(p, clip)
    if (inside && !prevIn) {
      run = []
      Eif (i > 0) {
        const q = edge(t, prevT)
        run.push([q.lon, q.lat])
      }
    }
    if (!inside && prevIn && run) {
      const q = edge(prevT, t)
      run.push([q.lon, q.lat])
      runs.push(run)
      run = null
    }
    if (inside) run!.push([p.lon, p.lat])
    prevIn = inside
    prevT = t
  }
  Iif (run && run.length > 1) runs.push(run)
  return runs
}
 
/** Full grid lines, or only ticks at the edges of the view and crosses at the intersections. */
export type MgrsGridStyle = 'Lines' | 'Ticks'
 
/** Tick length as a fraction of the view height. */
const TICK_FRACTION = 0.02
 
/** Cross arm length as a fraction of the grid spacing. */
const CROSS_FRACTION = 0.06
 
/** The first `length` of a path from its start, measured in degrees of latitude on the ground. */
function headOf(path: ReadonlyArray<Position>, length: number): Position[] {
  const head: Position[] = [path[0]!]
  let remaining = length
  for (let i = 1; i < path.length; i++) {
    const [lonA, latA] = path[i - 1]!
    const [lonB, latB] = path[i]!
    const cosLat = Math.cos((((latA + latB) / 2) * Math.PI) / 180)
    const step = Math.hypot((lonB - lonA) * cosLat, latB - latA)
    if (step >= remaining) {
      const k = step === 0 ? 0 : remaining / step
      head.push([lonA + (lonB - lonA) * k, latA + (latB - latA) * k])
      return head
    }
    head.push([lonB, latB])
    remaining -= step
  }
  return head
}
 
/** The digits a grid value shows at the given spacing, as in a square label: 620000 at 1 km is "20". */
function gridDigits(value: number, spacing: number): string {
  const digits = Math.round(Math.log10(100_000 / spacing))
  return String(Math.floor((value % 100_000) / spacing)).padStart(digits, '0')
}
 
export function mgrsGrid(
  view: Box,
  zoom: number,
  maxLines = 600,
  style: MgrsGridStyle = 'Lines',
): GridGeometry {
  const spacing = mgrsSpacing(zoom)
  const paths: GridPath[] = []
  const labels: GridLabel[] = []
  if (!spacing) return { paths, labels }
 
  const [viewW, viewS, viewE, viewN] = view
  const tickLength = (viewN - viewS) * TICK_FRACTION
  const edgeTolerance = Math.max(viewE - viewW, viewN - viewS) * 1e-6
  const isOnEdge = ([lon, lat]: Position): boolean =>
    Math.abs(lon - viewW) < edgeTolerance ||
    Math.abs(lon - viewE) < edgeTolerance ||
    Math.abs(lat - viewS) < edgeTolerance ||
    Math.abs(lat - viewN) < edgeTolerance
 
  /**
   * Add one traced grid line. In tick style only its ends on the view edge remain, and the
   * south and west ends carry the line's digits, like the margin of a chart.
   */
  const emit = (
    path: Position[],
    width: number,
    axis: 'easting' | 'northing',
    value: number,
  ) => {
    if (style === 'Lines') {
      paths.push({ path, width })
      return
    }
    const ends = [path, [...path].reverse()]
    for (const end of ends) {
      const start = end[0]!
      if (!isOnEdge(start)) continue
      const tick = headOf(end, tickLength)
      paths.push({ path: tick, width })
      const isLabelledEdge =
        axis === 'easting'
          ? Math.abs(start[1] - viewS) < edgeTolerance
          : Math.abs(start[0] - viewW) < edgeTolerance
      if (isLabelledEdge && spacing < 100_000) {
        labels.push({
          position: tick[tick.length - 1]!,
          text: gridDigits(value, spacing),
          size: 11,
        })
      }
    }
  }
 
  let lineCount = 0
  for (const c of GZD_CELLS) {
    if (c.zone === null) continue
    const clip = intersectBoxes(view, cellBox(c))
    if (!clip) continue
    const zone = c.zone
    const hemi: Hemisphere = c.band >= 'N' ? 'N' : 'S'
 
    // UTM extent of the clipped area, from its densified outline.
    let minE = Infinity
    let maxE = -Infinity
    let minN = Infinity
    let maxN = -Infinity
    for (const [lon, lat] of boxPath(clip, (clip[2] - clip[0]) / 8)) {
      // Keep samples on the cell's side of the equator so northings share one false origin.
      const cellLat =
        hemi === 'S' ? Math.min(lat, -1e-9) : Math.min(lat, 83.9999999)
      const u = toUtm({ lat: cellLat, lon: Math.min(lon, 179.9999999) }, zone)
      minE = Math.min(minE, u.easting)
      maxE = Math.max(maxE, u.easting)
      minN = Math.min(minN, u.northing)
      maxN = Math.max(maxN, u.northing)
    }
    const pad = spacing
    const e0 = Math.floor((minE - pad) / spacing) * spacing
    const n0 = Math.floor((minN - pad) / spacing) * spacing
    const e1 = Math.ceil((maxE + pad) / spacing) * spacing
    const n1 = Math.ceil((maxN + pad) / spacing) * spacing
    const ne = (e1 - e0) / spacing
    const nn = (n1 - n0) / spacing
    if (lineCount + ne + nn > maxLines) continue
    lineCount += ne + nn
 
    const steps = (count: number) => Math.min(64, Math.max(8, count * 2))
    for (let e = e0; e <= e1; e += spacing) {
      const major = e % 100_000 === 0
      for (const path of traceClipped(
        zone,
        hemi,
        t => [e, n0 + (n1 - n0) * t],
        steps(nn),
        clip,
      )) {
        emit(path, major ? 1.5 : 0.75, 'easting', e)
      }
    }
    for (let n = n0; n <= n1; n += spacing) {
      const major = n % 100_000 === 0
      for (const path of traceClipped(
        zone,
        hemi,
        t => [e0 + (e1 - e0) * t, n],
        steps(ne),
        clip,
      )) {
        emit(path, major ? 1.5 : 0.75, 'northing', n)
      }
    }
 
    // Tick style marks each grid intersection in view with a small cross.
    if (style === 'Ticks' && ne * nn < 1000) {
      const arm = spacing * CROSS_FRACTION
      for (let e = e0; e <= e1; e += spacing) {
        for (let n = n0; n <= n1; n += spacing) {
          if (!inBox(fromUtm(zone, hemi, e, n), clip)) continue
          for (const [[ea, na], [eb, nb]] of [
            [
              [e - arm, n],
              [e + arm, n],
            ],
            [
              [e, n - arm],
              [e, n + arm],
            ],
          ] as const) {
            const a = fromUtm(zone, hemi, ea, na)
            const b = fromUtm(zone, hemi, eb, nb)
            paths.push({
              path: [
                [a.lon, a.lat],
                [b.lon, b.lat],
              ],
              width: 1,
            })
          }
        }
      }
    }
 
    // Label 100 km squares at their centres, and finer squares with their digits. Tick style
    // labels finer squares on the edges instead.
    const isSquareLabelled =
      spacing === 100_000 || (style === 'Lines' && ne * nn < 1000)
    if (isSquareLabelled) {
      const size = spacing
      for (let e = e0; e < e1; e += size) {
        for (let n = n0; n < n1; n += size) {
          const p = fromUtm(zone, hemi, e + size / 2, n + size / 2)
          if (!inBox(p, clip)) continue
          const sq = utmSquareId(zone, e, n)
          let text = sq
          if (size < 100_000) {
            const digits = Math.round(Math.log10(100_000 / size))
            const de = String(Math.floor((e % 100_000) / size)).padStart(
              digits,
              '0',
            )
            const dn = String(Math.floor((n % 100_000) / size)).padStart(
              digits,
              '0',
            )
            text = `${sq} ${de} ${dn}`
          }
          labels.push({ position: [p.lon, p.lat], text, size: 12 })
        }
      }
    }
  }
  return { paths, labels }
}
 
/** Outline of the MGRS square that contains `mgrs` (any precision). */
export function mgrsSquareOutline(mgrs: string): Position[] | null {
  try {
    const compact = mgrs.replace(/\s+/g, '').toUpperCase()
    if (/^[ABYZ]/.test(compact)) return null
    const { sw, size } = fromMgrs(compact)
    const zone = parseInt(compact, 10)
    const u = toUtm(sw, zone)
    const hemi = u.hemisphere
    // Snap the south-west corner to the square grid, then walk the edges in UTM space.
    const e0 = Math.round(u.easting / size) * size
    const n0 = Math.round(u.northing / size) * size
    const corners: [number, number][] = [
      [e0, n0],
      [e0 + size, n0],
      [e0 + size, n0 + size],
      [e0, n0 + size],
      [e0, n0],
    ]
    const out: Position[] = []
    for (let i = 0; i < 4; i++) {
      const [ea, na] = corners[i]!
      const [eb, nb] = corners[i + 1]!
      for (let k = 0; k < 8; k++) {
        const p = fromUtm(
          zone,
          hemi,
          ea + ((eb - ea) * k) / 8,
          na + ((nb - na) * k) / 8,
        )
        out.push([p.lon, p.lat])
      }
    }
    out.push(out[0]!)
    return out
  } catch {
    return null
  }
}