Files
moon-well-ui/data/addon/ForeverUI/WorldMapZoom.lua
T
Adrestie 4fa43a4349 Map zoom: enlarge the map instead of scaling it
Scaling WorldMapDetailFrame and WorldMapButton grew every marker with the
map, and the explored areas (WorldMapOverlay<n>) overflowed the window.
The two frames now get width and height x zoom: the client places markers
at their map fraction x that width, so they follow the map at their own
size (markers placed once are moved by the zoom ratio). Tiles and explored
areas are laid out x zoom and cropped to the view.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:34:40 +02:00

477 lines
15 KiB
Lua

-- World map zoom: the mouse wheel enlarges the map toward the cursor (x 1.25 per step, 1 to 4)
-- and a left-button drag pans the zoomed map; a click without drag keeps its meaning.
-- Camelot zooms through map art layers (mapcanvas_scrollcontainermixin.lua), which 3.3.5
-- lacks. The zoom returns to 1 when the map changes, changes mode or closes.
-- The map is ENLARGED, not scaled: WorldMapDetailFrame and WorldMapButton get their width and
-- height x zoom at their own scale. The client places every marker at its map fraction x
-- their width, so the markers move with the map and keep their size, as in camelot. The art
-- (tiles, explored areas) has fixed sizes in the client: it is laid out and cropped here.
-- Units: "map units" are those of the whole map (1002 x 668); the frames' are map units x zoom.
local W = ForeverUI.WorldMap
local Z = { z = 1, cx = 0.5, cy = 0.5, active = false, STEP = 1.25, MAX = 4, THRESHOLD = 4 }
W.zoom = Z
local CARD_W, CARD_H = 1002, 668
local TILE, COLUMNS, ROWS = 256, 4, 3
local function tile(k)
return _G["WorldMapDetailTile" .. k]
end
local function ready()
return W.built and W.canvas and WorldMapDetailFrame and WorldMapButton and WORLDMAP_SETTINGS
end
-- Screen pixels per map unit
local function pixelsPerUnit()
return WorldMapDetailFrame:GetEffectiveScale() * Z.z
end
-- The view: the canvas rectangle in map units, origin at the map's top left
local function viewSize()
local c, d = W.canvas, WorldMapDetailFrame
local k = c:GetEffectiveScale() / d:GetEffectiveScale() / Z.z
return c:GetWidth() * k, c:GetHeight() * k
end
local function view()
local vw, vh = viewSize()
local x, y = Z.cx * CARD_W, Z.cy * CARD_H
return x - vw / 2, y - vh / 2, x + vw / 2, y + vh / 2
end
Z.view = view
-- Keep the center where the view stays inside the map
local function clampView()
local vw, vh = viewSize()
local halfX, halfY = vw / 2 / CARD_W, vh / 2 / CARD_H
if halfX >= 0.5 then Z.cx = 0.5 else Z.cx = math.max(halfX, math.min(1 - halfX, Z.cx)) end
if halfY >= 0.5 then Z.cy = 0.5 else Z.cy = math.max(halfY, math.min(1 - halfY, Z.cy)) end
end
-- ------------------------------------------------------------------ Art
-- 3.3.5 clips nothing outside a ScrollFrame and client frames are never reparented, so each
-- piece of art is cropped to the view: its visible part, at its place x zoom, with matching
-- tex coords. x, y, w, h: the piece in map units; u0, u1, v0, v1: its tex coords.
local function cropRegion(t, x, y, w, h, u0, u1, v0, v1, x0, y0, x1, y1)
local d, z = WorldMapDetailFrame, Z.z
local ix0, iy0 = math.max(x, x0), math.max(y, y0)
local ix1 = math.min(x + w, CARD_W, x1)
local iy1 = math.min(y + h, CARD_H, y1)
t:ClearAllPoints()
if ix1 - ix0 < 0.01 or iy1 - iy0 < 0.01 then
t:SetPoint("TOPLEFT", d, "TOPLEFT", x * z, -y * z)
t:SetWidth(1)
t:SetHeight(1)
t:SetAlpha(0)
else
local du, dv = (u1 - u0) / w, (v1 - v0) / h
t:SetPoint("TOPLEFT", d, "TOPLEFT", ix0 * z, -iy0 * z)
t:SetWidth((ix1 - ix0) * z)
t:SetHeight((iy1 - iy0) * z)
t:SetTexCoord(u0 + (ix0 - x) * du, u0 + (ix1 - x) * du, v0 + (iy0 - y) * dv, v0 + (iy1 - y) * dv)
t:SetAlpha(1)
end
end
-- Explored areas (WorldMapOverlay<n>, laid out by WorldMapFrame_Update in map units from the
-- map's top left): each piece's place, size and tex coords, read after the client sets them
local function overlay(i)
return _G["WorldMapOverlay" .. i]
end
local function recordOverlays()
for i = 1, NUM_WORLDMAP_OVERLAYS or 0 do
local o = overlay(i)
if o and o:IsShown() then
local _, _, _, x, y = o:GetPoint(1)
local ulx, uly, llx, lly, urx = o:GetTexCoord()
o.foreverZoomArt = { x or 0, -(y or 0), o:GetWidth(), o:GetHeight(),
ulx or 0, urx or 1, uly or 0, lly or 1 }
elseif o then
o.foreverZoomArt = nil
end
end
end
local function restoreOverlays()
for i = 1, NUM_WORLDMAP_OVERLAYS or 0 do
local o = overlay(i)
local a = o and o.foreverZoomArt
if a then
o:ClearAllPoints()
o:SetPoint("TOPLEFT", WorldMapDetailFrame, "TOPLEFT", a[1], -a[2])
o:SetWidth(a[3])
o:SetHeight(a[4])
o:SetTexCoord(a[5], a[6], a[7], a[8])
o:SetAlpha(1)
o.foreverZoomArt = nil
end
end
end
local function cropTexture()
local x0, y0, x1, y1 = view()
for rowLine = 1, ROWS do
for column = 1, COLUMNS do
local t = tile((rowLine - 1) * COLUMNS + column)
if t then
cropRegion(t, (column - 1) * TILE, (rowLine - 1) * TILE, TILE, TILE, 0, 1, 0, 1,
x0, y0, x1, y1)
end
end
end
for i = 1, NUM_WORLDMAP_OVERLAYS or 0 do
local o = overlay(i)
local a = o and o.foreverZoomArt
if a and o:IsShown() then
cropRegion(o, a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], x0, y0, x1, y1)
end
end
end
-- Tile anchors from WorldMapFrame.xml (541-636): the first at the corner, each right of the
-- previous one, each row under the first tile of the row above; then the small-window crop
-- (WorldMap.lua)
local function rechainTiles()
for k = 1, COLUMNS * ROWS do
local t = tile(k)
if t then
t:ClearAllPoints()
t:SetAlpha(1)
if k == 1 then
t:SetPoint("TOPLEFT", WorldMapDetailFrame, "TOPLEFT", 0, 0)
elseif (k - 1) % COLUMNS == 0 then
t:SetPoint("TOPLEFT", tile(k - COLUMNS), "BOTTOMLEFT", 0, 0)
else
t:SetPoint("TOPLEFT", tile(k - 1), "TOPRIGHT", 0, 0)
end
end
end
if W.cropTiles then W.cropTiles(true) end
end
-- --------------------------------------------------------------- Markers
-- A marker out of the view fades out and releases the mouse (invisible, it would still
-- steal hover and clicks from the quest log or the frame); back in view, it gets its alpha
-- and mouse back
local function turnOff(f)
f.foreverZoomAlpha = f:GetAlpha()
f.foreverZoomMouse = f.IsMouseEnabled and f:IsMouseEnabled() and true or false
f:SetAlpha(0)
if f.foreverZoomMouse then f:EnableMouse(false) end
end
local function restore(f)
f:SetAlpha(f.foreverZoomAlpha)
if f.foreverZoomMouse then f:EnableMouse(true) end
f.foreverZoomAlpha, f.foreverZoomMouse = nil, nil
end
-- Fade out the markers outside the view, restore those back inside
local function sortBy()
local c = W.canvas
local e = c:GetEffectiveScale()
local left, down, right, top = c:GetLeft(), c:GetBottom(), c:GetRight(), c:GetTop()
if not (left and down and right and top) then return end
left, down, right, top = left * e, down * e, right * e, top * e
for _, parent in ipairs({ WorldMapButton, WorldMapPOIFrame }) do
if parent then
for _, f in ipairs({ parent:GetChildren() }) do
if f ~= WorldMapFrameAreaFrame and f:IsShown() then
local x, y = f:GetCenter()
local fe = f:GetEffectiveScale()
local inside = not x or (x * fe >= left and x * fe <= right and y * fe >= down and y * fe <= top)
if inside then
if f.foreverZoomAlpha then restore(f) end
elseif not f.foreverZoomAlpha then
turnOff(f)
end
end
end
end
end
end
local function restoreChildren()
for _, parent in ipairs({ WorldMapButton, WorldMapPOIFrame }) do
if parent then
for _, f in ipairs({ parent:GetChildren() }) do
if f.foreverZoomAlpha then restore(f) end
end
end
end
end
-- Markers placed once (landmarks, quest POIs...) keep the place the client gave them at the
-- previous zoom: their offsets from the map's top left go x new / old. Markers the client
-- places on each frame (units) already follow the width. foreverZoomSet remembers the
-- offsets written here, to tell them from offsets the client wrote since.
local function replaceMarkers(old, new)
if old == new then return end
local d, b, p = WorldMapDetailFrame, WorldMapButton, WorldMapPOIFrame
for _, parent in ipairs({ b, p, d }) do
if parent then
for _, f in ipairs({ parent:GetChildren() }) do
if f ~= b and f ~= p and f ~= WorldMapBlobFrame and f ~= WorldMapFrameAreaFrame
and f:GetNumPoints() == 1 then
local point, rel, relPoint, x, y = f:GetPoint(1)
if (rel == d or rel == b or rel == p) and relPoint == "TOPLEFT" and x and y then
local s = f.foreverZoomSet
local bx, by
if s and math.abs(s[1] - x) < 1e-3 and math.abs(s[2] - y) < 1e-3 then
bx, by = s[3], s[4]
else
bx, by = x / old, y / old
end
f:ClearAllPoints()
f:SetPoint(point, rel, relPoint, bx * new, by * new)
f.foreverZoomSet = { bx * new, by * new, bx, by }
end
end
end
end
end
end
-- ------------------------------------------------------------- Apply
-- Current map key: a map change resets the zoom to 1
function Z.mapKey()
return tostring(GetCurrentMapAreaID and GetCurrentMapAreaID()) .. ":" ..
tostring(GetCurrentMapDungeonLevel and GetCurrentMapDungeonLevel())
end
-- The enlarged frames: WorldMapDetailFrame and WorldMapButton (siblings, same scale and corner)
local function enlarge()
for f, s in pairs(Z.sizes) do
f:SetWidth(s[1] * Z.z)
f:SetHeight(s[2] * Z.z)
end
end
-- The quest blob (WorldMapBlobFrame) is drawn by the engine in one piece and would overflow,
-- so it hides while zoomed; WorldMapButton's hit rect is limited to the view.
-- old: the zoom before this change (markers placed once follow it)
function Z.apply(old)
local d = WorldMapDetailFrame
if not Z.active then
Z.active = true
Z.points = {}
for i = 1, d:GetNumPoints() do
Z.points[i] = { d:GetPoint(i) }
end
Z.sizes = {
[d] = { d:GetWidth(), d:GetHeight() },
[WorldMapButton] = { WorldMapButton:GetWidth(), WorldMapButton:GetHeight() },
}
Z.key = Z.mapKey()
recordOverlays()
old = 1
end
old = old or Z.z
local z = Z.z
enlarge()
clampView()
d:ClearAllPoints()
d:SetPoint("CENTER", W.canvas, "CENTER", (0.5 - Z.cx) * CARD_W * z, (Z.cy - 0.5) * CARD_H * z)
if WorldMapBlobFrame then
WorldMapBlobFrame:SetAlpha(0)
if WorldMapBlobFrame_CalculateHitTranslations then WorldMapBlobFrame_CalculateHitTranslations() end
end
local a = WorldMapFrameAreaFrame
if a then
a:ClearAllPoints()
a:SetPoint("TOP", W.canvas, "TOP", 0, -10)
end
local x0, y0, x1, y1 = view()
WorldMapButton:SetHitRectInsets(math.max(0, x0) * z, math.max(0, CARD_W - x1) * z,
math.max(0, y0) * z, math.max(0, CARD_H - y1) * z)
cropTexture()
replaceMarkers(old, z)
sortBy()
end
-- Back to the whole map. After a mode change, the client and WorldMap.lua have just set
-- their own anchors: the map's is kept then.
function Z.reset(isModeChange)
if not Z.active then
Z.z = 1
return
end
local old = Z.z
Z.z = 1
Z.active = false
enlarge()
local d = WorldMapDetailFrame
if not isModeChange and Z.points then
d:ClearAllPoints()
for _, p in ipairs(Z.points) do
d:SetPoint(p[1], p[2], p[3], p[4], p[5])
end
end
if WorldMapBlobFrame then
WorldMapBlobFrame:SetAlpha(1)
if WorldMapBlobFrame_CalculateHitTranslations then WorldMapBlobFrame_CalculateHitTranslations() end
end
local a = WorldMapFrameAreaFrame
if a then
a:ClearAllPoints()
a:SetPoint("TOP", WorldMapButton, "TOP", 0, -10)
end
WorldMapButton:SetHitRectInsets(0, 0, 0, 0)
WorldMapButton:EnableMouse(true)
Z.pressPoint, Z.dragging = nil, false
rechainTiles()
restoreOverlays()
replaceMarkers(old, 1)
restoreChildren()
Z.cx, Z.cy = 0.5, 0.5
end
-- ------------------------------------------------------------- Mouse wheel
-- Map point under the cursor, and the cursor offset from the canvas center in screen pixels
local function pointUnderCursor()
local x, y = GetCursorPosition()
local c = W.canvas
local cx, cy = c:GetCenter()
if not cx then return nil end
local ce = c:GetEffectiveScale()
local dx, dy = x - cx * ce, y - cy * ce
local de = pixelsPerUnit()
return Z.cx + dx / (CARD_W * de), Z.cy - dy / (CARD_H * de), dx, dy, de
end
function Z.wheel(direction)
if not ready() then return end
local old = Z.z
local new = direction > 0 and old * Z.STEP or old / Z.STEP
if new > Z.MAX then new = Z.MAX end
if new < 1.0001 then new = 1 end
if new == old then return end
if new == 1 then
Z.reset(false)
return
end
if not Z.active then Z.cx, Z.cy = 0.5, 0.5 end
-- Keep the point under the cursor in place (camelot: SetPanTarget)
local fx, fy, dx, dy, de = pointUnderCursor()
Z.z = new
if fx then
local de2 = de * new / old
Z.cx = fx - dx / (CARD_W * de2)
Z.cy = fy + dy / (CARD_H * de2)
end
Z.apply(old)
end
-- --------------------------------------------------------------- Watcher
local mapKey = Z.mapKey
-- Child of the map frame, so it only runs while the map is open
local watcher = CreateFrame("Frame", nil, WorldMapFrame)
watcher:SetScript("OnUpdate", function(_, elapsed)
if not (Z.active and ready()) then
Z.key = ready() and mapKey() or nil
return
end
if mapKey() ~= Z.key then
Z.reset(false)
Z.key = mapKey()
return
end
-- Drag: past THRESHOLD pixels the map follows the cursor. WorldMapButton_OnClick navigates
-- on mouse up, so WorldMapButton releases the mouse until the button is up. The client code
-- is not wrapped: it closes dropdowns and would taint their state if called from the addon.
local pressPoint = Z.pressPoint
if pressPoint then
local x, y = GetCursorPosition()
if not IsMouseButtonDown("LeftButton") then
Z.pressPoint = nil
if Z.dragging then
Z.dragging = false
WorldMapButton:EnableMouse(true)
end
else
if not Z.dragging and math.abs(x - pressPoint.x) + math.abs(y - pressPoint.y) > Z.THRESHOLD then
Z.dragging = true
WorldMapButton:EnableMouse(false)
end
if Z.dragging then
local de = pixelsPerUnit()
Z.cx = pressPoint.cx - (x - pressPoint.x) / (CARD_W * de)
Z.cy = pressPoint.cy + (y - pressPoint.y) / (CARD_H * de)
Z.apply()
return
end
end
end
-- Markers move with their units: ten passes a second are enough (Z.apply sorts at once).
-- Each pass reads every child of the map buttons (about sixty) and builds three tables.
Z.sortWait = (Z.sortWait or 0) - elapsed
if Z.sortWait <= 0 then
Z.sortWait = 0.1
sortBy()
end
end)
Z.watcher = watcher
-- ------------------------------------------------------------ Hooks
if WorldMapButton then
WorldMapButton:EnableMouseWheel(true)
WorldMapButton:SetScript("OnMouseWheel", function(_, direction)
Z.wheel(direction)
end)
WorldMapButton:HookScript("OnMouseDown", function(_, button)
if button == "LeftButton" and Z.active then
local x, y = GetCursorPosition()
Z.pressPoint = { x = x, y = y, cx = Z.cx, cy = Z.cy }
Z.dragging = false
end
end)
-- Player arrow: WorldMapButton_OnUpdate places it (position x the map's width x
-- WORLDMAP_SETTINGS.size); place it again, or hide it out of the view
WorldMapButton:HookScript("OnUpdate", function()
if not Z.active then return end
local x, y = GetPlayerMapPosition("player")
if not x or (x == 0 and y == 0) then return end
local px, py = x * CARD_W, y * CARD_H
local x0, y0, x1, y1 = view()
if px < x0 or px > x1 or py < y0 or py > y1 then
if ShowWorldMapArrowFrame then ShowWorldMapArrowFrame(nil) end
elseif PositionWorldMapArrowFrame then
local s = WORLDMAP_SETTINGS.size * Z.z
PositionWorldMapArrowFrame("CENTER", "WorldMapDetailFrame", "TOPLEFT", px * s, -py * s)
end
end)
end
if WorldMapFrame then
WorldMapFrame:HookScript("OnHide", function() Z.reset(false) end)
end
-- WorldMapFrame_Update reloads the tiles and lays out the explored areas again
-- (WORLD_MAP_UPDATE): crop them again while zoomed
if WorldMapFrame_Update then
hooksecurefunc("WorldMapFrame_Update", function()
if Z.active then
recordOverlays()
cropTexture()
end
end)
end
-- Mode or view change: back to 1, without undoing what the client has just set
for _, name in ipairs({ "WorldMap_ToggleSizeDown", "WorldMap_ToggleSizeUp", "WorldMapFrame_SetMiniMode",
"WorldMapFrame_SetQuestMapView", "WorldMapFrame_SetFullMapView" }) do
if _G[name] then
hooksecurefunc(name, function() Z.reset(true) end)
end
end