1

2# -*- coding: utf-8 -*-

3

4 u'''Web Mercator (WM) projection.

5

6 Classes L{Wm} and L{WebMercatorError} and functions L{parseWM} and L{toWm}.

7

8 Pure Python implementation of a U{Web Mercator<https://WikiPedia.org/wiki/Web_Mercator>}

9(aka I{Pseudo-Mercator}) class and conversion functions for spherical and near-spherical

10 earth models.

11

12@see: U{Google Maps / Bing Maps Spherical Mercator Projection

13<https://AlastairA.WordPress.com/2011/01/23/the-google-maps-bing-maps-spherical-mercator-projection>},

14 U{Geomatics Guidance Note 7, part 2<https://www.IOGP.org/wp-content/uploads/2019/09/373-07-02.pdf>}

15 and U{Implementation Practice Web Mercator Map Projection<https://Web.Archive.org/web/20141009142830/

16 http://earth-info.nga.mil/GandG/wgs84/web_mercator/(U)%20NGA_SIG_0011_1.0.0_WEBMERC.pdf>}.

17'''

18# make sure int/int division yields float quotient, see .basics

19 from __future__ import division as _; del _ # PYCHOK semicolon

20

21 from pygeodesy.basics import _splituple, _xinstanceof

22 from pygeodesy.constants import PI_2, R_MA, _2_0

23 from pygeodesy.datums import Datum, _spherical_datum

24 from pygeodesy.dms import clipDegrees, parseDMS2

25 from pygeodesy.errors import _parseX, _ValueError, _xattr, _xkwds, _xkwds_pop2

26 from pygeodesy.interns import NN, _COMMASPACE_, _datum_, _earth_, _easting_, \

27 _northing_, _radius_, _SPACE_, _x_, _y_

28# from pygeodesy.lazily import _ALL_LAZY from .named

29 from pygeodesy.named import _name2__, _NamedBase, _NamedTuple, _ALL_LAZY

30 from pygeodesy.namedTuples import LatLon2Tuple, LatLonDatum3Tuple, PhiLam2Tuple

31 from pygeodesy.props import deprecated_method, Property_RO

32 from pygeodesy.streprs import Fmt, strs, _xzipairs

33 from pygeodesy.units import Easting, _isRadius, Lat, Northing, Radius

34 from pygeodesy.utily import degrees90, degrees180

35

36 from math import atan, atanh, exp, radians, sin, tanh

37

38__all__ = _ALL_LAZY.webmercator

39__version__ = '24.11.06'

40

41# _FalseEasting = 0 # false Easting (C{meter})

42# _FalseNorthing = 0 # false Northing (C{meter})

43_LatLimit = Lat(limit=85.051129) # latitudinal limit (C{degrees})

44# _LonOrigin = 0 # longitude of natural origin (C{degrees})

45

46

47 class EasNorRadius3Tuple(_NamedTuple):

48 '''3-Tuple C{(easting, northing, radius)}, all in C{meter}.

49 '''

50 _Names_ = (_easting_, _northing_, _radius_)

51 _Units_ = ( Easting, Northing, Radius)

52

53

54 class WebMercatorError(_ValueError):

55 '''Web Mercator (WM) parser or L{Wm} issue.

56 '''

57 pass

58

59

60 class Wm(_NamedBase):

61 '''Web Mercator (WM) coordinate.

62 '''

63 _datum = None # set further below

64 _earths = () # dito

65 _radius = R_MA # earth radius (C{meter})

66 _x = 0 # Easting (C{meter})

67 _y = 0 # Northing (C{meter})

68

69 def __init__(self, x, y, earth=R_MA, **name_radius):

70 '''New L{Wm} Web Mercator (WM) coordinate.

71

72 @arg x: Easting from central meridian (C{meter}).

73 @arg y: Northing from equator (C{meter}).

74 @kwarg earth: Earth radius (C{meter}), datum or ellipsoid (L{Datum},

75 L{a_f2Tuple}, L{Ellipsoid} or L{Ellipsoid2}).

76 @kwarg name_radius: Optional C{B{name}=NN} (C{str}) and DEPRECATED

77 keyword argument C{B{radius}=earth}, use B{C{earth}}.

78

79 @note: WM is strictly defined for spherical and WGS84 ellipsoidal

80 earth models only.

81

82 @raise WebMercatorError: Invalid B{C{x}}, B{C{y}} or B{C{radius}}.

83 '''

84 self._x = Easting( x=x, Error=WebMercatorError)

85 self._y = Northing(y=y, Error=WebMercatorError)

86

87 R, name = _xkwds_pop2(name_radius, radius=earth)

88 if name:

89 self.name = name

90 if R not in Wm._earths:

91 self._datum = _datum(R, _radius_ if _radius_ in name_radius else _earth_)

92 self._radius = self.datum.ellipsoid.a

93

94 @Property_RO

95 def datum(self):

96 '''Get the datum (C{Datum}).

97 '''

98 return self._datum

99

100 @Property_RO

101 def ellipsoid(self):

102 '''Get the ellipsoid (C{Ellipsoid}).

103 '''

104 return self.datum.ellipsoid

105

106 @Property_RO

107 def latlon(self):

108 '''Get the lat- and longitude (L{LatLon2Tuple}).

109 '''

110 return self.latlon2()

111

112 def latlon2(self, datum=None):

113 '''Convert this WM coordinate to a lat- and longitude.

114

115 @kwarg datum: Optional datum (L{Datum}, L{Ellipsoid},

116 L{Ellipsoid2} or L{a_f2Tuple}) or earth

117 radius (C{meter}), overriding this WM's

118 C{radius} and C{datum}.

119

120 @return: A L{LatLon2Tuple}C{(lat, lon)}.

121

122 @note: WM is strictly defined for spherical and WGS84

123 ellipsoidal earth models only.

124

125 @raise TypeError: Invalid or non-ellipsoidal B{C{datum}}.

126

127 @see: Method C{toLatLon} for other return types.

128 '''

129 d = self.datum if datum in (None, self.datum, self.radius) else _datum(datum)

130 E = d.ellipsoid

131 R = self.radius

132 x = self.x / R

133 y = atan(exp(self.y / R)) * _2_0 - PI_2

134 if E.es or E.a != R: # strictly, WGS84 only

135 # <https://Web.Archive.org/web/20141009142830/http://earth-info.nga.mil/

136 # GandG/wgs84/web_mercator/(U)%20NGA_SIG_0011_1.0.0_WEBMERC.pdf>

137 y = y / R # /= chokes PyChecker

138 y -= E.es_atanh(tanh(y))

139 y *= E.a

140 x *= E.a / R

141

142 return LatLon2Tuple(degrees90(y), degrees180(x), name=self.name)

143

144 def parse(self, strWM, **name):

145 '''Parse a string to a similar L{Wm} instance.

146

147 @arg strWM: The WM coordinate (C{str}), see function L{parseWM}.

148 @kwarg name: Optional C{B{name}=NN} (C{str}), overriding this name.

149

150 @return: The similar instance (L{Wm}).

151

152 @raise WebMercatorError: Invalid B{C{strWM}}.

153 '''

154 return parseWM(strWM, radius=self.radius, Wm=self.classof,

155 name=self._name__(name))

156

157 @deprecated_method

158 def parseWM(self, strWM, name=NN): # PYCHOK no cover

159 '''DEPRECATED, use method L{Wm.parse}.'''

160 return self.parse(strWM, name=name)

161

162 @Property_RO

163 def philam(self):

164 '''Get the lat- and longitude ((L{PhiLam2Tuple}).

165 '''

166 return PhiLam2Tuple(*map(radians, self.latlon), name=self.name)

167

168 @Property_RO

169 def radius(self):

170 '''Get the earth radius (C{meter}).

171 '''

172 return self._radius

173

174 @deprecated_method

175 def to2ll(self, datum=None): # PYCHOK no cover

176 '''DEPRECATED, use method C{latlon2}.

177

178 @return: A L{LatLon2Tuple}C{(lat, lon)}.

179 '''

180 return self.latlon2(datum=datum)

181

182 def toLatLon(self, LatLon=None, datum=None, **LatLon_kwds):

183 '''Convert this WM coordinate to a geodetic point.

184

185 @kwarg LatLon: Ellipsoidal or sphperical C{LatLon} class to

186 return the geodetic point (C{LatLon}) or C{None}.

187 @kwarg datum: Optional, datum (C{Datum}) overriding this WM's.

188 @kwarg LatLon_kwds: Optional, additional B{C{LatLon}} keyword

189 arguments, ignored if C{B{LatLon} is None}.

190

191 @return: This WM coordinate as B{C{LatLon}} or if C{B{LatLon}

192 is None} a L{LatLonDatum3Tuple}.

193

194 @raise TypeError: If B{C{datum}} is invalid or if B{C{LatLon}}

195 and B{C{datum}} are incompatible.

196 '''

197 d = datum or self.datum

198 _xinstanceof(Datum, datum=d)

199 r = self.latlon2(datum=d)

200 r = LatLonDatum3Tuple(r.lat, r.lon, d, name=r.name) if LatLon is None else \

201 LatLon(r.lat, r.lon, **_xkwds(LatLon_kwds, datum=d, name=r.name))

202 return r

203

204 def toRepr(self, prec=3, fmt=Fmt.SQUARE, sep=_COMMASPACE_, radius=False, **unused): # PYCHOK expected

205 '''Return a string representation of this WM coordinate.

206

207 @kwarg prec: Number of (decimal) digits, unstripped (C{int}).

208 @kwarg fmt: Enclosing backets format (C{str}).

209 @kwarg sep: Optional separator between name:value pairs (C{str}).

210 @kwarg radius: If C{True}, include the radius (C{bool}) or

211 C{scalar} to override this WM's radius.

212

213 @return: This WM as "[x:meter, y:meter]" (C{str}) or as "[x:meter,

214 y:meter], radius:meter]" if B{C{radius}} is C{True} or

215 C{scalar}.

216

217 @raise WebMercatorError: Invalid B{C{radius}}.

218 '''

219 t = self.toStr(prec=prec, sep=None, radius=radius)

220 n = (_x_, _y_, _radius_)[:len(t)]

221 return _xzipairs(n, t, sep=sep, fmt=fmt)

222

223 def toStr(self, prec=3, fmt=Fmt.F, sep=_SPACE_, radius=False, **unused): # PYCHOK expected

224 '''Return a string representation of this WM coordinate.

225

226 @kwarg prec: Number of (decimal) digits, unstripped (C{int}).

227 @kwarg fmt: Optional C{float} format (C{letter}).

228 @kwarg sep: Optional separator to join (C{str}) or C{None}

229 to return an unjoined C{tuple} of C{str}s.

230 @kwarg radius: If C{True}, include the radius (C{bool}) or

231 C{scalar} to override this WM's radius.

232

233 @return: This WM as "meter meter" (C{str}) or as "meter meter

234 radius" if B{C{radius}} is C{True} or C{scalar}.

235

236 @raise WebMercatorError: Invalid B{C{radius}}.

237 '''

238 fs = self.x, self.y

239 if _isRadius(radius):

240 fs += (radius,)

241 elif radius: # is True:

242 fs += (self.radius,)

243 elif radius not in (None, False):

244 raise WebMercatorError(radius=radius)

245 t = strs(fs, prec=prec)

246 return t if sep is None else sep.join(t)

247

248 @Property_RO

249 def x(self):

250 '''Get the easting (C{meter}).

251 '''

252 return self._x

253

254 @Property_RO

255 def y(self):

256 '''Get the northing (C{meter}).

257 '''

258 return self._y

259

260 Wm._datum = _spherical_datum(Wm._radius, name=Wm.__name__, raiser=_radius_) # PYCHOK defaults

261 Wm._earths = (Wm._radius, Wm._datum, Wm._datum.ellipsoid)

262

263

264 def _datum(earth, name=_datum_):

265 '''(INTERNAL) Make a datum from an C{earth} radius, datum or ellipsoid.

266 '''

267 if earth in Wm._earths:

268 return Wm._datum

269 try:

270 return _spherical_datum(earth, name=name)

271 except Exception as x:

272 raise WebMercatorError(name, earth, cause=x)

273

274

275 def parseWM(strWM, radius=R_MA, Wm=Wm, **name):

276 '''Parse a string C{"e n [r]"} representing a WM coordinate,

277 consisting of easting, northing and an optional radius.

278

279 @arg strWM: A WM coordinate (C{str}).

280 @kwarg radius: Optional earth radius (C{meter}), needed in

281 case B{C{strWM}} doesn't include C{r}.

282 @kwarg Wm: Optional class to return the WM coordinate (L{Wm})

283 or C{None}.

284 @kwarg name: Optional C{B{name}=NN} (C{str}).

285

286 @return: The WM coordinate (B{C{Wm}}) or if C{B{Wm} is None}, an

287 L{EasNorRadius3Tuple}C{(easting, northing, radius)}.

288

289 @raise WebMercatorError: Invalid B{C{strWM}}.

290 '''

291 def _WM(strWM, radius, Wm, name):

292 w = _splituple(strWM)

293

294 if len(w) == 2:

295 w += (radius,)

296 elif len(w) != 3:

297 raise ValueError

298 x, y, R = map(float, w)

299

300 return EasNorRadius3Tuple(x, y, R, **name) if Wm is None else \

301 Wm(x, y, earth=R, **name)

302

303 return _parseX(_WM, strWM, radius, Wm, name,

304 strWM=strWM, Error=WebMercatorError)

305

306

307 def toWm(latlon, lon=None, earth=R_MA, Wm=Wm, **name_Wm_kwds_radius):

308 '''Convert a lat-/longitude point to a WM coordinate.

309

310 @arg latlon: Latitude (C{degrees}) or an (ellipsoidal or spherical)

311 geodetic C{LatLon} point.

312 @kwarg lon: Optional longitude (C{degrees} or C{None}).

313 @kwarg earth: Earth radius (C{meter}), datum or ellipsoid (L{Datum},

314 L{a_f2Tuple}, L{Ellipsoid} or L{Ellipsoid2}), overridden

315 by B{C{latlon}}'s datum if present.

316 @kwarg Wm: Optional class to return the WM coordinate (L{Wm}) or C{None}.

317 @kwarg name_Wm_kwds_radius: Optional C{B{name}=NN} (C{str}), optionally,

318 additional B{C{Wm}} keyword arguments, ignored if C{B{Wm} is

319 None} and DEPRECATED keyword argument C{B{radius}=earth}, use

320 B{C{earth}}.

321

322 @return: The WM coordinate (B{C{Wm}}) or if C{B{Wm} is None}, an

323 L{EasNorRadius3Tuple}C{(easting, northing, radius)}.

324

325 @raise ValueError: If B{C{earth}} is invalid, if B{C{lon}} value is missing,

326 if B{C{latlon}} is not scalar, or if B{C{latlon}} is beyond

327 the valid WM range and L{rangerrrors<pygeodesy.rangerrors>}

328 is C{True}.

329 '''

330 name, kwds = _name2__(name_Wm_kwds_radius)

331 R, kwds = _xkwds_pop2(kwds, radius=earth)

332 d = _datum(R, _radius_ if _radius_ in name_Wm_kwds_radius else _earth_)

333 try:

334 y, x = latlon.lat, latlon.lon

335 y = clipDegrees(y, _LatLimit)

336 d = _xattr(latlon, datum=d)

337 n = latlon._name__(name)

338 except AttributeError:

339 y, x = parseDMS2(latlon, lon, clipLat=_LatLimit)

340 n = name

341 E = d.ellipsoid

342 R = E.a

343 s = sin(radians(y))

344 y = atanh(s) # == log(tand((90 + lat) / 2)) == log(tanPI_2_2(radians(lat)))

345 if E.es:

346 y -= E.es_atanh(s) # strictly, WGS84 only

347 y *= R

348 x = R * radians(x)

349 r = EasNorRadius3Tuple(x, y, R, name=n) if Wm is None else \

350 Wm(x, y, **_xkwds(kwds, earth=d, name=n))

351 return r

352

353# **) MIT License

354#

355# Copyright (C) 2016-2025 -- mrJean1 at Gmail -- All Rights Reserved.

356#

357# Permission is hereby granted, free of charge, to any person obtaining a

358# copy of this software and associated documentation files (the "Software"),

359# to deal in the Software without restriction, including without limitation

360# the rights to use, copy, modify, merge, publish, distribute, sublicense,

361# and/or sell copies of the Software, and to permit persons to whom the

362# Software is furnished to do so, subject to the following conditions:

363#

364# The above copyright notice and this permission notice shall be included

365# in all copies or substantial portions of the Software.

366#

367# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS

368# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,

369# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL

370# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR

371# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,

372# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR

373# OTHER DEALINGS IN THE SOFTWARE.