模块:Mapframe
外观
此模块的文档可以在模块: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 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"
}
-- 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 ) .. "=",
}
-- 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
-- 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
-- Kartographer automatically calculates coords if geolines/shapes are used (T227402)
if not coordsDerivedFromFeatures then
local type = contentArgs['type']
coordsDerivedFromFeatures = ( type == L10n.str.line or type == L10n.str.shape ) and true or false
end
content[contentIndex] = makeContentJson(contentArgs)
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
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