All files / core/systems/tile tile.ts

97.87% Statements 46/47
95.45% Branches 21/22
100% Functions 9/9
100% Lines 40/40

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                15x 15x                             8132x     2071x 2071x       2071x 2071x 2071x 2071x         2071x                 2028x 2028x       2028x       1012x               4128x     2061x 2061x 23682x 23682x   2061x       4x 1x 3x 3x 3x 3x 38x 38x 38x 38x   3x     15x       1004x 1004x 1004x 1004x           1x 1003x 1x 1002x    
/**
 * Web map tiles (the XYZ or "slippy map" scheme): Web Mercator split into 2^z × 2^z tiles,
 * x from 180°W and y from the north edge, as OpenStreetMap, MapLibre and most tile servers
 * use. A quadkey (Bing Maps) writes the same tile as one base-4 digit per zoom level.
 */
import { type LatLon, wrapLon } from '../../geo/ellipsoid'
 
/** Web Mercator stops here, so the tile grid is square. */
export const MAX_TILE_LATITUDE = 85.0511287798066
export const MAX_TILE_ZOOM = 24
 
export interface Tile {
  z: number
  x: number
  y: number
}
 
export interface TileCell {
  west: number
  south: number
  east: number
  north: number
}
 
const tileCount = (z: number) => 2 ** z
 
export function toTile({ lat, lon }: LatLon, z: number): Tile {
  const count = tileCount(z)
  const clampedLat = Math.max(
    -MAX_TILE_LATITUDE,
    Math.min(MAX_TILE_LATITUDE, lat),
  )
  const latRadians = (clampedLat * Math.PI) / 180
  const wrappedLon = wrapLon(lon)
  const x = Math.floor(((wrappedLon + 180) / 360) * count)
  const y = Math.floor(
    ((1 - Math.log(Math.tan(latRadians) + 1 / Math.cos(latRadians)) / Math.PI) /
      2) *
      count,
  )
  return {
    z,
    x: Math.min(count - 1, Math.max(0, x)),
    y: Math.min(count - 1, Math.max(0, y)),
  }
}
 
/** The latitude of a tile row's north edge. */
export function tileRowLatitude(y: number, z: number): number {
  const n = Math.PI * (1 - (2 * y) / tileCount(z))
  return (Math.atan(Math.sinh(n)) * 180) / Math.PI
}
 
export function tileColumnLongitude(x: number, z: number): number {
  return (x / tileCount(z)) * 360 - 180
}
 
export function tileCell({ z, x, y }: Tile): TileCell {
  return {
    west: tileColumnLongitude(x, z),
    east: tileColumnLongitude(x + 1, z),
    north: tileRowLatitude(y, z),
    south: tileRowLatitude(y + 1, z),
  }
}
 
export const tilePath = ({ z, x, y }: Tile): string => `${z}/${x}/${y}`
 
export function toQuadkey({ z, x, y }: Tile): string {
  let quadkey = ''
  for (let level = z; level > 0; level--) {
    const mask = 1 << (level - 1)
    quadkey += String((x & mask ? 1 : 0) + (y & mask ? 2 : 0))
  }
  return quadkey
}
 
export function fromQuadkey(quadkey: string): Tile {
  if (!/^[0-3]{1,24}$/.test(quadkey))
    throw new Error(`Invalid quadkey "${quadkey}"`)
  let x = 0
  let y = 0
  const z = quadkey.length
  for (let index = 0; index < z; index++) {
    const mask = 1 << (z - index - 1)
    const digit = Number(quadkey.charAt(index))
    if (digit & 1) x |= mask
    if (digit & 2) y |= mask
  }
  return { z, x, y }
}
 
const TILE_PATH_RE = /^(\d{1,2})\/(\d+)\/(\d+)$/
 
/** Read `z/x/y`; the tile must exist at that zoom. */
export function fromTilePath(text: string): Tile {
  const match = TILE_PATH_RE.exec(text)
  Iif (!match) throw new Error(`Invalid tile "${text}"`)
  const [z, x, y] = [match[1], match[2], match[3]].map(Number)
  if (
    z === undefined ||
    x === undefined ||
    y === undefined ||
    z > MAX_TILE_ZOOM
  )
    throw new Error(`Invalid tile "${text}"`)
  if (x >= tileCount(z) || y >= tileCount(z))
    throw new Error(`Tile "${text}" is outside zoom ${z}`)
  return { z, x, y }
}