Files
CombatLedger/UI_EncounterReport.lua
kobeniiiiii 5cc3a7478e Add pfUI chat dock, class icons, ability icons, fix text collision and window bounds
New-user feedback batch:
- New optional pfUI right-chat-panel dock (UI_PfuiDock.lua), using
  pfUI's real pfUI.thirdparty.meters:RegisterMeter API (same one
  TWThreat/SW_Stats/KTM use) - opt-in via Options, off by default.
- New "Show class icon" option - a real class icon before the name on
  each bar, independent of the existing class-colored fill.
- Spell/melee icons added to the breakdown window: the "Top Ability"
  stat row, and a large icon next to the name when a specific ability
  is selected in the detail panel.
- Fixed text collision across the breakdown window (detail rows,
  per-ability icon list, target list) and proactively in the main
  window's bars - name text was never bounded against value text in
  any of them, so long names/values could run into each other.
- Main window raised to TOOLTIP strata (highest) so it can't end up
  hidden behind other windows.
- Every CombatLedger window now uses SetClampedToScreen(true) - can no
  longer be dragged/pushed off-screen.
2026-08-21 10:24:04 -05:00

472 lines
16 KiB
Lua

--[[
UI_EncounterReport - a real per-encounter summary: shape-of-the-fight
graph (Aggregator's bucketed series) plus a leaderboard, for one
saved (or the live) encounter. Opened from a History row's "Report"
button, or /cl report for whatever's currently showing as Current
Fight.
Deliberately not a full event-by-event log viewer - see Aggregator's
series comment for why that tradeoff was made. This is the first
real consumer of series data; a future scrubbable timeline (click a
graph point, see exactly what landed there) would need a separate,
live-only raw event buffer layered on top of this, the same way
DeathRecap's rolling buffer already works.
]]
local CL = CombatLedger
local RC = {}
CL.UIEncounterReport = RC
local HEADER_HEIGHT = 40
local TOGGLE_HEIGHT = 20
local GRAPH_HEIGHT = 90
local LEADER_GAP_ABOVE = 6
local BAR_HEIGHT = 16
local BAR_GAP = 2
local FOOTER_GAP = 12
local MAX_LEADER_BARS = 10
local GRAPH_BARS = 40
local WINDOW_WIDTH, WINDOW_HEIGHT = 400, 400
local MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT = 300, 280
local MAX_WINDOW_WIDTH, MAX_WINDOW_HEIGHT = 700, 700
local METRIC_UNIT_FIELD = { damage = "damageDone", healing = "healingDone", taken = "damageTaken" }
local METRIC_LABELS = { damage = "Damage", healing = "Healing", taken = "Taken" }
local METRIC_ORDER = { "damage", "healing", "taken" }
local METRIC_COLOR = {
damage = { 0.95, 0.55, 0.15 },
healing = { 0.25, 0.85, 0.35 },
taken = { 0.85, 0.2, 0.2 },
}
local window = nil
local toggleBtns = {}
local graphBars = {}
local leaderBars = {}
local function FormatNumber(n)
return CL.FormatNumber(n)
end
local function ClassColorHex(classToken)
if classToken and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classToken] then
local c = RAID_CLASS_COLORS[classToken]
return string.format("%02x%02x%02x", c.r * 255, c.g * 255, c.b * 255)
end
return "ffffff"
end
local function ClassColorRGB(classToken)
if classToken and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classToken] then
local c = RAID_CLASS_COLORS[classToken]
return c.r, c.g, c.b
end
return 0.7, 0.7, 0.7
end
-- Downsamples encounter.series (fixed 2s buckets, may run long past
-- GRAPH_BARS worth of them) into exactly GRAPH_BARS points, each the
-- average per-second rate over its merged window - averaging by actual
-- elapsed seconds (not just bucket count) keeps a shorter trailing
-- group from reading as artificially taller/shorter than a full one.
local function BuildGraphPoints(encounter, metric)
local series = (encounter and encounter.series) or {}
local total = table.getn(series)
local points = {}
if total < 1 then return points, 0 end
local bucketSeconds = (CL.Aggregator and CL.Aggregator.SERIES_BUCKET_SECONDS) or 2
local perGroup = math.ceil(total / GRAPH_BARS)
if perGroup < 1 then perGroup = 1 end
local i = 1
local maxRate = 0
while i <= total do
local groupEnd = i + perGroup - 1
if groupEnd > total then groupEnd = total end
local sum = 0
local j
for j = i, groupEnd do
local b = series[j]
if b then sum = sum + (b[metric] or 0) end
end
local seconds = (groupEnd - i + 1) * bucketSeconds
local rate = (seconds > 0) and (sum / seconds) or 0
table.insert(points, rate)
if rate > maxRate then maxRate = rate end
i = i + perGroup
end
return points, maxRate
end
local function CreateGraphBar(parent)
local tex = parent:CreateTexture(nil, "ARTWORK")
tex:SetTexture(CL.GetBarTexture())
tex:Hide()
return tex
end
local function CreateLeaderBar(parent, index)
local height = CL.GetBarHeight(BAR_HEIGHT)
local bar = CreateFrame("Button", nil, parent)
bar:SetHeight(height)
bar:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -((index - 1) * (height + BAR_GAP)))
bar:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -((index - 1) * (height + BAR_GAP)))
bar:EnableMouse(true)
bar:RegisterForClicks("LeftButtonUp")
local statusBar = CreateFrame("StatusBar", nil, bar)
statusBar:SetAllPoints(bar)
statusBar:SetStatusBarTexture(CL.GetBarTexture())
statusBar:SetMinMaxValues(0, 1)
statusBar:SetValue(0)
bar.statusBar = statusBar
local bg = statusBar:CreateTexture(nil, "BACKGROUND")
bg:SetAllPoints(statusBar)
bg:SetTexture(CL.GetBarTexture())
bg:SetVertexColor(0.15, 0.15, 0.15, 0.85)
bar.bg = bg
local nameText = statusBar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
nameText:SetPoint("LEFT", statusBar, "LEFT", 4, 0)
nameText:SetJustifyH("LEFT")
CL.ApplyFont(nameText, CL.GetFontSize())
bar.nameText = nameText
local valueText = statusBar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
valueText:SetPoint("RIGHT", statusBar, "RIGHT", -4, 0)
valueText:SetJustifyH("RIGHT")
CL.ApplyFont(valueText, CL.GetFontSize())
bar.valueText = valueText
bar:SetScript("OnClick", function()
if bar.guid and window and window.encounter and CL.UIBreakdown then
CL.UIBreakdown.Show(bar.guid, window.metric, "history", window.encounter)
end
end)
bar:Hide()
return bar
end
local function RestyleReport()
if not window then return end
local r, g, b = CL.GetThemeColor()
CL.ApplyWindowSkin(window, r, g, b, 0.85)
CL.ApplyFont(window.title)
CL.ApplyFont(window.subText)
CL.ApplyFont(window.graphEmptyLabel)
CL.ApplyFont(window.leaderEmptyLabel)
local height = CL.GetBarHeight(BAR_HEIGHT)
CL.RepositionBarPool(leaderBars, height, BAR_GAP)
local i
for i = 1, table.getn(leaderBars) do
leaderBars[i].statusBar:SetStatusBarTexture(CL.GetBarTexture())
leaderBars[i].bg:SetTexture(CL.GetBarTexture())
CL.ApplyFont(leaderBars[i].nameText, CL.GetFontSize())
CL.ApplyFont(leaderBars[i].valueText, CL.GetFontSize())
end
for i = 1, table.getn(graphBars) do
graphBars[i]:SetTexture(CL.GetBarTexture())
end
end
CL.OnAppearanceChanged(RestyleReport)
local function CreateToggleButton(parent, metric, index)
local btn = CreateFrame("Button", nil, parent)
btn:SetHeight(TOGGLE_HEIGHT)
btn:SetWidth(70)
btn:SetPoint("TOPLEFT", parent, "TOPLEFT", 6 + (index - 1) * 74, -HEADER_HEIGHT + 2)
btn:SetBackdrop({
bgFile = "Interface\\BUTTONS\\WHITE8X8", tile = false, tileSize = 0,
edgeFile = "Interface\\BUTTONS\\WHITE8X8", edgeSize = 1,
insets = { left = -1, right = -1, top = -1, bottom = -1 },
})
btn:SetBackdropColor(0.12, 0.12, 0.12, 0.8)
local label = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
label:SetAllPoints(btn)
label:SetJustifyH("CENTER")
label:SetText(METRIC_LABELS[metric])
CL.ApplyFont(label)
btn.label = label
btn.metric = metric
btn:SetScript("OnClick", function()
if window then
window.metric = btn.metric
RC.Refresh()
end
end)
return btn
end
local function CreateWindow()
local f = CreateFrame("Frame", "CombatLedgerEncounterReportWindow", UIParent)
f:SetWidth(WINDOW_WIDTH)
f:SetHeight(WINDOW_HEIGHT)
f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
f:SetBackdrop(CL.WINDOW_BACKDROP)
local themeR, themeG, themeB, themeHex = CL.GetThemeColor()
f:SetBackdropColor(0, 0, 0, CL.GetBackdropAlpha(0.85))
f:SetBackdropBorderColor(themeR, themeG, themeB, 1)
f:SetFrameStrata("MEDIUM")
f:SetClampedToScreen(true)
f:SetMovable(true)
f:EnableMouse(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function() this:StartMoving() end)
f:SetScript("OnDragStop", function()
this:StopMovingOrSizing()
CL.SaveLayout("encounterReport", this)
end)
f:Hide()
CL.ApplyLayout("encounterReport", f, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT, MAX_WINDOW_WIDTH, MAX_WINDOW_HEIGHT)
local alreadyRegistered = false
local i
for i = 1, table.getn(UISpecialFrames) do
if UISpecialFrames[i] == "CombatLedgerEncounterReportWindow" then
alreadyRegistered = true
end
end
if not alreadyRegistered then
table.insert(UISpecialFrames, "CombatLedgerEncounterReportWindow")
end
local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
title:SetPoint("TOP", f, "TOP", 0, -6)
title:SetText("|cff" .. themeHex .. "Encounter Report|r")
CL.ApplyFont(title)
f.title = title
local subText = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
subText:SetPoint("TOP", f, "TOP", 0, -20)
CL.ApplyFont(subText)
f.subText = subText
local closeBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton")
closeBtn:SetWidth(18)
closeBtn:SetHeight(18)
closeBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -2, -2)
closeBtn:SetScript("OnClick", function() f:Hide() end)
for i = 1, table.getn(METRIC_ORDER) do
toggleBtns[i] = CreateToggleButton(f, METRIC_ORDER[i], i)
end
local graphFrame = CreateFrame("Frame", nil, f)
graphFrame:SetPoint("TOPLEFT", f, "TOPLEFT", 6, -(HEADER_HEIGHT + TOGGLE_HEIGHT + 6))
graphFrame:SetPoint("TOPRIGHT", f, "TOPRIGHT", -6, -(HEADER_HEIGHT + TOGGLE_HEIGHT + 6))
graphFrame:SetHeight(GRAPH_HEIGHT)
graphFrame:SetBackdrop({
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
insets = { left = 0, right = 0, top = 0, bottom = 0 },
})
graphFrame:SetBackdropColor(0, 0, 0, 0.3)
f.graphFrame = graphFrame
for i = 1, GRAPH_BARS do
graphBars[i] = CreateGraphBar(graphFrame)
end
local graphEmpty = graphFrame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
graphEmpty:SetPoint("CENTER", graphFrame, "CENTER", 0, 0)
graphEmpty:SetText("No timeline data")
CL.ApplyFont(graphEmpty)
f.graphEmptyLabel = graphEmpty
local leaderLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
leaderLabel:SetPoint("TOPLEFT", graphFrame, "BOTTOMLEFT", 0, -LEADER_GAP_ABOVE)
leaderLabel:SetText("Leaderboard")
CL.ApplyFont(leaderLabel)
f.leaderLabel = leaderLabel
local leaderParent = CreateFrame("Frame", nil, f)
leaderParent:SetPoint("TOPLEFT", leaderLabel, "BOTTOMLEFT", 0, -4)
leaderParent:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -6, FOOTER_GAP)
f.leaderParent = leaderParent
for i = 1, MAX_LEADER_BARS do
leaderBars[i] = CreateLeaderBar(leaderParent, i)
end
local leaderEmpty = leaderParent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
leaderEmpty:SetPoint("CENTER", leaderParent, "CENTER", 0, 0)
leaderEmpty:SetText("No data")
CL.ApplyFont(leaderEmpty)
f.leaderEmptyLabel = leaderEmpty
local grip = CreateFrame("Button", nil, f)
grip:SetWidth(16)
grip:SetHeight(16)
grip:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -2, 2)
grip:SetFrameLevel(f:GetFrameLevel() + 10)
grip:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
grip:SetBackdropColor(themeR, themeG, themeB, 0.4)
grip:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_LEFT")
GameTooltip:SetText("Drag to resize")
GameTooltip:Show()
end)
grip:SetScript("OnLeave", function() GameTooltip:Hide() end)
grip:SetScript("OnMouseDown", function()
this.sizing = true
this.startX, this.startY = GetCursorPosition()
this.startW, this.startH = f:GetWidth(), f:GetHeight()
this.scale = f:GetEffectiveScale()
end)
grip:SetScript("OnMouseUp", function()
this.sizing = nil
CL.SaveLayout("encounterReport", f)
end)
grip:SetScript("OnUpdate", function()
if not this.sizing then return end
local x, y = GetCursorPosition()
local scale = this.scale or 1
local newW = this.startW + (x - this.startX) / scale
local newH = this.startH - (y - this.startY) / scale
if newW < MIN_WINDOW_WIDTH then newW = MIN_WINDOW_WIDTH end
if newW > MAX_WINDOW_WIDTH then newW = MAX_WINDOW_WIDTH end
if newH < MIN_WINDOW_HEIGHT then newH = MIN_WINDOW_HEIGHT end
if newH > MAX_WINDOW_HEIGHT then newH = MAX_WINDOW_HEIGHT end
f:SetWidth(newW)
f:SetHeight(newH)
RC.Refresh()
end)
f.resizeGrip = grip
CL.ApplyWindowSkin(f, themeR, themeG, themeB, 0.85)
if pfUI and pfUI.api then
pcall(pfUI.api.SkinCloseButton, closeBtn)
end
window = f
window.metric = "damage"
return f
end
local function RefreshGraph()
local encounter = window.encounter
local points, maxRate = BuildGraphPoints(encounter, window.metric)
local numPoints = table.getn(points)
local color = METRIC_COLOR[window.metric]
local graphFrame = window.graphFrame
local graphWidth = graphFrame:GetWidth()
-- Stretch to fill the full width regardless of point count, so a
-- short fight (fewer than GRAPH_BARS downsampled groups) still
-- reads as a complete graph instead of a partial one hugging the
-- left edge.
local barWidth = graphWidth / ((numPoints > 0) and numPoints or 1)
if numPoints == 0 then
window.graphEmptyLabel:Show()
else
window.graphEmptyLabel:Hide()
end
local i
for i = 1, GRAPH_BARS do
local bar = graphBars[i]
local value = (i <= numPoints) and points[i] or nil
if value and maxRate > 0 then
local pct = value / maxRate
if pct > 1 then pct = 1 end
if pct < 0.02 then pct = 0.02 end
bar:ClearAllPoints()
bar:SetPoint("BOTTOMLEFT", graphFrame, "BOTTOMLEFT", (i - 1) * barWidth, 0)
bar:SetWidth(barWidth > 1 and (barWidth - 1) or 1)
bar:SetHeight(GRAPH_HEIGHT * pct)
bar:SetVertexColor(color[1], color[2], color[3], 0.9)
bar:Show()
else
bar:Hide()
end
end
end
local function RefreshLeaderboard()
local encounter = window.encounter
local field = METRIC_UNIT_FIELD[window.metric]
local list = {}
if encounter and encounter.units then
local guid, u
for guid, u in pairs(encounter.units) do
local bucket = u[field]
local total = (bucket and bucket.total) or 0
if total > 0 then
table.insert(list, { guid = guid, u = u, total = total })
end
end
end
table.sort(list, function(a, b) return a.total > b.total end)
local maxVal = (list[1] and list[1].total) or 1
local shown = 0
local i
for i = 1, MAX_LEADER_BARS do
local bar = leaderBars[i]
local entry = list[i]
if entry then
shown = shown + 1
local r, g, b = ClassColorRGB(entry.u.classToken)
bar.statusBar:SetStatusBarColor(r, g, b, 0.9)
bar.statusBar:SetValue((maxVal > 0) and (entry.total / maxVal) or 0)
local hex = ClassColorHex(entry.u.classToken)
bar.nameText:SetText(i .. ". |cff" .. hex .. entry.u.name .. "|r")
bar.valueText:SetText(FormatNumber(entry.total))
bar.guid = entry.guid
bar:Show()
else
bar.guid = nil
bar:Hide()
end
end
if shown == 0 then
window.leaderEmptyLabel:Show()
else
window.leaderEmptyLabel:Hide()
end
end
function RC.Refresh()
if not window or not window:IsShown() or not window.encounter then return end
local enc = window.encounter
local themeR, themeG, themeB, themeHex = CL.GetThemeColor()
window.title:SetText("|cff" .. themeHex .. (enc.label or "Encounter") .. "|r")
local durText = string.format("%.0fs", enc.duration or 0)
local zoneText = enc.zone or ""
local pullText = (enc.pullBy and enc.pullBy.name) and (" - pulled by " .. enc.pullBy.name) or ""
window.subText:SetText(zoneText .. " - " .. durText .. pullText)
local i
for i = 1, table.getn(toggleBtns) do
local btn = toggleBtns[i]
if btn.metric == window.metric then
btn:SetBackdropColor(themeR, themeG, themeB, 0.5)
else
btn:SetBackdropColor(0.12, 0.12, 0.12, 0.8)
end
end
RefreshGraph()
RefreshLeaderboard()
end
function RC.Show(encounter)
if not encounter then return end
if not window then CreateWindow() end
window.encounter = encounter
window:Show()
RC.Refresh()
end
function RC.Hide()
if window then window:Hide() end
end