diff --git a/common/configs/keybind_catalog.json b/common/configs/keybind_catalog.json index c52662d3a11..967ee9e2a59 100644 --- a/common/configs/keybind_catalog.json +++ b/common/configs/keybind_catalog.json @@ -154,11 +154,6 @@ "label": "commands.patrol", "alwaysModifier": "shift" }, - { - "action": "wantcloak", - "label": "actions.orders.cloak", - "alwaysModifier": "any" - }, { "action": "stop", "label": "commands.stop", @@ -285,13 +280,25 @@ "description": "commands.repeat_tooltip" }, { - "prefix": "trajectory_toggle ", - "label": "actions.unitStates.trajectory", - "members": [ - "0", - "1", - "2" - ] + "action": "wantcloak", + "label": "actions.orders.cloak", + "description": "commands.wantcloak_tooltip", + "alwaysModifier": "any" + }, + { + "action": "trajectory_toggle 0", + "label": "actions.unitStates.trajectoryLow", + "description": "commands.trajectory_toggle_tooltip" + }, + { + "action": "trajectory_toggle 1", + "label": "actions.unitStates.trajectoryHigh", + "description": "commands.trajectory_toggle_tooltip" + }, + { + "action": "trajectory_toggle 2", + "label": "actions.unitStates.trajectoryAuto", + "description": "commands.trajectory_toggle_tooltip" }, { "action": "firestate 0", @@ -322,6 +329,51 @@ "action": "movestate 2", "label": "actions.unitStates.moveRoam", "description": "commands.movestate_tooltip" + }, + { + "action": "idlemode", + "label": "actions.unitStates.idleMode", + "description": "commands.idlemode_tooltip" + }, + { + "action": "idlemode 0", + "label": "actions.unitStates.idleModeFly", + "description": "commands.idlemode_tooltip" + }, + { + "action": "idlemode 1", + "label": "actions.unitStates.idleModeLand", + "description": "commands.idlemode_tooltip" + }, + { + "action": "priority", + "label": "actions.unitStates.buildPriority", + "description": "commands.priority_tooltip" + }, + { + "action": "priority 0", + "label": "actions.unitStates.buildPriorityLow", + "description": "commands.priority_tooltip" + }, + { + "action": "priority 1", + "label": "actions.unitStates.buildPriorityHigh", + "description": "commands.priority_tooltip" + }, + { + "action": "aplandat", + "label": "actions.unitStates.airPlantExit", + "description": "commands.aplandat_tooltip" + }, + { + "action": "aplandat 0", + "label": "actions.unitStates.airPlantExitFly", + "description": "commands.aplandat_tooltip" + }, + { + "action": "aplandat 1", + "label": "actions.unitStates.airPlantExitLand", + "description": "commands.aplandat_tooltip" } ] }, @@ -509,7 +561,8 @@ }, { "action": "factoryqueuemode", - "label": "actions.factory.queueMode" + "label": "actions.factory.queueMode", + "description": "commands.factoryqueuemode_tooltip" }, { "prefix": "factoryguard ", @@ -726,6 +779,10 @@ "action": "unit_stats", "label": "actions.interfaceDisplay.unitStats" }, + { + "action": "customgameinfo", + "label": "actions.interfaceDisplay.gameInfo" + }, { "action": "savegame", "label": "actions.interfaceDisplay.saveGame" diff --git a/common/configs/keybind_defaults.json b/common/configs/keybind_defaults.json index cccfb377946..97e2dbae499 100644 --- a/common/configs/keybind_defaults.json +++ b/common/configs/keybind_defaults.json @@ -911,6 +911,10 @@ "keyset": "sc_i", "action": "unit_stats" }, + { + "keyset": "Ctrl+sc_i", + "action": "customgameinfo" + }, { "keyset": "sc_j", "action": "loadunits" @@ -2162,6 +2166,10 @@ "keyset": "sc_i", "action": "unit_stats" }, + { + "keyset": "Ctrl+sc_i", + "action": "customgameinfo" + }, { "keyset": "sc_j", "action": "loadunits" @@ -4309,6 +4317,10 @@ "keyset": "Any+space", "action": "unit_stats" }, + { + "keyset": "sc_i", + "action": "customgameinfo" + }, { "keyset": "Ctrl+Shift+sc_o", "action": "cameraflip" @@ -5996,6 +6008,10 @@ "keyset": "Any+space", "action": "unit_stats" }, + { + "keyset": "sc_i", + "action": "customgameinfo" + }, { "keyset": "Ctrl+Shift+sc_o", "action": "cameraflip" diff --git a/gamedata/icontypes.lua b/gamedata/icontypes.lua index e6bb4d4c4ff..65911e4bfec 100644 --- a/gamedata/icontypes.lua +++ b/gamedata/icontypes.lua @@ -2994,11 +2994,11 @@ local icontypes = { }, legshot = { bitmap = "icons/bot_t2_impulse2x.png", - size = 1.60000002, + size = 1.35, }, babylegshot = { bitmap = "icons/kbot_t2_shotgun.png", - size = 1.60000002, + size = 1.0, }, legsnapper = { bitmap = "icons/corroach_0.9.png", diff --git a/language/en/commands.json b/language/en/commands.json index af53c877464..0babc4b85ed 100644 --- a/language/en/commands.json +++ b/language/en/commands.json @@ -152,13 +152,24 @@ "onoffOn": "Turn unit on", "repeatOff": "Repeat off", "repeatOn": "Repeat on", - "trajectory": "Trajectory %{n}", + "trajectoryLow": "Trajectory: Low", + "trajectoryHigh": "Trajectory: High", + "trajectoryAuto": "Trajectory: Auto", "fireHold": "Fire state: Hold fire", "fireReturn": "Fire state: Return fire", "fireAtWill": "Fire state: Fire at will", "moveHold": "Move state: Hold position", "moveManeuver": "Move state: Maneuver", - "moveRoam": "Move state: Roam" + "moveRoam": "Move state: Roam", + "idleMode": "Toggle aircraft idle mode", + "idleModeFly": "Aircraft idle: Fly", + "idleModeLand": "Aircraft idle: Land", + "buildPriority": "Toggle builder priority", + "buildPriorityLow": "Builder priority: Low", + "buildPriorityHigh": "Builder priority: High", + "airPlantExit": "Toggle air plant exit mode", + "airPlantExitFly": "Air plant exit: Fly", + "airPlantExitLand": "Air plant exit: Land" }, "controlGroups": { "select": "Select control group %{n}", @@ -185,7 +196,7 @@ "spacingDown": "Decrease build spacing" }, "factory": { - "queueMode": "Toggle factory repeat mode", + "queueMode": "Toggle factory queue/quota mode", "loadPreset": "Load factory preset %{n}", "savePreset": "Save factory preset %{n}", "showPresets": "Show factory presets", @@ -230,6 +241,7 @@ "interfaceDisplay": { "settings": "Settings", "unitStats": "Unit stats", + "gameInfo": "Game info", "saveGame": "Save game", "screenshot": "Take screenshot", "firingRange": "Display firing range", diff --git a/language/en/interface.json b/language/en/interface.json index ed5887fdf5e..5d542394d25 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -389,8 +389,18 @@ "unitCount": "(%{count} units)", "unitOne": "(1 unit)", "milestones": "Milestones", + "graph": { + "composition": "Composition", + "compositionDesc": "The team's unit value split by kind over the game, as shares of the whole.", + "foldHint": "Click to fold this team's players away, and again to show them.", + "legendHint": "Click to highlight this team and show its milestones; right-click to hide it.", + "blockHint": "The players of this ally team; each square is one of them.", + "all": "All", + "allHint": "Every team alike, with all their milestones on the chart.", + "waiting": "The first samples arrive after 15 seconds.", + "noData": "Nothing to plot for this stat yet." + }, "milestone": { - "factory": "First factory", "tech2": "Tech 2", "tech3": "Tech 3", "nuke": "First nuke", @@ -406,10 +416,11 @@ "combat": "Combat", "units": "Units", "composition": "Composition", - "activity": "Activity", - "graphs": "Graphs" + "activity": "Activity" }, "switch": { + "graphs": "Graphs", + "graphsDesc": "The picked group's stats as charts over the game instead of the table.", "groupByTeam": "Group by team", "groupByTeamDesc": "Players under their team, with the team's totals above them. Off, every player is listed in one sorted list.", "shareOfTeam": "Share of team", @@ -417,7 +428,9 @@ "perMinute": "Per minute", "perMinuteDesc": "Totals divided by the minutes each team was in the game, so a short game or an early exit compares fairly.", "bars": "Bars", - "barsDesc": "A bar behind every number, scaled to the largest in its column." + "barsDesc": "A bar behind every number, scaled to the largest in its column.", + "milestones": "Milestones", + "milestonesDesc": "The highlighted team's milestones as unit pictures on the chart, every team's when none is highlighted; hover one for what it was." }, "damage": "Damage", "damageDealt": "Dealt", diff --git a/luarules/gadgets/api_teamstats.lua b/luarules/gadgets/api_teamstats.lua index 9f52116a515..9698d82c134 100644 --- a/luarules/gadgets/api_teamstats.lua +++ b/luarules/gadgets/api_teamstats.lua @@ -55,12 +55,6 @@ local BUCKETS = { "army", "air", "sea", "defense", "strategic", "factories", "bu -- a team finishes and `lost` on every unit it loses; a milestone is kept once per team, -- or every time when `every` is set. The unit that reached it is stored with it. local MILESTONES = { - { - key = "factory", - built = function(ud) - return ud.isFactory - end, - }, { key = "tech2", built = function(ud) @@ -129,6 +123,7 @@ local SAMPLED = { "teamKillValue", "comKills", "comLost", + "actionsPerMinute", } local countKey, valueKey = {}, {} for i = 1, #BUCKETS do @@ -377,6 +372,9 @@ local function readLive(teamID, out) out.energyStorage = storage or 0 out.convCapacity = spGetTeamRulesParam(teamID, "mmCapacity") or 0 out.convUse = spGetTeamRulesParam(teamID, "mmUse") or 0 + -- The APM broadcast gadget's last figure for the team, so the rate has a history. + local apm = GG.teamAPM + out.actionsPerMinute = apm and apm[teamID] or 0 ---@type number local active = 0 for unitID, buildSpeed in pairs(builders[teamID]) do @@ -612,12 +610,13 @@ local function GetTeamStatsInfo() } end --- For other unsynced gadgets, as globals. +-- For other unsynced gadgets, through the shared table: GG.TeamStats.GetLive(teamID), +-- GetHistory(teamID, from), GetMilestones(teamID), GetInfo(). The same gate applies. local exports = { - GetTeamStatsLive = GetTeamStatsLive, - GetTeamStatsHistory = GetTeamStatsHistory, - GetTeamStatsMilestones = GetTeamStatsMilestones, - GetTeamStatsInfo = GetTeamStatsInfo, + GetLive = GetTeamStatsLive, + GetHistory = GetTeamStatsHistory, + GetMilestones = GetTeamStatsMilestones, + GetInfo = GetTeamStatsInfo, } ---------------------------------------------------------------- @@ -629,16 +628,21 @@ local exports = { ---@diagnostic disable-next-line: undefined-global local Script = Script --- A widget takes part by registering globals: `TeamStatsLive(all, frame)` is handed --- the live values of every team the viewer may see, keyed by team, every LIVE_PERIOD --- frames for as long as it is registered; `TeamStatsHistoryRequest()` returning --- { [teamID] = fromIndex } is answered through `TeamStatsHistory(teamID, history)` --- with the samples from that index on (see GetTeamStatsHistory for the shape), so a --- caller holding the first n samples asks for what came after them. +-- LuaUI takes part by registering globals (luaui/Widgets/api_teamstats.lua holds them +-- for every widget): `TeamStatsLive(all, frame)` is handed the live values of every +-- team the viewer may see, keyed by team, every LIVE_PERIOD frames for as long as it is +-- registered; `TeamStatsInfo(info)` is handed the layout while it is registered; +-- `TeamStatsHistoryRequest()` returning { [teamID] = fromIndex } is answered through +-- `TeamStatsHistory(teamID, history)` with the samples from that index on (see +-- GetTeamStatsHistory for the shape), so a caller holding the first n samples asks for +-- what came after them. local function serveLuaUI(frame) if Script.LuaUI("TeamStatsLive") then Script.LuaUI.TeamStatsLive(GetTeamStatsLive(), frame) end + if Script.LuaUI("TeamStatsInfo") then + Script.LuaUI.TeamStatsInfo(GetTeamStatsInfo()) + end if Script.LuaUI("TeamStatsHistoryRequest") and Script.LuaUI("TeamStatsHistory") then local wanted = Script.LuaUI.TeamStatsHistoryRequest() if type(wanted) == "table" then @@ -683,13 +687,9 @@ function gadget:Initialize() ---@cast unitDefID -? unitCreated(unitID, unitDefID, Spring.GetUnitTeam(unitID)) end - for name, fn in pairs(exports) do - gadgetHandler:RegisterGlobal(name, fn) - end + GG.TeamStats = exports end function gadget:Shutdown() - for name in pairs(exports) do - gadgetHandler:DeregisterGlobal(name) - end + GG.TeamStats = nil end diff --git a/luarules/gadgets/game_apm_broadcast.lua b/luarules/gadgets/game_apm_broadcast.lua index 1e38b9a3a3e..fe635f01634 100644 --- a/luarules/gadgets/game_apm_broadcast.lua +++ b/luarules/gadgets/game_apm_broadcast.lua @@ -97,17 +97,24 @@ if gadgetHandler:IsSyncedCode() then totalTeamActions[teamID] = nil end else -- unsynced + -- The last figure per team, kept for other unsynced gadgets: the team stats gadget + -- samples it into its history. + local teamAPM = {} + local function handleApmEvent(_, teamID, apm) + teamAPM[teamID] = apm if Script.LuaUI("ApmEvent") then Script.LuaUI.ApmEvent(teamID, apm) end end function gadget:Initialize() + GG.teamAPM = teamAPM gadgetHandler:AddSyncAction("apmBroadcast", handleApmEvent) end function gadget:Shutdown() + GG.teamAPM = nil gadgetHandler:RemoveSyncAction("apmBroadcast") end end diff --git a/luaui/Include/graph.lua b/luaui/Include/graph.lua new file mode 100644 index 00000000000..d6ef0219666 --- /dev/null +++ b/luaui/Include/graph.lua @@ -0,0 +1,1587 @@ +-- Charts for widgets: line graphs, 100% stacked areas and radar charts. The picture is +-- baked into a display list and only the hover overlay is drawn each frame, so a chart +-- costs a list call while nothing changes. +-- +-- local Graph = VFS.Include("luaui/Include/graph.lua") +-- local chart = Graph.new({ +-- kind = "line", -- "line" | "stacked" | "radar" +-- x = 100, y = 100, width = 600, height = 300, -- bottom-left corner, screen pixels +-- font = WG.fonts.getFont(), fontSize = 12, +-- title = "Metal income", +-- xs = { 0, 450, 900, ... }, -- one x per sample, shared by the series +-- series = { +-- { name = "Floris", color = { 0.4, 0.7, 1 }, values = { 12, 15, 30, ... } }, +-- { name = "Nemo", color = { 1, 0.5, 0.3 }, points = { { 0, 3 }, { 450, 8 } } }, +-- }, +-- xFormat = function(x) return ... end, -- axis labels and tooltips +-- yFormat = function(y) return ... end, +-- smooth = true, -- monotone cubic between samples, no overshoot +-- markers = { -- unit pictures on the chart, with a text for hover +-- { x = 3000, texture = "#143", text = "1:40 First factory (Bot Lab)", series = 1 }, +-- }, +-- }) +-- chart:draw() -- in DrawScreen +-- local hit = chart:hitTest(mx, my) -- nil, or what is under the cursor +-- chart:setHover(hit) -- what the overlay marks +-- WG.tooltip.ShowTooltip("mychart", chart:describe(hit)) +-- +-- Everything in the constructor can be changed later through chart:configure({ ... }), +-- chart:setSeries(list), chart:setMarkers(list), chart:setBounds(x, y, w, h) and +-- chart:setHighlight(seriesIndex); each marks the picture for a rebuild on the next draw. +-- chart:destroy() frees the list. Radar charts take `radar = { axes = { { key = "speed", +-- label = "Speed", max = 100 }, ... }, rings = 4 }` and series with `values` keyed by axis +-- (an array in axis order, or a table by axis key). A stacked chart turns every sample +-- into shares of that sample's total, so the y axis is 0 to 100%. + +---@class Graph +---@field cfg table +---@field series table[] +---@field markers table[] +---@field highlight integer? +---@field hover table? +---@field list integer? +---@field dirty boolean +---@field pending table[] +---@field pendingCount integer +---@field xs number[] +---@field prepared table[] +---@field plot table +---@field area table +---@field placed table[] +---@field markerRow table +---@field yMin number +---@field yMax number +---@field yStep number +---@field xMin number +---@field xMax number +---@field sx fun(x: number): number +---@field sy fun(y: number): number +---@field xFormatter fun(v: number): string +---@field yFormatter fun(v: number): string +---@field radar table +---@field axes table[] +---@field axisMax fun(ai: integer): number +---@field axisPoint fun(ai: integer, share: number): number, number +local Graph = {} +Graph.__index = Graph + +local glCreateList = gl.CreateList +local glCallList = gl.CallList +local glDeleteList = gl.DeleteList +local glBeginEnd = gl.BeginEnd +local glVertex = gl.Vertex +local glColor = gl.Color +local glTexture = gl.Texture +local glTexRect = gl.TexRect +local glLineWidth = gl.LineWidth +-- Anti-aliased lines; absent in an offline stub. +---@type function? +local glSmoothing = gl.Smoothing +local GL_LINES = GL.LINES +local GL_LINE_STRIP = GL.LINE_STRIP +local GL_LINE_LOOP = GL.LINE_LOOP +local GL_TRIANGLE_STRIP = GL.TRIANGLE_STRIP +local GL_TRIANGLE_FAN = GL.TRIANGLE_FAN +local GL_QUADS = GL.QUADS + +local mathFloor = math.floor +local mathMax = math.max +local mathMin = math.min +local mathAbs = math.abs +local mathHuge = math.huge +local mathSqrt = math.sqrt +local mathCos = math.cos +local mathSin = math.sin +local mathPi = math.pi +local mathLog10 = math.log10 +local stringFormat = string.format +local stringChar = string.char + +---------------------------------------------------------------- +-- Defaults +---------------------------------------------------------------- + +---@type table +local DEFAULTS = { + kind = "line", + x = 0, + y = 0, + width = 300, + height = 150, + fontSize = 12, + -- Room between the frame and what is drawn inside it, in pixels; scales with the font. + pad = nil, + title = nil, + -- The values along x, one per sample, shared by every series that gives `values`. Left + -- out, samples are numbered from 1. + xs = nil, + xFormat = nil, + yFormat = nil, + -- Stacked: each band named inside it at its right end, where it is thick enough. + bandLabels = false, + -- The y axis: fixed ends, or found from the data (a line chart always shows zero). + yMin = nil, + yMax = nil, + includeZero = true, + gridLines = 4, + xTicks = 5, + -- "frames" makes the x axis a game clock: ticks on whole seconds and minutes, labels + -- as m:ss. `xStep` fixes the tick step in x units instead. + xUnit = nil, + xStep = nil, + -- Curves through the samples rather than corners at them. Steps between two samples + -- come from the pixel distance when left nil. + smooth = false, + smoothSteps = nil, + lineWidth = 2, + -- A faint fill under each line. + fill = false, + legend = true, + -- Markers: pictures with a hover text, at an x, on a series or in a lane above the plot. + -- The picture is zoomed in by this share of its edges (nil: a subtle share that grows + -- as the picture shrinks), and framed this thick. + markerSize = nil, + markerZoom = nil, + markerFrameWidth = 2, + radar = { + rings = 4, + axes = nil, + fill = true, + }, + look = { + -- A backdrop under the whole chart, for one drawn straight over the world rather + -- than inside a panel. nil for none. + background = nil, + plotFill = { 0, 0, 0, 0.14 }, + grid = { 1, 1, 1, 0.07 }, + axis = { 1, 1, 1, 0.22 }, + text = "\255\195\195\195", + title = "\255\235\235\235", + outline = { 0, 0, 0, 0.4 }, + crosshair = { 1, 1, 1, 0.22 }, + hoverDot = { 1, 1, 1, 0.9 }, + -- The other series step back to this share of their colour when one is highlighted. + dimAlpha = 0.28, + areaAlpha = 0.6, + fillAlpha = 0.16, + markerFrame = { 1, 1, 1, 0.35 }, + markerHover = { 1, 0.78, 0.51, 0.95 }, + markerTick = { 1, 1, 1, 0.22 }, + radarFillAlpha = 0.22, + }, +} + +local function copyInto(dst, src) + for k, v in pairs(src) do + if type(v) == "table" and type(dst[k]) == "table" then + copyInto(dst[k], v) + elseif type(v) == "table" then + local t = {} + copyInto(t, v) + dst[k] = t + else + dst[k] = v + end + end + return dst +end + +---------------------------------------------------------------- +-- Numbers +---------------------------------------------------------------- + +local function isFinite(v) + return type(v) == "number" and v == v and v ~= mathHuge and v ~= -mathHuge +end + +-- A round step for about `count` divisions of `range`: 1, 2 or 5 times a power of ten. +local function niceStep(range, count) + if range <= 0 or count < 1 then + return 1 + end + local raw = range / count + local mag = 10 ^ mathFloor(mathLog10(raw)) + local norm = raw / mag + local step + if norm < 1.5 then + step = 1 + elseif norm < 3 then + step = 2 + elseif norm < 7 then + step = 5 + else + step = 10 + end + return step * mag +end +Graph.niceStep = niceStep + +local function defaultFormat(v) + if not isFinite(v) then + return "-" + end + if string.formatSI and mathAbs(v) >= 1000 then + return string.formatSI(v) + end + if mathAbs(v) < 10 and v ~= mathFloor(v) then + return stringFormat("%.1f", v) + end + return stringFormat("%d", mathFloor(v + 0.5)) +end + +-- A frame count as a game clock: m:ss, and h:mm:ss past the hour. +local function frameLabel(frames) + if not isFinite(frames) then + return "-" + end + local seconds = mathFloor(frames / 30 + 0.5) + local h = mathFloor(seconds / 3600) + local m = mathFloor(seconds / 60) % 60 + local s = seconds % 60 + if h > 0 then + return stringFormat("%d:%02d:%02d", h, m, s) + end + return stringFormat("%d:%02d", m, s) +end +Graph.frameLabel = frameLabel + +local function colorCode(c) + return stringChar( + 255, + mathMax(1, mathFloor(c[1] * 255)), + mathMax(1, mathFloor(c[2] * 255)), + mathMax(1, mathFloor(c[3] * 255)) + ) +end + +-- Monotone cubic interpolation (Fritsch-Carlson): a curve through the samples that never +-- overshoots them, so a value that never went above 10 is never drawn above 10. +local function monotoneSlopes(xs, ys) + local n = #xs + ---@type table + local m = {} + if n < 2 then + m[1] = 0 + return m + end + ---@type table + local d = {} + for i = 1, n - 1 do + local dx = xs[i + 1] - xs[i] + d[i] = dx > 0 and (ys[i + 1] - ys[i]) / dx or 0 + end + m[1] = d[1] + m[n] = d[n - 1] + for i = 2, n - 1 do + if d[i - 1] * d[i] <= 0 then + m[i] = 0 + else + m[i] = (d[i - 1] + d[i]) * 0.5 + end + end + for i = 1, n - 1 do + if d[i] == 0 then + m[i] = 0 + m[i + 1] = 0 + else + local a = m[i] / d[i] + local b = m[i + 1] / d[i] + local s = a * a + b * b + if s > 9 then + local t = 3 / mathSqrt(s) + m[i] = t * a * d[i] + m[i + 1] = t * b * d[i] + end + end + end + return m +end + +-- The y at `x` between samples i and i + 1, from the slopes. +local function hermite(xs, ys, m, i, x) + local x1, x2 = xs[i], xs[i + 1] + local h = x2 - x1 + if h <= 0 then + return ys[i] + end + local t = (x - x1) / h + local t2 = t * t + local t3 = t2 * t + local h00 = 2 * t3 - 3 * t2 + 1 + local h10 = t3 - 2 * t2 + t + local h01 = -2 * t3 + 3 * t2 + local h11 = t3 - t2 + return h00 * ys[i] + h10 * h * m[i] + h01 * ys[i + 1] + h11 * h * m[i + 1] +end + +---------------------------------------------------------------- +-- Construction +---------------------------------------------------------------- + +function Graph.new(cfg) + local self = setmetatable({}, Graph) + ---@cast self Graph + self.cfg = copyInto({}, DEFAULTS) + if cfg then + copyInto(self.cfg, cfg) + end + self.series = self.cfg.series or {} + self.markers = self.cfg.markers or {} + self.cfg.series = nil + self.cfg.markers = nil + self.highlight = nil + self.hover = nil + self.list = nil + self.dirty = true + self.pending = {} + self.pendingCount = 0 + self.placed = {} + self.xs = {} + self.prepared = {} + return self +end + +function Graph:configure(cfg) + copyInto(self.cfg, cfg) + if cfg.series then + self.series = cfg.series + self.cfg.series = nil + end + if cfg.markers then + self.markers = cfg.markers + self.cfg.markers = nil + end + self.dirty = true +end + +function Graph:setSeries(series) + self.series = series or {} + self.dirty = true +end + +function Graph:setMarkers(markers) + self.markers = markers or {} + self.dirty = true +end + +function Graph:setBounds(x, y, width, height) + local c = self.cfg + if c.x ~= x or c.y ~= y or c.width ~= width or c.height ~= height then + c.x, c.y, c.width, c.height = x, y, width, height + self.dirty = true + end +end + +-- The series drawn in front, the others stepped back. nil for none. +function Graph:setHighlight(index) + if self.highlight ~= index then + self.highlight = index + self.dirty = true + end +end + +-- What the overlay marks: a hit from hitTest, or nil. +function Graph:setHover(hit) + self.hover = hit +end + +function Graph:destroy() + if self.list then + glDeleteList(self.list) + self.list = nil + end +end + +---------------------------------------------------------------- +-- Text, queued while the list is built and printed in one batch +---------------------------------------------------------------- + +function Graph:text(str, x, y, opts, size) + local n = self.pendingCount + 1 + self.pendingCount = n + self.pending[n] = { str, x, y, size or self.cfg.fontSize, opts or "o" } +end + +function Graph:flushText() + local font = self.cfg.font + if not font or self.pendingCount == 0 then + self.pendingCount = 0 + return + end + font:Begin() + -- The font is shared with every widget; pinned so a bake does not freeze in whatever + -- outline the last one set. + font:SetOutlineColor(self.cfg.look.outline) + for i = 1, self.pendingCount do + local p = self.pending[i] + ---@cast p -? + font:Print(p[1], p[2], p[3], p[4], p[5]) + self.pending[i] = nil + end + font:End() + self.pendingCount = 0 +end + +function Graph:textWidth(str, size) + local font = self.cfg.font + if not font then + return #str * (size or self.cfg.fontSize) * 0.55 + end + return font:GetTextWidth(str) * (size or self.cfg.fontSize) +end + +---------------------------------------------------------------- +-- Preparing the data: samples, ranges and the mapping to pixels +---------------------------------------------------------------- + +-- Reads every series into xs/ys arrays over one shared x list. A series giving `points` +-- is read as is; one giving `values` takes its x from cfg.xs or the sample number. A +-- value that is not a number is a gap. +function Graph:prepareSamples() + local cfg = self.cfg + local xs = {} + local shared = cfg.xs + local n = 0 + for _, s in ipairs(self.series) do + if s.values then + n = mathMax(n, #s.values) + end + end + if shared then + for i = 1, mathMax(n, #shared) do + xs[i] = shared[i] or i + end + else + for i = 1, n do + xs[i] = i + end + end + -- Series with their own points are sampled onto the shared list as well, and add + -- their x values to it, so the hover column means the same for all. + local extra = {} + for _, s in ipairs(self.series) do + if s.points and not s.values then + for _, p in ipairs(s.points) do + extra[p[1]] = true + end + end + end + local seen = {} + for i = 1, #xs do + seen[xs[i]] = true + end + for x in pairs(extra) do + if not seen[x] then + xs[#xs + 1] = x + seen[x] = true + end + end + table.sort(xs) + + local prepared = {} + for si, s in ipairs(self.series) do + local ys = {} + if s.values then + for i = 1, #xs do + local v = s.values[i] + ys[i] = isFinite(v) and v or nil + end + end + ---@type table? + local raw = nil + if not s.values then + local byX = {} + local rawByX = {} + for _, p in ipairs(s.points or {}) do + if isFinite(p[2]) then + byX[p[1]] = p[2] + -- A third value is what the point really is, when the plotted one was + -- bounded: the tooltip says that one. + if p[3] ~= nil then + rawByX[p[1]] = p[3] + raw = raw or {} + end + end + end + for i = 1, #xs do + ys[i] = byX[xs[i]] + if raw then + raw[i] = rawByX[xs[i]] + end + end + end + prepared[si] = { + source = s, + name = s.name or ("Series " .. si), + color = s.color or { 0.8, 0.8, 0.8 }, + ys = ys, + raw = raw, + width = s.width or cfg.lineWidth, + smooth = s.smooth, + } + end + self.xs = xs + self.prepared = prepared +end + +-- The rectangle the curves are drawn in: the frame less the axis labels, the legend, the +-- title and the marker lane. +function Graph:layout() + local cfg = self.cfg + local fs = cfg.fontSize + local pad = cfg.pad or mathFloor(fs * 0.5) + local left = cfg.x + pad + local right = cfg.x + cfg.width - pad + local bottom = cfg.y + pad + local top = cfg.y + cfg.height - pad + -- The title row is tall enough for the topmost axis label to stay clear of it. Whole + -- pixels throughout, so edges and glyphs do not land between two. + if cfg.title then + top = mathFloor(top - fs * 1.9) + end + if cfg.legend and #self.series > 0 and cfg.kind ~= "radar" then + top = mathFloor(top - fs * 1.7) + end + self.plot = { left = left, right = right, bottom = bottom, top = top, pad = pad } +end + +-- The y range and the pixel mappings, for a line or a stacked chart. +function Graph:prepareLine() + local cfg = self.cfg + local plot = self.plot + local fs = cfg.fontSize + local xs = self.xs + local stacked = cfg.kind == "stacked" + + -- Stacked: every sample's values become shares of the sample's total. + if stacked then + for i = 1, #xs do + local total = 0 + for _, p in ipairs(self.prepared) do + total = total + (p.ys[i] or 0) + end + for _, p in ipairs(self.prepared) do + p.shares = p.shares or {} + p.shares[i] = total > 0 and (p.ys[i] or 0) / total * 100 or 0 + end + end + end + + local yMin, yMax = mathHuge, -mathHuge + if stacked then + yMin, yMax = 0, 100 + else + for _, p in ipairs(self.prepared) do + for i = 1, #xs do + local v = p.ys[i] + if v then + yMin = mathMin(yMin, v) + yMax = mathMax(yMax, v) + end + end + end + if yMin == mathHuge then + yMin, yMax = 0, 1 + end + if cfg.includeZero then + yMin = mathMin(yMin, 0) + yMax = mathMax(yMax, 0) + end + if cfg.yMin then + yMin = cfg.yMin + end + if cfg.yMax then + yMax = cfg.yMax + end + if yMax <= yMin then + yMax = yMin + 1 + end + -- Round the top up to the grid, so the topmost line is labelled. + local step = niceStep(yMax - yMin, cfg.gridLines) + if not cfg.yMax then + yMax = math.ceil(yMax / step - 1e-9) * step + end + if not cfg.yMin then + yMin = mathFloor(yMin / step + 1e-9) * step + end + end + self.yMin, self.yMax = yMin, yMax + + -- The y labels decide the left inset. + local yFormat = cfg.yFormat + or (stacked and function(v) + return stringFormat("%d%%", mathFloor(v + 0.5)) + end) + or defaultFormat + self.yFormatter = yFormat + local step = stacked and 25 or niceStep(yMax - yMin, cfg.gridLines) + self.yStep = step + local labelW = 0 + local v = yMin + while v <= yMax + step * 0.001 do + labelW = mathMax(labelW, self:textWidth(yFormat(v), fs)) + v = v + step + end + local left = mathFloor(plot.left + labelW + fs * 0.6) + local bottom = mathFloor(plot.bottom + fs * 1.5) + local right = plot.right + + local xMin, xMax = xs[1] or 0, xs[#xs] or 1 + if xMax <= xMin then + xMax = xMin + 1 + end + self.xMin, self.xMax = xMin, xMax + local xScale = (right - left) / (xMax - xMin) + self.sx = function(x) + return left + (x - xMin) * xScale + end + -- The lane above the plot takes as many rows as the markers need. + local rows = self:markerRows() + local size = cfg.markerSize or mathFloor(fs * 2.6) + local top = plot.top - (rows > 0 and (rows * size + mathFloor(fs * 0.4)) or 0) + self.area = { left = left, right = right, bottom = bottom, top = top } + local yScale = (top - bottom) / (yMax - yMin) + self.sy = function(y) + return bottom + (y - yMin) * yScale + end + if cfg.xUnit == "frames" then + self.xFormatter = cfg.xFormat or frameLabel + else + self.xFormatter = cfg.xFormat or defaultFormat + end +end + +-- The lane above the plot: every marker without a series takes the first row of it in +-- which no earlier marker sits too close in x, rows stacking upwards. Returns the rows +-- needed; each marker's row is kept for placeMarkers. Needs the x mapping. +function Graph:markerRows() + local cfg = self.cfg + local size = cfg.markerSize or mathFloor(cfg.fontSize * 2.6) + local rows = {} + local count = 0 + self.markerRow = {} + for mi, m in ipairs(self.markers) do + if not m.series and isFinite(m.x) then + local px = self.sx(m.x) + local row = 1 + while true do + local taken = false + for _, other in ipairs(rows[row] or {}) do + if mathAbs(other - px) < size then + taken = true + end + end + if not taken then + break + end + row = row + 1 + end + rows[row] = rows[row] or {} + table.insert(rows[row], px) + self.markerRow[mi] = row + count = mathMax(count, row) + end + end + return count +end + +-- Where each marker goes: on its series' value at its x, or in its row of the lane. +function Graph:placeMarkers() + local cfg = self.cfg + local size = cfg.markerSize or mathFloor(cfg.fontSize * 2.6) + local area = self.area + self.placed = {} + for mi, m in ipairs(self.markers) do + if isFinite(m.x) then + local px = self.sx(m.x) + if px >= area.left - size and px <= area.right + size then + local py + local onSeries = m.series and self.prepared[m.series] + if onSeries then + local i = self:nearestIndex(m.x) + local v = cfg.kind == "stacked" and self:stackTop(m.series, i) or onSeries.ys[i] + py = self.sy(v or self.yMin) + else + local row = self.markerRow[mi] or 1 + py = area.top + mathFloor(cfg.fontSize * 0.4) + size * 0.5 + (row - 1) * size + end + local half = size * 0.5 + -- The picture stays inside the plot's width, so one at the first sample does + -- not sit on the axis labels; its tick still points at the true x. + local cx = mathMin(mathMax(px, area.left + half), area.right - half) + self.placed[#self.placed + 1] = { + marker = m, + x1 = mathFloor(cx - half), + y1 = mathFloor(py - half), + x2 = mathFloor(cx + half), + y2 = mathFloor(py + half), + px = px, + py = py, + cx = cx, + onSeries = onSeries ~= nil, + } + end + end + end +end + +function Graph:nearestIndex(x) + local xs = self.xs + local best, bestD = 1, mathHuge + for i = 1, #xs do + local d = mathAbs(xs[i] - x) + if d < bestD then + best, bestD = i, d + end + end + return best +end + +-- The top of a stacked series at sample i: its share and every share under it. +function Graph:stackTop(si, i) + local top = 0 + for k = 1, si do + local p = self.prepared[k] + ---@cast p -? + top = top + (p.shares[i] or 0) + end + return top +end + +---------------------------------------------------------------- +-- Drawing a line or stacked chart into the list +---------------------------------------------------------------- + +local function alphaOf(self, si, base) + if self.highlight and self.highlight ~= si then + return base * self.cfg.look.dimAlpha + end + return base +end + +-- The points of a curve in pixels: the samples, or the smoothed run through them. A gap +-- (a sample with no value) ends one run and starts the next. +function Graph:curveRuns(ys, smooth) + local xs = self.xs + local cfg = self.cfg + local runs = {} + local run = {} + local rxs, rys = {}, {} + local function flush() + if #rxs > 0 then + if smooth and #rxs > 2 then + local m = monotoneSlopes(rxs, rys) + local steps = cfg.smoothSteps + if not steps then + local px = (self.sx(rxs[#rxs]) - self.sx(rxs[1])) / mathMax(1, #rxs - 1) + steps = mathMax(1, mathMin(10, mathFloor(px / 3))) + end + for i = 1, #rxs - 1 do + for k = 0, steps - 1 do + local x = rxs[i] + (rxs[i + 1] - rxs[i]) * k / steps + run[#run + 1] = { x, hermite(rxs, rys, m, i, x) } + end + end + run[#run + 1] = { rxs[#rxs], rys[#rys] } + else + for i = 1, #rxs do + run[#run + 1] = { rxs[i], rys[i] } + end + end + runs[#runs + 1] = run + end + run, rxs, rys = {}, {}, {} + end + for i = 1, #xs do + local y = ys[i] + if y then + rxs[#rxs + 1] = xs[i] + rys[#rys + 1] = y + else + flush() + end + end + flush() + return runs +end + +-- Whole seconds, minutes and hours make the ticks of a time axis, at the coarsest step +-- that still gives about cfg.xTicks of them. +local TIME_STEPS = { 150, 300, 450, 900, 1800, 3600, 9000, 18000, 27000, 54000, 108000, 216000 } + +function Graph:xTickStep() + local cfg = self.cfg + if cfg.xStep then + return cfg.xStep + end + local range = self.xMax - self.xMin + if cfg.xUnit == "frames" then + for _, step in ipairs(TIME_STEPS) do + if range / step <= cfg.xTicks then + return step + end + end + return TIME_STEPS[#TIME_STEPS] + end + return niceStep(range, cfg.xTicks) +end + +function Graph:drawGrid() + local cfg = self.cfg + local look = cfg.look + local area = self.area + local fs = cfg.fontSize + + glColor(look.plotFill) + glBeginEnd(GL_QUADS, function() + glVertex(area.left, area.bottom) + glVertex(area.right, area.bottom) + glVertex(area.right, area.top) + glVertex(area.left, area.top) + end) + + -- Horizontal lines and their labels. + local v = self.yMin + local yFormat = self.yFormatter + glColor(look.grid) + glBeginEnd(GL_LINES, function() + local y = v + while y <= self.yMax + self.yStep * 0.001 do + local py = mathFloor(self.sy(y)) + 0.5 + glVertex(area.left, py) + glVertex(area.right, py) + y = y + self.yStep + end + end) + while v <= self.yMax + self.yStep * 0.001 do + local label = yFormat(v) + self:text(look.text .. label, mathFloor(area.left - fs * 0.4), mathFloor(self.sy(v) - fs * 0.35), "or", fs) + v = v + self.yStep + end + + -- Vertical ticks and their labels. + local xStep = self:xTickStep() + local xFormat = self.xFormatter + local x0 = math.ceil(self.xMin / xStep - 1e-9) * xStep + glColor(look.grid) + glBeginEnd(GL_LINES, function() + local x = x0 + while x <= self.xMax + xStep * 0.001 do + local px = mathFloor(self.sx(x)) + 0.5 + glVertex(px, area.bottom) + glVertex(px, area.top) + x = x + xStep + end + end) + local x = x0 + while x <= self.xMax + xStep * 0.001 do + self:text(look.text .. xFormat(x), mathFloor(self.sx(x)), mathFloor(area.bottom - fs * 1.2), "oc", fs) + x = x + xStep + end + + -- The axes. + glColor(look.axis) + glBeginEnd(GL_LINE_STRIP, function() + glVertex(area.left + 0.5, area.top) + glVertex(area.left + 0.5, area.bottom + 0.5) + glVertex(area.right, area.bottom + 0.5) + end) +end + +function Graph:drawLines() + local cfg = self.cfg + local look = cfg.look + local area = self.area + local sx, sy = self.sx, self.sy + local smoothAll = cfg.smooth + + -- Fills first, so every line lies on top of every fill. + if cfg.fill then + for si, p in ipairs(self.prepared) do + local c = p.color + local a = alphaOf(self, si, look.fillAlpha) + glColor(c[1], c[2], c[3], a) + local smooth = p.smooth + if smooth == nil then + smooth = smoothAll + end + for _, run in ipairs(self:curveRuns(p.ys, smooth)) do + glBeginEnd(GL_TRIANGLE_STRIP, function() + for _, pt in ipairs(run) do + local px = sx(pt[1]) + glVertex(px, area.bottom) + glVertex(px, sy(pt[2])) + end + end) + end + end + end + + if glSmoothing then + glSmoothing(false, true, false) + end + for si, p in ipairs(self.prepared) do + local c = p.color + local a = alphaOf(self, si, 1) + local width = p.width + if self.highlight == si then + width = width + 1 + end + glLineWidth(width) + glColor(c[1], c[2], c[3], a) + local smooth = p.smooth + if smooth == nil then + smooth = smoothAll + end + for _, run in ipairs(self:curveRuns(p.ys, smooth)) do + if #run == 1 then + local px, py = sx(run[1][1]), sy(run[1][2]) + glBeginEnd(GL_QUADS, function() + glVertex(px - width, py - width) + glVertex(px + width, py - width) + glVertex(px + width, py + width) + glVertex(px - width, py + width) + end) + else + glBeginEnd(GL_LINE_STRIP, function() + for _, pt in ipairs(run) do + glVertex(sx(pt[1]), sy(pt[2])) + end + end) + end + end + end + glLineWidth(1) + if glSmoothing then + glSmoothing(false, false, false) + end +end + +-- A stacked chart: every series is a band between the top of the one under it and its +-- own top, all shares of the sample's total. Smoothing runs on the shares and the bands +-- are normalised again after it, so they still add up to the whole at every pixel. +function Graph:drawStacked() + local cfg = self.cfg + local look = cfg.look + local sx, sy = self.sx, self.sy + local xs = self.xs + local n = #self.prepared + if n == 0 or #xs == 0 then + return + end + + -- Every band's share along the (smoothed) x run. + local runs = {} + for si, p in ipairs(self.prepared) do + local smooth = p.smooth + if smooth == nil then + smooth = cfg.smooth + end + local filled = {} + for i = 1, #xs do + filled[i] = p.shares[i] or 0 + end + runs[si] = self:curveRuns(filled, smooth)[1] or {} + end + local count = #runs[1] + for si = 2, n do + count = mathMin(count, #runs[si]) + end + + for si = 1, n do + local p = self.prepared[si] + ---@cast p -? + local c = p.color + local a = alphaOf(self, si, look.areaAlpha) + glColor(c[1], c[2], c[3], a) + glBeginEnd(GL_TRIANGLE_STRIP, function() + for k = 1, count do + ---@type number + local total = 0 + for j = 1, n do + total = total + mathMax(0, runs[j][k][2]) + end + ---@type number, number + local under, own = 0, 0 + for j = 1, si do + local share = mathMax(0, runs[j][k][2]) + if total > 0 then + share = share / total * 100 + else + share = 0 + end + if j < si then + under = under + share + else + own = share + end + end + local px = sx(runs[si][k][1]) + glVertex(px, sy(under)) + glVertex(px, sy(mathMin(100, under + own))) + end + end) + end + + -- The seams between the bands, so neighbours in similar colours stay apart. + if glSmoothing then + glSmoothing(false, true, false) + end + glLineWidth(1) + glColor(0, 0, 0, 0.25) + for si = 1, n - 1 do + glBeginEnd(GL_LINE_STRIP, function() + for k = 1, count do + ---@type number + local total = 0 + for j = 1, n do + total = total + mathMax(0, runs[j][k][2]) + end + ---@type number + local top = 0 + for j = 1, si do + local share = mathMax(0, runs[j][k][2]) + top = top + (total > 0 and share / total * 100 or 0) + end + glVertex(sx(runs[si][k][1]), sy(top)) + end + end) + end + if glSmoothing then + glSmoothing(false, false, false) + end + + -- The bands named inside them at their right end, where they are thick enough for + -- the name and the name is not most of the plot. + if cfg.bandLabels and count > 0 then + local fs = cfg.fontSize + local k = count + ---@type number + local total = 0 + for j = 1, n do + total = total + mathMax(0, runs[j][k][2]) + end + local right = mathFloor(sx(runs[1][k][1]) - fs * 0.4) + local left = self.area and self.area.left or right + ---@type number + local under = 0 + for si = 1, n do + local share = total > 0 and mathMax(0, runs[si][k][2]) / total * 100 or 0 + local y0, y1 = sy(under), sy(mathMin(100, under + share)) + if mathAbs(y1 - y0) >= fs * 1.3 then + local p = self.prepared[si] + ---@cast p -? + if self:textWidth(p.name, fs) <= (right - left) * 0.5 then + self:text(look.text .. p.name, right, mathFloor((y0 + y1) * 0.5), "orv", fs) + end + end + under = under + share + end + end +end + +function Graph:drawMarkers() + local look = self.cfg.look + for _, m in ipairs(self.placed) do + local marker = m.marker + -- A tick from the picture down to the plot, or to the point it sits on. + glColor(look.markerTick) + glBeginEnd(GL_LINES, function() + glVertex(m.cx + 0.5, m.y1) + glVertex(m.px + 0.5, m.onSeries and m.py or self.area.bottom) + end) + if marker.texture then + -- Zoomed in a little, the more the smaller the picture: a unit picture has + -- air around the unit. The engine's textures load flipped, so t runs from 1 + -- down to 0, as the plain call does on its own. + local z = marker.zoom or self.cfg.markerZoom or mathMin(0.06, 2.5 / mathMax(1, m.x2 - m.x1)) + glColor(1, 1, 1, 1) + glTexture(marker.texture) + glTexRect(m.x1, m.y1, m.x2, m.y2, z, 1 - z, 1 - z, z) + glTexture(false) + else + local c = marker.color or { 1, 1, 1 } + glColor(c[1], c[2], c[3], 0.9) + glBeginEnd(GL_QUADS, function() + glVertex(m.x1, m.y1) + glVertex(m.x2, m.y1) + glVertex(m.x2, m.y2) + glVertex(m.x1, m.y2) + end) + end + -- The frame, in the marker's own colour when it has one (a team's), as four + -- whole-pixel bars so it is the same thickness all round. + local frame = marker.frame + local w = self.cfg.markerFrameWidth + if frame then + glColor(frame[1], frame[2], frame[3], frame[4] or 1) + else + glColor(look.markerFrame) + end + glBeginEnd(GL_QUADS, function() + glVertex(m.x1, m.y1) + glVertex(m.x2, m.y1) + glVertex(m.x2, m.y1 + w) + glVertex(m.x1, m.y1 + w) + glVertex(m.x1, m.y2 - w) + glVertex(m.x2, m.y2 - w) + glVertex(m.x2, m.y2) + glVertex(m.x1, m.y2) + glVertex(m.x1, m.y1 + w) + glVertex(m.x1 + w, m.y1 + w) + glVertex(m.x1 + w, m.y2 - w) + glVertex(m.x1, m.y2 - w) + glVertex(m.x2 - w, m.y1 + w) + glVertex(m.x2, m.y1 + w) + glVertex(m.x2, m.y2 - w) + glVertex(m.x2 - w, m.y2 - w) + end) + end +end + +-- The title and the legend, in the room the layout kept above the plot for them. +function Graph:drawLegend() + local cfg = self.cfg + local look = cfg.look + local fs = cfg.fontSize + local plot = self.plot + local titleH = cfg.title and fs * 1.9 or 0 + local legendH = (cfg.legend and #self.prepared > 0) and fs * 1.7 or 0 + local top = plot.top + titleH + legendH + if cfg.title then + self:text(look.title .. cfg.title, plot.left, mathFloor(top - fs * 1.1), "o", fs * 1.15) + end + if not cfg.legend then + return + end + local y = mathFloor(top - titleH - fs * 1.3) + ---@type number + local x = self.area and self.area.left or plot.left + local swatch = mathFloor(fs * 0.8) + for si, p in ipairs(self.prepared) do + local w = self:textWidth(p.name, fs) + if x + swatch + fs * 0.5 + w > plot.right then + break + end + local c = p.color + local a = alphaOf(self, si, 1) + glColor(c[1], c[2], c[3], a) + glBeginEnd(GL_QUADS, function() + glVertex(x, y) + glVertex(x + swatch, y) + glVertex(x + swatch, y + swatch) + glVertex(x, y + swatch) + end) + local color = self.highlight and self.highlight ~= si and "\255\130\130\130" or look.text + self:text(color .. p.name, mathFloor(x + swatch + fs * 0.4), mathFloor(y + swatch * 0.15), "o", fs) + x = mathFloor(x + swatch + fs * 0.4 + w + fs * 1.2) + end +end + +---------------------------------------------------------------- +-- Radar +---------------------------------------------------------------- + +function Graph:prepareRadar() + local cfg = self.cfg + local plot = self.plot + local fs = cfg.fontSize + local axes = cfg.radar.axes or {} + self.axes = axes + local n = #axes + -- Label room around the wheel. + local labelW = 0 + for _, a in ipairs(axes) do + labelW = mathMax(labelW, self:textWidth(a.label or a.key or "", fs)) + end + local cx = mathFloor((plot.left + plot.right) * 0.5) + local cy = mathFloor((plot.bottom + plot.top) * 0.5) + local radius = + mathMin((plot.right - plot.left) * 0.5 - labelW - fs * 0.6, (plot.top - plot.bottom) * 0.5 - fs * 1.6) + radius = mathMax(10, radius) + self.radar = { cx = cx, cy = cy, radius = radius, n = n } + + -- Each series' values per axis, as a share of the axis maximum. + for _, p in ipairs(self.prepared) do + local s = p.source + local vals = {} + for ai, a in ipairs(axes) do + local v = s.values and (s.values[ai] or (a.key and s.values[a.key])) + vals[ai] = isFinite(v) and v or 0 + end + p.axisValues = vals + end + for ai, a in ipairs(axes) do + if not a.max then + local top = 0 + for _, p in ipairs(self.prepared) do + top = mathMax(top, p.axisValues[ai]) + end + a.autoMax = top > 0 and top or 1 + end + end + self.axisMax = function(ai) + return axes[ai].max or axes[ai].autoMax or 1 + end + -- Axis ai at angle: the first straight up, the rest clockwise. + self.axisPoint = function(ai, share) + local ang = mathPi * 0.5 - (ai - 1) / mathMax(1, n) * 2 * mathPi + return cx + mathCos(ang) * radius * share, cy + mathSin(ang) * radius * share + end +end + +function Graph:drawRadar() + local cfg = self.cfg + local look = cfg.look + local fs = cfg.fontSize + local r = self.radar + local n = r.n + if n < 3 then + return + end + local axisPoint = self.axisPoint + + -- Rings and spokes. + if glSmoothing then + glSmoothing(false, true, false) + end + glLineWidth(1) + glColor(look.grid) + for k = 1, cfg.radar.rings do + local share = k / cfg.radar.rings + glBeginEnd(GL_LINE_LOOP, function() + for ai = 1, n do + glVertex(axisPoint(ai, share)) + end + end) + end + glColor(look.axis) + glBeginEnd(GL_LINES, function() + for ai = 1, n do + glVertex(r.cx, r.cy) + glVertex(axisPoint(ai, 1)) + end + end) + + -- Series: a filled polygon and its outline. + for si, p in ipairs(self.prepared) do + local c = p.color + local shares = {} + for ai = 1, n do + shares[ai] = mathMin(1, mathMax(0, p.axisValues[ai] / self.axisMax(ai))) + end + if cfg.radar.fill then + glColor(c[1], c[2], c[3], alphaOf(self, si, look.radarFillAlpha)) + glBeginEnd(GL_TRIANGLE_FAN, function() + glVertex(r.cx, r.cy) + for ai = 1, n do + glVertex(axisPoint(ai, shares[ai])) + end + glVertex(axisPoint(1, shares[1])) + end) + end + glLineWidth(self.highlight == si and p.width + 1 or p.width) + glColor(c[1], c[2], c[3], alphaOf(self, si, 1)) + glBeginEnd(GL_LINE_LOOP, function() + for ai = 1, n do + glVertex(axisPoint(ai, shares[ai])) + end + end) + end + glLineWidth(1) + if glSmoothing then + glSmoothing(false, false, false) + end + + -- Labels, past the end of each spoke. + for ai, a in ipairs(self.axes) do + local x, y = axisPoint(ai, 1) + local dx, dy = x - r.cx, y - r.cy + local opts = "o" + if dx > 1 then + opts = "o" + x = x + fs * 0.4 + elseif dx < -1 then + opts = "or" + x = x - fs * 0.4 + else + opts = "oc" + end + if dy > 1 then + y = y + fs * 0.4 + elseif dy < -1 then + y = y - fs * 1.1 + else + y = y - fs * 0.35 + end + self:text(look.text .. (a.label or a.key or ""), mathFloor(x), mathFloor(y), opts, fs) + end + local titleH = cfg.title and fs * 1.9 or 0 + if cfg.title then + self:text(look.title .. cfg.title, self.plot.left, mathFloor(self.plot.top + titleH - fs * 1.1), "o", fs * 1.15) + end + -- The legend: one row under the title, in the wheel's label room. + if cfg.legend then + ---@type number + local x = self.plot.left + local y = mathFloor(self.plot.top - fs * 1.2) + local swatch = mathFloor(fs * 0.8) + for si, p in ipairs(self.prepared) do + local w = self:textWidth(p.name, fs) + if x + swatch + fs * 0.5 + w > self.plot.right then + break + end + local c = p.color + glColor(c[1], c[2], c[3], alphaOf(self, si, 1)) + glBeginEnd(GL_QUADS, function() + glVertex(x, y) + glVertex(x + swatch, y) + glVertex(x + swatch, y + swatch) + glVertex(x, y + swatch) + end) + self:text(look.text .. p.name, mathFloor(x + swatch + fs * 0.4), mathFloor(y + swatch * 0.15), "o", fs) + x = mathFloor(x + swatch + fs * 0.4 + w + fs * 1.2) + end + end +end + +---------------------------------------------------------------- +-- Building and drawing +---------------------------------------------------------------- + +-- Everything the picture and the hit test share: the samples, the frame's layout and +-- the scales, the markers' places. No drawing in here, so a hit test can run it on +-- fresh series before they are drawn. +function Graph:measure() + local cfg = self.cfg + self:prepareSamples() + self:layout() + if cfg.kind == "radar" then + self.placed = {} + self:prepareRadar() + else + self:prepareLine() + self:placeMarkers() + end +end + +function Graph:build() + local cfg = self.cfg + self.pendingCount = 0 + self:measure() + if cfg.look.background then + glColor(cfg.look.background) + glBeginEnd(GL_QUADS, function() + glVertex(cfg.x, cfg.y) + glVertex(cfg.x + cfg.width, cfg.y) + glVertex(cfg.x + cfg.width, cfg.y + cfg.height) + glVertex(cfg.x, cfg.y + cfg.height) + end) + end + if cfg.kind == "radar" then + self:drawRadar() + else + self:drawGrid() + if cfg.kind == "stacked" then + self:drawStacked() + else + self:drawLines() + end + self:drawMarkers() + self:drawLegend() + end + self:flushText() + glColor(1, 1, 1, 1) +end + +function Graph:draw() + if self.dirty or not self.list then + if self.list then + glDeleteList(self.list) + end + self.list = glCreateList(function() + self:build() + end) + self.dirty = false + end + glCallList(self.list) + self:drawOverlay() +end + +-- What moves with the cursor: a line down the hovered sample with a dot on every series, +-- or a frame around the hovered marker. Drawn straight, not baked. +function Graph:drawOverlay() + local hit = self.hover + if not hit then + return + end + local look = self.cfg.look + if hit.kind == "marker" then + local m = hit.placed + glLineWidth(2) + glColor(look.markerHover) + glBeginEnd(GL_LINE_LOOP, function() + glVertex(m.x1 - 1, m.y1 - 1) + glVertex(m.x2 + 1, m.y1 - 1) + glVertex(m.x2 + 1, m.y2 + 1) + glVertex(m.x1 - 1, m.y2 + 1) + end) + glLineWidth(1) + glColor(1, 1, 1, 1) + return + end + if hit.kind == "point" and self.area then + local area = self.area + glColor(look.crosshair) + glBeginEnd(GL_LINES, function() + glVertex(mathFloor(hit.px) + 0.5, area.bottom) + glVertex(mathFloor(hit.px) + 0.5, area.top) + end) + local r = mathMax(2, self.cfg.lineWidth * 1.5) + for _, e in ipairs(hit.entries) do + local c = e.color + glColor(c[1], c[2], c[3], 1) + glBeginEnd(GL_TRIANGLE_FAN, function() + glVertex(hit.px, e.py) + for k = 0, 12 do + local ang = k / 12 * 2 * mathPi + glVertex(hit.px + mathCos(ang) * r, e.py + mathSin(ang) * r) + end + end) + end + glColor(1, 1, 1, 1) + return + end + if hit.kind == "axis" and self.radar then + glColor(look.crosshair) + glLineWidth(2) + glBeginEnd(GL_LINES, function() + glVertex(self.radar.cx, self.radar.cy) + glVertex(self.axisPoint(hit.axis, 1)) + end) + glLineWidth(1) + glColor(1, 1, 1, 1) + end +end + +---------------------------------------------------------------- +-- Hit testing and describing +---------------------------------------------------------------- + +-- What is under the cursor: a marker, the nearest sample column of a line or stacked +-- chart, or the nearest axis of a radar chart. nil outside the chart or before the first +-- draw. +function Graph:hitTest(mx, my) + local cfg = self.cfg + if mx < cfg.x or mx > cfg.x + cfg.width or my < cfg.y or my > cfg.y + cfg.height then + return nil + end + -- Fresh series are laid out for the test when they have not been drawn yet. + if self.dirty or not self.list then + self:measure() + end + -- Later markers lie on top of earlier ones, so they are tested first. + for i = #self.placed, 1, -1 do + local m = self.placed[i] + ---@cast m -? + if mx >= m.x1 and mx <= m.x2 and my >= m.y1 and my <= m.y2 then + return { kind = "marker", marker = m.marker, placed = m, px = m.px, py = m.py, text = m.marker.text } + end + end + if cfg.kind == "radar" then + local r = self.radar + if not r or r.n < 3 then + return nil + end + local dx, dy = mx - r.cx, my - r.cy + if dx * dx + dy * dy > (r.radius * 1.15) ^ 2 then + return nil + end + local ang = math.atan2(dy, dx) + -- Axis 1 points up and the rest run clockwise. + local turn = (mathPi * 0.5 - ang) / (2 * mathPi) + turn = turn - mathFloor(turn) + local axis = mathFloor(turn * r.n + 0.5) % r.n + 1 + local entries = {} + for si, p in ipairs(self.prepared) do + entries[#entries + 1] = { series = si, name = p.name, color = p.color, value = p.axisValues[axis] } + end + local a = self.axes[axis] + ---@cast a -? + return { kind = "axis", axis = axis, label = a.label or a.key, entries = entries } + end + local area = self.area + if not area or #self.xs == 0 then + return nil + end + if mx < area.left or mx > area.right then + return nil + end + local x = self.xMin + (mx - area.left) / mathMax(1, area.right - area.left) * (self.xMax - self.xMin) + local i = self:nearestIndex(x) + local px = self.sx(self.xs[i] or 0) + local entries = {} + for si, p in ipairs(self.prepared) do + local v = p.ys[i] + if cfg.kind == "stacked" then + local share = p.shares and p.shares[i] or 0 + entries[#entries + 1] = { + series = si, + name = p.name, + color = p.color, + value = v or 0, + share = share, + py = self.sy(self:stackTop(si, i) - share * 0.5), + } + elseif v then + entries[#entries + 1] = { + series = si, + name = p.name, + color = p.color, + value = v, + raw = p.raw and p.raw[i] or nil, + py = self.sy(v), + } + end + end + return { kind = "point", index = i, x = self.xs[i], px = px, entries = entries } +end + +-- A tooltip for a hit: the x, then every series' value in its colour, largest first. +function Graph:describe(hit) + if not hit then + return nil + end + if hit.kind == "marker" then + return hit.text + end + local cfg = self.cfg + local lines = {} + local entries = {} + for i = 1, #hit.entries do + entries[i] = hit.entries[i] + end + table.sort(entries, function(a, b) + return (a.value or 0) > (b.value or 0) + end) + if hit.kind == "point" then + lines[1] = cfg.look.title .. (self.xFormatter or defaultFormat)(hit.x) + local yFormat = cfg.kind == "stacked" and (cfg.yFormat or defaultFormat) or self.yFormatter + for _, e in ipairs(entries) do + local value = yFormat(e.raw or e.value) + if e.share then + value = value .. cfg.look.text .. stringFormat(" (%d%%)", mathFloor(e.share + 0.5)) + end + lines[#lines + 1] = colorCode(e.color) .. e.name .. " " .. cfg.look.title .. value + end + else + lines[1] = cfg.look.title .. (hit.label or "") + local yFormat = cfg.yFormat or defaultFormat + for _, e in ipairs(entries) do + lines[#lines + 1] = colorCode(e.color) .. e.name .. " " .. cfg.look.title .. yFormat(e.value) + end + end + return table.concat(lines, "\n") +end + +return Graph diff --git a/luaui/Include/teamstats_graphs.lua b/luaui/Include/teamstats_graphs.lua new file mode 100644 index 00000000000..9bcdf65a558 --- /dev/null +++ b/luaui/Include/teamstats_graphs.lua @@ -0,0 +1,991 @@ +-- The Graphs page of the team stats panel: one chart of the picked stat over the game, +-- the teams as its series, with a stat list beside it and a legend bar above it that +-- highlights or hides a team. The panel owns the frame, the sidebar and the switches +-- and hands this page the room between them; the page keeps the histories it plots (the +-- engine's own team statistics and the team stats gadget's, through WG.teamStats) and +-- rebuilds the chart when either grows or the pick changes. +-- +-- The panel gives `ctx`: its column and label tables, the derive step that turns a +-- sample's counters into every column's value, its look, metrics and colours, the +-- FlowUI and gl draw calls and its text queue, and readers for what changes at runtime +-- (the ally teams read into the table, the gadget's last hand-over, the switches, the +-- group picked in the sidebar). See gui_teamstats.lua for the fields. + +local Graph = VFS.Include("luaui/Include/graph.lua") + +local mathFloor = math.floor +local mathMax = math.max +local mathMin = math.min +local mathHuge = math.huge +local tableSort = table.sort + +-- The composition bands: one colour per bucket, in the composition view's column order. +-- Softer than any team colour, so the bands are not read as teams. +local BUCKET_COLORS = { + { 0.86, 0.56, 0.48 }, + { 0.68, 0.8, 0.9 }, + { 0.5, 0.62, 0.8 }, + { 0.72, 0.72, 0.7 }, + { 0.7, 0.62, 0.84 }, + { 0.86, 0.8, 0.54 }, + { 0.62, 0.8, 0.62 }, + { 0.52, 0.78, 0.76 }, + { 0.82, 0.68, 0.76 }, +} + +-- The legend bar's looks: a block is a button, the picked one lit and framed warm. +local PLATE = { 1, 1, 1, 0.05 } +local PICKED_FRAME = { 1, 0.78, 0.51, 0.85 } + +-- A percentage for an axis or a tooltip: whole, a prefix past a million, infinity as +-- its sign. +local function percentFormat(v) + if v == mathHuge then + return "\226\136\158" + elseif v == -mathHuge then + return "-\226\136\158" + end + if math.abs(v) >= 1e6 and string.formatSI then + return string.formatSI(v) .. "%" + end + return string.format("%.0f%%", v) +end + +local M = {} + +function M.new(ctx) + ---@type table + local page = { + open = false, + stat = "damageDealt", + ---@type table + hidden = {}, + -- The highlighted unit's key; nil is every team alike. Starts on the viewer's own + -- team, when they have one. + ---@type string? + highlight = nil, + highlightSet = false, + hover = { stat = 0, legend = 0, block = 0 }, + -- Per team: the engine's history entries taken at the period, derived, and how + -- many of the engine's list they are; the live newest entry is left out so the + -- chart changes once a period, not every second. + ---@type table + engine = {}, + -- Per team: the gadget's samples as a run per key. + ---@type table }> + gadget = {}, + lastPeriod = -1, + -- Bumped when either history grows; the sample tables are rebuilt against it. + version = 0, + samplesVersion = -1, + ---@type table> + samples = {}, + -- Whether any team has a sample at all: tells a chart that waits for the first + -- ones from a stat that has nothing to plot. + anySamples = false, + dirty = true, + ---@type table[] + statList = {}, + ---@type table[] + units = {}, + ---@type table + unitByKey = {}, + -- The legend bar: the All button, then a block per ally team, and the squares in + -- the blocks as hit targets. + ---@type table[] + barBlocks = {}, + ---@type table[] + barItems = {}, + barLabels = true, + -- Bumped whenever what the panel bakes for the page changes: the pick, the + -- highlight, the hidden set, the bar's layout. Part of the panel's bake signature. + gen = 0, + ---@type table? + rects = nil, + ---@type table? + chartHit = nil, + empty = true, + scale = 1, + } + + local chart = Graph.new({ + kind = "line", + legend = false, + xUnit = "frames", + lineWidth = 2, + includeZero = true, + look = { plotFill = { 0, 0, 0, 0.16 } }, + }) + page.chart = chart + + local function grouped() + return ctx.filters.groupByTeam and not ctx.isFFA + end + + ---------------------------------------------------------------- + -- The stat list + ---------------------------------------------------------------- + + -- The stats of the sidebar's group, the composition chart first in its group. The + -- gadget's columns, and the ones only it keeps a history of, only while it is there. + function page.rebuildStatList() + local group = ctx.groupByKey[ctx.selectedGroup()] or ctx.GROUPS[1] + local list = {} + if group.key == "composition" and ctx.gadgetOn() then + list[#list + 1] = { key = "composition", label = ctx.i18n("ui.teamStats.graph.composition") } + end + for i = 1, #group.columns do + local column = ctx.COLUMNS[group.columns[i]] + if ctx.gadgetOn() or not (column.gadget or column.liveOnly) then + list[#list + 1] = { key = column.key, label = ctx.columnTitle(column), column = column } + end + end + page.statList = list + local found = false + for i = 1, #list do + if list[i].key == page.stat then + found = true + end + end + if not found and list[1] then + page.stat = list[1].key + end + page.dirty = true + page.gen = page.gen + 1 + end + + ---------------------------------------------------------------- + -- Layout + ---------------------------------------------------------------- + + ---@type fun() + local layoutBar + + -- The room the panel hands over, bottom-left to top-right, and the scale: the stat + -- list down the left, the legend bar along the top, the chart in the rest. + function page.setLayout(x1, y1, x2, y2, s) + page.scale = s + local listW = mathFloor(200 * s) + local gap = mathFloor(12 * s) + local barH = ctx.metrics.rowHeight + mathFloor(8 * s) + page.rects = { + list = { x1, y1, x1 + listW, y2 }, + bar = { x1 + listW + gap, y2 - barH, x2, y2 }, + chart = { x1 + listW + gap, y1, x2, y2 - barH - mathFloor(4 * s) }, + rowH = ctx.metrics.rowHeight, + -- A legend square, the block plate's inset from the bar, and the frame around + -- the picked block. + square = ctx.metrics.rowHeight - mathFloor(8 * s), + inset = mathFloor(2 * s), + frame = mathMax(2, mathFloor(s)), + } + local c = page.rects.chart + ---@cast c [number, number, number, number] + chart:setBounds(c[1], c[2], c[3] - c[1], c[4] - c[2]) + page.rebuildStatList() + layoutBar() + end + + function page.setFont(font, fontSize) + chart:configure({ font = font, fontSize = fontSize }) + end + + local function statRect(i) + local r = page.rects.list + local top = r[4] - ctx.metrics.cardLip - (i - 1) * page.rects.rowH + return r[1], top - page.rects.rowH, r[3], top + end + + ---------------------------------------------------------------- + -- Histories + ---------------------------------------------------------------- + + -- Reads what grew since the last time: the engine's entries past the ones held, all + -- but the live newest, and asks the gadget for the samples past the ones held once + -- per period. Called by the panel every second while the page is open. + function page.refresh() + local grew = false + local frame = ctx.frame() + local hub = ctx.hub() + local period = mathFloor(frame / 450) + local ask = hub ~= nil and period ~= page.lastPeriod + for _, ally in ipairs(ctx.allies()) do + for _, team in ipairs(ally.teams) do + local teamID = team.id + local count = ctx.history(teamID) + local e = page.engine[teamID] + if not e then + e = { used = 0, entries = {} } + page.engine[teamID] = e + end + if count and count - 1 > e.used then + -- Both indices: without the second the engine gives one entry. + local entries = ctx.history(teamID, e.used + 1, count) + if entries then + -- The newest is live; the ones before it are the period's. + for i = 1, #entries - 1 do + local entry = entries[i] + ctx.derive(entry) + e.entries[#e.entries + 1] = entry + end + e.used = e.used + mathMax(0, #entries - 1) + grew = true + end + end + if ask then + local g = page.gadget[teamID] + hub.requestHistory(teamID, (g and #g.frames or 0) + 1) + end + end + end + if ask then + page.lastPeriod = period + end + if grew then + page.version = page.version + 1 + page.dirty = true + end + end + + -- The gadget's answer, through the panel's subscription. + function page.receiveHistory(teamID, h) + if not h or not h.frames or #h.frames == 0 then + return + end + local g = page.gadget[teamID] + if not g or h.from == 1 then + g = { frames = {}, values = {} } + page.gadget[teamID] = g + elseif h.from ~= #g.frames + 1 then + -- Out of step with what is held: start over from what came. + return + end + for i = 1, #h.frames do + g.frames[#g.frames + 1] = h.frames[i] + end + for key, run in pairs(h.values) do + local held = g.values[key] + if not held then + held = {} + g.values[key] = held + end + for i = 1, #run do + held[#held + 1] = run[i] + end + end + page.version = page.version + 1 + page.dirty = true + end + + -- A team's samples by frame: the engine's entry at that frame with the gadget's + -- values added to it, or the gadget's alone, so any column can be read off once + -- derived. + local function samplesOf(teamID) + local byFrame = {} + local e = page.engine[teamID] + if e then + for i = 1, #e.entries do + local entry = e.entries[i] + local t = {} + for k, v in pairs(entry) do + if type(v) == "number" then + t[k] = v + end + end + byFrame[entry.frame] = t + end + end + local g = page.gadget[teamID] + if g then + for i = 1, #g.frames do + local f = g.frames[i] + local t = byFrame[f] + if not t then + t = {} + byFrame[f] = t + end + for key, run in pairs(g.values) do + t[key] = run[i] + end + end + end + return byFrame + end + + local function rebuildSamples() + if page.samplesVersion == page.version then + return + end + page.samples = {} + page.anySamples = false + for _, ally in ipairs(ctx.allies()) do + for _, team in ipairs(ally.teams) do + local samples = samplesOf(team.id) + page.samples[team.id] = samples + if next(samples) then + page.anySamples = true + end + end + end + page.samplesVersion = page.version + end + + -- The members' samples summed frame by frame and derived: the value of `key` at + -- every frame, as { { frame, value }, ... } in frame order. Per minute turns a + -- running total into the rate between two samples. A clamp bounds the value for the + -- plot, the point keeping what it really was for the tooltip. + local function pointsOf(members, key, perMinute, clamp) + local sums = {} + for _, teamID in ipairs(members) do + local samples = page.samples[teamID] + if samples then + for frame, t in pairs(samples) do + local sum = sums[frame] + if not sum then + sum = {} + sums[frame] = sum + end + for k, v in pairs(t) do + sum[k] = (sum[k] or 0) + v + end + end + end + end + local frames = {} + for frame in pairs(sums) do + frames[#frames + 1] = frame + end + tableSort(frames) + local points = {} + ---@type number?, number + local previous, previousFrame = nil, 0 + for i = 1, #frames do + local frame = frames[i] + local t = sums[frame] + ctx.derive(t) + local v = t[key] + if v and v == v then + local raw = v + if clamp then + v = mathMax(clamp[1], mathMin(clamp[2], v)) + end + if v ~= mathHuge and v ~= -mathHuge then + if perMinute then + if previous then + local minutes = (frame - previousFrame) / 1800 + points[#points + 1] = { frame, minutes > 0 and (v - previous) / minutes or 0 } + end + previous, previousFrame = v, frame + else + points[#points + 1] = { frame, v, raw ~= v and raw or nil } + end + end + end + end + return points + end + + ---------------------------------------------------------------- + -- Series and the legend bar + ---------------------------------------------------------------- + + -- What a series stands for: an ally team with the grouping switch on, a team without. + -- In a fixed order, ally teams by id and teams by id, the way the player list has + -- them, whatever the table is sorted by. + function page.rebuildUnits() + local allies = {} + for _, ally in ipairs(ctx.allies()) do + local teams = {} + for _, team in ipairs(ally.teams) do + teams[#teams + 1] = team + end + tableSort(teams, function(a, b) + return a.id < b.id + end) + allies[#allies + 1] = { id = ally.id, teams = teams } + end + tableSort(allies, function(a, b) + return a.id < b.id + end) + + local units = {} + local byKey = {} + for _, ally in ipairs(allies) do + if grouped() then + local first = ally.teams[1] + local members = {} + local isLocal = false + for _, team in ipairs(ally.teams) do + members[#members + 1] = team.id + isLocal = isLocal or team.isLocal == true + end + local unit = { + key = "ally" .. ally.id, + name = ctx.i18n("ui.teamStats.team", { number = ally.id + 1 }), + color = first and { first.accent[1], first.accent[2], first.accent[3] } or { 0.8, 0.8, 0.8 }, + members = members, + teams = ally.teams, + isLocal = isLocal, + } + units[#units + 1] = unit + byKey[unit.key] = unit + else + for _, team in ipairs(ally.teams) do + local unit = { + key = "team" .. team.id, + name = team.name, + color = { team.accent[1], team.accent[2], team.accent[3] }, + members = { team.id }, + teams = { team }, + isLocal = team.isLocal, + quiet = team.dead or team.gone, + } + units[#units + 1] = unit + byKey[unit.key] = unit + end + end + end + page.units = units + page.unitByKey = byKey + -- The first highlight, and one that stopped standing for anything (the grouping + -- switch turned), is the viewer's own unit; a cleared one stays cleared. + if not page.highlightSet or (page.highlight and not byKey[page.highlight]) then + page.highlightSet = true + page.highlight = nil + for _, unit in ipairs(units) do + if unit.isLocal then + page.highlight = unit.key + end + end + end + layoutBar() + end + + -- The legend bar: the All button first, then a button per ally team, its caption and + -- its members as squares of their colour side by side. Captions stay while the whole + -- row fits; squares shrink when even that does not. With the grouping switch on the + -- whole button is the ally team; off, each square is its player and the caption is + -- only a caption. + layoutBar = function() + local r = page.rects + if not r then + return + end + local pad = ctx.metrics.sidePad + local fs = ctx.metrics.catFs + local font = ctx.font() + local function widthOf(label) + return font and mathFloor(font:GetTextWidth(label) * fs) or #label * fs * 0.55 + end + local all = { all = true, label = ctx.i18n("ui.teamStats.graph.all"), members = {} } + all.labelW = widthOf(all.label) + ---@type table[] + local blocks = { all } + local seen = {} + for _, unit in ipairs(page.units) do + for _, team in ipairs(unit.teams) do + local block = seen[team.allyID] + if not block then + block = { + ally = team.allyID, + label = ctx.i18n("ui.teamStats.team", { number = team.allyID + 1 }), + members = {}, + unit = grouped() and unit or nil, + } + seen[team.allyID] = block + blocks[#blocks + 1] = block + end + block.members[#block.members + 1] = { team = team, unit = unit } + end + end + local count, labelW = 0, 0 + for i = 2, #blocks do + local b = blocks[i] + count = count + #b.members + b.labelW = widthOf(b.label) + labelW = labelW + b.labelW + pad + end + local teamBlocks = #blocks - 1 + -- The All button always keeps its caption; the team blocks share the rest. + local avail = r.bar[3] - r.bar[1] - pad * 2 - (all.labelW + pad * 3) + local gaps = pad * mathMax(0, teamBlocks - 1) + pad * teamBlocks + local square = r.square + local withLabels = count * square + gaps + labelW <= avail + if not withLabels then + square = mathMax(4, mathMin(square, mathFloor((avail - gaps) / mathMax(1, count)))) + end + local items = {} + local x = r.bar[1] + pad + local cy = mathFloor((r.bar[2] + r.bar[4]) * 0.5) + local y1 = cy - mathFloor(square * 0.5) + local y2 = y1 + square + for _, unit in ipairs(page.units) do + unit.barX1, unit.barX2 = nil, nil + end + local half = mathFloor(pad * 0.5) + for _, b in ipairs(blocks) do + -- The plate runs from the caption to the last square, with half a pad of air. + b.x1 = x - half + if b.all then + b.labelX = x + x = x + b.labelW + else + if withLabels then + b.labelX = x + x = x + b.labelW + pad + end + for _, m in ipairs(b.members) do + items[#items + 1] = { unit = m.unit, team = m.team, x1 = x, y1 = y1, x2 = x + square, y2 = y2 } + m.unit.barX1 = mathMin(m.unit.barX1 or x, x) + m.unit.barX2 = mathMax(m.unit.barX2 or x + square, x + square) + x = x + square + end + end + b.x2 = x + half + x = x + pad + pad + end + page.barBlocks = blocks + page.barItems = items + page.barLabels = withLabels + page.barY1, page.barY2 = y1, y2 + page.gen = page.gen + 1 + end + + -- The units not hidden from the chart. + local function shownUnits() + local list = {} + for _, u in ipairs(page.units) do + if not page.hidden[u.key] then + list[#list + 1] = u + end + end + return list + end + + -- The milestones of these units as pictures on the chart, each framed in its unit's + -- colour, on the unit's series where `indexByKey` names one, else in the lane. + local function milestoneMarkers(list, indexByKey) + local markers = {} + local live = ctx.live() + if not live or not ctx.filters.milestones then + return markers + end + for _, unit in ipairs(list) do + for _, teamID in ipairs(unit.members) do + local team = live[teamID] + for _, m in ipairs(team and team.milestones or {}) do + local ud = m.unitDefID and UnitDefs[m.unitDefID] or nil + ---@cast ud table? + local label = ctx.L.milestone[m.key] or m.key + if ud then + label = label .. " (" .. (ud.translatedHumanName or ud.name) .. ")" + end + markers[#markers + 1] = { + x = m.frame, + texture = ud and ("#" .. m.unitDefID) or nil, + text = Graph.frameLabel(m.frame) .. " " .. label, + series = indexByKey and indexByKey[unit.key] or nil, + frame = unit.color, + } + end + end + end + return markers + end + + -- The chart's series from the pick and the switches: the teams' runs of the stat as + -- lines, the highlighted one lifted and its milestones on it (every team's when none + -- is highlighted); or a composition as bands, the highlighted team's or everyone's + -- together; or, with the share switch on, the highlighted ally team's members as + -- shares of it, or every team as a share of the whole when none is highlighted. + function page.build() + rebuildSamples() + page.rebuildUnits() + local column = ctx.COLUMNS[page.stat] + local isGrouped = grouped() + local perMinute = ctx.filters.perMinute and column and column.rate or false + local focus = page.highlight and page.unitByKey[page.highlight] or nil + local shown = shownUnits() + local series, markers = {}, {} + local kind = "line" + local title + local yFormat = nil + local highlightIndex = nil + local ownLegend = false + local all = ctx.i18n("ui.teamStats.graph.all") + + if page.stat == "composition" then + kind = "stacked" + ownLegend = true + local members = {} + for _, u in ipairs(focus and { focus } or shown) do + for _, teamID in ipairs(u.members) do + members[#members + 1] = teamID + end + end + local group = ctx.groupByKey.composition + local bi = 0 + for i = 1, #group.columns do + local c = ctx.COLUMNS[group.columns[i]] + -- The buckets, not the total that heads the view. + if c.count and c.group == "value" then + bi = bi + 1 + series[#series + 1] = { + name = ctx.L.full[c.key], + color = BUCKET_COLORS[bi] or { 0.8, 0.8, 0.8 }, + points = pointsOf(members, c.key, false), + } + end + end + title = ctx.i18n("ui.teamStats.graph.composition") .. " \194\183 " .. (focus and focus.name or all) + markers = milestoneMarkers(focus and { focus } or shown, nil) + elseif column and ctx.filters.shareOfTeam and isGrouped and column.fmt == "si" then + kind = "stacked" + if focus then + for _, team in ipairs(focus.teams) do + series[#series + 1] = { + name = team.name, + color = { team.accent[1], team.accent[2], team.accent[3] }, + points = pointsOf({ team.id }, column.key, perMinute), + } + end + else + for _, u in ipairs(shown) do + series[#series + 1] = { + name = u.name, + color = u.color, + points = pointsOf(u.members, column.key, perMinute), + } + end + end + title = ctx.columnTitle(column) + .. " \194\183 " + .. ctx.L.switch.shareOfTeam + .. " \194\183 " + .. (focus and focus.name or all) + markers = milestoneMarkers(focus and { focus } or shown, nil) + elseif column then + local indexByKey = {} + for _, u in ipairs(shown) do + series[#series + 1] = { + name = u.name, + color = u.color, + points = pointsOf(u.members, column.key, perMinute, column.clamp), + width = 2, + } + indexByKey[u.key] = #series + if u.key == page.highlight then + highlightIndex = #series + end + end + title = ctx.columnTitle(column) + if perMinute then + title = title .. ctx.L.perMinuteSuffix + end + if column.fmt == "percent" then + yFormat = percentFormat + end + markers = milestoneMarkers(focus and { focus } or shown, indexByKey) + end + + page.empty = true + for _, s in ipairs(series) do + if #s.points > 0 then + page.empty = false + end + end + -- The bands of the composition chart are named by the chart itself; teams are + -- named by the legend bar. The axis format is set straight: a nil handed to + -- configure would leave the last one. + chart.cfg.yFormat = yFormat + chart:configure({ kind = kind, title = title, legend = ownLegend, bandLabels = ownLegend }) + chart:setSeries(series) + chart:setMarkers(markers) + chart:setHighlight(kind == "line" and highlightIndex or nil) + page.dirty = false + end + + ---------------------------------------------------------------- + -- Drawing + ---------------------------------------------------------------- + + -- Four whole-pixel bars around a rect. + local function frame(x1, y1, x2, y2, w, color) + local Color, Rect = ctx.draw.Color, ctx.draw.Rect + Color(color[1], color[2], color[3], color[4]) + Rect(x1, y1, x2, y1 + w) + Rect(x1, y2 - w, x2, y2) + Rect(x1, y1, x1 + w, y2) + Rect(x2 - w, y1, x2, y2) + end + + -- Whether a block of the bar is the picked one: All with no highlight, an ally + -- team's when the grouping switch makes the block the unit. + local function blockLit(b) + if b.all then + return page.highlight == nil + end + return grouped() and b.unit ~= nil and b.unit.key == page.highlight + end + + -- What is baked into the panel's list: the stat list on a card like the sidebar's, + -- and the legend bar. + function page.drawPanel() + local r = page.rects + if not r then + return + end + local look, colors, metrics = ctx.look, ctx.colors, ctx.metrics + local RectRound, Highlight, Color, Rect = ctx.draw.RectRound, ctx.draw.Highlight, ctx.draw.Color, ctx.draw.Rect + local fs = metrics.catFs + local cs = metrics.csSmall + + RectRound( + r.list[1], + r.list[2], + r.list[3], + r.list[4], + metrics.csPanel, + 1, + 1, + 1, + 1, + look.sidebarFill, + look.sidebarFillTop + ) + for i, entry in ipairs(page.statList) do + local x1, y1, x2, y2 = statRect(i) + local selected = entry.key == page.stat + if selected then + RectRound(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, cs, 1, 1, 1, 1, look.selectedFill) + elseif i == page.hover.stat then + Highlight(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, cs, look.rowHoverOpacity, look.white) + end + local label = ctx.text.fit(ctx.font(), entry.label, x2 - x1 - metrics.sidePad * 2, fs) + ctx.queueText( + (selected and colors.selected or colors.dim) .. label, + x1 + metrics.sidePad, + mathFloor((y1 + y2) * 0.5), + fs, + "ov" + ) + end + + -- The legend bar: a plate per block like a button, lit when it is the picked one + -- or hovered, then its caption and squares; the picked one framed warm. + local isGrouped = grouped() + local cy = mathFloor((r.bar[2] + r.bar[4]) * 0.5) + local py1, py2 = r.bar[2] + r.inset, r.bar[4] - r.inset + local fw = r.frame + for i, b in ipairs(page.barBlocks) do + local lit = blockLit(b) + RectRound(b.x1, py1, b.x2, py2, cs, 1, 1, 1, 1, lit and look.selectedFill or PLATE) + if lit then + -- A frame that follows the plate's corners. + ctx.draw.RectRoundOutline(b.x1, py1, b.x2, py2, cs, fw, 1, 1, 1, 1, PICKED_FRAME, PICKED_FRAME) + end + if i == page.hover.block and not lit then + Highlight(b.x1, py1, b.x2, py2, cs, look.rowHoverOpacity, look.white) + end + if b.labelX then + ctx.queueText((lit and colors.selected or colors.dim) .. b.label, b.labelX, cy, fs, "ov") + end + end + for i, item in ipairs(page.barItems) do + local c = item.team.accent + local hidden = page.hidden[item.unit.key] + local alpha = hidden and 0.22 or (i == page.hover.legend and 1 or 0.9) + Color(c[1], c[2], c[3], alpha) + Rect(item.x1, item.y1, item.x2, item.y2) + end + if not isGrouped then + for _, unit in ipairs(page.units) do + if unit.key == page.highlight and unit.barX1 then + frame(unit.barX1 - fw, page.barY1 - fw, unit.barX2 + fw, page.barY2 + fw, fw, PICKED_FRAME) + end + end + end + Color(1, 1, 1, 1) + + if page.empty then + local c = r.chart + local key = page.anySamples and "ui.teamStats.graph.noData" or "ui.teamStats.graph.waiting" + ctx.queueText( + colors.dim .. ctx.i18n(key), + mathFloor((c[1] + c[3]) * 0.5), + mathFloor((c[2] + c[4]) * 0.5), + fs, + "ovc" + ) + end + end + + -- The chart itself, after the panel's list: its own list plus the hover overlay. + function page.drawChart(mx, my) + if page.dirty then + page.build() + end + local r = page.rects + local hit = nil + if r and mx >= r.chart[1] and mx <= r.chart[3] and my >= r.chart[2] and my <= r.chart[4] then + hit = chart:hitTest(mx, my) + end + page.chartHit = hit + chart:setHover(hit) + chart:draw() + end + + ---------------------------------------------------------------- + -- The cursor + ---------------------------------------------------------------- + + -- Which list entry, legend square or legend block the cursor is over, for the + -- panel's bake signature. + function page.hoverAt(mx, my) + page.hover.stat, page.hover.legend, page.hover.block = 0, 0, 0 + local r = page.rects + if not r then + return "0|0|0|0" + end + if mx >= r.list[1] and mx <= r.list[3] then + for i = 1, #page.statList do + local _, y1, _, y2 = statRect(i) + if my > y1 and my <= y2 then + page.hover.stat = i + end + end + elseif my >= r.bar[2] and my <= r.bar[4] then + for i, b in ipairs(page.barBlocks) do + if mx >= b.x1 and mx < b.x2 then + page.hover.block = i + end + end + for i, item in ipairs(page.barItems) do + if mx >= item.x1 and mx < item.x2 and my >= item.y1 and my <= item.y2 then + page.hover.legend = i + end + end + end + return page.hover.stat .. "|" .. page.hover.legend .. "|" .. page.hover.block .. "|" .. page.gen + end + + -- The unit under the cursor in the bar: a square's, or the block's ally team when + -- the grouping switch makes the block the unit. + local function unitUnderCursor() + if page.hover.legend > 0 then + local item = page.barItems[page.hover.legend] + ---@cast item -? + return item.unit + end + if page.hover.block > 0 then + local b = page.barBlocks[page.hover.block] + ---@cast b -? + return b.unit + end + return nil + end + + local function changed() + page.dirty = true + page.gen = page.gen + 1 + ctx.playSound() + end + + -- A press: picks a stat; on the bar, highlights the unit under the cursor, or hides + -- it with the right button; All clears the highlight. + function page.mousePress(x, y, button) + page.hoverAt(x, y) + if page.hover.stat > 0 then + local entry = page.statList[page.hover.stat] + ---@cast entry -? + if button ~= 3 and entry.key ~= page.stat then + page.stat = entry.key + changed() + end + return true + end + local block = page.hover.block > 0 and page.barBlocks[page.hover.block] or nil + if block and block.all then + if button ~= 3 and page.highlight ~= nil then + page.highlight = nil + changed() + end + return true + end + local unit = unitUnderCursor() + if unit then + if button == 3 then + page.hidden[unit.key] = not page.hidden[unit.key] or nil + elseif page.highlight == unit.key then + page.highlight = nil + else + page.highlight = unit.key + end + changed() + return true + end + return block ~= nil + end + + -- The tooltip for the cursor: the chart's description, a stat's explanation, or the + -- team under the cursor in the bar and how the bar works. + function page.tooltip() + if page.chartHit then + return chart.cfg.title, chart:describe(page.chartHit) + end + if page.hover.stat > 0 then + local entry = page.statList[page.hover.stat] + ---@cast entry -? + if entry.column then + return entry.label, ctx.L.desc[entry.key] + end + return entry.label, ctx.i18n("ui.teamStats.graph.compositionDesc") + end + if page.hover.legend > 0 then + local item = page.barItems[page.hover.legend] + ---@cast item -? + local title = item.team.name + if item.unit.name ~= item.team.name then + title = item.team.name .. " \194\183 " .. item.unit.name + end + return title, ctx.i18n("ui.teamStats.graph.legendHint") + end + if page.hover.block > 0 then + local b = page.barBlocks[page.hover.block] + ---@cast b -? + if b.all then + return b.label, ctx.i18n("ui.teamStats.graph.allHint") + end + return b.label, + b.unit and ctx.i18n("ui.teamStats.graph.legendHint") or ctx.i18n("ui.teamStats.graph.blockHint") + end + return nil + end + + ---------------------------------------------------------------- + -- Housekeeping + ---------------------------------------------------------------- + + function page.getConfig() + return { graphStat = page.stat } + end + + function page.setConfig(data) + if type(data.graphStat) == "string" then + page.stat = data.graphStat + end + end + + -- The switches and the sidebar change what the chart is made of. + function page.invalidate() + page.dirty = true + end + + function page.destroy() + chart:destroy() + end + + return page +end + +return M diff --git a/luaui/Widgets/api_teamstats.lua b/luaui/Widgets/api_teamstats.lua new file mode 100644 index 00000000000..7f7d6f922c9 --- /dev/null +++ b/luaui/Widgets/api_teamstats.lua @@ -0,0 +1,210 @@ +local widget = widget ---@type Widget + +function widget:GetInfo() + return { + name = "Team stats API", + desc = "WG.teamStats: the team stats gadget's live values, history and milestones, for any widget", + author = "Floris", + date = "September 2026", + license = "GNU GPL, v2 or later", + layer = -828889, + enabled = true, + } +end + +-- The one place the team stats gadget (luarules/gadgets/api_teamstats.lua) hands over to. +-- A widget cannot call into a gadget, so the gadget calls LuaUI: while a receiver global +-- is registered here it is handed the live values of every team the viewer may see every +-- second, and a history request is answered with the backlog. Only one widget can hold +-- that global, so this one holds it for everyone: a widget subscribes with the callbacks +-- it wants, and the receiver exists only while someone has subscribed, so nothing is +-- handed over while no widget asks. +-- +-- WG.teamStats.subscribe(name, { live = fn(all, frame), milestone = fn(teamID, m), +-- history = fn(teamID, h) }) -- every callback optional +-- WG.teamStats.unsubscribe(name) +-- WG.teamStats.isSubscribed(name) +-- WG.teamStats.getLive([teamID]) -> the last hand-over (one team or all), and its frame +-- WG.teamStats.getMilestones(teamID) +-- WG.teamStats.requestHistory(teamID [, fromIndex]) -- answered through `history` +-- WG.teamStats.getInfo() -> { period, keys, buckets, milestones, energyPerMetal } +-- WG.teamStats.isAvailable() -> whether the gadget is there to hand over +-- +-- A live table is keyed by team and holds the keys `getInfo().keys` plus `dead` and +-- `milestones` ({ key, frame, unitDefID, unitID }, in order). A history is +-- { period, from, frames = {...}, values = { [key] = {...} } } from `fromIndex` on, so a +-- caller holding the first n samples asks for what came after them. + +local spGetGameFrame = Spring.GetGameFrame + +-- Hand-overs come every 30 frames while asked for; this long without one, the gadget +-- counts as gone rather than quiet. +local STALE_FRAMES = 90 + +---@type table +local subscribers = {} +local subscriberCount = 0 +-- The last hand-over and its frame; kept after the last unsubscribe, so a late reader +-- still gets the last known values. +---@type table?, number? +local live, liveFrame +-- The frame the receiver was last registered at. +---@type number? +local askedFrame +---@type table? +local info +-- How many milestones of each team have been seen, so new ones raise an event. +---@type table +local milestonesSeen = {} +-- History asked for and not yet handed to the gadget: teamID -> from index. +---@type table +local pending = {} +local pendingAny = false +local listening = false + +local function receiveLive(all, frame) + live, liveFrame = all, frame + for _, callbacks in pairs(subscribers) do + if callbacks.live then + callbacks.live(all, frame) + end + end + for teamID, team in pairs(all) do + local marks = team.milestones + local seen = milestonesSeen[teamID] or 0 + if marks and #marks > seen then + for i = seen + 1, #marks do + for _, callbacks in pairs(subscribers) do + if callbacks.milestone then + callbacks.milestone(teamID, marks[i]) + end + end + end + milestonesSeen[teamID] = #marks + end + end +end + +-- The gadget describes its layout once; after that the global is taken down again. +local function receiveInfo(described) + info = described + widgetHandler:DeregisterGlobal("TeamStatsInfo") +end + +local function historyRequest() + if not pendingAny then + return nil + end + local wanted = pending + pending = {} + pendingAny = false + return wanted +end + +local function receiveHistory(teamID, history) + for _, callbacks in pairs(subscribers) do + if callbacks.history then + callbacks.history(teamID, history) + end + end +end + +local function listen(on) + if on == listening then + return + end + listening = on + if on then + widgetHandler:RegisterGlobal("TeamStatsLive", receiveLive) + widgetHandler:RegisterGlobal("TeamStatsHistoryRequest", historyRequest) + widgetHandler:RegisterGlobal("TeamStatsHistory", receiveHistory) + if not info then + widgetHandler:RegisterGlobal("TeamStatsInfo", receiveInfo) + end + askedFrame = spGetGameFrame() + else + widgetHandler:DeregisterGlobal("TeamStatsLive") + widgetHandler:DeregisterGlobal("TeamStatsHistoryRequest") + widgetHandler:DeregisterGlobal("TeamStatsHistory") + widgetHandler:DeregisterGlobal("TeamStatsInfo") + end +end + +local api = {} + +function api.subscribe(name, callbacks) + if type(name) ~= "string" or type(callbacks) ~= "table" then + return false + end + if not subscribers[name] then + subscriberCount = subscriberCount + 1 + end + subscribers[name] = callbacks + listen(true) + return true +end + +function api.unsubscribe(name) + if not subscribers[name] then + return false + end + subscribers[name] = nil + subscriberCount = subscriberCount - 1 + if subscriberCount == 0 then + listen(false) + end + return true +end + +function api.isSubscribed(name) + return subscribers[name] ~= nil +end + +function api.getLive(teamID) + if teamID ~= nil then + return live and live[teamID], liveFrame + end + return live, liveFrame +end + +function api.getMilestones(teamID) + local team = live and live[teamID] + return team and team.milestones +end + +function api.requestHistory(teamID, fromIndex) + if not listening then + return false + end + pending[teamID] = fromIndex or 1 + pendingAny = true + return true +end + +function api.getInfo() + return info +end + +-- Whether the gadget is there: taken to be until it has been asked for this long +-- without a hand-over, or the hand-overs stopped. Asking again gives it that long again, +-- so a subscriber arriving after a quiet spell does not see it gone for a second. +-- Meaningful while someone is subscribed: nobody asking means nothing arrives. +function api.isAvailable() + local last = liveFrame + if askedFrame and (not last or askedFrame > last) then + last = askedFrame + end + if not last then + return true + end + return spGetGameFrame() - last <= STALE_FRAMES +end + +function widget:Initialize() + WG.teamStats = api +end + +function widget:Shutdown() + listen(false) + WG.teamStats = nil +end diff --git a/luaui/Widgets/gui_teamstats.lua b/luaui/Widgets/gui_teamstats.lua index 077e025e9b6..ef577075e88 100644 --- a/luaui/Widgets/gui_teamstats.lua +++ b/luaui/Widgets/gui_teamstats.lua @@ -67,7 +67,7 @@ local buttonclick = "LuaUI/Sounds/buildbar_waypoint.wav" -- The Graphs entry in the column is where a history page will go. Off until there is one -- to open, so players are not offered something that does nothing. ---@type boolean -local showPlannedPages = false +local showPlannedPages = true local screenHeightOrg = 610 local screenWidthOrg = 1100 @@ -112,11 +112,25 @@ local COLUMNS = { name = { group = "", stat = "player", short = "player", fmt = "name" }, damageDealt = { group = "damage", stat = "damageDealt", short = "damageDealt", fmt = "si", rate = true }, damageReceived = { group = "damage", stat = "damageReceived", short = "shortReceived", fmt = "si", rate = true }, - damageEfficiency = { group = "damage", stat = "damageEfficiency", short = "shortEfficiency", fmt = "percent" }, + -- `clamp` bounds what the chart plots of a ratio: over nothing it is infinite, and a + -- few thousand percent early on would put every other line on the floor. + damageEfficiency = { + group = "damage", + stat = "damageEfficiency", + short = "shortEfficiency", + fmt = "percent", + clamp = { 0, 1000 }, + }, unitsProduced = { group = "units", stat = "unitsProduced", short = "shortBuilt", fmt = "si", rate = true }, unitsKilled = { group = "units", stat = "unitsKilled", short = "unitsKilled", fmt = "si", rate = true }, unitsDied = { group = "units", stat = "unitsDied", short = "unitsDied", fmt = "si", rate = true }, - killEfficiency = { group = "units", stat = "killEfficiency", short = "shortEfficiency", fmt = "percent" }, + killEfficiency = { + group = "units", + stat = "killEfficiency", + short = "shortEfficiency", + fmt = "percent", + clamp = { 0, 1000 }, + }, unitsCaptured = { group = "units", stat = "unitsCaptured", short = "unitsCaptured", fmt = "si", rate = true }, unitsStolen = { group = "units", stat = "unitsStolen", short = "unitsStolen", fmt = "si", rate = true }, unitsReceived = { group = "units", stat = "unitsReceived", short = "unitsReceived", fmt = "si", rate = true }, @@ -134,8 +148,21 @@ local COLUMNS = { energySent = { group = "energy", stat = "resourceSent", short = "resourceSent", fmt = "si", rate = true }, energyReceived = { group = "energy", stat = "resourceReceived", short = "shortReceived", fmt = "si", rate = true }, energyStored = { group = "energy", stat = "resourceStored", short = "resourceStored", fmt = "si" }, - aggressionLevel = { group = "activity", stat = "aggression", short = "shortAggression", fmt = "plain" }, - actionsPerMinute = { group = "activity", stat = "actionsPerMinute", short = "shortActionsPerMinute", fmt = "plain" }, + aggressionLevel = { + group = "activity", + stat = "aggression", + short = "shortAggression", + fmt = "plain", + clamp = { -30, 30 }, + }, + -- Live from the APM broadcast; only the team stats gadget keeps a history of it. + actionsPerMinute = { + group = "activity", + stat = "actionsPerMinute", + short = "shortActionsPerMinute", + fmt = "plain", + liveOnly = true, + }, -- What is happening right now, read fresh at every refresh: the economy from the engine -- and the rest from the team stats gadget. None of these is a running total, so the -- per-minute switch leaves them be. `of` names the two amounts a percentage is made of, @@ -229,6 +256,7 @@ local COLUMNS = { short = "shortEfficiency", fmt = "percent", even = true, + clamp = { 0, 1000 }, }, teamKillValue = { group = "traded", stat = "teamKillValue", short = "shortTeamKill", fmt = "si", rate = true }, comKills = { group = "commanders", stat = "comKills", short = "unitsKilled", fmt = "si", rate = true }, @@ -436,14 +464,31 @@ end -- something with ally teams to group by, so it is left out of a free-for-all. ---@type table[] local switches = { + -- The Graphs page: a mode, so it leads the row; its state is the page's, not a filter. + { key = "graphs", mode = true }, + -- The Graphs page's alone, beside its switch: the highlighted team's milestones on + -- the chart. + { key = "milestones", page = true }, { key = "groupByTeam" }, -- Only offered while there are bands to take a share of. { key = "shareOfTeam" }, + -- Only offered where a rate means something: a running total on the chart or among + -- the picked group's columns. { key = "perMinute" }, - { key = "bars" }, + -- The table's alone. + { key = "bars", table = true }, } +-- The ally teams whose players are folded away under their band, by ally id. +---@type table +local collapsed = {} ---@type table -local filters = { groupByTeam = true, shareOfTeam = false, perMinute = false, bars = false } +local filters = { + groupByTeam = true, + shareOfTeam = false, + perMinute = false, + bars = false, + milestones = true, +} -- Excess is waste once it passes a share of what was produced; these say what to compare -- each excess column with. @@ -566,7 +611,7 @@ local look = { barFill = { 1, 1, 1, 0.07 }, -- Every second team row under a band takes this, so the eye keeps its line across a -- dozen columns. Faint: it is a guide, not a highlight. - stripeFill = { 1, 1, 1, 0.035 }, + stripeFill = { 1, 1, 1, 0.022 }, -- The sort marker. sortMark = { 0.92, 0.73, 0.27, 0.9 }, -- The font is shared with every other widget, and whichever of them set its outline @@ -576,13 +621,18 @@ local look = { -- The sorted column, tinted down its length in the hue of its caption, so the eye -- follows the values the table is ordered by. sortedFill = { 1, 0.78, 0.51, 0.04 }, + -- The line framing the sorted column, caption to last row, in the same hue. + sortedLine = { 1, 0.78, 0.51, 0.28 }, + -- The sorted column's numbers, a shade warmer than the rest. + sortedText = "\255\240\226\205", + -- A number that is nothing, or not known: quieter, so what there is stands out. + zeroText = "\255\150\150\150", -- Excess above this share of what was produced reads as waste. wasteShare = 0.1, } local colorTitle = "\255\235\235\235" local colorHeader = "\255\255\200\130" local colorKey = "\255\235\185\070" -local colorName = "\255\235\235\235" local colorValue = "\255\215\212\208" -- An ally team's totals in its band: lighter than a player's numbers, since they sit on -- the band's own sheen, and not the warm of the caption beside them. @@ -656,7 +706,8 @@ local teamAPM = {} -- calls it every second; after `stale` frames without, the values count as gone rather -- than old, and the table falls back to what the engine tells allies on its own. -- `postGame` asks for one more read after game over, so the other side's live values --- show once they may. `on` is whether the gadget is taken to be there: yes until the +-- show once they may. `direct` is set while the panel holds the receiver global itself, +-- with no API widget to hold it for everyone. `on` is whether the gadget is taken to be there: yes until the -- panel has been open (`opened`) for `stale` frames without a hand-over, or the -- hand-overs stop; while it is not, the gadget's columns, views and the history page -- are left out of the panel rather than shown empty. @@ -685,6 +736,10 @@ local pendingCount = 0 ---@type function local rebuildRows +-- The Graphs page (luaui/Include/teamstats_graphs.lua), made at the end of the file once +-- everything it reads from is declared. +---@type table +local graphs ---------------------------------------------------------------- -- Numbers @@ -736,29 +791,33 @@ end -- Ratios and levels, from the counters they are made of. Run for a team and for an ally -- team's total alike, so a band's efficiency is the efficiency of its sums. local function derive(s) - s.metalStored = s.metalProduced + s.metalReceived - (s.metalUsed + s.metalSent + s.metalExcess) - s.energyStored = s.energyProduced + s.energyReceived - (s.energyUsed + s.energySent + s.energyExcess) - s.unitsStolen = s.unitsOutCaptured - s.unitsActive = s.unitsProduced - + s.unitsReceived - + s.unitsCaptured - - (s.unitsDied + s.unitsSent + s.unitsOutCaptured) - if s.damageReceived ~= 0 then - s.damageEfficiency = (s.damageDealt / s.damageReceived) * 100 - else - s.damageEfficiency = mathHuge - end - if s.unitsDied ~= 0 then - s.killEfficiency = (s.unitsKilled / s.unitsDied) * 100 - else - s.killEfficiency = mathHuge - end - -- Energy counts at a sixtieth of metal, the game's usual exchange. - local resources = s.metalProduced + s.metalReceived + (s.energyProduced + s.energyReceived) / 60 - if resources ~= 0 and s.damageDealt ~= 0 then - s.aggressionLevel = mathFloor(10 * mathLog10(s.damageDealt / resources) + 0.5) - else - s.aggressionLevel = -mathHuge + -- The engine's counters come as a set or not at all: a graph sample taken from the + -- gadget alone has none of them. + if s.metalProduced ~= nil then + s.metalStored = s.metalProduced + s.metalReceived - (s.metalUsed + s.metalSent + s.metalExcess) + s.energyStored = s.energyProduced + s.energyReceived - (s.energyUsed + s.energySent + s.energyExcess) + s.unitsStolen = s.unitsOutCaptured + s.unitsActive = s.unitsProduced + + s.unitsReceived + + s.unitsCaptured + - (s.unitsDied + s.unitsSent + s.unitsOutCaptured) + if s.damageReceived ~= 0 then + s.damageEfficiency = (s.damageDealt / s.damageReceived) * 100 + else + s.damageEfficiency = mathHuge + end + if s.unitsDied ~= 0 then + s.killEfficiency = (s.unitsKilled / s.unitsDied) * 100 + else + s.killEfficiency = mathHuge + end + -- Energy counts at a sixtieth of metal, the game's usual exchange. + local resources = s.metalProduced + s.metalReceived + (s.energyProduced + s.energyReceived) / 60 + if resources ~= 0 and s.damageDealt ~= 0 then + s.aggressionLevel = mathFloor(10 * mathLog10(s.damageDealt / resources) + 0.5) + else + s.aggressionLevel = -mathHuge + end end -- The live ratios, from amounts that can be unknown: a team the viewer may not see -- has none, and its ally team's sums are unknown with it. @@ -986,11 +1045,20 @@ local function readTeam(teamID, allyID, frame, live) r, g, b = anonymousTeamColor[1], anonymousTeamColor[2], anonymousTeamColor[3] else r, g, b = spGetTeamColor(teamID) + r, g, b = r or 1, g or 1, b or 1 end -- A rate is over the time the team was in the game, and never over less than a -- minute, or the first seconds would show rates of thousands. local alive = deathFrame[teamID] and mathMin(deathFrame[teamID], frame) or frame + -- The name in the team's colour, lifted towards white so a dark team still reads on + -- the dark panel. + local nameColor = string.char( + 255, + mathFloor((r * 0.65 + 0.35) * 255), + mathFloor((g * 0.65 + 0.35) * 255), + mathFloor((b * 0.65 + 0.35) * 255) + ) return { id = teamID, allyID = allyID, @@ -998,6 +1066,7 @@ local function readTeam(teamID, allyID, frame, live) name = name, sortName = stringLower(name), label = label, + nameColor = nameColor, accent = { r, g, b, isDead and 0.35 or 0.9 }, dead = isDead, gone = gone, @@ -1115,11 +1184,17 @@ rebuildRows = function() if grouped() then for i = 1, #allies do local ally = allies[i] - rows[#rows + 1] = { type = "band", ally = ally } - for j = 1, #ally.teams do - -- Striped by place under the band, so the pattern starts afresh with each - -- ally team rather than running on across its band. - rows[#rows + 1] = { type = "team", team = ally.teams[j], stripe = j % 2 == 0 } + -- An ally team of one is its one team: the band says the name and the numbers + -- once. A band of more folds its teams away when clicked. + local solo = #ally.teams == 1 and ally.teams[1] or nil + rows[#rows + 1] = + { type = "band", ally = ally, team = solo, folded = not solo and collapsed[ally.id] or nil } + if not solo and not collapsed[ally.id] then + for j = 1, #ally.teams do + -- Striped by place under the band, so the pattern starts afresh with each + -- ally team rather than running on across its band. + rows[#rows + 1] = { type = "team", team = ally.teams[j], stripe = j % 2 == 0 } + end end end else @@ -1429,6 +1504,22 @@ end -- Rebuilds every rect against the panel size. Whole pixels throughout, so glyph and -- rectangle edges do not land between pixels. +-- Whether a rate means anything where the eye is: the picked stat on the Graphs page, +-- any running total among the picked group's columns on the table. +local function rateShown() + if graphs and graphs.open then + local column = COLUMNS[graphs.stat] + return column ~= nil and column.rate == true + end + local group = groupByKey[selectedGroup] + for _, key in ipairs(group and group.columns or {}) do + if COLUMNS[key].rate then + return true + end + end + return false +end + local function setLayout() local s = widgetScale local pad = mathFloor(8 * s) @@ -1490,6 +1581,11 @@ local function setLayout() -- it, so the bar sits in a channel rather than hugging them. barX1 = area.x2 - metrics.edgeInset - metrics.barW listRight = barX1 - metrics.listGap + -- The Graphs page takes the table's room, header rows and scrollbar included. + if graphs then + graphs.setFont(font, metrics.rowFs) + graphs.setLayout(listX1, listBottom, area.x2 - metrics.edgeInset, metrics.bandTop, s) + end -- The header band: the switches, right to left from the panel's edge. A switch this -- small is a poor click target on its own, so its caption is part of it and the hover @@ -1507,7 +1603,15 @@ local function setLayout() local x2 = area.x2 - metrics.edgeInset for i = #switches, 1, -1 do local sw = switches[i] - if (sw.key == "groupByTeam" and isFFA) or (sw.key == "shareOfTeam" and not grouped()) then + local onGraphs = graphs and graphs.open + if + (sw.mode and not (showPlannedPages and handover.on)) + or (sw.key == "perMinute" and not rateShown()) + or (sw.key == "groupByTeam" and isFFA) + or (sw.key == "shareOfTeam" and not grouped()) + or (sw.table and onGraphs) + or (sw.page and not onGraphs) + then sw.draw, sw.hit = nil, nil else local labelW = mathFloor(font:GetTextWidth(sw.label) * metrics.toggleFs) @@ -1556,10 +1660,14 @@ local function flushText() end -- The sort marker: a small triangle pointing the way the column is sorted. ----@type number, number, number, number, boolean -local triX, triY, triW, triH, triUp = 0, 0, 0, 0, false +---@type number, number, number, number, boolean, boolean +local triX, triY, triW, triH, triUp, triRight = 0, 0, 0, 0, false, false local function triangleVertices() - if triUp then + if triRight then + glVertex(triX, triY) + glVertex(triX, triY + triH) + glVertex(triX + triW, triY + triH * 0.5) + elseif triUp then glVertex(triX, triY) glVertex(triX + triW, triY) glVertex(triX + triW * 0.5, triY + triH) @@ -1571,7 +1679,18 @@ local function triangleVertices() end local function drawSortMark(x, cy) - triX, triY, triW, triH, triUp = x, cy - mathFloor(metrics.triH * 0.5), metrics.triW, metrics.triH, sortAscending + triX, triY, triW, triH, triUp, triRight = + x, cy - mathFloor(metrics.triH * 0.5), metrics.triW, metrics.triH, sortAscending, false + glColor(look.sortMark) + glBeginEnd(GL_TRIANGLES, triangleVertices) + glColor(1, 1, 1, 1) +end + +-- The fold mark after a band's caption: down while its players are shown, right once +-- they are folded away. +local function drawFoldMark(x, cy, folded) + triX, triY, triW, triH, triUp, triRight = + x, cy - mathFloor(metrics.triH * 0.5), metrics.triW, metrics.triH, false, folded == true glColor(look.sortMark) glBeginEnd(GL_TRIANGLES, triangleVertices) glColor(1, 1, 1, 1) @@ -1605,17 +1724,20 @@ local function fitRow(row) else local ally = row.ally local caption = BAR.I18N("ui.teamStats.team", { number = ally.id + 1 }) - local members = #ally.teams == 1 and L.memberOne or BAR.I18N("ui.teamStats.members", { count = #ally.teams }) + local members = row.team and row.team.label + or (#ally.teams == 1 and L.memberOne or BAR.I18N("ui.teamStats.members", { count = #ally.teams })) local room = nameColumn.x2 - nameColumn.x1 - metrics.rowPad * 2 row.caption = text.fit(font, caption, room, metrics.bandFs) row.captionW = mathFloor(font:GetTextWidth(row.caption) * metrics.bandFs) -- The count goes after the caption when it fits beside it, and is dropped when -- not: cut short it would say nothing. - local left = room - row.captionW - metrics.rowPad * 2 + local left = room - row.captionW - metrics.rowPad * 2 - metrics.triW * 2 if font:GetTextWidth(members) * metrics.rowFs <= left then row.members = members + row.membersW = mathFloor(font:GetTextWidth(members) * metrics.rowFs) + metrics.rowPad else row.members = nil + row.membersW = 0 end for i = 2, #columns do local c = columns[i] @@ -1627,7 +1749,7 @@ local function fitRow(row) end end -local function drawBand(row, top, bottom) +local function drawBand(row, top, bottom, hovered) RectRound( listX1, bottom, @@ -1641,6 +1763,34 @@ local function drawBand(row, top, bottom) look.sheenTop, look.sheenTop ) + local nameColumn = columns[1] + ---@cast nameColumn -? + if hovered and not row.team then + -- The caption is the fold button. + Highlight( + listX1, + bottom, + nameColumn.x2, + top - metrics.csSmall, + metrics.csSmall, + look.rowHoverOpacity, + look.white + ) + end + if row.team then + RectRound( + listX1, + bottom + metrics.accentPad, + listX1 + metrics.accentW, + top - metrics.csSmall - metrics.accentPad, + 0, + 0, + 0, + 0, + 0, + row.team.accent + ) + end RectRound( listX1, bottom, @@ -1655,25 +1805,34 @@ local function drawBand(row, top, bottom) look.headerLineFade ) local by = text.baseline(font, bottom, top, metrics.bandFs) - queueText(colorHeader .. row.caption, listX1 + metrics.rowPad, by, metrics.bandFs, "o") + local captionX = listX1 + (row.team and metrics.nameIndent or metrics.rowPad) + queueText(colorHeader .. row.caption, captionX, by, metrics.bandFs, "o") if row.members then + local team = row.team + local color = colorDim + if team then + color = (team.dead or team.gone) and colorDim or team.nameColor + end queueText( - colorDim .. row.members, - listX1 + metrics.rowPad * 3 + row.captionW, + color .. row.members, + captionX + metrics.rowPad * 2 + row.captionW, text.baseline(font, bottom, top, metrics.rowFs), metrics.rowFs, "o" ) end + if not row.team then + drawFoldMark( + captionX + metrics.rowPad * 2 + row.captionW + (row.membersW or 0), + mathFloor((bottom + top - metrics.csSmall) * 0.5), + row.folded + ) + end local vy = text.baseline(font, bottom, top, metrics.rowFs) for i = 2, #columns do - queueText( - (row.tones[i] or colorTotal) .. row.cells[i], - columns[i].x2 - metrics.cellPad, - vy, - metrics.rowFs, - "or" - ) + local v = row.vals[i] + local valueColor = row.tones[i] or ((v == 0 or not isFinite(v)) and look.zeroText or colorTotal) + queueText(valueColor .. row.cells[i], columns[i].x2 - metrics.cellPad, vy, metrics.rowFs, "or") end end @@ -1731,9 +1890,27 @@ local function drawTeamRow(row, top, bottom, hovered) local by = text.baseline(font, bottom, top, metrics.rowFs) local quiet = team.dead or team.gone - queueText((quiet and colorDim or colorName) .. row.fitName, listX1 + metrics.nameIndent, by, metrics.rowFs, "o") + queueText( + (quiet and colorDim or team.nameColor) .. row.fitName, + listX1 + metrics.nameIndent, + by, + metrics.rowFs, + "o" + ) for i = 2, #columns do - local valueColor = quiet and colorDim or (row.tones[i] or colorValue) + local v = row.vals[i] + local valueColor + if quiet then + valueColor = colorDim + elseif row.tones[i] then + valueColor = row.tones[i] + elseif v == 0 or not isFinite(v) then + valueColor = look.zeroText + elseif columns[i].key == sortKey then + valueColor = look.sortedText + else + valueColor = colorValue + end queueText(valueColor .. row.cells[i], columns[i].x2 - metrics.cellPad, by, metrics.rowFs, "or") end end @@ -1752,6 +1929,10 @@ local function drawRows() end end if sortedColumn then + -- From the top of the first row drawn - a band's plate starts a corner lower - to + -- the bottom of the last. + local first = rows[scroll + 1] + local top = listTop - ((first and first.type == "band") and metrics.csSmall or 0) local bottom = listTop for i = scroll + 1, #rows do local rowBottom = listTop - (rows[i].off - base) - rowHeightOf(rows[i]) @@ -1760,8 +1941,19 @@ local function drawRows() end bottom = rowBottom end - if bottom < listTop then - RectRound(sortedColumn.x1, bottom, sortedColumn.x2, listTop, metrics.csSmall, 0, 0, 1, 1, look.sortedFill) + if bottom < top then + RectRound(sortedColumn.x1, bottom, sortedColumn.x2, top, metrics.csSmall, 0, 0, 1, 1, look.sortedFill) + -- And a frame around it, from the first row to the last one drawn, so the column + -- the table is ordered by is found at a glance. The caption stays outside: its + -- sort marker says enough, and a frame up there would run into the group line. + -- Plain whole-pixel rects, so all four sides are the same thickness. + local x1, x2 = sortedColumn.x1, sortedColumn.x2 + glColor(look.sortedLine) + gl.Rect(x1, bottom, x1 + 1, top) + gl.Rect(x2 - 1, bottom, x2, top) + gl.Rect(x1, top - 1, x2, top) + gl.Rect(x1, bottom, x2, bottom + 1) + glColor(1, 1, 1, 1) end end @@ -1777,7 +1969,7 @@ local function drawRows() end fitRow(row) if row.type == "band" then - drawBand(row, top, bottom) + drawBand(row, top, bottom, hover.row == i) else drawTeamRow(row, top, bottom, hover.row == i) end @@ -1912,9 +2104,10 @@ local function drawHeader() if hovered then Highlight(sw.hit[1], sw.hit[2], sw.hit[3], sw.hit[4], metrics.csSmall, look.rowHoverOpacity, look.white) end - UiToggle(sw.draw[1], sw.draw[2], sw.draw[3], sw.draw[4], filters[sw.key], hovered) + local on = sw.mode and graphs.open or filters[sw.key] + UiToggle(sw.draw[1], sw.draw[2], sw.draw[3], sw.draw[4], on, hovered) queueText( - (filters[sw.key] and colorSelected or colorDim) .. sw.label, + (on and colorSelected or colorDim) .. sw.label, sw.draw[1] - metrics.rowPad, mathFloor((sw.hit[2] + sw.hit[4]) * 0.5), metrics.toggleFs, @@ -1929,6 +2122,12 @@ end local function drawPanel() drawSidebar() drawHeader() + if graphs.open then + graphs.drawPanel() + flushText() + glColor(1, 1, 1, 1) + return + end drawTableHeader() drawRows() @@ -2004,7 +2203,10 @@ local function panelSignature(mx, my) hover.tog = switchAt(mx, my) or 0 hover.bar = 0 - if hover.tog == 0 and mx >= listX1 and mx < listRight then + local extra = "" + if graphs.open then + extra = "|g" .. graphs.hoverAt(mx, my) + elseif hover.tog == 0 and mx >= listX1 and mx < listRight then hover.hcol = headerColumnAt(mx, my) or 0 if hover.hcol == 0 then hover.row = rowAt(my) or 0 @@ -2038,6 +2240,7 @@ local function panelSignature(mx, my) .. layoutGen .. "|" .. (dragging and 1 or 0) + .. extra end ---------------------------------------------------------------- @@ -2139,9 +2342,9 @@ local function rebuildEntries() if not selectedStays then selectedGroup = GROUPS[1].key end - if showPlannedPages and handover.on then - entries[#entries + 1] = { divider = true } - entries[#entries + 1] = { key = "graphs", label = L.group.graphs, disabled = true } + if not (showPlannedPages and handover.on) and graphs and graphs.open then + -- The page went with the gadget: back to the table. + graphs.open = false end end @@ -2149,13 +2352,14 @@ local function loadLabels() L.title = BAR.I18N("ui.teamStats.title") L.titleText = colorTitle .. L.title L.notYet = BAR.I18N("ui.teamStats.notYet") + L.foldHint = BAR.I18N("ui.teamStats.foldHint") L.perMinuteSuffix = BAR.I18N("ui.teamStats.perMinuteSuffix") L.perMinuteNote = BAR.I18N("ui.teamStats.perMinuteNote") L.memberOne = BAR.I18N("ui.teamStats.memberOne") -- The milestone kinds the gadget records. L.milestones = BAR.I18N("ui.teamStats.milestones") L.milestone = {} - for _, key in ipairs({ "factory", "tech2", "tech3", "nuke", "antinuke", "lrpc", "commanderLost", "teamDied" }) do + for _, key in ipairs({ "tech2", "tech3", "nuke", "antinuke", "lrpc", "commanderLost", "teamDied" }) do L.milestone[key] = BAR.I18N("ui.teamStats.milestone." .. key) end @@ -2163,7 +2367,6 @@ local function loadLabels() for _, group in ipairs(GROUPS) do L.group[group.key] = BAR.I18N("ui.teamStats.group." .. group.key) end - L.group.graphs = BAR.I18N("ui.teamStats.group.graphs") L.switch, L.switchDesc = {}, {} for _, sw in ipairs(switches) do @@ -2191,7 +2394,10 @@ end -- change what the panel is made of between two frames. local function syncGadgetState(frame) local on - if handover.frame then + if WG.teamStats then + -- The API widget listens for every widget, so it knows first. + on = WG.teamStats.isAvailable() + elseif handover.frame then on = frame - handover.frame <= handover.stale else on = handover.opened == nil or frame - handover.opened <= handover.stale @@ -2209,6 +2415,9 @@ end local function refresh() syncGadgetState(spGetGameFrame()) refreshStats() + if graphs.open then + graphs.refresh() + end end function widget:ViewResize() @@ -2270,6 +2479,11 @@ function widget:DrawScreen() ---@cast panelList -? glCallList(windowList) glCallList(panelList) + if show and graphs.open then + -- The chart keeps its own list and draws its hover overlay straight, so the + -- cursor moving over it never rebakes the panel. + graphs.drawChart(mx, my) + end if WG.guishader and backgroundGuishader == nil then backgroundGuishader = glCreateList(function() @@ -2285,7 +2499,9 @@ function widget:DrawScreen() if WG.tooltip then local title, tip local entry = hover.sb > 0 and entries[hover.sb] or nil - if hover.hcol > 1 then + if graphs.open and hover.sb == 0 and hover.tog == 0 then + title, tip = graphs.tooltip() + elseif hover.hcol > 1 then local column = columns[hover.hcol] ---@cast column -? title = columnTitle(column) @@ -2304,9 +2520,12 @@ function widget:DrawScreen() elseif hover.row > 0 and hover.col == 1 then -- Everything about the player, on the name: the columns the open view hides too. local row = rows[scroll + hover.row] - if row and row.type == "team" then + if row and row.team then title = row.team.label tip = nameCard(row.team) + elseif row then + title = row.caption + tip = L.foldHint end elseif hover.row > 0 and hover.col > 1 then -- The cell's whole number: a cell rounds to three figures. A share says what it @@ -2350,10 +2569,35 @@ local function receiveLive(all, frame) end end +-- The gadget's hand-over reaches the panel through the team stats API widget when it is +-- there, so other widgets share it, and straight from the gadget when it is not. +local function listen(on) + local hub = WG.teamStats + if on then + if hub then + if not hub.isSubscribed("teamstats") then + hub.subscribe("teamstats", { live = receiveLive, history = graphs.receiveHistory }) + end + elseif not handover.direct then + -- The receiver is the API widget's when it is there: only one the panel + -- registered itself is the panel's to take down. + handover.direct = widgetHandler:RegisterGlobal("TeamStatsLive", receiveLive) + end + else + if hub then + hub.unsubscribe("teamstats") + end + if handover.direct then + widgetHandler:DeregisterGlobal("TeamStatsLive") + handover.direct = false + end + end +end + local function closePanel() show = false dragging = false - widgetHandler:DeregisterGlobal("TeamStatsLive") + listen(false) if WG.tooltip then WG.tooltip.RemoveTooltip("teamstats") end @@ -2369,8 +2613,8 @@ local function setShown(state) WG.topbar.hideWindows() end show = true - -- Registered while open only, so the gadget hands nothing over to a closed panel. - widgetHandler:RegisterGlobal("TeamStatsLive", receiveLive) + -- Listening while open only, so the gadget hands nothing over to a closed panel. + listen(true) handover.opened = spGetGameFrame() -- The numbers freeze at game over; a panel first opened after it still needs one read. if not gameover or #allies == 0 then @@ -2380,11 +2624,17 @@ end local function selectEntry(i) local e = entries[i] or {} - if e.disabled or e.key == selectedGroup then + if e.disabled then + return + end + if e.key ~= selectedGroup then + selectedGroup = e.key + -- The whole header: the per-minute switch is only offered where a rate means + -- something, and that changes with the group. + setLayout() + else return end - selectedGroup = e.key - layoutColumns() if playSounds then spPlaySoundFile(buttonclick, 0.6, "ui") end @@ -2407,16 +2657,29 @@ end local function toggleSwitch(i) local sw = switches[i] or {} local key = tostring(sw.key) - filters[key] = not filters[key] - -- Grouping decides whether the share switch is offered, so the header is laid out - -- again. The others only change what the columns say: a rate changes every value and - -- so the order, and the captions say when they are rates. - if key == "groupByTeam" then + if sw.mode then + -- The Graphs page is a mode: on, the sidebar's groups pick the stats it lists. + graphs.open = not graphs.open setLayout() + dropLists() + if graphs.open then + graphs.refresh() + graphs.invalidate() + end else - layoutColumns() + filters[key] = not filters[key] + -- Grouping decides whether the share switch is offered, so the header is laid + -- out again. The others only change what the columns say: a rate changes every + -- value and so the order, and the captions say when they are rates. + if key == "groupByTeam" then + setLayout() + else + layoutColumns() + end + rebuildRows() + -- The chart is made of what the switches say too. + graphs.invalidate() end - rebuildRows() if playSounds then spPlaySoundFile(buttonclick, 0.6, "ui") end @@ -2445,6 +2708,11 @@ function widget:MouseWheel(up, _value) return false end + -- Nothing scrolls on the Graphs page; the wheel is still the panel's. + if graphs.open then + return true + end + -- The chat history's modifiers: Ctrl moves three notches' worth at once, Shift a whole -- page - the rows the band holds from where the list is now, since a band is taller. local _, ctrl, _, shift = spGetModKeyState() @@ -2485,14 +2753,38 @@ local function mouseEvent(x, y, button, release) end if math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then - if not release and button == 1 then + if not release and button == 3 and graphs.open then + -- The Graphs page's legend takes the right button too: it hides a team. + graphs.mousePress(x, y, 3) + elseif not release and button == 1 then local sw = switchAt(x, y) local i = sidebarIndexAt(x, y) - local col = x >= listX1 and x < listRight and headerColumnAt(x, y) + local col = not graphs.open and x >= listX1 and x < listRight and headerColumnAt(x, y) + local fold = nil + if not graphs.open and x >= listX1 and x < listRight then + local r = rowAt(y) + local row = r and rows[scroll + r] + if row and row.type == "band" and not row.team and columnAt(x) == 1 then + fold = row.ally.id + end + end if sw then toggleSwitch(sw) elseif i then selectEntry(i) + elseif graphs.open then + local stat = graphs.stat + graphs.mousePress(x, y, 1) + if graphs.stat ~= stat then + -- Another stat may or may not be a rate: the switches are laid out again. + setLayout() + end + elseif fold then + collapsed[fold] = not collapsed[fold] or nil + rebuildRows() + if playSounds then + spPlaySoundFile(buttonclick, 0.6, "ui") + end elseif col then sortBy(columns[col]) elseif math_isInRect(x, y, barX1, listBottom, area.x2, listTop) then @@ -2528,6 +2820,8 @@ function widget:GameFrame(n) if handover.frame and n - handover.frame < UPDATE_FRAMES then return end + -- A reloaded API widget forgets its subscribers; this puts the panel back on its list. + listen(true) refresh() end @@ -2587,12 +2881,29 @@ function widget:Initialize() WG.teamstats.isvisible = function() return show end + -- The Graphs page, for a key or another widget: opens the panel on it when needed. + WG.teamstats.showGraphs = function(state) + if state == nil then + state = not graphs.open + end + if state and not show then + setShown(true) + end + if state ~= graphs.open then + for i, sw in ipairs(switches) do + if sw.mode and sw.hit then + toggleSwitch(i) + end + end + end + end end function widget:Shutdown() dropLists() deleteGuishader() - widgetHandler:DeregisterGlobal("TeamStatsLive") + graphs.destroy() + listen(false) if WG.tooltip then WG.tooltip.RemoveTooltip("teamstats") end @@ -2609,6 +2920,8 @@ function widget:GetConfigData() groupByTeam = filters.groupByTeam, perMinute = filters.perMinute, bars = filters.bars, + milestones = filters.milestones, + graphStat = graphs.stat, } end @@ -2636,6 +2949,7 @@ function widget:SetConfigData(data) filters[filterKey] = data[filterKey] == true end end + graphs.setConfig(data) end function widget:LanguageChanged() @@ -2646,3 +2960,63 @@ function widget:LanguageChanged() refresh() end end + +-- The Graphs page, handed everything of the panel's it reads. Made here, at the end, so +-- every function above is in reach of it; the callins run later than this. +graphs = VFS.Include("luaui/Include/teamstats_graphs.lua").new({ + COLUMNS = COLUMNS, + GROUPS = GROUPS, + groupByKey = groupByKey, + L = L, + columnTitle = columnTitle, + derive = derive, + look = look, + metrics = metrics, + colors = { title = colorTitle, dim = colorDim, faded = colorFaded, selected = colorSelected, value = colorValue }, + -- The FlowUI draw calls are fetched at ViewResize, so they are read when called; the + -- plain rect is for whole-pixel lines and squares. + draw = { + RectRound = function(...) + return RectRound(...) + end, + Highlight = function(...) + return Highlight(...) + end, + Color = glColor, + Rect = gl.Rect, + -- Looked up when drawn: FlowUI is there by then. + RectRoundOutline = function(...) + return WG.FlowUI.Draw.RectRoundOutline(...) + end, + }, + queueText = queueText, + text = text, + font = function() + return font + end, + filters = filters, + isFFA = isFFA, + selectedGroup = function() + return selectedGroup + end, + allies = function() + return allies + end, + live = function() + return handover.all + end, + gadgetOn = function() + return handover.on + end, + frame = spGetGameFrame, + history = spGetTeamStatsHistory, + hub = function() + return WG.teamStats + end, + i18n = BAR.I18N, + playSound = function() + if playSounds then + spPlaySoundFile(buttonclick, 0.6, "ui") + end + end, +}) diff --git a/objects3d/Units/legeheatraymech.s3o b/objects3d/Units/legeheatraymech.s3o index 6db850c5b2e..d7007122749 100644 Binary files a/objects3d/Units/legeheatraymech.s3o and b/objects3d/Units/legeheatraymech.s3o differ diff --git a/scripts/Units/legeheatraymech.bos b/scripts/Units/legeheatraymech.bos index 1ddf155ace3..e9d90341f2c 100644 --- a/scripts/Units/legeheatraymech.bos +++ b/scripts/Units/legeheatraymech.bos @@ -34,7 +34,7 @@ flakPitchPivot, flakHousing, flakBarrel, lToroid, -Plane, +lArmConnector, lUpperArm, lHeatrayStrut, lHeatrayHeadingPivot, @@ -68,7 +68,7 @@ rToroidFlare, torsoCalcFlare, hipCalcFlare; -static-var InMotion, restoreDelay, aimingPose, wpn1_lasthead, isFiring1, isFiring2, oldHeading1, oldHeading2, oldHeading3, oldHeading4, oldSsteerHeading, torsoAim, isTurning, isSmoking2, heatrayReady, whichAAbarrel, turningBack; +static-var InMotion, restoreDelay, aimingPose, wpn1_lasthead, isFiring1, isFiring2, oldHeading1, oldHeading2, oldHeading3, oldHeading4, oldSteerHeading, torsoAim, isTurning, isSmoking2, heatrayReady, whichAAbarrel, turningBack, aimAdj; lua_UnitScriptDecal(lightIndex, xpos,zpos, heading) { @@ -113,8 +113,8 @@ Walk() { } if(aimingPose){ - turn lHeatrayHeadingPivot to x-axis <-4.248822> speed <3.285009> / animSpeed; - turn rHeatrayHeadingPivot to x-axis <-4.248822> speed <3.285009> / animSpeed; + turn lHeatrayStrut to x-axis <-4.248822> speed <3.285009> / animSpeed; + turn rHeatrayStrut to x-axis <-4.248822> speed <3.285009> / animSpeed; } turn rLegConnector to y-axis <0> speed <806.536634> / animSpeed; turn lLegConnector to y-axis <0> speed <806.536634> / animSpeed; @@ -547,42 +547,42 @@ Walk() { StopWalking() { animSpeed = 10; // tune restore speed here, higher values are slower restore speeds move hip to y-axis [0] speed [200.053223] / animSpeed; - turn Plane to z-axis <-5.000000> speed <-24.999999> / animSpeed; - turn hip to y-axis <0> speed <105.172227> / animSpeed; + turn lArmConnector to z-axis <-5.000000> speed <-24.999999> / animSpeed; + //turn hip to y-axis <0> speed <105.172227> / animSpeed; turn lArmGun to x-axis <0> speed <491.281590> / animSpeed; turn lFootJoint to x-axis <0> speed <1699.337533> / animSpeed; - turn lFootJoint to y-axis <0> speed <118.331861> / animSpeed; + //turn lFootJoint to y-axis <0> speed <118.331861> / animSpeed; turn lFootJoint to z-axis <0> speed <36.892880> / animSpeed; turn lLegConnector to x-axis <0> speed <806.536634> / animSpeed; - turn lLegConnector to y-axis <10> speed <806.536634> / animSpeed; + //turn lLegConnector to y-axis <10> speed <806.536634> / animSpeed; turn lLowerArm to z-axis <-15.000000> speed <-75.000002> / animSpeed; turn lLowerLeg to x-axis <0> speed <2211.840479> / animSpeed; - turn lLowerLeg to y-axis <0> speed <51.188909> / animSpeed; + //turn lLowerLeg to y-axis <0> speed <51.188909> / animSpeed; turn lLowerLeg to z-axis <0> speed <68.609114> / animSpeed; turn lTibia to x-axis <0> speed <1948.855058> / animSpeed; turn lUpperArm to x-axis <0> speed <509.326609> / animSpeed; - turn lUpperArm to y-axis <0> speed <10.400697> / animSpeed; + //turn lUpperArm to y-axis <0> speed <10.400697> / animSpeed; turn lUpperArm to z-axis <0> speed <25.876967> / animSpeed; turn lUpperLeg to x-axis <0> speed <74.586842> / animSpeed; - turn lUpperLeg to y-axis <0> speed <17.910444> / animSpeed; + //turn lUpperLeg to y-axis <0> speed <17.910444> / animSpeed; turn lUpperLeg to z-axis <0> speed <42.231262> / animSpeed; turn rArmConnector to z-axis <5.000000> speed <-24.999999> / animSpeed; turn rArmGun to x-axis <0> speed <492.160209> / animSpeed; turn rLegConnector to x-axis <0> speed <806.564873> / animSpeed; - turn rLegConnector to y-axis <-10> speed <806.536634> / animSpeed; + //turn rLegConnector to y-axis <-10> speed <806.536634> / animSpeed; turn rLegFrontFoot to x-axis <0> speed <1700.834326> / animSpeed; - turn rLegFrontFoot to y-axis <0> speed <118.160992> / animSpeed; + //turn rLegFrontFoot to y-axis <0> speed <118.160992> / animSpeed; turn rLegFrontFoot to z-axis <0> speed <36.970088> / animSpeed; turn rLowerArm to z-axis <16> speed <-74.999994> / animSpeed; turn rLowerLeg to x-axis <0> speed <2215.708073> / animSpeed; - turn rLowerLeg to y-axis <0> speed <51.324195> / animSpeed; + //turn rLowerLeg to y-axis <0> speed <51.324195> / animSpeed; turn rLowerLeg to z-axis <0> speed <68.752781> / animSpeed; turn rTibia to x-axis <0> speed <1952.924400> / animSpeed; turn rUpperArm to x-axis <0> speed <510.226315> / animSpeed; - turn rUpperArm to y-axis <0> speed <10.427450> / animSpeed; + //turn rUpperArm to y-axis <0> speed <10.427450> / animSpeed; turn rUpperArm to z-axis <0> speed <25.858963> / animSpeed; turn rUpperLeg to x-axis <0> speed <75.320274> / animSpeed; - turn rUpperLeg to y-axis <0> speed <18.079775> / animSpeed; + //turn rUpperLeg to y-axis <0> speed <18.079775> / animSpeed; turn rUpperLeg to z-axis <0> speed <42.643251> / animSpeed; turn torso to x-axis <0> speed <217.916161> / animSpeed; turn lHeatrayStrut to x-axis <0> speed <130.749697> / animSpeed; @@ -593,7 +593,7 @@ StopWalking() { CreatePose() { move hip to y-axis [0] now; - turn Plane to z-axis <-5.000000> now; + turn lArmConnector to z-axis <-5.000000>now; turn hip to y-axis <0> now; turn lArmGun to x-axis <0> now; turn lFootJoint to x-axis <0> now; @@ -726,7 +726,7 @@ Close() { aimingPose = 0; move hip to y-axis [0] speed [117.053223] / animSpeed; - turn Plane to z-axis <-5.000000> speed <-24.999999> / animSpeed; + turn lArmConnector to z-axis <-5.000000> speed <-24.999999> / animSpeed; turn hip to y-axis <0> speed <105.172227> / animSpeed; turn lArmGun to x-axis <0> speed <491.281590> / animSpeed; turn lFootJoint to x-axis <0> speed <1699.337533> / animSpeed; @@ -814,7 +814,7 @@ Create() oldHeading3 = 0; oldHeading4 = 0; restoreDelay = 1000; - oldSsteerHeading = get HEADING; + oldSteerHeading = get HEADING; isTurning = 0; isSmoking2 = 0; SLEEP_UNTIL_UNITFINISHED; @@ -863,7 +863,7 @@ Steering(heading, currentSpeed) while (TRUE) { heading = get HEADING; - if ((get ABS(oldSsteerHeading - heading)) > <1>) + if ((get ABS(oldSteerHeading - heading)) > <1>) { isTurning = 1; } @@ -871,8 +871,10 @@ Steering(heading, currentSpeed) { isTurning = 0; } - oldSsteerHeading = heading; + heading = get HEADING; + aimAdj = (heading - oldsteerheading); sleep 20; + oldSteerHeading = heading; } } @@ -893,7 +895,7 @@ ExecuteRestoreAfterDelay(addTime) oldHeading3 = 0; oldHeading4 = 0; torsoAim = 0; - oldSsteerHeading = get HEADING;; + oldSteerHeading = get HEADING;; isTurning = 0; start-script Close(); } @@ -1048,14 +1050,14 @@ AimWeapon2(heading, pitch) if (isTurning == 1) { //if (((heading - torsoAim)<<50> AND (heading - torsoAim)><-50>) OR (get ABS(heading - torsoAim)>58000)){ - turn lHeatrayHeadingPivot to y-axis (heading-(torsoAim)) speed <90>; + turn lHeatrayHeadingPivot to y-axis (heading-torsoAim-1*aimAdj) speed <90>; //} } if (isTurning == 0) { //if (((heading - torsoAim)<<50> AND (heading - torsoAim)><-50>) OR (get ABS(heading - torsoAim)>58000)){ - turn lHeatrayHeadingPivot to y-axis (heading-(torsoAim)) speed <35>; + turn lHeatrayHeadingPivot to y-axis (heading-torsoAim-1*aimAdj) speed <35>; //} } turn lHeatrayPitchPivot to x-axis (-1*pitch) speed <35>; @@ -1096,14 +1098,14 @@ AimWeapon3(heading, pitch) if (isTurning == 1) { //if (((heading - torsoAim)<<50> AND (heading - torsoAim)><-50>) OR (get ABS(heading - torsoAim)>58000)){ - turn rHeatrayHeadingPivot to y-axis (heading-(torsoAim)) speed <90>; + turn rHeatrayHeadingPivot to y-axis (heading-torsoAim-1*aimAdj) speed <90>; //} } if (isTurning == 0) { //if (((heading - torsoAim)<<50> AND (heading - torsoAim)><-50>) OR (get ABS(heading - torsoAim)>58000)){ - turn rHeatrayHeadingPivot to y-axis (heading-(torsoAim)) speed <35>; + turn rHeatrayHeadingPivot to y-axis (heading-torsoAim-1*aimAdj) speed <35>; //} } turn rHeatrayPitchPivot to x-axis (-1*pitch) speed <35>; diff --git a/scripts/Units/legeheatraymech.cob b/scripts/Units/legeheatraymech.cob index 6ac89032a3b..fddb5d9fd7c 100644 Binary files a/scripts/Units/legeheatraymech.cob and b/scripts/Units/legeheatraymech.cob differ diff --git a/units/Legion/Ships/legnavydestro.lua b/units/Legion/Ships/legnavydestro.lua index 6e417b08937..f9e7895fc83 100644 --- a/units/Legion/Ships/legnavydestro.lua +++ b/units/Legion/Ships/legnavydestro.lua @@ -22,7 +22,7 @@ return { minwaterdepth = 12, movementclass = "BOAT4", movestate = 0, - nochasecategory = "VTOL", + nochasecategory = "VTOL UNDERWATER", objectname = "Units/legnavydestro.s3o", script = "Units/legnavydestro.cob", seismicsignature = 0, diff --git a/units/Legion/Ships/legnavyfrigate.lua b/units/Legion/Ships/legnavyfrigate.lua index a09c251b499..eff4b06f7fa 100644 --- a/units/Legion/Ships/legnavyfrigate.lua +++ b/units/Legion/Ships/legnavyfrigate.lua @@ -22,7 +22,7 @@ return { minwaterdepth = 6, movementclass = "BOAT4", movestate = 0, - nochasecategory = "VTOL UNDERWATER", + nochasecategory = "VTOL", objectname = "Units/legnavyfrigate.s3o", script = "Units/legnavyfrigate.cob", seismicsignature = 0, diff --git a/units/Legion/T3/legeheatraymech.lua b/units/Legion/T3/legeheatraymech.lua index d939e7d65aa..cc3b90fe9c3 100644 --- a/units/Legion/T3/legeheatraymech.lua +++ b/units/Legion/T3/legeheatraymech.lua @@ -151,7 +151,7 @@ return { explosiongenerator = "custom:heatray-large", firestarter = 90, -- tolerance = 750, - firetolerance = 750, + firetolerance = 50, largebeamlaser = true, impulsefactor = 0, intensity = 5,