Vés al contingut

Mòdul:Map

De Viquidites

La documentació d'ús d'aquest mòdul es pot crear a Mòdul:Map/ús

-- 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: 20181028

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'}
	-- see function ViewSomething on Module:Wikidata
	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)
	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
			coord[#coord+1] = value.latitude .. ',' .. value.longitude
		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 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

-- Main function
function p._tag(args)
	local tagname = args.type or 'mapframe'
	if tagname ~= 'maplink' and tagname ~= 'mapframe' then printError('type-invalid', tagname) end
	
	local geojson
	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
		args['title1'] = args['title1'] or args['title']
		args['image1'] = args['image1'] or args['image']
		args['description1'] = args['description1'] or args['description']
		args['geotype1'] = args['geotype1'] or args['geotype']
		args['commons1'] = args['commons1'] or args['commons']
	end
	
	local externalData = {['geoshape'] = true, ['geomask'] = true, ['geoline'] = true, ['page'] = true, ['none'] = true}
	local featureCollection = {['Point'] = true, ['MultiPoint'] = true, ['LineString'] = true, ['Polygon'] = true}
	local myfeatures, myexternal, allpoints = {}, {}, {}
	local i, j = 1, 1
	while args['coordinates'..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 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
				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
	
	return tagname, geojson and mw.text.jsonEncode(geojson) or '', tagArgs
end

function p.tag(frame)
	if mw.title.new(frame:getParent():getTitle()).isContentPage and not mw.title.new(frame:getTitle()).isSubpage then
		-- invoked from a content page and not invoking a module subpage
		printError('not-from-content-page')
	end
	local getArgs = require('Module:Arguments').getArgs
	local args = getArgs(frame)
	local tag, geojson, tagArgs = p._tag(args)
	
	local categories = ''
	
	if errormessage then
		categories = mw.message.new('Kartographer-broken-category'):inLanguage(mw.language.getContentLanguage().code):plain()
		return errormessage .. '[[Category:' .. categories .. ']]'
	end
	
	for k, v in pairs(cat) do
		if v then
			categories = categories .. '[[Category:' .. i18n[k] .. ']]'
		end
	end
	
	return frame:extensionTag(tag, geojson, tagArgs) .. categories
end

return p