Module:BubbleGraph: Difference between revisions
From IdleOn MMO Wiki
m (Reverted edits by BHY4A (talk) to last revision by Butt Toast) Tag: Rollback |
mNo edit summary |
||
(24 intermediate revisions by 2 users not shown) | |||
Line 1: | Line 1: | ||
local BubbleGraph = {} | local BubbleGraph = {} | ||
local | local floor = math.floor | ||
local format = string.format | |||
local max = math.max | |||
local min = math.min | |||
local abs = math.abs | |||
local function formatLargeNumber(num, digits) | |||
local si = { | |||
{ value = 1, symbol = "" }, | |||
{ value = 1E3, symbol = "k" }, | |||
{ value = 1E6, symbol = "M" }, | |||
{ value = 1E9, symbol = "B" }, | |||
{ value = 1E12, symbol = "T" }, | |||
{ value = 1E15, symbol = "Q" }, | |||
{ value = 1E18, symbol = "QQ" }, | |||
{ value = 1E21, symbol = "QT" }, | |||
} | |||
for i = #si, 1, -1 do | |||
if num >= si[i].value then | |||
return format("%.2f%s", num / si[i].value, si[i].symbol) | |||
end | |||
end | |||
return tostring(num) | |||
end | |||
local function round(num, places) | local function round(num, places) | ||
local mult = 10 ^ (places or 0) | |||
return floor(num * mult + 0.5) / mult | |||
end | end | ||
local function | local function lavaLog(num) | ||
return math.log(max(num, 1)) / 2.303 | |||
end | end | ||
BubbleGraph.bonusCalculations = { | |||
add = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (level - 1)) / (x1 / x2)) * level * x1 or level * x1 | |||
end, | |||
decay = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return (level * x1) / (level + x2) | |||
end, | |||
intervalAdd = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return x1 + floor(level / x2) | |||
end, | |||
decayMulti = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return 1 + (level * x1) / (level + x2) | |||
end, | |||
bigBase = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return x1 + x2 * level | |||
end, | |||
addLower = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return x1 + x2 * (level + 1) | |||
end, | |||
decayLower = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2) | |||
end, | |||
decayMultiLower = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2) | |||
end, | |||
bigBaseLower = function(_, _, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return x2 or 0 | |||
end, | |||
intervalAddLower = function(level, _, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return max(floor((level + 1) / x2), 0) - max(floor(level / x2), 0) | |||
end, | |||
reduce = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return x1 - x2 * level | |||
end, | |||
reduceLower = function(level, x1, x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
return x1 - x2 * (level + 1) | |||
end, | |||
goldFood = function(level, x1, x2) | |||
if not x1 or not x2 then | |||
return 0 | |||
end | |||
local amount = x1 | |||
local stack = level | |||
local logValue = lavaLog(1 + stack) | |||
return round(amount * 0.05 * logValue * (1 + logValue / 2.14), 2) | |||
end, | |||
statue = function(level, x1, x2) | |||
if not x1 then | |||
return 0 | |||
end | |||
return level * x1 | |||
end, | |||
} | } | ||
BubbleGraph. | local function calculateRequiredStatues(level) | ||
return floor(math.pow(level, 1.17) * math.pow(1.35, level / 10) + 1) | |||
end | |||
BubbleGraph.DISPLAY_MODES = { | |||
STANDARD = "standard", | |||
EFFICIENCY = "efficiency", | |||
LOGARITHMIC = "logarithmic", | |||
PERCENTAGE = "percentage", | |||
DELTA = "delta", | |||
RELATIVE = "relative", | |||
CUMULATIVE = "cumulative", | |||
NORMALIZED = "normalized", | |||
GOLDENFOOD = "goldenfood", | |||
STATUE = "statue", | |||
} | } | ||
function | local function roundFormat(num) | ||
local str = tostring(num) | |||
return str:find("%.") and str:gsub("0+$", ""):gsub("%.$", "") or str | |||
end | |||
local CONSTANTS = { | |||
MAX_LEVELS = 1000, | |||
STEP = 20, | |||
GRAPH_WIDTH = 1000, | |||
GRAPH_HEIGHT = 250, | |||
DECIMAL_PLACES = 3, | |||
FONT_FAMILY = "Idleon", | |||
COLUMN_MIN_WIDTH = 2, | |||
MAJOR_GRID_LINES = 10, | |||
SHOW_GRID = true, | |||
TOOLTIP_WIDTH = 150, | |||
TOOLTIP_PADDING = 5, | |||
TEXT_SHADOW = "-.065em 0 #000, 0 .065em #000, .065em 0 #000, 0 -.065em #000", | |||
THRESHOLD_HEIGHT = 3, | |||
MIN_TEXT_HEIGHT = 20, | |||
LABEL_BOTTOM_OFFSET = 20, | |||
Y_AXIS_WIDTH = 40, | |||
FONT_SIZES = { | |||
TITLE = 19, | |||
LABEL = 10, | |||
Y_AXIS = 12, | |||
}, | |||
COLORS = { | |||
THRESHOLD = "#ff0000", | |||
TOOLTIP_BG = "#000142cc", | |||
TOOLTIP_BORDER = "#000142", | |||
COLUMN_BORDER = "#000", | |||
TEXT = "#fff", | |||
GRID_LINE = "#00000033", | |||
GRID_MAJOR = "#00000033", | |||
}, | |||
Z_INDEX = { | |||
GRID = 1, | |||
GRAPH = 2, | |||
Y_AXIS = 3, | |||
THRESHOLD = 4, | |||
TOOLTIP = 10, | |||
}, | |||
} | |||
CONSTANTS.FORMATS = { | |||
RELATIVE = { | |||
PREFIX = "", | |||
SUFFIX = "x", | |||
}, | |||
CUMULATIVE = { | |||
PREFIX = "Σ ", | |||
SUFFIX = "", | |||
}, | |||
NORMALIZED = { | |||
PREFIX = "", | |||
SUFFIX = "%", | |||
}, | |||
} | |||
local function formatValueByMode(value, displayMode) | |||
local format = CONSTANTS.FORMATS[displayMode] or { PREFIX = "", SUFFIX = "" } | |||
local formattedValue = roundFormat(round(value, CONSTANTS.DECIMAL_PLACES)) | |||
return format.PREFIX .. formattedValue .. format.SUFFIX | |||
end | |||
local bubbleColors = { | |||
orange = { | |||
columnStart = "#ffb74dff", | |||
columnEnd = "#d84315ff", | |||
backgroundStart = "#fff3e0cc", | |||
backgroundEnd = "#f57c00cc", | |||
}, | |||
green = { | |||
columnStart = "#9ccc65ff", | |||
columnEnd = "#2e7d32ff", | |||
backgroundStart = "#f1f8e9cc", | |||
backgroundEnd = "#43a047cc", | |||
}, | |||
purple = { | |||
columnStart = "#ba68c8ff", | |||
columnEnd = "#4527a0ff", | |||
backgroundStart = "#f3e5f5cc", | |||
backgroundEnd = "#8e24aacc", | |||
}, | |||
yellow = { | |||
columnStart = "#ffd54fff", | |||
columnEnd = "#ff8f00ff", | |||
backgroundStart = "#fffde7cc", | |||
backgroundEnd = "#ffc107cc", | |||
}, | |||
statue = { | |||
columnStart = "#aba28eff", | |||
columnEnd = "#635d53ff", | |||
backgroundStart = "#f5f2ebcc", | |||
backgroundEnd = "#8c857acc", | |||
}, | |||
goldFood = { | |||
columnStart = "#ffd700ff", | |||
columnEnd = "#b8860bff", | |||
backgroundStart = "#fff7e6cc", | |||
backgroundEnd = "#daa520cc", | |||
}, | |||
other = { | |||
columnStart = "#ab47bcff", | |||
columnEnd = "#6a1b9aff", | |||
backgroundStart = "#f3e5f5cc", | |||
backgroundEnd = "#8e24aacc", | |||
}, | |||
} | |||
local function sanitizeInput(text) | |||
return text:gsub("[<>&\"']", { | |||
["<"] = "<", | |||
[">"] = ">", | |||
["&"] = "&", | |||
['"'] = """, | |||
["'"] = "'", | |||
}) | |||
end | end | ||
function | local function getGoldFoodThreshold(x1) | ||
local targetEfficiency = 0.90 | |||
local baseValue = x1 * 0.05 | |||
local low = 1 | |||
local high = 100000 | |||
while high - low > 1 do | |||
local mid = floor((low + high) / 2) | |||
local logValue = lavaLog(1 + mid) | |||
local currentBonus = baseValue * logValue * (1 + logValue / 2.14) | |||
local maxBonus = baseValue * logValue * (1 + logValue / 2.14) | |||
if currentBonus / maxBonus >= targetEfficiency then | |||
high = mid | |||
else | |||
low = mid | |||
end | |||
end | |||
return high | |||
end | |||
local function getThresholdLevel(x2) | |||
if not x2 then | |||
return 0 | |||
end | |||
if func == "statue" then | |||
return CONSTANTS.MAX_LEVELS | |||
end | |||
if func == "goldFood" then | |||
return getGoldFoodThreshold(x1) | |||
end | |||
return x2 ~= 0 and x2 * 90 / (100 - 90) or CONSTANTS.MAX_LEVELS | |||
end | |||
local function hexToRgb(hex) | |||
local r = tonumber(hex:sub(2, 3), 16) | |||
local g = tonumber(hex:sub(4, 5), 16) | |||
local b = tonumber(hex:sub(6, 7), 16) | |||
local a = tonumber(hex:sub(8, 9) or "ff", 16) | |||
return { r, g, b, a } | |||
end | end | ||
local | local function interpolateColor(color1, color2, ratio) | ||
local rgb1, rgb2 = hexToRgb(color1), hexToRgb(color2) | |||
local interpolatedRgb = {} | |||
for i = 1, 4 do | |||
interpolatedRgb[i] = floor(rgb1[i] + ratio * (rgb2[i] - rgb1[i])) | |||
end | |||
return format("#%02x%02x%02x%02x", interpolatedRgb[1], interpolatedRgb[2], interpolatedRgb[3], interpolatedRgb[4]) | |||
end | |||
local function | local function formatDescription(description, realBonus) | ||
return description:gsub( | |||
"{", | |||
format( | |||
'<span class="bonus-value" style="color: %s; text-shadow: %s; font-size: 1.2em;">%s</span>', | |||
CONSTANTS.COLORS.THRESHOLD, | |||
CONSTANTS.TEXT_SHADOW, | |||
roundFormat(round(realBonus, CONSTANTS.DECIMAL_PLACES)) | |||
) | |||
) | |||
end | |||
function BubbleGraph.calculateBonus(func, level, x1, x2) | |||
if not func or not BubbleGraph.bonusCalculations[func] then | |||
return 0 | |||
end | |||
local calcFunc = BubbleGraph.bonusCalculations[func] | |||
return round(calcFunc(level, x1, x2), CONSTANTS.DECIMAL_PLACES) | |||
end | |||
local function calculateUtility(level, x1, x2, func) | |||
local actualMaxLevel = CONSTANTS.MAX_LEVELS | |||
if not x1 or not x2 then | |||
return 0 | |||
end | |||
if func == "statue" then | |||
return 100 | |||
end | |||
if func == "goldFood" then | |||
local currentBonus = BubbleGraph.calculateBonus(func, level, x1, x2) | |||
local theoreticalMax = BubbleGraph.calculateBonus(func, 100000, x1, x2) | |||
return (currentBonus / theoreticalMax) * 100 | |||
end | |||
if func == "decayMulti" then | |||
local current = 1 + (level * x1) / (level + x2) | |||
local limit = 1 + x1 | |||
local base = 1 | |||
return ((current - base) / (limit - base)) * 100 | |||
elseif func == "decay" then | |||
local current = (level * x1) / (level + x2) | |||
local limit = x1 | |||
return (current / limit) * 100 | |||
elseif func == "add" then | |||
local current = x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (level - 1)) / (x1 / x2)) * level * x1 or level * x1 | |||
local maxValue = x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (actualMaxLevel - 1)) / (x1 / x2)) * actualMaxLevel * x1 | |||
or actualMaxLevel * x1 | |||
return (current / maxValue) * 100 | |||
elseif func == "intervalAdd" then | |||
local current = x1 + floor(level / x2) | |||
local maxIntervals = floor(actualMaxLevel / x2) | |||
local maxValue = x1 + maxIntervals | |||
return (current / maxValue) * 100 | |||
elseif func == "bigBase" then | |||
local current = x1 + x2 * level | |||
local maxValue = x1 + x2 * actualMaxLevel | |||
return (current / maxValue) * 100 | |||
elseif func == "addLower" then | |||
local current = x1 + x2 * (level + 1) | |||
local maxValue = x1 + x2 * (actualMaxLevel + 1) | |||
return (current / maxValue) * 100 | |||
elseif func == "decayLower" or func == "decayMultiLower" then | |||
local current = x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2) | |||
local limit = x1 / x2 | |||
return (current / limit) * 100 | |||
elseif func == "bigBaseLower" then | |||
return 100 | |||
elseif func == "intervalAddLower" then | |||
local current = max(floor((level + 1) / x2), 0) - max(floor(level / x2), 0) | |||
local maxIntervals = max(floor((actualMaxLevel + 1) / x2), 0) - max(floor(actualMaxLevel / x2), 0) | |||
return current > 0 and 100 or 0 | |||
elseif func == "reduce" then | |||
local current = x1 - x2 * level | |||
local minValue = x1 - x2 * actualMaxLevel | |||
local maxValue = x1 | |||
return 100 - ((current - minValue) / (maxValue - minValue)) * 100 | |||
elseif func == "reduceLower" then | |||
local current = x1 - x2 * (level + 1) | |||
local minValue = x1 - x2 * (actualMaxLevel + 1) | |||
local maxValue = x1 - x2 | |||
return 100 - ((current - minValue) / (maxValue - minValue)) * 100 | |||
end | |||
return 0 | |||
end | end | ||
function | local function calculateEfficiencyThresholdLevel(x1, x2, func, targetEfficiency) | ||
local minLevel = 1 | |||
local maxLevel = 100000 | |||
local tolerance = 0.0001 | |||
if not x2 then | |||
return 0 | |||
end | |||
while minLevel < maxLevel do | |||
local level = floor((minLevel + maxLevel) / 2) | |||
local utility = calculateUtility(level, x1, x2, func) | |||
if abs(utility - targetEfficiency) < tolerance then | |||
return level | |||
elseif utility < targetEfficiency then | |||
minLevel = level + 1 | |||
else | |||
maxLevel = level - 1 | |||
end | |||
end | |||
return minLevel | |||
end | end | ||
local function | local function addThresholdAndEfficiencyIndicators(column, point, x2) | ||
if not column or not point or not point.bonus then | |||
return column | |||
end | |||
local thresholdLevel = x2 ~= 0 and x2 * 90 / (100 - 90) or CONSTANTS.MAX_LEVELS | |||
if point.efficiency then | |||
local zoneClass = "efficiency-zone" | |||
if point.efficiency > 90 then | |||
column:tag("div"):addClass("efficiency-zone-high"):css({ | |||
height = "100%", | |||
opacity = "0.25", | |||
}) | |||
elseif point.efficiency > 75 then | |||
column:tag("div"):addClass("efficiency-zone-medium"):css({ | |||
height = "100%", | |||
opacity = "0.25", | |||
}) | |||
else | |||
column:tag("div"):addClass("efficiency-zone-low"):css({ | |||
height = "100%", | |||
opacity = "0.25", | |||
}) | |||
end | |||
end | |||
return column | |||
end | end | ||
local function | local function calculateGrowthAnalysis(points) | ||
if not points or #points < 2 then | |||
return { | |||
rates = {}, | |||
thresholds = { | |||
average = 0, | |||
significant = 0, | |||
critical = 0, | |||
}, | |||
} | |||
end | |||
local rates = {} | |||
local sum = 0 | |||
local count = 0 | |||
for i = 2, #points do | |||
local prevBonus = points[i - 1].bonus | |||
if prevBonus ~= 0 then | |||
local rate = (points[i].bonus - prevBonus) / prevBonus | |||
table.insert(rates, rate) | |||
sum = sum + rate | |||
count = count + 1 | |||
end | |||
end | |||
local mean = count > 0 and sum / count or 0 | |||
return { | |||
rates = rates, | |||
thresholds = { | |||
average = mean, | |||
significant = mean * 1.5, | |||
critical = mean * 2, | |||
}, | |||
} | |||
end | end | ||
local function | local function calculateAverage(points) | ||
local sum = 0 | |||
for _, point in ipairs(points) do | |||
sum = sum + point.bonus | |||
end | |||
return sum / #points | |||
end | |||
local function calculateVariance(points) | |||
local avg = calculateAverage(points) | |||
local sum = 0 | |||
for _, point in ipairs(points) do | |||
sum = sum + (point.bonus - avg) ^ 2 | |||
end | |||
return sum / #points | |||
end | |||
local function calculateRelativeValues(points, baseValue) | |||
if not points or #points == 0 then | |||
return {} | |||
end | |||
baseValue = baseValue or points[1].bonus | |||
local relativePoints = {} | |||
for _, point in ipairs(points) do | |||
local relativeCopy = {} | |||
for k, v in pairs(point) do | |||
relativeCopy[k] = v | |||
end | |||
relativeCopy.bonus = point.bonus / baseValue | |||
relativeCopy.formattedBonus = roundFormat(round(relativeCopy.bonus, CONSTANTS.DECIMAL_PLACES)) | |||
table.insert(relativePoints, relativeCopy) | |||
end | |||
return relativePoints | |||
end | |||
local function calculateCumulativeValues(points) | |||
if not points or #points == 0 then | |||
return {} | |||
end | |||
local cumulativePoints = {} | |||
local sum = 0 | |||
for _, point in ipairs(points) do | |||
local cumulativeCopy = {} | |||
for k, v in pairs(point) do | |||
cumulativeCopy[k] = v | |||
end | |||
sum = sum + point.bonus | |||
cumulativeCopy.bonus = sum | |||
cumulativeCopy.formattedBonus = roundFormat(round(sum, CONSTANTS.DECIMAL_PLACES)) | |||
table.insert(cumulativePoints, cumulativeCopy) | |||
end | |||
return cumulativePoints | |||
end | |||
local function calculateNormalizedValues(points) | |||
if not points or #points == 0 then | |||
return {} | |||
end | |||
local minVal = math.huge | |||
local maxVal = -math.huge | |||
for _, point in ipairs(points) do | |||
minVal = min(minVal, point.bonus) | |||
maxVal = max(maxVal, point.bonus) | |||
end | |||
local range = maxVal - minVal | |||
local normalizedPoints = {} | |||
for _, point in ipairs(points) do | |||
local normalizedCopy = {} | |||
for k, v in pairs(point) do | |||
normalizedCopy[k] = v | |||
end | |||
normalizedCopy.bonus = range ~= 0 and (point.bonus - minVal) / range or 0 | |||
normalizedCopy.formattedBonus = roundFormat(round(normalizedCopy.bonus, CONSTANTS.DECIMAL_PLACES)) | |||
table.insert(normalizedPoints, normalizedCopy) | |||
end | |||
return normalizedPoints | |||
end | |||
function BubbleGraph.renderGrid(width, height, majorLines) | |||
local grid = mw.html.create("div"):css({ | |||
position = "absolute", | |||
top = "0", | |||
left = "0", | |||
width = width .. "px", | |||
height = height .. "px", | |||
["pointer-events"] = "none", | |||
["z-index"] = CONSTANTS.Z_INDEX.GRID, | |||
["background-image"] = format( | |||
"linear-gradient(to right, %s 1px, transparent 1px), linear-gradient(to bottom, %s 1px, transparent 1px)", | |||
CONSTANTS.COLORS.GRID_LINE, | |||
CONSTANTS.COLORS.GRID_LINE | |||
), | |||
["background-size"] = format("%dpx 100%%, 100%% %dpx", width / majorLines, height / majorLines), | |||
}) | |||
for i = 0, majorLines do | |||
local position = (i / majorLines) * 100 | |||
grid:tag("div"):css({ | |||
position = "absolute", | |||
left = position .. "%", | |||
top = "0", | |||
height = "100%", | |||
width = "1px", | |||
["background-color"] = CONSTANTS.COLORS.GRID_MAJOR, | |||
}) | |||
end | |||
return grid | |||
end | |||
local function createGraphContainer(width, height, options) | |||
local container = mw.html | |||
.create("div") | |||
:addClass("bubble-graph-container") | |||
:attr("role", "img") | |||
:attr("aria-label") | |||
:attr("tabindex", "0") | |||
:css({ | |||
width = width .. "px", | |||
height = height .. "px", | |||
position = "relative", | |||
["font-family"] = CONSTANTS.FONT_FAMILY, | |||
["text-align"] = "center", | |||
["padding-bottom"] = "50px", | |||
["padding-left"] = CONSTANTS.Y_AXIS_WIDTH .. "px", | |||
}) | |||
return container | |||
end | end | ||
local function | local function createTooltipText(point, options, allPoints) | ||
local leftSection = {} | |||
local rightSection = {} | |||
if options.showMetrics then | |||
local avg = calculateAverage(allPoints) | |||
local var = calculateVariance(allPoints) | |||
table.insert(rightSection, format("Average: %.2f", avg)) | |||
table.insert(rightSection, format("Variance: %.2f", var)) | |||
if #allPoints > 1 then | |||
local growthAnalysis = calculateGrowthAnalysis(allPoints) | |||
local pointIndex | |||
for i, p in ipairs(allPoints) do | |||
if p.level == point.level then | |||
pointIndex = i - 1 | |||
break | |||
end | |||
end | |||
if pointIndex and pointIndex > 0 and growthAnalysis.rates[pointIndex] then | |||
table.insert(rightSection, format("Growth Rate: %.2f%%", growthAnalysis.rates[pointIndex] * 100)) | |||
end | |||
end | |||
end | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then | |||
table.insert(leftSection, format("Level: %s", tostring(point.level))) | |||
table.insert(leftSection, format("Bonus: %s", point.formattedBonus)) | |||
table.insert( | |||
leftSection, | |||
format("Required Statues: %s (%s total)", point.formattedStatues, point.formattedTotalStatues) | |||
) | |||
else | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then | |||
table.insert(leftSection, format("Amount: %s", tostring(point.level))) | |||
else | |||
table.insert(leftSection, format("Level: %s", tostring(point.level))) | |||
end | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then | |||
table.insert(leftSection, format("Value: %s", formatValueByMode(point.bonus, "RELATIVE"))) | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then | |||
table.insert(leftSection, format("Value: %s", formatValueByMode(point.bonus, "CUMULATIVE"))) | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then | |||
table.insert(leftSection, format("Value: %s", formatValueByMode(point.bonus * 100, "NORMALIZED"))) | |||
else | |||
table.insert(leftSection, format("Value: %s", point.formattedBonus)) | |||
end | |||
end | |||
if options.showAnalysis then | |||
if point.efficiency then | |||
table.insert(leftSection, format("Efficiency: %.1f%%", point.efficiency)) | |||
end | |||
if point.bonusGain then | |||
local sign = point.bonusGain > 0 and "+" or "" | |||
table.insert(rightSection, format("Growth: %s%s", sign, tostring(point.bonusGain))) | |||
end | |||
end | |||
local tooltipHTML = format( | |||
[[ | |||
<div class="tooltip-container"> | |||
<div class="tooltip-top"> | |||
<div class="tooltip-left">%s</div> | |||
<div class="tooltip-right">%s</div> | |||
</div> | |||
<div class="tooltip-bottom">%s</div> | |||
</div> | |||
]], | |||
table.concat(leftSection, "<br>"), | |||
table.concat(rightSection, "<br>"), | |||
format('<span class="description">%s</span>', point.formattedDescription) | |||
) | |||
return tooltipHTML | |||
end | end | ||
local function | local function createColumn(point, maxBonus, options, scheme, points) | ||
local height | |||
local displayValue | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then | |||
if point.bonus > 0 and maxBonus > 0 then | |||
local scale = math.sqrt(point.bonus) / math.sqrt(maxBonus) | |||
height = floor(scale * options.graphHeight) | |||
height = min(height, options.graphHeight) | |||
height = max(height, 1) | |||
else | |||
height = 0 | |||
end | |||
displayValue = point.formattedBonus | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.LOGARITHMIC then | |||
if point.bonus > 0 and maxBonus > 0 then | |||
local scale = (point.bonus ^ (1 / 3)) / (maxBonus ^ (1 / 3)) | |||
height = floor(scale * options.graphHeight) | |||
height = min(height, options.graphHeight) | |||
height = max(height, 1) | |||
else | |||
height = 0 | |||
end | |||
displayValue = point.formattedBonus | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then | |||
if point.bonus > 0 and maxBonus > 0 then | |||
local minValue = math.log(1) | |||
local maxValue = math.log(maxBonus + 1) | |||
local currentValue = math.log(point.bonus + 1) | |||
height = floor(((currentValue - minValue) / (maxValue - minValue)) * options.graphHeight) | |||
height = min(height, options.graphHeight) | |||
height = max(height, 1) | |||
else | |||
height = 0 | |||
end | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then | |||
local relativeValue = point.bonus / maxBonus | |||
local scale = 0.1 | |||
height = min(floor(relativeValue * options.graphHeight * scale), options.graphHeight) | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.PERCENTAGE then | |||
height = floor((point.bonus / maxBonus) * 100 * (options.graphHeight / 100)) | |||
displayValue = format("%.1f%%", (point.bonus / maxBonus) * 100) | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.DELTA then | |||
height = floor((abs(point.bonusGain or 0) / maxBonus) * options.graphHeight) | |||
elseif options.displayMode == "efficiency" then | |||
height = point.relativeHeight * options.graphHeight | |||
else | |||
height = floor((point.bonus / maxBonus) * options.graphHeight) | |||
end | |||
if not displayValue then | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then | |||
displayValue = formatValueByMode(point.bonus, "RELATIVE") | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then | |||
displayValue = formatValueByMode(point.bonus, "CUMULATIVE") | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then | |||
displayValue = formatValueByMode(point.bonus * 100, "NORMALIZED") | |||
else | |||
displayValue = point.formattedBonus | |||
end | |||
end | |||
height = floor(height) | |||
local column = mw.html | |||
.create("div") | |||
:addClass("bubble-graph-column") | |||
:attr("role", "gridcell") | |||
:attr("aria-label", format("Level %s, Value %s", point.level, point.formattedBonus)) | |||
:css({ | |||
flex = "1", | |||
height = height .. "px", | |||
position = "relative", | |||
["min-width"] = CONSTANTS.COLUMN_MIN_WIDTH .. "px", | |||
}) | |||
local color | |||
if options.displayMode == "efficiency" then | |||
local ratio = point.relativeHeight | |||
color = interpolateColor(scheme.columnStart, scheme.columnEnd, ratio) | |||
else | |||
local thresholdLevel = getThresholdLevel(options.x2, options.func, options.x1) | |||
local maxHeight = options.graphHeight | |||
local heightRatio = height / maxHeight | |||
if point.level <= thresholdLevel then | |||
local ratio = point.level / thresholdLevel | |||
color = interpolateColor(scheme.columnStart, scheme.columnEnd, ratio) | |||
else | |||
local ratio = (point.level - thresholdLevel) / (options.maxLevels - thresholdLevel) | |||
color = interpolateColor(scheme.columnEnd, scheme.columnStart, ratio) | |||
end | |||
end | |||
column:css("background-color", color) | |||
local tooltipText = createTooltipText(point, options, points) | |||
local tooltip = mw.html | |||
.create("div") | |||
:addClass("bubble-graph-tooltip") | |||
:css({ | |||
["background-color"] = CONSTANTS.COLORS.TOOLTIP_BG, | |||
border = format("1px solid %s", CONSTANTS.COLORS.TOOLTIP_BORDER), | |||
padding = CONSTANTS.TOOLTIP_PADDING .. "px", | |||
color = CONSTANTS.COLORS.TEXT, | |||
["text-align"] = "center", | |||
["z-index"] = CONSTANTS.Z_INDEX.TOOLTIP, | |||
width = CONSTANTS.TOOLTIP_WIDTH .. "px", | |||
bottom = floor(height / 5) .. "px", | |||
["pointer-events"] = "none", | |||
left = point.ratio * 100 .. "%", | |||
transform = format("translate(-%d%%, 0)", point.ratio * 100), | |||
}) | |||
:wikitext(tooltipText) | |||
column:node(tooltip) | |||
if point.isThreshold then | |||
column:tag("div"):css({ | |||
position = "absolute", | |||
top = "-5px", | |||
width = "100%", | |||
height = CONSTANTS.THRESHOLD_HEIGHT .. "px", | |||
["background-color"] = CONSTANTS.COLORS.THRESHOLD, | |||
["z-index"] = CONSTANTS.Z_INDEX.THRESHOLD, | |||
}) | |||
end | |||
if point.formattedBonus ~= "0" then | |||
local valueSpan = mw.html | |||
.create("span") | |||
:addClass("bubble-graph-value") | |||
:css({ | |||
position = "absolute", | |||
color = CONSTANTS.COLORS.TEXT, | |||
["text-shadow"] = CONSTANTS.TEXT_SHADOW, | |||
["white-space"] = "nowrap", | |||
["z-index"] = 1, | |||
}) | |||
:wikitext(point.formattedBonus) | |||
local textLength = #point.formattedBonus * 8 | |||
local padding = 5 | |||
local minHeight = max(CONSTANTS.MIN_TEXT_HEIGHT, textLength + padding * 2) | |||
local actualHeight = height | |||
if options.displayMode == "efficiency" then | |||
actualHeight = floor(point.relativeHeight * options.graphHeight) | |||
end | |||
if actualHeight < minHeight then | |||
valueSpan:css({ | |||
bottom = "100%", | |||
left = "50%", | |||
transform = "translate(-50%, -2px) rotate(-90deg)", | |||
}) | |||
else | |||
local textYPos = min(actualHeight / 2, actualHeight - textLength / 2 - padding) | |||
textYPos = max(textLength / 2 + padding, textYPos) | |||
valueSpan:css({ | |||
bottom = textYPos .. "px", | |||
left = "50%", | |||
transform = "translate(-50%, 50%) rotate(-90deg)", | |||
}) | |||
end | |||
column:node(valueSpan) | |||
end | |||
if point.showLabel then | |||
local labelText | |||
if options.displayMode == "efficiency" then | |||
labelText = tostring(point.efficiency) .. "%" | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then | |||
labelText = formatLargeNumber(point.level) | |||
local formattedNumber = formatLargeNumber(point.level, 2) | |||
labelText = roundFormat(tonumber(formattedNumber:match("[%d.]+")) or point.level) .. formattedNumber:match("[^%d.]*$") | |||
else | |||
labelText = tostring(point.level) | |||
end | |||
column | |||
:tag("div") | |||
:css({ | |||
position = "absolute", | |||
bottom = -CONSTANTS.LABEL_BOTTOM_OFFSET .. "px", | |||
width = "100%", | |||
["text-align"] = "center", | |||
["font-size"] = CONSTANTS.FONT_SIZES.LABEL .. "px", | |||
["z-index"] = CONSTANTS.Z_INDEX.GRAPH, | |||
["text-wrap"] = "nowrap", | |||
}) | |||
:wikitext(labelText) | |||
end | |||
return column | |||
end | end | ||
function | local function generateGraphPoints(x1, x2, func, maxLevels, step, options) | ||
local maxBonus = 0 | |||
local minLevel = max(1, options.minLevels or 1) | |||
local displayLevels = {} | |||
local thresholdLevel = floor(getThresholdLevel(x2, func, x1)) | |||
local pointsWithThreshold = {} | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then | |||
local lastStatues = 0 | |||
for level = 10, 500, 10 do | |||
local rawBonus = BubbleGraph.calculateBonus(func, level, x1, x2) | |||
local totalStatues = calculateRequiredStatues(level) | |||
local statuesForLevel = totalStatues - lastStatues | |||
local point = { | |||
level = level, | |||
bonus = rawBonus, | |||
realBonus = rawBonus, | |||
formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)), | |||
showLabel = true, | |||
isThreshold = false, | |||
statues = statuesForLevel, | |||
totalStatues = totalStatues, | |||
formattedStatues = formatLargeNumber(statuesForLevel, 2), | |||
formattedTotalStatues = formatLargeNumber(totalStatues, 2), | |||
} | |||
table.insert(pointsWithThreshold, point) | |||
maxBonus = max(maxBonus, rawBonus) | |||
lastStatues = totalStatues | |||
end | |||
return pointsWithThreshold, maxBonus | |||
end | |||
local baseLevel = minLevel - (minLevel % 100) | |||
if minLevel % 100 ~= 0 then | |||
baseLevel = baseLevel + 100 | |||
end | |||
table.insert(displayLevels, minLevel) | |||
for level = baseLevel, maxLevels, 100 do | |||
if level > minLevel and level <= maxLevels then | |||
table.insert(displayLevels, level) | |||
end | |||
end | |||
if maxLevels % 100 ~= 0 then | |||
table.insert(displayLevels, maxLevels) | |||
end | |||
local thresholdBonus = BubbleGraph.calculateBonus(func, thresholdLevel, x1, x2) | |||
local thresholdAligned = (thresholdLevel % step) == 0 | |||
local pointsWithThreshold = {} | |||
for level = minLevel - (minLevel % step), maxLevels, step do | |||
if level >= minLevel then | |||
local actualLevel = level | |||
local rawBonus = BubbleGraph.calculateBonus(func, actualLevel, x1, x2) | |||
local utilityRatio = calculateUtility(actualLevel, x1, x2, func) / 100 | |||
local displayBonus = actualLevel <= thresholdLevel and rawBonus | |||
or thresholdBonus * (thresholdLevel / actualLevel) | |||
local point = { | |||
level = actualLevel, | |||
bonus = displayBonus, | |||
realBonus = rawBonus, | |||
formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)), | |||
showLabel = false, | |||
isThreshold = actualLevel == thresholdLevel, | |||
utilityRatio = utilityRatio, | |||
efficiency = calculateUtility(actualLevel, x1, x2, func), | |||
} | |||
for _, displayLevel in ipairs(displayLevels) do | |||
if actualLevel == displayLevel then | |||
point.showLabel = true | |||
break | |||
end | |||
end | |||
table.insert(pointsWithThreshold, point) | |||
maxBonus = max(maxBonus, displayBonus) | |||
if not thresholdAligned and actualLevel < thresholdLevel and (actualLevel + step) > thresholdLevel then | |||
local thresholdPoint = { | |||
level = thresholdLevel, | |||
bonus = thresholdBonus, | |||
realBonus = thresholdBonus, | |||
formattedBonus = roundFormat(round(thresholdBonus, CONSTANTS.DECIMAL_PLACES)), | |||
showLabel = true, | |||
isThreshold = true, | |||
utilityRatio = calculateUtility(thresholdLevel, x1, x2, func) / 100, | |||
efficiency = calculateUtility(thresholdLevel, x1, x2, func), | |||
} | |||
table.insert(pointsWithThreshold, thresholdPoint) | |||
maxBonus = max(maxBonus, thresholdBonus) | |||
end | |||
end | |||
end | |||
table.sort(pointsWithThreshold, function(a, b) | |||
return a.level < b.level | |||
end) | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then | |||
maxBonus = BubbleGraph.calculateBonus(func, maxLevels, x1, x2) | |||
for _, point in ipairs(pointsWithThreshold) do | |||
point.maxPossibleBonus = maxBonus | |||
end | |||
end | |||
if options.displayMode then | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then | |||
local baseValue = pointsWithThreshold[1] and pointsWithThreshold[1].bonus or 1 | |||
pointsWithThreshold = calculateRelativeValues(pointsWithThreshold, baseValue) | |||
maxBonus = 1 | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then | |||
pointsWithThreshold = calculateCumulativeValues(pointsWithThreshold) | |||
maxBonus = pointsWithThreshold[#pointsWithThreshold].bonus | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then | |||
pointsWithThreshold = calculateNormalizedValues(pointsWithThreshold) | |||
maxBonus = 1 | |||
end | |||
end | |||
for i = 1, #pointsWithThreshold - 1 do | |||
local currentBonus = pointsWithThreshold[i].realBonus | |||
local nextBonus = pointsWithThreshold[i + 1].realBonus | |||
pointsWithThreshold[i].bonusGain = nextBonus - currentBonus | |||
end | |||
if #pointsWithThreshold > 1 then | |||
local lastIndex = #pointsWithThreshold | |||
local prevIndex = lastIndex - 1 | |||
local lastBonusGain = pointsWithThreshold[lastIndex].realBonus - pointsWithThreshold[prevIndex].realBonus | |||
pointsWithThreshold[lastIndex].bonusGain = lastBonusGain | |||
end | |||
local maxDelta = 0 | |||
for _, point in ipairs(pointsWithThreshold) do | |||
maxDelta = max(maxDelta, abs(point.bonusGain or 0)) | |||
end | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.DELTA then | |||
maxBonus = maxDelta | |||
end | |||
return pointsWithThreshold, maxBonus | |||
end | |||
local function generateGraphPointsEfficiency(x1, x2, func) | |||
local points = {} | |||
local maxBonus = 0 | |||
local prevEfficiency = 0 | |||
local efficiencies = {} | |||
for eff = 2, 98, 2 do | |||
table.insert(efficiencies, eff) | |||
end | |||
table.insert(efficiencies, 99) | |||
for _, efficiency in ipairs(efficiencies) do | |||
local level = calculateEfficiencyThresholdLevel(x1, x2, func, efficiency) | |||
local rawBonus = BubbleGraph.calculateBonus(func, level, x1, x2) | |||
local point = { | |||
level = level, | |||
bonus = rawBonus, | |||
realBonus = rawBonus, | |||
formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)), | |||
showLabel = true, | |||
isThreshold = false, | |||
utilityRatio = efficiency / 100, | |||
efficiency = efficiency, | |||
bonusGain = 0, | |||
relativeHeight = 0, | |||
} | |||
table.insert(points, point) | |||
maxBonus = max(maxBonus, rawBonus) | |||
prevEfficiency = efficiency | |||
end | |||
for i = 1, #points - 1 do | |||
points[i].bonusGain = abs(points[i + 1].bonus - points[i].bonus) | |||
end | |||
points[#points].bonusGain = points[#points - 1].bonusGain | |||
local maxGain = 0 | |||
local minGain = math.huge | |||
for _, point in ipairs(points) do | |||
maxGain = max(maxGain, point.bonusGain) | |||
if point.bonusGain > 0 then | |||
minGain = min(minGain, point.bonusGain) | |||
end | |||
end | |||
if maxGain > 0 then | |||
for _, point in ipairs(points) do | |||
local normalizedGain = (point.bonusGain - minGain) / (maxGain - minGain) | |||
point.relativeHeight = 0.1 + (0.9 * math.sqrt(normalizedGain)) | |||
end | |||
else | |||
for _, point in ipairs(points) do | |||
point.relativeHeight = 0.1 | |||
end | |||
end | |||
return points, maxBonus | |||
end | |||
function BubbleGraph.generateGraph(x1, x2, func, bubbleColor, bubbleName, description, options) | |||
local graphWidth = options.graphWidth or CONSTANTS.GRAPH_WIDTH | |||
local graphHeight = options.graphHeight or CONSTANTS.GRAPH_HEIGHT | |||
local containerHeight = options.displayMode == "efficiency" and graphHeight or (graphHeight + 50) | |||
local container = createGraphContainer(graphWidth, containerHeight, options) | |||
local scheme = bubbleColors[bubbleColor] or bubbleColors.other | |||
local graphTitle = mw.html | |||
.create("div") | |||
:css({ | |||
position = "relative", | |||
width = graphWidth .. "px", | |||
height = "20px", | |||
["font-size"] = CONSTANTS.FONT_SIZES.TITLE .. "px", | |||
["padding-top"] = "5px", | |||
["margin-top"] = "5px", | |||
}) | |||
:wikitext(bubbleName) | |||
local graphContainer = mw.html.create("div"):css({ | |||
position = "relative", | |||
width = graphWidth .. "px", | |||
height = graphHeight .. "px", | |||
["margin-top"] = "10px", | |||
["padding-top"] = "10px", | |||
["z-index"] = CONSTANTS.Z_INDEX.GRAPH, | |||
}) | |||
if options.showGrid then | |||
graphContainer:node( | |||
BubbleGraph.renderGrid(graphWidth, graphHeight, options.majorGridLines or CONSTANTS.MAJOR_GRID_LINES) | |||
) | |||
end | |||
local graphBody = mw.html.create("div"):css({ | |||
width = graphWidth .. "px", | |||
height = graphHeight .. "px", | |||
display = "flex", | |||
["align-items"] = "flex-end", | |||
position = "absolute", | |||
top = "0", | |||
left = "0", | |||
["z-index"] = CONSTANTS.Z_INDEX.GRAPH, | |||
["background-image"] = format("linear-gradient(%s, %s)", scheme.backgroundStart, scheme.backgroundEnd), | |||
border = format("1px solid %s", CONSTANTS.COLORS.COLUMN_BORDER), | |||
["border-radius"] = "3px", | |||
padding = "3px", | |||
}) | |||
local points, maxBonus | |||
if options.displayMode == "efficiency" then | |||
points, maxBonus = generateGraphPointsEfficiency(x1, x2, func, options) | |||
else | |||
points, maxBonus = generateGraphPoints(x1, x2, func, options.maxLevels, options.step, options) | |||
end | |||
for i, point in ipairs(points) do | |||
point.ratio = i / #points | |||
point.formattedDescription = formatDescription(description, point.realBonus) | |||
local column = createColumn(point, maxBonus, options, scheme, points) | |||
if options.showEfficiencyZones or options.thresholds then | |||
column = addThresholdAndEfficiencyIndicators(column, point, options.x2) | |||
end | |||
graphBody:node(column) | |||
end | |||
local yAxis = mw.html.create("div"):css({ | |||
position = "absolute", | |||
top = "0", | |||
left = -CONSTANTS.Y_AXIS_WIDTH .. "px", | |||
width = CONSTANTS.Y_AXIS_WIDTH .. "px", | |||
height = graphHeight .. "px", | |||
["pointer-events"] = "none", | |||
["z-index"] = CONSTANTS.Z_INDEX.Y_AXIS, | |||
}) | |||
for i = 0, 10 do | |||
local value = round(maxBonus * (10 - i) / 10, 2) | |||
local displayValue | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then | |||
displayValue = formatValueByMode(value, "RELATIVE") | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then | |||
displayValue = formatValueByMode(value, "CUMULATIVE") | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then | |||
displayValue = formatValueByMode(value * 100, "NORMALIZED") | |||
else | |||
displayValue = roundFormat(round(value, CONSTANTS.DECIMAL_PLACES)) | |||
end | |||
local label = mw.html | |||
.create("div") | |||
:css({ | |||
position = "absolute", | |||
top = (i * 10) .. "%", | |||
right = "5px", | |||
["text-align"] = "right", | |||
["font-size"] = CONSTANTS.FONT_SIZES.Y_AXIS .. "px", | |||
["line-height"] = "1", | |||
transform = "translateY(-50%)", | |||
}) | |||
:wikitext(roundFormat(value)) | |||
yAxis:node(label) | |||
end | |||
graphContainer:node(graphBody) | |||
graphContainer:node(yAxis) | |||
container:node(graphTitle) | |||
container:node(graphContainer) | |||
return tostring(container) | |||
end | end | ||
function BubbleGraph.render(frame) | function BubbleGraph.render(frame) | ||
local args = frame.args or frame:getParent().args | |||
local x1 = tonumber(args.x1) | |||
local x2 = tonumber(args.x2) | |||
local func = args.func | |||
local options = { | |||
maxLevels = CONSTANTS.MAX_LEVELS, | |||
minLevels = tonumber(args.min_levels) or 1, | |||
step = tonumber(args.step) or CONSTANTS.STEP, | |||
graphWidth = tonumber(args.width) or CONSTANTS.GRAPH_WIDTH, | |||
graphHeight = tonumber(args.height) or CONSTANTS.GRAPH_HEIGHT, | |||
showGrid = args.show_grid == nil and CONSTANTS.SHOW_GRID or (args.show_grid == "true"), | |||
majorGridLines = tonumber(args.major_grid_lines) or CONSTANTS.MAJOR_GRID_LINES, | |||
displayMode = args.display_mode or BubbleGraph.DISPLAY_MODES.STANDARD, | |||
showEfficiencyZones = args.show_efficiency_zones ~= "false", | |||
showAnalysis = args.show_analysis ~= "false", | |||
showMetrics = args.show_metrics ~= "false", | |||
showGrowthIndicators = args.show_growth_indicators ~= "false", | |||
} | |||
local scheme = bubbleColors[args.bubble_color or args.color or "other"] | |||
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then | |||
options.maxLevels = 100000 | |||
options.step = 2000 | |||
func = "goldFood" | |||
scheme = bubbleColors.goldFood | |||
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then | |||
options.maxLevels = 500 | |||
options.step = 10 | |||
options.minLevels = 10 | |||
func = "statue" | |||
scheme = bubbleColors.statue | |||
end | |||
options.x2 = x2 | |||
options.func = func | |||
options.x1 = x1 | |||
local points, maxBonus | |||
if options.displayMode == "efficiency" then | |||
points, maxBonus = generateGraphPointsEfficiency(x1, x2, func, options) | |||
else | |||
points, maxBonus = generateGraphPoints(x1, x2, func, options.maxLevels, options.step, options) | |||
end | |||
return BubbleGraph.generateGraph( | |||
tonumber(args.x1), | |||
tonumber(args.x2), | |||
func, | |||
sanitizeInput(args.bubble_color or args.color or "other"), | |||
sanitizeInput(args.bubble_name or args.name or ""), | |||
sanitizeInput(args.description or ""), | |||
options | |||
) | |||
end | end | ||
return BubbleGraph | return BubbleGraph |
Latest revision as of 05:20, 5 January 2025
Documentation for this module may be created at Module:BubbleGraph/doc
local BubbleGraph = {}
local floor = math.floor
local format = string.format
local max = math.max
local min = math.min
local abs = math.abs
local function formatLargeNumber(num, digits)
local si = {
{ value = 1, symbol = "" },
{ value = 1E3, symbol = "k" },
{ value = 1E6, symbol = "M" },
{ value = 1E9, symbol = "B" },
{ value = 1E12, symbol = "T" },
{ value = 1E15, symbol = "Q" },
{ value = 1E18, symbol = "QQ" },
{ value = 1E21, symbol = "QT" },
}
for i = #si, 1, -1 do
if num >= si[i].value then
return format("%.2f%s", num / si[i].value, si[i].symbol)
end
end
return tostring(num)
end
local function round(num, places)
local mult = 10 ^ (places or 0)
return floor(num * mult + 0.5) / mult
end
local function lavaLog(num)
return math.log(max(num, 1)) / 2.303
end
BubbleGraph.bonusCalculations = {
add = function(level, x1, x2)
if not x2 then
return 0
end
return x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (level - 1)) / (x1 / x2)) * level * x1 or level * x1
end,
decay = function(level, x1, x2)
if not x2 then
return 0
end
return (level * x1) / (level + x2)
end,
intervalAdd = function(level, x1, x2)
if not x2 then
return 0
end
return x1 + floor(level / x2)
end,
decayMulti = function(level, x1, x2)
if not x2 then
return 0
end
return 1 + (level * x1) / (level + x2)
end,
bigBase = function(level, x1, x2)
if not x2 then
return 0
end
return x1 + x2 * level
end,
addLower = function(level, x1, x2)
if not x2 then
return 0
end
return x1 + x2 * (level + 1)
end,
decayLower = function(level, x1, x2)
if not x2 then
return 0
end
return x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2)
end,
decayMultiLower = function(level, x1, x2)
if not x2 then
return 0
end
return x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2)
end,
bigBaseLower = function(_, _, x2)
if not x2 then
return 0
end
return x2 or 0
end,
intervalAddLower = function(level, _, x2)
if not x2 then
return 0
end
return max(floor((level + 1) / x2), 0) - max(floor(level / x2), 0)
end,
reduce = function(level, x1, x2)
if not x2 then
return 0
end
return x1 - x2 * level
end,
reduceLower = function(level, x1, x2)
if not x2 then
return 0
end
return x1 - x2 * (level + 1)
end,
goldFood = function(level, x1, x2)
if not x1 or not x2 then
return 0
end
local amount = x1
local stack = level
local logValue = lavaLog(1 + stack)
return round(amount * 0.05 * logValue * (1 + logValue / 2.14), 2)
end,
statue = function(level, x1, x2)
if not x1 then
return 0
end
return level * x1
end,
}
local function calculateRequiredStatues(level)
return floor(math.pow(level, 1.17) * math.pow(1.35, level / 10) + 1)
end
BubbleGraph.DISPLAY_MODES = {
STANDARD = "standard",
EFFICIENCY = "efficiency",
LOGARITHMIC = "logarithmic",
PERCENTAGE = "percentage",
DELTA = "delta",
RELATIVE = "relative",
CUMULATIVE = "cumulative",
NORMALIZED = "normalized",
GOLDENFOOD = "goldenfood",
STATUE = "statue",
}
local function roundFormat(num)
local str = tostring(num)
return str:find("%.") and str:gsub("0+$", ""):gsub("%.$", "") or str
end
local CONSTANTS = {
MAX_LEVELS = 1000,
STEP = 20,
GRAPH_WIDTH = 1000,
GRAPH_HEIGHT = 250,
DECIMAL_PLACES = 3,
FONT_FAMILY = "Idleon",
COLUMN_MIN_WIDTH = 2,
MAJOR_GRID_LINES = 10,
SHOW_GRID = true,
TOOLTIP_WIDTH = 150,
TOOLTIP_PADDING = 5,
TEXT_SHADOW = "-.065em 0 #000, 0 .065em #000, .065em 0 #000, 0 -.065em #000",
THRESHOLD_HEIGHT = 3,
MIN_TEXT_HEIGHT = 20,
LABEL_BOTTOM_OFFSET = 20,
Y_AXIS_WIDTH = 40,
FONT_SIZES = {
TITLE = 19,
LABEL = 10,
Y_AXIS = 12,
},
COLORS = {
THRESHOLD = "#ff0000",
TOOLTIP_BG = "#000142cc",
TOOLTIP_BORDER = "#000142",
COLUMN_BORDER = "#000",
TEXT = "#fff",
GRID_LINE = "#00000033",
GRID_MAJOR = "#00000033",
},
Z_INDEX = {
GRID = 1,
GRAPH = 2,
Y_AXIS = 3,
THRESHOLD = 4,
TOOLTIP = 10,
},
}
CONSTANTS.FORMATS = {
RELATIVE = {
PREFIX = "",
SUFFIX = "x",
},
CUMULATIVE = {
PREFIX = "Σ ",
SUFFIX = "",
},
NORMALIZED = {
PREFIX = "",
SUFFIX = "%",
},
}
local function formatValueByMode(value, displayMode)
local format = CONSTANTS.FORMATS[displayMode] or { PREFIX = "", SUFFIX = "" }
local formattedValue = roundFormat(round(value, CONSTANTS.DECIMAL_PLACES))
return format.PREFIX .. formattedValue .. format.SUFFIX
end
local bubbleColors = {
orange = {
columnStart = "#ffb74dff",
columnEnd = "#d84315ff",
backgroundStart = "#fff3e0cc",
backgroundEnd = "#f57c00cc",
},
green = {
columnStart = "#9ccc65ff",
columnEnd = "#2e7d32ff",
backgroundStart = "#f1f8e9cc",
backgroundEnd = "#43a047cc",
},
purple = {
columnStart = "#ba68c8ff",
columnEnd = "#4527a0ff",
backgroundStart = "#f3e5f5cc",
backgroundEnd = "#8e24aacc",
},
yellow = {
columnStart = "#ffd54fff",
columnEnd = "#ff8f00ff",
backgroundStart = "#fffde7cc",
backgroundEnd = "#ffc107cc",
},
statue = {
columnStart = "#aba28eff",
columnEnd = "#635d53ff",
backgroundStart = "#f5f2ebcc",
backgroundEnd = "#8c857acc",
},
goldFood = {
columnStart = "#ffd700ff",
columnEnd = "#b8860bff",
backgroundStart = "#fff7e6cc",
backgroundEnd = "#daa520cc",
},
other = {
columnStart = "#ab47bcff",
columnEnd = "#6a1b9aff",
backgroundStart = "#f3e5f5cc",
backgroundEnd = "#8e24aacc",
},
}
local function sanitizeInput(text)
return text:gsub("[<>&\"']", {
["<"] = "<",
[">"] = ">",
["&"] = "&",
['"'] = """,
["'"] = "'",
})
end
local function getGoldFoodThreshold(x1)
local targetEfficiency = 0.90
local baseValue = x1 * 0.05
local low = 1
local high = 100000
while high - low > 1 do
local mid = floor((low + high) / 2)
local logValue = lavaLog(1 + mid)
local currentBonus = baseValue * logValue * (1 + logValue / 2.14)
local maxBonus = baseValue * logValue * (1 + logValue / 2.14)
if currentBonus / maxBonus >= targetEfficiency then
high = mid
else
low = mid
end
end
return high
end
local function getThresholdLevel(x2)
if not x2 then
return 0
end
if func == "statue" then
return CONSTANTS.MAX_LEVELS
end
if func == "goldFood" then
return getGoldFoodThreshold(x1)
end
return x2 ~= 0 and x2 * 90 / (100 - 90) or CONSTANTS.MAX_LEVELS
end
local function hexToRgb(hex)
local r = tonumber(hex:sub(2, 3), 16)
local g = tonumber(hex:sub(4, 5), 16)
local b = tonumber(hex:sub(6, 7), 16)
local a = tonumber(hex:sub(8, 9) or "ff", 16)
return { r, g, b, a }
end
local function interpolateColor(color1, color2, ratio)
local rgb1, rgb2 = hexToRgb(color1), hexToRgb(color2)
local interpolatedRgb = {}
for i = 1, 4 do
interpolatedRgb[i] = floor(rgb1[i] + ratio * (rgb2[i] - rgb1[i]))
end
return format("#%02x%02x%02x%02x", interpolatedRgb[1], interpolatedRgb[2], interpolatedRgb[3], interpolatedRgb[4])
end
local function formatDescription(description, realBonus)
return description:gsub(
"{",
format(
'<span class="bonus-value" style="color: %s; text-shadow: %s; font-size: 1.2em;">%s</span>',
CONSTANTS.COLORS.THRESHOLD,
CONSTANTS.TEXT_SHADOW,
roundFormat(round(realBonus, CONSTANTS.DECIMAL_PLACES))
)
)
end
function BubbleGraph.calculateBonus(func, level, x1, x2)
if not func or not BubbleGraph.bonusCalculations[func] then
return 0
end
local calcFunc = BubbleGraph.bonusCalculations[func]
return round(calcFunc(level, x1, x2), CONSTANTS.DECIMAL_PLACES)
end
local function calculateUtility(level, x1, x2, func)
local actualMaxLevel = CONSTANTS.MAX_LEVELS
if not x1 or not x2 then
return 0
end
if func == "statue" then
return 100
end
if func == "goldFood" then
local currentBonus = BubbleGraph.calculateBonus(func, level, x1, x2)
local theoreticalMax = BubbleGraph.calculateBonus(func, 100000, x1, x2)
return (currentBonus / theoreticalMax) * 100
end
if func == "decayMulti" then
local current = 1 + (level * x1) / (level + x2)
local limit = 1 + x1
local base = 1
return ((current - base) / (limit - base)) * 100
elseif func == "decay" then
local current = (level * x1) / (level + x2)
local limit = x1
return (current / limit) * 100
elseif func == "add" then
local current = x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (level - 1)) / (x1 / x2)) * level * x1 or level * x1
local maxValue = x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (actualMaxLevel - 1)) / (x1 / x2)) * actualMaxLevel * x1
or actualMaxLevel * x1
return (current / maxValue) * 100
elseif func == "intervalAdd" then
local current = x1 + floor(level / x2)
local maxIntervals = floor(actualMaxLevel / x2)
local maxValue = x1 + maxIntervals
return (current / maxValue) * 100
elseif func == "bigBase" then
local current = x1 + x2 * level
local maxValue = x1 + x2 * actualMaxLevel
return (current / maxValue) * 100
elseif func == "addLower" then
local current = x1 + x2 * (level + 1)
local maxValue = x1 + x2 * (actualMaxLevel + 1)
return (current / maxValue) * 100
elseif func == "decayLower" or func == "decayMultiLower" then
local current = x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2)
local limit = x1 / x2
return (current / limit) * 100
elseif func == "bigBaseLower" then
return 100
elseif func == "intervalAddLower" then
local current = max(floor((level + 1) / x2), 0) - max(floor(level / x2), 0)
local maxIntervals = max(floor((actualMaxLevel + 1) / x2), 0) - max(floor(actualMaxLevel / x2), 0)
return current > 0 and 100 or 0
elseif func == "reduce" then
local current = x1 - x2 * level
local minValue = x1 - x2 * actualMaxLevel
local maxValue = x1
return 100 - ((current - minValue) / (maxValue - minValue)) * 100
elseif func == "reduceLower" then
local current = x1 - x2 * (level + 1)
local minValue = x1 - x2 * (actualMaxLevel + 1)
local maxValue = x1 - x2
return 100 - ((current - minValue) / (maxValue - minValue)) * 100
end
return 0
end
local function calculateEfficiencyThresholdLevel(x1, x2, func, targetEfficiency)
local minLevel = 1
local maxLevel = 100000
local tolerance = 0.0001
if not x2 then
return 0
end
while minLevel < maxLevel do
local level = floor((minLevel + maxLevel) / 2)
local utility = calculateUtility(level, x1, x2, func)
if abs(utility - targetEfficiency) < tolerance then
return level
elseif utility < targetEfficiency then
minLevel = level + 1
else
maxLevel = level - 1
end
end
return minLevel
end
local function addThresholdAndEfficiencyIndicators(column, point, x2)
if not column or not point or not point.bonus then
return column
end
local thresholdLevel = x2 ~= 0 and x2 * 90 / (100 - 90) or CONSTANTS.MAX_LEVELS
if point.efficiency then
local zoneClass = "efficiency-zone"
if point.efficiency > 90 then
column:tag("div"):addClass("efficiency-zone-high"):css({
height = "100%",
opacity = "0.25",
})
elseif point.efficiency > 75 then
column:tag("div"):addClass("efficiency-zone-medium"):css({
height = "100%",
opacity = "0.25",
})
else
column:tag("div"):addClass("efficiency-zone-low"):css({
height = "100%",
opacity = "0.25",
})
end
end
return column
end
local function calculateGrowthAnalysis(points)
if not points or #points < 2 then
return {
rates = {},
thresholds = {
average = 0,
significant = 0,
critical = 0,
},
}
end
local rates = {}
local sum = 0
local count = 0
for i = 2, #points do
local prevBonus = points[i - 1].bonus
if prevBonus ~= 0 then
local rate = (points[i].bonus - prevBonus) / prevBonus
table.insert(rates, rate)
sum = sum + rate
count = count + 1
end
end
local mean = count > 0 and sum / count or 0
return {
rates = rates,
thresholds = {
average = mean,
significant = mean * 1.5,
critical = mean * 2,
},
}
end
local function calculateAverage(points)
local sum = 0
for _, point in ipairs(points) do
sum = sum + point.bonus
end
return sum / #points
end
local function calculateVariance(points)
local avg = calculateAverage(points)
local sum = 0
for _, point in ipairs(points) do
sum = sum + (point.bonus - avg) ^ 2
end
return sum / #points
end
local function calculateRelativeValues(points, baseValue)
if not points or #points == 0 then
return {}
end
baseValue = baseValue or points[1].bonus
local relativePoints = {}
for _, point in ipairs(points) do
local relativeCopy = {}
for k, v in pairs(point) do
relativeCopy[k] = v
end
relativeCopy.bonus = point.bonus / baseValue
relativeCopy.formattedBonus = roundFormat(round(relativeCopy.bonus, CONSTANTS.DECIMAL_PLACES))
table.insert(relativePoints, relativeCopy)
end
return relativePoints
end
local function calculateCumulativeValues(points)
if not points or #points == 0 then
return {}
end
local cumulativePoints = {}
local sum = 0
for _, point in ipairs(points) do
local cumulativeCopy = {}
for k, v in pairs(point) do
cumulativeCopy[k] = v
end
sum = sum + point.bonus
cumulativeCopy.bonus = sum
cumulativeCopy.formattedBonus = roundFormat(round(sum, CONSTANTS.DECIMAL_PLACES))
table.insert(cumulativePoints, cumulativeCopy)
end
return cumulativePoints
end
local function calculateNormalizedValues(points)
if not points or #points == 0 then
return {}
end
local minVal = math.huge
local maxVal = -math.huge
for _, point in ipairs(points) do
minVal = min(minVal, point.bonus)
maxVal = max(maxVal, point.bonus)
end
local range = maxVal - minVal
local normalizedPoints = {}
for _, point in ipairs(points) do
local normalizedCopy = {}
for k, v in pairs(point) do
normalizedCopy[k] = v
end
normalizedCopy.bonus = range ~= 0 and (point.bonus - minVal) / range or 0
normalizedCopy.formattedBonus = roundFormat(round(normalizedCopy.bonus, CONSTANTS.DECIMAL_PLACES))
table.insert(normalizedPoints, normalizedCopy)
end
return normalizedPoints
end
function BubbleGraph.renderGrid(width, height, majorLines)
local grid = mw.html.create("div"):css({
position = "absolute",
top = "0",
left = "0",
width = width .. "px",
height = height .. "px",
["pointer-events"] = "none",
["z-index"] = CONSTANTS.Z_INDEX.GRID,
["background-image"] = format(
"linear-gradient(to right, %s 1px, transparent 1px), linear-gradient(to bottom, %s 1px, transparent 1px)",
CONSTANTS.COLORS.GRID_LINE,
CONSTANTS.COLORS.GRID_LINE
),
["background-size"] = format("%dpx 100%%, 100%% %dpx", width / majorLines, height / majorLines),
})
for i = 0, majorLines do
local position = (i / majorLines) * 100
grid:tag("div"):css({
position = "absolute",
left = position .. "%",
top = "0",
height = "100%",
width = "1px",
["background-color"] = CONSTANTS.COLORS.GRID_MAJOR,
})
end
return grid
end
local function createGraphContainer(width, height, options)
local container = mw.html
.create("div")
:addClass("bubble-graph-container")
:attr("role", "img")
:attr("aria-label")
:attr("tabindex", "0")
:css({
width = width .. "px",
height = height .. "px",
position = "relative",
["font-family"] = CONSTANTS.FONT_FAMILY,
["text-align"] = "center",
["padding-bottom"] = "50px",
["padding-left"] = CONSTANTS.Y_AXIS_WIDTH .. "px",
})
return container
end
local function createTooltipText(point, options, allPoints)
local leftSection = {}
local rightSection = {}
if options.showMetrics then
local avg = calculateAverage(allPoints)
local var = calculateVariance(allPoints)
table.insert(rightSection, format("Average: %.2f", avg))
table.insert(rightSection, format("Variance: %.2f", var))
if #allPoints > 1 then
local growthAnalysis = calculateGrowthAnalysis(allPoints)
local pointIndex
for i, p in ipairs(allPoints) do
if p.level == point.level then
pointIndex = i - 1
break
end
end
if pointIndex and pointIndex > 0 and growthAnalysis.rates[pointIndex] then
table.insert(rightSection, format("Growth Rate: %.2f%%", growthAnalysis.rates[pointIndex] * 100))
end
end
end
if options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then
table.insert(leftSection, format("Level: %s", tostring(point.level)))
table.insert(leftSection, format("Bonus: %s", point.formattedBonus))
table.insert(
leftSection,
format("Required Statues: %s (%s total)", point.formattedStatues, point.formattedTotalStatues)
)
else
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
table.insert(leftSection, format("Amount: %s", tostring(point.level)))
else
table.insert(leftSection, format("Level: %s", tostring(point.level)))
end
if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
table.insert(leftSection, format("Value: %s", formatValueByMode(point.bonus, "RELATIVE")))
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then
table.insert(leftSection, format("Value: %s", formatValueByMode(point.bonus, "CUMULATIVE")))
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then
table.insert(leftSection, format("Value: %s", formatValueByMode(point.bonus * 100, "NORMALIZED")))
else
table.insert(leftSection, format("Value: %s", point.formattedBonus))
end
end
if options.showAnalysis then
if point.efficiency then
table.insert(leftSection, format("Efficiency: %.1f%%", point.efficiency))
end
if point.bonusGain then
local sign = point.bonusGain > 0 and "+" or ""
table.insert(rightSection, format("Growth: %s%s", sign, tostring(point.bonusGain)))
end
end
local tooltipHTML = format(
[[
<div class="tooltip-container">
<div class="tooltip-top">
<div class="tooltip-left">%s</div>
<div class="tooltip-right">%s</div>
</div>
<div class="tooltip-bottom">%s</div>
</div>
]],
table.concat(leftSection, "<br>"),
table.concat(rightSection, "<br>"),
format('<span class="description">%s</span>', point.formattedDescription)
)
return tooltipHTML
end
local function createColumn(point, maxBonus, options, scheme, points)
local height
local displayValue
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
if point.bonus > 0 and maxBonus > 0 then
local scale = math.sqrt(point.bonus) / math.sqrt(maxBonus)
height = floor(scale * options.graphHeight)
height = min(height, options.graphHeight)
height = max(height, 1)
else
height = 0
end
displayValue = point.formattedBonus
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.LOGARITHMIC then
if point.bonus > 0 and maxBonus > 0 then
local scale = (point.bonus ^ (1 / 3)) / (maxBonus ^ (1 / 3))
height = floor(scale * options.graphHeight)
height = min(height, options.graphHeight)
height = max(height, 1)
else
height = 0
end
displayValue = point.formattedBonus
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
if point.bonus > 0 and maxBonus > 0 then
local minValue = math.log(1)
local maxValue = math.log(maxBonus + 1)
local currentValue = math.log(point.bonus + 1)
height = floor(((currentValue - minValue) / (maxValue - minValue)) * options.graphHeight)
height = min(height, options.graphHeight)
height = max(height, 1)
else
height = 0
end
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
local relativeValue = point.bonus / maxBonus
local scale = 0.1
height = min(floor(relativeValue * options.graphHeight * scale), options.graphHeight)
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.PERCENTAGE then
height = floor((point.bonus / maxBonus) * 100 * (options.graphHeight / 100))
displayValue = format("%.1f%%", (point.bonus / maxBonus) * 100)
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.DELTA then
height = floor((abs(point.bonusGain or 0) / maxBonus) * options.graphHeight)
elseif options.displayMode == "efficiency" then
height = point.relativeHeight * options.graphHeight
else
height = floor((point.bonus / maxBonus) * options.graphHeight)
end
if not displayValue then
if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
displayValue = formatValueByMode(point.bonus, "RELATIVE")
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then
displayValue = formatValueByMode(point.bonus, "CUMULATIVE")
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then
displayValue = formatValueByMode(point.bonus * 100, "NORMALIZED")
else
displayValue = point.formattedBonus
end
end
height = floor(height)
local column = mw.html
.create("div")
:addClass("bubble-graph-column")
:attr("role", "gridcell")
:attr("aria-label", format("Level %s, Value %s", point.level, point.formattedBonus))
:css({
flex = "1",
height = height .. "px",
position = "relative",
["min-width"] = CONSTANTS.COLUMN_MIN_WIDTH .. "px",
})
local color
if options.displayMode == "efficiency" then
local ratio = point.relativeHeight
color = interpolateColor(scheme.columnStart, scheme.columnEnd, ratio)
else
local thresholdLevel = getThresholdLevel(options.x2, options.func, options.x1)
local maxHeight = options.graphHeight
local heightRatio = height / maxHeight
if point.level <= thresholdLevel then
local ratio = point.level / thresholdLevel
color = interpolateColor(scheme.columnStart, scheme.columnEnd, ratio)
else
local ratio = (point.level - thresholdLevel) / (options.maxLevels - thresholdLevel)
color = interpolateColor(scheme.columnEnd, scheme.columnStart, ratio)
end
end
column:css("background-color", color)
local tooltipText = createTooltipText(point, options, points)
local tooltip = mw.html
.create("div")
:addClass("bubble-graph-tooltip")
:css({
["background-color"] = CONSTANTS.COLORS.TOOLTIP_BG,
border = format("1px solid %s", CONSTANTS.COLORS.TOOLTIP_BORDER),
padding = CONSTANTS.TOOLTIP_PADDING .. "px",
color = CONSTANTS.COLORS.TEXT,
["text-align"] = "center",
["z-index"] = CONSTANTS.Z_INDEX.TOOLTIP,
width = CONSTANTS.TOOLTIP_WIDTH .. "px",
bottom = floor(height / 5) .. "px",
["pointer-events"] = "none",
left = point.ratio * 100 .. "%",
transform = format("translate(-%d%%, 0)", point.ratio * 100),
})
:wikitext(tooltipText)
column:node(tooltip)
if point.isThreshold then
column:tag("div"):css({
position = "absolute",
top = "-5px",
width = "100%",
height = CONSTANTS.THRESHOLD_HEIGHT .. "px",
["background-color"] = CONSTANTS.COLORS.THRESHOLD,
["z-index"] = CONSTANTS.Z_INDEX.THRESHOLD,
})
end
if point.formattedBonus ~= "0" then
local valueSpan = mw.html
.create("span")
:addClass("bubble-graph-value")
:css({
position = "absolute",
color = CONSTANTS.COLORS.TEXT,
["text-shadow"] = CONSTANTS.TEXT_SHADOW,
["white-space"] = "nowrap",
["z-index"] = 1,
})
:wikitext(point.formattedBonus)
local textLength = #point.formattedBonus * 8
local padding = 5
local minHeight = max(CONSTANTS.MIN_TEXT_HEIGHT, textLength + padding * 2)
local actualHeight = height
if options.displayMode == "efficiency" then
actualHeight = floor(point.relativeHeight * options.graphHeight)
end
if actualHeight < minHeight then
valueSpan:css({
bottom = "100%",
left = "50%",
transform = "translate(-50%, -2px) rotate(-90deg)",
})
else
local textYPos = min(actualHeight / 2, actualHeight - textLength / 2 - padding)
textYPos = max(textLength / 2 + padding, textYPos)
valueSpan:css({
bottom = textYPos .. "px",
left = "50%",
transform = "translate(-50%, 50%) rotate(-90deg)",
})
end
column:node(valueSpan)
end
if point.showLabel then
local labelText
if options.displayMode == "efficiency" then
labelText = tostring(point.efficiency) .. "%"
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
labelText = formatLargeNumber(point.level)
local formattedNumber = formatLargeNumber(point.level, 2)
labelText = roundFormat(tonumber(formattedNumber:match("[%d.]+")) or point.level) .. formattedNumber:match("[^%d.]*$")
else
labelText = tostring(point.level)
end
column
:tag("div")
:css({
position = "absolute",
bottom = -CONSTANTS.LABEL_BOTTOM_OFFSET .. "px",
width = "100%",
["text-align"] = "center",
["font-size"] = CONSTANTS.FONT_SIZES.LABEL .. "px",
["z-index"] = CONSTANTS.Z_INDEX.GRAPH,
["text-wrap"] = "nowrap",
})
:wikitext(labelText)
end
return column
end
local function generateGraphPoints(x1, x2, func, maxLevels, step, options)
local maxBonus = 0
local minLevel = max(1, options.minLevels or 1)
local displayLevels = {}
local thresholdLevel = floor(getThresholdLevel(x2, func, x1))
local pointsWithThreshold = {}
if options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then
local lastStatues = 0
for level = 10, 500, 10 do
local rawBonus = BubbleGraph.calculateBonus(func, level, x1, x2)
local totalStatues = calculateRequiredStatues(level)
local statuesForLevel = totalStatues - lastStatues
local point = {
level = level,
bonus = rawBonus,
realBonus = rawBonus,
formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)),
showLabel = true,
isThreshold = false,
statues = statuesForLevel,
totalStatues = totalStatues,
formattedStatues = formatLargeNumber(statuesForLevel, 2),
formattedTotalStatues = formatLargeNumber(totalStatues, 2),
}
table.insert(pointsWithThreshold, point)
maxBonus = max(maxBonus, rawBonus)
lastStatues = totalStatues
end
return pointsWithThreshold, maxBonus
end
local baseLevel = minLevel - (minLevel % 100)
if minLevel % 100 ~= 0 then
baseLevel = baseLevel + 100
end
table.insert(displayLevels, minLevel)
for level = baseLevel, maxLevels, 100 do
if level > minLevel and level <= maxLevels then
table.insert(displayLevels, level)
end
end
if maxLevels % 100 ~= 0 then
table.insert(displayLevels, maxLevels)
end
local thresholdBonus = BubbleGraph.calculateBonus(func, thresholdLevel, x1, x2)
local thresholdAligned = (thresholdLevel % step) == 0
local pointsWithThreshold = {}
for level = minLevel - (minLevel % step), maxLevels, step do
if level >= minLevel then
local actualLevel = level
local rawBonus = BubbleGraph.calculateBonus(func, actualLevel, x1, x2)
local utilityRatio = calculateUtility(actualLevel, x1, x2, func) / 100
local displayBonus = actualLevel <= thresholdLevel and rawBonus
or thresholdBonus * (thresholdLevel / actualLevel)
local point = {
level = actualLevel,
bonus = displayBonus,
realBonus = rawBonus,
formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)),
showLabel = false,
isThreshold = actualLevel == thresholdLevel,
utilityRatio = utilityRatio,
efficiency = calculateUtility(actualLevel, x1, x2, func),
}
for _, displayLevel in ipairs(displayLevels) do
if actualLevel == displayLevel then
point.showLabel = true
break
end
end
table.insert(pointsWithThreshold, point)
maxBonus = max(maxBonus, displayBonus)
if not thresholdAligned and actualLevel < thresholdLevel and (actualLevel + step) > thresholdLevel then
local thresholdPoint = {
level = thresholdLevel,
bonus = thresholdBonus,
realBonus = thresholdBonus,
formattedBonus = roundFormat(round(thresholdBonus, CONSTANTS.DECIMAL_PLACES)),
showLabel = true,
isThreshold = true,
utilityRatio = calculateUtility(thresholdLevel, x1, x2, func) / 100,
efficiency = calculateUtility(thresholdLevel, x1, x2, func),
}
table.insert(pointsWithThreshold, thresholdPoint)
maxBonus = max(maxBonus, thresholdBonus)
end
end
end
table.sort(pointsWithThreshold, function(a, b)
return a.level < b.level
end)
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
maxBonus = BubbleGraph.calculateBonus(func, maxLevels, x1, x2)
for _, point in ipairs(pointsWithThreshold) do
point.maxPossibleBonus = maxBonus
end
end
if options.displayMode then
if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
local baseValue = pointsWithThreshold[1] and pointsWithThreshold[1].bonus or 1
pointsWithThreshold = calculateRelativeValues(pointsWithThreshold, baseValue)
maxBonus = 1
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then
pointsWithThreshold = calculateCumulativeValues(pointsWithThreshold)
maxBonus = pointsWithThreshold[#pointsWithThreshold].bonus
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then
pointsWithThreshold = calculateNormalizedValues(pointsWithThreshold)
maxBonus = 1
end
end
for i = 1, #pointsWithThreshold - 1 do
local currentBonus = pointsWithThreshold[i].realBonus
local nextBonus = pointsWithThreshold[i + 1].realBonus
pointsWithThreshold[i].bonusGain = nextBonus - currentBonus
end
if #pointsWithThreshold > 1 then
local lastIndex = #pointsWithThreshold
local prevIndex = lastIndex - 1
local lastBonusGain = pointsWithThreshold[lastIndex].realBonus - pointsWithThreshold[prevIndex].realBonus
pointsWithThreshold[lastIndex].bonusGain = lastBonusGain
end
local maxDelta = 0
for _, point in ipairs(pointsWithThreshold) do
maxDelta = max(maxDelta, abs(point.bonusGain or 0))
end
if options.displayMode == BubbleGraph.DISPLAY_MODES.DELTA then
maxBonus = maxDelta
end
return pointsWithThreshold, maxBonus
end
local function generateGraphPointsEfficiency(x1, x2, func)
local points = {}
local maxBonus = 0
local prevEfficiency = 0
local efficiencies = {}
for eff = 2, 98, 2 do
table.insert(efficiencies, eff)
end
table.insert(efficiencies, 99)
for _, efficiency in ipairs(efficiencies) do
local level = calculateEfficiencyThresholdLevel(x1, x2, func, efficiency)
local rawBonus = BubbleGraph.calculateBonus(func, level, x1, x2)
local point = {
level = level,
bonus = rawBonus,
realBonus = rawBonus,
formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)),
showLabel = true,
isThreshold = false,
utilityRatio = efficiency / 100,
efficiency = efficiency,
bonusGain = 0,
relativeHeight = 0,
}
table.insert(points, point)
maxBonus = max(maxBonus, rawBonus)
prevEfficiency = efficiency
end
for i = 1, #points - 1 do
points[i].bonusGain = abs(points[i + 1].bonus - points[i].bonus)
end
points[#points].bonusGain = points[#points - 1].bonusGain
local maxGain = 0
local minGain = math.huge
for _, point in ipairs(points) do
maxGain = max(maxGain, point.bonusGain)
if point.bonusGain > 0 then
minGain = min(minGain, point.bonusGain)
end
end
if maxGain > 0 then
for _, point in ipairs(points) do
local normalizedGain = (point.bonusGain - minGain) / (maxGain - minGain)
point.relativeHeight = 0.1 + (0.9 * math.sqrt(normalizedGain))
end
else
for _, point in ipairs(points) do
point.relativeHeight = 0.1
end
end
return points, maxBonus
end
function BubbleGraph.generateGraph(x1, x2, func, bubbleColor, bubbleName, description, options)
local graphWidth = options.graphWidth or CONSTANTS.GRAPH_WIDTH
local graphHeight = options.graphHeight or CONSTANTS.GRAPH_HEIGHT
local containerHeight = options.displayMode == "efficiency" and graphHeight or (graphHeight + 50)
local container = createGraphContainer(graphWidth, containerHeight, options)
local scheme = bubbleColors[bubbleColor] or bubbleColors.other
local graphTitle = mw.html
.create("div")
:css({
position = "relative",
width = graphWidth .. "px",
height = "20px",
["font-size"] = CONSTANTS.FONT_SIZES.TITLE .. "px",
["padding-top"] = "5px",
["margin-top"] = "5px",
})
:wikitext(bubbleName)
local graphContainer = mw.html.create("div"):css({
position = "relative",
width = graphWidth .. "px",
height = graphHeight .. "px",
["margin-top"] = "10px",
["padding-top"] = "10px",
["z-index"] = CONSTANTS.Z_INDEX.GRAPH,
})
if options.showGrid then
graphContainer:node(
BubbleGraph.renderGrid(graphWidth, graphHeight, options.majorGridLines or CONSTANTS.MAJOR_GRID_LINES)
)
end
local graphBody = mw.html.create("div"):css({
width = graphWidth .. "px",
height = graphHeight .. "px",
display = "flex",
["align-items"] = "flex-end",
position = "absolute",
top = "0",
left = "0",
["z-index"] = CONSTANTS.Z_INDEX.GRAPH,
["background-image"] = format("linear-gradient(%s, %s)", scheme.backgroundStart, scheme.backgroundEnd),
border = format("1px solid %s", CONSTANTS.COLORS.COLUMN_BORDER),
["border-radius"] = "3px",
padding = "3px",
})
local points, maxBonus
if options.displayMode == "efficiency" then
points, maxBonus = generateGraphPointsEfficiency(x1, x2, func, options)
else
points, maxBonus = generateGraphPoints(x1, x2, func, options.maxLevels, options.step, options)
end
for i, point in ipairs(points) do
point.ratio = i / #points
point.formattedDescription = formatDescription(description, point.realBonus)
local column = createColumn(point, maxBonus, options, scheme, points)
if options.showEfficiencyZones or options.thresholds then
column = addThresholdAndEfficiencyIndicators(column, point, options.x2)
end
graphBody:node(column)
end
local yAxis = mw.html.create("div"):css({
position = "absolute",
top = "0",
left = -CONSTANTS.Y_AXIS_WIDTH .. "px",
width = CONSTANTS.Y_AXIS_WIDTH .. "px",
height = graphHeight .. "px",
["pointer-events"] = "none",
["z-index"] = CONSTANTS.Z_INDEX.Y_AXIS,
})
for i = 0, 10 do
local value = round(maxBonus * (10 - i) / 10, 2)
local displayValue
if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
displayValue = formatValueByMode(value, "RELATIVE")
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then
displayValue = formatValueByMode(value, "CUMULATIVE")
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then
displayValue = formatValueByMode(value * 100, "NORMALIZED")
else
displayValue = roundFormat(round(value, CONSTANTS.DECIMAL_PLACES))
end
local label = mw.html
.create("div")
:css({
position = "absolute",
top = (i * 10) .. "%",
right = "5px",
["text-align"] = "right",
["font-size"] = CONSTANTS.FONT_SIZES.Y_AXIS .. "px",
["line-height"] = "1",
transform = "translateY(-50%)",
})
:wikitext(roundFormat(value))
yAxis:node(label)
end
graphContainer:node(graphBody)
graphContainer:node(yAxis)
container:node(graphTitle)
container:node(graphContainer)
return tostring(container)
end
function BubbleGraph.render(frame)
local args = frame.args or frame:getParent().args
local x1 = tonumber(args.x1)
local x2 = tonumber(args.x2)
local func = args.func
local options = {
maxLevels = CONSTANTS.MAX_LEVELS,
minLevels = tonumber(args.min_levels) or 1,
step = tonumber(args.step) or CONSTANTS.STEP,
graphWidth = tonumber(args.width) or CONSTANTS.GRAPH_WIDTH,
graphHeight = tonumber(args.height) or CONSTANTS.GRAPH_HEIGHT,
showGrid = args.show_grid == nil and CONSTANTS.SHOW_GRID or (args.show_grid == "true"),
majorGridLines = tonumber(args.major_grid_lines) or CONSTANTS.MAJOR_GRID_LINES,
displayMode = args.display_mode or BubbleGraph.DISPLAY_MODES.STANDARD,
showEfficiencyZones = args.show_efficiency_zones ~= "false",
showAnalysis = args.show_analysis ~= "false",
showMetrics = args.show_metrics ~= "false",
showGrowthIndicators = args.show_growth_indicators ~= "false",
}
local scheme = bubbleColors[args.bubble_color or args.color or "other"]
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
options.maxLevels = 100000
options.step = 2000
func = "goldFood"
scheme = bubbleColors.goldFood
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then
options.maxLevels = 500
options.step = 10
options.minLevels = 10
func = "statue"
scheme = bubbleColors.statue
end
options.x2 = x2
options.func = func
options.x1 = x1
local points, maxBonus
if options.displayMode == "efficiency" then
points, maxBonus = generateGraphPointsEfficiency(x1, x2, func, options)
else
points, maxBonus = generateGraphPoints(x1, x2, func, options.maxLevels, options.step, options)
end
return BubbleGraph.generateGraph(
tonumber(args.x1),
tonumber(args.x2),
func,
sanitizeInput(args.bubble_color or args.color or "other"),
sanitizeInput(args.bubble_name or args.name or ""),
sanitizeInput(args.description or ""),
options
)
end
return BubbleGraph