2021-08-22 17:05:09 (UTC-03:00)
Marcel Rodrigues <marcelgmr@gmail.com>
add mapping utilities
diff --git a/map.lua b/map.lua new file mode 100644 index 0000000..5e56549 --- /dev/null +++ b/map.lua @@ -0,0 +1,257 @@ +local abs = math.abs +local rad, deg = math.rad, math.deg +local cos, sin, tan = math.cos, math.sin, math.tan +local acos, asin, atan, atan2 = math.acos, math.asin, math.atan, math.atan2 +local sqrt = math.sqrt +local pi, huge = math.pi, math.huge + +-- == Utilities == + +-- region is a list of polygons in geographic coordinates. + +local function distance(lon1, lat1, lon2, lat2, r) + r = r or 6378137 + local dlat = rad(lat2 - lat1) + local dlon = rad(lon2 - lon1) + lat1, lat2 = rad(lat1), rad(lat2) + local a1, a2, a, c + a1 = sin(dlat/2) * sin(dlat/2) + a2 = sin(dlon/2) * sin(dlon/2) * cos(lat1) * cos(lat2) + a = a1 + a2 + c = 2 * atan2(sqrt(a), sqrt(1-a)) + return r * c +end + +local function bbox(region) + local x0, y0, x1, y1 = huge, huge, -huge, -huge + for _, polygon in ipairs(region) do + for _, point in ipairs(polygon) do + local x, y = unpack(point) + x0 = x < x0 and x or x0 + y0 = y < y0 and y or y0 + x1 = x > x1 and x or x1 + y1 = y > y1 and y or y1 + end + end + return x0, y0, x1, y1 +end + +local function centroid(region) + local epsilon = 1e-10 + local x0, y0, x1, y1 = bbox(region) + local lon0 = (x0 + x1) / 2 + local lat0 = (y0 + y1) / 2 + local lon1, lat1 + while true do + local prj = Proj("AzimuthalEqualArea", {lon0, lat0}) + local cw = {} + for i, polygon in ipairs(region) do + local xys = {} + for j, point in ipairs(polygon) do + xys[j] = {prj:map(unpack(point))} + end + if xys[#xys][0] ~= xys[1][0] or xys[#xys][1] ~= xys[1][1] then + xys[#xys+1] = xys[1] + end + -- http://en.wikipedia.org/wiki/Centroid#Centroid_of_polygon + local cx, cy, sa = 0, 0, 0 + for j = 1, #xys-1 do + local x0, y0 = unpack(xys[j]) + local x1, y1 = unpack(xys[j+1]) + local f = x0 * y1 - x1 * y0 + cx = cx + (x0 + x1) * f + cy = cy + (y0 + y1) * f + sa = sa + f + end + cx = cx / (3 * sa) + cy = cy / (3 * sa) + cw[#cw+1] = {cx, cy, sa} + end + local cx, cy, sw = 0, 0, 0 + for i = 1, #cw do + local x, y, w = unpack(cw[i]) + cx = cx + x * w + cy = cy + y * w + sw = sw + w + end + cx = cx / sw + cy = cy / sw + lon1, lat1 = prj:inv(cx, cy) + if abs(lon1-lon0) <= epsilon and abs(lat1-lat0) <= epsilon then + break + end + lon0, lat0 = lon1, lat1 + end + return lon1, lat1 +end + +-- == Projections == + +-- Lambert Azimuthal Equal-Area Projection for the Spherical Earth. + +local AzimuthalEqualArea = {} +AzimuthalEqualArea.__index = AzimuthalEqualArea + +function AzimuthalEqualArea:map(lon, lat) + lon, lat = rad(lon), rad(lat) + lon = lon - self.lon0 + local k, x, y + k = sqrt(2 / (1 + sin(self.lat0) * sin(lat) + cos(self.lat0) * cos(lat) * cos(lon))) + x = self.r * k * cos(lat) * sin(lon) + y = self.r * k * (cos(self.lat0) * sin(lat) - sin(self.lat0) * cos(lat) * cos(lon)) + return x, y +end + +function AzimuthalEqualArea:inv(x, y) + local p = sqrt(x*x + y*y) + local c = 2 * asin(p / (2 * self.r)) + local lon, lat + -- FIXME: In the formulas below, should it be atan or atan2? + if self.lat0 == pi / 2 then + -- North Polar Aspect. + lon = self.lon0 + atan(x/(-y)) + elseif self.lat0 == -pi / 2 then + -- South Polar Aspect. + lon = self.lon0 + atan(x/y) + else + -- Any other Oblique Aspect. + local den = p * cos(self.lat0) * cos(c) - y * sin(self.lat0) * sin(c) + lon = self.lon0 + atan(x * sin(c) / den) + end + lat = asin(cos(c) * sin(self.lat0) + y * sin(c) * cos(self.lat0) / p) + lon, lat = deg(lon), deg(lat) + return lon, lat +end + +function AzimuthalEqualArea:model() + return {type="sphere", r=self.r} +end + +local projs = { + AzimuthalEqualArea=AzimuthalEqualArea +} + +-- Generic projection interface. + +local function Proj(name, origin, radius) + local proj = {} + proj.name = name + proj.lon0, proj.lat0 = unpack(origin or {0, 0}) + proj.lon0, proj.lat0 = rad(proj.lon0), rad(proj.lat0) + proj.r = radius or 6378137 + return setmetatable(proj, projs[name]) +end + +-- == Frames == + +--[[ + A frame stores information that specify the relation between the Earth's +geometry (geodesy) and a particular map geometry (usually in 2D). Each map has a +frame over which multiple layers of entities are drawn. There are two parameters +that define a unique frame: +* a projection; +* a bounding box on the projected (plane) space. + + The projection parameter must be fully specified, including the center of the +projection, the orientation of the projection and the Earth model used (sphere +or ellipsoid) along with its parameters. + + The bounding box is specified as two points, determining minimal an maximal +coordinates. The coordinate system for the bounding box is the projected one, +but without scale, i.e. with meters as unit. +]] + +local sep = ":" + +local function save_frame(fname, model, projection, bounding) + local frm = io.open(fname, "w") + frm:write("type", sep, model.type, "\n") + if model.type == "ellipsoid" then + frm:write("a", sep, model.a, "\n") + frm:write("b", sep, model.b, "\n") + frm:write("e", sep, model.e, "\n") + frm:write("f", sep, model.f, "\n") + elseif model.type == "sphere" then + frm:write("r", sep, model.r, "\n") + end + frm:write("proj", sep, projection.name, "\n") + frm:write("lon", sep, math.deg(projection.lon0), "\n") + frm:write("lat", sep, math.deg(projection.lat0), "\n") + frm:write("x0", sep, bounding.x0, "\n") + frm:write("y0", sep, bounding.y0, "\n") + frm:write("x1", sep, bounding.x1, "\n") + frm:write("y1", sep, bounding.y1, "\n") + frm:close() +end + +local function load_frame(fname) + local frm = io.open(fname, "r") + local function get(field) + local line = frm:read() + local got = line:sub(1, #field) + assert(got == field, "expected field "..field.." but got "..got) + return line:sub(#field+#sep+1) + end + local model = {} + model.type = get "type" + if model.type == "ellipsoid" then + model.a = tonumber(get "a") + model.b = tonumber(get "b") + model.e = tonumber(get "e") + model.f = tonumber(get "f") + elseif model.type == "sphere" then + model.r = tonumber(get "r") + end + local projection = {} + projection.name = get "proj" + projection.lon = tonumber(get "lon") + projection.lat = tonumber(get "lat") + local bounding = {} + bounding.x0 = tonumber(get "x0") + bounding.y0 = tonumber(get "y0") + bounding.x1 = tonumber(get "x1") + bounding.y1 = tonumber(get "y1") + frm:close() + return model, projection, bounding +end + +local Frame = {} +Frame.__index = Frame + +function Frame:map(lon, lat) + local x, y = self.prj:map(lon, lat) + x = (x - self.bb.x0) * self.s + y = self.h - (y - self.bb.y0) * self.s + return x, y +end + +function Frame:set_height(h) + local mw = self.bb.x1 - self.bb.x0 + local mh = self.bb.y1 - self.bb.y0 + self.h = h + self.s = h / mh + self.w = math.floor(mw * self.s + 0.5) +end + +function Frame:mapped(points) + return coroutine.wrap(function() + for p in points do + local lat, lon = unpack(p) + local x, y = self:map(lat, lon) + coroutine.yield({x, y}) + end + end) +end + +local function new_frame(fname) + local self = setmetatable({}, Frame) + local m, p, b = load(fname) + self.bb = b + self.prj = proj.Proj(p.name, {p.lon, p.lat}, m.r) + return self +end + +return { + distance=distance, bbox=bbox, centroid=centroid, Proj=Proj, + save_frame=save_frame, load_frame=load_frame, new_frame=new_frame +}