Module:Sandbox/Wnt/FindFeatures
< Module:Sandbox | Wnt
Parameters
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 searched
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.
Usage
-- 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