-- | 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