Modul:Coordinates

Aus Stadtbahn-Wiki Bielefeld
Version vom 2. September 2021, 19:16 Uhr von StadtbahnBI>Verdy p
(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)
Zur Navigation springen Zur Suche springen

Die Dokumentation für dieses Modul kann unter Modul:Coordinates/Doku erstellt werden

--[[
  __  __           _       _         ____                    _ _             _            
 |  \/  | ___   __| |_   _| | ___ _ / ___|___   ___  _ __ __| (_)_ __   __ _| |_ ___  ___ 
 | |\/| |/ _ \ / _` | | | | |/ _ (_) |   / _ \ / _ \| '__/ _` | | '_ \ / _` | __/ _ \/ __|
 | |  | | (_) | (_| | |_| | |  __/_| |__| (_) | (_) | | | (_| | | | | | (_| | ||  __/\__ \
 |_|  |_|\___/ \__,_|\__,_|_|\___(_)\____\___/ \___/|_|  \__,_|_|_| |_|\__,_|\__\___||___/
                                                                                          

This module is intended to provide functionality of {{location}} and related
templates. It was developed on Wikimedia Commons, so if you find this code on
other sites, check there for updates and discussions.

Please do not modify this code without applying the changes first at Module:Coordinates/sandbox and testing 
at Module:Coordinates/sandbox/testcases and Module talk:Coordinates/sandbox/testcases.

Authors and maintainers:
* User:Jarekt
* User:Ebraminio

Functions:
*function p.LocationTemplateCore(frame)
**function p.GeoHack_link(frame)
***function p.lat_lon(frame)
****function p._deg2dms(deg,lang)
***function p.externalLink(frame)
****function p._externalLink(site, globe, latStr, lonStr, lang, attributes)
**function p._getHeading(attributes)
**function p.externalLinksSection(frame)
***function p._externalLink(site, globe, latStr, lonStr, lang, attributes)
*function p.getHeading(frame)  
*function p.deg2dms(frame)

]]

-- =======================================
-- === Dependencies ======================
-- =======================================
require('Module:No globals') -- used for debugging purposes as it detects cases of unintended global variables
local i18n = require('Module:I18n/coordinates') -- get localized translations of site names
local core = require('Module:Core')

-- Cached references to function from various libraries for performance
local langSwitch = core.langSwitch
local min, max, abs, floor, sqrt, pow = math.min, math.max, math.abs, math.floor, math.sqrt, math.pow
local PI, deg, rad, cos, sin, atan2 = math.pi, math.deg, math.rad, math.cos, math.sin, math.atan2
local upper, find, gsub, match, format = string.upper, string.find, string.gsub, string.match, string.format
local usub = mw.ustring.sub -- for linguistic formatting of numbers only
local decode, uriencode = mw.text.decode, mw.uri.encode

-- Common utilities

-- Like string.gsub but using plain-text and not Lua patterns in the two substitution arguments.
local function gsubPlain(text, pattern, replace, maxcount)
    -- Plain-text arguments are first converted to patterns before applying string.gsub to the given text.
	return gsub(text,
		gsub(pattern, '[%$%%%(%)%*%+%-%.%?%[%]%^]', '%%%0'),
		gsub(replace, '%%', '%%%%'), maxcount)
end

-- math.fmod() is incoherent: it uses using an Eudidian integer division towards 0 (truncating instead of using floor),
-- so with negative values of x (with a positive divisor y) it returns a negative remainder.
-- Using the operator (x % y) is not equivalent (it could be correct but in some versions of Lua, it has incorrect
-- roundings when operands are integers, due to incorrect internal "optimization" using integer arithmetic
-- but changing the rounding direction).
-- This version always returns a remainder with the same sign as y (not the same sign of x), using math.floor().
-- When y is positive (in this module it is always the case with y a constant), the return value always
-- will also be positive, in [0, y). When y is negative, the result will also be negative in (y, 0].
-- The return value will be NaN if any operand is infinite or NaN or if y is zero.
local function fmod(x, y)
	return x - floor(x / y) * y
end

-- =======================================
-- === Hardwired parameters ==============
-- =======================================

-- ===========================================================
-- Angles associated with each abbreviation of compass point names. See [[:en:Points of the compass]]
local compass_points = {
	N    = 0,
	NBE  = 11.25,
	NNE  = 22.5,
	NEBN = 33.75,
	NE   = 45,
	NEBE = 56.25,
	ENE  = 67.5,
	EBN  = 78.75,
	E    = 90,
	EBS  = 101.25,
	ESE  = 112.5,
	SEBE = 123.75,
	SE   = 135,
	SEBS = 146.25,
	SSE  = 157.5,
	SBE  = 168.75,
	S    = 180,
	SBW  = 191.25,
	SSW  = 202.5,
	SWBS = 213.75,
	SW   = 225,
	SWBW = 236.25,
	WSW  = 247.5,
	WBS  = 258.75,
	W    = 270,
	WBN  = 281.25,
	WNW  = 292.5,
	NWBW = 303.75,
	NW   = 315,
	NWBN = 326.25,
	NNW  = 337.5,
	NBW  = 348.75,
}

-- ===========================================================
-- files to use for different headings
local heading_icon = {
	[ 1] = 'File:Compass-icon bb N.svg',
	[ 2] = 'File:Compass-icon bb NbE.svg',
	[ 3] = 'File:Compass-icon bb NNE.svg',
	[ 4] = 'File:Compass-icon bb NEbN.svg',
	[ 5] = 'File:Compass-icon bb NE.svg',
	[ 6] = 'File:Compass-icon bb NEbE.svg',
	[ 7] = 'File:Compass-icon bb ENE.svg',
	[ 8] = 'File:Compass-icon bb EbN.svg',
	[ 9] = 'File:Compass-icon bb E.svg',
	[10] = 'File:Compass-icon bb EbS.svg',
	[11] = 'File:Compass-icon bb ESE.svg',
	[12] = 'File:Compass-icon bb SEbE.svg',
	[13] = 'File:Compass-icon bb SE.svg',
	[14] = 'File:Compass-icon bb SEbS.svg',
	[15] = 'File:Compass-icon bb SSE.svg',
	[16] = 'File:Compass-icon bb SbE.svg',
	[17] = 'File:Compass-icon bb S.svg',
	[18] = 'File:Compass-icon bb SbW.svg',
	[19] = 'File:Compass-icon bb SSW.svg',
	[20] = 'File:Compass-icon bb SWbS.svg',
	[21] = 'File:Compass-icon bb SW.svg',
	[22] = 'File:Compass-icon bb SWbW.svg',
	[23] = 'File:Compass-icon bb WSW.svg',
	[24] = 'File:Compass-icon bb WbS.svg',
	[25] = 'File:Compass-icon bb W.svg',
	[26] = 'File:Compass-icon bb WbN.svg',
	[27] = 'File:Compass-icon bb WNW.svg',
	[28] = 'File:Compass-icon bb NWbW.svg',
	[29] = 'File:Compass-icon bb NW.svg',
	[30] = 'File:Compass-icon bb NWbN.svg',
	[31] = 'File:Compass-icon bb NNW.svg',
	[32] = 'File:Compass-icon bb NbW.svg'
}

-- ===========================================================
-- URL definitions for different sites. Strings: $lat, $lon, $lang, $attr, $page will be replaced
-- with latitude, longitude, language code, GeoHack attributes, and current full page name.
local SiteURL = {
	GeoHack = '//geohack.toolforge.org/geohack.php?pagename=$page&params=$lat_N_$lon_E_$attr&language=$lang',
	--GoogleEarth = '//geocommons.toolforge.org/earth.kml?latdegdec=$lat&londegdec=$lon&scale=10000&commons=1',
	--Proximityrama = '//tools.wmflabs.org/geocommons/proximityrama?latlon=$lat,$lon',
	WikimediaMap = '//maps.wikimedia.org/#16/$lat/$lon',
	--OpenStreetMap1 = '//wiwosm.toolforge.org/osm-on-ol/commons-on-osm.php?zoom=16&lat=$lat&lon=$lon',
	OpenStreetMap1 = '//wikimap.toolforge.org/?wp=false&basemap=2&cluster=false&zoom=16&lat=$lat&lon=$lon',
	OpenStreetMap2 = '//tools.wmflabs.org/osm4wiki/cgi-bin/wiki/wiki-osm.pl?project=Commons&article=$page&l=$level',
	GoogleMaps = {
		Mars = '//www.google.com/mars/#lat=$lat&lon=$lon&zoom=8',
		Moon = '//www.google.com/moon/#lat=$lat&lon=$lon&zoom=8',
		Earth = '//tools.wmflabs.org/wp-world/googlmaps-proxy.php?page=' ..
			uriencode('http://tools.wmflabs.org/kmlexport/?project=Commons&article=', 'QUERY') ..
			'$prox&l=$level&output=classic'
	}
}

-- ===========================================================
-- Categories
local CoorCat = {
	-- File     = '[[Category:Media with locations]]',
	-- Gallery  = '[[Category:Galleries with coordinates]]',
	-- Category = '[[Category:Categories with coordinates]]',
	strucData0 = '[[Category:Pages with %s coordinates from %s]]',
	strucData1 = '[[Category:Pages with local %s coordinates and matching %s coordinates]]',
	strucData2 = '[[Category:Pages with local %s coordinates and similar %s coordinates]]',
	strucData3 = '[[Category:Pages with local %s coordinates and mismatching %s coordinates]]',
	strucData4 = '[[Category:Pages with local %s coordinates and missing %s coordinates]]',
	sHeading3  = '[[Category:Pages with local %s heading and mismatching %s heading]]',
	sHeading4  = '[[Category:Pages with local %s heading and missing %s heading]]',
	sHeading5  = '[[Category:Pages with local %s heading:0 and missing %s heading]]',
	globe      = '[[Category:Media with %s locations]]',
	default    = '[[Category:Media with default locations]]',
	attribute  = '[[Category:Media with erroneous geolocation attributes]]',
	erroneous  = '[[Category:Media with erroneous locations]]',
	dms        = '[[Category:Media with coordinates in DMS format]]'
}

local globeLUT = { Q2='Earth', Q111='Mars', Q405='Moon'}
local NoLatLonString = 'latitude, longitude'

-- =======================================
-- === Local Functions ===================
-- =======================================

-- ===========================================================
local function add_maplink(lat, lon, marker, text)
	local tstr = ''
	if text then
		tstr = format('text="%s" ', text)
	end
	return format('<maplink %szoom="13" latitude="%f" longitude="%f" class="no-icon">{' ..
		'"type":"Feature",' ..
		'"geometry":{"type":"Point","coordinates":[%f,%f]},' ..
		'"properties":{"marker-symbol":"%s","marker-size":"large","marker-color":"0050D0"}' ..
		'}</maplink>', tstr, lat, lon, lon, lat, marker)
end

-- ===========================================================
local function add_maplink2(lat1, lon1, lat2, lon2)
	return format('<maplink zoom="13" latitude="%f" longitude="%f" class="no-icon">[{'..
		'"type":"Feature",' ..
		'"geometry":{"type": "Point","coordinates":[%f,%f]},' ..
		'"properties":{"marker-symbol":"c","marker-size":"large","marker-color":"0050D0","title":"Location on Wikimedia Commons"}' ..
		'},{' ..
		'"type":"Feature",' ..
		'"geometry":{"type":"Point","coordinates":[%f,%f]},' ..
		'"properties":{"marker-symbol":"w","marker-size":"large","marker-color":"228B22","title":"Location on Wikidata"}' ..
		'}]</maplink>', lat2, lon2, lon1, lat1, lon2, lat2)
end

-- ===========================================================
local function info_box(text)
	return format(
        '<table class="messagebox plainlinks layouttemplate" style="clear:both;width:100%%;background:#FFE;' ..
        'border:2px solid #F28500;border-left-width:8px;border-collapse:collapse;direction:ltr"><tr>' ..
		'<td class="mbox-image" style="padding-left:.9em">[[File:Commons-emblem-issue.svg|class=noviewer|45px]]</td>' ..
		'<td class="mbox-text">%s</td></tr></table>',
        text)
end

-- ===========================================================
local function distance(lat1, lon1, lat2, lon2)
	-- calculate distance
	local dLat, dLon = rad(lat1 - lat2), rad(lon1 - lon2)
	local d =
	    pow(sin(dLat / 2), 2) +
	    pow(sin(dLon / 2), 2) * cos(rad(lat1)) * cos(rad(lat2))
	d = floor(
			atan2(sqrt(d), sqrt(1 - d)) * 2 -- angular distance in radians
			* 6371000 -- radians to meters conversion
		+ 0.5) -- round it to nearest integer of meters
	return d
end

-- ===========================================================
local function getSDCoords(entity, prop)
    -- get coordinates from structured data (either wikidata or SDC)
	local coords = {id=entity.id, source=prop}
	if not entity or not entity.claims or not entity.claims[prop]then 
		return coords
	end
	for _, statement in pairs( entity:getBestStatements( prop )) do
		local v = statement.mainsnak.datavalue.value	-- get coordinates
		if v.latitude then
			coords.lat   = v.latitude
			coords.lon   = v.longitude
			coords.prec  = v.precision or 1e-4
			coords.prec  = floor(coords.prec * 111000) -- convert precision from degrees to meters and round
			coords.prec  = max(min(coords.prec, 111000), 5) -- bound precision to a number between 5 meters and 1 degree
			coords.globe = gsubPlain(v.globe, 'http://www.wikidata.org/entity/', '')
			coords.globe = globeLUT[coords.globe]
			if statement.qualifiers and statement.qualifiers.P7787 then
				v = statement.qualifiers.P7787[1].datavalue.value
				if v.unit == "http://www.wikidata.org/entity/Q28390" then -- in degrees
					coords.heading = v.amount
				elseif v.unit == "http://www.wikidata.org/entity/Q33680" then -- in radians
					coords.heading = v.amount * 57.2957795131
				end	
			end			
			return coords
		end
	end
	return coords
end

-- ===========================================================
local function compareCoords(loc, sd, mode, source)
-- compare coordinates
--INPUTS:
--  * loc - local coordinates
--  * sd  - structured data coords
	local coord = loc
	local cat, dist_str = '', ''
	local case, mapLink, message

	if not loc.lat or not loc.lon then -- structured data/wikidata coordinates only
		coord = sd
		cat = format(CoorCat.strucData0, mode, source)
		case = 0
	elseif loc.lat and loc.lon and not sd.lat and not sd.lon then	
		cat = format(CoorCat.strucData4, mode, source)
		case = 4 -- local coordinates only
	elseif loc.lat and loc.lon and sd.lat and sd.lon then
		local dist = distance(loc.lat, loc.lon, sd.lat, sd.lon) -- calculate distance
		-- will be displayed when hovering a mouse above wikidata icon:
		dist_str = format(
			' (discrepancy of %i meters between the above coordinates and the ones stored on Wikidata)',
			dist)

		if dist < 20 or dist < sd.prec then -- will consider location within 20 meters or precision distance as the same
			if source == 'Wikidata' then
				cat = format(CoorCat.strucData1, mode, source)
			end
			case = 1
		elseif (dist < 1000 or dist < 5 * sd.prec) and mode == 'object' then 
			--cat = format(CoorCat.strucData2, mode, source)
			case = 2
		else -- locations 1 km off and 5 precision distances away are likely wrong. The issue might be with wrong precision
			mapLink = mw.getCurrentFrame():preprocess(add_maplink2(loc.lat, loc.lon, sd.lat, sd.lon)) -- fancy link to OSM
			message = format(
				'There is a discrepancy of %i meters between the above coordinates and the ones stored at ' ..
				'%s (%s, precision: %i m). Please [[Commons:Structured data/Reconciliation|reconcile them]]. ',
				dist, source, mapLink, sd.prec)	
			cat = format(CoorCat.strucData3, mode, source) .. info_box(message)
			case = 3
		end
	end
	if not loc.heading and sd.heading then -- structured data/wikidata heading only
		coord.heading = sd.heading
	elseif loc.heading == 0 and not sd.heading and sd.lat and sd.lon then -- local heading only
		cat = cat .. format(CoorCat.sHeading5, mode, source) 
	elseif loc.heading and not sd.heading and sd.lat and sd.lon then -- local heading only
		cat = cat .. format(CoorCat.sHeading4, mode, source) 
	elseif loc.heading and sd.heading then
		local dh = abs(fmod(loc.heading, 360) - fmod(sd.heading, 360))
		if dh > 1 and dh < 359 then
			message = format(
                "There is a discrepancy of %i degrees between the above camera heading (set to %i) and the ones stored at %s (set to %i). Please [[Commons:Structured data/Reconciliation|reconcile them]]. ",
                dh, loc.heading, source, sd.heading)
			cat = cat .. format(CoorCat.sHeading3, mode, source)  .. info_box(message)
		end
	end
    local qs
	if source == 'Wikidata' and case >= 3 then
        -- create full URL link
		qs = format(
            "[[File:Commons to Wikidata QuickStatements.svg|15px|link=%s|Copy geo coordinates to Wikidata]]",
		    'https://quickstatements.toolforge.org/#/v1=' ..
		    gsubPlain(uriencode(
                    format(
                        '%s|P625|@%09.5f/%09.5f|S143|Q565|S813|%s|S4656|"%s"',
                        sd.wID,
                        loc.lat, loc.lon,
                        '+' .. os.date('!%F') .. 'T00:00:00Z/11', -- today's date in QS format
                        mw.title.getCurrentTitle():canonicalUrl()),
                'QUERY'), '%2520', '%20'))
	end
	return coord, cat, { dist_str = dist_str, case = case, qs = qs }
end

-- ===========================================================
local LUT_NSEW = { N = 1, S = -1, E = 1, W = -1 } -- look up table
local function dms2deg_ ( d, m, s, h )
  	d, m, s, h = tonumber(d), tonumber(m), tonumber(s), LUT_NSEW[upper(h)]
  	if not(d and m and s and h) then
		return nil
	end
	return ((s / 60.0 + m) / 60.0 + d) * h
end

-- ===========================================================
local function dms2deg ( dms )
  	local ltab  = mw.text.split(dms:gsub("[°'′″\",%s]+" , "/" ):gsub("^%/", ""), "/")
  	local deg = dms2deg_ (ltab[1], ltab[2], ltab[3], ltab[4])
	--return dms .. '->' .. dms:gsub("[°'′″\",%s]+" , "/" ):gsub("^%/", "")  .. '->' .. (degre or 'nil')
	return deg or dms
end

-- =======================================
-- === External Functions ================
-- =======================================
local p = {}
p.debug = 'nothing'

-- parse attribute variable returning desired field (used for debugging)
function p.parseAttribute(frame)
	return match(decode(frame.args[1]), decode(frame.args[2]) .. ':' .. '([^_]*)') or ''
end

-- ===========================================================
-- Helper core function for getHeading. 
function p._getHeading(attributes)
	if attributes == nil then
		return nil
	end
	local hStr = match(decode(attributes), 'heading:([^_]*)')
	if hStr == nil then
		return nil
	end
	local hNum = tonumber(hStr)
	if hNum == nil then
		hStr = upper(hStr)
		hNum = compass_points[hStr]  
	end
	if hNum then
		hNum = fmod(hNum, 360)
	end
	return hNum
end

--[[============================================================================
Parse attribute variable returning heading field. If heading is a string than 
try to convert it to an angle
==============================================================================]]
function p.getHeading(frame)  
	local attributes
	if frame.args[1] then
		attributes = frame.args[1]
	elseif frame.args.attributes then
		attributes = frame.args.attributes
	else
		return ''
	end
	local hNum  = p._getHeading(attributes)
	if hNum == nil then
		return ''
	end
	return tostring(hNum)
end


--[[============================================================================
Helper core function for deg2dms. deg2dms can be called by templates, while 
_deg2dms should be called from Lua.
Inputs:
* degree - positive coordinate in degrees
* degPrec - coordinate precision in degrees will result in different angle format
* lang - language to used when formatting the number
==============================================================================]]
function p._deg2dms(degree, degPrec, lang)
	local Lang = mw.language.new(lang or 'en')
	local formatStr = Lang:formatNum(1.0625) -- 17/16 is n/2^p in (1.01 .. 1.09) with integer n and smallest integer p 
	local decsep = usub(formatStr, 2, 2) -- decimal separator string in local language
	local zero   = usub(formatStr, 3, 3) -- zero string in local language
	-- Adjust number display based on precision.
	-- The total length (over 360 degrees) of the equator on Earth is about 40,075 km so:
	-- * 1 degree      of longitude on the equator is ~111.3194 km
	-- * 1 minute      of longitude on the equator is ~1.885324 km
	-- * 1 second      of longitude on the equator is ~30.92207 m
	-- * 1 centisecond of longitude on the equator is ~30.92207 cm (slightly worse than decimetric precision)
	-- * 1 millisecond of longitude on the equator is ~30.92207 mm (slightly better than decimetric precision)
	-- The precision of maps on Earth needed in dense areas is now decimetric (centimetric for 1-3 pixels of
	-- good aerial orthophotos), so we need the precision up to 2 milliseconds; within a range of +/-360°,
	-- this precision of angles fits in a 32-bit IEEE floatting point (8.5 significant digits).
	local scaling
	if     degPrec * 3600000 < 5 then --               degPrec < 1.38889e-6
		-- The scaling factor is 1,800,000 (not 3,600,000) to get 8.5 (not 9) significant digits.
		-- Displayed milliseconds will skip from "(n).998" to "(n+1).000", rounding the last digit to the
		-- nearest even (precision is 2 millisecond, decimetric on Earth along the equator and meridians).
		scaling, formatStr = 1800000, '%s°&nbsp;%s′&nbsp;%s%s%s%s%s″' -- format: DDD° MM′ SS.dcm″
	elseif degPrec *  360000 < 5 then --               degPrec < 1.38889e-5
		scaling, formatStr =  360000, '%s°&nbsp;%s′&nbsp;%s%s%s%s″'   -- format: DDD° MM′ SS.dc″
	elseif degPrec *   36000 < 5 then -- 1.38889e-5 <= degPrec < 1.38889e-4
		scaling, formatStr =   36000, '%s°&nbsp;%s′&nbsp;%s%s%s″'     -- format: DDD° MM′ SS.d″
	elseif degPrec *     600 < 5 then -- 1.38889e-4 <= degPrec < 8.33333e-3
		scaling, formatStr =    3600, '%s°&nbsp;%s′&nbsp;%s″'         -- format: DDD° MM′ SS″
	elseif degPrec *      10 < 5 then -- 8.33333e-3 <= degPrec < 0.5
		scaling, formatStr =      60, '%s°&nbsp;%s′'                  -- format: DDD° MM′
	else                              -- 0.5        <= degPrec
		scaling, formatStr =       1, '%s°'                           -- format: DDD°
	end
	-- This rounding (modulo 360°) MUST be identical for all displayed fields to avoid incorrect results.
	local scaled = floor(fmod(degree, 360) * scaling + 0.5) -- round to an INTEGER number of scaled degrees.
	-- Compute numbers of degrees, minutes, seconds, and fractions of seconds.
	-- We MUST NOT compute floatting point seconds (may cause extra decimals displayed unrounded).
	-- Instead compute the integral part and the decimals using only operations on integers,
	-- and before truncating to integers, we always compute divisions after multiplications,
	-- to preserve the precision and avoid all rounding errors caused by inexact divisions.
	      degree = floor(scaled           / scaling)      -- degrees (integer in 0-359 range)
	local minute = floor(scaled *      60 / scaling) % 60 -- minutes (integer in 0-59 range)
	local second = floor(scaled *    3600 / scaling) % 60 -- seconds (integer in 0-59 range)
	local dsec   = floor(scaled *   36000 / scaling) % 10 -- deciseconds (integer in 0-9 range)
	local csec   = floor(scaled *  360000 / scaling) % 10 -- centiseconds (integer in 0-9 range)
	local msec   = floor(scaled * 3600000 / scaling) % 10 -- milliseconds (integer in 0-9 range)
	-- Final localized format
	return format(formatStr, Lang:formatNum(degree), -- degrees
		(minute < 10 and zero or '') .. Lang:formatNum(minute), -- minutes padded on 2 digits
		(second < 10 and zero or '') .. Lang:formatNum(second), -- seconds padded on 2 digits
		msec + csec + dsec > 0 and decsep or '', -- decimal separator (removed if fractions of second == 0)
		msec + csec + dsec > 0 and Lang:formatNum(dsec) or '', -- deciseconds  (trailing zeroes removed)
		msec + csec > 0 and Lang:formatNum(csec) or '', -- centiseconds (trailing zeroes removed)
		msec > 0 and Lang:formatNum(msec) or '') -- milliseconds (trailing zeroes removed)
end

--[[============================================================================
Convert degrees to degrees/minutes/seconds notation commonly used when displaying 
coordinates.
Inputs:
1) latitude or longitude angle in degrees
2) georeference precision in degrees
3) language used in formatting of the number
==============================================================================]]
function p.deg2dms(frame)
	local args = core.getArgs(frame)
	local degree  = tonumber(args[1])
	local degPrec = tonumber(args[2]) or 0-- precision in degrees

	if degree == nil then
		return args[1]
	else
		return p._deg2dms(degree, degPrec, args.lang)
	end
end

function p.dms2deg(frame)
	return dms2deg(frame.args[1])
end

--[[============================================================================
Format coordinate location string, by creating and joining DMS strings for 
latitude and longitude. Also convert precision from meters to degrees.
INPUTS:
 * lat        = latitude in degrees
 * lon        = longitude in degrees
 * lang       = language code
 * prec       = geolocation precision in meters
==============================================================================]]
function p._lat_lon(lat, lon, prec, lang)
	lat  = tonumber(lat)
	lon  = tonumber(lon)
	prec = abs(tonumber(prec) or 0)
	if lon then -- get longitude to be in -180 to 180 range
		lon = fmod(lon + 180, 360) - 180
	end
	if lat==nil or lon==nil then
		return NoLatLonString
	else
		local nsew = langSwitch(i18n.NSEW, lang) -- find set of localized translation of N, S, W and E in the desired language 
		local SN, EW, latStr, lonStr, lon2m, lat2m, phi
		if lat<0 then SN = nsew.S else SN = nsew.N end              -- choose S or N depending on latitude  degree sign
		if lon<0 then EW = nsew.W else EW = nsew.E end              -- choose W or E depending on longitude degree sign
		lat2m=1
		lon2m=1
		if prec>0 then -- if user specified the precision of the geo location...
			phi   = rad(abs(lat)) -- latitude in radians
			lon2m = rad(6378137 * cos(phi)) -- see https://en.wikipedia.org/wiki/Longitude
			lat2m = 111000 -- average latitude degree size in meters
		end
		latStr = p._deg2dms(abs(lat), prec / lat2m, lang) -- Convert latitude  degrees to degrees/minutes/seconds
		lonStr = p._deg2dms(abs(lon), prec / lon2m, lang) -- Convert longitude degrees to degrees/minutes/seconds
		return format('%s&nbsp;%s, %s&nbsp;%s', latStr, SN, lonStr, EW)
		--return format('<span class="latitude">%s %s</span>, <span class="longitude">%s %s</span>', latStr, SN, lonStr, EW)
	end
end

function p.lat_lon(frame)
	local args = core.getArgs(frame)
	return p._lat_lon(args.lat, args.lon, args.prec, args.lang)
end

--[[============================================================================
Helper core function for externalLink. Create URL for different sites:
INPUTS:
 * site       = Possible sites: GeoHack, GoogleEarth, Proximityrama, 
                OpenStreetMap, GoogleMaps (for Earth, Mars and Moon)
 * globe      = Possible options: Earth, Mars or Moon. Venus, Mercury, Titan, 
                Ganymede are also supported but are unused as of 2013.
 * latStr     = latitude string or number
 * lonStr     = longitude string or number
 * lang       = language code
 * attributes = attributes to be passed to GeoHack
==============================================================================]]
function p._externalLink(site, globe, latStr, lonStr, lang, attributes, level)
    site = mw.text.trim(site or 'GeoHack')
    globe = mw.text.trim(globe or 'Earth')
    latStr = mw.text.trim(latStr or '')
    lonStr = mw.text.trim(lonStr or '')
    lang = mw.text.trim(lang or '')
    attributes = mw.text.trim(attributes or '')
	level = mw.text.trim(level or 1)

	local url = SiteURL[site]
    if type(url) == 'table' then -- e.g. with site == 'GoogleMaps'
        url = url[globe]
        if type(url) ~= 'string' then
            return '<strong class="error">Error: unsupported globe "' .. globe .. '" on site "' .. site .. '"!</strong>'
        end
    elseif type(url) ~= 'string' then -- including url == nil (site not mapped)
        return '<strong class="error">Error: unsupported site "' .. site .. '"!</strong>'
	elseif site == 'GeoHack' then
		attributes = format('globe:%s_%s', globe, attributes)
    end
	local page = uriencode( mw.title.getCurrentTitle().prefixedText, 'WIKI' )
	local prox = uriencode( page, 'QUERY' )

	url = gsubPlain( url, '$page' , page )
	url = gsubPlain( url, '$prox', prox )
	url = gsubPlain( url, '$lat', latStr )
	url = gsubPlain( url, '$lon', lonStr )
	url = gsubPlain( url, '$lang', lang )
	url = gsubPlain( url, '$level', level )
	url = gsubPlain( url, '$attr', attributes )
	return url
end

--[[============================================================================
Create URL for different sites.
INPUTS:
 * site       = Possible sites: GeoHack, GoogleEarth, Proximityrama, 
                OpenStreetMap, GoogleMaps (for Earth, Mars and Moon)
 * globe      = Possible options: Earth, Mars or Moon. Venus, Mercury, Titan, 
                Ganymede are also supported but are unused as of 2013.
 * lat        = latitude string or number
 * lon        = longitude string or number
 * lang       = language code
 * attributes = attributes to be passed to GeoHack
==============================================================================]]
function p.externalLink(frame)
	local args = core.getArgs(frame)
	return p._externalLink(args.site, args.globe, args.lat, args.lon, args.lang, args.attributes)
end

--[[============================================================================
Adjust GeoHack attributes depending on the template that calls it
INPUTS:
 * attributes = attributes to be passed to GeoHack
 * mode = set by each calling template
==============================================================================]]
function p.alterAttributes(attributes, mode, heading)
	-- indicate which template called it
	if mode == 'camera' then          -- Used by {{Location}} and {{Location dec}}
		if not find(attributes, 'type:') then
			attributes = 'type:camera_' .. attributes
		end
	--elseif mode == 'inline' then    -- Used by {{Inline coordinates}}
	    -- (actually that template does not set any attributes at the moment)
	elseif mode == 'object' then      -- Used by {{Object location}}
		if not find(attributes, 'type:') then
			attributes = 'type:object_' .. attributes
		end
		if not find(attributes, 'class:') then
			attributes = 'class:object_' .. attributes
		end
	elseif mode == 'institution' then -- Used by {{Institution/coordinates}} (categories only)	
		if not find(attributes, 'type:') then
			attributes = 'type:institution'
		end
	elseif mode == 'user' then        -- Used by {{User location}}
		if not find(attributes, 'type:') then
			attributes = 'type:user_location'
		end
	end
	local hStr = tonumber(heading) and format('heading:%6.2f', tonumber(heading)) or '' -- if heading is a number 
	if not find(attributes, 'heading:') then
		attributes = attributes .. '_' .. hStr
	else
		attributes = gsub(attributes, 'heading:[^_]*', hStr) -- replace heading in form heading:N with heading=0 
	end
	return gsubPlain(gsubPlain(attributes, ' ', ''), '__', '_')
end
	
--[[============================================================================
 Create link to GeoHack tool which displays latitude and longitude coordinates 
 in DMS format
 INPUTS:
 * globe      = Possible options: Earth, Mars or Moon. Venus, Mercury, Titan, 
                Ganymede are also supported but are unused as of 2013.
 * lat        = latitude in degrees
 * lon        = longitude in degrees
 * lang       = language code
 * prec       = geolocation precision in meters
 * attributes = attributes to be passed to GeoHack
==============================================================================]]
function p._GeoHack_link(args)
	-- create link and coordintate string
	local latlon = p._lat_lon(args.lat, args.lon, args.prec, args.lang)
	if latlon==NoLatLonString then
		return latlon
	else
		local url = p._externalLink('GeoHack', args.globe or 'Earth', args.lat, args.lon, args.lang, args.attributes or '')
		return format('<span class="plainlinksneverexpand">[%s %s]</span>', url, latlon) --<span class="plainlinks nourlexpansion">
	end
end

function p.GeoHack_link(frame)
	return p._GeoHack_link(core.getArgs(frame))
end


--[[============================================================================
 Create full external links section of {{Location}} or {{Object location}} 
 templates, based on:
 * globe      = Possible options: Earth, Mars or Moon. Venus, Mercury, Titan, Ganymede are also supported but are unused as of 2013.
 * mode       = Possible options: 
  - camera - call from {{location}}
  - object - call from {{Object location}}
  - globe  - call from {{Globe location}}
 * lat        = latitude in degrees
 * lon        = longitude in degrees
 * lang       = language code
 * namespace  = namespace name: File, Category, (Gallery)
==============================================================================]]
function p._externalLinksSection(args)
	local lang = args.lang
	if not args.namespace then
		args.namespace = mw.title.getCurrentTitle().nsText
	end
	local str, link1, link2, link3, link4
	if args.globe=='Earth' and args.namespace~="Category" then -- Earth locations for files will have 2 links
		link1 = p._externalLink('OpenStreetMap1', 'Earth', args.lat, args.lon, lang, '')
		--link2 = p._externalLink('GoogleEarth'   , 'Earth', args.lat, args.lon, lang, '')
		str = format('[%s %s]', link1, langSwitch(i18n.OpenStreetMaps, lang))
			--link2, langSwitch(i18n.GoogleEarth, lang)) 
	elseif args.globe=='Earth' and args.namespace=="Category" then -- Earth locations for categories will have 4 links
		link1 = p._externalLink('OpenStreetMap2', 'Earth', args.lat, args.lon, lang, '', args.catRecurse)
		--link2 = p._externalLink('GoogleMaps'    , 'Earth', args.lat, args.lon, lang, '', args.catRecurse) 
		--link3 = p._externalLink('GoogleEarth'   , 'Earth', args.lat, args.lon, lang, '')
		--link4 = p._externalLink('Proximityrama' , 'Earth', args.lat, args.lon, lang, '')
		str = format('[%s %s]', link1, langSwitch(i18n.OpenStreetMaps, lang))
			--link2, langSwitch(i18n.GoogleMaps, lang),
			--link3, langSwitch(i18n.GoogleEarth, lang),
			--link4, langSwitch(i18n.Proximityrama, lang))
	elseif args.globe=='Mars' or args.globe=='Moon' then
		link1 = p._externalLink('GoogleMaps', args.globe, args.lat, args.lon, lang, '')
		str = format('[%s %s]', link1, langSwitch(i18n.GoogleMaps, lang))
	end
	return str
end

function p.externalLinksSection(frame)
	return p._externalLinksSection(core.getArgs(frame))
end

--[[============================================================================
Core section of template:Location, template:Object location and template:Globe location.
This method requires several arguments to be passed to it or it's parent method/template:
 * globe      = Possible options: Earth, Mars or Moon. Venus, Mercury, Titan, Ganymede are also supported but are unused as of 2013.
 * mode       = Possible options: 
  - camera - call from {{location}}
  - object - call from {{Object location}}
  - globe  - call from {{Globe location}}
 * lat        = latitude in degrees
 * lon        = longitude in degrees
 * attributes = attributes
 * lang       = language code
 * namespace  = namespace: File, Category, Gallery
 * prec       = geolocation precision in meters
==============================================================================]]
function p._LocationTemplateCore(args)
	-- prepare arguments
	if not (args.namespace) then -- if namespace not provided than look it up
		args.namespace = mw.title.getCurrentTitle().nsText
	end
	if args.namespace=='' then -- if empty than it is a gallery
		args.namespace = 'Gallery'
	end
	local bare   = core.yesno(args.bare,false)
	local Status = 'primary' -- used by {{#coordinates:}}
	if core.yesno(args.secondary,false) then
		Status = 'secondary'
	end
	args.globe = mw.language.new('en'):ucfirst(args.globe or 'Earth') 
	
	-- Convert coordinates from string to numbers
	local lat = tonumber(args.lat)
	local lon = tonumber(args.lon)
	local precission = tonumber(args.prec or '0')
	local heading = p._getHeading(args.attributes)	-- get heading arrow section
	if lon then -- get longitude to be in -180 to 180 range
		lon = fmod(lon + 180, 360) - 180
	end
	
	-- If wikidata link provided than compare coordinates
	local Categories, geoMicroFormat, coorTag, edit_icon, wikidata_link = '', '', '', '', '', '', ''
	local entity, coord, sd, cmp
	local loc = {lat=lat, lon=lon, heading=heading, source='loc'}
	local ID = args.wikidata
	if ID == nil then 
		entity = mw.wikibase.getEntity()
	elseif type(ID) == 'string' and ID:match( '^[QqMm]%d+$' ) then
		entity = mw.wikibase.getEntity(ID)
	elseif type(ID) ~= 'string' and ID.id then
		entity = ID -- entities can be passed from outside
	end
	
	if entity then
		if (args.mode=='object' or args.mode=='globe') then
			sd = getSDCoords(entity,'P9149')  -- fetch coordinates of depicted place
			if not sd.lat then
				sd = getSDCoords(entity,'P625')  -- fallback to coordinate location
			end
		elseif (args.mode=='camera') then
			sd = getSDCoords(entity,'P1259') -- fetch camera coordinates or coordinates of the point of view
		end
		if (args.namespace=='File') then -- look up lat/lon on SDC
			coord, Categories, cmp = compareCoords(loc, sd, args.mode, 'SDC')
			if coord.source~='loc' then
				 edit_icon = core.editAtSDC(coord.source, args.lang)
				 lat, lon, heading, precission = coord.lat, coord.lon, coord.heading, coord.prec
			end
		elseif (args.namespace == 'Category') then  -- look up lat/lon on wikidata
			sd.wID = entity.id
			coord, Categories, cmp = compareCoords(loc, sd, args.mode, 'Wikidata')
			if coord.source~='loc' then
				local str = "\n[[File:Wikidata-logo.svg|20px|Field with data from Wikidata's %s property<br/>%s|link=wikidata:%s#%s]]"
				edit_icon = core.editAtWikidata(entity.id, coord.source, args.lang)
				lat, lon, heading, precission = coord.lat, coord.lon, coord.heading, coord.prec
			end
			if cmp.qs then
				wikidata_link = cmp.qs 
			end
		end
	elseif (args.namespace=='File') then
		Categories = format(CoorCat.strucData4, args.mode, 'SDC')
	end

	args.lat  = format('%010.6f', lat or 0)
	args.lon  = format('%011.6f', lon or 0)
	args.prec = precission
	args.attributes = p.alterAttributes(args.attributes or '', args.mode, heading)
	local frame = mw.getCurrentFrame()

	-- Categories, {{#coordinates}} and geoMicroFormat will be only added to File, Category and Gallery pages
	if (args.namespace == 'File' or args.namespace == 'Category' or args.namespace == 'Gallery') then
		if lat and lon then -- if lat and lon are numbers...
			if lat==0 and lon==0 then -- lat=0 and lon=0 is a common issue when copying from flickr and other sources
				Categories = Categories .. CoorCat.default
			end
			if args.attributes and find(args.attributes, '=') then
				Categories = Categories .. CoorCat.attribute
			end
			if abs(lon) >180 or abs(lat) > 90 then -- check for errors ({{#coordinates:}} also checks for errors)
				Categories = Categories .. '<strong class="error">Error: Invalid parameters! (coordinates are outside allowed range)</strong>\n' .. CoorCat.erroneous
			end
			-- local cat = CoorCat[args.namespace]
			-- if cat then -- add category based on namespace
				-- Categories = Categories .. cat
			-- end
			-- if not earth than add a category for each globe
			if args.mode and args.globe and args.mode=='globe' and args.globe~='Earth' then
				Categories = Categories .. format(CoorCat[args.mode], args.globe)
			end
			-- add  <span class="geo"> Geo (microformat) code: it is included for machine readability
			geoMicroFormat = format('<span class="geo" style="display:none">%10.6f; %11.6f</span>', lat, lon)
			-- add {{#coordinates}} tag, see https://www.mediawiki.org/wiki/Extension:GeoData
			if args.namespace == 'File' and Status == 'primary' and args.mode=='camera' then 
				coorTag = frame:callParserFunction( '#coordinates', { 'primary', lat, lon, args.attributes } )
			elseif args.namespace == 'File' and args.mode=='object' then 
				coorTag = frame:callParserFunction( '#coordinates', { lat, lon, args.attributes } )
			end
		else -- if lat and lon are not numbers then add error category
			Categories = Categories .. '<strong class="error">Error: Invalid parameters! (coordinates are missing or not numeric)</strong>\n' .. CoorCat.erroneous
		end
	end

	-- Call helper functions to render different parts of the template
	local coor,  info_link, inner_table, OSM = '','','','','',''
	coor = p._GeoHack_link(args)  			-- the p and link to GeoHack
	coor = format('<span class=plainlinks>%s</span>%s', coor, edit_icon)
	if heading then  
		local k = fmod(floor(fmod(heading + 360, 360) / 11.25 + 0.5), 32) + 1
		local fname = heading_icon[k]
		coor = format('%s&nbsp;&nbsp;<span title="%s°">[[%s|25px|link=|alt=Heading=%s°]]</span>', coor, heading, fname, heading)
	end
	if args.globe=='Earth' then
		local icon = 'marker'
		if args.mode=='camera' then 
			icon = 'camera'
		end
		OSM = frame:preprocess(add_maplink(args.lat, args.lon, icon, '[[File:Openstreetmap logo.svg|20px|link=|Kartographer map based on OpenStreetMap.]]')) -- fancy link to OSM
	end
	local external_link = p._externalLinksSection(args) -- external link section
	if external_link and args.namespace == 'File' then
		external_link = langSwitch(i18n.LocationTemplateLinkLabel, args.lang) .. ' ' .. external_link 	-- header of the link section for {{location}} template
	elseif external_link then
		external_link = langSwitch(i18n.ObjectLocationTemplateLinkLabel, args.lang) .. ' ' .. external_link -- header of the link section for {{Object location}} template
	end
	info_link = format('[[File:OOjs UI icon help.svg|18x18px|alt=info|link=%s]]', langSwitch(i18n.COM_GEO, args.lang))
	inner_table = format('<td style="border:none">%s&nbsp;%s</td><td style="border:none">%s</td><td style="border:none">%s%s%s</td>', 
		coor, OSM, external_link or '', wikidata_link, info_link, geoMicroFormat)
	
	-- Combine strings into a table.
	local templateText
	if bare then
		templateText = format('<table style="width:100%%"><tr>%s</tr></table>', inner_table)
	else
		-- Choose name of the field and create row.
		local field_name = 'Location'
		if args.mode == 'camera' then 
			field_name = langSwitch(i18n.CameraLocation, args.lang)
		elseif args.mode == 'object' then 
			field_name = langSwitch(i18n.ObjectLocation, args.lang)
		elseif args.mode == 'globe' then
			local field_list = langSwitch(i18n.GlobeLocation, args.lang)
			if args.globe and i18n.GlobeLocation['en'][args.globe] then -- verify globe is provided and is recognized
				field_name = field_list[args.globe]
			end
		end
		-- Create HTML text.
		local dir = mw.language.new(args.lang):getDir() -- get text direction
		templateText = format(
			'<table class="mw-content-%s toccolours layouttemplate commons-file-information-table"' ..
			' style="width:100%%" dir="%s" lang="%s"><tr><th class="type fileinfo-paramfield">%s</th>' ..
			'%s</tr></table>', dir, dir, args.lang, field_name, inner_table)
	end
	return templateText, Categories, coorTag
end

function p.LocationTemplateCore(frame)
	local args = core.getArgs(frame)
	args.namespace = mw.title.getCurrentTitle().nsText
	if not args.lat and not args.lon then -- if no lat and lon but numbered arguments present
		if args[4] then -- DMS with pipes format, ex. "34|5|32.36|N|116|9|24|55|W"
			args.lat = dms2deg_ ( args[1], args[2], args[3], args[4] )
			args.lon = dms2deg_ ( args[5], args[6], args[7], args[8] )
			args.attributes = args.attributes or args[9]
		elseif args[2] and not (type(args[2])=='string' and args[2]:find(":")) then -- decimal format or DMS with one pipe, ex. "34° 05′ 32.36″ N| 116° 09′ 24.55″ W"
			args.lat = args[1]
			args.lon = args[2]
			args.attributes = args.attributes or args[3]
		elseif args[1] then -- detect a single argument in the form "34° 05′ 32.36″ N, 116° 09′ 24.55″ W" or similar
			local v = mw.text.split(args[1]:gsub("([NnSs])", "%1/" ), "/") -- split into lat and lon using splitting point after any letter
			args.lat, args.lon = v[1], v[2]
			args.attributes = args.attributes or args[2]
		end
	end
	local cat = ''
	if args.lat and args.lon then
		local lat = tonumber(args.lat)
		local lon = tonumber(args.lon)
		if not lat or not lon then
			args.lat = dms2deg(args.lat or '')
			args.lon = dms2deg(args.lon or '')
			if (args.namespace == 'File' or args.namespace == 'Category') then
				cat = CoorCat.dms
			end
		end
	end
	local templateText, Categories, coorTag = p._LocationTemplateCore(args)
	return templateText .. Categories .. cat .. coorTag
end

return p