All files / core/systems/angles ddmGrid.ts

98.68% Statements 75/76
96.42% Branches 27/28
100% Functions 12/12
100% Lines 67/67

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                                    15x 15x     15x           15x     15x     15x   15x       8x 71x         9x   91x         90x                 149x 149x 149x     149x     149x 149x 92x     57x         149x     149x         16x 16x 16x 790x 16x       4x 4x 4x 4x 4x 4x 140x   4x 90x 90x       90x                   4x 50x 50x       50x             4x 4x               4x 4x 4x 4x 4x 4x 1020x       4x 4x 424x 334x 334x 334x   4x 226x 176x 176x 176x   4x         2x 2x 2x 2x    
/**
 * Degrees and decimal minutes grid, as on a nautical chart: lines at whole degrees and minutes,
 * chart-style labels, and minute ticks along the edges of the view. One minute of latitude is
 * one nautical mile.
 */
import type { LatLon } from '../../geo/ellipsoid'
import {
  type Box,
  type GridGeometry,
  type GridLabel,
  type GridPath,
  type Position,
  boxPath,
  densify,
} from '../../geo/geometry'
import { hemi } from './angles'
 
/** Grid steps in hundredths of a minute, so lines fall on exact minute values. */
const UNITS_PER_MINUTE = 100
const UNITS_PER_DEGREE = 60 * UNITS_PER_MINUTE
 
/** Line and tick steps, largest first: 10°, 5°, 2°, 1°, 30′, 20′, 10′, 5′, 2′, 1′, then tenths and hundredths. */
const STEPS = [
  60000, 30000, 12000, 6000, 3000, 2000, 1000, 500, 200, 100, 50, 20, 10, 5, 2,
  1,
]
 
/** About two dozen lines across the view, the same density as the decimal graticule. */
const MAX_LINES = 24
 
/** At least this many ticks between two grid lines; fewer ticks are not drawn. */
const MIN_TICKS_PER_STEP = 4
 
/** Tick length as a fraction of the view height. */
const TICK_FRACTION = 0.012
 
const LABEL_INSET = 0.03
 
/** The smallest step that keeps no more than MAX_LINES lines across the larger span. */
export function ddmStep(view: Box): number {
  const span = Math.max(view[2] - view[0], view[3] - view[1]) * UNITS_PER_DEGREE
  return STEPS.findLast(step => span / step <= MAX_LINES) ?? STEPS[0]!
}
 
/** The largest step that divides a line step into at least MIN_TICKS_PER_STEP parts, or 0 for no ticks. */
export function ddmTickStep(lineStep: number): number {
  return (
    STEPS.find(
      step => lineStep % step === 0 && lineStep / step >= MIN_TICKS_PER_STEP,
    ) ?? 0
  )
}
 
const wrapLon = (lon: number): number =>
  ((((lon + 180) % 360) + 360) % 360) - 180
 
/** A grid value as a chart label: `50° N` at whole-degree steps, else `50° 30′ N` or `001° 15.5′ W`; the antimeridian is `180°`. */
export function formatDdmLabel(
  units: number,
  axis: 'lat' | 'lon',
  step: number,
): string {
  const abs = Math.abs(units)
  const degrees = Math.floor(abs / UNITS_PER_DEGREE)
  const minuteUnits = abs - degrees * UNITS_PER_DEGREE
  // NOTE: the antimeridian is neither east nor west, so charts write it as plain 180°.
  const hemisphere =
    axis === 'lon' && abs === 180 * UNITS_PER_DEGREE
      ? ''
      : ` ${hemi(units, axis, units === 0)}`
  const paddedDegrees = String(degrees).padStart(axis === 'lat' ? 2 : 3, '0')
  if (step % UNITS_PER_DEGREE === 0) {
    return `${paddedDegrees}°${hemisphere}`
  }
  const decimals =
    step % UNITS_PER_MINUTE === 0
      ? 0
      : step % (UNITS_PER_MINUTE / 10) === 0
        ? 1
        : 2
  const minutes = (minuteUnits / UNITS_PER_MINUTE)
    .toFixed(decimals)
    .padStart(decimals === 0 ? 2 : decimals + 3, '0')
  return `${paddedDegrees}° ${minutes}′${hemisphere}`
}
 
/** Grid values in [low, high], as whole multiples of step. */
function valuesIn(low: number, high: number, step: number): number[] {
  const values: number[] = []
  const first = Math.ceil((low * UNITS_PER_DEGREE) / step)
  const last = Math.floor((high * UNITS_PER_DEGREE) / step)
  for (let i = first; i <= last; i++) values.push(i * step)
  return values
}
 
export function buildDdmGrid(view: Box): GridGeometry {
  const [w, s, e, n] = view
  const south = Math.max(s, -90)
  const north = Math.min(n, 90)
  const step = ddmStep(view)
  const paths: GridPath[] = []
  const labels: GridLabel[] = []
  const widthFor = (units: number) => (units % UNITS_PER_DEGREE === 0 ? 1.6 : 1)
 
  for (const units of valuesIn(w, e, step)) {
    const lon = units / UNITS_PER_DEGREE
    paths.push({
      path: densify([lon, south], [lon, north], (north - south) / 32),
      width: widthFor(units),
    })
    labels.push({
      position: [lon, south + (north - south) * LABEL_INSET],
      text: formatDdmLabel(
        Math.round(wrapLon(lon) * UNITS_PER_DEGREE),
        'lon',
        step,
      ),
      size: 10,
    })
  }
  for (const units of valuesIn(south, north, step)) {
    const lat = units / UNITS_PER_DEGREE
    paths.push({
      path: densify([w, lat], [e, lat], (e - w) / 32),
      width: widthFor(units),
    })
    labels.push({
      position: [w + (e - w) * LABEL_INSET, lat],
      text: formatDdmLabel(units, 'lat', step),
      size: 10,
    })
  }
 
  paths.push(...edgeTicks([w, south, e, north], step))
  return { paths, labels }
}
 
/**
 * Minute ticks along the four edges of the view, like the latitude and longitude scales on a
 * chart border. A tick across a parallel is as long on the ground as a tick across a meridian.
 */
function edgeTicks(view: Box, lineStep: number): GridPath[] {
  const tickStep = ddmTickStep(lineStep)
  Iif (!tickStep) return []
  const [w, s, e, n] = view
  const latLength = (n - s) * TICK_FRACTION
  const midLat = (((s + n) / 2) * Math.PI) / 180
  const lonLength = latLength / Math.max(Math.cos(midLat), 0.01)
  const tick = (from: Position, to: Position): GridPath => ({
    path: [from, to],
    width: 1,
  })
  const ticks: GridPath[] = []
  for (const units of valuesIn(w, e, tickStep)) {
    if (units % lineStep === 0) continue
    const lon = units / UNITS_PER_DEGREE
    ticks.push(tick([lon, s], [lon, s + latLength]))
    ticks.push(tick([lon, n], [lon, n - latLength]))
  }
  for (const units of valuesIn(s, n, tickStep)) {
    if (units % lineStep === 0) continue
    const lat = units / UNITS_PER_DEGREE
    ticks.push(tick([w, lat], [w + lonLength, lat]))
    ticks.push(tick([e, lat], [e - lonLength, lat]))
  }
  return ticks
}
 
/** The one-minute cell (one nautical mile north to south) that contains a point. */
export function ddmCellOutline(point: LatLon): Position[] {
  const minute = 1 / 60
  const south = Math.min(Math.floor(point.lat * 60) / 60, 90 - minute)
  const west = Math.min(Math.floor(point.lon * 60) / 60, 180 - minute)
  return boxPath([west, south, west + minute, south + minute], minute)
}