Module:FindFeatures

From Omniversalis

Documentation for this module may be created at Module:FindFeatures/doc

 -- This module finds features with coordinates in a certain area on a globe.
 -- It uses other modules containing database files, which can be generated by Module:FindFeatures/displayDatabase
 -- These files can be edited manually, so for brevity they use simple indexes:
 -- * recordname = dataitem[1]
 -- * latitude = dataitem[2][1]
 -- * longitude = dataitem[2][2]
 
local getArgs = require('Module:Arguments').getArgs
local p = {}
local DEFAULTHITS = 5
local DEFAULTSHOWDIST = 1
local GLOBES = mw.loadData('Module:FindFeatures/globes') or {}
local GLOBEDATA = {}
local i = 1
while GLOBES[i] do
    local fcn = GLOBES[i][1]
    GLOBEDATA[fcn] = {GLOBES[i][2], GLOBES[i][3], GLOBES[i][4], "Module:FindFeatures/"..fcn}
    p[fcn] = function (frame)
    return p.main(frame, unpack(GLOBEDATA[fcn]))
    end
    p[mw.ustring.gsub(fcn, "(.)", mw.ustring.lower, 1)] = p[fcn]
    i = i + 1
end
local DEBUGLOG = ""
local WARNCATEGORIES = {}

function selfLink(link, current, distance)
    -- link may contain "|" piping but should otherwise be ready to go in [[ ]]
    local link = mw.ustring.gsub(link, "%s*|.*$", "") or link
    if (link == current) then
        if (distance and distance > 0.0001) then
            table.insert(WARNCATEGORIES, "position")
        end
        return true
    else
        return nil
    end
end

function warnings()
    local messages = ""
    local i = 1
    while WARNCATEGORIES[i] do
        messages = messages .. "[[Category: Errors reported by Module:FindFeatures/" .. WARNCATEGORIES[i] .. "]]"
        i = i + 1
    end
    return messages
end

function parseBounds(args)
    local i
    local norths = {}
    local easts = {}
    for i = 1, 4 do
        if args[i] then
            local value, direction = parseBound(args[i])
            if (direction == "S") or (direction == "W") then value = 0 - value end
            if direction == "N" or direction == "S" then
                table.insert(norths, value)
            elseif direction == "E" or direction == "W" then
                table.insert(easts, value)
            end
        end
    end
    if (#norths == 2 and #easts == 2) then
        local bound = {}
        if norths[1] > norths[2] then
            bound.N, bound.S = norths[1], norths[2]
        else
            bound.N, bound.S = norths[2], norths[1]
        end
        -- screw the wrap.  I don't even care anymore.  Let the user think about it.
        if easts[1] > easts[2] then
            bound.E, bound.W = easts[1], easts[2]
        else
            bound.E, bound.W = easts[2], easts[1]
        end			
        return bound
    end
end

function tidyNum(text)
    text = mw.ustring.gsub(text, " ", "")
    text = mw.ustring.gsub(text, ",", ".")
    return tonumber(text)
end

function parseValue(text)
    -- extract 3 or 2 or 1 values from the string.  Can contain . or , as a decimal, no spaces allowed.
    local d, m, s = mw.ustring.match(text,"(%-?%d+[%.,]?%d*)[%a%c%s%z!@#$%%^&%*%(%)%=|\~']+(%-?%d+[%.,]?%d*)[%a%c%s%z!@#$%%^&%*%(%)%=|\~']+(%-?%d+[%.,]?%d*)")
    if not d then d, m = mw.ustring.match(text,"(%-?%d+[%.,]?%d*)[%a%c%s%z!@#$%%^&%*%(%)%=|\~']+(%-?%d+[%.,]?%d*)") end
    if not d then d = mw.ustring.match(text,"(%-?%d+[%.,]?%d*)") end
    if d then
    	d = tidyNum(d or "0") + tidyNum(m or "0")/60 + tidyNum(s or "0")/3600
    end
    return d
end

function parseDirection(text)
    local direction = mw.ustring.match(text,"%A([NSEWnsew])%A") or mw.ustring.match(text,"^([NSEWnsew])%A") or mw.ustring.match(text,"%A([NSEWnsew])$")
    if (not direction) then
        direction = mw.ustring.match(text,"([Nn])[Oo][Rr][Tt][Hh]") or mw.ustring.match(text,"([Ss])[Oo][Uu][Tt][Hh]") or mw.ustring.match(text,"([Ee])[Aa][Ss][Tt]") or mw.ustring.match(text,"([Ww])[Ee][Ss][Tt]")
    end
    if direction then direction = mw.ustring.upper(direction) end
    return direction
end

function parseBound(text)
    -- note: currently does NOT hunt for deg, min, sec variations.  ASSuMEs that order.
    -- analogous to parseCoord, but we just want one number and direction.  But direction is mandatory.
    -- What to do when presented with "47 40 N": assume degree and minute
    -- "47,40 N": assume European decimal
    -- "47, 40 N" : assume degree and minute, I guess
    -- "47. 40 N" : assume US-style decimal, I guess
    -- this logic may be contested, esp. as it gives different results for different decimal types.
    -- therefore, for both "guess" issues and even 47,40 N, the alternate way is: if there are ONLY the two 
    -- numbers separated by space both are considered one, but if there are more, consider them two.
    local value = parseValue(text)
    -- single letter, can be NSEWnsew, could be beginning or end
    local direction = parseDirection(text)
    return value, direction
end

function parseCoord(text)
    local text = mw.ustring.upper(text) -- we're only getting direction letters and numbers here
    local coord = {}
    -- maybe it's a Coord call like "{{Coord|37.3|N|259.0|E|globe:Mars_type:mountain}}" - then only search the template
    text = mw.ustring.match(text,"{{COORD(.-)}}") or text
    -- maybe it's a simple coordinate like 37N,33E?
    -- note: currently does NOT hunt for deg, min, sec variations.  ASSuMEs that order.
    -- In this case, parsing what to do based on three numbers starts to fall apart (what if there are five?)
    -- Instead, look for the direction markers first, then split into two bound parsing problems
    local first, second = mw.ustring.match(text,"^(.-%A)[NSEW](%A.-)$")
    if first and second and mw.ustring.match(first,"%d") then
    coord[1] = parseValue(first)
    second = mw.ustring.match(second, "^(.-%A)[NSEW]%A.-$") or mw.ustring.match(second, "^(.-%A)[NSEW]$") or second
    coord[2] = parseValue(second)
    if not (coord[1] and coord[2]) then return nil end
    else
        -- last ditch effort: take the first two numbers in the section, WHATEVER they are.  Can be signed.
        coord[1], coord[2] = mw.ustring.match(text,"(%-?%d+[%.,]?%d*)[%a%c%s%z!@#$%%^&%*%(%)%=]+(%-?%d+[%.,]?%d*)")
        if not (coord[1] and coord[2]) then return nil end
        coord[1] = tidyNum(coord[1])
        coord[2] = tidyNum(coord[2])
    end
    -- at this point the amounts of coord[1] (lat) and coord[2] (lon) are set, but what directions?
    local firstdir = parseDirection(text)
    local seconddir = firstdir
    if firstdir then
        frag = text
        repeat -- I just keep the first letter of the direction, not the context, so need to run forward to it
            frag = mw.ustring.match(frag, firstdir .. "(.*)$")
            seconddir = parseDirection(frag)
        until seconddir ~= firstdir
    end
    -- invert signs for west, south positions
    if (firstdir == "W" or firstdir == "S") then
        coord[1] = 0 - coord[1]
    end
    if (seconddir == "W" or seconddir == "S") then
        coord[2] = 0 - coord[2]
    end
    -- if first is E/W, put it second
    if (firstdir == "W" or firstdir == "E") then
        coord[1], coord[2] = coord[2], coord[1]
    end
    -- default without directions specified: first = latitude, no sign reversal
    if (not firstdir) then
        firstdir = "N"
    end
    if (not seconddir) then
        seconddir = "E"
    end
    if (seconddir == "N" or seconddir == "S" or firstdir == "E" or firstdir == "W") then
        table.insert(WARNCATEGORIES, "coordinates")
        return nil
    end
    coord[2] = (coord[2] + 180) % 360 - 180
    -- at this point firstdir and seconddir no longer mean anything - direction is in the + or - and first or second position
    return coord
end

function display(dataitem, globe, distance)
    local recordname, coord1, coord2 = dataitem[1], dataitem[2][1], dataitem[2][2]
    local dir1, dir2
    -- distance comes as a prerounded number of km, leaves as a string
    distance = (distance ~= nil) and (": " .. tostring(distance) .. " km")  or ""
    -- The Coord template is absolutely up on its hind legs demanding this for non-Earth globes - see
    -- https://en.wikipedia.org/wiki/Template_talk:Infobox_mill_building.  Needs fixing.
    if coord1<0 then 
        dir1 = "S"
        coord1 = 0 - coord1
    else 
        dir1 = "N" 
    end
    if coord2<0 then 
        dir2 = "W"
        coord2 = 0 - coord2
    else dir2 = "E"
    end
    return '[['..recordname..']] ({{Coord|' .. coord1 .. "|" .. dir1 .. "|" .. coord2 .. "|" .. dir2 .. "|globe:" .. globe .. "}}" .. distance .. ")"
end

function inBounds(datapoint, region)
    return (datapoint[2][1] < region.N and datapoint[2][1] > region.S and datapoint[2][2] > region.W and datapoint[2][2] < region.E)
end

function haversine(radians)
    return (1 - math.cos(radians))/2
end

function inverseHaversine(number)
    if number > 1 then number = 1 end
    if number < -1 then number = -1 end
    return 2 * math.asin(number ^ 0.5)
end

function haversineFunction(lat1, lon1, lat2, lon2)
    local rLat1 = lat1 * math.pi / 180
    local rLat2 = lat2 * math.pi / 180
    local rLon1 = lon1 * math.pi / 180
    local rLon2 = lon2 * math.pi / 180
    -- returns d/r; must be multiplied by planetary radius to get a distance
    return inverseHaversine(haversine(rLat2 - rLat1) + math.cos(rLat1)*math.cos(rLat2)*haversine(rLon2 - rLon1))
end

function inRadius(datapoint, region)
    local lat = datapoint[2][1]
    local lon = datapoint[2][2]
    local clat = region.center[1]
    local clon = region.center[2]
    local distance = haversineFunction(lat, lon, clat, clon)
    return ((not region.threshold) or distance < region.threshold) and distance
end

function p._main(region, pRadius, eRadius, database, globe, suppress, current)
    -- default list style; others not implemented
    local outprefix = ""
    local delimiter = ", "
    local outsuffix = ""
    local outarray = {}
    local criterion
    -- ndatabase = "#database"; it's a pseudo table.  If there's a dumber way to do this let me know.
    local ndatabase = 1
    while database[ndatabase] do
        ndatabase = ndatabase + 1
    end
    ndatabase = ndatabase - 1
    if region.type == "circle" then
        local localRadius = ((pRadius * math.sin(region.center[1]*math.pi/180))^2 + (eRadius * math.cos(region.center[1]*math.pi/180))^2)^0.5
        if region.radius then region.threshold = region.radius / localRadius end
        if region.hits then
            local hits = {}
            for i = 1, ndatabase do
                -- presently this isn't the real distance; it's relative to radius/threshold
                local distance = inRadius(database[i], region) * localRadius
                -- if radius isn't defined, everything is inRadius
                if distance then
                    -- table is ranked from 1 to hits.  Insert hit at the lowest position where there
                    -- is either a vacancy or the distance is currently greater.
                    -- Table entries are 1.. hits containing {distance, database[i]}
                    local p = region.hits
                    while (p > 0) and ((hits[p] == nil) or (hits[p][1] > distance)) do
                        p = p - 1
                    end
                    if (p < region.hits) then
                    	if not (suppress and selfLink(database[i][1], current, distance)) then
                            table.insert(hits, p + 1, {distance, database[i]})
                            table[region.hits + 1] = nil -- scrap most distant entry
                        end
                    end
                end
            end
            for i = 1, region.hits do
                table.insert(outarray, display(hits[i][2], globe, region.showdist and math.floor(hits[i][1]/region.showdist)*region.showdist))
            end
        else
            criterion = inRadius
        end
    else
    	criterion = inBounds
    end
    if criterion then
        for i = 1, ndatabase do
            if (criterion(database[i], region)) and not (suppress and selfLink(database[i][1], current, distance)) then
                table.insert(outarray, display(database[i], globe, nil))
            end
        end
    end
    return outprefix .. table.concat(outarray, delimiter) .. outsuffix
end

function p.main(frame, globe, pRadius, eRadius, datafile)
	 -- no presets - look up polar, equator, datafile from parameters
     -- begin processing args here:
    local args = getArgs(frame)
    globe = args.globe or globe
    pRadius = args.polar or pRadius
    eRadius = args.equator or eRadius
    datafile = args.datafile or datafile -- these values override the presets
    if not (globe and pRadius and eRadius and datafile) then
        table.insert(WARNCATEGORIES, "parameters")
        return warnings()..DEBUGLOG
    end
    local region = {}
    if args.center then
        region.type = "circle"
        region.center = parseCoord(args.center)
        region.radius = args.radius
        region.showdist = args.showdist and (tonumber(args.showdist) or DEFAULTSHOWDIST)
        region.hits = args.hits and tidyNum(args.hits)
        if (not region.hits) and (not region.radius) then region.hits = DEFAULTHITS end
    else
        region = parseBounds(args)
        if (not region) then
            table.insert(WARNCATEGORIES, "bounds")
            return warnings() .. DEBUGLOG end
        region.type = "square"
    end
    database = mw.loadData(datafile)
    -- may write more generally; for now parameter 'suppress' means don't show link to the current article
    if args.suppress then args.suppress = {self = true} end
    current = mw.title.getCurrentTitle().fullText
    if args.nowiki then
        return frame:preprocess("<pre><nowiki>"..tostring(p._main(region, pRadius, eRadius, database, globe, args.suppress, current)).."</nowiki></pre>") .. warnings() .. DEBUGLOG
    else
        return frame:preprocess(tostring(p._main(region, pRadius, eRadius, database, globe, args.suppress, current))) .. warnings() .. DEBUGLOG
    end
end

return p