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
2298 lines
87 KiB
Lua
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)
|