-- 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: the ease closes -- `1 - 0.5^(elapsed * RATE / 7)` of the remaining gap, so the half-life is `7 / RATE` seconds. -- -- The scale's was 10 — a 0.7 s half-life, still visibly moving two seconds after the last wheel -- notch. That was tolerable only while a notch could not accumulate, because the target was never -- more than one rung away; once notches add up the glide is the thing you feel. grom, 2026-11-04: -- *"scroll is better but feels pretty slippery, like scrolls past where I stop scrolling."* At 24 -- the half-life is 0.29 s and a multi-rung spin lands about where the wheel stopped. -- -- 0.29 s was still the wrong answer. grom, 2026-11-05: *"its the maps motion that is laggy."* -- That is the same constant read from the other side — at 60 fps a 0.29 s half-life closes 90% of -- a notch in 58 frames, so the map keeps drifting for a second after the wheel stops, and the two -- reports are "it overshoots" and "it lags behind" about one glide. Guessing a third number would -- be the third guess, so it is a SETTING now, and these two are the ends of its range rather than -- the behaviour: `mapMotionSmoothing` is a percentage of [`SMOOTH_MAX_HALFLIFE`], and 0 is no ease -- at all. -- -- The top of the slider is the 0.29 s half-life he called laggy, because that is the slowest -- anyone could want and there is no reason to offer slower. The default is a quarter of it: -- 0.075 s, which closes 90% of a wheel notch in 0.25 s and the last thousandth in 0.69 s. -- Measured over a full rung (scale 1 -> 1.6) at 60 fps, frames to settle within 0.001: -- 0% = 1, 10% = 17, 20% = 34, 50% = 84, 100% = 167 (the old constant's own 167). local SMOOTH_MAX_HALFLIFE = 0.30 -- The default percentage, and the fallback when the CVar is missing (a bare VM, a test). local SMOOTH_DEFAULT = 25 -- `nil` means no ease: snap to the target this frame. `RATE` is the file's own relation, -- `7 / halflife`, so the arithmetic below is unchanged. local function SmoothRate() local pct = tonumber(GetCVar and GetCVar("mapMotionSmoothing") or nil) or SMOOTH_DEFAULT if pct <= 0 then return nil end if pct > 100 then pct = 100 end return 7 / (pct / 100 * SMOOTH_MAX_HALFLIFE) end -- A drag under this many pixels is still a click, which is what `WouldCursorPositionBeClick` -- answers; Era's own threshold. local CLICK_SLOP = 5 -- The stratum `WorldMapFrame.xml` declares for itself, re-asserted on every show; see -- `SeatLevels`. local MAP_STRATA = "FULLSCREEN" -------------------------------------------------------------------------------- -- 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 -- The ladder's two ends, or nil before there is a ladder. function MapCanvasScrollControllerMixin:GetZoomBounds() local levels = self.zoomLevels local n = levels and table.getn(levels) or 0 if n == 0 then return nil end return levels[1].scale, levels[n].scale end -- Pull every scale the controller holds onto the ladder it currently has. -- -- `CreateZoomLevels` is the documented extension point and a canvas addon replaces it wholesale, -- so the ladder can change out from under a scale that was already set -- and `OnLoad` sets one, -- 1, before any addon has run. OpenMapClassic's ladder is 27 steps from 0.0125 to 0.899, its -- whole range *below* this file's own 1, because its stitched world is reached by zooming out -- past a single zone; measured with it loaded, the canvas sat at 1.0, above its own ceiling, and -- `ZoomIn`/`ZoomOut` walk the ladder **by value** -- they look for the first step above or below -- the current scale -- so a scale outside it cannot be scrolled back onto at either end. -- -- Here rather than in `CreateZoomLevels`, because the addon's replacement is the one that will -- not have it. function MapCanvasScrollControllerMixin:ClampZoomToLevels() local floor, ceil = self:GetZoomBounds() if not floor then return end local function clamp(v) if not v then return v elseif v < floor then return floor elseif v > ceil then return ceil end return v end self.currentScale = clamp(self.currentScale) self.targetScale = clamp(self.targetScale) self.zoomTarget = clamp(self.zoomTarget) end function MapCanvasScrollControllerMixin:GetCanvasScale() self:ClampZoomToLevels() 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 -- Pulled onto the ladder BEFORE the child is scaled, which the ease path below already does -- by construction (it applies `self.currentScale`) and this one did not. The child's scale is -- what the player SEES; `GetCanvasScale` -- which clamps, in `ClampZoomToLevels` -- is what -- every scroll extent, pan clamp and pin placement is computed FROM. Setting the raw argument -- let the two disagree: past the ladder's ceiling the art drew at one scale while the whole -- framework believed another, so the extents were computed for the wrong canvas size and the -- view would not pan where it should. scale = self:GetCanvasScale() 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, floored at the scale that fits the -- whole map in the container. That used to be the constant 1 and the comment here -- said why: the canvas is sized to the layer and the container *is* the rect that -- layer filled, so 1 was the whole map by construction and a `minScale` below 1 would -- open the map zoomed out of its own window. The container is the window's content -- area now (`SeatContainer`), which is 1002x668 only when the window is 1.12's own -- size, so the fit has to be measured. It still comes out exactly 1 when it is. local fit = self:GetFitScale() local lo = layer.minScale or 1 local hi = layer.maxScale or 1 if lo > fit then lo = fit 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 -- No art layer to build a ladder from: one step, at the scale that fits the canvas in -- the window. 1 while the two are the same size, which is 1.12's own map. levels[1] = { scale = self:GetFitScale(), layerIndex = 1 } end self.zoomLevels = levels self.baseScale = levels[1].scale -- A window that grew raises the floor, so a view below it is pulled back on; see -- `ClampZoomToLevels`, which is where the rule lives because a canvas addon's own -- `CreateZoomLevels` will not have it. self:ClampZoomToLevels() -- 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 -- The scale at which the whole canvas child fits inside the container, which is the zoom ladder's -- floor. 1 whenever the container is the art's own size, which is 1.12's own map. function MapCanvasScrollControllerMixin:GetFitScale() local child = self.Child if not child then return 1 end self:GetLeft() local cw, ch = child:GetWidth(), child:GetHeight() local w, h = self:GetWidth(), self:GetHeight() if not cw or cw <= 0 or not ch or ch <= 0 or not w or w <= 0 or not h or h <= 0 then return 1 end local fit = math.min(w / cw, h / ch) if fit <= 0 then return 1 end return fit 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 -- Which rung a wheel notch steps FROM: where the view is heading, not where the ease has got -- to. `currentScale` eases toward the target (`SmoothRate`), so a second notch -- arriving a frame after the first re-reads a scale that has barely moved, finds the same rung -- under it, and re-sets the target it already has. Measured at the player's own window size -- with his addons loaded: four wheel-down notches over the map moved the ladder ONE step -- (1.0000 -> 0.9030) and two more moved it none. Stepping from `targetScale` makes notches -- accumulate, which is both how it feels in Era and the only way either end of a 27-step -- ladder is reachable at all -- OpenMapClassic's runs 0.033 to 2.373, and its bottom rung is -- the whole stitched world. function MapCanvasScrollControllerMixin:GetZoomStepScale() -- Through `GetCanvasScale`, because it clamps every held scale onto the current ladder as a -- side effect, and a canvas addon can have replaced the ladder since the last notch. local live = self:GetCanvasScale() return self.targetScale or live end function MapCanvasScrollControllerMixin:ZoomIn() local n = table.getn(self.zoomLevels) local current = self:GetZoomStepScale() 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:GetZoomStepScale() 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 -------------------------------------------------------------------------------- -- 1002x668 is a contract, not a size -- -- 1.12's `WorldMapDetailFrame` is 1002x668 and every single thing drawn into it is placed in -- absolute pixels against that number: the twelve 256x256 tiles, each exploration overlay at the -- offset `WorldMapOverlay.dbc` gives it, the hover highlight -- and, far more importantly, -- **every addon's map art**, because that frame is where a 1.12 map addon puts its own layers. -- pfUI's reveal is the one that reached grom (`pfUI/modules/mapreveal.lua:157`): one -- `SetPoint("TOPLEFT", "WorldMapDetailFrame", "TOPLEFT", tile.offsetX, -tile.offsetY)` per tile, -- raw, like everyone else's. -- -- So the frame's **own size must stay 1002x668**. Any rescaling this file does to compensate for -- a different size can only ever reach the children it knows the names of, and the children it -- does not know the names of are the whole point of the exercise. -- -- It did not stay 1002x668, and the reason is a second layout authority. 1.12's windowed map -- shrinks its art by putting `WORLDMAP_WINDOWED_SCALE` (0.7) on `WorldMapDetailFrame` *and* -- `WorldMapButton` (`WorldMapFrame.lua:205-208`), because there the window is a fixed sheet and -- scaling the two frames is the only way to make the art smaller inside it. Under the canvas the -- container already carries the window's size, so that 0.7 is counted twice -- and since the -- frame is `SetAllPoints` to the canvas, the half that is not wanted comes back as **unit size**: -- 1002 / 0.7 = 1431. Measured in grom's own session log, 1431x954 at scale 0.70 inside a 1002x668 -- canvas at 0.70, the same screen rect described in two different numbers of units. -- -- Everything absolute inside it then lands at 0.7 of where it belongs, measured off his -- screenshot before any code was read: pfUI draws "Coldridge Valley" at (360,525) while the base -- art draws it at (460,700), and both axes solve to the same origin (127,117) -- the frame's own -- top-left -- at k = 1002/1431 = 0.700. -- -- `WorldMapButton` is the same bug wearing a different coat. It keeps its XML 1002x668 and is -- anchored to the frame's TOPLEFT, so with 0.7 on it and 1431 units of art beside it, **the map's -- mouse surface covered 70% of the map**: click-to-zoom, the hover highlight and every projection -- through `WorldMapButton_OnClick`'s own width were all reading a rect the art had outgrown. -- -- `SeatOnCanvas` is the fix and it is one idea: the canvas is the only layout authority here, so -- both frames go to scale 1, the frame fills the canvas and the button covers the frame. The -- screen rect does not move (1431 x 0.49 and 1002 x 0.70 are the same pixels), so nothing about -- the picture changes except that the units inside it are 1.12's again. -- -- `ScaleDetailArt` stays for the one case that cannot be fixed this way: a map with no art layer, -- where the canvas falls back to the container's rect and the frame genuinely is not 1002x668. It -- is a no-op whenever the contract holds, which is every map that has art. -------------------------------------------------------------------------------- local DETAIL_BASE_W, DETAIL_BASE_H = 1002, 668 local DETAIL_TILE, DETAIL_TILES = 256, 12 -- Base geometry per overlay texture, as `WorldMapFrame_Update` wrote it: [texture] = {x,y,w,h}. local overlayBase = {} -- Scale the stock map art to whatever size the detail frame currently has. `w`,`h` are a -- fallback only, for the one caller that has just set a size the layout cannot answer yet. local function ScaleDetailArt(w, h) local d = WorldMapDetailFrame if not d then return end -- The detail frame's OWN size is the authority, because the tiles are its children. With -- `SeatOnCanvas` holding the contract that size reads 1002x668 and the whole function is a -- no-op; it earns its keep only on a map with no art layer, where the canvas takes the -- container's rect and the frame really is some other size. -- -- `GetLeft` first: the size is derived, and without forcing the layout to settle a read taken -- straight after a resize answers the size from before it. The caller's `w`,`h` remain the -- fallback for the one case that cannot be measured at all. d:GetLeft() local dw, dh = d:GetWidth(), d:GetHeight() if dw and dh and dw > 0 and dh > 0 then w, h = dw, dh end if not w or not h or w <= 0 or h <= 0 then return end local sx, sy = w / DETAIL_BASE_W, h / DETAIL_BASE_H for i = 1, DETAIL_TILES do local tile = _G["WorldMapDetailTile" .. i] if tile then tile:SetWidth(DETAIL_TILE * sx) tile:SetHeight(DETAIL_TILE * sy) end end for tex, base in pairs(overlayBase) do tex:SetWidth(base.w * sx) tex:SetHeight(base.h * sy) tex:ClearAllPoints() tex:SetPoint("TOPLEFT", WorldMapDetailFrame, "TOPLEFT", base.x * sx, base.y * sy) end end -- After the stock repaint: every overlay it just placed is at its 1002x668 offset and size, which -- is the only moment those base values are readable. Captured, then scaled. local function CaptureOverlays() for tex in pairs(overlayBase) do overlayBase[tex] = nil end local n = NUM_WORLDMAP_OVERLAYS or 0 for i = 1, n do local tex = _G["WorldMapOverlay" .. i] if tex and tex:IsShown() then local _, _, _, x, y = tex:GetPoint() if x then overlayBase[tex] = { x = x, y = y, w = tex:GetWidth(), h = tex:GetHeight() } end end end ScaleDetailArt() end -- The window's content area, the rect 1.12 gives its map art, in both of the two layouts the -- stock map has. Taken from `WorldMapFrame_Minimize`/`_Maximize` themselves, as insets rather -- than as the one size each of them happens to produce: -- -- windowed `WorldMapDetailFrame:SetPoint("TOPLEFT", 15, -33)` at scale 0.7 inside a 720x521 -- window — 701x468 of art, so 15 left, 33 top, 4 right, 20 bottom. -- maximized `TOPLEFT WorldMapPositioningGuide TOP -502,-69` at scale 1 — the guide is a fixed -- **1024x768** frame centred on the window, so 10 left, 69 top, 12 right, 31 bottom, -- and a maximized map that fills the screen still draws its sheet at 1024x768 with -- `BlackoutWorld` around it, which is 1.12's own full-screen map. -- -- Read as insets, the same two numbers reproduce 1.12 exactly at 1.12's sizes and do the obvious -- thing at any other: pfUI resizes the window to `WorldMapButton`'s size plus its own chrome -- (`pfUI/modules/map.lua:111`), and the art then fills that instead of sitting at 70% in the -- corner of it. Before this the container kept the maximized anchor whatever the mode, so the -- maximize button left the art a 1002x668 patch in a full-screen window and the minimize button -- could not put it back. local WINDOWED_INSET = { l = 15, t = 33, r = 4, b = 20 } local MAXIMIZED_INSET = { l = 10, t = 69, r = 12, b = 31 } -- The windowed top inset has one thing it has to clear that 33 does not cover: the continent and -- zone dropdowns, when something has made them visible again. -- -- 1.12 anchors both to `WorldMapPositioningGuide` at `TOP, -80, -35`, and `WorldMapFrame_Minimize` -- pins that guide to the window's own corners (`WorldMapFrame.lua:201-203`) -- so in windowed mode -- they sit 34 to 66 below the window's top, under art that starts at 33. The realm knows: three -- lines further on the same function sets their alpha to 0 and takes the mouse off them, because -- a 720x521 window has nowhere else to put them. -- -- pfUI puts them back. `SkinDropDown` runs over both (`pfUI/modules/map.lua:154`), and the window -- it sizes them into is `WorldMapButton` plus 15x55 of chrome -- room for a title bar, except -- that the dropdowns' own anchor wants 66 of it. So they return at full alpha into a band the art -- is using, 31 of their 32 points under `WorldMapDetailFrame`, which draws at level 3 against -- their 1. grom, 2026-11-09: *"I can't see/click the dropdowns at the top"* -- what is left of -- them is a one-point sliver, and a click on it lands on the map. -- -- That is the reference's behaviour too, art and all; what is ours is that our art fills the -- window the addon sized instead of staying a 701x468 patch in the corner of it, which is what -- makes the burial total. So: when they are visible, the art starts below them. When they are -- hidden -- which is stock, and every chain without a map addon -- the inset is 1.12's own 33 and -- the windowed map is pixel-identical to what it was. local DROPDOWN_CLEARANCE = 4 local windowedInset = { l = 15, t = 33, r = 4, b = 20 } local function WindowedInset() local map = WorldMapFrame windowedInset.l, windowedInset.r = WINDOWED_INSET.l, WINDOWED_INSET.r windowedInset.b, windowedInset.t = WINDOWED_INSET.b, WINDOWED_INSET.t if not map then return windowedInset end local ms = map:GetEffectiveScale() local top = map:GetTop() if not ms or ms <= 0 or not top then return windowedInset end top = top * ms local list = { WorldMapContinentDropDown, WorldMapZoneDropDown } local i for i = 1, 2 do local dd = list[i] if dd and dd:IsShown() and (dd:GetAlpha() or 0) > 0.01 then dd:GetLeft() local ds, bottom = dd:GetEffectiveScale(), dd:GetBottom() if ds and ds > 0 and bottom then local below = (top - bottom * ds) / ms + DROPDOWN_CLEARANCE -- A dropdown above the window's top, or one somewhere past the middle of it, is -- not a title bar and is not this rule's business. local limit = (map:GetHeight() or 0) * 0.5 if below > windowedInset.t and below < limit then windowedInset.t = below end end end end return windowedInset end -- `(host, inset)` for the current mode: the frame the content area is measured in, and how far -- inside it the art sits. local function ContentHost() if WORLDMAP_WINDOWED == 1 then return WorldMapFrame, WindowedInset() end return WorldMapPositioningGuide or WorldMapFrame, MAXIMIZED_INSET end -- Put the container on that content area. The size is written explicitly rather than left to a -- two-corner anchor, because this client fires `OnSizeChanged` only for an explicit -- `SetWidth`/`SetHeight` — and that event is what rebuilds the zoom ladder. local function SeatContainer() local sc = WorldMapFrame and WorldMapFrame.ScrollContainer local host, inset = ContentHost() if not sc or not host then return end host:GetLeft() local hw, hh = host:GetWidth(), host:GetHeight() if not hw or hw <= 0 or not hh or hh <= 0 then return end local w = hw - inset.l - inset.r local h = hh - inset.t - inset.b if w <= 0 or h <= 0 then return end sc:ClearAllPoints() sc:SetPoint("TOPLEFT", host, "TOPLEFT", inset.l, -inset.t) if math.abs((sc:GetWidth() or 0) - w) > 0.5 or math.abs((sc:GetHeight() or 0) - h) > 0.5 then sc:SetWidth(w) sc:SetHeight(h) -- Driven rather than waited for: a resize is what rebuilds the zoom ladder and the scroll -- extents, and this runs from inside `SeatOnCanvas`, which `OnCanvasSizeChanged` itself -- calls -- so leaving it to the `OnSizeChanged` script would land the rebuild after the -- ladder had already been built from the old rect. The size test above is the recursion -- guard: the re-entry finds the container already the right size and sets nothing. sc:OnCanvasSizeChanged() end end -- The two frames the canvas took over, re-seated on it: both at scale 1, the art filling the -- canvas and the mouse surface covering the art. This is what keeps `WorldMapDetailFrame` at -- 1002x668 — see "1002x668 is a contract, not a size". -- -- Called again after `WorldMapFrame_Minimize`/`WorldMapFrame_Maximize`, the only two writers of -- that scale and the only two that re-anchor the frame (`SetPoint("TOPLEFT", 15, -33)` and the -- positioning guide). Both are 1.12 telling a fixed-size window how small to draw its art; here -- the container already answers that, so what they write is re-stated rather than argued with. -- `w`,`h` are the canvas size a caller has just set and the layout cannot answer yet, as in -- `ScaleDetailArt`. local function SeatOnCanvas(w, h) local canvas = WorldMapFrame and WorldMapFrame.Canvas local detail, button = WorldMapDetailFrame, WorldMapButton if not canvas or not detail then return end SeatContainer() detail:SetScale(1) detail:ClearAllPoints() detail:SetAllPoints(canvas) if button then -- 1.12's own anchor, with the art's size written on it. The button is the map's mouse -- surface and `WorldMapButton_OnClick` divides the cursor's offset by the button's own -- width, so it has to be the art's rect exactly -- but an explicit size rather than -- `SetAllPoints`, because `WorldMapButton:GetSize()` is **read by addons**: pfUI sizes -- the whole map window from it (`pfUI/modules/map.lua:111`, `wmWidth + 15`), at -- `PLAYER_ENTERING_WORLD`, and a derived size answers 0 until the layout settles. detail:GetLeft() local bw, bh = detail:GetWidth(), detail:GetHeight() if not bw or bw <= 0 or not bh or bh <= 0 then bw, bh = w or DETAIL_BASE_W, h or DETAIL_BASE_H end button:SetScale(1) button:ClearAllPoints() button:SetPoint("TOPLEFT", detail, "TOPLEFT", 0, 0) button:SetWidth(bw) button:SetHeight(bh) end ScaleDetailArt(w, h) 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 is still -- 1.12's own art rect, which is what `WorldMapDetailFrame` and everything pinned into it are -- placed in. It used to take the container's size instead, and that was only ever safe while -- the container *was* 1002x668: now that the container is the window's content area -- (`SeatContainer`), the fallback would hand the whole 1002x668 contract over to whatever -- size the player's map window happens to be. The view still fits, because the zoom ladder is -- floored at `GetFitScale` rather than at 1. if not w or w <= 0 or not h or h <= 0 then w, h = DETAIL_BASE_W, DETAIL_BASE_H end child:SetWidth(w) child:SetHeight(h) -- The art, its mouse surface and the canvas have to agree on one rect, and this is the only -- place the rect changes (see "1002x668 is a contract, not a size"). The size just set is -- passed in, because the detail frame will not answer it until the layout pass. SeatOnCanvas(w, 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 -- One rate for both eases, read once a frame: the two are one gesture to a player, and a -- zoom that settles while the pan is still drifting reads as the pan being broken. local smooth = SmoothRate() local rate = smooth and (1 - math.pow(0.5, (elapsed or 0) * smooth / 7)) or 1 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 smooth = SmoothRate() local rate = smooth and (1 - math.pow(0.5, (elapsed or 0) * smooth / 7)) or 1 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 -- The same news, for the addons that were never written to this API. -- -- A 1.12 map addon anchors its icons to `WorldMapDetailFrame` or `WorldMapButton` and keeps them -- a constant size on screen by counter-scaling them whenever the map's scale moves. On 1.12 the -- only thing that can move it is `SetScale` on one of those frames -- Magnify, Mapster, -- `WorldMapFrame_Minimize` -- so that is what such an addon hooks, and it reads the new scale off -- `GetEffectiveScale` *inside* the hook rather than from the argument (pfQuest hooks all three of -- `WorldMapFrame`, `WorldMapDetailFrame` and `WorldMapButton`, `pfQuest/map.lua:1622-1630`, and -- recomputes from `WorldMapButton:GetEffectiveScale()`). -- -- The canvas zooms by scaling its *child*, so the effective scale those addons would read is -- already right and the hook that would make them read it never fires: measured over a glide from -- 8x to 30x with OpenMapClassic driving, `pfMap.ResizeNodes` ran **0** times, and a 16 px icon on -- `WorldMapButton` drew 25.3 px at one zoom and 53.1 px at another. That is grom's "can only see -- the pfui pins at certain distances" -- icons sized for whichever zoom they were last built at. -- With the announcement the same glide runs it 24 times, once per easing frame, which is what a -- 1.12 smooth-zoom addon already costs it. -- -- So announce it the only way 1.12 can hear: `SetScale` with the frame's *own* scale. It is a -- notification and nothing else -- the value is unchanged, so nothing moves, nothing is scaled -- twice, and an addon that ignores `SetScale` is exactly as it was. local function AnnounceScaleToTwelve() for _, f in ipairs({ WorldMapDetailFrame, WorldMapButton }) do if f and f.GetScale and f.SetScale then local scale = f:GetScale() if scale and scale > 0 then f:SetScale(scale) end end end 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") AnnounceScaleToTwelve() 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 -- The container takes the mouse as well, UNDER 1.12's `WorldMapButton`. -- -- The button was the whole mouse surface here, on the reasoning that it covers the same rect -- and sits above this in the tree. That holds at rest and stops holding the moment a canvas -- addon scales the canvas: the button is pinned to the canvas child, so at a scale below 1 it -- shrinks away from the container and leaves the rest of the visible map with no mouse -- surface at all. A press there reaches the next frame that takes the mouse, which with pfUI -- loaded is `WorldMapFrame` itself -- `modules/map.lua:45` makes the whole window a -- `RegisterForDrag("LeftButton")` handle -- so dragging the map moved the window instead of -- panning, and whether it did depended on the zoom and where you grabbed. grom, 2026-11-04: -- *"sometimes its panning the map as it should and sometimes its grabbing and moving the -- whole window, this should only happen on the borders."* -- -- The button still wins wherever it reaches, being deeper; this only catches what used to -- fall through the floor, and it drives the same controller, so a pan begun on either is one -- gesture. It takes no clicks (`RegisterForClicks` is never called), so it cannot swallow a -- click the map's own surface would have had. sc:EnableMouse(true) -- ...and the container crops everything below it as well, which the ScrollFrame alone does -- not: that crops the scroll child's subtree, and an addon is free to parent a layer to the -- CONTAINER beside the canvas. Era's own container is a plain Frame with nothing but this, so -- that is where such a layer expects to be cropped -- OpenMapClassic's pin layer sits exactly -- there and asks for `SetClipsChildren` itself, behind an `if` that was false on this client -- until the verb existed. Both mechanisms now hold, and they intersect. sc:SetClipsChildren(true) 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) button:SetParent(canvas) -- ...and the canvas becomes their only layout authority, which is a change of owner and not -- just of parent: see "1002x668 is a contract, not a size". SeatOnCanvas() if WorldMapFrame_Minimize then hooksecurefunc("WorldMapFrame_Minimize", SeatOnCanvas) end if WorldMapFrame_Maximize then hooksecurefunc("WorldMapFrame_Maximize", SeatOnCanvas) end -- The stock repaint places the exploration overlays in 1002x668 pixels and is the only moment -- those base offsets are readable; after it, they are scaled to whatever size the canvas has -- given the detail frame (see "The stock art, at a canvas size that is not 1.12's"). if WorldMapFrame_Update then hooksecurefunc("WorldMapFrame_Update", CaptureOverlays) end CaptureOverlays() -- The container resizing IS a canvas size change, and until now nothing said so: the canvas -- was re-sized only when the map changed (`SetMapID`, `SyncFromSelection`), so a window -- resized while the map stayed put left the canvas at the old size. openmap resizes this -- container explicitly, which is the case that reached grom. -- -- Only an explicit `SetWidth`/`SetHeight` is covered, because that is all this client fires -- `OnSizeChanged` for — measured: a child `SetAllPoints` to a parent that is then resized -- gets no event at all. That is why the art is rescaled inside `OnCanvasSizeChanged` rather -- than hung off the detail frame's own resize, which would never run. sc:SetScript("OnSizeChanged", function(self) self:OnCanvasSizeChanged() end) -- The window resizing moves the content area, so the container has to follow it. pfUI's map -- module is the case that reaches a player: it sizes `WorldMapFrame` from -- `WorldMapButton:GetSize()` at `PLAYER_ENTERING_WORLD`, long after this ran. Same limit as -- the container's own hook — only an explicit `SetWidth`/`SetHeight` fires it, which is what -- `WorldMapFrame_Minimize` and pfUI both do; the maximize leg has no size of its own -- (`SetAllPoints`) and is covered by the `WorldMapFrame_Maximize` hook below. map:HookScript("OnSizeChanged", function() SeatOnCanvas() end) -- Everything the button carries is map content except one frame: `WorldMapFrameAreaFrame`, -- the hovered-zone name. 1.12's XML hangs it off the mouse surface (TOP, 400x128) because -- that is what covers the art, but it is chrome — it names what the cursor is over and sits -- at the top of the window, at the size 1.12 drew it. Its label is a **102 px THICKOUTLINE** -- FontString, so inside the canvas it scales with the zoom: at zoom 2.5 that is a 261 px -- glyph per letter, and a zone name's worth of those fills the whole glyph sheet. Measured in -- grom's 2026-11-02 run — `ui_text: a 261.10907 logical-px request ... past the 256 ceiling` -- and then 20 `glyph sheet full ... resetting at the frame boundary`, one per hover, each -- costing every string on screen a frame. So it goes back on the window, anchored to the -- container's top, which is where the art's top edge is now; at rest that is the pixel it was -- already at. local area = WorldMapFrameAreaFrame if area then area:SetParent(map) area:ClearAllPoints() area:SetPoint("TOP", sc, "TOP", 0, -10) end -- 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") -- ...and the same three off the container, for the part of the window the button has shrunk -- away from. `OnUpdate` is the button's alone: the controller eases once a frame, and two -- callers would ease it twice. -- -- The container must out-level the map window, and the button must out-level the container. -- -- The hit sweep probes one `(strata, level)` plane in **link** order, and a parent is linked -- before its children (`0x764aa0`; `TargetFrame.lua:33` relies on it), so a frame tied with -- its own parent loses every press to it. Tied with `WorldMapFrame` the container took -- nothing, which is a mouse-enabled container that still handed the drag to pfUI. -- -- Re-asserted on every show, not seated once: `WorldMapFrame` is level 0 while this builds -- and 1 once `ShowUIPanel` has raised it, so a level computed at build time ties the moment -- the window is first opened. -- ...and the window's own stratum with them, because a reparent takes it away. -- -- `WorldMapFrame.xml:5` declares `frameStrata="FULLSCREEN"`, which is what puts 1.12's map -- over every other window. The realm's own `WorldMapFrame_Minimize` -- (`WorldMapFrame.lua:183`, reached from `WorldMapFrame_OnEvent`) calls -- `WorldMapFrame:SetParent(UIParent)`, and a reparent forces the moved frame into its new -- parent's stratum (`0x76ab5a`, pinned by `script::tests::setparent`) -- so on every open the -- window fell into `UIParent`'s MEDIUM at level 0, underneath every pfUI frame, pfUI's unit -- frames being MEDIUM themselves (`pfUI/api/unitframes.lua:413`). Measured on grom's install: -- FULLSCREEN with no addons loaded, MEDIUM/0 with his. grom, 2026-11-05: *"the map needs to be -- on a higher level as I can see my unitframe over it."* -- -- Re-asserted rather than fixed in the reparent, because the reparent is right: the force is -- read off the binary, and the realm's minimize is the thing that spends it. Here it is one -- window's own declared stratum restored after the code that drops it, on the same hook that -- already re-seats the levels for the same reason. -- -- Restoring the window's stratum is not free: `SetFrameStrata` forces the whole subtree -- (`0x76a470`), and `WorldMapTooltip` is **a child of this window with a stratum of its own** -- -- `WorldMapFrame.xml:748` declares `frameStrata="TOOLTIP"`. The realm knows it: both -- `WorldMapFrame_Minimize` (`WorldMapFrame.lua:210`) and `_Maximize` (`:284`) re-assert that -- TOOLTIP on the line after their reparent. This hook runs after them, so the re-assert above -- took the tooltip back down with it -- to FULLSCREEN, level 1, under `WorldMapDetailFrame` -- at level 3. -- -- What that looks like is grom's report: a pfQuest node tooltip whose text and dark fill stop -- dead at the map art's edge while its border, on pfUI's `backdrop_border` child at level 3, -- carries on across it. Only in the windowed map, because `_Maximize` reparents to `nil` and -- leaves the stratum alone, so the guard below never fires there. local function SeatLevels() if map:GetFrameStrata() ~= MAP_STRATA then map:SetFrameStrata(MAP_STRATA) -- Put back what that just took, exactly as the realm's own two callers do. if WorldMapTooltip then WorldMapTooltip:SetFrameStrata("TOOLTIP") end end local base = map:GetFrameLevel() or 0 sc:SetFrameLevel(base + 1) if canvas:GetFrameLevel() <= sc:GetFrameLevel() then canvas:SetFrameLevel(sc:GetFrameLevel() + 1) end if button:GetFrameLevel() <= canvas:GetFrameLevel() then button:SetFrameLevel(canvas:GetFrameLevel() + 1) end end SeatLevels() map:HookScript("OnShow", SeatLevels) -- The minimize is where the stratum goes, so re-seat on it rather than waiting for the next -- show: it runs from `WorldMapFrame_OnEvent`, which fires while the window is already up. if WorldMapFrame_Minimize then hooksecurefunc("WorldMapFrame_Minimize", SeatLevels) end if WorldMapFrame_Maximize then hooksecurefunc("WorldMapFrame_Maximize", SeatLevels) end sc:SetScript("OnMouseDown", function() sc:OnMouseDown(arg1) end) sc:SetScript("OnMouseUp", function() sc:OnMouseUp(arg1) end) sc:SetScript("OnMouseWheel", function() sc:OnMouseWheel(arg1) end) sc:EnableMouseWheel(true) -- 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() -- THE PLAYER ARROW, THE ONE PIECE OF 1.12'S MAP CONTENT THAT IS NOT IN THE CANVAS -- -- `WorldMapFrame_OnLoad` builds it with `CreateWorldMapArrowFrame(WorldMapFrame)` -- (WorldMapFrame.lua:19), so its parent is the WINDOW, while everything it is anchored -- against was reparented into the canvas above. `SetPoint`'s offsets are in the positioned -- frame's OWN units, so the offsets `WorldMapButton_OnUpdate` computes -- -- `playerX * WorldMapDetailFrame:GetWidth()`, times `WorldMapDetailFrame:GetScale()`, which -- `SeatOnCanvas` pins at 1 because the zoom lives one level up on `ScrollContainer.Child` -- -- are short by exactly the canvas zoom. grom, 2026-11-05: *"my player arrow goes to the wrong -- spot at any zoom level, this needs to be accurate all of the time."* Measured on a 1600x900 -- harness with the view centred on the player: exact at zoom 1, 250.5 px right and 501.0 px -- down at zoom 2, so the error is the zoom and nothing else. -- -- Note that `WorldMapPlayer` -- the invisible mouseover rect for the same spot, set from the -- same two FrameXML lines -- has always been right, because 1.12's XML nests it under -- `WorldMapButton`, which IS in the canvas. That is what makes it the oracle the test uses -- instead of a recomputed expectation. -- -- Why not reparent the arrow as well: it is a size-less `Model` whose rect is the arrow -- file's bounding box, so inside the canvas it would grow with the zoom, and there is no -- frame scale that fixes the size without undoing the position -- both read the same -- effective scale. Outside the canvas it keeps one apparent size at every zoom, which is what -- the player pin does on every later client. -- -- The cost of staying outside is clipping: the container crops its own subtree and the arrow -- is not in it. So the bounds test below, which is a hide the stock code never needed because -- a map that cannot pan cannot put the player off-screen. local arrow for _, child in ipairs({ map:GetChildren() }) do if child.GetObjectType and child:GetObjectType() == "Model" then arrow = child break end end -- Runs every frame the map is open, so nothing in it may force a layout resolve. Two rules -- follow from that, and both are why this reads the frames it reads: -- -- * the ART'S SIZE comes off `WorldMapButton`, not off `WorldMapDetailFrame`. The detail -- frame is `SetAllPoints` to the canvas, so its width is DERIVED and asking for it -- resolves the tree; the button carries the same rect as an explicit `SetWidth`, for the -- reason `SeatOnCanvas` states (pfUI reads it). -- * the BOUNDS TEST is arithmetic on the controller's own normalized scroll, not a rect -- comparison. `GetLeft` on a dirty tree measured 30-370 us against 0.42 us settled -- (`benilla-getleft-forces-a-layout-resolve`), and a pan writes the scroll every frame, -- so the tree is dirty every frame this runs — six of those is a millisecond of nothing. local function SeatPlayerArrow() if not map:IsVisible() then return end local px, py = GetPlayerMapPosition("player") if not px or not py or (px == 0 and py == 0) then return -- the stock update has already hidden it end local de = detail:GetEffectiveScale() local ae = arrow and arrow:GetEffectiveScale() or map:GetEffectiveScale() if not de or not ae or de == 0 or ae == 0 then return end local aw = button and button:GetWidth() or 0 local ah = button and button:GetHeight() or 0 if aw <= 0 or ah <= 0 then return end -- In the detail frame's own units, which is where the art and every 1.12 icon live... -- ...and out again into the arrow's, which is the whole conversion that was missing. local k = de / ae PositionWorldMapArrowFrame("CENTER", "WorldMapDetailFrame", "TOPLEFT", px * aw * k, -py * ah * k) -- Hidden where the container would have cropped it, which it cannot do for a frame that -- is not in the container. The view is the box of half-width `halfU` around the scroll -- centre, in the canvas's own normalized space -- the same numbers -- `CalculateScrollExtentsAtScale` works in, which is why they are asked for rather than -- recomputed. local scale = sc:GetCanvasScale() if scale and scale > 0 then local halfU = sc:NormalizeHorizontalSize(sc:GetWidth() * 0.5 / scale) local halfV = sc:NormalizeVerticalSize(sc:GetHeight() * 0.5 / scale) local cu = sc:GetNormalizedHorizontalScroll() or 0.5 local cv = sc:GetNormalizedVerticalScroll() or 0.5 if math.abs(px - cu) > halfU or math.abs(py - cv) > halfV then ShowWorldMapArrowFrame(nil) end end end -- THE MAP FADES WHILE THE PLAYER IS MOVING -- -- grom, 2026-11-05: *"It would also be nice if the whole map window went to 80% -- transparentcy when the player is moving."* `mapFadeWhileMoving` is the percent, 0 off, with -- a row on the Options window's Interface page; 1.12 has no such behaviour at all. -- -- Written as a FACTOR of whatever alpha the window already has, not as an absolute, because -- the window's alpha belongs to somebody else: pfUI sets it from its own saved config and -- offers shift-wheel over the map to change it (`pfUI/modules/map.lua:67`). So the base is -- re-read whenever the live alpha is not the one this last wrote — that is the signature of -- another writer, and it makes their setting the thing that gets faded rather than the thing -- that gets overwritten. -- -- `GetSpeed()` is the movement test: 1.12 has no "is the player moving" verb, and this one is -- ours, always present, and 0 standing still by construction (`compat/superwow.rs`) — the same -- move flags the engine answers `IsSwimming` and `IsMounted` from, so it cannot disagree with -- them about what the player is doing this frame. local fadeBase, fadeApplied local function FadeWhileMoving() if not map:IsVisible() then return end local pct = tonumber(GetCVar("mapFadeWhileMoving")) or 0 local now = map:GetAlpha() or 1 if not fadeApplied or math.abs(now - fadeApplied) > 0.001 then fadeBase = now -- somebody else wrote it: that is the alpha to fade FROM end local want = fadeBase or 1 if pct > 0 and GetSpeed and GetSpeed() > 0 then want = want * (1 - pct / 100) end if math.abs(now - want) > 0.001 then map:SetAlpha(want) end fadeApplied = want end if WorldMapButton_OnUpdate then -- Hooked rather than replaced, and hooked on the function that places it, so if anything -- ever suppresses the stock update this stops running with it rather than fighting it. hooksecurefunc("WorldMapButton_OnUpdate", function() SeatPlayerArrow() FadeWhileMoving() end) end -- A map closed while faded must not come back faded: the base is whatever it was before. map:HookScript("OnHide", function() if fadeApplied and fadeBase and math.abs((map:GetAlpha() or 1) - fadeApplied) <= 0.001 then map:SetAlpha(fadeBase) end fadeApplied = nil end) 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)