Module:Coordinates

-- 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 = 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 = '' .. ci.errorMsg.faulty .. ' ' .. 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 = '' .. ci.errorMsg.faulty .. ' ' .. 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 = '' .. ci.errorMsg.faulty .. ' ' .. 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