I have written a Django app that would get some input and give you an image as output. The output image has 3 layers:
- Google Map image
- rosreestr (russian docs layer)
- data from geojson layer
I hear that I write code like a junior developer, so please help me improve, especially regarding clear code and architecture. (I am also interested in learning Erlang or Scala.)
concat.py
from cStringIO import StringIO
import PIL
def concat_images(image, layer, rosreestr=None):
"""
function that concat png images like sandwich
used for concat google map static image and
mapnik layers image
image - png image google map
layer - png mapnik image
"""
buf = StringIO()
if rosreestr:
rosreestr = rosreestr.resize(image.size)
image = PIL.Image.alpha_composite(image, rosreestr)
PIL.Image.alpha_composite(image, layer).save(buf, 'PNG')
buf.seek(0)
return buf
consts.py
from pyproj import Proj
from math import pi
EARTH_RADIUS = 6378137
EQUATOR_CIRCUMFERENCE = 2 * pi * EARTH_RADIUS
INITIAL_RESOLUTION = EQUATOR_CIRCUMFERENCE / 256.0
ORIGIN_SHIFT = EQUATOR_CIRCUMFERENCE / 2.0
MAP_SRS = '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 '
MAP_SRS += '+lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 '
MAP_SRS += '+units=m +nadgrids=@null +wktext +no_defs'
EPSG4326 = 'epsg:4326'
EPSG3857 = 'epsg:3857'
IN_PROJ = Proj(init=EPSG4326)
OUT_PROJ = Proj(init=EPSG3857)
TMP_DIR = 'tmp'
TMP_GEOJSON = 'tmp.geojson'
exceptions.py
class GeometryTypeError(Exception):
pass
map_filler.py
import mapnik
from mapnik._mapnik import DataGeometryType
from pyproj import transform
import PIL
import os
import json
from tempfile import NamedTemporaryFile
from . import utils
from . import consts
from .exceptions import GeometryTypeError
def create_symbolizer(datasource):
"""
symbolizer fabric for various types of shape
"""
geom_type = datasource.geometry_type()
if geom_type == DataGeometryType.Point:
symbolizer = mapnik.PointSymbolizer()
elif geom_type == DataGeometryType.Polygon:
symbolizer = mapnik.PolygonSymbolizer()
elif geom_type == DataGeometryType.LineString:
symbolizer = mapnik.LineSymbolizer()
elif geom_type == DataGeometryType.Collection:
symbolizer = mapnik.LineSymbolizer()
else:
msg = 'Invalid geomerty type of object %s' % datasource
raise GeometryTypeError(msg)
return symbolizer
class MapFiller(mapnik.Map):
"""
mapnik.Map object that create map with included styles,
datasource, coordinates
"""
styles = {'stroke': 'color',
'fill': 'color',
'fill_opacity': 'opacity',
'stroke_opacity': 'opacity',
'opacity': 'opacity',
'stroke_width': 'weight',
'width': 'weight'}
def __init__(self, imager, **kwargs):
self.upperleft = imager.upperleft
self.lowerright = imager.lowerright
box_xy = self.create_valid_box(imager)
super(MapFiller, self).__init__(*box_xy, **kwargs)
self.srs = consts.MAP_SRS
if not os.path.isdir(consts.TMP_DIR):
os.mkdir(consts.TMP_DIR)
def create_valid_box(self, imager):
self.dx = int(imager.dx)
self.dy = int(imager.dy)
return (self.dx, self.dy)
def filling_map(self, layers):
for i in range(len(layers)):
self.append_layer(layers[i], i)
def append_layer(self, layer, i):
self.correct_layer_geom(layer)
geom = layer.get('geom')
if not geom:
return
layer_geojson = self.create_geojson(geom)
filename = os.path.join(consts.TMP_DIR, consts.TMP_GEOJSON)
datasource = self.write_datasource(filename, layer_geojson)
symbolizer = create_symbolizer(datasource)
self.set_style(symbolizer, layer['style'])
name = 'style%s' % str(i)
self.push_style(name, symbolizer)
self.push_layer(name, datasource)
def correct_layer_geom(self, layer):
for j in range(len(layer.get('geom', []))):
if layer['geom'][j]:
layer['geom'][j]['coordinates'] = \
self.epsg4326_to_3857(layer['geom'][j])
def create_geojson(self, geom):
geojson = {"type": "FeatureCollection"}
geojson['features'] = [{"type": "Feature",
"geometry": coord,
"properties": {}} for coord in geom]
return geojson
def write_datasource(self, filename, geojson):
with open(filename, 'w') as f:
f.write(json.dumps(geojson))
datasource = mapnik.Datasource(type='geojson', file=filename)
os.remove(filename)
return datasource
def push_layer(self, name, datasource):
new_layer = mapnik.Layer(name)
new_layer.datasource = datasource
new_layer.srs = consts.MAP_SRS
new_layer.styles.append(name)
self.layers.append(new_layer)
def push_style(self, name, symbolizer):
style = mapnik.Style()
rule = mapnik.Rule()
rule.symbols.append(symbolizer)
style.rules.append(rule)
self.append_style(name, style)
def epsg4326_to_3857(self, coordinates):
if coordinates['type'] == 'Point':
return self.transform_point(coordinates['coordinates'])
elif coordinates['type'] == 'MultiLineString' or \
coordinates['type'] == 'Polygon':
return self.list_comp_map(coordinates['coordinates'])
elif coordinates['type'] == 'LineString' or \
coordinates['type'] == 'MultiPoint':
coords = coordinates['coordinates']
return [self.transform_point(p) for p in coords]
elif coordinates['type'] == 'MultiPolygon':
return map(lambda cc: self.list_comp_map(cc),
coordinates['coordinates'])
else:
msg = 'Invalid geomerty type of json object by database'
raise GeometryTypeError(msg)
def set_style(self, sym, params):
for k, v in self.styles.iteritems():
if hasattr(sym, k) and v in params:
if v == 'color':
setattr(sym, k, mapnik.Color(str(params[v])))
else:
setattr(sym, k, float(params[v]))
return sym
def render_map(self):
self.map_tmp_file = NamedTemporaryFile()
mapnik.render_to_file(self,
self.map_tmp_file.name,
'png')
def zoom_to_layers_box(self):
box = self.create_box(self.upperleft, self.lowerright)
self.zoom_to_box(box)
def create_box(self, upperleft, lowerright):
upperleft, lowerright = utils.get_coords(upperleft, lowerright, False)
upperleft = MapFiller.transform_point(upperleft)
lowerright = MapFiller.transform_point(lowerright)
coords = upperleft + lowerright
return mapnik.Box2d(*coords)
@staticmethod
def transform_point(coords):
return list(transform(consts.IN_PROJ,
consts.OUT_PROJ,
*coords))
def list_comp_map(self, list_of_points):
return map(lambda c: [self.transform_point(p) for p in c],
list_of_points)
map_imager.py
from cStringIO import StringIO
from math import ceil
import urllib
from PIL import Image
from . import utils
class BaseMapImager(object):
zoom = None
upperleft = None
lowerright = None
maxsize = 450
scale = 1
bottom = 0
encoded_delimeter = '%2C'
def __init__(self, *args, **kwargs):
self.unpack_kwargs(**kwargs)
self.valid_params()
self.set_coords_angles_of_image()
def valid_params(self):
if not hasattr(self, 'upperleft') or not hasattr(self, 'lowerright'):
raise Exception('Not enough params, need lowerright and upperleft')
def unpack_kwargs(self, **kwargs):
for key in kwargs:
setattr(self, key, kwargs[key])
def init_image(self):
self.create_parent_image()
self.fill_image()
return self.parent_image
def create_parent_image(self):
size = (int(self.dx), int(self.dy))
self.parent_image = Image.new("RGBA", size)
def load_image(self, url):
f = urllib.urlopen(url)
return Image.open(StringIO(f.read()))
def fill_image(self):
for x in range(self.cols):
for y in range(self.rows):
self.fill_in_position(x, y)
def set_coords_angles_of_image(self):
ullat, ullon = map(float, self.upperleft.split(','))
lrlat, lrlon = map(float, self.lowerright.split(','))
self.coords = {'upperleft_lat': ullat,
'upperleft_lon': ullon,
'lowerright_lat': lrlat,
'lowerright_lon': lrlon}
class MapImager(BaseMapImager):
def __init__(self, *args, **kwargs):
super(MapImager, self).__init__(*args, **kwargs)
self.set_size_of_image()
self.get_cols_rows()
self.set_sizes_of_chunk(self.bottom)
def set_size_of_image(self):
coords = self.coords
upperleft_x_y = utils.latlontopixels(coords['upperleft_lat'],
coords['upperleft_lon'],
self.zoom)
self.upperleft_x, self.upperleft_y = upperleft_x_y
lowerright_x_y = utils.latlontopixels(coords['lowerright_lat'],
coords['lowerright_lon'],
self.zoom)
self.lowerright_x, self.lowerright_y = lowerright_x_y
self.dx = self.lowerright_x - self.upperleft_x
self.dy = self.upperleft_y - self.lowerright_y
def get_cols_rows(self):
self.cols = int(ceil(self.dx / self.maxsize))
self.rows = int(ceil(self.dy / self.maxsize))
def set_sizes_of_chunk(self, bottom):
self.largura = int(ceil(self.dx / self.cols))
self.altura = int(ceil(self.dy / self.rows))
self.alturaplus = self.altura + bottom
def set_position(self, x, y):
dxn = self.largura * (0.5 + x)
dyn = self.altura * (0.5 + y)
px = self.upperleft_x + dxn
py = self.upperleft_y - dyn - self.bottom / 2
latn, lonn = utils.pixelstolatlon(px, py, self.zoom)
return self.latn_lonn_to_string(latn, lonn)
def latn_lonn_to_string(self, latn, lonn):
return ','.join((str(latn), str(lonn)))
def fill_in_position(self, x, y):
position = self.set_position(x, y)
urlparams = self.get_url_params(position)
url = self.url + urlparams
image_inst = self.load_image(url)
self.parent_image.paste(image_inst,
(int(x * self.largura),
int(y * self.altura)))
class GoogleImager(MapImager):
"""
interprate for google
"""
url = 'http://maps.google.com/maps/api/staticmap?'
maxsize = 640
def get_url_params(self, position):
url = urllib.urlencode({'center': position,
'zoom': str(self.zoom),
'size': '%dx%d' % (self.largura,
self.alturaplus),
'maptype': self.map_type,
'scale': self.scale})
return url.replace(self.encoded_delimeter, ',')
class YandexImager(MapImager):
url = 'https://static-maps.yandex.ru/1.x/?'
maxsize = 450
# def load_image(url):
# return urllib.urlopen(url)
def latn_lonn_to_string(self, latn, lonn):
return ','.join((str(lonn), str(latn)))
def get_url_params(self, position):
url = urllib.urlencode({'ll': position,
'z': str(self.zoom),
'size': '%d,%d' % (self.largura,
self.alturaplus),
'l': self.map_type,
'scale': self.scale})
return url.replace(self.encoded_delimeter, ',')
class GoogleMapImager(GoogleImager):
map_type = 'roadmap'
class GoogleSatImager(GoogleImager):
map_type = 'satellite'
class YandexMapImager(YandexImager):
map_type = 'map'
class YandexSatImager(YandexMapImager):
map_type = 'sat'
class TwoGisMapImager(MapImager):
url = 'http://static.maps.2gis.com/1.0?'
maxsize = 1200
def latn_lonn_to_string(self, latn, lonn):
return ','.join((str(lonn), str(latn)))
def get_url_params(self, position):
url = urllib.urlencode({'center': position,
'zoom': str(self.zoom),
'size': '%d,%d' % (self.largura,
self.alturaplus)})
return url.replace(self.encoded_delimeter, ',')
class OSMMapImager(MapImager):
access_token = 'pk.eyJ1IjoiZGVubnk1MzEiLCJhIjoiY2l3NHhlbjkwMDAwcTJ0bzRzc3p0bmNxaCJ9.QG39g1_q4GANnTPVIizKEg'
url = 'https://api.mapbox.com/v4/mapbox.emerald/'
maxsize = 1280
def latn_lonn_to_string(self, latn, lonn):
return ','.join((str(lonn), str(latn)))
def get_url_params(self, position):
url = '%s,%s/%sx%s.png?' % (position,
self.zoom,
self.largura,
self.altura)
url += urllib.urlencode({'access_token': self.access_token})
return url.replace(self.encoded_delimeter, ',')
class RosreestrImager(MapImager):
maxsize = 2048
url = 'http://pkk5.rosreestr.ru/arcgis/rest/services/Cadastre/Cadastre/MapServer/export?'
layers = 'show:0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24'
bboxSR = 4326
imageSR = 3857
size = '2048,2048'
format = 'png24'
transparent = True
f = 'image'
dpi = 15
bbox = None
def __init__(self, *args, **kwargs):
super(RosreestrImager, self).__init__(*args, **kwargs)
self.calculate_numbers_of_chunks()
self.calculate_delta_coords_image()
def calculate_delta_coords_image(self):
self.calculate_delta_lon()
self.calculate_delta_lat()
def calculate_delta_lon(self):
lowerright = self.coords['lowerright_lon']
upperleft = self.coords['upperleft_lon']
self.delta_lon = abs(upperleft - lowerright) / \
self.number_of_chunks
def calculate_delta_lat(self):
lowerright = self.coords['lowerright_lat']
upperleft = self.coords['upperleft_lat']
self.delta_lat = abs(upperleft - lowerright) / \
self.number_of_chunks
def latn_lonn_to_string(self, latn, lonn):
return ','.join((str(lonn), str(latn)))
def get_image_size(self):
return '%s,%s' % (self.maxsize, self.maxsize)
def calculate_numbers_of_chunks(self):
self.number_of_chunks = self.detail_level + 1
def set_position(self, x, y):
ullon = self.coords['upperleft_lon'] + self.delta_lon * x
lrlon = ullon + self.delta_lon * (x + 1)
ullat = self.coords['upperleft_lat'] + self.delta_lon * y
lrlat = ullat + self.delta_lat * (y + 1)
return '%s,%s,%s,%s' % (ullon, ullat, lrlon, lrlat)
def get_url_params(self, position):
url = urllib.urlencode({'layers': self.layers,
'bboxSR': self.bboxSR,
'imageSR': self.imageSR,
'size': self.get_image_size(),
'format': self.format,
'transparent': self.transparent,
'f': self.f,
'dpi': self.dpi,
'bbox': position})
return url.replace(self.encoded_delimeter, ',')
def select_map_image(name):
map_hash = {'google_map': GoogleMapImager,
'google_sat': GoogleSatImager,
'yandex_map': YandexMapImager,
'yandex_sat': YandexSatImager,
'2gis': TwoGisMapImager,
'osm': OSMMapImager}
return map_hash[name]
def create_map_image(map_lay):
map_lay.render_map()
return PIL.Image.open(map_lay.map_tmp_file.name)
rosreestr.py
import urllib
from PIL import Image
from .map_imager import BaseMapImager
class RosreestrImager(BaseMapImager):
# longitude binded to x coordinates
# latitude to y
maxsize = 2048
url = 'http://pkk5.rosreestr.ru/arcgis/rest/services/Cadastre/Cadastre/MapServer/export?'
layers = 'show:0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24'
bboxSR = 4326
imageSR = 3857
size = '2048,2048'
format = 'png24'
transparent = True
f = 'image'
dpi = 15
bbox = None
def __init__(self, **kwargs):
super(RosreestrImager, self).__init__(**kwargs)
self.normalize_angles()
self.calculate_bbox()
self.calculate_numbers_of_chunks()
self.calculate_delta_coords_image()
self.create_parent_image()
def normalize_angles(self):
self.normalize_angle('lowerright')
self.normalize_angle('upperleft')
def normalize_angle(self, name):
angle_attr = getattr(self, name)
tmp = angle_attr.split(',')
tmp.reverse()
setattr(self, name, ','.join(tmp))
def calculate_bbox(self):
self.bbox = self.upperleft + ',' + self.lowerright
def calculate_numbers_of_chunks(self):
self.number_of_chunks = self.detail_level + 1
def calculate_delta_coords_image(self):
self.calculate_delta_lon()
self.calculate_delta_lat()
def calculate_delta_lon(self):
lowerright = self.coords['lowerright_lon']
upperleft = self.coords['upperleft_lon']
self.delta_lon = abs(upperleft - lowerright) / \
self.number_of_chunks
def calculate_delta_lat(self):
lowerright = self.coords['lowerright_lat']
upperleft = self.coords['upperleft_lat']
self.delta_lat = abs(upperleft - lowerright) / \
self.number_of_chunks
def create_parent_image(self):
self.parent_image = Image.new("RGBA")
def create_image(self):
for x in xrange(1, self.number_of_chunks + 1):
for y in xrange(1, self.number_of_chunks + 1):
self.create_chunk_image(x, y)
def create_chunk_image(self, x, y):
bbox = self.calculate_bbox_chunk(x, y)
urlparams = self.get_url_params(bbox)
url = self.url + urlparams
img = self.load_image(url)
def calculate_bbox_chunk(self, x, y):
ullon = self.coords['upperleft_lon']
lrlon = ullon + self.delta_lon * x
ullat = self.coords['upperleft_lat']
lrlat = ullat + self.delta_lat * y
return '%s,%s,%s,%s' % (ullon, ullat, lrlon, lrlat)
def init_image(self):
urlparams = self.get_url_params()
url = self.url + urlparams
return self.load_image(url)
def get_url_params(self, bbox=None):
url = urllib.urlencode({'layers': self.layers,
'bboxSR': self.bboxSR,
'imageSR': self.imageSR,
'size': self.get_image_size(),
'format': self.format,
'transparent': self.transparent,
'f': self.f,
'dpi': self.dpi,
'bbox': bbox or self.bbox})
return url.replace(self.encoded_delimeter, ',')
def get_image_size(self):
return '%s,%s' % (self.maxsize, self.maxsize)
utils.py
from math import pi, log, tan, atan, exp
import urllib2
import json
from . import consts
from .exceptions import GeometryTypeError
def latlontopixels(lat, lon, zoom):
mx = (lon * consts.ORIGIN_SHIFT) / 180.0
my = log(tan((90 + lat) * pi / 360.0)) / (pi / 180.0)
my = (my * consts.ORIGIN_SHIFT) / 180.0
res = consts.INITIAL_RESOLUTION / (2**zoom)
px = (mx + consts.ORIGIN_SHIFT) / res
py = (my + consts.ORIGIN_SHIFT) / res
return px, py
def pixelstolatlon(px, py, zoom):
"""
convert resolution of image to coordinates
"""
res = consts.INITIAL_RESOLUTION / (2**zoom)
mx = px * res - consts.ORIGIN_SHIFT
my = py * res - consts.ORIGIN_SHIFT
lat = (my / consts.ORIGIN_SHIFT) * 180.0
lat = 180 / pi * (2 * atan(exp(lat * pi / 180.0)) - pi / 2.0)
lon = (mx / consts.ORIGIN_SHIFT) * 180.0
return lat, lon
def get_coords(upperleft, lowerright, concat=True):
"""
convert bounds coordinates strings to array of coordinates
"""
upperleft = coords_string_to_float(upperleft, reverse=True)
# upperleft = coords_string_to_float(upperleft)
lowerright = coords_string_to_float(lowerright, reverse=True)
if concat:
return upperleft + lowerright
else:
return upperleft, lowerright
def coords_string_to_float(coord, reverse=False):
"""
string coordiantes to float
"""
values = coord.split(',')
result = [float(v) for v in values]
if reverse:
result.reverse()
return result
def map_geom_data(obj):
"""
from url and styles hash
create object that include
geomatry of object and styles
"""
response = urllib2.urlopen(obj['url']).read()
response = json.loads(response)
layer = {}
if 'features' in response:
layer['geom'] = [geom['geometry'] for geom in response['features']]
else:
msg = 'Invalid geojson'
raise GeometryTypeError(msg)
if 'style' in obj:
layer['style'] = obj['style']
else:
layer['style'] = {}
return layer
views.py
# -*- coding: utf-8 -*-
from django.http import HttpResponse
from django.views.generic import View
import json
from .map_imager import select_map_image, create_map_image, RosreestrImager
from .map_filler import MapFiller
from . import utils
from .concat import concat_images
from .mixins import respond_as_attachment
class PrintLayView(View):
def get(self, request, *args, **kwargs):
self.init_data(request)
if self.include_rosreestr:
self.create_rosreestr_image()
try:
self.create_map_image()
except IOError:
return HttpResponse('Произошла ошибка. Попробуй задать меньше "Максимальный размер плитки (тайла)"')
self.create_lay_image()
image_stream = concat_images(self.img, self.lay, self.img_rosreestr)
return respond_as_attachment(request, image_stream)
def init_data(self, request):
self.img_rosreestr = None
self.data = json.loads(request.GET['data'])
self.valid_data()
self.unpack_data()
def valid_data(self):
if 'layersProps' not in self.data:
return HttpResponse('Data is not a valid, need `layersProps`')
if 'mapName' not in self.data:
return HttpResponse('Data is not a valid, need `mapName`')
def unpack_data(self):
self.layers_props = json.loads(self.data['layersProps'])
self.upperleft = self.data['upperleft']
self.lowerright = self.data['lowerright']
self.detail_level = int(self.data['detailLevel'])
self.zoom = int(self.data['zoom'])
self.zoom += self.detail_level
self.map_name = self.data['mapName']
self.include_rosreestr = self.data['includeRosreestr']
def create_rosreestr_image(self):
rosreestr_imager = RosreestrImager(upperleft=self.upperleft,
lowerright=self.lowerright,
detail_level=self.detail_level,
zoom=self.zoom)
self.img_rosreestr = rosreestr_imager.init_image()
def create_map_image(self):
map_imager = select_map_image(self.map_name)
self.imager = map_imager(upperleft=self.upperleft,
lowerright=self.lowerright,
zoom=self.zoom)
self.img = self.imager.init_image()
def create_lay_image(self):
layers = map(utils.map_geom_data,
self.layers_props)
Map = MapFiller(self.imager)
Map.filling_map(layers)
Map.zoom_to_layers_box()
self.lay = create_map_image(Map)
mixins.py
from django.http import HttpResponse
import mimetypes
import os
import urllib
import uuid
def respond_as_attachment(request, file_stream):
"""
mixin that return file like stream
"""
original_filename = str(uuid.uuid4()) + '.png'
response = HttpResponse(file_stream.read())
file_stream.seek(0, os.SEEK_END)
response['Content-Length'] = file_stream.tell()
file_stream.close()
type, encoding = mimetypes.guess_type(original_filename)
if type is None:
type = 'application/octet-stream'
response['Content-Type'] = type
if encoding is not None:
response['Content-Encoding'] = encoding
if 'WebKit' in request.META['HTTP_USER_AGENT']:
filename_header = 'filename=%s' % original_filename.encode('utf-8')
elif 'MSIE' in request.META['HTTP_USER_AGENT']:
filename_header = ''
else:
encoded_name = original_filename.encode('utf-8')
filename_header = \
'filename*=UTF-8\'\'%s' % urllib.quote(encoded_name)
response['Content-Disposition'] = 'attachment; ' + filename_header
return response
-
1\$\begingroup\$ if you have this type of thing going on in your code if type == square then do this elseif type ==circle then do that, if you are checking for types and then responding - that's a no-no in OOP. it means you are missing a duck type. \$\endgroup\$BenKoshy– BenKoshy2017年06月01日 23:13:47 +00:00Commented Jun 1, 2017 at 23:13
-
\$\begingroup\$ here this may help: bkspurgeon.github.io/BKSpurgeon.github.io/duck-types \$\endgroup\$BenKoshy– BenKoshy2017年06月01日 23:54:02 +00:00Commented Jun 1, 2017 at 23:54
-
\$\begingroup\$ Thanks you, but I can't inherite from this geometry type classes, I may just appropriate lambda function for for every of this type, but I think ii's not a better idea. Or not? \$\endgroup\$Denny– Denny2017年06月02日 22:30:43 +00:00Commented Jun 2, 2017 at 22:30
-
\$\begingroup\$ here is another option: gist.github.com/BKSpurgeon/8d32e295278236f439cbe80ac332df6e \$\endgroup\$BenKoshy– BenKoshy2017年06月03日 00:38:33 +00:00Commented Jun 3, 2017 at 0:38
1 Answer 1
I agree with BKSpurgeon's duck type criticism of create_symbolizer()
. (Also, typo in msg
, with possible copy-n-paste error into epsg4326_to_3857()
.)
In consts.py MAP_SRS, I see +a=6378137 +b=6378137
, but was hoping to see +a=%d +b=%d
with string formatting filling them in from EARTH_RADIUS.
Otherwise, it looks pretty good.
Explore related questions
See similar questions with these tags.