Module:Map
Jump to navigation
Jump to search
This documentation is transcluded from Module:Map/doc. (edit | history)
Note to editors: Please don't categorize this template by editing it directly. Instead, place the category in its documentation page, in its "includeonly" section.
Note to editors: Please don't categorize this template by editing it directly. Instead, place the category in its documentation page, in its "includeonly" section.
This module provides input parameters for mapframe and maplink functions supported by Extension:Kartographer.
Usage:
{{#invoke:map|tag|type=maplink|geotype=Point|title=Example|latitude=59.0|longitude=29.0}}
Parameter | Usage |
---|---|
type | maplink or mapframe depending on which function should be invoked
|
geotype | Point for individual points, Polygon for polygons
|
title | Object name |
latitude and longitude | use 'NA' to disable coordinates, including the ones from wikidata |
zoom | Zoom level of the map |
marker-symbol | Symbol, letter, or number for displaying on the map as marker |
marker-color | Color of the map marker |
group | Group of markers (see, eat, drink, etc.) |
show | Which marker groups to show (by default shows the most common groups like see, eat, drink, ...) |
data | data=values fills the polygon given by data data=world;;values fills the area outside of the polygon
|
image | Name of the image shown in the thumbnail |
width and height | map width and map height in px or % of screen width, only for mapframe |
wikidata | if specified, the missing title/lat/long/image fields will be fetched from the respective wikidata entry fields |
local getArgs = require('Module:Arguments').getArgs
local p = {}
function dbg(v, msg)
mw.log((msg or '') .. mw.text.jsonEncode(v))
end
local function has_value (tab, val)
for index, value in ipairs(tab) do
if value == val then
return true
end
end
return false
end
-- Parse all unnamed string parameters in a form of "latitude, longitude" into the real number pairs
function getSequence(args)
local coords = {}
for ind, val in pairs( args ) do
if type(ind) == "number" then
local valid = false
local val2 = mw.text.split( val, ',', true )
-- allow for elevation
if #val2 >= 2 and #val2 <= 3 then
local lat = tonumber(val2[1])
local lon = tonumber(val2[2])
if lat ~= nil and lon ~= nil then
table.insert(coords, { lon, lat } )
valid = true
end
end
if not valid then error('Unnamed parameter #' .. ind .. ' "' .. val .. '" is not recognized as a valid "latitude,longitude" value') end
end
end
return coords
end
-- See http://geojson.org/geojson-spec.html
-- Convert a comma and semicolon separated numbers into geojson coordinate arrays
-- Each geotype expects a certain array depth:
-- Point - [ lon, lat ] All other types use point as the basic type
-- MultiPoint - array of points: [ point, ... ]
-- LineString - array of 2 or more points: [ point, point, ... ]
-- MultiLineString - array of LineStrings: [ [ point, point, ... ], ... ]
-- Polygon - [ [ point, point, point, point, ... ], ... ]
-- each LinearRing is an array of 4 or more points, where first and last must be the same
-- first LinearRing is the exterior ring, subsequent rings are holes in it
-- MultiPolygon - array of Polygons: [ [ [ point, point, point, point, ... ], ... ], ... ]
--
-- For example, for the LineString, data "p1;p2;p3" would be converted to [p1,p2,p3] (each "p" is a [lon,lat] value)
-- LineString has the depth of "1" -- array of points (each point being a two value array)
-- For Polygon, the same sequence "p1;p2;p3" would be converted to [[p1,p2,p3]]
-- Which is an array of array of points. But sometimes we need to specify two subarrays of points:
-- [[p1,p2],[p3]] (last point is in a separate array), and we do it with "p1;p2;;p3"
-- Similarly, for MultiPolygon, "p1;p2;;;p3" would generate [[[p1,p2]],[[p3]]]
--
function p.parseGeoSequence(args)
local result = p._parseGeoSequence(args)
if type(result) == 'string' then error(result) end
return result
end
function p._parseGeoSequence(args)
local allTypes = {
-- how many nested array levels until we get to the Point,
-- second is the minimum number of values each Points array must have
Point = { 1, 1 },
MultiPoint = { 1, 0 },
LineString = { 1, 2 },
MultiLineString = { 2, 2 },
Polygon = { 2, 4 },
MultiPolygon = { 3, 4 },
}
if not allTypes[args.geotype] then return ('Unknown geotype ' .. args.geotype) end
local levels, min = unpack(allTypes[args.geotype])
local result
result = {}
for i = 1, levels do result[i] = {} end
local gap = 0
-- Example for levels==3, converting "p1 ; p2 ; ; ; p3 ; ; p4" => [[[p1, p2]], [[p3],[p4]]]
-- This function will be called after each gap, and all values are done, so the above will call:
-- before p3: gap=2, [],[],[p1,p2] => [[[p1,p2]]],[],[]
-- before p4: gap=1, [[[p1,p2]]],[],[p3] => [[[p1,p2]]],[[p3]]],[]
-- the end, gap=2, [[[p1,p2]]],[[p3]]],[p4] => [[[p1,p2]],[[p3],[p4]]],[],[]
-- Here, convert at "p1 ; ; " from [[],[p1]]
local closeArrays = function (gap)
if #result[levels] < min then
error('Each points array must be at least ' .. min .. ' values')
elseif min == 1 and #result[levels] ~= 1 then
-- Point
error('Point must have exactly one data point')
end
-- attach arrays in reverse order to the higher order ones
for i = levels, levels-gap+1, -1 do
table.insert(result[i-1], result[i])
result[i] = {}
end
return 0
end
local usedSequence = false
for val in mw.text.gsplit(args.data, ';', true) do
local val2 = mw.text.split(val, ',', true)
-- allow for elevation
if #val2 >= 2 and #val2 <= 3 and not usedSequence then
if gap > 0 then gap = closeArrays(gap) end
local lat = tonumber(val2[1])
local lon = tonumber(val2[2])
if lat == nil or lon == nil then return ('Bad data value "' .. val .. '"') end
table.insert(result[levels], { lon, lat } )
else
val = mw.text.trim(val)
if val == '' then
usedSequence = false
gap = gap + 1
if (gap >= levels) then return ('Data must not skip more than ' .. levels-1 .. ' values') end
elseif usedSequence then
return ('Coordinates may not be added right after the named sequence')
else
if gap > 0 then
gap = closeArrays(gap)
elseif #result[levels] > 0 then
return ('Named sequence "' .. val .. '" cannot be used in the middle of the sequence')
end
-- Parse value as a sequence name. Eventually we can load data from external data sources
if val == 'values' then
val = getSequence(args)
elseif min == 4 and val == 'world' then
val = {{36000,-180}, {36000,180}, {-36000,180}, {-36000,-180}, {36000,-180}}
elseif tonumber(val) ~= nil then
return ('Not a valid coordinate or a sequence name: ' .. val)
else
return ('Sequence "' .. val .. '" is not known. Try "values" or "world" (for Polygons), or specify values as lat,lon;lat,lon;... pairs')
end
result[levels] = val
usedSequence = true
end
end
end
-- allow one empty last value (some might close the list with an extra semicolon)
if (gap > 1) then return ('Data values must not have blanks at the end') end
closeArrays(levels-1)
return args.geotype == 'Point' and result[1][1] or result[1]
end
-- Run this function to check that the above works ok
function p.parseGeoSequenceTest()
local testSeq = function(data, expected)
local result = getSequence(data)
if type(result) == 'table' then
local actual = mw.text.jsonEncode(result)
result = actual ~= expected and 'data="' .. mw.text.jsonEncode(data) .. '", actual="' .. actual .. '", expected="' .. expected .. '"<br>\n' or ''
else
result = result .. '<br>\n'
end
return result
end
local test = function(geotype, data, expected, values)
values = values or {}
values.geotype = geotype;
values.data = data;
local result = p._parseGeoSequence(values)
if type(result) == 'table' then
local actual = mw.text.jsonEncode(result)
result = actual ~= expected and 'geotype="' .. geotype .. '", data="' .. data .. '", actual="' .. actual .. '", expected="' .. expected .. '"<br>\n' or ''
else
result = 'geotype="' .. geotype .. '", data="' .. data .. '", error="' .. result .. '<br>\n'
end
return result
end
local values = {' 9 , 8 ','7,6'}
local result = '' ..
testSeq({}, '[]') ..
testSeq({'\t\n 1 \r,-10'}, '[[-10,1]]') ..
testSeq(values, '[[8,9],[6,7]]') ..
test('Point', '1,2', '[2,1]') ..
test('MultiPoint', '1,2;3,4;5,6', '[[2,1],[4,3],[6,5]]') ..
test('LineString', '1,2;3,4', '[[2,1],[4,3]]') ..
test('MultiLineString', '1,2;3,4', '[[[2,1],[4,3]]]') ..
test('MultiLineString', '1,2;3,4;;5,6;7,8', '[[[2,1],[4,3]],[[6,5],[8,7]]]') ..
test('Polygon', '1,2;3,4;5,6;1,2', '[[[2,1],[4,3],[6,5],[2,1]]]') ..
test('MultiPolygon', '1,2;3,4;5,6;1,2', '[[[[2,1],[4,3],[6,5],[2,1]]]]') ..
test('MultiPolygon', '1,2;3,4;5,6;1,2;;11,12;13,14;15,16;11,12', '[[[[2,1],[4,3],[6,5],[2,1]],[[12,11],[14,13],[16,15],[12,11]]]]') ..
test('MultiPolygon', '1,2;3,4;5,6;1,2;;;11,12;13,14;15,16;11,12', '[[[[2,1],[4,3],[6,5],[2,1]]],[[[12,11],[14,13],[16,15],[12,11]]]]') ..
test('MultiPolygon', '1,2;3,4;5,6;1,2;;;11,12;13,14;15,16;11,12;;21,22;23,24;25,26;21,22', '[[[[2,1],[4,3],[6,5],[2,1]]],[[[12,11],[14,13],[16,15],[12,11]],[[22,21],[24,23],[26,25],[22,21]]]]') ..
test('MultiLineString', 'values;;1,2;3,4', '[[[8,9],[6,7]],[[2,1],[4,3]]]', values) ..
test('Polygon', 'world;;world', '[[[36000,-180],[36000,180],[-36000,180],[-36000,-180],[36000,-180]],[[36000,-180],[36000,180],[-36000,180],[-36000,-180],[36000,-180]]]') ..
''
return result ~= '' and result or 'Tests passed'
end
function p._tag(args)
local tagname = args.type or 'maplink'
if tagname ~= 'maplink' and tagname ~= 'mapframe' then error('unknown type "' .. tagname .. '"') end
local geojson
local tagArgs = {
text = args.text,
zoom = tonumber(args.zoom),
latitude = tonumber(args.latitude),
longitude = tonumber(args.longitude),
group = args.group,
show = args.show,
class = args.class,
url = args.url,
image = args.image,
}
if (args.wikidata ~= nil) then
local e = mw.wikibase.getEntity(args.wikidata)
if e.claims ~= nil then
if (not tagArgs.latitude or not tagArgs.longitude) then
if e.claims.P625 ~= nil then
tagArgs.latitude = e.claims.P625[1].mainsnak.datavalue.value.latitude
tagArgs.longitude = e.claims.P625[1].mainsnak.datavalue.value.longitude
end
end
if e.labels.en ~= nil then
-- always try to fetch title, to get a reference in 'Wikidata entities used in this page'
title = e.labels.en.value
end
if not args.title then
args.title = title
end
--if not tagArgs.url then
-- if e.claims.P856 ~= nil then
-- tagArgs.url = e.claims.P856[1].mainsnak.datavalue.value
-- end
--end
if not tagArgs.image then
if e.claims.P18 ~= nil then
tagArgs.image = e.claims.P18[1].mainsnak.datavalue.value
end
end
end
end
if not args.title then
args.title = ''
end
if not tagArgs.url then
tagArgs.url = ''
end
if not tagArgs.image then
tagArgs.image = ''
end
tagArgs.title = args.title
if args.ismarker and (args.latitude == 'NA' or args.longitude == 'NA' or not tagArgs.latitude or not tagArgs.longitude) then
return 'nowiki', '', tagArgs
end
if tagname == 'mapframe' then
tagArgs.width = args.width == nil and 420 or args.width
tagArgs.height = args.height == nil and 420 or args.height
tagArgs.align = args.align == nil and 'right' or args.align
elseif not args.class and (args.text == '' or args.text == '""') then
-- Hide pushpin icon in front of an empty text link
tagArgs.class = 'no-icon'
end
if args.data == '' then args.data = nil end
if (not args.geotype) ~= (not args.data) then
-- one is given, but not the other
if args.data then
error('Parameter "data" is given, but "geotype" is not set. Use one of these: Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon')
elseif args.geotype == "Point" and tagArgs.latitude ~= nil and tagArgs.longitude ~= nil then
-- For Point geotype, it is enough to set latitude and logitude, and data will be set up automatically
args.data = tagArgs.latitude .. ',' .. tagArgs.longitude
else
error('Parameter data must be set. Use "values" to use all unnamed parameters as coordinates (lat,lon|lat,lon|...), "world" for the whole world, a combination to make a mask, e.g. "world;;values", or direct values "lat,lon;lat,lon..." with ";" as value separator')
end
end
-- Kartographer can now automatically calculate needed zoom & lat/long based on the data provided
-- Current version ignores mapmasks, but that will also be fixed soon. Leaving this for now, but can be removed if all is good.
-- tagArgs.zoom = tagArgs.zoom == nil and 14 or tagArgs.zoom
-- tagArgs.latitude = tagArgs.latitude == nil and 51.47766 or tagArgs.latitude
-- tagArgs.longitude = tagArgs.longitude == nil and -0.00115 or tagArgs.longitude
if tagArgs.image ~= '' then
args.description = (args.description or '') .. '[[file:' .. tagArgs.image .. '|300px]]'
end
if args.geotype then
geojson = {
type = "Feature",
properties = {
title = args.title,
description = args.description,
['marker-size'] = args['marker-size'],
['marker-symbol'] = args['marker-symbol'],
['marker-color'] = args['marker-color'],
stroke = args.stroke,
['stroke-opacity'] = tonumber(args['stroke-opacity']),
['stroke-width'] = tonumber(args['stroke-width']),
fill = args.fill,
['fill-opacity'] = tonumber(args['fill-opacity']),
},
geometry = {
type = args.geotype,
coordinates = p.parseGeoSequence(args)
}
}
end
if args.debug ~= nil then
local html = mw.html.create(tagname, not geojson and {selfClosing=true} or nil)
:attr(tagArgs)
if geojson then
html:wikitext( mw.text.jsonEncode(geojson, mw.text.JSON_PRETTY) )
end
return 'syntaxhighlight', tostring(html) .. mw.text.jsonEncode(args, mw.text.JSON_PRETTY), { lang = 'json', latitude=0, longitude=0, title='', url='' }
end
return tagname, geojson and mw.text.jsonEncode(geojson) or '', tagArgs
end
function p.tag(frame)
out = {}
local args = getArgs(frame)
local tag, geojson, tagArgs = p._tag(args)
local listingTypes = {'see', 'eat', 'buy', 'drink', 'sleep'}
if args.ismarker == 'yes' then
if mw.title.getCurrentTitle().namespace == 0 and
has_value({'do', unpack(listingTypes)}, string.lower(args.group)) -- prepend to copy of listingTypes,
then
out[#out + 1] = "[[Category:Has "..string.lower(args.group).." listing]]"
end
if geojson ~= '' then
coordargs = {tagArgs.latitude, tagArgs.longitude, ['title'] = tagArgs.title}
out[#out + 1] = '<span class="noprint listing-coordinates" style="display:none">'
out[#out + 1] = '<span class="geo">'
out[#out + 1] = '<abbr class="latitude">' .. tagArgs.latitude ..'</abbr>'
out[#out + 1] = '<abbr class="longitude">' .. tagArgs.longitude ..'</abbr>'
out[#out + 1] = '</span></span>'
out[#out + 1] = '<span title="Map for this \''.. args.group ..'\' marker">' -- TODO
out[#out + 1] = frame:extensionTag(tag, geojson, tagArgs)
out[#out + 1] = ' </span>'
if mw.title.getCurrentTitle().namespace == 0 then
out[#out + 1] = "[[Category:Has map markers]]"
end
else
if mw.title.getCurrentTitle().namespace == 0 and
has_value(listingTypes, string.lower(args.group)) and
(args.latitude ~= 'NA' and args.longitude ~= 'NA')
then
out[#out + 1] = "[[Category:"..string.lower(args.group).." listing with no coordinates]]"
end
end
if mw.title.getCurrentTitle().namespace == 0 and
has_value({'city', 'vicinity'}, string.lower(args.group)) and
(args.wikidata == nil or args.wikidata == '') and
(args.image == nil or args.image == '') then
out[#out + 1] = "[[Category:Region markers without wikidata]]"
end
if tagArgs.title ~= '' then
title = '<span id="'.. mw.uri.anchorEncode(tagArgs.title) ..'" class="fn org listing-name">\'\'\''.. tagArgs.title ..'\'\'\'</span>'
else
title = ''
end
if tagArgs.url ~= '' then
out[#out + 1] = '['.. tagArgs.url ..' '..title..']'
else
out[#out + 1] = title
end
return table.concat(out, "")
else
return frame:extensionTag(tag, geojson, tagArgs)
end
end
return p