MTH_Map = { enabled = true, showWorld = true, showMinimap = true, activeSource = "focus", providers = {}, nodesByZone = {}, minimapNodesByZone = {}, worldPins = {}, minimapPins = {}, zoneNameToId = {}, zoneNameNormToId = {}, mapZoneCache = {}, focusNodes = {}, pendingZoneOpenId = nil, pendingZoneOpenPfqId = nil, pendingZoneOpenSourceId = nil, pendingZoneOpenAttempts = 0, pendingZoneOpenNextAttemptAt = 0, controller = nil, minimapTick = 0, lastMapContext = "n/a", lastWorldReason = "n/a", lastMiniReason = "n/a", nodeRevision = 0, minimapForceRefreshInterval = 1.0, verboseMiniReasons = false, minimapTickActive = 0.05, minimapTickIdle = 0.75, } -- Capture our PizzaSauce instance for the world map pin drop-in (guarded; may be nil). local MTH_MAP_Sauce = PizzaSauce local function MTH_Map_AnimatePin(pin) if not MTH_MAP_Sauce or not pin then return end if type(MTH_FX_IsEnabled) == "function" and not MTH_FX_IsEnabled("ui.mappins") then return end if type(pin.SetAlpha) == "function" then pin:SetAlpha(0) end -- Bouncy scale "pop": the pin overshoots its size and springs back while -- fading in. Far more eye-catching than a plain alpha fade. MTH_MAP_Sauce:Group({ MTH_MAP_Sauce:FadeIn(pin, 0.20, "outQuad", { defer = true }), MTH_MAP_Sauce:Rubber(pin, 0.35, 0.55, { defer = true }), }) end -- Ping a minimap pin that just came into range. Only called for genuinely new -- nodes (see UpdateMinimap), never every tick, to avoid strobing. local function MTH_Map_PingMinimapPin(pin) if not MTH_MAP_Sauce or not pin then return end if type(MTH_FX_IsEnabled) == "function" and not MTH_FX_IsEnabled("ui.minimapping") then return end if type(pin.SetAlpha) == "function" then pin:SetAlpha(0) end -- Springy grow-in so a freshly-discovered node clearly "pops" onto the minimap. MTH_MAP_Sauce:Group({ MTH_MAP_Sauce:FadeIn(pin, 0.20, "outQuad", { defer = true }), MTH_MAP_Sauce:Rubber(pin, 0.30, 0.55, { defer = true }), }) end local MTH_MAP_MINIMAP_ZOOM = { [0] = { [0] = 300, [1] = 240, [2] = 180, [3] = 120, [4] = 80, [5] = 50 }, [1] = { [0] = 466 + 2/3, [1] = 400, [2] = 333 + 1/3, [3] = 266 + 2/6, [4] = 200, [5] = 133 + 1/3 }, } local MTH_MAP_FAMILY_PALETTE = { { 0.95, 0.35, 0.35 }, { 0.95, 0.55, 0.20 }, { 0.95, 0.78, 0.20 }, { 0.82, 0.92, 0.20 }, { 0.45, 0.90, 0.35 }, { 0.20, 0.88, 0.62 }, { 0.20, 0.82, 0.95 }, { 0.35, 0.65, 0.95 }, { 0.62, 0.55, 0.95 }, { 0.88, 0.45, 0.88 }, { 0.95, 0.45, 0.68 }, { 0.78, 0.78, 0.82 }, } local MTH_MAP_FAMILY_COLOR_CACHE = {} local function MTH_Map_Mod(a, b) if b == 0 then return 0 end if math.mod then return math.mod(a, b) end if math.fmod then return math.fmod(a, b) end return 0 end local function MTH_Map_NormalizeName(name) if not name then return "" end local lowered = string.lower(tostring(name)) return string.gsub(lowered, "[^%w]", "") end local function MTH_Map_GetFamilyColor(family) local key = MTH_Map_NormalizeName(family) if key == "" then return 0.95, 0.82, 0.10 end if MTH_MAP_FAMILY_COLOR_CACHE[key] then local c = MTH_MAP_FAMILY_COLOR_CACHE[key] return c[1], c[2], c[3] end local names = {} local seen = {} if MTH_DS_Beasts then for _, beast in pairs(MTH_DS_Beasts) do if beast and beast.family then local n = MTH_Map_NormalizeName(beast.family) if n ~= "" and not seen[n] then seen[n] = beast.family table.insert(names, beast.family) end end end end table.sort(names, function(a, b) return MTH_Map_NormalizeName(a) < MTH_Map_NormalizeName(b) end) for i = 1, table.getn(names) do local normalized = MTH_Map_NormalizeName(names[i]) if not MTH_MAP_FAMILY_COLOR_CACHE[normalized] then local paletteIndex = MTH_Map_Mod((i - 1), table.getn(MTH_MAP_FAMILY_PALETTE)) + 1 MTH_MAP_FAMILY_COLOR_CACHE[normalized] = MTH_MAP_FAMILY_PALETTE[paletteIndex] end end local resolved = MTH_MAP_FAMILY_COLOR_CACHE[key] if resolved then return resolved[1], resolved[2], resolved[3] end return 0.95, 0.82, 0.10 end local function MTH_Map_StripThePrefix(name) if not name then return nil end local lowered = string.lower(name) if string.sub(lowered, 1, 4) == "the " and string.len(name) > 4 then return string.sub(name, 5) end return nil end local function MTH_Map_WmaToPfq(zoneId) if type(MTH_MAP_WMA_TO_PFQ) == "table" then return MTH_MAP_WMA_TO_PFQ[zoneId] or zoneId end return zoneId end local function MTH_Map_PfqToWma(pfqId) if type(MTH_MAP_PFQ_TO_WMA) == "table" then return MTH_MAP_PFQ_TO_WMA[pfqId] or pfqId end return pfqId end local function MTH_Map_GetMinimapSizes() if pfDB and pfDB["minimap"] and MTH_DS_MinimapSizes then if not MTH_Map._mergedMinimapSizes then local merged = {} for zoneId, size in pairs(pfDB["minimap"]) do merged[zoneId] = size end for zoneId, size in pairs(MTH_DS_MinimapSizes) do if merged[zoneId] == nil then merged[zoneId] = size end end MTH_Map._mergedMinimapSizes = merged end return MTH_Map._mergedMinimapSizes elseif pfDB and pfDB["minimap"] then return pfDB["minimap"] end return MTH_DS_MinimapSizes or {} end local function MTH_Map_GetZoneNameById(zoneId) local normalizedId = tonumber(zoneId) or zoneId if not normalizedId then return nil end -- Translate WMA→pfQ first so WMA IDs don't accidentally hit wrong pfQ entries. local pfqId = MTH_Map_WmaToPfq(normalizedId) if MTH_DS_ZoneNamesFallback and MTH_DS_ZoneNamesFallback[pfqId] then return MTH_DS_ZoneNamesFallback[pfqId] end if not MTH_DS_Zones then return nil end local row = MTH_DS_Zones[pfqId] or MTH_DS_Zones[tostring(pfqId)] if not row or not row.names then return nil end return row.names.enUS or row.names[GetLocale()] or row.names.deDE or row.names.frFR end local function MTH_Map_MinimapIndoorState() local tempzoom = 0 local state = 1 if GetCVar("minimapZoom") == GetCVar("minimapInsideZoom") then if (GetCVar("minimapInsideZoom") + 0) >= 3 then Minimap:SetZoom(Minimap:GetZoom() - 1) tempzoom = 1 else Minimap:SetZoom(Minimap:GetZoom() + 1) tempzoom = -1 end end if (GetCVar("minimapInsideZoom") + 0) == Minimap:GetZoom() then state = 0 end Minimap:SetZoom(Minimap:GetZoom() + tempzoom) return state end local function MTH_Map_AddNode(list, zoneId, x, y, title, detail, r, g, b, meta) local nx = tonumber(x) local ny = tonumber(y) local zid = tonumber(zoneId) if not nx or not ny or not zid then return end if nx <= 0 or ny <= 0 or nx > 100 or ny > 100 then return end local node = { zoneId = zid, x = nx, y = ny, title = title or "Unknown", detail = detail, color = { r or 1, g or 1, b or 1 }, } if type(meta) == "table" then for key, value in pairs(meta) do node[key] = value end end table.insert(list, node) end local function MTH_Map_TT_Trim(value) if value == nil then return "" end local text = tostring(value) text = string.gsub(text, "^%s+", "") text = string.gsub(text, "%s+$", "") return text end local function MTH_Map_TT_Lower(value) if value == nil then return "" end return string.lower(tostring(value)) end local function MTH_Map_TT_GetKnownSpellMap() if not MTH_CharSavedVariables or not MTH_CharSavedVariables.petTraining then return nil end return MTH_CharSavedVariables.petTraining.spellMap end local function MTH_Map_TT_ParseAbilityToken(token) local text = MTH_Map_TT_Trim(token) if text == "" then return "", nil end local _, _, abilityName, rankText = string.find(text, "^(.-)%s+(%d+)$") if abilityName and abilityName ~= "" then return MTH_Map_TT_Trim(abilityName), tonumber(rankText) end return text, nil end local function MTH_Map_TT_HunterKnowsAbility(abilityName, rankNumber) local abilityLower = MTH_Map_TT_Lower(abilityName) if abilityLower == "" then return false end local spellMap = MTH_Map_TT_GetKnownSpellMap() if type(spellMap) ~= "table" then return false end for _, row in pairs(spellMap) do if row and row.name and row.isKnown ~= false then if MTH_Map_TT_Lower(row.name) == abilityLower then if rankNumber then if tonumber(row.rankNumber) == tonumber(rankNumber) then return true end else return true end end end end return false end local function MTH_Map_TT_ParseAbilities(abilitiesText) local text = MTH_Map_TT_Trim(abilitiesText) local parsed = {} if text == "" or text == "None" then return parsed end local startPos = 1 local len = string.len(text) while startPos <= len do local commaPos = string.find(text, ",", startPos, true) local token if commaPos then token = string.sub(text, startPos, commaPos - 1) startPos = commaPos + 1 else token = string.sub(text, startPos) startPos = len + 1 end token = MTH_Map_TT_Trim(token) if token ~= "" then local abilityName, rankNumber = MTH_Map_TT_ParseAbilityToken(token) table.insert(parsed, { text = token, known = MTH_Map_TT_HunterKnowsAbility(abilityName, rankNumber), }) end end return parsed end local function MTH_Map_GetCoordsForUnit(unitId, fallbackCoords) if not unitId then return fallbackCoords end if MTH_DS_UnitCoords and MTH_DS_UnitCoords[unitId] and table.getn(MTH_DS_UnitCoords[unitId]) > 0 then return MTH_DS_UnitCoords[unitId] end if MTH_DS_CreatureCoords and MTH_DS_CreatureCoords[unitId] and table.getn(MTH_DS_CreatureCoords[unitId]) > 0 then return MTH_DS_CreatureCoords[unitId] end return fallbackCoords end function MTH_Map:BuildZoneLookup() self.zoneNameToId = {} self.zoneNameNormToId = {} if MTH_DS_Zones then for zoneId, row in pairs(MTH_DS_Zones) do local zid = tonumber(zoneId) if zid and row and row.names then for _, value in pairs(row.names) do if value and value ~= "" then self.zoneNameToId[value] = zid self.zoneNameNormToId[MTH_Map_NormalizeName(value)] = zid end end end end end if MTH_DS_ZoneNamesFallback then for zoneId, value in pairs(MTH_DS_ZoneNamesFallback) do local zid = tonumber(zoneId) if zid and value and value ~= "" then if not self.zoneNameToId[value] then self.zoneNameToId[value] = zid end local norm = MTH_Map_NormalizeName(value) if not self.zoneNameNormToId[norm] then self.zoneNameNormToId[norm] = zid end end end end local count = 0 for _ in pairs(self.zoneNameNormToId) do count = count + 1 end end function MTH_Map:GetMapIDByName(name) if not name or name == "" then return nil end if not next(self.zoneNameNormToId or {}) then self:BuildZoneLookup() end local id = self.zoneNameToId[name] if id then return id end local normalized = MTH_Map_NormalizeName(name) id = self.zoneNameNormToId[normalized] if id then return id end local stripped = MTH_Map_StripThePrefix(name) if stripped then id = self.zoneNameToId[stripped] if id then return id end id = self.zoneNameNormToId[MTH_Map_NormalizeName(stripped)] if id then return id end else local withThe = "The " .. name id = self.zoneNameToId[withThe] if id then return id end id = self.zoneNameNormToId[MTH_Map_NormalizeName(withThe)] if id then return id end end return nil end function MTH_Map:ResolveZoneHierarchy(zoneId) local inputId = tonumber(zoneId) local pfqInputId = MTH_Map_WmaToPfq(inputId) local inputIsWma = inputId and pfqInputId ~= inputId or false local resolved = { inputId = inputId, zoneId = inputId, parentId = nil, subzoneId = nil, continentId = nil, hasMinimapSize = false, } if not inputId then return resolved end local sizes = MTH_Map_GetMinimapSizes() local visited = {} local cursor = pfqInputId local depth = 0 while cursor and not visited[cursor] and depth < 8 do visited[cursor] = true local row = MTH_DS_Zones and (MTH_DS_Zones[cursor] or MTH_DS_Zones[tostring(cursor)]) if not row then break end local parent = tonumber(row.parent or row.continent) local outputCursor = inputIsWma and MTH_Map_PfqToWma(cursor) or cursor local outputParent = parent and (inputIsWma and MTH_Map_PfqToWma(parent) or parent) or nil if parent and not resolved.continentId then resolved.continentId = outputParent end if parent and parent > 0 and parent ~= cursor then resolved.subzoneId = resolved.subzoneId or outputCursor resolved.parentId = outputParent cursor = parent resolved.zoneId = inputIsWma and MTH_Map_PfqToWma(cursor) or cursor depth = depth + 1 else break end end local renderZoneId = resolved.zoneId local pfqRenderId = tonumber(cursor or pfqInputId) local pfqParentId = resolved.parentId and MTH_Map_WmaToPfq(resolved.parentId) or nil if not sizes[renderZoneId] then if sizes[inputId] then renderZoneId = inputId elseif resolved.parentId and sizes[resolved.parentId] then renderZoneId = resolved.parentId elseif pfqRenderId and sizes[pfqRenderId] then renderZoneId = pfqRenderId elseif pfqParentId and sizes[pfqParentId] then renderZoneId = pfqParentId end end resolved.zoneId = renderZoneId resolved.hasMinimapSize = sizes[renderZoneId] and true or false return resolved end function MTH_Map:GetCurrentMapID(space) local continent = GetCurrentMapContinent and GetCurrentMapContinent() or nil local zone = GetCurrentMapZone and GetCurrentMapZone() or nil local zoneName = nil local mapId = nil local worldMapShown = WorldMapFrame and WorldMapFrame.IsShown and WorldMapFrame:IsShown() or false local preferPfq = space == "pfq" if continent and zone and continent > 0 and zone > 0 and GetMapZones then if not self.mapZoneCache[continent] then self.mapZoneCache[continent] = { GetMapZones(continent) } end zoneName = self.mapZoneCache[continent][zone] mapId = self:GetMapIDByName(zoneName) if mapId then local resolved = self:ResolveZoneHierarchy(mapId) local normalized = resolved.zoneId or mapId if not preferPfq then normalized = MTH_Map_PfqToWma(normalized) end self.lastMapContext = string.format("continent=%s zone=%s mapZone='%s' real=nil mapId=%s normalized=%s", tostring(continent), tostring(zone), tostring(zoneName), tostring(mapId), tostring(normalized)) return normalized end end if worldMapShown then self.lastMapContext = string.format("continent=%s zone=%s mapZone='%s' real=skipped mapId=nil (zoomed-out)", tostring(continent), tostring(zone), tostring(zoneName)) return nil end local real = GetRealZoneText and GetRealZoneText() or nil if real then mapId = self:GetMapIDByName(real) local resolved = self:ResolveZoneHierarchy(mapId) local normalized = resolved.zoneId or mapId if not preferPfq then normalized = MTH_Map_PfqToWma(normalized) end self.lastMapContext = string.format("continent=%s zone=%s mapZone='%s' real='%s' mapId=%s normalized=%s", tostring(continent), tostring(zone), tostring(zoneName), tostring(real), tostring(mapId), tostring(normalized)) return normalized end self.lastMapContext = string.format("continent=%s zone=%s mapZone='%s' real=nil mapId=nil", tostring(continent), tostring(zone), tostring(zoneName)) return nil end function MTH_Map:GetCurrentMinimapMapID() local realZoneName = GetRealZoneText and GetRealZoneText() or nil if realZoneName and realZoneName ~= "" then local mapId = self:GetMapIDByName(realZoneName) if mapId then return mapId end end local continent = GetCurrentMapContinent and GetCurrentMapContinent() or nil local zone = GetCurrentMapZone and GetCurrentMapZone() or nil if continent and zone and continent > 0 and zone > 0 and GetMapZones then if not self.mapZoneCache[continent] then self.mapZoneCache[continent] = { GetMapZones(continent) } end local zoneName = self.mapZoneCache[continent][zone] if zoneName and zoneName ~= "" then return self:GetMapIDByName(zoneName) end end return nil end function MTH_Map:RegisterProvider(key, provider) if not key or type(key) ~= "string" then return false end if type(provider) ~= "table" or type(provider.buildNodes) ~= "function" then return false end self.providers[key] = provider return true end local function MTH_Map_ClearPendingZoneOpen() MTH_Map.pendingZoneOpenId = nil MTH_Map.pendingZoneOpenPfqId = nil MTH_Map.pendingZoneOpenSourceId = nil MTH_Map.pendingZoneOpenAttempts = 0 MTH_Map.pendingZoneOpenNextAttemptAt = 0 end function MTH_Map:EnsureInitialized() if self.controller then return true end self:Init() return self.controller ~= nil end function MTH_Map:SetSource(key) if not self:EnsureInitialized() then return false end if not self.providers[key] then return false end if key ~= "focus" and self.activeSource == key and self.nodesByZone and next(self.nodesByZone) then self:UpdateWorldMap() self:UpdateMinimap() return true end self.activeSource = key self:RebuildNodes() self:UpdateWorldMap() self:UpdateMinimap() return true end local function MTH_Map_ApplyWorldMapZone(targetZoneId, sourceZoneId) if not (GetMapContinents and GetMapZones and SetMapZoom) then return false end local targetNames = {} local resolvedName = MTH_Map_GetZoneNameById(targetZoneId) if resolvedName and resolvedName ~= "" then targetNames[MTH_Map_NormalizeName(resolvedName)] = true end local sourceName = MTH_Map_GetZoneNameById(sourceZoneId) if sourceName and sourceName ~= "" then targetNames[MTH_Map_NormalizeName(sourceName)] = true end local continents = { GetMapContinents() } for continentId = 1, table.getn(continents) do local zones = { GetMapZones(continentId) } for mapIndex = 1, table.getn(zones) do local zoneName = zones[mapIndex] if zoneName and targetNames[MTH_Map_NormalizeName(zoneName)] then SetMapZoom(continentId, mapIndex) return true end end end return false end function MTH_Map:OpenWorldMapForZone(zoneId) local zid = tonumber(zoneId) if not zid then return false end -- Translate WMA ID → pfQ so game map APIs (pfMap, SetMapZoom) work correctly. local pfqZid = (type(MTH_MAP_WMA_TO_PFQ) == "table" and MTH_MAP_WMA_TO_PFQ[zid]) or zid local resolved = self:ResolveZoneHierarchy(pfqZid) local targetZoneId = tonumber(resolved.zoneId or pfqZid) or pfqZid -- WMA ID of this zone, for comparing against GetCurrentMapID() which returns WMA IDs. local targetWmaId = (type(MTH_MAP_PFQ_TO_WMA) == "table" and MTH_MAP_PFQ_TO_WMA[targetZoneId]) or zid if ToggleWorldMap then if not WorldMapFrame or not WorldMapFrame:IsShown() then ToggleWorldMap() end elseif WorldMapFrame and not WorldMapFrame:IsShown() then WorldMapFrame:Show() end if pfMap and pfMap.SetMapByID then pfMap:SetMapByID(targetZoneId) local currentAfterPF = self:GetCurrentMapID() if tonumber(currentAfterPF) == tonumber(targetWmaId) then MTH_Map_ClearPendingZoneOpen() return true end end local zoomApplied = MTH_Map_ApplyWorldMapZone(zid, zid) if zoomApplied then local currentAfterZoom = self:GetCurrentMapID() if tonumber(currentAfterZoom) == tonumber(targetWmaId) then MTH_Map_ClearPendingZoneOpen() return true end end local nowMapId = self:GetCurrentMapID() if tonumber(nowMapId) == tonumber(targetWmaId) then MTH_Map_ClearPendingZoneOpen() return true end self.pendingZoneOpenId = targetWmaId self.pendingZoneOpenPfqId = targetZoneId self.pendingZoneOpenSourceId = pfqZid self.pendingZoneOpenAttempts = 8 self.pendingZoneOpenNextAttemptAt = (GetTime and GetTime() or 0) + 0.25 return true end function MTH_Map:GetFamilyColor(family) return MTH_Map_GetFamilyColor(family) end function MTH_Map:FocusBeast(beastId, beast) local id = tonumber(beastId) if not id then return false end local row = beast or (MTH_DS_Beasts and MTH_DS_Beasts[id]) if not row then return false end local coords = MTH_Map_GetCoordsForUnit(id, row.coords) if not coords or table.getn(coords) == 0 then return false end local cr, cg, cb = MTH_Map_GetFamilyColor(row.family) local nodes = {} local firstZoneId = nil local displayName = (MTH and MTH.GetLocalizedBeastName and MTH:GetLocalizedBeastName(id, row.name)) or row.name or "Unknown" for i = 1, table.getn(coords) do local c = coords[i] if c and c[1] and c[2] and c[3] then local zoneId = tonumber(c[3]) if zoneId and not firstZoneId then firstZoneId = zoneId end local details = string.format("Family: %s\nLevel: %s\nAbilities: %s", row.family or "?", row.lvl or "?", row.abilities or "None") MTH_Map_AddNode(nodes, c[3], c[1], c[2], string.format("%s (%d)", displayName, id), details, cr, cg, cb, { kind = "beast", beastId = id }) end end if table.getn(nodes) == 0 then return false end self.focusNodes = nodes if not self:SetSource("focus") then return false end if firstZoneId then self:OpenWorldMapForZone(firstZoneId) end self:UpdateWorldMap() self:UpdateMinimap() return true end function MTH_Map:FocusVendor(vendorId, vendor) local id = tonumber(vendorId) if not id then return false end local row = vendor or (MTH_DS_Vendors and MTH_DS_Vendors[id]) if not row then return false end local coords = MTH_Map_GetCoordsForUnit(id, row.coords) if not coords or table.getn(coords) == 0 then return false end local react = tostring(row.fac or "AH") local cr, cg, cb = 0.20, 0.75, 0.95 if react == "A" then cr, cg, cb = 0.30, 0.55, 1.00 elseif react == "H" then cr, cg, cb = 0.95, 0.35, 0.35 elseif react == "AH" then cr, cg, cb = 0.95, 0.90, 0.35 end local metaTags = "-" if row.meta then local labels = {} for key, value in pairs(row.meta) do if type(key) == "string" and value and tostring(value) ~= "" then table.insert(labels, key) end end if table.getn(labels) > 0 then table.sort(labels) metaTags = table.concat(labels, ", ") end end local nodes = {} local firstZoneId = nil local displayName = (MTH and MTH.GetLocalizedVendorName and MTH:GetLocalizedVendorName(id, row.name)) or row.name or "Unknown" for i = 1, table.getn(coords) do local c = coords[i] if c and c[1] and c[2] and c[3] then local zoneId = tonumber(c[3]) if zoneId and not firstZoneId then firstZoneId = zoneId end local details = string.format("React: %s\nLevel: %s\nFunctions: %s", react ~= "" and react or "?", row.lvl or "?", metaTags) MTH_Map_AddNode(nodes, c[3], c[1], c[2], string.format("%s (%d)", displayName, id), details, cr, cg, cb, { kind = "vendor", vendorId = id }) end end if table.getn(nodes) == 0 then return false end self.focusNodes = nodes if not self:SetSource("focus") then return false end if firstZoneId then self:OpenWorldMapForZone(firstZoneId) end self:UpdateWorldMap() self:UpdateMinimap() return true end function MTH_Map:SetEnabled(enabled) self.enabled = enabled and true or false if not self.enabled then self:HideAllPins() else if not self:EnsureInitialized() then return end self:UpdateWorldMap() self:UpdateMinimap() end end function MTH_Map:SetWorldEnabled(enabled) self.showWorld = enabled and true or false if not self.showWorld then self:HideWorldPins() else if not self:EnsureInitialized() then return end self:UpdateWorldMap() end end function MTH_Map:SetMinimapEnabled(enabled) self.showMinimap = enabled and true or false if not self.showMinimap then self:HideMinimapPins() else if not self:EnsureInitialized() then return end self:UpdateMinimap() end end function MTH_Map:GetStatusText() return string.format("source=%s, enabled=%s, world=%s, minimap=%s", self.activeSource, self.enabled and "on" or "off", self.showWorld and "on" or "off", self.showMinimap and "on" or "off") end local function MTH_Map_CountList(list) local count = 0 if not list then return 0 end for _ in pairs(list) do count = count + 1 end return count end function MTH_Map:GetStatsText() local zones = MTH_Map_CountList(self.nodesByZone) local activeNodes = 0 for _, rows in pairs(self.nodesByZone) do activeNodes = activeNodes + MTH_Map_CountList(rows) end local worldTotal = MTH_Map_CountList(self.worldPins) local worldVisible = 0 for _, pin in pairs(self.worldPins) do if pin and pin.IsShown and pin:IsShown() then worldVisible = worldVisible + 1 end end local miniTotal = MTH_Map_CountList(self.minimapPins) local miniVisible = 0 for _, pin in pairs(self.minimapPins) do if pin and pin.IsShown and pin:IsShown() then miniVisible = miniVisible + 1 end end return string.format("nodes=%d zones=%d world=%d/%d mini=%d/%d worldReason=%s miniReason=%s map=%s", activeNodes, zones, worldVisible, worldTotal, miniVisible, miniTotal, tostring(self.lastWorldReason), tostring(self.lastMiniReason), tostring(self.lastMapContext)) end function MTH_Map:GetContextText() local continent = GetCurrentMapContinent and GetCurrentMapContinent() or nil local zone = GetCurrentMapZone and GetCurrentMapZone() or nil local zoneName = nil if continent and zone and continent > 0 and zone > 0 and GetMapZones then if not self.mapZoneCache[continent] then self.mapZoneCache[continent] = { GetMapZones(continent) } end zoneName = self.mapZoneCache[continent][zone] end local mapIdFromZone = zoneName and self:GetMapIDByName(zoneName) or nil local realZoneName = GetRealZoneText and GetRealZoneText() or nil local mapIdFromReal = realZoneName and self:GetMapIDByName(realZoneName) or nil local baseMapId = mapIdFromZone or mapIdFromReal local resolved = self:ResolveZoneHierarchy(baseMapId) return string.format( "continent=%s zoneIndex=%s mapZone='%s' realZone='%s' mapId(zone)=%s mapId(real)=%s sourceZone=%s subzone=%s parent=%s renderZone=%s minimap=%s", tostring(continent), tostring(zone), tostring(zoneName), tostring(realZoneName), tostring(mapIdFromZone), tostring(mapIdFromReal), tostring(resolved.inputId), tostring(resolved.subzoneId), tostring(resolved.parentId), tostring(resolved.zoneId), resolved.hasMinimapSize and "yes" or "no" ) end function MTH_Map:BuildPin(parent, name, isMinimap) local pin = CreateFrame("Button", name, parent) pin:SetWidth(isMinimap and 14 or 17) pin:SetHeight(isMinimap and 14 or 17) pin:SetFrameStrata(isMinimap and "MEDIUM" or "FULLSCREEN") pin:SetFrameLevel(isMinimap and 8 or 120) pin.tex = pin:CreateTexture(nil, "ARTWORK") pin.tex:SetAllPoints(pin) pin.tex:SetTexture("Interface\\AddOns\\MetaHunt\\img\\node") pin.tex:SetVertexColor(1, 1, 1, 1) pin:SetScript("OnEnter", function() if not this.meta then return end GameTooltip:SetOwner(this, "ANCHOR_RIGHT") GameTooltip:ClearLines() GameTooltip:AddLine(this.meta.title or "Unknown", 1, 0.85, 0.2) if this.meta.detail and this.meta.detail ~= "" then GameTooltip:AddLine(this.meta.detail, 0.9, 0.9, 0.9, true) end local beastId = this.meta.beastId and tonumber(this.meta.beastId) or nil local beast = beastId and MTH_DS_Beasts and MTH_DS_Beasts[beastId] or nil if beast and beast.abilities then local abilities = MTH_Map_TT_ParseAbilities(beast.abilities) if table.getn(abilities) > 0 then GameTooltip:AddLine("Pet Abilities:", 1.0, 0.95, 0.45) for i = 1, table.getn(abilities) do local entry = abilities[i] if entry and entry.known then GameTooltip:AddLine(" " .. tostring(entry.text or ""), 0.2, 1.0, 0.2) else GameTooltip:AddLine(" " .. tostring(entry and entry.text or ""), 1.0, 0.25, 0.25) end end end end if this.meta.subzoneId and this.meta.zoneId and tonumber(this.meta.subzoneId) ~= tonumber(this.meta.zoneId) then local subzoneName = MTH_Map_GetZoneNameById(this.meta.subzoneId) if subzoneName then GameTooltip:AddLine("Subzone: " .. subzoneName, 0.7, 0.9, 1) end end local zoneName = MTH_Map_GetZoneNameById(this.meta.zoneId) if zoneName then GameTooltip:AddLine(zoneName .. string.format(" (%.1f, %.1f)", this.meta.x or 0, this.meta.y or 0), 0.6, 0.8, 1) end GameTooltip:Show() end) pin:SetScript("OnLeave", function() GameTooltip:Hide() end) pin:Hide() return pin end function MTH_Map:HideWorldPins() for i = 1, table.getn(self.worldPins) do if self.worldPins[i] then self.worldPins[i]:Hide() end end end function MTH_Map:HideMinimapPins() for i = 1, table.getn(self.minimapPins) do if self.minimapPins[i] then self.minimapPins[i]:Hide() end end self._miniShownSet = nil end function MTH_Map:HideAllPins() self:HideWorldPins() self:HideMinimapPins() end function MTH_Map:RebuildNodes() self:BuildZoneLookup() self.nodesByZone = {} self.minimapNodesByZone = {} self.nodeRevision = (tonumber(self.nodeRevision) or 0) + 1 self._lastWorldRenderKey = nil self._lastMiniState = nil local provider = self.providers[self.activeSource] if not provider then return end local allNodes = provider.buildNodes() if type(allNodes) ~= "table" then return end for i = 1, table.getn(allNodes) do local node = allNodes[i] if node and node.zoneId then local sourceZoneId = tonumber(node.zoneId) local worldResolved = self:ResolveZoneHierarchy(sourceZoneId) local worldZoneId = tonumber(worldResolved.zoneId or sourceZoneId) local pfqSourceZoneId = MTH_Map_WmaToPfq(sourceZoneId) local minimapZoneId = tonumber(pfqSourceZoneId) if worldZoneId then node.sourceZoneId = sourceZoneId node.zoneId = worldZoneId node.parentZoneId = worldResolved.parentId node.subzoneId = worldResolved.subzoneId node.minimapZoneId = minimapZoneId if not self.nodesByZone[worldZoneId] then self.nodesByZone[worldZoneId] = {} end table.insert(self.nodesByZone[worldZoneId], node) if minimapZoneId then if not self.minimapNodesByZone[minimapZoneId] then self.minimapNodesByZone[minimapZoneId] = {} end table.insert(self.minimapNodesByZone[minimapZoneId], node) end end end end end function MTH_Map:UpdateWorldMap() if not self.enabled or not self.showWorld then self.lastWorldReason = "disabled" self._lastWorldRenderKey = nil self:HideWorldPins() return end local worldButton = WorldMapButton if not WorldMapFrame or not WorldMapFrame:IsShown() or not worldButton then self.lastWorldReason = "worldmap-hidden-or-missing" self._lastWorldRenderKey = nil self:HideWorldPins() return end local mapId = self:GetCurrentMapID() if not mapId or not self.nodesByZone[mapId] then self.lastWorldReason = "no-mapid-or-nodes" self._lastWorldRenderKey = nil self:HideWorldPins() return end local nodes = self.nodesByZone[mapId] local worldWidth = worldButton:GetWidth() or 0 local worldHeight = worldButton:GetHeight() or 0 local worldKey = tostring(mapId) .. "|" .. tostring(worldWidth) .. "x" .. tostring(worldHeight) .. "|" .. tostring(table.getn(nodes)) .. "|" .. tostring(self.nodeRevision or 0) .. "|" .. tostring(self.activeSource or "") if self._lastWorldRenderKey == worldKey then self.lastWorldReason = "cached(map=" .. tostring(mapId) .. ")" return end local shown = 0 for i = 1, table.getn(nodes) do local node = nodes[i] if node then shown = shown + 1 if not self.worldPins[shown] then self.worldPins[shown] = self:BuildPin(worldButton, "MTHMapWorldPin" .. shown, nil) end local pin = self.worldPins[shown] pin.meta = node pin.tex:SetVertexColor(node.color[1], node.color[2], node.color[3], 0.95) local x = node.x / 100 * worldButton:GetWidth() local y = node.y / 100 * worldButton:GetHeight() pin:ClearAllPoints() pin:SetPoint("CENTER", worldButton, "TOPLEFT", x, -y) pin:Show() MTH_Map_AnimatePin(pin) end end for i = shown + 1, table.getn(self.worldPins) do if self.worldPins[i] then self.worldPins[i]:Hide() end end self._lastWorldRenderKey = worldKey self.lastWorldReason = "ok(map=" .. tostring(mapId) .. ",shown=" .. tostring(shown) .. ")" end function MTH_Map:UpdateMinimap() if not self.enabled or not self.showMinimap then self.lastMiniReason = "disabled" self._lastMiniState = nil self:HideMinimapPins() return end if not Minimap or not GetPlayerMapPosition then self.lastMiniReason = "minimap-or-position-api-missing" self._lastMiniState = nil self:HideMinimapPins() return end local mapId = self:GetCurrentMinimapMapID() local nodes = mapId and self.minimapNodesByZone[mapId] or nil if not mapId or not nodes then self.lastMiniReason = "no-mapid-or-nodes" self._lastMiniState = nil self:HideMinimapPins() return end if table.getn(nodes) <= 0 then self.lastMiniReason = "no-nodes-in-zone" self._lastMiniState = nil self:HideMinimapPins() return end local now = GetTime() or 0 local xPlayer, yPlayer = GetPlayerMapPosition("player") if not xPlayer or not yPlayer or (xPlayer == 0 and yPlayer == 0) then self.lastMiniReason = "player-position-unavailable" self._lastMiniState = nil self:HideMinimapPins() return end xPlayer = xPlayer * 100 yPlayer = yPlayer * 100 local zoom = Minimap:GetZoom() local miniState = self._lastMiniState if miniState and miniState.mapId == mapId and miniState.zoom == zoom and miniState.nodeRevision == (self.nodeRevision or 0) then if xPlayer == miniState.xPlayer and yPlayer == miniState.yPlayer and now < (miniState.nextForceAt or 0) then self.lastMiniReason = self.verboseMiniReasons and "throttled-still" or "throttle" return end end local minimapSizes = MTH_Map_GetMinimapSizes() local mapSize = minimapSizes and minimapSizes[mapId] if not mapSize then local wmaId = MTH_Map_PfqToWma(mapId) mapSize = wmaId and minimapSizes and minimapSizes[wmaId] end if not mapSize then self.lastMiniReason = self.verboseMiniReasons and "missing-minimap-size" or "missing-size" self._lastMiniState = nil self:HideMinimapPins() return end local indoor = MTH_Map_MinimapIndoorState() local zoomSet = MTH_MAP_MINIMAP_ZOOM[indoor] if not zoomSet then self.lastMiniReason = self.verboseMiniReasons and "missing-zoomset" or "missing-zoomset" self._lastMiniState = nil self:HideMinimapPins() return end local mapZoom = zoomSet[zoom] if not mapZoom then self.lastMiniReason = self.verboseMiniReasons and "missing-mapzoom" or "missing-zoom" self._lastMiniState = nil self:HideMinimapPins() return end local mapWidth = mapSize[1] local mapHeight = mapSize[2] if not mapWidth or not mapHeight or mapWidth == 0 or mapHeight == 0 then self.lastMiniReason = self.verboseMiniReasons and "invalid-map-size" or "invalid-size" self._lastMiniState = nil self:HideMinimapPins() return end local xScale = mapZoom / mapWidth local yScale = mapZoom / mapHeight local xDraw = Minimap:GetWidth() / xScale / 100 local yDraw = Minimap:GetHeight() / yScale / 100 local shown = 0 local prevShown = self._miniShownSet or {} local nowShown = {} for i = 1, table.getn(nodes) do local node = nodes[i] if node then local xPos = (node.x - xPlayer) * xDraw local yPos = (node.y - yPlayer) * yDraw local distance = math.sqrt(xPos * xPos + yPos * yPos) local shouldShow = (distance + 6) < (Minimap:GetWidth() / 2) if shouldShow then shown = shown + 1 if not self.minimapPins[shown] then self.minimapPins[shown] = self:BuildPin(Minimap, "MTHMapMiniPin" .. shown, true) end local pin = self.minimapPins[shown] pin.meta = node pin.tex:SetVertexColor(node.color[1], node.color[2], node.color[3], 0.95) pin:ClearAllPoints() pin:SetPoint("CENTER", Minimap, "CENTER", xPos, -yPos) pin:Show() local nodeKey = tostring(node.name or "") .. "@" .. tostring(node.x) .. "," .. tostring(node.y) nowShown[nodeKey] = true if not prevShown[nodeKey] then MTH_Map_PingMinimapPin(pin) end end end end self._miniShownSet = nowShown for i = shown + 1, table.getn(self.minimapPins) do if self.minimapPins[i] then self.minimapPins[i]:Hide() end end local lastState = self._lastMiniState or {} lastState.mapId = mapId lastState.zoom = zoom lastState.indoor = indoor lastState.xPlayer = xPlayer lastState.yPlayer = yPlayer lastState.nodeRevision = self.nodeRevision or 0 lastState.nextForceAt = now + (tonumber(self.minimapForceRefreshInterval) or 1.0) self._lastMiniState = lastState if self.verboseMiniReasons then self.lastMiniReason = "ok(map=" .. tostring(mapId) .. ",shown=" .. tostring(shown) .. ")" else self.lastMiniReason = "ok" end end function MTH_Map:RegisterDefaultProviders() self:RegisterProvider("focus", { name = "Focused Beast", buildNodes = function() if MTH_Map.focusNodes and table.getn(MTH_Map.focusNodes) > 0 then return MTH_Map.focusNodes end return {} end, }) self:RegisterProvider("beasts", { name = "Beasts", buildNodes = function() local nodes = {} if not MTH_DS_Beasts then return nodes end for beastId, beast in pairs(MTH_DS_Beasts) do local coords = beast and MTH_Map_GetCoordsForUnit(beastId, beast.coords) if beast and coords and table.getn(coords) > 0 then local displayName = (MTH and MTH.GetLocalizedBeastName and MTH:GetLocalizedBeastName(beastId, beast.name)) or beast.name or "Unknown" local cr, cg, cb = MTH_Map_GetFamilyColor(beast.family) for i = 1, table.getn(coords) do local c = coords[i] if c and c[1] and c[2] and c[3] then local details = string.format("Family: %s\nLevel: %s\nAbilities: %s", beast.family or "?", beast.lvl or "?", beast.abilities or "None") MTH_Map_AddNode(nodes, c[3], c[1], c[2], string.format("%s (%d)", displayName, beastId), details, cr, cg, cb, { kind = "beast", beastId = beastId }) end end end end return nodes end, }) self:RegisterProvider("vendors", { name = "Vendors", buildNodes = function() local nodes = {} if not MTH_DS_Vendors then return nodes end for vendorId, vendor in pairs(MTH_DS_Vendors) do local coords = vendor and MTH_Map_GetCoordsForUnit(vendorId, vendor.coords) if vendor and coords and table.getn(coords) > 0 then local displayName = (MTH and MTH.GetLocalizedVendorName and MTH:GetLocalizedVendorName(vendorId, vendor.name)) or vendor.name or "Unknown" for i = 1, table.getn(coords) do local c = coords[i] if c and c[1] and c[2] and c[3] then local details = string.format("Faction: %s\nLevel: %s", vendor.fac or "?", vendor.lvl or "?") MTH_Map_AddNode(nodes, c[3], c[1], c[2], string.format("%s (%d)", displayName, vendorId), details, 0.20, 0.90, 1.00, { kind = "vendor", vendorId = vendorId }) end end end end return nodes end, }) self:RegisterProvider("drops", { name = "Drops", buildNodes = function() local nodes = {} local items = MTH_DS_Items or MTH_DS_AmmoItems if not items then return nodes end local unresolvedCount = 0 local unresolvedSamples = {} for itemId, item in pairs(items) do if item and item.drops then local itemDefaultName = item.name or "Unknown" local itemDisplayName = (MTH and MTH.GetLocalizedItemName) and MTH:GetLocalizedItemName(itemId, itemDefaultName) or itemDefaultName for creatureId, chance in pairs(item.drops) do local creature = MTH_DS_Beasts and MTH_DS_Beasts[creatureId] local coords = MTH_Map_GetCoordsForUnit(creatureId, creature and creature.coords) local creatureName = nil if MTH_DS_CreatureCoords and MTH_DS_CreatureCoords[creatureId] and table.getn(MTH_DS_CreatureCoords[creatureId]) > 0 then creatureName = (MTH_DS_CreatureNames and MTH_DS_CreatureNames[creatureId]) or ((MTH and MTH.GetLocalizedBeastName and creature and MTH:GetLocalizedBeastName(creatureId, creature.name)) or (creature and creature.name)) elseif creature and creature.coords and table.getn(creature.coords) > 0 then creatureName = (MTH and MTH.GetLocalizedBeastName and MTH:GetLocalizedBeastName(creatureId, creature.name)) or creature.name end if coords and table.getn(coords) > 0 then for i = 1, table.getn(coords) do local c = coords[i] if c and c[1] and c[2] and c[3] then local pct = tonumber(chance) local chanceText = pct and string.format("%.2f%%", pct) or tostring(chance or "?") local details = string.format( "Item: %s (%d)\nDrop Creature: %s (%d)\nDrop Chance: %s", itemDisplayName or "Unknown", tonumber(itemId) or 0, creatureName or "Unknown", tonumber(creatureId) or 0, chanceText ) MTH_Map_AddNode( nodes, c[3], c[1], c[2], string.format("%s ← %s", itemDisplayName or "Unknown Item", creatureName or "Unknown Creature"), details, 1.00, 0.35, 0.35 ) end end else unresolvedCount = unresolvedCount + 1 if table.getn(unresolvedSamples) < 5 then table.insert(unresolvedSamples, tostring(creatureId)) end end end end end if unresolvedCount > 0 then end return nodes end, }) end function MTH_Map:Init() if self.controller then return end self:BuildZoneLookup() self:RegisterDefaultProviders() self.nodesByZone = self.nodesByZone or {} if not self.controller then self.controller = CreateFrame("Frame", "MTH_MapController", UIParent) self.controller:RegisterEvent("PLAYER_ENTERING_WORLD") self.controller:RegisterEvent("ZONE_CHANGED") self.controller:RegisterEvent("ZONE_CHANGED_NEW_AREA") self.controller:RegisterEvent("MINIMAP_ZONE_CHANGED") self.controller:RegisterEvent("WORLD_MAP_UPDATE") self.controller:SetScript("OnEvent", function(frame, eventName) eventName = eventName or event if eventName == "WORLD_MAP_UPDATE" and MTH_Map._syncingMapContext then return end if eventName == "PLAYER_ENTERING_WORLD" or eventName == "ZONE_CHANGED" or eventName == "ZONE_CHANGED_NEW_AREA" or eventName == "MINIMAP_ZONE_CHANGED" then if SetMapToCurrentZone and (not WorldMapFrame or not WorldMapFrame:IsShown()) then SetMapToCurrentZone() end end MTH_Map:UpdateWorldMap() MTH_Map:UpdateMinimap() end) self.controller:SetScript("OnUpdate", function(frame) frame = frame or this if not frame then return end local now = GetTime() or 0 if (frame._mthTick or 0) > now then return end if MTH_Map.pendingZoneOpenId and MTH_Map.pendingZoneOpenAttempts and MTH_Map.pendingZoneOpenAttempts > 0 then if (MTH_Map.pendingZoneOpenNextAttemptAt or 0) <= now then MTH_Map.pendingZoneOpenNextAttemptAt = now + 0.25 if WorldMapFrame and WorldMapFrame:IsShown() then if pfMap and pfMap.SetMapByID then pfMap:SetMapByID(MTH_Map.pendingZoneOpenPfqId or MTH_Map.pendingZoneOpenId) end MTH_Map_ApplyWorldMapZone(MTH_Map.pendingZoneOpenId or MTH_Map.pendingZoneOpenPfqId, MTH_Map.pendingZoneOpenId or MTH_Map.pendingZoneOpenPfqId) local nowMapId = MTH_Map:GetCurrentMapID() if tonumber(nowMapId) == tonumber(MTH_Map.pendingZoneOpenId) then MTH_Map_ClearPendingZoneOpen() MTH_Map:UpdateWorldMap() else MTH_Map.pendingZoneOpenAttempts = MTH_Map.pendingZoneOpenAttempts - 1 end else MTH_Map.pendingZoneOpenAttempts = MTH_Map.pendingZoneOpenAttempts - 1 end end if MTH_Map.pendingZoneOpenAttempts <= 0 then MTH_Map_ClearPendingZoneOpen() end end if MTH_Map.enabled and MTH_Map.showMinimap then local mapId = MTH_Map:GetCurrentMinimapMapID() if not mapId and (frame._mthMapRecoverAt or 0) <= now then frame._mthMapRecoverAt = now + 2.0 if SetMapToCurrentZone and (not WorldMapFrame or not WorldMapFrame:IsShown()) then SetMapToCurrentZone() mapId = MTH_Map:GetCurrentMinimapMapID() end end local nodes = mapId and MTH_Map.minimapNodesByZone and MTH_Map.minimapNodesByZone[mapId] or nil local hasNodes = nodes and table.getn(nodes) > 0 frame._mthTick = now + (hasNodes and (tonumber(MTH_Map.minimapTickActive) or 0.20) or (tonumber(MTH_Map.minimapTickIdle) or 0.75)) if hasNodes then MTH_Map:UpdateMinimap() elseif (frame._mthNoNodeTick or 0) <= now then frame._mthNoNodeTick = now + 1.0 MTH_Map:UpdateMinimap() end else frame._mthTick = now + (tonumber(MTH_Map.minimapTickIdle) or 0.75) end end) end end