From 38ff3f61bd87c1ab31f895e1ff6d7f66c49a1f11 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Thu, 26 Mar 2026 14:44:51 -0400 Subject: [PATCH] feat: enhance billboard rendering with pie chart and storage ring display --- manager/display.lua | 425 +++++++++++++++++++++++++++++++------------- 1 file changed, 302 insertions(+), 123 deletions(-) diff --git a/manager/display.lua b/manager/display.lua index 4dbe54c..f1b9707 100644 --- a/manager/display.lua +++ b/manager/display.lua @@ -1450,8 +1450,8 @@ end ------------------------------------------------- -- Billboard rendering (raw monitor API, no Opus UI) --- Read-only goals display: storage bar, top items --- chart, stock alerts, activity indicators. +-- Visual goals display with pie chart, storage +-- gauge, stock alerts, and activity indicators. ------------------------------------------------- local BB = {} -- billboard color theme @@ -1470,10 +1470,24 @@ BB.alertLow = colors.red BB.alertWarn = colors.orange BB.activityOn = colors.lime BB.activityOff = colors.gray -BB.graphBar = colors.cyan -BB.graphBarAlt = colors.lightBlue BB.sectionHead = colors.yellow +-- Pie chart slice colors (12 distinct CC colors) +local PIE_COLORS = { + colors.red, + colors.orange, + colors.yellow, + colors.lime, + colors.green, + colors.cyan, + colors.lightBlue, + colors.blue, + colors.purple, + colors.magenta, + colors.pink, + colors.brown, +} + local function bbFormatNumber(n) if n >= 1000000 then return string.format("%.1fM", n / 1000000) @@ -1509,24 +1523,17 @@ local function bbClearLine(y, bg) D.billboardMon.write(string.rep(" ", bbW)) end -local function bbWriteCentered(y, text, fg, bg) - bbClearLine(y, bg or BB.bg) - bbSetColors(fg or BB.value, bg or BB.bg) - D.billboardMon.setCursorPos(math.floor((bbW - #text) / 2) + 1, y) - D.billboardMon.write(text) -end - local function bbWriteAt(x, y, text, fg, bg) bbSetColors(fg or BB.value, bg or BB.bg) D.billboardMon.setCursorPos(x, y) D.billboardMon.write(text) end -local function bbHLine(y, char, fg, bg) - bbClearLine(y, bg or BB.bg) - bbSetColors(fg or BB.border, bg or BB.bg) +local function bbHLine(y, fg) + bbClearLine(y) + bbSetColors(fg or BB.border, BB.bg) D.billboardMon.setCursorPos(1, y) - D.billboardMon.write(string.rep(char or "-", bbW)) + D.billboardMon.write(string.rep("\x8c", bbW)) end local function bbDrawBar(x, y, width, filled, fgColor, bgColor) @@ -1541,52 +1548,171 @@ local function bbDrawBar(x, y, width, filled, fgColor, bgColor) D.billboardMon.setBackgroundColor(BB.bg) end +--- Draw a pie chart using colored character cells. +--- slices = { { fraction=0.0-1.0, color=colors.X }, ... } +--- Draws into the rectangle (x1,y1) to (x1+size-1, y1+rows-1) +--- size = width in chars, rows = height in chars +local function bbDrawPie(x1, y1, size, rows, slices) + local cx = size / 2 -- center in char coords + local cy = rows / 2 + local radius = math.min(cx, cy) - 0.5 + -- Character cells are ~1.5x taller than wide; squish Y + local aspect = 1.5 + + -- Build cumulative angle boundaries + local angles = {} + local cumulative = 0 + for i, slice in ipairs(slices) do + angles[i] = { start = cumulative, stop = cumulative + slice.fraction, color = slice.color } + cumulative = cumulative + slice.fraction + end + + for row = 0, rows - 1 do + D.billboardMon.setCursorPos(x1, y1 + row) + for col = 0, size - 1 do + local dx = (col + 0.5 - cx) + local dy = (row + 0.5 - cy) * aspect + local dist = math.sqrt(dx * dx + dy * dy) + + if dist <= radius then + -- Compute angle 0-1 (0 = top, clockwise) + local angle = math.atan2(dx, -dy) -- top = 0, clockwise + if angle < 0 then angle = angle + 2 * math.pi end + local frac = angle / (2 * math.pi) + + -- Find which slice + local cellColor = BB.bg + for _, s in ipairs(angles) do + if frac >= s.start and frac < s.stop then + cellColor = s.color + break + end + end + -- Catch rounding at the very end + if cellColor == BB.bg and #angles > 0 then + cellColor = angles[#angles].color + end + + D.billboardMon.setBackgroundColor(cellColor) + D.billboardMon.write(" ") + else + D.billboardMon.setBackgroundColor(BB.bg) + D.billboardMon.write(" ") + end + end + end + D.billboardMon.setBackgroundColor(BB.bg) +end + +--- Draw a storage ring/donut gauge. +--- Draws a ring showing used vs free with percentage in center. +local function bbDrawStorageRing(x1, y1, size, rows) + local cx = size / 2 + local cy = rows / 2 + local outerR = math.min(cx, cy) - 0.5 + local innerR = outerR * 0.55 + local aspect = 1.5 + local ratio = cache.usedRatio or 0 + + local usedColor = BB.barFull + if ratio > 0.9 then usedColor = BB.barCrit + elseif ratio > 0.75 then usedColor = BB.barWarn end + + for row = 0, rows - 1 do + D.billboardMon.setCursorPos(x1, y1 + row) + for col = 0, size - 1 do + local dx = (col + 0.5 - cx) + local dy = (row + 0.5 - cy) * aspect + local dist = math.sqrt(dx * dx + dy * dy) + + if dist <= outerR and dist >= innerR then + -- In the ring — determine angle (top = 0, clockwise) + local angle = math.atan2(dx, -dy) + if angle < 0 then angle = angle + 2 * math.pi end + local frac = angle / (2 * math.pi) + + if frac < ratio then + D.billboardMon.setBackgroundColor(usedColor) + else + D.billboardMon.setBackgroundColor(BB.barEmpty) + end + D.billboardMon.write(" ") + else + D.billboardMon.setBackgroundColor(BB.bg) + D.billboardMon.write(" ") + end + end + end + + -- Write percentage in center of ring + local pct = tostring(math.floor(ratio * 100 + 0.5)) .. "%" + local textY = y1 + math.floor(cy) + local textX = x1 + math.floor(cx - #pct / 2) + bbWriteAt(textX, textY, pct, usedColor) +end + -- Billboard section: header local function bbDrawHeader(y) bbClearLine(y, BB.headerBg) bbSetColors(BB.headerFg, BB.headerBg) - local title = " INVENTORY GOALS BILLBOARD " + local title = " INVENTORY BILLBOARD " D.billboardMon.setCursorPos(math.floor((bbW - #title) / 2) + 1, y) D.billboardMon.write(title) return y + 1 end --- Billboard section: storage capacity -local function bbDrawStorage(y) +-- Billboard section: storage ring + stats (side by side) +local function bbDrawStorageSection(y) bbHLine(y) y = y + 1 - bbWriteAt(2, y, "> STORAGE", BB.sectionHead) - y = y + 1 + + -- Ring takes up a square area + local ringSize = math.min(math.floor(bbW * 0.35), bbH - y - 6) + if ringSize < 5 then ringSize = 5 end + local ringRows = math.floor(ringSize / 1.5) -- aspect correction + if ringRows < 3 then ringRows = 3 end + + -- Draw ring on the left + bbDrawStorageRing(2, y, ringSize, ringRows) + + -- Stats text to the right of the ring + local statsX = ringSize + 4 + local statsY = y + 1 + + bbWriteAt(statsX, statsY, "STORAGE", BB.sectionHead) + statsY = statsY + 1 local ratio = cache.usedRatio or 0 - local pct = math.floor(ratio * 100 + 0.5) - local barColor = BB.barFull - if ratio > 0.9 then barColor = BB.barCrit - elseif ratio > 0.75 then barColor = BB.barWarn end + local usedColor = BB.barFull + if ratio > 0.9 then usedColor = BB.barCrit + elseif ratio > 0.75 then usedColor = BB.barWarn end - local barW = bbW - 10 - if barW < 10 then barW = 10 end - bbWriteAt(2, y, bbPadLeft(pct .. "%", 4), barColor) - bbDrawBar(7, y, barW, ratio, barColor, BB.barEmpty) - y = y + 1 + bbWriteAt(statsX, statsY, string.format("%s / %s slots", + bbFormatNumber(cache.usedSlots), bbFormatNumber(cache.totalSlots)), BB.value) + statsY = statsY + 1 - local stats = string.format( - "Slots: %s/%s | Items: %s | Chests: %d", - bbFormatNumber(cache.usedSlots), - bbFormatNumber(cache.totalSlots), - bbFormatNumber(cache.grandTotal), - cache.chestCount - ) - bbWriteAt(2, y, stats, BB.label) - y = y + 1 - return y + bbWriteAt(statsX, statsY, string.format("%s total items", + bbFormatNumber(cache.grandTotal)), BB.label) + statsY = statsY + 1 + + bbWriteAt(statsX, statsY, string.format("%d chests", cache.chestCount), BB.label) + statsY = statsY + 1 + + -- Mini capacity bar + local barW = bbW - statsX - 1 + if barW > 3 then + bbWriteAt(statsX, statsY, "", BB.label) + bbDrawBar(statsX, statsY, barW, ratio, usedColor, BB.barEmpty) + end + + return y + ringRows + 1 end --- Billboard section: top items bar chart -local function bbDrawItems(y, maxRows) +-- Billboard section: pie chart + legend +local function bbDrawPieSection(y, maxH) bbHLine(y) y = y + 1 - bbWriteAt(2, y, "> TOP ITEMS", BB.sectionHead) + bbWriteAt(2, y, "ITEM DISTRIBUTION", BB.sectionHead) y = y + 1 state.ensureItemList() @@ -1596,49 +1722,96 @@ local function bbDrawItems(y, maxRows) return y + 1 end + -- Sort and pick top N for pie slices local sorted = {} for i, item in ipairs(items) do sorted[i] = item end table.sort(sorted, function(a, b) return a.total > b.total end) - local count = math.min(#sorted, maxRows, cfg.BILLBOARD_TOP_ITEMS or 20) - if count < 1 then count = 1 end - - local maxVal = sorted[1].total - if maxVal < 1 then maxVal = 1 end - - local numW = 8 - local nameW = math.floor(bbW * 0.35) - if nameW < 12 then nameW = 12 end - if nameW > 25 then nameW = 25 end - local barW = bbW - nameW - numW - 5 - if barW < 5 then barW = 5 end - - for i = 1, count do - local item = sorted[i] - local name = shortName(item.name) - local num = bbFormatNumber(item.total) - local frac = item.total / maxVal - - bbClearLine(y) - bbWriteAt(1, y, bbPadLeft(tostring(i), 2), BB.border) - bbWriteAt(4, y, bbPadRight(name, nameW), BB.value) - - local barColor = (i % 2 == 0) and BB.graphBarAlt or BB.graphBar - local barStart = 4 + nameW + 1 - bbDrawBar(barStart, y, barW, frac, barColor, BB.barEmpty) - bbWriteAt(barStart + barW + 1, y, bbPadLeft(num, numW), BB.value) - - y = y + 1 + local maxSlices = math.min(#PIE_COLORS, cfg.BILLBOARD_TOP_ITEMS or 12, #sorted) + local topTotal = 0 + for i = 1, maxSlices do + topTotal = topTotal + sorted[i].total end - return y + -- "Other" bucket for remaining items + local otherTotal = (cache.grandTotal or 0) - topTotal + local total = cache.grandTotal or 1 + if total < 1 then total = 1 end + + -- Build slices + local slices = {} + local legendItems = {} + for i = 1, maxSlices do + local frac = sorted[i].total / total + if frac < 0.005 then break end -- skip tiny slices + table.insert(slices, { fraction = frac, color = PIE_COLORS[i] }) + table.insert(legendItems, { + name = shortName(sorted[i].name), + count = sorted[i].total, + pct = math.floor(frac * 100 + 0.5), + color = PIE_COLORS[i], + }) + end + if otherTotal > 0 then + table.insert(slices, { fraction = otherTotal / total, color = BB.border }) + table.insert(legendItems, { + name = "Other", + count = otherTotal, + pct = math.floor(otherTotal / total * 100 + 0.5), + color = BB.border, + }) + end + + -- Layout: pie on left, legend on right + local pieSize = math.min(math.floor(bbW * 0.4), maxH - 1) + if pieSize < 5 then pieSize = 5 end + local pieRows = math.floor(pieSize / 1.5) + if pieRows < 3 then pieRows = 3 end + if pieRows > maxH - 1 then pieRows = maxH - 1 end + + -- Draw pie + if #slices > 0 then + bbDrawPie(2, y, pieSize, pieRows, slices) + end + + -- Draw legend to the right + local legX = pieSize + 4 + local legY = y + local legW = bbW - legX - 1 + + for i, item in ipairs(legendItems) do + if legY >= y + pieRows then break end + if legW < 10 then break end + + -- Color swatch + D.billboardMon.setCursorPos(legX, legY) + D.billboardMon.setBackgroundColor(item.color) + D.billboardMon.write(" ") + D.billboardMon.setBackgroundColor(BB.bg) + D.billboardMon.write(" ") + + -- Name + count + local label = item.name + local countStr = bbFormatNumber(item.count) + local pctStr = item.pct .. "%" + local infoW = legW - 4 -- 2 swatch + 1 space + padding + local detail = string.format("%s %s", pctStr, countStr) + local nameW = infoW - #detail - 1 + if nameW < 4 then nameW = 4 end + if #label > nameW then label = label:sub(1, nameW - 1) .. "." end + + bbWriteAt(legX + 3, legY, bbPadRight(label, nameW), BB.value) + bbWriteAt(legX + 3 + nameW + 1, legY, detail, BB.label) + + legY = legY + 1 + end + + return y + pieRows end --- Billboard section: stock alerts +-- Billboard section: stock alerts (compact) local function bbDrawAlerts(y, maxRows) bbHLine(y) y = y + 1 - bbWriteAt(2, y, "> STOCK ALERTS", BB.sectionHead) - y = y + 1 local alerts = state.activeAlerts if not alerts or #alerts == 0 then @@ -1646,13 +1819,18 @@ local function bbDrawAlerts(y, maxRows) return y + 1 end + bbWriteAt(2, y, "ALERTS", BB.sectionHead) + local countStr = string.format("(%d)", #alerts) + bbWriteAt(9, y, countStr, BB.alertWarn) + y = y + 1 + local colW = math.floor(bbW / 2) - local twoCol = (bbW >= 40) + local twoCol = (bbW >= 30) local row = 0 for i, alert in ipairs(alerts) do if row >= maxRows then - bbWriteAt(2, y, string.format(" ... +%d more", #alerts - i + 1), BB.alertWarn) + bbWriteAt(2, y, string.format(" +%d more", #alerts - i + 1), BB.alertWarn) y = y + 1 break end @@ -1662,14 +1840,8 @@ local function bbDrawAlerts(y, maxRows) local minVal = alert.min or 0 local ratio = minVal > 0 and (current / minVal) or 1 - local icon, color - if ratio < 0.5 then - icon, color = "!", BB.alertLow - else - icon, color = "!", BB.alertWarn - end - - local text = string.format("%s %s: %s/%s", icon, label, bbFormatNumber(current), bbFormatNumber(minVal)) + local color = ratio < 0.5 and BB.alertLow or BB.alertWarn + local text = string.format("! %s %s/%s", label, bbFormatNumber(current), bbFormatNumber(minVal)) if twoCol then local col = ((i - 1) % 2 == 0) and 2 or (colW + 1) @@ -1689,43 +1861,45 @@ local function bbDrawAlerts(y, maxRows) return y end --- Billboard section: activity footer -local function bbDrawActivity(y) - bbHLine(y) - y = y + 1 - bbClearLine(y) - bbWriteAt(2, y, "ACTIVITY:", BB.sectionHead) +-- Billboard section: activity bar (single line) +local function bbDrawActivityBar(y) + bbClearLine(y, BB.headerBg) + bbSetColors(BB.headerFg, BB.headerBg) local labels = { { key = "sorting", label = "SORT" }, { key = "scanning", label = "SCAN" }, - { key = "smelting", label = "SMELT" }, - { key = "dispensing", label = "DISPENSE" }, - { key = "defragging", label = "DEFRAG" }, - { key = "composting", label = "COMPOST" }, - { key = "crafting", label = "CRAFT" }, - { key = "autocrafting", label = "AUTOCRAFT" }, - { key = "discarding", label = "DISCARD" }, + { key = "smelting", label = "SMLT" }, + { key = "dispensing", label = "DISP" }, + { key = "defragging", label = "DEFR" }, + { key = "composting", label = "COMP" }, + { key = "crafting", label = "CRFT" }, + { key = "autocrafting", label = "AUTO" }, + { key = "discarding", label = "DISC" }, } - local x = 12 - local anyActive = false + local parts = {} for _, entry in ipairs(labels) do if activity[entry.key] then - anyActive = true - if x + #entry.label + 2 > bbW then - y = y + 1 - bbClearLine(y) - x = 3 - end - bbWriteAt(x, y, entry.label, BB.activityOn) - x = x + #entry.label + 2 + table.insert(parts, entry.label) end end - if not anyActive then - bbWriteAt(12, y, "IDLE", BB.activityOff) + local text + if #parts > 0 then + text = " " .. table.concat(parts, " | ") .. " " + else + text = " IDLE " end + + D.billboardMon.setCursorPos(math.floor((bbW - #text) / 2) + 1, y) + if #parts > 0 then + bbSetColors(colors.white, colors.green) + else + bbSetColors(BB.label, BB.headerBg) + end + D.billboardMon.write(text) + return y + 1 end @@ -1738,29 +1912,34 @@ function D.drawBillboard() D.billboardMon.clear() local y = 1 + + -- Header bar y = bbDrawHeader(y) - y = bbDrawStorage(y) - -- Allocate vertical space: alerts ~3-8 lines, footer ~2 lines - local alertLines = state.activeAlerts and #state.activeAlerts or 0 - local alertSectionH = math.max(3, math.min(6, math.ceil(alertLines / 2) + 2)) - local footerH = 2 - local itemRows = bbH - y - alertSectionH - footerH - if itemRows < 3 then itemRows = 3 end + -- Storage ring + stats + y = bbDrawStorageSection(y) - y = bbDrawItems(y, itemRows) + -- Pie chart + legend (gets remaining space minus alerts + footer) + local alertCount = state.activeAlerts and #state.activeAlerts or 0 + local alertH = math.max(2, math.min(5, math.ceil(alertCount / 2) + 2)) + local footerH = 1 -- activity bar + local pieH = bbH - y - alertH - footerH + if pieH < 5 then pieH = 5 end + y = bbDrawPieSection(y, pieH) - local alertMaxRows = bbH - y - footerH - if alertMaxRows < 2 then alertMaxRows = 2 end + -- Alerts + local alertMaxRows = bbH - y - footerH - 1 + if alertMaxRows < 1 then alertMaxRows = 1 end y = bbDrawAlerts(y, alertMaxRows) -- Fill gap before footer - local footerY = bbH - 1 - while y < footerY do + while y < bbH do bbClearLine(y) y = y + 1 end - bbDrawActivity(footerY) + + -- Activity bar at very bottom + bbDrawActivityBar(bbH) end return D