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 .. ""       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 "37.3°N, 259°W" - then only search the template text = mw.ustring.match(text,"") 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..' (°N, °W" .. 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