Files
benilla-octo/crates/benilla-app/assets/ui/MapCanvas.lua
T
gromandClaude Opus 5 082dd2ed0f The map's arrow, its stratum, and a fade while you walk
Three of grom's 2026-11-05 reports about the built-in map, all measured.

**The player arrow was the one piece of 1.12's map content left outside the canvas.**
*"my player arrow goes to the wrong spot at any zoom level, this needs to be accurate all
of the time."* `WorldMapFrame_OnLoad` builds it with
`CreateWorldMapArrowFrame(WorldMapFrame)` (`WorldMapFrame.lua:19`), so its parent is the
WINDOW, while `MapCanvas.lua` reparents everything it is anchored against into the canvas.
`SetPoint`'s offsets are in the positioned frame's own units, so the offsets the stock
update computes — `playerX * 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. Measured on a 1600x900 harness with the view centred
on the player: exact at zoom 1, 250.5 x 501.0 screen px out at zoom 2.

`WorldMapPlayer` — the invisible mouseover rect for the same spot, 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 makes it the oracle the test uses rather than a recomputed
expectation. Not reparented like the detail frame and the button: the arrow is a size-less
`Model` whose rect is the file's bounding box, so inside the canvas it would grow with the
zoom, and no frame scale fixes the size without undoing the position — both read the same
effective scale. The cost of staying outside is clipping, hence an explicit bounds test,
which is a hide the stock code never needed because a map that cannot pan cannot put the
player off-screen.

That re-seat runs every frame the map is open, so nothing in it may force a layout resolve:
the art's size is read off `WorldMapButton` (explicitly sized) rather than the detail frame
(`SetAllPoints`, so derived), and the bounds test is arithmetic on the controller's own
normalized scroll rather than a rect comparison — `GetLeft` on a dirty tree measures
30-370 us against 0.42 us settled, and a pan writes the scroll every frame.

While in there: `InstantPanAndZoom` set `Child:SetScale` from its raw argument while
`GetCanvasScale` clamps to the zoom ladder, so a scale past the ceiling drew the art at one
scale with every extent, pan clamp and pin placement computed for another. It clamps first
now, as the ease path already did by construction.

**The window fell out of its own stratum every time it opened.** *"the map needs to be on a
higher level as I can see my unitframe over it."* `WorldMapFrame.xml:5` declares
`frameStrata="FULLSCREEN"`; measured FULLSCREEN with no addons and MEDIUM at level 0 with
his, which is the bottom of the bucket pfUI's unit frames live in
(`pfUI/api/unitframes.lua:413`). Five visible frames drew over the map rect.

No addon does it. The new `$WOW_WATCH_STRATA=<FrameName>` instrument — every
`SetFrameStrata` AND every `SetParent` on a named frame, with the Lua caller site — found
one FULLSCREEN from the XML loader and then `WorldMapFrame:SetParent(UIParent)`, from the
realm's own `WorldMapFrame_Minimize` (`WorldMapFrame.lua:183`) by way of
`WorldMapFrame_OnEvent`, on the open path. A reparent forces the moved frame into its new
parent's stratum (`0x76ab5a`, pinned off the binary by `script::tests::setparent`), and
`UIParent` is MEDIUM. The reparent is right and stays; the window's own declared stratum is
re-asserted on `SeatLevels` instead, which already re-seats the frame levels on every show
for a cousin reason, plus hooks on Minimize/Maximize since the minimize runs while the
window is up. After: FULLSCREEN, 0 frames over the map.

**And the fade.** *"It would also be nice if the whole map window went to 80%
transparentcy when the player is moving."* `mapFadeWhileMoving`, percent, 0 off, default 80,
with a slider on the Options window's Interface page. Written as a FACTOR of whatever alpha
the window already has, because that alpha belongs to somebody else — pfUI sets it from its
own config and offers shift-wheel over the map — so the base is re-read whenever the live
alpha is not the one this last wrote, which is another writer's signature; their setting is
what gets faded, and closing the map restores it. `GetSpeed()` is the movement test: ours,
always present, and 0 standing still by construction, off the same move flags the engine
answers `IsSwimming` and `IsMounted` from.

Also `$WOW_CAPTURE_EVAL`/`_SETUP` take a path ending in `.lua` as the chunk. A probe worth
writing is longer than a shell argument survives, and a quoting error in a probe reads
exactly like the thing being measured not being there.

Verified: `the_player_arrow_tracks_the_hit_area_at_every_canvas_zoom` walks zoom 1, 1.5,
2.5 and 4 asserting the arrow and the hit area agree within a screen pixel, and that the
canvas applied the zoom asked for. In a real session with his addons: FULLSCREEN and 0
visible frames over the map (was MEDIUM/0 and 5). Options census 140 -> 141 on both counts.
benilla-app at its recorded baseline, benilla-ui 2128/0, clippy and the two lints clean.

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

2298 lines
87 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. The pan's is
-- left alone: dragging writes the scroll directly and never eases.
local PAN_RATE = 10
local SCALE_RATE = 24
-- 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` closes about 63% of the gap a second (`SCALE_RATE`), 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
local rate = 1 - math.pow(0.5, (elapsed or 0) * PAN_RATE / 7)
local tx, ty = self.targetScrollX or 0.5, self.targetScrollY or 0.5
local cx, cy = self.currentScrollX or 0.5, self.currentScrollY or 0.5
if math.abs(tx - cx) > PAN_SNAP or math.abs(ty - cy) > PAN_SNAP then
self.currentScrollX = cx + (tx - cx) * rate
self.currentScrollY = cy + (ty - cy) * rate
moved = true
elseif cx ~= tx or cy ~= ty then
self.currentScrollX, self.currentScrollY = tx, ty
moved = true
end
end
local scaled = false
local ts, cs = self.targetScale or 1, self.currentScale or 1
if math.abs(ts - cs) > SCALE_SNAP then
local rate = 1 - math.pow(0.5, (elapsed or 0) * SCALE_RATE / 7)
self.currentScale = cs + (ts - cs) * rate
scaled = true
elseif cs ~= ts then
self.currentScale = ts
scaled = true
end
if scaled then
if self.Child then
self.Child:SetScale(self.currentScale)
end
self:CalculateScrollExtents()
self.currentScrollX, self.currentScrollY = self:ClampScrollToExtents(
self.currentScrollX, self.currentScrollY)
moved = true
end
if moved then
self:ApplyCurrentScroll()
self:MarkViewRectDirty()
end
local map = self:GetMapCanvas()
if map then
if scaled then
map:OnCanvasScaleChanged()
end
if moved then
map:OnCanvasPanChanged()
end
end
end
--------------------------------------------------------------------------------
-- MapCanvasMixin — the map itself.
--------------------------------------------------------------------------------
MapCanvasMixin = {}
-- What a global pin mouse-action handler is told happened. The values are opaque to a caller,
-- which compares against these names (`MapCanvasMixin.MouseAction.Click`), so only the identity
-- matters.
MapCanvasMixin.MouseAction = { Click = 1, Up = 2, Down = 3 }
function MapCanvasMixin:OnLoad()
-- A SET, keyed by the provider with `true` as the value, because that is upstream's shape and
-- callers iterate it: `for provider in pairs(map.dataProviders) do` binds the *key*, so an
-- array here hands the loop the number 1 and the next line indexes a number. Measured: it
-- raised at `Map/Extras.lua:281`, inside the addon's own `OnEnable`, which caught it, printed
-- it and left the whole addon disabled — the same trap `pools.rs` names for `activeObjects`,
-- where a "tidier" array would break `for icon in pool:EnumerateActive()` silently.
--
-- `dataProviderOrder` is ours, beside it: `pairs` order is undefined, and a map whose
-- providers are notified in a different order each session is a bug nobody can reproduce. So
-- the set is the shape addons read and the list is the order we walk.
self.dataProviders = {}
self.dataProviderOrder = {}
self.dataProviderEventsCount = {}
self.pinPools = {}
self.pinTemplateTypes = {}
self.activePins = {}
self.canvasClickHandlers = {}
self.globalPinMouseActionHandlers = {}
self.cursorHandlers = {}
self.pinFrameLevelsManager = CreatePinFrameLevelsManager()
self.mapID = nil
self.globalPinScale = 1
end
function MapCanvasMixin:GetMapID()
return self.mapID
end
function MapCanvasMixin:GetCanvas()
return self.ScrollContainer and self.ScrollContainer.Child
end
function MapCanvasMixin:GetCanvasContainer()
return self.ScrollContainer
end
function MapCanvasMixin:GetCanvasScale()
return self.ScrollContainer and self.ScrollContainer:GetCanvasScale() or 1
end
function MapCanvasMixin:GetGlobalPinScale()
return self.globalPinScale or 1
end
function MapCanvasMixin:SetGlobalPinScale(scale)
self.globalPinScale = scale
self:OnCanvasScaleChanged()
end
function MapCanvasMixin:GetPinFrameLevelsManager()
return self.pinFrameLevelsManager
end
-- The view in canvas-normalized coordinates: left, right, top, bottom.
function MapCanvasMixin:GetViewRect()
local sc = self.ScrollContainer
if not sc then
return 0, 1, 0, 1
end
local scale = sc:GetCanvasScale()
if scale <= 0 then
return 0, 1, 0, 1
end
local halfW = sc:NormalizeHorizontalSize(sc:GetWidth() * 0.5 / scale)
local halfH = sc:NormalizeVerticalSize(sc:GetHeight() * 0.5 / scale)
local x, y = sc.currentScrollX or 0.5, sc.currentScrollY or 0.5
return x - halfW, x + halfW, y - halfH, y + halfH
end
function MapCanvasMixin:NormalizeHorizontalSize(size)
return self.ScrollContainer and self.ScrollContainer:NormalizeHorizontalSize(size) or 0
end
function MapCanvasMixin:NormalizeVerticalSize(size)
return self.ScrollContainer and self.ScrollContainer:NormalizeVerticalSize(size) or 0
end
function MapCanvasMixin:SetPinNudgingDirty(dirty)
self.pinNudgingDirty = dirty
end
--------------------------------------------------------------------------------
-- Data providers
--------------------------------------------------------------------------------
function MapCanvasMixin:AddDataProvider(dataProvider)
if not dataProvider or self.dataProviders[dataProvider] then
return
end
self.dataProviders[dataProvider] = true
self.dataProviderOrder[table.getn(self.dataProviderOrder) + 1] = dataProvider
if dataProvider.OnAdded then
dataProvider:OnAdded(self)
end
if self:IsShown() and dataProvider.RefreshAllData then
dataProvider:RefreshAllData(false)
end
end
function MapCanvasMixin:RemoveDataProvider(dataProvider)
if not dataProvider or not self.dataProviders[dataProvider] then
return
end
self.dataProviders[dataProvider] = nil
for i = 1, table.getn(self.dataProviderOrder) do
if self.dataProviderOrder[i] == dataProvider then
table.remove(self.dataProviderOrder, i)
break
end
end
if dataProvider.RemoveAllData then
dataProvider:RemoveAllData()
end
if dataProvider.OnRemoved then
dataProvider:OnRemoved(self)
end
end
function MapCanvasMixin:RemoveAllDataProviders()
while table.getn(self.dataProviderOrder) > 0 do
self:RemoveDataProvider(self.dataProviderOrder[1])
end
end
-- One walk, one guard: a provider that raises must not stop the providers after it, because a
-- third-party provider is ordinary here and a half-notified map draws a half-built view.
function MapCanvasMixin:CallDataProviders(method, a, b, c)
local providers = self.dataProviderOrder
for i = 1, table.getn(providers) do
local provider = providers[i]
local fn = provider[method]
if fn then
local ok, err = pcall(fn, provider, a, b, c)
if not ok then
geterrorhandler()(err)
end
end
end
end
function MapCanvasMixin:RefreshAllDataProviders(fromOnShow)
self:CallDataProviders("RefreshAllData", fromOnShow)
end
function MapCanvasMixin:OnMapChanged()
self:CallDataProviders("OnMapChanged")
end
-- 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)