All files / core/domain camera.ts

100% Statements 18/18
100% Branches 15/15
100% Functions 7/7
100% Lines 18/18

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                        8x               8x 8x     8x 7x           8x 8x     8x 2x               8x 8x   8x 18x           8x 7x       7x       13x 3x              
import {
  Array,
  Number as EffectNumber,
  Option,
  Schema,
  String,
  pipe,
} from 'effect'
 
import { LatLon, clampToWorld } from './latLon'
 
/** Where the map is looking. Bounds are derived by the map from these and its size. */
export const Camera = Schema.Struct({
  center: LatLon,
  zoom: Schema.Finite,
  bearing: Schema.Finite,
  pitch: Schema.Finite,
})
export type Camera = typeof Camera.Type
 
const DEGREE_TOLERANCE = 1e-6
const ZOOM_TOLERANCE = 1e-3
 
/** Whether two cameras show the same view, within rounding. */
export const isSameView = (left: Camera, right: Camera): boolean =>
  Math.abs(left.center.lat - right.center.lat) < DEGREE_TOLERANCE &&
  Math.abs(left.center.lon - right.center.lon) < DEGREE_TOLERANCE &&
  Math.abs(left.zoom - right.zoom) < ZOOM_TOLERANCE &&
  Math.abs(left.bearing - right.bearing) < DEGREE_TOLERANCE &&
  Math.abs(left.pitch - right.pitch) < DEGREE_TOLERANCE
 
const ZOOM_DECIMALS = 2
const DEGREE_DECIMALS = 5
 
/** The URL form `zoom/lat/lon/bearing/pitch`, matching MapLibre's own hash. */
export const toUrlValue = ({ center, zoom, bearing, pitch }: Camera): string =>
  [
    zoom.toFixed(ZOOM_DECIMALS),
    center.lat.toFixed(DEGREE_DECIMALS),
    center.lon.toFixed(DEGREE_DECIMALS),
    bearing.toFixed(1),
    pitch.toFixed(1),
  ].join('/')
 
const MAX_ZOOM = 24
const MAX_PITCH = 85
 
const isNumberText = (part: string): boolean =>
  String.isNonEmpty(part) && globalThis.Number.isFinite(globalThis.Number(part))
 
/**
 * Read `zoom/lat/lon` with optional `/bearing/pitch`. A shared link can hold anything, so the
 * values are clamped to what MapLibre accepts; MapLibre throws on a latitude past the poles.
 */
export const fromUrlValue = (value: string): Option.Option<Camera> =>
  pipe(
    value.split('/'),
    Option.liftPredicate(
      parts =>
        parts.length >= 3 &&
        parts.length <= 5 &&
        Array.every(parts, isNumberText),
    ),
    Option.map(Array.map(part => globalThis.Number(part))),
    Option.map(([zoom = 0, lat = 0, lon = 0, bearing = 0, pitch = 0]) => ({
      center: clampToWorld({ lat, lon }),
      zoom: EffectNumber.clamp(zoom, { minimum: 0, maximum: MAX_ZOOM }),
      bearing,
      pitch: EffectNumber.clamp(pitch, { minimum: 0, maximum: MAX_PITCH }),
    })),
  )