feat: enhance billboard rendering with pie chart and storage ring display

This commit is contained in:
MayaTheShy
2026-03-26 14:44:51 -04:00
parent 664741504f
commit 38ff3f61bd

View File

@@ -1450,8 +1450,8 @@ end
------------------------------------------------- -------------------------------------------------
-- Billboard rendering (raw monitor API, no Opus UI) -- Billboard rendering (raw monitor API, no Opus UI)
-- Read-only goals display: storage bar, top items -- Visual goals display with pie chart, storage
-- chart, stock alerts, activity indicators. -- gauge, stock alerts, and activity indicators.
------------------------------------------------- -------------------------------------------------
local BB = {} -- billboard color theme local BB = {} -- billboard color theme
@@ -1470,10 +1470,24 @@ BB.alertLow = colors.red
BB.alertWarn = colors.orange BB.alertWarn = colors.orange
BB.activityOn = colors.lime BB.activityOn = colors.lime
BB.activityOff = colors.gray BB.activityOff = colors.gray
BB.graphBar = colors.cyan
BB.graphBarAlt = colors.lightBlue
BB.sectionHead = colors.yellow 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) local function bbFormatNumber(n)
if n >= 1000000 then if n >= 1000000 then
return string.format("%.1fM", n / 1000000) return string.format("%.1fM", n / 1000000)
@@ -1509,24 +1523,17 @@ local function bbClearLine(y, bg)
D.billboardMon.write(string.rep(" ", bbW)) D.billboardMon.write(string.rep(" ", bbW))
end 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) local function bbWriteAt(x, y, text, fg, bg)
bbSetColors(fg or BB.value, bg or BB.bg) bbSetColors(fg or BB.value, bg or BB.bg)
D.billboardMon.setCursorPos(x, y) D.billboardMon.setCursorPos(x, y)
D.billboardMon.write(text) D.billboardMon.write(text)
end end
local function bbHLine(y, char, fg, bg) local function bbHLine(y, fg)
bbClearLine(y, bg or BB.bg) bbClearLine(y)
bbSetColors(fg or BB.border, bg or BB.bg) bbSetColors(fg or BB.border, BB.bg)
D.billboardMon.setCursorPos(1, y) D.billboardMon.setCursorPos(1, y)
D.billboardMon.write(string.rep(char or "-", bbW)) D.billboardMon.write(string.rep("\x8c", bbW))
end end
local function bbDrawBar(x, y, width, filled, fgColor, bgColor) 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) D.billboardMon.setBackgroundColor(BB.bg)
end 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 -- Billboard section: header
local function bbDrawHeader(y) local function bbDrawHeader(y)
bbClearLine(y, BB.headerBg) bbClearLine(y, BB.headerBg)
bbSetColors(BB.headerFg, 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.setCursorPos(math.floor((bbW - #title) / 2) + 1, y)
D.billboardMon.write(title) D.billboardMon.write(title)
return y + 1 return y + 1
end end
-- Billboard section: storage capacity -- Billboard section: storage ring + stats (side by side)
local function bbDrawStorage(y) local function bbDrawStorageSection(y)
bbHLine(y) bbHLine(y)
y = y + 1 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 ratio = cache.usedRatio or 0
local pct = math.floor(ratio * 100 + 0.5) local usedColor = BB.barFull
local barColor = BB.barFull if ratio > 0.9 then usedColor = BB.barCrit
if ratio > 0.9 then barColor = BB.barCrit elseif ratio > 0.75 then usedColor = BB.barWarn end
elseif ratio > 0.75 then barColor = BB.barWarn end
local barW = bbW - 10 bbWriteAt(statsX, statsY, string.format("%s / %s slots",
if barW < 10 then barW = 10 end bbFormatNumber(cache.usedSlots), bbFormatNumber(cache.totalSlots)), BB.value)
bbWriteAt(2, y, bbPadLeft(pct .. "%", 4), barColor) statsY = statsY + 1
bbDrawBar(7, y, barW, ratio, barColor, BB.barEmpty)
y = y + 1
local stats = string.format( bbWriteAt(statsX, statsY, string.format("%s total items",
"Slots: %s/%s | Items: %s | Chests: %d", bbFormatNumber(cache.grandTotal)), BB.label)
bbFormatNumber(cache.usedSlots), statsY = statsY + 1
bbFormatNumber(cache.totalSlots),
bbFormatNumber(cache.grandTotal), bbWriteAt(statsX, statsY, string.format("%d chests", cache.chestCount), BB.label)
cache.chestCount statsY = statsY + 1
)
bbWriteAt(2, y, stats, BB.label) -- Mini capacity bar
y = y + 1 local barW = bbW - statsX - 1
return y if barW > 3 then
bbWriteAt(statsX, statsY, "", BB.label)
bbDrawBar(statsX, statsY, barW, ratio, usedColor, BB.barEmpty)
end
return y + ringRows + 1
end end
-- Billboard section: top items bar chart -- Billboard section: pie chart + legend
local function bbDrawItems(y, maxRows) local function bbDrawPieSection(y, maxH)
bbHLine(y) bbHLine(y)
y = y + 1 y = y + 1
bbWriteAt(2, y, "> TOP ITEMS", BB.sectionHead) bbWriteAt(2, y, "ITEM DISTRIBUTION", BB.sectionHead)
y = y + 1 y = y + 1
state.ensureItemList() state.ensureItemList()
@@ -1596,49 +1722,96 @@ local function bbDrawItems(y, maxRows)
return y + 1 return y + 1
end end
-- Sort and pick top N for pie slices
local sorted = {} local sorted = {}
for i, item in ipairs(items) do sorted[i] = item end for i, item in ipairs(items) do sorted[i] = item end
table.sort(sorted, function(a, b) return a.total > b.total 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) local maxSlices = math.min(#PIE_COLORS, cfg.BILLBOARD_TOP_ITEMS or 12, #sorted)
if count < 1 then count = 1 end local topTotal = 0
for i = 1, maxSlices do
local maxVal = sorted[1].total topTotal = topTotal + sorted[i].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
end 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 end
-- Billboard section: stock alerts -- Billboard section: stock alerts (compact)
local function bbDrawAlerts(y, maxRows) local function bbDrawAlerts(y, maxRows)
bbHLine(y) bbHLine(y)
y = y + 1 y = y + 1
bbWriteAt(2, y, "> STOCK ALERTS", BB.sectionHead)
y = y + 1
local alerts = state.activeAlerts local alerts = state.activeAlerts
if not alerts or #alerts == 0 then if not alerts or #alerts == 0 then
@@ -1646,13 +1819,18 @@ local function bbDrawAlerts(y, maxRows)
return y + 1 return y + 1
end 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 colW = math.floor(bbW / 2)
local twoCol = (bbW >= 40) local twoCol = (bbW >= 30)
local row = 0 local row = 0
for i, alert in ipairs(alerts) do for i, alert in ipairs(alerts) do
if row >= maxRows then 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 y = y + 1
break break
end end
@@ -1662,14 +1840,8 @@ local function bbDrawAlerts(y, maxRows)
local minVal = alert.min or 0 local minVal = alert.min or 0
local ratio = minVal > 0 and (current / minVal) or 1 local ratio = minVal > 0 and (current / minVal) or 1
local icon, color local color = ratio < 0.5 and BB.alertLow or BB.alertWarn
if ratio < 0.5 then local text = string.format("! %s %s/%s", label, bbFormatNumber(current), bbFormatNumber(minVal))
icon, color = "!", BB.alertLow
else
icon, color = "!", BB.alertWarn
end
local text = string.format("%s %s: %s/%s", icon, label, bbFormatNumber(current), bbFormatNumber(minVal))
if twoCol then if twoCol then
local col = ((i - 1) % 2 == 0) and 2 or (colW + 1) local col = ((i - 1) % 2 == 0) and 2 or (colW + 1)
@@ -1689,43 +1861,45 @@ local function bbDrawAlerts(y, maxRows)
return y return y
end end
-- Billboard section: activity footer -- Billboard section: activity bar (single line)
local function bbDrawActivity(y) local function bbDrawActivityBar(y)
bbHLine(y) bbClearLine(y, BB.headerBg)
y = y + 1 bbSetColors(BB.headerFg, BB.headerBg)
bbClearLine(y)
bbWriteAt(2, y, "ACTIVITY:", BB.sectionHead)
local labels = { local labels = {
{ key = "sorting", label = "SORT" }, { key = "sorting", label = "SORT" },
{ key = "scanning", label = "SCAN" }, { key = "scanning", label = "SCAN" },
{ key = "smelting", label = "SMELT" }, { key = "smelting", label = "SMLT" },
{ key = "dispensing", label = "DISPENSE" }, { key = "dispensing", label = "DISP" },
{ key = "defragging", label = "DEFRAG" }, { key = "defragging", label = "DEFR" },
{ key = "composting", label = "COMPOST" }, { key = "composting", label = "COMP" },
{ key = "crafting", label = "CRAFT" }, { key = "crafting", label = "CRFT" },
{ key = "autocrafting", label = "AUTOCRAFT" }, { key = "autocrafting", label = "AUTO" },
{ key = "discarding", label = "DISCARD" }, { key = "discarding", label = "DISC" },
} }
local x = 12 local parts = {}
local anyActive = false
for _, entry in ipairs(labels) do for _, entry in ipairs(labels) do
if activity[entry.key] then if activity[entry.key] then
anyActive = true table.insert(parts, entry.label)
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
end end
end end
if not anyActive then local text
bbWriteAt(12, y, "IDLE", BB.activityOff) if #parts > 0 then
text = " " .. table.concat(parts, " | ") .. " "
else
text = " IDLE "
end 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 return y + 1
end end
@@ -1738,29 +1912,34 @@ function D.drawBillboard()
D.billboardMon.clear() D.billboardMon.clear()
local y = 1 local y = 1
-- Header bar
y = bbDrawHeader(y) y = bbDrawHeader(y)
y = bbDrawStorage(y)
-- Allocate vertical space: alerts ~3-8 lines, footer ~2 lines -- Storage ring + stats
local alertLines = state.activeAlerts and #state.activeAlerts or 0 y = bbDrawStorageSection(y)
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
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 -- Alerts
if alertMaxRows < 2 then alertMaxRows = 2 end local alertMaxRows = bbH - y - footerH - 1
if alertMaxRows < 1 then alertMaxRows = 1 end
y = bbDrawAlerts(y, alertMaxRows) y = bbDrawAlerts(y, alertMaxRows)
-- Fill gap before footer -- Fill gap before footer
local footerY = bbH - 1 while y < bbH do
while y < footerY do
bbClearLine(y) bbClearLine(y)
y = y + 1 y = y + 1
end end
bbDrawActivity(footerY)
-- Activity bar at very bottom
bbDrawActivityBar(bbH)
end end
return D return D