Files
benilla-octo/crates/benilla-app/assets/ui/MapCanvas.lua
T
gromandClaude Opus 5 524e500d43 The map's glide is a setting, because two reports bracketed one guess
grom, 2026-11-05: *"its the maps motion that is laggy."* That is `SCALE_RATE`, and it is the
same constant he reported from the other side a day earlier — *"scroll is better but feels pretty
slippery, like scrolls past where I stop scrolling"* — which is what took it from 10 to 24. At 24
the half-life is 0.29 s, so at 60 fps a wheel notch is still closing 167 frames later: "it
overshoots" and "it lags behind" are two readings of one glide, and a third guess would be a third
guess.

`mapMotionSmoothing` is a percentage of a 0.30 s half-life, 0 is no ease at all, and the slider is
on the Options window's Interface page. The top of the range is the 0.29 s he called laggy,
because that is the slowest anyone could want; the default is a quarter of it. Measured over a
full rung (scale 1 -> 1.6) at 60 fps, frames to settle within 0.001: **0% = 1, 10% = 17, 20% = 34,
25% (shipped) = 42, 50% = 84, 100% = 167** — so the default is four times snappier than what he
just played, and still eased.

One rate drives both the pan and the zoom ease, 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. A
drag still writes the scroll directly and never eases, as before.

Verified in a live session with his addons: `mapMotionSmoothing=25`, one notch settles in 42
frames (0.70 s), map still FULLSCREEN, fade still 80. Options census 141 -> 142 on both counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-05 11:48:43 -07:00

2328 lines
88 KiB
Lua

-- 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 }
-- `(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, WINDOWED_INSET
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 `<OnMouseUp>` *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.
local function SeatLevels()
if map:GetFrameStrata() ~= MAP_STRATA then
map:SetFrameStrata(MAP_STRATA)
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)