ParametersEdit

function name = identifies the planet to search (mercury, venus, moon, earthCraters, mars presently implemented)

  • unnamed parameters - set four to numbers and directions to establish a bounding box (see rectangular region example)
  • center = sets the center of a circular region
  • radius = kilometers radius around the center, using haversine formula with radius set to the area of the center.
  • hits = # of hits to return (but only within radius, if provided)
  • showdist = if provided, the distance is indicated after each coordinate set. If showdist is a number it is rounded off to this amount (e.g. use 0.1 for one decimal place)
  • nowiki = debugging parameter: if set, the output is in nowiki format
  • suppress = set to "self" to prevent the link to the page presently being displayed from being included.

Creating the database from which features are searchedEdit

Databases for this module are created by {{#invoke:FindFeatures|venus|displaydatabase=yes}} and then copied to places like Module:FindFeatures/Venus (in module space) for later use. The data sources (articles) can be set by data=page1|page2|page3 and all records in tables, especially using the Coord template, are supposed to be mined from them. Because each article is written differently this can be problematic - the data produced should be checked, and might turn out to need further adjustment. Module talk:FindFeatures/data has been used as a scratchpad for splitting up data that wouldn't compile in a single run.

UsageEdit

See Module talk:FindFeatures


 -- 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:Sandbox/Wnt/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:Sandbox/Wnt/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