Module:Mapframe: Difference between revisions

From Random Island Wiki
Jump to navigation Jump to search
>Evad37
(Refactoring code: no major changes. Require no globals, add comments to methods.)
>Evad37
(Undid revision 964739874 by Evad37 (talk): revert, see User_talk:Evad37#Mapframe_errors)
Line 1: Line 1:
-- Note: Originally written on English Wikipedia at https://en.wikipedia.org/wiki/Module:Mapframe
-- Note: Originally written on English Wikipedia at https://en.wikipedia.org/wiki/Module:Mapframe
 
-- ##### Localisation (L10n) settings #####
--[[----------------------------------------------------------------------------
-- Replace values in quotes ("") with localised values
##### Localisation (L10n) settings #####
local transclude = require("Module:Transclude") -- local copy of https://www.mediawiki.org/wiki/Module:Transclude
Replace values in quotes ("") with localised values
----------------------------------------------------------------------------]]--
local L10n = {}
local L10n = {}
-- Modue dependencies
local transclude = require("Module:Transclude") -- local copy of https://www.mediawiki.org/wiki/Module:Transclude
require('Module:No globals') -- local copy of https://www.mediawiki.org/wiki/Module:No_globals


-- Template parameter names (unnumbered versions only)
-- Template parameter names (unnumbered versions only)
Line 140: Line 134:
-- #### End of L10n settings ####
-- #### End of L10n settings ####


--[[----------------------------------------------------------------------------
function getParameterValue(args, param_id, suffix)
Utility methods
----------------------------------------------------------------------------]]--
local util = {}
 
--[[
Looks up a parameter value based on the id (a key from the L10n.para table) and
optionally a suffix, for parameters that can be suffixed (e.g. type2 is type
with suffix 2).
@param {table} args  key-value pairs of parameter names and their values
@param {string} param_id  id for parameter name (key from the L10n.para table)
@param {string} [suffix]  suffix for parameter name
@returns {string|nil} parameter value if found, or nil if not found
]]--
function util.getParameterValue(args, param_id, suffix)
suffix = suffix or ''
suffix = suffix or ''
if type( L10n.para[param_id] ) ~= 'table' then
if type( L10n.para[param_id] ) ~= 'table' then
Line 167: Line 147:
end
end


--[[
-- Trim whitespace from args, and remove empty args. Also fix control characters.
Trim whitespace from args, and remove empty args. Also fix control characters.
function trimArgs(argsTable)
@param {table} argsTable
@returns {table} trimmed args table
]]--
function util.trimArgs(argsTable)
local cleanArgs = {}
local cleanArgs = {}
for key, val in pairs(argsTable) do
for key, val in pairs(argsTable) do
Line 189: Line 165:
end
end


--[[
function isAffirmed(val)
Check if a value is affirmed (one of the values in L10n.str.affirmedWords)
@param {string} val  Value to be checked
@returns {boolean} true if affirmed, false otherwise
]]--
function util.isAffirmed(val)
if not(val) then return false end
if not(val) then return false end
return string.find(L10n.str.affirmedWords, ' '..val..' ', 1, true ) and true or false
return string.find(L10n.str.affirmedWords, ' '..val..' ', 1, true ) and true or false
end
end


--[[
function isDeclined(val)
Check if a value is declined (one of the values in L10n.str.declinedWords)
@param {string} val  Value to be checked
@returns {boolean} true if declined, false otherwise
]]--
function util.isDeclined(val)
if not(val) then return false end
if not(val) then return false end
return string.find(L10n.str.declinedWords , ' '..val..' ', 1, true ) and true or false
return string.find(L10n.str.declinedWords , ' '..val..' ', 1, true ) and true or false
end
end


--[[
local coordsDerivedFromFeatures = false;
Recursively extract coord templates which have a name parameter.
function makeContent(args)
@param {string} wikitext
if getParameterValue(args, 'raw') then
@returns {table} table sequence of coord templates
coordsDerivedFromFeatures = true -- Kartographer should be able to automatically calculate coords from raw geoJSON
]]--
return getParameterValue(args, 'raw')
function util.extractCoordTemplates(wikitext)
end
 
local content = {}
 
    local argsExpanded = {}
    for k, v in pairs(args) do
    local index = string.match( k, '^[^0-9]+([0-9]*)$' )
    if index ~= nil then
    local indexNumber = ''
    if index ~= '' then
    indexNumber = tonumber(index)
    else
    indexNumber = 1
    end
   
    if argsExpanded[indexNumber] == nil then
    argsExpanded[indexNumber] = {}
    end
    argsExpanded[indexNumber][ string.gsub(k, index, '') ] = v
    end
    end
for contentIndex, contentArgs in pairs(argsExpanded) do
local argType = getParameterValue(contentArgs, "type")
-- Kartographer automatically calculates coords if geolines/shapes are used (T227402)
if not coordsDerivedFromFeatures then
coordsDerivedFromFeatures = ( argType == L10n.str.line or argType == L10n.str.shape ) and true or false
end
if argType == L10n.str.named then
local namedCoords = getNamedCoords(getParameterValue(contentArgs, "from"))
local typeKey = type(L10n.para.type) == "table" and L10n.para.type[1] or L10n.para.type
local coordKey = type(L10n.para.coord) == "table" and L10n.para.coord[1] or L10n.para.coord
local titleKey = type(L10n.para.title) == "table" and L10n.para.title[1] or L10n.para.title
local descKey = type(L10n.para.description) == "table" and L10n.para.description[1] or L10n.para.description
for _, namedCoord in pairs(namedCoords) do
contentArgs[typeKey] = "point"
contentArgs[coordKey]  = namedCoord.coord
contentArgs[titleKey]  = namedCoord.name
contentArgs[descKey] = namedCoord.description
content[#content+1] = makeContentJson(contentArgs)
end
else
content[#content + 1] = makeContentJson(contentArgs)
end
end
--Single item, no array needed
if #content==1 then return content[1] end
 
--Multiple items get placed in a FeatureCollection
local contentArray = '[\n' .. table.concat( content, ',\n') .. '\n]'
return contentArray
end
 
-- recursively extract coord templates
function extractCoordTemplates(wikitext)
local output = {}
local output = {}
local templates = mw.ustring.gmatch(wikitext, '{%b{}}')
local templates = mw.ustring.gmatch(wikitext, '{%b{}}')
Line 224: Line 245:
if nameParam then table.insert(output, template) end
if nameParam then table.insert(output, template) end
elseif mw.ustring.find(template, L10n.template.Coord) then
elseif mw.ustring.find(template, L10n.template.Coord) then
local subOutput = util.extractCoordTemplates(mw.ustring.sub(template, 2))
local subOutput = extractCoordTemplates(mw.ustring.sub(template, 2))
for _, t in pairs(subOutput) do
for _, t in pairs(subOutput) do
table.insert(output, t)
table.insert(output, t)
Line 237: Line 258:
end
end


--[[
--[[ getNamedCoords
Gets all named coordiates from a page or a section of a page.
  Gets all named coordiates from a page or a section of a page.
@param {string|nil} page  Page name, or name#section, to get named coordinates
  @param {string|nil} page  Page name, or name#section, to get named coordinates
  from. If the name is omitted, i.e. #section or nil or empty string, then
    from. If the name is omitted, i.e. #section or nil or empty string, then
  the current page will be used.
    the current page will be used.
@returns {table} sequence of {coord, name, description} tables where coord is
  @returns {table} sequence of {coord, name, description} where coord is the
  the coordinates in a format suitable for #util.parseCoords, name is a string,
    coordinates in a format suitable for #parseCoords, and name is a string, and
  and description is a string (coordinates in a format suitable for displaying
    description is a string (the coordinates in a format suitable for displaying
  to the reader). If for some reason the name can't be found, the description
    to the reader). If for some reason the name can't be found, the description
  is nil and the name contains display-format coordinates.
    is nil and the name contains display-format coordinates.
@throws {L10n.error.noNamedCoords} if no named coordinates are found.
  @throws {L10n.error.noNamedCoords} if no named coordinates are found.
]]--
]]--
function util.getNamedCoords(page)
function getNamedCoords(page)
local parts = mw.text.split(page or "", "#", true)
local parts = mw.text.split(page or "", "#", true)
local name = parts[1] == "" and mw.title.getCurrentTitle().prefixedText or parts[1]
local name = parts[1] == "" and mw.title.getCurrentTitle().prefixedText or parts[1]
local section = parts[2]
local section = parts[2]
local pageWikitext = transclude.get(section and name.."#"..section or name)
local pageWikitext = transclude.get(section and name.."#"..section or name)
local coordTemplates = util.extractCoordTemplates(pageWikitext)
local coordTemplates = extractCoordTemplates(pageWikitext)
if #coordTemplates == 0 then error(string.format(L10n.error.noNamedCoords, page or name), 0) end
if #coordTemplates == 0 then error(string.format(L10n.error.noNamedCoords, page or name), 0) end
local frame = mw.getCurrentFrame()
local frame = mw.getCurrentFrame()
Line 274: Line 295:
end
end


--[[
function parseCoords(coords)
Parse coordinate values from the params passed in a GeoHack url (such as
//tools.wmflabs.org/geohack/geohack.php?pagename=Example&params=1_2_N_3_4_W_ or
//tools.wmflabs.org/geohack/geohack.php?pagename=Example&params=1.23_S_4.56_E_ )
@param {string} coords
@returns {number, number} latitude, longitude
]]--
function util.parseCoords(coords)
local parts = mw.text.split((mw.ustring.match(coords,'[_%.%d]+[NS][_%.%d]+[EW]') or ''), '_')
local parts = mw.text.split((mw.ustring.match(coords,'[_%.%d]+[NS][_%.%d]+[EW]') or ''), '_')


Line 303: Line 317:
end
end


--[[
function wikidataCoords(item_id)
Get coordinates from a Wikidata item
@param {string} item_id  Wikidata item id (Q number)
@returns {number, number} latitude, longitude
@throws {L10n.error.noCoords} if item_id is invalid or the item does not exist
@throws {L10n.error.wikidataCoords} if the the item does not have a P625
  statement (coordinates), or it is set to "no value"
]]--
function util.wikidataCoords(item_id)
if not(mw.wikibase.isValidEntityId(item_id)) or not(mw.wikibase.entityExists(item_id)) then
if not(mw.wikibase.isValidEntityId(item_id)) or not(mw.wikibase.entityExists(item_id)) then
error(L10n.error.noCoords, 0)
error(L10n.error.noCoords, 0)
Line 327: Line 333:
end
end


--[[
function makeCoords(args, plainOutput)
Creates a polygon that approximates a circle
local coords, lat, long
@param {number} lat Latitude
local frame = mw.getCurrentFrame()
@param {number} long Longitude
if getParameterValue(args, 'coord') then
@param {number} radius Radius in metres
coords = frame:preprocess( getParameterValue(args, 'coord') )
@param {number} n  Number of edges for the polygon
lat, long = parseCoords(coords)
@returns {table} sequence of {latitude, longitude} table sequences, where
else
  latitude and longitude are both numbers
lat, long = wikidataCoords(getParameterValue(args, 'id') or mw.wikibase.getEntityIdForCurrentPage())
]]--
end
function util.circleToPolygon(lat, long, radius, n) -- n is number of edges
if plainOutput then
return lat, long
end
return {[0] = long, [1] = lat}
end
 
function makeCircleCoords(args)
local lat, long = makeCoords(args, true)
local radius = getParameterValue(args, 'radius')
if not radius then
radius = getParameterValue(args, 'radiusKm') and tonumber(getParameterValue(args, 'radiusKm'))*1000
if not radius then
radius = getParameterValue(args, 'radiusMi') and tonumber(getParameterValue(args, 'radiusMi'))*1609.344
if not radius then
radius = getParameterValue(args, 'radiusFt') and tonumber(getParameterValue(args, 'radiusFt'))*0.3048
end
end
end
local edges = getParameterValue(args, 'edges') or L10n.defaults.edges
if not lat or not long then
error(L10n.error.noCircleCoords, 0)
elseif not radius then
error(L10n.error.noRadius, 0)
elseif tonumber(radius) <= 0 then
error(L10n.error.negativeRadius, 0)
elseif tonumber(edges) <= 0 then
error(L10n.error.negativeEdges, 0)
end
return circleToPolygon(lat, long, radius, tonumber(edges))
end
 
function circleToPolygon(lat, long, radius, n) -- n is number of edges
-- Based on https://github.com/gabzim/circle-to-polygon, ISC licence
-- Based on https://github.com/gabzim/circle-to-polygon, ISC licence
local function offset(cLat, cLon, distance, bearing)
function offset(cLat, cLon, distance, bearing)
local lat1 = math.rad(cLat)
local lat1 = math.rad(cLat)
local lon1 = math.rad(cLon)
local lon1 = math.rad(cLon)
Line 366: Line 403:
end
end


 
function makeContentJson(contentArgs)
--[[
Get the number of key-value pairs in a table, which might not be a sequence.
@param {table} t
@returns {number} count of key-value pairs
]]--
function util.tableCount(t)
local count = 0
for k, v in pairs(t) do
count = count + 1
end
return count
end
 
--[[
For a table where the values are all tables, returns either the util.tableCount
of the subtables if they are all the same, or nil if they are not all the same.
@param {table} t
@returns {number|nil} count of key-value pairs of subtable, or nil if subtables
  have different counts
]]--
function util.subTablesCount(t)
local count = nil
for k, v in pairs(t) do
if count == nil then
count = util.tableCount(v)
elseif count ~= util.tableCount(v) then
return nil
end
end
return count
end
 
--[[
Splits a list into a table sequence. The items in the list may be separated by
commas, or by semicolons (if items may contain commas), or by "###" (if items
may contain semicolons).
@param {string} listString
@returns {table} sequence of list items
]]--
function util.tableFromList(listString)
if type(listString) ~= "string" or listString == "" then return nil end
local separator = (mw.ustring.find(listString, "###", 0, true ) and "###") or
(mw.ustring.find(listString, ";", 0, true ) and ";") or ","
local pattern = "%s*"..separator.."%s*"
return mw.text.split(listString, pattern)
end
 
-- Boolean in outer scope indicating if Kartographer should be able to
-- automatically calculate coordinates (see phab:T227402)
local coordsDerivedFromFeatures = false;
 
--[[----------------------------------------------------------------------------
Make methods: These take in a table of arguments, and return either a string
or a table to be used in the eventual output.
----------------------------------------------------------------------------]]--
local make = {}
 
--[[
Makes content to go inside the maplink or mapframe tag.
 
@param {table} args
@returns {string} tag content
]]--
function make.content(args)
if util.getParameterValue(args, 'raw') then
coordsDerivedFromFeatures = true -- Kartographer should be able to automatically calculate coords from raw geoJSON
return util.getParameterValue(args, 'raw')
end
 
local content = {}
 
    local argsExpanded = {}
    for k, v in pairs(args) do
    local index = string.match( k, '^[^0-9]+([0-9]*)$' )
    if index ~= nil then
    local indexNumber = ''
    if index ~= '' then
    indexNumber = tonumber(index)
    else
    indexNumber = 1
    end
   
    if argsExpanded[indexNumber] == nil then
    argsExpanded[indexNumber] = {}
    end
    argsExpanded[indexNumber][ string.gsub(k, index, '') ] = v
    end
    end
for contentIndex, contentArgs in pairs(argsExpanded) do
local argType = util.getParameterValue(contentArgs, "type")
-- Kartographer automatically calculates coords if geolines/shapes are used (T227402)
if not coordsDerivedFromFeatures then
coordsDerivedFromFeatures = ( argType == L10n.str.line or argType == L10n.str.shape ) and true or false
end
if argType == L10n.str.named then
local namedCoords = util.getNamedCoords(util.getParameterValue(contentArgs, "from"))
local typeKey = type(L10n.para.type) == "table" and L10n.para.type[1] or L10n.para.type
local coordKey = type(L10n.para.coord) == "table" and L10n.para.coord[1] or L10n.para.coord
local titleKey = type(L10n.para.title) == "table" and L10n.para.title[1] or L10n.para.title
local descKey = type(L10n.para.description) == "table" and L10n.para.description[1] or L10n.para.description
for _, namedCoord in pairs(namedCoords) do
contentArgs[typeKey] = "point"
contentArgs[coordKey]  = namedCoord.coord
contentArgs[titleKey]  = namedCoord.name
contentArgs[descKey]  = namedCoord.description
content[#content+1] = make.contentJson(contentArgs)
end
else
content[#content + 1] = make.contentJson(contentArgs)
end
end
--Single item, no array needed
if #content==1 then return content[1] end
 
--Multiple items get placed in a FeatureCollection
local contentArray = '[\n' .. table.concat( content, ',\n') .. '\n]'
return contentArray
end
 
--[[
Make coordinates from the coord arg, or the id arg, or the current page's
Wikidata item.
@param {table} args
@param {boolean} [plainOutput]
@returns {Mixed} Either:
  {number, number} latitude, longitude  if plainOutput is true; or
  {table} table sequence of longitude, then latitude (gives the required format
  for GeoJSON when encoded)
]]--
function make.coords(args, plainOutput)
local coords, lat, long
local frame = mw.getCurrentFrame()
if util.getParameterValue(args, 'coord') then
coords = frame:preprocess( util.getParameterValue(args, 'coord') )
lat, long = util.parseCoords(coords)
else
lat, long = util.wikidataCoords(util.getParameterValue(args, 'id') or mw.wikibase.getEntityIdForCurrentPage())
end
if plainOutput then
return lat, long
end
return {[0] = long, [1] = lat}
end
 
--[[
Makes a table of coordinates that approximate a circle.
@param {table} args
@returns {table} sequence of {latitude, longitude} table sequences, where
  latitude and longitude are both numbers
@throws {L10n.error.noCircleCoords} if centre coordinates are not specified
@throws {L10n.error.noRadius} if radius is not specified
@throws {L10n.error.negativeRadius} if radius is negative or zero
@throws {L10n.error.negativeEdges} if edges is negative or zero
]]--
function make.circleCoords(args)
local lat, long = make.coords(args, true)
local radius = util.getParameterValue(args, 'radius')
if not radius then
radius = util.getParameterValue(args, 'radiusKm') and tonumber(util.getParameterValue(args, 'radiusKm'))*1000
if not radius then
radius = util.getParameterValue(args, 'radiusMi') and tonumber(util.getParameterValue(args, 'radiusMi'))*1609.344
if not radius then
radius = util.getParameterValue(args, 'radiusFt') and tonumber(util.getParameterValue(args, 'radiusFt'))*0.3048
end
end
end
local edges = util.getParameterValue(args, 'edges') or L10n.defaults.edges
if not lat or not long then
error(L10n.error.noCircleCoords, 0)
elseif not radius then
error(L10n.error.noRadius, 0)
elseif tonumber(radius) <= 0 then
error(L10n.error.negativeRadius, 0)
elseif tonumber(edges) <= 0 then
error(L10n.error.negativeEdges, 0)
end
return util.circleToPolygon(lat, long, radius, tonumber(edges))
end
 
--[[
Makes JSON data for a feature
@param contentArgs  args for this feature. Keys must be the non-suffixed version
  of the parameter names, i.e. use type, stroke, fill,... rather than type3,
  stroke3, fill3,...
@returns {string} JSON encoded data
]]--
function make.contentJson(contentArgs)
local data = {}
local data = {}


if util.getParameterValue(contentArgs, 'type') == L10n.str.point or util.getParameterValue(contentArgs, 'type') == L10n.str.circle then
if getParameterValue(contentArgs, 'type') == L10n.str.point or getParameterValue(contentArgs, 'type') == L10n.str.circle then
local isCircle = util.getParameterValue(contentArgs, 'type') == L10n.str.circle
local isCircle = getParameterValue(contentArgs, 'type') == L10n.str.circle
data.type = "Feature"
data.type = "Feature"
data.geometry = {
data.geometry = {
type = isCircle and "LineString" or "Point",
type = isCircle and "LineString" or "Point",
coordinates = isCircle and make.circleCoords(contentArgs) or make.coords(contentArgs)
coordinates = isCircle and makeCircleCoords(contentArgs) or makeCoords(contentArgs)
}
}
data.properties = {
data.properties = {
title = util.getParameterValue(contentArgs, 'title') or mw.getCurrentFrame():getParent():getTitle()
title = getParameterValue(contentArgs, 'title') or mw.getCurrentFrame():getParent():getTitle()
}
}
if isCircle then
if isCircle then
-- TODO: This is very similar to below, should be extracted into a function
-- TODO: This is very similar to below, should be extracted into a function
data.properties.stroke = util.getParameterValue(contentArgs, 'strokeColor') or L10n.defaults.strokeColor
data.properties.stroke = getParameterValue(contentArgs, 'strokeColor') or L10n.defaults.strokeColor
data.properties["stroke-width"] = tonumber(util.getParameterValue(contentArgs, 'strokeWidth')) or L10n.defaults.strokeWidth
data.properties["stroke-width"] = tonumber(getParameterValue(contentArgs, 'strokeWidth')) or L10n.defaults.strokeWidth
local strokeOpacity = util.getParameterValue(contentArgs, 'strokeOpacity')
local strokeOpacity = getParameterValue(contentArgs, 'strokeOpacity')
if strokeOpacity then
if strokeOpacity then
data.properties['stroke-opacity'] = tonumber(strokeOpacity)
data.properties['stroke-opacity'] = tonumber(strokeOpacity)
end
end
local fill = util.getParameterValue(contentArgs, 'fill')
local fill = getParameterValue(contentArgs, 'fill')
if fill then
if fill then
data.properties.fill = fill
data.properties.fill = fill
local fillOpacity = util.getParameterValue(contentArgs, 'fillOpacity')
local fillOpacity = getParameterValue(contentArgs, 'fillOpacity')
data.properties['fill-opacity'] = fillOpacity and tonumber(fillOpacity) or 0.6
data.properties['fill-opacity'] = fillOpacity and tonumber(fillOpacity) or 0.6
end
end
else -- is a point
else -- is a point
data.properties["marker-symbol"] = util.getParameterValue(contentArgs, 'marker') or  L10n.defaults.marker
data.properties["marker-symbol"] = getParameterValue(contentArgs, 'marker') or  L10n.defaults.marker
data.properties["marker-color"] = util.getParameterValue(contentArgs, 'markerColor') or L10n.defaults.markerColor
data.properties["marker-color"] = getParameterValue(contentArgs, 'markerColor') or L10n.defaults.markerColor
data.properties["marker-size"] = util.getParameterValue(contentArgs, 'markerSize') or L10n.defaults.markerSize
data.properties["marker-size"] = getParameterValue(contentArgs, 'markerSize') or L10n.defaults.markerSize
end
end
else
else
data.type = "ExternalData"
data.type = "ExternalData"


if util.getParameterValue(contentArgs, 'type') == L10n.str.data or util.getParameterValue(contentArgs, 'from') then
if getParameterValue(contentArgs, 'type') == L10n.str.data or getParameterValue(contentArgs, 'from') then
data.service = "page"
data.service = "page"
elseif util.getParameterValue(contentArgs, 'type') == L10n.str.line then
elseif getParameterValue(contentArgs, 'type') == L10n.str.line then
data.service = "geoline"
data.service = "geoline"
elseif util.getParameterValue(contentArgs, 'type') == L10n.str.shape then
elseif getParameterValue(contentArgs, 'type') == L10n.str.shape then
data.service = "geoshape"
data.service = "geoshape"
elseif util.getParameterValue(contentArgs, 'type') == L10n.str.shapeInverse then
elseif getParameterValue(contentArgs, 'type') == L10n.str.shapeInverse then
data.service = "geomask"
data.service = "geomask"
end
end


if util.getParameterValue(contentArgs, 'id') or (not (util.getParameterValue(contentArgs, 'from')) and mw.wikibase.getEntityIdForCurrentPage()) then
if getParameterValue(contentArgs, 'id') or (not (getParameterValue(contentArgs, 'from')) and mw.wikibase.getEntityIdForCurrentPage()) then
data.ids = util.getParameterValue(contentArgs, 'id') or mw.wikibase.getEntityIdForCurrentPage()
data.ids = getParameterValue(contentArgs, 'id') or mw.wikibase.getEntityIdForCurrentPage()
else  
else  
data.title = util.getParameterValue(contentArgs, 'from')
data.title = getParameterValue(contentArgs, 'from')
end
end


data.properties = {
data.properties = {
stroke = util.getParameterValue(contentArgs, 'strokeColor') or L10n.defaults.strokeColor,
stroke = getParameterValue(contentArgs, 'strokeColor') or L10n.defaults.strokeColor,
["stroke-width"] = tonumber(util.getParameterValue(contentArgs, 'strokeWidth')) or L10n.defaults.strokeWidth
["stroke-width"] = tonumber(getParameterValue(contentArgs, 'strokeWidth')) or L10n.defaults.strokeWidth
}
}
local strokeOpacity = util.getParameterValue(contentArgs, 'strokeOpacity')
local strokeOpacity = getParameterValue(contentArgs, 'strokeOpacity')
if strokeOpacity then
if strokeOpacity then
data.properties['stroke-opacity'] = tonumber(strokeOpacity)
data.properties['stroke-opacity'] = tonumber(strokeOpacity)
end
end
local fill = util.getParameterValue(contentArgs, 'fill')
local fill = getParameterValue(contentArgs, 'fill')
if fill and (data.service == "geoshape" or data.service == "geomask") then
if fill and (data.service == "geoshape" or data.service == "geomask") then
data.properties.fill = fill
data.properties.fill = fill
local fillOpacity = util.getParameterValue(contentArgs, 'fillOpacity')
local fillOpacity = getParameterValue(contentArgs, 'fillOpacity')
if fillOpacity then
if fillOpacity then
data.properties['fill-opacity'] = tonumber(fillOpacity)
data.properties['fill-opacity'] = tonumber(fillOpacity)
Line 624: Line 472:
end
end


data.properties.title = util.getParameterValue(contentArgs, 'title') or mw.title.getCurrentTitle().text
data.properties.title = getParameterValue(contentArgs, 'title') or mw.title.getCurrentTitle().text
if util.getParameterValue(contentArgs, 'description') then
if getParameterValue(contentArgs, 'description') then
data.properties.description = util.getParameterValue(contentArgs, 'description')
data.properties.description = getParameterValue(contentArgs, 'description')
end
end


Line 632: Line 480:
end
end


--[[
function makeTagAttribs(args, isTitle)
Makes attributes for the maplink or mapframe tag.
@param {table} args
@param {boolean} [isTitle]  Tag is to be displayed in the title of page rather
  than inline
@returns {table<string,string>} key-value pairs of attribute names and values
]]--
function make.tagAttribs(args, isTitle)
local attribs = {}
local attribs = {}
if util.getParameterValue(args, 'zoom') then
if getParameterValue(args, 'zoom') then
attribs.zoom = util.getParameterValue(args, 'zoom')
attribs.zoom = getParameterValue(args, 'zoom')
end
end
if util.isDeclined(util.getParameterValue(args, 'icon')) then
if isDeclined(getParameterValue(args, 'icon')) then
attribs.class = "no-icon"
attribs.class = "no-icon"
end
end
if util.getParameterValue(args, 'type') == L10n.str.point and not coordsDerivedFromFeatures then
if getParameterValue(args, 'type') == L10n.str.point and not coordsDerivedFromFeatures then
local lat, long = make.coords(args, 'plainOutput')
local lat, long = makeCoords(args, 'plainOutput')
attribs.latitude = tostring(lat)
attribs.latitude = tostring(lat)
attribs.longitude = tostring(long)
attribs.longitude = tostring(long)
end
end
if util.isAffirmed(util.getParameterValue(args, 'frame')) and not(isTitle) then
if isAffirmed(getParameterValue(args, 'frame')) and not(isTitle) then
attribs.width = util.getParameterValue(args, 'frameWidth') or L10n.defaults.frameWidth
attribs.width = getParameterValue(args, 'frameWidth') or L10n.defaults.frameWidth
attribs.height = util.getParameterValue(args, 'frameHeight') or L10n.defaults.frameHeight
attribs.height = getParameterValue(args, 'frameHeight') or L10n.defaults.frameHeight
if util.getParameterValue(args, 'frameCoordinates') then
if getParameterValue(args, 'frameCoordinates') then
local frameLat, frameLong = util.parseCoords(util.getParameterValue(args, 'frameCoordinates'))
local frameLat, frameLong = parseCoords(getParameterValue(args, 'frameCoordinates'))
attribs.latitude = frameLat
attribs.latitude = frameLat
attribs.longitude = frameLong
attribs.longitude = frameLong
else
else
if util.getParameterValue(args, 'frameLatitude') then
if getParameterValue(args, 'frameLatitude') then
attribs.latitude = util.getParameterValue(args, 'frameLatitude')
attribs.latitude = getParameterValue(args, 'frameLatitude')
end
end
if util.getParameterValue(args, 'frameLongitude') then
if getParameterValue(args, 'frameLongitude') then
attribs.longitude = util.getParameterValue(args, 'frameLongitude')
attribs.longitude = getParameterValue(args, 'frameLongitude')
end
end
end
end
if not attribs.latitude and not attribs.longitude and not coordsDerivedFromFeatures then
if not attribs.latitude and not attribs.longitude and not coordsDerivedFromFeatures then
local success, lat, long = pcall(util.wikidataCoords, util.getParameterValue(args, 'id') or mw.wikibase.getEntityIdForCurrentPage())
local success, lat, long = pcall(wikidataCoords, getParameterValue(args, 'id') or mw.wikibase.getEntityIdForCurrentPage())
if success then
if success then
attribs.latitude = tostring(lat)
attribs.latitude = tostring(lat)
Line 674: Line 515:
end
end
end
end
if util.getParameterValue(args, 'frameAlign') then
if getParameterValue(args, 'frameAlign') then
attribs.align = util.getParameterValue(args, 'frameAlign')
attribs.align = getParameterValue(args, 'frameAlign')
end
end
if util.isAffirmed(util.getParameterValue(args, 'plain')) then
if isAffirmed(getParameterValue(args, 'plain')) then
attribs.frameless = "1"
attribs.frameless = "1"
else
else
attribs.text = util.getParameterValue(args, 'text') or L10n.defaults.text
attribs.text = getParameterValue(args, 'text') or L10n.defaults.text
end
end
else
else
attribs.text = util.getParameterValue(args, 'text') or L10n.defaults.text
attribs.text = getParameterValue(args, 'text') or L10n.defaults.text
end
end
return attribs
return attribs
end
end


--[[
function makeTitleOutput(args, tagContent)
Makes maplink wikitext that will be located in the top-right of the title of the
  local titleTag = mw.text.tag('maplink', makeTagAttribs(args, true), tagContent)
page (the same place where coords with |display=title are positioned).
@param {table} args
@param {string} tagContent  Content for the maplink tag
@returns {string}
]]--
function make.titleOutput(args, tagContent)
  local titleTag = mw.text.tag('maplink', make.tagAttribs(args, true), tagContent)
local spanAttribs = {
local spanAttribs = {
style = "font-size: small;",
style = "font-size: small;",
Line 704: Line 538:
end
end


--[[
function makeInlineOutput(args, tagContent)
Makes maplink or mapframe wikitext that will be located inline.
@param {table} args
@param {string} tagContent  Content for the maplink tag
@returns {string}
]]--
function make.inlineOutput(args, tagContent)
local tagName = 'maplink'
local tagName = 'maplink'
if util.getParameterValue(args, 'frame') then
if getParameterValue(args, 'frame') then
tagName = 'mapframe'
tagName = 'mapframe'
end
end


return mw.text.tag(tagName, make.tagAttribs(args), tagContent)
return mw.text.tag(tagName, makeTagAttribs(args), tagContent)
end
end


-- Get the number of key-value pairs in a table, which might not be a sequence.
function tableCount(t)
local count = 0
for k, v in pairs(t) do
count = count + 1
end
return count
end
-- For a table where the values are all tables, returns either the tableCount of
-- the subtables if they are all the same, or nil if they are not all the same.
function subTablesCount(t)
local count = nil
for k, v in pairs(t) do
if count == nil then
count = tableCount(v)
elseif count ~= tableCount(v) then
return nil
end
end
return count
end
function tableFromList(listString)
if type(listString) ~= "string" or listString == "" then return nil end
local separator = (mw.ustring.find(listString, "###", 0, true ) and "###") or
(mw.ustring.find(listString, ";", 0, true ) and ";") or ","
local pattern = "%s*"..separator.."%s*"
return mw.text.split(listString, pattern)
end


--[[
--[[
Makes the HTML required for the swicther to work, including the templatestyles
Makes the HTML required for the swicther to work, including the templatestyles tag
tag.
 
@param {table} params  table sequence of {map, label} tables
@param {table} params  table sequence of {map, label} tables
   @param {string} params{}.map  Wikitext for mapframe map
   @param {string} params{}.map  Wikitext for mapframe map
Line 733: Line 591:
@retruns {string} swicther HTML
@retruns {string} swicther HTML
]]--
]]--
function make.switcherHtml(params, options)
function makeSwitcherHtml(params, options)
if not options then option = {} end
if not options then option = {} end
local frame = mw.getCurrentFrame()
local frame = mw.getCurrentFrame()
Line 786: Line 644:
end
end


--[[----------------------------------------------------------------------------
Package to be exported, i.e. methods which will available to templates and
other modules.
----------------------------------------------------------------------------]]--
local p = {}
local p = {}


Line 795: Line 649:
function p.main(frame)
function p.main(frame)
local parent = frame.getParent(frame)
local parent = frame.getParent(frame)
local switch = util.getParameterValue(parent.args, 'switch')
local switch = getParameterValue(parent.args, 'switch')
local isMulti = switch and mw.text.trim(switch) ~= ""
local isMulti = switch and mw.text.trim(switch) ~= ""
local output = isMulti and p.multi(parent.args) or p._main(parent.args)
local output = isMulti and p.multi(parent.args) or p._main(parent.args)
Line 803: Line 657:
-- Entry points for modules
-- Entry points for modules
function p._main(_args)
function p._main(_args)
local args = util.trimArgs(_args)
local args = trimArgs(_args)
   
   
local tagContent = make.content(args)
local tagContent = makeContent(args)


local display = mw.text.split(util.getParameterValue(args, 'display') or L10n.defaults.display, '%s*' .. L10n.str.dsep .. '%s*')
local display = mw.text.split(getParameterValue(args, 'display') or L10n.defaults.display, '%s*' .. L10n.str.dsep .. '%s*')
local displayInTitle = display[1] ==  L10n.str.title or display[2] ==  L10n.str.title
local displayInTitle = display[1] ==  L10n.str.title or display[2] ==  L10n.str.title
local displayInline = display[1] ==  L10n.str.inline or display[2] ==  L10n.str.inline
local displayInline = display[1] ==  L10n.str.inline or display[2] ==  L10n.str.inline
Line 813: Line 667:
local output
local output
if displayInTitle and displayInline then
if displayInTitle and displayInline then
output = make.titleOutput(args, tagContent) .. make.inlineOutput(args, tagContent)
output = makeTitleOutput(args, tagContent) .. makeInlineOutput(args, tagContent)
elseif displayInTitle then
elseif displayInTitle then
output = make.titleOutput(args, tagContent)
output = makeTitleOutput(args, tagContent)
elseif displayInline then
elseif displayInline then
output = make.inlineOutput(args, tagContent)
output = makeInlineOutput(args, tagContent)
else
else
error(L10n.error.badDisplayPara)
error(L10n.error.badDisplayPara)
Line 826: Line 680:


function p.multi(_args)
function p.multi(_args)
local args = util.trimArgs(_args)
local args = trimArgs(_args)
if not args[L10n.para.switch] then error(L10n.error.noSwitchPara, 0) end
if not args[L10n.para.switch] then error(L10n.error.noSwitchPara, 0) end
local switchParamValue = util.getParameterValue(args, 'switch')
local switchParamValue = getParameterValue(args, 'switch')
local switchLabels = util.tableFromList(switchParamValue)
local switchLabels = tableFromList(switchParamValue)
if #switchLabels == 1 then error(L10n.error.oneSwitchLabel, 0) end
if #switchLabels == 1 then error(L10n.error.oneSwitchLabel, 0) end
Line 843: Line 697:
local switchList = string.match(val, "^"..L10n.str.switch..":(.+)")
local switchList = string.match(val, "^"..L10n.str.switch..":(.+)")
if switchList ~= nil then
if switchList ~= nil then
local values = util.tableFromList(switchList)
local values = tableFromList(switchList)
if #values == 1 then
if #values == 1 then
error(string.format(L10n.error.oneSwitchValue, name), 0)
error(string.format(L10n.error.oneSwitchValue, name), 0)
Line 850: Line 704:
end
end
end
end
if util.tableCount(switchParams) == 0 then
if tableCount(switchParams) == 0 then
error(L10n.error.noSwitchLists, 0)
error(L10n.error.noSwitchLists, 0)
end
end
local switchCount = util.subTablesCount(switchParams)
local switchCount = subTablesCount(switchParams)
if not switchCount then  
if not switchCount then  
error(L10n.error.switchMismatches, 0)
error(L10n.error.switchMismatches, 0)
Line 861: Line 715:
-- Ensure a plain frame will be used (thumbnail will be built by the
-- Ensure a plain frame will be used (thumbnail will be built by the
-- make.switcherHtml function if required, so that switcher options are
-- makeSwitcherHtml function if required, so that switcher options are
-- inside the thumnail)
-- inside the thumnail)
mapframeArgs.plain = "yes"
mapframeArgs.plain = "yes"
Line 876: Line 730:
})
})
end
end
return make.switcherHtml(switcher, {
return makeSwitcherHtml(switcher, {
alignment = args["frame-align"] or "right",
alignment = args["frame-align"] or "right",
isThumbnail = (args.frame and not args.plain) and true or false,
isThumbnail = (args.frame and not args.plain) and true or false,

Revision as of 04:28, 27 June 2020

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

-- Note: Originally written on English Wikipedia at https://en.wikipedia.org/wiki/Module:Mapframe
-- ##### Localisation (L10n) settings #####
-- Replace values in quotes ("") with localised values
local transclude = require("Module:Transclude") -- local copy of https://www.mediawiki.org/wiki/Module:Transclude
local L10n = {}

-- Template parameter names (unnumbered versions only)
--   Specify each as either a single string, or a table of strings (aliases)
--   Aliases are checked left-to-right, i.e. `{ "one", "two" }` is equivalent to using `{{{one| {{{two|}}} }}}` in a template
L10n.para = {
	display		= "display",
	type		= "type",
	id              = { "id", "ids" },
	from		= "from",
	raw		= "raw",
	title		= "title",
	description	= "description",
	strokeColor     = { "stroke-color", "stroke-colour" },
	strokeWidth	= "stroke-width",
	strokeOpacity = "stroke-opacity",
	fill        = "fill",
	fillOpacity     = "fill-opacity",
	coord		= "coord",
	marker		= "marker",
	markerColor	= { "marker-color", "marker-colour" },
	markerSize = "marker-size",
	radius      = { "radius", "radius_m" },
	radiusKm    = "radius_km",
	radiusFt    = "radius_ft",
	radiusMi    = "radius_mi",
	edges       = "edges",
	text		= "text",
	icon		= "icon",
	zoom		= "zoom",
	frame		= "frame",
	plain		= "plain",
	frameWidth	= "frame-width",
	frameHeight	= "frame-height",
	frameCoordinates = { "frame-coordinates", "frame-coord" }, 
	frameLatitude	= { "frame-lat", "frame-latitude" },
	frameLongitude	= { "frame-long", "frame-longitude" },
	frameAlign	= "frame-align",
	switch = "switch",
}

-- Names of other templates this module depends on
L10n.template = {
	Coord		= "coord" -- lowercase template name
}

-- Error messages
L10n.error = {
	badDisplayPara    = "Invalid display parameter",
	noCoords	      = "Coordinates must be specified on Wikidata or in |" .. ( type(L10n.para.coord)== 'table' and L10n.para.coord[1] or L10n.para.coord ) .. "=",
	wikidataCoords    = "Coordinates not found on Wikidata",
	noCircleCoords    = "Circle centre coordinates must be specified, or available via Wikidata",
	negativeRadius    = "Circle radius must be a positive number",
	noRadius          = "Circle radius must be specified",
	negativeEdges     = "Circle edges must be a positive number",
	noSwitchPara      = "Found only one switch value in |" .. ( type(L10n.para.switch)== 'table' and L10n.para.switch[1] or L10n.para.switch ) .. "=",
	oneSwitchLabel    = "Found only one label in |" .. ( type(L10n.para.switch)== 'table' and L10n.para.switch[1] or L10n.para.switch ) .. "=",
	noSwitchLists     = "At least one parameter must have a SWITCH: list",
	switchMismatches  = "All SWITCH: lists must have the same number of values",
	
	 -- "%s" and "%d" tokens will be replaced with strings and numbers when used
	oneSwitchValue    = "Found only one switch value in |%s=",
	fewerSwitchLabels = "Found %d switch values but only %d labels in |" .. ( type(L10n.para.switch)== 'table' and L10n.para.switch[1] or L10n.para.switch ) .. "=",
	noNamedCoords     = "No named coordinates found in %s"
}

-- Other strings
L10n.str = {
	-- valid values for display parameter, e.g. (|display=inline) or (|display=title) or (|display=inline,title) or (|display=title,inline)
	inline		= "inline",			
	title		= "title",	
	dsep		= ",",			-- separator between inline and title (comma in the example above)

	-- valid values for type paramter
	line		= "line",		-- geoline feature (e.g. a road)
	shape		= "shape",		-- geoshape feature (e.g. a state or province)
	shapeInverse	= "shape-inverse",	-- geomask feature (the inverse of a geoshape)
	data		= "data",		-- geoJSON data page on Commons
	point		= "point",		-- single point feature (coordinates)
	circle      = "circle",     -- circular area around a point
	named       = "named",      -- all named coordinates in an article or section
	
	-- Keyword to indicate a switch list. Must NOT use the special characters ^$()%.[]*+-?
	switch = "SWITCH",
	
	-- valid values for icon, frame, and plain parameters
	affirmedWords = ' '..table.concat({
		"add",
		"added",
		"affirm",
		"affirmed",
		"include",
		"included",
		"on",
		"true",
		"yes",
		"y"
	}, ' ')..' ',
	declinedWords = ' '..table.concat({
		"decline",
		"declined",
		"exclude",
		"excluded",
		"false",
		"none",
		"not",
		"no",
		"n",
		"off",
		"omit",
		"omitted",
		"remove",
		"removed"
	}, ' ')..' '
}

-- Default values for parameters
L10n.defaults = {
	display		= L10n.str.inline,
	text		= "Map",
	frameWidth	= "300",
	frameHeight	= "200",
	markerColor	= "5E74F3",
	markerSize	= nil,
	strokeColor	= "#ff0000",
	strokeWidth	= 6,
	edges = 32 -- number of edges used to approximate a circle
}

-- #### End of L10n settings ####

function getParameterValue(args, param_id, suffix)
	suffix = suffix or ''
	if type( L10n.para[param_id] ) ~= 'table' then
		return args[L10n.para[param_id]..suffix]
	end
	for _i, paramAlias in ipairs(L10n.para[param_id]) do
		if args[paramAlias..suffix] then
			return args[paramAlias..suffix]
		end
	end
	return nil
end	

-- Trim whitespace from args, and remove empty args. Also fix control characters.
function trimArgs(argsTable)
	local cleanArgs = {}
	for key, val in pairs(argsTable) do
		if type(val) == 'string' then
			val = val:match('^%s*(.-)%s*$')
			if val ~= '' then
				-- control characters inside json need to be escaped, but stripping them is simpler
				-- See also T214984
				cleanArgs[key] = val:gsub('%c',' ')
			end
		else
			cleanArgs[key] = val
		end
	end
	return cleanArgs
end

function isAffirmed(val)
	if not(val) then return false end
	return string.find(L10n.str.affirmedWords, ' '..val..' ', 1, true ) and true or false
end

function isDeclined(val)
	if not(val) then return false end
	return string.find(L10n.str.declinedWords , ' '..val..' ', 1, true ) and true or false
end

local coordsDerivedFromFeatures = false;
function makeContent(args)
	if getParameterValue(args, 'raw') then
		coordsDerivedFromFeatures = true -- Kartographer should be able to automatically calculate coords from raw geoJSON
		return getParameterValue(args, 'raw')
	end

	local content = {}

    local argsExpanded = {}
    for k, v in pairs(args) do
    	local index = string.match( k, '^[^0-9]+([0-9]*)$' )
    	if index ~= nil then
    		local indexNumber = ''
    		if index ~= '' then
    			indexNumber = tonumber(index)
    		else
    			indexNumber = 1
    		end
    		
    		if argsExpanded[indexNumber] == nil then
    			argsExpanded[indexNumber] = {}
    		end
    		argsExpanded[indexNumber][ string.gsub(k, index, '') ] = v
    	end
    end
	
	for contentIndex, contentArgs in pairs(argsExpanded) do
		local argType = getParameterValue(contentArgs, "type")
		-- Kartographer automatically calculates coords if geolines/shapes are used (T227402)
		if not coordsDerivedFromFeatures then
			coordsDerivedFromFeatures = ( argType == L10n.str.line or argType == L10n.str.shape ) and true or false
		end
		if argType == L10n.str.named then
			local namedCoords = getNamedCoords(getParameterValue(contentArgs, "from"))
			local typeKey = type(L10n.para.type) == "table" and L10n.para.type[1] or L10n.para.type
			local coordKey = type(L10n.para.coord) == "table" and L10n.para.coord[1] or L10n.para.coord
			local titleKey = type(L10n.para.title) == "table" and L10n.para.title[1] or L10n.para.title
			local descKey = type(L10n.para.description) == "table" and L10n.para.description[1] or L10n.para.description
			for _, namedCoord in pairs(namedCoords) do
				contentArgs[typeKey] = "point"
				contentArgs[coordKey]  = namedCoord.coord
				contentArgs[titleKey]  = namedCoord.name
				contentArgs[descKey]  = namedCoord.description
				content[#content+1] = makeContentJson(contentArgs)
			end
		else
			content[#content + 1] = makeContentJson(contentArgs)
		end
	end
	
	--Single item, no array needed
	if #content==1 then return content[1] end

	--Multiple items get placed in a FeatureCollection
	local contentArray = '[\n' .. table.concat( content, ',\n') .. '\n]'
	return contentArray
end

-- recursively extract coord templates
function extractCoordTemplates(wikitext)
	local output = {}
	local templates = mw.ustring.gmatch(wikitext, '{%b{}}')
	local subtemplates = {}
	for template in templates do
		local name = mw.ustring.match(template, '{{([^}|]+)') -- get the template name
		local nameParam = mw.ustring.match(template, "|%s*name%s*=%s*[^}|]+")
		if mw.ustring.lower(mw.text.trim(name)) == L10n.template.Coord then
			if nameParam then table.insert(output, template) end
		elseif mw.ustring.find(template, L10n.template.Coord) then
			local subOutput = extractCoordTemplates(mw.ustring.sub(template, 2))
			for _, t in pairs(subOutput) do
				table.insert(output, t)
			end
		end
	end
	-- ensure coords are not using title display
	for k, v in pairs(output) do
		output[k] = mw.ustring.gsub(v, "|%s*display%s*=[^|}]+", "|display=inline")
	end
	return output
end

--[[ getNamedCoords
  Gets all named coordiates from a page or a section of a page.
  @param {string|nil} page  Page name, or name#section, to get named coordinates
    from. If the name is omitted, i.e. #section or nil or empty string, then
    the current page will be used.
  @returns {table} sequence of {coord, name, description} where coord is the
    coordinates in a format suitable for #parseCoords, and name is a string, and
    description is a string (the coordinates in a format suitable for displaying
    to the reader). If for some reason the name can't be found, the description
    is nil and the name contains display-format coordinates.
  @throws {L10n.error.noNamedCoords} if no named coordinates are found.
]]--
function getNamedCoords(page)
	local parts = mw.text.split(page or "", "#", true)
	local name = parts[1] == "" and mw.title.getCurrentTitle().prefixedText or parts[1]
	local section = parts[2]
	local pageWikitext = transclude.get(section and name.."#"..section or name)
	local coordTemplates = extractCoordTemplates(pageWikitext)
	if #coordTemplates == 0 then error(string.format(L10n.error.noNamedCoords, page or name), 0) end
	local frame = mw.getCurrentFrame()
	local sep = "________"
	local expandedContent = frame:preprocess(table.concat(coordTemplates, sep))
	local expandedTemplates = mw.text.split(expandedContent, sep)
	local namedCoords = {}
	for _, expandedTemplate in pairs(expandedTemplates) do
		local coord = mw.ustring.match(expandedTemplate, "<span class=\"geo%-dec\".->(.-)</span>")
		if coord then
			local name = mw.ustring.match(expandedTemplate, "<span class=\"fn org\">(.-)</span>") or coord
			local description = name ~= coord and coord
			local coord = mw.ustring.gsub(coord, "[° ]", "_")
			table.insert(namedCoords, {coord=coord, name=name, description=description})
		end
	end
	if #namedCoords == 0 then error(string.format(L10n.error.noNamedCoords, page or name), 0) end
	return namedCoords
end

function parseCoords(coords)
	local parts = mw.text.split((mw.ustring.match(coords,'[_%.%d]+[NS][_%.%d]+[EW]') or ''), '_')

	local lat_d = tonumber(parts[1])
	local lat_m = tonumber(parts[2]) -- nil if coords are in decimal format
	local lat_s = lat_m and tonumber(parts[3]) -- nil if coords are either in decimal format or degrees and minutes only
	local lat = lat_d + (lat_m or 0)/60 + (lat_s or 0)/3600
	if parts[#parts/2] == 'S' then
		lat = lat * -1
	end

	local long_d = tonumber(parts[1+#parts/2])
	local long_m = tonumber(parts[2+#parts/2]) -- nil if coords are in decimal format
	local long_s = long_m and tonumber(parts[3+#parts/2]) -- nil if coords are either in decimal format or degrees and minutes only
	local long = long_d + (long_m or 0)/60 + (long_s or 0)/3600
	if parts[#parts] == 'W' then
		long = long * -1
	end

	return lat, long
end

function wikidataCoords(item_id)
	if not(mw.wikibase.isValidEntityId(item_id)) or not(mw.wikibase.entityExists(item_id)) then
		error(L10n.error.noCoords, 0)
	end
	local coordStatements = mw.wikibase.getBestStatements(item_id, 'P625')
	if not coordStatements or #coordStatements == 0 then
		error(L10n.error.wikidataCoords, 0)
	end
	local hasNoValue = ( coordStatements[1].mainsnak and coordStatements[1].mainsnak.snaktype == 'novalue' )
	if hasNoValue then
		error(L10n.error.wikidataCoords, 0)
	end
	local wdCoords = coordStatements[1]['mainsnak']['datavalue']['value']
	return tonumber(wdCoords['latitude']), tonumber(wdCoords['longitude'])
end

function makeCoords(args, plainOutput) 
	local coords, lat, long
	local frame = mw.getCurrentFrame()
	if getParameterValue(args, 'coord') then
		coords = frame:preprocess( getParameterValue(args, 'coord') )
		lat, long = parseCoords(coords)
	else
		lat, long = wikidataCoords(getParameterValue(args, 'id') or mw.wikibase.getEntityIdForCurrentPage())
	end
	if plainOutput then
		return lat, long
	end
	return {[0] = long, [1] = lat}
end

function makeCircleCoords(args)
	local lat, long = makeCoords(args, true)
	local radius = getParameterValue(args, 'radius')
	if not radius then
		radius = getParameterValue(args, 'radiusKm') and tonumber(getParameterValue(args, 'radiusKm'))*1000
		if not radius then
			radius = getParameterValue(args, 'radiusMi') and tonumber(getParameterValue(args, 'radiusMi'))*1609.344
			if not radius then
				radius = getParameterValue(args, 'radiusFt') and tonumber(getParameterValue(args, 'radiusFt'))*0.3048
			end
		end
	end
	local edges = getParameterValue(args, 'edges') or L10n.defaults.edges
	if not lat or not long then
		error(L10n.error.noCircleCoords, 0)
	elseif not radius then
		error(L10n.error.noRadius, 0)
	elseif tonumber(radius) <= 0 then
		error(L10n.error.negativeRadius, 0)
	elseif tonumber(edges) <= 0 then
		error(L10n.error.negativeEdges, 0)
	end
	return circleToPolygon(lat, long, radius, tonumber(edges))
end

function circleToPolygon(lat, long, radius, n) -- n is number of edges
	-- Based on https://github.com/gabzim/circle-to-polygon, ISC licence
	
	function offset(cLat, cLon, distance, bearing)
		local lat1 = math.rad(cLat)
		local lon1 = math.rad(cLon)
		local dByR = distance / 6378137 -- distance divided by 6378137 (radius of the earth) wgs84
		local lat = math.asin(
			math.sin(lat1) * math.cos(dByR) +
			math.cos(lat1) * math.sin(dByR) * math.cos(bearing)
		)
		local lon = lon1 + math.atan2(
			math.sin(bearing) * math.sin(dByR) * math.cos(lat1),
			math.cos(dByR) - math.sin(lat1) * math.sin(lat)
		)
		return {math.deg(lon), math.deg(lat)}
	end
	
	local coordinates = {};
	local i = 0;
	while i < n do
		table.insert(coordinates,
			offset(lat, long, radius, (2*math.pi*i*-1)/n)
		)
		i = i + 1
	end
	table.insert(coordinates, offset(lat, long, radius, 0))
	return coordinates
end

function makeContentJson(contentArgs)
	local data = {}

	if getParameterValue(contentArgs, 'type') == L10n.str.point or getParameterValue(contentArgs, 'type') == L10n.str.circle then
		local isCircle = getParameterValue(contentArgs, 'type') == L10n.str.circle
		data.type = "Feature"
		data.geometry = {
			type = isCircle and "LineString" or "Point",
			coordinates = isCircle and makeCircleCoords(contentArgs) or makeCoords(contentArgs)
		}
		data.properties = {
			title = getParameterValue(contentArgs, 'title') or mw.getCurrentFrame():getParent():getTitle()
		}
		if isCircle then
			-- TODO: This is very similar to below, should be extracted into a function
			data.properties.stroke = getParameterValue(contentArgs, 'strokeColor') or L10n.defaults.strokeColor
			data.properties["stroke-width"] = tonumber(getParameterValue(contentArgs, 'strokeWidth')) or L10n.defaults.strokeWidth
			local strokeOpacity = getParameterValue(contentArgs, 'strokeOpacity')
			if strokeOpacity then
				data.properties['stroke-opacity'] = tonumber(strokeOpacity)
			end
			local fill = getParameterValue(contentArgs, 'fill')
			if fill then
				data.properties.fill = fill
				local fillOpacity = getParameterValue(contentArgs, 'fillOpacity')
				data.properties['fill-opacity'] = fillOpacity and tonumber(fillOpacity) or 0.6
			end
		else -- is a point
			data.properties["marker-symbol"] = getParameterValue(contentArgs, 'marker') or  L10n.defaults.marker
			data.properties["marker-color"] = getParameterValue(contentArgs, 'markerColor') or L10n.defaults.markerColor
			data.properties["marker-size"] = getParameterValue(contentArgs, 'markerSize') or L10n.defaults.markerSize
		end
	else
		data.type = "ExternalData"

		if getParameterValue(contentArgs, 'type') == L10n.str.data or getParameterValue(contentArgs, 'from') then
			data.service = "page"
		elseif getParameterValue(contentArgs, 'type') == L10n.str.line then
			data.service = "geoline"
		elseif getParameterValue(contentArgs, 'type') == L10n.str.shape then
			data.service = "geoshape"
		elseif getParameterValue(contentArgs, 'type') == L10n.str.shapeInverse then
			data.service = "geomask"
		end

		if getParameterValue(contentArgs, 'id') or (not (getParameterValue(contentArgs, 'from')) and mw.wikibase.getEntityIdForCurrentPage()) then
			data.ids = getParameterValue(contentArgs, 'id') or mw.wikibase.getEntityIdForCurrentPage()
		else 
			data.title = getParameterValue(contentArgs, 'from')
		end

		data.properties = {
			stroke = getParameterValue(contentArgs, 'strokeColor') or L10n.defaults.strokeColor,
			["stroke-width"] = tonumber(getParameterValue(contentArgs, 'strokeWidth')) or L10n.defaults.strokeWidth
		}
		local strokeOpacity = getParameterValue(contentArgs, 'strokeOpacity')
		if strokeOpacity then
			data.properties['stroke-opacity'] = tonumber(strokeOpacity)
		end
		local fill = getParameterValue(contentArgs, 'fill')
		if fill and (data.service == "geoshape" or data.service == "geomask") then
			data.properties.fill = fill
			local fillOpacity = getParameterValue(contentArgs, 'fillOpacity')
			if fillOpacity then
				data.properties['fill-opacity'] = tonumber(fillOpacity)
			end
		end
	end

	data.properties.title = getParameterValue(contentArgs, 'title') or mw.title.getCurrentTitle().text
	if getParameterValue(contentArgs, 'description') then
		data.properties.description = getParameterValue(contentArgs, 'description')
	end

	return mw.text.jsonEncode(data)
end

function makeTagAttribs(args, isTitle)
	local attribs = {}
	if getParameterValue(args, 'zoom') then
		attribs.zoom = getParameterValue(args, 'zoom')
	end
	if isDeclined(getParameterValue(args, 'icon')) then
		attribs.class = "no-icon"
	end
	if getParameterValue(args, 'type') == L10n.str.point and not coordsDerivedFromFeatures then
		local lat, long = makeCoords(args, 'plainOutput')
		attribs.latitude = tostring(lat)
		attribs.longitude = tostring(long)
	end
	if isAffirmed(getParameterValue(args, 'frame')) and not(isTitle) then
		attribs.width = getParameterValue(args, 'frameWidth') or L10n.defaults.frameWidth
		attribs.height = getParameterValue(args, 'frameHeight') or L10n.defaults.frameHeight
		if getParameterValue(args, 'frameCoordinates') then
			local frameLat, frameLong = parseCoords(getParameterValue(args, 'frameCoordinates'))
			attribs.latitude = frameLat
			attribs.longitude = frameLong
		else
			if getParameterValue(args, 'frameLatitude') then
				attribs.latitude = getParameterValue(args, 'frameLatitude')
			end
			if getParameterValue(args, 'frameLongitude') then
				attribs.longitude = getParameterValue(args, 'frameLongitude')
			end
		end
		if not attribs.latitude and not attribs.longitude and not coordsDerivedFromFeatures then
			local success, lat, long = pcall(wikidataCoords, getParameterValue(args, 'id') or mw.wikibase.getEntityIdForCurrentPage())
			if success then
				attribs.latitude = tostring(lat)
				attribs.longitude = tostring(long)
			end
		end
		if getParameterValue(args, 'frameAlign') then
			attribs.align = getParameterValue(args, 'frameAlign')
		end
		if isAffirmed(getParameterValue(args, 'plain')) then
			attribs.frameless = "1"
		else
			attribs.text = getParameterValue(args, 'text') or L10n.defaults.text
		end
	else
		attribs.text = getParameterValue(args, 'text') or L10n.defaults.text
	end
	return attribs
end

function makeTitleOutput(args, tagContent)
 	local titleTag = mw.text.tag('maplink', makeTagAttribs(args, true), tagContent)
	local spanAttribs = {
		style = "font-size: small;",
		id = "coordinates"
	}
	return mw.text.tag('span', spanAttribs, titleTag)
end

function makeInlineOutput(args, tagContent)
	local tagName = 'maplink'
	if getParameterValue(args, 'frame') then
		tagName = 'mapframe'
	end

	return mw.text.tag(tagName, makeTagAttribs(args), tagContent)
end

-- Get the number of key-value pairs in a table, which might not be a sequence.
function tableCount(t)
	local count = 0
	for k, v in pairs(t) do
		count = count + 1
	end
	return count
end

-- For a table where the values are all tables, returns either the tableCount of
-- the subtables if they are all the same, or nil if they are not all the same.
function subTablesCount(t)
	local count = nil
	for k, v in pairs(t) do
		if count == nil then
			count = tableCount(v)
		elseif count ~= tableCount(v) then
			return nil
		end
	end
	return count
end

function tableFromList(listString)
	if type(listString) ~= "string" or listString == "" then return nil end
	local separator = (mw.ustring.find(listString, "###", 0, true ) and "###") or
		(mw.ustring.find(listString, ";", 0, true ) and ";") or ","
	local pattern = "%s*"..separator.."%s*"
	return mw.text.split(listString, pattern)
end

--[[
Makes the HTML required for the swicther to work, including the templatestyles tag

@param {table} params  table sequence of {map, label} tables
  @param {string} params{}.map  Wikitext for mapframe map
  @param {string} params{}.label  Label text for swicther option
@param {table} options
  @param {string} options.alignment  "left" or "center" or "right"
  @param {boolean} options.isThumbnail  Display in a thumbnail
  @param {string} options.width  Width of frame, e.g. "200"
  @param {string} [options.caption]  Caption wikitext for thumnail
@retruns {string} swicther HTML
]]--
function makeSwitcherHtml(params, options)
	if not options then option = {} end
	local frame = mw.getCurrentFrame()
	local styles = frame:extensionTag{
		name = "templatestyles",
		args = {src = "Template:Maplink/styles-multi.css"}
	}
	local container = mw.html.create("div")
		:addClass("switcher-container")
		:addClass("mapframe-multi-container")
	if options.alignment == "left" or options.alignment == "right" then
		container:addClass("float"..options.alignment)
	else -- alignment is "center"
		container:addClass("center")
	end
	for i = 1, #params do
		container
			:tag("div")
				:wikitext(params[i].map)
				:tag("span")
					:addClass("switcher-label")
					:css("display", "none")
					:wikitext(mw.text.trim(params[i].label))
	end
	if not options.isThumbnail then
		return styles .. tostring(container)
	end
	local classlist = container:getAttr("class")
	classlist = mw.ustring.gsub(classlist, "%a*"..options.alignment, "")
	container:attr("class", classlist)
	local outerCountainer = mw.html.create("div")
		:addClass("mapframe-multi-outer-container")
		:addClass("mw-kartographer-container")
		:addClass("thumb")
	if options.alignment == "left" or options.alignment == "right" then
		outerCountainer:addClass("t"..options.alignment)
	else -- alignment is "center"
		outerCountainer
			:addClass("tnone")
			:addClass("center")
	end
	outerCountainer
		:tag("div")
			:addClass("thumbinner")
			:css("width", options.width.."px")
			:node(container)
			:node(options.caption and mw.html.create("div")
				:addClass("thumbcaption")
				:wikitext(options.caption)
			)
	return styles .. tostring(outerCountainer)
end

local p = {}

-- Entry point for templates
function p.main(frame)
	local parent = frame.getParent(frame)
	local switch = getParameterValue(parent.args, 'switch')
	local isMulti = switch and mw.text.trim(switch) ~= ""
	local output = isMulti and p.multi(parent.args) or p._main(parent.args)
	return frame:preprocess(output)
end

-- Entry points for modules
function p._main(_args)
	local args = trimArgs(_args)
 
	local tagContent = makeContent(args)

	local display = mw.text.split(getParameterValue(args, 'display') or L10n.defaults.display, '%s*' .. L10n.str.dsep .. '%s*')
	local displayInTitle = display[1] ==  L10n.str.title or display[2] ==  L10n.str.title
	local displayInline = display[1] ==  L10n.str.inline or display[2] ==  L10n.str.inline

	local output
	if displayInTitle and displayInline then
		output = makeTitleOutput(args, tagContent) .. makeInlineOutput(args, tagContent)
	elseif displayInTitle then
		output = makeTitleOutput(args, tagContent)
	elseif displayInline then
		output = makeInlineOutput(args, tagContent)
	else
		error(L10n.error.badDisplayPara)
	end

	return output
end

function p.multi(_args)
	local args = trimArgs(_args)
	if not args[L10n.para.switch] then error(L10n.error.noSwitchPara, 0) end
	local switchParamValue = getParameterValue(args, 'switch')
	local switchLabels = tableFromList(switchParamValue)
	if #switchLabels == 1 then error(L10n.error.oneSwitchLabel, 0) end
	
	local mapframeArgs = {}
	local switchParams = {}
	for name, val in pairs(args) do
		-- Copy to mapframeArgs, if not the switch labels or a switch parameter
		if val ~= switchParamValue and not string.match(val, "^"..L10n.str.switch..":") then
			mapframeArgs[name] = val
		end
		-- Check if this is a param to switch. If so, store the name and switch
		-- values in switchParams table.
		local switchList = string.match(val, "^"..L10n.str.switch..":(.+)")
		if switchList ~= nil then
			local values = tableFromList(switchList)
			if #values == 1 then
				error(string.format(L10n.error.oneSwitchValue, name), 0)
			end
			switchParams[name] = values
		end
	end
	if tableCount(switchParams) == 0 then
		error(L10n.error.noSwitchLists, 0)
	end
	local switchCount = subTablesCount(switchParams)
	if not switchCount then 
		error(L10n.error.switchMismatches, 0)
	elseif switchCount > #switchLabels then
		error(string.format(L10n.error.fewerSwitchLabels, switchCount, #switchLabels), 0)
	end
	
	-- Ensure a plain frame will be used (thumbnail will be built by the
	-- makeSwitcherHtml function if required, so that switcher options are
	-- inside the thumnail)
	mapframeArgs.plain = "yes"
	
	local switcher = {}
	for i = 1, switchCount do
		local label = switchLabels[i]
		for name, values in pairs(switchParams) do
			mapframeArgs[name] = values[i]
		end
		table.insert(switcher, {
			map = p._main(mapframeArgs),
			label = "Show "..label
		})
	end
	return makeSwitcherHtml(switcher, {
		alignment = args["frame-align"] or "right",
		isThumbnail = (args.frame and not args.plain) and true or false,
		width = args["frame-width"] or L10n.defaults.frameWidth,
		caption = args.text
	})
end

return p