Quantcast
-- HereBeDragons is a data API for the World of Warcraft mapping system

-- HereBeDragons-1.0 is not supported on WoW 8.0
if select(4, GetBuildInfo()) >= 80000 then
	return
end

local MAJOR, MINOR = "HereBeDragons-1.0", 33
assert(LibStub, MAJOR .. " requires LibStub")

local HereBeDragons, oldversion = LibStub:NewLibrary(MAJOR, MINOR)
if not HereBeDragons then return end

local CBH = LibStub("CallbackHandler-1.0")

HereBeDragons.eventFrame       = HereBeDragons.eventFrame or CreateFrame("Frame")

HereBeDragons.mapData          = HereBeDragons.mapData or {}
HereBeDragons.continentZoneMap = HereBeDragons.continentZoneMap or { [-1] = { [0] = WORLDMAP_COSMIC_ID }, [0] = { [0] = WORLDMAP_AZEROTH_ID }}
HereBeDragons.mapToID          = HereBeDragons.mapToID or { Cosmic = WORLDMAP_COSMIC_ID, World = WORLDMAP_AZEROTH_ID }
HereBeDragons.microDungeons    = HereBeDragons.microDungeons or {}
HereBeDragons.transforms       = HereBeDragons.transforms or {}

HereBeDragons.callbacks        = HereBeDragons.callbacks or CBH:New(HereBeDragons, nil, nil, false)

-- constants
local TERRAIN_MATCH = "_terrain%d+$"

-- Lua upvalues
local PI2 = math.pi * 2
local atan2 = math.atan2
local pairs, ipairs = pairs, ipairs
local type = type
local band = bit.band

-- WoW API upvalues
local UnitPosition = UnitPosition

-- data table upvalues
local mapData          = HereBeDragons.mapData -- table { width, height, left, top }
local continentZoneMap = HereBeDragons.continentZoneMap
local mapToID          = HereBeDragons.mapToID
local microDungeons    = HereBeDragons.microDungeons
local transforms       = HereBeDragons.transforms

local currentPlayerZoneMapID, currentPlayerLevel, currentMapFile, currentMapIsMicroDungeon

-- Override instance ids for phased content
local instanceIDOverrides = {
    -- Draenor
    [1152] = 1116, -- Horde Garrison 1
    [1330] = 1116, -- Horde Garrison 2
    [1153] = 1116, -- Horde Garrison 3
    [1154] = 1116, -- Horde Garrison 4 (unused)
    [1158] = 1116, -- Alliance Garrison 1
    [1331] = 1116, -- Alliance Garrison 2
    [1159] = 1116, -- Alliance Garrison 3
    [1160] = 1116, -- Alliance Garrison 4 (unused)
    [1191] = 1116, -- Ashran PvP Zone
    [1203] = 1116, -- Frostfire Finale Scenario
    [1207] = 1116, -- Talador Finale Scenario
    [1277] = 1116, -- Defense of Karabor Scenario (SMV)
    [1402] = 1116, -- Gorgrond Finale Scenario
    [1464] = 1116, -- Tanaan
    [1465] = 1116, -- Tanaan
    -- Legion
    [1478] = 1220, -- Temple of Elune Scenario (Val'Sharah)
    [1495] = 1220, -- Protection Paladin Artifact Scenario (Stormheim)
    [1498] = 1220, -- Havoc Demon Hunter Artifact Scenario (Suramar)
    [1502] = 1220, -- Dalaran Underbelly
    [1533] = 0,    -- Karazhan Artifact Scenario
    [1612] = 1220, -- Feral Druid Artifact Scenario (Suramar)
    [1626] = 1220, -- Suramar Withered Scenario
    [1662] = 1220, -- Suramar Invasion Scenario
}

-- unregister and store all WORLD_MAP_UPDATE registrants, to avoid excess processing when
-- retrieving info from stateful map APIs
local wmuRegistry
local function UnregisterWMU()
    wmuRegistry = {GetFramesRegisteredForEvent("WORLD_MAP_UPDATE")}
    for _, frame in ipairs(wmuRegistry) do
        frame:UnregisterEvent("WORLD_MAP_UPDATE")
    end
end

-- restore WORLD_MAP_UPDATE to all frames in the registry
local function RestoreWMU()
    assert(wmuRegistry)
    for _, frame in ipairs(wmuRegistry) do
        frame:RegisterEvent("WORLD_MAP_UPDATE")
    end
    wmuRegistry = nil
end

-- gather map info, but only if this isn't an upgrade (or the upgrade version forces a re-map)
if not oldversion or oldversion < 33 then
    -- wipe old data, if required, otherwise the upgrade path isn't triggered
    if oldversion then
        wipe(mapData)
        wipe(microDungeons)
    end

    local MAPS_TO_REMAP = {
         -- alliance garrison
        [973] = 971,
        [974] = 971,
        [975] = 971,
        [991] = 971,
        -- horde garrison
        [980] = 976,
        [981] = 976,
        [982] = 976,
        [990] = 976,
    }

    -- some zones will remap initially, but have a fixup later
    local REMAP_FIXUP_EXEMPT = {
        -- main draenor garrison maps
        [971] = true,
        [976] = true,

        -- legion class halls
        [1072] = { Z = 10, mapFile = "TrueshotLodge" }, -- true shot lodge
        [1077] = { Z = 7,  mapFile = "TheDreamgrove" }, -- dreamgrove
    }

    local function processTransforms()
        wipe(transforms)
        for _, tID in ipairs(GetWorldMapTransforms()) do
            local terrainMapID, newTerrainMapID, _, _, transformMinY, transformMaxY, transformMinX, transformMaxX, offsetY, offsetX, flags = GetWorldMapTransformInfo(tID)
            -- flag 4 indicates the transform is only for the flight map
            if band(flags, 4) ~= 4 and (offsetY ~= 0 or offsetX ~= 0) then
                local transform = {
                    instanceID = terrainMapID,
                    newInstanceID = newTerrainMapID,
                    minY = transformMinY,
                    maxY = transformMaxY,
                    minX = transformMinX,
                    maxX = transformMaxX,
                    offsetY = offsetY,
                    offsetX = offsetX
                }
                table.insert(transforms, transform)
            end
        end
    end

    local function applyMapTransforms(instanceID, left, right, top, bottom)
        for _, transformData in ipairs(transforms) do
            if transformData.instanceID == instanceID then
                if left < transformData.maxX and right > transformData.minX and top < transformData.maxY and bottom > transformData.minY then
                    instanceID = transformData.newInstanceID
                    left   = left   + transformData.offsetX
                    right  = right  + transformData.offsetX
                    top    = top    + transformData.offsetY
                    bottom = bottom + transformData.offsetY
                    break
                end
            end
        end
        return instanceID, left, right, top, bottom
    end

    -- gather the data of one zone (by mapID)
    local function processZone(id)
        if not id or mapData[id] then return end

        -- set the map and verify it could be set
        local success = SetMapByID(id)
        if not success then
            return
        elseif id ~= GetCurrentMapAreaID() and not REMAP_FIXUP_EXEMPT[id] then
            -- this is an alias zone (phasing terrain changes), just skip it and remap it later
            if not MAPS_TO_REMAP[id] then
                MAPS_TO_REMAP[id] = GetCurrentMapAreaID()
            end
            return
        end

        -- dimensions of the map
        local originalInstanceID, _, _, left, right, top, bottom = GetAreaMapInfo(id)
        local instanceID = originalInstanceID
        if (left and top and right and bottom and (left ~= 0 or top ~= 0 or right ~= 0 or bottom ~= 0)) then
            instanceID, left, right, top, bottom = applyMapTransforms(originalInstanceID, left, right, top, bottom)
            mapData[id] = { left - right, top - bottom, left, top }
        else
            mapData[id] = { 0, 0, 0, 0 }
        end

        mapData[id].instance = instanceID
        mapData[id].name = GetMapNameByID(id)

        -- store the original instance id (ie. not remapped for map transforms) for micro dungeons
        mapData[id].originalInstance = originalInstanceID

        local mapFile = type(REMAP_FIXUP_EXEMPT[id]) == "table" and REMAP_FIXUP_EXEMPT[id].mapFile or GetMapInfo()
        if mapFile then
            -- remove phased terrain from the map names
            mapFile = mapFile:gsub(TERRAIN_MATCH, "")

            if not mapToID[mapFile] then mapToID[mapFile] = id end
            mapData[id].mapFile = mapFile
        end

        local C, Z = GetCurrentMapContinent(), GetCurrentMapZone()

        -- maps that remap generally have wrong C/Z info, so allow the fixup table to override it
        if type(REMAP_FIXUP_EXEMPT[id]) == "table" then
            C = REMAP_FIXUP_EXEMPT[id].C or C
            Z = REMAP_FIXUP_EXEMPT[id].Z or Z
        end

        mapData[id].C = C or -100
        mapData[id].Z = Z or -100

        if mapData[id].C > 0 and mapData[id].Z >= 0 then
            -- store C/Z lookup table
            if not continentZoneMap[C] then
                continentZoneMap[C] = {}
            end
            if not continentZoneMap[C][Z] then
                continentZoneMap[C][Z] = id
            end
        end

        -- retrieve floors
        local floors = { GetNumDungeonMapLevels() }

        -- offset floors for terrain map
        if DungeonUsesTerrainMap() then
            for i = 1, #floors do
                floors[i] = floors[i] + 1
            end
        end

        -- check for fake floors
        if #floors == 0 and GetCurrentMapDungeonLevel() > 0 then
            floors[1] = GetCurrentMapDungeonLevel()
            mapData[id].fakefloor = GetCurrentMapDungeonLevel()
        end

        mapData[id].floors = {}
        mapData[id].numFloors = #floors
        for i = 1, mapData[id].numFloors do
            local f = floors[i]
            SetDungeonMapLevel(f)
            local _, right, bottom, left, top = GetCurrentMapDungeonLevel()
            if left and top and right and bottom then
                instanceID, left, right, top, bottom = applyMapTransforms(originalInstanceID, left, right, top, bottom)
                mapData[id].floors[f] = { left - right, top - bottom, left, top }
                mapData[id].floors[f].instance = mapData[id].instance
            elseif f == 1 and DungeonUsesTerrainMap() then
                mapData[id].floors[f] = { mapData[id][1], mapData[id][2], mapData[id][3], mapData[id][4] }
                mapData[id].floors[f].instance = mapData[id].instance
            end
        end

        -- setup microdungeon storage if the its a zone map or has no floors of its own
        if (mapData[id].C > 0 and mapData[id].Z > 0) or mapData[id].numFloors == 0 then
            if not microDungeons[originalInstanceID] then
                microDungeons[originalInstanceID] = { global = {} }
            end
        end
    end

    local function processMicroDungeons()
        for _, dID in ipairs(GetDungeonMaps()) do
            local floorIndex, minX, maxX, minY, maxY, terrainMapID, parentWorldMapID, flags = GetDungeonMapInfo(dID)

            -- apply transform
            local originalTerrainMapID = terrainMapID
            terrainMapID, maxX, minX, maxY, minY = applyMapTransforms(terrainMapID, maxX, minX, maxY, minY)

            -- check if this zone can have microdungeons
            if microDungeons[originalTerrainMapID] then
                -- store per-zone info
                if not microDungeons[originalTerrainMapID][parentWorldMapID] then
                    microDungeons[originalTerrainMapID][parentWorldMapID] = {}
                end

                microDungeons[originalTerrainMapID][parentWorldMapID][floorIndex] = { maxX - minX, maxY - minY, maxX, maxY }
                microDungeons[originalTerrainMapID][parentWorldMapID][floorIndex].instance = terrainMapID

                -- store global info, as some microdungeon are associated to the wrong zone when phasing is involved (garrison, and more)
                -- but only store the first, since there can be overlap on the same continent otherwise
                if not microDungeons[originalTerrainMapID].global[floorIndex] then
                    microDungeons[originalTerrainMapID].global[floorIndex] = microDungeons[originalTerrainMapID][parentWorldMapID][floorIndex]
                end
            end
        end
    end

    local function fixupZones()
        -- fake cosmic map
        mapData[WORLDMAP_COSMIC_ID] = {0, 0, 0, 0}
        mapData[WORLDMAP_COSMIC_ID].instance = -1
        mapData[WORLDMAP_COSMIC_ID].mapFile = "Cosmic"
        mapData[WORLDMAP_COSMIC_ID].floors = {}
        mapData[WORLDMAP_COSMIC_ID].C = -1
        mapData[WORLDMAP_COSMIC_ID].Z = 0
        mapData[WORLDMAP_COSMIC_ID].name = WORLD_MAP

        -- fake azeroth world map
        -- the world map has one "floor" per continent it contains, which allows
        -- using these floors to translate coordinates from and to the world map.
        -- note: due to artistic differences in the drawn azeroth maps, the values
        -- used for the continents are estimates and not perfectly accurate
        mapData[WORLDMAP_AZEROTH_ID] = { 63570, 42382, 53730, 19600 } -- Eastern Kingdoms, or floor 0
        mapData[WORLDMAP_AZEROTH_ID].floors = {
            -- Kalimdor
            [1] =    { 65700, 43795, 11900, 23760, instance = 1    },
            -- Northrend
            [571] =  { 65700, 43795, 33440, 11960, instance = 571  },
            -- Pandaria
            [870] =  { 58520, 39015, 29070, 34410, instance = 870  },
            -- Broken Isles
            [1220] = { 96710, 64476, 63100, 29960, instance = 1220 },
        }
        mapData[WORLDMAP_AZEROTH_ID].instance = 0
        mapData[WORLDMAP_AZEROTH_ID].mapFile = "World"
        mapData[WORLDMAP_AZEROTH_ID].C = 0
        mapData[WORLDMAP_AZEROTH_ID].Z = 0
        mapData[WORLDMAP_AZEROTH_ID].name = WORLD_MAP

        -- alliance draenor garrison
        if mapData[971] then
            mapData[971].Z = 5

            mapToID["garrisonsmvalliance_tier1"] = 971
            mapToID["garrisonsmvalliance_tier2"] = 971
            mapToID["garrisonsmvalliance_tier3"] = 971
        end

        -- horde draenor garrison
        if mapData[976] then
            mapData[976].Z = 3

            mapToID["garrisonffhorde_tier1"] = 976
            mapToID["garrisonffhorde_tier2"] = 976
            mapToID["garrisonffhorde_tier3"] = 976
        end

        -- remap zones with alias IDs
        for remapID, validMapID in pairs(MAPS_TO_REMAP) do
            if mapData[validMapID] then
                mapData[remapID] = mapData[validMapID]
            end
        end
    end

    local function gatherMapData()
        -- unregister WMU to reduce the processing burden
        UnregisterWMU()

        -- load transforms
        processTransforms()

        -- load the main zones
        -- these should be processed first so they take precedence in the mapFile lookup table
        local continents = {GetMapContinents()}
        for i = 1, #continents, 2 do
            processZone(continents[i])
            local zones = {GetMapZones((i + 1) / 2)}
            for z = 1, #zones, 2 do
                processZone(zones[z])
            end
        end

        -- process all other zones, this includes dungeons and more
        local areas = GetAreaMaps()
        for idx, zoneID in pairs(areas) do
            processZone(zoneID)
        end

        -- fix a few zones with data lookup problems
        fixupZones()

        -- and finally, the microdungeons
        processMicroDungeons()

        -- restore WMU
        RestoreWMU()
    end

    gatherMapData()
end

-- Transform a set of coordinates based on the defined map transformations
local function applyCoordinateTransforms(x, y, instanceID)
    for _, transformData in ipairs(transforms) do
        if transformData.instanceID == instanceID then
            if transformData.minX <= x and transformData.maxX >= x and transformData.minY <= y and transformData.maxY >= y then
                instanceID = transformData.newInstanceID
                x = x + transformData.offsetX
                y = y + transformData.offsetY
                break
            end
        end
    end
    if instanceIDOverrides[instanceID] then
        instanceID = instanceIDOverrides[instanceID]
    end
    return x, y, instanceID
end

-- get the data table for a map and its level (floor)
local function getMapDataTable(mapID, level)
    if not mapID then return nil end
    if type(mapID) == "string" then
        mapID = mapID:gsub(TERRAIN_MATCH, "")
        mapID = mapToID[mapID]
    end
    local data = mapData[mapID]
    if not data then return nil end

    if (type(level) ~= "number" or level == 0) and data.fakefloor then
        level = data.fakefloor
    end

    if type(level) == "number" and level > 0 then
        if data.floors[level] then
            return data.floors[level]
        elseif data.originalInstance and microDungeons[data.originalInstance] then
            if microDungeons[data.originalInstance][mapID] and microDungeons[data.originalInstance][mapID][level] then
                return microDungeons[data.originalInstance][mapID][level]
            elseif microDungeons[data.originalInstance].global[level] then
                return microDungeons[data.originalInstance].global[level]
            end
        end
    else
        return data
    end
end

local StartUpdateTimer
local function UpdateCurrentPosition()
    UnregisterWMU()

    -- save active map and level
    local prevContinent
    local prevMapID, prevLevel = GetCurrentMapAreaID(), GetCurrentMapDungeonLevel()

    -- handle continent maps (751 is the maelstrom continent, which fails with SetMapByID)
    if not prevMapID or prevMapID < 0 or prevMapID == 751 then
        prevContinent = GetCurrentMapContinent()
    end

    -- set current map
    SetMapToCurrentZone()

    -- retrieve active values
    local newMapID, newLevel = GetCurrentMapAreaID(), GetCurrentMapDungeonLevel()
    local mapFile, _, _, isMicroDungeon, microFile = GetMapInfo()

    -- we want to ignore any terrain phasings
    if mapFile then
        mapFile = mapFile:gsub(TERRAIN_MATCH, "")
    end

    -- hack to update the mapfile for the garrison map (as it changes when the player updates his garrison)
    -- its not ideal to only update it when the player is in the garrison, but updates should only really happen then
    if (newMapID == 971 or newMapID == 976) and mapData[newMapID] and mapFile ~= mapData[newMapID].mapFile then
        mapData[newMapID].mapFile = mapFile
    end

    -- restore previous map
    if prevContinent then
        SetMapZoom(prevContinent)
    else
        -- reset map if it changed, or we need to go back to level 0
        if prevMapID and (prevMapID ~= newMapID or (prevLevel ~= newLevel and prevLevel == 0)) then
            SetMapByID(prevMapID)
        end
        if prevLevel and prevLevel > 0 then
            SetDungeonMapLevel(prevLevel)
        end
    end

    RestoreWMU()

    if newMapID ~= currentPlayerZoneMapID or newLevel ~= currentPlayerLevel then
        -- store micro dungeon map lookup, if available
        if microFile and not mapToID[microFile] then mapToID[microFile] = newMapID end

        -- update upvalues and signal callback
        currentPlayerZoneMapID, currentPlayerLevel, currentMapFile, currentMapIsMicroDungeon = newMapID, newLevel, microFile or mapFile, isMicroDungeon
        HereBeDragons.callbacks:Fire("PlayerZoneChanged", currentPlayerZoneMapID, currentPlayerLevel, currentMapFile, currentMapIsMicroDungeon)
    end

    -- start a timer to update in micro dungeons since multi-level micro dungeons do not reliably fire events
    if isMicroDungeon then
        StartUpdateTimer()
    end
end

-- upgradeable timer callback, don't want to keep calling the old function if the library is upgraded
HereBeDragons.UpdateCurrentPosition = UpdateCurrentPosition
local function UpdateTimerCallback()
    -- signal that the timer ran
    HereBeDragons.updateTimerActive = nil

    -- run update now
    HereBeDragons.UpdateCurrentPosition()
end

function StartUpdateTimer()
    if not HereBeDragons.updateTimerActive then
        -- prevent running multiple timers
        HereBeDragons.updateTimerActive = true

        -- and queue an update
        C_Timer.After(1, UpdateTimerCallback)
    end
end

local function OnEvent(frame, event, ...)
    UpdateCurrentPosition()
end

HereBeDragons.eventFrame:SetScript("OnEvent", OnEvent)
HereBeDragons.eventFrame:UnregisterAllEvents()
HereBeDragons.eventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
HereBeDragons.eventFrame:RegisterEvent("ZONE_CHANGED")
HereBeDragons.eventFrame:RegisterEvent("ZONE_CHANGED_INDOORS")
HereBeDragons.eventFrame:RegisterEvent("NEW_WMO_CHUNK")
HereBeDragons.eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")

-- if we're loading after entering the world (ie. on demand), update position now
if IsLoggedIn() then
    UpdateCurrentPosition()
end

--- Return the localized zone name for a given mapID or mapFile
-- @param mapID numeric mapID or mapFile
function HereBeDragons:GetLocalizedMap(mapID)
    if type(mapID) == "string" then
        mapID = mapID:gsub(TERRAIN_MATCH, "")
        mapID = mapToID[mapID]
    end
    return mapData[mapID] and mapData[mapID].name or nil
end

--- Return the map id to a mapFile
-- @param mapFile Map File
function HereBeDragons:GetMapIDFromFile(mapFile)
    if mapFile then
        mapFile = mapFile:gsub(TERRAIN_MATCH, "")
        return mapToID[mapFile]
    end
    return nil
end

--- Return the mapFile to a map ID
-- @param mapID Map ID
function HereBeDragons:GetMapFileFromID(mapID)
    return mapData[mapID] and mapData[mapID].mapFile or nil
end

--- Lookup the map ID for a Continent / Zone index combination
-- @param C continent index from GetCurrentMapContinent
-- @param Z zone index from GetCurrentMapZone
function HereBeDragons:GetMapIDFromCZ(C, Z)
    if C and continentZoneMap[C] then
        return Z and continentZoneMap[C][Z]
    end
    return nil
end

--- Lookup the C/Z values for map
-- @param mapID the MapID
function HereBeDragons:GetCZFromMapID(mapID)
    if mapData[mapID] then
        return mapData[mapID].C, mapData[mapID].Z
    end
    return nil, nil
end

--- Get the size of the zone
-- @param mapID Map ID or MapFile of the zone
-- @param level Optional map level
-- @return width, height of the zone, in yards
function HereBeDragons:GetZoneSize(mapID, level)
    local data = getMapDataTable(mapID, level)
    if not data then return 0, 0 end

    return data[1], data[2]
end

--- Get the number of floors for a map
-- @param mapID map ID or mapFile of the zone
function HereBeDragons:GetNumFloors(mapID)
    if not mapID then return 0 end
    if type(mapID) == "string" then
        mapID = mapID:gsub(TERRAIN_MATCH, "")
        mapID = mapToID[mapID]
    end

    if not mapData[mapID] or not mapData[mapID].numFloors then return 0 end

    return mapData[mapID].numFloors
end

--- Get a list of all map IDs
-- @return array-style table with all known/valid map IDs
function HereBeDragons:GetAllMapIDs()
    local t = {}
    for id in pairs(mapData) do
        table.insert(t, id)
    end
    return t
end

--- Convert local/point coordinates to world coordinates in yards
-- @param x X position in 0-1 point coordinates
-- @param y Y position in 0-1 point coordinates
-- @param zone MapID or MapFile of the zone
-- @param level Optional level of the zone
function HereBeDragons:GetWorldCoordinatesFromZone(x, y, zone, level)
    local data = getMapDataTable(zone, level)
    if not data or data[1] == 0 or data[2] == 0 then return nil, nil, nil end
    if not x or not y then return nil, nil, nil end

    local width, height, left, top = data[1], data[2], data[3], data[4]
    x, y = left - width * x, top - height * y

    return x, y, data.instance
end

--- Convert world coordinates to local/point zone coordinates
-- @param x Global X position
-- @param y Global Y position
-- @param zone MapID or MapFile of the zone
-- @param level Optional level of the zone
-- @param allowOutOfBounds Allow coordinates to go beyond the current map (ie. outside of the 0-1 range), otherwise nil will be returned
function HereBeDragons:GetZoneCoordinatesFromWorld(x, y, zone, level, allowOutOfBounds)
    local data = getMapDataTable(zone, level)
    if not data or data[1] == 0 or data[2] == 0 then return nil, nil end
    if not x or not y then return nil, nil end

    local width, height, left, top = data[1], data[2], data[3], data[4]
    x, y = (left - x) / width, (top - y) / height

    -- verify the coordinates fall into the zone
    if not allowOutOfBounds and (x < 0 or x > 1 or y < 0 or y > 1) then return nil, nil end

    return x, y
end

--- Translate zone coordinates from one zone to another
-- @param x X position in 0-1 point coordinates, relative to the origin zone
-- @param y Y position in 0-1 point coordinates, relative to the origin zone
-- @param oZone Origin Zone, mapID or mapFile
-- @param oLevel Origin Zone Level
-- @param dZone Destination Zone, mapID or mapFile
-- @param dLevel Destination Zone Level
-- @param allowOutOfBounds Allow coordinates to go beyond the current map (ie. outside of the 0-1 range), otherwise nil will be returned
function HereBeDragons:TranslateZoneCoordinates(x, y, oZone, oLevel, dZone, dLevel, allowOutOfBounds)
    local xCoord, yCoord, instance = self:GetWorldCoordinatesFromZone(x, y, oZone, oLevel)
    if not xCoord then return nil, nil end

    local data = getMapDataTable(dZone, dLevel)
    if not data or data.instance ~= instance then return nil, nil end

    return self:GetZoneCoordinatesFromWorld(xCoord, yCoord, dZone, dLevel, allowOutOfBounds)
end

--- Return the distance from an origin position to a destination position in the same instance/continent.
-- @param instanceID instance ID
-- @param oX origin X
-- @param oY origin Y
-- @param dX destination X
-- @param dY destination Y
-- @return distance, deltaX, deltaY
function HereBeDragons:GetWorldDistance(instanceID, oX, oY, dX, dY)
    if not oX or not oY or not dX or not dY then return nil, nil, nil end
    local deltaX, deltaY = dX - oX, dY - oY
    return (deltaX * deltaX + deltaY * deltaY)^0.5, deltaX, deltaY
end

--- Return the distance between two points on the same continent
-- @param oZone origin zone map id or mapfile
-- @param oLevel optional origin zone level (floor)
-- @param oX origin X, in local zone/point coordinates
-- @param oY origin Y, in local zone/point coordinates
-- @param dZone destination zone map id or mapfile
-- @param dLevel optional destination zone level (floor)
-- @param dX destination X, in local zone/point coordinates
-- @param dY destination Y, in local zone/point coordinates
-- @return distance, deltaX, deltaY in yards
function HereBeDragons:GetZoneDistance(oZone, oLevel, oX, oY, dZone, dLevel, dX, dY)
    local oX, oY, oInstance = self:GetWorldCoordinatesFromZone(oX, oY, oZone, oLevel)
    if not oX then return nil, nil, nil end

    -- translate dX, dY to the origin zone
    local dX, dY, dInstance = self:GetWorldCoordinatesFromZone(dX, dY, dZone, dLevel)
    if not dX then return nil, nil, nil end

    if oInstance ~= dInstance then return nil, nil, nil end

    return self:GetWorldDistance(oInstance, oX, oY, dX, dY)
end

--- Return the angle and distance from an origin position to a destination position in the same instance/continent.
-- @param instanceID instance ID
-- @param oX origin X
-- @param oY origin Y
-- @param dX destination X
-- @param dY destination Y
-- @return angle, distance where angle is in radians and distance in yards
function HereBeDragons:GetWorldVector(instanceID, oX, oY, dX, dY)
    local distance, deltaX, deltaY = self:GetWorldDistance(instanceID, oX, oY, dX, dY)
    if not distance then return nil, nil end

    -- calculate the angle from deltaY and deltaX
    local angle = atan2(-deltaX, deltaY)

    -- normalize the angle
    if angle > 0 then
        angle = PI2 - angle
    else
        angle = -angle
    end

    return angle, distance
end

--- Get the current world position of the specified unit
-- The position is transformed to the current continent, if applicable
-- NOTE: The same restrictions as for the UnitPosition() API apply,
-- which means a very limited set of unit ids will actually work.
-- @param unitId Unit Id
-- @return x, y, instanceID
function HereBeDragons:GetUnitWorldPosition(unitId)
    -- get the current position
    local y, x, z, instanceID = UnitPosition(unitId)
    if not x or not y then return nil, nil, instanceIDOverrides[instanceID] or instanceID end

    -- return transformed coordinates
    return applyCoordinateTransforms(x, y, instanceID)
end

--- Get the current world position of the player
-- The position is transformed to the current continent, if applicable
-- @return x, y, instanceID
function HereBeDragons:GetPlayerWorldPosition()
    -- get the current position
    local y, x, z, instanceID = UnitPosition("player")
    if not x or not y then return nil, nil, instanceIDOverrides[instanceID] or instanceID end

    -- return transformed coordinates
    return applyCoordinateTransforms(x, y, instanceID)
end

--- Get the current zone and level of the player
-- The returned mapFile can represent a micro dungeon, if the player currently is inside one.
-- @return mapID, level, mapFile, isMicroDungeon
function HereBeDragons:GetPlayerZone()
    return currentPlayerZoneMapID, currentPlayerLevel, currentMapFile, currentMapIsMicroDungeon
end

--- Get the current position of the player on a zone level
-- The returned values are local point coordinates, 0-1. The mapFile can represent a micro dungeon.
-- @param allowOutOfBounds Allow coordinates to go beyond the current map (ie. outside of the 0-1 range), otherwise nil will be returned
-- @return x, y, mapID, level, mapFile, isMicroDungeon
function HereBeDragons:GetPlayerZonePosition(allowOutOfBounds)
    if not currentPlayerZoneMapID then return nil, nil, nil, nil end
    local x, y, instanceID = self:GetPlayerWorldPosition()
    if not x or not y then return nil, nil, nil, nil end

    x, y = self:GetZoneCoordinatesFromWorld(x, y, currentPlayerZoneMapID, currentPlayerLevel, allowOutOfBounds)
    if x and y then
        return x, y, currentPlayerZoneMapID, currentPlayerLevel, currentMapFile, currentMapIsMicroDungeon
    end
    return nil, nil, nil, nil
end