naqsha-0.2.0.0: Naqsha/Geometry/Coordinate/GeoHash.hs
-- | This module implements the geohash encoding of geo-locations.
-- https://en.wikipedia.org/wiki/Geohash. To try out geohash encoding
-- on web visit http://geohash.org
module Naqsha.Geometry.Coordinate.GeoHash
( GeoHash, encode, decode, accuracy, toByteString
) where
import Data.Bits
import qualified Data.ByteString as B
import Data.ByteString.Internal ( c2w, w2c )
import Data.Char ( ord )
import Data.String
import Data.Monoid ( (<>) )
import Data.Word ( Word8 )
import Naqsha.Geometry.Internal
import Naqsha.Geometry.Coordinate ( Geo(..) )
-- | Precision of encoding measured in base32 digits.
accuracyBase32 :: Int
accuracyBase32 = 12
-- | Precision of encoding measured in bits.
accuracy :: Int
accuracy = accuracyBase32 * 5
-- | The length of the output.
outputLength :: Int
outputLength = 2 * accuracyBase32
-- | The encoding of geo-coordinates as a geohash string. Currently,
-- the encoding supports 24 base32 digits of geo hash value which
-- means we loose about 4-bits of accuracy w.r.t the representation of
-- angles in the library. However, this loss is rather theoretical as
-- the angular error that results from such loss is so insignificant
-- that for all practical purposes, this accuracy is good enough ---
-- GPS devices will have much greater errors. The quantity `accuracy`
-- gives the number of bits of precision supported by the geohash
-- implementation exposed here. As expected GeoHash implementations
-- here will have problems at regions close to the poles.
newtype GeoHash = GeoHash B.ByteString deriving (Eq, Ord)
instance Show GeoHash where
show (GeoHash x) = map b32ToChar $ B.unpack x
instance IsString GeoHash where
fromString = GeoHash . B.pack . map cToB32 . take 24
------------------------------------------ Base 32 encoding used by geohash --------------------------
-- The digit ranges are
-- 0-9, b-h, jk, mn, p-z
--
-- b - 10
-- c - 11
-- d - 12
-- e - 13
-- f - 14
-- g - 15
-- h - 16
--------- Broken range ---
-- j - 17
-- k - 18
--------- Broken range ----
-- m - 19
-- n - 20
---------- Broken range ---
-- p - 21
-- q - 22
-- r - 23
-- s - 24
-- t - 25
-- u - 26
-- v - 27
-- w - 28
-- x - 29
-- y - 30
-- x - 31
cToB32 :: Char -> Word8
cToB32 x
| '0' <= x && x <= '9' = toEnum $ ord x - ord '0'
| 'b' <= x && x <= 'h' = toEnum $ ord x - ord 'b' + 10
| 'p' <= x && x <= 'z' = toEnum $ ord x - ord 'p' + 21
| x == 'j' = 17
| x == 'k' = 18
| x == 'm' = 19
| x == 'n' = 20
| otherwise = error $ "geohash: bad character " ++ show x
b32ToChar8 :: Word8 -> Word8
b32ToChar8 b32
| 0 <= w && w <= 9 = c2w '0' + w
| 10 <= w && w <= 16 = c2w 'b' + w - 10
| 21 <= w && w <= 32 = c2w 'p' + w - 21
| w == 17 = c2w 'j'
| w == 18 = c2w 'k'
| w == 19 = c2w 'm'
| w == 20 = c2w 'n'
| otherwise = error "geohash: fatal this should never happen"
where w = b32 .&. 0x1F
b32ToChar :: Word8 -> Char
b32ToChar = w2c . b32ToChar8
-- Geohash encoding
-- ---------------
--
-- Notice that the bits of the geohash encoding is essentially got by
-- iterleaving the bits of the longitude and the latitude. However,
-- the first bit is 0 for negative angles 1 for positive
-- angles. Therefore we need to complement the sign bit. Since
-- longitudes vary over the entire range of angles, this is all we
-- need to do for adjustment before interleaving the bits.
--
-- However, the latitudes like in the range -90 to +90. If we ignore the
-- +90 angle then we have the following property of its bit pattern
--
-- 1. Every positive angle (other than +90) is of the form 00xxxxxxx.
--
-- 2. Every negative angle is of the form 11xxxxxxx.
--
-- Therefore, to get the actual bits that need to be interleaved, we
-- need to shift left the bits left by 1 and complement the
-- bit. During decoding, we need to do the reverse, i.e. complement
-- the bit and shift right by 1 with sign extension.
--
-- For the +90 case while encoding we treat the bit stream as all 1's.
-- While decoding we will never get an angle of 90 but can be pretty
-- close.
-- | Adjust the latitude for encoding.
adjustEncodeLat :: Latitude -> Angle
adjustEncodeLat lt
| testBit lt 63 = clearBit a 63 -- negative angle (starting bit = 0)
| testBit a 63 = complement zeroBits -- +90
| otherwise = setBit a 63 -- positive angle (starting bit = 1)
where a = unsafeShiftL (unLat lt) 1
-- | Adjust the angle while decoding.
adjustDecodeLat :: Angle -> Latitude
adjustDecodeLat a = sgnBit .|. unsafeShiftR lt 1
where lt = Latitude $ complementBit a 63
sgnBit = bit 63 .&. lt
-- | Adjusting longitude while encoding. Just nee
adjustEncodeLon :: Longitude -> Angle
adjustEncodeLon = flip complementBit 63 . unLong
-- | Adjusting longitude while decoding
adjustDecodeLon :: Angle -> Longitude
adjustDecodeLon = Longitude . flip complementBit 63
-- | Convert the geo hash to bytestring.
toByteString :: GeoHash -> B.ByteString
toByteString (GeoHash x) = B.map b32ToChar8 x
--------------- Interleaved base32 encoding ------
-- | The @interleaveAndMerge (x,y)@ merges 5-bits, 3 from @x@ and 2
-- from @y@ into a word and returns it. An appropriate swap is done so
-- that the next bytes are taken from y and x respectively.
interleaveAndMerge :: (Angle, Angle) -> (Word8, (Angle, Angle))
interleaveAndMerge (x,y) = (w, (yp, xp))
where xp = rotateL x 3 -- Take the top 3 bits
yp = rotateL y 2 -- Take the top 2 bits
wx = fromIntegral $ unAngle xp
wy = fromIntegral $ unAngle yp
w = unsafeShiftL (wx .&. 4) 2 -- x2 -> w4
.|. unsafeShiftL (wx .&. 2) 1 -- x1 -> w2
.|. (wx .&. 1) -- x0 -> w0
.|. unsafeShiftL (wy .&. 2) 2 -- y1 -> w3
.|. unsafeShiftL (wy .&. 1) 1 -- y0 -> w1
-- | Encode a geo-location into its GeoHash string.
encode :: Geo -> GeoHash
encode (Geo lt lng) = GeoHash $ fst $ B.unfoldrN outputLength fld (adjustEncodeLon lng , adjustEncodeLat lt)
where fld = Just . interleaveAndMerge
-------------------------- Decoding --------------------------------
-- | This function distributes the bits of the Word8 argument
-- (actually only 5-bits matter) to x and y in an interleaved fashion.
-- x gets 3-bits and y gets 2. The arguments are switched so that for
-- the next byte is distributed to y and x respectively.
splitAndDistribute :: (Angle, Angle) -> Word8 -> (Angle , Angle)
splitAndDistribute (x,y) w = (yp,xp)
where xp = unsafeShiftL x 3
.|. (4 `bitTo` 2)
.|. (2 `bitTo` 1)
.|. (0 `bitTo` 0)
yp = unsafeShiftL y 2
.|. (3 `bitTo` 1)
.|. (1 `bitTo` 0)
bitTo i j = Angle $ fromIntegral $ unsafeShiftL (unsafeShiftR w i .&. 1) j
-- | Decode the geo-location from its GeoHash string.
decode :: GeoHash -> Geo
decode (GeoHash hsh) = Geo lt ln
where lt = adjustDecodeLat $ unsafeShiftL y 4
ln = adjustDecodeLon $ unsafeShiftL x 4
(x,y) = B.foldl splitAndDistribute (Angle 0,Angle 0) strP
hshLen = B.length hsh
strP = if hshLen > outputLength then B.take outputLength hsh
else hsh <> B.replicate (outputLength - hshLen) 0