-- MapCanvas.lua — the Era map-canvas framework, bolted onto 1.12's own WorldMapFrame. -- -- WHY THIS FILE EXISTS, AND WHY IT IS NOT A MIGRATION -- -- 1.12's world map is a fixed 1002x668 frame showing one zone at a time: twelve detail tiles, a -- hit-testing button over them, POI buttons placed in its coordinates, and two dropdowns. It has -- no canvas, no pan, no zoom, no pins and no notion of where one map sits on another. Every map -- addon written after 4.0 — a seamless world map, Mapster, TomTom's pin layer, Questie's map — -- drives the *canvas* framework instead: a scaled, pannable child frame, pins acquired from pools -- at named frame levels, and data providers notified when the map, the view or the scale changes. -- There is no stock 1.12 file to migrate from, because 1.12 has no such thing. This is the same -- footing as `CompatTemplates.xml`: a FrameXML-era name addons inherit and reach for, which this -- client has to provide or the addon simply does not run. -- -- THE ONE CONSTRAINT THAT SHAPED EVERY DECISION HERE -- -- `WorldMapFrame` already has eight consumers on this install (Atlas-CFM, FlightMap, -- LevelRange-Turtle, pfQuest, pfUI, pfUI-locplus, SoftSpoken, SoftSpoken_Places), and all eight -- are 1.12 addons: they anchor their icons to `WorldMapDetailFrame` and `WorldMapButton` and read -- the `(continent, zone)` selection. Replacing the window would break all eight. So nothing here -- replaces anything: -- -- * The canvas is ADDED to `WorldMapFrame`, as `ScrollContainer` and `ScrollContainer.Child`. -- * `WorldMapDetailFrame` is REPARENTED INTO the canvas and `SetAllPoints`ed to it. At rest the -- canvas is exactly the rect the detail frame had, at scale 1 with no pan, so the stock map is -- pixel-identical. The payoff is that the 1.12 art and every POI anchored to it now pan and -- zoom WITH the canvas for free — a 1.12 quest icon stays on its quest while a canvas addon -- moves the view, which no amount of per-addon work could achieve. -- * `SetMapID` moves the 1.12 selection too (`BenillaSelectMapID`), so the stock window and the -- canvas can never show different places. -- -- WHAT GIVES US CLIPPING -- -- Era's `ScrollContainer` is a plain Frame with `SetClipsChildren(true)`, which this client has -- no verb for. Ours is a real `ScrollFrame` with the canvas as its scroll child, because this -- client clips a ScrollFrame's scroll child and every descendant of it to the ScrollFrame's rect -- — in the draw pass and in the pointer hit-test, from one shared source (`script/clip.rs`), so -- drawing and hitting agree. The pan is the scroll offset, which is what a ScrollFrame's offsets -- already mean; the zoom is `Child:SetScale`. -- -- DIALECT -- -- 1.12 Lua 5.0: `table.getn` rather than `#`, `arg`/`unpack(arg)` rather than `...` as an -- expression, `string.format` reachable as `format`. Written the way the rest of this chain is. -- The world sheet, Era's own id for Azeroth, which `C_Map.GetFallbackWorldMapID` answers. local WORLD_MAP_ID = C_Map.GetFallbackWorldMapID() -- Zoom behaviour constants Era publishes and addons read off the container. MAP_CANVAS_MOUSE_WHEEL_ZOOM_BEHAVIOR_FULL = 1 MAP_CANVAS_MOUSE_WHEEL_ZOOM_BEHAVIOR_SMOOTH = 2 -- How close to the target the smooth pan/zoom has to be before it snaps and stops. Era's own -- values; small enough that the snap is invisible and large enough that the OnUpdate stops. local PAN_SNAP = 0.0005 local SCALE_SNAP = 0.001 -- How fast the view approaches its target, per second, as a fraction closed. local PAN_RATE = 10 local SCALE_RATE = 10 -- A drag under this many pixels is still a click, which is what `WouldCursorPositionBeClick` -- answers; Era's own threshold. local CLICK_SLOP = 5 -------------------------------------------------------------------------------- -- PinFrameLevelsManager -- -- Pins draw in named bands rather than at raw frame levels, so an addon can insert its own band -- relative to Blizzard's by name and get a range of levels inside it. Era's structure exactly, -- because the names are the contract: `InsertFrameLevelAbove("PIN_FRAME_LEVEL_OPENMAP_STITCH", -- "PIN_FRAME_LEVEL_MAP_EXPLORATION", n)` has to mean what it means upstream or the art lands in -- the wrong band. -------------------------------------------------------------------------------- PinFrameLevelsManagerMixin = {} function PinFrameLevelsManagerMixin:Initialize() self.frameLevelRanges = {} self.ordered = {} self.currentFrameLevels = {} end -- The base level every band is offset from: above the canvas itself, so a pin never hides behind -- the art it sits on. local PIN_FRAME_LEVEL_BASE = 100 function PinFrameLevelsManagerMixin:Reset() self.currentFrameLevels = {} for i = 1, table.getn(self.ordered) do self.currentFrameLevels[self.ordered[i]] = 0 end end -- Append a band with `count` levels in it; the first call for a name wins, as upstream's does. function PinFrameLevelsManagerMixin:InsertFrameLevel(frameLevelType, count) if self.frameLevelRanges[frameLevelType] then return false end local base = PIN_FRAME_LEVEL_BASE local n = table.getn(self.ordered) if n > 0 then local last = self.frameLevelRanges[self.ordered[n]] base = last.base + last.count end self.frameLevelRanges[frameLevelType] = { base = base, count = count or 1 } self.ordered[n + 1] = frameLevelType self.currentFrameLevels[frameLevelType] = 0 return true end -- Insert a band immediately above a named one, shifting every band above it up. False when the -- reference band does not exist, which is the answer a caller branches on: OpenMapClassic prints -- "Blizzard's map layers aren't the expected ones" and carries on. function PinFrameLevelsManagerMixin:InsertFrameLevelAbove(frameLevelType, aboveType, count) if self.frameLevelRanges[frameLevelType] or not self.frameLevelRanges[aboveType] then return false end count = count or 1 local at for i = 1, table.getn(self.ordered) do if self.ordered[i] == aboveType then at = i break end end if not at then return false end table.insert(self.ordered, at + 1, frameLevelType) -- Re-base every band from the bottom, so the ranges stay contiguous and ordered. local base = PIN_FRAME_LEVEL_BASE for i = 1, table.getn(self.ordered) do local name = self.ordered[i] local range = self.frameLevelRanges[name] if not range then range = { count = count } self.frameLevelRanges[name] = range self.currentFrameLevels[name] = 0 end range.base = base base = base + range.count end return true end function PinFrameLevelsManagerMixin:InsertFrameLevelBelow(frameLevelType, belowType, count) if not self.frameLevelRanges[belowType] then return false end -- Below `belowType` is above whatever is under it; with nothing under it, the bottom. local at for i = 1, table.getn(self.ordered) do if self.ordered[i] == belowType then at = i break end end if not at then return false end if at == 1 then if self.frameLevelRanges[frameLevelType] then return false end table.insert(self.ordered, 1, frameLevelType) self.frameLevelRanges[frameLevelType] = { base = 0, count = count or 1 } self.currentFrameLevels[frameLevelType] = 0 local base = PIN_FRAME_LEVEL_BASE for i = 1, table.getn(self.ordered) do local range = self.frameLevelRanges[self.ordered[i]] range.base = base base = base + range.count end return true end return self:InsertFrameLevelAbove(frameLevelType, self.ordered[at - 1], count) end -- The next free level inside a band, which is what a pin asks for as it is acquired. A band that -- runs out keeps handing back its top level rather than spilling into the next band's. function PinFrameLevelsManagerMixin:GetValidFrameLevel(frameLevelType) local range = self.frameLevelRanges[frameLevelType] if not range then return PIN_FRAME_LEVEL_BASE end local used = self.currentFrameLevels[frameLevelType] or 0 if used < range.count then self.currentFrameLevels[frameLevelType] = used + 1 else used = range.count - 1 end return range.base + used end function PinFrameLevelsManagerMixin:GetFrameLevelRange(frameLevelType) local range = self.frameLevelRanges[frameLevelType] if not range then return nil end return range.base, range.base + range.count - 1 end local function CreatePinFrameLevelsManager() local manager = {} for k, v in pairs(PinFrameLevelsManagerMixin) do manager[k] = v end manager:Initialize() return manager end -------------------------------------------------------------------------------- -- MapCanvasDataProviderMixin — the base every data provider is built from. -- -- Every member is a no-op, deliberately: upstream's is too, and that is the whole contract. A -- provider overrides the two or three hooks it cares about and inherits silence for the rest, so -- adding a hook here can never break an existing provider. -------------------------------------------------------------------------------- MapCanvasDataProviderMixin = {} function MapCanvasDataProviderMixin:OnAdded(mapCanvas) self.owningMap = mapCanvas end function MapCanvasDataProviderMixin:OnRemoved(mapCanvas) self.owningMap = nil end function MapCanvasDataProviderMixin:GetMap() return self.owningMap end function MapCanvasDataProviderMixin:RemoveAllData() end function MapCanvasDataProviderMixin:RefreshAllData(fromOnShow) end function MapCanvasDataProviderMixin:OnShow() end function MapCanvasDataProviderMixin:OnHide() end function MapCanvasDataProviderMixin:OnEvent(event, arg1, arg2, arg3) end function MapCanvasDataProviderMixin:OnMapChanged() end function MapCanvasDataProviderMixin:OnMapInsetSizeChanged(insetIndex, expanded) end function MapCanvasDataProviderMixin:OnCanvasScaleChanged() end function MapCanvasDataProviderMixin:OnCanvasPanChanged() end function MapCanvasDataProviderMixin:OnCanvasSizeChanged() end function MapCanvasDataProviderMixin:OnGlobalAlphaChanged() end -------------------------------------------------------------------------------- -- MapCanvasPinMixin — the base every pin is built from. -- -- A pin is a frame on the canvas placed by a normalized position rather than by anchors, so it -- stays on its place as the canvas pans and scales. `ApplyCurrentPosition` is the one that does -- the work and is called on every pan and scale change. -------------------------------------------------------------------------------- MapCanvasPinMixin = {} function MapCanvasPinMixin:OnLoad() self.numActiveAnimations = 0 self.scaleFactor = 1 self.startScale = 1 self.endScale = 1 self.normalizedX = 0.5 self.normalizedY = 0.5 end function MapCanvasPinMixin:GetMap() return self.owningMap end function MapCanvasPinMixin:OnAcquired() end function MapCanvasPinMixin:OnReleased() end function MapCanvasPinMixin:OnClick(button) end function MapCanvasPinMixin:OnMouseEnter() end function MapCanvasPinMixin:OnMouseLeave() end function MapCanvasPinMixin:OnMapInsetSizeChanged(insetIndex, expanded) end function MapCanvasPinMixin:OnCanvasScaleChanged() self:ApplyCurrentScale() end function MapCanvasPinMixin:OnCanvasPanChanged() end function MapCanvasPinMixin:OnCanvasSizeChanged() end function MapCanvasPinMixin:SetPosition(normalizedX, normalizedY, insetIndex) self.normalizedX = normalizedX self.normalizedY = normalizedY self.insetIndex = insetIndex self:ApplyCurrentPosition() end function MapCanvasPinMixin:GetPosition() return self.normalizedX, self.normalizedY end function MapCanvasPinMixin:ApplyCurrentPosition() local map = self:GetMap() if not map then return end local canvas = map:GetCanvas() if not canvas then return end self:ClearAllPoints() self:SetPoint("CENTER", canvas, "TOPLEFT", (self.normalizedX or 0.5) * canvas:GetWidth(), -(self.normalizedY or 0.5) * canvas:GetHeight()) end -- A pin's own scale, held constant on screen as the canvas scales: a quest icon should stay the -- same size however far the map is zoomed out, which is `1 / canvasScale` times whatever the pin -- asked for. `scaleFactor` 0 opts out and lets the pin scale with the map, which is what an area -- outline (a "blob") wants. function MapCanvasPinMixin:SetScalingLimits(scaleFactor, startScale, endScale) self.scaleFactor = scaleFactor self.startScale = startScale self.endScale = endScale end function MapCanvasPinMixin:ApplyCurrentScale() local map = self:GetMap() if not map then return end local factor = self.scaleFactor if not factor or factor == 0 then self:SetScale(1) return end local canvasScale = map:GetCanvasScale() if not canvasScale or canvasScale <= 0 then return end local scale = factor / canvasScale local lo = self.startScale local hi = self.endScale if lo and hi and lo > hi then lo, hi = hi, lo end if lo and scale < lo then scale = lo end if hi and scale > hi then scale = hi end self:SetScale(scale) end function MapCanvasPinMixin:UseFrameLevelType(pinFrameLevelType, index) self.pinFrameLevelType = pinFrameLevelType self.pinFrameLevelIndex = index end function MapCanvasPinMixin:GetFrameLevelType() return self.pinFrameLevelType end function MapCanvasPinMixin:ApplyFrameLevel() local map = self:GetMap() if not map or not self.pinFrameLevelType then return end local manager = map:GetPinFrameLevelsManager() if manager then self:SetFrameLevel(manager:GetValidFrameLevel(self.pinFrameLevelType)) end end -- Which mouse buttons fall through to the canvas underneath. Era's pins declare this so a -- right-drag over an icon still pans the map; a pin with no declaration swallows everything, -- which is what a clickable icon wants. function MapCanvasPinMixin:SetPassThroughButtons(button1, button2, button3) self.passThroughButtons = { button1, button2, button3 } end function MapCanvasPinMixin:CheckMouseButtonPassthrough(button) local list = self.passThroughButtons if not list then return false end for i = 1, table.getn(list) do if list[i] == button then return true end end return false end -------------------------------------------------------------------------------- -- MapCanvasScrollControllerMixin — the pan and zoom. -- -- The view is held as a NORMALIZED CENTRE (`currentScrollX`, `currentScrollY`: the canvas point -- under the middle of the container, 0..1 across the canvas) plus a scale. That is upstream's own -- representation, and it has to be, because a canvas addon reads and writes those fields directly -- — OpenMapClassic's `SetView` assigns `currentScrollX`, `targetScrollX`, `currentScale`, -- `targetScale` and `zoomTarget` by hand (`Map/WorldMap.lua:184-191`) and then calls -- `SetNormalizedHorizontalScroll`. So the field names and their meanings are the contract, not an -- implementation detail. -- -- The pan is applied as the ScrollFrame's own scroll offset, which is what a ScrollFrame offset -- already means and is the cheapest write this client has (`SetVerticalScroll` re-anchors one -- node and skips the layout touch). -------------------------------------------------------------------------------- MapCanvasScrollControllerMixin = {} function MapCanvasScrollControllerMixin:OnLoad() self.currentScrollX = 0.5 self.currentScrollY = 0.5 self.targetScrollX = 0.5 self.targetScrollY = 0.5 self.currentScale = 1 self.targetScale = 1 self.zoomTarget = 1 self.baseScale = 1 self.zoomLevels = {} self.scrollXExtentsMin = 0.5 self.scrollXExtentsMax = 0.5 self.scrollYExtentsMin = 0.5 self.scrollYExtentsMax = 0.5 self.mouseButtonInfo = { LeftButton = { down = false, x = 0, y = 0 }, RightButton = { down = false, x = 0, y = 0 }, MiddleButton = { down = false, x = 0, y = 0 }, } self.scalingMode = "SCALING_MODE_TRANSLATE_FASTER_THAN_SCALE" self.mouseWheelZoomMode = MAP_CANVAS_MOUSE_WHEEL_ZOOM_BEHAVIOR_FULL self.panning = false self.viewRectDirty = true self.areaTriggersDirty = true end function MapCanvasScrollControllerMixin:GetCanvasContainer() return self end function MapCanvasScrollControllerMixin:GetCanvas() return self.Child end function MapCanvasScrollControllerMixin:GetCanvasScale() return self.currentScale or 1 end function MapCanvasScrollControllerMixin:SetScalingMode(mode) self.scalingMode = mode end function MapCanvasScrollControllerMixin:SetMouseWheelZoomMode(mode) self.mouseWheelZoomMode = mode end function MapCanvasScrollControllerMixin:MarkViewRectDirty() self.viewRectDirty = true end function MapCanvasScrollControllerMixin:MarkAreaTriggersDirty() self.areaTriggersDirty = true end -- Canvas px to a fraction of the canvas, the two helpers every extents computation goes through. function MapCanvasScrollControllerMixin:NormalizeHorizontalSize(size) local w = self.Child and self.Child:GetWidth() if not w or w <= 0 then return 0 end return size / w end function MapCanvasScrollControllerMixin:NormalizeVerticalSize(size) local h = self.Child and self.Child:GetHeight() if not h or h <= 0 then return 0 end return size / h end function MapCanvasScrollControllerMixin:DenormalizeHorizontalSize(size) local w = self.Child and self.Child:GetWidth() return size * (w or 0) end function MapCanvasScrollControllerMixin:DenormalizeVerticalSize(size) local h = self.Child and self.Child:GetHeight() return size * (h or 0) end -- The normalized centre may range over this, so the view stays inside the canvas. When the canvas -- is smaller than the container on an axis the range collapses to its middle, which centres it. function MapCanvasScrollControllerMixin:CalculateScrollExtentsAtScale(scale) local halfW = self:NormalizeHorizontalSize(self:GetWidth() * 0.5 / scale) local halfH = self:NormalizeVerticalSize(self:GetHeight() * 0.5 / scale) local minX, maxX = halfW, 1 - halfW local minY, maxY = halfH, 1 - halfH if minX > maxX then minX, maxX = 0.5, 0.5 end if minY > maxY then minY, maxY = 0.5, 0.5 end return minX, maxX, minY, maxY end -- Through `CalculateScrollExtentsAtScale`, never duplicating its arithmetic: a canvas addon -- replaces that one method to widen the pan (OpenMapClassic pans the whole world, not just the -- map in view), and a second copy of the law here would ignore the replacement. function MapCanvasScrollControllerMixin:CalculateScrollExtents() self.scrollXExtentsMin, self.scrollXExtentsMax, self.scrollYExtentsMin, self.scrollYExtentsMax = self:CalculateScrollExtentsAtScale(self:GetCanvasScale()) end local function clamp(v, lo, hi) if lo and v < lo then return lo end if hi and v > hi then return hi end return v end function MapCanvasScrollControllerMixin:ClampScrollToExtents(x, y) return clamp(x, self.scrollXExtentsMin, self.scrollXExtentsMax), clamp(y, self.scrollYExtentsMin, self.scrollYExtentsMax) end function MapCanvasScrollControllerMixin:SetNormalizedHorizontalScroll(x) self.currentScrollX = x self:ApplyCurrentScroll() self:MarkViewRectDirty() end function MapCanvasScrollControllerMixin:SetNormalizedVerticalScroll(y) self.currentScrollY = y self:ApplyCurrentScroll() self:MarkViewRectDirty() end function MapCanvasScrollControllerMixin:GetCurrentScrollX() return self.currentScrollX end function MapCanvasScrollControllerMixin:GetCurrentScrollY() return self.currentScrollY end function MapCanvasScrollControllerMixin:GetNormalizedHorizontalScroll() return self.currentScrollX end function MapCanvasScrollControllerMixin:GetNormalizedVerticalScroll() return self.currentScrollY end -- The whole view at once, no easing: the canvas scaled, the extents recomputed at that scale, and -- the centre clamped into them. function MapCanvasScrollControllerMixin:InstantPanAndZoom(scale, x, y) self.currentScale = scale self.targetScale = scale self.zoomTarget = scale if self.Child then self.Child:SetScale(scale) end self:CalculateScrollExtents() x, y = self:ClampScrollToExtents(x, y) self.currentScrollX, self.targetScrollX = x, x self.currentScrollY, self.targetScrollY = y, y self:ApplyCurrentScroll() self:MarkViewRectDirty() self:MarkAreaTriggersDirty() local map = self:GetMapCanvas() if map then map:OnCanvasScaleChanged() map:OnCanvasPanChanged() end end function MapCanvasScrollControllerMixin:GetMapCanvas() return self.mapCanvas end -- The zoom steps, derived from the map's own art layers: one step per layer, at that layer's -- minimum scale, plus a fitted step so the whole map is reachable. A canvas addon replaces this -- wholesale (OpenMapClassic builds a geometric ladder from its own zoom option), which is why it -- is one method and why `ResetZoom` reads `zoomLevels` rather than recomputing. function MapCanvasScrollControllerMixin:CreateZoomLevels() local mapID = self.mapID local levels = {} if mapID then local layers = C_Map.GetMapArtLayers(mapID) local layer = layers and layers[1] if layer then -- A geometric ladder from the layer's own bounds. Scale 1 is the whole map in the -- container by construction — the canvas is sized to the layer and the container is the -- rect that layer used to fill — so a `minScale` below 1 would open the map zoomed -- *out* of its own window, which is how this was first wrong. local lo = layer.minScale or 1 local hi = layer.maxScale or 1 if lo > 1 then lo = 1 end if hi < lo then hi = lo end local steps = (layer.additionalZoomSteps or 0) + 1 if steps < 1 then steps = 1 end local ratio = 1 if steps > 1 and lo > 0 then ratio = math.pow(hi / lo, 1 / (steps - 1)) end local scale = lo for i = 1, steps do levels[i] = { scale = scale, layerIndex = 1 } scale = scale * ratio end end end if table.getn(levels) == 0 then levels[1] = { scale = 1, layerIndex = 1 } end self.zoomLevels = levels self.baseScale = levels[1].scale -- The scale itself is only seeded when there is none: `CreateZoomLevels` is called again on -- every map change and on every container resize, and a view the player has zoomed must not -- snap back because the window was dragged. if not self.currentScale then self.currentScale = levels[1].scale self.targetScale = levels[1].scale self.zoomTarget = levels[1].scale end end function MapCanvasScrollControllerMixin:GetScaleForMaxZoom() local n = table.getn(self.zoomLevels) if n == 0 then return 1 end return self.zoomLevels[n].scale end function MapCanvasScrollControllerMixin:GetScaleForMinZoom() if table.getn(self.zoomLevels) == 0 then return 1 end return self.zoomLevels[1].scale end function MapCanvasScrollControllerMixin:IsAtMaxZoom() return self:GetCanvasScale() >= self:GetScaleForMaxZoom() end function MapCanvasScrollControllerMixin:IsAtMinZoom() return self:GetCanvasScale() <= self:GetScaleForMinZoom() end function MapCanvasScrollControllerMixin:IsZoomingIn() return (self.targetScale or 1) > (self.currentScale or 1) end function MapCanvasScrollControllerMixin:IsZoomingOut() return (self.targetScale or 1) < (self.currentScale or 1) end function MapCanvasScrollControllerMixin:IsZooming() return self:IsZoomingIn() or self:IsZoomingOut() end function MapCanvasScrollControllerMixin:IsGamepadZooming() return self.gamepadZoom and self.gamepadZoom ~= 0 end function MapCanvasScrollControllerMixin:HasGamepadScrollSpeed() return self.gamepadZoomSpeed ~= nil end -- The next step up or down the ladder, toward a point if one is given. function MapCanvasScrollControllerMixin:SetZoomTarget(scale, x, y) self.zoomTarget = scale self.targetScale = scale if x and y then self.targetScrollX, self.targetScrollY = x, y end end function MapCanvasScrollControllerMixin:ZoomIn() local n = table.getn(self.zoomLevels) local current = self:GetCanvasScale() for i = 1, n do if self.zoomLevels[i].scale > current + 0.0001 then self:SetZoomTarget(self.zoomLevels[i].scale) return end end end function MapCanvasScrollControllerMixin:ZoomOut() local current = self:GetCanvasScale() for i = table.getn(self.zoomLevels), 1, -1 do if self.zoomLevels[i].scale < current - 0.0001 then self:SetZoomTarget(self.zoomLevels[i].scale) return end end end function MapCanvasScrollControllerMixin:ResetZoom() local scale = self:GetScaleForMinZoom() self:InstantPanAndZoom(scale, 0.5, 0.5) end -- Called when the container or the canvas changes size, and on the first show: the canvas is -- sized from the map's own art layer, the zoom ladder rebuilt and the view reset. function MapCanvasScrollControllerMixin:OnCanvasSizeChanged() local child = self.Child if not child then return end local mapID = self.mapID local w, h if mapID then local layers = C_Map.GetMapArtLayers(mapID) local layer = layers and layers[1] if layer then w, h = layer.layerWidth, layer.layerHeight end end -- With no art layer (an instance map, a map the realm has no folder for) the canvas keeps the -- container's own size, so pins still place and the view is simply not zoomable. if not w or w <= 0 or not h or h <= 0 then w, h = self:GetWidth(), self:GetHeight() end child:SetWidth(w) child:SetHeight(h) self:CreateZoomLevels() self:CalculateScrollExtents() self.currentScrollX, self.currentScrollY = self:ClampScrollToExtents( self.currentScrollX or 0.5, self.currentScrollY or 0.5) self.targetScrollX, self.targetScrollY = self.currentScrollX, self.currentScrollY child:SetScale(self:GetCanvasScale()) self:ApplyCurrentScroll() self:MarkViewRectDirty() local map = self:GetMapCanvas() if map then map:OnCanvasSizeChanged() end end -- The cursor in canvas-normalized coordinates, which is what a click handler is handed. function MapCanvasScrollControllerMixin:GetCursorPosition() local x, y = GetCursorPosition() local effective = self:GetEffectiveScale() if not effective or effective == 0 then return 0.5, 0.5 end return x / effective, y / effective end function MapCanvasScrollControllerMixin:GetNormalizedCursorPosition() local cx, cy = self:GetCursorPosition() local left, bottom = self:GetLeft(), self:GetBottom() local w, h = self:GetWidth(), self:GetHeight() if not left or not w or w <= 0 or h <= 0 then return 0.5, 0.5 end -- Inside the container first, then through the view onto the canvas: the container's centre is -- the normalized scroll, and one container width spans `containerW / (canvasW * scale)` of it. local fx = (cx - left) / w local fy = 1 - (cy - bottom) / h local spanX = self:NormalizeHorizontalSize(w / self:GetCanvasScale()) local spanY = self:NormalizeVerticalSize(h / self:GetCanvasScale()) return (self.currentScrollX or 0.5) + (fx - 0.5) * spanX, (self.currentScrollY or 0.5) + (fy - 0.5) * spanY end function MapCanvasScrollControllerMixin:WouldCursorPositionBeClick(button, x, y) local info = self.mouseButtonInfo and self.mouseButtonInfo[button] if not info or not info.down then return false end local dx = (x or 0) - (info.x or 0) local dy = (y or 0) - (info.y or 0) return dx * dx + dy * dy <= CLICK_SLOP * CLICK_SLOP end function MapCanvasScrollControllerMixin:OnMouseDown(button) local info = self.mouseButtonInfo and self.mouseButtonInfo[button] if not info then return end local x, y = self:GetCursorPosition() info.down = true info.x, info.y = x, y if button == "LeftButton" then self.panning = true self.panStartX, self.panStartY = x, y self.panStartScrollX = self.currentScrollX self.panStartScrollY = self.currentScrollY end end function MapCanvasScrollControllerMixin:OnMouseUp(button) local info = self.mouseButtonInfo and self.mouseButtonInfo[button] if not info then return end local x, y = self:GetCursorPosition() local wasClick = self:WouldCursorPositionBeClick(button, x, y) info.down = false if button == "LeftButton" then self.panning = false end -- Recorded, not just returned: the stock `WorldMapButton`'s own mouse-up is the 1.12 -- click-to-zoom, and it must not run when a canvas handler took the click or when the gesture -- was a drag. The wrapper in `BuildCanvas` reads this. self.lastClickConsumed = true local map = self:GetMapCanvas() if wasClick and map then local x, y = self:GetNormalizedCursorPosition() self.lastClickConsumed = map:ProcessCanvasClickHandlers(button, x, y) and true or false end end function MapCanvasScrollControllerMixin:OnMouseWheel(delta) if delta > 0 then self:ZoomIn() else self:ZoomOut() end end -- The drag, and the ease toward the pan and zoom targets. One place, so a drag during a zoom -- keeps working, and it stops writing once both have arrived. function MapCanvasScrollControllerMixin:OnUpdate(elapsed) local moved = false if self.panning then local x, y = self:GetCursorPosition() local scale = self:GetCanvasScale() if scale > 0 then local dx = self:NormalizeHorizontalSize((self.panStartX - x) / scale) local dy = self:NormalizeVerticalSize((y - self.panStartY) / scale) local nx, ny = self:ClampScrollToExtents( (self.panStartScrollX or 0.5) + dx, (self.panStartScrollY or 0.5) + dy) if nx ~= self.currentScrollX or ny ~= self.currentScrollY then self.currentScrollX, self.currentScrollY = nx, ny self.targetScrollX, self.targetScrollY = nx, ny moved = true end end else local rate = 1 - math.pow(0.5, (elapsed or 0) * PAN_RATE / 7) local tx, ty = self.targetScrollX or 0.5, self.targetScrollY or 0.5 local cx, cy = self.currentScrollX or 0.5, self.currentScrollY or 0.5 if math.abs(tx - cx) > PAN_SNAP or math.abs(ty - cy) > PAN_SNAP then self.currentScrollX = cx + (tx - cx) * rate self.currentScrollY = cy + (ty - cy) * rate moved = true elseif cx ~= tx or cy ~= ty then self.currentScrollX, self.currentScrollY = tx, ty moved = true end end local scaled = false local ts, cs = self.targetScale or 1, self.currentScale or 1 if math.abs(ts - cs) > SCALE_SNAP then local rate = 1 - math.pow(0.5, (elapsed or 0) * SCALE_RATE / 7) self.currentScale = cs + (ts - cs) * rate scaled = true elseif cs ~= ts then self.currentScale = ts scaled = true end if scaled then if self.Child then self.Child:SetScale(self.currentScale) end self:CalculateScrollExtents() self.currentScrollX, self.currentScrollY = self:ClampScrollToExtents( self.currentScrollX, self.currentScrollY) moved = true end if moved then self:ApplyCurrentScroll() self:MarkViewRectDirty() end local map = self:GetMapCanvas() if map then if scaled then map:OnCanvasScaleChanged() end if moved then map:OnCanvasPanChanged() end end end -------------------------------------------------------------------------------- -- MapCanvasMixin — the map itself. -------------------------------------------------------------------------------- MapCanvasMixin = {} -- What a global pin mouse-action handler is told happened. The values are opaque to a caller, -- which compares against these names (`MapCanvasMixin.MouseAction.Click`), so only the identity -- matters. MapCanvasMixin.MouseAction = { Click = 1, Up = 2, Down = 3 } function MapCanvasMixin:OnLoad() -- A SET, keyed by the provider with `true` as the value, because that is upstream's shape and -- callers iterate it: `for provider in pairs(map.dataProviders) do` binds the *key*, so an -- array here hands the loop the number 1 and the next line indexes a number. Measured: it -- raised at `Map/Extras.lua:281`, inside the addon's own `OnEnable`, which caught it, printed -- it and left the whole addon disabled — the same trap `pools.rs` names for `activeObjects`, -- where a "tidier" array would break `for icon in pool:EnumerateActive()` silently. -- -- `dataProviderOrder` is ours, beside it: `pairs` order is undefined, and a map whose -- providers are notified in a different order each session is a bug nobody can reproduce. So -- the set is the shape addons read and the list is the order we walk. self.dataProviders = {} self.dataProviderOrder = {} self.dataProviderEventsCount = {} self.pinPools = {} self.pinTemplateTypes = {} self.activePins = {} self.canvasClickHandlers = {} self.globalPinMouseActionHandlers = {} self.cursorHandlers = {} self.pinFrameLevelsManager = CreatePinFrameLevelsManager() self.mapID = nil self.globalPinScale = 1 end function MapCanvasMixin:GetMapID() return self.mapID end function MapCanvasMixin:GetCanvas() return self.ScrollContainer and self.ScrollContainer.Child end function MapCanvasMixin:GetCanvasContainer() return self.ScrollContainer end function MapCanvasMixin:GetCanvasScale() return self.ScrollContainer and self.ScrollContainer:GetCanvasScale() or 1 end function MapCanvasMixin:GetGlobalPinScale() return self.globalPinScale or 1 end function MapCanvasMixin:SetGlobalPinScale(scale) self.globalPinScale = scale self:OnCanvasScaleChanged() end function MapCanvasMixin:GetPinFrameLevelsManager() return self.pinFrameLevelsManager end -- The view in canvas-normalized coordinates: left, right, top, bottom. function MapCanvasMixin:GetViewRect() local sc = self.ScrollContainer if not sc then return 0, 1, 0, 1 end local scale = sc:GetCanvasScale() if scale <= 0 then return 0, 1, 0, 1 end local halfW = sc:NormalizeHorizontalSize(sc:GetWidth() * 0.5 / scale) local halfH = sc:NormalizeVerticalSize(sc:GetHeight() * 0.5 / scale) local x, y = sc.currentScrollX or 0.5, sc.currentScrollY or 0.5 return x - halfW, x + halfW, y - halfH, y + halfH end function MapCanvasMixin:NormalizeHorizontalSize(size) return self.ScrollContainer and self.ScrollContainer:NormalizeHorizontalSize(size) or 0 end function MapCanvasMixin:NormalizeVerticalSize(size) return self.ScrollContainer and self.ScrollContainer:NormalizeVerticalSize(size) or 0 end function MapCanvasMixin:SetPinNudgingDirty(dirty) self.pinNudgingDirty = dirty end -------------------------------------------------------------------------------- -- Data providers -------------------------------------------------------------------------------- function MapCanvasMixin:AddDataProvider(dataProvider) if not dataProvider or self.dataProviders[dataProvider] then return end self.dataProviders[dataProvider] = true self.dataProviderOrder[table.getn(self.dataProviderOrder) + 1] = dataProvider if dataProvider.OnAdded then dataProvider:OnAdded(self) end if self:IsShown() and dataProvider.RefreshAllData then dataProvider:RefreshAllData(false) end end function MapCanvasMixin:RemoveDataProvider(dataProvider) if not dataProvider or not self.dataProviders[dataProvider] then return end self.dataProviders[dataProvider] = nil for i = 1, table.getn(self.dataProviderOrder) do if self.dataProviderOrder[i] == dataProvider then table.remove(self.dataProviderOrder, i) break end end if dataProvider.RemoveAllData then dataProvider:RemoveAllData() end if dataProvider.OnRemoved then dataProvider:OnRemoved(self) end end function MapCanvasMixin:RemoveAllDataProviders() while table.getn(self.dataProviderOrder) > 0 do self:RemoveDataProvider(self.dataProviderOrder[1]) end end -- One walk, one guard: a provider that raises must not stop the providers after it, because a -- third-party provider is ordinary here and a half-notified map draws a half-built view. function MapCanvasMixin:CallDataProviders(method, a, b, c) local providers = self.dataProviderOrder for i = 1, table.getn(providers) do local provider = providers[i] local fn = provider[method] if fn then local ok, err = pcall(fn, provider, a, b, c) if not ok then geterrorhandler()(err) end end end end function MapCanvasMixin:RefreshAllDataProviders(fromOnShow) self:CallDataProviders("RefreshAllData", fromOnShow) end function MapCanvasMixin:OnMapChanged() self:CallDataProviders("OnMapChanged") end function MapCanvasMixin:OnCanvasScaleChanged() local pins = self.activePins for i = 1, table.getn(pins) do local pin = pins[i] if pin.OnCanvasScaleChanged then pin:OnCanvasScaleChanged() end end self:CallDataProviders("OnCanvasScaleChanged") end function MapCanvasMixin:OnCanvasPanChanged() local pins = self.activePins for i = 1, table.getn(pins) do local pin = pins[i] if pin.OnCanvasPanChanged then pin:OnCanvasPanChanged() end end self:CallDataProviders("OnCanvasPanChanged") end function MapCanvasMixin:OnCanvasSizeChanged() local pins = self.activePins for i = 1, table.getn(pins) do local pin = pins[i] if pin.ApplyCurrentPosition then pin:ApplyCurrentPosition() end end self:CallDataProviders("OnCanvasSizeChanged") end -------------------------------------------------------------------------------- -- Pins -------------------------------------------------------------------------------- function MapCanvasMixin:GetPinPool(pinTemplate) local pool = self.pinPools[pinTemplate] if not pool then local frameType = self.pinTemplateTypes[pinTemplate] or "Frame" pool = CreateFramePool(frameType, self:GetCanvas(), pinTemplate, function(_, pin) pin:Hide() pin:ClearAllPoints() if pin.OnReleased then pin:OnReleased() end end) self.pinPools[pinTemplate] = pool end return pool end -- Declare the frame type a template needs, for the templates that are not plain Frames. Era's own -- `SetPinTemplateType`; without it a Button-shaped pin would be created as a Frame and its -- `SetScript("OnClick")` would be nil. function MapCanvasMixin:SetPinTemplateType(pinTemplate, pinFrameType) self.pinTemplateTypes[pinTemplate] = pinFrameType end -- Declared vararg so `arg` is this function's own table: `LUA_COMPAT_VARARG` hands it over to a -- vararg function that does not otherwise mention `...` (`script/lua50.rs`), and without the `...` -- `arg` would resolve to the *global* one a script handler leaves behind. function MapCanvasMixin:AcquirePin(pinTemplate, ...) local extra = arg local pool = self:GetPinPool(pinTemplate) local pin, isNew = pool:Acquire() pin.pinTemplate = pinTemplate pin.owningMap = self if isNew then for k, v in pairs(MapCanvasPinMixin) do if pin[k] == nil then pin[k] = v end end if pin.OnLoad then pin:OnLoad() end end self.activePins[table.getn(self.activePins) + 1] = pin pin:Show() pin:ApplyFrameLevel() if pin.OnAcquired then if extra and table.getn(extra) > 0 then pin:OnAcquired(unpack(extra)) else pin:OnAcquired() end end pin:ApplyCurrentScale() return pin end function MapCanvasMixin:RemovePin(pin) for i = 1, table.getn(self.activePins) do if self.activePins[i] == pin then table.remove(self.activePins, i) break end end local pool = pin.pinTemplate and self.pinPools[pin.pinTemplate] if pool then pool:Release(pin) else pin:Hide() end end function MapCanvasMixin:RemoveAllPinsByTemplate(pinTemplate) local i = 1 while i <= table.getn(self.activePins) do local pin = self.activePins[i] if pin.pinTemplate == pinTemplate then table.remove(self.activePins, i) local pool = self.pinPools[pinTemplate] if pool then pool:Release(pin) else pin:Hide() end else i = i + 1 end end end function MapCanvasMixin:RemoveAllPins() while table.getn(self.activePins) > 0 do self:RemovePin(self.activePins[1]) end end -- An iterator, because that is what every caller writes: `for pin in -- map:EnumeratePinsByTemplate(t) do`. A template with no pins answers an iterator that stops -- immediately, never nil, so the loop is always safe. function MapCanvasMixin:EnumeratePinsByTemplate(pinTemplate) local pins = self.activePins local i = 0 return function() while true do i = i + 1 local pin = pins[i] if not pin then return nil end if pin.pinTemplate == pinTemplate then return pin end end end end function MapCanvasMixin:EnumerateAllPins() local pins = self.activePins local i = 0 return function() i = i + 1 return pins[i] end end function MapCanvasMixin:GetNumActivePinsByTemplate(pinTemplate) local n = 0 for i = 1, table.getn(self.activePins) do if self.activePins[i].pinTemplate == pinTemplate then n = n + 1 end end return n end function MapCanvasMixin:GetHoveredPin() for i = 1, table.getn(self.activePins) do local pin = self.activePins[i] if pin:IsShown() and pin:IsMouseOver() then return pin end end return nil end -------------------------------------------------------------------------------- -- Handlers: canvas clicks, global pin mouse actions, the cursor -- -- Each is a priority-sorted list, highest first, and the first handler that returns true consumes -- the event. That ordering is the contract — a canvas addon registers at priority 1000 precisely -- so it sees a click before Blizzard's own handlers. -------------------------------------------------------------------------------- local function InsertByPriority(list, handler, priority) local entry = { handler = handler, priority = priority or 0 } local n = table.getn(list) local at = n + 1 for i = 1, n do if (list[i].priority or 0) < entry.priority then at = i break end end table.insert(list, at, entry) end function MapCanvasMixin:AddCanvasClickHandler(handler, priority) InsertByPriority(self.canvasClickHandlers, handler, priority) end function MapCanvasMixin:AddGlobalPinMouseActionHandler(handler, priority) InsertByPriority(self.globalPinMouseActionHandlers, handler, priority) end function MapCanvasMixin:AddCursorHandler(handler, priority) InsertByPriority(self.cursorHandlers, handler, priority) end function MapCanvasMixin:ProcessCanvasClickHandlers(button, cursorX, cursorY) local list = self.canvasClickHandlers for i = 1, table.getn(list) do local ok, consumed = pcall(list[i].handler, self, button, cursorX, cursorY) if not ok then geterrorhandler()(consumed) elseif consumed then return true end end return false end function MapCanvasMixin:ProcessGlobalPinMouseActionHandlers(mouseAction, button) local list = self.globalPinMouseActionHandlers for i = 1, table.getn(list) do local ok, consumed = pcall(list[i].handler, self, mouseAction, button) if not ok then geterrorhandler()(consumed) elseif consumed then return true end end return false end function MapCanvasMixin:ProcessCursorHandlers() local list = self.cursorHandlers for i = 1, table.getn(list) do local ok, cursor = pcall(list[i].handler, self) if not ok then geterrorhandler()(cursor) elseif cursor then return cursor end end return nil end -------------------------------------------------------------------------------- -- Navigation -------------------------------------------------------------------------------- function MapCanvasMixin:NavigateToMap(mapID) self:SetMapID(mapID) end function MapCanvasMixin:NavigateToParentMap() local info = self.mapID and C_Map.GetMapInfo(self.mapID) if info and info.parentMapID and info.parentMapID ~= 0 then self:SetMapID(info.parentMapID) return true end return false end function MapCanvasMixin:NavigateToCursor() local sc = self.ScrollContainer if not sc or not self.mapID then return false end local x, y = sc:GetNormalizedCursorPosition() local info = C_Map.GetMapInfoAtPosition(self.mapID, x, y) if info and info.mapID ~= self.mapID then self:SetMapID(info.mapID) return true end return false end -------------------------------------------------------------------------------- -- The map change -------------------------------------------------------------------------------- function MapCanvasMixin:SetMapID(mapID) if not mapID or self.mapID == mapID then return end self.mapID = mapID if self.ScrollContainer then self.ScrollContainer.mapID = mapID end -- The stock window follows, so the 1.12 detail art under this canvas and every 1.12 addon's -- icons on it are the same place the canvas shows. The engine's own selection is the authority -- for both; see `BenillaSelectMapID`. BenillaSelectMapID(mapID) self:RefreshDetailLayers() if self.ScrollContainer then self.ScrollContainer:OnCanvasSizeChanged() self.ScrollContainer:ResetZoom() end self:OnMapChanged() self:RefreshAllDataProviders(false) end -- Follow the stock window when something else moved it (its dropdowns, `SetMapToCurrentZone`, -- another addon's `SetMapZoom`), without calling back into it. function MapCanvasMixin:SyncFromSelection() local mapID = BenillaMapIDForSelection() if not mapID or mapID == self.mapID then return end self.mapID = mapID if self.ScrollContainer then self.ScrollContainer.mapID = mapID end self:RefreshDetailLayers() if self.ScrollContainer then self.ScrollContainer:OnCanvasSizeChanged() self.ScrollContainer:ResetZoom() end self:OnMapChanged() self:RefreshAllDataProviders(false) end -------------------------------------------------------------------------------- -- The detail layers -- -- Era's canvas builds its base art from a pool of layer frames. Ours has exactly one layer and it -- is 1.12's own `WorldMapDetailFrame`, reparented into the canvas — so the base art is whatever -- the stock window draws, from the same selection, and there is no second copy of the tiling law -- to disagree with it. The pool exists because a canvas addon enumerates it to hide the base art -- while it draws its own (`for layer in map.detailLayerPool:EnumerateActive() do`), and that is -- the only thing anyone does with it. -------------------------------------------------------------------------------- local DetailLayerPoolMixin = {} function DetailLayerPoolMixin:EnumerateActive() local layers = self.layers local i = 0 return function() i = i + 1 return layers[i] end end function DetailLayerPoolMixin:GetNumActive() return table.getn(self.layers) end function MapCanvasMixin:RefreshDetailLayers() -- `WorldMapFrame_Update` is the stock repaint, driven by `WORLD_MAP_UPDATE`; nothing to do -- here but let a hook see the call, which is what a canvas addon hooks it for. end -------------------------------------------------------------------------------- -- MapUtil -------------------------------------------------------------------------------- MapUtil = {} function MapUtil.GetDisplayableMapForPlayer() local mapID = C_Map.GetBestMapForUnit("player") if mapID then return mapID end return C_Map.GetFallbackWorldMapID() end -- The name of the smallest thing under the cursor: a sub-area's if its highlight claims the -- point, else the child map's, else nothing. Over `C_Map`, so the name is the one the stock -- window's own hover would show for that point. function MapUtil.FindBestAreaNameAtMouse(mapID, x, y) if not mapID or not x or not y then return nil end local info = C_Map.GetMapInfoAtPosition(mapID, x, y) if info and info.mapID ~= mapID then return info.name end return nil end function MapUtil.ShouldMapTypeShowQuests() return true end function MapUtil.IsMapTypeAllowed(mapType) return mapType == Enum.UIMapType.Continent or mapType == Enum.UIMapType.Zone end -------------------------------------------------------------------------------- -- Bolting the canvas onto 1.12's WorldMapFrame -------------------------------------------------------------------------------- local function Mix(target, mixin) for k, v in pairs(mixin) do target[k] = v end end local function BuildDefaultFrameLevels(manager) -- Blizzard's own band names and order, bottom to top. The names are the contract: a canvas -- addon inserts its own band relative to one of these by name, so a missing or renamed band is -- art landing in the wrong place. manager:InsertFrameLevel("PIN_FRAME_LEVEL_TERRAIN", 4) manager:InsertFrameLevel("PIN_FRAME_LEVEL_MAP_EXPLORATION", 4) manager:InsertFrameLevel("PIN_FRAME_LEVEL_MAP_HIGHLIGHT", 4) manager:InsertFrameLevel("PIN_FRAME_LEVEL_AREA_POI", 8) manager:InsertFrameLevel("PIN_FRAME_LEVEL_FLIGHT_POINT", 8) manager:InsertFrameLevel("PIN_FRAME_LEVEL_WORLD_QUEST", 16) manager:InsertFrameLevel("PIN_FRAME_LEVEL_STORY_LINE", 8) manager:InsertFrameLevel("PIN_FRAME_LEVEL_GROUP_MEMBER", 8) manager:InsertFrameLevel("PIN_FRAME_LEVEL_CORPSE", 4) manager:InsertFrameLevel("PIN_FRAME_LEVEL_UNIT", 8) manager:InsertFrameLevel("PIN_FRAME_LEVEL_MAP_LINK", 4) manager:InsertFrameLevel("PIN_FRAME_LEVEL_DEATH_RELEASE", 4) end -- The pan mechanism: the canvas's own TOPLEFT against the container's, which is Blizzard's own -- way and the one whose signs this file controls. A ScrollFrame's scroll offsets would do it with -- no layout touch, but their sign convention is the engine's rather than this file's, and a pan -- already dirties the layout of everything on the canvas. function MapCanvasScrollControllerMixin:ApplyCurrentScroll() local child = self.Child if not child then return end local scale = self:GetCanvasScale() local cw = child:GetWidth() * scale local ch = child:GetHeight() * scale -- The canvas point under the container's middle is `currentScroll`; place the canvas so that -- point lands there. Divided by the scale because the offsets are in the canvas's own units, -- which the scale multiplies. local offsetX = ((self.currentScrollX or 0.5) * cw - self:GetWidth() * 0.5) / scale local offsetY = ((self.currentScrollY or 0.5) * ch - self:GetHeight() * 0.5) / scale child:ClearAllPoints() child:SetPoint("TOPLEFT", self, "TOPLEFT", -offsetX, offsetY) end local function BuildCanvas() local map = WorldMapFrame local detail = WorldMapDetailFrame local button = WorldMapButton if not map or not detail or not button or map.ScrollContainer then return end Mix(map, MapCanvasMixin) map:OnLoad() -- The container takes the detail frame's own rect, so at rest the canvas is exactly where the -- stock art was and the window is pixel-identical. A real ScrollFrame, for the clipping its -- scroll child and every descendant of it get (see this file's header). local sc = CreateFrame("ScrollFrame", "WorldMapScrollContainer", map) sc:SetWidth(detail:GetWidth()) sc:SetHeight(detail:GetHeight()) -- The detail frame's **own** XML anchor, copied rather than referenced: once it is reparented -- into the canvas below, anchoring the container to it would be a cycle — container to detail -- to canvas to container — and a cycle resolves to nothing, which showed up as -- `WorldMapButton:GetCenter()` answering nil inside the stock hover pass. The measured offsets -- against `WorldMapFrame` are the fallback for a chain whose map window is laid out some other -- way. local guide = WorldMapPositioningGuide if guide then sc:SetPoint("TOPLEFT", guide, "TOP", -502, -69) else local l, t = detail:GetLeft(), detail:GetTop() local ml, mt = map:GetLeft(), map:GetTop() if l and t and ml and mt then sc:SetPoint("TOPLEFT", map, "TOPLEFT", l - ml, t - mt) else sc:SetPoint("TOPLEFT", map, "TOPLEFT", 0, 0) end end -- No mouse of its own: the map's mouse surface is 1.12's `WorldMapButton`, which covers the -- same rect and is above this in the tree, so a mouse-enabled container here would never see -- a press anyway. The hooks below drive the controller from that button instead. sc:EnableMouse(false) local canvas = CreateFrame("Frame", "WorldMapCanvas", sc) canvas:SetWidth(detail:GetWidth()) canvas:SetHeight(detail:GetHeight()) sc:SetScrollChild(canvas) Mix(sc, MapCanvasScrollControllerMixin) sc.Child = canvas sc.mapCanvas = map sc:OnLoad() map.ScrollContainer = sc map.Canvas = canvas -- 1.12's art and its mouse surface become the canvas's content: the twelve detail tiles, the -- exploration overlays, the hover highlight, the POI buttons, the player arrow and the party -- and raid pins — and with them every 1.12 addon's icons, which are all anchored to or -- parented into these two frames. They now pan, scale and clip with the canvas, which is the -- whole reason the canvas is built around them instead of beside them: a 1.12 quest icon stays -- on its quest while a canvas addon moves the view. detail:SetParent(canvas) detail:ClearAllPoints() detail:SetAllPoints(canvas) button:SetParent(canvas) -- The base-art layer a canvas addon hides while it draws its own. It is a proxy rather than the -- detail frame itself, and that distinction is the point: `Hide()` on the frame would take the -- POI buttons and every addon's icons down with it, because they are its children and its -- siblings' children. Switching off its BACKGROUND and ARTWORK **draw layers** removes exactly -- the stock tiles, the exploration overlays and the hover highlight — regions, all three — -- and leaves every child frame drawing. local baseLayer = { SetShown = function(self, shown) if shown then detail:EnableDrawLayer("BACKGROUND") detail:EnableDrawLayer("ARTWORK") else detail:DisableDrawLayer("BACKGROUND") detail:DisableDrawLayer("ARTWORK") end self.shown = shown and true or false end, Show = function(self) self:SetShown(true) end, Hide = function(self) self:SetShown(false) end, IsShown = function(self) return self.shown ~= false end, SetAlpha = function() end, GetAlpha = function() return 1 end, } map.detailLayerPool = { layers = { baseLayer } } Mix(map.detailLayerPool, DetailLayerPoolMixin) BuildDefaultFrameLevels(map.pinFrameLevelsManager) -- The controller's input, off 1.12's own map button. -- -- Its `` *is* the 1.12 click-to-zoom (`WorldMapButton_OnClick`), so that one is -- replaced rather than hooked: a canvas click handler that consumes the click must stop the -- stock zoom, and a drag that ended over the map is not a click at all. Both of those are -- decisions the controller has already made by the time the wrapper reads -- `lastClickConsumed`, and neither can be expressed by a hook, which cannot refuse. local stockMouseUp = button:GetScript("OnMouseUp") button:SetScript("OnMouseUp", function() sc:OnMouseUp(arg1) if not sc.lastClickConsumed and stockMouseUp then stockMouseUp() end end) button:SetScript("OnMouseDown", function() sc:OnMouseDown(arg1) end) button:SetScript("OnMouseWheel", function() sc:OnMouseWheel(arg1) end) button:HookScript("OnUpdate", function() sc:OnUpdate(arg1) end) button:EnableMouseWheel(true) button:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp") -- Seat the first map before anything reads `GetMapID()`: the stock window has already resolved -- the player's zone by the time this runs, so the canvas starts where the window is. map.mapID = BenillaMapIDForSelection() or WORLD_MAP_ID sc.mapID = map.mapID sc:OnCanvasSizeChanged() map:HookScript("OnShow", function() WorldMapFrame:SyncFromSelection() WorldMapFrame:RefreshAllDataProviders(true) WorldMapFrame:CallDataProviders("OnShow") end) map:HookScript("OnHide", function() WorldMapFrame:CallDataProviders("OnHide") end) end local loader = CreateFrame("Frame", "BenillaMapCanvasLoader") loader:RegisterEvent("PLAYER_LOGIN") loader:RegisterEvent("WORLD_MAP_UPDATE") loader:SetScript("OnEvent", function() if event == "PLAYER_LOGIN" then BuildCanvas() elseif event == "WORLD_MAP_UPDATE" and WorldMapFrame and WorldMapFrame.SyncFromSelection then -- Something moved the stock window: its dropdowns, `SetMapToCurrentZone`, another addon. -- The canvas follows without calling back, so the two cannot fight over the selection. WorldMapFrame:SyncFromSelection() end end)