Module:Map

From RuneRealm Wiki

This is the current revision of this page, as edited by Alex (talk | contribs) at 22:09, 11 October 2024 (Created page with "local hc = require('Module:Paramtest').has_content -- Package local p = {} -- Feature functions local feat = {} local zoomRatios = { { 3, 8 }, { 2, 4 }, { 1, 2 }, { 0, 1 }, { -1, 1/2 }, { -2, 1/4 }, { -3, 1/8 } } -- Default arg values local defaults = { -- Map options type = 'mapframe', width = 300, height = 300, zoom = 2, mapID = 0, -- RuneScape surface x = 3233, -- Lumbridge lodestone y = 3222, plane = 0, align = 'center', -- Feat..."). The present address (URL) is a permanent link to this version.

(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search
Module documentation
This documentation is transcluded from Module:Map/doc. [edit] [history] [purge]
Module:Map's function map is invoked by Template:Map.
Module:Map requires Module:Paramtest.
Module:Map is required by Module:Clues.
map(frame)
The main entry point for templates and pages. Should only be called via {{#invoke}} outside of a module.
ArgumentTypeDescriptionOptional
frameframe objectThe frame object automatically passed via {{#invoke}}.
ReturnsstringA fully rendered map.
buildMap(args)
The main entry point for other modules.
ArgumentTypeDescriptionOptional
argstableAny map or feature arguments.
ReturnsstringA fully rendered map.
getMap(name, mapOpts)
Pulls a map from SMW.
ArgumentTypeDescriptionOptional
namestringThe page name to use in the look up. Append the version name the map is defined under (e.g., #Version1 or #Version2) if there is one.
mapOptstableMap options to control the map behaviour.
ReturnsstringA fully rendered map.

local hc = require('Module:Paramtest').has_content

-- Package
local p = {}
-- Feature functions
local feat = {}

local zoomRatios = {
  { 3, 8 },
  { 2, 4 },
  { 1, 2 },
  { 0, 1 },
  { -1, 1/2 },
  { -2, 1/4 },
  { -3, 1/8 }
}

-- Default arg values
local defaults = {
  -- Map options
  type = 'mapframe',
  width = 300,
  height = 300,
  zoom = 2,
  mapID = 0, -- RuneScape surface
  x = 3233, -- Lumbridge lodestone
  y = 3222,
  plane = 0,
  align = 'center',
  -- Feature options
  mtype = 'pin',
  -- Rectangles, squares, circles
  radius = 10,
  -- Dots
  fill = '#ffffff',
  -- Pins
  icon = 'greenPin',
  iconSize = 25,
  iconAnchor = 0,
  popupAnchor = 0,
  group = 'pins',
  -- Text
  position = 'top'
}

local mtypes = {
  singlePoint = { pin=true, rectangle=true, square=true, circle=true, dot=true, text=true },
  multiPoint = { polygon=true, line=true }
}

-- Named-only arguments
local namedOnlyArgs = { type=true, width=true, height=true, zoom=true, mapID=true, align=true, caption=true, text=true, nopreprocess=true, smw=true, smwName=true, plainTiles=true, mapVersion=true }

-- Anonymous feature options that should be removed for comma separation
local optsWithCommas = { 'iconSize', 'iconAnchor', 'popupAnchor' }

-- Optional feature properties
local properties = {
  any = { title='string', desc='string' },
  line = { stroke=true, ['stroke-opacity']=true, ['stroke-width']=true },
  polygon = { stroke=true, ['stroke-opacity']=true, ['stroke-width']=true, fill=true, ['fill-opacity']=true },
  dot = { fill=true },
  pin = { icon=true, iconWikiLink=true, iconSize=true, iconAnchor=true, popupAnchor=true },
  text = {}
}

-- Template entry point
function p.map(frame)
  return p.buildMap(frame:getParent().args)
end

-- Module entry point to get completed map element
function p.buildMap(_args)
  local args = {}

  for k, v in pairs(_args) do
    args[k] = v
  end

  local features, mapOpts = p.parseArgs(args)

  return p.buildMapFromOpts(features, mapOpts)
end

-- Build full GeoJSON and insert into HTML
-- Can be used to turn Location JSON into completed map
function p.buildMapFromOpts(features, mapOpts)
  local noPreprocess = mapOpts.nopreprocess
  local collection = {}

  if #features > 0 then
    collection = {
      type = 'FeatureCollection',
      features = features
    }
  end

  local map = createMapElement(mapOpts, collection)

  if noPreprocess then
    return tostring(map)
  end

  return mw.getCurrentFrame():preprocess(tostring(map))
end

-- Create map HTML element
function createMapElement(mapOpts, collection)
  local mapElem = mw.html.create(mapOpts.type)

  mapOpts.x = math.floor(mapOpts.x)
  mapOpts.y = math.floor(mapOpts.y)

  -- Remove unnecessary values
  mapOpts.type = nil
  mapOpts.range = nil
  mapOpts.maxPinY = nil
  mapOpts.nopreprocess = nil

  mapElem:attr(mapOpts):newline():wikitext(toJSON(collection)):newline()

  -- Set mapOpts in SMW so queries can rebuild with #buildMapFromOpts
  -- Need to remove these opts just before setting
  if hc(mapOpts.smw) then
    local smwOpts = {
      x = mapOpts.x,
      y = mapOpts.y,
      mapID = mapOpts.mapID,
      plane = mapOpts.plane,
      zoom = mapOpts.zoom
    }

    parseSMW(mapOpts, smwOpts)
  end

  return mapElem
end

-- Parse all arguments
function p.parseArgs(args)
  local features = {}
  local mapOpts = p.parseMapArgs(args)

  -- Parse anon args and add features to table
  local anonFeatures = p.parseAnonArgs(args, mapOpts)
  combineTables(features, anonFeatures)

  if #anonFeatures == 0 and hc(args.mtype) then
    -- Parse named args and add feature to table
    local namedFeature = p.parseNamedArgs(args, mapOpts)
    table.insert(features, namedFeature)
  end

  if #features == 0 then
    mapOpts.range = {
      xMin = mapOpts.x or defaults.x,
      xMax = mapOpts.x or defaults.x,
      yMin = mapOpts.y or defaults.y,
      yMax = mapOpts.y or defaults.y
    }
  end

  calculateView(args, mapOpts)

  return features, mapOpts
end

function calculateView(args, mapOpts)
  if not tonumber(args.x) then
    mapOpts.x = math.floor((mapOpts.range.xMax + mapOpts.range.xMin) / 2)
  else
    mapOpts.x = args.x
  end

  if not tonumber(args.y) then
    mapOpts.y = math.floor((mapOpts.range.yMax + mapOpts.range.yMin) / 2)
  else
    mapOpts.y = args.y
  end

  local width, height = mapOpts.width, mapOpts.height

  if args.type == 'maplink' then
    width, height = 800, 800

    mapOpts.width = nil
    mapOpts.height = nil
  end

  if not tonumber(args.zoom) then
    local zoom, ratio = defaults.zoom, 1

    local xRange = mapOpts.range.xMax - mapOpts.range.xMin
    local yRange = mapOpts.range.yMax - mapOpts.range.yMin

    -- Ensure space between outer-most points and view border
    local bufferX, bufferY = width / 25, height / 25

    for _, v in ipairs(zoomRatios) do
      local sizeX, sizeY = width / v[2], height / v[2]

      -- Check if the dynamic sizes are greater than the buffered ranges
      if sizeX > xRange + bufferX and sizeY > yRange + bufferY then
        zoom = v[1]
        ratio = v[2]
        break
      end
    end

    if mapOpts.maxPinY then
      -- Default pin height relative to zoom 1
      local pinHeight = 40
      -- Northern-most pin Y plus its dynamic height
      local maxPinHeightY = mapOpts.maxPinY + (pinHeight / ratio)
      -- New Y range using this value
      local yRangeMaxPin = maxPinHeightY - mapOpts.range.yMin

      if maxPinHeightY > mapOpts.range.yMax then
        -- Move the view up by half the pin's dynamic height
        mapOpts.y = mapOpts.y + (pinHeight / ratio / 2)
      
        -- Zoom out if new range is too big
        if yRangeMaxPin + bufferY > height / ratio then
          zoom = zoom - 1
        end
      end
    end

    if zoom > defaults.zoom then
      zoom = defaults.zoom
    end

    mapOpts.zoom = zoom
  end
end

function adjustRange(coords, mapOpts)
  for _, v in ipairs(coords) do
    if v[1] > mapOpts.range.xMax then
      mapOpts.range.xMax = v[1]
    end

    if v[1] < mapOpts.range.xMin then
      mapOpts.range.xMin = v[1]
    end

    if v[2] > mapOpts.range.yMax then
      mapOpts.range.yMax = v[2]
    end

    if v[2] < mapOpts.range.yMin then
      mapOpts.range.yMin = v[2]
    end
  end
end

-- Parse named map arguments
function p.parseMapArgs(args)
  local opts = {
    type = ternary(hc(args.type), args.type, defaults.type),
    x = ternary(hc(args.x), args.x, defaults.x),
    y = ternary(hc(args.y), args.y, defaults.y),
    width = ternary(hc(args.width), args.width, defaults.width),
    height = ternary(hc(args.height), args.height, defaults.height),
    mapID = ternary(hc(args.mapID), args.mapID, defaults.mapID),
    plane = ternary(hc(args.plane), args.plane, defaults.plane),
    zoom = ternary(hc(args.zoom), args.zoom, defaults.zoom),
    align = ternary(hc(args.align), args.align, defaults.align),
    nopreprocess = args.nopreprocess,
    smw = args.smw,
    smwName = args.smwName,
    range = {
      xMin = 10000000,
      xMax = -10000000,
      yMin = 10000000,
      yMax = -10000000
    }
  }

  -- Feature grouping across map instances
  if hc(args.group) then
    opts.group = args.group
  end

  -- Plain map tiles
  if hc(args.plainTiles) then
    opts.plainTiles = 'true'
  end

  -- Alternate map tile version
  if hc(args.mapVersion) then
    opts.mapVersion = args.mapVersion
  end

  -- Map type
  if hc(args.type) and args.type ~= 'mapframe' and args.type ~= 'maplink' then
    mapError('Argument `type` must be either `mapframe`, `maplink`, or not provided')
  end

  -- Caption or link text
  if args.type == 'maplink' then
    if args.text then
      if args.text:find('[%[%]]') then
        mapError('Argument `text` cannot contain links')
      end

      opts.text = args.text
    else
      opts.text = 'Maplink'
    end
  elseif hc(args.caption) then
    opts.text = args.caption
  else
    opts.frameless = ''
  end

  return opts
end

-- Parse named arguments
-- This is called per anon feature as well
function p.parseNamedArgs(_args, mapOpts)
  local args = mw.clone(_args)

  if not feat[args.mtype] then
    mapError('Argument `mtype` has an unsupported value')
  end

  -- Use named X and Y as coords only if no other points
  if #args.coords == 0 and args.x and args.y then
    args.coords = { {
      tonumber(args.x) or defaults.x,
      tonumber(args.y) or defaults.y
    } }
  end

  -- No feature if no coords
  if not args.coords or #args.coords == 0 then
    return nil
  end

  -- Save northern-most pin Y for later view adjustment
  if args.mtype == 'pin' and not args.iconWikiLink then
    if mapOpts.maxPinY then
      if args.coords[1][2] > mapOpts.maxPinY then
        mapOpts.maxPinY = args.coords[1][2]
      end
    else
      mapOpts.maxPinY = args.coords[1][2]
    end
  end

  -- Center all points of combo multi-point and line features
  if (args.isInCombo and mtypes.multiPoint[args.mtype]) or args.mtype == 'line' then
    for _, v in ipairs(args.coords) do
      centeredCoords(v)
    end
  end

  -- Handle range adjustment individually for these types
  if not isCenteredPointFeature(args.mtype) then
    adjustRange(args.coords, mapOpts)
  end

  if not mapOpts.group and args.mtype == 'pin' then
    mapOpts.group = args.group or defaults.group
  end

  -- This key must match a key found in `defaults`
  parseIconXYArg(args, 'iconSize')
  parseIconXYArg(args, 'iconAnchor')
  parseIconXYArg(args, 'popupAnchor')

  args.desc = parseDesc(args)

  local featJson = feat[args.mtype](args, mapOpts)

  -- Set feature in SMW
  if hc(mapOpts.smw) then
    parseSMW(mapOpts, featJson)
  end

  return featJson
end

-- Parse icon X/Y argument
function parseIconXYArg(args, key)
  if hc(args[key]) then
    if args[key]:find(',') then
      local xy = mw.text.split(args[key], '%s*,%s*')
      args[key] = { tonumber(xy[1]) or defaults[key], tonumber(xy[2]) or defaults[key] }
    else
      args[key] = { tonumber(args[key]) or defaults[key], tonumber(args[key]) or defaults[key] }
    end
  elseif hc(args[key..'X']) and hc(args[key..'Y']) then
    args[key] = { tonumber(args[key..'X']), tonumber(args[key..'Y']) }
  end
end

-- Parse anonymous arguments and add to the features table
-- Note 1: Anon X/Y coords generate anon features
-- Note 2: "Repeatable" means a feature that can be created once for each X/Y
function p.parseAnonArgs(args, mapOpts)
  local features = {}
  local i = 1
  
  -- Collect unusable anon coords for use by named feature
  args.coords = {}

  while args[i] do
    local arg = mw.text.trim(args[i])

    if hc(arg) then
      local anonOpts = { coords = {} }
      local rawOpts = {}
      -- Track all X and Y to find mismatches
      local xyCount = 0

      -- Remove opts with commas manually
      -- Big workaround for Lua not supporting positive lookaheads
      for _, opt in ipairs(optsWithCommas) do
        arg = arg:gsub(opt..':%d+,%d+', function(s)
          table.insert(rawOpts, s)
          return ''
        end)
      end

      -- Temporarily replace escaped commas for use in text opts
      arg = arg:gsub('\\,', '**')
      
      -- Split arg into options by "," and put extra commas back
      for opt in mw.text.gsplit(arg, '%s*,%s*') do
        opt = opt:gsub('%*%*', ',')
        table.insert(rawOpts, opt)
      end

      for _, opt in ipairs(rawOpts) do
        if hc(opt) then
          -- Temporarily replace escaped colons for use in text opts
          opt = opt:gsub('\\:', '^^')

          -- Split option into key/value by ":"
          local kv = mw.text.split(opt, '%s*:%s*')

          -- If option is a value with no key, assume it's a standalone X or Y
          if #kv == 1 then
            xyCount = xyCount + 1
            addXYToCoords(anonOpts.coords, kv[1])
          else
            if namedOnlyArgs[kv[1]] then
              mapError('Anonymous option `'..kv[1]..'` can only be used as a named argument')
            -- Add X/Y pair
            elseif tonumber(kv[1]) and tonumber(kv[2]) then
              xyCount = xyCount + 2
              table.insert(anonOpts.coords, { tonumber(kv[1]), tonumber(kv[2]) })
            -- Add individual X or Y
            elseif kv[1] == 'x' or kv[1] == 'y' then
              xyCount = xyCount + 1
              addXYToCoords(anonOpts.coords, kv[2])
            else
              -- Put extra colons back
              kv[2] = kv[2]:gsub('%^%^', ':')
              anonOpts[kv[1]] = mw.text.trim(kv[2])
            end
          end
        end
      end

      if xyCount % 2 > 0 then
        mapError('Feature contains mismatched coordinates')
      end

      -- Named args are applied to all anon features if not specified
      -- An anon feature opts take precedence over named args
      for k, v in pairs(args) do
        if not tonumber(k) and
           k ~= 'x' and k ~= 'y' and
           not namedOnlyArgs[k] and
           not anonOpts[k] then
          anonOpts[k] = v
        end
      end

      if not anonOpts.mtype then
        if #anonOpts.coords > 0 then
          -- Save coord without an mtype to apply to map view X/Y
          table.insert(args.coords, anonOpts.coords[1])
        end
      elseif mtypes.singlePoint[anonOpts.mtype] then
        if #anonOpts.coords == 0 then
          mapError('Anonymous `'..anonOpts.mtype..'` feature must have at least one point')
        end

        addFeaturePerCoord(features, anonOpts, mapOpts)
      elseif mtypes.multiPoint[anonOpts.mtype] then
        parseMultiPointFeature(features, anonOpts, mapOpts, true, args)
      elseif anonOpts.mtype:find('-') then
        parseComboFeature(features, anonOpts, mapOpts, true, args)
      end
    end

    i = i + 1
  end

  if #args.coords > 0 then
    -- Use first coord without mtype as map view X/Y
    if not args.mtype then
      mapOpts.x = args.coords[1][1]
      mapOpts.y = args.coords[1][2]
    elseif mtypes.singlePoint[args.mtype] then
      addFeaturePerCoord(features, args, mapOpts)
    elseif mtypes.multiPoint[args.mtype] then
      parseMultiPointFeature(features, args, mapOpts, false)
    elseif args.mtype:find('-') then
      parseComboFeature(features, args, mapOpts, false)
    end
  end

  return features
end

-- Add individual X or Y to next coord set
-- Handles coords split by commas (e.g., `|1000,2000`)
function addXYToCoords(coords, value)
  local xy = coords[#coords]

  if xy and #xy == 1 then
    local y = tonumber(value) or defaults.y
    table.insert(xy, y)
  else
    local x = tonumber(value) or defaults.x
    table.insert(coords, { x })
  end
end

-- Parse opts to build multi-point feature
function parseMultiPointFeature(features, opts, mapOpts, isAnon, namedArgs)
  -- Anon multi-point can't have 0 coords
  if isAnon and #opts.coords == 0 then
    mapError('Anonymous multi-point `'..opts.mtype..'` feature must have at least 1 point')
  elseif isAnon and #opts.coords == 1 then
    if not namedArgs.mtype then
      mapError('Anonymous multi-point `'..opts.mtype..'` feature must have 2 or more points')
    end

    -- Single coord for multi-point isn't possible,
    -- so save coord to apply to named feature
    table.insert(namedArgs.coords, opts.coords[1])
  -- Named multi-point can't have <2 coords
  elseif not isAnon and #opts.coords < 2 then
    mapError('Named multi-point `'..opts.mtype..'` feature must have 2 or more points')
  else
    local feature = p.parseNamedArgs(opts, mapOpts)
    table.insert(features, feature)
  end
end

-- Parse opts to build multi-point feature
function parseComboFeature(features, opts, mapOpts, isAnon, namedArgs)
  local combo = mw.text.split(opts.mtype, '-')

  if #combo ~= 2 or not mtypes.singlePoint[combo[1]] or  not mtypes.multiPoint[combo[2]] then
    mapError('Feature `'..opts.mtype..'` is not a single-point + multi-point combo')
  end

  if isAnon and #opts.coords == 0 then
    mapError('Anonymous feature in `'..opts.mtype..'` combo must have at least 1 point')
  elseif #opts.coords == 1 then
    if isAnon then
      if not namedArgs.mtype then
        mapError('Anonymous feature `'..combo[2]..'` in `'..opts.mtype..'` combo must have 2 or more points')
      else
        -- Create single-point and also save to use with named multi-point
        opts.mtype = combo[1]
        local feature = p.parseNamedArgs(opts, mapOpts)
        table.insert(features, feature)
        table.insert(namedArgs.coords, opts.coords[1])
      end
    else
      mapError('Named feature `'..combo[2]..'` in `'..opts.mtype..'` combo must have 2 or more points')
    end
  else
    -- Create all anon single-points
    if isAnon then
      opts.mtype = combo[1]
      addFeaturePerCoord(features, opts, mapOpts)
    end

    -- Create named multi-point
    opts.mtype = combo[2]
    opts.isInCombo = true
    local feature = p.parseNamedArgs(opts, mapOpts)
    table.insert(features, feature)
  end
end

-- Add feature per coordinate provided
function addFeaturePerCoord(features, opts, mapOpts)
  local tempOpts = mw.clone(opts)

  for _, v in ipairs(opts.coords) do
    tempOpts.coords = { v }

    local feature = p.parseNamedArgs(tempOpts, mapOpts)
    table.insert(features, feature)
  end
end

function feat.rectangle(featOpts, mapOpts)
  local x, y = featOpts.coords[1][1], featOpts.coords[1][2]

  local r = tonumber(featOpts.r)
  local rectX = tonumber(featOpts.rectX or featOpts.squareX) or defaults.radius * 2
  local rectY = tonumber(featOpts.rectY or featOpts.squareY) or defaults.radius * 2

  if hc(r) and r % 1 > 0 then
    x = x + 0.5
    y = y + 0.5
  end

  local rectXR = r or math.floor(rectX / 2) or defaults.radius
  local rectYR = r or math.floor(rectY / 2) or defaults.radius
  
  local xLeft = x - rectXR
  local xRight = x + rectXR
  local yTop = y + rectYR
  local yBottom = y - rectYR

  if rectX % 2 > 0 then
    xRight = x + (rectXR + 1)
  end

  if rectY % 2 > 0 then
    yTop = y + (rectYR + 1)
  end

  local corners = {
    { xLeft, yBottom },
    { xLeft, yTop },
    { xRight, yTop },
    { xRight, yBottom }
  }

  local featJson = {
    type = 'Feature',
    properties = {
      mapID = featOpts.mapID or mapOpts.mapID,
      plane = featOpts.plane or defaults.plane
    },
    geometry = {
      type = 'Polygon',
      coordinates = { corners }
    }
  }

  adjustRange(corners, mapOpts)
  setProperties(featJson, featOpts, 'polygon')

  return featJson
end

-- Create a square/rectangle feature
function feat.square(featOpts, mapOpts)
  return feat.rectangle(featOpts, mapOpts)
end

-- Create a polygon feature
function feat.polygon(featOpts, mapOpts)
  local points = {}
  local lastPoint = featOpts.coords[#featOpts.coords]

  for _, v in ipairs(featOpts.coords) do
    table.insert(points, { v[1], v[2] })
  end

  -- Close polygon
  if not (points[1][1] == lastPoint[1] and points[1][2] == lastPoint[2]) then
    table.insert(points, { points[1][1], points[1][2] })
  end

  local featJson = {
    type = 'Feature',
    properties = {
      mapID = featOpts.mapID or mapOpts.mapID,
      plane = featOpts.plane or defaults.plane
    },
    geometry = {
      type = 'Polygon',
      coordinates = { points }
    }
  }

  setProperties(featJson, featOpts, 'polygon')

  return featJson
end

-- Create a line feature
function feat.line(featOpts, mapOpts)
  local featJson = {
    type = 'Feature',
    properties = {
      shape = 'Line',
      mapID = featOpts.mapID or mapOpts.mapID,
      plane = featOpts.plane or defaults.plane
    },
    geometry = {
      type = 'LineString',
      coordinates = featOpts.coords
    }
  }

  setProperties(featJson, featOpts, 'line')

  return featJson
end

-- Create a circle feature
function feat.circle(featOpts, mapOpts)
  local radius = tonumber(featOpts.r) or defaults.radius
  local featJson = {
    type = 'Feature',
    properties = {
      shape = 'Circle',
      radius = radius,
      mapID = featOpts.mapID or mapOpts.mapID,
      plane = featOpts.plane or defaults.plane
    },
    geometry = {
      type = 'Point',
      coordinates = featOpts.coords[1]
    }
  }

  local corners = {
    { featOpts.coords[1][1] - radius, featOpts.coords[1][2] - radius },
    { featOpts.coords[1][1] - radius, featOpts.coords[1][2] + radius },
    { featOpts.coords[1][1] + radius, featOpts.coords[1][2] - radius },
    { featOpts.coords[1][1] + radius, featOpts.coords[1][2] + radius }
  }

  adjustRange(corners, mapOpts)
  setProperties(featJson, featOpts, 'polygon')

  return featJson
end

-- Create a dot feature
function feat.dot(featOpts, mapOpts)
  local featJson = {
    type = 'Feature',
    properties = {
      shape = 'Dot',
      mapID = featOpts.mapID or mapOpts.mapID,
      plane = featOpts.plane or defaults.plane,
      fill = featOpts.fill or defaults.fill,
    },
    geometry = {
      type = 'Point',
      coordinates = centeredCoords(featOpts.coords[1])
    }
  }

  setProperties(featJson, featOpts, 'dot')

  return featJson
end

-- Create a pin feature
function feat.pin(featOpts, mapOpts)
  local featJson = {
    type = 'Feature',
    properties = {
      providerID = 0,
      mapID = featOpts.mapID or mapOpts.mapID,
      plane = featOpts.plane or defaults.plane,
      group = featOpts.group or defaults.group
    },
    geometry = {
      type = 'Point',
      coordinates = centeredCoords(featOpts.coords[1])
    }
  }

  if hc(featOpts.iconWikiLink) then
    featOpts.iconWikiLink = mw.ext.GloopTweaks.filepath(featOpts.iconWikiLink)
  else
    featOpts.icon = ternary(hc(featOpts.icon), featOpts.icon, defaults.icon)
  end

  setProperties(featJson, featOpts, 'pin')

  return featJson
end

-- Create a text feature
function feat.text(featOpts, mapOpts)
  if not featOpts.label then
    mapError('Argument `label` missing on text feature')
  end

  local featJson = {
    type = 'Feature',
    properties = {
      shape = 'Text',
      label = featOpts.label,
      direction = featOpts.position or defaults.position,
      class = featOpts.class or 'lbl-bg-grey',
      mapID = featOpts.mapID or mapOpts.mapID,
      plane = featOpts.plane or defaults.plane
    },
    geometry = {
      type = 'Point',
      coordinates = centeredCoords(featOpts.coords[1])
    }
  }

  setProperties(featJson, featOpts, 'text')
  
  return featJson
end

-- Create feature description
function parseDesc(args)
  local pageName = mw.title.getCurrentTitle().text

  local coordsStr = 'X/Y: '..math.floor(args.coords[1][1])..','..math.floor(args.coords[1][2])
  local coordsElem = mw.html.create('p')
    :wikitext(coordsStr)
    :attr('style', 'font-size:10px; margin:0px;')

  if args.ptype == 'item' or
     args.ptype == 'monster' or
     args.ptype == 'npc' or
     args.ptype == 'object' then
    local tableElem = mw.html.create('table')
      :addClass('wikitable')
      :attr('style', 'font-size:12px; text-align:left; margin:0px; width:100%;')

    if args.ptype == 'item' then
      addTableRow(tableElem, 'Item', args.name or pageName)
      addTableRow(tableElem, 'Quantity', args.qty or 1)

      if hc(args.respawn) then
        addTableRow(tableElem, 'Respawn time', args.respawn)
      end
    elseif args.ptype == 'monster' then
      addTableRow(tableElem, 'Monster', args.name or pageName)

      if hc(args.levels) then
        addTableRow(tableElem, 'Level(s)', args.levels)
      end

      if hc(args.respawn) then
        addTableRow(tableElem, 'Respawn time', args.respawn)
      end
    elseif args.ptype == 'npc' then
      addTableRow(tableElem, 'NPC', args.name or pageName)

      if hc(args.levels) then
        addTableRow(tableElem, 'Level(s)', args.levels)
      end

      if hc(args.respawn) then
        addTableRow(tableElem, 'Respawn time', args.respawn)
      end
    elseif args.ptype == 'object' then
      addTableRow(tableElem, 'Object', args.name or pageName)
    end

    if hc(args.id) then
      addTableRow(tableElem, 'ID', args.id)
    end

    return tostring(tableElem)..tostring(coordsElem)
  end

  local desc = ''
    
  if hc(args.desc) then
    desc = args.desc
  end

  return desc..tostring(coordsElem)
end

-- Add row to table element
function addTableRow(table, label, value)
  local row = table:tag('tr')
  row:tag('td'):wikitext("'''"..label.."'''")
  row:tag('td'):wikitext(value)
end

-- Move coords to tile center
function centeredCoords(coords)
  for k, v in ipairs(coords) do
    coords[k] = v + 0.5
  end

  return coords
end

-- Set GeoJSON properties
-- If an option exists in the allowed feature props, add it
function setProperties(featJson, opts, mtype)
  for k, v in pairs(opts) do
    if properties[mtype][k] or properties.any[k] then
      if k == 'desc' then
        featJson.properties.description = v
      else
        -- If marked as string, use value as is, otherwise try number
        featJson.properties[k] = ternary(properties.any[k] == 'string', v, tonumber(v) or v)
      end
    end
  end
end

-- Parse SMW args
function parseSMW(args, data)
  if args.smw:lower() == 'yes' then
    if not hc(args.smwName) then
      setSMW(data, 'Location JSON')
    else
      setSMW(data, 'Location JSON', args.smwName)
    end
  elseif args.smw:lower() == 'hist' then
    setSMW(data, 'Historic Location JSON')
  end
end

-- Create SMW entry
function setSMW(obj, prop, subobjectName)
  if not subobjectName then
    mw.smw.set({ [prop] = toJSON(obj) })
  else
  	mw.smw.subobject({ [prop] = toJSON(obj) }, subobjectName)
  end
end

-- Helper function to get rendered map through SMW lookup
function p.getMap(name, _mapOpts)
  local features, mapOpts = {}, {}
  local query = {
    '?Location JSON',
    'limit=1'
  }

  if name then
    table.insert(query, string.format('[[%s]][[Location JSON::+]]', name))
  else
    return nil
  end

  local results = mw.smw.ask(query) or {}
  local page = results[1]
  local entries = page and page['Location JSON']
  
  if not entries then
    return nil
  end
  
  for _, entry in ipairs(entries) do
    local data = mw.text.jsonDecode(entry)
    
    if data.type then
      table.insert(features, data)
    else
      mapOpts = data
    end
  end
  
  if #features > 0 then
    for k, v in pairs(_mapOpts or {}) do
  		mapOpts[k] = v
  	end
    
    return p.buildMapFromOpts(features, mapOpts)
  else
    return nil
  end
end

-- Test if feature is based on a center point with calculated size
function isCenteredPointFeature(mtype)
  return
    mtype == 'rectangle' or
    mtype == 'square' or
    mtype == 'circle'
end

-- Add all elements of table 2 to table 1
function combineTables(table1, table2)
  for _, v in ipairs(table2) do
    table.insert(table1, v)
  end
end

-- Create JSON
function toJSON(val)
  local good, json = pcall(mw.text.jsonEncode, val)

  if good then
    return json
  end

  mapError('Error converting value to JSON')
end

-- Makeshift ternary operator
function ternary(condition, a, b)
  if condition then
    return a
  else
    return b
  end
end

-- Produce an error
function mapError(message)
  error('[Module:Map] '..message, 0)
end

return p