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

"..tostring(p._main(region, pRadius, eRadius, database, globe, args.suppress, current)).."
") .. warnings() .. DEBUGLOG

else

return frame:preprocess(tostring(p._main(region, pRadius, eRadius, database, globe, args.suppress, current))) .. warnings() .. DEBUGLOG

end

end

return p