Module:Map
Documentation for this module may be created at Module:Map/doc
-- Credits:
-- Original from Wikivoyage
-- Developed for Kartographer version on Wikipedia by Vriullop @cawiki
-- Formulae:
-- CSGNetwork at http://www.csgnetwork.com/degreelenllavcalc.html via @enwiki
-- OpenStreetMap
-- Version: 20210211
local p = {}
-- Localization on [[Module:Map/i18n]]
local i18n = {
["coordinate-invalid"] = "Parameter $1 is an invalid value of \"latitude,longitude\".",
["type-invalid"] = "Type $1 is invalid. Use mapframe or maplink.",
["geotype-invalid"] = "Geotype $1 is an invalid value.",
["ids-invalid"] = "Parameter ids $1 is invalid.",
["polygon-required-points"] = "A polygon requires a minimum of 4 coordinate points.",
["polygon-not-closed"] = "A closed polygon requires last point equal to first one.",
['ids-not-found'] = "Ids not found for external data.",
--['not-from-content-page'] = "Do not invoke from content page. Use a template or use a module subpage like /sandbox for testing .",
-- local categories
['cat-several-features'] = "",
['cat-linestring-drawn'] = "",
['cat-polygon-drawn'] = "",
}
local cat = {['cat-several-features'] = false, ['cat-linestring-drawn'] = false, ['cat-polygon-drawn'] = false}
-- Credit to http://stackoverflow.com/a/1283608/2644759, cc-by-sa 3.0
local function tableMerge(t1, t2)
for k, v in pairs(t2) do
if type(v) == "table" then
if type(t1[k] or false) == "table" then
tableMerge(t1[k] or {}, t2[k] or {})
else
t1[k] = v
end
else
t1[k] = v
end
end
return t1
end
local function loadI18n()
local exist, res = pcall(require, "Module:Map/i18n")
if exist and next(res) ~= nil then
tableMerge(i18n, res.i18n)
end
end
loadI18n()
local errormessage
local function printError(key, par)
-- just print first error
errormessage = errormessage or ('<span class="error">' .. (par and mw.ustring.gsub(i18n[key], "$1", par) or i18n[key]) .. '</span>')
end
-- Convert coordinates input format to geojson table
local function parseGeoSequence(data, geotype)
local coordsGeo = {}
for line_coord in mw.text.gsplit(data, ':', true) do -- Polygon - linearRing:linearRing...
local coordsLine = {}
for point_coord in mw.text.gsplit(line_coord, ';', true) do -- LineString or MultiPoint - point;point...
local valid = false
local val = mw.text.split(point_coord, ',', true) -- Point - lat,lon
-- allow for elevation
if #val >= 2 and #val <= 3 then
local lat = tonumber(val[1])
local lon = tonumber(val[2])
if lat ~= nil and lon ~= nil then
table.insert(coordsLine, {lon, lat})
valid = true
end
end
if not valid and point_coord ~= '' then printError('coordinate-invalid', point_coord) end
end
if geotype == 'Polygon' then
if #coordsLine < 4 then
printError('polygon-required-points')
elseif table.concat(coordsLine[1]) ~= table.concat(coordsLine[#coordsLine]) then
printError('polygon-not-closed')
end
end
table.insert(coordsGeo, coordsLine)
end
if geotype == 'Point' then
coordsGeo = coordsGeo[1][1]
elseif geotype == "LineString" or geotype == "MultiPoint" then
coordsGeo = coordsGeo[1]
elseif geotype ~= 'Polygon' then
printError('geotype-invalid', geotype)
end
return coordsGeo
end
-- data Point - {lon,lat}
-- data LineString - { {lon,lat}, {lon,lat}, ... }
-- data Polygon - { { {lon,lat}, {lon,lat} }, { {lon,lat}, {lon,lat} }, ... }
-- output as LineString format
local function mergePoints(stack, merger)
if merger == nil then return stack end
for _, val in ipairs(merger) do
if type(val) == "number" then -- Point format
stack[#stack + 1] = merger
break
elseif type(val[1]) == "table" then -- Polygon format
for _, val2 in ipairs(val) do
stack[#stack + 1] = val2
end
else -- LineString format
stack[#stack + 1] = val
end
end
return stack
end
-- remove duplicated points, they may affect zoom calculation
local function setUniquePoints(t)
-- build set of unique values
local uniqueElements = {}
for _, point in ipairs(t) do
if not uniqueElements[point[1]] then
uniqueElements[point[1]] = {}
end
uniqueElements[point[1]][point[2]] = true
end
-- convert the set
local result = {}
for lon, _ in pairs(uniqueElements) do
for lat, _ in pairs(uniqueElements[lon]) do
table.insert(result, {lon, lat})
end
end
return result
end
local function getCoordBounds(data)
local latN, latS = -90, 90
local lonE, lonW = -180, 180
for i, val in ipairs(data) do
latN = math.max(val[2], latN)
latS = math.min(val[2], latS)
lonE = math.max(val[1], lonE)
lonW = math.min(val[1], lonW)
end
return latN, latS, lonE, lonW
end
local function getCoordCenter(data)
local latN, latS, lonE, lonW = getCoordBounds(data)
local latCenter = latS + (latN - latS) / 2
local lonCenter = lonW + (lonE - lonW) / 2
return lonCenter, latCenter
end
-- meters per degree by latitude
local function mxdByLat(lat)
local latRad = math.rad(lat)
-- see [[Geographic coordinate system#Expressing latitude and longitude as linear units]], by CSGNetwork
local mxdLat = 111132.92 - 559.82 * math.cos(2 * latRad) + 1.175 * math.cos(4 * latRad) - 0.023 * math.cos(6 * latRad)
local mxdLon = 111412.84 * math.cos(latRad) - 93.5 * math.cos(3 * latRad) + 0.118 * math.cos(5 * latRad)
return mxdLat, mxdLon
end
-- Calculate zoom to fit coordinate bounds into height and width of frame
local function getZoom(data, height, width)
local lat1, lat2, lon1, lon2 = getCoordBounds(data)
local latMid = (lat1 + lat2) / 2 -- mid latitude
local mxdLat, mxdLon = mxdByLat(latMid)
-- distances in meters
local distLat = math.abs((lat1 - lat2) * mxdLat)
local distLon = math.abs((lon1 - lon2) * mxdLon)
-- margin 100px in height and width, right upper icon is about 50x50px
local validHeight = math.max(height - 100, 100)
local validWidth = math.max(width - 100, 100)
-- maximum zoom fitting all points
local latRad = math.rad(latMid)
for zoom = 19, 0, -1 do
-- see https://wiki.openstreetmap.org/wiki/Zoom_levels#Metres_per_pixel_math
-- equatorial circumference 40 075 036 m: [[Equator#Exact length]]
local distLatFrame = 40075036 * validHeight * math.cos(latRad) / (2 ^ (zoom + 8))
local distLonFrame = 40075036 * validWidth * math.cos(latRad) / (2 ^ (zoom + 8))
if distLatFrame > distLat and distLonFrame > distLon then
return zoom
end
end
return 0
end
-- Geotype based on coordinates format pattern
local function findGeotype(coord)
local _, semicolons = string.gsub(coord, ';', '')
local firstcoord = string.match(coord, "[0-9%.%-]+%s*,%s*[0-9%.%-]+")
local lastcoord = string.match(string.reverse(coord), "[0-9%.%-]+%s*,%s*[0-9%.%-]+")
if firstcoord == nil or lastcoord == nil then
printError('coordinate-invalid', coord)
else
lastcoord = string.reverse(lastcoord)
end
if string.find(coord, ':') or (semicolons > 2 and firstcoord == lastcoord) then
return 'Polygon'
elseif semicolons > 0 then
return 'LineString' -- or MultiPoint
else
return 'Point'
end
end
local function fetchWikidata(id, snak)
-- snak is a table like {'claims', 'P625', 1, 'mainsnak', 'datavalue', 'value'}
local value
id = mw.text.trim(id)
if not string.find(id, "^Q%d+$") then
printError('ids-invalid', id)
else
value = mw.wikibase.getBestStatements(id, snak[2])
for i = 3, #snak do
if value == nil then break end
value = value[snak[i]]
end
end
return value
end
-- Fetch coordinates from Wikidata for a list of comma separated ids
local function getCoordinatesById(ids)
local function roundPrec(num, prec)
if prec == nil or prec <= 0 then return num end
local sig = 10^math.floor(math.log10(prec)+.5) -- significant figure from sexagesimal precision: 0.00123 -> 0.001
return math.floor(num / sig + 0.5) * sig
end
if ids == nil then return end
local coord = {}
local snak = {'claims', 'P625', 1, 'mainsnak', 'datavalue', 'value'}
for idx in mw.text.gsplit(ids, '%s*,%s*') do
local value = fetchWikidata(idx, snak)
if value then
local prec = value.precision
coord[#coord+1] = roundPrec(value.latitude, prec) .. ',' .. roundPrec(value.longitude, prec)
end
end
return #coord > 0 and table.concat(coord, ';') or nil
end
local function getBoundsById(ids, coordInput)
if ids == nil then return {} end
local coord = mw.text.split(coordInput, '%s*;%s*')
local id = mw.text.split(ids, '%s*,%s*')
if #coord ~= #id then return {} end
local id_parent = nil
if #id == 1 then
id_parent = fetchWikidata(id[1], {'claims', 'P131', 1, 'mainsnak', 'datavalue', 'value', 'id'})
if id_parent ~= nil then
id[2] = id_parent -- P131: located in the administrative territorial entity, last try
coord[2] = coord[1]
end
end
local bounds = {}
-- try to fetch Wikidata in this order: area, watershed area, population, and finally by administrative entity
local snak_area = {'claims', 'P2046', 1, 'mainsnak', 'datavalue', 'value'} -- area and unit
local snak_warea = {'claims', 'P2053', 1, 'mainsnak', 'datavalue', 'value'} -- area and unit
local snak_pop = {'claims', 'P1082', 1, 'mainsnak', 'datavalue', 'value'} -- population
local convert_area = {['Q712226'] = 1000000, ['Q35852'] = 10000, ['Q232291'] = 2589988.110336, ['Q81292'] = 4046.8564224,
['Q935614'] = 1600, ['Q857027'] = 0.09290304, ['Q21074767'] = 1138100, ['Q25343'] = 1} -- to square metres
-- query Wikidata: http://tinyurl.com/j8aez2g
for i = 1, #id do
if i == 2 and id[2] == id_parent and #bounds > 0 then break end -- only if not found previously
local amount, unit, area
local value = fetchWikidata(id[i], snak_area) or fetchWikidata(id[i], snak_warea)
if value then
amount = tonumber(value.amount)
unit = string.match(value.unit, "(Q%d+)")
if convert_area[unit] then
area = amount * convert_area[unit]
end
end
if area == nil then
value = fetchWikidata(id[i], snak_pop)
if value then
amount = tonumber(value.amount)
-- average density estimated for populated areas: 100; see [[Population density]]
area = amount / 100 * 1000000
end
end
if area then
local radius = math.sqrt(area / math.pi) -- approximation with a circle
local latlon = mw.text.split(coord[i], '%s*,%s*')
local mxdLat, mxdLon = mxdByLat(latlon[1])
bounds[#bounds+1] = {latlon[2] + (radius / mxdLon), latlon[1] + (radius / mxdLat)} -- NE bound, geoJSON format
bounds[#bounds+1] = {latlon[2] - (radius / mxdLon), latlon[1] - (radius / mxdLat)} -- SW bound
end
end
return bounds
end
local function circleToPolygon(center, radius, edges, turn)
-- From en:Module:Mapframe, based on https://github.com/gabzim/circle-to-polygon, ISC licence
local 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(lat) .. ',' .. math.deg(lon)
end
local coords = mw.text.split(center, ',', true)
local lat = tonumber(coords[1])
local long = tonumber(coords[2])
edges = edges or 32
local move = 2 * math.pi * (turn or 0)
local coordinates = {}
for i = 0, edges do
table.insert(coordinates, offset(lat, long, radius, ((2*math.pi*-i)/edges) + move))
end
return table.concat(coordinates, ';')
end
local function addCategories(geotype, i)
if not mw.title.getCurrentTitle().isContentPage then return end
if i > 2 and i18n["cat-several-features"] ~= '' then
cat["cat-several-features"] = true
end
if geotype == "LineString" and i18n["cat-linestring-drawn"] ~= '' then
cat["cat-linestring-drawn"] = true
elseif geotype == "Polygon" and i18n["cat-polygon-drawn"] ~= '' then
cat["cat-polygon-drawn"] = true
end
return
end
-- Recursively extract coord templates which have a name parameter.
-- from en:Module:Mapframe
local 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 not nameParam then
nameParam = mw.ustring.match(template, "|%s*nom%s*=%s*[^}|]+")
end
if mw.ustring.lower(mw.text.trim(name)) == 'coord' then
if nameParam then table.insert(output, template) end
elseif mw.ustring.find(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
-- Gets all named coordiates from a page or a section of a page.
-- dependency: Module:Transcluder
local 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 = require('Module:Transcluder').get(section and name.."#"..section or name)
local coordTemplates = extractCoordTemplates(pageWikitext)
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\">(.-)</span>")
if coord then
coord = mw.ustring.gsub(coord, ";", ",")
local name = mw.ustring.match(expandedTemplate, "&title=(.-)<span") or coord
name = mw.uri.decode(name)
local description = name ~= coord and coord
table.insert(namedCoords, {coord=coord, name=name, description=description})
end
end
return namedCoords
end
-- Main function
local function main(args)
local tagname = args.type or 'mapframe'
if tagname ~= 'maplink' and tagname ~= 'mapframe' then printError('type-invalid', tagname) end
local tagArgs = {
text = args.text,
zoom = tonumber(args.zoom),
latitude = tonumber(args.latitude),
longitude = tonumber(args.longitude)
}
local defaultzoom = tonumber(args.default_zoom)
if tagname == 'mapframe' then
tagArgs.width = args.width or 300
tagArgs.height = args.height or 300
tagArgs.align = args.align or 'right'
if args.frameless ~= nil and tagArgs.text == nil then tagArgs.frameless = true end
else
tagArgs.class = args.class
end
local wdid = args.item or mw.wikibase.getEntityIdForCurrentPage()
if args['coordinates1'] == nil and args['geotype1'] == nil then -- single feature
args['coordinates1'] = args['coordinates'] or args[1]
if args['coordinates1'] == nil and args['latitude'] and args['longitude'] then
args['coordinates1'] = args['latitude'] .. ',' .. args['longitude']
elseif args['coordinates1'] == nil then
args['coordinates1'] = getCoordinatesById(wdid)
end
local par = {'title', 'image', 'description', 'geotype', 'commons', 'radius', 'radiuskm', 'edges', 'turn', 'from'}
for _, v in ipairs(par) do
args[v .. '1'] = args[v .. '1'] or args[v]
end
end
local externalData = {['geoshape'] = true, ['geomask'] = true, ['geoline'] = true, ['page'] = true, ['none'] = true, ['named'] = true}
local featureCollection = {['Point'] = true, ['MultiPoint'] = true, ['LineString'] = true, ['Polygon'] = true, ['circle'] = true}
local myfeatures, myexternal, allpoints = {}, {}, {}
local i, j = 1, 1
while args['coordinates'..i] or args['ids'..i] or externalData[args['geotype'..i]] or args['commons'..i] do
local geotypex = args['geotype'..i] or args['geotype']
if geotypex == nil and args['commons'..i] then
geotypex = 'page'
end
if geotypex ~= nil and not (featureCollection[geotypex] or externalData[geotypex]) then
printError('geotype-invalid', geotypex)
break
end
if geotypex == 'none' then -- skip this object
i = i + 1
else
local mystack
if geotypex == 'named' then
local namedCoords = getNamedCoords(args['from'..i])
mystack = myfeatures
for _, namedCoord in pairs(namedCoords) do
j = #mystack + 1
mystack[j] = {}
mystack[j]['type'] = "Feature"
mystack[j]['geometry'] = {}
mystack[j]['geometry']['type'] = "Point"
mystack[j]['geometry']['coordinates'] = parseGeoSequence(namedCoord.coord, 'Point')
allpoints = mergePoints(allpoints, mystack[j]['geometry']['coordinates'])
mystack[j]['properties'] = {}
mystack[j]['properties']['title'] = namedCoord.name
mystack[j]['properties']['description'] = namedCoord.description
mystack[j]['properties']['marker-size'] = args['marker-size'..i] or args['marker-size']
mystack[j]['properties']['marker-symbol'] = args['marker-symbol'..i] or args['marker-symbol']
mystack[j]['properties']['marker-color'] = args['marker-color'..i] or args['marker-color']
end
break
elseif externalData[geotypex or ''] then
mystack = myexternal
j = #mystack + 1
mystack[j] = {}
mystack[j]['type'] = "ExternalData"
mystack[j]['service'] = geotypex
if geotypex == "page" then
local page_name = args['commons'..i]
if mw.ustring.find(page_name, "Data:", 1, true) == 1 then
page_name = string.sub(page_name, 6)
end
if mw.ustring.find(page_name, ".map", -4, true) == nil then
page_name = page_name .. '.map'
end
mystack[j]['title'] = page_name
else
mystack[j]['ids'] = args['ids'..i] or args['ids'] or wdid
if mystack[j]['ids'] == nil then printError('ids-not-found'); break end
end
local mycoordinates = args['coordinates'..i]
if mycoordinates == nil and (tagArgs.latitude == nil or tagArgs.longitude == nil or tagArgs.zoom == nil) then
mycoordinates = getCoordinatesById(mystack[j]['ids'])
end
if mycoordinates ~= nil then
local mypoints = getBoundsById(mystack[j]['ids'], mycoordinates)
if #mypoints == 0 then
mypoints = parseGeoSequence(mycoordinates, mycoordinates:find(';') and 'MultiPoint' or 'Point')
end
allpoints = mergePoints(allpoints, mypoints)
end
else
args['coordinates'..i] = args['coordinates'..i] or getCoordinatesById(args['ids'..i])
if geotypex == 'circle' then
if not args['radius'..i] and args['radiuskm'..i] then
args['radius'..i] = args['radiuskm'..i] * 1000
end
args['coordinates'..i] = circleToPolygon(args['coordinates'..i], args['radius'..i], args['edges'..i], args['turn'..i])
geotypex = 'Polygon'
end
mystack = myfeatures
j = #mystack + 1
mystack[j] = {}
mystack[j]['type'] = "Feature"
mystack[j]['geometry'] = {}
mystack[j]['geometry']['type'] = geotypex or findGeotype(args['coordinates'..i])
mystack[j]['geometry']['coordinates'] = parseGeoSequence(args['coordinates'..i], mystack[j]['geometry']['type'])
allpoints = mergePoints(allpoints, mystack[j]['geometry']['coordinates'])
addCategories(mystack[j]['geometry']['type'], i)
end
mystack[j]['properties'] = {}
mystack[j]['properties']['title'] = args['title'..i] or (geotypex and geotypex .. i) or mystack[j]['geometry']['type'] .. i
if args['image'..i] then
args['description'..i] = (args['description'..i] or '') .. '[[File:' .. args['image'..i] .. '|300px]]'
end
mystack[j]['properties']['description'] = args['description'..i]
mystack[j]['properties']['marker-size'] = args['marker-size'..i] or args['marker-size']
mystack[j]['properties']['marker-symbol'] = args['marker-symbol'..i] or args['marker-symbol']
mystack[j]['properties']['marker-color'] = args['marker-color'..i] or args['marker-color']
mystack[j]['properties']['stroke'] = args['stroke'..i] or args['stroke']
mystack[j]['properties']['stroke-opacity'] = tonumber(args['stroke-opacity'..i] or args['stroke-opacity'])
mystack[j]['properties']['stroke-width'] = tonumber(args['stroke-width'..i] or args['stroke-width'])
mystack[j]['properties']['fill'] = args['fill'..i] or args['fill']
mystack[j]['properties']['fill-opacity'] = tonumber(args['fill-opacity'..i] or args['fill-opacity'])
i = i + 1
end
end
-- calculate defaults for static mapframe; maplink is dynamic
if (tagArgs.latitude == nil or tagArgs.longitude == nil) and #allpoints > 0 then
if tagname == "mapframe" or tagArgs.text == nil then -- coordinates needed for text in maplink
tagArgs.longitude, tagArgs.latitude = getCoordCenter(allpoints)
end
end
if tagArgs.zoom == nil then
if tagname == "mapframe" then
local uniquepoints = setUniquePoints(allpoints)
if #uniquepoints == 1 then
local coordInput = uniquepoints[1][2] .. ',' .. uniquepoints[1][1]
local mybounds = getBoundsById(wdid, coordInput) -- try to fetch by area
uniquepoints = mergePoints(uniquepoints, mybounds)
end
if #uniquepoints <= 1 then
tagArgs.zoom = defaultzoom or 9
else
tagArgs.zoom = getZoom(uniquepoints, tagArgs.height, tagArgs.width)
end
else
tagArgs.zoom = defaultzoom
end
end
local geojson = myexternal
if #myfeatures > 0 then
geojson[#geojson + 1] = {type = "FeatureCollection", features = myfeatures}
end
if args.debug ~= nil then
local html = mw.text.tag{name = tagname, attrs = tagArgs, content = mw.text.jsonEncode(geojson, mw.text.JSON_PRETTY)}
return 'syntaxhighlight', tostring(html) .. ' Arguments:' .. mw.text.jsonEncode(args, mw.text.JSON_PRETTY), {lang = 'json'}
end
if geojson and #geojson == 0 then
errormessage = erromessage or '' -- previous message or void for no map data
end
return tagname, geojson and mw.text.jsonEncode(geojson) or '', tagArgs
end
local function addCat(cat)
local categories = ''
for k, v in pairs(cat) do
if v then
categories = categories .. '[[Category:' .. i18n[k] .. ']]'
end
end
return categories
end
local function errorMessage(message)
if message == '' then -- no map data
return
else
categories = mw.message.new('Kartographer-broken-category'):inLanguage(mw.language.getContentLanguage().code):plain()
return message .. '[[Category:' .. categories .. ']]'
end
end
function p.tag(frame) -- entry point from invoke
local getArgs = require('Module:Arguments').getArgs
local args = getArgs(frame)
local tag, geojson, tagArgs = main(args)
if errormessage then return errorMessage(errormessage) end
return frame:extensionTag(tag, geojson, tagArgs) .. addCat(cat)
end
function p._tag(args) -- entry point from require
local tag, geojson, tagArgs = main(args)
if errormessage then return errorMessage(errormessage) end
return mw.getCurrentFrame():extensionTag(tag, geojson, tagArgs) .. addCat(cat)
end
return p