Era's map canvas is built on one verb this client did not have. Its `ScrollContainer` is a plain Frame with `SetClipsChildren(true)`, and everything below it is cropped to that rect. `assets/ui/MapCanvas.lua` substituted a real ScrollFrame, because this client crops a ScrollFrame's scroll child and every descendant of it — which covers the canvas and nothing parented to the container beside it. On Era that distinction does not exist, so an addon has no reason to respect it. OpenMapClassic parents its pin layer exactly there and asks for the verb by name, behind an `if` (`Map/Stitch.lua:1070`), so the absence was not a raise. Measured on grom's own config with the map open: the container sits at L=1151 R=2153 and its seventeen direct children span L=-765 R=2153 — about 1900 units off the left edge, cropped by nothing. Implemented as a frame flag with `SetClipsChildren`/`DoesClipChildren` and the `clipChildren` XML attribute; `effective_clip` now intersects the rect of every ancestor that sets it alongside the ScrollFrame rule, so the two nest and compose. An ancestor rather than the frame itself: the verb clips a frame's children, and stock art overhangs its own frame on purpose. `MapCanvas.lua` sets it on the container too, so a layer parked there is cropped whether or not its addon thought to ask. Not claimed as the whole of grom's map report: the screenshot behind it has not been reproduced offline, because a capture's view is not the view his session was in. This is the part that was provably drawing uncropped. Also here, both from the same play-test: - `C_Spell.CancelSpellByID` answered with `SpellStopCasting` only. pfUI's buff bar cancels with it on a right click where 1.12's calls `CancelPlayerBuff`, so with nothing in flight the click did nothing and said nothing — grom had never been able to right-click a buff off on this client. It now finds the id among the player's own auras and queues it on the same CMSG_CANCEL_AURA path, behind the same `cancel_authorized` gate, and falls through to the cast only when there is no such aura. - `$WOW_CAPTURE_SAVED=<Realm>-<Character>` points a capture at a player's own saved variables. A capture is hermetic by design, but with addons and no saved variables it renders an interface nobody is configured to be looking at — which is why the world map came out correct in a capture while being wrong on his screen. It doubles as the identity because a capture never logs in and `pfUI_config` is SavedVariablesPerCharacter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
1788 lines
60 KiB
Lua
1788 lines
60 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.
|
|
local PAN_RATE = 10
|
|
local SCALE_RATE = 10
|
|
-- A drag under this many pixels is still a click, which is what `WouldCursorPositionBeClick`
|
|
-- answers; Era's own threshold.
|
|
local CLICK_SLOP = 5
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- PinFrameLevelsManager
|
|
--
|
|
-- Pins draw in named bands rather than at raw frame levels, so an addon can insert its own band
|
|
-- relative to Blizzard's by name and get a range of levels inside it. Era's structure exactly,
|
|
-- because the names are the contract: `InsertFrameLevelAbove("PIN_FRAME_LEVEL_OPENMAP_STITCH",
|
|
-- "PIN_FRAME_LEVEL_MAP_EXPLORATION", n)` has to mean what it means upstream or the art lands in
|
|
-- the wrong band.
|
|
--------------------------------------------------------------------------------
|
|
|
|
PinFrameLevelsManagerMixin = {}
|
|
|
|
function PinFrameLevelsManagerMixin:Initialize()
|
|
self.frameLevelRanges = {}
|
|
self.ordered = {}
|
|
self.currentFrameLevels = {}
|
|
end
|
|
|
|
-- The base level every band is offset from: above the canvas itself, so a pin never hides behind
|
|
-- the art it sits on.
|
|
local PIN_FRAME_LEVEL_BASE = 100
|
|
|
|
function PinFrameLevelsManagerMixin:Reset()
|
|
self.currentFrameLevels = {}
|
|
for i = 1, table.getn(self.ordered) do
|
|
self.currentFrameLevels[self.ordered[i]] = 0
|
|
end
|
|
end
|
|
|
|
-- Append a band with `count` levels in it; the first call for a name wins, as upstream's does.
|
|
function PinFrameLevelsManagerMixin:InsertFrameLevel(frameLevelType, count)
|
|
if self.frameLevelRanges[frameLevelType] then
|
|
return false
|
|
end
|
|
local base = PIN_FRAME_LEVEL_BASE
|
|
local n = table.getn(self.ordered)
|
|
if n > 0 then
|
|
local last = self.frameLevelRanges[self.ordered[n]]
|
|
base = last.base + last.count
|
|
end
|
|
self.frameLevelRanges[frameLevelType] = { base = base, count = count or 1 }
|
|
self.ordered[n + 1] = frameLevelType
|
|
self.currentFrameLevels[frameLevelType] = 0
|
|
return true
|
|
end
|
|
|
|
-- Insert a band immediately above a named one, shifting every band above it up. False when the
|
|
-- reference band does not exist, which is the answer a caller branches on: OpenMapClassic prints
|
|
-- "Blizzard's map layers aren't the expected ones" and carries on.
|
|
function PinFrameLevelsManagerMixin:InsertFrameLevelAbove(frameLevelType, aboveType, count)
|
|
if self.frameLevelRanges[frameLevelType] or not self.frameLevelRanges[aboveType] then
|
|
return false
|
|
end
|
|
count = count or 1
|
|
local at
|
|
for i = 1, table.getn(self.ordered) do
|
|
if self.ordered[i] == aboveType then
|
|
at = i
|
|
break
|
|
end
|
|
end
|
|
if not at then
|
|
return false
|
|
end
|
|
table.insert(self.ordered, at + 1, frameLevelType)
|
|
-- Re-base every band from the bottom, so the ranges stay contiguous and ordered.
|
|
local base = PIN_FRAME_LEVEL_BASE
|
|
for i = 1, table.getn(self.ordered) do
|
|
local name = self.ordered[i]
|
|
local range = self.frameLevelRanges[name]
|
|
if not range then
|
|
range = { count = count }
|
|
self.frameLevelRanges[name] = range
|
|
self.currentFrameLevels[name] = 0
|
|
end
|
|
range.base = base
|
|
base = base + range.count
|
|
end
|
|
return true
|
|
end
|
|
|
|
function PinFrameLevelsManagerMixin:InsertFrameLevelBelow(frameLevelType, belowType, count)
|
|
if not self.frameLevelRanges[belowType] then
|
|
return false
|
|
end
|
|
-- Below `belowType` is above whatever is under it; with nothing under it, the bottom.
|
|
local at
|
|
for i = 1, table.getn(self.ordered) do
|
|
if self.ordered[i] == belowType then
|
|
at = i
|
|
break
|
|
end
|
|
end
|
|
if not at then
|
|
return false
|
|
end
|
|
if at == 1 then
|
|
if self.frameLevelRanges[frameLevelType] then
|
|
return false
|
|
end
|
|
table.insert(self.ordered, 1, frameLevelType)
|
|
self.frameLevelRanges[frameLevelType] = { base = 0, count = count or 1 }
|
|
self.currentFrameLevels[frameLevelType] = 0
|
|
local base = PIN_FRAME_LEVEL_BASE
|
|
for i = 1, table.getn(self.ordered) do
|
|
local range = self.frameLevelRanges[self.ordered[i]]
|
|
range.base = base
|
|
base = base + range.count
|
|
end
|
|
return true
|
|
end
|
|
return self:InsertFrameLevelAbove(frameLevelType, self.ordered[at - 1], count)
|
|
end
|
|
|
|
-- The next free level inside a band, which is what a pin asks for as it is acquired. A band that
|
|
-- runs out keeps handing back its top level rather than spilling into the next band's.
|
|
function PinFrameLevelsManagerMixin:GetValidFrameLevel(frameLevelType)
|
|
local range = self.frameLevelRanges[frameLevelType]
|
|
if not range then
|
|
return PIN_FRAME_LEVEL_BASE
|
|
end
|
|
local used = self.currentFrameLevels[frameLevelType] or 0
|
|
if used < range.count then
|
|
self.currentFrameLevels[frameLevelType] = used + 1
|
|
else
|
|
used = range.count - 1
|
|
end
|
|
return range.base + used
|
|
end
|
|
|
|
function PinFrameLevelsManagerMixin:GetFrameLevelRange(frameLevelType)
|
|
local range = self.frameLevelRanges[frameLevelType]
|
|
if not range then
|
|
return nil
|
|
end
|
|
return range.base, range.base + range.count - 1
|
|
end
|
|
|
|
local function CreatePinFrameLevelsManager()
|
|
local manager = {}
|
|
for k, v in pairs(PinFrameLevelsManagerMixin) do
|
|
manager[k] = v
|
|
end
|
|
manager:Initialize()
|
|
return manager
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- MapCanvasDataProviderMixin — the base every data provider is built from.
|
|
--
|
|
-- Every member is a no-op, deliberately: upstream's is too, and that is the whole contract. A
|
|
-- provider overrides the two or three hooks it cares about and inherits silence for the rest, so
|
|
-- adding a hook here can never break an existing provider.
|
|
--------------------------------------------------------------------------------
|
|
|
|
MapCanvasDataProviderMixin = {}
|
|
|
|
function MapCanvasDataProviderMixin:OnAdded(mapCanvas)
|
|
self.owningMap = mapCanvas
|
|
end
|
|
|
|
function MapCanvasDataProviderMixin:OnRemoved(mapCanvas)
|
|
self.owningMap = nil
|
|
end
|
|
|
|
function MapCanvasDataProviderMixin:GetMap()
|
|
return self.owningMap
|
|
end
|
|
|
|
function MapCanvasDataProviderMixin:RemoveAllData() end
|
|
function MapCanvasDataProviderMixin:RefreshAllData(fromOnShow) end
|
|
function MapCanvasDataProviderMixin:OnShow() end
|
|
function MapCanvasDataProviderMixin:OnHide() end
|
|
function MapCanvasDataProviderMixin:OnEvent(event, arg1, arg2, arg3) end
|
|
function MapCanvasDataProviderMixin:OnMapChanged() end
|
|
function MapCanvasDataProviderMixin:OnMapInsetSizeChanged(insetIndex, expanded) end
|
|
function MapCanvasDataProviderMixin:OnCanvasScaleChanged() end
|
|
function MapCanvasDataProviderMixin:OnCanvasPanChanged() end
|
|
function MapCanvasDataProviderMixin:OnCanvasSizeChanged() end
|
|
function MapCanvasDataProviderMixin:OnGlobalAlphaChanged() end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- MapCanvasPinMixin — the base every pin is built from.
|
|
--
|
|
-- A pin is a frame on the canvas placed by a normalized position rather than by anchors, so it
|
|
-- stays on its place as the canvas pans and scales. `ApplyCurrentPosition` is the one that does
|
|
-- the work and is called on every pan and scale change.
|
|
--------------------------------------------------------------------------------
|
|
|
|
MapCanvasPinMixin = {}
|
|
|
|
function MapCanvasPinMixin:OnLoad()
|
|
self.numActiveAnimations = 0
|
|
self.scaleFactor = 1
|
|
self.startScale = 1
|
|
self.endScale = 1
|
|
self.normalizedX = 0.5
|
|
self.normalizedY = 0.5
|
|
end
|
|
|
|
function MapCanvasPinMixin:GetMap()
|
|
return self.owningMap
|
|
end
|
|
|
|
function MapCanvasPinMixin:OnAcquired() end
|
|
function MapCanvasPinMixin:OnReleased() end
|
|
function MapCanvasPinMixin:OnClick(button) end
|
|
function MapCanvasPinMixin:OnMouseEnter() end
|
|
function MapCanvasPinMixin:OnMouseLeave() end
|
|
function MapCanvasPinMixin:OnMapInsetSizeChanged(insetIndex, expanded) end
|
|
function MapCanvasPinMixin:OnCanvasScaleChanged()
|
|
self:ApplyCurrentScale()
|
|
end
|
|
function MapCanvasPinMixin:OnCanvasPanChanged() end
|
|
function MapCanvasPinMixin:OnCanvasSizeChanged() end
|
|
|
|
function MapCanvasPinMixin:SetPosition(normalizedX, normalizedY, insetIndex)
|
|
self.normalizedX = normalizedX
|
|
self.normalizedY = normalizedY
|
|
self.insetIndex = insetIndex
|
|
self:ApplyCurrentPosition()
|
|
end
|
|
|
|
function MapCanvasPinMixin:GetPosition()
|
|
return self.normalizedX, self.normalizedY
|
|
end
|
|
|
|
function MapCanvasPinMixin:ApplyCurrentPosition()
|
|
local map = self:GetMap()
|
|
if not map then
|
|
return
|
|
end
|
|
local canvas = map:GetCanvas()
|
|
if not canvas then
|
|
return
|
|
end
|
|
self:ClearAllPoints()
|
|
self:SetPoint("CENTER", canvas, "TOPLEFT",
|
|
(self.normalizedX or 0.5) * canvas:GetWidth(),
|
|
-(self.normalizedY or 0.5) * canvas:GetHeight())
|
|
end
|
|
|
|
-- A pin's own scale, held constant on screen as the canvas scales: a quest icon should stay the
|
|
-- same size however far the map is zoomed out, which is `1 / canvasScale` times whatever the pin
|
|
-- asked for. `scaleFactor` 0 opts out and lets the pin scale with the map, which is what an area
|
|
-- outline (a "blob") wants.
|
|
function MapCanvasPinMixin:SetScalingLimits(scaleFactor, startScale, endScale)
|
|
self.scaleFactor = scaleFactor
|
|
self.startScale = startScale
|
|
self.endScale = endScale
|
|
end
|
|
|
|
function MapCanvasPinMixin:ApplyCurrentScale()
|
|
local map = self:GetMap()
|
|
if not map then
|
|
return
|
|
end
|
|
local factor = self.scaleFactor
|
|
if not factor or factor == 0 then
|
|
self:SetScale(1)
|
|
return
|
|
end
|
|
local canvasScale = map:GetCanvasScale()
|
|
if not canvasScale or canvasScale <= 0 then
|
|
return
|
|
end
|
|
local scale = factor / canvasScale
|
|
local lo = self.startScale
|
|
local hi = self.endScale
|
|
if lo and hi and lo > hi then
|
|
lo, hi = hi, lo
|
|
end
|
|
if lo and scale < lo then
|
|
scale = lo
|
|
end
|
|
if hi and scale > hi then
|
|
scale = hi
|
|
end
|
|
self:SetScale(scale)
|
|
end
|
|
|
|
function MapCanvasPinMixin:UseFrameLevelType(pinFrameLevelType, index)
|
|
self.pinFrameLevelType = pinFrameLevelType
|
|
self.pinFrameLevelIndex = index
|
|
end
|
|
|
|
function MapCanvasPinMixin:GetFrameLevelType()
|
|
return self.pinFrameLevelType
|
|
end
|
|
|
|
function MapCanvasPinMixin:ApplyFrameLevel()
|
|
local map = self:GetMap()
|
|
if not map or not self.pinFrameLevelType then
|
|
return
|
|
end
|
|
local manager = map:GetPinFrameLevelsManager()
|
|
if manager then
|
|
self:SetFrameLevel(manager:GetValidFrameLevel(self.pinFrameLevelType))
|
|
end
|
|
end
|
|
|
|
-- Which mouse buttons fall through to the canvas underneath. Era's pins declare this so a
|
|
-- right-drag over an icon still pans the map; a pin with no declaration swallows everything,
|
|
-- which is what a clickable icon wants.
|
|
function MapCanvasPinMixin:SetPassThroughButtons(button1, button2, button3)
|
|
self.passThroughButtons = { button1, button2, button3 }
|
|
end
|
|
|
|
function MapCanvasPinMixin:CheckMouseButtonPassthrough(button)
|
|
local list = self.passThroughButtons
|
|
if not list then
|
|
return false
|
|
end
|
|
for i = 1, table.getn(list) do
|
|
if list[i] == button then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- MapCanvasScrollControllerMixin — the pan and zoom.
|
|
--
|
|
-- The view is held as a NORMALIZED CENTRE (`currentScrollX`, `currentScrollY`: the canvas point
|
|
-- under the middle of the container, 0..1 across the canvas) plus a scale. That is upstream's own
|
|
-- representation, and it has to be, because a canvas addon reads and writes those fields directly
|
|
-- — OpenMapClassic's `SetView` assigns `currentScrollX`, `targetScrollX`, `currentScale`,
|
|
-- `targetScale` and `zoomTarget` by hand (`Map/WorldMap.lua:184-191`) and then calls
|
|
-- `SetNormalizedHorizontalScroll`. So the field names and their meanings are the contract, not an
|
|
-- implementation detail.
|
|
--
|
|
-- The pan is applied as the ScrollFrame's own scroll offset, which is what a ScrollFrame offset
|
|
-- already means and is the cheapest write this client has (`SetVerticalScroll` re-anchors one
|
|
-- node and skips the layout touch).
|
|
--------------------------------------------------------------------------------
|
|
|
|
MapCanvasScrollControllerMixin = {}
|
|
|
|
function MapCanvasScrollControllerMixin:OnLoad()
|
|
self.currentScrollX = 0.5
|
|
self.currentScrollY = 0.5
|
|
self.targetScrollX = 0.5
|
|
self.targetScrollY = 0.5
|
|
self.currentScale = 1
|
|
self.targetScale = 1
|
|
self.zoomTarget = 1
|
|
self.baseScale = 1
|
|
self.zoomLevels = {}
|
|
self.scrollXExtentsMin = 0.5
|
|
self.scrollXExtentsMax = 0.5
|
|
self.scrollYExtentsMin = 0.5
|
|
self.scrollYExtentsMax = 0.5
|
|
self.mouseButtonInfo = {
|
|
LeftButton = { down = false, x = 0, y = 0 },
|
|
RightButton = { down = false, x = 0, y = 0 },
|
|
MiddleButton = { down = false, x = 0, y = 0 },
|
|
}
|
|
self.scalingMode = "SCALING_MODE_TRANSLATE_FASTER_THAN_SCALE"
|
|
self.mouseWheelZoomMode = MAP_CANVAS_MOUSE_WHEEL_ZOOM_BEHAVIOR_FULL
|
|
self.panning = false
|
|
self.viewRectDirty = true
|
|
self.areaTriggersDirty = true
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetCanvasContainer()
|
|
return self
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetCanvas()
|
|
return self.Child
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetCanvasScale()
|
|
return self.currentScale or 1
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:SetScalingMode(mode)
|
|
self.scalingMode = mode
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:SetMouseWheelZoomMode(mode)
|
|
self.mouseWheelZoomMode = mode
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:MarkViewRectDirty()
|
|
self.viewRectDirty = true
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:MarkAreaTriggersDirty()
|
|
self.areaTriggersDirty = true
|
|
end
|
|
|
|
-- Canvas px to a fraction of the canvas, the two helpers every extents computation goes through.
|
|
function MapCanvasScrollControllerMixin:NormalizeHorizontalSize(size)
|
|
local w = self.Child and self.Child:GetWidth()
|
|
if not w or w <= 0 then
|
|
return 0
|
|
end
|
|
return size / w
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:NormalizeVerticalSize(size)
|
|
local h = self.Child and self.Child:GetHeight()
|
|
if not h or h <= 0 then
|
|
return 0
|
|
end
|
|
return size / h
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:DenormalizeHorizontalSize(size)
|
|
local w = self.Child and self.Child:GetWidth()
|
|
return size * (w or 0)
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:DenormalizeVerticalSize(size)
|
|
local h = self.Child and self.Child:GetHeight()
|
|
return size * (h or 0)
|
|
end
|
|
|
|
-- The normalized centre may range over this, so the view stays inside the canvas. When the canvas
|
|
-- is smaller than the container on an axis the range collapses to its middle, which centres it.
|
|
function MapCanvasScrollControllerMixin:CalculateScrollExtentsAtScale(scale)
|
|
local halfW = self:NormalizeHorizontalSize(self:GetWidth() * 0.5 / scale)
|
|
local halfH = self:NormalizeVerticalSize(self:GetHeight() * 0.5 / scale)
|
|
local minX, maxX = halfW, 1 - halfW
|
|
local minY, maxY = halfH, 1 - halfH
|
|
if minX > maxX then
|
|
minX, maxX = 0.5, 0.5
|
|
end
|
|
if minY > maxY then
|
|
minY, maxY = 0.5, 0.5
|
|
end
|
|
return minX, maxX, minY, maxY
|
|
end
|
|
|
|
-- Through `CalculateScrollExtentsAtScale`, never duplicating its arithmetic: a canvas addon
|
|
-- replaces that one method to widen the pan (OpenMapClassic pans the whole world, not just the
|
|
-- map in view), and a second copy of the law here would ignore the replacement.
|
|
function MapCanvasScrollControllerMixin:CalculateScrollExtents()
|
|
self.scrollXExtentsMin, self.scrollXExtentsMax, self.scrollYExtentsMin, self.scrollYExtentsMax =
|
|
self:CalculateScrollExtentsAtScale(self:GetCanvasScale())
|
|
end
|
|
|
|
local function clamp(v, lo, hi)
|
|
if lo and v < lo then
|
|
return lo
|
|
end
|
|
if hi and v > hi then
|
|
return hi
|
|
end
|
|
return v
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:ClampScrollToExtents(x, y)
|
|
return clamp(x, self.scrollXExtentsMin, self.scrollXExtentsMax),
|
|
clamp(y, self.scrollYExtentsMin, self.scrollYExtentsMax)
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:SetNormalizedHorizontalScroll(x)
|
|
self.currentScrollX = x
|
|
self:ApplyCurrentScroll()
|
|
self:MarkViewRectDirty()
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:SetNormalizedVerticalScroll(y)
|
|
self.currentScrollY = y
|
|
self:ApplyCurrentScroll()
|
|
self:MarkViewRectDirty()
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetCurrentScrollX()
|
|
return self.currentScrollX
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetCurrentScrollY()
|
|
return self.currentScrollY
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetNormalizedHorizontalScroll()
|
|
return self.currentScrollX
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetNormalizedVerticalScroll()
|
|
return self.currentScrollY
|
|
end
|
|
|
|
-- The whole view at once, no easing: the canvas scaled, the extents recomputed at that scale, and
|
|
-- the centre clamped into them.
|
|
function MapCanvasScrollControllerMixin:InstantPanAndZoom(scale, x, y)
|
|
self.currentScale = scale
|
|
self.targetScale = scale
|
|
self.zoomTarget = scale
|
|
if self.Child then
|
|
self.Child:SetScale(scale)
|
|
end
|
|
self:CalculateScrollExtents()
|
|
x, y = self:ClampScrollToExtents(x, y)
|
|
self.currentScrollX, self.targetScrollX = x, x
|
|
self.currentScrollY, self.targetScrollY = y, y
|
|
self:ApplyCurrentScroll()
|
|
self:MarkViewRectDirty()
|
|
self:MarkAreaTriggersDirty()
|
|
local map = self:GetMapCanvas()
|
|
if map then
|
|
map:OnCanvasScaleChanged()
|
|
map:OnCanvasPanChanged()
|
|
end
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetMapCanvas()
|
|
return self.mapCanvas
|
|
end
|
|
|
|
-- The zoom steps, derived from the map's own art layers: one step per layer, at that layer's
|
|
-- minimum scale, plus a fitted step so the whole map is reachable. A canvas addon replaces this
|
|
-- wholesale (OpenMapClassic builds a geometric ladder from its own zoom option), which is why it
|
|
-- is one method and why `ResetZoom` reads `zoomLevels` rather than recomputing.
|
|
function MapCanvasScrollControllerMixin:CreateZoomLevels()
|
|
local mapID = self.mapID
|
|
local levels = {}
|
|
if mapID then
|
|
local layers = C_Map.GetMapArtLayers(mapID)
|
|
local layer = layers and layers[1]
|
|
if layer then
|
|
-- A geometric ladder from the layer's own bounds. Scale 1 is the whole map in the
|
|
-- container by construction — the canvas is sized to the layer and the container is the
|
|
-- rect that layer used to fill — so a `minScale` below 1 would open the map zoomed
|
|
-- *out* of its own window, which is how this was first wrong.
|
|
local lo = layer.minScale or 1
|
|
local hi = layer.maxScale or 1
|
|
if lo > 1 then
|
|
lo = 1
|
|
end
|
|
if hi < lo then
|
|
hi = lo
|
|
end
|
|
local steps = (layer.additionalZoomSteps or 0) + 1
|
|
if steps < 1 then
|
|
steps = 1
|
|
end
|
|
local ratio = 1
|
|
if steps > 1 and lo > 0 then
|
|
ratio = math.pow(hi / lo, 1 / (steps - 1))
|
|
end
|
|
local scale = lo
|
|
for i = 1, steps do
|
|
levels[i] = { scale = scale, layerIndex = 1 }
|
|
scale = scale * ratio
|
|
end
|
|
end
|
|
end
|
|
if table.getn(levels) == 0 then
|
|
levels[1] = { scale = 1, layerIndex = 1 }
|
|
end
|
|
self.zoomLevels = levels
|
|
self.baseScale = levels[1].scale
|
|
-- The scale itself is only seeded when there is none: `CreateZoomLevels` is called again on
|
|
-- every map change and on every container resize, and a view the player has zoomed must not
|
|
-- snap back because the window was dragged.
|
|
if not self.currentScale then
|
|
self.currentScale = levels[1].scale
|
|
self.targetScale = levels[1].scale
|
|
self.zoomTarget = levels[1].scale
|
|
end
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetScaleForMaxZoom()
|
|
local n = table.getn(self.zoomLevels)
|
|
if n == 0 then
|
|
return 1
|
|
end
|
|
return self.zoomLevels[n].scale
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:GetScaleForMinZoom()
|
|
if table.getn(self.zoomLevels) == 0 then
|
|
return 1
|
|
end
|
|
return self.zoomLevels[1].scale
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:IsAtMaxZoom()
|
|
return self:GetCanvasScale() >= self:GetScaleForMaxZoom()
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:IsAtMinZoom()
|
|
return self:GetCanvasScale() <= self:GetScaleForMinZoom()
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:IsZoomingIn()
|
|
return (self.targetScale or 1) > (self.currentScale or 1)
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:IsZoomingOut()
|
|
return (self.targetScale or 1) < (self.currentScale or 1)
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:IsZooming()
|
|
return self:IsZoomingIn() or self:IsZoomingOut()
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:IsGamepadZooming()
|
|
return self.gamepadZoom and self.gamepadZoom ~= 0
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:HasGamepadScrollSpeed()
|
|
return self.gamepadZoomSpeed ~= nil
|
|
end
|
|
|
|
-- The next step up or down the ladder, toward a point if one is given.
|
|
function MapCanvasScrollControllerMixin:SetZoomTarget(scale, x, y)
|
|
self.zoomTarget = scale
|
|
self.targetScale = scale
|
|
if x and y then
|
|
self.targetScrollX, self.targetScrollY = x, y
|
|
end
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:ZoomIn()
|
|
local n = table.getn(self.zoomLevels)
|
|
local current = self:GetCanvasScale()
|
|
for i = 1, n do
|
|
if self.zoomLevels[i].scale > current + 0.0001 then
|
|
self:SetZoomTarget(self.zoomLevels[i].scale)
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:ZoomOut()
|
|
local current = self:GetCanvasScale()
|
|
for i = table.getn(self.zoomLevels), 1, -1 do
|
|
if self.zoomLevels[i].scale < current - 0.0001 then
|
|
self:SetZoomTarget(self.zoomLevels[i].scale)
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
function MapCanvasScrollControllerMixin:ResetZoom()
|
|
local scale = self:GetScaleForMinZoom()
|
|
self:InstantPanAndZoom(scale, 0.5, 0.5)
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- The stock art, at a canvas size that is not 1.12's
|
|
--
|
|
-- 1.12's `WorldMapDetailFrame` is always 1002x668, so everything drawn into it is placed in
|
|
-- absolute pixels against that: twelve 256x256 tiles anchor-chained from its TOPLEFT (1024x768 of
|
|
-- art, the overhang clipped), each exploration overlay at the offset and size
|
|
-- `WorldMapOverlay.dbc` gives it, and the hover highlight likewise. The canvas breaks that
|
|
-- assumption the moment it sizes the frame to anything else — and it does, every time
|
|
-- `OnCanvasSizeChanged` has no art layer to read and falls back to the container's own rect.
|
|
--
|
|
-- Measured in grom's 2026-11-02 run, through `WOW_MAP_TRACE=1`:
|
|
--
|
|
-- detail 1431x954 @scale 0.70 | 1 256x256 @(10,1122) | 2 256x256 @(266,1122) | ...
|
|
--
|
|
-- A 1024x768 sheet of art pinned to the top-left of a 1431x954 frame: it covered 72% of the width
|
|
-- and 80% of the height and the rest of the map drew **nothing**, which is the grey he reported,
|
|
-- with the art magnified relative to what was left.
|
|
--
|
|
-- So the art is rescaled to whatever size the frame has, by the same factors on each axis. The
|
|
-- tiles need only their size set, because 1.12's XML chains them TOPRIGHT-to-TOPLEFT and
|
|
-- BOTTOMLEFT-to-TOPLEFT — resize one and the grid follows. The overlays and the highlight carry
|
|
-- absolute offsets, so those are scaled from the values the stock repaint last wrote, kept per
|
|
-- texture so a second pass cannot compound.
|
|
--------------------------------------------------------------------------------
|
|
|
|
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 = {}
|
|
|
|
-- `w`,`h` are the size the detail frame is ABOUT to have, passed by the caller that just set it.
|
|
-- Reading the frame instead would answer the old size: the detail frame is `SetAllPoints` to the
|
|
-- canvas, so its own size is derived, and a derived size does not resolve until the layout pass
|
|
-- — measured, a child answered `0x0` immediately after its parent was resized. Without them the
|
|
-- frame's current size is read, which is right for a caller that is not resizing it (the overlay
|
|
-- capture after a repaint).
|
|
local function ScaleDetailArt(w, h)
|
|
local d = WorldMapDetailFrame
|
|
if not d then
|
|
return
|
|
end
|
|
w = w or d:GetWidth()
|
|
h = h or d:GetHeight()
|
|
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
|
|
|
|
-- Called when the container or the canvas changes size, and on the first show: the canvas is
|
|
-- sized from the map's own art layer, the zoom ladder rebuilt and the view reset.
|
|
function MapCanvasScrollControllerMixin:OnCanvasSizeChanged()
|
|
local child = self.Child
|
|
if not child then
|
|
return
|
|
end
|
|
local mapID = self.mapID
|
|
local w, h
|
|
if mapID then
|
|
local layers = C_Map.GetMapArtLayers(mapID)
|
|
local layer = layers and layers[1]
|
|
if layer then
|
|
w, h = layer.layerWidth, layer.layerHeight
|
|
end
|
|
end
|
|
-- With no art layer (an instance map, a map the realm has no folder for) the canvas keeps the
|
|
-- container's own size, so pins still place and the view is simply not zoomable.
|
|
if not w or w <= 0 or not h or h <= 0 then
|
|
w, h = self:GetWidth(), self:GetHeight()
|
|
end
|
|
child:SetWidth(w)
|
|
child:SetHeight(h)
|
|
-- The stock art is placed in 1002x668 pixels; this is where the frame holding it stops being
|
|
-- that size (see "The stock art, at a canvas size that is not 1.12's"). The size just set is
|
|
-- passed in, because the detail frame will not answer it until the layout pass.
|
|
ScaleDetailArt(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
|
|
|
|
function MapCanvasMixin:OnCanvasScaleChanged()
|
|
local pins = self.activePins
|
|
for i = 1, table.getn(pins) do
|
|
local pin = pins[i]
|
|
if pin.OnCanvasScaleChanged then
|
|
pin:OnCanvasScaleChanged()
|
|
end
|
|
end
|
|
self:CallDataProviders("OnCanvasScaleChanged")
|
|
end
|
|
|
|
function MapCanvasMixin:OnCanvasPanChanged()
|
|
local pins = self.activePins
|
|
for i = 1, table.getn(pins) do
|
|
local pin = pins[i]
|
|
if pin.OnCanvasPanChanged then
|
|
pin:OnCanvasPanChanged()
|
|
end
|
|
end
|
|
self:CallDataProviders("OnCanvasPanChanged")
|
|
end
|
|
|
|
function MapCanvasMixin:OnCanvasSizeChanged()
|
|
local pins = self.activePins
|
|
for i = 1, table.getn(pins) do
|
|
local pin = pins[i]
|
|
if pin.ApplyCurrentPosition then
|
|
pin:ApplyCurrentPosition()
|
|
end
|
|
end
|
|
self:CallDataProviders("OnCanvasSizeChanged")
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Pins
|
|
--------------------------------------------------------------------------------
|
|
|
|
function MapCanvasMixin:GetPinPool(pinTemplate)
|
|
local pool = self.pinPools[pinTemplate]
|
|
if not pool then
|
|
local frameType = self.pinTemplateTypes[pinTemplate] or "Frame"
|
|
pool = CreateFramePool(frameType, self:GetCanvas(), pinTemplate, function(_, pin)
|
|
pin:Hide()
|
|
pin:ClearAllPoints()
|
|
if pin.OnReleased then
|
|
pin:OnReleased()
|
|
end
|
|
end)
|
|
self.pinPools[pinTemplate] = pool
|
|
end
|
|
return pool
|
|
end
|
|
|
|
-- Declare the frame type a template needs, for the templates that are not plain Frames. Era's own
|
|
-- `SetPinTemplateType`; without it a Button-shaped pin would be created as a Frame and its
|
|
-- `SetScript("OnClick")` would be nil.
|
|
function MapCanvasMixin:SetPinTemplateType(pinTemplate, pinFrameType)
|
|
self.pinTemplateTypes[pinTemplate] = pinFrameType
|
|
end
|
|
|
|
-- Declared vararg so `arg` is this function's own table: `LUA_COMPAT_VARARG` hands it over to a
|
|
-- vararg function that does not otherwise mention `...` (`script/lua50.rs`), and without the `...`
|
|
-- `arg` would resolve to the *global* one a script handler leaves behind.
|
|
function MapCanvasMixin:AcquirePin(pinTemplate, ...)
|
|
local extra = arg
|
|
local pool = self:GetPinPool(pinTemplate)
|
|
local pin, isNew = pool:Acquire()
|
|
pin.pinTemplate = pinTemplate
|
|
pin.owningMap = self
|
|
if isNew then
|
|
for k, v in pairs(MapCanvasPinMixin) do
|
|
if pin[k] == nil then
|
|
pin[k] = v
|
|
end
|
|
end
|
|
if pin.OnLoad then
|
|
pin:OnLoad()
|
|
end
|
|
end
|
|
self.activePins[table.getn(self.activePins) + 1] = pin
|
|
pin:Show()
|
|
pin:ApplyFrameLevel()
|
|
if pin.OnAcquired then
|
|
if extra and table.getn(extra) > 0 then
|
|
pin:OnAcquired(unpack(extra))
|
|
else
|
|
pin:OnAcquired()
|
|
end
|
|
end
|
|
pin:ApplyCurrentScale()
|
|
return pin
|
|
end
|
|
|
|
function MapCanvasMixin:RemovePin(pin)
|
|
for i = 1, table.getn(self.activePins) do
|
|
if self.activePins[i] == pin then
|
|
table.remove(self.activePins, i)
|
|
break
|
|
end
|
|
end
|
|
local pool = pin.pinTemplate and self.pinPools[pin.pinTemplate]
|
|
if pool then
|
|
pool:Release(pin)
|
|
else
|
|
pin:Hide()
|
|
end
|
|
end
|
|
|
|
function MapCanvasMixin:RemoveAllPinsByTemplate(pinTemplate)
|
|
local i = 1
|
|
while i <= table.getn(self.activePins) do
|
|
local pin = self.activePins[i]
|
|
if pin.pinTemplate == pinTemplate then
|
|
table.remove(self.activePins, i)
|
|
local pool = self.pinPools[pinTemplate]
|
|
if pool then
|
|
pool:Release(pin)
|
|
else
|
|
pin:Hide()
|
|
end
|
|
else
|
|
i = i + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
function MapCanvasMixin:RemoveAllPins()
|
|
while table.getn(self.activePins) > 0 do
|
|
self:RemovePin(self.activePins[1])
|
|
end
|
|
end
|
|
|
|
-- An iterator, because that is what every caller writes: `for pin in
|
|
-- map:EnumeratePinsByTemplate(t) do`. A template with no pins answers an iterator that stops
|
|
-- immediately, never nil, so the loop is always safe.
|
|
function MapCanvasMixin:EnumeratePinsByTemplate(pinTemplate)
|
|
local pins = self.activePins
|
|
local i = 0
|
|
return function()
|
|
while true do
|
|
i = i + 1
|
|
local pin = pins[i]
|
|
if not pin then
|
|
return nil
|
|
end
|
|
if pin.pinTemplate == pinTemplate then
|
|
return pin
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function MapCanvasMixin:EnumerateAllPins()
|
|
local pins = self.activePins
|
|
local i = 0
|
|
return function()
|
|
i = i + 1
|
|
return pins[i]
|
|
end
|
|
end
|
|
|
|
function MapCanvasMixin:GetNumActivePinsByTemplate(pinTemplate)
|
|
local n = 0
|
|
for i = 1, table.getn(self.activePins) do
|
|
if self.activePins[i].pinTemplate == pinTemplate then
|
|
n = n + 1
|
|
end
|
|
end
|
|
return n
|
|
end
|
|
|
|
function MapCanvasMixin:GetHoveredPin()
|
|
for i = 1, table.getn(self.activePins) do
|
|
local pin = self.activePins[i]
|
|
if pin:IsShown() and pin:IsMouseOver() then
|
|
return pin
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Handlers: canvas clicks, global pin mouse actions, the cursor
|
|
--
|
|
-- Each is a priority-sorted list, highest first, and the first handler that returns true consumes
|
|
-- the event. That ordering is the contract — a canvas addon registers at priority 1000 precisely
|
|
-- so it sees a click before Blizzard's own handlers.
|
|
--------------------------------------------------------------------------------
|
|
|
|
local function InsertByPriority(list, handler, priority)
|
|
local entry = { handler = handler, priority = priority or 0 }
|
|
local n = table.getn(list)
|
|
local at = n + 1
|
|
for i = 1, n do
|
|
if (list[i].priority or 0) < entry.priority then
|
|
at = i
|
|
break
|
|
end
|
|
end
|
|
table.insert(list, at, entry)
|
|
end
|
|
|
|
function MapCanvasMixin:AddCanvasClickHandler(handler, priority)
|
|
InsertByPriority(self.canvasClickHandlers, handler, priority)
|
|
end
|
|
|
|
function MapCanvasMixin:AddGlobalPinMouseActionHandler(handler, priority)
|
|
InsertByPriority(self.globalPinMouseActionHandlers, handler, priority)
|
|
end
|
|
|
|
function MapCanvasMixin:AddCursorHandler(handler, priority)
|
|
InsertByPriority(self.cursorHandlers, handler, priority)
|
|
end
|
|
|
|
function MapCanvasMixin:ProcessCanvasClickHandlers(button, cursorX, cursorY)
|
|
local list = self.canvasClickHandlers
|
|
for i = 1, table.getn(list) do
|
|
local ok, consumed = pcall(list[i].handler, self, button, cursorX, cursorY)
|
|
if not ok then
|
|
geterrorhandler()(consumed)
|
|
elseif consumed then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function MapCanvasMixin:ProcessGlobalPinMouseActionHandlers(mouseAction, button)
|
|
local list = self.globalPinMouseActionHandlers
|
|
for i = 1, table.getn(list) do
|
|
local ok, consumed = pcall(list[i].handler, self, mouseAction, button)
|
|
if not ok then
|
|
geterrorhandler()(consumed)
|
|
elseif consumed then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function MapCanvasMixin:ProcessCursorHandlers()
|
|
local list = self.cursorHandlers
|
|
for i = 1, table.getn(list) do
|
|
local ok, cursor = pcall(list[i].handler, self)
|
|
if not ok then
|
|
geterrorhandler()(cursor)
|
|
elseif cursor then
|
|
return cursor
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Navigation
|
|
--------------------------------------------------------------------------------
|
|
|
|
function MapCanvasMixin:NavigateToMap(mapID)
|
|
self:SetMapID(mapID)
|
|
end
|
|
|
|
function MapCanvasMixin:NavigateToParentMap()
|
|
local info = self.mapID and C_Map.GetMapInfo(self.mapID)
|
|
if info and info.parentMapID and info.parentMapID ~= 0 then
|
|
self:SetMapID(info.parentMapID)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
function MapCanvasMixin:NavigateToCursor()
|
|
local sc = self.ScrollContainer
|
|
if not sc or not self.mapID then
|
|
return false
|
|
end
|
|
local x, y = sc:GetNormalizedCursorPosition()
|
|
local info = C_Map.GetMapInfoAtPosition(self.mapID, x, y)
|
|
if info and info.mapID ~= self.mapID then
|
|
self:SetMapID(info.mapID)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- The map change
|
|
--------------------------------------------------------------------------------
|
|
|
|
function MapCanvasMixin:SetMapID(mapID)
|
|
if not mapID or self.mapID == mapID then
|
|
return
|
|
end
|
|
self.mapID = mapID
|
|
if self.ScrollContainer then
|
|
self.ScrollContainer.mapID = mapID
|
|
end
|
|
-- The stock window follows, so the 1.12 detail art under this canvas and every 1.12 addon's
|
|
-- icons on it are the same place the canvas shows. The engine's own selection is the authority
|
|
-- for both; see `BenillaSelectMapID`.
|
|
BenillaSelectMapID(mapID)
|
|
self:RefreshDetailLayers()
|
|
if self.ScrollContainer then
|
|
self.ScrollContainer:OnCanvasSizeChanged()
|
|
self.ScrollContainer:ResetZoom()
|
|
end
|
|
self:OnMapChanged()
|
|
self:RefreshAllDataProviders(false)
|
|
end
|
|
|
|
-- Follow the stock window when something else moved it (its dropdowns, `SetMapToCurrentZone`,
|
|
-- another addon's `SetMapZoom`), without calling back into it.
|
|
function MapCanvasMixin:SyncFromSelection()
|
|
local mapID = BenillaMapIDForSelection()
|
|
if not mapID or mapID == self.mapID then
|
|
return
|
|
end
|
|
self.mapID = mapID
|
|
if self.ScrollContainer then
|
|
self.ScrollContainer.mapID = mapID
|
|
end
|
|
self:RefreshDetailLayers()
|
|
if self.ScrollContainer then
|
|
self.ScrollContainer:OnCanvasSizeChanged()
|
|
self.ScrollContainer:ResetZoom()
|
|
end
|
|
self:OnMapChanged()
|
|
self:RefreshAllDataProviders(false)
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- The detail layers
|
|
--
|
|
-- Era's canvas builds its base art from a pool of layer frames. Ours has exactly one layer and it
|
|
-- is 1.12's own `WorldMapDetailFrame`, reparented into the canvas — so the base art is whatever
|
|
-- the stock window draws, from the same selection, and there is no second copy of the tiling law
|
|
-- to disagree with it. The pool exists because a canvas addon enumerates it to hide the base art
|
|
-- while it draws its own (`for layer in map.detailLayerPool:EnumerateActive() do`), and that is
|
|
-- the only thing anyone does with it.
|
|
--------------------------------------------------------------------------------
|
|
|
|
local DetailLayerPoolMixin = {}
|
|
|
|
function DetailLayerPoolMixin:EnumerateActive()
|
|
local layers = self.layers
|
|
local i = 0
|
|
return function()
|
|
i = i + 1
|
|
return layers[i]
|
|
end
|
|
end
|
|
|
|
function DetailLayerPoolMixin:GetNumActive()
|
|
return table.getn(self.layers)
|
|
end
|
|
|
|
function MapCanvasMixin:RefreshDetailLayers()
|
|
-- `WorldMapFrame_Update` is the stock repaint, driven by `WORLD_MAP_UPDATE`; nothing to do
|
|
-- here but let a hook see the call, which is what a canvas addon hooks it for.
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- MapUtil
|
|
--------------------------------------------------------------------------------
|
|
|
|
MapUtil = {}
|
|
|
|
function MapUtil.GetDisplayableMapForPlayer()
|
|
local mapID = C_Map.GetBestMapForUnit("player")
|
|
if mapID then
|
|
return mapID
|
|
end
|
|
return C_Map.GetFallbackWorldMapID()
|
|
end
|
|
|
|
-- The name of the smallest thing under the cursor: a sub-area's if its highlight claims the
|
|
-- point, else the child map's, else nothing. Over `C_Map`, so the name is the one the stock
|
|
-- window's own hover would show for that point.
|
|
function MapUtil.FindBestAreaNameAtMouse(mapID, x, y)
|
|
if not mapID or not x or not y then
|
|
return nil
|
|
end
|
|
local info = C_Map.GetMapInfoAtPosition(mapID, x, y)
|
|
if info and info.mapID ~= mapID then
|
|
return info.name
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function MapUtil.ShouldMapTypeShowQuests()
|
|
return true
|
|
end
|
|
|
|
function MapUtil.IsMapTypeAllowed(mapType)
|
|
return mapType == Enum.UIMapType.Continent or mapType == Enum.UIMapType.Zone
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Bolting the canvas onto 1.12's WorldMapFrame
|
|
--------------------------------------------------------------------------------
|
|
|
|
local function Mix(target, mixin)
|
|
for k, v in pairs(mixin) do
|
|
target[k] = v
|
|
end
|
|
end
|
|
|
|
local function BuildDefaultFrameLevels(manager)
|
|
-- Blizzard's own band names and order, bottom to top. The names are the contract: a canvas
|
|
-- addon inserts its own band relative to one of these by name, so a missing or renamed band is
|
|
-- art landing in the wrong place.
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_TERRAIN", 4)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_MAP_EXPLORATION", 4)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_MAP_HIGHLIGHT", 4)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_AREA_POI", 8)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_FLIGHT_POINT", 8)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_WORLD_QUEST", 16)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_STORY_LINE", 8)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_GROUP_MEMBER", 8)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_CORPSE", 4)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_UNIT", 8)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_MAP_LINK", 4)
|
|
manager:InsertFrameLevel("PIN_FRAME_LEVEL_DEATH_RELEASE", 4)
|
|
end
|
|
|
|
-- The pan mechanism: the canvas's own TOPLEFT against the container's, which is Blizzard's own
|
|
-- way and the one whose signs this file controls. A ScrollFrame's scroll offsets would do it with
|
|
-- no layout touch, but their sign convention is the engine's rather than this file's, and a pan
|
|
-- already dirties the layout of everything on the canvas.
|
|
function MapCanvasScrollControllerMixin:ApplyCurrentScroll()
|
|
local child = self.Child
|
|
if not child then
|
|
return
|
|
end
|
|
local scale = self:GetCanvasScale()
|
|
local cw = child:GetWidth() * scale
|
|
local ch = child:GetHeight() * scale
|
|
-- The canvas point under the container's middle is `currentScroll`; place the canvas so that
|
|
-- point lands there. Divided by the scale because the offsets are in the canvas's own units,
|
|
-- which the scale multiplies.
|
|
local offsetX = ((self.currentScrollX or 0.5) * cw - self:GetWidth() * 0.5) / scale
|
|
local offsetY = ((self.currentScrollY or 0.5) * ch - self:GetHeight() * 0.5) / scale
|
|
child:ClearAllPoints()
|
|
child:SetPoint("TOPLEFT", self, "TOPLEFT", -offsetX, offsetY)
|
|
end
|
|
|
|
local function BuildCanvas()
|
|
local map = WorldMapFrame
|
|
local detail = WorldMapDetailFrame
|
|
local button = WorldMapButton
|
|
if not map or not detail or not button or map.ScrollContainer then
|
|
return
|
|
end
|
|
|
|
Mix(map, MapCanvasMixin)
|
|
map:OnLoad()
|
|
|
|
-- The container takes the detail frame's own rect, so at rest the canvas is exactly where the
|
|
-- stock art was and the window is pixel-identical. A real ScrollFrame, for the clipping its
|
|
-- scroll child and every descendant of it get (see this file's header).
|
|
local sc = CreateFrame("ScrollFrame", "WorldMapScrollContainer", map)
|
|
sc:SetWidth(detail:GetWidth())
|
|
sc:SetHeight(detail:GetHeight())
|
|
-- The detail frame's **own** XML anchor, copied rather than referenced: once it is reparented
|
|
-- into the canvas below, anchoring the container to it would be a cycle — container to detail
|
|
-- to canvas to container — and a cycle resolves to nothing, which showed up as
|
|
-- `WorldMapButton:GetCenter()` answering nil inside the stock hover pass. The measured offsets
|
|
-- against `WorldMapFrame` are the fallback for a chain whose map window is laid out some other
|
|
-- way.
|
|
local guide = WorldMapPositioningGuide
|
|
if guide then
|
|
sc:SetPoint("TOPLEFT", guide, "TOP", -502, -69)
|
|
else
|
|
local l, t = detail:GetLeft(), detail:GetTop()
|
|
local ml, mt = map:GetLeft(), map:GetTop()
|
|
if l and t and ml and mt then
|
|
sc:SetPoint("TOPLEFT", map, "TOPLEFT", l - ml, t - mt)
|
|
else
|
|
sc:SetPoint("TOPLEFT", map, "TOPLEFT", 0, 0)
|
|
end
|
|
end
|
|
-- No mouse of its own: the map's mouse surface is 1.12's `WorldMapButton`, which covers the
|
|
-- same rect and is above this in the tree, so a mouse-enabled container here would never see
|
|
-- a press anyway. The hooks below drive the controller from that button instead.
|
|
sc:EnableMouse(false)
|
|
|
|
-- ...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)
|
|
detail:ClearAllPoints()
|
|
detail:SetAllPoints(canvas)
|
|
button:SetParent(canvas)
|
|
|
|
-- 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)
|
|
|
|
-- 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")
|
|
|
|
-- Seat the first map before anything reads `GetMapID()`: the stock window has already resolved
|
|
-- the player's zone by the time this runs, so the canvas starts where the window is.
|
|
map.mapID = BenillaMapIDForSelection() or WORLD_MAP_ID
|
|
sc.mapID = map.mapID
|
|
sc:OnCanvasSizeChanged()
|
|
|
|
map:HookScript("OnShow", function()
|
|
WorldMapFrame:SyncFromSelection()
|
|
WorldMapFrame:RefreshAllDataProviders(true)
|
|
WorldMapFrame:CallDataProviders("OnShow")
|
|
end)
|
|
map:HookScript("OnHide", function()
|
|
WorldMapFrame:CallDataProviders("OnHide")
|
|
end)
|
|
end
|
|
|
|
local loader = CreateFrame("Frame", "BenillaMapCanvasLoader")
|
|
loader:RegisterEvent("PLAYER_LOGIN")
|
|
loader:RegisterEvent("WORLD_MAP_UPDATE")
|
|
loader:SetScript("OnEvent", function()
|
|
if event == "PLAYER_LOGIN" then
|
|
BuildCanvas()
|
|
elseif event == "WORLD_MAP_UPDATE" and WorldMapFrame and WorldMapFrame.SyncFromSelection then
|
|
-- Something moved the stock window: its dropdowns, `SetMapToCurrentZone`, another addon.
|
|
-- The canvas follows without calling back, so the two cannot fight over the selection.
|
|
WorldMapFrame:SyncFromSelection()
|
|
end
|
|
end)
|