Difference between revisions of "Module:Map"

From Northumberland Climbing
Jump to navigation Jump to search
module>ARR8
(also create category for Do)
 
m (1 revision imported)
 
(No difference)

Latest revision as of 08:20, 30 July 2020

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] = '&#32;</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