Jump to content

Module:Coordinates

From Wikivoyage

Documentation for this module may be created at Module:Coordinates/doc

-- Coordinate conversion procedures
-- This module is intended to replace the functionality of MapSources extension
-- designed for use both in modules and for direct invoking

-- functions for use in modules
--  toDec( coord, aDir, prec )
--   returns a decimal coordinate from decimal or deg-min-sec-letter strings
--  getDMSString( coord, prec, aDir, plus, minus, aFormat )
--   formats a decimal/dms coordinate to a deg-min-sec-letter string
--  getGeoLink( pattern, lat, long, plusLat, plusLong, minusLat, minusLong, prec, aFormat )
--   converts a complete dms geographic coordinate without reapplying the toDec function
--  getDecGeoLink( pattern, lat, long, prec )
--   converts a complete decimal geographic coordinate without reapplying the toDec function

-- Invokable functions
--  dec2dms( frame )
--  dms2dec( frame )
--  geoLink( frame )

-- documentation
local Coordinates = {
	suite  = 'Coordinates',
	serial = '2020-08-18',
	item   = 7348344
}
 
-- module import
local ci = require( 'Module:Coordinates/i18n' )

-- module variable
local cd = {}

-- helper function getErrorMsg
-- returns error message by error number which
local function getErrorMsg( which )
	if which == 'noError' or which == 0 then
		return ci.errorMsg.noError
	elseif which > #ci.errorMsg then
		return ci.errorMsg.unknown
	else
		return ci.errorMsg[ which ]
	end
end

-- helper function round
-- num: value to round
-- idp: number of digits after the decimal point
local function round( n, idp )
	local m = 10^( idp or 0 )
	if n >= 0 then
		return math.floor( n * m + 0.5 ) / m
	else
		return math.ceil( n * m - 0.5 ) / m
	end
end

-- helper function getPrecision
-- returns integer precision number
-- possible values: numbers, D, DM, DMS
-- default result: 4
local function getPrecision( prec )
	local p = tonumber( prec )
	if p then
		p = round( p, 0 )
		if p < -1 then
			p = -1
		elseif p > 8 then -- maximum 8 decimals
			p = 8
		end
		return p
	else
		p = prec and prec:upper() or 'DMS'
		if p == 'D' then
			return 0
		elseif p == 'DM' then
			return 2
		else
			return 4 -- DMS = default
		end
	end
end

-- helper function toDMS
-- splits a decimal coordinate dec to degree, minute and second depending on the
-- precision. prec <= 0 means only degree, prec < 3 degree and minute, and so on
-- returns a result array
local function toDMS( dec, prec )
	local result = { dec = 0, deg = 0, min = 0, sec = 0, sign = 1,
		NS = 'N', EW = 'E', prec = getPrecision( prec ) }
	local p = result.prec
	
	result.dec = round( dec, 8 )
	if result.dec < 0 then 
		result.sign = -1
		result.NS = 'S'
		result.EW = 'W'
	end

	local angle = math.abs( round( result.dec, p ) )
	result.deg = math.floor( angle )
	result.min = ( angle - result.deg ) * 60

	if p > 4 then
		result.sec = round( ( result.min - math.floor( result.min ) ) * 60, p - 4 )
	else
		result.sec = round( ( result.min - math.floor( result.min ) ) * 60 )
	end
	result.min = math.floor( result.min )
	
	if result.sec >= 60 then
		result.sec = result.sec - 60
		result.min = result.min + 1 
	end
	if p < 3 and result.sec >= 30 then
		result.min = result.min + 1
	end
	if p < 3 then
		result.sec = 0
	end
	if result.min >= 60 then
		result.min = result.min - 60
		result.deg = result.deg + 1
	end
	if p < 1 and result.min >= 30 then
		result.deg = result.deg + 1
	end
	if p < 1 then
		result.min = 0
	end

	return result
end

-- toDec converts decimal and hexagesimal DMS formatted coordinates to decimal
-- coordinates
-- input
--  dec: coordinate
--  prec: number of digits after the decimal point
--  aDir: lat/long directions
-- returns a result array
-- output
--  dec: decimal value
--  error: error number
--  parts: number of DMS parts, usually 1 (already decimal) ... 4
function cd.toDec( coord, aDir, prec )
	local result = { dec = 0, error = 0, parts = 1 }

	local s = mw.text.trim( coord )
	if s == '' then
		result.error = 1
		return result
	end
	
	-- pretest if already a decimal
	local dir = aDir or ''
	local r = tonumber( s )
	if r then
		if dir == 'lat' and ( r < -90 or r > 90 ) then
			result.error = 13
			return result
		elseif r <= -180 or r > 180 then
			result.error = 5
			return result
		end
		result.dec = round( r, getPrecision ( prec ) )
		return result
	end

	s = mw.ustring.gsub( s, '[‘’′´`]', "'" )
	s = s:gsub( "''", '"' )
	s = mw.ustring.gsub( s, '[“”″]', '"' )
	s = mw.ustring.gsub( s, '[−–—]', '-' )
	s = mw.ustring.upper( mw.ustring.gsub( s, '[_/%c%s%z]', ' ' ) )
	local mStr = '^[ %.%-°\'"0-9' -- string to match, illegal characters?
	for key, value in pairs( ci.inputLetters ) do
		mStr = mStr .. key
	end
	mStr = mStr .. ']+$'
	if not mw.ustring.match( s, mStr ) then
		result.error = 3
		return result
	end	
	s = mw.ustring.gsub( s, '(%u)', ' %1' )
	s = mw.ustring.gsub( s, '%s*([°"\'])', '%1 ' )
	s = mw.text.split( s, '%s' )
	for i = #s, 1, -1 do
		if mw.text.trim( s[ i ] ) == '' then
			table.remove( s, i )
		end
	end
	result.parts = #s

	if #s < 1 or #s > 4 then
		result.error = 2
		return result
	end

	local units = { '°', "'", '"', ' ' }
	local res   = { 0, 0, 0, 1 } -- 1 = positive direction

	local v
	local l
	for i = 1, #s, 1 do
		v = mw.ustring.gsub( s[ i ], units[ i ], '' )

		if tonumber( v ) then
			if i > 3 then -- this position is for direction letter, not for number
				result.error = 4
				return result
			end

			v = tonumber( v )
			if i == 1 then
				if v <= -180 or v > 180 then
					result.error = 5
					return result
				end
				res[ 1 ] = v
			elseif i == 2 or i == 3 then
				if v < 0 or v >= 60 then
					result.error = 2 + 2 * i
					return result
				end
				if res[ i - 1 ] ~= round( res[ i - 1 ], 0 ) then
					result.error = 3 + 2 * i
					return result
				end
				res[ i ] = v
			end
		else -- no number
			if i ~= #s then -- allowed only at the last position
				result.error = 10
				return result
			end
			if res[ 1 ] < 0 then
				result.error = 11
				return result
			end
			l = ci.inputLetters[ v ]
			if mw.ustring.len( v ) ~= 1 or not l then
				result.error = 3
				return result
			end

			-- l[1]: factor
			-- l[2]: lat/long
			if ( dir == 'long' and l[ 2 ] ~= 'long' ) or
				( dir == 'lat' and l[ 2 ] ~= 'lat' ) then
				result.error = 12
				return result
			else
				dir = l[ 2 ]
			end

			res[ 4 ] = l[ 1 ]
		end
	end

	if dir == 'lat' and ( res[ 1 ] < -90 or res[ 1 ] > 90 ) then
		result.error = 13
		return result
	end

	if res[ 1 ] >= 0 then
		result.dec = ( res[ 1 ] + res[ 2 ] / 60 + res[ 3 ] / 3600 ) * res[ 4 ]
	else
		result.dec = ( res[ 1 ] - res[ 2 ] / 60 - res[ 3 ] / 3600 ) * res[ 4 ]
	end
	result.dec = round( result.dec, getPrecision ( prec ) )
	return result
end

-- getDMSString formats a degree-minute-second string for output in accordance
-- to a given format specification
-- input
--  coord: decimal or hexagesimal DMS coordinate
--  prec: precion of the coorninate string: D, DM, DMS
--  aDir: lat/long direction to add correct direction letters
--  plus: alternative direction string for positive directions
--  minus: alternative direction string for negative directions
--  aFormat: format array with delimiter and leadZeros values or a predefined
--  dmsFormats key. Default format key is f1.
-- outputs 3 results
--  1: formatted string or error message for display
--  2: decimal coordinate
--  3: absolute decimal coordinate including the direction letter like 51.2323_N
function cd.getDMSString( coord, prec, aDir, aPlus, aMinus, aFormat )
	local d = aDir or ''
	local p = aPlus or ''
	local m = aMinus or ''

	-- format
	local f = aFormat or 'f1'
	if type( f ) ~= 'table' then
		f = ci.dmsFormats[ f ]
	end
	local del = f.delimiter or ' '
	local lz = f.leadZeros or false

	local c = { dec = tonumber( coord ), error = 0, parts = 1 } 
	if not c.dec then
		c = cd.toDec( coord, d, 8 )
	elseif c.dec <= -180 or c.dec > 180 then
		c.error = 5
	elseif d == 'lat' and ( c.dec < -90 or c.dec > 90 ) then
		c.error = 5 
	end

	local l = ''
	local wp = ''
	local result = ''
	if c.error == 0 then
		local dms = toDMS( c.dec, prec )
		if dms.dec < 0 and d == '' and m == '' then
			dms.deg = -dms.deg
		end

		if lz and dms.min < 10 then
			dms.min = '0' .. dms.min
		end
		if lz and dms.sec < 10 then
			dms.sec = '0' .. dms.sec
		end
		result = dms.deg .. '°'
		if dms.prec > 0 then
			if ((dms.sec ~= '00') and (dms.sec ~= '0') and (dms.sec ~= 0)) or ((dms.min ~= '00') and (dms.min ~= '0') and (dms.min ~= 0)) then
				result = result .. del .. dms.min .. '′'
			end
		end
		if dms.prec > 2 and dms.prec < 5 then
			if (dms.sec ~= '00') and (dms.sec ~= '0') and (dms.sec ~= 0) then
				result = result .. del .. dms.sec .. '″'
			end
		end
		if dms.prec > 4 then 
			-- enforce sec decimal digits even if zero
			local s = string.format( "%." .. dms.prec - 4 .. "f″", dms.sec )
			if ci.decimalPoint ~= '.' then 
				s = mw.ustring.gsub( s, '%.', ci.decimalPoint )
			end
			result = result .. del .. s
		end
		
		if d == 'lat' then
			wp = dms.NS
		elseif d == 'long' then
			wp = dms.EW
		end
		if dms.dec >= 0 and p ~= '' then
			l = p
		elseif dms.dec < 0 and m ~= '' then
			l = m
		else
			l = ci.outputLetters[ wp ]
		end

		if l and l ~= '' then
			result = result .. del .. l
		end
		if c.parts > 1 then
			result = result .. ci.categories.dms
		end
		
		return result--, dms.dec, math.abs( dms.dec ) .. '_' .. wp
	else
		if d == 'lat' then
			wp = 'N'
		elseif d == 'long' then
			wp = 'E'
		end
		result = '<span class="error" title="' .. getErrorMsg( c.error ) ..'">'
			.. ci.errorMsg.faulty .. '</span>' .. ci.categories.faulty
		return result, '0', '0_' .. wp
	end
	return result	
end

-- getGeoLink returns complete dms geographic coordinate without reapplying the toDec
-- and toDMS functions. Pattern can contain placeholders $1 ... $6
--  $1: latitude in Wikipedia syntax including the direction letter like 51.2323_N
--  $2: longitude in Wikipedia syntax including the direction letter like 51.2323_E
--  $3: latitude in degree, minute and second format considering the strings for
--      the cardinal directions and the precision
--  $4: longitude in degree, minute and second format considering the strings
--      for the cardinal directions and the precision
--  $5: latitude
--  $6: longitude
-- aFormat: format array with delimiter and leadZeros values or a predefined
-- dmsFormats key. Default format key is f1.
-- outputs 3 results
--  1: formatted string or error message for display
--  2: decimal latitude
--  3: decimal longitude
function cd.getGeoLink( pattern, lat, long, plusLat, plusLong, minusLat,
	minusLong, prec, aFormat )
	local lat_s, lat_dec, lat_wp =
		cd.getDMSString( lat, prec, 'lat', plusLat, minusLat, aFormat )
	local long_s, long_dec, long_wp =
		cd.getDMSString( long, prec, 'long', plusLong, minusLong, aFormat )
		
	local s = pattern
	s = mw.ustring.gsub( s, '($1)', lat_wp )
	s = mw.ustring.gsub( s, '($2)', long_wp )
	s = mw.ustring.gsub( s, '($3)', lat_s )
	s = mw.ustring.gsub( s, '($4)', long_s )
	s = mw.ustring.gsub( s, '($5)', lat_dec )
	s = mw.ustring.gsub( s, '($6)', long_dec )
	
	return s, lat_dec, long_dec
end

-- getDecGeoLink returns complete decimal geographic coordinate without reapplying
-- the toDec function. Pattern can contain placeholders $1 ... $4
function cd.getDecGeoLink( pattern, lat, long, prec )

	local function getDec( coord, prec, aDir, aPlus, aMinus )
		local l = aPlus
		local c = cd.toDec( coord, aDir, 8 )
		if c.error == 0 then
			if c.dec < 0 then
				l = aMinus
			end
			local d = round( c.dec, prec ) .. ''
			if ci.decimalPoint ~= '.' then 
				d = mw.ustring.gsub( d, '%.', ci.decimalPoint )
			end
			return d, math.abs( c.dec ) .. '_' .. l
		else
			c.dec = '<span class="error" title="' .. getErrorMsg( c.error ) ..'">'
				.. ci.errorMsg.faulty .. '</span>' .. ci.categories.faulty
			return c.dec, '0_' .. l
		end
	end

	local lat_dec, lat_wp = getDec( lat, prec, 'lat', 'N', 'S' )
	local long_dec, long_wp = getDec( long, prec, 'long', 'E', 'W' )

	local s = pattern
	s = mw.ustring.gsub( s, '($1)', lat_wp)
	s = mw.ustring.gsub( s, '($2)', long_wp)
	s = mw.ustring.gsub( s, '($3)', lat_dec)
	s = mw.ustring.gsub( s, '($4)', long_dec)

	return s, lat_dec, long_dec
end

-- Invokable functions

-- identical to MapSources #dd2dms tag
-- frame input
--  1 or coord: decimal or hexagesimal coordinate
--  precision: precion of the coorninate string: D, DM, DMS
--  plus: alternative direction string for positive directions
--  minus: alternative direction string for negative directions
--  format: Predefined dmsFormats key. Default format key is f1.
function cd.dec2dms( frame )
	local args     = frame:getParent().args
	args.coord     = args[ 1 ] or args.coord or ''
	args.precision = args[ 2 ] or args.precision or ''

	return cd.getDMSString( args.coord, args.precision, '',
		args.plus, args.minus, args.format )
end

-- identical to MapSources #deg2dd tag
function cd.dms2dec( frame )
	local args     = frame:getParent().args
	args.coord     = args[ 1 ] or args.coord or ''
	args.precision = args[ 2 ] or args.precision or ''

	local r = cd.toDec( args.coord, '', args.precision )
	local s = r.dec
	if r.error ~= 0 then
		if args.coord == '' then
			s = ci.categories.faulty
		else
			s = '<span class="error" title="' .. getErrorMsg( r.error ) ..'">'
				.. ci.errorMsg.faulty .. '</span>' .. ci.categories.faulty
		end
	end
	return s
end

-- identical to MapSources #geoLink tag
-- This function can be extended to add Extension:GeoData #coordinates because
-- cd.getGeoLink returns lat and long, too
function cd.geoLink( frame )
	local args   = frame:getParent().args
	args.pattern = args[ 1 ] or args.pattern or ''
	if args.pattern == '' then
		return errorMsg[ 14 ]
	end

	return cd.getGeoLink( args.pattern, args.lat, args.long,
		args.plusLat, args.plusLong, args.minusLat, args.minusLong,
		args.precision, args.format )
end

return cd