{-# LANGUAGE RankNTypes #-}

module Math.Geometry.Spherical
    ( -- * Computations
      areaTriangle
    , compactness
    , relativeCompactness
    , areaConvex
    , perimeterPolygon
    , areaPolygon
    , distance
    -- * Projections
    , albers
    , littow
    , craig
    , winkel3
    , mercator
    , bonne
    -- * Reference points
    , washingtonDC
    , mecca
    -- * Helper functions
    , radians
    , toRadians
    , project
    ) where

import           Control.Composition

-- | Convert from degrees to radians.
radians :: Floating a => a -> a
radians = (*(pi/180))

-- | Convert both coördinates to radians.
toRadians :: Floating a => (a, a) -> (a, a)
toRadians = both radians

sinc :: (Eq a, Floating a) => a -> a
sinc 0 = 1
sinc x = sin x / x

-- | For use as a reference point
washingtonDC :: (Floating a) => (a, a)
washingtonDC = toRadians (38.9072, -77.0369)

-- | For use as a reference point
mecca :: (Floating a) => (a, a)
mecca = toRadians (21.3891, 39.8579)

-- | Littow retroazimuthal + conformal projection
littow :: Floating a => (a, a) -> (a, a)
littow (long, lat) = (sin (long - referenceLong) /cos lat, cos (long - referenceLong) * tan lat)
    where referenceLong = radians 0

-- | Craig retroazimuthal projection
craig :: (Floating a, Eq a)
      => (a, a) -- ^ Reference point given in radians
      -> ((a, a) -> (a, a))
craig referencePoint (long, lat) = (long - referenceLong, y)
    where (referenceLong, referenceLat) = referencePoint
          expr = sin lat * cos (long - referenceLong) - tan referenceLat * cos lat
          y | long - referenceLong == 0 = expr
            | otherwise = (long - referenceLong) / sin (long - referenceLong) * expr

-- | Winkel Tripel projection
winkel3 :: (Eq a, Floating a) => (a, a) -> (a, a)
winkel3 (long, lat) = ((lambda * cos phi1 + (2 * cos lat * sin (lambda/2)/sinc alpha))/2, (lat + sin lat/sinc alpha)/2)
    where lambda = long - lambda0
          phi1 = acos $ 2 / pi
          alpha = acos $ cos lat * cos (lambda/2)
          lambda0 = radians (-77.0369)

-- | Mercator projection.
mercator :: Floating a => (a, a) -> (a, a)
mercator (long, lat) = (long - meridian, asinh (tan lat))
    where meridian = radians (-98.5795)

-- | Bonne projection.
bonne :: Floating a
      => a -- ^ Standard Parallel. If you are unsure of what to put, try @radians 45@.
      -> a -- ^ Central meridian. If you are unsure of what to put, try @snd washingtonDC@.
      -> (a, a)
      -> (a, a)
bonne phi1 meridian (long, lat) = (rho * sin e, cot phi1 - rho * cos e)
    where rho = cot phi1 + phi1 - lat
          e = (long - meridian) * cos lat / rho
          cot = (1/) . tan

-- | Albers projection for a given reference point.
--
-- > ablers washingtonDC
albers :: Floating a => (a, a) -- ^ A reference point on the sphere
                     -> ((a, a) -> (a, a))
albers referencePoint (long, lat) = (rho * sin theta, rho' - rho * cos theta)
    where n = (sin phi1 + sin phi2) / 2
          theta = n * (long - referenceLong)
          c = cos phi1^(2 :: Int) + 2 * n * sin phi1
          rho = sqrt (c - 2 * n * sin lat) / n
          rho' = sqrt (c - 2 * n * sin referenceLat) / n
          -- standard parallels @ 20, 50 degrees
          phi1 = radians 20
          phi2 = radians 50
          (referenceLong, referenceLat) = referencePoint

-- | Project a given polygon
project :: (Floating a, Functor f) => ((a, a) -> (a, a)) -- ^ A projection
                                   -> f (a, a) -- ^ A polygon defined by points on the sphere, in degrees
                                   -> f (a, a)
project f = fmap (f . toRadians)

-- | Compute the area of a triangle using L\'Huillier\'s formula
areaTriangle :: Floating a => a -- ^ Radius of the sphere
                           -> (a, a) -- ^ A point given in radians
                           -> (a, a)
                           -> (a, a)
                           -> a
areaTriangle r x1 x2 x3 = r^(2 :: Int) * e
    where e = 4 * atan(sqrt(tan(s/2) * tan((s - a)/2) * tan((s - b)/2) * tan((s - c)/2)))
          s = (a + b + c) / 2
          a = distanceRad x1 x2
          b = distanceRad x1 x3
          c = distanceRad x2 x3
          distanceRad = on centralAngle toRadians

-- TODO mandelbrot/fractal dimension?
-- consider "area of largest circumscribable circle" as well.

-- | Relative compactness. Dimensionless.
relativeCompactness :: (Floating a) => [(a, a)] -> a
relativeCompactness = (*scale) . compactness
    where scale = 1/4*pi

-- | Take the area of the polygon and divide by the perimeter squared. Dimensionless.
compactness :: (Floating a)
             => [(a, a)] -- ^ Polygons on surface of the sphere, given in degrees
             -> a
compactness p = areaPolygon 1 p/(perimeterPolygon 1 p^(2 :: Int))

-- | Compute the area of a convex polygon on the surface of a sphere.
areaConvex :: (Floating a)
           => a -- ^ Radius of a sphere
           -> [(a, a)] -- ^ Polygon on the surface of the sphere
           -> a
areaConvex r (base1:base2:pts) = fst $ foldr stepArea (0,base2) pts
    where stepArea point (sum', base) = (sum' + areaTriangle r base1 base point, point)
areaConvex _ _ = error "attempted to take area of polygon with < 3 points"

-- | Uses areal projection; then finds area of the polygon by the shoelace
-- method.
--
-- This is morally dubious in that it uses the Bonne projection centered around
-- DC, so it will blow up in some cases.
areaPolygon :: Floating a
            => a -- ^ Radius of sphere
            -> [(a, a)] -- ^ Polygon on the sphere, with points given in degrees.
            -> a
areaPolygon r = (*factor) . areaPolyRectangular . fmap (bonne (radians 25) (snd washingtonDC) . toRadians)
    where factor = 1717856/4.219690791828533e-2 * ((r / 6371) ^ (2 :: Int))

perimeterPolygon :: Floating a
                 => a -- ^ Radius of sphere
                 -> [(a, a)] -- ^ Polygon on sphere given in degrees
                 -> a
perimeterPolygon r [x1, x2]       = distance r x1 x2
perimeterPolygon r (x1:x2:points) = perimeterPolygon r (x2:points) + distance r x1 x2
perimeterPolygon _ _              = error "Attempted to take perimeter of polygon with no points"

-- | Find the area of a polygon with rectangular coördinates given. This
-- function will throw an error when a polygon with no points is given.
areaPolyRectangular :: (Floating a) => [(a, a)] -> a
areaPolyRectangular (pt:pts) = abs . (*0.5) . fst $ foldr areaPolyCalc (0,pt) pts
    where areaPolyCalc (x2, y2) (sum',(x1,y1)) = (sum' + (x1 * y2 - x2 * y1),(x2,y2))
areaPolyRectangular _ = error "Attempted to take area of polygon with no points"

-- | Distance in kilometers between two points given in degrees.
distance :: Floating a
         => a -- ^ Radius of sphere
         -> (a, a) -- ^ Point on sphere given in degrees
         -> (a, a) -- ^ Point on sphere given in degrees
         -> a
distance r = (*r) .* on centralAngle toRadians

-- | Compute central angle from points given in radians
centralAngle :: Floating a => (a, a) -> (a, a) -> a
centralAngle (long1, lat1) (long2, lat2) =
    acos $ sin lat1 * sin lat2 + cos lat1 * cos lat2 * cos (long1 - long2)